commit 7bafead8804c250ce3291afc570f0174c1509026 Author: Roland Moers Date: Sun Dec 24 00:33:06 2017 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bbc71c --- /dev/null +++ b/.gitignore @@ -0,0 +1,101 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..850806a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Roland Moers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..2575ee9 --- /dev/null +++ b/Pipfile @@ -0,0 +1,20 @@ +[[source]] + +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + + +[dev-packages] + + + +[packages] + +bpylist = "==0.1.4" +click = "==6.7" + + +[requires] + +python_version = "3.6" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..65c1480 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,47 @@ +{ + "_meta": { + "hash": { + "sha256": "f412a27aca9ac43baaa70aad1e56e911c64dc2c33b5fac3f039380103c38d197" + }, + "host-environment-markers": { + "implementation_name": "cpython", + "implementation_version": "3.6.4", + "os_name": "posix", + "platform_machine": "x86_64", + "platform_python_implementation": "CPython", + "platform_release": "17.3.0", + "platform_system": "Darwin", + "platform_version": "Darwin Kernel Version 17.3.0: Thu Nov 9 18:09:22 PST 2017; root:xnu-4570.31.3~1/RELEASE_X86_64", + "python_full_version": "3.6.4", + "python_version": "3.6", + "sys_platform": "darwin" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.6" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.python.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "bpylist": { + "hashes": [ + "sha256:ac065c9f7b804a201999ebbc31b02df18e0fc29e03bcb1eac3e63696599b88ce" + ], + "version": "==0.1.4" + }, + "click": { + "hashes": [ + "sha256:29f99fc6125fbc931b758dc053b3114e55c77a6e4c6c3a2674a2dc986016381d", + "sha256:f15516df478d5a56180fbf80e68f206010e6d160fc39fa508b65e035fd75130b" + ], + "version": "==6.7" + } + }, + "develop": {} +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa978c8 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# decrypt-otpauth-files + +This tool allows for decrypting the encrypted backups/account files created by [OTP Auth for iOS](http://cooperrs.de/otpauth.html). + +If you find problems with the file format (in particular security related issues), do not hesitate and file an issue. + +## Usage + +Requires: + + - [Python 3.6](https://www.python.org/downloads/) + - [pipenv](https://docs.pipenv.org) + - An encrypted OTP Auth backup/account file + +``` +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 +``` + +## Demo + +The project contains two OTP Auth exports for demo purposes: + +* `backup.otpauthdb`: A complete OTP Auth backup +* `account.otpauth`: One account exported by OTP Auth + +The password for both files is `abc123`. + +## 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 new file mode 100644 index 0000000..96bdcac --- /dev/null +++ b/decrypt_otpauth.py @@ -0,0 +1,187 @@ +import click +import getpass + +from enum import Enum + +from Crypto.Cipher import AES +import hashlib + +from bpylist import archiver +from bpylist.archive_types import uid + +class Type(Enum): + Unknown = 0 + HOTP = 1 + TOTP = 2 + +class Algorithm(Enum): + 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 + + def __init__(self, name, accounts): + self.name = name + self.accounts = accounts + + def __repr__(self): + return f'' + + def decode_archive(archive): + name = archive.decode('name') + accounts = archive.decode('accounts') + return OTPFolder(name, accounts) + +class OTPAccount: + label = None + issue = None + secret = None + type = None + algorithm = None + digits = None + counter = None + period = None + refDate = None + + def __init__(self, label, issuer, secret, type, algorithm, digits, counter, period, refDate): + self.label = label + self.issuer = issuer + self.secret = secret + self.type = type + self.algorithm = algorithm + self.digits = digits + self.counter = counter + self.period = period + self.refDate = refDate + + def __repr__(self): + return f'' + + def decode_archive(archive): + label = archive.decode("label") + issuer = archive.decode("issuer") + secret = bytes(archive.decode("secret")) + type = Type(archive.decode("type")) + algorithm = Algorithm(archive.decode("algorithm")) + digits = archive.decode("digits") + counter = archive.decode("counter") + period = archive.decode("period") + refDate = archive.decode("refDate") + return OTPAccount(label, issuer, secret, type, algorithm, digits, counter, period, refDate) + +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): + if index == 0: + return None + + obj = self.unpacked_uids.get(index) + + if obj is not None: + return obj + + raw_obj = self.objects[index] + + # if obj is a (semi-)primitive type (e.g. str) + if not isinstance(raw_obj, dict): + return raw_obj + + class_uid = raw_obj.get('$class') + if not isinstance(class_uid, uid): + raise archiver.MissingClassUID(raw_obj) + + klass = self.class_for_uid(class_uid) + obj = klass.decode_archive(archiver.ArchivedObject(raw_obj, self)) + + 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() + +# # 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() + +# # 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) + +@click.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): + # Get password from user + password = getpass.getpass(f'Password for export file {encrypted_otpauth_backup.name}: ') + + # Get IV and key for wrapping archive + iv = bytes(16) + key = hashlib.sha256('Authenticator'.encode('utf-8')).digest() + + # Decrypt wrapping archive + data = AES.new(key, AES.MODE_CBC, iv).decrypt(encrypted_otpauth_backup.read()) + data = data[:-data[-1]] + + # Decode wrapping archive + archive = DangerousUnarchive(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) + + +if __name__ == '__main__': + main() diff --git a/demo.otpauthdb b/demo.otpauthdb new file mode 100644 index 0000000..62ab81b Binary files /dev/null and b/demo.otpauthdb differ diff --git a/test.otpauth b/test.otpauth new file mode 100644 index 0000000..4327152 Binary files /dev/null and b/test.otpauth differ