extract from camera

- add help description
- use f-strings
- handle plural correctly
- rename methods, use otp_url instead of line
- remove importlib.util
- move cv2 imports to top
- remove unnecessary global delcarations
- group image tests
cv2_1
scito 1 year ago
parent 7964c687f6
commit 9f0872c2d0

@ -54,6 +54,8 @@ jobs:
tags: | tags: |
scit0/extract_otp_secret_keys_no_qr_reader:latest scit0/extract_otp_secret_keys_no_qr_reader:latest
ghcr.io/scito/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 # build on feature branches, push only on master branch
# TODO push: ${{ github.ref == 'refs/heads/master' }} # TODO push: ${{ github.ref == 'refs/heads/master' }}
push: true push: true

@ -20,7 +20,16 @@ cd extract_otp_secret_keys
## Usage ## 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 1. Open "Google Authenticator" app on the mobile phone
2. Export the QR codes from "Google Authenticator" app 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 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 1. Open "Google Authenticator" app on the mobile phone
2. Export the QR codes from "Google Authenticator" app 2. Export the QR codes from "Google Authenticator" app
@ -43,22 +52,28 @@ cd extract_otp_secret_keys
## Program help: arguments and options ## Program help: arguments and options
<pre>usage: extract_otp_secret_keys.py [-h] [--json FILE] [--csv FILE] [--keepass FILE] [--printqr] [--saveqr DIR] [--verbose | --quiet] infile [infile ...] <pre>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: 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: options:
-h, --help show this help message and exit -h, --help show this help message and exit
--json FILE, -j FILE export json file or - for stdout --camera NUMBER, -C NUMBER camera number of system (default camera: 0)
--csv FILE, -c FILE export csv file or - for stdout --json FILE, -j FILE export json file or - for stdout
--keepass FILE, -k FILE export totp/hotp csv file(s) for KeePass, - for stdout --csv FILE, -c FILE export csv file or - for stdout
--printqr, -p print QR code(s) as text to the terminal (requires qrcode module) --keepass FILE, -k FILE export totp/hotp csv file(s) for KeePass, - for stdout
--saveqr DIR, -s DIR save QR code(s) as images to the given folder (requires qrcode module) --printqr, -p print QR code(s) as text to the terminal (requires qrcode module)
--verbose, -v verbose output --saveqr DIR, -s DIR save QR code(s) as images to the given folder (requires qrcode module)
--quiet, -q no stdout output, except output set by - --verbose, -v verbose output
--quiet, -q no stdout output, except output set by -
examples: examples:
python extract_otp_secret_keys.py
python extract_otp_secret_keys.py example_*.txt python extract_otp_secret_keys.py example_*.txt
python extract_otp_secret_keys.py - < example_export.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 --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 /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 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
docker build . -t extract_otp_secret_keys_no_qr_reader -f Dockerfile_no_qr_reader --pull --build-arg run_tests=false 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 docker run --entrypoint /extract/run_pytest.sh --rm -v "$(pwd)":/files:ro extract_otp_secret_keys_no_qr_reader

@ -1,4 +1,5 @@
# 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/ # 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/
# Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY # Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B

@ -45,35 +45,45 @@ import argparse
import base64 import base64
import csv import csv
import fileinput import fileinput
import importlib
import json import json
import os import os
import re import re
import sys import sys
import urllib.parse as urlparse import urllib.parse as urlparse
from enum import Enum
from operator import add
from qrcode import QRCode
import protobuf_generated_python.google_auth_pb2 import protobuf_generated_python.google_auth_pb2
# These dynamic import are below: try:
# import cv2 import cv2
# import numpy import numpy
# from qreader import QReader
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(): def sys_main():
main(sys.argv[1:]) main(sys.argv[1:])
def main(sys_args): def main(sys_args):
global verbose, quiet, qreader_available
# allow to use sys.stdout with with (avoid closing) # allow to use sys.stdout with with (avoid closing)
sys.stdout.close = lambda: None sys.stdout.close = lambda: None
# sys.stdout.reconfigure(encoding='utf-8')
args = parse_args(sys_args) args = parse_args(sys_args)
verbose = args.verbose if args.verbose else 0
quiet = args.quiet
otps = extract_otps(args) otps = extract_otps(args)
write_csv(args, otps) write_csv(args, otps)
@ -82,16 +92,25 @@ def main(sys_args):
def parse_args(sys_args): def parse_args(sys_args):
formatter = lambda prog: argparse.RawDescriptionHelpFormatter(prog, max_help_position=52) global verbose, quiet
example_text = '''examples: 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_*.txt
python extract_otp_secret_keys.py - < example_export.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 --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, arg_parser = argparse.ArgumentParser(formatter_class=formatter,
description=description_text,
epilog=example_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('--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'))
@ -103,58 +122,122 @@ python extract_otp_secret_keys.py = < example_export.png'''
args = arg_parser.parse_args(sys_args) args = arg_parser.parse_args(sys_args)
if args.csv == '-' or args.json == '-' or args.keepass == '-': if args.csv == '-' or args.json == '-' or args.keepass == '-':
args.quiet = args.q = True args.quiet = args.q = True
verbose = args.verbose if args.verbose else 0
quiet = True if args.quiet else False
return args return args
def extract_otps(args): def extract_otps(args):
global verbose, quiet if not args.infile:
quiet = args.quiet 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 = [] otps = []
i = j = k = 0 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: for infile in args.infile:
if verbose: print('Processing infile {}'.format(infile)) if verbose: print(f"Processing infile {infile}")
k += 1 k += 1
for line in get_lines_from_file(infile): for line in get_otp_urls_from_file(infile):
if verbose: print(line) if verbose: print(line)
if line.startswith('#') or line == '': continue if line.startswith('#') or line == '': continue
i += 1 i += 1
payload = get_payload_from_line(line, i, infile) j = extract_otp_from_otp_url(line, otps, i, j, infile, args)
if verbose: print(f"{k} infile{'s'[:k != 1]} processed")
# 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))
return otps return otps
def get_lines_from_file(filename): def get_otp_urls_from_file(filename):
global qreader_available
# 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)
@ -163,45 +246,73 @@ def get_lines_from_file(filename):
return lines return lines
# could not process text file, try reading as image # could not process text file, try reading as image
if filename != '-': if filename != '-' and qreader_available:
return convert_img_to_line(filename) return convert_img_to_otp_url(filename)
return []
def read_lines_from_text_file(filename): 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) finput = fileinput.input(filename)
try: try:
lines = [] lines = []
for line in (line.strip() for line in finput): for line in (line.strip() for line in finput):
if verbose: print(line) if verbose: print(line)
if is_binary(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 # unfortunately yield line leads to random test fails
lines.append(line) lines.append(line)
if not lines: if not lines:
eprint("WARN: {} is empty".format(filename.replace('-', 'stdin'))) eprint(f"WARN: {filename.replace('-', 'stdin')} is empty")
return lines return lines
except UnicodeDecodeError: except UnicodeDecodeError:
if filename == '-': if filename == '-':
abort('\nERROR: Unable to open text file form stdin. ' abort("\nERROR: Unable to open text file form stdin. "
'In case you want read an image file from stdin, you must use "=" instead of "-".') "In case you want read an image file from stdin, you must use '=' instead of '-'.")
else: # The file is probably an image, process below else: # The file is probably an image, process below
return None return None
finally: finally:
finput.close() finput.close()
def convert_img_to_line(filename): def extract_otp_from_otp_url(otpauth_migration_url, otps, i, j, infile, args):
try: payload = get_payload_from_otp_url(otpauth_migration_url, i, infile)
import cv2
import numpy # pylint: disable=no-member
except Exception as e: for raw_otp in payload.otp_parameters:
eprint("WARNING: No cv2 or numpy module installed. Exception: {}".format(str(e))) j += 1
return [] if verbose: print(f"\n{j}. Secret Key")
if verbose: print('Reading image {}'.format(filename)) 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: try:
if filename != '=': if filename != '=':
image = cv2.imread(filename) img = cv2.imread(filename)
else: else:
try: try:
stdin = sys.stdin.buffer.read() stdin = sys.stdin.buffer.read()
@ -213,60 +324,48 @@ def convert_img_to_line(filename):
try: try:
img_array = numpy.frombuffer(stdin, dtype='uint8') img_array = numpy.frombuffer(stdin, dtype='uint8')
except TypeError as e: 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: if not img_array.size:
return [] return []
image = cv2.imdecode(img_array, cv2.IMREAD_UNCHANGED) img = cv2.imdecode(img_array, cv2.IMREAD_UNCHANGED)
if image is None:
abort('\nERROR: Unable to open file for reading.\ninput file: {}'.format(filename))
# dynamic import of QReader since this module has a dependency to zbar lib and import it only when necessary if img is None:
try: abort(f"\nERROR: Unable to open file for reading.\ninput file: {filename}")
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)))
decoder = QReader() decoded_text = QReader().detect_and_decode(img)
decoded_text = decoder.detect_and_decode(image=image)
if decoded_text is None: 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] return [decoded_text]
except Exception as e: 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): def get_payload_from_otp_url(otpauth_migration_url, i, input_source):
global verbose if not otpauth_migration_url.startswith('otpauth-migration://'):
if not line.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")
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(otpauth_migration_url)
parsed_url = urlparse.urlparse(line) if verbose > 2: print(f"\nDEBUG: parsed_url={parsed_url}")
if verbose > 1: print('\nDEBUG: parsed_url={}'.format(parsed_url))
try: try:
params = urlparse.parse_qs(parsed_url.query, strict_parsing=True) params = urlparse.parse_qs(parsed_url.query, strict_parsing=True)
except: # Not necessary for Python >= 3.11 except: # Not necessary for Python >= 3.11
params = [] params = []
if verbose > 1: print('\nDEBUG: querystring params={}'.format(params)) if verbose > 2: print(f"\nDEBUG: querystring params={params}")
if 'data' not in 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] 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(' ', '+') 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) data = base64.b64decode(data_base64_fixed, validate=True)
payload = protobuf_generated_python.google_auth_pb2.MigrationPayload() payload = protobuf_generated_python.google_auth_pb2.MigrationPayload()
try: try:
payload.ParseFromString(data) payload.ParseFromString(data)
except: except:
abort('\nERROR: Cannot decode otpauth-migration migration payload.\n' abort(f"\nERROR: Cannot decode otpauth-migration migration payload.\n"
'data={}'.format(data_base64)) f"data={data_base64}")
if verbose: if verbose:
print('\n{}. Payload Line'.format(i), payload, sep='\n') print(f"\n{i}. Payload Line", payload, sep='\n')
return payload return payload
@ -289,17 +388,17 @@ def build_otp_url(secret, raw_otp):
url_params = {'secret': secret} url_params = {'secret': secret}
if raw_otp.type == 1: url_params['counter'] = raw_otp.counter if raw_otp.type == 1: url_params['counter'] = raw_otp.counter
if raw_otp.issuer: url_params['issuer'] = raw_otp.issuer 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 return otp_url
def print_otp(otp): def print_otp(otp):
print('Name: {}'.format(otp['name'])) print(f"Name: {otp['name']}")
print('Secret: {}'.format(otp['secret'])) print(f"Secret: {otp['secret']}")
if otp['issuer']: print('Issuer: {}'.format(otp['issuer'])) if otp['issuer']: print(f"Issuer: {otp['issuer']}")
print('Type: {}'.format(otp['type'])) print(f"Type: {otp['type']}")
if otp['type'] == 'hotp': if otp['type'] == 'hotp':
print('Counter: {}'.format(otp['counter'])) print(f"Counter: {otp['counter']}")
if verbose: if verbose:
print(otp['url']) print(otp['url'])
@ -310,39 +409,34 @@ def save_qr(otp, args, j):
pattern = re.compile(r'[\W_]+') pattern = re.compile(r'[\W_]+')
file_otp_name = pattern.sub('', otp['name']) file_otp_name = pattern.sub('', otp['name'])
file_otp_issuer = pattern.sub('', otp['issuer']) 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 return file_otp_issuer
def save_qr_file(args, data, name): def save_qr_file(args, data, name):
from qrcode import QRCode
global verbose
qr = QRCode() qr = QRCode()
qr.add_data(data) qr.add_data(data)
img = qr.make_image(fill_color='black', back_color='white') 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) img.save(name)
def print_qr(args, data): def print_qr(args, data):
from qrcode import QRCode
qr = QRCode() qr = QRCode()
qr.add_data(data) qr.add_data(data)
qr.print_ascii() qr.print_ascii()
def write_csv(args, otps): def write_csv(args, otps):
global verbose, quiet
if args.csv and len(otps) > 0: if args.csv and len(otps) > 0:
with open_file_or_stdout_for_csv(args.csv) as outfile: with open_file_or_stdout_for_csv(args.csv) as outfile:
writer = csv.DictWriter(outfile, otps[0].keys()) writer = csv.DictWriter(outfile, otps[0].keys())
writer.writeheader() writer.writeheader()
writer.writerows(otps) 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): def write_keepass_csv(args, otps):
global verbose, quiet
if args.keepass and len(otps) > 0: if args.keepass and len(otps) > 0:
has_totp = has_otp_type(otps, 'totp') has_totp = has_otp_type(otps, 'totp')
has_hotp = has_otp_type(otps, 'hotp') has_hotp = has_otp_type(otps, 'hotp')
@ -360,7 +454,7 @@ def write_keepass_csv(args, otps):
'Title': otp['issuer'], 'Title': otp['issuer'],
'User Name': otp['name'], 'User Name': otp['name'],
'TimeOtp-Secret-Base32': otp['secret'] if otp['type'] == 'totp' else None, '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 count_totp_entries += 1
if has_hotp: if has_hotp:
@ -374,20 +468,19 @@ def write_keepass_csv(args, otps):
'User Name': otp['name'], 'User Name': otp['name'],
'HmacOtp-Secret-Base32': otp['secret'] if otp['type'] == 'hotp' else None, 'HmacOtp-Secret-Base32': otp['secret'] if otp['type'] == 'hotp' else None,
'HmacOtp-Counter': otp['counter'] 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 count_hotp_entries += 1
if not quiet: 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_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( "Exported {} hotp entries to keepass csv file {}".format(count_hotp_entries, otp_filename_hotp)) 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): def write_json(args, otps):
global verbose, quiet
if args.json: if args.json:
with open_file_or_stdout(args.json) as outfile: with open_file_or_stdout(args.json) as outfile:
json.dump(otps, outfile, indent=4) 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): def has_otp_type(otps, otp_type):
@ -420,8 +513,8 @@ def open_file_or_stdout_for_csv(filename):
def check_file_exists(filename): def check_file_exists(filename):
if filename != '-' and not os.path.isfile(filename): if filename != '-' and not os.path.isfile(filename):
abort('\nERROR: Input file provided is non-existent or not a file.' abort(f"\nERROR: Input file provided is non-existent or not a file."
'\ninput file: {}'.format(filename)) f"\ninput file: {filename}")
def is_binary(line): def is_binary(line):
@ -432,11 +525,6 @@ def is_binary(line):
return True 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): def eprint(*args, **kwargs):
'''Print to stderr.''' '''Print to stderr.'''
print(*args, file=sys.stderr, **kwargs) print(*args, file=sys.stderr, **kwargs)

@ -3,3 +3,4 @@ qrcode
Pillow Pillow
qreader qreader
opencv-python opencv-python
pyzbar

@ -2,6 +2,7 @@ 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
# 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/ # 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/
# Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY # Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B
@ -20,6 +21,7 @@ otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAE
# Name: "encoding: ¿äÄéÉ? (demo)" # Name: "encoding: ¿äÄéÉ? (demo)"
otpauth-migration://offline?data=CjYKEPqlBekzoNEukL7qlsjBCDYSHGVuY29kaW5nOiDCv8Okw4TDqcOJPyAoZGVtbykgASgBMAIQARgBIAAorfCurv%2F%2F%2F%2F%2F%2FAQ%3D%3D 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/ # 2FA example from https://www.raspberrypi.org/blog/setting-up-two-factor-authentication-on-your-raspberry-pi/
# Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY # Secret key: 7KSQL2JTUDIS5EF65KLMRQIIGY
# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi
otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B
@ -157,4 +159,4 @@ Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY
Type: totp Type: totp
otpauth://totp/encoding%3A%20%C2%BF%C3%A4%C3%84%C3%A9%C3%89%3F%20%28demo%29?secret=7KSQL2JTUDIS5EF65KLMRQIIGY 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

@ -28,7 +28,7 @@ import pytest
import extract_otp_secret_keys import extract_otp_secret_keys
from utils import * 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): def test_extract_stdout(capsys):
@ -42,22 +42,6 @@ def test_extract_stdout(capsys):
assert captured.err == '' 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): def test_extract_non_existent_file(capsys):
# Act # Act
with pytest.raises(SystemExit) as e: 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' 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): def test_extract_csv(capsys):
# Arrange # Arrange
cleanup() cleanup()
@ -443,7 +407,7 @@ def test_extract_verbose(capsys, relaxed):
def test_extract_debug(capsys): def test_extract_debug(capsys):
# Act # Act
extract_otp_secret_keys.main(['-vv', 'example_export.txt']) extract_otp_secret_keys.main(['-vvv', 'example_export.txt'])
# Assert # Assert
captured = capsys.readouterr() captured = capsys.readouterr()
@ -469,7 +433,7 @@ def test_extract_help(capsys):
assert e.type == SystemExit assert e.type == SystemExit
assert e.value.code == 0 assert e.value.code == 0
@pytest.mark.skipif(qreader_available, reason="Cannot test interactive mode")
def test_extract_no_arguments(capsys): def test_extract_no_arguments(capsys):
# Act # Act
with pytest.raises(SystemExit) as e: with pytest.raises(SystemExit) as e:
@ -530,13 +494,13 @@ def test_wrong_content(capsys):
expected_stderr = ''' expected_stderr = '''
WARN: line is not a otpauth-migration:// URL WARN: line is not a otpauth-migration:// URL
input file: test/test_export_wrong_content.txt 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." 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 Probably a wrong file was given
ERROR: no data query parameter in input URL ERROR: no data query parameter in input URL
input file: test/test_export_wrong_content.txt 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 Probably a wrong file was given
''' '''
@ -555,8 +519,8 @@ def test_wrong_prefix(capsys):
expected_stderr = ''' expected_stderr = '''
WARN: line is not a otpauth-migration:// URL WARN: line is not a otpauth-migration:// URL
input file: test/test_export_wrong_prefix.txt input: test/test_export_wrong_prefix.txt
line "QR-Code:otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B" line 'QR-Code:otpauth-migration://offline?data=CjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACjr4JKK%2B%2F%2F%2F%2F%2F8B'
Probably a wrong file was given 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.out == EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT_PNG
assert captured.err == '' 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 @pytest.mark.qreader
def test_img_qr_reader_from_stdin(capsys, monkeypatch): def test_img_qr_reader_from_stdin(capsys, monkeypatch):
# Arrange # Arrange
@ -643,6 +624,26 @@ def test_img_qr_reader_from_stdin_wrong_symbol(capsys, monkeypatch):
assert e.type == SystemExit 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 @pytest.mark.qreader
def test_img_qr_reader_no_qr_code_in_image(capsys): def test_img_qr_reader_no_qr_code_in_image(capsys):
# Act # Act
@ -686,13 +687,13 @@ def test_non_image_file(capsys):
captured = capsys.readouterr() captured = capsys.readouterr()
expected_stderr = ''' expected_stderr = '''
WARN: line is not a otpauth-migration:// URL WARN: line is not a otpauth-migration:// URL
input file: test/text_masquerading_as_image.jpeg input: 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 Probably a wrong file was given
ERROR: no data query parameter in input URL ERROR: no data query parameter in input URL
input file: test/text_masquerading_as_image.jpeg 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 Probably a wrong file was given
''' '''

@ -179,7 +179,7 @@ Type: totp
def test_extract_debug(self): def test_extract_debug(self):
out = io.StringIO() out = io.StringIO()
with redirect_stdout(out): 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() actual_output = out.getvalue()
expected_stdout = read_file_to_str('test/print_verbose_output.txt') expected_stdout = read_file_to_str('test/print_verbose_output.txt')

@ -65,13 +65,13 @@ class TestQRImageExtract(unittest.TestCase):
expected_output = [ expected_output = [
'', '',
'WARN: line is not a otpauth-migration:// URL', 'WARN: line is not a otpauth-migration:// URL',
'input file: test/text_masquerading_as_image.jpeg', 'input: 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', 'Probably a wrong file was given',
'', '',
'ERROR: no data query parameter in input URL', 'ERROR: no data query parameter in input URL',
'input file: test/text_masquerading_as_image.jpeg', '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' 'Probably a wrong file was given'
] ]

Loading…
Cancel
Save