diff --git a/Pipfile b/Pipfile index 2575ee9..9aaf126 100644 --- a/Pipfile +++ b/Pipfile @@ -13,6 +13,8 @@ name = "pypi" bpylist = "==0.1.4" click = "==6.7" +pycrypto = "==2.6.1" +pyqrcode = "==1.2.1" [requires] diff --git a/Pipfile.lock b/Pipfile.lock index 65c1480..5f223ce 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f412a27aca9ac43baaa70aad1e56e911c64dc2c33b5fac3f039380103c38d197" + "sha256": "81d534465fc2e9af42dacab3d37d305414bfe0fb1642f22fb3142ffebf745505" }, "host-environment-markers": { "implementation_name": "cpython", @@ -41,6 +41,19 @@ "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" ], "version": "==6.7" + }, + "pycrypto": { + "hashes": [ + "sha256:f2ce1e989b272cfcb677616763e0a2e7ec659effa67a88aa92b3a65528f60a3c" + ], + "version": "==2.6.1" + }, + "pyqrcode": { + "hashes": [ + "sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5", + "sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6" + ], + "version": "==1.2.1" } }, "develop": {} diff --git a/README.md b/README.md index aa978c8..45d8391 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,10 @@ Requires: git clone https://github.com/CooperRS/decrypt-otpauth-files.git cd decrypt-otpauth-files pipenv install -pipenv run python decrypt_otpauth.py --encrypted-otpauth-backup +# Decrypt a full backup file +pipenv run python decrypt_otpauth.py decrypt_backup --encrypted-otpauth-backup +# Decrypt a single account export +pipenv run python decrypt_otpauth.py decrypt_account --encrypted-otpauth-account ``` ## Demo @@ -28,6 +31,8 @@ The project contains two OTP Auth exports for demo purposes: The password for both files is `abc123`. +![example gif](demo.gif) + ## Credits Inspired by [ewdurbin](https://github.com/ewdurbin) and his [evacuate_2STP](https://github.com/ewdurbin/evacuate_2stp) repo. diff --git a/decrypt_otpauth.py b/decrypt_otpauth.py index 96bdcac..3a920c3 100644 --- a/decrypt_otpauth.py +++ b/decrypt_otpauth.py @@ -1,36 +1,45 @@ +import base64 import click import getpass +import hashlib from enum import Enum +from urllib.parse import quote -from Crypto.Cipher import AES -import hashlib +import pyqrcode from bpylist import archiver from bpylist.archive_types import uid +from Crypto.Cipher import AES + + class Type(Enum): - Unknown = 0 - HOTP = 1 - TOTP = 2 + Unknown = 0 + HOTP = 1 + TOTP = 2 + class Algorithm(Enum): - Unknown = 0 - SHA1 = 1 # Used in case of Unknown - SHA256 = 2 - SHA512 = 3 - MD5 = 4 + Unknown = 0 + SHA1 = 1 # Used in case of Unknown + SHA256 = 2 + SHA512 = 3 + MD5 = 4 + class MutableString: def decode_archive(archive): return archive.decode('NS.string') + class MutableData: def decode_archive(archive): return bytes(archive.decode('NS.data')) + class OTPFolder: name = None accounts = None @@ -40,16 +49,17 @@ class OTPFolder: self.accounts = accounts def __repr__(self): - return f'' + return f'' def decode_archive(archive): name = archive.decode('name') accounts = archive.decode('accounts') return OTPFolder(name, accounts) + class OTPAccount: label = None - issue = None + issuer = None secret = None type = None algorithm = None @@ -70,7 +80,7 @@ class OTPAccount: self.refDate = refDate def __repr__(self): - return f'' + return f'' def decode_archive(archive): label = archive.decode("label") @@ -84,11 +94,39 @@ class OTPAccount: refDate = archive.decode("refDate") return OTPAccount(label, issuer, secret, type, algorithm, digits, counter, period, refDate) + def from_dict(in_dict): + label = in_dict.get("label") + issuer = in_dict.get("issuer") + secret = bytes(in_dict.get("secret")) + type = Type(in_dict.get("type")) + algorithm = Algorithm(in_dict.get("algorithm")) + digits = in_dict.get("digits") + counter = in_dict.get("counter") + period = in_dict.get("period") + refDate = in_dict.get("refDate") + return OTPAccount(label, issuer, secret, type, algorithm, digits, counter, period, refDate) + + def otp_uri(self): + otp_type = self.type + otp_label = quote(f'{self.issuer}:{self.label}') + otp_parameters = { + 'secret': base64.b32encode(self.secret).decode("utf-8"), + 'algorithm': self.algorithm, + 'period': self.period, + 'digits': self.digits, + 'issuer': self.issuer, + 'counter': self.counter, + } + otp_parameters = '&'.join([f'{str(k)}={quote(str(v))}' for (k, v) in otp_parameters.items() if v]) + return f'otpauth://{otp_type}/{otp_label}?{otp_parameters}' + + archiver.update_class_map({'NSMutableData': MutableData}) archiver.update_class_map({'NSMutableString': MutableString}) archiver.update_class_map({'ACOTPFolder': OTPFolder}) archiver.update_class_map({'ACOTPAccount': OTPAccount}) + class DangerousUnarchive(archiver.Unarchive): def decode_object(self, index): @@ -116,45 +154,63 @@ class DangerousUnarchive(archiver.Unarchive): self.unpacked_uids[index] = obj return obj -# @click.command() -# @click.option('--encrypted-otpauth-account', -# help="path to your encrypted OTP Auth account (.otpauth)", -# required=True, -# type=click.File('rb')) -# def main(encrypted_otpauth_account): -# # Get password from user -# password = getpass.getpass(f'Password for export file {encrypted_otpauth_account.name}: ') -# # Get IV and key for wrapping archive -# iv = bytes(16) -# key = hashlib.sha256('OTPAuth'.encode('utf-8')).digest() +def render_qr_to_terminal(otp_uri, type, issuer, label): + qr = pyqrcode.create(otp_uri, error="L") + click.echo("") + click.echo(f'{type}: {issuer} - {label}') + click.echo(qr.terminal(quiet_zone=4)) + click.echo("") -# # Decrypt wrapping archive -# data = AES.new(key, AES.MODE_CBC, iv).decrypt(encrypted_otpauth_account.read()) -# data = data[:-data[-1]] -# # Decode wrapping archive -# archive = DangerousUnarchive(data).top_object() +@click.group() +def cli(): + pass -# # Get IV and key for actual archive -# iv = hashlib.sha1(archive['IV']).digest()[:16] -# salt = archive['Salt'] -# key = hashlib.sha256((salt + '-' + password).encode('utf-8')).digest() - -# # Decrypt actual archive -# data = AES.new(key, AES.MODE_CBC, iv).decrypt(archive['Data']) -# data = data[:-data[-1]] -# # Decode actual archive -# archive = DangerousUnarchive(data).top_object() -# print(archive) +@cli.command() +@click.option('--encrypted-otpauth-account', + help="path to your encrypted OTP Auth account (.otpauth)", + required=True, + type=click.File('rb')) +def decrypt_account(encrypted_otpauth_account): + # Get password from user + password = getpass.getpass(f'Password for export file {encrypted_otpauth_account.name}: ') -@click.command() + # Get IV and key for wrapping archive + iv = bytes(16) + key = hashlib.sha256('OTPAuth'.encode('utf-8')).digest() + + # Decrypt wrapping archive + data = AES.new(key, AES.MODE_CBC, iv).decrypt(encrypted_otpauth_account.read()) + data = data[:-data[-1]] + + # Decode wrapping archive + archive = archiver.Unarchive(data).top_object() + + # Get IV and key for actual archive + iv = hashlib.sha1(archive['IV']).digest()[:16] + salt = archive['Salt'] + key = hashlib.sha256((salt + '-' + password).encode('utf-8')).digest() + + # Decrypt actual archive + data = AES.new(key, AES.MODE_CBC, iv).decrypt(archive['Data']) + data = data[:-data[-1]] + + # Decode actual archive + archive = DangerousUnarchive(data).top_object() + + # Construct OTPAccount object from returned dictionary + account = OTPAccount.from_dict(archive) + render_qr_to_terminal(account.otp_uri(), account.type, account.issuer, account.label) + + +@cli.command() @click.option('--encrypted-otpauth-backup', help="path to your encrypted OTP Auth backup (.otpauthdb)", required=True, type=click.File('rb')) -def main(encrypted_otpauth_backup): +def decrypt_backup(encrypted_otpauth_backup): # Get password from user password = getpass.getpass(f'Password for export file {encrypted_otpauth_backup.name}: ') @@ -167,21 +223,25 @@ def main(encrypted_otpauth_backup): data = data[:-data[-1]] # Decode wrapping archive - archive = DangerousUnarchive(data).top_object() + archive = archiver.Unarchive(data).top_object() # Get IV and key for actual archive iv = hashlib.sha1(archive['IV'].encode('utf-8')).digest()[:16] salt = archive['Salt'] key = hashlib.sha256((salt + '-' + password).encode('utf-8')).digest() - + # Decrypt actual archive data = AES.new(key, AES.MODE_CBC, iv).decrypt(archive['WrappedData']) data = data[:-data[-1]] # Decode actual archive archive = DangerousUnarchive(data).top_object() - print(archive) + + accounts = [account for folder in archive['Folders'] for account in folder.accounts] + for account in accounts: + render_qr_to_terminal(account.otp_uri(), account.type, account.issuer, account.label) + input("Press Enter to continue...") if __name__ == '__main__': - main() + cli() diff --git a/demo.gif b/demo.gif new file mode 100644 index 0000000..7e88df5 Binary files /dev/null and b/demo.gif differ