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"
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": [
|
||||
|
|
|
|||
156
README.md
|
|
@ -1,7 +1,7 @@
|
|||
# Extract TOTP/HOTP secrets from QR codes exported by two-factor authentication apps
|
||||
|
||||
[](https://github.com/scito/extract_otp_secrets/actions/workflows/ci.yml)
|
||||

|
||||

|
||||
[](https://github.com/scito/extract_otp_secrets/actions/workflows/ci_docker.yml)
|
||||

|
||||
[](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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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.
|
||||

|
||||
3. Select "Export accounts"
|
||||

|
||||
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.
|
||||

|
||||
|
||||
## 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
|
||||
|
|
|
|||
31
build.sh
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
BIN
docs/Export-account-option-in-the-Google-Authenticator.webp
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
BIN
docs/Exported-QR-codes.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
docs/Exported-QR-codes_300px.webp
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/Transfer-accounts-option-in-the-Google-Authenticator.webp
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
|
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}"
|
||||
|
|
|
|||
BIN
tests/data/qr_but_without_otp.png
Normal file
|
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:
|
||||
|
|
|
|||