From 1f04dd71e2a5b573f7545f011799a2a9123ac381 Mon Sep 17 00:00:00 2001 From: scito Date: Sat, 31 Dec 2022 15:41:37 +0100 Subject: [PATCH] allow to choose qr reader for images --- .vscode/extensions.json | 2 +- .vscode/launch.json | 1 - Dockerfile | 1 + Dockerfile_only_txt | 1 + Pipfile | 4 +- Pipfile.lock | 24 +++++++++- pyproject.toml | 4 +- requirements.txt | 4 +- src/extract_otp_secrets.py | 73 ++++++++++++++++++++++------- tests/conftest.py | 13 ++++- tests/data/print_verbose_output.txt | 2 + tests/extract_otp_secrets_test.py | 12 +++++ 12 files changed, 112 insertions(+), 29 deletions(-) diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 118b1f5..500cc75 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,7 @@ { "recommendations": [ "ms-python.python", - "mms-python.isort", + "ms-python.isort", "tamasfe.even-better-toml", ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index b57d456..eec54ae 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -32,7 +32,6 @@ "request": "launch", "program": "src/extract_otp_secrets.py", "args": [ - "--qr", "CV2" ], "console": "integratedTerminal" }, diff --git a/Dockerfile b/Dockerfile index 377f226..2a5344c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ FROM python:3.11-slim-bullseye # For debugging # docker build . -t extract_otp_secrets --pull --build-arg RUN_TESTS=false +# docker run --rm -v "$(pwd)":/files:ro extract_otp_secrets # docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets # docker run --entrypoint /bin/bash -it --rm -v "$(pwd)":/files:ro --device="/dev/video0:/dev/video0" --env="DISPLAY" -v /tmp/.X11-unix:/tmp/.X11-unix:ro extract_otp_secrets diff --git a/Dockerfile_only_txt b/Dockerfile_only_txt index 061200e..6561931 100644 --- a/Dockerfile_only_txt +++ b/Dockerfile_only_txt @@ -1,6 +1,7 @@ FROM python:3.11-alpine # For debugging +# docker run --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt # docker build . -t extract_otp_secrets_only_txt -f Dockerfile_only_txt --pull --build-arg RUN_TESTS=false # docker run --entrypoint /bin/sh -it --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt # docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secrets_only_txt extract_otp_secrets_test.py -k "not qreader" --relaxed diff --git a/Pipfile b/Pipfile index bb2458f..8b6c822 100644 --- a/Pipfile +++ b/Pipfile @@ -8,8 +8,8 @@ protobuf = "*" qrcode = "*" pillow = "*" qreader = "*" -opencv-python = "*" -# for macOS: opencv-python = "<=4.7.0" +opencv-contrib-python = "*" +# for macOS: opencv-contrib-python = "<=4.7.0" # for PYTHON <= 3.7: typing_extensions = "*" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index 8752bd1..bf17ee5 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "d07d5e2bd005a0045969de4ed2427a1edc17c0fee0bde853aef1437da16b31ec" + "sha256": "beffcba766af29a6a313c019cc98ab27e61c6dd433d02df0917fdb3808b90379" }, "pipfile-spec": 6, "requires": { @@ -50,19 +50,39 @@ "markers": "python_version >= '3.10'", "version": "==1.24.1" }, - "opencv-python": { + "opencv-contrib-python": { "hashes": [ + "sha256:1a48c2f24440cfd6e49c84dbe39c39feff5efbc90be8299c76e7141973d403b6", + "sha256:2b8e3a1a7af31ebed28487d161ca4be0edd0b0e241667c6e9c842ac683313b2f", + "sha256:2f0c32b0f2f55255632a44bdcfa185f88c7fb6d2616869942aff9d5a39df4997", + "sha256:35e9a3809da10a47189c06d4d78b8e7821b9a3578dec8cbddf6ee1675bd83557", "sha256:3a00e12546e5578f6bb7ed408c37fcfea533d74e9691cfaf40926f6b43295577", "sha256:6d1c993811f92ddd7919314ada7b9be1f23db1c73f1384915c834dee8549c0b9", "sha256:7a08f9d1f9dd52de63a7bb448ab7d6d4a1a85b767c2358501d968d1e4d95098d", + "sha256:7a75f1775790106e54bcfb101c0e00e1f801a57d9baebc82d0b6758fc83a4ca0", "sha256:86f4b60b9536948f16d2170ba3a9b22d3955a957dc61a9bc56e53692c6db2c7e", "sha256:9829e6efedde1d1b8419c5bd4d62d289ecbf44ae35b843c6da9e3cbcba1a9a8a", "sha256:abc6adfa8694f71a4caffa922b279bd9d96954a37eee40b147f613c64310b411", + "sha256:b4033a164b2e2ea0049ba8c1194dab82dca680953ac36f33d1cc2c060906555f", + "sha256:e3967b1f3d74b8c70be724dbc07921faec87e8806cc87b2db5e7057815d6a08c", "sha256:e770e9f653a0e5e72b973adb8213fae2df4642730ba1faf31e73a54287a4d5d4" ], "index": "pypi", "version": "==4.7.0.68" }, + "opencv-python": { + "hashes": [ + "sha256:3a00e12546e5578f6bb7ed408c37fcfea533d74e9691cfaf40926f6b43295577", + "sha256:6d1c993811f92ddd7919314ada7b9be1f23db1c73f1384915c834dee8549c0b9", + "sha256:7a08f9d1f9dd52de63a7bb448ab7d6d4a1a85b767c2358501d968d1e4d95098d", + "sha256:86f4b60b9536948f16d2170ba3a9b22d3955a957dc61a9bc56e53692c6db2c7e", + "sha256:9829e6efedde1d1b8419c5bd4d62d289ecbf44ae35b843c6da9e3cbcba1a9a8a", + "sha256:abc6adfa8694f71a4caffa922b279bd9d96954a37eee40b147f613c64310b411", + "sha256:e770e9f653a0e5e72b973adb8213fae2df4642730ba1faf31e73a54287a4d5d4" + ], + "markers": "python_version >= '3.6'", + "version": "==4.7.0.68" + }, "pillow": { "hashes": [ "sha256:03150abd92771742d4a8cd6f2fa6246d847dcd2e332a18d0c15cc75bf6703040", diff --git a/pyproject.toml b/pyproject.toml index 20c36d8..4cb5f4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,8 +34,8 @@ dependencies = [ "Pillow", "qreader", "pyzbar", - "opencv-python<=4.7.0; sys_platform == 'darwin'", - "opencv-python; sys_platform != 'darwin'", + "opencv-contrib-python<=4.7.0; sys_platform == 'darwin'", + "opencv-contrib-python; sys_platform != 'darwin'", "typing_extensions; python_version<='3.7'", ] description = "Extract two-factor authentication (2FA, TFA, OTP) secret keys from export QR codes of 'Google Authenticator' app" diff --git a/requirements.txt b/requirements.txt index ee7e531..07f99f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ protobuf qrcode Pillow qreader -opencv-python<=4.7.0; sys_platform == 'darwin' -opencv-python; sys_platform != 'darwin' +opencv-contrib-python<=4.7.0; sys_platform == 'darwin' +opencv-contrib-python; sys_platform != 'darwin' pyzbar typing_extensions; python_version<='3.7' diff --git a/src/extract_otp_secrets.py b/src/extract_otp_secrets.py index 59a3f1f..eae4161 100644 --- a/src/extract_otp_secrets.py +++ b/src/extract_otp_secrets.py @@ -113,7 +113,7 @@ Otps = List[Otp] # PYTHON > 3.9: OtpUrls = list[OtpUrl] OtpUrls = List[OtpUrl] -QRMode = Enum('QRMode', ['QREADER', 'DEEP_QREADER', 'CV2'], start=0) +QRMode = Enum('QRMode', ['QREADER', 'DEEP_QREADER', 'ZBAR', 'CV2', 'WECHAT'], start=0) # Constants @@ -159,7 +159,7 @@ python extract_otp_secrets.py = < example_export.png""" b) image file containing a QR code or = for stdin for an image containing a QR code""", nargs='*' if qreader_available else '+') if qreader_available: arg_parser.add_argument('--camera', '-C', help='camera number of system (default camera: 0)', default=0, nargs=1, metavar=('NUMBER')) - arg_parser.add_argument('--qr', '-Q', help=f'initial QR reader for camera (default: {QRMode.QREADER.name})', type=str, choices=[mode.name for mode in QRMode], default=QRMode.QREADER.name) + arg_parser.add_argument('--qr', '-Q', help=f'QR reader (default: {QRMode.ZBAR.name})', type=str, choices=[mode.name for mode in QRMode], default=QRMode.ZBAR.name) arg_parser.add_argument('--json', '-j', help='export json file or - for stdout', metavar=('FILE')) arg_parser.add_argument('--csv', '-c', help='export csv file or - for stdout', metavar=('FILE')) arg_parser.add_argument('--keepass', '-k', help='export totp/hotp csv file(s) for KeePass, - for stdout', metavar=('FILE')) @@ -175,6 +175,9 @@ b) image file containing a QR code or = for stdin for an image containing a QR c verbose = args.verbose if args.verbose else 0 quiet = True if args.quiet else False if verbose: print(f"QReader installed: {qreader_available}") + if qreader_available: + if verbose > 1: print(f"CV2 version: {cv2.__version__}") + if verbose: print(f"QR reading mode: {args.qr}\n") return args @@ -202,36 +205,48 @@ def extract_otps_from_camera(args: Args) -> Otps: otps: Otps = [] qr_mode = QRMode[args.qr] - if verbose: print(f"QR reading mode: {qr_mode}") cam = cv2.VideoCapture(args.camera) - window_name = "Extract OTP Secret Keys: Capture QR Codes from Camera" + window_name = "Extract OTP Secrets: Capture QR Codes from Camera" cv2.namedWindow(window_name, cv2.WINDOW_AUTOSIZE) - decoder = QReader() + qreader = QReader() + cv2_qr = cv2.QRCodeDetector() + cv2_qr_wechat = cv2.wechat_qrcode.WeChatQRCode() while True: success, img = cam.read() + new_otps_count = 0 if not success: eprint("ERROR: Failed to capture image") break if qr_mode in [QRMode.QREADER, QRMode.DEEP_QREADER]: - bbox, found = decoder.detect(img) + bbox, found = qreader.detect(img) if qr_mode == QRMode.DEEP_QREADER: - otp_url = decoder.detect_and_decode(img) + otp_url = qreader.detect_and_decode(img, True) elif qr_mode == QRMode.QREADER: - otp_url = decoder.decode(img, bbox) if found else None - new_otps_count = 0 + otp_url = qreader.decode(img, bbox) if found else None if otp_url: new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args) if found: cv2.rectangle(img, (bbox[0], bbox[1]), (bbox[2], bbox[3]), get_color(new_otps_count, otp_url), RECT_THICKNESS) - elif qr_mode == QRMode.CV2: + elif qr_mode == QRMode.ZBAR: for qrcode in zbar.decode(img): otp_url = qrcode.data.decode('utf-8') pts = numpy.array([qrcode.polygon], numpy.int32) pts = pts.reshape((-1, 1, 2)) new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args) cv2.polylines(img, [pts], True, get_color(new_otps_count, otp_url), RECT_THICKNESS) + elif qr_mode in [QRMode.CV2, QRMode.WECHAT]: + if QRMode.CV2: + otp_url, raw_pts, _ = cv2_qr.detectAndDecode(img) + else: + otp_url, raw_pts = cv2_qr_wechat.detectAndDecode(img) + if raw_pts is not None: + if otp_url: + new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args) + pts = numpy.array([raw_pts], numpy.int32) + pts = pts.reshape((-1, 1, 2)) + cv2.polylines(img, [pts], True, get_color(new_otps_count, otp_url), RECT_THICKNESS) else: assert False, f"ERROR: Wrong QReader mode {qr_mode.name}" @@ -265,7 +280,6 @@ def extract_otps_from_camera(args: Args) -> Otps: return otps -# TODO write test def extract_otps_from_otp_url(otp_url: str, otp_urls: OtpUrls, otps: Otps, args: Args) -> int: '''Returns -1 if opt_url was already added.''' if otp_url and verbose: print(otp_url) @@ -288,7 +302,7 @@ def extract_otps_from_files(args: Args) -> Otps: for infile in args.infile: if verbose: print(f"Processing infile {infile}") files_count += 1 - for line in get_otp_urls_from_file(infile): + for line in get_otp_urls_from_file(infile, args): if verbose: print(line) if line.startswith('#') or line == '': continue urls_count += 1 @@ -297,7 +311,7 @@ def extract_otps_from_files(args: Args) -> Otps: return otps -def get_otp_urls_from_file(filename: str) -> OtpUrls: +def get_otp_urls_from_file(filename: str, args: Args) -> OtpUrls: # stdin stream cannot be rewinded, thus distinguish, use - for utf-8 stdin and = for binary image stdin if filename != '=': check_file_exists(filename) @@ -307,7 +321,7 @@ def get_otp_urls_from_file(filename: str) -> OtpUrls: # could not process text file, try reading as image if filename != '-' and qreader_available: - return convert_img_to_otp_url(filename) + return convert_img_to_otp_url(filename, args) return [] @@ -372,7 +386,7 @@ def extract_otp_from_otp_url(otpauth_migration_url: str, otps: Otps, urls_count: return new_otps_count -def convert_img_to_otp_url(filename: str) -> OtpUrls: +def convert_img_to_otp_url(filename: str, args: Args) -> OtpUrls: if verbose: print(f"Reading image {filename}") try: if filename != '=': @@ -396,12 +410,35 @@ def convert_img_to_otp_url(filename: str) -> OtpUrls: if img is None: abort(f"\nERROR: Unable to open file for reading.\ninput file: {filename}") - decoded_text = QReader().detect_and_decode(img) - if decoded_text is None: + qr_mode = QRMode[args.qr] + otp_urls: OtpUrls = [] + if qr_mode == QRMode.QREADER: + # otp_url = QReader().detect_and_decode(img, False) # broken + qreader = QReader() + bbox, found = qreader.detect(img) + if found: + otp_url = qreader.decode(img, bbox) + otp_urls.append(otp_url) + elif qr_mode == QRMode.DEEP_QREADER: + otp_url = QReader().detect_and_decode(img, True) + otp_urls.append(otp_url) + elif qr_mode == QRMode.CV2: + otp_url, _, _ = cv2.QRCodeDetector().detectAndDecode(img) + otp_urls.append(otp_url) + elif qr_mode == QRMode.WECHAT: + otp_url, _ = cv2.wechat_qrcode.WeChatQRCode().detectAndDecode(img) + otp_urls += list(otp_url) + elif qr_mode == QRMode.ZBAR: + qrcodes = zbar.decode(img) + otp_urls += [qrcode.data.decode('utf-8') for qrcode in qrcodes] + else: + assert False, f"ERROR: Wrong QReader mode {qr_mode.name}" + + if len(otp_urls) == 0: abort(f"\nERROR: Unable to read QR Code from file.\ninput file: {filename}") except Exception as e: abort(f"\nERROR: Encountered exception '{e}'.\ninput file: {filename}") - return [decoded_text] + return otp_urls # PYTHON >= 3.10 use: pb.MigrationPayload | None diff --git a/tests/conftest.py b/tests/conftest.py index 46c17f3..10c03c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,22 @@ -import pytest from typing import Any +import pytest + +from extract_otp_secrets import QRMode + def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption("--relaxed", action='store_true', help="run tests in relaxed mode") + parser.addoption("--fast", action="store_true", help="faster execution, do not run all combinations") @pytest.fixture def relaxed(request: pytest.FixtureRequest) -> Any: return request.config.getoption("--relaxed") + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + if "qr_mode" in metafunc.fixturenames: + number = 2 if metafunc.config.getoption("fast") else len(QRMode) + qr_modes = [mode.name for mode in QRMode] + metafunc.parametrize("qr_mode", qr_modes[0:number]) diff --git a/tests/data/print_verbose_output.txt b/tests/data/print_verbose_output.txt index aefbb4f..675483c 100644 --- a/tests/data/print_verbose_output.txt +++ b/tests/data/print_verbose_output.txt @@ -1,4 +1,6 @@ QReader installed: True +QR reading mode: ZBAR + Input files: ['example_export.txt'] Processing infile example_export.txt Reading lines of example_export.txt diff --git a/tests/extract_otp_secrets_test.py b/tests/extract_otp_secrets_test.py index 3486c3a..206aa70 100644 --- a/tests/extract_otp_secrets_test.py +++ b/tests/extract_otp_secrets_test.py @@ -582,6 +582,18 @@ def test_img_qr_reader_from_file_happy_path(capsys: pytest.CaptureFixture[str]) assert captured.err == '' +@pytest.mark.qreader +def test_img_qr_reader_by_parameter(capsys: pytest.CaptureFixture[str], qr_mode: str) -> None: + # Act + extract_otp_secrets.main(['--qr', qr_mode, 'tests/data/test_googleauth_export.png']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG + assert captured.err == '' + + @pytest.mark.qreader def test_extract_multiple_files_and_mixed(capsys: pytest.CaptureFixture[str]) -> None: # Act