diff --git a/Pipfile.lock b/Pipfile.lock index 132d596..3bd055a 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -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": [ diff --git a/README.md b/README.md index f9d1695..93d76f5 100644 --- a/README.md +++ b/README.md @@ -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
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
diff --git a/build.sh b/build.sh
index 7b79cfc..3c8fd05 100755
--- a/build.sh
+++ b/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 "" | 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"
 
diff --git a/docs/Export-account-option-in-the-Google-Authenticator.webp b/docs/Export-account-option-in-the-Google-Authenticator.webp
new file mode 100644
index 0000000..209da82
Binary files /dev/null and b/docs/Export-account-option-in-the-Google-Authenticator.webp differ
diff --git a/docs/Export-account-option-in-the-Google-Authenticator_300px.webp b/docs/Export-account-option-in-the-Google-Authenticator_300px.webp
new file mode 100644
index 0000000..ef25bae
Binary files /dev/null and b/docs/Export-account-option-in-the-Google-Authenticator_300px.webp differ
diff --git a/docs/Exported-QR-codes.webp b/docs/Exported-QR-codes.webp
new file mode 100644
index 0000000..35ecd5f
Binary files /dev/null and b/docs/Exported-QR-codes.webp differ
diff --git a/docs/Exported-QR-codes_300px.webp b/docs/Exported-QR-codes_300px.webp
new file mode 100644
index 0000000..18d91c3
Binary files /dev/null and b/docs/Exported-QR-codes_300px.webp differ
diff --git a/docs/Transfer-accounts-option-in-the-Google-Authenticator.webp b/docs/Transfer-accounts-option-in-the-Google-Authenticator.webp
new file mode 100644
index 0000000..b8bbf78
Binary files /dev/null and b/docs/Transfer-accounts-option-in-the-Google-Authenticator.webp differ
diff --git a/docs/Transfer-accounts-option-in-the-Google-Authenticator_300px.webp b/docs/Transfer-accounts-option-in-the-Google-Authenticator_300px.webp
new file mode 100644
index 0000000..67f9ea3
Binary files /dev/null and b/docs/Transfer-accounts-option-in-the-Google-Authenticator_300px.webp differ
diff --git a/src/extract_otp_secrets.py b/src/extract_otp_secrets.py
index 7ac9b40..a03b4df 100644
--- a/src/extract_otp_secrets.py
+++ b/src/extract_otp_secrets.py
@@ -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}"
diff --git a/tests/data/qr_but_without_otp.png b/tests/data/qr_but_without_otp.png
new file mode 100644
index 0000000..6d4ee04
Binary files /dev/null and b/tests/data/qr_but_without_otp.png differ
diff --git a/tests/extract_otp_secrets_test.py b/tests/extract_otp_secrets_test.py
index b8e90db..9c56021 100644
--- a/tests/extract_otp_secrets_test.py
+++ b/tests/extract_otp_secrets_test.py
@@ -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: