Home

Duo Mobile CLI


Do you ever sign in to a website with two-factor auth, then realize you don’t have your phone nearby to retrieve the code? It would be nice to generate the same TOTP codes as your phone, right from your CLI. I use Duo Mobile to achieve this (no affiliation).

Why Duo? It’s one of the best authenticator apps for iOS:

  • Third-party accounts are stored locally on the device, not synced to a “cloud”.
  • OTP secrets are stored encrypted.
  • Once encrypted, accounts are backed up natively with iOS, either to iCloud or locally.

I realize there are desktop and CLI TOTP authenticators out there, but a) I already have ~40 accounts on Duo, b) many websites only allow one authenticator app at a time, and c) I’ve been curious about Duo’s encryption system.


Backups

iOS apps are provided with the <Application_Home>/Documents directory for documents that are backed up automatically. Once an encryption password has been set in the app, Duo writes the list of third-party accounts to a binary plist within that directory: com.duosecurity.DuoMobile.plist

Parsing the file is easy enough since Python includes plistlib in the stdlib:

import plistlib

with open("com.duosecurity.DuoMobile.plist", "rb") as f:
    plist = plistlib.load(f, fmt=plistlib.FMT_BINARY)

The structure of the plist is simple. Abbreviated here:

  • DUOSortedAccountInfoArrayKey
    • Array
      • serviceName
      • displayLabel
      • encryptedOTPString (This is what we need to generate TOTPs)
  • kBackupEncryptionStoreDerivationParamsDictKey
    • memLimitKey
    • opsLimitKey
    • passwordSaltKey
  • kBackupEncryptionStoreEncryptedTestStringKey

Encryption

The memLimit and opsLimit params look familiar from libsodium, so that seems like a good place to start reversing the encryption method. Conveniently, the plist gives us an encrypted string for testing. It looks like two base64-encoded strings joined with a colon. Since the second string is 20 bytes, I’m guessing ciphertext and nonce:

import base64

test_string = "mohgd+mzkw6z8qkqA1IeWIxZ3WeafeH5OLoTDgcL3y2Asg7S2x3PO40CrWrgyerEdm9qVw==:pQJBe+rUv/Iv8Dc1oPtvjSJ/9UHxVmji"
[cipher, nonce] = test_string.split(":")
cipher = base64.b64decode(cipher)
nonce = base64.b64decode(nonce)

We’ve got the rest of the parameters from the plist, and we know the password since we set it in the app, so we can try plugging the values in. Trying here with argon2id:

import nacl.secret
import nacl.pwhash

params = plist["kBackupEncryptionStoreDerivationParamsDictKey"]
salt = params["passwordSaltKey"]
opslimit = params["opsLimitKey"]
memlimit = params["memLimitKey"]

key = nacl.pwhash.argon2id.kdf(
    nacl.secret.SecretBox.KEY_SIZE,
    password,
    salt,
    opslimit=opslimit,
    memlimit=memlimit,
)

box = nacl.secret.SecretBox(key)
>>> box.decrypt(cipher, nonce)
"VDD40B86F-F945-4FB1-A4C2-589BA2C75195"

Boom! Plaintext of the test string.


Generating OTPs

Now that we know the encryption method, we can decrypt the OTP secrets for each account and generate TOTP codes. Secrets vary in length. Of my ~40 accounts, most secrets are 20 bytes and a few a 10 or 16 bytes. Cloudflare’s is the longest at 40 bytes. Just like the test string, each encrypted secret is base64-encoded and formatted as "{cipher}:{nonce}"

Passlib, unlike other OTP libraries, makes it easy to pass raw bytes to the TOTP generator, instead of strings like other libraries expect:

import passlib.totp

[cipher, nonce] = encrypted_otp_string.split(":")
cipher = base64.b64decode(cipher)
nonce = base64.b64decode(nonce)
secret = box.decrypt(cipher, nonce)

otp = passlib.totp.TOTP(key=secret, format="raw")
>>> otp.generate().token
123456

Wrap it in a CLI

Using the Click framework, it’s fairly simple to wrap this concept in a CLI. I published the package duo-cli and it can be installed with pipx:

$ pipx install duo-cli
done! ✨ 🌟 ✨

$ duo --help
Usage: duo [ACCOUNT] [OPTIONS]

Options:
  --password TEXT  Duo Mobile backup encryption password.
  --no-copy        Disable copying to clipboard.
  --help           Show this message and exit.

With no arguments, all available accounts are listed and selectable to generate a TOTP code. Otherwise, an account name can be passed to generate a TOTP code for that service.

If you don’t pass your password as an option, you will be prompted to enter it to decrypt the OTP secret. On first run, you can choose to save a derived key of your password in ~/.duo/config.json so decryption happens automatically going forward.

The generated TOTP code is automatically copied to the clipboard, unless --no-copy is passed or if copy is disabled in the JSON configuration:

{
    "copy": false
}

Getting the Plist File

On first run, you’ll be prompted for the com.duosecurity.DuoMobile.plist path. If your iOS device is set to backup locally, duo will search the most recent backup for the file and ask if you want to use it. There are also various apps and tools available online for extracting files from iOS backups, both locally and in iCloud. Jailbroken devices of course can extract the file directly.


Future Steps

  • iCloud Backups: It would be nice to integrate with iCloud backups rather than just local backups. There’s code on GitHub for doing this. Follow: #1
  • Encrypted Backups: Local backups must be stored unencrypted for this tool to read them. There’s code on GitHub that describes how to decrypt them. Follow: #2

Stay in touch

The next post will be about mapping Bosnia & Herzegovina's minefields and minimizing risk while hiking and mountaineering. Subscribe to the email list to get notified when it's published. No spam, ever.

Your email address will never be shared or sold.