Assisted Recovery (Guardian-Gated Escrow Wrap): Complete Flutter Implementation Guide
Overview
This document explains how backup and recovery scenarios work from a Flutter application's perspective, including:
- Complete service call chains (Flutter → KMP → Platform)
- Backup creation flows with Guardian-gated escrow, where a policy gate (gate contact/SMS/social) is required
- All recovery scenarios with detailed diagrams
- Where credentials and proofs come from
Primary vs secondary:
- Passkey = primary (fast local recovery)
- Guardian-gated escrow = secondary (if primary is lost, use the gate to re-register a new passkey, retrieve the KEK, and decrypt the user-held bundle)
Gate note: "gate contact" is just one possible gate. Others can be SMS or social recovery. The mechanics below use gate contact as an example gate only.
Code note: The current SDK interface names still use GuardianRecoveryApiOperations / RealGuardianRecoveryApi even though the gate can be non-gate contact.
Note: The PRD "Backup & Recovery (Portable Key)" is the source of truth for semantics and terminology. Other architecture docs may lag or use older naming.
Service Architecture: Flutter to KMP Call Chain
┌─────────────────────────────────────────────────────────────────────────────────┐
│ FLUTTER LAYER │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Flutter App │ │ Flutter App │ │ Flutter App │ │
│ │ (Backup Page) │ │ (Recovery Page)│ │ (Home Page) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ BlockchainKitSdk (Dart) │ │
│ │ │ │
│ │ sdk.backupService sdk.recoveryService sdk.passkeyService│ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ MethodChannel │
│ ▼ │
└─────────────────────────────────────────────────────────────────────────────────┘
│
│
┌─────────────────────────────────────────────────────────────────────────────────┐
│ KMP LAYER (commonMain) │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ BlockchainKitSdk (Kotlin) │ │
│ │ │ │
│ │ @FlutterModule - Entry point for all Flutter calls │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │BackupServiceModule│ │ PasskeyManager │ │RecoveryService │ │
│ │ @FlutterSubmodule│ │ │ │ (future) │ │
│ └────────┬────────┘ └────────┬────────┘ └─────────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │WalletBackupService│ │ PasskeyClient │ │GuardianRecoveryApi │ │
│ │ │ │ (expect/actual) │ │ Operations │ │
│ └────────┬────────┘ └─────────────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │BackupEncryption │ │RealGuardianRecovery│ │
│ │ │ │Api (HTTP) │ │
│ └────────┬────────┘ │ OR │ │
│ │ │PortableVault │ │
│ ▼ │ ApiAdapter │ │
│ ┌─────────────────┐ │ (testing) │ │
│ │ KeyUnlockService│◄─────────────────────────└─────────────────┘ │
│ │ │ │
│ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
│
│
┌─────────────────────────────────────────────────────────────────────────────────┐
│ PLATFORM LAYER (androidMain / iosMain) │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ PasskeyClient (actual) │ │
│ ├──────────────────────────────┬───────────────────────────────── ─────┤ │
│ │ Android │ iOS │ │
│ │ │ │ │
│ │ CredentialManager API │ ASAuthorizationController │ │
│ │ (Android 14+) │ (iOS 18+ for PRF) │ │
│ │ │ │ │
│ │ ┌─────────────────┐ │ ┌─────────────────┐ │ │
│ │ │ Google Password │ │ │ iCloud Keychain │ │ │
│ │ │ Manager │ │ │ Passkeys │ │ │
│ │ └─────────────────┘ │ └─────────────────┘ │ │
│ └──────────────────────────────┴──────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Crypto (actual) │ │
│ ├──────────────────────────────┬──────────────────────────────────────┤ │
│ │ Android │ iOS │ │
│ │ │ │ │
│ │ - AES-GCM (javax.crypto) │ - AES-GCM (CommonCrypto) │ │
│ │ - HKDF (BouncyCastle) │ - HKDF (CommonCrypto) │ │
│ │ - PBKDF2 (javax.crypto) │ - PBKDF2 (CommonCrypto) │ │
│ │ - SHA256 (java.security) │ - SHA256 (CommonCrypto) │ │
│ └──────────────────────────────┴──────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
Backup Scenarios
Backup Configuration Options
Production backup methods: PRF (Passkey) and Guardian-Gated Escrow (Assisted Recovery).
Testing-only: PIN/Password wrappers may exist for test coverage but are not product methods.
| Configuration | Description | Server Required | Offline Capable |
|---|---|---|---|
| PRF Only | Passkey PRF derives KEK locally | No | Yes |
| Assisted (Policy Gate) Only | KEK escrowed to backend | Yes (for gate) | No |
| PRF + Assisted (Policy Gate) | Both wrappers (recommended) | Yes (for gate) | Partial |
| PIN/Password (Testing-only) | PBKDF2 derives KEK locally | No | Yes |
Backup Scenario 1: PRF + Assisted (Policy Gate) (Recommended)
Best practice: Create backup with both PRF wrapper (for fast local recovery) AND assisted recovery wrapper (policy gate) (for disaster recovery).
┌─────────────────────────────────────────────────────────────────────────────────┐
│ BACKUP SCENARIO 1: PRF + Assisted (Policy Gate) (Recommended) │
└─────────────────────────────────────────────────────────────────────────────────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Flutter App │ │ KMP SDK │ │ Platform │ │ Recovery API │
│ │ │ │ │ (iOS/Android)│ │ (Backend) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
│ 1. User taps │ │ │
│ "Create Backup"│ │ │
│ with assisted │ │ │
│ recovery ON │ │ │
│ │ │ │
│ ══════════════════════════════════════════════════════════ │
│ STEP 1: Configure backup options │
│ ══════════════════════════════════════════════════════════ │
│ │ │ │
│ BackupOptions( │ │ │
│ wrappers: [ │ │ │
│ AllPasskeys, │ │ │
│ GuardianGatedEscrow.Gate │ │
│ ], │ │ │
│ gate contact: "user@..." ) │ │
│───────────────────>│ │ │
│ │ │ │
│ ══════════════════════════════════════════════════════════ │
│ STEP 2: Generate DEK and encrypt shares │
│ ══════════════════════════════════════════════════════════ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │
│ │ │ BackupEncryption.encrypt() │ │
│ │ │ │ │
│ │ │ a) Generate random DEK (32 B) │ │
│ │ │ b) Generate random salt (16 B) │ │
│ │ │ c) Encrypt each share with DEK │ │
│ │ │ using AES-256-GCM │ │
│ │ └──────────────────────────────────┘ │
│ │ │ │
│ ══════════════════════════════════════════════════════════ │
│ STEP 3: Create PRF wrapper (local, no server) │
│ ══════════════════════════════════════════════════════════ │
│ │ │ │
│ │ 2. Trigger passkey│ │
│ │ with PRF │ │
│ │───────────────────>│ │
│ │ │ │
│ 3. Biometric │ │ │
│ prompt │◄───────────────────│ │
│◄───────────────────│ │ │
│ │ │ │
│ 4. User scans │ │ │
│ fingerprint │ │ │
│───────────────────>│───────────────────>│ │
│ │ │ │
│ │ │ ┌───────────────┐ │
│ │ │ │ Secure Enclave│ │
│ │ │ │ │ │
│ │ │ │ PRF(salt) → │ │
│ │ │ │ prfOutput │ │
│ │ │ │ (32 bytes) │ │
│ │ │ └───────┬───────┘ │
│ │ │ │ │
│ │ 5. prfOutput │◄─────────┘ │
│ │◄───────────────────│ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │
│ │ │ KeyUnlockService │ │
│ │ │ │ │
│ │ │ KEK_PRF = HKDF(prfOutput, salt) │ │
│ │ │ │ │
│ │ │ wrappedDEK = AES-GCM(DEK, KEK_PRF)│ │
│ │ │ │ │
│ │ │ → DekWrap { │ │
│ │ │ wrap_type: "passkey_prf...", │ │
│ │ │ credential_reference: "...", │ │
│ │ │ ciphertext_dek_escrow: "...",│ │
│ │ │ } │ │
│ │ └──────────────────────────────────┘ │
│ │ │ │
│ ══════════════════════════════════════════════════════════ │
│ STEP 4: Create assisted recovery wrapper (requires server escrow) │
│ ══════════════════════════════════════════════════════════ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │
│ │ │ BackupEncryption │ │
│ │ │ │ │
│ │ │ KEK_ASSISTED = randomBytes(32) │ │
│ │ │ (random, NOT derived!) │ │
│ │ │ │ │
│ │ │ wrappedDEK = AES-GCM(DEK, │ │
│ │ │ KEK_ASSISTED) │ │
│ │ └──────────────────────────────────┘ │
│ │ │ │
│ │ 6. escrowKek( │ │
│ │ gate contact, │ │
│ │ KEK_ASSISTED)│ │
│ │────────────────────────────────────────>│
│ │ │ │
│ │ │ ┌───────────────┤
│ │ │ │ Store KEK │
│ │ │ │ encrypted │
│ │ │ │ with HSM/KMS │
│ │ │ │ │
│ │ │ │ Send gate │
│ │ │ │ notification │
│ │ │ └───────────────┤
│ │ │ │
│ │ 7. {recoveryId, │ │
│ │ kekId} │ │
│ │◄────────────────────────────────────────│
│ │ │ │
│ │ ┌──────────────────────────────────┐ │
│ │ │ SECURITY: Clear KEK_ASSISTED │ │
│ │ │ from memory NOW! │ │
│ │ │ │ │
│ │ │ KEK_ASSISTED.fill(0) │ │
│ │ │ │ │
│ │ │ (KEK only exists on backend now) │ │
│ │ └──────────────────────────────────┘ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │
│ │ │ → DekWrap { │ │
│ │ │ wrap_type: "guardian_gated_escrow...",│ │
│ │ │ recovery_reference: { │ │
│ │ │ recovery_id: "...", │ │
│ │ │ kek_id: "...", │ │
│ │ │ }, │ │
│ │ │ ciphertext_dek_escrow: "...",│ │
│ │ │ } │ │
│ │ └──────────────────────────────────┘ │
│ │ │ │
│ ══════════════════════════════════════════════════════════ │
│ STEP 5: Build and save bundle │
│ ══════════════════════════════════════════════════════════ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │
│ │ │ BackupBundleV3 { │ │
│ │ │ format_version: 3, │ │
│ │ │ salt: "...", │ │
│ │ │ dek_wraps: [ │ │
│ │ │ { wrap_type: "passkey_prf..."},│ │
│ │ │ { wrap_type: "guardian_gated_escrow.."}│ │
│ │ │ ], │ │
│ │ │ wallet: { shares: "encrypted" },│ │
│ │ │ checksum: SHA256(...) │ │
│ │ │ } │ │
│ │ └──────────────────────────────────┘ │
│ │ │ │
│ │ 8. Save to cloud storage │
│ │──────────────────────────────────── ────>│
│ │ │ │
│ 9. Backup │ │ │
│ complete! │ │ │
│◄───────────────────│ │ │
│ │ │ │
RESULT: Bundle has TWO wrappers:
┌────────────────────────────────────────────────────────────────────┐
│ BackupBundleV3 │
│ │
│ dek_wraps: [ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Wrapper 1: passkey_prf_hkdf_sha256 │ │
│ │ - KEK derived from PRF(passkey, salt) │ │
│ │ - Works OFFLINE │ │
│ │ - Requires original passkey │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Wrapper 2: guardian_gated_escrow │ │
│ │ - KEK escrowed to backend │ │
│ │ - Works if passkey LOST │ │
│ │ - Requires policy gate verification + timelock │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ] │
└────────────────────────────────────────────────────────────────────┘
Flutter Code: Create Backup with PRF + Assisted (Policy Gate)
// backup_with_assisted_recovery.dart
class BackupSetupPage extends StatefulWidget {
_BackupSetupPageState createState() => _BackupSetupPageState();
}
class _BackupSetupPageState extends State<BackupSetupPage> {
final sdk = BlockchainKitSdk();
bool _enableGuardianRecovery = true;
String? _gateContact;
Future<void> createBackup() async {
// ═══════════════════════════════════════════════════════════════════
// STEP 1: Build WrapperConfig list
// ═══════════════════════════════════════════════════════════════════
final wrappers = <WrapperConfig>[
// Always include passkey PRF (if device supports it)
WrapperConfig.allPasskeys(),
];
// Add assisted recovery (policy gate) if user opted in
if (_enableGuardianRecovery && _gateContact != null) {
wrappers.add(WrapperConfigFactories.guardianGatedEscrow());
}
// ═══════════════════════════════════════════════════════════════════
// STEP 2: Create BackupOptions
// ═══════════════════════════════════════════════════════════════════
final options = BackupOptions(
wrappers: wrappers,
storageLocations: [StorageLocation.cloud],
);
// ═══════════════════════════════════════════════════════════════════
// STEP 3: Call SDK to create backup
// ═══════════════════════════════════════════════════════════════════
//
// This triggers the following internal call chain:
//
// Flutter: sdk.backupService.createBackup(options, gate contact)
// │
// ▼ MethodChannel
// KMP: BlockchainKitSdk.backupServiceModule.createBackup(...)
// │
// ▼
// BackupServiceModule.createBackup(...)
// │
// ▼
// WalletBackupService.addWallet(...)
// │
// ├──► BackupEncryption.encrypt(...)
// │ │
// │ ├──► generateDek() → random 32 bytes
// │ ├──► encryptShares(shares, DEK)
// │ │
// │ ├──► [For PRF wrapper]
// │ │ PasskeyManager.authenticateWithPrf(salt)
// │ │ │
// │ │ ▼ expect/actual
// │ │ PasskeyClient.getAssertionWithPrf(...)
// │ │ │
// │ │ ▼ Platform
// │ │ iOS: ASAuthorizationController
// │ │ Android: CredentialManager
// │ │ │
// │ │ ▼
// │ │ prfOutput (32 bytes from secure enclave)
// │ │ │
// │ │ KeyUnlockService.deriveKek(PasskeyPrf)
// │ │ HKDF(prfOutput, salt) → KEK_PRF
// │ │
// │ ├──► [For assisted recovery wrapper]
// │ │ generateRandomKek() → KEK_ASSISTED
// │ │ │
// │ │ GuardianRecoveryApiOperations.escrowKek(...)
// │ │ │
// │ │ ▼
// │ │ POST /recovery/v1/escrow
// │ │ │
// │ │ clearKek(KEK_ASSISTED) // Security!
// │ │
// │ └──► buildBundle(encryptedShares, dekWraps, checksum)
// │
// └──► StorageProvider.save(bundle)
//
final result = await sdk.backupService.createBackup(
groupId: wallet.groupId,
options: options,
gate contact: _enableGuardianRecovery ? _gateContact : null,
);
result.fold(
(bundleId) {
showSuccess('Backup created! ID: $bundleId');
navigateToHome();
},
(error) {
showError('Backup failed: $error');
},
);
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Secure Your Wallet')),
body: Column(
children: [
// Assisted recovery toggle
SwitchListTile(
title: Text('Enable Assisted Recovery'),
subtitle: Text('Recover wallet even if you lose your device'),
value: _enableGuardianRecovery,
onChanged: (v) => setState(() => _enableGuardianRecovery = v),
),
if (_enableGuardianRecovery)
TextField(
decoration: InputDecoration(labelText: 'Recovery Contact'),
onChanged: (v) => setState(() => _gateContact = v),
),
ElevatedButton(
onPressed: createBackup,
child: Text('Create Secure Backup'),
),
],
),
);
}
}
Backup Scenario 2: Assisted (Policy Gate) Only (No PRF)
Use case: Device doesn't support PRF (iOS < 18, Android < 14), or user doesn't have PRF-capable passkey. The user still authenticates with a passkey for escrow flows, but KEK derivation uses KEK_ASSISTED instead of PRF.
Primary use case callout: When PRF is unavailable, KEK_ASSISTED becomes the encryption path for the bundle. The passkey is still used for biometric/auth, but it does not provide KEK_PRF.
Key difference from PRF: With PRF, the KEK is derived deterministically from the passkey (same passkey + salt = same KEK). With assisted recovery (policy-gated), there's no passkey to derive from, so the KEK must be escrowed (stored securely) on the server.
┌─────────────────────────────────────────────────────────────────────────────────┐
│ BACKUP SCENARIO 2: Assisted (Policy Gate) Only (No PRF) │
└─────────────────────────────────────────────────────────────────────────────────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Flutter App │ │ KMP SDK │ │ Recovery API │ │ HSM/KMS │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
│ BackupOptions( │ │ │
│ wrappers: [ │ │ │
│ GuardianGatedEscrow.Gate │ │
│ ], │ │ │
│ gate contact: "user@...") │ │
│───────────────────>│ │ │
│ │ │ │
│ │ ┌────────────────────── ────────────┐ │
│ │ │ 1. Generate DEK (32 bytes) │ │
│ │ │ 2. Encrypt shares with DEK │ │
│ │ │ 3. Generate KEK_ASSISTED (random) │ │
│ │ │ ← CLIENT generates! │ │
│ │ │ 4. Wrap DEK with KEK_ASSISTED │ │
│ │ └──────────────────────────────────┘ │
│ │ │ │
│ │ escrowKek(gate contact, │ │
│ │ KEK_ASSISTED) │
│ │───────────────────>│ │
│ │ │ │
│ │ │ ══════════════════════════════
│ │ │ SERVER-SIDE KEK ENCRYPTION
│ │ │ ══════════════════════════════
│ │ │ │
│ │ │ 1. Encrypt KEK │
│ │ │ with HSM │
│ │ │───────────────────>│
│ │ │ │
│ │ │ ┌───────────────┤
│ │ │ │ HSM Operation │
│ │ │ │ │
│ │ │ │ Algorithm: │
│ │ │ │ AES-256-GCM │
│ │ │ │ │
│ │ │ │ Master Key │
│ │ │ │ (MK) never │
│ │ │ │ leaves HSM │
│ │ │ │ │
│ │ │ │ encrypted_kek │
│ │ │ │ = AES-GCM( │
│ │ │ │ MK, │
│ │ │ │ KEK_ASSISTED, │
│ │ │ │ nonce │
│ │ │ │ ) │
│ │ │ └───────────────┤
│ │ │ │
│ │ │ 2. encrypted_kek │
│ │ │◄───────────────────│
│ │ │ │
│ │ │ 3. Store in DB: │
│ │ │ { │
│ │ │ recovery_id, │
│ │ │ gate_contact_hash, │
│ │ │ encrypted_kek│
│ │ │ nonce, │
│ │ │ created_at │
│ │ │ } │
│ │ │ │
│ │ {recoveryId, │ │
│ │ kekId} │ │
│ │◄───────────────────│ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │
│ │ │ SECURITY: Clear KEK_ASSISTED │ │
│ │ │ from memory NOW! │ │
│ │ │ │ │
│ │ │ KEK_ASSISTED.fill(0) │ │
│ │ │ │ │
│ │ │ KEK now ONLY exists encrypted │ │
│ │ │ in server's HSM-protected DB │ │
│ │ └──────────────────────────────────┘ │
│ │ │ │
│ Backup complete! │ │ │
│◄───────────────────│ │ │
│ │ │ │
SERVER-SIDE STORAGE MODEL:
┌────────────────────────────────────────────────────────────────────────────────┐
│ Recovery Backend Database │
├────────────────────────────────────────────────────────────────────────────────┤
│ │
│ escrowed_keys table: │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ recovery_id │ gate_contact_hash │ encrypted_kek │ nonce │ created_at │ │
│ ├─────────────┼────────────┼────────────────────┼──────────┼─────────────┤ │
│ │ uuid-1234 │ sha256(e@) │ [32 bytes + tag] │ [12 B] │ 2026-02-02 │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
│ │
│ Security properties: │
│ • encrypted_kek is AES-256-GCM ciphertext (32B data + 16B auth tag = 48B) │
│ • Master Key (MK) stored in HSM, never exported │
│ • gate contact stored as hash only (privacy) │
│ • DB compromise alone cannot recover KEK (needs HSM access) │
│ │
└────────────────────────────────────────────────────────────────────────────────┘
DURING RECOVERY (retrieveKek):
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Recovery API │ │ HSM/KMS │ │ Response │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 1. Load from DB: │ │
│ encrypted_kek, │ │
│ nonce │ │
│ │ │
│ 2. Decrypt( │ │
│ encrypted_kek, │
│ nonce) │ │
│───────────────────>│ │
│ │ │
│ ┌──────────────┤ │
│ │ HSM decrypts │ │
│ │ with MK │ │
│ │ │ │
│ │ KEK_ASSISTED = │ │
│ │ AES-GCM-Dec( │ │
│ │ MK, │ │
│ │ encrypted, │ │
│ │ nonce │ │
│ │ ) │ │
│ └──────────────┤ │
│ │ │
│ 3. KEK_ASSISTED │ │
│ (plaintext) │ │
│◄───────────────────│ │
│ │ │
│ 4. Return KEK to │ │
│ client (TLS) │ │
│─────────────────────────────────────────>
│ │ │
RESULT: Bundle has ONE wrapper (assisted-only, policy gate example):
┌────────────────────────────────────────────────────────────────────┐
│ BackupBundleV3 │
│ │
│ dek_wraps: [ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Wrapper 1: guardian_gated_escrow │ │
│ │ - KEK generated by CLIENT (random 32 bytes) │ │
│ │ - KEK escrowed to backend (AES-256-GCM encrypted) │ │
│ │ - ONLY recovery method │ │
│ │ - Requires policy gate verification + timelock (or passkey)│ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ] │
│ │
│ ⚠️ WARNING: If user can't complete the gate, wallet is IRRECOVERABLE │
└────────────────────────────────────────────────────────────────────┘
Server-Side Security: Why HSM/KMS?
┌─────────────────────────────────────────────────────────────────────────────────┐
│ WHY HSM/KMS FOR KEK STORAGE? │
└─────────────────────────────────────────────────────────────────────────────────┘
Without HSM (BAD):
┌─────────────────────────────────────────────────────────────────────────────────┐
│ DB stores: KEK (plaintext) or KEK encrypted with key in config file │
│ │
│ Attack: DB breach → attacker gets all KEKs → can decrypt all wallets │
│ Attack: Server compromise → attacker reads config → decrypts all KEKs │
└─────────────────────────────────────────────────────────────────────────────────┘
With HSM (GOOD):
┌─────────────────────────────────────────────────────────────────────────────────┐
│ DB stores: encrypted_kek (ciphertext) │
│ HSM stores: Master Key (MK) - NEVER exported, NEVER in software │
│ │
│ Attack: DB breach → attacker gets encrypted_kek → USELESS without MK │
│ Attack: Server compromise → attacker can't extract MK from HSM │
│ Attack: HSM breach → extremely difficult (tamper-resistant hardware) │
│ │
│ To decrypt a KEK, attacker needs: │
│ 1. DB access (get encrypted_kek) │
│ 2. HSM access (to decrypt with MK) │
│ 3. Valid authentication to HSM │
│ │
│ Common HSM/KMS options: │
│ - AWS KMS (FIPS 140-2 Level 3) │
│ - Google Cloud KMS / Cloud HSM │
│ - Azure Key Vault (HSM-backed) │
│ - On-premise HSM (Thales, Gemalto, etc.) │
└─────────────────────────────────────────────────────────────────────────────────┘
Backup Scenario 3: PRF Only (No Assisted Recovery)
Use case: User opts out of assisted recovery (policy gate) (not recommended).
┌─────────────────────────────────────────────────────────────────────────────────┐
│ BACKUP SCENARIO 3: PRF Only (No Assisted Recovery) │
└────────────────────────────── ───────────────────────────────────────────────────┘
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Flutter App │ │ KMP SDK │ │ Platform │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ BackupOptions( │ │
│ wrappers: [ │ │
│ AllPasskeys │ │
│ ]) │ │
│───────────────────>│ │
│ │ │
│ │ ┌──────────────────────────────────┐
│ │ │ 1. Generate DEK (32 bytes) │
│ │ │ 2. Encrypt shares with DEK │
│ │ └──────────────────────────────────┘
│ │ │
│ │ Trigger PRF auth │
│ │───────────────────>│
│ │ │
│ Biometric prompt │ │
│◄───────────────────│◄───────────────────│
│ │ │
│ Fingerprint │ │
│───────────────────>│───────────────────>│
│ │ │
│ │ prfOutput │
│ │◄───────────────────│
│ │ │
│ │ ┌──────────────────────────────────┐
│ │ │ KEK = HKDF(prfOutput, salt) │
│ │ │ Wrap DEK with KEK │
│ │ │ │
│ │ │ NO SERVER CALLS! │
│ │ │ Everything is local. │
│ │ └──────────────────────────────────┘
│ │ │
│ Backup complete! │ │
│◄───────────────────│ │
│ │ │
RESULT: Bundle has ONE wrapper (PRF only):
┌────────────────────────────────────────────────────────────────────┐
│ BackupBundleV3 │
│ │
│ dek_wraps: [ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Wrapper 1: passkey_prf_hkdf_sha256 │ │
│ │ - KEK derived locally from PRF │ │
│ │ - Works OFFLINE │ │
│ │ - Requires SAME passkey to restore │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ ] │
│ │
│ ⚠️ WARNING: If passkey is lost, wallet is IRRECOVERABLE │
└────────────────────────────────────────────────────────────────────┘
Backup Scenario Comparison
┌─────────────────────────────────────────────────────────────────────────────────┐
│ BACKUP SCENARIO COMPARISON │
└─────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────┬─────────────────┬─────────────────┬─────────────────┐
│ │ PRF + Assisted (Policy Gate) │ Assisted (Policy Gate) Only │ PRF Only │
│ │ (Recommended) │ │ │
├─────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ Server Required │ Yes (for gate) │ Yes │ No │
├─────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ Offline Backup │ Partial (PRF) │ No │ Yes │
├─────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ Passkey Lost │ ✅ Use gate │ ✅ Use gate │ ❌ Irrecoverable│
├─────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ Gate Compromised│ ✅ Use PRF │ ⚠️ Timelock │ ✅ No risk │
├─────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ Both Lost │ ❌ Irrecoverable│ ❌ Irrecoverable│ ❌ Irrecoverable│
├─────────────────┼─────────────────┼─────────────────┼─────────────────┤
│ Best For │ Most users │ No PRF support │ Privacy focused │
└─────────────────┴─────────────────┴─────────────────┴─────────────────┘
Recovery Scenarios Matrix
| Backup Configuration | Passkey Available | Passkey Lost + Gate OK | Can't Complete Gate |
|---|---|---|---|
| PRF + Assisted (Policy Gate) | Scenario A: Fast PRF unlock | Scenario B: Assisted recovery (policy gate) with timelock | Irrecoverable |
| Assisted (Policy Gate) Only | Scenario C: Fast passkey skip | Scenario D: Full assisted recovery (policy gate) | Irrecoverable |
| PRF Only | Scenario E: PRF unlock | Irrecoverable | Irrecoverable |
Scenario A: PRF + Assisted (Policy Gate) Backup, Passkey Available (Fast Path)
User situation: Has both PRF and assisted recovery wrappers in backup, and still has their passkey on the device.
Best recovery method: Use PRF directly (no server interaction needed).
┌─────────────────────────────────────────────────────────────────────────────┐
│ SCENARIO A: PRF Fast Path │
│ (Passkey Available, PRF + Assisted (Policy Gate) Backup) │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Flutter App │ │ KMP SDK │ │ Platform Auth │
│ │ │ │ │ (iOS/Android) │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
│ 1. User taps "Restore"│ │
│───────────────────────>│ │
│ │ │
│ │ 2. Load backup bundle │
│ │ (from cloud/local) │
│ │ │
│ │ 3. Find PRF wrapper │
│ │ in dek_wraps[] │
│ │ │
│ 4. Request passkey auth │
│<───────────────────────│ │
│ │ │
│ 5. User performs biometric │
│────────────────────────────────────────────────>│
│ │ │
│ │ ┌────────────┴────────────┐
│ │ │ Platform Authenticator │
│ │ │ │
│ │ │ a) Verify biometric │
│ │ │ b) Sign challenge │
│ │ │ c) Compute PRF(salt) │
│ │ │ → prfOutput (32 B) │
│ │ └────────────┬────────────┘
│ │ │
│ 6. PRF output (never leaves device) │
│<────────────────────────────────────────────────│
│ │ │
│ 7. Derive KEK from PRF│ │
│───────────────────────>│ │
│ │ │
│ │ ┌─────────────────────┴───────────┐
│ │ │ HKDF-SHA256(prfOutput, salt) │
│ │ │ → KEK (32 bytes) │
│ │ │ │
│ │ │ AES-GCM-Unwrap(wrappedDEK, KEK) │
│ │ │ → DEK (32 bytes) │
│ │ │ │
│ │ │ AES-GCM-Decrypt(shares, DEK) │
│ │ │ → plaintext wallet shares │
│ │ └─────────────────────────────────┘
│ │ │
│ 8. Wallet restored! │ │
│<───────────────────────│ │
│ │ │
Flutter Code (Scenario A)
// scenario_a_prf_fast_path.dart
class RestorePage extends StatefulWidget {
_RestorePageState createState() => _RestorePageState();
}
class _RestorePageState extends State<RestorePage> {
final sdk = BlockchainKitSdk();
Future<void> restoreWithPasskey() async {
// Step 1: Load backup bundle
final bundleResult = await sdk.backupService.loadBackup();
final bundle = bundleResult.getOrThrow();
// Step 2: Check if PRF wrapper exists
final hasPrf = bundle.dekWraps.any(
(wrap) => wrap.wrapType == 'passkey_prf_hkdf_sha256'
);
if (!hasPrf) {
// Fall back to assisted recovery (policy gate) (Scenario B or D)
return initiateAssistedRecovery();
}
// Step 3: Trigger passkey authentication with PRF
// This calls the platform authenticator (Face ID / fingerprint)
final passkeyResult = await sdk.passkeyService.authenticateWithPrf(
salt: bundle.salt,
);
passkeyResult.fold(
(prfAuth) async {
// Step 4: Use PRF output to derive KEK and decrypt
// The prfAuth contains:
// - credentialId: which passkey was used
// - prfSecret: the 32-byte PRF output (NEVER sent to server)
final strategy = UnlockStrategy.passkeyPrf(
credentialId: prfAuth.credentialId,
prfInput: prfAuth.prfSecret,
);
final restoreResult = await sdk.backupService.restore(strategy);
restoreResult.fold(
(wallet) => navigateToHome(wallet),
(error) => showError('Restore failed: $error'),
);
},
(error) {
if (error is PasskeyCancelled) {
// User cancelled biometric - offer assisted recovery (policy gate)
offerAssistedRecovery();
} else {
showError('Passkey error: $error');
}
},
);
}
}
Where Does the PRF Output Come From?
┌───────────────────────────────────────────────────────────────────────────┐
│ PRF (Pseudo-Random Function) Flow │
└───────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐
│ WebAuthn PRF Extension │
│ │
│ The PRF extension is part of WebAuthn Level 3. When you authenticate │
│ with a passkey, you can request an additional PRF evaluation: │
│ │
│ Input: salt (from backup bundle) + passkey private key │
│ Output: 32 bytes of deterministic pseudorandom data │
│ │
│ This output is: │
│ - Deterministic: same salt + same passkey = same output │
│ - Unique: different salt or passkey = completely different output │
│ - Secret: never leaves the secure enclave / TPM │
│ - Unforgeable: requires biometric + correct passkey │
└─────────────────────────────────────────────────────────────────────────┘
Platform Flow:
┌──────────────┐
│ User's │
│ Finger/Face │
└──────┬───────┘
│ biometric
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Secure │ │ Passkey │ │ PRF │
│ Enclave │─────>│ Private │─────>│ HMAC-SHA256│
│ (TEE) │ │ Key │ │ (salt) │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐
│ prfOutput │
│ (32 bytes) │
│ │
│ NEVER sent │
│ to server! │
└──────────────┘
Scenario B: PRF + Assisted (Policy Gate) Backup, Passkey Lost (Assisted Recovery)
User situation: Has both PRF and assisted recovery wrappers, but lost their device/passkey.
Recovery method: Use policy gate verification to retrieve escrowed KEK.
┌─────────────────────────────────────────────────────────────────────────────┐
│ SCENARIO B: Assisted Recovery (Policy Gate) (Full Flow) │
│ (Passkey Lost, but has assisted recovery wrapper) │
└─────────────────────────────────────────────────────────────────────────────┘
PHASE 1: INITIATE RECOVERY
═══════════════════════════
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Flutter App │ │ KMP SDK │ │ Recovery API │ │ Gate Provider│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │ │
│ 1. "I lost my │ │ │
│ passkey" │ │ │
│───────────────────>│ │ │
│ │ │ │
│ │ 2. Load local bundle (user storage) │
│ │ If missing → irrecoverable │
│ │ │ │
│ │ 3. Extract │ │
│ │ recoveryId from │ │
│ │ assisted recovery wrapper (policy gate) │ │
│ │ │ │
│ │ 5. initiateRecovery│ │
│ │ (recoveryId, │ │
│ │ kekId) │ │
│ │───────────────────>│ │
│ │ │ │
│ │ │ 6. Send OTP via │
│ │ │ gate (gate contact ex) │
│ │ │───────────────────>│
│ │ │ │
│ │ 7. {challengeId, │ │
│ │ gateMasked} │ │
│ │<───────────────────│ │
│ │ │ │
│ 8. Show "Check │ │ │
│ j***@gate contact.com" │ (gate contact example) │ │
│<───────────────────│ │ │
│ │ │ │
PHASE 2: OTP VERIFICATION
═════════════════════════
│ │ │ │
│ 9. User enters │ │ │
│ OTP "123456" │ │ │
│───────────────────>│ │ │
│ │ │ │
│ │ 10. verifyOtp │ │
│ │ (challengeId, │ │
│ │ "123456") │ │
│ │───────────────────>│ │
│ │ │ │
│ │ 11. OTP valid! │ │
│ │ timelock starts│ │
│ │<───────────────────│ │
│ │ │ │
PHASE 3: TIMELOCK WAIT (24-48 hours in production, 5 seconds in tests)
══════════════════════════════════════════════════════════════════════
│ │ │ │
│ 12. Show timelock │ │ │
│ countdown UI │ │ │
│<───────────────────│ │ │
│ │ │ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ SECURITY WINDOW │ │
│ │ │ │
│ │ During this time, the legitimate owner receives │ │
│ │ an gate contact: "Someone is trying to recover your │ │
│ │ wallet. If this wasn't you, click here to cancel." │ │
│ │ │ │
│ │ This prevents attackers who compromised gate contact │ │
│ │ from immediately stealing funds. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │ │
│ 13. Poll status │ │ │
│ periodically │ │ │
│───────────────────>│ │ │
│ │ 14. checkStatus │ │
│ │───────────────────>│ │
│ │ │ │
│ │ 15. TIMELOCK_ACTIVE│ │
│ │ remaining: 45s │ │
│ │<───────────────────│ │
│ │ │ │
│ ... time passes ... │ │
│ │ │ │
│ │ 16. READY_FOR_ │ │
│ │ RETRIEVAL │ │
│ │<───────────────────│ │
│ │ │ │
PHASE 4: REGISTER NEW PASSKEY & RETRIEVE KEK
════════════════════════════════════════════
│ │ │ │
│ 17. "Timelock │ │ │
│ complete! │ │ │
│ Register new │ │ │
│ passkey" │ │ │
│<───────────────────│ │ │
│ │ │ │
│ 18. User creates │ │ │
│ new passkey │ │ │
│ (biometric) │ │ │
│───────────────────>│ │ │
│ │ │ │
│ │ 19. retrieveKek │ │
│ │ (challengeId) │ │
│ │───────────────────>│ │
│ │ │ │
│ │ 20. KEK (32 bytes) │ │
│ │<───────────────────│ │
│ │ │ │
│ │ ┌──────────────────────────────────┐ │
│ │ │ Unwrap DEK with retrieved KEK │ │
│ │ │ Decrypt shares with DEK │ │
│ │ │ Clear KEK from memory │ │
│ │ └──────────────────────────────────┘ │
│ │ │ │
│ 21. Wallet │ │ │
│ restored! │ │ │
│<───────────────────│ │ │
│ │ │ │
PHASE 5: CREATE NEW BACKUP WITH NEW PASSKEY
═══════════════════════════════════════════
│ │ │ │
│ 22. "Create new │ │ │
│ backup with │ │ │
│ new passkey?" │ │ │
│<────────────────── ─│ │ │
│ │ │ │
│ 23. Yes, create │ │ │
│───────────────────>│ │ │
│ │ │ │
│ │ 24. Generate new DEK │ │
│ │ Wrap with new passkey PRF │ │
│ │ + new gate contact escrow │ │
│ │ Upload new bundle │ │
│ │ │ │
│ 25. New backup │ │ │
│ created! │ │ │
│<───────────────────│ │ │
│ │ │ │
Flutter Code (Scenario B)
// scenario_b_assisted_recovery.dart
class AssistedRecoveryPage extends StatefulWidget {
_AssistedRecoveryPageState createState() => _AssistedRecoveryPageState();
}
class _GuardianRecoveryPageState extends State<GuardianRecoveryPage> {
final sdk = BlockchainKitSdk();
RecoveryState _state = RecoveryState.initial;
String? _challengeId;
String? _gateMasked;
Duration? _timelockRemaining;
// ═══════════════════════════════════════════════════════════════════════
// PHASE 1: INITIATE RECOVERY
// ═══════════════════════════════════════════════════════════════════════
Future<void> initiateRecovery(String gate contact) async {
setState(() => _state = RecoveryState.loading);
// Step 1: Load the local bundle (user storage). If missing, recovery is irrecoverable.
final bundleResult = await sdk.backupService.loadBackup();
final bundle = bundleResult.getOrNull();
if (bundle == null) {
setState(() => _state = RecoveryState.error);
showError('No backup found for this gate contact');
return;
}
// Step 2: Extract recoveryId from the assisted wrapper
final gateWrap = bundle.dekWraps.firstWhere(
(w) => w.wrapType == 'assisted_assisted_recovery',
orElse: () => throw Exception('No assisted recovery wrapper (policy gate) in bundle'),
);
final recoveryId = gateWrap.recoveryReference!.recoveryId;
// Step 4: Initiate recovery (sends OTP to gate contact)
final initiateResult = await sdk.recoveryService.initiateRecovery(
recoveryId,
);
initiateResult.fold(
(result) {
setState(() {
_state = RecoveryState.otpRequired;
_challengeId = result.challengeId;
_gateMasked = result.gateMasked;
});
},
(error) {
if (error is RecoveryApiResult.TimeLockActive) {
// Recovery already in progress
setState(() {
_state = RecoveryState.timelockActive;
_timelockRemaining = Duration(
milliseconds: error.endsAt - DateTime.now().millisecondsSinceEpoch
);
});
} else {
showError('Failed to initiate: $error');
}
},
);
}
// ════════ ═══════════════════════════════════════════════════════════════
// PHASE 2: OTP VERIFICATION
// ═══════════════════════════════════════════════════════════════════════
Future<void> verifyOtp(String otpCode) async {
setState(() => _state = RecoveryState.verifying);
final result = await sdk.recoveryService.verifyOtp(
_challengeId!,
otpCode,
);
result.fold(
(_) {
// OTP correct! Start timelock
setState(() => _state = RecoveryState.timelockActive);
startTimelockPolling();
},
(error) {
if (error is RecoveryApiResult.OtpInvalid) {
setState(() => _state = RecoveryState.otpRequired);
showError('Invalid OTP. ${error.attemptsRemaining} attempts remaining');
} else {
showError('Verification failed: $error');
}
},
);
}
// ═══════════════════════════════════════════════════════════════════════
// PHASE 3: TIMELOCK WAIT
// ═══════════════════════════════════════════════════════════════════════
Timer? _pollTimer;
void startTimelockPolling() {
_pollTimer = Timer.periodic(Duration(seconds: 5), (_) async {
final status = await sdk.recoveryService.checkStatus(_challengeId!);
status.fold(
(result) {
if (result.state == RecoveryState.READY_FOR_RETRIEVAL) {
_pollTimer?.cancel();
setState(() => _state = RecoveryState.readyForRetrieval);
} else {
setState(() {
_timelockRemaining = result.timelockRemainingSeconds != null
? Duration(seconds: result.timelockRemainingSeconds!)
: null;
});
}
},
(error) {
if (error is RecoveryApiResult.Cancelled) {
_pollTimer?.cancel();
showError('Recovery was cancelled');
setState(() => _state = RecoveryState.cancelled);
}
},
);
});
}
// ═══════════════════════════════════════════════════════════════════════
// PHASE 4: RETRIEVE KEK & RESTORE
// ═══════════════════════════════════════════════════════════════════════
Future<void> retrieveAndRestore() async {
setState(() => _state = RecoveryState.retrieving);
// Step 1: Retrieve the escrowed KEK
final kekResult = await sdk.recoveryService.retrieveKek(
_challengeId!,
null, // No passkey proof needed - we lost the passkey
);
await kekResult.fold(
(kekBytes) async {
// Step 2: Use retrieved KEK to unlock backup
final strategy = UnlockStrategy.guardianGatedEscrow(
recoveryId: _recoveryId,
challengeId: _challengeId!,
);
final restoreResult = await sdk.backupService.restore(strategy);
restoreResult.fold(
(wallet) {
setState(() => _state = RecoveryState.restored);
// Prompt user to create new backup with new passkey
showCreateNewBackupDialog(wallet);
},
(error) => showError('Restore failed: $error'),
);
},
(error) => showError('KEK retrieval failed: $error'),
);
}
// ═══════════════════════════════════════════════════════════════════════
// UI
// ═══════════════════════════════════════════════════════════════════════
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Recover Wallet')),
body: switch (_state) {
RecoveryState.initial => GateContactInputForm(onSubmit: initiateRecovery),
RecoveryState.otpRequired => OtpInputForm(
gateMasked: _gateMasked!,
onSubmit: verifyOtp,
onResend: () => sdk.recoveryService.resendOtp(_challengeId!),
),
RecoveryState.timelockActive => TimelockWaitingScreen(
remaining: _timelockRemaining,
onCancel: () => sdk.recoveryService.cancelRecovery(_challengeId!),
),
RecoveryState.readyForRetrieval => ReadyToRestoreScreen(
onRestore: retrieveAndRestore,
),
RecoveryState.restored => RestoreSuccessScreen(),
_ => LoadingIndicator(),
},
);
}
}
Scenario C: Assisted (Policy Gate) Only Backup, Passkey Available (Fast Skip)
User situation: Has only assisted recovery wrapper in backup, but still has their passkey.
Recovery method: Use passkey proof to skip timelock and retrieve KEK immediately.
┌─────────────────────────────────────────────────────────────────────────────┐
│ SCENARIO C: Assisted (Policy Gate) Only + Passkey Skip │
│ (Has passkey, can skip timelock with proof) │
└─────────────────────────────────────────────────────────────────────────────┘
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Flutter App │ │ KMP SDK │ │ Recovery API │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
│ 1. Start recovery │ │
│───────────────────────>│ │
│ │ │
│ │ 2. initiateRecovery │
│ │───────────────────────>│
│ │ │
│ 3. OTP sent to gate contact │ │
│<───────────────────────│ │
│ │ │
│ 4. User enters OTP │ │
│───────────────────────>│ │
│ │ │
│ │ 5. verifyOtp │
│ │───────────────────────>│
│ │ │
│ │ 6. TIMELOCK_ACTIVE │
│ │<───────────────────────│
│ │ │
│ 7. "Skip timelock │ │
│ with passkey?" │ │
│<───────────────────────│ │
│ │ │
│ 8. User authenticates │ │
│ with passkey │ │
│───────────────────────>│ │
│ │ │
│ │ ┌─────────────────────┐
│ │ │ Get passkey │
│ │ │ assertion (NOT PRF) │
│ │ │ │
│ │ │ This proves the │
│ │ │ user owns a passkey │
│ │ │ registered with │
│ │ │ this account │
│ │ └─────────────────────┘
│ │ │
│ │ 9. retrieveKek │
│ │ (challengeId, │
│ │ passkeyProof) │
│ │───────────────────────>│
│ │ │
│ │ ┌────────────────┤
│ │ │ Verify passkey │
│ │ │ signature │
│ │ │ │
│ │ │ Skip timelock! │
│ │ │ │
│ │ │ Return KEK │
│ │ └────────────────┤
│ │ │
│ │ 10. KEK (immediate!) │
│ │<───────────────────────│
│ │ │
│ 11. Wallet restored │ │
│ (no waiting!) │ │
│<───────────────────────│ │
│ │ │
Where Does the Passkey Proof Come From?
┌───────────────────────────────────────────────────────────────────────────┐
│ Passkey Proof for Fast Skip │
└───────────────────────────────────────────────────────────────────────────┘
The "passkey proof" is a WebAuthn assertion (NOT PRF output):
┌─────────────────────────────────────────────────────────────────────────┐
│ │
│ WebAuthn Assertion = signature over: │
│ - challenge (from server) │
│ - authenticatorData (includes credential ID, user presence flags) │
│ - clientDataJSON (origin, type) │
│ │
│ The server verifies: │
│ 1. Signature is valid for the registered public key │
│ 2. Challenge matches what server sent │
│ 3. User presence flag is set (user interacted) │
│ │
│ This proves: "The user who owns this registered passkey is present" │
│ │
│ NOTE: This is different from PRF! │
│ - PRF output: used to DERIVE a KEK locally │
│ - Assertion: used to PROVE identity to server │
│ │
└─────────────────────────────────────────────────────────────────────────┘
Flow:
┌──────────────┐
│ Recovery │
│ Backend │
└──────┬───────┘
│
│ 1. Generate challenge
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Challenge │ │ Passkey │ │ Assertion │
│ (random) │─────>│ Private │─────>│ Signature │
│ │ │ Key │ │ │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
┌───────────────────────────────────── ───────┘
│
▼
┌──────────────┐
│ Backend │
│ verifies │
│ signature │
│ │
│ → Skip │
│ timelock! │
└──────────────┘
Flutter Code (Scenario C)
// scenario_c_passkey_skip.dart
Future<void> retrieveWithPasskeySkip() async {
// We're in TIMELOCK_ACTIVE state but have a passkey
// Step 1: Get passkey assertion (NOT PRF)
// This creates a cryptographic signature proving passkey ownership
final assertionResult = await sdk.passkeyService.getAssertion(
challenge: await fetchChallengeFromServer(),
rpId: 'your-app.com',
);
await assertionResult.fold(
(assertion) async {
// Step 2: Convert assertion to proof bytes
// The proof is the authenticatorData + clientDataJSON + signature
final passkeyProof = encodeAssertionAsProof(assertion);
// Step 3: Retrieve KEK with passkey proof (skips timelock!)
final kekResult = await sdk.recoveryService.retrieveKek(
_challengeId!,
passkeyProof, // This triggers fast-path on server
);
// If passkey is valid, we get KEK immediately without waiting!
kekResult.fold(
(kekBytes) => completeRestore(kekBytes),
(error) {
if (error is RecoveryApiResult.TimeLockActive) {
// Passkey not recognized - must wait for timelock
showMessage('Passkey not recognized. Please wait for timelock.');
}
},
);
},
(error) {
// Passkey failed - must wait for timelock
showMessage('Could not verify passkey. Waiting for timelock...');
},
);
}
/// Encode WebAuthn assertion as bytes for server verification
Uint8List encodeAssertionAsProof(GetAssertionResult assertion) {
// This format should match what your server expects
// Typically: authenticatorData || clientDataHash || signature
final buffer = BytesBuilder();
buffer.add(assertion.authenticatorData);
buffer.add(sha256(assertion.clientDataJSON));
buffer.add(assertion.signature);
return buffer.toBytes();
}
Scenario D: Assisted (Policy Gate) Only Backup, Passkey Lost (Full Timelock)
User situation: Has only assisted recovery wrapper, and lost their passkey.
Recovery method: Full assisted recovery (policy gate) with complete timelock wait.
This is the same as Scenario B (Phase 1-5), but without the option to skip timelock.
Scenario E: PRF Only Backup, Passkey Available
User situation: Has only PRF wrapper (no assisted recovery (policy gate) enabled).
Recovery method: Direct PRF decryption (same as Scenario A).
If passkey lost: Irrecoverable - user must have backup recovery methods.
Complete State Machine (Flutter)
┌─────────────────────────────────────────────────────────────────────────────┐
│ COMPLETE RECOVERY STATE MACHINE │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────┐
│ START │
└──────┬──────┘
│
▼
┌─────────────────┐
│ Check backup │
│ wrapper types │
└────────┬────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ PRF Only │ │ PRF + Assisted (Policy Gate) │ │ Assisted (Policy Gate) Only │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌────────────────┐
│ Has passkey? │ │ Has passkey? │ │ Has passkey? │
└───────┬────────┘ └───────┬────────┘ └───────┬────────┘
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ │ │ │ │ │
YES NO YES NO YES NO
│ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼
┌──────────┐ ┌──────┐ ┌──────────┐ ┌──────┐ ┌──────────┐ ┌──────┐
│Scenario A│ │DEAD │ │Scenario A│ │Scen B│ │Scenario C│ │Scen D│
│(PRF fast)│ │END ❌│ │(PRF fast)│ │(full)│ │(skip TL) │ │(full)│
└────┬─────┘ └──────┘ └────┬─────┘ └──┬───┘ └────┬─────┘ └──┬───┘
│ │ │ │ │
▼ ▼ │ │ │
┌──────────┐ ┌──────────┐ │ │ │
│ RESTORED │ │ RESTORED │ │ │ │
└──────────┘ └─── ───────┘ │ │ │
│ │ │
┌──────────────────────┴──────────┴──────────┘
│
▼
┌───────────────┐
│ INITIATED │◄──────────────────────────────┐
│ (OTP sent) │ │
└───────┬───────┘ │
│ │
▼ │
┌───────────────┐ │
│ OTP_REQUIRED │ │
│ │───── wrong OTP ──────────────>│
└───────┬───────┘ (< 3 attempts) │
│ │
│ correct OTP │
▼ │
┌───────────────┐ │
│ TIMELOCK_ │ │
│ ACTIVE │───── cancel ─────────────────>│ CANCELLED
└───────┬───────┘ │
│ │
┌──────────┴──────────┐ │
│ │ │
has passkey no passkey │
proof │
│ │ │
▼ ▼ │
┌───────────┐ ┌───────────┐ │
│ SKIP │ │ WAIT │ │
│ TIMELOCK │ │ (24-48h) │ │
└─────┬─────┘ └─────┬─────┘ │
│ │ │
└─────────┬──────────┘ │
│ │
▼ │
┌───────────────┐ │
│ READY_FOR_ │ │
│ RETRIEVAL │ │
└───────┬───────┘ │
│ │
▼ │
┌───────────────┐ │
│ RETRIEVED │ │
│ (KEK got) │ │
└───────┬───────┘ │
│ │
▼ │
┌───────────────┐ │
│ RESTORED │ │
│ ✅ │ │
└───────────────┘ │
Security Properties Summary
| Property | PRF Path | Gate Path | Gate + Passkey Skip |
|---|---|---|---|
| Server trust | None | Required | Required |
| Offline capable | Yes | No | No |
| Time to recover | Instant | 24-48 hours | Instant |
| Gate compromise | No risk | Risk during timelock | Risk during timelock |
| Passkey compromise | Full risk | No risk (if passkey lost) | Full risk |
Testing with PortableEscrowKekVault
For testing, configure a short timelock:
// In your test setup
await sdk.setTestAssistedRecoveryVault(timelockDurationSeconds: 5);
// Now the full assisted recovery (policy gate) flow completes in ~5 seconds
// instead of 24-48 hours
The portable vault uses fixed OTP "123456" for deterministic testing. This is a test-only shortcut and does not represent production Guardian policy enforcement.
Note on bundle availability: The Portable Recovery Kit always lives in user-controlled storage. If the user loses the bundle, recovery is irrecoverable.
Why Two IDs (recoveryId + kekId)
Assisted recovery uses two identifiers in the recovery reference:
- recoveryId identifies the recovery record/session (policy gates, OTP/timelock, auditing).
- kekId identifies the specific escrowed KEK blob stored in the vault/HSM.
This separation matters because:
- A single recovery record can reference multiple escrowed KEKs over time (rotation, re-backups).
- The vault/HSM typically keys encrypted KEKs by their own identifier.
- Auditing and revocation are cleaner when the recovery flow and the crypto blob are distinct.