Skip to content

Instantly share code, notes, and snippets.

@jleclanche
Last active March 5, 2025 05:59
A guide to back up and recover 2FA tokens from FreeOTP (Android)

Backing up and recovering 2FA tokens from FreeOTP

NOTE: THIS MAY NOT WORK ANYMORE - SEE COMMENTS

Backing up FreeOTP

Using adb, create a backup of the app using the following command:

adb backup -f freeotp-backup.ab -apk org.fedorahosted.freeotp

org.fedorahosted.freeotp is the app ID for FreeOTP.

This will ask, on the phone, for a password to encrypt the backup. Proceed with a password.

Manually extracting the backup

The backups are some form of encrypted tar file. Android Backup Extractor can decrypt them. It's available on the AUR as android-backup-extractor-git.

Use it like so (this command will ask you for the password you just set to decrypt it):

abe unpack freeotp-backup.ab freeotp-backup.tar

Then extract the generated tar file:

$ tar xvf freeotp-backup.tar
apps/org.fedorahosted.freeotp/_manifest
apps/org.fedorahosted.freeotp/sp/tokens.xml

We don't care about the manifest file, so let's look at apps/org.fedorahosted.freeotp/sp/tokens.xml.

Reading tokens.xml

The tokens.xml file is the preference file of FreeOTP. Each <string>...</string> is a token (except the one with the name tokenOrder).

The token is a JSON blob. Let's take a look at an example token (which is no longer valid!):

<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
	<!-- ... -->
	<string name="Discord:me@example.org">{&quot;algo&quot;:&quot;SHA1&quot;,&quot;counter&quot;:0,&quot;digits&quot;:6,&quot;imageAlt&quot;:&quot;content://com.google.android.apps.photos.contentprovider/-1/1/content%3A%2F%2Fmedia%2Fexternal%2Ffile%2F3195/ORIGINAL/NONE/741876674&quot;,&quot;issuerExt&quot;:&quot;Discord&quot;,&quot;issuerInt&quot;:&quot;Discord&quot;,&quot;label&quot;:&quot;me@example.org&quot;,&quot;period&quot;:30,&quot;secret&quot;:[122,-15,11,51,-100,-109,21,89,-30,-35],&quot;type&quot;:&quot;TOTP&quot;}</string>
</map>

Let's open a python shell and get the inner text of the XML into a Python 3 shell. We'll need base64, json and html in a moment:

>>> import base64, json, html
>>> s = """{&quot;algo&quot;:&quot;SHA1&quot;,&quot;counter&quot;:0,&quot;digits&quot;:6,&quot;imageAlt&quot;:&quot;content://com.google.android.apps.photos.contentprovider/-1/1/content%3A%2F%2Fmedia%2Fexternal%2Ffile%2F3195/ORIGINAL/NONE/741876674&quot;,&quot;issuerExt&quot;:&quot;Discord&quot;,&quot;issuerInt&quot;:&quot;Discord&quot;,&quot;label&quot;:&quot;me@example.org&quot;,&quot;period&quot;:30,&quot;secret&quot;:[122,-15,11,51,-100,-109,21,89,-30,-35],&quot;type&quot;:&quot;TOTP&quot;}"""

We decode all those HTML entities from the XML encoding:

>>> s = html.unescape(s); print(s)
{"algo":"SHA1","counter":0,"digits":6,"imageAlt":"content://com.google.android.apps.photos.contentprovider/-1/1/content%3A%2F%2Fmedia%2Fexternal%2Ffile%2F3195/ORIGINAL/NONE/741876674","issuerExt":"Discord","issuerInt":"Discord","label":"me@example.org","period":30,"secret":[122,-15,11,51,-100,-109,21,89,-30,-35],"type":"TOTP"}

What we specifically need from this is the secret. It's a signed byte array from Java... Let's grab it:

>>> token = json.loads(s); print(token["secret"])
[122, -15, 11, 51, -100, -109, 21, 89, -30, -35]

Now we have to turn this into a Python bytestring. For that, these bytes need to be turned back into unsigned bytes. Let's go:

>>> secret = bytes((x + 256) & 255 for x in token["secret"]); print(secret)
b'z\xf1\x0b3\x9c\x93\x15Y\xe2\xdd'

Finally, the TOTP standard uses base32 strings for TOTP secrets, so we'll need to turn those bytes into a base32 string:

>>> code = base64.b32encode(secret); print(code.decode())
PLYQWM44SMKVTYW5

There we go. PLYQWM44SMKVTYW5 is our secret in a format we can manually input into FreeOTP or Keepass.

@Necem
Copy link

Necem commented Dec 14, 2024

I managed to get current backup format working with the following corrections

def decrypt(cipher_text, key, parameters, aad):
        # paramerts is in ASN.1 encoding with ivlen being optional and only included if != 12
        # => following code handles only 16-byte iv's
        iv = parameters[4:16]
        ivlen = ord(parameters[18:])
        cipher_text, tag = cipher_text[:-16], cipher_text[-16:]
        cipher = Cipher(algorithms.AES(key), modes.GCM(iv, tag=tag), backend=default_backend())
        decryptor = cipher.decryptor()
        decryptor.authenticate_additional_data(aad)
        return decryptor.update(cipher_text) + decryptor.finalize()
decrypted_master_key = decrypt(
    mEncryptedKey_cipherText, master_key, mEncryptedKey_parameters,  master_key_data["mEncryptedKey"]["mToken"].encode()
)
decrypted_totp_secret = decrypt(key_cipherText, decrypted_master_key, key_parameters, json.loads(totp_key_data["mToken"])["mParameters"].encode())

@malcoriel
Copy link

I also managed to recover my TOTP key by managing to parse tokensBackup.xml that I had saved from a working version of the FreeOTP:
However, I had to change the last line like

decrypted_totp_secret = decrypt(key_cipherText, decrypted_master_key, key_parameters, json.loads(totp_key_data['key'])["mToken"].encode())

Probably it's due to some minor format difference - my app was 2.0.3 when the disaster of being unable to recover from backup happened.

Then, you can print the resulting bytes like

print("Decrypted TOTP secret:", decrypted_totp_secret.hex())

Which is the hex for the bytes of the totp secret. In order to import it to another app (I used Aegis), I also had to encode the resulting hex string to base32, and it worked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment