use only cv2_draw_box, move core functions to top

- improve camera test
- add more tests
- improve README
    - add "How to export otp secrets from Google Authenticator app"
    - reorder: put usage before installation
    - add "Full local build"
pull/38/head v2.0.1
scito 1 year ago committed by Roland Kurmann
parent 36fd0c0bb6
commit 2ed923591e

104
Pipfile.lock generated

@ -233,60 +233,60 @@
"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"
"sha256:037b51ee86bc600f99b3b957c20a172431c35c2ef9c1ca34bc813ab5b51fd9f5",
"sha256:0bce4ad5bdd0b02e177a085d28d2cea5fc57bb4ba2cead395e763e34cf934eb1",
"sha256:112cfead1bd22eada8a8db9ed387bd3e8be5528debc42b5d3c1f7da4ffaf9fb5",
"sha256:13121fa22dcd2c7b19c5161e3fd725692448f05377b788da4502a383573227b3",
"sha256:18b09811f849cc958d23f733a350a66b54a8de3fed1e6128ba55a5c97ffb6f65",
"sha256:25fde928306034e8deecd5fc91a07432dcc282c8acb76749581a28963c9f4f3f",
"sha256:2b865aa679bee7fbd1c55960940dbd3252621dd81468268786c67122bbd15343",
"sha256:2f7c51b6074a8a3063c341953dffe48fd6674f8e4b1d3c8aa8a91f58d6e716a8",
"sha256:349d0b545520e8516f7b4f12373afc705d17d901e1de6a37a20e4ec9332b61f7",
"sha256:44d6a556de4418f1f3bfd57094b8c49f0408df5a433cf0d253eeb3075261c762",
"sha256:4959dc506be74e4963bd2c42f7b87d8e4b289891201e19ec551e64c6aa5441f8",
"sha256:49da0ff241827ebb52d5d6d5a36d33b455fa5e721d44689c95df99fd8db82437",
"sha256:55e46fa4168ccb7497c9be78627fcb147e06f474f846a10d55feeb5108a24ef0",
"sha256:5722269ed05fbdb94eef431787c66b66260ff3125d1a9afcc00facff8c45adf9",
"sha256:628f47eaf66727fc986d3b190d6fa32f5e6b7754a243919d28bc0fd7974c449f",
"sha256:62ef3800c4058844e2e3fa35faa9dd0ccde8a8aba6c763aae50342e00d4479d4",
"sha256:75e43c6f4ea4d122dac389aabdf9d4f0e160770a75e63372f88005d90f5bcc80",
"sha256:7e8b0642c38b3d3b3c01417643ccc645345b03c32a2e84ef93cdd6844d6fe530",
"sha256:88834e5d56d01c141c29deedacba5773fe0bed900b1edc957595a8a6c0da1c3c",
"sha256:985ad2af5ec3dbb4fd75d5b0735752c527ad183455520055a08cf8d6794cabfc",
"sha256:a530663a361eb27375cec28aea5cd282089b5e4b022ae451c4c3493b026a68a5",
"sha256:acef7f3a3825a2d218a03dd02f5f3cc7f27aa31d882dd780191d1ad101120d74",
"sha256:ae871d09901911eedda1981ea6fd0f62a999107293cdc4c4fd612321c5b34745",
"sha256:af6cef3796b8068713a48dd67d258dc9a6e2ebc3bd4645bfac03a09672fa5d20",
"sha256:af87e906355fa42447be5c08c5d44e6e1c005bf142f303f726ddf5ed6e0c8a4d",
"sha256:b07651e3b9af8f1a092861d88b4c74d913634a7f1f2280fca0ad041ad84e9e96",
"sha256:b1ffc8f58b81baed3f8962e28c30d99442079b82ce1ec836a1f67c0accad91c1",
"sha256:b5b38813eee5b4739f505d94247604c72eae626d5088a16dd77b08b8b1724ab3",
"sha256:b791beb17b32ac019a78cfbe6184f992b6273fdca31145b928ad2099435e2fcb",
"sha256:b82343a5bc51627b9d606f0b6b6b9551db7b6311a5dd920fa52a94beae2e8959",
"sha256:ba9af1218fa01b1f11c72271bc7290b701d11ad4dbc2ae97c445ecacf6858dba",
"sha256:bdbda870e0fda7dd0fe7db7135ca226ec4c1ade8aa76e96614829b56ca491012",
"sha256:bf76d79dfaea802f0f28f50153ffbc1a74ae1ee73e480baeda410b4f3e7ab25f",
"sha256:c1cee10662c25c94415bbb987f2ec0e6ba9e8fce786334b10be7e6a7ab958f69",
"sha256:c5648c7eec5cf1ba5db1cf2d6c10036a582d7f09e172990474a122e30c841361",
"sha256:c58cd6bb46dcb922e0d5792850aab5964433d511b3a020867650f8d930dde4f4",
"sha256:c5d9b480ebae60fc2cbc8d6865194136bc690538fa542ba58726433bed6e04cc",
"sha256:ca15308ef722f120967af7474ba6a453e0f5b6f331251e20b8145497cf1bc14a",
"sha256:d0df04495b76a885bfef009f45eebe8fe2fbf815ad7a83dabcf5aced62f33162",
"sha256:d5be4e93acce64f516bf4fd239c0e6118fc913c93fa1a3f52d15bdcc60d97b2d",
"sha256:d8249666c23683f74f8f93aeaa8794ac87cc61c40ff70374a825f3352a4371dc",
"sha256:e3f1cd1cd65695b1540b3cf7828d05b3515974a9d7c7530f762ac40f58a18161",
"sha256:e56fae4292e216b8deeee38ace84557b9fa85b52db005368a275427cdabb8192",
"sha256:e6dcc70a25cb95df0ae33dfc701de9b09c37f7dd9f00394d684a5b57257f8246",
"sha256:e89d5abf86c104de808108a25d171ad646c07eda96ca76c8b237b94b9c71e518",
"sha256:ed7c9debf7bfc63c9b9f8b595409237774ff4b061bf29fba6f53b287a2fdeab9",
"sha256:ef001a60e888f8741e42e5aa79ae55c91be73761e4df5e806efca1ddd62fd400",
"sha256:f30090e22a301952c5abd0e493a1c8358b4f0b368b49fa3e4568ed3ed68b8d1f",
"sha256:f79691335257d60951638dd43576b9bcd6f52baa5c1c2cd07a509bb003238372",
"sha256:f918e9ef4c98f477a5458238dde2a1643aed956c7213873ab6b6b82e32b8ef61",
"sha256:fd0a8aa431f9b7ad9eb8264f55ef83cbb254962af3775092fb6e93890dea9ca2"
],
"markers": "python_version >= '3.7'",
"version": "==7.0.2"
"version": "==7.0.3"
},
"dill": {
"hashes": [

@ -1,7 +1,7 @@
# Extract TOTP/HOTP secrets from QR codes exported by two-factor authentication apps
[![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-93%25-brightgreen)
![coverage](https://img.shields.io/badge/coverage-94%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_secrets)](https://github.com/scito/extract_otp_secrets/blob/master/Pipfile.lock)
@ -21,7 +21,47 @@ The exported QR codes from authentication apps can be read in three ways:
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.**
**This project/script was renamed from `extract_otp_secret_keys` to `extract_otp_secrets`.**
## Usage
### Capture QR codes from camera (🆕 since version 2.0)
1. Open "Google Authenticator" app on the mobile phone
2. Export the QR codes from "Google Authenticator" app (see [how to export](#how-to-export-otp-secrets-from-google-authenticator-app))
3. Point the exported QR codes to the camera of your computer
4. Call this script without infile parameters:
python src/extract_otp_secrets.py
![CV2 Capture from camera screenshot](cv2_capture_screenshot.png)
Detected QR codes are surrounded with a frame. The color of the frame indicates the extracting result:
* Green: The QR code is detected, decoded and the OTP secret was successfully extracted.
* Red: The QR code is detected and decoded, but could not be successfully extracted. This is the case if a QR code not containing OTP data is captured.
* Magenta: The QR code is detected, but could not be decoded. The QR code should be presented better to the camera or another QR reader could be used.
### With builtin QR decoder from image files (🆕 since version 2.0)
1. Open "Google Authenticator" app on the mobile phone
2. Export the QR codes from "Google Authenticator" app (see [how to export](#how-to-export-otp-secrets-from-Google-Authenticator))
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:
python src/extract_otp_secrets.py example_export.png
### With external QR decoder app from text files
1. Open "Google Authenticator" app on the mobile phone
2. Export the QR codes from "Google Authenticator" app (see [how to export](#how-to-export-otp-secrets-from-Google-Authenticator))
3. Read QR codes with a third-party QR code reader (e.g. from another phone)
4. Save the captured QR codes from 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:
python src/extract_otp_secrets.py example_export.txt
## Installation
@ -68,46 +108,6 @@ The zbar DLLs are included with the Windows Python wheels. However, you might ne
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/)
## Usage
### Capture QR codes from camera (🆕 since version 2.0)
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:
python src/extract_otp_secrets.py
![CV2 Capture from camera screenshot](cv2_capture_screenshot.png)
Detected QR codes are surrounded with a frame. The color of the frame indicates the extracting result:
* Green: The QR code is detected, decoded and the OTP secret was successfully extracted.
* Red: The QR code is detected and decoded, but could not be successfully extracted. This is the case if a QR code not containing OTP data is captured.
* Magenta: The QR code is detected, but could not be decoded. The QR code should be presented better to the camera or another QR reader could be used.
### With builtin QR decoder from image files (🆕 since version 2.0)
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:
python src/extract_otp_secrets.py example_export.png
### With external QR decoder app from text files
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:
python src/extract_otp_secrets.py example_export.txt
## Program help: arguments and options
<pre>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 ...]
@ -266,23 +266,18 @@ Import CSV with HOTP entries in KeePass as
KeePass can be used as a backup for one time passwords (second factor) from the mobile phone.
## Technical background
## How to export otp secrets from Google Authenticator app
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 or new protobuf versions):
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
* Template code: https://github.com/beemdevelopment/Aegis/pull/406
1. Open "Google Authenticator" app
2. Select "Transfer accounts" in the three dot menu of the app.
![Transfer accounts option in the Google Authenticator.](docs/Transfer-accounts-option-in-the-Google-Authenticator_300px.webp)
3. Select "Export accounts"
![Export account option in the Google Authenticator.](docs/Export-account-option-in-the-Google-Authenticator_300px.webp)
4. Pass the verification by password or fingerprint.
5. Select your accounts
6. Press "Next" button
7. The exported QR code(s) ready for extraction are shown.
![Exported Google Authenticator QR codes](docs/Exported-QR-codes_300px.webp)
## Glossary
@ -371,7 +366,6 @@ Prebuilt docker images are available for amd64 and arm64 architectures on [Docke
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 =
```
@ -485,7 +479,6 @@ Run tests in docker container:
docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets
```
#### Alpine (only text file processing)
```bash
@ -498,6 +491,49 @@ Run tests in docker container:
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
```
### Full local build
There is a Bash script for a full local build including linting and type checking.
```bash
./build.sh
```
The options of the build script:
```
Build extract_otp_secrets project
./build.sh [options]
Options:
-i Interactive mode, all steps must be confirmed
-C Ignore version check of protobuf/protoc
-D Do not build docker
-G Do not start extract_otp_secrets.py in GUI mode
-c Clean everything
-r Generate result files
-h, --help Help
```
## 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 or new protobuf versions):
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
* Template code: https://github.com/beemdevelopment/Aegis/pull/406
## Issues
* Segmentation fault on macOS with CV2 4.7.0: https://github.com/opencv/opencv/issues/23072

@ -73,11 +73,6 @@ askContinueYn() {
# 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=false
ignore_version_check=true
clean=false
@ -88,16 +83,16 @@ generate_result_files=false
while test $# -gt 0; do
case $1 in
-h|--help)
echo "Upgrade Protoc"
echo "Build extract_otp_secrets project"
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 "-i Interactive mode, all steps must be confirmed"
echo "-C Ignore version check of protobuf/protoc"
echo "-D Do not build docker"
echo "-G Do not start extract_otp_secrets.py in GUI mode"
echo "-c Clean everything"
echo "-r Generate result files"
echo "-h, --help Help"
quit
@ -144,10 +139,6 @@ MYPY="$PYTHON -m mypy"
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="docker image prune -f || echo 'No docker image pruned'"
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
@ -186,6 +177,16 @@ cmd="$PIP install --use-pep517 -U -r requirements-dev.txt"
if $interactive ; then askContinueYn "$cmd"; else echo -e "${cyan}$cmd${reset}";fi
eval "$cmd"
echo -e "\n\nChecking 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
OLDVERSION=$(cat $BIN/$DEST/.VERSION.txt || echo "")
echo -e "\nProtoc remote version $VERSION\n"
echo -e "Protoc local version: $OLDVERSION\n"
if [ "$OLDVERSION" != "$VERSION" ] || ! $ignore_version_check; then
echo "Upgrade protoc from $OLDVERSION to $VERSION"

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

@ -164,6 +164,77 @@ def main(sys_args: list[str]) -> None:
write_json(args, otps)
# 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 extract_otp_from_otp_url(otpauth_migration_url: str, otps: Otps, urls_count: int, infile: str, args: Args) -> int:
'''Converts the otp migration payload into a normal Python dictionary. This function is the core of the this appliation.'''
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 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"
@ -249,7 +320,7 @@ def extract_otps_from_camera(args: Args) -> Otps:
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)
cv2_draw_box(img, [(bbox[0], bbox[1]), (bbox[2], bbox[1]), (bbox[2], bbox[3]), (bbox[0], bbox[3])], get_color(new_otps_count, otp_url))
elif qr_mode == QRMode.ZBAR:
for qrcode in zbar.decode(img):
otp_url = qrcode.data.decode('utf-8')
@ -414,45 +485,6 @@ def read_lines_from_text_file(filename: str) -> list[str]:
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:
@ -506,37 +538,6 @@ def decode_qr_img_otp_urls(img: Any, qr_mode: QRMode) -> OtpUrls:
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}"

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

@ -40,6 +40,7 @@ import extract_otp_secrets
try:
import cv2 # type: ignore
from extract_otp_secrets import SUCCESS_COLOR, FAILURE_COLOR, FONT, FONT_SCALE, FONT_COLOR, FONT_THICKNESS, FONT_LINE_STYLE
except ImportError:
# ignore
pass
@ -106,6 +107,20 @@ def test_extract_stdin_empty(capsys: pytest.CaptureFixture[str], monkeypatch: py
assert captured.err == '\nWARN: stdin is empty\n'
def test_extract_stdin_only_comments(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange
monkeypatch.setattr('sys.stdin', io.StringIO("\n\n# comment 1\n\n\n#comment 2"))
# Act
extract_otp_secrets.main(['-n', '-'])
# Assert
captured = capsys.readouterr()
assert captured.out == ''
assert captured.err == ''
def test_extract_empty_file_no_qreader(capsys: pytest.CaptureFixture[str]) -> None:
if qreader_available:
# Act
@ -132,6 +147,17 @@ def test_extract_empty_file_no_qreader(capsys: pytest.CaptureFixture[str]) -> No
assert captured.out == ''
def test_extract_only_comments_file_no_qreader(capsys: pytest.CaptureFixture[str]) -> None:
# Act
extract_otp_secrets.main(['-n', 'tests/data/only_comments.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
@ -147,6 +173,24 @@ def test_extract_stdin_img_empty(capsys: pytest.CaptureFixture[str], monkeypatch
assert captured.err == '\nWARN: stdin is empty\n'
@pytest.mark.qreader
def test_extract_stdin_img_garbage(capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch) -> None:
# Arrange
monkeypatch.setattr('sys.stdin', io.BytesIO("garbage".encode('utf-8')))
# Act
with pytest.raises(SystemExit) as e:
extract_otp_secrets.main(['-n', '='])
# Assert
captured = capsys.readouterr()
assert captured.out == ''
assert captured.err == '\nERROR: Unable to open file for reading.\ninput file: =\n'
assert e.type == SystemExit
assert e.value.code == 1
def test_extract_csv(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None:
# Arrange
output_file = str(tmp_path / 'test_example_output.csv')
@ -182,6 +226,19 @@ def test_extract_csv_stdout(capsys: pytest.CaptureFixture[str]) -> None:
assert captured.err == ''
def test_extract_csv_stdout_only_comments(capsys: pytest.CaptureFixture[str]) -> None:
# Act
extract_otp_secrets.main(['-c', '-', 'tests/data/only_comments.txt'])
# Assert
assert not file_exits('test_example_output.csv')
captured = capsys.readouterr()
assert captured.out == ''
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')))
@ -307,6 +364,18 @@ def test_extract_json_stdout(capsys: pytest.CaptureFixture[str]) -> None:
assert captured.err == ''
def test_extract_json_stdout_only_comments(capsys: pytest.CaptureFixture[str]) -> None:
# Act
extract_otp_secrets.main(['-j', '-', 'tests/data/only_comments.txt'])
# Assert
assert not file_exits('test_example_output.json')
captured = capsys.readouterr()
assert captured.out == '[]'
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'])
@ -544,25 +613,36 @@ class MockCam:
pass
@pytest.mark.parametrize("qr_reader", [
None,
'ZBAR',
'QREADER',
'QREADER_DEEP',
'CV2',
'CV2_WECHAT'
@pytest.mark.parametrize("qr_reader,file,success", [
(None, 'example_export.png', True),
('ZBAR', 'example_export.png', True),
('QREADER', 'example_export.png', True),
('QREADER_DEEP', 'example_export.png', True),
('CV2', 'example_export.png', True),
('CV2_WECHAT', 'example_export.png', True),
(None, 'tests/data/qr_but_without_otp.png', False),
('ZBAR', 'tests/data/qr_but_without_otp.png', False),
('QREADER', 'tests/data/qr_but_without_otp.png', False),
('QREADER_DEEP', 'tests/data/qr_but_without_otp.png', False),
('CV2', 'tests/data/qr_but_without_otp.png', False),
('CV2_WECHAT', 'tests/data/qr_but_without_otp.png', False),
(None, 'tests/data/lena_std.tif', None),
('ZBAR', 'tests/data/lena_std.tif', None),
('QREADER', 'tests/data/lena_std.tif', None),
('QREADER_DEEP', 'tests/data/lena_std.tif', None),
('CV2', 'tests/data/lena_std.tif', None),
('CV2_WECHAT', 'tests/data/lena_std.tif', None),
])
def test_extract_otps_from_camera(qr_reader: Optional[str], capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None:
def test_extract_otps_from_camera(qr_reader: Optional[str], file: str, success: bool, capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None:
if qreader_available:
# Arrange
mockCam = MockCam()
mockCam = MockCam([file])
mocker.patch('cv2.VideoCapture', return_value=mockCam)
mocker.patch('cv2.namedWindow')
mocker.patch('cv2.rectangle')
mocker.patch('cv2.polylines')
mocked_polylines = mocker.patch('cv2.polylines')
mocker.patch('cv2.imshow')
mocker.patch('cv2.getTextSize', return_value=([8, 200], False))
mocker.patch('cv2.putText')
mocked_putText = mocker.patch('cv2.putText')
mocker.patch('cv2.getWindowImageRect', return_value=[0, 0, 640, 480])
mocker.patch('cv2.waitKey', return_value=27)
mocker.patch('cv2.getWindowProperty', return_value=False)
@ -578,8 +658,21 @@ def test_extract_otps_from_camera(qr_reader: Optional[str], capsys: pytest.Captu
# Assert
captured = capsys.readouterr()
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG
assert captured.err == ''
if success:
assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG
assert captured.err == ''
mocked_polylines.assert_called_with(mocker.ANY, mocker.ANY, True, SUCCESS_COLOR, mocker.ANY)
mocked_putText.assert_called_with(mocker.ANY, "3 otps extracted", mocker.ANY, FONT, FONT_SCALE, FONT_COLOR, FONT_THICKNESS, FONT_LINE_STYLE)
elif success is None:
assert captured.out == ''
assert captured.err == ''
mocked_polylines.assert_not_called()
mocked_putText.assert_called_with(mocker.ANY, "0 otps extracted", mocker.ANY, FONT, FONT_SCALE, FONT_COLOR, FONT_THICKNESS, FONT_LINE_STYLE)
else:
assert captured.out == ''
assert captured.err != ''
mocked_polylines.assert_called_with(mocker.ANY, mocker.ANY, True, FAILURE_COLOR, mocker.ANY)
mocked_putText.assert_called_with(mocker.ANY, "0 otps extracted", mocker.ANY, FONT, FONT_SCALE, FONT_COLOR, FONT_THICKNESS, FONT_LINE_STYLE)
else:
# Act
with pytest.raises(SystemExit) as e:

Loading…
Cancel
Save