OEM Cookbook · native vault sync · opaque encrypted backup

Sync the wallet vault across devices — opaquely.

Store the wallet's credential vault as an opaque encrypted blob on CodeB's server so the user can restore it on a fresh install or a new device. Encryption happens entirely on-device using a KEK derived from a biometric-gated hardware key; the server sees only the ciphertext and never has the key material to decrypt. Per-OEM azp is baked into the server-side storage-key hash (SHA-256 of sub|tenant|azp) so OEMs on shared managed hosting cannot see each other's users. European Digital Identity Wallet compliant.

Design invariant. The server MUST never be able to decrypt. If your OEM app decides to derive its KEK from the OIDC subject (or any server-visible value), you have broken the confidentiality guarantee. Use a biometric-gated Secure Enclave / Keystore key.

1 Vault blob shape (opaque to server)

The server enforces JSON shape validation but never decrypts. Required top-level keys:

{
  "v": 1,
  "wrappedDeks": [
    { "kek_id": "<stable id of the KEK that wrapped this DEK>",
      "dek_cipher": "<base64url of AES-KW / RSA-OAEP wrapped DEK>" }
  ],
  "cipher": "<base64url of AES-GCM ciphertext of the vault contents>",
  "iv":     "<base64url of 12-byte GCM IV>",
  "tag":    "<base64url of 16-byte GCM tag>",
  "updatedUtc": "2026-08-12T19:00:00Z",
  "hint":       "iPhone 15 Pro"
}

wrappedDeks as an array lets you enroll multiple KEKs (multi-device without cross-device unwrap trust: each device has its own KEK, DEK is wrapped once per KEK).

2 Mint a narrow session token (D1)

Trade your full OIDC access token for a short-lived HS256 token scoped only to wallet-backup.ashx. The narrow token carries your OEM azp, so the server-side storage-key hash keeps OEM isolation intact.

POST /wallet-session-token.ashx
Authorization: Bearer <OIDC access token>

-> { "access_token": "<HS256 JWT>", "token_type": "Bearer", "expires_in": 600,
     "aud": "wallet-backup", ... }

Cache 8–9 min then refresh; refresh proactively on 401 during a save/load.

3 Encrypt on-device

iOS Swift

import CryptoKit
import LocalAuthentication

// 1. Random per-vault DEK
let dek = SymmetricKey(size: .bits256)

// 2. KEK = biometric-gated key from Secure Enclave
let context = LAContext()
context.localizedReason = "Encrypt your wallet vault"
let access = SecAccessControlCreateWithFlags(nil,
    kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
    [.privateKeyUsage, .biometryCurrentSet], nil)!
let kekPriv = try SecureEnclave.P256.KeyAgreement.PrivateKey(accessControl: access)

// 3. Wrap the DEK using ECDH-ES against the KEK's own public key
//    (self-ECDH is a pragmatic trick to derive a stable wrapping key
//    that only the biometric-gated Secure Enclave can reproduce)
let sharedSecret = try kekPriv.sharedSecretFromKeyAgreement(with: kekPriv.publicKey)
let wrappingKey = sharedSecret.hkdfDerivedSymmetricKey(
    using: SHA256.self, salt: Data(), sharedInfo: "vault-kek-v1".data(using: .utf8)!,
    outputByteCount: 32)
let sealedDek = try AES.GCM.seal(dek.withUnsafeBytes { Data($0) }, using: wrappingKey)

// 4. Encrypt the vault contents with the DEK
let plaintext = try JSONEncoder().encode(vault)
let sealedVault = try AES.GCM.seal(plaintext, using: dek)

// 5. Build the opaque blob
let blob: [String: Any] = [
    "v": 1,
    "wrappedDeks": [[ "kek_id": kekPriv.publicKey.rawRepresentation.b64url(),
                      "dek_cipher": (sealedDek.iv + sealedDek.ciphertext + sealedDek.tag).b64url() ]],
    "cipher":     sealedVault.ciphertext.b64url(),
    "iv":         sealedVault.iv.b64url(),
    "tag":        sealedVault.tag.b64url(),
    "updatedUtc": ISO8601DateFormatter().string(from: Date()),
    "hint":       UIDevice.current.name
]

Android Kotlin

// 1. DEK
val dek = ByteArray(32).also { SecureRandom().nextBytes(it) }

// 2. KEK = BiometricPrompt-gated Keystore AES key
val keyGenParameterSpec = KeyGenParameterSpec.Builder("vault-kek", KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setKeySize(256)
    .setUserAuthenticationRequired(true)
    .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG)
    .setIsStrongBoxBacked(true)
    .build()
val kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
kg.init(keyGenParameterSpec)
kg.generateKey()

val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val kek = ks.getKey("vault-kek", null) as SecretKey

// 3. Wrap DEK (biometric prompt is triggered inside doFinal)
val wrapCipher = Cipher.getInstance("AES/GCM/NoPadding")
wrapCipher.init(Cipher.ENCRYPT_MODE, kek)
val prompt = BiometricPrompt(activity, executor, callback)
prompt.authenticate(promptInfo, BiometricPrompt.CryptoObject(wrapCipher))
// on callback.onAuthenticationSucceeded:
val wrappedDek = wrapCipher.doFinal(dek)
val wrapIv = wrapCipher.iv

// 4. Encrypt vault with DEK
val vaultCipher = Cipher.getInstance("AES/GCM/NoPadding")
vaultCipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(dek, "AES"))
val ct = vaultCipher.doFinal(json.encodeToString(vault).toByteArray())
val iv = vaultCipher.iv

4 POST save (D2)

POST /wallet-backup.ashx?action=save
Authorization: Bearer <narrow HS256 token from step 2>
Content-Type: application/json

<opaque blob>

Response: { "ok": true, "sync_id": "<iso>", "bytes": <n> }. Errors:

  • 400 invalid_json — malformed JSON body.
  • 400 invalid_vault_shape — missing top-level keys (v, wrappedDeks, cipher).
  • 413 blob_too_large — over 1 MB. Split large media out of the vault.
  • 429 rate_limited — too many saves in the hour. Retry-After header.

5 GET load on the new device (D3)

GET /wallet-backup.ashx?action=load
Authorization: Bearer <narrow HS256 token>

Response: the opaque blob JSON on 200, or {"error":"not_found"} on 404 (fresh user, nothing to restore).

On the new device, the user's biometric authenticates the KEK, DEK unwraps, vault decrypts. If your app supports multiple devices, add a fresh KEK entry to wrappedDeks for the new device on first successful restore, then save.

6 Conflict resolution

Compare updatedUtc from server vs local. If server > local, restore silently. If local > server, save. If they differ by more than a minute AND both have edits since the divergence point, surface a prompt: “Keep this device's changes, keep the cloud's, or merge?” A silent overwrite of newer content is the failure mode to avoid.

7 Failure modes to plan for

  • Android KeyPermanentlyInvalidatedException. Any enrollment change on a biometric-gated Keystore key (adding or removing a fingerprint / face) permanently invalidates the key. The wrapped DEKs become unusable. Recovery: on this exception, prompt the user to re-authenticate with their OIDC identity and either re-download the last vault (server copy still exists) or re-populate from OID4VCI. Store a secondary KEK path (e.g. an OS-keychain-guarded recovery key) if downtime is unacceptable.
  • iOS Secure Enclave key eviction on device restore. A Secure Enclave key does NOT migrate to a new device via iCloud restore. Treat the vault as device-scoped; the server-side blob is the recovery vehicle.
  • iOS self-ECDH wrap (step 3) is cryptographically sound (only the biometric-gated Secure Enclave can reproduce the shared secret) but non-standard. If your security team prefers AES-KW / RSA-OAEP over ECDH-self, generate a symmetric wrap key protected by Secure Enclave-backed access control instead. Both approaches are acceptable; the ECDH-self variant is shown because it exercises the same primitive used elsewhere in the wallet.
  • Server 429. If your sync loop hits the per-user hourly cap, back off — the local copy is authoritative between syncs anyway.

Next: Self-issue → Previous: WA API reference