w3pk 0.10.3 → 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)
@@ -115,6 +116,7 @@ interface RegisterResult {
115
116
  address: string; // Ethereum address (account #0 derived from BIP44)
116
117
  username: string; // Registered username
117
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)
118
120
  }
119
121
  ```
120
122
 
@@ -125,18 +127,18 @@ interface RegisterResult {
125
127
  4. Encrypts mnemonic using WebAuthn credential (AES-GCM-256)
126
128
  5. Stores encrypted wallet in localStorage, next to the credential metadata
127
129
  6. Starts 1-hour session with decrypted mnemonic in memory
128
- 7. Returns Ethereum address, username and whether a backup exists
130
+ 7. Returns Ethereum address, username, whether a backup exists and whether the browser may wipe the wallet
129
131
 
130
132
  **Example:**
131
133
 
132
134
  ```typescript
133
- const { address, username, hasBackup } = await w3pk.register({
135
+ const { address, username, hasBackup, evictionRisk } = await w3pk.register({
134
136
  username: 'alice'
135
137
  })
136
138
  console.log('Registered:', username, 'with address:', address)
137
139
 
138
140
  if (!hasBackup) {
139
- showBackupPrompt() // see getBackupStatus()
141
+ showBackupPrompt({ blocking: evictionRisk }) // see getBackupStatus()
140
142
  }
141
143
  ```
142
144
 
@@ -167,8 +169,8 @@ interface UserInfo {
167
169
  7. Subsequent operations use cached session - no repeated biometric prompts
168
170
 
169
171
  **Throws:**
170
- - `SyncRequiredError` if the passkey authenticated but this device has no credential metadata for it (carries `credentialId`); start a sync with `syncWalletWithPasskey()`
171
- - `WalletMissingError` if the credential metadata is here but the wallet record is gone (carries `credentialId` and `ethereumAddress`); offer a restore from backup
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()`
172
174
 
173
175
  In both cases the user is not logged in and `onAuthStateChanged(true, …)` is not called.
174
176
 
@@ -1365,6 +1367,61 @@ await accountAbstractionContract.verifyWebAuthnSignature({
1365
1367
 
1366
1368
  ---
1367
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
+
1368
1425
  ### `sendTransaction(tx, options?): Promise<TransactionResult>`
1369
1426
 
1370
1427
  Send an on-chain transaction using the wallet derived for the active security mode.
@@ -2276,6 +2333,7 @@ type BackupMethod = 'file' | 'qr' | 'phrase';
2276
2333
 
2277
2334
  interface BackupStatus {
2278
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
2279
2337
  lastBackupAt?: string; // ISO 8601 time of the latest backup
2280
2338
  methods: BackupMethod[];
2281
2339
  passkeySync: PasskeySyncStatus;
@@ -2287,11 +2345,15 @@ interface BackupStatus {
2287
2345
 
2288
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`.
2289
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
+
2290
2350
  **Example:**
2291
2351
 
2292
2352
  ```typescript
2293
2353
  const status = await w3pk.getBackupStatus()
2294
- if (!status.hasBackup) {
2354
+ if (!status.hasBackup && status.evictionRisk) {
2355
+ showBackupGate() // blocking; on iOS, suggest "Add to Home Screen"
2356
+ } else if (!status.hasBackup) {
2295
2357
  showBackupWarning()
2296
2358
  }
2297
2359
  console.log('Security Score:', status.securityScore.total) // 0-100
@@ -2675,14 +2737,17 @@ console.log('Estimated devices:', capabilities.estimatedDevices)
2675
2737
 
2676
2738
  ---
2677
2739
 
2678
- ### `exportForSync(): Promise<{ blob: Blob; filename: string; qrCode?: string }>`
2740
+ ### `exportForSync(password?: string): Promise<{ blob: Blob; filename: string; qrCode?: string }>`
2679
2741
 
2680
2742
  Export wallet for syncing to another device.
2681
2743
 
2682
- 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.
2683
2745
 
2684
2746
  **Use case:** User wants to sync wallet to another device that has the same passkey.
2685
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
+
2686
2751
  **Returns:**
2687
2752
 
2688
2753
  ```typescript
@@ -2693,7 +2758,11 @@ Uses passkey encryption so it works on devices where the passkey is synced.
2693
2758
  }
2694
2759
  ```
2695
2760
 
2696
- **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.
2697
2766
 
2698
2767
  **Example:**
2699
2768
 
@@ -2715,13 +2784,18 @@ if (qrCode) {
2715
2784
  }
2716
2785
  ```
2717
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
+
2718
2792
  ---
2719
2793
 
2720
2794
  ### `importFromSync(syncData: string | Blob): Promise<{ ethereumAddress: string; success: boolean }>`
2721
2795
 
2722
- 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.
2723
2797
 
2724
- **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)`.
2725
2799
 
2726
2800
  **Parameters:**
2727
2801
  - `syncData: string | Blob` - Sync file data
@@ -2738,23 +2812,29 @@ Import wallet from another device (sync wallet to this device).
2738
2812
  **Example:**
2739
2813
 
2740
2814
  ```typescript
2741
- // User must be logged in first
2742
- await w3pk.login()
2743
-
2744
- // Import from sync file
2745
- const syncData = '...' // From file upload or QR scan
2815
+ const syncData = '...' // From exportForSync(), by file upload or QR scan
2746
2816
 
2747
2817
  const result = await w3pk.importFromSync(syncData)
2748
2818
  console.log('Wallet synced:', result.ethereumAddress)
2749
2819
  ```
2750
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
+
2751
2829
  ---
2752
2830
 
2753
2831
  ### `syncWalletWithPasskey(backupData: string | Blob, password?: string): Promise<{ mnemonic: string; ethereumAddress: string }>`
2754
2832
 
2755
2833
  Sync wallet to this device using an existing cloud-synced passkey and a backup file. No prior session required.
2756
2834
 
2757
- **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.
2758
2838
 
2759
2839
  **Parameters:**
2760
2840
  - `backupData: string | Blob` - Backup file content (JSON string or Blob)
@@ -2762,26 +2842,25 @@ Sync wallet to this device using an existing cloud-synced passkey and a backup f
2762
2842
 
2763
2843
  **Returns:** `{ mnemonic: string; ethereumAddress: string }`
2764
2844
 
2765
- **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
2766
2846
 
2767
2847
  **Example:**
2768
2848
 
2769
2849
  ```typescript
2770
- // Passkey-encrypted backup (no password needed)
2771
- const result = await w3pk.syncWalletWithPasskey(backupData)
2850
+ const result = await w3pk.syncWalletWithPasskey(backupData, 'MyPassword123!')
2772
2851
  console.log('Synced wallet address:', result.ethereumAddress)
2773
-
2774
- // Password or hybrid backup
2775
- const result2 = await w3pk.syncWalletWithPasskey(backupData, 'MyPassword123!')
2776
- console.log('Synced wallet address:', result2.ethereumAddress)
2777
2852
  ```
2778
2853
 
2779
2854
  **Throws:**
2780
2855
  - `WalletError` if password is required but not provided
2781
- - `WalletError` if passkey public key is not available on this device
2782
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
2783
2862
 
2784
- **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.
2785
2864
 
2786
2865
  ---
2787
2866
 
@@ -3944,7 +4023,9 @@ import {
3944
4023
  StorageError,
3945
4024
  ApiError,
3946
4025
  SyncRequiredError,
3947
- WalletMissingError
4026
+ WalletMissingError,
4027
+ SyncBootstrapError,
4028
+ PrfUnsupportedError
3948
4029
  } from 'w3pk'
3949
4030
  ```
3950
4031
 
@@ -3959,6 +4040,8 @@ import {
3959
4040
  - `ApiError` - API/network errors (RPC, chainlist)
3960
4041
  - `SyncRequiredError` - `login()`: passkey authenticated, but this device has no credential metadata (`credentialId`)
3961
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`)
3962
4045
 
3963
4046
  ### Error Handling
3964
4047
 
@@ -3974,10 +4057,11 @@ try {
3974
4057
  await w3pk.login()
3975
4058
  } catch (error) {
3976
4059
  if (error instanceof SyncRequiredError) {
3977
- // Passkey synced to this device, wallet data isn't: ask for the backup file
3978
- await w3pk.syncWalletWithPasskey(backupFile)
4060
+ // Passkey synced to this device, wallet data isn't: ask for the password backup
4061
+ await w3pk.syncWalletWithPasskey(backupFile, password)
3979
4062
  } else if (error instanceof WalletMissingError) {
3980
- console.error('No wallet for', error.ethereumAddress, '- restore from backup')
4063
+ // Metadata here, wallet record gone: any backup of this passkey restores it
4064
+ await w3pk.syncWalletWithPasskey(backupFile, password)
3981
4065
  } else if (error instanceof AuthenticationError) {
3982
4066
  console.error('Authentication failed:', error.message)
3983
4067
  } else if (error instanceof WalletError) {
@@ -110,6 +110,17 @@ The encrypted wallet, persistent sessions and backup metadata live in localStora
110
110
 
111
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
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
+
113
124
  ### Hardware Security Keys
114
125
 
115
126
  | Key Type | CTAP Version | w3pk Support | Release Date |
@@ -1225,13 +1225,20 @@ async function promptBackupOptions() {
1225
1225
 
1226
1226
  **Critical:** Users should set up at least one backup method immediately after registration. Browser storage can be wiped (eviction, Safari's 7-day cap, clearing site data), and a wallet without a backup is then lost for good.
1227
1227
 
1228
+ `evictionRisk` tells you whether this browser is likely to wipe the wallet: it is `true` on WebKit (every iOS browser, Safari and other WebKit browsers on desktop) in a tab whose storage is not persisted, and `false` in a home-screen app, once storage is persisted, and on Chromium and Firefox. Use it to choose how hard to push:
1229
+
1230
+ - `!hasBackup && evictionRisk`: block the app until the user backs up
1231
+ - `!hasBackup` otherwise: show a dismissible banner
1232
+ - On iOS, also suggest "Add to Home Screen": home-screen apps are exempt from Safari's 7-day cap
1233
+
1228
1234
  ```typescript
1229
1235
  // ✅ Ask for a backup right after registration
1230
- const { hasBackup } = await w3pk.register({ username })
1236
+ const { hasBackup, evictionRisk } = await w3pk.register({ username })
1231
1237
 
1232
1238
  if (!hasBackup) {
1239
+ // Blocking where the browser may wipe the wallet, dismissible otherwise
1233
1240
  // "Back up now": a backup file, or the recovery phrase written down
1234
- const choice = await showBackupNowPrompt()
1241
+ const choice = await showBackupNowPrompt({ blocking: evictionRisk })
1235
1242
 
1236
1243
  if (choice === 'file') {
1237
1244
  const { blob, filename } = await w3pk.createBackupFile('password', password)
@@ -1246,7 +1253,9 @@ if (!hasBackup) {
1246
1253
  async function checkBackup() {
1247
1254
  const status = await w3pk.getBackupStatus()
1248
1255
 
1249
- if (!status.hasBackup) {
1256
+ if (!status.hasBackup && status.evictionRisk) {
1257
+ showBackupGate() // blocking; on iOS, suggest "Add to Home Screen"
1258
+ } else if (!status.hasBackup) {
1250
1259
  showBackupWarning() // the score is capped at 20 ('vulnerable')
1251
1260
  }
1252
1261
 
@@ -9,10 +9,12 @@ This guide explains how to move, backup, and link your W3PK wallet across differ
9
9
  - [For End-Users](#for-end-users)
10
10
  - [Linking an External Wallet (MetaMask, etc.)](#linking-an-external-wallet-metamask-etc)
11
11
  - [Creating and Restoring Backups](#creating-and-restoring-backups)
12
+ - [Moving Your Wallet to a New Device](#moving-your-wallet-to-a-new-device)
12
13
  - [Extracting Your Mnemonic](#extracting-your-mnemonic)
13
14
  - [For Developers](#for-developers)
14
15
  - [Implementing External Wallet Linking](#implementing-external-wallet-linking)
15
16
  - [Implementing Backup/Restore Features](#implementing-backuprestore-features)
17
+ - [Implementing Device Sync](#implementing-device-sync)
16
18
  - [Building Mnemonic Export Tools](#building-mnemonic-export-tools)
17
19
 
18
20
  ---
@@ -99,6 +101,21 @@ Your backup contains:
99
101
 
100
102
  ---
101
103
 
104
+ ### Moving Your Wallet to a New Device
105
+
106
+ If your passkey syncs to the new device (iCloud Keychain on Apple devices, Google Password Manager on Android and Chrome), you can move your wallet without a password:
107
+
108
+ 1. **On the device that has your wallet**, choose "Sync to another device" (or similar) and confirm with your passkey
109
+ 2. **Transfer the sync file**: scan the QR code, or send the file by AirDrop, email, etc.
110
+ 3. **On the new device**, open the app, load the sync file and confirm with the same passkey
111
+ 4. **Done**: your wallet is on the new device, with the same address
112
+
113
+ The sync file can't be opened with the file alone: it also takes your passkey and your fingerprint or face. Still, keep it private and delete it once the sync is done.
114
+
115
+ **When it doesn't work**: this needs a browser that supports the passkey PRF extension (Chrome 108+, Safari 17+ / iOS 17+; not Firefox), and passkeys only sync within one ecosystem (Apple or Google). In other cases, use a password-encrypted backup: the app asks for a password when it creates the sync file, and the new device asks for the same password.
116
+
117
+ ---
118
+
102
119
  ### Extracting Your Mnemonic
103
120
 
104
121
  If you need to import your W3PK wallet into another wallet application (like MetaMask, Trust Wallet, etc.), you can extract your mnemonic phrase.
@@ -378,6 +395,55 @@ See [src/core/sdk.ts](../src/core/sdk.ts) for implementation details of `createB
378
395
 
379
396
  ---
380
397
 
398
+ ### Implementing Device Sync
399
+
400
+ `exportForSync()` makes a sync file encrypted with a key derived from the passkey's [WebAuthn PRF](https://w3c.github.io/webauthn/#prf-extension) output (`encryptionMethod: 'prf'`). Any device where the passkey synced restores it with one prompt and no password, even a fresh device with no data for the passkey.
401
+
402
+ ```typescript
403
+ // Device A, logged in: one passkey prompt
404
+ const { blob, filename, qrCode } = await sdk.exportForSync()
405
+
406
+ // Device B, logged out: one passkey prompt, restricted to the file's passkey
407
+ const { ethereumAddress } = await sdk.importFromSync(syncData)
408
+ ```
409
+
410
+ `syncWalletWithPasskey(syncData)` restores the same files and also takes password backups.
411
+
412
+ **Password fallback**: PRF works in Chrome 108+ and Safari 17+ / iOS 17+, not in Firefox. Without it, `exportForSync()` throws `PrfUnsupportedError`. Pass a password to fall back to a password backup, which the new device restores with `syncWalletWithPasskey(syncData, password)`:
413
+
414
+ ```typescript
415
+ import { PrfUnsupportedError } from 'w3pk'
416
+
417
+ let sync
418
+ try {
419
+ sync = await sdk.exportForSync()
420
+ } catch (error) {
421
+ if (!(error instanceof PrfUnsupportedError)) throw error
422
+ sync = await sdk.exportForSync(await askUserForPassword())
423
+ }
424
+ ```
425
+
426
+ Or pass the password up front: `exportForSync(password)` uses it only when PRF is unavailable. Passkeys sync within one ecosystem only (Apple or Google), so moving between ecosystems also takes a password backup.
427
+
428
+ **PRF sync file structure:**
429
+
430
+ ```json
431
+ {
432
+ "createdAt": "2026-10-25T10:30:00.000Z",
433
+ "ethereumAddress": "0x...",
434
+ "encryptedMnemonic": "base64(iv || AES-256-GCM ciphertext)",
435
+ "encryptionMethod": "prf",
436
+ "credential": { "id": "passkey credential id", "publicKey": "base64url SPKI" },
437
+ "prfSalt": "base64url, 32 random bytes",
438
+ "username": "alice",
439
+ "addressChecksum": "..."
440
+ }
441
+ ```
442
+
443
+ The key is HKDF-SHA256 over the PRF output evaluated at `SHA-256("w3pk-prf-sync-v1" || prfSalt)`. The public key doesn't decrypt anything; the new device needs it for later logins and checks it against the assertion's signature. The file fits in a QR code (`generateSyncQR()`).
444
+
445
+ ---
446
+
381
447
  ### Building Mnemonic Export Tools
382
448
 
383
449
  For advanced users who need to export their mnemonic to use in other wallets, W3PK provides a standalone extraction tool.
package/docs/RECOVERY.md CHANGED
@@ -219,70 +219,81 @@ await sdk.importMnemonic(mnemonic);
219
219
 
220
220
  **Purpose**: Sync wallet to devices where passkey is already synced
221
221
 
222
- There are two approaches depending on whether you have an existing wallet session:
222
+ There are two approaches: a PRF sync file needs no password, a password backup works everywhere.
223
223
 
224
- #### Option A: Sync with passkey + backup file (recommended for new devices)
224
+ #### Option A: PRF sync file (no password)
225
225
 
226
- Use this when you're on a new device that already has your passkey synced (e.g., via iCloud/Google Password Manager) but doesn't have the wallet data yet.
226
+ Use this when the wallet is active on Device A and the passkey synced to Device B (iCloud Keychain, Google Password Manager). Device B can be a fresh device with no data for the passkey.
227
227
 
228
+ **Export from Device A:**
228
229
  ```typescript
229
230
  const sdk = createWeb3Passkey();
231
+ await sdk.login(); // On device with wallet
230
232
 
231
- // Provide your backup file (any encryption method: password, passkey, or hybrid)
232
- const backupData = '...'; // JSON string or Blob from your backup file
233
- const { mnemonic, ethereumAddress } = await sdk.syncWalletWithPasskey(backupData);
234
- // 1. SDK prompts you to select your synced passkey
235
- // 2. Decrypts backup using the selected passkey
236
- // 3. Stores wallet data locally and starts a session ✅
233
+ const { blob, filename, qrCode } = await sdk.exportForSync();
234
+ // One passkey prompt. Transfer via QR code, AirDrop, email, etc.
237
235
  ```
238
236
 
239
- For password or hybrid backups, pass the password as a second argument:
240
-
237
+ **Import on Device B:**
241
238
  ```typescript
242
- const { mnemonic, ethereumAddress } = await sdk.syncWalletWithPasskey(backupData, 'MyPassword123!');
239
+ const sdk = createWeb3Passkey();
240
+
241
+ await sdk.importFromSync(syncData); // or sdk.syncWalletWithPasskey(syncData)
242
+ // 1. One passkey prompt, restricted to the file's passkey
243
+ // 2. Decrypts the file and checks its address checksum
244
+ // 3. Saves the passkey's metadata on this device if it's missing
245
+ // 4. Stores wallet data locally, starts a session and fires onAuthStateChanged(true, user) ✅
243
246
  ```
244
247
 
245
- **Requirement**: Passkey must be synced to this device (iCloud/Google Password Manager)
248
+ No `login()` first: on a device without the wallet, `login()` throws `SyncRequiredError` or `WalletMissingError`.
246
249
 
247
- **Supports**: All backup encryption methods (`password`, `passkey`, `hybrid`)
250
+ **How it works**: the file (`encryptionMethod: 'prf'`) is encrypted with a key derived from the passkey's [WebAuthn PRF](https://w3c.github.io/webauthn/#prf-extension) output: HKDF-SHA256 over the PRF output at `SHA-256("w3pk-prf-sync-v1" || prfSalt)`, with a random `prfSalt` per file, then AES-256-GCM. The PRF secret syncs with the passkey but never leaves the authenticator, and the authenticator releases the output only on a user-verified assertion. So the file or its QR code alone decrypts nothing: it also takes the synced passkey and a biometric. An altered address checksum, address or ciphertext is rejected.
248
251
 
249
- #### Option B: Export/import sync file (between two active devices)
252
+ The file also names its passkey (`credential: { id, publicKey }`) and carries the username. The public key is not a secret and doesn't decrypt the file. A fresh device needs it for `login()` and for the wallet key, and checks it against the assertion's signature first (`SyncBootstrapError` with `reason: 'PUBLIC_KEY_MISMATCH'` if it doesn't match).
250
253
 
251
- Use this when you have the wallet active on Device A and want to transfer to Device B.
254
+ **Fallback**: PRF works in Chrome 108+ and Safari 17+ / iOS 17+, not in Firefox. Where the authenticator has no PRF support, `exportForSync()` throws `PrfUnsupportedError`; `exportForSync(password)` emits a password backup instead, to restore with Option B. Passkeys sync within one ecosystem only (Apple or Google), so moving between ecosystems also takes Option B.
252
255
 
253
- **Export from Device A:**
254
- ```typescript
255
- const sdk = createWeb3Passkey();
256
- await sdk.login(); // On device with wallet
256
+ #### Option B: Sync with passkey + password backup
257
257
 
258
- const { blob, filename, qrCode } = await sdk.exportForSync();
259
- // Transfer via QR code, AirDrop, email, etc.
260
- ```
258
+ Use this when PRF is unavailable, or when you only have a password backup. This is also a path to take when `login()` throws `SyncRequiredError` or `WalletMissingError`.
261
259
 
262
- **Import on Device B:**
263
260
  ```typescript
264
261
  const sdk = createWeb3Passkey();
265
- await sdk.login(); // Passkey must be synced to this device
266
262
 
267
- await sdk.importFromSync(syncData);
268
- // Wallet now available on Device B, credentials stored locally ✅
263
+ const backupData = '...'; // JSON string or Blob from your password backup file
264
+ const { mnemonic, ethereumAddress } = await sdk.syncWalletWithPasskey(backupData, 'MyPassword123!');
265
+ // 1. SDK prompts you to select your synced passkey
266
+ // 2. Decrypts the backup
267
+ // 3. Saves the passkey's metadata on this device if it's missing
268
+ // 4. Stores wallet data locally, starts a session and fires onAuthStateChanged(true, user) ✅
269
269
  ```
270
270
 
271
- **Encryption**: WebAuthn-derived key → AES-256-GCM
271
+ A password backup made while logged in names its passkey (`credential: { id, publicKey }`), which is what lets a fresh device set the passkey up: an assertion doesn't carry the public key, and `login()` and the wallet key both need it. The public key is not a secret, and the fresh device checks it against the assertion's signature.
272
272
 
273
- **Use Case**: Wallet on desktop, want it on mobile (same passkey ecosystem)
273
+ **Requirement**: Passkey must be synced to this device (iCloud/Google Password Manager)
274
+
275
+ **Supports**:
276
+ - On a device with no data for the passkey: PRF files and password backups made by the passkey's user
277
+ - On a device that already has the passkey's metadata (the wallet record was lost): all backup encryption methods (`prf`, `password`, `passkey`, `hybrid`)
278
+
279
+ On a fresh device, `syncWalletWithPasskey()` and `importFromSync()` throw `SyncBootstrapError` when they can't set the passkey up:
280
+ - `reason: 'PASSKEY_BACKUP'`: passkey or hybrid backup, which can't be decrypted without the public key
281
+ - `reason: 'NO_CREDENTIAL'`: password backup that doesn't name its passkey (made before 0.10.4); make a new one on a logged-in device
282
+ - `reason: 'CREDENTIAL_MISMATCH'`: the backup names another passkey than the one selected (or than the logged-in one, for `importFromSync()`)
283
+ - `reason: 'PUBLIC_KEY_MISMATCH'`: the backup's public key doesn't match the passkey's signature
274
284
 
275
- **Requirement**: Passkey must be synced via iCloud/Google Password Manager
285
+ **Use Case**: Wallet on desktop, want it on mobile (same passkey ecosystem)
276
286
 
277
287
  **Pros:**
278
- - ✅ No password needed (passkey-encrypted backups)
288
+ - ✅ No password needed with PRF sync files
279
289
  - ✅ Leverages existing passkey sync
280
290
  - ✅ Fast and convenient
281
- - ✅ `syncWalletWithPasskey()` works without an existing session
291
+ - ✅ `syncWalletWithPasskey()` and `importFromSync()` work without an existing session
282
292
 
283
293
  **Cons:**
284
294
  - ⚠️ Requires passkey on target device
285
295
  - ⚠️ Platform-specific (iCloud or Google)
296
+ - ⚠️ PRF sync files need Chrome 108+ or Safari 17+ / iOS 17+; elsewhere, use a password backup
286
297
 
287
298
  ---
288
299
 
@@ -527,17 +538,16 @@ await w3pk.importMnemonic(mnemonic);
527
538
  ### Cross-Device Sync
528
539
 
529
540
  ```typescript
530
- // Sync to new device using existing passkey + backup file (no prior session needed)
531
- const { mnemonic, ethereumAddress } = await w3pk.syncWalletWithPasskey(backupData);
532
- // or with password for password/hybrid backups:
533
- const result = await w3pk.syncWalletWithPasskey(backupData, 'MyPassword123!');
534
-
535
- // Export for sync (Device A - requires active session)
541
+ // Export for sync (Device A - requires active session): PRF-encrypted, no password
536
542
  const { blob, qrCode } = await w3pk.exportForSync();
543
+ // Or with a password fallback where PRF is unavailable
544
+ const fallback = await w3pk.exportForSync('MyPassword123!');
545
+
546
+ // Import on Device B where the passkey synced (no login needed, no password)
547
+ await w3pk.importFromSync(syncData);
537
548
 
538
- // Import on another device (Device B - stores credentials locally)
539
- await w3pk.login(); // Must have passkey synced
540
- await w3pk.importFromSync(backupData);
549
+ // Sync with a password backup (no prior session needed)
550
+ const { mnemonic, ethereumAddress } = await w3pk.syncWalletWithPasskey(backupData, 'MyPassword123!');
541
551
  ```
542
552
 
543
553
  ### Social Recovery
@@ -747,8 +757,8 @@ Verification:
747
757
  1. Get new device (same ecosystem)
748
758
  2. Sign into cloud account (iCloud/Google) - passkey syncs automatically
749
759
  3. Go to w3pk website
750
- 4. Provide your backup file (any encryption method)
751
- 5. Call syncWalletWithPasskey(backupData) or use the app's "Sync Wallet" flow
760
+ 4. Provide your password backup file
761
+ 5. Call syncWalletWithPasskey(backupData, password) or use the app's "Sync Wallet" flow
752
762
  6. Authenticate with biometric when prompted
753
763
  7. Wallet decrypted and stored locally ✅
754
764
 
@@ -761,10 +771,10 @@ Platform-specific:
761
771
  **Option B: Export/import sync file (transfer from active device)**
762
772
 
763
773
  ```markdown
764
- 1. On Device A: export sync file via exportForSync()
774
+ 1. On Device A: export sync file via exportForSync() (one passkey prompt)
765
775
  2. Transfer file to Device B (AirDrop, QR code, etc.)
766
- 3. On Device B: sign in with synced passkey, then importFromSync(syncData)
767
- 4. Wallet decrypted ✅
776
+ 3. On Device B: importFromSync(syncData), and confirm the synced passkey when prompted
777
+ 4. Wallet decrypted ✅ (no password; needs PRF: Chrome 108+, Safari 17+ / iOS 17+, not Firefox)
768
778
  ```
769
779
 
770
780
  #### **Method 3: Using Social Recovery**
@@ -822,7 +832,7 @@ A: **No!** This is a critical distinction. Your passkey (Face ID/Touch ID creden
822
832
  A: Only if you also transferred your wallet data or created a backup. The passkey will sync automatically, allowing you to authenticate, but the encrypted wallet data needs to be restored separately from a backup. Use `syncWalletWithPasskey(backupData)` to combine your synced passkey with a backup file in a single step.
823
833
 
824
834
  **Q: What's the difference between `syncWalletWithPasskey()` and `importFromSync()`?**
825
- A: `syncWalletWithPasskey()` is for new devices with no active session — it prompts for your synced passkey and accepts any backup file (password, passkey, or hybrid encrypted). `importFromSync()` requires you to already be logged in and uses a passkey-encrypted sync export from another device.
835
+ A: `syncWalletWithPasskey()` takes any backup file and prompts for your synced passkey; on a device with no data for the passkey, that backup must be a PRF sync file or a password backup. `importFromSync()` takes only PRF sync files from `exportForSync()`, with no password. Both restore a PRF sync file the same way, and neither needs a prior `login()`.
826
836
 
827
837
  **Q: Can I use multiple backup methods?**
828
838
  A: Yes! We recommend using at least 2 methods for redundancy.
@@ -889,7 +899,7 @@ Your backup is automatically marked as "verified" when you successfully:
889
899
  - Restore from a backup file using `restoreFromBackupFile()`
890
900
  - Register a new passkey from backup using `registerWithBackupFile()`
891
901
  - Recover from guardian shares using `recoverFromGuardians()`
892
- - Import from device sync using `importFromSync()`
902
+ - Import from device sync using `importFromSync()` or `syncWalletWithPasskey()`
893
903
 
894
904
  Verification proves that your backup actually works, increasing your security score.
895
905