allow to choose qr reader for images

cv2_1
scito 1 year ago
parent 2dea161cdc
commit 1f04dd71e2

@ -1,7 +1,7 @@
{ {
"recommendations": [ "recommendations": [
"ms-python.python", "ms-python.python",
"mms-python.isort", "ms-python.isort",
"tamasfe.even-better-toml", "tamasfe.even-better-toml",
] ]
} }

@ -32,7 +32,6 @@
"request": "launch", "request": "launch",
"program": "src/extract_otp_secrets.py", "program": "src/extract_otp_secrets.py",
"args": [ "args": [
"--qr", "CV2"
], ],
"console": "integratedTerminal" "console": "integratedTerminal"
}, },

@ -2,6 +2,7 @@ FROM python:3.11-slim-bullseye
# For debugging # For debugging
# docker build . -t extract_otp_secrets --pull --build-arg RUN_TESTS=false # 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 /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 # 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

@ -1,6 +1,7 @@
FROM python:3.11-alpine FROM python:3.11-alpine
# For debugging # 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 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 /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 # 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

@ -8,8 +8,8 @@ protobuf = "*"
qrcode = "*" qrcode = "*"
pillow = "*" pillow = "*"
qreader = "*" qreader = "*"
opencv-python = "*" opencv-contrib-python = "*"
# for macOS: opencv-python = "<=4.7.0" # for macOS: opencv-contrib-python = "<=4.7.0"
# for PYTHON <= 3.7: typing_extensions = "*" # for PYTHON <= 3.7: typing_extensions = "*"
[dev-packages] [dev-packages]

24
Pipfile.lock generated

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "d07d5e2bd005a0045969de4ed2427a1edc17c0fee0bde853aef1437da16b31ec" "sha256": "beffcba766af29a6a313c019cc98ab27e61c6dd433d02df0917fdb3808b90379"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": { "requires": {
@ -50,19 +50,39 @@
"markers": "python_version >= '3.10'", "markers": "python_version >= '3.10'",
"version": "==1.24.1" "version": "==1.24.1"
}, },
"opencv-python": { "opencv-contrib-python": {
"hashes": [ "hashes": [
"sha256:1a48c2f24440cfd6e49c84dbe39c39feff5efbc90be8299c76e7141973d403b6",
"sha256:2b8e3a1a7af31ebed28487d161ca4be0edd0b0e241667c6e9c842ac683313b2f",
"sha256:2f0c32b0f2f55255632a44bdcfa185f88c7fb6d2616869942aff9d5a39df4997",
"sha256:35e9a3809da10a47189c06d4d78b8e7821b9a3578dec8cbddf6ee1675bd83557",
"sha256:3a00e12546e5578f6bb7ed408c37fcfea533d74e9691cfaf40926f6b43295577", "sha256:3a00e12546e5578f6bb7ed408c37fcfea533d74e9691cfaf40926f6b43295577",
"sha256:6d1c993811f92ddd7919314ada7b9be1f23db1c73f1384915c834dee8549c0b9", "sha256:6d1c993811f92ddd7919314ada7b9be1f23db1c73f1384915c834dee8549c0b9",
"sha256:7a08f9d1f9dd52de63a7bb448ab7d6d4a1a85b767c2358501d968d1e4d95098d", "sha256:7a08f9d1f9dd52de63a7bb448ab7d6d4a1a85b767c2358501d968d1e4d95098d",
"sha256:7a75f1775790106e54bcfb101c0e00e1f801a57d9baebc82d0b6758fc83a4ca0",
"sha256:86f4b60b9536948f16d2170ba3a9b22d3955a957dc61a9bc56e53692c6db2c7e", "sha256:86f4b60b9536948f16d2170ba3a9b22d3955a957dc61a9bc56e53692c6db2c7e",
"sha256:9829e6efedde1d1b8419c5bd4d62d289ecbf44ae35b843c6da9e3cbcba1a9a8a", "sha256:9829e6efedde1d1b8419c5bd4d62d289ecbf44ae35b843c6da9e3cbcba1a9a8a",
"sha256:abc6adfa8694f71a4caffa922b279bd9d96954a37eee40b147f613c64310b411", "sha256:abc6adfa8694f71a4caffa922b279bd9d96954a37eee40b147f613c64310b411",
"sha256:b4033a164b2e2ea0049ba8c1194dab82dca680953ac36f33d1cc2c060906555f",
"sha256:e3967b1f3d74b8c70be724dbc07921faec87e8806cc87b2db5e7057815d6a08c",
"sha256:e770e9f653a0e5e72b973adb8213fae2df4642730ba1faf31e73a54287a4d5d4" "sha256:e770e9f653a0e5e72b973adb8213fae2df4642730ba1faf31e73a54287a4d5d4"
], ],
"index": "pypi", "index": "pypi",
"version": "==4.7.0.68" "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": { "pillow": {
"hashes": [ "hashes": [
"sha256:03150abd92771742d4a8cd6f2fa6246d847dcd2e332a18d0c15cc75bf6703040", "sha256:03150abd92771742d4a8cd6f2fa6246d847dcd2e332a18d0c15cc75bf6703040",

@ -34,8 +34,8 @@ dependencies = [
"Pillow", "Pillow",
"qreader", "qreader",
"pyzbar", "pyzbar",
"opencv-python<=4.7.0; sys_platform == 'darwin'", "opencv-contrib-python<=4.7.0; sys_platform == 'darwin'",
"opencv-python; sys_platform != 'darwin'", "opencv-contrib-python; sys_platform != 'darwin'",
"typing_extensions; python_version<='3.7'", "typing_extensions; python_version<='3.7'",
] ]
description = "Extract two-factor authentication (2FA, TFA, OTP) secret keys from export QR codes of 'Google Authenticator' app" description = "Extract two-factor authentication (2FA, TFA, OTP) secret keys from export QR codes of 'Google Authenticator' app"

@ -2,7 +2,7 @@ protobuf
qrcode qrcode
Pillow Pillow
qreader qreader
opencv-python<=4.7.0; sys_platform == 'darwin' opencv-contrib-python<=4.7.0; sys_platform == 'darwin'
opencv-python; sys_platform != 'darwin' opencv-contrib-python; sys_platform != 'darwin'
pyzbar pyzbar
typing_extensions; python_version<='3.7' typing_extensions; python_version<='3.7'

@ -113,7 +113,7 @@ Otps = List[Otp]
# PYTHON > 3.9: OtpUrls = list[OtpUrl] # PYTHON > 3.9: OtpUrls = list[OtpUrl]
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 # 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 '+') 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: 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('--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('--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('--csv', '-c', help='export csv file or - for stdout', metavar=('FILE'))
arg_parser.add_argument('--keepass', '-k', help='export totp/hotp csv file(s) for KeePass, - for stdout', metavar=('FILE')) arg_parser.add_argument('--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 verbose = args.verbose if args.verbose else 0
quiet = True if args.quiet else False quiet = True if args.quiet else False
if verbose: print(f"QReader installed: {qreader_available}") 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 return args
@ -202,36 +205,48 @@ def extract_otps_from_camera(args: Args) -> Otps:
otps: Otps = [] otps: Otps = []
qr_mode = QRMode[args.qr] qr_mode = QRMode[args.qr]
if verbose: print(f"QR reading mode: {qr_mode}")
cam = cv2.VideoCapture(args.camera) 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) cv2.namedWindow(window_name, cv2.WINDOW_AUTOSIZE)
decoder = QReader() qreader = QReader()
cv2_qr = cv2.QRCodeDetector()
cv2_qr_wechat = cv2.wechat_qrcode.WeChatQRCode()
while True: while True:
success, img = cam.read() success, img = cam.read()
new_otps_count = 0
if not success: if not success:
eprint("ERROR: Failed to capture image") eprint("ERROR: Failed to capture image")
break break
if qr_mode in [QRMode.QREADER, QRMode.DEEP_QREADER]: 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: 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: elif qr_mode == QRMode.QREADER:
otp_url = decoder.decode(img, bbox) if found else None otp_url = qreader.decode(img, bbox) if found else None
new_otps_count = 0
if otp_url: if otp_url:
new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args) new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args)
if found: if found:
cv2.rectangle(img, (bbox[0], bbox[1]), (bbox[2], bbox[3]), get_color(new_otps_count, otp_url), RECT_THICKNESS) 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): for qrcode in zbar.decode(img):
otp_url = qrcode.data.decode('utf-8') otp_url = qrcode.data.decode('utf-8')
pts = numpy.array([qrcode.polygon], numpy.int32) pts = numpy.array([qrcode.polygon], numpy.int32)
pts = pts.reshape((-1, 1, 2)) pts = pts.reshape((-1, 1, 2))
new_otps_count = extract_otps_from_otp_url(otp_url, otp_urls, otps, args) 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) 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: else:
assert False, f"ERROR: Wrong QReader mode {qr_mode.name}" assert False, f"ERROR: Wrong QReader mode {qr_mode.name}"
@ -265,7 +280,6 @@ def extract_otps_from_camera(args: Args) -> Otps:
return otps return otps
# TODO write test
def extract_otps_from_otp_url(otp_url: str, otp_urls: OtpUrls, otps: Otps, args: Args) -> int: 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.''' '''Returns -1 if opt_url was already added.'''
if otp_url and verbose: print(otp_url) 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: for infile in args.infile:
if verbose: print(f"Processing infile {infile}") if verbose: print(f"Processing infile {infile}")
files_count += 1 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 verbose: print(line)
if line.startswith('#') or line == '': continue if line.startswith('#') or line == '': continue
urls_count += 1 urls_count += 1
@ -297,7 +311,7 @@ def extract_otps_from_files(args: Args) -> Otps:
return 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 # stdin stream cannot be rewinded, thus distinguish, use - for utf-8 stdin and = for binary image stdin
if filename != '=': if filename != '=':
check_file_exists(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 # could not process text file, try reading as image
if filename != '-' and qreader_available: if filename != '-' and qreader_available:
return convert_img_to_otp_url(filename) return convert_img_to_otp_url(filename, args)
return [] return []
@ -372,7 +386,7 @@ def extract_otp_from_otp_url(otpauth_migration_url: str, otps: Otps, urls_count:
return new_otps_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}") if verbose: print(f"Reading image {filename}")
try: try:
if filename != '=': if filename != '=':
@ -396,12 +410,35 @@ def convert_img_to_otp_url(filename: str) -> OtpUrls:
if img is None: if img is None:
abort(f"\nERROR: Unable to open file for reading.\ninput file: {filename}") abort(f"\nERROR: Unable to open file for reading.\ninput file: {filename}")
decoded_text = QReader().detect_and_decode(img) qr_mode = QRMode[args.qr]
if decoded_text is None: 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}") abort(f"\nERROR: Unable to read QR Code from file.\ninput file: {filename}")
except Exception as e: except Exception as e:
abort(f"\nERROR: Encountered exception '{e}'.\ninput file: {filename}") abort(f"\nERROR: Encountered exception '{e}'.\ninput file: {filename}")
return [decoded_text] return otp_urls
# PYTHON >= 3.10 use: pb.MigrationPayload | None # PYTHON >= 3.10 use: pb.MigrationPayload | None

@ -1,11 +1,22 @@
import pytest
from typing import Any from typing import Any
import pytest
from extract_otp_secrets import QRMode
def pytest_addoption(parser: pytest.Parser) -> None: def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption("--relaxed", action='store_true', help="run tests in relaxed mode") 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 @pytest.fixture
def relaxed(request: pytest.FixtureRequest) -> Any: def relaxed(request: pytest.FixtureRequest) -> Any:
return request.config.getoption("--relaxed") 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])

@ -1,4 +1,6 @@
QReader installed: True QReader installed: True
QR reading mode: ZBAR
Input files: ['example_export.txt'] Input files: ['example_export.txt']
Processing infile example_export.txt Processing infile example_export.txt
Reading lines of example_export.txt Reading lines of example_export.txt

@ -582,6 +582,18 @@ def test_img_qr_reader_from_file_happy_path(capsys: pytest.CaptureFixture[str])
assert captured.err == '' 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 @pytest.mark.qreader
def test_extract_multiple_files_and_mixed(capsys: pytest.CaptureFixture[str]) -> None: def test_extract_multiple_files_and_mixed(capsys: pytest.CaptureFixture[str]) -> None:
# Act # Act

Loading…
Cancel
Save