diff --git a/.github/workflows/ci_docker.yml b/.github/workflows/ci_docker.yml index fb01f8d..d59eee6 100644 --- a/.github/workflows/ci_docker.yml +++ b/.github/workflows/ci_docker.yml @@ -54,6 +54,8 @@ jobs: tags: | scit0/extract_otp_secret_keys_no_qr_reader:latest ghcr.io/scito/extract_otp_secret_keys_no_qr_reader:latest + scit0/extract_otp_secret_keys:latest-no-qreader + ghcr.io/scito/extract_otp_secret_keys:latest-no-qreader # build on feature branches, push only on master branch # TODO push: ${{ github.ref == 'refs/heads/master' }} push: true diff --git a/README.md b/README.md index d5deba5..dd98f6d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,16 @@ cd extract_otp_secret_keys ## Usage -### With builtin QR decoder +### Capture QR codes from camera + +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 with the file as input: + + python extract_otp_secret_keys.py + +### With builtin QR decoder from image files 1. Open "Google Authenticator" app on the mobile phone 2. Export the QR codes from "Google Authenticator" app @@ -30,7 +39,7 @@ cd extract_otp_secret_keys python extract_otp_secret_keys.py example_export.png -### With external QR decoder app +### 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 @@ -43,22 +52,28 @@ cd extract_otp_secret_keys ## Program help: arguments and options -
usage: extract_otp_secret_keys.py [-h] [--json FILE] [--csv FILE] [--keepass FILE] [--printqr] [--saveqr DIR] [--verbose | --quiet] infile [infile ...] +usage: extract_otp_secret_keys.py [-h] [--camera NUMBER] [--json FILE] [--csv FILE] [--keepass FILE] [--printqr] [--saveqr DIR] [--verbose | --quiet] [infile ...] + +Extracts one time password (OTP) secret keys from QR codes, e.g. from Google Authenticator app. +If no infiles are provided, the QR codes are interactively captured from the camera. positional arguments: - infile 1) file or - for stdin with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored; or 2) image file containing a QR code or = for stdin for an image containing a QR code + infile a) file or - for stdin with 'otpauth-migration://...' URLs separated by newlines, lines starting with # are ignored; + b) image file containing a QR code or = for stdin for an image containing a QR code options: - -h, --help show this help message and exit - --json FILE, -j FILE export json file or - for stdout - --csv FILE, -c FILE export csv file or - for stdout - --keepass FILE, -k FILE export totp/hotp csv file(s) for KeePass, - for stdout - --printqr, -p print QR code(s) as text to the terminal (requires qrcode module) - --saveqr DIR, -s DIR save QR code(s) as images to the given folder (requires qrcode module) - --verbose, -v verbose output - --quiet, -q no stdout output, except output set by - + -h, --help show this help message and exit + --camera NUMBER, -C NUMBER camera number of system (default camera: 0) + --json FILE, -j FILE export json file or - for stdout + --csv FILE, -c FILE export csv file or - for stdout + --keepass FILE, -k FILE export totp/hotp csv file(s) for KeePass, - for stdout + --printqr, -p print QR code(s) as text to the terminal (requires qrcode module) + --saveqr DIR, -s DIR save QR code(s) as images to the given folder (requires qrcode module) + --verbose, -v verbose output + --quiet, -q no stdout output, except output set by - examples: +python extract_otp_secret_keys.py python extract_otp_secret_keys.py example_*.txt python extract_otp_secret_keys.py - < example_export.txt python extract_otp_secret_keys.py --csv - example_*.png | tail -n+2 @@ -318,6 +333,7 @@ docker run --rm -v "$(pwd)":/files:ro -i extract_otp_secret_keys = < example_exp docker run --entrypoint /bin/bash -it --rm -v "$(pwd)":/files:ro extract_otp_secret_keys docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys +docker login -uscit0 docker build . -t extract_otp_secret_keys_no_qr_reader -f Dockerfile_no_qr_reader --pull docker build . -t extract_otp_secret_keys_no_qr_reader -f Dockerfile_no_qr_reader --pull --build-arg run_tests=false docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys_no_qr_reader diff --git a/example_export.txt b/example_export.txt index 1af539f..6b0fa7e 100644 --- a/example_export.txt +++ b/example_export.txt @@ -1,4 +1,5 @@ # 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/ + # Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B diff --git a/extract_otp_secret_keys.py b/extract_otp_secret_keys.py index f467783..0ec87a8 100644 --- a/extract_otp_secret_keys.py +++ b/extract_otp_secret_keys.py @@ -45,35 +45,45 @@ import argparse import base64 import csv import fileinput -import importlib import json import os import re import sys import urllib.parse as urlparse +from enum import Enum +from operator import add + +from qrcode import QRCode import protobuf_generated_python.google_auth_pb2 -# These dynamic import are below: -# import cv2 -# import numpy -# from qreader import QReader +try: + import cv2 + import numpy + + try: + import pyzbar.pyzbar as zbar + from qreader import QReader + except ImportError as e: + abort(f""" +ERROR: Cannot import QReader module. This problem is probably due to the missing zbar shared library. +On Linux and macOS libzbar0 must be installed. +See in README.md for the installation of the libzbar0. +Exception: {e}""") + qreader_available = True +except ImportError as e: + qreader_available = False + def sys_main(): main(sys.argv[1:]) def main(sys_args): - global verbose, quiet, qreader_available - # allow to use sys.stdout with with (avoid closing) sys.stdout.close = lambda: None - # sys.stdout.reconfigure(encoding='utf-8') - args = parse_args(sys_args) - verbose = args.verbose if args.verbose else 0 - quiet = args.quiet otps = extract_otps(args) write_csv(args, otps) @@ -82,16 +92,25 @@ def main(sys_args): def parse_args(sys_args): - formatter = lambda prog: argparse.RawDescriptionHelpFormatter(prog, max_help_position=52) - example_text = '''examples: + global verbose, quiet + formatter = lambda prog: argparse.RawTextHelpFormatter(prog, max_help_position=52) + description_text = "Extracts one time password (OTP) secret keys from QR codes, e.g. from Google Authenticator app." + if qreader_available: + description_text += "\nIf no infiles are provided, the QR codes are interactively captured from the camera." + example_text = """examples: +python extract_otp_secret_keys.py python extract_otp_secret_keys.py example_*.txt python extract_otp_secret_keys.py - < example_export.txt python extract_otp_secret_keys.py --csv - example_*.png | tail -n+2 -python extract_otp_secret_keys.py = < example_export.png''' +python extract_otp_secret_keys.py = < example_export.png""" arg_parser = argparse.ArgumentParser(formatter_class=formatter, + description=description_text, epilog=example_text) - arg_parser.add_argument('infile', help='1) file or - for stdin with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored; or 2) image file containing a QR code or = for stdin for an image containing a QR code', nargs='+') + arg_parser.add_argument('infile', help="""a) file or - for stdin with 'otpauth-migration://...' URLs separated by newlines, lines starting with # are ignored; +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('--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')) @@ -103,58 +122,122 @@ python extract_otp_secret_keys.py = < example_export.png''' args = arg_parser.parse_args(sys_args) if args.csv == '-' or args.json == '-' or args.keepass == '-': args.quiet = args.q = True + + verbose = args.verbose if args.verbose else 0 + quiet = True if args.quiet else False + return args def extract_otps(args): - global verbose, quiet - quiet = args.quiet + if not args.infile: + return extract_otps_from_camera(args) + else: + return extract_otps_from_files(args) + +def extract_otps_from_camera(args): + if verbose: print("Capture QR codes from camera") + otp_urls = [] + otps = [] + + QRMode = Enum('QRMode', ['QREADER', 'DEEP_QREADER', 'CV2'], start = 0) + qr_mode = QRMode.QREADER + 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" + cv2.namedWindow(window_name, cv2.WINDOW_AUTOSIZE) + font = cv2.FONT_HERSHEY_PLAIN + font_scale = 1 + font_thickness = 1 + pos_text = 5, 20 + font_dy = 0, cv2.getTextSize("M", font, font_scale, font_thickness)[0][1] + 5 + font_line = cv2.LINE_AA + text_color = 255, 255, 255 + rect_color = 255, 0, 255 + rect_color_success = 0, 255, 0 + rect_thickness = 5 + + decoder = QReader() + while True: + success, img = cam.read() + if not success: + eprint("ERROR: Failed to capture image") + break + if qr_mode in [QRMode.QREADER, QRMode.DEEP_QREADER]: + bbox, found = decoder.detect(img) + if qr_mode == QRMode.DEEP_QREADER: + otp_url = decoder.detect_and_decode(img) + elif qr_mode == QRMode.QREADER: + otp_url = decoder.decode(img, bbox) if found else None + if found: + cv2.rectangle(img, (bbox[0], bbox[1]), (bbox[2], bbox[3]), rect_color_success if otp_url else rect_color, rect_thickness) + if otp_url: + extract_otps_from_otp_url(otp_url, otp_urls, otps, args) + elif qr_mode == QRMode.CV2: + 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)) + cv2.polylines(img, [pts], True, rect_color_success if otp_url else rect_color, rect_thickness) + extract_otps_from_otp_url(otp_url, otp_urls, otps, args) + else: + assert False, f"ERROR: Wrong QReader mode {qreader.name}" + + cv2.putText(img, f"Mode: {qr_mode.name} (Hit space to change)", pos_text, font, font_scale, text_color, font_thickness, font_line) + cv2.putText(img, "Hit ESC to quit", tuple(map(add, pos_text, font_dy)), font, font_scale, text_color, font_thickness, font_line) + + window_dim = cv2.getWindowImageRect(window_name) + qrcodes_text = f"{len(otp_urls)} QR code{'s'[:len(otp_urls) != 1]} captured" + pos_qrcodes_text = window_dim[2] - cv2.getTextSize(qrcodes_text, font, font_scale, font_thickness)[0][0] - 5, pos_text[1] + cv2.putText(img, qrcodes_text, pos_qrcodes_text, font, font_scale, text_color, font_thickness, font_line) + + otps_text = f"{len(otps)} otp{'s'[:len(otps) != 1]} extracted" + pos_otps_text = window_dim[2] - cv2.getTextSize(otps_text, font, font_scale, font_thickness)[0][0] - 5, pos_text[1] + font_dy[1] + cv2.putText(img, otps_text, pos_otps_text, font, font_scale, text_color, font_thickness, font_line) + cv2.imshow(window_name, img) + + key = cv2.waitKey(1) & 0xFF + if key == 27 or key == ord('q') or key == 13: + # ESC pressed + break + elif key == 32: + qr_mode = QRMode((qr_mode.value + 1) % len(QRMode)) + if verbose: print(f"QR reading mode: {qr_mode}") + + cam.release() + cv2.destroyAllWindows() + + return otps + + +def extract_otps_from_otp_url(otp_url, otp_urls, otps, args): + if otp_url and verbose: print(otp_url) + if otp_url and otp_url not in otp_urls: + otp_urls.append(otp_url) + extract_otp_from_otp_url(otp_url, otps, len(otp_urls), len(otps), 'camera', args) + if verbose: print(f"{len(otps)} otp{'s'[:len(otps) != 1]} from {len(otp_urls)} QR code{'s'[:len(otp_urls) != 1]} extracted") + + +def extract_otps_from_files(args): otps = [] i = j = k = 0 - if verbose: print('Input files: {}'.format(args.infile)) + if verbose: print(f"Input files: {args.infile}") for infile in args.infile: - if verbose: print('Processing infile {}'.format(infile)) + if verbose: print(f"Processing infile {infile}") k += 1 - for line in get_lines_from_file(infile): + for line in get_otp_urls_from_file(infile): if verbose: print(line) if line.startswith('#') or line == '': continue i += 1 - payload = get_payload_from_line(line, i, infile) - - # pylint: disable=no-member - for raw_otp in payload.otp_parameters: - j += 1 - if verbose: print('\n{}. Secret Key'.format(j)) - secret = convert_secret_from_bytes_to_base32_str(raw_otp.secret) - otp_type_enum = 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 = { - "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 not quiet: - print_otp(otp) - if args.printqr: - print_qr(args, otp_url) - if args.saveqr: - save_qr(otp, args, j) - if not quiet: - print() - - otps.append(otp) - if verbose: print('{} infile(s) processed'.format(k)) + j = extract_otp_from_otp_url(line, otps, i, j, infile, args) + if verbose: print(f"{k} infile{'s'[:k != 1]} processed") return otps -def get_lines_from_file(filename): - global qreader_available +def get_otp_urls_from_file(filename): # stdin stream cannot be rewinded, thus distinguish, use - for utf-8 stdin and = for binary image stdin if filename != '=': check_file_exists(filename) @@ -163,45 +246,73 @@ def get_lines_from_file(filename): return lines # could not process text file, try reading as image - if filename != '-': - return convert_img_to_line(filename) + if filename != '-' and qreader_available: + return convert_img_to_otp_url(filename) + + return [] def read_lines_from_text_file(filename): - if verbose: print('Reading lines of {}'.format(filename)) + if verbose: print(f"Reading lines of {filename}") finput = fileinput.input(filename) try: lines = [] for line in (line.strip() for line in finput): if verbose: print(line) if is_binary(line): - abort('\nBinary input was given in stdin, please use = instead of - as infile argument for images.') + abort("\nBinary input was given in stdin, please use = instead of - as infile argument for images.") # unfortunately yield line leads to random test fails lines.append(line) if not lines: - eprint("WARN: {} is empty".format(filename.replace('-', 'stdin'))) + eprint(f"WARN: {filename.replace('-', 'stdin')} is empty") return lines except UnicodeDecodeError: if filename == '-': - abort('\nERROR: Unable to open text file form stdin. ' - 'In case you want read an image file from stdin, you must use "=" instead of "-".') + abort("\nERROR: Unable to open text file form stdin. " + "In case you want read an image file from stdin, you must use '=' instead of '-'.") else: # The file is probably an image, process below return None finally: finput.close() -def convert_img_to_line(filename): - try: - import cv2 - import numpy - except Exception as e: - eprint("WARNING: No cv2 or numpy module installed. Exception: {}".format(str(e))) - return [] - if verbose: print('Reading image {}'.format(filename)) +def extract_otp_from_otp_url(otpauth_migration_url, otps, i, j, infile, args): + payload = get_payload_from_otp_url(otpauth_migration_url, i, infile) + + # pylint: disable=no-member + for raw_otp in payload.otp_parameters: + j += 1 + if verbose: print(f"\n{j}. Secret Key") + secret = convert_secret_from_bytes_to_base32_str(raw_otp.secret) + otp_type_enum = 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 = { + "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 + } + otps.append(otp) + if not quiet: + print_otp(otp) + if args.printqr: + print_qr(args, otp_url) + if args.saveqr: + save_qr(otp, args, j) + if not quiet: + print() + + return j + + +def convert_img_to_otp_url(filename): + if verbose: print(f"Reading image {filename}") try: if filename != '=': - image = cv2.imread(filename) + img = cv2.imread(filename) else: try: stdin = sys.stdin.buffer.read() @@ -213,60 +324,48 @@ def convert_img_to_line(filename): try: img_array = numpy.frombuffer(stdin, dtype='uint8') except TypeError as e: - abort('\nERROR: Cannot read binary stdin buffer. Exception: {}'.format(str(e))) + abort(f"\nERROR: Cannot read binary stdin buffer. Exception: {e}") if not img_array.size: return [] - image = cv2.imdecode(img_array, cv2.IMREAD_UNCHANGED) - - if image is None: - abort('\nERROR: Unable to open file for reading.\ninput file: {}'.format(filename)) + img = cv2.imdecode(img_array, cv2.IMREAD_UNCHANGED) - # dynamic import of QReader since this module has a dependency to zbar lib and import it only when necessary - try: - from qreader import QReader - except ImportError as e: - abort(''' -ERROR: Cannot import QReader module. This problem is probably due to the missing zbar shared library. -On Linux and macOS libzbar0 must be installed. -See in README.md for the installation of the libzbar0. -Exception: {}'''.format(str(e))) + if img is None: + abort(f"\nERROR: Unable to open file for reading.\ninput file: {filename}") - decoder = QReader() - decoded_text = decoder.detect_and_decode(image=image) + decoded_text = QReader().detect_and_decode(img) if decoded_text is None: - abort('\nERROR: Unable to read QR Code from file.\ninput file: {}'.format(filename)) + abort(f"\nERROR: Unable to read QR Code from file.\ninput file: {filename}") return [decoded_text] except Exception as e: - abort('\nERROR: Encountered exception "{}".\ninput file: {}'.format(str(e), filename)) + abort(f"\nERROR: Encountered exception '{e}'.\ninput file: {filename}") -def get_payload_from_line(line, i, infile): - global verbose - if not line.startswith('otpauth-migration://'): - eprint( '\nWARN: line is not a otpauth-migration:// URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(infile, line)) - parsed_url = urlparse.urlparse(line) - if verbose > 1: print('\nDEBUG: parsed_url={}'.format(parsed_url)) +def get_payload_from_otp_url(otpauth_migration_url, i, input_source): + if not otpauth_migration_url.startswith('otpauth-migration://'): + eprint(f"\nWARN: line is not a otpauth-migration:// URL\ninput: {input_source}\nline '{otpauth_migration_url}'\nProbably a wrong file was given") + parsed_url = urlparse.urlparse(otpauth_migration_url) + if verbose > 2: print(f"\nDEBUG: parsed_url={parsed_url}") try: params = urlparse.parse_qs(parsed_url.query, strict_parsing=True) except: # Not necessary for Python >= 3.11 params = [] - if verbose > 1: print('\nDEBUG: querystring params={}'.format(params)) + if verbose > 2: print(f"\nDEBUG: querystring params={params}") if 'data' not in params: - abort('\nERROR: no data query parameter in input URL\ninput file: {}\nline "{}"\nProbably a wrong file was given'.format(infile, line)) + abort(f"\nERROR: no data query parameter in input URL\ninput file: {input_source}\nline '{otpauth_migration_url}'\nProbably a wrong file was given") data_base64 = params['data'][0] - if verbose > 1: print('\nDEBUG: data_base64={}'.format(data_base64)) + if verbose > 2: print(f"\nDEBUG: data_base64={data_base64}") data_base64_fixed = data_base64.replace(' ', '+') - if verbose > 1: print('\nDEBUG: data_base64_fixed={}'.format(data_base64)) + if verbose > 2: print(f"\nDEBUG: data_base64_fixed={data_base64_fixed}") data = base64.b64decode(data_base64_fixed, validate=True) payload = protobuf_generated_python.google_auth_pb2.MigrationPayload() try: payload.ParseFromString(data) except: - abort('\nERROR: Cannot decode otpauth-migration migration payload.\n' - 'data={}'.format(data_base64)) + abort(f"\nERROR: Cannot decode otpauth-migration migration payload.\n" + f"data={data_base64}") if verbose: - print('\n{}. Payload Line'.format(i), payload, sep='\n') + print(f"\n{i}. Payload Line", payload, sep='\n') return payload @@ -289,17 +388,17 @@ def build_otp_url(secret, raw_otp): url_params = {'secret': secret} if raw_otp.type == 1: url_params['counter'] = raw_otp.counter if raw_otp.issuer: url_params['issuer'] = raw_otp.issuer - otp_url = 'otpauth://{}/{}?'.format(get_otp_type_str_from_code(raw_otp.type), urlparse.quote(raw_otp.name)) + urlparse.urlencode( url_params) + otp_url = f"otpauth://{get_otp_type_str_from_code(raw_otp.type)}/{urlparse.quote(raw_otp.name)}?" + urlparse.urlencode(url_params) return otp_url def print_otp(otp): - print('Name: {}'.format(otp['name'])) - print('Secret: {}'.format(otp['secret'])) - if otp['issuer']: print('Issuer: {}'.format(otp['issuer'])) - print('Type: {}'.format(otp['type'])) + print(f"Name: {otp['name']}") + print(f"Secret: {otp['secret']}") + if otp['issuer']: print(f"Issuer: {otp['issuer']}") + print(f"Type: {otp['type']}") if otp['type'] == 'hotp': - print('Counter: {}'.format(otp['counter'])) + print(f"Counter: {otp['counter']}") if verbose: print(otp['url']) @@ -310,39 +409,34 @@ def save_qr(otp, args, j): pattern = re.compile(r'[\W_]+') file_otp_name = pattern.sub('', otp['name']) file_otp_issuer = pattern.sub('', otp['issuer']) - save_qr_file(args, otp['url'], '{}/{}-{}{}.png'.format(dir, j, file_otp_name, '-' + file_otp_issuer if file_otp_issuer else '')) + save_qr_file(args, otp['url'], f"{dir}/{j}-{file_otp_name}{'-' + file_otp_issuer if file_otp_issuer else ''}.png") return file_otp_issuer def save_qr_file(args, data, name): - from qrcode import QRCode - global verbose qr = QRCode() qr.add_data(data) img = qr.make_image(fill_color='black', back_color='white') - if verbose: print('Saving to {}'.format(name)) + if verbose: print(f"Saving to {name}") img.save(name) def print_qr(args, data): - from qrcode import QRCode qr = QRCode() qr.add_data(data) qr.print_ascii() def write_csv(args, otps): - global verbose, quiet if args.csv and len(otps) > 0: with open_file_or_stdout_for_csv(args.csv) as outfile: writer = csv.DictWriter(outfile, otps[0].keys()) writer.writeheader() writer.writerows(otps) - if not quiet: print("Exported {} otps to csv {}".format(len(otps), args.csv)) + if not quiet: print(f"Exported {len(otps)} otp{'s'[:len(otps) != 1]} to csv {args.csv}") def write_keepass_csv(args, otps): - global verbose, quiet if args.keepass and len(otps) > 0: has_totp = has_otp_type(otps, 'totp') has_hotp = has_otp_type(otps, 'hotp') @@ -360,7 +454,7 @@ def write_keepass_csv(args, otps): 'Title': otp['issuer'], 'User Name': otp['name'], 'TimeOtp-Secret-Base32': otp['secret'] if otp['type'] == 'totp' else None, - 'Group': "OTP/{}".format(otp['type'].upper()) + 'Group': f"OTP/{otp['type'].upper()}" }) count_totp_entries += 1 if has_hotp: @@ -374,20 +468,19 @@ def write_keepass_csv(args, otps): 'User Name': otp['name'], 'HmacOtp-Secret-Base32': otp['secret'] if otp['type'] == 'hotp' else None, 'HmacOtp-Counter': otp['counter'] if otp['type'] == 'hotp' else None, - 'Group': "OTP/{}".format(otp['type'].upper()) + 'Group': f"OTP/{otp['type'].upper()}" }) count_hotp_entries += 1 if not quiet: - if count_totp_entries > 0: print( "Exported {} totp entries to keepass csv file {}".format(count_totp_entries, otp_filename_totp)) - if count_hotp_entries > 0: print( "Exported {} hotp entries to keepass csv file {}".format(count_hotp_entries, otp_filename_hotp)) + if count_totp_entries > 0: print(f"Exported {count_totp_entries} totp entrie{'s'[:count_totp_entries != 1]} to keepass csv file {otp_filename_totp}") + if count_hotp_entries > 0: print(f"Exported {count_hotp_entries} hotp entrie{'s'[:count_hotp_entries != 1]} to keepass csv file {otp_filename_hotp}") def write_json(args, otps): - global verbose, quiet if args.json: with open_file_or_stdout(args.json) as outfile: json.dump(otps, outfile, indent=4) - if not quiet: print("Exported {} otp entries to json {}".format(len(otps), args.json)) + if not quiet: print(f"Exported {len(otps)} otp{'s'[:len(otps) != 1]} to json {args.json}") def has_otp_type(otps, otp_type): @@ -420,8 +513,8 @@ def open_file_or_stdout_for_csv(filename): def check_file_exists(filename): if filename != '-' and not os.path.isfile(filename): - abort('\nERROR: Input file provided is non-existent or not a file.' - '\ninput file: {}'.format(filename)) + abort(f"\nERROR: Input file provided is non-existent or not a file." + f"\ninput file: {filename}") def is_binary(line): @@ -432,11 +525,6 @@ def is_binary(line): return True -def check_module_available(module_name): - module_spec = importlib.util.find_spec(module_name) - return module_spec is not None - - def eprint(*args, **kwargs): '''Print to stderr.''' print(*args, file=sys.stderr, **kwargs) diff --git a/requirements.txt b/requirements.txt index c57362c..5243777 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,4 @@ qrcode Pillow qreader opencv-python +pyzbar diff --git a/test/print_verbose_output.txt b/test/print_verbose_output.txt index cf01dae..d920927 100644 --- a/test/print_verbose_output.txt +++ b/test/print_verbose_output.txt @@ -2,6 +2,7 @@ Input files: ['example_export.txt'] Processing infile example_export.txt Reading lines of example_export.txt # 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/ + # Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B @@ -20,6 +21,7 @@ otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAE # Name: "encoding: ¿äÄéÉ? (demo)" otpauth-migration://offline?data=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv%2F%2F%2F%2F%2F%2FAQ%3D%3D # 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/ + # Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B @@ -157,4 +159,4 @@ Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY Type: totp otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY -1 infile(s) processed +1 infile processed diff --git a/test_extract_otp_secret_keys_pytest.py b/test_extract_otp_secret_keys_pytest.py index 0031971..b62b676 100644 --- a/test_extract_otp_secret_keys_pytest.py +++ b/test_extract_otp_secret_keys_pytest.py @@ -28,7 +28,7 @@ import pytest import extract_otp_secret_keys from utils import * -qreader_available = extract_otp_secret_keys.check_module_available('cv2') +qreader_available = extract_otp_secret_keys.qreader_available def test_extract_stdout(capsys): @@ -42,22 +42,6 @@ def test_extract_stdout(capsys): assert captured.err == '' -@pytest.mark.qreader -def test_extract_multiple_files_and_mixed(capsys): - # Act - extract_otp_secret_keys.main([ - 'example_export.txt', - 'test/test_googleauth_export.png', - 'example_export.txt', - 'test/test_googleauth_export.png']) - - # Assert - captured = capsys.readouterr() - - assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG - assert captured.err == '' - - def test_extract_non_existent_file(capsys): # Act with pytest.raises(SystemExit) as e: @@ -144,26 +128,6 @@ def test_extract_stdin_img_empty(capsys, monkeypatch): assert captured.err == 'WARN: stdin is empty\n' -@pytest.mark.qreader -def test_extract_stdin_stdout_wrong_symbol(capsys, monkeypatch): - # Arrange - monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt'))) - - # Act - with pytest.raises(SystemExit) as e: - extract_otp_secret_keys.main(['=']) - - # Assert - captured = capsys.readouterr() - - expected_stderr = "\nERROR: Cannot read binary stdin buffer. Exception: a bytes-like object is required, not 'str'\n" - - assert captured.err == expected_stderr - assert captured.out == '' - assert e.value.code == 1 - assert e.type == SystemExit - - def test_extract_csv(capsys): # Arrange cleanup() @@ -443,7 +407,7 @@ def test_extract_verbose(capsys, relaxed): def test_extract_debug(capsys): # Act - extract_otp_secret_keys.main(['-vv', 'example_export.txt']) + extract_otp_secret_keys.main(['-vvv', 'example_export.txt']) # Assert captured = capsys.readouterr() @@ -469,7 +433,7 @@ def test_extract_help(capsys): assert e.type == SystemExit assert e.value.code == 0 - +@pytest.mark.skipif(qreader_available, reason="Cannot test interactive mode") def test_extract_no_arguments(capsys): # Act with pytest.raises(SystemExit) as e: @@ -530,13 +494,13 @@ def test_wrong_content(capsys): expected_stderr = ''' WARN: line is not a otpauth-migration:// URL -input file: test/test_export_wrong_content.txt -line "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +input: test/test_export_wrong_content.txt +line 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.' Probably a wrong file was given ERROR: no data query parameter in input URL input file: test/test_export_wrong_content.txt -line "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua." +line 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.' Probably a wrong file was given ''' @@ -555,8 +519,8 @@ def test_wrong_prefix(capsys): expected_stderr = ''' WARN: line is not a otpauth-migration:// URL -input file: test/test_export_wrong_prefix.txt -line "QR-Code:otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B" +input: test/test_export_wrong_prefix.txt +line 'QR-Code:otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B' Probably a wrong file was given ''' @@ -588,6 +552,23 @@ def test_img_qr_reader_from_file_happy_path(capsys): assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG assert captured.err == '' + +@pytest.mark.qreader +def test_extract_multiple_files_and_mixed(capsys): + # Act + extract_otp_secret_keys.main([ + 'example_export.txt', + 'test/test_googleauth_export.png', + 'example_export.txt', + 'test/test_googleauth_export.png']) + + # Assert + captured = capsys.readouterr() + + assert captured.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG + assert captured.err == '' + + @pytest.mark.qreader def test_img_qr_reader_from_stdin(capsys, monkeypatch): # Arrange @@ -643,6 +624,26 @@ def test_img_qr_reader_from_stdin_wrong_symbol(capsys, monkeypatch): assert e.type == SystemExit +@pytest.mark.qreader +def test_extract_stdin_stdout_wrong_symbol(capsys, monkeypatch): + # Arrange + monkeypatch.setattr('sys.stdin', io.StringIO(read_file_to_str('example_export.txt'))) + + # Act + with pytest.raises(SystemExit) as e: + extract_otp_secret_keys.main(['=']) + + # Assert + captured = capsys.readouterr() + + expected_stderr = "\nERROR: Cannot read binary stdin buffer. Exception: a bytes-like object is required, not 'str'\n" + + assert captured.err == expected_stderr + assert captured.out == '' + assert e.value.code == 1 + assert e.type == SystemExit + + @pytest.mark.qreader def test_img_qr_reader_no_qr_code_in_image(capsys): # Act @@ -686,13 +687,13 @@ def test_non_image_file(capsys): captured = capsys.readouterr() expected_stderr = ''' WARN: line is not a otpauth-migration:// URL -input file: test/text_masquerading_as_image.jpeg -line "This is just a text file masquerading as an image file." +input: test/text_masquerading_as_image.jpeg +line 'This is just a text file masquerading as an image file.' Probably a wrong file was given ERROR: no data query parameter in input URL input file: test/text_masquerading_as_image.jpeg -line "This is just a text file masquerading as an image file." +line 'This is just a text file masquerading as an image file.' Probably a wrong file was given ''' diff --git a/test_extract_otp_secret_keys_unittest.py b/test_extract_otp_secret_keys_unittest.py index 874ef09..acdd851 100644 --- a/test_extract_otp_secret_keys_unittest.py +++ b/test_extract_otp_secret_keys_unittest.py @@ -179,7 +179,7 @@ Type: totp def test_extract_debug(self): out = io.StringIO() with redirect_stdout(out): - extract_otp_secret_keys.main(['-vv', 'example_export.txt']) + extract_otp_secret_keys.main(['-vvv', 'example_export.txt']) actual_output = out.getvalue() expected_stdout = read_file_to_str('test/print_verbose_output.txt') diff --git a/test_extract_qrcode_unittest.py b/test_extract_qrcode_unittest.py index 7f9c718..9152511 100644 --- a/test_extract_qrcode_unittest.py +++ b/test_extract_qrcode_unittest.py @@ -65,13 +65,13 @@ class TestQRImageExtract(unittest.TestCase): expected_output = [ '', 'WARN: line is not a otpauth-migration:// URL', - 'input file: test/text_masquerading_as_image.jpeg', - 'line "This is just a text file masquerading as an image file."', + 'input: test/text_masquerading_as_image.jpeg', + "line 'This is just a text file masquerading as an image file.'", 'Probably a wrong file was given', '', 'ERROR: no data query parameter in input URL', 'input file: test/text_masquerading_as_image.jpeg', - 'line "This is just a text file masquerading as an image file."', + "line 'This is just a text file masquerading as an image file.'", 'Probably a wrong file was given' ]