w3pk 0.10.2 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +10,7 @@ Complete reference for all methods, types, and utilities in the w3pk SDK.
10
10
  - [Origin-Specific Address Derivation](#origin-specific-address-derivation)
11
11
  - [`sendTransaction()`](#sendtransactiontx-options-promisetransactionresult)
12
12
  - [`getEIP1193Provider()`](#geteip1193provideroptions-eip1193providerinterface)
13
+ - [`derivePrfSecret()`](#deriveprfsecrettag-string-promiseprfsecret)
13
14
  - [Stealth Addresses (ERC-5564)](#stealth-addresses-erc-5564)
14
15
  - [Zero-Knowledge Proofs](#zero-knowledge-proofs)
15
16
  - [Backup & Recovery](#backup--recovery)
@@ -43,7 +44,7 @@ interface Web3PasskeyConfig {
43
44
  debug?: boolean; // Enable debug mode (default: false)
44
45
  onError?: (error: Web3PasskeyError) => void; // Global error handler
45
46
  onAuthStateChanged?: (isAuthenticated: boolean, user?: UserInfo) => void; // Auth state callback
46
- storage?: Storage; // Custom storage backend (default: IndexedDB)
47
+ storage?: Storage; // Custom storage backend (default: localStorage)
47
48
  sessionDuration?: number; // Session duration in hours (default: 1)
48
49
  persistentSession?: Partial<PersistentSessionConfig>; // Enable "Remember Me" functionality
49
50
  stealthAddresses?: StealthAddressConfig; // Enable stealth addresses
@@ -57,7 +58,7 @@ interface PersistentSessionConfig {
57
58
  }
58
59
  ```
59
60
 
60
- **PRF requirement:** persistent sessions are encrypted under a key derived from the WebAuthn PRF extension, which the authenticator only releases during a user-verified assertion. On authenticators without PRF support, no persistent session is stored — the device falls back to in-memory sessions only (there is no weaker at-rest fallback). The session is re-keyed at every real (prompted) login; the configured `duration` is therefore also the renewal interval. With `requireReauth: true` the decryption key is never written to disk; with `requireReauth: false` it is stored as a non-extractable `CryptoKey` to allow silent restore. See [SECURITY.md](./SECURITY.md#at-rest-encryption-what-it-protects-and-what-it-doesnt) for the full model.
61
+ **PRF requirement:** persistent sessions are encrypted under a key derived from the WebAuthn PRF extension, which the authenticator only releases during a user-verified assertion. On authenticators without PRF support, no persistent session is stored — the device falls back to in-memory sessions only (there is no weaker at-rest fallback). The session is re-keyed at every real (prompted) login; the configured `duration` is therefore also the renewal interval. With `requireReauth: true` the decryption key is never written to disk; with `requireReauth: false` it is stored next to the blob in localStorage to allow silent restore. See [SECURITY.md](./SECURITY.md#at-rest-encryption-what-it-protects-and-what-it-doesnt) for the full model.
61
62
 
62
63
  **Example:**
63
64
 
@@ -114,6 +115,8 @@ Register a new user with WebAuthn passkey authentication.
114
115
  interface RegisterResult {
115
116
  address: string; // Ethereum address (account #0 derived from BIP44)
116
117
  username: string; // Registered username
118
+ hasBackup: boolean; // false for a new wallet: prompt for a backup now
119
+ evictionRisk: boolean; // true where the browser may wipe the wallet (WebKit tab, storage not persisted)
117
120
  }
118
121
  ```
119
122
 
@@ -122,17 +125,21 @@ interface RegisterResult {
122
125
  2. Creates WebAuthn credential (triggers biometric prompt)
123
126
  3. Derives Ethereum wallet from mnemonic (path: m/44'/60'/0'/0/0)
124
127
  4. Encrypts mnemonic using WebAuthn credential (AES-GCM-256)
125
- 5. Stores encrypted wallet in IndexedDB
128
+ 5. Stores encrypted wallet in localStorage, next to the credential metadata
126
129
  6. Starts 1-hour session with decrypted mnemonic in memory
127
- 7. Returns Ethereum address and username
130
+ 7. Returns Ethereum address, username, whether a backup exists and whether the browser may wipe the wallet
128
131
 
129
132
  **Example:**
130
133
 
131
134
  ```typescript
132
- const { address, username } = await w3pk.register({
135
+ const { address, username, hasBackup, evictionRisk } = await w3pk.register({
133
136
  username: 'alice'
134
137
  })
135
138
  console.log('Registered:', username, 'with address:', address)
139
+
140
+ if (!hasBackup) {
141
+ showBackupPrompt({ blocking: evictionRisk }) // see getBackupStatus()
142
+ }
136
143
  ```
137
144
 
138
145
  ---
@@ -153,14 +160,20 @@ interface UserInfo {
153
160
  ```
154
161
 
155
162
  **What happens:**
156
- 1. Retrieves credential ID from IndexedDB
163
+ 1. Retrieves credential ID from localStorage
157
164
  2. Triggers WebAuthn authentication (biometric prompt)
158
- 3. Retrieves encrypted wallet from IndexedDB
165
+ 3. Retrieves encrypted wallet from localStorage
159
166
  4. Decrypts mnemonic using WebAuthn signature
160
167
  5. Starts new session (1 hour by default)
161
168
  6. Returns user info
162
169
  7. Subsequent operations use cached session - no repeated biometric prompts
163
170
 
171
+ **Throws:**
172
+ - `SyncRequiredError` if the passkey authenticated but this device has no credential metadata for it (carries `credentialId`); start a sync with `syncWalletWithPasskey()` and a password backup
173
+ - `WalletMissingError` if the credential metadata is here but the wallet record is gone (carries `credentialId` and `ethereumAddress`); restore with `syncWalletWithPasskey()` or `importFromSync()`
174
+
175
+ In both cases the user is not logged in and `onAuthStateChanged(true, …)` is not called.
176
+
164
177
  **Example:**
165
178
 
166
179
  ```typescript
@@ -218,6 +231,23 @@ if (currentUser) {
218
231
 
219
232
  ---
220
233
 
234
+ ### `storagePersisted: boolean | null` (property)
235
+
236
+ Whether the browser granted persistent storage. w3pk calls `navigator.storage.persist()` after each register, login and restore without waiting for it. `true` means the origin's storage is exempt from storage-pressure eviction. `null` until the browser answers (Firefox asks the user), or where the API is missing.
237
+
238
+ Persisted storage still doesn't survive Safari's 7-day cap, clearing site data or private browsing: see [SECURITY.md → Storage durability](./SECURITY.md#storage-durability-what-can-still-wipe-the-wallet).
239
+
240
+ **Example:**
241
+
242
+ ```typescript
243
+ await w3pk.login()
244
+ if (w3pk.storagePersisted === false) {
245
+ console.warn('Storage may be evicted: make sure the user has a backup')
246
+ }
247
+ ```
248
+
249
+ ---
250
+
221
251
  ### `hasExistingCredential(): Promise<boolean>`
222
252
 
223
253
  Check if there are existing credentials (wallets) on this device.
@@ -772,6 +802,7 @@ interface SignatureResult {
772
802
  - Uses EIP-191 prefix (for EOA accounts)
773
803
  - See https://docs.login.xyz for message format
774
804
  - Use `createSiweMessage()` helper for proper formatting
805
+ - Not supported in PRIMARY mode (throws): use STANDARD, STRICT or YOLO
775
806
 
776
807
  **EIP-712 Signing:**
777
808
  - Signs structured typed data according to EIP-712 standard
@@ -1336,6 +1367,61 @@ await accountAbstractionContract.verifyWebAuthnSignature({
1336
1367
 
1337
1368
  ---
1338
1369
 
1370
+ ### `derivePrfSecret(tag: string): Promise<PrfSecret>`
1371
+
1372
+ Derive a 32-byte secret bound to the passkey, separate per origin and tag, for an identity in an anonymous protocol or a per-feature encryption key.
1373
+
1374
+ It uses the [WebAuthn PRF extension](https://w3c.github.io/webauthn/#prf-extension): the authenticator holds a per-credential secret and releases `PRF(input)` only on a live, user-verified assertion. The secret is never derived from or stored with the mnemonic.
1375
+
1376
+ **Parameters:**
1377
+ - `tag: string` - Non-empty label without `:`, such as `"zk-identity"`. Case-sensitive.
1378
+
1379
+ **Returns:** `PrfSecret`
1380
+
1381
+ ```typescript
1382
+ interface PrfSecret {
1383
+ secret: Uint8Array; // 32 bytes
1384
+ credentialId: string; // passkey the secret is bound to
1385
+ }
1386
+ ```
1387
+
1388
+ **What happens:**
1389
+ 1. Prompts for the passkey with `userVerification: "required"`; when logged in, only the current user's passkey is allowed, otherwise the user picks one
1390
+ 2. Evaluates the PRF at `SHA-256("w3pk-prf-v1:" + origin + ":" + tag)`, with `origin` the normalized current origin (the browser further hashes it with the `"WebAuthn PRF"` context)
1391
+ 3. Returns `HKDF-SHA256(PRF output, salt "w3pk-prf-secret-v1", info "w3pk-prf-v1:" + origin + ":" + tag)`, 32 bytes
1392
+
1393
+ The same passkey, origin and tag always give the same secret. A different tag, origin or passkey gives an unrelated one. Store `credentialId` next to whatever the secret protects: when it changes (a new passkey), so does the secret.
1394
+
1395
+ **Example:**
1396
+
1397
+ ```typescript
1398
+ import { PrfUnsupportedError } from 'w3pk'
1399
+
1400
+ try {
1401
+ const { secret, credentialId } = await w3pk.derivePrfSecret('zk-identity')
1402
+ // Use the secret, keep it in memory only
1403
+ } catch (error) {
1404
+ if (error instanceof PrfUnsupportedError) {
1405
+ // This authenticator can't provide a passkey-bound secret
1406
+ }
1407
+ }
1408
+ ```
1409
+
1410
+ **Throws:**
1411
+ - `PrfUnsupportedError` (`code: 'PRF_UNSUPPORTED'`, with `credentialId`) if the authenticator returns no PRF output. There is no fallback.
1412
+ - `AuthenticationError` if the prompt is cancelled or the passkey isn't available
1413
+ - `CryptoError` if `tag` is empty or contains `:`
1414
+
1415
+ **Threat model:**
1416
+ - **Not in backups.** The secret depends only on the authenticator's PRF, so backup files, recovery phrases, social recovery shares and sync exports don't contain it, and their leak doesn't leak it.
1417
+ - **Not stored.** w3pk never writes the secret or the PRF output to localStorage or sessions, and zeroes its copy of the PRF output after the derivation. Each call prompts the user. If the app stores the secret, its storage is the weakest link.
1418
+ - **Bound to the passkey, not the wallet.** Restoring the wallet from a backup with a different passkey gives a different secret. The secret survives only as long as the passkey: a synced passkey (iCloud Keychain, Google Password Manager) carries it to other devices, a deleted or device-bound passkey loses it. Anything encrypted under it must be recoverable another way.
1419
+ - **Origin separation is w3pk's, not the browser's.** Passkeys are scoped to the RP ID (the hostname), so another origin on the same hostname (another port or scheme) can use the same passkey. The origin in the PRF input keeps `derivePrfSecret()` from giving one origin another's secret, but a script running on such an origin can evaluate the PRF itself with any input. Serve one app per hostname when that matters.
1420
+ - **Script on the origin.** Code running on the page (XSS, a compromised dependency) can call `derivePrfSecret()` or the PRF directly and get the secret after the user approves a prompt. User verification bounds it to a user action, not to the app's intent.
1421
+ - **Separate from the persistent session key.** That key uses a different PRF input, so no tag gives it.
1422
+
1423
+ ---
1424
+
1339
1425
  ### `sendTransaction(tx, options?): Promise<TransactionResult>`
1340
1426
 
1341
1427
  Send an on-chain transaction using the wallet derived for the active security mode.
@@ -2243,7 +2329,13 @@ Get comprehensive backup status showing what protects the wallet.
2243
2329
  **Returns:**
2244
2330
 
2245
2331
  ```typescript
2332
+ type BackupMethod = 'file' | 'qr' | 'phrase';
2333
+
2246
2334
  interface BackupStatus {
2335
+ hasBackup: boolean; // a backup file, QR code or confirmed phrase exists
2336
+ evictionRisk: boolean; // the browser may wipe the wallet: WebKit outside a home-screen app, storage not persisted
2337
+ lastBackupAt?: string; // ISO 8601 time of the latest backup
2338
+ methods: BackupMethod[];
2247
2339
  passkeySync: PasskeySyncStatus;
2248
2340
  recoveryPhrase: RecoveryPhraseStatus;
2249
2341
  socialRecovery?: SocialRecoveryStatus;
@@ -2251,18 +2343,42 @@ interface BackupStatus {
2251
2343
  }
2252
2344
  ```
2253
2345
 
2346
+ Browser storage can be wiped (eviction, Safari's 7-day cap, clearing site data), so a wallet without a backup can be lost for good. While `hasBackup` is `false`, the security score is capped at 20 and its level is `vulnerable`.
2347
+
2348
+ `evictionRisk` is computed on each call from `navigator.storage.persisted()` and the display mode: `true` on WebKit (every iOS browser, Safari and other WebKit browsers on desktop) in a tab whose storage is not persisted, `false` in a home-screen app, once storage is persisted, and on Chromium and Firefox.
2349
+
2254
2350
  **Example:**
2255
2351
 
2256
2352
  ```typescript
2257
2353
  const status = await w3pk.getBackupStatus()
2258
- console.log('Security Score:', status.securityScore.score) // 0-100
2259
- console.log('Passkey sync:', status.passkeySync.enabled)
2260
- console.log('Recovery phrase:', status.recoveryPhrase.status)
2354
+ if (!status.hasBackup && status.evictionRisk) {
2355
+ showBackupGate() // blocking; on iOS, suggest "Add to Home Screen"
2356
+ } else if (!status.hasBackup) {
2357
+ showBackupWarning()
2358
+ }
2359
+ console.log('Security Score:', status.securityScore.total) // 0-100
2360
+ console.log('Level:', status.securityScore.level)
2361
+ console.log('Backup methods:', status.methods)
2261
2362
  console.log('Social recovery:', status.socialRecovery?.enabled)
2262
2363
  ```
2263
2364
 
2264
2365
  ---
2265
2366
 
2367
+ ### `confirmRecoveryPhraseBackup(): Promise<void>`
2368
+
2369
+ Record that the user wrote down their recovery phrase. Call it once the user confirms, so `getBackupStatus()` reports `hasBackup: true` with the `phrase` method.
2370
+
2371
+ **Example:**
2372
+
2373
+ ```typescript
2374
+ await showRecoveryPhrase()
2375
+ if (await userConfirmsPhraseWrittenDown()) {
2376
+ await w3pk.confirmRecoveryPhraseBackup()
2377
+ }
2378
+ ```
2379
+
2380
+ ---
2381
+
2266
2382
  ### `createBackupFile(encryptionType?: 'password' | 'passkey' | 'hybrid', password?: string): Promise<{ blob: Blob; filename: string }>`
2267
2383
 
2268
2384
  Create simplified backup file for wallet recovery.
@@ -2621,14 +2737,17 @@ console.log('Estimated devices:', capabilities.estimatedDevices)
2621
2737
 
2622
2738
  ---
2623
2739
 
2624
- ### `exportForSync(): Promise<{ blob: Blob; filename: string; qrCode?: string }>`
2740
+ ### `exportForSync(password?: string): Promise<{ blob: Blob; filename: string; qrCode?: string }>`
2625
2741
 
2626
2742
  Export wallet for syncing to another device.
2627
2743
 
2628
- Uses passkey encryption so it works on devices where the passkey is synced.
2744
+ Runs a fresh assertion restricted to the current passkey and encrypts the mnemonic with a key derived from its [WebAuthn PRF](https://w3c.github.io/webauthn/#prf-extension) output (`encryptionMethod: 'prf'`). Any device where the passkey synced restores the file with one prompt and no password, including a fresh device.
2629
2745
 
2630
2746
  **Use case:** User wants to sync wallet to another device that has the same passkey.
2631
2747
 
2748
+ **Parameters:**
2749
+ - `password?: string` - Fallback when the authenticator has no PRF support: the file is then a password backup that names its passkey, to restore with `syncWalletWithPasskey(syncData, password)`
2750
+
2632
2751
  **Returns:**
2633
2752
 
2634
2753
  ```typescript
@@ -2639,7 +2758,11 @@ Uses passkey encryption so it works on devices where the passkey is synced.
2639
2758
  }
2640
2759
  ```
2641
2760
 
2642
- **Security:** Forces fresh authentication
2761
+ **File format (`prf`):** `encryptedMnemonic`, `prfSalt` (32 random bytes per file, base64url), `credential: { id, publicKey }`, `username`, `addressChecksum`. Key: HKDF-SHA256 over the PRF output evaluated at `SHA-256("w3pk-prf-sync-v1" || prfSalt)`, then AES-256-GCM. The public key doesn't decrypt the file; a fresh device needs it for `login()` and checks it against the assertion's signature.
2762
+
2763
+ **Security:** The PRF assertion is the fresh user verification; with an active session, export takes one prompt.
2764
+
2765
+ **Browser support:** PRF works in Chrome 108+ and Safari 17+ / iOS 17+, not in Firefox. Passkeys sync within one ecosystem only (Apple or Google); across ecosystems, use a password backup.
2643
2766
 
2644
2767
  **Example:**
2645
2768
 
@@ -2661,13 +2784,18 @@ if (qrCode) {
2661
2784
  }
2662
2785
  ```
2663
2786
 
2787
+ **Throws:**
2788
+ - `WalletError` if not logged in
2789
+ - `PrfUnsupportedError` if the authenticator returns no PRF output and no `password` was given
2790
+ - `AuthenticationError` if the assertion fails or is cancelled
2791
+
2664
2792
  ---
2665
2793
 
2666
2794
  ### `importFromSync(syncData: string | Blob): Promise<{ ethereumAddress: string; success: boolean }>`
2667
2795
 
2668
- Import wallet from another device (sync wallet to this device).
2796
+ Import a PRF sync file from `exportForSync()`. No prior `login()` and no password required: one assertion restricted to the file's passkey decrypts it.
2669
2797
 
2670
- **Use case:** User has passkey on both devices, wallet only on one.
2798
+ **Use case:** User has the passkey on both devices (synced through iCloud Keychain or Google Password Manager) and the wallet on one. Works on a fresh device with no data for the passkey: the SDK checks the file's public key against the assertion's signature, saves the passkey's metadata, stores the wallet, starts a session and fires `onAuthStateChanged(true, user)`.
2671
2799
 
2672
2800
  **Parameters:**
2673
2801
  - `syncData: string | Blob` - Sync file data
@@ -2684,23 +2812,29 @@ Import wallet from another device (sync wallet to this device).
2684
2812
  **Example:**
2685
2813
 
2686
2814
  ```typescript
2687
- // User must be logged in first
2688
- await w3pk.login()
2689
-
2690
- // Import from sync file
2691
- const syncData = '...' // From file upload or QR scan
2815
+ const syncData = '...' // From exportForSync(), by file upload or QR scan
2692
2816
 
2693
2817
  const result = await w3pk.importFromSync(syncData)
2694
2818
  console.log('Wallet synced:', result.ethereumAddress)
2695
2819
  ```
2696
2820
 
2821
+ **Throws:**
2822
+ - `WalletError` if the file isn't a PRF sync file (use `syncWalletWithPasskey()` for other backups, including `exportForSync(password)`'s fallback)
2823
+ - `SyncBootstrapError` with `reason: 'CREDENTIAL_MISMATCH'` if logged in with another passkey than the file's
2824
+ - `SyncBootstrapError` with `reason: 'PUBLIC_KEY_MISMATCH'` if the file's public key doesn't match the passkey's signature
2825
+ - `AuthenticationError` if the passkey isn't on this device or the prompt is cancelled
2826
+ - `PrfUnsupportedError` if the authenticator returns no PRF output
2827
+ - `CryptoError` or `Error` if the file was altered (ciphertext, address or address checksum)
2828
+
2697
2829
  ---
2698
2830
 
2699
2831
  ### `syncWalletWithPasskey(backupData: string | Blob, password?: string): Promise<{ mnemonic: string; ethereumAddress: string }>`
2700
2832
 
2701
2833
  Sync wallet to this device using an existing cloud-synced passkey and a backup file. No prior session required.
2702
2834
 
2703
- **Use case:** New device that already has a passkey synced via iCloud/Google Password Manager. The user provides their backup file; the SDK prompts them to select the passkey, decrypts the backup, stores wallet data locally, and starts a session.
2835
+ **Use case:** New device that already has a passkey synced via iCloud/Google Password Manager. The user provides their backup file; the SDK prompts them to select the passkey, decrypts the backup, saves the passkey's metadata if this device has none, stores wallet data locally, starts a session and fires `onAuthStateChanged(true, user)`.
2836
+
2837
+ A PRF sync file from `exportForSync()` takes no password: it's restored as `importFromSync()` does, with one assertion restricted to its passkey. On a device with no data for the passkey, the other backups that work are password backups made by a logged-in user: they name their passkey (`credential: { id, publicKey }`), and the username comes from the passkey's user handle. In both cases the backup's public key is checked against the assertion's signature.
2704
2838
 
2705
2839
  **Parameters:**
2706
2840
  - `backupData: string | Blob` - Backup file content (JSON string or Blob)
@@ -2708,26 +2842,25 @@ Sync wallet to this device using an existing cloud-synced passkey and a backup f
2708
2842
 
2709
2843
  **Returns:** `{ mnemonic: string; ethereumAddress: string }`
2710
2844
 
2711
- **Supported encryption methods:** `password`, `passkey`, `hybrid`
2845
+ **Supported encryption methods:** `prf` and `password` everywhere; `passkey` and `hybrid` only where this device already has the passkey's metadata
2712
2846
 
2713
2847
  **Example:**
2714
2848
 
2715
2849
  ```typescript
2716
- // Passkey-encrypted backup (no password needed)
2717
- const result = await w3pk.syncWalletWithPasskey(backupData)
2850
+ const result = await w3pk.syncWalletWithPasskey(backupData, 'MyPassword123!')
2718
2851
  console.log('Synced wallet address:', result.ethereumAddress)
2719
-
2720
- // Password or hybrid backup
2721
- const result2 = await w3pk.syncWalletWithPasskey(backupData, 'MyPassword123!')
2722
- console.log('Synced wallet address:', result2.ethereumAddress)
2723
2852
  ```
2724
2853
 
2725
2854
  **Throws:**
2726
2855
  - `WalletError` if password is required but not provided
2727
- - `WalletError` if passkey public key is not available on this device
2728
2856
  - `WalletError` if backup uses an unknown encryption method
2857
+ - `SyncBootstrapError` on a device with no data for the passkey (carries `reason` and `credentialId`):
2858
+ - `'PASSKEY_BACKUP'`: passkey or hybrid backup
2859
+ - `'NO_CREDENTIAL'`: password backup that doesn't name its passkey (made before 0.10.4)
2860
+ - `'CREDENTIAL_MISMATCH'`: the backup names another passkey than the one selected
2861
+ - `'PUBLIC_KEY_MISMATCH'`: the backup's public key doesn't match the passkey's signature
2729
2862
 
2730
- **Note:** Differs from `importFromSync()` in that it does not require a prior `login()` call and accepts any backup encryption method, not just passkey-encrypted sync exports.
2863
+ **Note:** Differs from `importFromSync()` in that it also accepts password, passkey and hybrid backups.
2731
2864
 
2732
2865
  ---
2733
2866
 
@@ -2736,7 +2869,7 @@ console.log('Synced wallet address:', result2.ethereumAddress)
2736
2869
  w3pk provides two types of sessions:
2737
2870
 
2738
2871
  1. **In-Memory Sessions** (default): Cached in RAM, cleared on page refresh
2739
- 2. **Persistent Sessions** (opt-in): Encrypted in IndexedDB, survives page refresh
2872
+ 2. **Persistent Sessions** (opt-in): Encrypted in localStorage, survives page refresh
2740
2873
 
2741
2874
  **Security Modes & Persistence:**
2742
2875
  - **STANDARD mode**: Persistent sessions allowed ✅
@@ -2844,14 +2977,14 @@ Enable persistent sessions to maintain user login across page refreshes.
2844
2977
 
2845
2978
  **How it works:**
2846
2979
  1. On login, mnemonic is encrypted with WebAuthn-derived key
2847
- 2. Encrypted session stored in IndexedDB
2980
+ 2. Encrypted session stored in localStorage
2848
2981
  3. On page refresh, session is automatically restored (if not expired)
2849
2982
  4. `requireReauth: true` prompts for biometric on refresh (more secure)
2850
2983
  5. `requireReauth: false` silently restores session (more convenient)
2851
2984
 
2852
2985
  **Security (read the trade-off):**
2853
2986
  - Sessions only persist for STANDARD and YOLO modes; STRICT mode is NEVER persisted
2854
- - Time-limited expiration; origin-isolated via IndexedDB
2987
+ - Time-limited expiration; origin-isolated via localStorage
2855
2988
  - ⚠️ The "WebAuthn-derived key" is derived from **public** credential metadata stored in the same browser profile, **not** from an authenticator-held secret. A persistent session is therefore **decryptable by anyone who can read this origin's storage** (malicious extension, XSS exfil, disk image), and it keeps the seed in that state for the whole duration it's enabled. This is a UX-for-security trade-off, not hardware-backed protection.
2856
2989
  - Enabling "Remember Me" does not add a new trust dependency (you already trust your origin's code), it **widens the window** in which a compromise of your origin can reach the seed. Keep `duration` short for higher-value wallets; don't enable on shared/untrusted devices.
2857
2990
  - Full threat model: **[SECURITY.md → At-Rest Encryption](./SECURITY.md#at-rest-encryption-what-it-protects-and-what-it-doesnt)**.
@@ -3389,9 +3522,10 @@ Encrypt data using ML-KEM-1024 (post-quantum KEM) + AES-256-GCM for one or more
3389
3522
  **Returns:**
3390
3523
  ```typescript
3391
3524
  {
3525
+ version: 2; // Payload format (absent on legacy v1 payloads)
3392
3526
  recipients: Array<{
3393
3527
  publicKey: string; // Base64 recipient public key (1568 bytes)
3394
- ciphertext: string; // Base64 ML-KEM ciphertext for this recipient (1600 bytes)
3528
+ ciphertext: string; // Base64 ML-KEM ciphertext + AES-KW wrapped key (1608 bytes)
3395
3529
  }>;
3396
3530
  encryptedData: string; // Base64 AES-encrypted data (shared across all recipients)
3397
3531
  iv: string; // Base64 initialization vector (12 bytes)
@@ -3887,7 +4021,11 @@ import {
3887
4021
  WalletError,
3888
4022
  CryptoError,
3889
4023
  StorageError,
3890
- ApiError
4024
+ ApiError,
4025
+ SyncRequiredError,
4026
+ WalletMissingError,
4027
+ SyncBootstrapError,
4028
+ PrfUnsupportedError
3891
4029
  } from 'w3pk'
3892
4030
  ```
3893
4031
 
@@ -3898,18 +4036,33 @@ import {
3898
4036
  - `RegistrationError` - Registration failures
3899
4037
  - `WalletError` - Wallet operations (derivation, signing)
3900
4038
  - `CryptoError` - Cryptographic operations (encryption, decryption)
3901
- - `StorageError` - Storage operations (IndexedDB)
4039
+ - `StorageError` - Storage operations (localStorage)
3902
4040
  - `ApiError` - API/network errors (RPC, chainlist)
4041
+ - `SyncRequiredError` - `login()`: passkey authenticated, but this device has no credential metadata (`credentialId`)
4042
+ - `WalletMissingError` - `login()`: credential metadata found, but the wallet record is gone (`credentialId`, `ethereumAddress`)
4043
+ - `SyncBootstrapError` - `syncWalletWithPasskey()`, `importFromSync()`: the backup can't set this device up for the passkey (`reason`, `credentialId`)
4044
+ - `PrfUnsupportedError` - `derivePrfSecret()`, `exportForSync()`, `importFromSync()`: the authenticator returned no WebAuthn PRF output (`credentialId`)
3903
4045
 
3904
4046
  ### Error Handling
3905
4047
 
3906
4048
  ```typescript
3907
- import { AuthenticationError, WalletError } from 'w3pk'
4049
+ import {
4050
+ AuthenticationError,
4051
+ WalletError,
4052
+ SyncRequiredError,
4053
+ WalletMissingError
4054
+ } from 'w3pk'
3908
4055
 
3909
4056
  try {
3910
4057
  await w3pk.login()
3911
4058
  } catch (error) {
3912
- if (error instanceof AuthenticationError) {
4059
+ if (error instanceof SyncRequiredError) {
4060
+ // Passkey synced to this device, wallet data isn't: ask for the password backup
4061
+ await w3pk.syncWalletWithPasskey(backupFile, password)
4062
+ } else if (error instanceof WalletMissingError) {
4063
+ // Metadata here, wallet record gone: any backup of this passkey restores it
4064
+ await w3pk.syncWalletWithPasskey(backupFile, password)
4065
+ } else if (error instanceof AuthenticationError) {
3913
4066
  console.error('Authentication failed:', error.message)
3914
4067
  } else if (error instanceof WalletError) {
3915
4068
  console.error('Wallet error:', error.message)
@@ -105,7 +105,7 @@ interface WebAuthnCredential {
105
105
  - Standards: BIP32, BIP39, BIP44, EIP-155
106
106
  - Curve: secp256k1 (Bitcoin/Ethereum standard)
107
107
  - Algorithm: ECDSA + Keccak-256
108
- - Storage: IndexedDB (AES-256-GCM encrypted)
108
+ - Storage: localStorage (AES-256-GCM encrypted)
109
109
  - Managed by: Application (w3pk)
110
110
 
111
111
  **Capabilities**:
@@ -231,10 +231,10 @@ STEP 3: Derive Encryption Key
231
231
 
232
232
  STEP 4: Encrypt & Store
233
233
  ┌────────────────────────────┬────────────────────────────┐
234
- │ localStorage │ IndexedDB │
234
+ │ localStorage │ localStorage │
235
235
  │ ───────────── │ ───────────── │
236
236
  │ │ │
237
- │ w3pk_credential_*: │ Web3PasskeyWallet: │
237
+ │ w3pk_credential_*: │ w3pk_wallet_<address>: │
238
238
  │ { │ { │
239
239
  │ id: "hash(abc123)", │ ethereumAddress, │
240
240
  │ publicKey (plaintext), │ encryptedMnemonic, │
@@ -268,9 +268,9 @@ STEP 1: WebAuthn Authentication
268
268
 
269
269
  STEP 2: Fetch Stored Data
270
270
  ┌────────────────────────────┬────────────────────────────┐
271
- │ localStorage │ IndexedDB │
271
+ │ localStorage │ localStorage │
272
272
  │ │ │
273
- │ Get by credentialId: │ Get by credentialId: │
273
+ │ Get by credentialId: │ Get by address: │
274
274
  │ → publicKey │ → encryptedMnemonic │
275
275
  └─────────────┬──────────────┴──────────────┬─────────────┘
276
276
  │ │
@@ -393,15 +393,16 @@ User wants to send 1 ETH
393
393
 
394
394
  **Why publicKey is plaintext**: It's needed to derive the encryption key. It's the **public** part of the WebAuthn key pair—not sensitive.
395
395
 
396
- ### IndexedDB Structure
396
+ ### Wallet Storage
397
397
 
398
- **Database**: `Web3PasskeyWallet`
399
- **Store**: `wallets`
400
- **Key Path**: `ethereumAddress`
398
+ **Storage**: localStorage, next to the credential metadata, so browser eviction removes both or neither
399
+ **Key**: `w3pk_wallet_<ethereumAddress>`
401
400
  **Purpose**: Encrypted wallet storage
402
401
 
402
+ Persistent sessions use `w3pk_session_<ethereumAddress>` and backup metadata `w3pk_backups`, in the same localStorage.
403
+
403
404
  ```typescript
404
- // Example record in IndexedDB
405
+ // Example record in localStorage
405
406
  {
406
407
  ethereumAddress: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7",
407
408
  encryptedMnemonic: "U2FsdGVkX1+vupppZksvRf...", // AES-256-GCM blob
@@ -563,7 +564,7 @@ Layer 2: Encryption Key Derivation
563
564
  Layer 3: Encrypted Mnemonic Storage
564
565
  ┌─────────────────────────────────────┐
565
566
  │ ✅ AES-256-GCM encryption │
566
- │ ✅ IndexedDB storage │
567
+ │ ✅ localStorage storage │
567
568
  │ ✅ Cannot decrypt without Layers 1&2│
568
569
  └─────────────────────────────────────┘
569
570
  ↓ REVEALS
@@ -749,7 +750,7 @@ Passkey (Face ID) → Decrypt mnemonic → Use wallet
749
750
  |---------|----------|---------------|------------|
750
751
  | **Curve** | P-256 | secp256k1 | N/A (uses AES) |
751
752
  | **Purpose** | Authentication | Transactions | Encryption |
752
- | **Storage** | Secure Enclave | IndexedDB | None (derived) |
753
+ | **Storage** | Secure Enclave | localStorage | None (derived) |
753
754
  | **Backup** | iCloud/Google | 12-word phrase | N/A |
754
755
  | **Standards** | FIDO2, W3C | BIP32/39/44 | PBKDF2, AES-GCM |
755
756
  | **Managed By** | OS | App | App |
@@ -808,7 +809,7 @@ Scenarios:
808
809
  }
809
810
  ```
810
811
 
811
- ### What's in IndexedDB?
812
+ ### What's in `w3pk_wallet_<address>`?
812
813
 
813
814
  ```json
814
815
  {
@@ -97,6 +97,30 @@ This document details WebAuthn support across browsers, operating systems, and d
97
97
  | **VirtualBox/VMs** | No biometric passthrough | Use password fallback |
98
98
  | **Remote Desktop** | Biometrics not forwarded | Use local device |
99
99
 
100
+ #### 🗑️ **Storage That Can Be Wiped**
101
+
102
+ The encrypted wallet, persistent sessions and backup metadata live in localStorage, next to the credential metadata, so they are either all there or all gone. The passkey itself lives in the authenticator and survives all of the cases below, but the wallet does not: the mnemonic is random and can't be rebuilt from the passkey. Users need a backup file or the recovery phrase.
103
+
104
+ | Environment | What happens | Mitigation |
105
+ |-------------|--------------|------------|
106
+ | **Safari (macOS, iOS)** | localStorage deleted after 7 days of browser use without the user interacting with the site | Add the web app to the Home Screen (exempt), keep a backup |
107
+ | **Any browser: clearing site data** | localStorage deleted with cookies and site data | Keep a backup |
108
+ | **Private/Incognito windows** | localStorage discarded when the window closes | Don't use for a long-lived wallet |
109
+ | **Chrome/Firefox under storage pressure** | Origins without persistent storage can be evicted | w3pk calls `navigator.storage.persist()`; check `w3pk.storagePersisted` |
110
+
111
+ Chrome grants persistent storage from engagement heuristics (bookmarked, installed, frequently visited) without a prompt; Firefox asks the user; Safari doesn't exempt sites from its 7-day cap. See [SECURITY.md → Storage durability](./SECURITY.md#storage-durability-what-can-still-wipe-the-wallet).
112
+
113
+ `register()` and `getBackupStatus()` return `evictionRisk`, computed on each call:
114
+
115
+ | Environment | `evictionRisk` |
116
+ |-------------|----------------|
117
+ | **WebKit tab, storage not persisted** (every iOS browser, Safari and other WebKit browsers on desktop) | `true` |
118
+ | **WebKit home-screen app** (`display-mode: standalone` or `navigator.standalone`) | `false` |
119
+ | **WebKit, `navigator.storage.persisted()` is `true`** | `false` |
120
+ | **Chromium, Firefox** | `false` |
121
+
122
+ Block on a backup while `!hasBackup && evictionRisk`, and show a dismissible banner on `!hasBackup` otherwise. See [Integration Guidelines → Backup & Recovery](./INTEGRATION_GUIDELINES.md#backup--recovery).
123
+
100
124
  ### Hardware Security Keys
101
125
 
102
126
  | Key Type | CTAP Version | w3pk Support | Release Date |