diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22978f4..b341585 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,14 +4,14 @@ on: push: pull_request: schedule: - - cron: '47 3 * * *' + - cron: '37 3 * * *' jobs: build: strategy: matrix: - python-version: ["3.x", "3.11", "3.10", "3.9", "pypy-3.9", "3.8", "pypy-3.8", "3.7", "pypy-3.7"] + python-version: ["3.x", "3.11", "3.10", "3.9", "3.8", "3.7"] platform: [ubuntu-latest, macos-latest, windows-latest] exclude: - platform: windows-latest @@ -25,6 +25,14 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + - name: Install zbar shared lib for QReader (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get install -y libzbar0 + - name: Install zbar shared lib for QReader (macOS) + if: runner.os == 'macOS' + run: | + brew install zbar - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/ci_docker.yml b/.github/workflows/ci_docker.yml new file mode 100644 index 0000000..fb01f8d --- /dev/null +++ b/.github/workflows/ci_docker.yml @@ -0,0 +1,76 @@ +name: "Docker: build and publish" + +# How to setup: https://event-driven.io/en/how_to_buid_and_push_docker_image_with_github_actions/ +# How to run: https://aschmelyun.com/blog/using-docker-run-inside-of-github-actions/ + +on: + # run it on push to the default repository branch + push: + # branches: [master] + # run it during pull request + # pull_request: + +jobs: + # define job to build and publish docker image + build-and-push-docker-image: + name: Build Docker image and push to repositories + # run only when code is compiling and tests are passing + runs-on: ubuntu-latest + + # steps to perform in job + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + # setup Docker build action + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to Github Packages + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GHCR_IO_TOKEN }} + + - name: "no_qr_reader: Build image and push to Docker Hub and GitHub Container Registry" + uses: docker/build-push-action@v2 + with: + # relative path to the place where source code with Dockerfile is located + platforms: linux/amd64,linux/arm64 + context: . + file: Dockerfile_no_qr_reader + # Note: tags has to be all lower-case + tags: | + scit0/extract_otp_secret_keys_no_qr_reader:latest + ghcr.io/scito/extract_otp_secret_keys_no_qr_reader:latest + # build on feature branches, push only on master branch + # TODO push: ${{ github.ref == 'refs/heads/master' }} + push: true + + - name: "qr_reader: Build image and push to Docker Hub and GitHub Container Registry" + uses: docker/build-push-action@v2 + with: + platforms: linux/amd64,linux/arm64 + # relative path to the place where source code with Dockerfile is located + context: . + # Note: tags has to be all lower-case + tags: | + scit0/extract_otp_secret_keys:latest + ghcr.io/scito/extract_otp_secret_keys:latest + # build on feature branches, push only on master branch + # TODO push: ${{ github.ref == 'refs/heads/master' }} + push: true + + - name: Image digest + run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/Dockerfile b/Dockerfile index 37f7f1e..5c2403e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,17 @@ -FROM python:3.11-alpine +FROM python:3.11-slim-bullseye WORKDIR /extract COPY . . -RUN pip install -r requirements.txt +ARG run_tests=true + +RUN apt-get update && apt-get install -y libzbar0 python3-opencv nano \ + && pip install -r requirements.txt \ + && if [[ "$run_tests" == "true" ]] ; then /extract/run_pytest.sh ; else echo "Not running tests..." ; fi WORKDIR /files -ENTRYPOINT [ "python", "/extract/extract_otp_secret_keys.py" ] +ENTRYPOINT ["python", "/extract/extract_otp_secret_keys.py"] + +LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secret_keys diff --git a/Dockerfile_no_qr_reader b/Dockerfile_no_qr_reader new file mode 100644 index 0000000..9ca838a --- /dev/null +++ b/Dockerfile_no_qr_reader @@ -0,0 +1,16 @@ +FROM python:3.11-alpine + +WORKDIR /extract + +COPY . . + +ARG run_tests=true + +RUN pip install protobuf qrcode Pillow \ + && if [[ "$run_tests" == "true" ]] ; then /extract/run_pytest.sh test_extract_otp_secret_keys_pytest.py -k "not qreader" --relaxed ; else echo "Not running tests..." ; fi + +WORKDIR /files + +ENTRYPOINT ["python", "/extract/extract_otp_secret_keys.py"] + +LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secret_keys diff --git a/Pipfile b/Pipfile index 5bdd714..899e049 100644 --- a/Pipfile +++ b/Pipfile @@ -4,15 +4,11 @@ verify_ssl = true name = "pypi" [packages] -protobuf = "==4.21.12" +protobuf = "*" qrcode = "*" pillow = "*" -wheel = "==0.38.4" -pytest = "==7.2.0" -flake8 = "==6.0.0" -pylint = "==2.15.9" -qreader = "==1.3.1" -opencv-python = "==4.6.0.66" +qreader = "*" +opencv-python = "*" [dev-packages] pytest = "*" diff --git a/Pipfile.lock b/Pipfile.lock index cb5fc84..6a11ca0 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0aea0eade4cf314b23a91c56582b66626a03b15f8e089b568ad55186cf8e6163" + "sha256": "2f4059c8dbac6be85b1e3b2c2032b884d48dc6a7fd520ffdebb951e23246a23e" }, "pipfile-spec": 6, "requires": { @@ -16,135 +16,39 @@ ] }, "default": { - "astroid": { - "hashes": [ - "sha256:10e0ad5f7b79c435179d0d0f0df69998c4eef4597534aae44910db060baeb907", - "sha256:1493fe8bd3dfd73dc35bd53c9d5b6e49ead98497c47b2307662556a5692d29d7" - ], - "markers": "python_full_version >= '3.7.2'", - "version": "==2.12.13" - }, - "attrs": { - "hashes": [ - "sha256:29e95c7f6778868dbd49170f98f8818f78f3dc5e0e37c0b1f474e3561b240836", - "sha256:c9227bfc2f01993c03f68db37d1d15c9690188323c067c641f1a35ca58185f99" - ], - "markers": "python_version >= '3.6'", - "version": "==22.2.0" - }, - "colorama": { - "hashes": [ - "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", - "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" - ], - "markers": "sys_platform == 'win32'", - "version": "==0.4.6" - }, - "dill": { - "hashes": [ - "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0", - "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373" - ], - "markers": "python_version < '3.11'", - "version": "==0.3.6" - }, - "exceptiongroup": { - "hashes": [ - "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828", - "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec" - ], - "markers": "python_version < '3.11'", - "version": "==1.0.4" - }, - "flake8": { - "hashes": [ - "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7", - "sha256:c61007e76655af75e6785a931f452915b371dc48f56efd765247c8fe68f2b181" - ], - "index": "pypi", - "version": "==6.0.0" - }, - "iniconfig": { - "hashes": [ - "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3", - "sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32" - ], - "version": "==1.1.1" - }, - "isort": { - "hashes": [ - "sha256:6db30c5ded9815d813932c04c2f85a360bcdd35fed496f4d8f35495ef0a261b6", - "sha256:c033fd0edb91000a7f09527fe5c75321878f98322a77ddcc81adbd83724afb7b" - ], - "markers": "python_full_version >= '3.7.0'", - "version": "==5.11.4" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:0c1c7c0433154bb7c54185714c6929acc0ba04ee1b167314a779b9025517eada", - "sha256:14010b49a2f56ec4943b6cf925f597b534ee2fe1f0738c84b3bce0c1a11ff10d", - "sha256:4e2d9f764f1befd8bdc97673261b8bb888764dfdbd7a4d8f55e4fbcabb8c3fb7", - "sha256:4fd031589121ad46e293629b39604031d354043bb5cdf83da4e93c2d7f3389fe", - "sha256:5b51d6f3bfeb289dfd4e95de2ecd464cd51982fe6f00e2be1d0bf94864d58acd", - "sha256:6850e4aeca6d0df35bb06e05c8b934ff7c533734eb51d0ceb2d63696f1e6030c", - "sha256:6f593f26c470a379cf7f5bc6db6b5f1722353e7bf937b8d0d0b3fba911998858", - "sha256:71d9ae8a82203511a6f60ca5a1b9f8ad201cac0fc75038b2dc5fa519589c9288", - "sha256:7e1561626c49cb394268edd00501b289053a652ed762c58e1081224c8d881cec", - "sha256:8f6ce2118a90efa7f62dd38c7dbfffd42f468b180287b748626293bf12ed468f", - "sha256:ae032743794fba4d171b5b67310d69176287b5bf82a21f588282406a79498891", - "sha256:afcaa24e48bb23b3be31e329deb3f1858f1f1df86aea3d70cb5c8578bfe5261c", - "sha256:b70d6e7a332eb0217e7872a73926ad4fdc14f846e85ad6749ad111084e76df25", - "sha256:c219a00245af0f6fa4e95901ed28044544f50152840c5b6a3e7b2568db34d156", - "sha256:ce58b2b3734c73e68f0e30e4e725264d4d6be95818ec0a0be4bb6bf9a7e79aa8", - "sha256:d176f392dbbdaacccf15919c77f526edf11a34aece58b55ab58539807b85436f", - "sha256:e20bfa6db17a39c706d24f82df8352488d2943a3b7ce7d4c22579cb89ca8896e", - "sha256:eac3a9a5ef13b332c059772fd40b4b1c3d45a3a2b05e33a361dee48e54a4dad0", - "sha256:eb329f8d8145379bf5dbe722182410fe8863d186e51bf034d2075eb8d85ee25b" - ], - "markers": "python_version >= '3.7'", - "version": "==1.8.0" - }, - "mccabe": { - "hashes": [ - "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", - "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" - ], - "markers": "python_version >= '3.6'", - "version": "==0.7.0" - }, "numpy": { "hashes": [ - "sha256:0104d8adaa3a4cc60c2777cab5196593bf8a7f416eda133be1f3803dd0838886", - "sha256:0885d9a7666cafe5f9876c57bfee34226e2b2847bfb94c9505e18d81011e5401", - "sha256:12bba5561d8118981f2f1ff069ecae200c05d7b6c78a5cdac0911f74bc71cbd1", - "sha256:2f8e0df2ecc1928ef7256f18e309c9d6229b08b5be859163f5caa59c93d53646", - "sha256:4445f472b246cad6514cc09fbb5ecb7aab09ca2acc3c16f29f8dca6c468af501", - "sha256:4d01f7832fa319a36fd75ba10ea4027c9338ede875792f7bf617f4b45056fc3a", - "sha256:4f5e78b8b710cd7cd1a8145994cfffc6ddd5911669a437777d8cedfce6c83a98", - "sha256:667b5b1f6a352419e340f6475ef9930348ae5cb7fca15f2cc3afcb530823715e", - "sha256:6e73a1f4f5b74a42abb55bc2b3d869f1b38cbc8776da5f8b66bf110284f7a437", - "sha256:73cf2c5b5a07450f20a0c8e04d9955491970177dce8df8d6903bf253e53268e0", - "sha256:7ad6a024a32ee61d18f5b402cd02e9c0e22c0fb9dc23751991b3a16d209d972e", - "sha256:8b1ddfac6a82d4f3c8e99436c90b9c2c68c0bb14658d1684cdd00f05fab241f5", - "sha256:90075ef2c6ac6397d0035bcd8b298b26e481a7035f7a3f382c047eb9c3414db0", - "sha256:9387c7d6d50e8f8c31e7bfc034241e9c6f4b3eb5db8d118d6487047b922f82af", - "sha256:9af91f794d2d3007d91d749ebc955302889261db514eb24caef30e03e8ec1e41", - "sha256:ab11f6a7602cf8ea4c093e091938207de3068c5693a0520168ecf4395750f7ea", - "sha256:ac4fe68f1a5a18136acebd4eff91aab8bed00d1ef2fdb34b5d9192297ffbbdfc", - "sha256:ada6c1e9608ceadaf7020e1deea508b73ace85560a16f51bef26aecb93626a72", - "sha256:c4ab7c9711fe6b235e86487ca74c1b092a6dd59a3cb45b63241ea0a148501853", - "sha256:cec79ff3984b2d1d103183fc4a3361f5b55bbb66cb395cbf5a920a4bb1fd588d", - "sha256:cf8960f72997e56781eb1c2ea256a70124f92a543b384f89e5fb3503a308b1d3", - "sha256:d7f223554aba7280e6057727333ed357b71b7da7422d02ff5e91b857888c25d1", - "sha256:dbb0490f0a880700a6cc4d000384baf19c1f4df59fff158d9482d4dbbca2b239", - "sha256:e63d2157f9fc98cc178870db83b0e0c85acdadd598b134b00ebec9e0db57a01f", - "sha256:ec3e5e8172a0a6a4f3c2e7423d4a8434c41349141b04744b11a90e017a95bad5", - "sha256:f3c4a9a9f92734a4728ddbd331e0124eabbc968a0359a506e8e74a9b0d2d419b", - "sha256:f9168790149f917ad8e3cf5047b353fefef753bd50b07c547da0bdf30bc15d91", - "sha256:fe44e925c68fb5e8db1334bf30ac1a1b6b963b932a19cf41d2e899cf02f36aab" - ], - "markers": "python_version >= '3.9'", - "version": "==1.24.0" + "sha256:0044f7d944ee882400890f9ae955220d29b33d809a038923d88e4e01d652acd9", + "sha256:0e3463e6ac25313462e04aea3fb8a0a30fb906d5d300f58b3bc2c23da6a15398", + "sha256:179a7ef0889ab769cc03573b6217f54c8bd8e16cef80aad369e1e8185f994cd7", + "sha256:2386da9a471cc00a1f47845e27d916d5ec5346ae9696e01a8a34760858fe9dd2", + "sha256:26089487086f2648944f17adaa1a97ca6aee57f513ba5f1c0b7ebdabbe2b9954", + "sha256:28bc9750ae1f75264ee0f10561709b1462d450a4808cd97c013046073ae64ab6", + "sha256:28e418681372520c992805bb723e29d69d6b7aa411065f48216d8329d02ba032", + "sha256:442feb5e5bada8408e8fcd43f3360b78683ff12a4444670a7d9e9824c1817d36", + "sha256:6ec0c021cd9fe732e5bab6401adea5a409214ca5592cd92a114f7067febcba0c", + "sha256:7094891dcf79ccc6bc2a1f30428fa5edb1e6fb955411ffff3401fb4ea93780a8", + "sha256:84e789a085aabef2f36c0515f45e459f02f570c4b4c4c108ac1179c34d475ed7", + "sha256:87a118968fba001b248aac90e502c0b13606721b1343cdaddbc6e552e8dfb56f", + "sha256:8e669fbdcdd1e945691079c2cae335f3e3a56554e06bbd45d7609a6cf568c700", + "sha256:ad2925567f43643f51255220424c23d204024ed428afc5aad0f86f3ffc080086", + "sha256:b0677a52f5d896e84414761531947c7a330d1adc07c3a4372262f25d84af7bf7", + "sha256:b07b40f5fb4fa034120a5796288f24c1fe0e0580bbfff99897ba6267af42def2", + "sha256:b09804ff570b907da323b3d762e74432fb07955701b17b08ff1b5ebaa8cfe6a9", + "sha256:b162ac10ca38850510caf8ea33f89edcb7b0bb0dfa5592d59909419986b72407", + "sha256:b31da69ed0c18be8b77bfce48d234e55d040793cebb25398e2a7d84199fbc7e2", + "sha256:caf65a396c0d1f9809596be2e444e3bd4190d86d5c1ce21f5fc4be60a3bc5b36", + "sha256:cfa1161c6ac8f92dea03d625c2d0c05e084668f4a06568b77a25a89111621566", + "sha256:dae46bed2cb79a58d6496ff6d8da1e3b95ba09afeca2e277628171ca99b99db1", + "sha256:ddc7ab52b322eb1e40521eb422c4e0a20716c271a306860979d450decbb51b8e", + "sha256:de92efa737875329b052982e37bd4371d52cabf469f83e7b8be9bb7752d67e51", + "sha256:e274f0f6c7efd0d577744f52032fdd24344f11c5ae668fe8d01aac0422611df1", + "sha256:ed5fb71d79e771ec930566fae9c02626b939e37271ec285e9efaf1b5d4370e7d", + "sha256:ef85cf1f693c88c1fd229ccd1055570cb41cdf4875873b7728b6301f12cd05bf", + "sha256:f1b739841821968798947d3afcefd386fa56da0caf97722a5de53e07c4ccedc7" + ], + "markers": "python_version >= '3.10'", + "version": "==1.24.1" }, "opencv-python": { "hashes": [ @@ -159,14 +63,6 @@ "index": "pypi", "version": "==4.6.0.66" }, - "packaging": { - "hashes": [ - "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3", - "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3" - ], - "markers": "python_version >= '3.7'", - "version": "==22.0" - }, "pillow": { "hashes": [ "sha256:03150abd92771742d4a8cd6f2fa6246d847dcd2e332a18d0c15cc75bf6703040", @@ -234,22 +130,6 @@ "index": "pypi", "version": "==9.3.0" }, - "platformdirs": { - "hashes": [ - "sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca", - "sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e" - ], - "markers": "python_version >= '3.7'", - "version": "==2.6.0" - }, - "pluggy": { - "hashes": [ - "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159", - "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3" - ], - "markers": "python_version >= '3.6'", - "version": "==1.0.0" - }, "protobuf": { "hashes": [ "sha256:1f22ac0ca65bb70a876060d96d914dae09ac98d114294f77584b0d2644fa9c30", @@ -270,38 +150,6 @@ "index": "pypi", "version": "==4.21.12" }, - "pycodestyle": { - "hashes": [ - "sha256:347187bdb476329d98f695c213d7295a846d1152ff4fe9bacb8a9590b8ee7053", - "sha256:8a4eaf0d0495c7395bdab3589ac2db602797d76207242c17d470186815706610" - ], - "markers": "python_version >= '3.6'", - "version": "==2.10.0" - }, - "pyflakes": { - "hashes": [ - "sha256:ec55bf7fe21fff7f1ad2f7da62363d749e2a470500eab1b555334b67aa1ef8cf", - "sha256:ec8b276a6b60bd80defed25add7e439881c19e64850afd9b346283d4165fd0fd" - ], - "markers": "python_version >= '3.6'", - "version": "==3.0.1" - }, - "pylint": { - "hashes": [ - "sha256:18783cca3cfee5b83c6c5d10b3cdb66c6594520ffae61890858fe8d932e1c6b4", - "sha256:349c8cd36aede4d50a0754a8c0218b43323d13d5d88f4b2952ddfe3e169681eb" - ], - "index": "pypi", - "version": "==2.15.9" - }, - "pytest": { - "hashes": [ - "sha256:892f933d339f068883b6fd5a459f03d85bfcb355e4981e146d2c7616c21fef71", - "sha256:c4014eb40e10f11f355ad4e3c2fb2c6c6d1919c73f3b5a433de4708202cade59" - ], - "index": "pypi", - "version": "==7.2.0" - }, "pyzbar": { "hashes": [ "sha256:13e3ee5a2f3a545204a285f41814d5c0db571967e8d4af8699a03afc55182a9c", @@ -323,100 +171,6 @@ ], "index": "pypi", "version": "==1.3.1" - }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "markers": "python_version < '3.11'", - "version": "==2.0.1" - }, - "tomlkit": { - "hashes": [ - "sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b", - "sha256:71b952e5721688937fb02cf9d354dbcf0785066149d2855e44531ebdd2b65d73" - ], - "markers": "python_version >= '3.6'", - "version": "==0.11.6" - }, - "wheel": { - "hashes": [ - "sha256:965f5259b566725405b05e7cf774052044b1ed30119b5d586b2703aafe8719ac", - "sha256:b60533f3f5d530e971d6737ca6d58681ee434818fab630c83a734bb10c083ce8" - ], - "index": "pypi", - "version": "==0.38.4" - }, - "wrapt": { - "hashes": [ - "sha256:00b6d4ea20a906c0ca56d84f93065b398ab74b927a7a3dbd470f6fc503f95dc3", - "sha256:01c205616a89d09827986bc4e859bcabd64f5a0662a7fe95e0d359424e0e071b", - "sha256:02b41b633c6261feff8ddd8d11c711df6842aba629fdd3da10249a53211a72c4", - "sha256:07f7a7d0f388028b2df1d916e94bbb40624c59b48ecc6cbc232546706fac74c2", - "sha256:11871514607b15cfeb87c547a49bca19fde402f32e2b1c24a632506c0a756656", - "sha256:1b376b3f4896e7930f1f772ac4b064ac12598d1c38d04907e696cc4d794b43d3", - "sha256:21ac0156c4b089b330b7666db40feee30a5d52634cc4560e1905d6529a3897ff", - "sha256:257fd78c513e0fb5cdbe058c27a0624c9884e735bbd131935fd49e9fe719d310", - "sha256:2b39d38039a1fdad98c87279b48bc5dce2c0ca0d73483b12cb72aa9609278e8a", - "sha256:2cf71233a0ed05ccdabe209c606fe0bac7379fdcf687f39b944420d2a09fdb57", - "sha256:2fe803deacd09a233e4762a1adcea5db5d31e6be577a43352936179d14d90069", - "sha256:3232822c7d98d23895ccc443bbdf57c7412c5a65996c30442ebe6ed3df335383", - "sha256:34aa51c45f28ba7f12accd624225e2b1e5a3a45206aa191f6f9aac931d9d56fe", - "sha256:36f582d0c6bc99d5f39cd3ac2a9062e57f3cf606ade29a0a0d6b323462f4dd87", - "sha256:380a85cf89e0e69b7cfbe2ea9f765f004ff419f34194018a6827ac0e3edfed4d", - "sha256:40e7bc81c9e2b2734ea4bc1aceb8a8f0ceaac7c5299bc5d69e37c44d9081d43b", - "sha256:43ca3bbbe97af00f49efb06e352eae40434ca9d915906f77def219b88e85d907", - "sha256:4fcc4649dc762cddacd193e6b55bc02edca674067f5f98166d7713b193932b7f", - "sha256:5a0f54ce2c092aaf439813735584b9537cad479575a09892b8352fea5e988dc0", - "sha256:5a9a0d155deafd9448baff28c08e150d9b24ff010e899311ddd63c45c2445e28", - "sha256:5b02d65b9ccf0ef6c34cba6cf5bf2aab1bb2f49c6090bafeecc9cd81ad4ea1c1", - "sha256:60db23fa423575eeb65ea430cee741acb7c26a1365d103f7b0f6ec412b893853", - "sha256:642c2e7a804fcf18c222e1060df25fc210b9c58db7c91416fb055897fc27e8cc", - "sha256:6a9a25751acb379b466ff6be78a315e2b439d4c94c1e99cb7266d40a537995d3", - "sha256:6b1a564e6cb69922c7fe3a678b9f9a3c54e72b469875aa8018f18b4d1dd1adf3", - "sha256:6d323e1554b3d22cfc03cd3243b5bb815a51f5249fdcbb86fda4bf62bab9e164", - "sha256:6e743de5e9c3d1b7185870f480587b75b1cb604832e380d64f9504a0535912d1", - "sha256:709fe01086a55cf79d20f741f39325018f4df051ef39fe921b1ebe780a66184c", - "sha256:7b7c050ae976e286906dd3f26009e117eb000fb2cf3533398c5ad9ccc86867b1", - "sha256:7d2872609603cb35ca513d7404a94d6d608fc13211563571117046c9d2bcc3d7", - "sha256:7ef58fb89674095bfc57c4069e95d7a31cfdc0939e2a579882ac7d55aadfd2a1", - "sha256:80bb5c256f1415f747011dc3604b59bc1f91c6e7150bd7db03b19170ee06b320", - "sha256:81b19725065dcb43df02b37e03278c011a09e49757287dca60c5aecdd5a0b8ed", - "sha256:833b58d5d0b7e5b9832869f039203389ac7cbf01765639c7309fd50ef619e0b1", - "sha256:88bd7b6bd70a5b6803c1abf6bca012f7ed963e58c68d76ee20b9d751c74a3248", - "sha256:8ad85f7f4e20964db4daadcab70b47ab05c7c1cf2a7c1e51087bfaa83831854c", - "sha256:8c0ce1e99116d5ab21355d8ebe53d9460366704ea38ae4d9f6933188f327b456", - "sha256:8d649d616e5c6a678b26d15ece345354f7c2286acd6db868e65fcc5ff7c24a77", - "sha256:903500616422a40a98a5a3c4ff4ed9d0066f3b4c951fa286018ecdf0750194ef", - "sha256:9736af4641846491aedb3c3f56b9bc5568d92b0692303b5a305301a95dfd38b1", - "sha256:988635d122aaf2bdcef9e795435662bcd65b02f4f4c1ae37fbee7401c440b3a7", - "sha256:9cca3c2cdadb362116235fdbd411735de4328c61425b0aa9f872fd76d02c4e86", - "sha256:9e0fd32e0148dd5dea6af5fee42beb949098564cc23211a88d799e434255a1f4", - "sha256:9f3e6f9e05148ff90002b884fbc2a86bd303ae847e472f44ecc06c2cd2fcdb2d", - "sha256:a85d2b46be66a71bedde836d9e41859879cc54a2a04fad1191eb50c2066f6e9d", - "sha256:a9a52172be0b5aae932bef82a79ec0a0ce87288c7d132946d645eba03f0ad8a8", - "sha256:aa31fdcc33fef9eb2552cbcbfee7773d5a6792c137b359e82879c101e98584c5", - "sha256:b014c23646a467558be7da3d6b9fa409b2c567d2110599b7cf9a0c5992b3b471", - "sha256:b21bb4c09ffabfa0e85e3a6b623e19b80e7acd709b9f91452b8297ace2a8ab00", - "sha256:b5901a312f4d14c59918c221323068fad0540e34324925c8475263841dbdfe68", - "sha256:b9b7a708dd92306328117d8c4b62e2194d00c365f18eff11a9b53c6f923b01e3", - "sha256:d1967f46ea8f2db647c786e78d8cc7e4313dbd1b0aca360592d8027b8508e24d", - "sha256:d52a25136894c63de15a35bc0bdc5adb4b0e173b9c0d07a2be9d3ca64a332735", - "sha256:d77c85fedff92cf788face9bfa3ebaa364448ebb1d765302e9af11bf449ca36d", - "sha256:d79d7d5dc8a32b7093e81e97dad755127ff77bcc899e845f41bf71747af0c569", - "sha256:dbcda74c67263139358f4d188ae5faae95c30929281bc6866d00573783c422b7", - "sha256:ddaea91abf8b0d13443f6dac52e89051a5063c7d014710dcb4d4abb2ff811a59", - "sha256:dee0ce50c6a2dd9056c20db781e9c1cfd33e77d2d569f5d1d9321c641bb903d5", - "sha256:dee60e1de1898bde3b238f18340eec6148986da0455d8ba7848d50470a7a32fb", - "sha256:e2f83e18fe2f4c9e7db597e988f72712c0c3676d337d8b101f6758107c42425b", - "sha256:e3fb1677c720409d5f671e39bac6c9e0e422584e5f518bfd50aa4cbbea02433f", - "sha256:ee2b1b1769f6707a8a445162ea16dddf74285c3964f605877a20e38545c3c462", - "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015", - "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af" - ], - "markers": "python_version < '3.11'", - "version": "==1.14.1" } }, "develop": { @@ -436,30 +190,14 @@ "markers": "python_version >= '3.6'", "version": "==22.2.0" }, - "colorama": { - "hashes": [ - "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", - "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" - ], - "markers": "sys_platform == 'win32'", - "version": "==0.4.6" - }, "dill": { "hashes": [ "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0", "sha256:e5db55f3687856d8fbdab002ed78544e1c4559a130302693d839dfe8f93f2373" ], - "markers": "python_version < '3.11'", + "markers": "python_version >= '3.11'", "version": "==0.3.6" }, - "exceptiongroup": { - "hashes": [ - "sha256:542adf9dea4055530d6e1279602fa5cb11dab2395fa650b8674eaec35fc4a828", - "sha256:bd14967b79cd9bdb54d97323216f8fdf533e278df937aa2a90089e7d6e06e5ec" - ], - "markers": "python_version < '3.11'", - "version": "==1.0.4" - }, "flake8": { "hashes": [ "sha256:3833794e27ff64ea4e9cf5d410082a8b97ff1a06c16aa3d2027339cd0f1195c7", @@ -572,14 +310,6 @@ "index": "pypi", "version": "==7.2.0" }, - "tomli": { - "hashes": [ - "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc", - "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f" - ], - "markers": "python_version < '3.11'", - "version": "==2.0.1" - }, "tomlkit": { "hashes": [ "sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b", @@ -663,7 +393,7 @@ "sha256:ee6acae74a2b91865910eef5e7de37dc6895ad96fa23603d1d27ea69df545015", "sha256:ef3f72c9666bba2bab70d2a8b79f2c6d2c1a42a7f7e2b0ec83bb2f9e383950af" ], - "markers": "python_version < '3.11'", + "markers": "python_version >= '3.11'", "version": "==1.14.1" } } diff --git a/README.md b/README.md index 2df19de..d5deba5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,18 @@ cd extract_otp_secret_keys ## Usage +### With builtin QR decoder + +1. Open "Google Authenticator" app on the mobile phone +2. Export the QR codes from "Google Authenticator" app +4. Save the captured QR codes as image files, e.g. example_export.png +5. Transfer the images files to the computer where his script is installed. +6. Call this script with the file as input: + + python extract_otp_secret_keys.py example_export.png + +### With external QR decoder app + 1. Open "Google Authenticator" app on the mobile phone 2. Export the QR codes from "Google Authenticator" app 3. Read QR codes with a QR code reader (e.g. from another phone) @@ -31,10 +43,10 @@ cd extract_otp_secret_keys ## Program help: arguments and options -
usage: extract_otp_secret_keys.py [-h] [--json FILE] [--csv FILE] [--keepass FILE] [--printqr] [--saveqr DIR] [--verbose | --quiet] infile
+
usage: extract_otp_secret_keys.py [-h] [--json FILE] [--csv FILE] [--keepass FILE] [--printqr] [--saveqr DIR] [--verbose | --quiet] infile [infile ...]
 
 positional arguments:
-  infile                   file or - for stdin with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored
+  infile                   1) file or - for stdin with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored; or 2) image file containing a QR code or = for stdin for an image containing a QR code
 
 options:
   -h, --help               show this help message and exit
@@ -44,7 +56,13 @@ options:
   --printqr, -p            print QR code(s) as text to the terminal (requires qrcode module)
   --saveqr DIR, -s DIR     save QR code(s) as images to the given folder (requires qrcode module)
   --verbose, -v            verbose output
-  --quiet, -q              no stdout output, except output set by -
+ --quiet, -q no stdout output, except output set by - + +examples: +python extract_otp_secret_keys.py example_*.txt +python extract_otp_secret_keys.py - < example_export.txt +python extract_otp_secret_keys.py --csv - example_*.png | tail -n+2 +python extract_otp_secret_keys.py = < example_export.png
## Dependencies @@ -57,25 +75,96 @@ Known to work with For protobuf versions 3.14.0 or similar or Python 3.6, use the extract_otp_secret_keys version 1.4.0. -### Optional +### Shared libs installation for reading QR code images + +For reading QR code images the zbar library must be installed. +If you do not extract directly from images, you do not need to install the zbar shared library. + +For a detailed installation documentation of [pyzbar](https://github.com/NaturalHistoryMuseum/pyzbar#installation). + +#### Windows + +The zbar DLLs are included with the Windows Python wheels. On other operating systems, you will need to install the zbar shared library. + +#### Linux (Debian, Ubuntu, ...) + + sudo apt-get install libzbar0 + +#### Linux (OpenSUSE) + + sudo zypper install libzbar0 + +#### Linux (Fedora) + + sudo dnf install libzbar0 + +#### Mac OS X + + brew install zbar -For printing QR codes, the qrcode module is required, otherwise it can be omitted. +## Examples - pip install qrcode[pil] +### Printing otp secrets form text file + + python extract_otp_secret_keys.py example_export.txt + +### Printing otp secrets from image file + + python extract_otp_secret_keys.py example_export.png + +### Printing otp secrets multiple files + + python extract_otp_secret_keys.py example_*.txt + python extract_otp_secret_keys.py example_*.png + python extract_otp_secret_keys.py example_export.* + python extract_otp_secret_keys.py example_*.txt example_*.png + +### Printing otp secrets from stdin (text) + + python extract_otp_secret_keys.py - < example_export.txt + +### Printing otp secrets from stdin (image) + + python extract_otp_secret_keys.py = < example_export.png + +### Printing otp secrets csv to stdout + + python extract_otp_secret_keys.py --csv - example_export.txt + +### Printing otp secrets csv to stdout without header line + + python extract_otp_secret_keys.py --csv - example_*.png | tail -n+2 + +### Reading from stdin and printing to stdout + + cat example_*.txt | python extract_otp_secret_keys.py --csv - - | tail -n+2 ## Features * Free and open source -* Supports Google Authenticator export +* Supports Google Authenticator exports (and compatible apps like Aegis Authenticator) * All functionality in one Python script: extract_otp_secret_keys.py (except protobuf generated code in protobuf_generated_python) * Supports TOTP and HOTP * Generates QR codes -* Various export formats: +* Reads QR Code images +* Exports to various formats: * CSV * JSON * Dedicated CSV for KeePass * QR code images -* Supports reading from stdin and writing to stdout by specifying '-' +* Supports reading from stdin and writing to stdout +* Reads from various import image formats containing export QR codes: (See [OpenCV docu](https://docs.opencv.org/3.4/d4/da8/group__imgcodecs.html#ga288b8b3da0892bd651fce07b3bbd3a56)) + * Portable Network Graphics - *.png + * WebP - *.webp + * JPEG files - *.jpeg, *.jpg, *.jpe + * TIFF files - *.tiff, *.tif + * Windows bitmaps - *.bmp, *.dib + * JPEG 2000 files - *.jp2 + * Portable image format - *.pbm, *.pgm, *.ppm *.pxm, *.pnm + * Sun rasters - *.sr, *.ras + * OpenEXR Image files - *.exr + * Radiance HDR - *.hdr, *.pic + * Raster and Vector geospatial data supported by GDAL * Errors and warnings are written to stderr * Many ways to run the script: * Native Python @@ -85,6 +174,10 @@ For printing QR codes, the qrcode module is required, otherwise it can be omitte * VSCode devcontainer * devbox * pip +* Compatible with multiple platforms (tested by CI): + * Linux + * macOS + * Windows ## KeePass @@ -216,10 +309,29 @@ Install [Docker](https://docs.docker.com/get-docker/). Build and run the app within the container: ```bash -docker build . -t extract_otp -docker run --rm -v "$(pwd)":/files:ro extract_otp -p example_export.txt +docker build . -t extract_otp_secret_keys --pull +docker run --rm -v "$(pwd)":/files:ro extract_otp_secret_keys example_export.txt +docker run --rm -v "$(pwd)":/files:ro extract_otp_secret_keys example_export.png ``` +docker run --rm -v "$(pwd)":/files:ro -i extract_otp_secret_keys = < example_export.png +docker run --entrypoint /bin/bash -it --rm -v "$(pwd)":/files:ro extract_otp_secret_keys +docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys + +docker build . -t extract_otp_secret_keys_no_qr_reader -f Dockerfile_no_qr_reader --pull +docker build . -t extract_otp_secret_keys_no_qr_reader -f Dockerfile_no_qr_reader --pull --build-arg run_tests=false +docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys_no_qr_reader +docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys_no_qr_reader test_extract_otp_secret_keys_pytest.py -k "not qreader" +docker run --rm -v "$(pwd)":/files:ro extract_otp_secret_keys_no_qr_reader example_export.txt +docker run --rm -v "$(pwd)":/files:ro -i extract_otp_secret_keys_no_qr_reader - < example_export.txt +docker build . -t extract_otp_secret_keys_no_qr_reader -f Dockerfile_no_qr_reader --pull && docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys_no_qr_reader test_extract_otp_secret_keys_pytest.py -k "not qreader" -vvv --relaxed -s + +docker pull scit0/extract_otp_secret_keys +docker pull scit0/extract_otp_secret_keys_no_qr_reader + +docker pull ghcr.io/scito/extract_otp_secret_keys +docker pull ghcr.io/scito/extract_otp_secret_keys_no_qr_reader + ## Tests ### PyTest @@ -268,6 +380,8 @@ pip install -U -r requirements.txt * [ZBar](https://github.com/mchehab/zbar) is an open source software suite for reading bar codes from various sources, including webcams. * [Aegis Authenticator](https://github.com/beemdevelopment/Aegis) is a free, secure and open source 2FA app for Android. * [Android OTP Extractor](https://github.com/puddly/android-otp-extractor) can extract your tokens from popular Android OTP apps and export them in a standard format or just display them as QR codes for easy importing. [Requires a _rooted_ Android phone.] +* [Python QReader](https://github.com/Eric-Canas/QReader) +* [pyzbar](https://github.com/NaturalHistoryMuseum/pyzbar) *** diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..9c66a08 --- /dev/null +++ b/conftest.py @@ -0,0 +1,10 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption( "--relaxed", action='store_true', help="run tests in relaxed mode") + + +@pytest.fixture +def relaxed(request): + return request.config.getoption("--relaxed") diff --git a/example_export.png b/example_export.png new file mode 100644 index 0000000..0a4d2b7 Binary files /dev/null and b/example_export.png differ diff --git a/extract_otp_secret_keys.py b/extract_otp_secret_keys.py index 47d8012..f467783 100644 --- a/extract_otp_secret_keys.py +++ b/extract_otp_secret_keys.py @@ -43,31 +43,33 @@ import argparse import base64 -import fileinput -import sys import csv +import fileinput +import importlib import json -import cv2 -from qreader import QReader -from urllib.parse import parse_qs, urlencode, urlparse, quote -from os import path, makedirs -from re import compile as rcompile -import protobuf_generated_python.google_auth_pb2 - +import os +import re +import sys +import urllib.parse as urlparse -verbose = False -quiet = True +import protobuf_generated_python.google_auth_pb2 +# These dynamic import are below: +# import cv2 +# import numpy +# from qreader import QReader def sys_main(): main(sys.argv[1:]) def main(sys_args): - global verbose, quiet + global verbose, quiet, qreader_available # allow to use sys.stdout with with (avoid closing) sys.stdout.close = lambda: None + # sys.stdout.reconfigure(encoding='utf-8') + args = parse_args(sys_args) verbose = args.verbose if args.verbose else 0 @@ -80,21 +82,21 @@ def main(sys_args): def parse_args(sys_args): - formatter = lambda prog: argparse.HelpFormatter(prog, max_help_position=52) - arg_parser = argparse.ArgumentParser(formatter_class=formatter) - arg_parser.add_argument('infile', - help="image file containing a QR code from a Google Authenticator export or a text file " - "or - for stdin with \"otpauth-migration://...\" URLs separated by newlines. Lines " - "starting with # are ignored.") + formatter = lambda prog: argparse.RawDescriptionHelpFormatter(prog, max_help_position=52) + example_text = '''examples: +python extract_otp_secret_keys.py example_*.txt +python extract_otp_secret_keys.py - < example_export.txt +python extract_otp_secret_keys.py --csv - example_*.png | tail -n+2 +python extract_otp_secret_keys.py = < example_export.png''' + + arg_parser = argparse.ArgumentParser(formatter_class=formatter, + epilog=example_text) + arg_parser.add_argument('infile', help='1) file or - for stdin with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored; or 2) image file containing a QR code or = for stdin for an image containing a QR code', nargs='+') arg_parser.add_argument('--json', '-j', help='export json file or - for stdout', metavar=('FILE')) arg_parser.add_argument('--csv', '-c', help='export csv file or - for stdout', metavar=('FILE')) - arg_parser.add_argument('--keepass', '-k', help='export totp/hotp csv file(s) for KeePass, - for stdout', - metavar=('FILE')) - arg_parser.add_argument('--printqr', '-p', help='print QR code(s) as text to the terminal (requires qrcode module)', - action='store_true') - arg_parser.add_argument('--saveqr', '-s', - help='save QR code(s) as images to the given folder (requires qrcode module)', - metavar=('DIR')) + arg_parser.add_argument('--keepass', '-k', help='export totp/hotp csv file(s) for KeePass, - for stdout', metavar=('FILE')) + arg_parser.add_argument('--printqr', '-p', help='print QR code(s) as text to the terminal (requires qrcode module)', action='store_true') + arg_parser.add_argument('--saveqr', '-s', help='save QR code(s) as images to the given folder (requires qrcode module)', metavar=('DIR')) output_group = arg_parser.add_mutually_exclusive_group() output_group.add_argument('--verbose', '-v', help='verbose output', action='count') output_group.add_argument('--quiet', '-q', help='no stdout output, except output set by -', action='store_true') @@ -110,112 +112,148 @@ def extract_otps(args): otps = [] - lines = get_lines_from_file(args.infile) - - i = j = 0 - - for line in lines: - if verbose: - print(line) - if line.startswith('#') or line == '': - continue - i += 1 - payload = get_payload_from_line(line, i, args) - - # pylint: disable=no-member - for raw_otp in payload.otp_parameters: - j += 1 - if verbose: - print('\n{}. Secret Key'.format(j)) - secret = convert_secret_from_bytes_to_base32_str(raw_otp.secret) - otp_type_enum = get_enum_name_by_number(raw_otp, 'type') - otp_type = get_otp_type_str_from_code(raw_otp.type) - otp_url = build_otp_url(secret, raw_otp) - otp = { - "name": raw_otp.name, - "secret": secret, - "issuer": raw_otp.issuer, - "type": otp_type, - "counter": raw_otp.counter if raw_otp.type == 1 else None, - "url": otp_url - } - if not quiet: - print_otp(otp) - if args.printqr: - print_qr(args, otp_url) - if args.saveqr: - save_qr(otp, args, j) - if not quiet: - print() - - otps.append(otp) - + i = j = k = 0 + if verbose: print('Input files: {}'.format(args.infile)) + for infile in args.infile: + if verbose: print('Processing infile {}'.format(infile)) + k += 1 + for line in get_lines_from_file(infile): + if verbose: print(line) + if line.startswith('#') or line == '': continue + i += 1 + payload = get_payload_from_line(line, i, infile) + + # pylint: disable=no-member + for raw_otp in payload.otp_parameters: + j += 1 + if verbose: print('\n{}. Secret Key'.format(j)) + secret = convert_secret_from_bytes_to_base32_str(raw_otp.secret) + otp_type_enum = get_enum_name_by_number(raw_otp, 'type') + otp_type = get_otp_type_str_from_code(raw_otp.type) + otp_url = build_otp_url(secret, raw_otp) + otp = { + "name": raw_otp.name, + "secret": secret, + "issuer": raw_otp.issuer, + "type": otp_type, + "counter": raw_otp.counter if raw_otp.type == 1 else None, + "url": otp_url + } + if not quiet: + print_otp(otp) + if args.printqr: + print_qr(args, otp_url) + if args.saveqr: + save_qr(otp, args, j) + if not quiet: + print() + + otps.append(otp) + if verbose: print('{} infile(s) processed'.format(k)) return otps -def get_lines_from_file(filepath): - global verbose +def get_lines_from_file(filename): + global qreader_available + # stdin stream cannot be rewinded, thus distinguish, use - for utf-8 stdin and = for binary image stdin + if filename != '=': + check_file_exists(filename) + lines = read_lines_from_text_file(filename) + if lines or filename == '-': + return lines - # Check if this is an image file - if(path.splitext(filepath)[1][1:].lower() in ('bmp', 'jpg', 'jpeg', 'png', 'tif', 'tiff')): - # It's an image file, so try to read it as a QR Code - try: - decoder = QReader() + # could not process text file, try reading as image + if filename != '-': + return convert_img_to_line(filename) - if not path.isfile(filepath): - eprint('\nERROR: Input file provided is non-existent or not a file.' - '\ninput file: {}'.format(filepath)) - return [] - image = cv2.imread(filepath) - if image is None: - eprint('\nERROR: Unable to open file for reading. Please ensure that you have read access to the ' - 'file and that the file is a valid image file.\ninput file: {}'.format(filepath)) - return [] +def read_lines_from_text_file(filename): + if verbose: print('Reading lines of {}'.format(filename)) + finput = fileinput.input(filename) + try: + lines = [] + for line in (line.strip() for line in finput): + if verbose: print(line) + if is_binary(line): + abort('\nBinary input was given in stdin, please use = instead of - as infile argument for images.') + # unfortunately yield line leads to random test fails + lines.append(line) + if not lines: + eprint("WARN: {} is empty".format(filename.replace('-', 'stdin'))) + return lines + except UnicodeDecodeError: + if filename == '-': + abort('\nERROR: Unable to open text file form stdin. ' + 'In case you want read an image file from stdin, you must use "=" instead of "-".') + else: # The file is probably an image, process below + return None + finally: + finput.close() - decoded_text = decoder.detect_and_decode(image=image) - if decoded_text is None: - eprint('\nERROR: Unable to read QR Code from file.\ninput file: {}'.format(filepath)) + +def convert_img_to_line(filename): + try: + import cv2 + import numpy + except Exception as e: + eprint("WARNING: No cv2 or numpy module installed. Exception: {}".format(str(e))) + return [] + if verbose: print('Reading image {}'.format(filename)) + try: + if filename != '=': + image = cv2.imread(filename) + else: + try: + stdin = sys.stdin.buffer.read() + except AttributeError: + # Workaround for pytest, since pytest cannot monkeypatch sys.stdin.buffer + stdin = sys.stdin.read() + if not stdin: + eprint("WARN: stdin is empty") + try: + img_array = numpy.frombuffer(stdin, dtype='uint8') + except TypeError as e: + abort('\nERROR: Cannot read binary stdin buffer. Exception: {}'.format(str(e))) + if not img_array.size: return [] + image = cv2.imdecode(img_array, cv2.IMREAD_UNCHANGED) - return [decoded_text] - except Exception as e: - eprint('\nERROR: Encountered exception "{}".\ninput file: {}'.format(str(e), filepath)) - return [] - else: - # Not an image file, so assume it's a text file and proceed as usual - lines = [] - finput = fileinput.input(filepath) + if image is None: + abort('\nERROR: Unable to open file for reading.\ninput file: {}'.format(filename)) + + # dynamic import of QReader since this module has a dependency to zbar lib and import it only when necessary try: - for line in (line.strip() for line in finput): - if verbose: - print(line) - if line.startswith('#') or line == '': - continue - lines.append(line) - finally: - finput.close() - return lines + from qreader import QReader + except ImportError as e: + abort(''' +ERROR: Cannot import QReader module. This problem is probably due to the missing zbar shared library. +On Linux and macOS libzbar0 must be installed. +See in README.md for the installation of the libzbar0. +Exception: {}'''.format(str(e))) + + decoder = QReader() + decoded_text = decoder.detect_and_decode(image=image) + if decoded_text is None: + abort('\nERROR: Unable to read QR Code from file.\ninput file: {}'.format(filename)) + return [decoded_text] + except Exception as e: + abort('\nERROR: Encountered exception "{}".\ninput file: {}'.format(str(e), filename)) -def get_payload_from_line(line, i, args): + +def get_payload_from_line(line, i, infile): global verbose if not line.startswith('otpauth-migration://'): - eprint( - '\nWARN: line is not a otpauth-migration:// URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format( - args.infile, line)) - parsed_url = urlparse(line) + eprint( '\nWARN: line is not a otpauth-migration:// URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(infile, line)) + parsed_url = urlparse.urlparse(line) if verbose > 1: print('\nDEBUG: parsed_url={}'.format(parsed_url)) try: - params = parse_qs(parsed_url.query, strict_parsing=True) + params = urlparse.parse_qs(parsed_url.query, strict_parsing=True) except: # Not necessary for Python >= 3.11 params = [] if verbose > 1: print('\nDEBUG: querystring params={}'.format(params)) if 'data' not in params: - eprint( - '\nERROR: no data query parameter in input URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format( - args.infile, line)) - sys.exit(1) + abort('\nERROR: no data query parameter in input URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(infile, line)) data_base64 = params['data'][0] if verbose > 1: print('\nDEBUG: data_base64={}'.format(data_base64)) data_base64_fixed = data_base64.replace(' ', '+') @@ -225,9 +263,8 @@ def get_payload_from_line(line, i, args): try: payload.ParseFromString(data) except: - eprint('\nERROR: Cannot decode otpauth-migration migration payload.') - eprint('data={}'.format(data_base64)) - exit(1) + abort('\nERROR: Cannot decode otpauth-migration migration payload.\n' + 'data={}'.format(data_base64)) if verbose: print('\n{}. Payload Line'.format(i), payload, sep='\n') @@ -252,8 +289,7 @@ def build_otp_url(secret, raw_otp): url_params = {'secret': secret} if raw_otp.type == 1: url_params['counter'] = raw_otp.counter if raw_otp.issuer: url_params['issuer'] = raw_otp.issuer - otp_url = 'otpauth://{}/{}?'.format(get_otp_type_str_from_code(raw_otp.type), quote(raw_otp.name)) + urlencode( - url_params) + otp_url = 'otpauth://{}/{}?'.format(get_otp_type_str_from_code(raw_otp.type), urlparse.quote(raw_otp.name)) + urlparse.urlencode( url_params) return otp_url @@ -270,12 +306,11 @@ def print_otp(otp): def save_qr(otp, args, j): dir = args.saveqr - if not (path.exists(dir)): makedirs(dir, exist_ok=True) - pattern = rcompile(r'[\W_]+') + if not (os.path.exists(dir)): os.makedirs(dir, exist_ok=True) + pattern = re.compile(r'[\W_]+') file_otp_name = pattern.sub('', otp['name']) file_otp_issuer = pattern.sub('', otp['issuer']) - save_qr_file(args, otp['url'], - '{}/{}-{}{}.png'.format(dir, j, file_otp_name, '-' + file_otp_issuer if file_otp_issuer else '')) + save_qr_file(args, otp['url'], '{}/{}-{}{}.png'.format(dir, j, file_otp_name, '-' + file_otp_issuer if file_otp_issuer else '')) return file_otp_issuer @@ -330,8 +365,7 @@ def write_keepass_csv(args, otps): count_totp_entries += 1 if has_hotp: with open_file_or_stdout_for_csv(otp_filename_hotp) as outfile: - writer = csv.DictWriter(outfile, - ["Title", "User Name", "HmacOtp-Secret-Base32", "HmacOtp-Counter", "Group"]) + writer = csv.DictWriter(outfile, ["Title", "User Name", "HmacOtp-Secret-Base32", "HmacOtp-Counter", "Group"]) writer.writeheader() for otp in otps: if otp['type'] == 'hotp': @@ -344,10 +378,8 @@ def write_keepass_csv(args, otps): }) count_hotp_entries += 1 if not quiet: - if count_totp_entries > 0: print( - "Exported {} totp entries to keepass csv file {}".format(count_totp_entries, otp_filename_totp)) - if count_hotp_entries > 0: print( - "Exported {} hotp entries to keepass csv file {}".format(count_hotp_entries, otp_filename_hotp)) + if count_totp_entries > 0: print( "Exported {} totp entries to keepass csv file {}".format(count_totp_entries, otp_filename_totp)) + if count_hotp_entries > 0: print( "Exported {} hotp entries to keepass csv file {}".format(count_hotp_entries, otp_filename_hotp)) def write_json(args, otps): @@ -367,7 +399,7 @@ def has_otp_type(otps, otp_type): def add_pre_suffix(file, pre_suffix): '''filename.ext, pre -> filename.pre.ext''' - name, ext = path.splitext(file) + name, ext = os.path.splitext(file) return name + "." + pre_suffix + (ext if ext else "") @@ -386,10 +418,34 @@ def open_file_or_stdout_for_csv(filename): return open(filename, "w", encoding='utf-8', newline='') if filename != '-' else sys.stdout +def check_file_exists(filename): + if filename != '-' and not os.path.isfile(filename): + abort('\nERROR: Input file provided is non-existent or not a file.' + '\ninput file: {}'.format(filename)) + + +def is_binary(line): + try: + line.startswith('#') + return False + except (UnicodeDecodeError, AttributeError, TypeError): + return True + + +def check_module_available(module_name): + module_spec = importlib.util.find_spec(module_name) + return module_spec is not None + + def eprint(*args, **kwargs): '''Print to stderr.''' print(*args, file=sys.stderr, **kwargs) +def abort(*args, **kwargs): + eprint(*args, **kwargs) + sys.exit(1) + + if __name__ == '__main__': sys_main() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..f9a13fe --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +markers = + qreader: QR image reader tests diff --git a/requirements-dev.txt b/requirements-dev.txt index cd0cf17..8202127 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,5 +2,3 @@ wheel pytest flake8 pylint -qreader -opencv-python diff --git a/run_pytest.sh b/run_pytest.sh new file mode 100755 index 0000000..e77ffae --- /dev/null +++ b/run_pytest.sh @@ -0,0 +1,3 @@ +#!/bin/sh +cd /extract +pip install -U pytest && pytest "$@" diff --git a/setup.py b/setup.py index 2613dad..8c9d510 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,9 @@ setup( install_requires=[ 'protobuf', 'qrcode', - 'Pillow' + 'Pillow', + 'qreader', + 'opencv-python' ], project_urls={ diff --git a/test/empty_file.txt b/test/empty_file.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/print_verbose_output.txt b/test/print_verbose_output.txt index da20bf7..cf01dae 100644 --- a/test/print_verbose_output.txt +++ b/test/print_verbose_output.txt @@ -1,3 +1,24 @@ +Input files: ['example_export.txt'] +Processing infile example_export.txt +Reading lines of example_export.txt +# 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/ +# Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi +otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B + +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4%2F%2F%2F%2F%2FwE%3D + +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B + +# otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 +otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D + +# otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +# Name: "encoding: ¿äÄéÉ? (demo)" +otpauth-migration://offline?data=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv%2F%2F%2F%2F%2F%2FAQ%3D%3D # 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/ # Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi @@ -136,3 +157,4 @@ Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY Type: totp otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +1 infile(s) processed diff --git a/test_extract_otp_secret_keys_pytest.py b/test_extract_otp_secret_keys_pytest.py index 110d239..0031971 100644 --- a/test_extract_otp_secret_keys_pytest.py +++ b/test_extract_otp_secret_keys_pytest.py @@ -18,13 +18,17 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from utils import read_csv, read_csv_str, read_json, read_json_str, remove_files, remove_dir_with_files, read_file_to_str, file_exits -from os import path -from pytest import raises, mark -from io import StringIO -from sys import implementation +import io +import os +import re +import sys + +import pytest import extract_otp_secret_keys +from utils import * + +qreader_available = extract_otp_secret_keys.check_module_available('cv2') def test_extract_stdout(capsys): @@ -38,9 +42,41 @@ def test_extract_stdout(capsys): assert captured.err == '' +@pytest.mark.qreader +def test_extract_multiple_files_and_mixed(capsys): + # Act + extract_otp_secret_keys.main([ + 'example_export.txt', + 'test/test_googleauth_export.png', + 'example_export.txt', + 'test/test_googleauth_export.png']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG + assert captured.err == '' + + +def test_extract_non_existent_file(capsys): + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secret_keys.main(['test/non_existent_file.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = '\nERROR: Input file provided is non-existent or not a file.\ninput file: test/non_existent_file.txt\n' + + assert captured.err == expected_stderr + assert captured.out == '' + assert e.value.code == 1 + assert e.type == SystemExit + + def test_extract_stdin_stdout(capsys, monkeypatch): # Arrange - monkeypatch.setattr('sys.stdin', StringIO(read_file_to_str('example_export.txt'))) + monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt'))) # Act extract_otp_secret_keys.main(['-']) @@ -52,6 +88,82 @@ def test_extract_stdin_stdout(capsys, monkeypatch): assert captured.err == '' +def test_extract_stdin_empty(capsys, monkeypatch): + # Arrange + monkeypatch.setattr('sys.stdin', io.StringIO()) + + # Act + extract_otp_secret_keys.main(['-']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == 'WARN: stdin is empty\n' + + +# @pytest.mark.skipif(not qreader_available, reason='Test if cv2 and qreader are not available.') +def test_extract_empty_file_no_qreader(capsys): + if qreader_available: + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secret_keys.main(['test/empty_file.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = 'WARN: test/empty_file.txt is empty\n\nERROR: Unable to open file for reading.\ninput file: test/empty_file.txt\n' + + assert captured.err == expected_stderr + assert captured.out == '' + assert e.value.code == 1 + assert e.type == SystemExit + else: + # Act + extract_otp_secret_keys.main(['test/empty_file.txt']) + + # Assert + captured = capsys.readouterr() + + assert captured.err == '' + assert captured.out == '' + + +@pytest.mark.qreader +def test_extract_stdin_img_empty(capsys, monkeypatch): + # Arrange + monkeypatch.setattr('sys.stdin', io.BytesIO()) + + # Act + extract_otp_secret_keys.main(['=']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == 'WARN: stdin is empty\n' + + +@pytest.mark.qreader +def test_extract_stdin_stdout_wrong_symbol(capsys, monkeypatch): + # Arrange + monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt'))) + + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secret_keys.main(['=']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = "\nERROR: Cannot read binary stdin buffer. Exception: a bytes-like object is required, not 'str'\n" + + assert captured.err == expected_stderr + assert captured.out == '' + assert e.value.code == 1 + assert e.type == SystemExit + + def test_extract_csv(capsys): # Arrange cleanup() @@ -99,7 +211,7 @@ def test_extract_csv_stdout(capsys): def test_extract_stdin_and_csv_stdout(capsys, monkeypatch): # Arrange cleanup() - monkeypatch.setattr('sys.stdin', StringIO(read_file_to_str('example_export.txt'))) + monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt'))) # Act extract_otp_secret_keys.main(['-c', '-', '-']) @@ -297,17 +409,20 @@ def test_extract_saveqr(capsys): assert captured.out == '' assert captured.err == '' - assert path.isfile('testout/qr/1-piraspberrypi-raspberrypi.png') - assert path.isfile('testout/qr/2-piraspberrypi.png') - assert path.isfile('testout/qr/3-piraspberrypi.png') - assert path.isfile('testout/qr/4-piraspberrypi-raspberrypi.png') + assert os.path.isfile('testout/qr/1-piraspberrypi-raspberrypi.png') + assert os.path.isfile('testout/qr/2-piraspberrypi.png') + assert os.path.isfile('testout/qr/3-piraspberrypi.png') + assert os.path.isfile('testout/qr/4-piraspberrypi-raspberrypi.png') # Clean up cleanup() -@mark.skipif(implementation.name == 'pypy', reason="Encoding problems in verbose mode in pypy.") -def test_extract_verbose(capsys): +def test_normalize_bytes(): + assert replace_escaped_octal_utf8_bytes_with_str('Before\\\\302\\\\277\\\\303\nname: enc: \\302\\277\\303\\244\\303\\204\\303\\251\\303\\211?\nAfter') == 'Before\\\\302\\\\277\\\\303\nname: enc: ¿äÄéÉ?\nAfter' + + +def test_extract_verbose(capsys, relaxed): # Act extract_otp_secret_keys.main(['-v', 'example_export.txt']) @@ -316,7 +431,13 @@ def test_extract_verbose(capsys): expected_stdout = read_file_to_str('test/print_verbose_output.txt') - assert captured.out == expected_stdout + if relaxed or sys.implementation.name == 'pypy': + print('\nRelaxed mode\n') + + assert replace_escaped_octal_utf8_bytes_with_str(captured.out) == replace_escaped_octal_utf8_bytes_with_str(expected_stdout) + assert quick_and_dirty_workaround_encoding_problem(captured.out) == quick_and_dirty_workaround_encoding_problem(expected_stdout) + else: + assert captured.out == expected_stdout assert captured.err == '' @@ -335,7 +456,7 @@ def test_extract_debug(capsys): def test_extract_help(capsys): - with raises(SystemExit) as pytest_wrapped_e: + with pytest.raises(SystemExit) as e: # Act extract_otp_secret_keys.main(['-h']) @@ -345,13 +466,13 @@ def test_extract_help(capsys): assert len(captured.out) > 0 assert "-h, --help" in captured.out and "--verbose, -v" in captured.out assert captured.err == '' - assert pytest_wrapped_e.type == SystemExit - assert pytest_wrapped_e.value.code == 0 + assert e.type == SystemExit + assert e.value.code == 0 def test_extract_no_arguments(capsys): # Act - with raises(SystemExit) as pytest_wrapped_e: + with pytest.raises(SystemExit) as e: extract_otp_secret_keys.main([]) # Assert @@ -361,10 +482,12 @@ def test_extract_no_arguments(capsys): assert expected_err_msg in captured.err assert captured.out == '' + assert e.value.code == 2 + assert e.type == SystemExit def test_verbose_and_quiet(capsys): - with raises(SystemExit) as pytest_wrapped_e: + with pytest.raises(SystemExit) as e: # Act extract_otp_secret_keys.main(['-v', '-q', 'example_export.txt']) @@ -374,10 +497,12 @@ def test_verbose_and_quiet(capsys): assert len(captured.err) > 0 assert 'error: argument --quiet/-q: not allowed with argument --verbose/-v' in captured.err assert captured.out == '' + assert e.value.code == 2 + assert e.type == SystemExit def test_wrong_data(capsys): - with raises(SystemExit) as pytest_wrapped_e: + with pytest.raises(SystemExit) as e: # Act extract_otp_secret_keys.main(['test/test_export_wrong_data.txt']) @@ -391,10 +516,12 @@ data=XXXX assert captured.err == expected_stderr assert captured.out == '' + assert e.value.code == 1 + assert e.type == SystemExit def test_wrong_content(capsys): - with raises(SystemExit) as pytest_wrapped_e: + with pytest.raises(SystemExit) as e: # Act extract_otp_secret_keys.main(['test/test_export_wrong_content.txt']) @@ -415,6 +542,8 @@ Probably a wrong file was given assert captured.out == '' assert captured.err == expected_stderr + assert e.value.code == 1 + assert e.type == SystemExit def test_wrong_prefix(capsys): @@ -448,6 +577,131 @@ def test_add_pre_suffix(capsys): assert extract_otp_secret_keys.add_pre_suffix("name", "totp") == "name.totp" +@pytest.mark.qreader +def test_img_qr_reader_from_file_happy_path(capsys): + # Act + extract_otp_secret_keys.main(['test/test_googleauth_export.png']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG + assert captured.err == '' + +@pytest.mark.qreader +def test_img_qr_reader_from_stdin(capsys, monkeypatch): + # Arrange + # sys.stdin.buffer should be monkey patched, but it does not work + monkeypatch.setattr('sys.stdin', read_binary_file_as_stream('test/test_googleauth_export.png')) + + # Act + extract_otp_secret_keys.main(['=']) + + # Assert + captured = capsys.readouterr() + + expected_stdout =\ +'''Name: Test1:test1@example1.com +Secret: JBSWY3DPEHPK3PXP +Issuer: Test1 +Type: totp + +Name: Test2:test2@example2.com +Secret: JBSWY3DPEHPK3PXQ +Issuer: Test2 +Type: totp + +Name: Test3:test3@example3.com +Secret: JBSWY3DPEHPK3PXR +Issuer: Test3 +Type: totp + +''' + + assert captured.out == expected_stdout + assert captured.err == '' + + +@pytest.mark.qreader +def test_img_qr_reader_from_stdin_wrong_symbol(capsys, monkeypatch): + # Arrange + # sys.stdin.buffer should be monkey patched, but it does not work + monkeypatch.setattr('sys.stdin', read_binary_file_as_stream('test/test_googleauth_export.png')) + + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secret_keys.main(['-']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = '\nBinary input was given in stdin, please use = instead of - as infile argument for images.\n' + + assert captured.err == expected_stderr + assert captured.out == '' + assert e.value.code == 1 + assert e.type == SystemExit + + +@pytest.mark.qreader +def test_img_qr_reader_no_qr_code_in_image(capsys): + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secret_keys.main(['test/lena_std.tif']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = '\nERROR: Unable to read QR Code from file.\ninput file: test/lena_std.tif\n' + + assert captured.err == expected_stderr + assert captured.out == '' + assert e.value.code == 1 + assert e.type == SystemExit + + +@pytest.mark.qreader +def test_img_qr_reader_nonexistent_file(capsys): + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secret_keys.main(['test/nonexistent.bmp']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = '\nERROR: Input file provided is non-existent or not a file.\ninput file: test/nonexistent.bmp\n' + + assert captured.err == expected_stderr + assert captured.out == '' + assert e.value.code == 1 + assert e.type == SystemExit + + +def test_non_image_file(capsys): + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secret_keys.main(['test/text_masquerading_as_image.jpeg']) + + # Assert + captured = capsys.readouterr() + expected_stderr = ''' +WARN: line is not a otpauth-migration:// URL +input file: test/text_masquerading_as_image.jpeg +line "This is just a text file masquerading as an image file." +Probably a wrong file was given + +ERROR: no data query parameter in input URL +input file: test/text_masquerading_as_image.jpeg +line "This is just a text file masquerading as an image file." +Probably a wrong file was given +''' + + assert captured.err == expected_stderr + assert captured.out == '' + assert e.value.code == 1 + assert e.type == SystemExit + + def cleanup(): remove_files('test_example_*.csv') remove_files('test_example_*.json') @@ -482,3 +736,21 @@ Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY Type: totp ''' + +EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG =\ +'''Name: Test1:test1@example1.com +Secret: JBSWY3DPEHPK3PXP +Issuer: Test1 +Type: totp + +Name: Test2:test2@example2.com +Secret: JBSWY3DPEHPK3PXQ +Issuer: Test2 +Type: totp + +Name: Test3:test3@example3.com +Secret: JBSWY3DPEHPK3PXR +Issuer: Test3 +Type: totp + +''' diff --git a/test_extract_otp_secret_keys_unittest.py b/test_extract_otp_secret_keys_unittest.py index 641c1df..874ef09 100644 --- a/test_extract_otp_secret_keys_unittest.py +++ b/test_extract_otp_secret_keys_unittest.py @@ -22,8 +22,8 @@ import unittest import io from contextlib import redirect_stdout from utils import read_csv, read_json, remove_file, remove_dir_with_files, Capturing, read_file_to_str -from os import path -from sys import implementation +import os +import sys import extract_otp_secret_keys @@ -160,13 +160,13 @@ Type: totp def test_extract_saveqr(self): extract_otp_secret_keys.main(['-q', '-s', 'testout/qr/', 'example_export.txt']) - self.assertTrue(path.isfile('testout/qr/1-piraspberrypi-raspberrypi.png')) - self.assertTrue(path.isfile('testout/qr/2-piraspberrypi.png')) - self.assertTrue(path.isfile('testout/qr/3-piraspberrypi.png')) - self.assertTrue(path.isfile('testout/qr/4-piraspberrypi-raspberrypi.png')) + self.assertTrue(os.path.isfile('testout/qr/1-piraspberrypi-raspberrypi.png')) + self.assertTrue(os.path.isfile('testout/qr/2-piraspberrypi.png')) + self.assertTrue(os.path.isfile('testout/qr/3-piraspberrypi.png')) + self.assertTrue(os.path.isfile('testout/qr/4-piraspberrypi-raspberrypi.png')) def test_extract_verbose(self): - if implementation.name == 'pypy': self.skipTest("Encoding problems in verbose mode in pypy.") + if sys.implementation.name == 'pypy': self.skipTest("Encoding problems in verbose mode in pypy.") out = io.StringIO() with redirect_stdout(out): extract_otp_secret_keys.main(['-v', 'example_export.txt']) @@ -187,19 +187,41 @@ Type: totp self.assertGreater(len(actual_output), len(expected_stdout)) self.assertTrue("DEBUG: " in actual_output) - def test_extract_help(self): + def test_extract_help_1(self): out = io.StringIO() with redirect_stdout(out): try: extract_otp_secret_keys.main(['-h']) - except SystemExit: - pass + self.fail("Must abort") + except SystemExit as e: + self.assertEqual(e.code, 0) actual_output = out.getvalue() self.assertGreater(len(actual_output), 0) self.assertTrue("-h, --help" in actual_output and "--verbose, -v" in actual_output) + def test_extract_help_2(self): + out = io.StringIO() + with redirect_stdout(out): + with self.assertRaises(SystemExit) as context: + extract_otp_secret_keys.main(['-h']) + + actual_output = out.getvalue() + + self.assertGreater(len(actual_output), 0) + self.assertTrue("-h, --help" in actual_output and "--verbose, -v" in actual_output) + self.assertEqual(context.exception.code, 0) + + def test_extract_help_3(self): + with Capturing() as actual_output: + with self.assertRaises(SystemExit) as context: + extract_otp_secret_keys.main(['-h']) + + self.assertGreater(len(actual_output), 0) + self.assertTrue("-h, --help" in "\n".join(actual_output) and "--verbose, -v" in "\n".join(actual_output)) + self.assertEqual(context.exception.code, 0) + def setUp(self): self.cleanup() diff --git a/test_extract_qrcode_unittest.py b/test_extract_qrcode_unittest.py index f753ae3..7f9c718 100644 --- a/test_extract_qrcode_unittest.py +++ b/test_extract_qrcode_unittest.py @@ -23,9 +23,8 @@ from utils import Capturing import extract_otp_secret_keys - -class TestExtract(unittest.TestCase): - def test_happy_path(self): +class TestQRImageExtract(unittest.TestCase): + def test_img_qr_reader_happy_path(self): with Capturing() as actual_output: extract_otp_secret_keys.main(['test/test_googleauth_export.png']) @@ -36,34 +35,48 @@ class TestExtract(unittest.TestCase): self.assertEqual(actual_output, expected_output) - def test_no_qr_code_in_image(self): + def test_img_qr_reader_no_qr_code_in_image(self): with Capturing() as actual_output: - extract_otp_secret_keys.main(['test/lena_std.tif']) + with self.assertRaises(SystemExit) as context: + extract_otp_secret_keys.main(['test/lena_std.tif']) expected_output =\ ['', 'ERROR: Unable to read QR Code from file.', 'input file: test/lena_std.tif'] self.assertEqual(actual_output, expected_output) + self.assertEqual(context.exception.code, 1) - def test_nonexistent_file(self): + def test_img_qr_reader_nonexistent_file(self): with Capturing() as actual_output: - extract_otp_secret_keys.main(['test/nonexistent.bmp']) + with self.assertRaises(SystemExit) as context: + extract_otp_secret_keys.main(['test/nonexistent.bmp']) expected_output =\ ['', 'ERROR: Input file provided is non-existent or not a file.', 'input file: test/nonexistent.bmp'] self.assertEqual(actual_output, expected_output) + self.assertEqual(context.exception.code, 1) - - def test_non_image_file(self): + def test_img_qr_reader_non_image_file(self): with Capturing() as actual_output: - extract_otp_secret_keys.main(['test/text_masquerading_as_image.jpeg']) - - expected_output =\ - ['', 'ERROR: Unable to open file for reading. Please ensure that you have read access to the file and that ' - 'the file is a valid image file.', 'input file: test/text_masquerading_as_image.jpeg'] + with self.assertRaises(SystemExit) as context: + extract_otp_secret_keys.main(['test/text_masquerading_as_image.jpeg']) + + expected_output = [ + '', + 'WARN: line is not a otpauth-migration:// URL', + 'input file: test/text_masquerading_as_image.jpeg', + 'line "This is just a text file masquerading as an image file."', + 'Probably a wrong file was given', + '', + 'ERROR: no data query parameter in input URL', + 'input file: test/text_masquerading_as_image.jpeg', + 'line "This is just a text file masquerading as an image file."', + 'Probably a wrong file was given' + ] self.assertEqual(actual_output, expected_output) + self.assertEqual(context.exception.code, 1) def setUp(self): self.cleanup() diff --git a/upgrade_deps.sh b/upgrade_deps.sh index 65fe5d1..339edc2 100755 --- a/upgrade_deps.sh +++ b/upgrade_deps.sh @@ -128,45 +128,45 @@ if [ "$OLDVERSION" != "$VERSION" ]; then mkdir -p $DOWNLOADS # https://github.com/protocolbuffers/protobuf/releases/download/v21.6/protoc-21.6-linux-x86_64.zip cmd="wget --trust-server-names https://github.com/protocolbuffers/protobuf/releases/download/v$VERSION/protoc-$VERSION-linux-x86_64.zip -O $DOWNLOADS/$ARCHIVE" - if $interactive ; then askContinueYn "$cmd"; fi + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="echo -e '\nSize [Byte]'; stat --printf='%s\n' $DOWNLOADS/$ARCHIVE; echo -e '\nMD5'; md5sum $DOWNLOADS/$ARCHIVE; echo -e '\nSHA256'; sha256sum $DOWNLOADS/$ARCHIVE;" - if $interactive ; then askContinueYn "$cmd"; fi + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="mkdir -p $BIN/$NAME; unzip $DOWNLOADS/$ARCHIVE -d $BIN/$NAME" - if $interactive ; then askContinueYn "$cmd"; fi + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="echo $VERSION > $BIN/$NAME/.VERSION.txt; echo $VERSION > $BIN/$NAME/.VERSION_$VERSION.txt" - if $interactive ; then askContinueYn "$cmd"; fi + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="[ -d $BIN/$DEST.old ] && rm -rf $BIN/$DEST.old || echo 'No old dir to delete'" - if $interactive ; then askContinueYn "$cmd"; fi + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="[ -d $BIN/$DEST ] && mv -iT $BIN/$DEST $BIN/$DEST.old || echo 'No previous dir to keep'" - if $interactive ; then askContinueYn "$cmd"; fi + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="mv -iT $BIN/$NAME $BIN/$DEST" - if $interactive ; then askContinueYn "$cmd"; fi + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="rm $DOWNLOADS/$ARCHIVE" - if $interactive ; then askContinueYn "$cmd"; fi + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="$BIN/$DEST/bin/protoc --python_out=protobuf_generated_python google_auth.proto" - if $interactive ; then askContinueYn "$cmd"; fi + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" # Update README.md cmd="perl -i -pe 's%proto(buf|c)([- ])(\d\.)?$OLDVERSION%proto\$1\$2\${3}$VERSION%g' README.md && perl -i -pe 's%(protobuf/releases/tag/v)$OLDVERSION%\${1}$VERSION%g' README.md" - if $interactive ; then askContinueYn "$cmd"; fi + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" else echo -e "\nVersion has not changed. Quit" @@ -176,27 +176,27 @@ fi # Upgrade pip requirements cmd="sudo pip install --upgrade pip" -if $interactive ; then askContinueYn "$cmd"; fi +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" $PIP --version cmd="$PIP install --use-pep517 -U -r requirements.txt" -if $interactive ; then askContinueYn "$cmd"; fi +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="$PIP install --use-pep517 -U -r requirements-dev.txt" -if $interactive ; then askContinueYn "$cmd"; fi +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="$PIP install -U pipenv" -if $interactive ; then askContinueYn "$cmd"; fi +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" $PIPENV --version cmd="$PIPENV update && $PIPENV --rm && $PIPENV install" -if $interactive ; then askContinueYn "$cmd"; fi +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" $PIPENV run python --version @@ -204,11 +204,33 @@ $PIPENV run python --version # Test cmd="pytest" -if $interactive ; then askContinueYn "$cmd"; fi +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" cmd="$PIPENV run pytest" -if $interactive ; then askContinueYn "$cmd"; fi +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# Build docker + +cmd="docker build . -t extract_otp_secret_keys_no_qr_reader -f Dockerfile_no_qr_reader --pull" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys_no_qr_reader test_extract_otp_secret_keys_pytest.py -k 'not qreader' -vvv --relaxed" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="docker build . -t extract_otp_secret_keys --pull" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="docker image prune" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi eval "$cmd" quit diff --git a/utils.py b/utils.py index 839864d..19cb52c 100644 --- a/utils.py +++ b/utils.py @@ -14,12 +14,13 @@ # along with this program. If not, see . import csv +import glob +import io import json import os +import re import shutil -from io import StringIO import sys -import glob # Ref. https://stackoverflow.com/a/16571630 @@ -31,9 +32,9 @@ with Capturing() as output: ''' def __enter__(self): self._stdout = sys.stdout - sys.stdout = self._stringio_std = StringIO() + sys.stdout = self._stringio_std = io.StringIO() self._stderr = sys.stderr - sys.stderr = self._stringio_err = StringIO() + sys.stderr = self._stringio_err = io.StringIO() return self def __exit__(self, *args): @@ -102,3 +103,22 @@ def read_file_to_list(filename): def read_file_to_str(filename): """Returns a str.""" return "".join(read_file_to_list(filename)) + +def read_binary_file_as_stream(filename): + """Returns binary file content.""" + with open(filename, "rb",) as infile: + return io.BytesIO(infile.read()) + +def replace_escaped_octal_utf8_bytes_with_str(str): + encoded_name_strings = re.findall(r'name: .*$', str, flags=re.MULTILINE) + for encoded_name_string in encoded_name_strings: + escaped_bytes = re.findall(r'((?:\\[0-9]+)+)', encoded_name_string) + for byte_sequence in escaped_bytes: + unicode_str = b''.join([int(byte, 8).to_bytes(1, 'little') for byte in byte_sequence.split('\\') if byte]).decode('utf-8') + print("Replace '{}' by '{}'".format(byte_sequence, unicode_str)) + str = str.replace(byte_sequence, unicode_str) + return str + + +def quick_and_dirty_workaround_encoding_problem(str): + return re.sub(r'name: "encoding: .*$', '', str, flags=re.MULTILINE)