diff --git a/.editorconfig b/.editorconfig index 9d6e0f0..cc9621a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -5,4 +5,11 @@ indent_style = space indent_size = 4 charset = utf-8 trim_trailing_whitespace = true -insert_final_newline = true \ No newline at end of file +insert_final_newline = true + +[*.md] +indent_size = 4 +trim_trailing_whitespace = false + +[*.{yml,toml}] +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b341585..791248f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,9 +1,13 @@ name: tests +# https://docs.github.com/de/actions/using-workflows/workflow-syntax-for-github-actions +# https://docs.github.com/en/actions/using-workflows + on: push: pull_request: schedule: + # Run daily on default branch - cron: '37 3 * * *' jobs: @@ -13,9 +17,7 @@ jobs: matrix: 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 - - python-version: [pypy-3.9] + # exclude: runs-on: ${{ matrix.platform }} @@ -36,13 +38,31 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install flake8 pytest - pip install --use-pep517 -r requirements.txt + pip install -U -r requirements-dev.txt + pip install -U . - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 . --count --exit-zero --max-complexity=10 --max-line-length=200 --statistics + if: matrix.python-version != '3.7' + - name: Type checking with mypy + run: | + mypy --install-types --non-interactive src/*.py tests/*.py + mypy --strict src/*.py tests/*.py + if: matrix.python-version == '3.x' && matrix.platform == 'ubuntu-latest' - name: Test with pytest run: pytest + if: (matrix.python-version != '3.x' || matrix.platform != 'ubuntu-latest') + # && matrix.platform != 'macos-latest' + - name: Test with pytest (with code coverage) + run: pytest --cov=extract_otp_secrets_test --junitxml=pytest.xml --cov-report=term-missing | tee pytest-coverage.txt + if: matrix.python-version == '3.x' && matrix.platform == 'ubuntu-latest' + # https://github.com/marketplace/actions/pytest-coverage-comment + - name: Pytest coverage comment + uses: MishaKav/pytest-coverage-comment@main + with: + pytest-coverage-path: ./pytest-coverage.txt + junitxml-path: ./pytest.xml + if: matrix.python-version == '3.x' && matrix.platform == 'ubuntu-latest' diff --git a/.github/workflows/ci_docker.yml b/.github/workflows/ci_docker.yml index fb01f8d..52e7042 100644 --- a/.github/workflows/ci_docker.yml +++ b/.github/workflows/ci_docker.yml @@ -1,4 +1,7 @@ -name: "Docker: build and publish" +name: docker + +# https://docs.github.com/de/actions/using-workflows/workflow-syntax-for-github-actions +# https://docs.github.com/en/actions/using-workflows # 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/ @@ -6,9 +9,9 @@ name: "Docker: build and publish" on: # run it on push to the default repository branch push: - # branches: [master] - # run it during pull request - # pull_request: + schedule: + # Run weekly on default branch + - cron: '47 3 * * 6' jobs: # define job to build and publish docker image @@ -22,6 +25,15 @@ jobs: - name: Checkout code uses: actions/checkout@v3 + # avoid building if there are testing errors + - name: Run smoke test + run: | + sudo apt-get install -y libzbar0 + python -m pip install --upgrade pip + pip install -U -r requirements-dev.txt + pip install -U . + pytest + - name: Set up QEMU uses: docker/setup-qemu-action@v2 @@ -44,33 +56,39 @@ jobs: password: ${{ secrets.GHCR_IO_TOKEN }} - name: "no_qr_reader: Build image and push to Docker Hub and GitHub Container Registry" + id: docker_build_only_txt 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 + file: Dockerfile_only_txt + # builder: ${{ steps.buildx.outputs.name }} # 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 + scit0/extract_otp_secrets:latest-only-txt + ghcr.io/scito/extract_otp_secrets:latest-only-txt # build on feature branches, push only on master branch - # TODO push: ${{ github.ref == 'refs/heads/master' }} - push: true + push: ${{ github.ref == 'refs/heads/master' }} + build-args: | + RUN_TESTS=true - name: "qr_reader: Build image and push to Docker Hub and GitHub Container Registry" + id: docker_build_qr_reader 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: . + # builder: ${{ steps.buildx.outputs.name }} # Note: tags has to be all lower-case tags: | - scit0/extract_otp_secret_keys:latest - ghcr.io/scito/extract_otp_secret_keys:latest + scit0/extract_otp_secrets:latest + ghcr.io/scito/extract_otp_secrets:latest # build on feature branches, push only on master branch - # TODO push: ${{ github.ref == 'refs/heads/master' }} - push: true + push: ${{ github.ref == 'refs/heads/master' }} - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} + run: | + echo "extract_otp_secrets: ${{ steps.docker_build_qr_reader.outputs.digest }}" + echo "extract_otp_secrets_only_txt: ${{ steps.docker_build_only_txt.outputs.digest }}" diff --git a/.gitignore b/.gitignore index 3edc334..19ff843 100644 --- a/.gitignore +++ b/.gitignore @@ -9,10 +9,13 @@ venv/ !example_output.csv !.github/ !.flake8 -.vscode -!.vscode/settings.json +!.vscode/ !.devcontainer/ !.devcontainer/*.json *.whl build/ -extract_otp_secret_keys.egg-info/ +dist/ +*.egg-info/ +*.xml +pytest-coverage.txt +tests/reports/ diff --git a/.vscode/extensions.json b/.vscode/extensions.json index ba653bc..500cc75 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,7 @@ { "recommendations": [ - "ms-python.python" + "ms-python.python", + "ms-python.isort", + "tamasfe.even-better-toml", ] } diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..eec54ae --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,39 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: extract_otp_secrets.py", + "type": "python", + "request": "launch", + "program": "src/extract_otp_secrets.py", + "args": [ + "example_export.txt" + ], + "console": "integratedTerminal" + }, + { + "name": "Python: extract_otp_secrets.py stdin pic", + "type": "python", + "request": "launch", + "program": "src/extract_otp_secrets.py", + "args": [ + "-", + "<", + "test/test_googleauth_export.png", + ], + "console": "integratedTerminal" + }, + { + "name": "Python: extract_otp_secrets.py capture", + "type": "python", + "request": "launch", + "program": "src/extract_otp_secrets.py", + "args": [ + ], + "console": "integratedTerminal" + }, + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index e46fded..b3addfc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,7 +2,7 @@ "python.testing.pytestArgs": [ "." ], - "python.testing.unittestEnabled": false, + "python.testing.unittestEnabled": true, "python.testing.pytestEnabled": true, "cSpell.words": [ "devbox", @@ -16,5 +16,9 @@ "qrcode", "TOTP", "venv" - ] + ], + "search.exclude": { + "**/build": true, + "**/dist": true + }, } diff --git a/Dockerfile b/Dockerfile index 5c2403e..7d5f5fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,32 @@ FROM python:3.11-slim-bullseye +# https://docs.docker.com/engine/reference/builder/ + +# For debugging +# docker build . -t extract_otp_secrets --pull --build-arg RUN_TESTS=false +# docker run --rm -v "$(pwd)":/files:ro extract_otp_secrets +# docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets +# docker run --entrypoint /bin/bash -it --rm -v "$(pwd)":/files:ro --device="/dev/video0:/dev/video0" --env="DISPLAY" -v /tmp/.X11-unix:/tmp/.X11-unix:ro extract_otp_secrets + WORKDIR /extract COPY . . -ARG run_tests=true +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 +RUN apt-get update && apt-get install -y \ + libgl1 \ + libglib2.0-0 \ + libsm6 \ + libzbar0 \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir -U -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/src/extract_otp_secrets.py"] -LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secret_keys +LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secrets +LABEL org.opencontainers.image.license GPL-3.0+ diff --git a/Dockerfile_no_qr_reader b/Dockerfile_no_qr_reader deleted file mode 100644 index 9ca838a..0000000 --- a/Dockerfile_no_qr_reader +++ /dev/null @@ -1,16 +0,0 @@ -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/Dockerfile_only_txt b/Dockerfile_only_txt new file mode 100644 index 0000000..b44e665 --- /dev/null +++ b/Dockerfile_only_txt @@ -0,0 +1,42 @@ +FROM python:3.11-alpine + +# https://docs.docker.com/engine/reference/builder/ + +# For debugging +# docker run --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt +# docker build . -t extract_otp_secrets_only_txt -f Dockerfile_only_txt --pull --build-arg RUN_TESTS=false +# docker run --entrypoint /bin/sh -it --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt +# docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt tests/extract_otp_secrets_test.py -k "not qreader" --relaxed -vvv -s + +WORKDIR /extract + +COPY . . + +ARG RUN_TESTS=true + +RUN apk add --no-cache \ + jpeg \ + zlib \ + && echo "Arch: $(apk --print-arch)" \ + && if [[ "$(apk --print-arch)" == "aarch64" ]]; then apk add --no-cache --virtual .build-deps \ + gcc \ + jpeg-dev \ + libc-dev \ + py3-setuptools \ + python3-dev \ + zlib-dev \ + ; fi \ + && pip install --no-cache-dir -U \ + colorama \ + Pillow \ + protobuf \ + qrcode \ + && if [[ "$(apk --print-arch)" == "aarch64" ]]; then apk del .build-deps; fi \ + && if [[ "$RUN_TESTS" == "true" ]]; then /extract/run_pytest.sh tests/extract_otp_secrets_test.py -k "not qreader" --relaxed; else echo "Not running tests..."; fi + +WORKDIR /files + +ENTRYPOINT ["python", "/extract/src/extract_otp_secrets.py"] + +LABEL org.opencontainers.image.source https://github.com/scito/extract_otp_secrets +LABEL org.opencontainers.image.license GPL-3.0+ diff --git a/Pipfile b/Pipfile index 899e049..90e9ecd 100644 --- a/Pipfile +++ b/Pipfile @@ -8,13 +8,20 @@ protobuf = "*" qrcode = "*" pillow = "*" qreader = "*" -opencv-python = "*" +opencv-contrib-python = "*" +colorama = ">=0.4.6" +# for macOS: opencv-contrib-python = "<=4.7.0" +# for PYTHON <= 3.7: typing_extensions = "*" [dev-packages] pytest = "*" +pytest-mock = "*" +pytest-cov = "*" wheel = "*" flake8 = "*" pylint = "*" +mypy = "*" +types-protobuf = "*" [requires] python_version = "3.11" diff --git a/Pipfile.lock b/Pipfile.lock index 6a11ca0..7125a63 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "2f4059c8dbac6be85b1e3b2c2032b884d48dc6a7fd520ffdebb951e23246a23e" + "sha256": "25b244c44cb891ac15ef20c4011eb043b87fb1f112396d68f470d0bb362e97f7" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,14 @@ ] }, "default": { + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "index": "pypi", + "version": "==0.4.6" + }, "numpy": { "hashes": [ "sha256:0044f7d944ee882400890f9ae955220d29b33d809a038923d88e4e01d652acd9", @@ -50,85 +58,107 @@ "markers": "python_version >= '3.10'", "version": "==1.24.1" }, - "opencv-python": { + "opencv-contrib-python": { "hashes": [ - "sha256:0dc82a3d8630c099d2f3ac1b1aabee164e8188db54a786abb7a4e27eba309440", - "sha256:5af8ba35a4fcb8913ffb86e92403e9a656a4bff4a645d196987468f0f8947875", - "sha256:6e32af22e3202748bd233ed8f538741876191863882eba44e332d1a34993165b", - "sha256:c5bfae41ad4031e66bb10ec4a0a2ffd3e514d092652781e8b1ac98d1b59f1158", - "sha256:dbdc84a9b4ea2cbae33861652d25093944b9959279200b7ae0badd32439f74de", - "sha256:e6e448b62afc95c5b58f97e87ef84699e6607fe5c58730a03301c52496005cae", - "sha256:f482e78de6e7b0b060ff994ffd859bddc3f7f382bb2019ef157b0ea8ca8712f5" + "sha256:1a48c2f24440cfd6e49c84dbe39c39feff5efbc90be8299c76e7141973d403b6", + "sha256:2b8e3a1a7af31ebed28487d161ca4be0edd0b0e241667c6e9c842ac683313b2f", + "sha256:2f0c32b0f2f55255632a44bdcfa185f88c7fb6d2616869942aff9d5a39df4997", + "sha256:35e9a3809da10a47189c06d4d78b8e7821b9a3578dec8cbddf6ee1675bd83557", + "sha256:3a00e12546e5578f6bb7ed408c37fcfea533d74e9691cfaf40926f6b43295577", + "sha256:6d1c993811f92ddd7919314ada7b9be1f23db1c73f1384915c834dee8549c0b9", + "sha256:7a08f9d1f9dd52de63a7bb448ab7d6d4a1a85b767c2358501d968d1e4d95098d", + "sha256:7a75f1775790106e54bcfb101c0e00e1f801a57d9baebc82d0b6758fc83a4ca0", + "sha256:86f4b60b9536948f16d2170ba3a9b22d3955a957dc61a9bc56e53692c6db2c7e", + "sha256:9829e6efedde1d1b8419c5bd4d62d289ecbf44ae35b843c6da9e3cbcba1a9a8a", + "sha256:abc6adfa8694f71a4caffa922b279bd9d96954a37eee40b147f613c64310b411", + "sha256:b4033a164b2e2ea0049ba8c1194dab82dca680953ac36f33d1cc2c060906555f", + "sha256:e3967b1f3d74b8c70be724dbc07921faec87e8806cc87b2db5e7057815d6a08c", + "sha256:e770e9f653a0e5e72b973adb8213fae2df4642730ba1faf31e73a54287a4d5d4" ], "index": "pypi", - "version": "==4.6.0.66" + "version": "==4.7.0.68" + }, + "opencv-python": { + "hashes": [ + "sha256:3a00e12546e5578f6bb7ed408c37fcfea533d74e9691cfaf40926f6b43295577", + "sha256:6d1c993811f92ddd7919314ada7b9be1f23db1c73f1384915c834dee8549c0b9", + "sha256:7a08f9d1f9dd52de63a7bb448ab7d6d4a1a85b767c2358501d968d1e4d95098d", + "sha256:86f4b60b9536948f16d2170ba3a9b22d3955a957dc61a9bc56e53692c6db2c7e", + "sha256:9829e6efedde1d1b8419c5bd4d62d289ecbf44ae35b843c6da9e3cbcba1a9a8a", + "sha256:abc6adfa8694f71a4caffa922b279bd9d96954a37eee40b147f613c64310b411", + "sha256:e770e9f653a0e5e72b973adb8213fae2df4642730ba1faf31e73a54287a4d5d4" + ], + "markers": "python_version >= '3.6'", + "version": "==4.7.0.68" }, "pillow": { "hashes": [ - "sha256:03150abd92771742d4a8cd6f2fa6246d847dcd2e332a18d0c15cc75bf6703040", - "sha256:073adb2ae23431d3b9bcbcff3fe698b62ed47211d0716b067385538a1b0f28b8", - "sha256:0b07fffc13f474264c336298d1b4ce01d9c5a011415b79d4ee5527bb69ae6f65", - "sha256:0b7257127d646ff8676ec8a15520013a698d1fdc48bc2a79ba4e53df792526f2", - "sha256:12ce4932caf2ddf3e41d17fc9c02d67126935a44b86df6a206cf0d7161548627", - "sha256:15c42fb9dea42465dfd902fb0ecf584b8848ceb28b41ee2b58f866411be33f07", - "sha256:18498994b29e1cf86d505edcb7edbe814d133d2232d256db8c7a8ceb34d18cef", - "sha256:1c7c8ae3864846fc95f4611c78129301e203aaa2af813b703c55d10cc1628535", - "sha256:22b012ea2d065fd163ca096f4e37e47cd8b59cf4b0fd47bfca6abb93df70b34c", - "sha256:276a5ca930c913f714e372b2591a22c4bd3b81a418c0f6635ba832daec1cbcfc", - "sha256:2e0918e03aa0c72ea56edbb00d4d664294815aa11291a11504a377ea018330d3", - "sha256:3033fbe1feb1b59394615a1cafaee85e49d01b51d54de0cbf6aa8e64182518a1", - "sha256:3168434d303babf495d4ba58fc22d6604f6e2afb97adc6a423e917dab828939c", - "sha256:32a44128c4bdca7f31de5be641187367fe2a450ad83b833ef78910397db491aa", - "sha256:3dd6caf940756101205dffc5367babf288a30043d35f80936f9bfb37f8355b32", - "sha256:40e1ce476a7804b0fb74bcfa80b0a2206ea6a882938eaba917f7a0f004b42502", - "sha256:41e0051336807468be450d52b8edd12ac60bebaa97fe10c8b660f116e50b30e4", - "sha256:4390e9ce199fc1951fcfa65795f239a8a4944117b5935a9317fb320e7767b40f", - "sha256:502526a2cbfa431d9fc2a079bdd9061a2397b842bb6bc4239bb176da00993812", - "sha256:51e0e543a33ed92db9f5ef69a0356e0b1a7a6b6a71b80df99f1d181ae5875636", - "sha256:57751894f6618fd4308ed8e0c36c333e2f5469744c34729a27532b3db106ee20", - "sha256:5d77adcd56a42d00cc1be30843d3426aa4e660cab4a61021dc84467123f7a00c", - "sha256:655a83b0058ba47c7c52e4e2df5ecf484c1b0b0349805896dd350cbc416bdd91", - "sha256:68943d632f1f9e3dce98908e873b3a090f6cba1cbb1b892a9e8d97c938871fbe", - "sha256:6c738585d7a9961d8c2821a1eb3dcb978d14e238be3d70f0a706f7fa9316946b", - "sha256:73bd195e43f3fadecfc50c682f5055ec32ee2c933243cafbfdec69ab1aa87cad", - "sha256:772a91fc0e03eaf922c63badeca75e91baa80fe2f5f87bdaed4280662aad25c9", - "sha256:77ec3e7be99629898c9a6d24a09de089fa5356ee408cdffffe62d67bb75fdd72", - "sha256:7db8b751ad307d7cf238f02101e8e36a128a6cb199326e867d1398067381bff4", - "sha256:801ec82e4188e935c7f5e22e006d01611d6b41661bba9fe45b60e7ac1a8f84de", - "sha256:82409ffe29d70fd733ff3c1025a602abb3e67405d41b9403b00b01debc4c9a29", - "sha256:828989c45c245518065a110434246c44a56a8b2b2f6347d1409c787e6e4651ee", - "sha256:829f97c8e258593b9daa80638aee3789b7df9da5cf1336035016d76f03b8860c", - "sha256:871b72c3643e516db4ecf20efe735deb27fe30ca17800e661d769faab45a18d7", - "sha256:89dca0ce00a2b49024df6325925555d406b14aa3efc2f752dbb5940c52c56b11", - "sha256:90fb88843d3902fe7c9586d439d1e8c05258f41da473952aa8b328d8b907498c", - "sha256:97aabc5c50312afa5e0a2b07c17d4ac5e865b250986f8afe2b02d772567a380c", - "sha256:9aaa107275d8527e9d6e7670b64aabaaa36e5b6bd71a1015ddd21da0d4e06448", - "sha256:9f47eabcd2ded7698106b05c2c338672d16a6f2a485e74481f524e2a23c2794b", - "sha256:a0a06a052c5f37b4ed81c613a455a81f9a3a69429b4fd7bb913c3fa98abefc20", - "sha256:ab388aaa3f6ce52ac1cb8e122c4bd46657c15905904b3120a6248b5b8b0bc228", - "sha256:ad58d27a5b0262c0c19b47d54c5802db9b34d38bbf886665b626aff83c74bacd", - "sha256:ae5331c23ce118c53b172fa64a4c037eb83c9165aba3a7ba9ddd3ec9fa64a699", - "sha256:af0372acb5d3598f36ec0914deed2a63f6bcdb7b606da04dc19a88d31bf0c05b", - "sha256:afa4107d1b306cdf8953edde0534562607fe8811b6c4d9a486298ad31de733b2", - "sha256:b03ae6f1a1878233ac620c98f3459f79fd77c7e3c2b20d460284e1fb370557d4", - "sha256:b0915e734b33a474d76c28e07292f196cdf2a590a0d25bcc06e64e545f2d146c", - "sha256:b4012d06c846dc2b80651b120e2cdd787b013deb39c09f407727ba90015c684f", - "sha256:b472b5ea442148d1c3e2209f20f1e0bb0eb556538690fa70b5e1f79fa0ba8dc2", - "sha256:b59430236b8e58840a0dfb4099a0e8717ffb779c952426a69ae435ca1f57210c", - "sha256:b90f7616ea170e92820775ed47e136208e04c967271c9ef615b6fbd08d9af0e3", - "sha256:b9a65733d103311331875c1dca05cb4606997fd33d6acfed695b1232ba1df193", - "sha256:bac18ab8d2d1e6b4ce25e3424f709aceef668347db8637c2296bcf41acb7cf48", - "sha256:bca31dd6014cb8b0b2db1e46081b0ca7d936f856da3b39744aef499db5d84d02", - "sha256:be55f8457cd1eac957af0c3f5ece7bc3f033f89b114ef30f710882717670b2a8", - "sha256:c7025dce65566eb6e89f56c9509d4f628fddcedb131d9465cacd3d8bac337e7e", - "sha256:c935a22a557a560108d780f9a0fc426dd7459940dc54faa49d83249c8d3e760f", - "sha256:dbb8e7f2abee51cef77673be97760abff1674ed32847ce04b4af90f610144c7b", - "sha256:e6ea6b856a74d560d9326c0f5895ef8050126acfdc7ca08ad703eb0081e82b74", - "sha256:ebf2029c1f464c59b8bdbe5143c79fa2045a581ac53679733d3a91d400ff9efb", - "sha256:f1ff2ee69f10f13a9596480335f406dd1f70c3650349e2be67ca3139280cade0" + "sha256:0845adc64fe9886db00f5ab68c4a8cd933ab749a87747555cec1c95acea64b0b", + "sha256:0884ba7b515163a1a05440a138adeb722b8a6ae2c2b33aea93ea3118dd3a899e", + "sha256:09b89ddc95c248ee788328528e6a2996e09eaccddeeb82a5356e92645733be35", + "sha256:0dd4c681b82214b36273c18ca7ee87065a50e013112eea7d78c7a1b89a739153", + "sha256:0e51f608da093e5d9038c592b5b575cadc12fd748af1479b5e858045fff955a9", + "sha256:0f3269304c1a7ce82f1759c12ce731ef9b6e95b6df829dccd9fe42912cc48569", + "sha256:16a8df99701f9095bea8a6c4b3197da105df6f74e6176c5b410bc2df2fd29a57", + "sha256:19005a8e58b7c1796bc0167862b1f54a64d3b44ee5d48152b06bb861458bc0f8", + "sha256:28676836c7796805914b76b1837a40f76827ee0d5398f72f7dcc634bae7c6264", + "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157", + "sha256:3fa1284762aacca6dc97474ee9c16f83990b8eeb6697f2ba17140d54b453e133", + "sha256:451f10ef963918e65b8869e17d67db5e2f4ab40e716ee6ce7129b0cde2876eab", + "sha256:46c259e87199041583658457372a183636ae8cd56dbf3f0755e0f376a7f9d0e6", + "sha256:46f39cab8bbf4a384ba7cb0bc8bae7b7062b6a11cfac1ca4bc144dea90d4a9f5", + "sha256:519e14e2c49fcf7616d6d2cfc5c70adae95682ae20f0395e9280db85e8d6c4df", + "sha256:53dcb50fbdc3fb2c55431a9b30caeb2f7027fcd2aeb501459464f0214200a503", + "sha256:54614444887e0d3043557d9dbc697dbb16cfb5a35d672b7a0fcc1ed0cf1c600b", + "sha256:575d8912dca808edd9acd6f7795199332696d3469665ef26163cd090fa1f8bfa", + "sha256:5dd5a9c3091a0f414a963d427f920368e2b6a4c2f7527fdd82cde8ef0bc7a327", + "sha256:5f532a2ad4d174eb73494e7397988e22bf427f91acc8e6ebf5bb10597b49c493", + "sha256:60e7da3a3ad1812c128750fc1bc14a7ceeb8d29f77e0a2356a8fb2aa8925287d", + "sha256:653d7fb2df65efefbcbf81ef5fe5e5be931f1ee4332c2893ca638c9b11a409c4", + "sha256:6663977496d616b618b6cfa43ec86e479ee62b942e1da76a2c3daa1c75933ef4", + "sha256:6abfb51a82e919e3933eb137e17c4ae9c0475a25508ea88993bb59faf82f3b35", + "sha256:6c6b1389ed66cdd174d040105123a5a1bc91d0aa7059c7261d20e583b6d8cbd2", + "sha256:6d9dfb9959a3b0039ee06c1a1a90dc23bac3b430842dcb97908ddde05870601c", + "sha256:765cb54c0b8724a7c12c55146ae4647e0274a839fb6de7bcba841e04298e1011", + "sha256:7a21222644ab69ddd9967cfe6f2bb420b460dae4289c9d40ff9a4896e7c35c9a", + "sha256:7ac7594397698f77bce84382929747130765f66406dc2cd8b4ab4da68ade4c6e", + "sha256:7cfc287da09f9d2a7ec146ee4d72d6ea1342e770d975e49a8621bf54eaa8f30f", + "sha256:847b114580c5cc9ebaf216dd8c8dbc6b00a3b7ab0131e173d7120e6deade1f57", + "sha256:8f127e7b028900421cad64f51f75c051b628db17fb00e099eb148761eed598c9", + "sha256:94cdff45173b1919350601f82d61365e792895e3c3a3443cf99819e6fbf717a5", + "sha256:9a3049a10261d7f2b6514d35bbb7a4dfc3ece4c4de14ef5876c4b7a23a0e566d", + "sha256:a1c2d7780448eb93fbcc3789bf3916aa5720d942e37945f4056680317f1cd23e", + "sha256:a2e0f87144fcbbe54297cae708c5e7f9da21a4646523456b00cc956bd4c65815", + "sha256:a4dfdae195335abb4e89cc9762b2edc524f3c6e80d647a9a81bf81e17e3fb6f0", + "sha256:a96e6e23f2b79433390273eaf8cc94fec9c6370842e577ab10dabdcc7ea0a66b", + "sha256:aabdab8ec1e7ca7f1434d042bf8b1e92056245fb179790dc97ed040361f16bfd", + "sha256:b222090c455d6d1a64e6b7bb5f4035c4dff479e22455c9eaa1bdd4c75b52c80c", + "sha256:b52ff4f4e002f828ea6483faf4c4e8deea8d743cf801b74910243c58acc6eda3", + "sha256:b9b752ab91e78234941e44abdecc07f1f0d8f51fb62941d32995b8161f68cfe5", + "sha256:ba6612b6548220ff5e9df85261bddc811a057b0b465a1226b39bfb8550616aee", + "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343", + "sha256:c3c4ed2ff6760e98d262e0cc9c9a7f7b8a9f61aa4d47c58835cdaf7b0b8811bb", + "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47", + "sha256:cb362e3b0976dc994857391b776ddaa8c13c28a16f80ac6522c23d5257156bed", + "sha256:d197df5489004db87d90b918033edbeee0bd6df3848a204bca3ff0a903bef837", + "sha256:d3b56206244dc8711f7e8b7d6cad4663917cd5b2d950799425076681e8766286", + "sha256:d5b2f8a31bd43e0f18172d8ac82347c8f37ef3e0b414431157718aa234991b28", + "sha256:d7081c084ceb58278dd3cf81f836bc818978c0ccc770cbbb202125ddabec6628", + "sha256:db74f5562c09953b2c5f8ec4b7dfd3f5421f31811e97d1dbc0a7c93d6e3a24df", + "sha256:df41112ccce5d47770a0c13651479fbcd8793f34232a2dd9faeccb75eb5d0d0d", + "sha256:e1339790c083c5a4de48f688b4841f18df839eb3c9584a770cbd818b33e26d5d", + "sha256:e621b0246192d3b9cb1dc62c78cfa4c6f6d2ddc0ec207d43c0dedecb914f152a", + "sha256:e8c5cf126889a4de385c02a2c3d3aba4b00f70234bfddae82a5eaa3ee6d5e3e6", + "sha256:e9d7747847c53a16a729b6ee5e737cf170f7a16611c143d95aa60a109a59c336", + "sha256:eaef5d2de3c7e9b21f1e762f289d17b726c2239a42b11e25446abf82b26ac132", + "sha256:ed3e4b4e1e6de75fdc16d3259098de7c6571b1a6cc863b1a49e7d3d53e036070", + "sha256:ef21af928e807f10bf4141cad4746eee692a0dd3ff56cfb25fce076ec3cc8abe", + "sha256:f09598b416ba39a8f489c124447b007fe865f786a89dbfa48bb5cf395693132a", + "sha256:f6e78171be3fb7941f9910ea15b4b14ec27725865a73c15277bc39f5ca4f8391", + "sha256:f715c32e774a60a337b2bb8ad9839b4abf75b267a0f18806f6f4f5f1688c4b5a" ], "index": "pypi", - "version": "==9.3.0" + "version": "==9.4.0" }, "protobuf": { "hashes": [ @@ -167,10 +197,10 @@ }, "qreader": { "hashes": [ - "sha256:fc75684088b4bef2b268eefda7006cb4b0276e53d6cdd678bc7d2bbb7d8fcd21" + "sha256:f96d2879c9f47f9641da55dc3e1df765cd4d6f1cfbc8b7fa811b70c1e27d4fba" ], "index": "pypi", - "version": "==1.3.1" + "version": "==1.3.2" } }, "develop": { @@ -190,6 +220,66 @@ "markers": "python_version >= '3.6'", "version": "==22.2.0" }, + "coverage": { + "extras": [ + "toml" + ], + "hashes": [ + "sha256:04691b8e832a900ed15f5bcccc2008fc2d1c8e7411251fd7d1422a84e1d72841", + "sha256:1a613d60be1a02c7a5184ea5c4227f48c08e0635608b9c17ae2b17efef8f2501", + "sha256:1d732b5dcafed67d81c5b5a0c404c31a61e13148946a3b910a340f72fdd1ec95", + "sha256:2b31f7f246dbff339b3b76ee81329e3eca5022ce270c831c65e841dbbb40115f", + "sha256:312fd77258bf1044ef4faa82091f2e88216e4b62dcf1a461d3e917144c8b09b7", + "sha256:321316a7b979892a13c148a9d37852b5a76f26717e4b911b606a649394629532", + "sha256:36c1a1b6d38ebf8a4335f65226ec36b5d6fd67743fdcbad5c52bdcd46c4f5842", + "sha256:38f281bb9bdd4269c451fed9451203512dadefd64676f14ed1e82c77eb5644fc", + "sha256:3a2d81c95d3b02638ee6ae647edc79769fd29bf5e9e5b6b0c29040579f33c260", + "sha256:3d40ad86a348c79c614e2b90566267dd6d45f2e6b4d2bfb794d78ea4a4b60b63", + "sha256:3d72e3d20b03e63bd27b1c4d6b754cd93eca82ecc5dd77b99262d5f64862ca35", + "sha256:3fbb59f84c8549113dcdce7c6d16c5731fe53651d0b46c0a25a5ebc7bb655869", + "sha256:405d8528a0ea07ca516d9007ecad4e1bd10e2eeef27411c6188d78c4e2dfcddc", + "sha256:420f10c852b9a489cf5a764534669a19f49732a0576c76d9489ebf287f81af6d", + "sha256:426895ac9f2938bec193aa998e7a77a3e65d3e46903f348e794b4192b9a5b61e", + "sha256:4438ba539bef21e288092b30ea2fc30e883d9af5b66ebeaf2fd7c25e2f074e39", + "sha256:46db409fc0c3ee5c859b84c7de9cb507166287d588390889fdf06a1afe452e16", + "sha256:483e120ea324c7fced6126bb9bf0535c71e9233d29cbc7e2fc4560311a5f8a32", + "sha256:4d7be755d7544dac2b9814e98366a065d15a16e13847eb1f5473bb714483391e", + "sha256:4e97b21482aa5c21e049e4755c95955ad71fb54c9488969e2f17cf30922aa5f6", + "sha256:5f44ba7c07e0aa4a7a2723b426c254e952da82a33d65b4a52afae4bef74a4203", + "sha256:62e5b942378d5f0b87caace567a70dc634ddd4d219a236fa221dc97d2fc412c8", + "sha256:7c669be1b01e4b2bf23aa49e987d9bedde0234a7da374a9b77ca5416d7c57002", + "sha256:7d47d666e17e57ef65fefc87229fde262bd5c9039ae8424bc53aa2d8f07dc178", + "sha256:7e184aa18f921b612ea08666c25dd92a71241c8ed40917f2824219c92289b8c7", + "sha256:80583c536e7e010e301002088919d4ea90566d1789ee02551574fdf3faa275ae", + "sha256:8217f73faf08623acb25fb2affd5d20cbcd8185213db308e46a37e6fd6a56a49", + "sha256:87d95eea58fb71f69b4f1c761099a19e0e9cb27d45dc1cc7042523128ee56337", + "sha256:8bd466135fb07f693dbdd999a5569ffbc0590e9c64df859243162f0ebee950c8", + "sha256:8e133ca2f8141b415ff396ba789bdeffdea8ff9a5c7fc9996ccf591d7d40ee93", + "sha256:8e6c0ca447b557a32642f22d0987be37950eda51c4f19fc788cebc99426284b6", + "sha256:9de96025ce25b9f4e744fbe558a003e673004af255da9b1f6ec243720ac5deeb", + "sha256:a27a8dca0dc6f0944ed9fd83c556d862e227a5cd4220e62af5d4c750389938f0", + "sha256:a2d4f68e4fa286fb6b00d58a1e87c79840e289d3f6e5dcb912ad7b0fbd9629fb", + "sha256:a6e1c77ff6f10eab496fbbcdaa7dfae84968928a0aadc43ce3c3453cec29bd79", + "sha256:a7b018811a0e1d3869d8d0600849953acd355a3a29c6bee0fbd24d7772bcc0a2", + "sha256:a99b2f2dd1236e8d9dc35974a3dc298a408cdfd512b0bb2451798cff1ce63408", + "sha256:ac1033942851bf01f28c76318155ea92d6648aecb924cab81fa23781d095e9ab", + "sha256:b6936cd38757dd323fefc157823e46436610328f0feb1419a412316f24b77f36", + "sha256:b6eab230b18458707b5c501548e997e42934b1c189fb4d1b78bf5aacc1c6a137", + "sha256:bcb57d175ff0cb4ff97fc547c74c1cb8d4c9612003f6d267ee78dad8f23d8b30", + "sha256:c1f02d016b9b6b5ad21949a21646714bfa7b32d6041a30f97674f05d6d6996e3", + "sha256:c40aaf7930680e0e5f3bd6d3d3dc97a7897f53bdce925545c4b241e0c5c3ca6a", + "sha256:c5e1874c601128cf54c1d4b471e915658a334fbc56d7b3c324ddc7511597ea82", + "sha256:c8805673b1953313adfc487d5323b4c87864e77057944a0888c98dd2f7a6052f", + "sha256:da458bdc9b0bcd9b8ca85bc73148631b18cc8ba03c47f29f4c017809990351aa", + "sha256:dcb708ab06f3f4dfc99e9f84821c9120e5f12113b90fad132311a2cb97525379", + "sha256:dfafc350f43fd7dc67df18c940c3b7ed208ebb797abe9fb3047f0c65be8e4c0f", + "sha256:e8931af864bd599c6af626575a02103ae626f57b34e3af5537d40b040d33d2ad", + "sha256:efa9d943189321f67f71070c309aa6f6130fa1ec35c9dfd0da0ed238938ce573", + "sha256:fd22ee7bff4b5c37bb6385efee1c501b75e29ca40286f037cb91c2182d1348ce" + ], + "markers": "python_version >= '3.7'", + "version": "==7.0.2" + }, "dill": { "hashes": [ "sha256:a07ffd2351b8c678dfc4a856a3005f8067aea51d6ba6c700796a4d9e280f39f0", @@ -254,6 +344,49 @@ "markers": "python_version >= '3.6'", "version": "==0.7.0" }, + "mypy": { + "hashes": [ + "sha256:0714258640194d75677e86c786e80ccf294972cc76885d3ebbb560f11db0003d", + "sha256:0c8f3be99e8a8bd403caa8c03be619544bc2c77a7093685dcf308c6b109426c6", + "sha256:0cca5adf694af539aeaa6ac633a7afe9bbd760df9d31be55ab780b77ab5ae8bf", + "sha256:1c8cd4fb70e8584ca1ed5805cbc7c017a3d1a29fb450621089ffed3e99d1857f", + "sha256:1f7d1a520373e2272b10796c3ff721ea1a0712288cafaa95931e66aa15798813", + "sha256:209ee89fbb0deed518605edddd234af80506aec932ad28d73c08f1400ef80a33", + "sha256:26efb2fcc6b67e4d5a55561f39176821d2adf88f2745ddc72751b7890f3194ad", + "sha256:37bd02ebf9d10e05b00d71302d2c2e6ca333e6c2a8584a98c00e038db8121f05", + "sha256:3a700330b567114b673cf8ee7388e949f843b356a73b5ab22dd7cff4742a5297", + "sha256:3c0165ba8f354a6d9881809ef29f1a9318a236a6d81c690094c5df32107bde06", + "sha256:3d80e36b7d7a9259b740be6d8d906221789b0d836201af4234093cae89ced0cd", + "sha256:4175593dc25d9da12f7de8de873a33f9b2b8bdb4e827a7cae952e5b1a342e243", + "sha256:4307270436fd7694b41f913eb09210faff27ea4979ecbcd849e57d2da2f65305", + "sha256:5e80e758243b97b618cdf22004beb09e8a2de1af481382e4d84bc52152d1c476", + "sha256:641411733b127c3e0dab94c45af15fea99e4468f99ac88b39efb1ad677da5711", + "sha256:652b651d42f155033a1967739788c436491b577b6a44e4c39fb340d0ee7f0d70", + "sha256:6d7464bac72a85cb3491c7e92b5b62f3dcccb8af26826257760a552a5e244aa5", + "sha256:74e259b5c19f70d35fcc1ad3d56499065c601dfe94ff67ae48b85596b9ec1461", + "sha256:7d17e0a9707d0772f4a7b878f04b4fd11f6f5bcb9b3813975a9b13c9332153ab", + "sha256:901c2c269c616e6cb0998b33d4adbb4a6af0ac4ce5cd078afd7bc95830e62c1c", + "sha256:98e781cd35c0acf33eb0295e8b9c55cdbef64fcb35f6d3aa2186f289bed6e80d", + "sha256:a12c56bf73cdab116df96e4ff39610b92a348cc99a1307e1da3c3768bbb5b135", + "sha256:ac6e503823143464538efda0e8e356d871557ef60ccd38f8824a4257acc18d93", + "sha256:b8472f736a5bfb159a5e36740847808f6f5b659960115ff29c7cecec1741c648", + "sha256:b86ce2c1866a748c0f6faca5232059f881cda6dda2a893b9a8373353cfe3715a", + "sha256:bc9ec663ed6c8f15f4ae9d3c04c989b744436c16d26580eaa760ae9dd5d662eb", + "sha256:c9166b3f81a10cdf9b49f2d594b21b31adadb3d5e9db9b834866c3258b695be3", + "sha256:d13674f3fb73805ba0c45eb6c0c3053d218aa1f7abead6e446d474529aafc372", + "sha256:de32edc9b0a7e67c2775e574cb061a537660e51210fbf6006b0b36ea695ae9bb", + "sha256:e62ebaad93be3ad1a828a11e90f0e76f15449371ffeecca4a0a0b9adc99abcef" + ], + "index": "pypi", + "version": "==0.991" + }, + "mypy-extensions": { + "hashes": [ + "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d", + "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8" + ], + "version": "==0.4.3" + }, "packaging": { "hashes": [ "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3", @@ -264,11 +397,11 @@ }, "platformdirs": { "hashes": [ - "sha256:1a89a12377800c81983db6be069ec068eee989748799b946cce2a6e80dcc54ca", - "sha256:b46ffafa316e6b83b47489d240ce17173f123a9b9c83282141c3daf26ad9ac2e" + "sha256:83c8f6d04389165de7c9b6f0c682439697887bca0aa2f1c87ef1826be3584490", + "sha256:e1fea1fe471b9ff8332e229df3cb7de4f53eeea4998d3b6bfff542115e998bd2" ], "markers": "python_version >= '3.7'", - "version": "==2.6.0" + "version": "==2.6.2" }, "pluggy": { "hashes": [ @@ -310,6 +443,22 @@ "index": "pypi", "version": "==7.2.0" }, + "pytest-cov": { + "hashes": [ + "sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b", + "sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470" + ], + "index": "pypi", + "version": "==4.0.0" + }, + "pytest-mock": { + "hashes": [ + "sha256:f4c973eeae0282963eb293eb173ce91b091a79c1334455acfac9ddee8a1c784b", + "sha256:fbbdb085ef7c252a326fd8cdcac0aa3b1333d8811f131bdcc701002e1be7ed4f" + ], + "index": "pypi", + "version": "==3.10.0" + }, "tomlkit": { "hashes": [ "sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b", @@ -318,6 +467,22 @@ "markers": "python_version >= '3.6'", "version": "==0.11.6" }, + "types-protobuf": { + "hashes": [ + "sha256:7df483d34ad3fcb1fa7fff1073560d596c9ac1f419cfa851b220c9a93386c998", + "sha256:aeefcf39d637016998b3c7b699750847071b555f7c2e0c9873d42ab6103d1a39" + ], + "index": "pypi", + "version": "==4.21.0.2" + }, + "typing-extensions": { + "hashes": [ + "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa", + "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e" + ], + "markers": "python_version >= '3.7'", + "version": "==4.4.0" + }, "wheel": { "hashes": [ "sha256:965f5259b566725405b05e7cf774052044b1ed30119b5d586b2703aafe8719ac", diff --git a/README.md b/README.md index d5deba5..0e25aa9 100644 --- a/README.md +++ b/README.md @@ -1,159 +1,197 @@ -# Extract TOTP/HOTP secret keys from Google Authenticator +# Extract TOTP/HOTP secrets from QR codes exported by two-factor authentication apps -[![CI Status](https://github.com/scito/extract_otp_secret_keys/actions/workflows/ci.yml/badge.svg)](https://github.com/scito/extract_otp_secret_keys/actions/workflows/ci.yml) +[![CI tests](https://github.com/scito/extract_otp_secrets/actions/workflows/ci.yml/badge.svg)](https://github.com/scito/extract_otp_secrets/actions/workflows/ci.yml) +![coverage](https://img.shields.io/badge/coverage-96%25-brightgreen) +[![CI docker](https://github.com/scito/extract_otp_secrets/actions/workflows/ci_docker.yml/badge.svg)](https://github.com/scito/extract_otp_secrets/actions/workflows/ci_docker.yml) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/protobuf) -[![GitHub Pipenv locked Python version](https://img.shields.io/github/pipenv/locked/python-version/scito/extract_otp_secret_keys)](https://github.com/scito/extract_otp_secret_keys/blob/master/Pipfile.lock) +[![GitHub Pipenv locked Python version](https://img.shields.io/github/pipenv/locked/python-version/scito/extract_otp_secrets)](https://github.com/scito/extract_otp_secrets/blob/master/Pipfile.lock) ![protobuf version](https://img.shields.io/badge/protobuf-4.21.12-informational) -[![License](https://img.shields.io/github/license/scito/extract_otp_secret_keys)](https://github.com/scito/extract_otp_secret_keys/blob/master/LICENSE) -[![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/scito/extract_otp_secret_keys?sort=semver&label=version)](https://github.com/scito/extract_otp_secret_keys/tags) +[![License](https://img.shields.io/github/license/scito/extract_otp_secrets)](https://github.com/scito/extract_otp_secrets/blob/master/LICENSE) +[![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/scito/extract_otp_secrets?sort=semver&label=version)](https://github.com/scito/extract_otp_secrets/tags) [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua) --- -Extract two-factor authentication (2FA, TFA, OTP) secret keys from export QR codes of "Google Authenticator" app. -The secret and otp values can be printed and exported to json or csv. The QR codes can be printed or saved as PNG images. +The Python script `extract_otp_secrets.py` extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps such as "Google Authenticator". +The exported QR codes from authentication apps can be read in three ways: + +1. Capture from the system camera using a GUI, _(new!)_ +2. Read image files containing the QR codes, and _(new!)_ +3. Read text files containing the QR code data generated by third-party QR readers. + +The secret and otp values can be exported to json or csv files, as well as printed or saved to PNG images. + +**The project and the script were renamed from extract_otp_secret_keys to extract_otp_secrets in version 2.0.0.** ## Installation -git clone https://github.com/scito/extract_otp_secret_keys.git -cd extract_otp_secret_keys +``` +git clone https://github.com/scito/extract_otp_secrets.git +cd extract_otp_secrets +pip install -U -r requirements.txt -## Usage +python src/extract_otp_secrets.py example_export.txt +``` -### With builtin QR decoder +In case this script is not starting properly, the debug mode can be activated by adding parameter `-d` in the command line. -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: +### Installation of shared system libraries - python extract_otp_secret_keys.py example_export.png +For reading QR codes with `ZBAR` QR reader, the zbar library must be installed. +If you do not use the `ZBAR` QR reader, you do not need to install the zbar shared library. Note: The `ZBAR` QR reader is the showed for me the best results and is thus default QR Reader. -### With external QR decoder app +For a detailed installation documentation of [pyzbar](https://github.com/NaturalHistoryMuseum/pyzbar#installation). -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) -4. Save the captured QR codes in the QR code reader to a text file, e.g. example_export.txt. Save each QR code on a new line. (The captured QR codes look like `otpauth-migration://offline?data=...`) -5. Transfer the file to the computer where his script is installed. -6. Call this script with the file as input: +#### Linux (Debian, Ubuntu, ...) - python extract_otp_secret_keys.py example_export.txt + sudo apt-get install libzbar0 -## Program help: arguments and options +#### Linux (OpenSUSE) -
usage: extract_otp_secret_keys.py [-h] [--json FILE] [--csv FILE] [--keepass FILE] [--printqr] [--saveqr DIR] [--verbose | --quiet] infile [infile ...]
+    sudo zypper install libzbar0
 
-positional arguments:
-  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
+#### Linux (Fedora)
 
-options:
-  -h, --help               show this help message and exit
-  --json FILE, -j FILE     export json file or - for stdout
-  --csv FILE, -c FILE      export csv file or - for stdout
-  --keepass FILE, -k FILE  export totp/hotp csv file(s) for KeePass, - for stdout
-  --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 -
+    sudo dnf install libzbar0
 
-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
+#### Mac OS X + + brew install zbar -## Dependencies +#### Windows - pip install -r requirements.txt +##### zbar -Known to work with +The zbar DLLs are included with the Windows Python wheels. However, you might need additionally to install [Visual C++ Redistributable Packages for Visual Studio 2013](https://www.microsoft.com/en-US/download/details.aspx?id=40784). Install `vcredist_x64.exe` if using 64-bit Python, `vcredist_x86.exe` if using 32-bit Python. For more information see [pyzbar](https://github.com/NaturalHistoryMuseum/pyzbar) -* Python 3.10.8, protobuf 4.21.9, qrcode 7.3.1, and pillow 9.2 -* Python 3.11.1, protobuf 4.21.12, qrcode 7.3.1, and pillow 9.2 +##### OpenCV -For protobuf versions 3.14.0 or similar or Python 3.6, use the extract_otp_secret_keys version 1.4.0. +OpenCV requires [Visual C++ redistributable 2015](https://www.microsoft.com/en-us/download/details.aspx?id=48145). For more information see [opencv-python](https://pypi.org/project/opencv-python/) -### Shared libs installation for reading QR code images +## Usage -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. +### Capture QR codes from camera (since version 2.0.0) -For a detailed installation documentation of [pyzbar](https://github.com/NaturalHistoryMuseum/pyzbar#installation). +1. Open "Google Authenticator" app on the mobile phone +2. Export the QR codes from "Google Authenticator" app +3. Point the QR codes to the camera of your computer +4. Call this script without infile parameters: -#### Windows + python src/extract_otp_secrets.py -The zbar DLLs are included with the Windows Python wheels. On other operating systems, you will need to install the zbar shared library. +![CV2 Capture from camera screenshot](cv2_capture_screenshot.png) -#### Linux (Debian, Ubuntu, ...) +### With builtin QR decoder from image files (since version 2.0.0) - sudo apt-get install libzbar0 +1. Open "Google Authenticator" app on the mobile phone +2. Export the QR codes from "Google Authenticator" app +4. Save the QR code as image file, 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: -#### Linux (OpenSUSE) + python src/extract_otp_secrets.py example_export.png - sudo zypper install libzbar0 +### With external QR decoder app from text files -#### Linux (Fedora) +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) +4. Save the captured QR codes in the QR code reader to a text file, e.g. example_export.txt. Save each QR code on a new line. (The captured QR codes look like `otpauth-migration://offline?data=...`) +5. Transfer the file to the computer where his script is installed. +6. Call this script with the file as input: - sudo dnf install libzbar0 + python src/extract_otp_secrets.py example_export.txt -#### Mac OS X +## Program help: arguments and options - brew install zbar +
usage: extract_otp_secrets.py [-h] [--csv FILE] [--keepass FILE] [--json FILE] [--printqr] [--saveqr DIR] [--camera NUMBER] [--qr {ZBAR,QREADER,QREADER_DEEP,CV2,CV2_WECHAT}] [-i] [--no-color] [-d | -v | -q] [infile ...]
+
+Extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps
+If no infiles are provided, a GUI window starts and QR codes are captured from the camera.
+
+positional arguments:
+  infile                        a) file or - for stdin with 'otpauth-migration://...' URLs separated by newlines, lines starting with # are ignored;
+                                b) 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
+  --csv FILE, -c FILE           export csv file or - for stdout
+  --keepass FILE, -k FILE       export totp/hotp csv file(s) for KeePass, - for stdout
+  --json FILE, -j FILE          export json file or - for stdout
+  --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)
+  --camera NUMBER, -C NUMBER    camera number of system (default camera: 0)
+  --qr {ZBAR,QREADER,QREADER_DEEP,CV2,CV2_WECHAT}, -Q {ZBAR,QREADER,QREADER_DEEP,CV2,CV2_WECHAT}
+                                QR reader (default: ZBAR)
+  -i, --ignore                  ignore duplicate otps
+  --no-color, -n                do not use ANSI colors in console output
+  -d, --debug                   enter debug mode, do checks and quit
+  -v, --verbose                 verbose output
+  -q, --quiet                   no stdout output, except output set by -
+
+examples:
+python extract_otp_secrets.py
+python extract_otp_secrets.py example_*.txt
+python extract_otp_secrets.py - < example_export.txt
+python extract_otp_secrets.py --csv - example_*.png | tail -n+2
+python extract_otp_secrets.py = < example_export.png
## Examples ### Printing otp secrets form text file - python extract_otp_secret_keys.py example_export.txt + python src/extract_otp_secrets.py example_export.txt ### Printing otp secrets from image file - python extract_otp_secret_keys.py example_export.png + python src/extract_otp_secrets.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 + python src/extract_otp_secrets.py example_*.txt + python src/extract_otp_secrets.py example_*.png + python src/extract_otp_secrets.py example_export.* + python src/extract_otp_secrets.py example_*.txt example_*.png ### Printing otp secrets from stdin (text) - python extract_otp_secret_keys.py - < example_export.txt + python src/extract_otp_secrets.py - < example_export.txt ### Printing otp secrets from stdin (image) - python extract_otp_secret_keys.py = < example_export.png + python src/extract_otp_secrets.py = < example_export.png ### Printing otp secrets csv to stdout - python extract_otp_secret_keys.py --csv - example_export.txt + python src/extract_otp_secrets.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 + python src/extract_otp_secrets.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 + cat example_*.txt | python src/extract_otp_secrets.py --csv - - | tail -n+2 ## Features * Free and open source * 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 +* Captures the the QR codes directly from the camera using different QR code libraries (based on OpenCV) + * ZBAR: [pyzbar](https://github.com/NaturalHistoryMuseum/pyzbar) - fast and reliable, good for images and video capture (default and recommended) + * QREADER: [QReader](https://github.com/Eric-Canas/QReader) + * QREADER_DEEP: [QReader](https://github.com/Eric-Canas/QReader) - very slow in GUI + * CV2: [QRCodeDetector](https://docs.opencv.org/4.x/de/dc3/classcv_1_1QRCodeDetector.html) + * CV2_WECHAT: [WeChatQRCode](https://docs.opencv.org/4.x/dd/d63/group__wechat__qrcode.html) +* Supports TOTP and HOTP standards * Generates QR codes -* 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 -* 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)) +* Supports reading from stdin and writing to stdout, thus pipes can be used +* Reads QR codes images: (See [OpenCV docu](https://docs.opencv.org/4.x/d4/da8/group__imgcodecs.html#ga288b8b3da0892bd651fce07b3bbd3a56)) * Portable Network Graphics - *.png * WebP - *.webp * JPEG files - *.jpeg, *.jpg, *.jpe @@ -161,23 +199,27 @@ The zbar DLLs are included with the Windows Python wheels. On other operating sy * 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 +* Prints errors and warnings to stderr +* Prints colored output * Many ways to run the script: * Native Python * pipenv + * pip * venv * Docker * VSCode devcontainer * devbox - * pip -* Compatible with multiple platforms (tested by CI): +* Prebuilt Docker images provided for amd64 and arm64 +* Compatible with major platforms: * Linux * macOS * Windows +* Uses UTF-8 on all platforms +* Supports Python >= 3.7 +* Provides a debug mode (-d) for analyzing import problems +* Written in modern Python using type hints and following best practices +* All these features are backed by tests ran nightly +* All functionality in one Python script: src/extract_otp_secrets.py (except protobuf generated code in protobuf_generated_python) ## KeePass @@ -224,10 +266,12 @@ The data parameter is a base64 encoded proto3 message (Google Protocol Buffers). Command for regeneration of Python code from proto3 message definition file (only necessary in case of changes of the proto3 message definition or new protobuf versions): - protoc --python_out=protobuf_generated_python google_auth.proto + protoc --plugin=protoc-gen-mypy=path/to/protoc-gen-mypy --python_out=src/protobuf_generated_python --mypy_out=src/protobuf_generated_python src/google_auth.proto The generated protobuf Python code was generated by protoc 21.12 (https://github.com/protocolbuffers/protobuf/releases/tag/v21.12). +For Python type hint generation the [mypy-protobuf](https://github.com/nipunn1313/mypy-protobuf) package is used. + ## References * Proto3 documentation: https://developers.google.com/protocol-buffers/docs/pythontutorial @@ -247,40 +291,49 @@ The generated protobuf Python code was generated by protoc 21.12 (https://github ### pip ``` -pip install git+https://github.com/scito/extract_otp_secret_keys -python -m extract_otp_secret_keys +pip install -U git+https://github.com/scito/extract_otp_secrets +python -m extract_otp_secrets +``` + +or from a specific tag + +``` +pip install -U git+https://github.com/scito/extract_otp_secrets.git@v2.0.0 +python -m extract_otp_secrets +curl -s https://raw.githubusercontent.com/scito/extract_otp_secrets/master/example_export.txt | python -m extract_otp_secrets - ``` -#### Example +### local pip ``` -wget https://raw.githubusercontent.com/scito/extract_otp_secret_keys/master/example_export.txt -python -m extract_otp_secret_keys example_export.txt +git clone https://github.com/scito/extract_otp_secrets.git +pip install -U -e extract_otp_secrets +python -m extract_otp_secrets example_export.txt ``` ### pipenv -You can you use [Pipenv](https://github.com/pypa/pipenv) for running extract_otp_secret_keys. +You can you use [Pipenv](https://github.com/pypa/pipenv) for running extract_otp_secrets. ``` pipenv --rm pipenv install pipenv shell -python extract_otp_secret_keys.py example_export.txt +python src/extract_otp_secrets.py example_export.txt ``` ### Visual Studio Code Remote - Containers / VSCode devcontainer -You can you use [VSCode devcontainer](https://code.visualstudio.com/docs/remote/containers-tutorial) for running extract_otp_secret_keys. +You can you use [VSCode devcontainer](https://code.visualstudio.com/docs/remote/containers-tutorial) for running extract_otp_secrets. Requirement: Docker 1. Start VSCode -2. Open extract_otp_secret_keys.code-workspace +2. Open extract_otp_secrets.code-workspace 3. Open VSCode command palette (Ctrl-Shift-P) 4. Type command "Remote-Containers: Reopen in Container" 5. Open integrated bash terminal in VSCode -6. Execute: python extract_otp_secret_keys.py example_export.txt +6. Execute: python src/extract_otp_secrets.py example_export.txt ### venv @@ -288,8 +341,8 @@ Alternatively, you can use a python virtual env for the dependencies: python -m venv venv . venv/bin/activate - pip install -r requirements-dev.txt - pip install -r requirements.txt + pip install -U -r requirements-dev.txt + pip install -U -r requirements.txt The requirements\*.txt files contain all the dependencies (also the optional ones). To leave the python virtual env just call `deactivate`. @@ -302,41 +355,51 @@ Install [devbox](https://github.com/jetpack-io/devbox), which is a wrapper for n devbox shell ``` -### Docker +### docker Install [Docker](https://docs.docker.com/get-docker/). -Build and run the app within the container: +Prebuilt docker images are available for amd64 and arm64 architectures on [Docker Hub](https://hub.docker.com/repository/docker/scit0/extract_otp_secrets) and on [GitHub Packages](https://github.com/users/scito/packages/container/package/extract_otp_secrets). -```bash -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 +Extracting from an QR image file: + +``` +docker login -u USERNAME +curl -s https://raw.githubusercontent.com/scito/extract_otp_secrets/master/example_export.png | docker run --pull always -i --rm -v "$(pwd)":/files:ro scit0/extract_otp_secrets = ``` -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 +Capturing from camera in GUI (X Window system required on host): -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 run --pull always --rm -v "$(pwd)":/files:ro -i --device="/dev/video0:/dev/video0" --env="DISPLAY" -v /tmp/.X11-unix:/tmp/.X11-unix:ro scit0/extract_otp_secrets +``` -docker pull scit0/extract_otp_secret_keys -docker pull scit0/extract_otp_secret_keys_no_qr_reader +If only text processing is required, there is a small Image based on Alpine Linux: -docker pull ghcr.io/scito/extract_otp_secret_keys -docker pull ghcr.io/scito/extract_otp_secret_keys_no_qr_reader +``` +curl -s https://raw.githubusercontent.com/scito/extract_otp_secrets/master/example_export.txt | docker run --pull always -i --rm -v "$(pwd)":/files:ro scit0/extract_otp_secrets:latest-only-txt - +``` + +Docker image from GitHub: + +``` +docker login ghcr.io -u USERNAME +curl -s https://raw.githubusercontent.com/scito/extract_otp_secrets/master/example_export.png | docker run --pull always -i --rm -v "$(pwd)":/files:ro ghcr.io/scito/extract_otp_secrets = +``` + +### More docker examples + + docker run --pull always --rm -v "$(pwd)":/files:ro scit0/extract_otp_secrets example_export.png + + docker run --pull always --rm -i -v "$(pwd)":/files:ro scit0/extract_otp_secrets_only_txt - < example_export.txt + + cat example_export.txt | docker run --pull always --rm -i -v "$(pwd)":/files:ro scit0/extract_otp_secrets:latest_only_txt - -c - > example_out.csv ## Tests ### PyTest -There are basic [pytest](https://pytest.org)s, see `test_extract_otp_secret_keys_pytest.py`. +The script is covered by [pytest](https://pytest.org)s, see `extract_otp_secrets_test.py`. Run tests: @@ -348,9 +411,19 @@ or python -m pytest ``` +#### Hints + +Your tests can run against an installed version after executing pip install . + +Your tests can run against the local copy with an editable install after executing pip install --editable . + +If you don’t use an editable install and are relying on the fact that Python by default puts the current directory in sys.path to import your package, you can execute python -m pytest to execute the tests against the local copy directly, without using pip. + +https://docs.pytest.org/en/7.1.x/explanation/pythonpath.html#pytest-vs-python-m-pytest + ### unittest -There are basic [unittest](https://docs.python.org/3.10/library/unittest.html)s, see `test_extract_otp_secret_keys_unittest.py`. +There are some [unittest](https://docs.python.org/3.10/library/unittest.html)s, see `extract_otp_secrets_txt_unit_test.py`. Run tests: @@ -358,6 +431,8 @@ Run tests: python -m unittest ``` +Note the `pytest`s are preferred and complete. For each unittest there is also a test in `pytest`. + ### VSCode Setup Setup for running the tests in VSCode. @@ -367,7 +442,18 @@ Setup for running the tests in VSCode. 3. Choose unittest or pytest. (pytest is recommended, both are supported) 4. Set ". Root" directory -## Maintenance +## Development + +### Build + +``` +pip install -U -e . +python src/extract_otp_secrets.py + +pip wheel . +``` + +Note: `python -m build --wheel` = `pip wheel --no-deps .` ### Upgrade pip Packages @@ -375,6 +461,59 @@ Setup for running the tests in VSCode. pip install -U -r requirements.txt ``` +### Build docker images + +Build and run the app within the container: + +```bash +docker build . -t extract_otp_secrets --pull --build-arg RUN_TESTS=false +``` + +```bash +docker build . -t extract_otp_secrets_only_txt --pull -f Dockerfile_only_txt --build-arg RUN_TESTS=false +``` + +Run tests in docker container: + +```bash +docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets +``` + +```bash +docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt extract_otp_secrets_test.py -k "not qreader" --relaxed +``` + +## Issues + +* Known issue for macOS: https://github.com/opencv/opencv/issues/23072 + +## Problems and Troubleshooting + +### Windows error message + +If you see an ugly ImportError on Windows you will most likely need the [Visual C++ Redistributable Packages for Visual Studio 2013](https://www.microsoft.com/en-US/download/details.aspx?id=40784). Install vcredist_x64.exe if using 64-bit Python, vcredist_x86.exe if using 32-bit Python. + +This library shared library is required by [pyzbar](https://pypi.org/project/pyzbar/). + +``` +Traceback (most recent call last): + File "C:\Users\Admin\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0\LocalCache\local-packages\Python311\site-packages\pyzbar\zbar_library.py", line 58, in load + dependencies, libzbar = load_objects(Path('')) + ^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\Admin\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0\LocalCache\local-packages\Python311\site-packages\pyzbar\zbar_library.py", line 50, in load_objects + deps = [ + ^ + File "C:\Users\Admin\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.11_qbz5n2kfra8p0\LocalCache\local-packages\Python311\site-packages\pyzbar\zbar_library.py", line 51, in + cdll.LoadLibrary(str(directory.joinpath(dep))) + File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.11_3.11.496.0_x64__qbz5n2kfra8p0\Lib\ctypes\__init__.py", line 454, in LoadLibrary + return self._dlltype(name) + ^^^^^^^^^^^^^^^^^^^ + File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.11_3.11.496.0_x64__qbz5n2kfra8p0\Lib\ctypes\__init__.py", line 376, in __init__ + self._handle = _dlopen(self._name, mode) + ^^^^^^^^^^^^^^^^^^^^^^^^^ +FileNotFoundError: Could not find module 'libiconv.dll' (or one of its dependencies). Try using the full path with constructor syntax. +``` + ## Related projects * [ZBar](https://github.com/mchehab/zbar) is an open source software suite for reading bar codes from various sources, including webcams. @@ -382,11 +521,10 @@ pip install -U -r requirements.txt * [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) +* [OpenCV](https://docs.opencv.org/4.x/) (CV2) Open Source Computer Vision library with [opencv-python](https://github.com/opencv/opencv-python) *** # #StandWithUkraine 🇺🇦 -I have Ukrainian relatives and friends. - #RussiaInvadedUkraine on 24 of February 2022, at 05:00 the armed forces of the Russian Federation attacked Ukraine. Please, stand with Ukraine, stay tuned for updates on Ukraine's official sources and channels in English and support Ukraine in its fight for freedom and democracy in Europe. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..5cc913c --- /dev/null +++ b/build.sh @@ -0,0 +1,407 @@ +#!/bin/bash + +# Upgrades Protoc from https://github.com/protocolbuffers/protobuf/releases + +black='\e[0;30m' +blackBold='\e[1;30m' +blackBackground='\e[1;40m' +red='\e[0;31m' +redBold='\e[1;31m' +redBackground='\e[0;41m' +green='\e[0;32m' +greenBold='\e[1;32m' +greenBackground='\e[0;42m' +yellow='\e[0;33m' +yellowBold='\e[1;33m' +yellowBackground='\e[0;43m' +blue='\e[0;34m' +blueBold='\e[1;34m' +blueBackground='\e[0;44m' +magenta='\e[0;35m' +magentaBold='\e[1;35m' +magentaBackground='\e[0;45m' +cyan='\e[0;36m' +cyanBold='\e[1;36m' +cyanBackground='\e[0;46m' +white='\e[0;37m' +whiteBold='\e[1;37m' +whiteBackground='\e[0;47m' +reset='\e[0m' + +abort() { + echo ' +*************** +*** ABORTED *** +*************** + ' >&2 + echo "An error occurred on line $1. Exiting..." >&2 + date -Iseconds >&2 + exit 1 +} + +trap 'abort $LINENO' ERR +set -e -o pipefail + +quit() { + trap : 0 + exit 0 +} + +# Asks if [Yn] if script shoud continue, otherwise exit 1 +# $1: msg or nothing +# Example call 1: askContinueYn +# Example call 1: askContinueYn "Backup DB?" +askContinueYn() { + if [[ $1 ]]; then + msg="$1 " + else + msg="" + fi + + # http://stackoverflow.com/questions/3231804/in-bash-how-to-add-are-you-sure-y-n-to-any-command-or-alias + read -e -p "${msg}Continue? [Y/n] " response + response=${response,,} # tolower + if [[ $response =~ ^(yes|y|)$ ]] ; then + # echo "" + # OK + : + else + echo "Aborted" + exit 1 + fi +} + +# Reference: https://gist.github.com/steinwaywhw/a4cd19cda655b8249d908261a62687f8 + +echo "Checking Protoc version..." +VERSION=$(curl -sL https://github.com/protocolbuffers/protobuf/releases/latest | grep -E "" | perl -pe's%.*Protocol Buffers v(\d+\.\d+(\.\d+)?).*%\1%') +BASEVERSION=4 +echo + +interactive=false +ignore_version_check=true +clean=false +build_docker=true +run_gui=true +generate_result_files=false + +while test $# -gt 0; do + case $1 in + -h|--help) + echo "Upgrade Protoc" + echo + echo "$0 [options]" + echo + echo "Options:" + echo "-i Interactive" + echo "-C Ignore version check" + echo "-D No docker build" + echo "-G No not run gui" + echo "-c Clean" + echo "-r Generate result files" + echo "-h, --help Help" + quit + ;; + -a) + interactive=true + shift + ;; + -C) + ignore_version_check=false + shift + ;; + -D) + build_docker=false + shift + ;; + -G) + run_gui=false + shift + ;; + -r) + generate_result_files=true + shift + ;; + -c) + clean=true + shift + ;; + esac +done + +BIN="$HOME/bin" +DOWNLOADS="$HOME/downloads" + +PYTHON="python3.11" +PIP="pip3.11" +PIPENV="$PYTHON -m pipenv" +FLAKE8="$PYTHON -m flake8" +MYPY="$PYTHON -m mypy" + +# Upgrade protoc + +DEST="protoc" + +OLDVERSION=$(cat $BIN/$DEST/.VERSION.txt || echo "") +echo -e "\nProtoc remote version $VERSION\n" +echo -e "Protoc local version: $OLDVERSION\n" + +if $clean; then + cmd="rm -r dist/ build/ *.whl pytest.xml pytest-coverage.txt .coverage tests/reports || true; find . -name '*.pyc' -type f -delete; find . -name '__pycache__' -type d -exec rm -r {} \; || true; find . -name '*.egg-info' -type d -exec rm -r {} \; || true; find . -name '*_cache' -type d -exec rm -r {} \; || true; mkdir -p tests/reports;" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="pipenv --rm || true" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="sudo pipenv --rm || true" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" +fi + +if [ "$OLDVERSION" != "$VERSION" ] || ! $ignore_version_check; then + echo "Upgrade protoc from $OLDVERSION to $VERSION" + + NAME="protoc-$VERSION" + ARCHIVE="$NAME.zip" + + 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"; 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"; 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"; 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"; 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"; 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"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="mv -iT $BIN/$NAME $BIN/$DEST" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="rm $DOWNLOADS/$ARCHIVE" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="$BIN/$DEST/bin/protoc --plugin=protoc-gen-mypy=$HOME/.local/bin/protoc-gen-mypy --python_out=src/protobuf_generated_python --mypy_out=src/protobuf_generated_python --proto_path=src google_auth.proto" + 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"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" +else + echo -e "\nVersion has not changed. Quit" +fi + + +# Upgrade pip requirements + +cmd="sudo pip install -U pip" +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"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="$PIP install --use-pep517 -U -r requirements-dev.txt" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# Lint + +LINT_OUT_FILE="tests/reports/flake8_results.txt" +cmd="$FLAKE8 . --count --select=E9,F63,F7,F82 --show-source --statistics | tee $LINT_OUT_FILE" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="$FLAKE8 . --count --exit-zero --max-complexity=10 --max-line-length=200 --statistics --exclude=.git,__pycache__,docs/source/conf.py,old,build,dist,protobuf_generated_python | tee -a $LINT_OUT_FILE" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# Type checking + +TYPE_CHECK_OUT_FILE="tests/reports/mypy_results.txt" +cmd="$MYPY --install-types --non-interactive src/*.py tests/*.py" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# change to src as python -m mypy adds the current dir Python sys.path +# execute in a subshell in order not to loose the exit code and not to change the dir in the currrent shell +cmd="$MYPY --strict src/*.py tests/*.py | tee $TYPE_CHECK_OUT_FILE" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# Test + +cmd="$PYTHON src/extract_otp_secrets.py example_export.txt" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="$PYTHON src/extract_otp_secrets.py - < example_export.txt" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +COVERAGE_OUT_FILE="tests/reports/pytest-coverage.txt" +cmd="pytest --cov=extract_otp_secrets_test --junitxml=tests/reports/pytest.xml --cov-report html:tests/reports/html --cov-report=term-missing tests/ | tee $COVERAGE_OUT_FILE" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# Pipenv + +cmd="$PIP install -U pipenv" +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"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +$PIPENV run python --version + +cmd="$PIPENV run pytest --cov=extract_otp_secrets_test tests/" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# sudo pip + +cmd="sudo $PIP install --use-pep517 -U -r requirements.txt" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="sudo $PIP install --use-pep517 -U -r requirements-dev.txt" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="sudo $PIP install -U pipenv" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# pip -e install (must be after other pip installs in order to have this environment for development) + +cmd="$PIP install -U -e ." +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="extract_otp_secrets example_export.txt" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +cmd="extract_otp_secrets - < example_export.txt" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# Build wheel + +cmd="$PIP wheel . +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +# Generate results files + +if $generate_result_files; then + cmd="for color in '' '-n'; do for level in '' '-v' '-vv' '-vvv'; do $PYTHON src/extract_otp_secrets.py example_export.txt $color $level > tests/data/print_verbose_output$color$level.txt; done; done" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" +fi + +# Update Code Coverage in README.md + +# https://github.com/marketplace/actions/pytest-coverage-comment +# Coverage-95%25-yellowgreen +echo -e "Update code coverage in README.md" +TOTAL_COVERAGE=$(cat $COVERAGE_OUT_FILE | grep 'TOTAL' | perl -ne 'print "$&" if /\b(\d{1,3})%/') && perl -i -pe "s/coverage-(\d{1,3}%)25-/coverage-${TOTAL_COVERAGE}25-/" README.md + +if $build_docker; then + # Build docker + + # Build Dockerfile_only_txt (Alpine) + cmd="docker build . -t extract_otp_secrets_only_txt -f Dockerfile_only_txt --pull --build-arg RUN_TESTS=false" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="docker run --rm -v \"$(pwd)\":/files:ro extract_otp_secrets_only_txt example_export.txt" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="docker run --rm -i -v \"$(pwd)\":/files:ro extract_otp_secrets_only_txt - < example_export.txt" + 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_secrets_only_txt tests/extract_otp_secrets_test.py -k 'not qreader' -vvv --relaxed" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + + # Build extract_otp_secrets (Debian) + cmd="docker build . -t extract_otp_secrets --pull --build-arg RUN_TESTS=false" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="docker run --rm -v \"$(pwd)\":/files:ro extract_otp_secrets example_export.txt" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="cat example_export.txt | docker run --rm -i -v \"$(pwd)\":/files:ro extract_otp_secrets - -c - > example_output.csv" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="docker run --rm -i -v \"$(pwd)\":/files:ro extract_otp_secrets = < example_export.png" + 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_secrets" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + cmd="docker image prune -f || echo 'No docker image pruned'" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + + if $run_gui; then + cmd="docker run --rm -v "$(pwd)":/files:ro --device=\"/dev/video0:/dev/video0\" --env=\"DISPLAY\" -v /tmp/.X11-unix:/tmp/.X11-unix:ro extract_otp_secrets &" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" + fi +fi + +if $run_gui; then + cmd="$PYTHON src/extract_otp_secrets.py &" + if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi + eval "$cmd" +fi + +line=$(printf '#%.0s' $(eval echo {1..$(( ($COLUMNS - 10) / 2))})) +echo -e "\n${blueBold}$line RESULTS $line${reset}" + +cmd="cat $TYPE_CHECK_OUT_FILE $LINT_OUT_FILE $COVERAGE_OUT_FILE" +if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi +eval "$cmd" + +echo -e "\n${greenBold}SUCCESS${reset}" + +quit diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 9c66a08..0000000 --- a/conftest.py +++ /dev/null @@ -1,10 +0,0 @@ -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/cv2_capture_screenshot.png b/cv2_capture_screenshot.png new file mode 100644 index 0000000..b469f4f Binary files /dev/null and b/cv2_capture_screenshot.png differ diff --git a/example_export.txt b/example_export.txt index 1af539f..6b0fa7e 100644 --- a/example_export.txt +++ b/example_export.txt @@ -1,4 +1,5 @@ # 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 diff --git a/extract_otp_secret_keys.code-workspace b/extract_otp_secret_keys.code-workspace index 3d6220a..b43c67f 100644 --- a/extract_otp_secret_keys.code-workspace +++ b/extract_otp_secret_keys.code-workspace @@ -11,10 +11,10 @@ "version": "0.2.0", "configurations": [ { - "name": "Python: extract_otp_secret_keys.py", + "name": "Python: extract_otp_secrets.py", "type": "python", "request": "launch", - "program": "extract_otp_secret_keys.py", + "program": "extract_otp_secrets.py", "args": [ "example_export.txt" ], diff --git a/extract_otp_secret_keys.py b/extract_otp_secret_keys.py deleted file mode 100644 index f467783..0000000 --- a/extract_otp_secret_keys.py +++ /dev/null @@ -1,451 +0,0 @@ -# Extract two-factor authentication (2FA, TFA) secret keys from export QR codes of "Google Authenticator" app -# -# Usage: -# 1. Export the QR codes from "Google Authenticator" app -# 2. Read QR codes with QR code reader (e.g. with a second device) -# 3. Save the captured QR codes in a text file. Save each QR code on a new line. (The captured QR codes look like "otpauth-migration://offline?data=...") -# 4. Call this script with the file as input: -# python extract_otp_secret_keys.py example_export.txt -# -# Requirement: -# The protobuf package of Google for proto3 is required for running this script. -# pip install protobuf -# -# Optional: -# For printing QR codes, the qrcode module is required -# pip install qrcode -# -# Technical background: -# The export QR code of "Google Authenticator" contains the URL "otpauth-migration://offline?data=...". -# The data parameter is a base64 encoded proto3 message (Google Protocol Buffers). -# -# Command for regeneration of Python code from proto3 message definition file (only necessary in case of changes of the proto3 message definition): -# protoc --python_out=generated_python google_auth.proto -# -# References: -# Proto3 documentation: https://developers.google.com/protocol-buffers/docs/pythontutorial -# Template code: https://github.com/beemdevelopment/Aegis/pull/406 - -# Author: Scito (https://scito.ch) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -import argparse -import base64 -import csv -import fileinput -import importlib -import json -import os -import re -import sys -import urllib.parse as urlparse - -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, 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 - quiet = args.quiet - - otps = extract_otps(args) - write_csv(args, otps) - write_keepass_csv(args, otps) - write_json(args, otps) - - -def parse_args(sys_args): - 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')) - 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') - args = arg_parser.parse_args(sys_args) - if args.csv == '-' or args.json == '-' or args.keepass == '-': - args.quiet = args.q = True - return args - - -def extract_otps(args): - global verbose, quiet - quiet = args.quiet - - otps = [] - - 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(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 - - # could not process text file, try reading as image - if filename != '-': - return convert_img_to_line(filename) - - -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() - - -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) - - 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: - 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, 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(infile, line)) - parsed_url = urlparse.urlparse(line) - if verbose > 1: print('\nDEBUG: parsed_url={}'.format(parsed_url)) - try: - 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: - 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(' ', '+') - if verbose > 1: print('\nDEBUG: data_base64_fixed={}'.format(data_base64)) - data = base64.b64decode(data_base64_fixed, validate=True) - payload = protobuf_generated_python.google_auth_pb2.MigrationPayload() - try: - payload.ParseFromString(data) - except: - abort('\nERROR: Cannot decode otpauth-migration migration payload.\n' - 'data={}'.format(data_base64)) - if verbose: - print('\n{}. Payload Line'.format(i), payload, sep='\n') - - return payload - - -# https://stackoverflow.com/questions/40226049/find-enums-listed-in-python-descriptor-for-protobuf -def get_enum_name_by_number(parent, field_name): - field_value = getattr(parent, field_name) - return parent.DESCRIPTOR.fields_by_name[field_name].enum_type.values_by_number.get(field_value).name - - -def get_otp_type_str_from_code(otp_type): - return 'totp' if otp_type == 2 else 'hotp' - - -def convert_secret_from_bytes_to_base32_str(bytes): - return str(base64.b32encode(bytes), 'utf-8').replace('=', '') - - -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), urlparse.quote(raw_otp.name)) + urlparse.urlencode( url_params) - return otp_url - - -def print_otp(otp): - print('Name: {}'.format(otp['name'])) - print('Secret: {}'.format(otp['secret'])) - if otp['issuer']: print('Issuer: {}'.format(otp['issuer'])) - print('Type: {}'.format(otp['type'])) - if otp['type'] == 'hotp': - print('Counter: {}'.format(otp['counter'])) - if verbose: - print(otp['url']) - - -def save_qr(otp, args, j): - dir = args.saveqr - 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 '')) - return file_otp_issuer - - -def save_qr_file(args, data, name): - from qrcode import QRCode - global verbose - qr = QRCode() - qr.add_data(data) - img = qr.make_image(fill_color='black', back_color='white') - if verbose: print('Saving to {}'.format(name)) - img.save(name) - - -def print_qr(args, data): - from qrcode import QRCode - qr = QRCode() - qr.add_data(data) - qr.print_ascii() - - -def write_csv(args, otps): - global verbose, quiet - if args.csv and len(otps) > 0: - with open_file_or_stdout_for_csv(args.csv) as outfile: - writer = csv.DictWriter(outfile, otps[0].keys()) - writer.writeheader() - writer.writerows(otps) - if not quiet: print("Exported {} otps to csv {}".format(len(otps), args.csv)) - - -def write_keepass_csv(args, otps): - global verbose, quiet - if args.keepass and len(otps) > 0: - has_totp = has_otp_type(otps, 'totp') - has_hotp = has_otp_type(otps, 'hotp') - otp_filename_totp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "totp") - otp_filename_hotp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "hotp") - count_totp_entries = 0 - count_hotp_entries = 0 - if has_totp: - with open_file_or_stdout_for_csv(otp_filename_totp) as outfile: - writer = csv.DictWriter(outfile, ["Title", "User Name", "TimeOtp-Secret-Base32", "Group"]) - writer.writeheader() - for otp in otps: - if otp['type'] == 'totp': - writer.writerow({ - 'Title': otp['issuer'], - 'User Name': otp['name'], - 'TimeOtp-Secret-Base32': otp['secret'] if otp['type'] == 'totp' else None, - 'Group': "OTP/{}".format(otp['type'].upper()) - }) - 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.writeheader() - for otp in otps: - if otp['type'] == 'hotp': - writer.writerow({ - 'Title': otp['issuer'], - 'User Name': otp['name'], - 'HmacOtp-Secret-Base32': otp['secret'] if otp['type'] == 'hotp' else None, - 'HmacOtp-Counter': otp['counter'] if otp['type'] == 'hotp' else None, - 'Group': "OTP/{}".format(otp['type'].upper()) - }) - 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)) - - -def write_json(args, otps): - global verbose, quiet - if args.json: - with open_file_or_stdout(args.json) as outfile: - json.dump(otps, outfile, indent=4) - if not quiet: print("Exported {} otp entries to json {}".format(len(otps), args.json)) - - -def has_otp_type(otps, otp_type): - for otp in otps: - if otp['type'] == otp_type: - return True - return False - - -def add_pre_suffix(file, pre_suffix): - '''filename.ext, pre -> filename.pre.ext''' - name, ext = os.path.splitext(file) - return name + "." + pre_suffix + (ext if ext else "") - - -def open_file_or_stdout(filename): - '''stdout is denoted as "-". - Note: Set before the following line: - sys.stdout.close = lambda: None''' - return open(filename, "w", encoding='utf-8') if filename != '-' else sys.stdout - - -def open_file_or_stdout_for_csv(filename): - '''stdout is denoted as "-". - newline='' - Note: Set before the following line: - sys.stdout.close = lambda: None''' - 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/mypy.ini b/mypy.ini new file mode 100644 index 0000000..8c0d273 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,4 @@ +[mypy] + +[mypy-protobuf_generated_python.*] +ignore_errors = True diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cb86bbe --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,66 @@ +[build-system] +requires = [ + "setuptools>=64.0.0", "wheel>=0.37.0", "pip", + # https://setuptools-git-versioning.readthedocs.io/en/latest/differences.html + "setuptools-git-versioning", +] +build-backend = "setuptools.build_meta" + +[project] +name = "extract_otp_secrets" +# https://pypi.org/classifiers/ +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Environment :: X11 Applications :: Qt", + "Environment :: Win32 (MS Windows)", + "Topic :: System :: Archiving :: Backup", + "Topic :: Utilities", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "Programming Language :: Python", + "Natural Language :: English", + "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", +] +dependencies = [ + "protobuf", + "qrcode", + "Pillow", + "qreader", + "pyzbar", + "opencv-contrib-python<=4.7.0; sys_platform == 'darwin'", + "opencv-contrib-python; sys_platform != 'darwin'", + "typing_extensions; python_version<='3.7'", + "colorama>=0.4.6", +] +description = "Extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps such as 'Google Authenticator'" +dynamic = ["version"] +keywords = ["python", "security", "json", "otp", "csv", "protobuf", "qrcode", "two-factor", "totp", "google-authenticator", "recovery", "proto3", "mfa", "two-factor-authentication", "tfa", "qr-codes", "otpauth", "2fa", "security-tools", "cv2"] +license = {text = "GNU General Public License v3 (GPLv3)"} +readme = "README.md" +authors = [{name = "scito", email = "info@scito.ch"}] +maintainers = [{name = "scito", email = "info@scito.ch"}] +requires-python = ">=3.7, <4" +scripts = {extract_otp_secrets = "extract_otp_secrets:sys_main"} +urls = {Project-URL = "https://github.com/scito/extract_otp_secrets", Bug-Reports = "https://github.com/scito/extract_otp_secrets/issues", Source = "https://github.com/scito/extract_otp_secrets"} + +# [tool.setuptools] +# Still in beta, once it is stable move config from setup.cfg to pyproject.toml +# py-modules = ["extract_otp_secrets", "protobuf_generated_python.protobuf_generated_python"] + +# [tool.setuptools.dynamic] +# version = {attr = "extract_otp_secrets.VERSION"} + +[tool.setuptools-git-versioning] +enabled = true + +# https://blog.ionelmc.ro/2014/05/25/python-packaging/#the-structure%3E +# https://docs.pytest.org/en/7.1.x/explanation/goodpractices.html#which-import-mode +[tool.pytest.ini_options] +addopts = [ "--import-mode=importlib", ] diff --git a/requirements-dev.txt b/requirements-dev.txt index 8202127..fce22e5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,4 +1,10 @@ -wheel -pytest flake8 +mypy +types-protobuf pylint +pytest +pytest-mock +pytest-cov +setuptools +wheel +build diff --git a/requirements.txt b/requirements.txt index c57362c..d456851 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,8 @@ protobuf qrcode Pillow qreader -opencv-python +opencv-contrib-python<=4.7.0; sys_platform == 'darwin' +opencv-contrib-python; sys_platform != 'darwin' +pyzbar +typing_extensions; python_version<='3.7' +colorama>=0.4.6 diff --git a/run_pytest.sh b/run_pytest.sh index e77ffae..08ccfdd 100755 --- a/run_pytest.sh +++ b/run_pytest.sh @@ -1,3 +1,3 @@ #!/bin/sh cd /extract -pip install -U pytest && pytest "$@" +pip install -U pytest pytest-mock && pip install --no-deps . && pytest "$@" diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e867b90 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,12 @@ +[metadata] +name = extract_otp_secrets + +[options] +python_requires = >=3.7, <4 +py_modules = extract_otp_secrets, protobuf_generated_python.google_auth_pb2 +package_dir = + =src +# packages=find: + +# [options.packages.find] +# where=src diff --git a/setup.py b/setup.py index 8c9d510..592874f 100644 --- a/setup.py +++ b/setup.py @@ -1,62 +1,5 @@ -import pathlib +#!/usr/bin/python3 from setuptools import setup -setup( - name='extract_otp_secret_keys', - version='1.6.0', - description='Extract two-factor authentication (2FA, TFA, OTP) secret keys from export QR codes of "Google Authenticator" app', - - long_description=(pathlib.Path(__file__).parent / 'README.md').read_text(), - long_description_content_type='text/markdown', - - url='https://github.com/scito/extract_otp_secret_keys', - author='scito', - author_email='info@scito.ch', - - classifiers=[ - 'Development Status :: 5 - Production/Stable', - - 'Environment :: Console', - 'Topic :: System :: Archiving :: Backup', - 'Topic :: Utilities', - - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Programming Language :: Python :: 3.11', - - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - - 'Programming Language :: Python' - 'Natural Language :: English', - 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', - ], - - keywords='python security json otp csv protobuf qrcode two-factor totp google-authenticator recovery proto3 mfa two-factor-authentication tfa qr-codes otpauth 2fa security-tools', - - py_modules=['extract_otp_secret_keys', 'protobuf_generated_python.google_auth_pb2'], - entry_points={ - 'console_scripts': [ - 'extract_otp_secret_keys = extract_otp_secret_keys:sys_main', - ] - }, - - python_requires='>=3.7, <4', - install_requires=[ - 'protobuf', - 'qrcode', - 'Pillow', - 'qreader', - 'opencv-python' - ], - - project_urls={ - 'Bug Reports': 'https://github.com/scito/extract_otp_secret_keys/issues', - 'Source': 'https://github.com/scito/extract_otp_secret_keys', - }, - - license='GNU General Public License v3 (GPLv3)', -) +# compatibility with legacy builds or versions of tools that don’t support certain packaging standards +setup() diff --git a/src/extract_otp_secrets.py b/src/extract_otp_secrets.py new file mode 100644 index 0000000..f3edd7c --- /dev/null +++ b/src/extract_otp_secrets.py @@ -0,0 +1,774 @@ +# Extract one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps such as "Google Authenticator" +# +# For more information, see README.md +# +# Source code available on https://github.com/scito/extract_otp_secrets +# +# Technical background: +# The export QR code from "Google Authenticator" contains the URL "otpauth-migration://offline?data=...". +# The data parameter is a base64 encoded proto3 message (Google Protocol Buffers). +# +# Command for regeneration of Python code from proto3 message definition file (only necessary in case of changes of the proto3 message definition): +# protoc --plugin=protoc-gen-mypy=path/to/protoc-gen-mypy --python_out=src/protobuf_generated_python --mypy_out=src/protobuf_generated_python --proto_path=src google_auth.proto +# +# References: +# Proto3 documentation: https://developers.google.com/protocol-buffers/docs/pythontutorial +# Template code: https://github.com/beemdevelopment/Aegis/pull/406 + +# Author: Scito (https://scito.ch) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +from __future__ import annotations # workaround for PYTHON <= 3.10 + +import argparse +import base64 +import csv +import fileinput +import json +import os +import re +import sys +import urllib.parse as urlparse +from enum import Enum, IntEnum +from typing import Any, List, Optional, TextIO, Tuple, Union + +# workaround for PYTHON <= 3.7: compatibility +if sys.version_info >= (3, 8): + from typing import Final, TypedDict +else: + from typing_extensions import Final, TypedDict + +from qrcode import QRCode # type: ignore + +import protobuf_generated_python.google_auth_pb2 as pb +import colorama + +debug_mode = '-d' in sys.argv[1:] or '--debug' in sys.argv[1:] + +try: + import cv2 # type: ignore # TODO use cv2 types if available + + import numpy as np # TODO use numpy types if available + + try: + import pyzbar.pyzbar as zbar # type: ignore + from qreader import QReader # type: ignore + except ImportError as e: + print(f""" +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: {e}\n""", file=sys.stderr) + raise e + + # Types + # workaround for PYTHON <= 3.9: Final[tuple[int]] + ColorBGR = Tuple[int, int, int] # RGB Color specified as Blue, Green, Red + Point = Tuple[int, int] + + # CV2 camera capture constants + FONT: Final[int] = cv2.FONT_HERSHEY_PLAIN + FONT_SCALE: Final[float] = 1.3 + FONT_THICKNESS: Final[int] = 1 + FONT_LINE_STYLE: Final[int] = cv2.LINE_AA + FONT_COLOR: Final[ColorBGR] = (255, 0, 0) + BOX_THICKNESS: Final[int] = 5 + # workaround for PYTHON <= 3.7: must use () for assignments + WINDOW_X: Final[int] = 0 + WINDOW_Y: Final[int] = 1 + WINDOW_WIDTH: Final[int] = 2 + WINDOW_HEIGHT: Final[int] = 3 + TEXT_WIDTH: Final[int] = 0 + TEXT_HEIGHT: Final[int] = 1 + BORDER: Final[int] = 5 + START_Y: Final[int] = 20 + START_POS_TEXT: Final[Point] = (BORDER, START_Y) + NORMAL_COLOR: Final[ColorBGR] = (255, 0, 255) + SUCCESS_COLOR: Final[ColorBGR] = (0, 255, 0) + FAILURE_COLOR: Final[ColorBGR] = (0, 0, 255) + CHAR_DX: Final[int] = (lambda text: cv2.getTextSize(text, FONT, FONT_SCALE, FONT_THICKNESS)[0][TEXT_WIDTH] // len(text))("28 QR codes capturedMMM") + FONT_DY: Final[int] = cv2.getTextSize("M", FONT, FONT_SCALE, FONT_THICKNESS)[0][TEXT_HEIGHT] + 5 + WINDOW_NAME: Final[str] = "Extract OTP Secrets: Capture QR Codes from Camera" + + TextPosition = Enum('TextPosition', ['LEFT', 'RIGHT']) + + qreader_available = True +except ImportError as e: + qreader_available = False + if debug_mode: + raise e + +# Workaround for PYTHON <= 3.9: Union[int, None] used instead of int | None + +# Types +Args = argparse.Namespace +OtpUrl = str +# workaround for PYTHON <= 3.7: Otp = TypedDict('Otp', {'name': str, 'secret': str, 'issuer': str, 'type': str, 'counter': int | None, 'url': OtpUrl}) +Otp = TypedDict('Otp', {'name': str, 'secret': str, 'issuer': str, 'type': str, 'counter': Union[int, None], 'url': OtpUrl}) +# workaround for PYTHON <= 3.9: Otps = list[Otp] +Otps = List[Otp] +# workaround for PYTHON <= 3.9: OtpUrls = list[OtpUrl] +OtpUrls = List[OtpUrl] + +QRMode = Enum('QRMode', ['ZBAR', 'QREADER', 'QREADER_DEEP', 'CV2', 'CV2_WECHAT'], start=0) +LogLevel = IntEnum('LogLevel', ['QUIET', 'NORMAL', 'VERBOSE', 'MORE_VERBOSE', 'DEBUG'], start=-1) + + +# Constants +CAMERA: Final[str] = 'camera' + +# Global variable declaration +verbose: IntEnum = LogLevel.NORMAL +quiet: bool = False +colored: bool = True + + +def sys_main() -> None: + main(sys.argv[1:]) + + +def main(sys_args: list[str]) -> None: + # allow to use sys.stdout with with (avoid closing) + sys.stdout.close = lambda: None # type: ignore + # set encoding to utf-8, needed for Windows + try: + sys.stdout.reconfigure(encoding='utf-8') # type: ignore + sys.stderr.reconfigure(encoding='utf-8') # type: ignore + except AttributeError: # '_io.StringIO' object has no attribute 'reconfigure' + # StringIO in tests do not have all attributes, ignore it + pass + + args = parse_args(sys_args) + + if colored: + colorama.just_fix_windows_console() + + if args.debug: + sys.exit(0 if do_debug_checks() else 1) + + otps = extract_otps(args) + write_csv(args, otps) + write_keepass_csv(args, otps) + write_json(args, otps) + + +def parse_args(sys_args: list[str]) -> Args: + global verbose, quiet, colored + description_text = "Extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps" + if qreader_available: + description_text += "\nIf no infiles are provided, a GUI window starts and QR codes are captured from the camera." + example_text = """examples: +python extract_otp_secrets.py +python extract_otp_secrets.py example_*.txt +python extract_otp_secrets.py - < example_export.txt +python extract_otp_secrets.py --csv - example_*.png | tail -n+2 +python extract_otp_secrets.py = < example_export.png""" + + arg_parser = argparse.ArgumentParser(formatter_class=lambda prog: argparse.RawTextHelpFormatter(prog, max_help_position=32), + description=description_text, + epilog=example_text) + arg_parser.add_argument('infile', help="""a) file or - for stdin with 'otpauth-migration://...' URLs separated by newlines, lines starting with # are ignored; +b) image file containing a QR code or = for stdin for an image containing a QR code""", nargs='*' if qreader_available else '+') + 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('--json', '-j', help='export json file or - 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')) + if qreader_available: + arg_parser.add_argument('--camera', '-C', help='camera number of system (default camera: 0)', default=0, type=int, metavar=('NUMBER')) + arg_parser.add_argument('--qr', '-Q', help=f'QR reader (default: {QRMode.ZBAR.name})', type=str, choices=[mode.name for mode in QRMode], default=QRMode.ZBAR.name) + arg_parser.add_argument('-i', '--ignore', help='ignore duplicate otps', action='store_true') + arg_parser.add_argument('--no-color', '-n', help='do not use ANSI colors in console output', action='store_true') + output_group = arg_parser.add_mutually_exclusive_group() + output_group.add_argument('-d', '--debug', help='enter debug mode, do checks and quit', action='count') + output_group.add_argument('-v', '--verbose', help='verbose output', action='count') + output_group.add_argument('-q', '--quiet', help='no stdout output, except output set by -', action='store_true') + args = arg_parser.parse_args(sys_args) + colored = not args.no_color + if args.csv == '-' or args.json == '-' or args.keepass == '-': + args.quiet = args.q = True + + verbose = args.verbose if args.verbose else LogLevel.NORMAL + if args.debug: + verbose = LogLevel.DEBUG + log_debug('Debug mode start') + quiet = True if args.quiet else False + if verbose: print(f"QReader installed: {qreader_available}") + if qreader_available: + if verbose >= LogLevel.VERBOSE: print(f"CV2 version: {cv2.__version__}") + if verbose: print(f"QR reading mode: {args.qr}\n") + + return args + + +def extract_otps(args: Args) -> Otps: + if not args.infile: + return extract_otps_from_camera(args) + else: + return extract_otps_from_files(args) + + +def get_color(new_otps_count: int, otp_url: str) -> ColorBGR: + if new_otps_count: + return SUCCESS_COLOR + else: + if otp_url: + return FAILURE_COLOR + else: + return NORMAL_COLOR + + +# TODO use cv2 types if available +def cv2_draw_box(img: Any, raw_pts: Any, color: ColorBGR) -> Any: + pts = np.array([raw_pts], np.int32) + pts = pts.reshape((-1, 1, 2)) + cv2.polylines(img, [pts], True, color, BOX_THICKNESS) + return pts + + +# TODO use cv2 types if available +def cv2_print_text(img: Any, text: str, line_number: int, position: TextPosition, color: ColorBGR, opposite_len: Optional[int] = None) -> None: + window_dim = cv2.getWindowImageRect(WINDOW_NAME) + out_text = text + if opposite_len: + text_dim, _ = cv2.getTextSize(out_text, FONT, FONT_SCALE, FONT_THICKNESS) + actual_width = text_dim[TEXT_WIDTH] + opposite_len * CHAR_DX + 4 * BORDER + if actual_width >= window_dim[WINDOW_WIDTH]: + out_text = out_text[:(window_dim[WINDOW_WIDTH] - actual_width) // CHAR_DX] + '.' + text_dim, _ = cv2.getTextSize(out_text, FONT, FONT_SCALE, FONT_THICKNESS) + if position == TextPosition.LEFT: + pos = BORDER, START_Y + line_number * FONT_DY + else: + pos = window_dim[WINDOW_WIDTH] - text_dim[TEXT_WIDTH] - BORDER, START_Y + line_number * FONT_DY + + cv2.putText(img, out_text, pos, FONT, FONT_SCALE, color, FONT_THICKNESS, FONT_LINE_STYLE) + + +def extract_otps_from_camera(args: Args) -> Otps: + if verbose: print("Capture QR codes from camera") + otp_urls: OtpUrls = [] + otps: Otps = [] + + qr_mode = QRMode[args.qr] + + cam = cv2.VideoCapture(args.camera) + cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_AUTOSIZE) + + qreader = QReader() + cv2_qr = cv2.QRCodeDetector() + cv2_qr_wechat = cv2.wechat_qrcode.WeChatQRCode() + while True: + success, img = cam.read() + new_otps_count = 0 + if not success: + log_error("Failed to capture image from camera") + break + try: + if qr_mode in [QRMode.QREADER, QRMode.QREADER_DEEP]: + found, bbox = qreader.detect(img) + if qr_mode == QRMode.QREADER_DEEP: + otp_url = qreader.detect_and_decode(img, True) + elif qr_mode == QRMode.QREADER: + otp_url = qreader.decode(img, bbox) if found else None + if otp_url: + new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args) + if found: + cv2.rectangle(img, (bbox[0], bbox[1]), (bbox[2], bbox[3]), get_color(new_otps_count, otp_url), BOX_THICKNESS) + elif qr_mode == QRMode.ZBAR: + for qrcode in zbar.decode(img): + otp_url = qrcode.data.decode('utf-8') + new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args) + cv2_draw_box(img, [qrcode.polygon], get_color(new_otps_count, otp_url)) + elif qr_mode in [QRMode.CV2, QRMode.CV2_WECHAT]: + if QRMode.CV2: + otp_url, raw_pts, _ = cv2_qr.detectAndDecode(img) + else: + otp_url, raw_pts = cv2_qr_wechat.detectAndDecode(img) + if raw_pts is not None: + if otp_url: + new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args) + cv2_draw_box(img, raw_pts, get_color(new_otps_count, otp_url)) + else: + abort(f"Invalid QReader mode: {qr_mode.name}") + except Exception as e: + log_error(f'An error occured during QR detection and decoding for QR reader {qr_mode}. Changed to the next QR reader.', e) + qr_mode = next_qr_mode(qr_mode) + continue + + cv2_print_text(img, f"Mode: {qr_mode.name} (Hit space to change)", 0, TextPosition.LEFT, FONT_COLOR, 20) + cv2_print_text(img, "Hit ESC to quit", 1, TextPosition.LEFT, FONT_COLOR, 17) + + cv2_print_text(img, f"{len(otp_urls)} QR code{'s'[:len(otp_urls) != 1]} captured", 0, TextPosition.RIGHT, FONT_COLOR) + cv2_print_text(img, f"{len(otps)} otp{'s'[:len(otps) != 1]} extracted", 1, TextPosition.RIGHT, FONT_COLOR) + + cv2.imshow(WINDOW_NAME, img) + + quit, qr_mode = cv2_handle_pressed_keys(qr_mode) + if quit: + break + + cam.release() + cv2.destroyAllWindows() + + return otps + + +def cv2_handle_pressed_keys(qr_mode: QRMode) -> Tuple[bool, QRMode]: + key = cv2.waitKey(1) & 0xFF + quit = False + if key == 27 or key == ord('q') or key == 13: + # ESC or Enter or q pressed + quit = True + elif key == 32: + qr_mode = next_qr_mode(qr_mode) + if verbose >= LogLevel.MORE_VERBOSE: print(f"QR reading mode: {qr_mode}") + if cv2.getWindowProperty(WINDOW_NAME, cv2.WND_PROP_VISIBLE) < 1: + # Window close clicked + quit = True + return quit, qr_mode + + +def extract_otps_from_otp_url(otp_url: str, otp_urls: OtpUrls, otps: Otps, args: Args) -> int: + '''Returns -1 if opt_url was already added.''' + if otp_url and verbose >= LogLevel.VERBOSE: print(otp_url) + if not otp_url: + return 0 + if otp_url not in otp_urls: + new_otps_count = extract_otp_from_otp_url(otp_url, otps, len(otp_urls), CAMERA, args) + if new_otps_count: + otp_urls.append(otp_url) + if verbose: print(f"Extracted {new_otps_count} otp{'s'[:len(otps) != 1]}. {len(otps)} otp{'s'[:len(otps) != 1]} from {len(otp_urls)} QR code{'s'[:len(otp_urls) != 1]} extracted") + return new_otps_count + return -1 + + +def extract_otps_from_files(args: Args) -> Otps: + otps: Otps = [] + + files_count = urls_count = otps_count = 0 + if verbose: print(f"Input files: {args.infile}") + for infile in args.infile: + if verbose >= LogLevel.MORE_VERBOSE: log_verbose(f"Processing infile {infile}") + files_count += 1 + for line in get_otp_urls_from_file(infile, args): + if verbose >= LogLevel.MORE_VERBOSE: log_verbose(line) + if line.startswith('#') or line == '': continue + urls_count += 1 + otps_count += extract_otp_from_otp_url(line, otps, urls_count, infile, args) + if verbose: print(f"Extracted {otps_count} otp{'s'[:otps_count != 1]} from {urls_count} otp url{'s'[:urls_count != 1]} by reading {files_count} infile{'s'[:files_count != 1]}") + return otps + + +def get_otp_urls_from_file(filename: str, args: Args) -> OtpUrls: + # 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 + + # could not process text file, try reading as image + if filename != '-' and qreader_available: + return convert_img_to_otp_urls(filename, args) + + return [] + + +def read_lines_from_text_file(filename: str) -> list[str]: + if verbose >= LogLevel.DEBUG: print(f"Reading lines of {filename}") + # workaround for PYTHON <= 3.9 support encoding + if sys.version_info >= (3, 10): + finput = fileinput.input(filename, encoding='utf-8') + else: + finput = fileinput.input(filename) + try: + lines = [] + for line in (line.strip() for line in finput): + if verbose >= LogLevel.DEBUG: log_verbose(line) + if is_binary(line): + abort("Binary 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: + log_warn(f"{filename.replace('-', 'stdin')} is empty") + except UnicodeDecodeError as e: + if filename == '-': + abort("Unable to open text file form stdin. " + "In case you want read an image file from stdin, you must use '=' instead of '-'.", e) + else: # The file is probably an image, process below + return [] + finally: + finput.close() + return lines + + +def extract_otp_from_otp_url(otpauth_migration_url: str, otps: Otps, urls_count: int, infile: str, args: Args) -> int: + payload = get_payload_from_otp_url(otpauth_migration_url, urls_count, infile) + + if not payload: + return 0 + + new_otps_count = 0 + # pylint: disable=no-member + for raw_otp in payload.otp_parameters: + if verbose: print(f"\n{len(otps) + 1}. Secret") + secret = convert_secret_from_bytes_to_base32_str(raw_otp.secret) + if verbose >= LogLevel.DEBUG: log_debug('OTP enum type:', 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: 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 otp not in otps or not args.ignore: + otps.append(otp) + new_otps_count += 1 + if not quiet: + print_otp(otp) + if args.printqr: + print_qr(args, otp_url) + if args.saveqr: + save_qr(otp, args, len(otps)) + if not quiet: + print() + elif args.ignore and not quiet: + eprint(f"Ignored duplicate otp: {otp['name']}", f" / {otp['issuer']}\n" if otp['issuer'] else '\n', sep='') + + return new_otps_count + + +def convert_img_to_otp_urls(filename: str, args: Args) -> OtpUrls: + if verbose: print(f"Reading image {filename}") + try: + if filename != '=': + img = 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() # type: ignore # Workaround for pytest fixtures + if not stdin: + log_warn("stdin is empty") + try: + img_array = np.frombuffer(stdin, dtype='uint8') + except TypeError as e: + abort("Cannot read binary stdin buffer.", e) + if not img_array.size: + return [] + img = cv2.imdecode(img_array, cv2.IMREAD_UNCHANGED) + + if img is None: + abort(f"Unable to open file for reading.\ninput file: {filename}") + + qr_mode = QRMode[args.qr] + otp_urls = decode_qr_img_otp_urls(img, qr_mode) + if len(otp_urls) == 0: + abort(f"Unable to read QR Code from file.\ninput file: {filename}") + except Exception as e: + abort(f"Encountered exception\ninput file: {filename}", e) + return otp_urls + + +def decode_qr_img_otp_urls(img: Any, qr_mode: QRMode) -> OtpUrls: + otp_urls: OtpUrls = [] + if qr_mode in [QRMode.QREADER, QRMode.QREADER_DEEP]: + otp_url = QReader().detect_and_decode(img, qr_mode == QRMode.QREADER_DEEP) + otp_urls.append(otp_url) + elif qr_mode == QRMode.CV2: + otp_url, _, _ = cv2.QRCodeDetector().detectAndDecode(img) + otp_urls.append(otp_url) + elif qr_mode == QRMode.CV2_WECHAT: + otp_url, _ = cv2.wechat_qrcode.WeChatQRCode().detectAndDecode(img) + otp_urls += list(otp_url) + elif qr_mode == QRMode.ZBAR: + qrcodes = zbar.decode(img) + otp_urls += [qrcode.data.decode('utf-8') for qrcode in qrcodes] + else: + assert False, f"Wrong QReader mode {qr_mode.name}" + + return otp_urls + + +# workaround for PYTHON <= 3.9 use: pb.MigrationPayload | None +def get_payload_from_otp_url(otp_url: str, i: int, source: str) -> Optional[pb.MigrationPayload]: + '''Extracts the otp migration payload from an otp url. This function is the core of the this appliation.''' + if not is_opt_url(otp_url, source): + return None + parsed_url = urlparse.urlparse(otp_url) + if verbose >= LogLevel.DEBUG: log_debug(f"parsed_url={parsed_url}") + try: + params = urlparse.parse_qs(parsed_url.query, strict_parsing=True) + except Exception: # workaround for PYTHON <= 3.10 + params = {} + if verbose >= LogLevel.DEBUG: log_debug(f"querystring params={params}") + if 'data' not in params: + log_error(f"could not parse query parameter in input url\nsource: {source}\nurl: {otp_url}") + return None + data_base64 = params['data'][0] + if verbose >= LogLevel.DEBUG: log_debug(f"data_base64={data_base64}") + data_base64_fixed = data_base64.replace(' ', '+') + if verbose >= LogLevel.DEBUG: log_debug(f"data_base64_fixed={data_base64_fixed}") + data = base64.b64decode(data_base64_fixed, validate=True) + payload = pb.MigrationPayload() + try: + payload.ParseFromString(data) + except Exception as e: + abort(f"Cannot decode otpauth-migration migration payload.\n" + f"data={data_base64}", e) + if verbose >= LogLevel.DEBUG: log_debug(f"\n{i}. Payload Line", payload, sep='\n') + + return payload + + +def is_opt_url(otp_url: str, source: str) -> bool: + if not otp_url.startswith('otpauth-migration://'): + msg = f"input is not a otpauth-migration:// url\nsource: {source}\ninput: {otp_url}" + if source == CAMERA: + log_warn(f"{msg}") + return False + else: + log_warn(f"{msg}\nMaybe a wrong file was given") + return True + + +# https://stackoverflow.com/questions/40226049/find-enums-listed-in-python-descriptor-for-protobuf +def get_enum_name_by_number(parent: Any, field_name: str) -> str: + field_value = getattr(parent, field_name) + return parent.DESCRIPTOR.fields_by_name[field_name].enum_type.values_by_number.get(field_value).name # type: ignore # generic code + + +def get_otp_type_str_from_code(otp_type: int) -> str: + return 'totp' if otp_type == 2 else 'hotp' + + +def convert_secret_from_bytes_to_base32_str(bytes: bytes) -> str: + return str(base64.b32encode(bytes), 'utf-8').replace('=', '') + + +def build_otp_url(secret: str, raw_otp: pb.MigrationPayload.OtpParameters) -> str: + url_params = {'secret': secret} + if raw_otp.type == 1: url_params['counter'] = str(raw_otp.counter) + if raw_otp.issuer: url_params['issuer'] = raw_otp.issuer + otp_url = f"otpauth://{get_otp_type_str_from_code(raw_otp.type)}/{urlparse.quote(raw_otp.name)}?" + urlparse.urlencode(url_params) + return otp_url + + +def print_otp(otp: Otp) -> None: + print(f"Name: {otp['name']}") + print(f"Secret: {otp['secret']}") + if otp['issuer']: print(f"Issuer: {otp['issuer']}") + print(f"Type: {otp['type']}") + if otp['type'] == 'hotp': + print(f"Counter: {otp['counter']}") + if verbose: + print(otp['url']) + + +def save_qr(otp: Otp, args: Args, j: int) -> str: + dir = args.saveqr + 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'], f"{dir}/{j}-{file_otp_name}{'-' + file_otp_issuer if file_otp_issuer else ''}.png") + return file_otp_name + + +def save_qr_file(args: Args, otp_url: OtpUrl, name: str) -> None: + qr = QRCode() + qr.add_data(otp_url) + img = qr.make_image(fill_color='black', back_color='white') + if verbose: print(f"Saving to {name}") + img.save(name) + + +def print_qr(args: Args, otp_url: str) -> None: + qr = QRCode() + qr.add_data(otp_url) + qr.print_ascii() + + +def write_csv(args: Args, otps: Otps) -> None: + if args.csv and len(otps) > 0: + with open_file_or_stdout_for_csv(args.csv) as outfile: + writer = csv.DictWriter(outfile, otps[0].keys()) + writer.writeheader() + writer.writerows(otps) + if not quiet: print(f"Exported {len(otps)} otp{'s'[:len(otps) != 1]} to csv {args.csv}") + + +def write_keepass_csv(args: Args, otps: Otps) -> None: + if args.keepass and len(otps) > 0: + has_totp = has_otp_type(otps, 'totp') + has_hotp = has_otp_type(otps, 'hotp') + if args.keepass != '-': + otp_filename_totp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "totp") + otp_filename_hotp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "hotp") + else: + otp_filename_totp = otp_filename_hotp = '-' + if has_totp: + count_totp_entries = write_keepass_totp_csv(otp_filename_totp, otps) + if has_hotp: + count_hotp_entries = write_keepass_htop_csv(otp_filename_hotp, otps) + if not quiet: + if count_totp_entries: print(f"Exported {count_totp_entries} totp entrie{'s'[:count_totp_entries != 1]} to keepass csv file {otp_filename_totp}") + if count_hotp_entries: print(f"Exported {count_hotp_entries} hotp entrie{'s'[:count_hotp_entries != 1]} to keepass csv file {otp_filename_hotp}") + + +def write_keepass_totp_csv(otp_filename: str, otps: Otps) -> int: + count_entries = 0 + with open_file_or_stdout_for_csv(otp_filename) as outfile: + writer = csv.DictWriter(outfile, ["Title", "User Name", "TimeOtp-Secret-Base32", "Group"]) + writer.writeheader() + for otp in otps: + if otp['type'] == 'totp': + writer.writerow({ + 'Title': otp['issuer'], + 'User Name': otp['name'], + 'TimeOtp-Secret-Base32': otp['secret'] if otp['type'] == 'totp' else None, + 'Group': f"OTP/{otp['type'].upper()}" + }) + count_entries += 1 + return count_entries + + +def write_keepass_htop_csv(otp_filename: str, otps: Otps) -> int: + count_entries = 0 + with open_file_or_stdout_for_csv(otp_filename) as outfile: + writer = csv.DictWriter(outfile, ["Title", "User Name", "HmacOtp-Secret-Base32", "HmacOtp-Counter", "Group"]) + writer.writeheader() + for otp in otps: + if otp['type'] == 'hotp': + writer.writerow({ + 'Title': otp['issuer'], + 'User Name': otp['name'], + 'HmacOtp-Secret-Base32': otp['secret'] if otp['type'] == 'hotp' else None, + 'HmacOtp-Counter': otp['counter'] if otp['type'] == 'hotp' else None, + 'Group': f"OTP/{otp['type'].upper()}" + }) + count_entries += 1 + return count_entries + + +def write_json(args: Args, otps: Otps) -> None: + if args.json: + with open_file_or_stdout(args.json) as outfile: + json.dump(otps, outfile, indent=4) + if not quiet: print(f"Exported {len(otps)} otp{'s'[:len(otps) != 1]} to json {args.json}") + + +def has_otp_type(otps: Otps, otp_type: str) -> bool: + for otp in otps: + if otp['type'] == otp_type: + return True + return False + + +def add_pre_suffix(file: str, pre_suffix: str) -> str: + '''filename.ext, pre -> filename.pre.ext''' + name, ext = os.path.splitext(file) + return name + "." + pre_suffix + (ext if ext else "") + + +def open_file_or_stdout(filename: str) -> TextIO: + '''stdout is denoted as "-". + Note: Set before the following line: + sys.stdout.close = lambda: None''' + return open(filename, "w", encoding='utf-8') if filename != '-' else sys.stdout + + +def open_file_or_stdout_for_csv(filename: str) -> TextIO: + '''stdout is denoted as "-". + newline='' + Note: Set before the following line: + sys.stdout.close = lambda: None''' + return open(filename, "w", encoding='utf-8', newline='') if filename != '-' else sys.stdout + + +def check_file_exists(filename: str) -> None: + if filename != '-' and not os.path.isfile(filename): + abort(f"Input file provided is non-existent or not a file." + f"\ninput file: {filename}") + + +def is_binary(line: str) -> bool: + try: + line.startswith('#') + return False + except (UnicodeDecodeError, AttributeError, TypeError): + return True + + +def next_qr_mode(qr_mode: QRMode) -> QRMode: + return QRMode((qr_mode.value + 1) % len(QRMode)) + + +def do_debug_checks() -> bool: + log_debug('Do debug checks') + log_debug('Try: import cv2') + import cv2 # noqa: F401 # This is only a debug import + log_debug('Try: import numpy as np') + import numpy as np # noqa: F401 # This is only a debug import + print(color('\nDebug checks passed', colorama.Fore.GREEN)) + return True + + +# workaround for PYTHON <= 3.9 use: BaseException | None +def log_debug(*values: object, sep: Optional[str] = ' ') -> None: + if colored: + print(f"{colorama.Fore.CYAN}\nDEBUG: {str(values[0])}", *values[1:], colorama.Fore.RESET, sep) + else: + print(f"\nDEBUG: {str(values[0])}", *values[1:], sep) + + +# workaround for PYTHON <= 3.9 use: BaseException | None +def log_verbose(msg: str) -> None: + print(color(msg, colorama.Fore.CYAN)) + + +# workaround for PYTHON <= 3.9 use: BaseException | None +def log_warn(msg: str, exception: Optional[BaseException] = None) -> None: + exception_text = "\nException: " + eprint(color(f"\nWARN: {msg}{(exception_text + str(exception)) if exception else ''}", colorama.Fore.RED)) + + +# workaround for PYTHON <= 3.9 use: BaseException | None +def log_error(msg: str, exception: Optional[BaseException] = None) -> None: + exception_text = "\nException: " + eprint(color(f"\nERROR: {msg}{(exception_text + str(exception)) if exception else ''}", colorama.Fore.RED)) + + +def color(msg: str, color: Optional[str] = None) -> str: + return f"{color if colored and color else ''}{msg}{colorama.Fore.RESET if colored and color else ''}" + + +def eprint(*values: object, **kwargs: Any) -> None: + '''Print to stderr.''' + print(*values, file=sys.stderr, **kwargs) + + +# workaround for PYTHON <= 3.9 use: BaseException | None +def abort(msg: str, exception: Optional[BaseException] = None) -> None: + log_error(msg, exception) + sys.exit(1) + + +if __name__ == '__main__': + sys_main() diff --git a/google_auth.proto b/src/google_auth.proto similarity index 100% rename from google_auth.proto rename to src/google_auth.proto diff --git a/protobuf_generated_python/google_auth_pb2.py b/src/protobuf_generated_python/google_auth_pb2.py similarity index 100% rename from protobuf_generated_python/google_auth_pb2.py rename to src/protobuf_generated_python/google_auth_pb2.py diff --git a/src/protobuf_generated_python/google_auth_pb2.pyi b/src/protobuf_generated_python/google_auth_pb2.pyi new file mode 100644 index 0000000..f4fb74c --- /dev/null +++ b/src/protobuf_generated_python/google_auth_pb2.pyi @@ -0,0 +1,108 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +""" +import builtins +import collections.abc +import google.protobuf.descriptor +import google.protobuf.internal.containers +import google.protobuf.internal.enum_type_wrapper +import google.protobuf.message +import sys +import typing + +if sys.version_info >= (3, 10): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +@typing_extensions.final +class MigrationPayload(google.protobuf.message.Message): + """Copied from: https://github.com/beemdevelopment/Aegis/blob/master/app/src/main/proto/google_auth.proto""" + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + class _Algorithm: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + + class _AlgorithmEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[MigrationPayload._Algorithm.ValueType], builtins.type): # noqa: F821 + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + ALGO_INVALID: MigrationPayload._Algorithm.ValueType # 0 + ALGO_SHA1: MigrationPayload._Algorithm.ValueType # 1 + + class Algorithm(_Algorithm, metaclass=_AlgorithmEnumTypeWrapper): ... + ALGO_INVALID: MigrationPayload.Algorithm.ValueType # 0 + ALGO_SHA1: MigrationPayload.Algorithm.ValueType # 1 + + class _OtpType: + ValueType = typing.NewType("ValueType", builtins.int) + V: typing_extensions.TypeAlias = ValueType + + class _OtpTypeEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[MigrationPayload._OtpType.ValueType], builtins.type): # noqa: F821 + DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor + OTP_INVALID: MigrationPayload._OtpType.ValueType # 0 + OTP_HOTP: MigrationPayload._OtpType.ValueType # 1 + OTP_TOTP: MigrationPayload._OtpType.ValueType # 2 + + class OtpType(_OtpType, metaclass=_OtpTypeEnumTypeWrapper): ... + OTP_INVALID: MigrationPayload.OtpType.ValueType # 0 + OTP_HOTP: MigrationPayload.OtpType.ValueType # 1 + OTP_TOTP: MigrationPayload.OtpType.ValueType # 2 + + @typing_extensions.final + class OtpParameters(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + SECRET_FIELD_NUMBER: builtins.int + NAME_FIELD_NUMBER: builtins.int + ISSUER_FIELD_NUMBER: builtins.int + ALGORITHM_FIELD_NUMBER: builtins.int + DIGITS_FIELD_NUMBER: builtins.int + TYPE_FIELD_NUMBER: builtins.int + COUNTER_FIELD_NUMBER: builtins.int + secret: builtins.bytes + name: builtins.str + issuer: builtins.str + algorithm: global___MigrationPayload.Algorithm.ValueType + digits: builtins.int + type: global___MigrationPayload.OtpType.ValueType + counter: builtins.int + def __init__( + self, + *, + secret: builtins.bytes = ..., + name: builtins.str = ..., + issuer: builtins.str = ..., + algorithm: global___MigrationPayload.Algorithm.ValueType = ..., + digits: builtins.int = ..., + type: global___MigrationPayload.OtpType.ValueType = ..., + counter: builtins.int = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["algorithm", b"algorithm", "counter", b"counter", "digits", b"digits", "issuer", b"issuer", "name", b"name", "secret", b"secret", "type", b"type"]) -> None: ... + + OTP_PARAMETERS_FIELD_NUMBER: builtins.int + VERSION_FIELD_NUMBER: builtins.int + BATCH_SIZE_FIELD_NUMBER: builtins.int + BATCH_INDEX_FIELD_NUMBER: builtins.int + BATCH_ID_FIELD_NUMBER: builtins.int + @property + def otp_parameters(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___MigrationPayload.OtpParameters]: ... + version: builtins.int + batch_size: builtins.int + batch_index: builtins.int + batch_id: builtins.int + def __init__( + self, + *, + otp_parameters: collections.abc.Iterable[global___MigrationPayload.OtpParameters] | None = ..., + version: builtins.int = ..., + batch_size: builtins.int = ..., + batch_index: builtins.int = ..., + batch_id: builtins.int = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["batch_id", b"batch_id", "batch_index", b"batch_index", "batch_size", b"batch_size", "otp_parameters", b"otp_parameters", "version", b"version"]) -> None: ... + +global___MigrationPayload = MigrationPayload diff --git a/test_extract_otp_secret_keys_pytest.py b/test_extract_otp_secret_keys_pytest.py deleted file mode 100644 index 0031971..0000000 --- a/test_extract_otp_secret_keys_pytest.py +++ /dev/null @@ -1,756 +0,0 @@ -# pytest for extract_otp_secret_keys.py - -# Run tests: -# pytest - -# Author: Scito (https://scito.ch) - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -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): - # Act - extract_otp_secret_keys.main(['example_export.txt']) - - # Assert - captured = capsys.readouterr() - - assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT - 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', io.StringIO(read_file_to_str('example_export.txt'))) - - # Act - extract_otp_secret_keys.main(['-']) - - # Assert - captured = capsys.readouterr() - - assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT - 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() - - # Act - extract_otp_secret_keys.main(['-q', '-c', 'test_example_output.csv', 'example_export.txt']) - - # Assert - expected_csv = read_csv('example_output.csv') - actual_csv = read_csv('test_example_output.csv') - - assert actual_csv == expected_csv - - captured = capsys.readouterr() - - assert captured.out == '' - assert captured.err == '' - - # Clean up - cleanup() - - -def test_extract_csv_stdout(capsys): - # Arrange - cleanup() - - # Act - extract_otp_secret_keys.main(['-c', '-', 'example_export.txt']) - - # Assert - assert not file_exits('test_example_output.csv') - - captured = capsys.readouterr() - - expected_csv = read_csv('example_output.csv') - actual_csv = read_csv_str(captured.out) - - assert actual_csv == expected_csv - assert captured.err == '' - - # Clean up - cleanup() - - -def test_extract_stdin_and_csv_stdout(capsys, monkeypatch): - # Arrange - cleanup() - monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt'))) - - # Act - extract_otp_secret_keys.main(['-c', '-', '-']) - - # Assert - assert not file_exits('test_example_output.csv') - - captured = capsys.readouterr() - - expected_csv = read_csv('example_output.csv') - actual_csv = read_csv_str(captured.out) - - assert actual_csv == expected_csv - assert captured.err == '' - - # Clean up - cleanup() - - -def test_keepass_csv(capsys): - '''Two csv files .totp and .htop are generated.''' - # Arrange - cleanup() - - # Act - extract_otp_secret_keys.main(['-q', '-k', 'test_example_keepass_output.csv', 'example_export.txt']) - - # Assert - expected_totp_csv = read_csv('example_keepass_output.totp.csv') - expected_hotp_csv = read_csv('example_keepass_output.hotp.csv') - actual_totp_csv = read_csv('test_example_keepass_output.totp.csv') - actual_hotp_csv = read_csv('test_example_keepass_output.hotp.csv') - - assert actual_totp_csv == expected_totp_csv - assert actual_hotp_csv == expected_hotp_csv - assert not file_exits('test_example_keepass_output.csv') - - captured = capsys.readouterr() - - assert captured.out == '' - assert captured.err == '' - - # Clean up - cleanup() - - -def test_keepass_csv_stdout(capsys): - '''Two csv files .totp and .htop are generated.''' - # Arrange - cleanup() - - # Act - extract_otp_secret_keys.main(['-k', '-', 'test/example_export_only_totp.txt']) - - # Assert - expected_totp_csv = read_csv('example_keepass_output.totp.csv') - expected_hotp_csv = read_csv('example_keepass_output.hotp.csv') - assert not file_exits('test_example_keepass_output.totp.csv') - assert not file_exits('test_example_keepass_output.hotp.csv') - assert not file_exits('test_example_keepass_output.csv') - - captured = capsys.readouterr() - actual_totp_csv = read_csv_str(captured.out) - - assert actual_totp_csv == expected_totp_csv - assert captured.err == '' - - # Clean up - cleanup() - - -def test_single_keepass_csv(capsys): - '''Does not add .totp or .hotp pre-suffix''' - # Arrange - cleanup() - - # Act - extract_otp_secret_keys.main(['-q', '-k', 'test_example_keepass_output.csv', 'test/example_export_only_totp.txt']) - - # Assert - expected_totp_csv = read_csv('example_keepass_output.totp.csv') - actual_totp_csv = read_csv('test_example_keepass_output.csv') - - assert actual_totp_csv == expected_totp_csv - assert not file_exits('test_example_keepass_output.totp.csv') - assert not file_exits('test_example_keepass_output.hotp.csv') - - captured = capsys.readouterr() - - assert captured.out == '' - assert captured.err == '' - - # Clean up - cleanup() - - -def test_extract_json(capsys): - # Arrange - cleanup() - - # Act - extract_otp_secret_keys.main(['-q', '-j', 'test_example_output.json', 'example_export.txt']) - - # Assert - expected_json = read_json('example_output.json') - actual_json = read_json('test_example_output.json') - - assert actual_json == expected_json - - captured = capsys.readouterr() - - assert captured.out == '' - assert captured.err == '' - - # Clean up - cleanup() - - -def test_extract_json_stdout(capsys): - # Arrange - cleanup() - - # Act - extract_otp_secret_keys.main(['-j', '-', 'example_export.txt']) - - # Assert - expected_json = read_json('example_output.json') - assert not file_exits('test_example_output.json') - captured = capsys.readouterr() - actual_json = read_json_str(captured.out) - - assert actual_json == expected_json - assert captured.err == '' - - # Clean up - cleanup() - - -def test_extract_not_encoded_plus(capsys): - # Act - extract_otp_secret_keys.main(['test/test_plus_problem_export.txt']) - - # Assert - captured = capsys.readouterr() - - expected_stdout = '''Name: SerenityLabs:test1@serenitylabs.co.uk -Secret: A4RFDYMF4GSLUIBQV4ZP67OJEZ2XUQVM -Issuer: SerenityLabs -Type: totp - -Name: SerenityLabs:test2@serenitylabs.co.uk -Secret: SCDDZ7PW5MOZLE3PQCAZM7L4S35K3UDX -Issuer: SerenityLabs -Type: totp - -Name: SerenityLabs:test3@serenitylabs.co.uk -Secret: TR76272RVYO6EAEY2FX7W7R7KUDEGPJ4 -Issuer: SerenityLabs -Type: totp - -Name: SerenityLabs:test4@serenitylabs.co.uk -Secret: N2ILWSXSJUQUB7S6NONPJSC62NPG7EXN -Issuer: SerenityLabs -Type: totp - -''' - - assert captured.out == expected_stdout - assert captured.err == '' - - -def test_extract_printqr(capsys): - # Act - extract_otp_secret_keys.main(['-p', 'example_export.txt']) - - # Assert - captured = capsys.readouterr() - - expected_stdout = read_file_to_str('test/printqr_output.txt') - - assert captured.out == expected_stdout - assert captured.err == '' - - -def test_extract_saveqr(capsys): - # Arrange - cleanup() - - # Act - extract_otp_secret_keys.main(['-q', '-s', 'testout/qr/', 'example_export.txt']) - - # Assert - captured = capsys.readouterr() - - assert captured.out == '' - assert captured.err == '' - - 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() - - -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']) - - # Assert - captured = capsys.readouterr() - - expected_stdout = read_file_to_str('test/print_verbose_output.txt') - - 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 == '' - - -def test_extract_debug(capsys): - # Act - extract_otp_secret_keys.main(['-vv', 'example_export.txt']) - - # Assert - captured = capsys.readouterr() - - expected_stdout = read_file_to_str('test/print_verbose_output.txt') - - assert len(captured.out) > len(expected_stdout) - assert "DEBUG: " in captured.out - assert captured.err == '' - - -def test_extract_help(capsys): - with pytest.raises(SystemExit) as e: - # Act - extract_otp_secret_keys.main(['-h']) - - # Assert - captured = capsys.readouterr() - - assert len(captured.out) > 0 - assert "-h, --help" in captured.out and "--verbose, -v" in captured.out - assert captured.err == '' - assert e.type == SystemExit - assert e.value.code == 0 - - -def test_extract_no_arguments(capsys): - # Act - with pytest.raises(SystemExit) as e: - extract_otp_secret_keys.main([]) - - # Assert - captured = capsys.readouterr() - - expected_err_msg = 'error: the following arguments are required: infile' - - 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 pytest.raises(SystemExit) as e: - # Act - extract_otp_secret_keys.main(['-v', '-q', 'example_export.txt']) - - # Assert - captured = capsys.readouterr() - - 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 pytest.raises(SystemExit) as e: - # Act - extract_otp_secret_keys.main(['test/test_export_wrong_data.txt']) - - # Assert - captured = capsys.readouterr() - - expected_stderr = ''' -ERROR: Cannot decode otpauth-migration migration payload. -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 pytest.raises(SystemExit) as e: - # Act - extract_otp_secret_keys.main(['test/test_export_wrong_content.txt']) - - # Assert - captured = capsys.readouterr() - - expected_stderr = ''' -WARN: line is not a otpauth-migration:// URL -input file: test/test_export_wrong_content.txt -line "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." -Probably a wrong file was given - -ERROR: no data query parameter in input URL -input file: test/test_export_wrong_content.txt -line "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." -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): - # Act - extract_otp_secret_keys.main(['test/test_export_wrong_prefix.txt']) - - # Assert - captured = capsys.readouterr() - - expected_stderr = ''' -WARN: line is not a otpauth-migration:// URL -input file: test/test_export_wrong_prefix.txt -line "QR-Code:otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B" -Probably a wrong file was given -''' - - expected_stdout = '''Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: totp - -''' - - assert captured.out == expected_stdout - assert captured.err == expected_stderr - - -def test_add_pre_suffix(capsys): - assert extract_otp_secret_keys.add_pre_suffix("name.csv", "totp") == "name.totp.csv" - assert extract_otp_secret_keys.add_pre_suffix("name.csv", "") == "name..csv" - 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') - remove_dir_with_files('testout/') - - -EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT = '''Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: totp - -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: totp - -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: totp - -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: totp - -Name: hotp demo -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: hotp -Counter: 4 - -Name: encoding: ¿äÄéÉ? (demo) -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_qrcode_unittest.py b/test_extract_qrcode_unittest.py deleted file mode 100644 index 7f9c718..0000000 --- a/test_extract_qrcode_unittest.py +++ /dev/null @@ -1,92 +0,0 @@ -# Unit test for extract_otp_secret_keys.py - -# Run tests: -# python -m unittest - -# Author: sssudame - -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see <https://www.gnu.org/licenses/>. - -import unittest -from utils import Capturing - -import extract_otp_secret_keys - -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']) - - expected_output =\ - ['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', ''] - - self.assertEqual(actual_output, expected_output) - - def test_img_qr_reader_no_qr_code_in_image(self): - with Capturing() as actual_output: - 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_img_qr_reader_nonexistent_file(self): - with Capturing() as actual_output: - 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_img_qr_reader_non_image_file(self): - with Capturing() as actual_output: - 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() - - def tearDown(self): - self.cleanup() - - def cleanup(self): - pass - - -if __name__ == '__main__': - unittest.main() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..10c03c1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,22 @@ +from typing import Any + +import pytest + +from extract_otp_secrets import QRMode + + +def pytest_addoption(parser: pytest.Parser) -> None: + parser.addoption("--relaxed", action='store_true', help="run tests in relaxed mode") + parser.addoption("--fast", action="store_true", help="faster execution, do not run all combinations") + + +@pytest.fixture +def relaxed(request: pytest.FixtureRequest) -> Any: + return request.config.getoption("--relaxed") + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + if "qr_mode" in metafunc.fixturenames: + number = 2 if metafunc.config.getoption("fast") else len(QRMode) + qr_modes = [mode.name for mode in QRMode] + metafunc.parametrize("qr_mode", qr_modes[0:number]) diff --git a/test/empty_file.txt b/tests/data/empty_file.txt similarity index 100% rename from test/empty_file.txt rename to tests/data/empty_file.txt diff --git a/test/example_export_only_totp.txt b/tests/data/example_export_only_totp.txt similarity index 100% rename from test/example_export_only_totp.txt rename to tests/data/example_export_only_totp.txt diff --git a/test/lena_std.tif b/tests/data/lena_std.tif similarity index 100% rename from test/lena_std.tif rename to tests/data/lena_std.tif diff --git a/tests/data/print_verbose_output-n-v.txt b/tests/data/print_verbose_output-n-v.txt new file mode 100644 index 0000000..e32c422 --- /dev/null +++ b/tests/data/print_verbose_output-n-v.txt @@ -0,0 +1,51 @@ +QReader installed: True +CV2 version: 4.7.0 +QR reading mode: ZBAR + +Input files: ['example_export.txt'] + +1. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + + +2. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY + + +3. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY + + +4. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + + +5. Secret +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 +otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 + + +6. Secret +Name: encoding: ¿äÄéÉ? (demo) +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 + +Extracted 6 otps from 5 otp urls by reading 1 infile diff --git a/tests/data/print_verbose_output-n-vv.txt b/tests/data/print_verbose_output-n-vv.txt new file mode 100644 index 0000000..544f975 --- /dev/null +++ b/tests/data/print_verbose_output-n-vv.txt @@ -0,0 +1,71 @@ +QReader installed: True +CV2 version: 4.7.0 +QR reading mode: ZBAR + +Input files: ['example_export.txt'] +Processing infile 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 + +1. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + + +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4%2F%2F%2F%2F%2FwE%3D + +2. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY + + +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B + +3. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY + + +4. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + + +# otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 +otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D + +5. Secret +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 +otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 + + +# 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 + +6. Secret +Name: encoding: ¿äÄéÉ? (demo) +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 + +Extracted 6 otps from 5 otp urls by reading 1 infile diff --git a/test/print_verbose_output.txt b/tests/data/print_verbose_output-n-vvv.txt similarity index 57% rename from test/print_verbose_output.txt rename to tests/data/print_verbose_output-n-vvv.txt index cf01dae..4013a77 100644 --- a/test/print_verbose_output.txt +++ b/tests/data/print_verbose_output-n-vvv.txt @@ -1,7 +1,12 @@ +QReader installed: True +CV2 version: 4.7.0 +QR reading mode: ZBAR + 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 @@ -20,12 +25,21 @@ otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAE # 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 otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B -1. Payload Line -otp_parameters { +DEBUG: parsed_url=ParseResult(scheme='otpauth-migration', netloc='offline', path='', params='', query='data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B', fragment='') + +DEBUG: querystring params={'data': ['CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK+/////8B']} + +DEBUG: data_base64=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK+/////8B + +DEBUG: data_base64_fixed=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK+/////8B + +DEBUG: +1. Payload Line otp_parameters { secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" name: "pi@raspberrypi" issuer: "raspberrypi" @@ -36,9 +50,12 @@ otp_parameters { version: 1 batch_size: 1 batch_id: -1320898453 + + +1. Secret -1. Secret Key +DEBUG: OTP enum type: OTP_TOTP Name: pi@raspberrypi Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY Issuer: raspberrypi @@ -49,8 +66,16 @@ otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspber # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4%2F%2F%2F%2F%2FwE%3D -2. Payload Line -otp_parameters { +DEBUG: parsed_url=ParseResult(scheme='otpauth-migration', netloc='offline', path='', params='', query='data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4%2F%2F%2F%2F%2FwE%3D', fragment='') + +DEBUG: querystring params={'data': ['CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4/////wE=']} + +DEBUG: data_base64=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4/////wE= + +DEBUG: data_base64_fixed=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4/////wE= + +DEBUG: +2. Payload Line otp_parameters { secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" name: "pi@raspberrypi" algorithm: ALGO_SHA1 @@ -60,9 +85,12 @@ otp_parameters { version: 1 batch_size: 1 batch_id: -2094403140 + + +2. Secret -2. Secret Key +DEBUG: OTP enum type: OTP_TOTP Name: pi@raspberrypi Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY Type: totp @@ -73,8 +101,16 @@ otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B -3. Payload Line -otp_parameters { +DEBUG: parsed_url=ParseResult(scheme='otpauth-migration', netloc='offline', path='', params='', query='data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B', fragment='') + +DEBUG: querystring params={'data': ['CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa+f////8B']} + +DEBUG: data_base64=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa+f////8B + +DEBUG: data_base64_fixed=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa+f////8B + +DEBUG: +3. Payload Line otp_parameters { secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" name: "pi@raspberrypi" algorithm: ALGO_SHA1 @@ -92,16 +128,21 @@ otp_parameters { version: 1 batch_size: 1 batch_id: -1822886384 + -3. Secret Key +3. Secret + +DEBUG: OTP enum type: OTP_TOTP Name: pi@raspberrypi Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY Type: totp otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY -4. Secret Key +4. Secret + +DEBUG: OTP enum type: OTP_TOTP Name: pi@raspberrypi Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY Issuer: raspberrypi @@ -112,8 +153,16 @@ otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspber # otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D -4. Payload Line -otp_parameters { +DEBUG: parsed_url=ParseResult(scheme='otpauth-migration', netloc='offline', path='', params='', query='data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D', fragment='') + +DEBUG: querystring params={'data': ['CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6/////wE=']} + +DEBUG: data_base64=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6/////wE= + +DEBUG: data_base64_fixed=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6/////wE= + +DEBUG: +4. Payload Line otp_parameters { secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" name: "hotp demo" algorithm: ALGO_SHA1 @@ -124,9 +173,12 @@ otp_parameters { version: 1 batch_size: 1 batch_id: -1558849573 + + +5. Secret -5. Secret Key +DEBUG: OTP enum type: OTP_HOTP Name: hotp demo Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY Type: hotp @@ -138,8 +190,16 @@ otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 # Name: "encoding: ¿äÄéÉ? (demo)" otpauth-migration://offline?data=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv%2F%2F%2F%2F%2F%2FAQ%3D%3D -5. Payload Line -otp_parameters { +DEBUG: parsed_url=ParseResult(scheme='otpauth-migration', netloc='offline', path='', params='', query='data=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv%2F%2F%2F%2F%2F%2FAQ%3D%3D', fragment='') + +DEBUG: querystring params={'data': ['CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv//////AQ==']} + +DEBUG: data_base64=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv//////AQ== + +DEBUG: data_base64_fixed=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv//////AQ== + +DEBUG: +5. Payload Line otp_parameters { secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" name: "encoding: ¿äÄéÉ? (demo)" algorithm: ALGO_SHA1 @@ -149,12 +209,15 @@ otp_parameters { version: 1 batch_size: 1 batch_id: -171198419 + + +6. Secret -6. Secret Key +DEBUG: OTP enum type: OTP_TOTP Name: encoding: ¿äÄéÉ? (demo) 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 +Extracted 6 otps from 5 otp urls by reading 1 infile diff --git a/tests/data/print_verbose_output-n.txt b/tests/data/print_verbose_output-n.txt new file mode 100644 index 0000000..7e1bceb --- /dev/null +++ b/tests/data/print_verbose_output-n.txt @@ -0,0 +1,27 @@ +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 + +Name: encoding: ¿äÄéÉ? (demo) +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + diff --git a/tests/data/print_verbose_output-v.txt b/tests/data/print_verbose_output-v.txt new file mode 100644 index 0000000..e32c422 --- /dev/null +++ b/tests/data/print_verbose_output-v.txt @@ -0,0 +1,51 @@ +QReader installed: True +CV2 version: 4.7.0 +QR reading mode: ZBAR + +Input files: ['example_export.txt'] + +1. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + + +2. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY + + +3. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY + + +4. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + + +5. Secret +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 +otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 + + +6. Secret +Name: encoding: ¿äÄéÉ? (demo) +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 + +Extracted 6 otps from 5 otp urls by reading 1 infile diff --git a/tests/data/print_verbose_output-vv.txt b/tests/data/print_verbose_output-vv.txt new file mode 100644 index 0000000..3819f4a --- /dev/null +++ b/tests/data/print_verbose_output-vv.txt @@ -0,0 +1,71 @@ +QReader installed: True +CV2 version: 4.7.0 +QR reading mode: ZBAR + +Input files: ['example_export.txt'] +Processing infile 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 + +1. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + + +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4%2F%2F%2F%2F%2FwE%3D + +2. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY + + +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B + +3. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY + + +4. Secret +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + + +# otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 +otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D + +5. Secret +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 +otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 + + +# 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 + +6. Secret +Name: encoding: ¿äÄéÉ? (demo) +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 + +Extracted 6 otps from 5 otp urls by reading 1 infile diff --git a/tests/data/print_verbose_output-vvv.txt b/tests/data/print_verbose_output-vvv.txt new file mode 100644 index 0000000..e81eb24 --- /dev/null +++ b/tests/data/print_verbose_output-vvv.txt @@ -0,0 +1,223 @@ +QReader installed: True +CV2 version: 4.7.0 +QR reading mode: ZBAR + +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 +otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B + +DEBUG: parsed_url=ParseResult(scheme='otpauth-migration', netloc='offline', path='', params='', query='data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B', fragment='')  + +DEBUG: querystring params={'data': ['CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK+/////8B']}  + +DEBUG: data_base64=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK+/////8B  + +DEBUG: data_base64_fixed=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK+/////8B  + +DEBUG: +1. Payload Line otp_parameters { + secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" + name: "pi@raspberrypi" + issuer: "raspberrypi" + algorithm: ALGO_SHA1 + digits: 1 + type: OTP_TOTP +} +version: 1 +batch_size: 1 +batch_id: -1320898453 +  + + +1. Secret + +DEBUG: OTP enum type: OTP_TOTP  +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + + +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4%2F%2F%2F%2F%2FwE%3D + +DEBUG: parsed_url=ParseResult(scheme='otpauth-migration', netloc='offline', path='', params='', query='data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4%2F%2F%2F%2F%2FwE%3D', fragment='')  + +DEBUG: querystring params={'data': ['CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4/////wE=']}  + +DEBUG: data_base64=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4/////wE=  + +DEBUG: data_base64_fixed=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4/////wE=  + +DEBUG: +2. Payload Line otp_parameters { + secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" + name: "pi@raspberrypi" + algorithm: ALGO_SHA1 + digits: 1 + type: OTP_TOTP +} +version: 1 +batch_size: 1 +batch_id: -2094403140 +  + + +2. Secret + +DEBUG: OTP enum type: OTP_TOTP  +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY + + +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B + +DEBUG: parsed_url=ParseResult(scheme='otpauth-migration', netloc='offline', path='', params='', query='data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B', fragment='')  + +DEBUG: querystring params={'data': ['CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa+f////8B']}  + +DEBUG: data_base64=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa+f////8B  + +DEBUG: data_base64_fixed=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa+f////8B  + +DEBUG: +3. Payload Line otp_parameters { + secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" + name: "pi@raspberrypi" + algorithm: ALGO_SHA1 + digits: 1 + type: OTP_TOTP +} +otp_parameters { + secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" + name: "pi@raspberrypi" + issuer: "raspberrypi" + algorithm: ALGO_SHA1 + digits: 1 + type: OTP_TOTP +} +version: 1 +batch_size: 1 +batch_id: -1822886384 +  + + +3. Secret + +DEBUG: OTP enum type: OTP_TOTP  +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY + + +4. Secret + +DEBUG: OTP enum type: OTP_TOTP  +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp +otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + + +# otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 +otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D + +DEBUG: parsed_url=ParseResult(scheme='otpauth-migration', netloc='offline', path='', params='', query='data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D', fragment='')  + +DEBUG: querystring params={'data': ['CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6/////wE=']}  + +DEBUG: data_base64=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6/////wE=  + +DEBUG: data_base64_fixed=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6/////wE=  + +DEBUG: +4. Payload Line otp_parameters { + secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" + name: "hotp demo" + algorithm: ALGO_SHA1 + digits: 1 + type: OTP_HOTP + counter: 4 +} +version: 1 +batch_size: 1 +batch_id: -1558849573 +  + + +5. Secret + +DEBUG: OTP enum type: OTP_HOTP  +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 +otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 + + +# 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 + +DEBUG: parsed_url=ParseResult(scheme='otpauth-migration', netloc='offline', path='', params='', query='data=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv%2F%2F%2F%2F%2F%2FAQ%3D%3D', fragment='')  + +DEBUG: querystring params={'data': ['CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv//////AQ==']}  + +DEBUG: data_base64=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv//////AQ==  + +DEBUG: data_base64_fixed=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv//////AQ==  + +DEBUG: +5. Payload Line otp_parameters { + secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" + name: "encoding: ¿äÄéÉ? (demo)" + algorithm: ALGO_SHA1 + digits: 1 + type: OTP_TOTP +} +version: 1 +batch_size: 1 +batch_id: -171198419 +  + + +6. Secret + +DEBUG: OTP enum type: OTP_TOTP  +Name: encoding: ¿äÄéÉ? (demo) +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 + +Extracted 6 otps from 5 otp urls by reading 1 infile diff --git a/tests/data/print_verbose_output.txt b/tests/data/print_verbose_output.txt new file mode 100644 index 0000000..7e1bceb --- /dev/null +++ b/tests/data/print_verbose_output.txt @@ -0,0 +1,27 @@ +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 + +Name: encoding: ¿äÄéÉ? (demo) +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + diff --git a/test/printqr_output.txt b/tests/data/printqr_output.txt similarity index 100% rename from test/printqr_output.txt rename to tests/data/printqr_output.txt diff --git a/test/test_export_wrong_content.txt b/tests/data/test_export_wrong_content.txt similarity index 100% rename from test/test_export_wrong_content.txt rename to tests/data/test_export_wrong_content.txt diff --git a/test/test_export_wrong_data.txt b/tests/data/test_export_wrong_data.txt similarity index 100% rename from test/test_export_wrong_data.txt rename to tests/data/test_export_wrong_data.txt diff --git a/test/test_export_wrong_prefix.txt b/tests/data/test_export_wrong_prefix.txt similarity index 100% rename from test/test_export_wrong_prefix.txt rename to tests/data/test_export_wrong_prefix.txt diff --git a/test/test_googleauth_export.png b/tests/data/test_googleauth_export.png similarity index 100% rename from test/test_googleauth_export.png rename to tests/data/test_googleauth_export.png diff --git a/test/test_plus_problem_export.txt b/tests/data/test_plus_problem_export.txt similarity index 100% rename from test/test_plus_problem_export.txt rename to tests/data/test_plus_problem_export.txt diff --git a/test/text_masquerading_as_image.jpeg b/tests/data/text_masquerading_as_image.jpeg similarity index 100% rename from test/text_masquerading_as_image.jpeg rename to tests/data/text_masquerading_as_image.jpeg diff --git a/tests/extract_otp_secrets_img_unit_test.py b/tests/extract_otp_secrets_img_unit_test.py new file mode 100644 index 0000000..51572c2 --- /dev/null +++ b/tests/extract_otp_secrets_img_unit_test.py @@ -0,0 +1,89 @@ +# Unit test for extract_otp_secrets.py + +# Run tests: +# python -m unittest + +# Author: sssudame + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +from __future__ import annotations # workaround for PYTHON <= 3.10 +import unittest + +import extract_otp_secrets +from utils import Capturing + + +class TestQRImageExtract(unittest.TestCase): + def test_img_qr_reader_happy_path(self) -> None: + with Capturing() as actual_output: + extract_otp_secrets.main(['tests/data/test_googleauth_export.png']) + + expected_output =\ + ['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', ''] + + self.assertEqual(actual_output, expected_output) + + def test_img_qr_reader_no_qr_code_in_image(self) -> None: + with Capturing() as actual_output: + with self.assertRaises(SystemExit) as context: + extract_otp_secrets.main(['-n', 'tests/data/lena_std.tif']) + + expected_output = ['', 'ERROR: Unable to read QR Code from file.', 'input file: tests/data/lena_std.tif'] + + self.assertEqual(actual_output, expected_output) + self.assertEqual(context.exception.code, 1) + + def test_img_qr_reader_nonexistent_file(self) -> None: + with Capturing() as actual_output: + with self.assertRaises(SystemExit) as context: + extract_otp_secrets.main(['-n', 'nonexistent.bmp']) + + expected_output = ['', 'ERROR: Input file provided is non-existent or not a file.', 'input file: nonexistent.bmp'] + + self.assertEqual(actual_output, expected_output) + self.assertEqual(context.exception.code, 1) + + def test_img_qr_reader_non_image_file(self) -> None: + with Capturing() as actual_output: + extract_otp_secrets.main(['-n', 'tests/data/text_masquerading_as_image.jpeg']) + + expected_output = [ + '', + 'WARN: input is not a otpauth-migration:// url', + 'source: tests/data/text_masquerading_as_image.jpeg', + "input: This is just a text file masquerading as an image file.", + 'Maybe a wrong file was given', + '', + 'ERROR: could not parse query parameter in input url', + 'source: tests/data/text_masquerading_as_image.jpeg', + "url: This is just a text file masquerading as an image file.", + ] + + self.assertEqual(actual_output, expected_output) + + def setUp(self) -> None: + self.cleanup() + + def tearDown(self) -> None: + self.cleanup() + + def cleanup(self) -> None: + pass + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/extract_otp_secrets_test.py b/tests/extract_otp_secrets_test.py new file mode 100644 index 0000000..75df41e --- /dev/null +++ b/tests/extract_otp_secrets_test.py @@ -0,0 +1,914 @@ +# pytest for extract_otp_secrets.py + +# Run tests: +# pytest + +# Author: Scito (https://scito.ch) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +from __future__ import annotations # workaround for PYTHON <= 3.10 + +import io +import os +import pathlib +import re +import sys +import time +from typing import Optional + +import colorama +import pytest +from pytest_mock import MockerFixture +from utils import (count_files_in_dir, file_exits, read_binary_file_as_stream, + read_csv, read_csv_str, read_file_to_str, read_json, + read_json_str, replace_escaped_octal_utf8_bytes_with_str) + +import extract_otp_secrets + +qreader_available: bool = extract_otp_secrets.qreader_available + + +# Quickfix comment +# @pytest.mark.skipif(sys.platform.startswith("win") or not qreader_available or sys.implementation.name == 'pypy' or sys.version_info >= (3, 10), reason="Quickfix") + + +def test_extract_stdout(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['example_export.txt']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + assert captured.err == '' + + +def test_extract_non_existent_file(capsys: pytest.CaptureFixture[str]) -> None: + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secrets.main(['-n', 'non_existent_file.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = '\nERROR: Input file provided is non-existent or not a file.\ninput file: 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: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt'))) + + # Act + extract_otp_secrets.main(['-']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + assert captured.err == '' + + +def test_extract_stdin_empty(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setattr('sys.stdin', io.StringIO()) + + # Act + extract_otp_secrets.main(['-n', '-']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == '\nWARN: stdin is empty\n' + + +def test_extract_empty_file_no_qreader(capsys: pytest.CaptureFixture[str]) -> None: + if qreader_available: + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secrets.main(['-n', 'tests/data/empty_file.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = '\nWARN: tests/data/empty_file.txt is empty\n\nERROR: Unable to open file for reading.\ninput file: tests/data/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_secrets.main(['tests/data/empty_file.txt']) + + # Assert + captured = capsys.readouterr() + + assert captured.err == '' + assert captured.out == '' + + +@pytest.mark.qreader +def test_extract_stdin_img_empty(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setattr('sys.stdin', io.BytesIO()) + + # Act + extract_otp_secrets.main(['-n', '=']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == '\nWARN: stdin is empty\n' + + +def test_extract_csv(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None: + # Arrange + output_file = str(tmp_path / 'test_example_output.csv') + + # Act + extract_otp_secrets.main(['-q', '-c', output_file, 'example_export.txt']) + + # Assert + expected_csv = read_csv('example_output.csv') + actual_csv = read_csv(output_file) + + assert actual_csv == expected_csv + + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == '' + + +def test_extract_csv_stdout(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['-c', '-', 'example_export.txt']) + + # Assert + assert not file_exits('test_example_output.csv') + + captured = capsys.readouterr() + + expected_csv = read_csv('example_output.csv') + actual_csv = read_csv_str(captured.out) + + assert actual_csv == expected_csv + assert captured.err == '' + + +def test_extract_stdin_and_csv_stdout(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt'))) + + # Act + extract_otp_secrets.main(['-c', '-', '-']) + + # Assert + assert not file_exits('test_example_output.csv') + + captured = capsys.readouterr() + + expected_csv = read_csv('example_output.csv') + actual_csv = read_csv_str(captured.out) + + assert actual_csv == expected_csv + assert captured.err == '' + + +def test_keepass_csv(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None: + '''Two csv files .totp and .htop are generated.''' + # Arrange + file_name = str(tmp_path / 'test_example_keepass_output.csv') + + # Act + extract_otp_secrets.main(['-q', '-k', file_name, 'example_export.txt']) + + # Assert + expected_totp_csv = read_csv('example_keepass_output.totp.csv') + expected_hotp_csv = read_csv('example_keepass_output.hotp.csv') + actual_totp_csv = read_csv(str(tmp_path / 'test_example_keepass_output.totp.csv')) + actual_hotp_csv = read_csv(str(tmp_path / 'test_example_keepass_output.hotp.csv')) + + assert actual_totp_csv == expected_totp_csv + assert actual_hotp_csv == expected_hotp_csv + assert not file_exits(file_name) + + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == '' + + +def test_keepass_csv_stdout(capsys: pytest.CaptureFixture[str]) -> None: + '''Two csv files .totp and .htop are generated.''' + # Act + extract_otp_secrets.main(['-k', '-', 'tests/data/example_export_only_totp.txt']) + + # Assert + expected_totp_csv = read_csv('example_keepass_output.totp.csv') + assert not file_exits('test_example_keepass_output.totp.csv') + assert not file_exits('test_example_keepass_output.hotp.csv') + assert not file_exits('test_example_keepass_output.csv') + + captured = capsys.readouterr() + actual_totp_csv = read_csv_str(captured.out) + + assert actual_totp_csv == expected_totp_csv + assert captured.err == '' + + +def test_single_keepass_csv(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None: + '''Does not add .totp or .hotp pre-suffix''' + # Act + extract_otp_secrets.main(['-q', '-k', str(tmp_path / 'test_example_keepass_output.csv'), 'tests/data/example_export_only_totp.txt']) + + # Assert + expected_totp_csv = read_csv('example_keepass_output.totp.csv') + actual_totp_csv = read_csv(str(tmp_path / 'test_example_keepass_output.csv')) + + assert actual_totp_csv == expected_totp_csv + assert not file_exits(tmp_path / 'test_example_keepass_output.totp.csv') + assert not file_exits(tmp_path / 'test_example_keepass_output.hotp.csv') + + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == '' + + +def test_extract_json(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None: + # Arrange + output_file = str(tmp_path / 'test_example_output.json') + + # Act + extract_otp_secrets.main(['-q', '-j', output_file, 'example_export.txt']) + + # Assert + expected_json = read_json('example_output.json') + actual_json = read_json(output_file) + + assert actual_json == expected_json + + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == '' + + +def test_extract_json_stdout(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['-j', '-', 'example_export.txt']) + + # Assert + expected_json = read_json('example_output.json') + assert not file_exits('test_example_output.json') + captured = capsys.readouterr() + actual_json = read_json_str(captured.out) + + assert actual_json == expected_json + assert captured.err == '' + + +def test_extract_not_encoded_plus(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['tests/data/test_plus_problem_export.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stdout = '''Name: SerenityLabs:test1@serenitylabs.co.uk +Secret: A4RFDYMF4GSLUIBQV4ZP67OJEZ2XUQVM +Issuer: SerenityLabs +Type: totp + +Name: SerenityLabs:test2@serenitylabs.co.uk +Secret: SCDDZ7PW5MOZLE3PQCAZM7L4S35K3UDX +Issuer: SerenityLabs +Type: totp + +Name: SerenityLabs:test3@serenitylabs.co.uk +Secret: TR76272RVYO6EAEY2FX7W7R7KUDEGPJ4 +Issuer: SerenityLabs +Type: totp + +Name: SerenityLabs:test4@serenitylabs.co.uk +Secret: N2ILWSXSJUQUB7S6NONPJSC62NPG7EXN +Issuer: SerenityLabs +Type: totp + +''' + + assert captured.out == expected_stdout + assert captured.err == '' + + +def test_extract_printqr(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['-p', 'example_export.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stdout = read_file_to_str('tests/data/printqr_output.txt') + + assert captured.out == expected_stdout + assert captured.err == '' + + +def test_extract_saveqr(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None: + # Act + extract_otp_secrets.main(['-q', '-s', str(tmp_path), 'example_export.txt']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == '' + + assert os.path.isfile(tmp_path / '1-piraspberrypi-raspberrypi.png') + assert os.path.isfile(tmp_path / '2-piraspberrypi.png') + assert os.path.isfile(tmp_path / '3-piraspberrypi.png') + assert os.path.isfile(tmp_path / '4-piraspberrypi-raspberrypi.png') + assert os.path.isfile(tmp_path / '5-hotpdemo.png') + assert os.path.isfile(tmp_path / '6-encodingäÄéÉdemo.png') + assert count_files_in_dir(tmp_path) == 6 + + +def test_extract_ignored_duplicates(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['-i', 'example_export.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stdout = '''Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 + +Name: encoding: ¿äÄéÉ? (demo) +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + +''' + + expected_stderr = '''Ignored duplicate otp: pi@raspberrypi + +Ignored duplicate otp: pi@raspberrypi / raspberrypi + +''' + + assert captured.out == expected_stdout + assert captured.err == expected_stderr + + +def test_normalize_bytes() -> None: + 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' + + +# Generate verbose output: +# for color in '' '-n'; do for level in '' '-v' '-vv' '-vvv'; do python3.11 src/extract_otp_secrets.py example_export.txt $color $level > tests/data/print_verbose_output$color$level.txt; done; done +# workaround for PYTHON <= 3.10 +@pytest.mark.skipif(sys.version_info < (3, 10), reason="fileinput.input encoding exists since PYTHON 3.10") +@pytest.mark.parametrize("verbose_level", ['', '-v', '-vv', '-vvv']) +@pytest.mark.parametrize("color", ['', '-n']) +def test_extract_verbose(verbose_level: str, color: str, capsys: pytest.CaptureFixture[str], relaxed: bool) -> None: + args = ['example_export.txt'] + if verbose_level: + args.append(verbose_level) + if color: + args.append(color) + # Act + extract_otp_secrets.main(args) + + # Assert + captured = capsys.readouterr() + + expected_stdout = normalize_verbose_text(read_file_to_str(f'tests/data/print_verbose_output{color}{verbose_level}.txt'), relaxed or sys.implementation.name == 'pypy') + actual_stdout = normalize_verbose_text(captured.out, relaxed or sys.implementation.name == 'pypy') + + assert actual_stdout == expected_stdout + assert captured.err == '' + + +def normalize_verbose_text(text: str, relaxed: bool) -> str: + normalized = re.sub('^.+ version: .+$', '', text, flags=re.MULTILINE | re.IGNORECASE) + if not qreader_available: + normalized = normalized \ + .replace('QReader installed: True', 'QReader installed: False') \ + .replace('\nQR reading mode: ZBAR\n\n', '') + if relaxed: + print('\nRelaxed mode\n') + normalized = replace_escaped_octal_utf8_bytes_with_str(normalized) + return normalized + + +def test_extract_debug(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['-vvv', 'example_export.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stdout = read_file_to_str('tests/data/print_verbose_output.txt') + + assert len(captured.out) > len(expected_stdout) + assert "DEBUG: " in captured.out + assert captured.err == '' + + +def test_extract_help(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as e: + # Act + extract_otp_secrets.main(['-h']) + + # Assert + captured = capsys.readouterr() + + assert len(captured.out) > 0 + assert "-h, --help" in captured.out and "-v, --verbose" in captured.out + assert captured.err == '' + assert e.type == SystemExit + assert e.value.code == 0 + + +def test_extract_no_arguments(capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None: + if qreader_available: + # Arrange + otps = read_json('example_output.json') + mocker.patch('extract_otp_secrets.extract_otps_from_camera', return_value=otps) + + # Act + extract_otp_secrets.main(['-c', '-']) + + # Assert + captured = capsys.readouterr() + + expected_csv = read_csv('example_output.csv') + actual_csv = read_csv_str(captured.out) + + assert actual_csv == expected_csv + assert captured.err == '' + else: + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secrets.main([]) + + # Assert + captured = capsys.readouterr() + + expected_err_msg = 'error: the following arguments are required: infile' + + 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: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as e: + # Act + extract_otp_secrets.main(['-n', '-v', '-q', 'example_export.txt']) + + # Assert + captured = capsys.readouterr() + + assert len(captured.err) > 0 + assert 'error: argument -q/--quiet: not allowed with argument -v/--verbose' in captured.err + assert captured.out == '' + assert e.value.code == 2 + assert e.type == SystemExit + + +@pytest.mark.parametrize("parameter,parameter_value,stdout_expected,stderr_expected", [ + ('-c', 'outfile', False, False), + ('-c', '-', True, False), + ('-k', 'outfile', False, False), + ('-k', '-', True, False), + ('-j', 'outfile', False, False), + ('-s', 'outfile', False, False), + ('-j', '-', True, False), + ('-i', None, False, False), + ('-p', None, True, False), + ('-Q', 'CV2', False, False), + ('-C', '0', False, False), + ('-n', None, False, False), +]) +def test_quiet(parameter: str, parameter_value: Optional[str], stdout_expected: bool, stderr_expected: bool, capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None: + if parameter in ['-Q', '-C'] and not qreader_available: + return + + # Arrange + args = ['-q', 'example_export.txt', 'example_export.png', parameter] + if parameter_value == 'outfile': + args.append(str(tmp_path / parameter_value)) + elif parameter_value: + args.append(parameter_value) + + # Act + extract_otp_secrets.main(args) + + # Assert + captured = capsys.readouterr() + + assert (captured.out == '' and not stdout_expected) or (len(captured.out) > 0 and stdout_expected) + assert (captured.err == '' and not stderr_expected) or (len(captured.err) > 0 and stderr_expected) + + +def test_wrong_data(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as e: + # Act + extract_otp_secrets.main(['-n', 'tests/data/test_export_wrong_data.txt']) + + # Assert + captured = capsys.readouterr() + + first_expected_stderr = ''' +ERROR: Cannot decode otpauth-migration migration payload. +data=XXXX +Exception: Error parsing message +''' + + # Alpine Linux prints this exception message + second_expected_stderr = ''' +ERROR: Cannot decode otpauth-migration migration payload. +data=XXXX +Exception: unpack requires a buffer of 4 bytes +''' + + assert captured.err == first_expected_stderr or captured.err == second_expected_stderr + assert captured.out == '' + assert e.value.code == 1 + assert e.type == SystemExit + + +def test_wrong_content(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['-n', 'tests/data/test_export_wrong_content.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = ''' +WARN: input is not a otpauth-migration:// url +source: tests/data/test_export_wrong_content.txt +input: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. +Maybe a wrong file was given + +ERROR: could not parse query parameter in input url +source: tests/data/test_export_wrong_content.txt +url: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. +''' + + assert captured.out == '' + assert captured.err == expected_stderr + + +def test_one_wrong_file(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['-n', 'tests/data/test_export_wrong_content.txt', 'example_export.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = ''' +WARN: input is not a otpauth-migration:// url +source: tests/data/test_export_wrong_content.txt +input: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. +Maybe a wrong file was given + +ERROR: could not parse query parameter in input url +source: tests/data/test_export_wrong_content.txt +url: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. +''' + + assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + assert captured.err == expected_stderr + + +def test_one_wrong_file_colored(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['tests/data/test_export_wrong_content.txt', 'example_export.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = f'''{colorama.Fore.RED} +WARN: input is not a otpauth-migration:// url +source: tests/data/test_export_wrong_content.txt +input: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. +Maybe a wrong file was given{colorama.Fore.RESET} +{colorama.Fore.RED} +ERROR: could not parse query parameter in input url +source: tests/data/test_export_wrong_content.txt +url: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.{colorama.Fore.RESET} +''' + + assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + assert captured.err == expected_stderr + + +def test_one_wrong_line(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setattr('sys.stdin', + io.StringIO(read_file_to_str('tests/data/test_export_wrong_content.txt') + read_file_to_str('example_export.txt'))) + + # Act + extract_otp_secrets.main(['-n', '-']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = ''' +WARN: input is not a otpauth-migration:// url +source: - +input: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. +Maybe a wrong file was given + +ERROR: could not parse query parameter in input url +source: - +url: Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. +''' + + assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + assert captured.err == expected_stderr + + +def test_wrong_prefix(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['-n', 'tests/data/test_export_wrong_prefix.txt']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = ''' +WARN: input is not a otpauth-migration:// url +source: tests/data/test_export_wrong_prefix.txt +input: QR-Code:otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B +Maybe a wrong file was given +''' + + expected_stdout = '''Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +''' + + assert captured.out == expected_stdout + assert captured.err == expected_stderr + + +def test_add_pre_suffix(capsys: pytest.CaptureFixture[str]) -> None: + assert extract_otp_secrets.add_pre_suffix("name.csv", "totp") == "name.totp.csv" + assert extract_otp_secrets.add_pre_suffix("name.csv", "") == "name..csv" + assert extract_otp_secrets.add_pre_suffix("name", "totp") == "name.totp" + + +@pytest.mark.qreader +def test_img_qr_reader_from_file_happy_path(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['tests/data/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_by_parameter(capsys: pytest.CaptureFixture[str], qr_mode: str) -> None: + # Act + start_s = time.process_time() + extract_otp_secrets.main(['--qr', qr_mode, 'tests/data/test_googleauth_export.png']) + elapsed_s = time.process_time() - start_s + + # Assert + captured = capsys.readouterr() + + assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG + assert captured.err == '' + + print(f"Elapsed time for {qr_mode}: {elapsed_s:.2f}s") + + +@pytest.mark.qreader +def test_extract_multiple_files_and_mixed(capsys: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main([ + 'example_export.txt', + 'tests/data/test_googleauth_export.png', + 'example_export.txt', + 'tests/data/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 == '' + + +@pytest.mark.qreader +def test_img_qr_reader_from_stdin(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + # sys.stdin.buffer should be monkey patched, but it does not work + monkeypatch.setattr('sys.stdin', read_binary_file_as_stream('tests/data/test_googleauth_export.png')) + + # Act + extract_otp_secrets.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: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + # sys.stdin.buffer should be monkey patched, but it does not work + monkeypatch.setattr('sys.stdin', read_binary_file_as_stream('tests/data/test_googleauth_export.png')) + + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secrets.main(['-n', '-']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = '\nERROR: Binary 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_extract_stdin_stdout_wrong_symbol(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None: + # Arrange + monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt'))) + + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secrets.main(['-n', '=']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = "\nERROR: Cannot read binary stdin buffer.\nException: 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 + + +@pytest.mark.qreader +def test_img_qr_reader_no_qr_code_in_image(capsys: pytest.CaptureFixture[str]) -> None: + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secrets.main(['-n', 'tests/data/lena_std.tif']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = '\nERROR: Unable to read QR Code from file.\ninput file: tests/data/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: pytest.CaptureFixture[str]) -> None: + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secrets.main(['-n', 'nonexistent.bmp']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = '\nERROR: Input file provided is non-existent or not a file.\ninput file: 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: pytest.CaptureFixture[str]) -> None: + # Act + extract_otp_secrets.main(['-n', 'tests/data/text_masquerading_as_image.jpeg']) + + # Assert + captured = capsys.readouterr() + expected_stderr = ''' +WARN: input is not a otpauth-migration:// url +source: tests/data/text_masquerading_as_image.jpeg +input: This is just a text file masquerading as an image file. +Maybe a wrong file was given + +ERROR: could not parse query parameter in input url +source: tests/data/text_masquerading_as_image.jpeg +url: This is just a text file masquerading as an image file. +''' + + assert captured.err == expected_stderr + assert captured.out == '' + + +EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT = '''Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 + +Name: encoding: ¿äÄéÉ? (demo) +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/tests/extract_otp_secrets_txt_unit_test.py similarity index 69% rename from test_extract_otp_secret_keys_unittest.py rename to tests/extract_otp_secrets_txt_unit_test.py index 874ef09..27d5512 100644 --- a/test_extract_otp_secret_keys_unittest.py +++ b/tests/extract_otp_secrets_txt_unit_test.py @@ -1,4 +1,4 @@ -# Unit test for extract_otp_secret_keys.py +# Unit test for extract_otp_secrets.py # Run tests: # python -m unittest @@ -18,37 +18,44 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. -import unittest +from __future__ import annotations # workaround for PYTHON <= 3.10 + 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 import os -import sys +import unittest +from contextlib import redirect_stdout + +from utils import (Capturing, count_files_in_dir, read_csv, read_file_to_str, + read_json, remove_dir_with_files, remove_file) + +import extract_otp_secrets -import extract_otp_secret_keys +# Conditional skip example +# if sys.implementation.name == 'pypy' or sys.platform.startswith("win") or sys.version_info < (3, 10): +# self.skipTest("Avoid encoding problems") class TestExtract(unittest.TestCase): - def test_extract_csv(self): - extract_otp_secret_keys.main(['-q', '-c', 'test_example_output.csv', 'example_export.txt']) + def test_extract_csv(self) -> None: + extract_otp_secrets.main(['-q', '-c', 'test_example_output.csv', 'example_export.txt']) expected_csv = read_csv('example_output.csv') actual_csv = read_csv('test_example_output.csv') self.assertEqual(actual_csv, expected_csv) - def test_extract_json(self): - extract_otp_secret_keys.main(['-q', '-j', 'test_example_output.json', 'example_export.txt']) + def test_extract_json(self) -> None: + extract_otp_secrets.main(['-q', '-j', 'test_example_output.json', 'example_export.txt']) expected_json = read_json('example_output.json') actual_json = read_json('test_example_output.json') self.assertEqual(actual_json, expected_json) - def test_extract_stdout_1(self): + def test_extract_stdout_1(self) -> None: with Capturing() as output: - extract_otp_secret_keys.main(['example_export.txt']) + extract_otp_secrets.main(['example_export.txt']) expected_output = [ 'Name: pi@raspberrypi', @@ -82,10 +89,10 @@ class TestExtract(unittest.TestCase): self.assertEqual(output, expected_output) # Ref for capturing https://stackoverflow.com/a/40984270 - def test_extract_stdout_2(self): + def test_extract_stdout_2(self) -> None: out = io.StringIO() with redirect_stdout(out): - extract_otp_secret_keys.main(['example_export.txt']) + extract_otp_secrets.main(['example_export.txt']) actual_output = out.getvalue() expected_output = '''Name: pi@raspberrypi @@ -118,10 +125,10 @@ Type: totp ''' self.assertEqual(actual_output, expected_output) - def test_extract_not_encoded_plus(self): + def test_extract_not_encoded_plus(self) -> None: out = io.StringIO() with redirect_stdout(out): - extract_otp_secret_keys.main(['test/test_plus_problem_export.txt']) + extract_otp_secrets.main(['tests/data/test_plus_problem_export.txt']) actual_output = out.getvalue() expected_output = '''Name: SerenityLabs:test1@serenitylabs.co.uk @@ -147,51 +154,43 @@ Type: totp ''' self.assertEqual(actual_output, expected_output) - def test_extract_printqr(self): + def test_extract_printqr(self) -> None: out = io.StringIO() with redirect_stdout(out): - extract_otp_secret_keys.main(['-p', 'example_export.txt']) + extract_otp_secrets.main(['-p', 'example_export.txt']) actual_output = out.getvalue() - expected_output = read_file_to_str('test/printqr_output.txt') + expected_output = read_file_to_str('tests/data/printqr_output.txt') self.assertEqual(actual_output, expected_output) - def test_extract_saveqr(self): - extract_otp_secret_keys.main(['-q', '-s', 'testout/qr/', 'example_export.txt']) + def test_extract_saveqr(self) -> None: + extract_otp_secrets.main(['-q', '-s', 'testout/qr/', 'example_export.txt']) 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')) + self.assertTrue(os.path.isfile('testout/qr/5-hotpdemo.png')) + self.assertTrue(os.path.isfile('testout/qr/6-encodingäÄéÉdemo.png')) + self.assertEqual(count_files_in_dir('testout/qr'), 6) - def test_extract_verbose(self): - 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']) - actual_output = out.getvalue() - - expected_output = read_file_to_str('test/print_verbose_output.txt') - - self.assertEqual(actual_output, expected_output) - - def test_extract_debug(self): + def test_extract_debug(self) -> None: out = io.StringIO() with redirect_stdout(out): - extract_otp_secret_keys.main(['-vv', 'example_export.txt']) + extract_otp_secrets.main(['-vvv', 'example_export.txt']) actual_output = out.getvalue() - expected_stdout = read_file_to_str('test/print_verbose_output.txt') + expected_stdout = read_file_to_str('tests/data/print_verbose_output.txt') self.assertGreater(len(actual_output), len(expected_stdout)) self.assertTrue("DEBUG: " in actual_output) - def test_extract_help_1(self): + def test_extract_help_1(self) -> None: out = io.StringIO() with redirect_stdout(out): try: - extract_otp_secret_keys.main(['-h']) + extract_otp_secrets.main(['-h']) self.fail("Must abort") except SystemExit as e: self.assertEqual(e.code, 0) @@ -199,36 +198,36 @@ Type: totp actual_output = out.getvalue() self.assertGreater(len(actual_output), 0) - self.assertTrue("-h, --help" in actual_output and "--verbose, -v" in actual_output) + self.assertTrue("-h, --help" in actual_output and "-v, --verbose" in actual_output) - def test_extract_help_2(self): + def test_extract_help_2(self) -> None: out = io.StringIO() with redirect_stdout(out): with self.assertRaises(SystemExit) as context: - extract_otp_secret_keys.main(['-h']) + extract_otp_secrets.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.assertTrue("-h, --help" in actual_output and "-v, --verbose" in actual_output) self.assertEqual(context.exception.code, 0) - def test_extract_help_3(self): + def test_extract_help_3(self) -> None: with Capturing() as actual_output: with self.assertRaises(SystemExit) as context: - extract_otp_secret_keys.main(['-h']) + extract_otp_secrets.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.assertTrue("-h, --help" in "\n".join(actual_output) and "-v, --verbose" in "\n".join(actual_output)) self.assertEqual(context.exception.code, 0) - def setUp(self): + def setUp(self) -> None: self.cleanup() - def tearDown(self): + def tearDown(self) -> None: self.cleanup() - def cleanup(self): + def cleanup(self) -> None: remove_file('test_example_output.csv') remove_file('test_example_output.json') remove_dir_with_files('testout/') diff --git a/utils.py b/tests/utils.py similarity index 66% rename from utils.py rename to tests/utils.py index 19cb52c..e555a10 100644 --- a/utils.py +++ b/tests/utils.py @@ -13,6 +13,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. +from __future__ import annotations # workaround for PYTHON <= 3.10 import csv import glob import io @@ -21,23 +22,32 @@ import os import re import shutil import sys +import pathlib +from typing import BinaryIO, Any, Union, List + + +# Types +# workaround for PYTHON <= 3.9: Workaround for str | pathlib.Path +PathLike = Union[str, pathlib.Path] # Ref. https://stackoverflow.com/a/16571630 -class Capturing(list): +# workaround for PYTHON <= 3.10: class Capturing(list[Any]): +class Capturing(List[Any]): '''Capture stdout and stderr Usage: with Capturing() as output: print("Output") ''' - def __enter__(self): + # TODO remove type ignore if fixed, see https://github.com/python/mypy/issues/11871, https://stackoverflow.com/questions/72174409/type-hinting-the-return-value-of-a-class-method-that-returns-self + def __enter__(self): # type: ignore self._stdout = sys.stdout sys.stdout = self._stringio_std = io.StringIO() self._stderr = sys.stderr sys.stderr = self._stringio_err = io.StringIO() return self - def __exit__(self, *args): + def __exit__(self, *args: Any) -> None: self.extend(self._stringio_std.getvalue().splitlines()) del self._stringio_std # free up some memory sys.stdout = self._stdout @@ -47,69 +57,71 @@ with Capturing() as output: sys.stderr = self._stderr -def file_exits(file): +def file_exits(file: PathLike) -> bool: return os.path.isfile(file) -def remove_file(file): +def remove_file(file: PathLike) -> None: if file_exits(file): os.remove(file) -def remove_files(glob_pattern): +def remove_files(glob_pattern: str) -> None: for f in glob.glob(glob_pattern): os.remove(f) -def remove_dir_with_files(dir): +def remove_dir_with_files(dir: PathLike) -> None: if os.path.exists(dir): shutil.rmtree(dir) -def read_csv(filename): +def read_csv(filename: str) -> List[List[str]]: """Returns a list of lines.""" with open(filename, "r", encoding="utf-8", newline='') as infile: - lines = [] + lines: List[List[str]] = [] reader = csv.reader(infile) for line in reader: lines.append(line) return lines -def read_csv_str(str): +def read_csv_str(data_str: str) -> List[List[str]]: """Returns a list of lines.""" - lines = [] - reader = csv.reader(str.splitlines()) + lines: List[List[str]] = [] + reader = csv.reader(data_str.splitlines()) for line in reader: lines.append(line) return lines -def read_json(filename): +def read_json(filename: str) -> Any: """Returns a list or a dictionary.""" with open(filename, "r", encoding="utf-8") as infile: return json.load(infile) -def read_json_str(str): +def read_json_str(data_str: str) -> Any: """Returns a list or a dictionary.""" - return json.loads(str) + return json.loads(data_str) -def read_file_to_list(filename): +def read_file_to_list(filename: str) -> List[str]: """Returns a list of lines.""" with open(filename, "r", encoding="utf-8") as infile: return infile.readlines() -def read_file_to_str(filename): +def read_file_to_str(filename: str) -> str: """Returns a str.""" return "".join(read_file_to_list(filename)) -def read_binary_file_as_stream(filename): + +def read_binary_file_as_stream(filename: str) -> BinaryIO: """Returns binary file content.""" with open(filename, "rb",) as infile: return io.BytesIO(infile.read()) -def replace_escaped_octal_utf8_bytes_with_str(str): + +def replace_escaped_octal_utf8_bytes_with_str(str: 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) @@ -120,5 +132,9 @@ def replace_escaped_octal_utf8_bytes_with_str(str): return str -def quick_and_dirty_workaround_encoding_problem(str): +def quick_and_dirty_workaround_encoding_problem(str: str) -> str: return re.sub(r'name: "encoding: .*$', '', str, flags=re.MULTILINE) + + +def count_files_in_dir(path: PathLike) -> int: + return len([name for name in os.listdir(path) if os.path.isfile(os.path.join(path, name))]) diff --git a/upgrade_deps.sh b/upgrade_deps.sh deleted file mode 100755 index 339edc2..0000000 --- a/upgrade_deps.sh +++ /dev/null @@ -1,236 +0,0 @@ -#!/bin/bash - -# Upgrades Protoc from https://github.com/protocolbuffers/protobuf/releases - -black='\e[0;30m' -blackBold='\e[1;30m' -blackBackground='\e[1;40m' -red='\e[0;31m' -redBold='\e[1;31m' -redBackground='\e[0;41m' -green='\e[0;32m' -greenBold='\e[1;32m' -greenBackground='\e[0;42m' -yellow='\e[0;33m' -yellowBold='\e[1;33m' -yellowBackground='\e[0;43m' -blue='\e[0;34m' -blueBold='\e[1;34m' -blueBackground='\e[0;44m' -magenta='\e[0;35m' -magentaBold='\e[1;35m' -magentaBackground='\e[0;45m' -cyan='\e[0;36m' -cyanBold='\e[1;36m' -cyanBackground='\e[0;46m' -white='\e[0;37m' -whiteBold='\e[1;37m' -whiteBackground='\e[0;47m' -reset='\e[0m' - -abort() { - echo ' -*************** -*** ABORTED *** -*************** - ' >&2 - echo "An error occurred on line $1. Exiting..." >&2 - date -Iseconds >&2 - exit 1 -} - -trap 'abort $LINENO' ERR -set -e -o pipefail - -quit() { - trap : 0 - exit 0 -} - -# Asks if [Yn] if script shoud continue, otherwise exit 1 -# $1: msg or nothing -# Example call 1: askContinueYn -# Example call 1: askContinueYn "Backup DB?" -askContinueYn() { - if [[ $1 ]]; then - msg="$1 " - else - msg="" - fi - - # http://stackoverflow.com/questions/3231804/in-bash-how-to-add-are-you-sure-y-n-to-any-command-or-alias - read -e -p "${msg}Continue? [Y/n] " response - response=${response,,} # tolower - if [[ $response =~ ^(yes|y|)$ ]] ; then - # echo "" - # OK - : - else - echo "Aborted" - exit 1 - fi -} - -# Reference: https://gist.github.com/steinwaywhw/a4cd19cda655b8249d908261a62687f8 - -echo "Checking Protoc version..." -VERSION=$(curl -sL https://github.com/protocolbuffers/protobuf/releases/latest | grep -E "<title>" | perl -pe's%.*Protocol Buffers v(\d+\.\d+(\.\d+)?).*%\1%') -BASEVERSION=4 -echo - -interactive=true -check_version=true - -while test $# -gt 0; do - case $1 in - -h|--help) - echo "Upgrade Protoc" - echo - echo "$0 [options]" - echo - echo "Options:" - echo "-a Automatic mode" - echo "-C Ignore version check" - echo "-h, --help Help" - quit - ;; - -a) - interactive=false - shift - ;; - -C) - check_version=false - shift - ;; - esac -done - -BIN="$HOME/bin" -DOWNLOADS="$HOME/downloads" - -PIP="pip3.11" -PIPENV="python3.11 -m pipenv" - -# Upgrade protoc - -DEST="protoc" - -OLDVERSION=$(cat $BIN/$DEST/.VERSION.txt || echo "") -echo -e "\nProtoc remote version $VERSION\n" -echo -e "Protoc local version: $OLDVERSION\n" - -if [ "$OLDVERSION" != "$VERSION" ]; then - echo "Upgrade protoc from $OLDVERSION to $VERSION" - - NAME="protoc-$VERSION" - ARCHIVE="$NAME.zip" - - 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"; 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"; 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"; 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"; 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"; 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"; else echo -e "${cyan}$cmd${reset}";fi - eval "$cmd" - - cmd="mv -iT $BIN/$NAME $BIN/$DEST" - if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi - eval "$cmd" - - cmd="rm $DOWNLOADS/$ARCHIVE" - 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"; 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"; else echo -e "${cyan}$cmd${reset}";fi - eval "$cmd" -else - echo -e "\nVersion has not changed. Quit" -fi - - -# Upgrade pip requirements - -cmd="sudo pip install --upgrade pip" -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"; else echo -e "${cyan}$cmd${reset}";fi -eval "$cmd" - -cmd="$PIP install --use-pep517 -U -r requirements-dev.txt" -if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi -eval "$cmd" - -cmd="$PIP install -U pipenv" -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"; else echo -e "${cyan}$cmd${reset}";fi -eval "$cmd" - -$PIPENV run python --version - -# Test - -cmd="pytest" -if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi -eval "$cmd" - -cmd="$PIPENV run pytest" -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