diff --git a/Dockerfile b/Dockerfile index 43ae3f3..8b2433c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,7 @@ RUN apt-get update && apt-get install -y \ libglib2.0-0 \ libsm6 \ libzbar0 \ + python3-tk \ && rm -rf /var/lib/apt/lists/* \ && pip install --no-cache-dir -U -r requirements.txt \ && if [ "$RUN_TESTS" = "true" ]; then /extract/run_pytest.sh; else echo "Not running tests..."; fi \ diff --git a/Pipfile b/Pipfile index d4a844c..6293a7d 100644 --- a/Pipfile +++ b/Pipfile @@ -9,6 +9,7 @@ opencv-contrib-python = "*" # for macOS: opencv-contrib-python = "<=4.7.0" # for PYTHON <= 3.7: typing_extensions = "*" pillow = "*" +pyzbar = "*" protobuf = "*" qrcode = "*" qreader = "<2.0.0" diff --git a/Pipfile.lock b/Pipfile.lock index 9385964..d08d2d4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "41edd4aebe075d6c39d035ec7cb10f0253a3ad21f9b4aa5b9c57deccca87874f" + "sha256": "42b14c5eae25b0924354520fe0a26a8d826c905f4613d717f3bfa52e98ed5e8e" }, "pipfile-spec": 6, "requires": { @@ -200,6 +200,7 @@ "sha256:4559628b8192feb25766d954b36a3753baaf5c97c03135aec7e4a026036b475d", "sha256:8f4c5264c9c7c6b9f20d01efc52a4eba1ded47d9ba857a94130afe33703eb518" ], + "index": "pypi", "version": "==0.1.9" }, "qrcode": { @@ -336,11 +337,11 @@ }, "isort": { "hashes": [ - "sha256:6db30c5ded9815d813932c04c2f85a360bcdd35fed496f4d8f35495ef0a261b6", - "sha256:c033fd0edb91000a7f09527fe5c75321878f98322a77ddcc81adbd83724afb7b" + "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504", + "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6" ], - "markers": "python_full_version >= '3.7.0'", - "version": "==5.11.4" + "markers": "python_full_version >= '3.8.0'", + "version": "==5.12.0" }, "lazy-object-proxy": { "hashes": [ @@ -545,11 +546,11 @@ }, "setuptools": { "hashes": [ - "sha256:6f590d76b713d5de4e49fe4fbca24474469f53c83632d5d0fd056f7ff7e8112b", - "sha256:ac4008d396bc9cd983ea483cb7139c0240a07bbc74ffb6232fceffedc6cf03a8" + "sha256:883131c5b6efa70b9101c7ef30b2b7b780a4283d5fc1616383cdf22c83cbefe6", + "sha256:9d790961ba6219e9ff7d9557622d2fe136816a264dd01d5997cfc057d804853d" ], "markers": "python_version >= '3.7'", - "version": "==66.1.1" + "version": "==67.0.0" }, "setuptools-git-versioning": { "hashes": [ diff --git a/README.md b/README.md index 99b1551..1ff366c 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,14 @@ Detected QR codes are surrounded with a frame. The color of the frame indicates * Red: The QR code is detected and decoded, but could not be successfully extracted. This is the case if a QR code not containing OTP data is captured. * Magenta: The QR code is detected, but could not be decoded. The QR code should be presented better to the camera or another QR reader could be used. +Key commands: + +* Space: change QR code reader +* C: save as csv file (🆕 since v2.2) +* J: save as json file (🆕 since v2.2) +* K: save as KeePass csv file (🆕 since v2.2) +* ESC, ENTER, Q: quit the program + The secrets are printed by default to the console. [Set program parameters](#program-help-arguments-and-options) for other types of output, e.g. `--csv exported_secrets.csv`. ### With builtin QR decoder from image files (🆕 since version 2.0) @@ -292,6 +300,10 @@ python extract_otp_secrets.py = < example_export.png * QREADER_DEEP: [QReader](https://github.com/Eric-Canas/QReader) - very slow in GUI * CV2: [QRCodeDetector](https://docs.opencv.org/4.x/de/dc3/classcv_1_1QRCodeDetector.html) * CV2_WECHAT: [WeChatQRCode](https://docs.opencv.org/4.x/dd/d63/group__wechat__qrcode.html) +* Program usable as pure GUI application without any command line switches (🆕 since v2.2) + * Save otp secrets as csv file (🆕 since v2.2) + * Save otp secrets as json file (🆕 since v2.2) + * Save otp secrets as KeePass csv file(s) (🆕 since v2.2) * Supports [TOTP](https://www.ietf.org/rfc/rfc6238.txt) and [HOTP](https://www.ietf.org/rfc/rfc4226.txt) standards * Generates QR codes * Exports to various formats: diff --git a/requirements.txt b/requirements.txt index 454f7d1..a722ada 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ colorama>=0.4.6 +importlib_metadata; python_version<='3.7' opencv-contrib-python; sys_platform != 'darwin' opencv-contrib-python<=4.7.0; sys_platform == 'darwin' Pillow @@ -7,4 +8,3 @@ pyzbar qrcode qreader<2.0.0 typing_extensions; python_version<='3.7' -importlib_metadata; python_version<='3.7' diff --git a/src/extract_otp_secrets.py b/src/extract_otp_secrets.py index 80c6165..3b4e2b4 100644 --- a/src/extract_otp_secrets.py +++ b/src/extract_otp_secrets.py @@ -65,11 +65,20 @@ else: debug_mode = '-d' in sys.argv[1:] or '--debug' in sys.argv[1:] +headless: bool = False + try: import cv2 # type: ignore # TODO use cv2 types if available import numpy as np # TODO use numpy types if available + try: + import tkinter + import tkinter.filedialog + import tkinter.messagebox + except ImportError: + headless = True + try: import pyzbar.pyzbar as zbar # type: ignore from qreader import QReader # type: ignore @@ -143,6 +152,7 @@ quiet: bool = False colored: bool = True executable: bool = False __version__: str +tk_root: tkinter.Tk def sys_main() -> None: @@ -150,7 +160,7 @@ def sys_main() -> None: def main(sys_args: list[str]) -> None: - global executable + global executable, tk_root, headless # allow to use sys.stdout with with (avoid closing) sys.stdout.close = lambda: None # type: ignore # set encoding to utf-8, needed for Windows @@ -164,6 +174,13 @@ def main(sys_args: list[str]) -> None: # https://pyinstaller.org/en/stable/runtime-information.html#run-time-information executable = getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS') + if qreader_available and not headless: + try: + tk_root = tkinter.Tk() + tk_root.withdraw() + except tkinter.TclError: + headless = True + args = parse_args(sys_args) if colored: @@ -174,9 +191,10 @@ def main(sys_args: list[str]) -> None: sys.exit(0 if do_debug_checks() else 1) otps = extract_otps(args) - write_csv(args, otps) - write_keepass_csv(args, otps) - write_json(args, otps) + + write_csv(args.csv, otps) + write_keepass_csv(args.keepass, otps) + write_json(args.json, otps) # workaround for PYTHON <= 3.9 use: pb.MigrationPayload | None @@ -362,15 +380,16 @@ def extract_otps_from_camera(args: Args) -> Otps: qr_mode = next_qr_mode(qr_mode) continue - cv2_print_text(img, f"Mode: {qr_mode.name} (Hit space to change)", 0, TextPosition.LEFT, FONT_COLOR, 20) - cv2_print_text(img, "Hit ESC to quit", 1, TextPosition.LEFT, FONT_COLOR, 17) + cv2_print_text(img, f"Mode: {qr_mode.name} (Hit SPACE to change)", 0, TextPosition.LEFT, FONT_COLOR, 20) + cv2_print_text(img, "Press ESC to quit", 1, TextPosition.LEFT, FONT_COLOR, 17) + cv2_print_text(img, "Press C/J/K to save as csv/json/keepass file", 2, TextPosition.LEFT, FONT_COLOR, None) cv2_print_text(img, f"{len(otp_urls)} QR code{'s'[:len(otp_urls) != 1]} captured", 0, TextPosition.RIGHT, FONT_COLOR) cv2_print_text(img, f"{len(otps)} otp{'s'[:len(otps) != 1]} extracted", 1, TextPosition.RIGHT, FONT_COLOR) cv2.imshow(WINDOW_NAME, img) - quit, qr_mode = cv2_handle_pressed_keys(qr_mode) + quit, qr_mode = cv2_handle_pressed_keys(qr_mode, otps) if quit: break @@ -416,12 +435,48 @@ def cv2_print_text(img: Any, text: str, line_number: int, position: TextPosition cv2.putText(img, out_text, pos, FONT, FONT_SCALE, color, FONT_THICKNESS, FONT_LINE_STYLE) -def cv2_handle_pressed_keys(qr_mode: QRMode) -> Tuple[bool, QRMode]: +def cv2_handle_pressed_keys(qr_mode: QRMode, otps: Otps) -> Tuple[bool, QRMode]: key = cv2.waitKey(1) & 0xFF quit = False - if key == 27 or key == ord('q') or key == 13: + if key == 27 or key == ord('q') or key == ord('Q') or key == 13: # ESC or Enter or q pressed quit = True + elif (key == ord('c') or key == ord('C')) and is_not_headless(): + if has_otps_or_show_warning(otps): + pass + else: + file_name = tkinter.filedialog.asksaveasfilename( + title="Save extracted otp secrets as CSV", + defaultextension='.csv', + filetypes=[('CSV', '*.csv'), ('All', '*.*')] + ) + tk_root.update() + if len(file_name) > 0: + write_csv(file_name, otps) + elif (key == ord('j') or key == ord('J')) and is_not_headless(): + if has_otps_or_show_warning(otps): + pass + else: + file_name = tkinter.filedialog.asksaveasfilename( + title="Save extracted otp secrets as JSON", + defaultextension='.json', + filetypes=[('JSON', '*.json'), ('All', '*.*')] + ) + tk_root.update() + if len(file_name) > 0: + write_json(file_name, otps) + elif (key == ord('k') or key == ord('K')) and is_not_headless(): + if has_otps_or_show_warning(otps): + pass + else: + file_name = tkinter.filedialog.asksaveasfilename( + title="Save extracted otp secrets as KeePass CSV file(s)", + defaultextension='.csv', + filetypes=[('CSV', '*.csv'), ('All', '*.*')] + ) + tk_root.update() + if len(file_name) > 0: + write_keepass_csv(file_name, otps) elif key == 32: qr_mode = next_qr_mode(qr_mode) if verbose >= LogLevel.MORE_VERBOSE: print(f"QR reading mode: {qr_mode}") @@ -626,22 +681,22 @@ def print_qr(args: Args, otp_url: str) -> None: qr.print_ascii() -def write_csv(args: Args, otps: Otps) -> None: - if args.csv and len(otps) > 0: - with open_file_or_stdout_for_csv(args.csv) as outfile: +def write_csv(file: str, otps: Otps) -> None: + if file and len(file) > 0 and len(otps) > 0: + with open_file_or_stdout_for_csv(file) as outfile: writer = csv.DictWriter(outfile, otps[0].keys()) writer.writeheader() writer.writerows(otps) - if not quiet: print(f"Exported {len(otps)} otp{'s'[:len(otps) != 1]} to csv {args.csv}") + if not quiet: print(f"Exported {len(otps)} otp{'s'[:len(otps) != 1]} to csv {file}") -def write_keepass_csv(args: Args, otps: Otps) -> None: - if args.keepass and len(otps) > 0: +def write_keepass_csv(file: str, otps: Otps) -> None: + if file and len(file) > 0 and len(otps) > 0: has_totp = has_otp_type(otps, 'totp') has_hotp = has_otp_type(otps, 'hotp') - if args.keepass != '-': - otp_filename_totp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "totp") - otp_filename_hotp = args.keepass if has_totp != has_hotp else add_pre_suffix(args.keepass, "hotp") + if file != '-': + otp_filename_totp = file if has_totp != has_hotp else add_pre_suffix(file, "totp") + otp_filename_hotp = file if has_totp != has_hotp else add_pre_suffix(file, "hotp") else: otp_filename_totp = otp_filename_hotp = '-' if has_totp: @@ -653,9 +708,9 @@ def write_keepass_csv(args: Args, otps: Otps) -> None: if count_hotp_entries: print(f"Exported {count_hotp_entries} hotp entrie{'s'[:count_hotp_entries != 1]} to keepass csv file {otp_filename_hotp}") -def write_keepass_totp_csv(otp_filename: str, otps: Otps) -> int: +def write_keepass_totp_csv(file: str, otps: Otps) -> int: count_entries = 0 - with open_file_or_stdout_for_csv(otp_filename) as outfile: + with open_file_or_stdout_for_csv(file) as outfile: writer = csv.DictWriter(outfile, ["Title", "User Name", "TimeOtp-Secret-Base32", "Group"]) writer.writeheader() for otp in otps: @@ -670,9 +725,9 @@ def write_keepass_totp_csv(otp_filename: str, otps: Otps) -> int: return count_entries -def write_keepass_htop_csv(otp_filename: str, otps: Otps) -> int: +def write_keepass_htop_csv(file: str, otps: Otps) -> int: count_entries = 0 - with open_file_or_stdout_for_csv(otp_filename) as outfile: + with open_file_or_stdout_for_csv(file) as outfile: writer = csv.DictWriter(outfile, ["Title", "User Name", "HmacOtp-Secret-Base32", "HmacOtp-Counter", "Group"]) writer.writeheader() for otp in otps: @@ -688,11 +743,11 @@ def write_keepass_htop_csv(otp_filename: str, otps: Otps) -> int: return count_entries -def write_json(args: Args, otps: Otps) -> None: - if args.json: - with open_file_or_stdout(args.json) as outfile: +def write_json(file: str, otps: Otps) -> None: + if file and len(file) > 0: + with open_file_or_stdout(file) as outfile: json.dump(otps, outfile, indent=4) - if not quiet: print(f"Exported {len(otps)} otp{'s'[:len(otps) != 1]} to json {args.json}") + if not quiet: print(f"Exported {len(otps)} otp{'s'[:len(otps) != 1]} to json {file}") def has_otp_type(otps: Otps, otp_type: str) -> bool: @@ -729,6 +784,14 @@ def check_file_exists(filename: str) -> None: f"\ninput file: {filename}") +def has_otps_or_show_warning(otps: Otps) -> bool: + if len(otps) == 0: + tkinter.messagebox.showinfo(title="No data", message="There are no otp secrets to write") + tk_root.update() # dispose dialog + + return len(otps) > 0 + + def is_binary(line: str) -> bool: try: line.startswith('#') @@ -751,6 +814,12 @@ def do_debug_checks() -> bool: return True +def is_not_headless() -> bool: + if headless: + log_warn(f"Cannot open dialog in headless mode") + return not headless + + class PrintVersionAction(argparse.Action): def __init__(self, option_strings: Sequence[str], dest: str, nargs: int = 0, **kwargs: Any) -> None: super().__init__(option_strings, dest, nargs, **kwargs)