Backup format · version 1

Documented down to the byte.

A backup is meant to outlive the app, so everything needed to open one is here. Nothing proprietary, nothing hidden.

The file

A single JSON document. All binary values are base64.

{
  "format":  "vault2fa-backup",
  "version": 1,
  "created": "2026-09-10T11:00:58Z",
  "kdf": {
    "name":       "pbkdf2-hmac-sha256",
    "iterations": 600000,
    "salt":       "<16 random bytes>"
  },
  "cipher": {
    "name":  "aes-256-gcm",
    "nonce": "<12 random bytes>"
  },
  "ciphertext": "<AES-GCM output with the 16-byte tag appended>"
}

Opening it

StepDetail
1. Derive the keyPBKDF2-HMAC-SHA256 over the UTF-8 passphrase with kdf.salt and kdf.iterations, 32-byte output.
2. Split the ciphertextLast 16 bytes are the GCM tag; everything before is the encrypted body.
3. DecryptAES-256-GCM with the key and cipher.nonce. No additional authenticated data.
4. ParseThe plaintext is the JSON below.

A wrong passphrase and a tampered file are indistinguishable — both fail the GCM tag check. That is by design: a damaged file is refused, never decrypted into garbage.

The plaintext

{
  "app":      "Vault2FA",
  "exported": "2026-09-10T11:00:58Z",
  "accounts": [
    {
      "uri":        "otpauth://totp/GitHub:you%40example.com?secret=…&issuer=GitHub&algorithm=SHA1&digits=6&period=30",
      "isFavorite": true,
      "notes":      "",
      "sortIndex":  0,
      "createdAt":  "2026-09-10T02:01:12Z"
    }
  ]
}

uri is a standard Key URI, so the accounts can be imported into any authenticator. The other fields are Vault2FA's own metadata.

Reference implementation

The whole decryption in about forty lines of Python, using the standard library plus cryptography for AES-GCM:

import base64, hashlib, json, getpass
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

env = json.load(open("vault2fa-backup-2026-09-10.json"))
assert env["format"] == "vault2fa-backup" and env["version"] == 1

key = hashlib.pbkdf2_hmac(
    "sha256",
    getpass.getpass("Passphrase: ").encode(),
    base64.b64decode(env["kdf"]["salt"]),
    env["kdf"]["iterations"],
    dklen=32,
)
plaintext = AESGCM(key).decrypt(
    base64.b64decode(env["cipher"]["nonce"]),
    base64.b64decode(env["ciphertext"]),
    None,
)
for account in json.loads(plaintext)["accounts"]:
    print(account["uri"])

Why these choices

PBKDF2 rather than Argon2 or scrypt because it is what iOS provides natively. A backup format should not depend on a third-party library still being maintained in ten years. 600,000 iterations exceeds the OWASP recommendation for PBKDF2-HMAC-SHA256.

AES-256-GCM provides authentication as well as confidentiality: a damaged or altered file is detected and refused rather than decrypted into garbage.

Plain otpauth:// URIs inside, because the point of a backup is that you can use it — with this app or any other.