From 7bafead8804c250ce3291afc570f0174c1509026 Mon Sep 17 00:00:00 2001 From: Roland Moers Date: Sun, 24 Dec 2017 00:33:06 +0100 Subject: [PATCH] Initial commit --- .gitignore | 101 ++++++++++++++++++++++++ LICENSE | 21 +++++ Pipfile | 20 +++++ Pipfile.lock | 47 ++++++++++++ README.md | 33 ++++++++ decrypt_otpauth.py | 187 +++++++++++++++++++++++++++++++++++++++++++++ demo.otpauthdb | Bin 0 -> 2704 bytes test.otpauth | Bin 0 -> 1296 bytes 8 files changed, 409 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 Pipfile create mode 100644 Pipfile.lock create mode 100644 README.md create mode 100644 decrypt_otpauth.py create mode 100644 demo.otpauthdb create mode 100644 test.otpauth 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 0000000000000000000000000000000000000000..62ab81bcd4ec25b2f00f5153bf88da74fd5006f4 GIT binary patch literal 2704 zcmV;B3UBqcw%i(NkFLZq&DpOxVUK6YuTg`V6$n ztlJ~U2gUN{Z~jUd(Z+I$&>^1CT3{-yavmk{1`|`}y2Pd1L<7Wy?XvndB9_7*AR#Jv z`Q4W8P_(!!eu>WXeP543rc)`W`#4uo{SHO0A5W&!UrRVglrjpau8qnA3 zXa8cJszmm4Nb|%WU`c)9Xt8Vy#=pj^!5&HZ4`IOSv3N9L@r*;~h`m>pDPN95UXKT{ z#a8#DvNM97^%3%5vTlAc5xKmSJ6}_CSi5cp>wJJ<-tz20DF{sf8S`fq6g?wsCKo`B zvfI9u^UW{rc6Q?t)0fg(RhE~^|R$y`+C{4jsKGdqM>>K_5(Lvkg{cL^{;(^(h3=$u3>*fhi&_}rRps~3PAFTgua{)SY<(Hfn?EccZpbz;5V*=+N29+z zq%U9zsiI5rdWhdJfJdh>zV;y)a1H?BxgDSCr2ZVtZ};9? zHgSE|kSIDWdZM*UA(Y)knh^?7JZ$jp@5%WscgT|Q)5J#Mv4{9xyECdS?vC@%jD zYLi$LOjX5c@lNi6(c2fp033{^Mtg&n$ouHOJ+OT=v2S7XgxLtP_`E2&+%LLRq1>7h z{xBu^3*i^>d~Kzd#HR3SNF4_P=WTER_j@K)Ql{Fb)GiYjPY~{(MM5nvb|ziop=F(- zH+!>qR`W_aBftS`pH9Xj8@h~)lzL2Dkc`1DKHSa<{Q0acyMxlWM~4E-Efk1&`!Y1+P*U}tc_#e8>3@rpH= z%bC8j;z-Y@N;yOmZO!!YQ!oR*(P&DU60qO^=jVz#}JB9x-F`Yd|~C zYvwTISiN3Lw7i7sVyYEsDGOrdHYPG) zV&aAw5Xf~q;&_(#qJ;|6`!|5i$K5uuZP#C%lz}d!#)kFTb-J$0IfR~|z=ONXklN`O z_%I!CTZ*Rxe8`Yso^WogzM@a(Dx`X+d&y(>(a+WBdBx%N>+F+Ti$0ApMkTS9Q5f_K z`Ic2h$x$A!CG*P13L!3qi2-_Hc0NnvdzYrv*j5Bzbq}YB9U%LJ+iSP%xb{!1 zx?%%M;HFPFO2pRAVpkjtK$h^uHEs4@mw$%UDZA-k7_YYBz;Y=ybcLk%9>2W9LjakdWo00AHDV`_v+r$+6&PnZ^Hg~VVGd!(b57f?3GGg`AKaZ!SeCBuM3%T$M9tpQG^<0Du^1u zLbk4=RSP9f)PUtk5K8M4mt#Ia9#FuRejUZ7DNi}nkkr>W>Sz%W{eAF!>{g@<;Uhe9uXPAM(y{C?OjVACv&y8R{SoswR@uCMs zkCrTC9JATU=^pykK_|RhM}|Hl;wGtR#+BhsUNVBy5lk#DljyhbFnTOD6-(& zZWJTix5ucfA~-RVcPae~iJ;OWdXmHV5==#xfc@ggRY_Qowles@bT|+L>3M2hWu%x% znuVb7@8_nPyOB4}`Y*k?DA5fV-2Nnvk(yIm46L&w7EiY_Gy=KRrS;()Ky^(8w2~26 zkZgQf24u;~hCRU8`mTWAX@;lu%1T}-Y?w}5245*YrSL;1njlEA*1y#sHJo6_6CCsQ8wbQb*qLO?*sr{|}{&xs&g=F|M`ML~{jD(&xd(GAq3DfE( zgbDdvU*H=9Uo}fyA8ap%C$9Gx?4%7^nl6Qby|TMX$2@5PI@9QZ#fl@)T=&!Y%xP;g zr(}!-xHkfmIe_k)G^v|d4~6P%hypB`Fk9Z2i}4dvrv}4_gDdHC_>^>xf-wXk#J_IU z3R!WwEDiX&)#<)q4hp;Mm$WN-msHA%kqZV&a3-uytG`_yL}AwkNs=bXz}P?D?|~y1 zS8$GQQeS4oyzeLsNZDy$DhrydR|cZFfP@ez*VxgdM+!OUL_`Q9K2yj-=xDzsETY$R zOER?x!Rg7;$~QYaf`epGJc#@j>8C=PGhqEDg@@tCCXu2r>Q}%aOPtV+p0QG>4wS!7 KaX_dRqIdRxI7x#5 literal 0 HcmV?d00001 diff --git a/test.otpauth b/test.otpauth new file mode 100644 index 0000000000000000000000000000000000000000..43271527d7e0cb26be244f6ac17a09a138ceb455 GIT binary patch literal 1296 zcmV+r1@HP;{gNUs(bvpLw0pcR#KBl+`F|z&eyg4~*@`Y-TdkV#e6JbV`8LY_{FLB>lnS0A2FH2YRk6Fbr+cH~vlHpI8Kv zOax#8c*M-$Vl7ycakGBw{tDV?DU_rSQ<`)HI6@E|++^5VzGvgefM!)qMD@~6D_(A4 zg(a0MCbV7D+5&6tYYo7Rb)c*A7|h#l?m->|52T-l!~hL$O)bSMeK{#Azqt3;^kKjl zFdb_q!g=?eo$cRJZi5UtbdKA>=<{74QnV|z)o)&rJSVK6KSyGutj4&Uz+kviH9v1q z5txEwl&F0r0dBk&Nz?sRx~VOCt3Uj(71GRC#DF0@F3BO+^TC={g7f9yzw-sT-Qmvg1uh%l+0(lyx;qZB4RN-~JJF|s%fpHKKNLhh; zh@bWpO_j>~lD9cqR330bAbE)XWcXrTQhAbC6kR}jd__C_hn1GDuMGE=sf<>Pno+BN zg>rk7P7IOf8{jrW>u}f5O9;@Nss}FFQ;4{$JYLC6+Y9g3!wKh2AQP)+jfzBX0A=ln zV3iHN*(|bQ&giTl0yrO8D8s#WURvKpi?<$%_rTOtHw$+c`j67)t%o{jzN&@tfTYN?ppQ ztU3X81anECHALGiUm>7*GBRy;u8)XPIm*M-NQR*YpGB^U%+hD>^&nvbY??gW_!YZC zX(MX6_TypF1%3SlHGI>r&0*d9PJS_<)G(&I$n1Q~OOLYYaqO<@*IlZsS}~t=qKC|& zWgTcE1F0~YypoMGHb02&9E3I{Q|yd-w-P^}?Z&-Wt!WndAxAZ$DQ!#EGT8MLsp+$# zGsv^_J)JeXjW_~B+CqTWGU<~|Z(P_i<1aeyQQE=Oggj`jiwbXms6?yf-d5`EWhTC| znm5YxvY=HS*$nGq&lp(Aet(~Z7VC$%NR(bcy~OxjtrYu2g7>M!0av@np!0T$7Je-V z<2tzIGSN6|qS!a$U&vL_6x*+b8sHjR6vd+)S;cEpBV`>7`pkpaiK;gufj306fu%xK zEgog}9oT@<%@KboBZIOmtImWokO$iAvkUE{RARjyotu$~4gbgxU}zWxYL*BHP|CC@ z_WBZ2XR5V8UsX<{;1>!2`E``d&w!-EyD8p=$vnlg1s0V8htm)2{@z9A4T5&1lznee zm$(1tbtSq1^}3}~Z&9VQcx*|TRi-m4G<}B2QD%ymWX+)TRyKy9^`Q1G3AZ_u6CGqe zr~H31{!mT4%6P0O@U9k%b5QTcz?&u7oD?4*!}9h8J$_@HKUCX=tr=A2H$DAo$rvevyYNJ(q7s&0#jR2{iye|53y4W|-b7)UJ@E9~1(zp~O?8eGX=_4t!T$4g01g G-;5^q)rb55 literal 0 HcmV?d00001