diff --git a/Pipfile.lock b/Pipfile.lock index 8f3f1b0..cf550d1 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -85,23 +85,23 @@ }, "protobuf": { "hashes": [ - "sha256:2c9c2ed7466ad565f18668aa4731c535511c5d9a40c6da39524bccf43e441719", - "sha256:48e2cd6b88c6ed3d5877a3ea40df79d08374088e89bedc32557348848dff250b", - "sha256:5b0834e61fb38f34ba8840d7dcb2e5a2f03de0c714e0293b3963b79db26de8ce", - "sha256:61f21493d96d2a77f9ca84fefa105872550ab5ef71d21c458eb80edcf4885a99", - "sha256:6e0be9f09bf9b6cf497b27425487706fa48c6d1632ddd94dab1a5fe11a422392", - "sha256:6e312e280fbe3c74ea9e080d9e6080b636798b5e3939242298b591064470b06b", - "sha256:7eb8f2cc41a34e9c956c256e3ac766cf4e1a4c9c925dc757a41a01be3e852965", - "sha256:84ea107016244dfc1eecae7684f7ce13c788b9a644cd3fca5b77871366556444", - "sha256:9227c14010acd9ae7702d6467b4625b6fe853175a6b150e539b21d2b2f2b409c", - "sha256:a419cc95fca8694804709b8c4f2326266d29659b126a93befe210f5bbc772536", - "sha256:a7d0ea43949d45b836234f4ebb5ba0b22e7432d065394b532cdca8f98415e3cf", - "sha256:b5ab0b8918c136345ff045d4b3d5f719b505b7c8af45092d7f45e304f55e50a1", - "sha256:e575c57dc8b5b2b2caa436c16d44ef6981f2235eb7179bfc847557886376d740", - "sha256:f9eae277dd240ae19bb06ff4e2346e771252b0e619421965504bd1b1bba7c5fa" + "sha256:0413addc126c40a5440ee59be098de1007183d68e9f5f20ed5fbc44848f417ca", + "sha256:05cbcb9a25cd781fd949f93f6f98a911883868c0360c6d2264fc99a903c8f0d7", + "sha256:0c968753028cb14b1d24cc839723f7e9505b305fc588a37a9e0f7d270cb59d89", + "sha256:2a172741b5b041a896b621cef4277077afd571e0d3a6e524e7171f1c70e33200", + "sha256:3f08f04b4f101dd469efbcc1731fbf48068eccd8a42f4e2ea530aa012a5f56f8", + "sha256:4d97c16c0d11155b3714a29245461f0eb60cace294455077f3a3b8a629afa383", + "sha256:5096b3922b45e4b7a04d3d3cb855d13bb5ccd4d5e44b129e706232ebf0ffb870", + "sha256:5efa8a8162ada7e10847140308fbf84fdc5b89dc21655d12ec04aed87284fe07", + "sha256:6b809f20923b6ef49dc1755cb50bdb21be179b4a3c7ffcab1fe5d3f139b58a51", + "sha256:81b233a06c62387ea5c9be2cd9aedd2ba09940e91da53b920e9ff5bd98e48e7f", + "sha256:a5e89eabaa0ca72ce1b7c8104a740d44cdb67942cbbed00c69a4c0541de17107", + "sha256:b78d7c2c36b51c0041b9bf000be4adb09f4112bfc40bc7a9d48ac0b0dfad139e", + "sha256:e53165dd14d19abc7f50733f365de431e51d1d262db40c0ee22e271a074fac59", + "sha256:e92768d17473657c87e98b79a4c7724b0ddfa23211b05ce137bfdc55e734e36f" ], "index": "pypi", - "version": "==4.21.9" + "version": "==4.21.10" }, "qrcode": { "hashes": [ @@ -234,11 +234,11 @@ }, "pylint": { "hashes": [ - "sha256:15060cc22ed6830a4049cf40bc24977744df2e554d38da1b2657591de5bcd052", - "sha256:25b13ddcf5af7d112cf96935e21806c1da60e676f952efb650130f2a4483421c" + "sha256:1d561d1d3e8be9dd880edc685162fbdaa0409c88b9b7400873c0cf345602e326", + "sha256:91e4776dbcb4b4d921a3e4b6fec669551107ba11f29d9199154a01622e460a57" ], "index": "pypi", - "version": "==2.15.6" + "version": "==2.15.7" }, "pyparsing": { "hashes": [ diff --git a/README.md b/README.md index 35304b7..520e5db 100644 --- a/README.md +++ b/README.md @@ -3,14 +3,14 @@ [![CI Status](https://github.com/scito/extract_otp_secret_keys/actions/workflows/ci.yml/badge.svg)](https://github.com/scito/extract_otp_secret_keys/actions/workflows/ci.yml) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/protobuf) [![GitHub Pipenv locked Python version](https://img.shields.io/github/pipenv/locked/python-version/scito/extract_otp_secret_keys)](https://github.com/scito/extract_otp_secret_keys/blob/master/Pipfile.lock) -![protobuf version](https://img.shields.io/badge/protobuf-4.21.9-informational) +![protobuf version](https://img.shields.io/badge/protobuf-4.21.10-informational) [![License](https://img.shields.io/github/license/scito/extract_otp_secret_keys)](https://github.com/scito/extract_otp_secret_keys/blob/master/LICENSE) [![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/scito/extract_otp_secret_keys?sort=semver&label=version)](https://github.com/scito/extract_otp_secret_keys/tags) [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/badges/StandWithUkraine.svg)](https://stand-with-ukraine.pp.ua) --- -Extract two-factor authentication (2FA, TFA) secret keys from export QR codes of "Google Authenticator" app. +Extract two-factor authentication (2FA, TFA, one time passwords, otp) secret keys from export QR codes of "Google Authenticator" app. The secret and otp values can be printed and exported to json or csv. The QR codes can be printed or saved as PNG images. ## Usage @@ -26,19 +26,20 @@ The secret and otp values can be printed and exported to json or csv. The QR cod ## Program help: arguments and options -
usage: extract_otp_secret_keys.py [-h] [--json FILE] [--csv FILE] [--printqr] [--saveqr DIR] [--verbose] [--quiet] infile
+
usage: extract_otp_secret_keys.py [-h] [--json FILE] [--csv FILE] [--keepass FILE] [--printqr] [--saveqr DIR] [--verbose] [--quiet] infile
 
 positional arguments:
-  infile                file or - for stdin (default: -) with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored
+  infile                   file or - for stdin (default: -) with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored
 
 options:
-  -h, --help            show this help message and exit
-  --json FILE, -j FILE  export to json file
-  --csv FILE, -c FILE   export to csv file
-  --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
+ -h, --help show this help message and exit + --json FILE, -j FILE export json file + --csv FILE, -c FILE export csv file + --keepass FILE, -k FILE export totp/hotp csv file(s) for KeePass + --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
## Dependencies @@ -47,7 +48,7 @@ options: Known to work with * Python 3.10.8, protobuf 4.21.9, qrcode 7.3.1, and pillow 9.2 -* Python 3.11.0, protobuf 4.21.9, qrcode 7.3.1, and pillow 9.2 +* Python 3.11.0, protobuf 4.21.10, qrcode 7.3.1, and pillow 9.2 For protobuf versions 3.14.0 or similar or Python 3.6, use the extract_otp_secret_keys version 1.4.0. @@ -57,6 +58,44 @@ For printing QR codes, the qrcode module is required, otherwise it can be omitte pip install qrcode[pil] +## KeePass + +[KeePass 2.51](https://keepass.info/news/n220506_2.51.html) (released in May 2022) and newer [support the generation of OTPs (TOTP and HOTP)](https://keepass.info/help/base/placeholders.html#otp). + +KeePass can generate the second factor password (2FA) if the OTP secret is stored in `TimeOtp-Secret-Base32` string field for TOTP or `HmacOtp-Secret-Base32` string field for HOTP. You view or edit them in entry dialog on the 'Advanced' tab page. + +KeePass provides menu commands in the main window for generating one-time passwords ('Copy HMAC-Based OTP', 'Show HMAC-Based OTP', 'Copy Time-Based OTP', 'Show Time-Based OTP'). Furthermore, one-time passwords can be generated during auto-type using the {HMACOTP} and {TIMEOTP} placeholders. + +In order to simplify the usage of the second factor password generation in KeePass a specific KeePass CSV export is available with option `-keepass` or `-k`. This KeePass CSV file can be imported by the ["Generic CSV Importer" of KeePass](https://keepass.info/help/kb/imp_csv.html). + +If TOTP and HOTP entries have to be exported, then two files with an intermediate suffix .totp or .hotp will be added to the KeePass export filename. + +Example: +- Only TOTP entries to export and parameter --keepass example_keepass_output.csv
+ → example_keepass_output.csv with TOTP entries will be exported +- Only HOTP entries to export and parameter --keepass example_keepass_output.csv
+ → example_keepass_output.csv with HOTP entries will be exported +- If both TOTP and HOTP entries to export and parameter --keepass example_keepass_output.csv
+ → example_keepass_output.totp.csv with TOTP entries will be exported
+ → example_keepass_output.hotp.csv with HOTP entries will be exported + +Import CSV with TOTP entries in KeePass as + +- Title +- User Name +- String (TimeOtp-Secret-Base32) +- Group (/) + +Import CSV with HOTP entries in KeePass as + +- Title +- User Name +- String (HmacOtp-Secret-Base32) +- String (HmacOtp-Counter) +- Group (/) + +KeePass can be used as a backup for one time passwords (second factor) from the mobile phone. + ## Technical background The export QR code of "Google Authenticator" contains the URL `otpauth-migration://offline?data=...`. @@ -66,7 +105,7 @@ Command for regeneration of Python code from proto3 message definition file (onl protoc --python_out=protobuf_generated_python google_auth.proto -The generated protobuf Python code was generated by protoc 21.9 (https://github.com/protocolbuffers/protobuf/releases/tag/v21.9). +The generated protobuf Python code was generated by protoc 21.10 (https://github.com/protocolbuffers/protobuf/releases/tag/v21.10). ## References diff --git a/example_export.txt b/example_export.txt index 022072e..12abdb1 100644 --- a/example_export.txt +++ b/example_export.txt @@ -9,3 +9,6 @@ otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXB # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi # otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B + +# otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 +otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D diff --git a/example_keepass_output.hotp.csv b/example_keepass_output.hotp.csv new file mode 100644 index 0000000..656e713 --- /dev/null +++ b/example_keepass_output.hotp.csv @@ -0,0 +1,2 @@ +Title,User Name,HmacOtp-Secret-Base32,HmacOtp-Counter,Group +,hotp demo,7KSQL2JTUDIS5EF65KLMRQIIGY,4,OTP/HOTP diff --git a/example_keepass_output.totp.csv b/example_keepass_output.totp.csv new file mode 100644 index 0000000..87add21 --- /dev/null +++ b/example_keepass_output.totp.csv @@ -0,0 +1,5 @@ +Title,User Name,TimeOtp-Secret-Base32,Group +raspberrypi,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP +,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP +,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP +raspberrypi,pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,OTP/TOTP diff --git a/example_output.csv b/example_output.csv index 7815773..98ee5b5 100644 --- a/example_output.csv +++ b/example_output.csv @@ -1,5 +1,6 @@ -name,secret,issuer,type,url -pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,raspberrypi,OTP_TOTP,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi -pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,,OTP_TOTP,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY -pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,,OTP_TOTP,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY -pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,raspberrypi,OTP_TOTP,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi +name,secret,issuer,type,counter,url +pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,raspberrypi,totp,,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi +pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,,totp,,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,,totp,,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +pi@raspberrypi,7KSQL2JTUDIS5EF65KLMRQIIGY,raspberrypi,totp,,otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi +hotp demo,7KSQL2JTUDIS5EF65KLMRQIIGY,,hotp,4,otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 diff --git a/example_output.json b/example_output.json index 3e85fd0..ac3f8a1 100644 --- a/example_output.json +++ b/example_output.json @@ -3,28 +3,40 @@ "name": "pi@raspberrypi", "secret": "7KSQL2JTUDIS5EF65KLMRQIIGY", "issuer": "raspberrypi", - "type": "OTP_TOTP", + "type": "totp", + "counter": null, "url": "otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi" }, { "name": "pi@raspberrypi", "secret": "7KSQL2JTUDIS5EF65KLMRQIIGY", "issuer": "", - "type": "OTP_TOTP", + "type": "totp", + "counter": null, "url": "otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY" }, { "name": "pi@raspberrypi", "secret": "7KSQL2JTUDIS5EF65KLMRQIIGY", "issuer": "", - "type": "OTP_TOTP", + "type": "totp", + "counter": null, "url": "otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY" }, { "name": "pi@raspberrypi", "secret": "7KSQL2JTUDIS5EF65KLMRQIIGY", "issuer": "raspberrypi", - "type": "OTP_TOTP", + "type": "totp", + "counter": null, "url": "otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi" + }, + { + "name": "hotp demo", + "secret": "7KSQL2JTUDIS5EF65KLMRQIIGY", + "issuer": "", + "type": "hotp", + "counter": 4, + "url": "otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4" } ] \ No newline at end of file diff --git a/extract_otp_secret_keys.py b/extract_otp_secret_keys.py index 8241143..d904d15 100644 --- a/extract_otp_secret_keys.py +++ b/extract_otp_secret_keys.py @@ -65,14 +65,17 @@ def main(sys_args): otps = extract_otps(args) write_csv(args, otps) + write_keepass_csv(args, otps) write_json(args, otps) def parse_args(sys_args): - arg_parser = argparse.ArgumentParser() + formatter = lambda prog: argparse.HelpFormatter(prog,max_help_position=52) + arg_parser = argparse.ArgumentParser(formatter_class=formatter) arg_parser.add_argument('infile', help='file or - for stdin (default: -) with "otpauth-migration://..." URLs separated by newlines, lines starting with # are ignored') - arg_parser.add_argument('--json', '-j', help='export to json file', metavar=('FILE')) - arg_parser.add_argument('--csv', '-c', help='export to csv file', metavar=('FILE')) + arg_parser.add_argument('--json', '-j', help='export json file', metavar=('FILE')) + arg_parser.add_argument('--csv', '-c', help='export csv file', metavar=('FILE')) + arg_parser.add_argument('--keepass', '-k', help='export totp/hotp csv file(s) for KeePass', 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('--verbose', '-v', help='verbose output', action='count') @@ -102,13 +105,15 @@ def extract_otps(args): j += 1 if verbose: print('\n{}. Secret Key'.format(j)) secret = convert_secret_from_bytes_to_base32_str(raw_otp.secret) - otp_type = get_enum_name_by_number(raw_otp, 'type') + 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: @@ -154,6 +159,10 @@ def get_enum_name_by_number(parent, field_name): return parent.DESCRIPTOR.fields_by_name[field_name].enum_type.values_by_number.get(field_value).name +def get_otp_type_str_from_code(otp_type): + return 'totp' if otp_type == 2 else 'hotp' + + def convert_secret_from_bytes_to_base32_str(bytes): return str(base64.b32encode(bytes), 'utf-8').replace('=', '') @@ -162,15 +171,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('totp' if raw_otp.type == 2 else 'hotp', quote(raw_otp.name)) + urlencode(url_params) + otp_url = 'otpauth://{}/{}?'.format(get_otp_type_str_from_code(raw_otp.type), quote(raw_otp.name)) + 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('Name: {}'.format(otp['name'])) + print('Secret: {}'.format(otp['secret'])) + if otp['issuer']: print('Issuer: {}'.format(otp['issuer'])) + print('Type: {}'.format(otp['type'])) + if otp['type'] == 'hotp': + print('Counter: {}'.format(otp['counter'])) if verbose: print(otp['url']) @@ -209,7 +220,48 @@ def write_csv(args, otps): writer = csv.DictWriter(outfile, otps[0].keys()) writer.writeheader() writer.writerows(otps) - if not quiet: print("Exported {} otps to csv".format(len(otps))) + if not quiet: print("Exported {} otps to csv {}".format(len(otps), 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') + 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") + count_totp_entries = 0 + count_hotp_entries = 0 + if has_totp: + with open(otp_filename_totp, "w") as outfile: + writer = csv.DictWriter(outfile, ["Title", "User Name", "TimeOtp-Secret-Base32", "Group"]) + writer.writeheader() + for otp in otps: + if otp['type'] == 'totp': + writer.writerow({ + 'Title': otp['issuer'], + 'User Name': otp['name'], + 'TimeOtp-Secret-Base32': otp['secret'] if otp['type'] == 'totp' else None, + 'Group': "OTP/{}".format(otp['type'].upper()) + }) + count_totp_entries += 1 + if has_hotp: + with open(otp_filename_hotp, "w") as outfile: + writer = csv.DictWriter(outfile, ["Title", "User Name", "HmacOtp-Secret-Base32", "HmacOtp-Counter", "Group"]) + writer.writeheader() + for otp in otps: + if otp['type'] == 'hotp': + writer.writerow({ + 'Title': otp['issuer'], + '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()) + }) + 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)) def write_json(args, otps): @@ -217,7 +269,20 @@ def write_json(args, otps): if args.json: with open(args.json, "w") as outfile: json.dump(otps, outfile, indent=4) - if not quiet: print("Exported {} otp entries to json".format(len(otps))) + if not quiet: print("Exported {} otp entries to json {}".format(len(otps), args.json)) + + +def has_otp_type(otps, otp_type): + for otp in otps: + if otp['type'] == otp_type: + return True + return False + + +def add_pre_suffix(file, pre_suffix): + '''filename.ext, pre -> filename.pre.ext''' + name, ext = path.splitext(file) + return name + "." + pre_suffix + (ext if ext else "") if __name__ == '__main__': diff --git a/test/example_export_only_totp.txt b/test/example_export_only_totp.txt new file mode 100644 index 0000000..022072e --- /dev/null +++ b/test/example_export_only_totp.txt @@ -0,0 +1,11 @@ +# 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 + +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACEAEYASAAKLzjp5n4%2F%2F%2F%2F%2FwE%3D + +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi +# otpauth://totp/pi@raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY +otpauth-migration://offline?data=CigKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpIAEoATACCjUKEPqlBekzoNEukL7qlsjBCDYSDnBpQHJhc3BiZXJyeXBpGgtyYXNwYmVycnlwaSABKAEwAhABGAEgACiQ7OOa%2Bf%2F%2F%2F%2F8B diff --git a/test/print_verbose_output.txt b/test/print_verbose_output.txt index c9770e5..6e18512 100644 --- a/test/print_verbose_output.txt +++ b/test/print_verbose_output.txt @@ -18,10 +18,10 @@ batch_id: -1320898453 1. Secret Key -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi @@ -42,9 +42,9 @@ batch_id: -2094403140 2. Secret Key -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY @@ -74,16 +74,41 @@ batch_id: -1822886384 3. Secret Key -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY 4. Secret Key -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp otpauth://totp/pi%40raspberrypi?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&issuer=raspberrypi + +# otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 +otpauth-migration://offline?data=CiUKEPqlBekzoNEukL7qlsjBCDYSCWhvdHAgZGVtbyABKAEwATgEEAEYASAAKNuv15j6%2F%2F%2F%2F%2FwE%3D + +4. Payload Line +otp_parameters { + secret: "\372\245\005\3513\240\321.\220\276\352\226\310\301\0106" + name: "hotp demo" + algorithm: ALGO_SHA1 + digits: 1 + type: OTP_HOTP + counter: 4 +} +version: 1 +batch_size: 1 +batch_id: -1558849573 + + +5. Secret Key +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 +otpauth://hotp/hotp%20demo?secret=7KSQL2JTUDIS5EF65KLMRQIIGY&counter=4 + diff --git a/test/printqr_output.txt b/test/printqr_output.txt index 81d5beb..d2ba2a4 100644 --- a/test/printqr_output.txt +++ b/test/printqr_output.txt @@ -1,7 +1,7 @@ -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp                                                                                                 █▀▀▀▀▀█  ▄▀▄▄█ █▀  ▀▀▀▀▀█  ▄▄ █▀▀▀▀▀█     @@ -26,9 +26,9 @@ Type: OTP_TOTP                                                                                             -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp                                                                                         █▀▀▀▀▀█ ▀▀██ █▄▀█ ▀▄▄▀█▀▄ █▀▀▀▀▀█     @@ -51,9 +51,9 @@ Type: OTP_TOTP                                                                                     -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp                                                                                         █▀▀▀▀▀█ ▀▀██ █▄▀█ ▀▄▄▀█▀▄ █▀▀▀▀▀█     @@ -76,10 +76,10 @@ Type: OTP_TOTP                                                                                     -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp                                                                                                 █▀▀▀▀▀█  ▄▀▄▄█ █▀  ▀▀▀▀▀█  ▄▄ █▀▀▀▀▀█     @@ -104,3 +104,31 @@ Type: OTP_TOTP                                                                                             +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 +                                              +                                              +    █▀▀▀▀▀█ ▄█▀▄▄ ▄▄▄██  █▄▄██ █▄ █▀▀▀▀▀█     +    █ ███ █  ▀▄   ▄▄▄  ▀▄ ▄▄▀ █▀  █ ███ █     +    █ ▀▀▀ █ ▀█ ▄▄█▄  ▄▀█▀▀██▄▄██▄ █ ▀▀▀ █     +    ▀▀▀▀▀▀▀ ▀▄▀ █▄▀▄█▄█▄█ ▀▄█▄█ █ ▀▀▀▀▀▀▀     +    █▄█ █ ▀▄▄ ▀ ▄▄███▄█▄   ▄█▀ ▀█  ▄█▄▄▀▄     +    ▄██▄▀▄▀██▄▀▄▀ ▀▀█ ▀▄ █▄▀██▄  ▀▄▀▀▀▄█▀     +    ▄ ▄█▄▀▀▀▀█▄▄▄▀▄█▄ ▄ ▄██▀█▀▄ ▀▄█▄ █▀▀█     +    ▄▀▄▀██▀█▀  ██▀▄ ▀▀ ▄▄▄█▄██ ▄▀█▄▄▄ ▀▄▀     +    █▄▀▀▀█▀█▄ ▄ ▀ ▀█ ▄ ▄█ █▄▀█▄ █▄█    ▀▄     +    ▀▀██▄█▀  ▄█▄▀▀█▄ ▄█▀██▄▄█▄  █▀▄█ ▀▀▀█     +    █████▄▀▀█▀▀█▀▀▄  ▄ ▀█▀▄ ██▄ ▄███ ▄▀█      +     ▄▄█▀▀▀█▀█▄█  ▄█▄▄█ ▀▀ ▄▀▄ ▄█▀▄▄█▀▀▄▄     +     ██▄ █▀▄▀▀ █  ▀██ █▄ ▄   █  ▀▄█▀▄█▄██     +    ▀▄▀ █ ▀▄▀▄██▄█ ▀█▀▄▄ ██▄▄▄▀ ▀▄ ▄█ ███     +    ▀  ▀▀ ▀▀▄ ▄▄▄█▄██▀▀ ▄█ ▀ █▀▀█▀▀▀█▀  █     +    █▀▀▀▀▀█  █▄█▀█▀▀█▀   ▄█ ▀▄▄▀█ ▀ █▀▀ ▀     +    █ ███ █ ▀█▀▀ ▀ █ ▄ ▄█▄█  █▄ █▀▀██▀ ██     +    █ ▀▀▀ █ ▀▄▀▄█▀▀▄  ▀▀█▄▄ ▀▄▄█   █▀▀▀▄▀     +    ▀▀▀▀▀▀▀ ▀▀▀▀  ▀ ▀  ▀▀▀   ▀▀ ▀ ▀▀▀▀ ▀▀     +                                              +                                              + diff --git a/test_extract_otp_secret_keys_pytest.py b/test_extract_otp_secret_keys_pytest.py index 5be3cfd..d98a9cc 100644 --- a/test_extract_otp_secret_keys_pytest.py +++ b/test_extract_otp_secret_keys_pytest.py @@ -18,7 +18,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from utils import read_csv, read_json, remove_file, remove_dir_with_files, read_file_to_str +from utils import read_csv, read_json, remove_files, remove_dir_with_files, read_file_to_str, file_exits from os import path from pytest import raises @@ -47,6 +47,58 @@ def test_extract_csv(capsys): cleanup() +def test_keepass_csv(capsys): + '''Two csv files .totp and .htop are generated.''' + # Arrange + cleanup() + + # Act + extract_otp_secret_keys.main(['-q', '-k', 'test_example_keepass_output.csv', 'example_export.txt']) + + # Assert + expected_totp_csv = read_csv('example_keepass_output.totp.csv') + expected_hotp_csv = read_csv('example_keepass_output.hotp.csv') + actual_totp_csv = read_csv('test_example_keepass_output.totp.csv') + actual_hotp_csv = read_csv('test_example_keepass_output.hotp.csv') + + assert actual_totp_csv == expected_totp_csv + assert actual_hotp_csv == expected_hotp_csv + assert not file_exits('test_example_keepass_output.csv') + + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == '' + + # Clean up + cleanup() + + +def test_single_keepass_csv(capsys): + '''Does not add .totp or .hotp pre-suffix''' + # Arrange + cleanup() + + # Act + extract_otp_secret_keys.main(['-q', '-k', 'test_example_keepass_output.csv', 'test/example_export_only_totp.txt']) + + # Assert + expected_totp_csv = read_csv('example_keepass_output.totp.csv') + actual_totp_csv = read_csv('test_example_keepass_output.csv') + + assert actual_totp_csv == expected_totp_csv + assert not file_exits('test_example_keepass_output.totp.csv') + assert not file_exits('test_example_keepass_output.hotp.csv') + + captured = capsys.readouterr() + + assert captured.out == '' + assert captured.err == '' + + # Clean up + cleanup() + + def test_extract_json(capsys): # Arrange cleanup() @@ -76,23 +128,28 @@ def test_extract_stdout(capsys): # Assert captured = capsys.readouterr() - expected_stdout = '''Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: OTP_TOTP + expected_stdout = '''Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: OTP_TOTP +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 ''' @@ -107,25 +164,25 @@ def test_extract_not_encoded_plus(capsys): # Assert captured = capsys.readouterr() - expected_stdout = '''Name: SerenityLabs:test1@serenitylabs.co.uk -Secret: A4RFDYMF4GSLUIBQV4ZP67OJEZ2XUQVM -Issuer: SerenityLabs -Type: OTP_TOTP + expected_stdout = '''Name: SerenityLabs:test1@serenitylabs.co.uk +Secret: A4RFDYMF4GSLUIBQV4ZP67OJEZ2XUQVM +Issuer: SerenityLabs +Type: totp -Name: SerenityLabs:test2@serenitylabs.co.uk -Secret: SCDDZ7PW5MOZLE3PQCAZM7L4S35K3UDX -Issuer: SerenityLabs -Type: OTP_TOTP +Name: SerenityLabs:test2@serenitylabs.co.uk +Secret: SCDDZ7PW5MOZLE3PQCAZM7L4S35K3UDX +Issuer: SerenityLabs +Type: totp -Name: SerenityLabs:test3@serenitylabs.co.uk -Secret: TR76272RVYO6EAEY2FX7W7R7KUDEGPJ4 -Issuer: SerenityLabs -Type: OTP_TOTP +Name: SerenityLabs:test3@serenitylabs.co.uk +Secret: TR76272RVYO6EAEY2FX7W7R7KUDEGPJ4 +Issuer: SerenityLabs +Type: totp -Name: SerenityLabs:test4@serenitylabs.co.uk -Secret: N2ILWSXSJUQUB7S6NONPJSC62NPG7EXN -Issuer: SerenityLabs -Type: OTP_TOTP +Name: SerenityLabs:test4@serenitylabs.co.uk +Secret: N2ILWSXSJUQUB7S6NONPJSC62NPG7EXN +Issuer: SerenityLabs +Type: totp ''' @@ -209,6 +266,7 @@ def test_extract_help(capsys): assert pytest_wrapped_e.type == SystemExit assert pytest_wrapped_e.value.code == 0 + def test_verbose_and_quiet(capsys): with raises(SystemExit) as pytest_wrapped_e: # Act @@ -220,7 +278,14 @@ def test_verbose_and_quiet(capsys): assert len(captured.out) > 0 assert 'The arguments --verbose and --quiet are mutually exclusive.' in captured.out + +def test_add_pre_suffix(capsys): + assert extract_otp_secret_keys.add_pre_suffix("name.csv", "totp") == "name.totp.csv" + assert extract_otp_secret_keys.add_pre_suffix("name.csv", "") == "name..csv" + assert extract_otp_secret_keys.add_pre_suffix("name", "totp") == "name.totp" + + def cleanup(): - remove_file('test_example_output.csv') - remove_file('test_example_output.json') + remove_files('test_example_*.csv') + remove_files('test_example_*.json') remove_dir_with_files('testout/') diff --git a/test_extract_otp_secret_keys_unittest.py b/test_extract_otp_secret_keys_unittest.py index 91a6f51..27d7b25 100644 --- a/test_extract_otp_secret_keys_unittest.py +++ b/test_extract_otp_secret_keys_unittest.py @@ -50,23 +50,28 @@ class TestExtract(unittest.TestCase): extract_otp_secret_keys.main(['example_export.txt']) expected_output = [ - 'Name: pi@raspberrypi', - 'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY', - 'Issuer: raspberrypi', - 'Type: OTP_TOTP', + 'Name: pi@raspberrypi', + 'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY', + 'Issuer: raspberrypi', + 'Type: totp', '', - 'Name: pi@raspberrypi', - 'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY', - 'Type: OTP_TOTP', + 'Name: pi@raspberrypi', + 'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY', + 'Type: totp', '', - 'Name: pi@raspberrypi', - 'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY', - 'Type: OTP_TOTP', + 'Name: pi@raspberrypi', + 'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY', + 'Type: totp', '', - 'Name: pi@raspberrypi', - 'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY', - 'Issuer: raspberrypi', - 'Type: OTP_TOTP', + 'Name: pi@raspberrypi', + 'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY', + 'Issuer: raspberrypi', + 'Type: totp', + '', + 'Name: hotp demo', + 'Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY', + 'Type: hotp', + 'Counter: 4', '' ] self.assertEqual(output, expected_output) @@ -78,23 +83,28 @@ class TestExtract(unittest.TestCase): extract_otp_secret_keys.main(['example_export.txt']) actual_output = out.getvalue() - expected_output = '''Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: OTP_TOTP + expected_output = '''Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp + +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: totp -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Type: OTP_TOTP +Name: pi@raspberrypi +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Issuer: raspberrypi +Type: totp -Name: pi@raspberrypi -Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY -Issuer: raspberrypi -Type: OTP_TOTP +Name: hotp demo +Secret: 7KSQL2JTUDIS5EF65KLMRQIIGY +Type: hotp +Counter: 4 ''' self.assertEqual(actual_output, expected_output) @@ -105,25 +115,25 @@ Type: OTP_TOTP extract_otp_secret_keys.main(['test/test_plus_problem_export.txt']) actual_output = out.getvalue() - expected_output = '''Name: SerenityLabs:test1@serenitylabs.co.uk -Secret: A4RFDYMF4GSLUIBQV4ZP67OJEZ2XUQVM -Issuer: SerenityLabs -Type: OTP_TOTP - -Name: SerenityLabs:test2@serenitylabs.co.uk -Secret: SCDDZ7PW5MOZLE3PQCAZM7L4S35K3UDX -Issuer: SerenityLabs -Type: OTP_TOTP - -Name: SerenityLabs:test3@serenitylabs.co.uk -Secret: TR76272RVYO6EAEY2FX7W7R7KUDEGPJ4 -Issuer: SerenityLabs -Type: OTP_TOTP - -Name: SerenityLabs:test4@serenitylabs.co.uk -Secret: N2ILWSXSJUQUB7S6NONPJSC62NPG7EXN -Issuer: SerenityLabs -Type: OTP_TOTP + expected_output = '''Name: SerenityLabs:test1@serenitylabs.co.uk +Secret: A4RFDYMF4GSLUIBQV4ZP67OJEZ2XUQVM +Issuer: SerenityLabs +Type: totp + +Name: SerenityLabs:test2@serenitylabs.co.uk +Secret: SCDDZ7PW5MOZLE3PQCAZM7L4S35K3UDX +Issuer: SerenityLabs +Type: totp + +Name: SerenityLabs:test3@serenitylabs.co.uk +Secret: TR76272RVYO6EAEY2FX7W7R7KUDEGPJ4 +Issuer: SerenityLabs +Type: totp + +Name: SerenityLabs:test4@serenitylabs.co.uk +Secret: N2ILWSXSJUQUB7S6NONPJSC62NPG7EXN +Issuer: SerenityLabs +Type: totp ''' self.assertEqual(actual_output, expected_output) diff --git a/utils.py b/utils.py index 26ca666..7532489 100644 --- a/utils.py +++ b/utils.py @@ -19,6 +19,7 @@ import os import shutil from io import StringIO import sys +import glob # Ref. https://stackoverflow.com/a/16571630 @@ -39,8 +40,17 @@ with Capturing() as output: sys.stdout = self._stdout +def file_exits(file): + return os.path.isfile(file) + + def remove_file(file): - if os.path.isfile(file): os.remove(file) + if file_exits(file): os.remove(file) + + +def remove_files(glob_pattern): + for f in glob.glob(glob_pattern): + os.remove(f) def remove_dir_with_files(dir):