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.
- package/README.md +1 -8
- package/dist/chainlist/index.d.mts +96 -0
- package/dist/chainlist/index.d.ts +96 -0
- package/dist/index.d.mts +114 -29
- package/dist/index.d.ts +114 -29
- package/dist/index.js +25 -25
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +25 -25
- package/dist/index.mjs.map +1 -1
- package/dist/inspect/node.d.mts +112 -0
- package/dist/inspect/node.d.ts +112 -0
- package/dist/zk/index.js +3 -3
- package/dist/zk/index.js.map +1 -1
- package/dist/zk/index.mjs +4 -4
- package/dist/zk/index.mjs.map +1 -1
- package/dist/zk/utils.js +3 -3
- package/dist/zk/utils.js.map +1 -1
- package/dist/zk/utils.mjs +3 -3
- package/dist/zk/utils.mjs.map +1 -1
- package/docs/API_REFERENCE.md +114 -30
- package/docs/BROWSER_COMPATIBILITY.md +11 -0
- package/docs/INTEGRATION_GUIDELINES.md +12 -3
- package/docs/PORTABILITY.md +66 -0
- package/docs/RECOVERY.md +58 -48
- package/package.json +42 -25
package/docs/API_REFERENCE.md
CHANGED
|
@@ -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
|
|
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`);
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/docs/PORTABILITY.md
CHANGED
|
@@ -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
|
|
222
|
+
There are two approaches: a PRF sync file needs no password, a password backup works everywhere.
|
|
223
223
|
|
|
224
|
-
#### Option A:
|
|
224
|
+
#### Option A: PRF sync file (no password)
|
|
225
225
|
|
|
226
|
-
Use this when
|
|
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
|
-
|
|
232
|
-
|
|
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
|
-
|
|
240
|
-
|
|
237
|
+
**Import on Device B:**
|
|
241
238
|
```typescript
|
|
242
|
-
const
|
|
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
|
-
|
|
248
|
+
No `login()` first: on a device without the wallet, `login()` throws `SyncRequiredError` or `WalletMissingError`.
|
|
246
249
|
|
|
247
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
**
|
|
285
|
+
**Use Case**: Wallet on desktop, want it on mobile (same passkey ecosystem)
|
|
276
286
|
|
|
277
287
|
**Pros:**
|
|
278
|
-
- ✅ No password needed
|
|
288
|
+
- ✅ No password needed with PRF sync files
|
|
279
289
|
- ✅ Leverages existing passkey sync
|
|
280
290
|
- ✅ Fast and convenient
|
|
281
|
-
- ✅ `syncWalletWithPasskey()`
|
|
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
|
-
//
|
|
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
|
-
//
|
|
539
|
-
await w3pk.
|
|
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
|
|
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:
|
|
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()`
|
|
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
|
|