diff --git a/src/extract_otp_secrets.py b/src/extract_otp_secrets.py index 3a74d06..6cd0334 100644 --- a/src/extract_otp_secrets.py +++ b/src/extract_otp_secrets.py @@ -65,6 +65,7 @@ else: debug_mode = '-d' in sys.argv[1:] or '--debug' in sys.argv[1:] +quiet = '-q' in sys.argv[1:] or '--quiet' in sys.argv[1:] headless: bool = False @@ -82,13 +83,17 @@ try: try: import pyzbar.pyzbar as zbar # type: ignore from qreader import QReader # type: ignore - except ImportError as e: - print(f""" -ERROR: Cannot import QReader module. This problem is probably due to the missing zbar shared library. + zbar_available = True + except Exception as e: + if not quiet: + print(f""" +WARN: Cannot import pyzbar or 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}\n""", file=sys.stderr) - raise e + zbar_available = False + if debug_mode: + raise e # Types # workaround for PYTHON <= 3.9: Final[tuple[int]] @@ -121,9 +126,9 @@ Exception: {e}\n""", file=sys.stderr) TextPosition = Enum('TextPosition', ['LEFT', 'RIGHT']) - qreader_available = True + cv2_available = True except ImportError as e: - qreader_available = False + cv2_available = False if debug_mode: raise e @@ -145,10 +150,10 @@ LogLevel = IntEnum('LogLevel', ['QUIET', 'NORMAL', 'VERBOSE', 'MORE_VERBOSE', 'D # Constants CAMERA: Final[str] = 'camera' +CV2_QRMODES: List[str] = [QRMode.CV2.name, QRMode.CV2_WECHAT.name] # Global variable declaration verbose: IntEnum = LogLevel.NORMAL -quiet: bool = False colored: bool = True executable: bool = False __version__: str @@ -174,7 +179,7 @@ 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: + if cv2_available and not headless: try: tk_root = tkinter.Tk() tk_root.withdraw() @@ -275,7 +280,7 @@ def parse_args(sys_args: list[str]) -> Args: name = os.path.basename(sys.argv[0]) cmd = f"python {name}" if name.endswith('.py') else f"{name}" description_text = "Extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps" - if qreader_available: + if cv2_available: description_text += "\nIf no infiles are provided, a GUI window starts and QR codes are captured from the camera." example_text = f"""examples: {cmd} @@ -288,15 +293,18 @@ def parse_args(sys_args: list[str]) -> Args: description=description_text, epilog=example_text) 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 '+') +b) image file containing a QR code or = for stdin for an image containing a QR code""", nargs='*' if cv2_available else '+') 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('--json', '-j', help='export json file or - for stdout', metavar=('FILE')) arg_parser.add_argument('--printqr', '-p', help='print QR code(s) as text to the terminal (requires qrcode module)', action='store_true') arg_parser.add_argument('--saveqr', '-s', help='save QR code(s) as images to the given folder (requires qrcode module)', metavar=('DIR')) - if qreader_available: + if cv2_available: arg_parser.add_argument('--camera', '-C', help='camera number of system (default camera: 0)', default=0, type=int, metavar=('NUMBER')) - 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) + if not zbar_available: + arg_parser.add_argument('--qr', '-Q', help=f'QR reader (default: {QRMode.CV2.name})', type=str, choices=[QRMode.CV2.name, QRMode.CV2_WECHAT.name], default=QRMode.CV2.name) + else: + 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('-i', '--ignore', help='ignore duplicate otps', action='store_true') arg_parser.add_argument('--no-color', '-n', help='do not use ANSI colors in console output', action='store_true') arg_parser.add_argument('--version', '-V', help='print version and quit', action=PrintVersionAction) @@ -314,8 +322,8 @@ b) image file containing a QR code or = for stdin for an image containing a QR c verbose = LogLevel.DEBUG log_debug('Debug mode start') quiet = True if args.quiet else False - if verbose: print(f"QReader installed: {qreader_available}") - if qreader_available: + if verbose: print(f"QReader installed: {cv2_available}") + if cv2_available: if verbose >= LogLevel.VERBOSE: print(f"CV2 version: {cv2.__version__}") if verbose: print(f"QR reading mode: {args.qr}\n") @@ -339,7 +347,8 @@ def extract_otps_from_camera(args: Args) -> Otps: cam = cv2.VideoCapture(args.camera) cv2.namedWindow(WINDOW_NAME, cv2.WINDOW_AUTOSIZE) - qreader = QReader() + if zbar_available: + qreader = QReader() cv2_qr = cv2.QRCodeDetector() cv2_qr_wechat = cv2.wechat_qrcode.WeChatQRCode() while True: @@ -478,7 +487,7 @@ def cv2_handle_pressed_keys(qr_mode: QRMode, otps: Otps) -> Tuple[bool, QRMode]: if len(file_name) > 0: write_keepass_csv(file_name, otps) elif key == 32: - qr_mode = next_qr_mode(qr_mode) + qr_mode = next_valid_qr_mode(qr_mode, zbar_available) if verbose >= LogLevel.MORE_VERBOSE: print(f"QR reading mode: {qr_mode}") if cv2.getWindowProperty(WINDOW_NAME, cv2.WND_PROP_VISIBLE) < 1: # Window close clicked @@ -526,7 +535,7 @@ def get_otp_urls_from_file(filename: str, args: Args) -> OtpUrls: return lines # could not process text file, try reading as image - if filename != '-' and qreader_available: + if filename != '-' and cv2_available: return convert_img_to_otp_urls(filename, args) return [] @@ -799,6 +808,14 @@ def is_binary(line: str) -> bool: return True +def next_valid_qr_mode(qr_mode: QRMode, with_zbar: bool = True) -> QRMode: + ok = False + while not ok: + qr_mode = next_qr_mode(qr_mode) + ok = True if with_zbar else qr_mode.name in CV2_QRMODES + return qr_mode + + def next_qr_mode(qr_mode: QRMode) -> QRMode: return QRMode((qr_mode.value + 1) % len(QRMode)) @@ -809,6 +826,10 @@ def do_debug_checks() -> bool: import cv2 # noqa: F401 # This is only a debug import log_debug('Try: import numpy as np') import numpy as np # noqa: F401 # This is only a debug import + log_debug('Try: import pyzbar.pyzbar as zbar') + import pyzbar.pyzbar as zbar # noqa: F401 # This is only a debug import + log_debug('Try: from qreader import QReader') + from qreader import QReader # noqa: F401 # This is only a debug import print(color('\nDebug checks passed', colorama.Fore.GREEN)) return True diff --git a/tests/extract_otp_secrets_test.py b/tests/extract_otp_secrets_test.py index e77c648..8e04676 100644 --- a/tests/extract_otp_secrets_test.py +++ b/tests/extract_otp_secrets_test.py @@ -45,11 +45,11 @@ except ImportError: # ignore pass -qreader_available: bool = extract_otp_secrets.qreader_available +cv2_available: bool = extract_otp_secrets.cv2_available # Quickfix comment -# @pytest.mark.skipif(sys.platform.startswith("win") or not qreader_available or sys.implementation.name == 'pypy' or sys.version_info >= (3, 10), reason="Quickfix") +# @pytest.mark.skipif(sys.platform.startswith("win") or not cv2 or sys.implementation.name == 'pypy' or sys.version_info >= (3, 10), reason="Quickfix") def test_extract_stdout(capsys: pytest.CaptureFixture[str]) -> None: @@ -122,7 +122,7 @@ def test_extract_stdin_only_comments(capsys: pytest.CaptureFixture[str], monkeyp def test_extract_empty_file_no_qreader(capsys: pytest.CaptureFixture[str]) -> None: - if qreader_available: + if cv2_available: # Act with pytest.raises(SystemExit) as e: extract_otp_secrets.main(['-n', 'tests/data/empty_file.txt']) @@ -510,7 +510,7 @@ def test_extract_verbose(verbose_level: str, color: str, capsys: pytest.CaptureF def normalize_verbose_text(text: str, relaxed: bool) -> str: normalized = re.sub('^.*version: .+$', '', text, flags=re.MULTILINE | re.IGNORECASE) - if not qreader_available: + if not cv2_available: normalized = normalized \ .replace('QReader installed: True', 'QReader installed: False') \ .replace('\nQR reading mode: ZBAR\n\n', '') @@ -564,7 +564,7 @@ def test_extract_version(capsys: pytest.CaptureFixture[str]) -> None: def test_extract_no_arguments(capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None: - if qreader_available: + if cv2_available: # Arrange otps = read_json('example_output.json') mocker.patch('extract_otp_secrets.extract_otps_from_camera', return_value=otps) @@ -648,7 +648,7 @@ class MockCam: ('CV2_WECHAT', 'tests/data/lena_std.tif', None), ]) def test_extract_otps_from_camera(qr_reader: Optional[str], file: str, success: bool, capsys: pytest.CaptureFixture[str], mocker: MockerFixture) -> None: - if qreader_available: + if cv2_available: # Arrange mockCam = MockCam([file]) mocker.patch('cv2.VideoCapture', return_value=mockCam) @@ -733,7 +733,7 @@ def test_verbose_and_quiet(capsys: pytest.CaptureFixture[str]) -> None: ('-n', None, False, False), ]) def test_quiet(parameter: str, parameter_value: Optional[str], stdout_expected: bool, stderr_expected: bool, capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None: - if parameter in ['-Q', '-C'] and not qreader_available: + if parameter in ['-Q', '-C'] and not cv2_available: return # Arrange @@ -1076,6 +1076,12 @@ url: This is just a text file masquerading as an image file. assert captured.out == '' +def test_next_valid_qr_mode() -> None: + assert extract_otp_secrets.next_valid_qr_mode(extract_otp_secrets.QRMode.CV2, True) == extract_otp_secrets.QRMode.CV2_WECHAT + assert extract_otp_secrets.next_valid_qr_mode(extract_otp_secrets.QRMode.CV2_WECHAT, True) == extract_otp_secrets.QRMode.ZBAR + assert extract_otp_secrets.next_valid_qr_mode(extract_otp_secrets.QRMode.CV2_WECHAT, False) == extract_otp_secrets.QRMode.CV2 + + EXPECTED_STDOUT_FROM_EXAMPLE_EXPORT = '''Name: pi@raspberrypi Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY Issuer: raspberrypi