Skip to main content

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.

ConfigurationDescriptionServer RequiredOffline Capable
PRF OnlyPasskey PRF derives KEK locallyNoYes
Assisted (Policy Gate) OnlyKEK escrowed to backendYes (for gate)No
PRF + Assisted (Policy Gate)Both wrappers (recommended)Yes (for gate)Partial
PIN/Password (Testing-only)PBKDF2 derives KEK locallyNoYes

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 ConfigurationPasskey AvailablePasskey Lost + Gate OKCan't Complete Gate
PRF + Assisted (Policy Gate)Scenario A: Fast PRF unlockScenario B: Assisted recovery (policy gate) with timelockIrrecoverable
Assisted (Policy Gate) OnlyScenario C: Fast passkey skipScenario D: Full assisted recovery (policy gate)Irrecoverable
PRF OnlyScenario E: PRF unlockIrrecoverableIrrecoverable

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

PropertyPRF PathGate PathGate + Passkey Skip
Server trustNoneRequiredRequired
Offline capableYesNoNo
Time to recoverInstant24-48 hoursInstant
Gate compromiseNo riskRisk during timelockRisk during timelock
Passkey compromiseFull riskNo 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.