diff --git a/.gitignore b/.gitignore index e93450b..b1dc7e0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ extract_otp_secrets_linux_arm64.spec extract_otp_secrets_linux_x86_64_bullseye.spec extract_otp_secrets_linux_x86_64.spec extract_otp_secrets.spec +test.txt diff --git a/Pipfile.lock b/Pipfile.lock index 9d7d7dd..0d1cccb 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -176,23 +176,22 @@ }, "protobuf": { "hashes": [ - "sha256:1f22ac0ca65bb70a876060d96d914dae09ac98d114294f77584b0d2644fa9c30", - "sha256:237216c3326d46808a9f7c26fd1bd4b20015fb6867dc5d263a493ef9a539293b", - "sha256:27f4d15021da6d2b706ddc3860fac0a5ddaba34ab679dc182b60a8bb4e1121cc", - "sha256:299ea899484ee6f44604deb71f424234f654606b983cb496ea2a53e3c63ab791", - "sha256:3d164928ff0727d97022957c2b849250ca0e64777ee31efd7d6de2e07c494717", - "sha256:6ab80df09e3208f742c98443b6166bcb70d65f52cfeb67357d52032ea1ae9bec", - "sha256:78a28c9fa223998472886c77042e9b9afb6fe4242bd2a2a5aced88e3f4422aa7", - "sha256:7cd532c4566d0e6feafecc1059d04c7915aec8e182d1cf7adee8b24ef1e2e6ab", - "sha256:89f9149e4a0169cddfc44c74f230d7743002e3aa0b9472d8c28f0388102fc4c2", - "sha256:a53fd3f03e578553623272dc46ac2f189de23862e68565e83dde203d41b76fc5", - "sha256:b135410244ebe777db80298297a97fbb4c862c881b4403b71bac9d4107d61fd1", - "sha256:b98d0148f84e3a3c569e19f52103ca1feacdac0d2df8d6533cf983d1fda28462", - "sha256:d1736130bce8cf131ac7957fa26880ca19227d4ad68b4888b3be0dea1f95df97", - "sha256:f45460f9ee70a0ec1b6694c6e4e348ad2019275680bd68a1d9314b8c7e01e574" + "sha256:1669cb7524221a8e2d9008d0842453dbefdd0fcdd64d67672f657244867635fb", + "sha256:29288813aacaa302afa2381db1d6e0482165737b0afdf2811df5fa99185c457b", + "sha256:47d31bdf58222dd296976aa1646c68c6ee80b96d22e0a3c336c9174e253fd35e", + "sha256:652d8dfece122a24d98eebfef30e31e455d300efa41999d1182e015984ac5930", + "sha256:7c535d126e7dcc714105ab20b418c4fedbd28f8b8afc42b7350b1e317bbbcc71", + "sha256:86c3d20428b007537ba6792b475c0853bba7f66b1f60e610d913b77d94b486e4", + "sha256:a33a273d21852f911b8bda47f39f4383fe7c061eb1814db2c76c9875c89c2491", + "sha256:ab4d043865dd04e6b09386981fe8f80b39a1e46139fb4a3c206229d6b9f36ff6", + "sha256:b2fea9dc8e3c0f32c38124790ef16cba2ee0628fe2022a52e435e1117bfef9b1", + "sha256:c27f371f0159feb70e6ea52ed7e768b3f3a4c5676c1900a7e51a24740381650e", + "sha256:c3325803095fb4c2a48649c321d2fbde59f8fbfcb9bfc7a86df27d112831c571", + "sha256:e474b63bab0a2ea32a7b26a4d8eec59e33e709321e5e16fb66e766b61b82a95e", + "sha256:e894e9ae603e963f0842498c4cd5d39c6a60f0d7e4c103df50ee939564298658" ], "index": "pypi", - "version": "==4.21.12" + "version": "==4.22.0" }, "pypng": { "hashes": [ @@ -227,11 +226,11 @@ }, "typing-extensions": { "hashes": [ - "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa", - "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e" + "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb", + "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4" ], "markers": "python_version >= '3.7'", - "version": "==4.4.0" + "version": "==4.5.0" } }, "develop": { @@ -483,23 +482,22 @@ }, "protobuf": { "hashes": [ - "sha256:1f22ac0ca65bb70a876060d96d914dae09ac98d114294f77584b0d2644fa9c30", - "sha256:237216c3326d46808a9f7c26fd1bd4b20015fb6867dc5d263a493ef9a539293b", - "sha256:27f4d15021da6d2b706ddc3860fac0a5ddaba34ab679dc182b60a8bb4e1121cc", - "sha256:299ea899484ee6f44604deb71f424234f654606b983cb496ea2a53e3c63ab791", - "sha256:3d164928ff0727d97022957c2b849250ca0e64777ee31efd7d6de2e07c494717", - "sha256:6ab80df09e3208f742c98443b6166bcb70d65f52cfeb67357d52032ea1ae9bec", - "sha256:78a28c9fa223998472886c77042e9b9afb6fe4242bd2a2a5aced88e3f4422aa7", - "sha256:7cd532c4566d0e6feafecc1059d04c7915aec8e182d1cf7adee8b24ef1e2e6ab", - "sha256:89f9149e4a0169cddfc44c74f230d7743002e3aa0b9472d8c28f0388102fc4c2", - "sha256:a53fd3f03e578553623272dc46ac2f189de23862e68565e83dde203d41b76fc5", - "sha256:b135410244ebe777db80298297a97fbb4c862c881b4403b71bac9d4107d61fd1", - "sha256:b98d0148f84e3a3c569e19f52103ca1feacdac0d2df8d6533cf983d1fda28462", - "sha256:d1736130bce8cf131ac7957fa26880ca19227d4ad68b4888b3be0dea1f95df97", - "sha256:f45460f9ee70a0ec1b6694c6e4e348ad2019275680bd68a1d9314b8c7e01e574" + "sha256:1669cb7524221a8e2d9008d0842453dbefdd0fcdd64d67672f657244867635fb", + "sha256:29288813aacaa302afa2381db1d6e0482165737b0afdf2811df5fa99185c457b", + "sha256:47d31bdf58222dd296976aa1646c68c6ee80b96d22e0a3c336c9174e253fd35e", + "sha256:652d8dfece122a24d98eebfef30e31e455d300efa41999d1182e015984ac5930", + "sha256:7c535d126e7dcc714105ab20b418c4fedbd28f8b8afc42b7350b1e317bbbcc71", + "sha256:86c3d20428b007537ba6792b475c0853bba7f66b1f60e610d913b77d94b486e4", + "sha256:a33a273d21852f911b8bda47f39f4383fe7c061eb1814db2c76c9875c89c2491", + "sha256:ab4d043865dd04e6b09386981fe8f80b39a1e46139fb4a3c206229d6b9f36ff6", + "sha256:b2fea9dc8e3c0f32c38124790ef16cba2ee0628fe2022a52e435e1117bfef9b1", + "sha256:c27f371f0159feb70e6ea52ed7e768b3f3a4c5676c1900a7e51a24740381650e", + "sha256:c3325803095fb4c2a48649c321d2fbde59f8fbfcb9bfc7a86df27d112831c571", + "sha256:e474b63bab0a2ea32a7b26a4d8eec59e33e709321e5e16fb66e766b61b82a95e", + "sha256:e894e9ae603e963f0842498c4cd5d39c6a60f0d7e4c103df50ee939564298658" ], "index": "pypi", - "version": "==4.21.12" + "version": "==4.22.0" }, "pycodestyle": { "hashes": [ @@ -519,11 +517,11 @@ }, "pylint": { "hashes": [ - "sha256:bad9d7c36037f6043a1e848a43004dfd5ea5ceb05815d713ba56ca4503a9fe37", - "sha256:ffe7fa536bb38ba35006a7c8a6d2efbfdd3d95bbf21199cad31f76b1c50aaf30" + "sha256:13b2c805a404a9bf57d002cd5f054ca4d40b0b87542bdaba5e05321ae8262c84", + "sha256:ff22dde9c2128cd257c145cfd51adeff0be7df4d80d669055f24a962b351bbe4" ], "index": "pypi", - "version": "==2.16.1" + "version": "==2.16.2" }, "pyproject-hooks": { "hashes": [ @@ -559,11 +557,11 @@ }, "setuptools": { "hashes": [ - "sha256:16ccf598aab3b506593c17378473978908a2734d7336755a8769b480906bec1c", - "sha256:b440ee5f7e607bb8c9de15259dba2583dd41a38879a7abc1d43a71c59524da48" + "sha256:95f00380ef2ffa41d9bba85d95b27689d923c93dfbafed4aecd7cf988a25e012", + "sha256:bb6d8e508de562768f2027902929f8523932fcd1fb784e6d573d2cafac995a48" ], "markers": "python_version >= '3.7'", - "version": "==67.2.0" + "version": "==67.3.2" }, "setuptools-git-versioning": { "hashes": [ @@ -583,19 +581,19 @@ }, "types-protobuf": { "hashes": [ - "sha256:46ffa6647e2f8d53a4828e905f8fb0e8ff8c918309b425572dd34ab4d0b48553", - "sha256:819a7c67e69476e39c3f0c9871bbb9ee82313645d317b6daeb60ac95a309dbd3" + "sha256:39167012ead0bc5920b6322a1e4dc2d088f66a34b84cce39bb88500e49ac955a", + "sha256:8c105b906569e9d53ba033465880d9ef17a59bf3ba8ab656d24c9eadb9d8a056" ], "index": "pypi", - "version": "==4.21.0.5" + "version": "==4.21.0.6" }, "typing-extensions": { "hashes": [ - "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa", - "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e" + "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb", + "sha256:fb33085c39dd998ac16d1431ebc293a8b3eedd00fd4a32de0ff79002c19511b4" ], "markers": "python_version >= '3.7'", - "version": "==4.4.0" + "version": "==4.5.0" }, "wheel": { "hashes": [ diff --git a/README.md b/README.md index 9f46347..1ba5d14 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua) +![protobuf version](https://img.shields.io/badge/protobuf-4.22.0-informational)--> @@ -228,7 +228,7 @@ OpenCV requires [Visual C++ redistributable 2015](https://www.microsoft.com/en-u ## Program help: arguments and options -
usage: extract_otp_secrets.py [-h] [--csv FILE] [--keepass FILE] [--json FILE] [--printqr] [--saveqr DIR] [--camera NUMBER] [--qr {ZBAR,QREADER,QREADER_DEEP,CV2,CV2_WECHAT}] [-i] [--no-color] [--version] [-d | -v | -q] [infile ...]
+
usage: extract_otp_secrets.py [-h] [--csv FILE] [--keepass FILE] [--json FILE] [--txt FILE] [--printqr] [--saveqr DIR] [--camera NUMBER] [--qr {ZBAR,QREADER,QREADER_DEEP,CV2,CV2_WECHAT}] [-i] [--no-color] [--version] [-d | -v | -q] [infile ...]
 
 Extracts one time password (OTP) secrets from QR codes exported by two-factor authentication (2FA) apps
 If no infiles are provided, a GUI window starts and QR codes are captured from the camera.
@@ -242,8 +242,9 @@ options:
   --csv FILE, -c FILE           export csv file or - for stdout
   --keepass FILE, -k FILE       export totp/hotp csv file(s) for KeePass, - for stdout
   --json FILE, -j FILE          export json file or - 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)
+  --txt FILE, -t FILE           export txt file or - for stdout
+  --printqr, -p                 print QR code(s) as text to the terminal
+  --saveqr DIR, -s DIR          save QR code(s) as images to directory
   --camera NUMBER, -C NUMBER    camera number of system (default camera: 0)
   --qr {ZBAR,QREADER,QREADER_DEEP,CV2,CV2_WECHAT}, -Q {ZBAR,QREADER,QREADER_DEEP,CV2,CV2_WECHAT}
                                 QR reader (default: ZBAR)
@@ -694,7 +695,7 @@ Command for regeneration of Python code from proto3 message definition file (onl
 
     protoc --plugin=protoc-gen-mypy=path/to/protoc-gen-mypy --python_out=src/protobuf_generated_python --mypy_out=src/protobuf_generated_python src/google_auth.proto
 
-The generated protobuf Python code was generated by protoc 21.12 (https://github.com/protocolbuffers/protobuf/releases/tag/v21.12).
+The generated protobuf Python code was generated by protoc 22.0 (https://github.com/protocolbuffers/protobuf/releases/tag/v22.0).
 
 For Python type hint generation the [mypy-protobuf](https://github.com/nipunn1313/mypy-protobuf) package is used.
 
diff --git a/src/extract_otp_secrets.py b/src/extract_otp_secrets.py
index 6cd0334..b239397 100644
--- a/src/extract_otp_secrets.py
+++ b/src/extract_otp_secrets.py
@@ -43,7 +43,7 @@ import re
 import sys
 import urllib.parse as urlparse
 from enum import Enum, IntEnum
-from typing import Any, List, Optional, Sequence, TextIO, Tuple, Union
+from typing import Any, List, Optional, Sequence, TextIO, Tuple, Union, TYPE_CHECKING
 
 import colorama
 from pkg_resources import DistributionNotFound, get_distribution
@@ -200,6 +200,7 @@ def main(sys_args: list[str]) -> None:
     write_csv(args.csv, otps)
     write_keepass_csv(args.keepass, otps)
     write_json(args.json, otps)
+    write_txt(args.txt, otps, True)
 
 
 # workaround for PYTHON <= 3.9 use: pb.MigrationPayload | None
@@ -262,9 +263,9 @@ def extract_otp_from_otp_url(otpauth_migration_url: str, otps: Otps, urls_count:
             if not quiet:
                 print_otp(otp)
             if args.printqr:
-                print_qr(args, otp_url)
+                print_qr(otp_url)
             if args.saveqr:
-                save_qr(otp, args, len(otps))
+                save_qr_image(otp, args.saveqr, len(otps))
             if not quiet:
                 print()
         elif args.ignore and not quiet:
@@ -297,8 +298,9 @@ b) image file containing a QR code or = for stdin for an image containing a QR c
     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'))
+    arg_parser.add_argument('--txt', '-t', help='export txt file or - for stdout', metavar=('FILE'))
+    arg_parser.add_argument('--printqr', '-p', help='print QR code(s) as text to the terminal', action='store_true')
+    arg_parser.add_argument('--saveqr', '-s', help='save QR code(s) as images to directory', metavar=('DIR'))
     if cv2_available:
         arg_parser.add_argument('--camera', '-C', help='camera number of system (default camera: 0)', default=0, type=int, metavar=('NUMBER'))
         if not zbar_available:
@@ -314,7 +316,7 @@ b) image file containing a QR code or = for stdin for an image containing a QR c
     output_group.add_argument('-q', '--quiet', help='no stdout output, except output set by -', action='store_true')
     args = arg_parser.parse_args(sys_args)
     colored = not args.no_color
-    if args.csv == '-' or args.json == '-' or args.keepass == '-':
+    if args.csv == '-' or args.json == '-' or args.keepass == '-' or args.txt == '-':
         args.quiet = args.q = True
 
     verbose = args.verbose if args.verbose else LogLevel.NORMAL
@@ -391,7 +393,7 @@ def extract_otps_from_camera(args: Args) -> Otps:
 
         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, "Press C/J/K/T to save as csv/json/keepass/txt 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)
@@ -486,6 +488,18 @@ def cv2_handle_pressed_keys(qr_mode: QRMode, otps: Otps) -> Tuple[bool, QRMode]:
             tk_root.update()
             if len(file_name) > 0:
                 write_keepass_csv(file_name, otps)
+    elif (key == ord('t') or key == ord('T')) and is_not_headless():
+        if has_no_otps_show_warning(otps):
+            pass
+        else:
+            file_name = tkinter.filedialog.asksaveasfilename(
+                title="Save extracted otp secrets as text",
+                defaultextension='.txt',
+                filetypes=[('Text', '*.txt'), ('All', '*.*')]
+            )
+            tk_root.update()
+            if len(file_name) > 0:
+                write_txt(file_name, otps, True)
     elif key == 32:
         qr_mode = next_valid_qr_mode(qr_mode, zbar_available)
         if verbose >= LogLevel.MORE_VERBOSE: print(f"QR reading mode: {qr_mode}")
@@ -655,28 +669,27 @@ def build_otp_url(secret: str, raw_otp: pb.MigrationPayload.OtpParameters) -> st
     return otp_url
 
 
-def print_otp(otp: Otp) -> None:
-    print(f"Name:    {otp['name']}")
-    print(f"Secret:  {otp['secret']}")
-    if otp['issuer']: print(f"Issuer:  {otp['issuer']}")
-    print(f"Type:    {otp['type']}")
+def print_otp(otp: Otp, out: Optional[TextIO] = None) -> None:
+    print(f"Name:    {otp['name']}", file=out)
+    print(f"Secret:  {otp['secret']}", file=out)
+    if otp['issuer']: print(f"Issuer:  {otp['issuer']}", file=out)
+    print(f"Type:    {otp['type']}", file=out)
     if otp['type'] == 'hotp':
-        print(f"Counter: {otp['counter']}")
+        print(f"Counter: {otp['counter']}", file=out)
     if verbose:
-        print(otp['url'])
+        print(otp['url'], file=out)
 
 
-def save_qr(otp: Otp, args: Args, j: int) -> str:
-    dir = args.saveqr
+def save_qr_image(otp: Otp, dir: str, j: int) -> str:
     if not (os.path.exists(dir)): os.makedirs(dir, exist_ok=True)
     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'], f"{dir}/{j}-{file_otp_name}{'-' + file_otp_issuer if file_otp_issuer else ''}.png")
+    save_qr_image_file(otp['url'], f"{dir}/{j}-{file_otp_name}{'-' + file_otp_issuer if file_otp_issuer else ''}.png")
     return file_otp_name
 
 
-def save_qr_file(args: Args, otp_url: OtpUrl, name: str) -> None:
+def save_qr_image_file(otp_url: OtpUrl, name: str) -> None:
     qr = QRCode()
     qr.add_data(otp_url)
     img = qr.make_image(fill_color='black', back_color='white')
@@ -684,10 +697,20 @@ def save_qr_file(args: Args, otp_url: OtpUrl, name: str) -> None:
     img.save(name)
 
 
-def print_qr(args: Args, otp_url: str) -> None:
+def print_qr(otp_url: str, out: Optional[TextIO] = None) -> None:
     qr = QRCode()
     qr.add_data(otp_url)
-    qr.print_ascii()
+    qr.print_ascii(out)
+
+
+def write_txt(file: str, otps: Otps, write_qr: bool = False) -> None:
+    if file and len(file) > 0 and len(otps) > 0:
+        with open_file_or_stdout(file) as outfile:
+            for otp in otps:
+                print_otp(otp, outfile)
+                if write_qr:
+                    print_qr(otp['url'], outfile)
+                print(file=outfile)
 
 
 def write_csv(file: str, otps: Otps) -> None:
diff --git a/src/protobuf_generated_python/google_auth_pb2.py b/src/protobuf_generated_python/google_auth_pb2.py
index 7099ee4..4181830 100644
--- a/src/protobuf_generated_python/google_auth_pb2.py
+++ b/src/protobuf_generated_python/google_auth_pb2.py
@@ -15,17 +15,18 @@ _sym_db = _symbol_database.Default()
 
 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11google_auth.proto\"\xb7\x03\n\x10MigrationPayload\x12\x37\n\x0eotp_parameters\x18\x01 \x03(\x0b\x32\x1f.MigrationPayload.OtpParameters\x12\x0f\n\x07version\x18\x02 \x01(\x05\x12\x12\n\nbatch_size\x18\x03 \x01(\x05\x12\x13\n\x0b\x62\x61tch_index\x18\x04 \x01(\x05\x12\x10\n\x08\x62\x61tch_id\x18\x05 \x01(\x05\x1a\xb7\x01\n\rOtpParameters\x12\x0e\n\x06secret\x18\x01 \x01(\x0c\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06issuer\x18\x03 \x01(\t\x12.\n\talgorithm\x18\x04 \x01(\x0e\x32\x1b.MigrationPayload.Algorithm\x12\x0e\n\x06\x64igits\x18\x05 \x01(\x05\x12\'\n\x04type\x18\x06 \x01(\x0e\x32\x19.MigrationPayload.OtpType\x12\x0f\n\x07\x63ounter\x18\x07 \x01(\x03\",\n\tAlgorithm\x12\x10\n\x0c\x41LGO_INVALID\x10\x00\x12\r\n\tALGO_SHA1\x10\x01\"6\n\x07OtpType\x12\x0f\n\x0bOTP_INVALID\x10\x00\x12\x0c\n\x08OTP_HOTP\x10\x01\x12\x0c\n\x08OTP_TOTP\x10\x02\x62\x06proto3')
 
-_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
-_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google_auth_pb2', globals())
+_globals = globals()
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'google_auth_pb2', _globals)
 if _descriptor._USE_C_DESCRIPTORS == False:
 
   DESCRIPTOR._options = None
-  _MIGRATIONPAYLOAD._serialized_start=22
-  _MIGRATIONPAYLOAD._serialized_end=461
-  _MIGRATIONPAYLOAD_OTPPARAMETERS._serialized_start=176
-  _MIGRATIONPAYLOAD_OTPPARAMETERS._serialized_end=359
-  _MIGRATIONPAYLOAD_ALGORITHM._serialized_start=361
-  _MIGRATIONPAYLOAD_ALGORITHM._serialized_end=405
-  _MIGRATIONPAYLOAD_OTPTYPE._serialized_start=407
-  _MIGRATIONPAYLOAD_OTPTYPE._serialized_end=461
+  _globals['_MIGRATIONPAYLOAD']._serialized_start=22
+  _globals['_MIGRATIONPAYLOAD']._serialized_end=461
+  _globals['_MIGRATIONPAYLOAD_OTPPARAMETERS']._serialized_start=176
+  _globals['_MIGRATIONPAYLOAD_OTPPARAMETERS']._serialized_end=359
+  _globals['_MIGRATIONPAYLOAD_ALGORITHM']._serialized_start=361
+  _globals['_MIGRATIONPAYLOAD_ALGORITHM']._serialized_end=405
+  _globals['_MIGRATIONPAYLOAD_OTPTYPE']._serialized_start=407
+  _globals['_MIGRATIONPAYLOAD_OTPTYPE']._serialized_end=461
 # @@protoc_insertion_point(module_scope)
diff --git a/tests/extract_otp_secrets_test.py b/tests/extract_otp_secrets_test.py
index 8e04676..e591ea7 100644
--- a/tests/extract_otp_secrets_test.py
+++ b/tests/extract_otp_secrets_test.py
@@ -376,6 +376,48 @@ def test_extract_json_stdout_only_comments(capsys: pytest.CaptureFixture[str]) -
     assert captured.err == ''
 
 
+def test_extract_txt(capsys: pytest.CaptureFixture[str], tmp_path: pathlib.Path) -> None:
+    # Arrange
+    output_file = str(tmp_path / 'test_example_output.txt')
+
+    # Act
+    extract_otp_secrets.main(['-q', '-t', output_file, 'example_export.txt'])
+
+    # Assert
+    expected_txt = read_file_to_str('tests/data/printqr_output.txt')
+    actual_txt = read_file_to_str(output_file)
+
+    assert actual_txt == expected_txt
+
+    captured = capsys.readouterr()
+
+    assert captured.out == ''
+    assert captured.err == ''
+
+
+def test_extract_txt_stdout(capsys: pytest.CaptureFixture[str]) -> None:
+    # Act
+    extract_otp_secrets.main(['-t', '-', 'example_export.txt'])
+
+    # Assert
+    expected_txt = read_file_to_str('tests/data/printqr_output.txt')
+    captured = capsys.readouterr()
+
+    assert captured.out == expected_txt
+    assert captured.err == ''
+
+
+def test_extract_txt_stdout_only_comments(capsys: pytest.CaptureFixture[str]) -> None:
+    # Act
+    extract_otp_secrets.main(['-t', '-', 'tests/data/only_comments.txt'])
+
+    # Assert
+    captured = capsys.readouterr()
+
+    assert captured.out == ''
+    assert captured.err == ''
+
+
 def test_extract_not_encoded_plus(capsys: pytest.CaptureFixture[str]) -> None:
     # Act
     extract_otp_secrets.main(['tests/data/test_plus_problem_export.txt'])
@@ -724,8 +766,10 @@ def test_verbose_and_quiet(capsys: pytest.CaptureFixture[str]) -> None:
     ('-k', 'outfile', False, False),
     ('-k', '-', True, False),
     ('-j', 'outfile', False, False),
-    ('-s', 'outfile', False, False),
     ('-j', '-', True, False),
+    ('-t', 'outfile', False, False),
+    ('-t', '-', True, False),
+    ('-s', 'outfile', False, False),
     ('-i', None, False, False),
     ('-p', None, True, False),
     ('-Q', 'CV2', False, False),