w3pk 0.10.1 → 0.10.2

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/docs/SECURITY.md CHANGED
@@ -2,19 +2,86 @@
2
2
 
3
3
  This document explains the security model of w3pk and how wallet protection works.
4
4
 
5
- ✅ **SECURITY**: Uses PRF-based encryption with authenticator-held secrets and random salts to prevent offline decryption attacks. See [PRF-based Encryption](#migration-to-prf-based-encryption) section below.
6
-
7
5
  ## Overview
8
6
 
9
7
  w3pk provides **multiple layers of security** to protect user wallets:
10
8
 
11
- 1. **WebAuthn authentication** - Biometric/PIN gating for wallet access
12
- 2. **Application isolation** - Apps cannot access master mnemonic
9
+ 1. **WebAuthn authentication** - Biometric/PIN gating for wallet access **via the SDK**
10
+ 2. **Application isolation** - Apps cannot access master mnemonic through the SDK API
13
11
  3. **Origin-specific derivation** - Each website gets unique isolated addresses
14
12
  4. **Mode-based access control** - STANDARD/STRICT modes are view-only, YOLO mode provides full access
15
- 5. **Encrypted storage** - AES-256-GCM encryption at rest
13
+ 5. **Encrypted storage** - AES-256-GCM at rest, under a key derived from *public* credential metadata (see the honest boundary below)
16
14
  6. **Secure sessions** - In-memory and optional persistent sessions (disabled in STRICT mode)
17
- 7. **PRF-based encryption** - WebAuthn PRF extension for secure key derivation (v0.9.4+)
15
+
16
+ > **Before you rely on any of this for a threat model, read [At-Rest Encryption: What It Protects, and What It Doesn't](#at-rest-encryption-what-it-protects-and-what-it-doesnt) immediately below.** The biometric prompt gates the *SDK*, not the *ciphertext*: an attacker who can read this origin's browser storage can decrypt the seed offline without any prompt. The at-rest encryption is not a defense against that. This is a deliberate design; the section explains exactly what is and isn't protected.
17
+
18
+ ## At-Rest Encryption: What It Protects, and What It Doesn't
19
+
20
+ The short version: **treat the encrypted wallet on disk as decryptable by anyone who can read this origin's browser storage.** The real security boundaries are WebAuthn's origin binding, your application's code integrity, and device security — not the at-rest ciphertext.
21
+
22
+ ### How the at-rest key is derived (and why that matters)
23
+
24
+ The AES-256-GCM key that encrypts the wallet mnemonic is derived from **public credential metadata**, not from a secret held by the authenticator:
25
+
26
+ ```
27
+ key = PBKDF2( SHA-256("w3pk-v4:" + credentialId + ":" + publicKey),
28
+ salt = SHA-256("w3pk-salt-v4"), // fixed constant, present in the source
29
+ 210_000 iterations, SHA-256 )
30
+ ```
31
+
32
+ Every input to that key lives in the browser profile:
33
+
34
+ - `credentialId` — stored next to the ciphertext in **IndexedDB**
35
+ - `publicKey` — stored in **plaintext in localStorage** (it's needed for key derivation)
36
+ - `salt` — a **constant string** in the open-source code
37
+
38
+ So an attacker who can read this origin's localStorage **and** IndexedDB — a malicious browser extension, a one-shot XSS that exfiltrates storage, a disk image of an unlocked device, or a Time Machine / cloud backup of the browser profile — can recompute the key and decrypt the seed **offline, with no biometric/PIN prompt and without ever calling the SDK.** The prompt gates SDK control flow (a UX and anti-casual-misuse boundary), not the cryptography at rest.
39
+
40
+ **What at-rest encryption does buy you:** protection against a wallet blob that leaks *in isolation* (e.g. only the IndexedDB object, without localStorage's public key) and against casual inspection. It is **not** a defense against read access to the full browser profile. The [Threat Model](#threat-model) section has the per-scenario matrix.
41
+
42
+ ### "Remember Me" (persistent sessions): PRF-keyed, renewed at every real login
43
+
44
+ `persistentSession.enabled: true` stores the encrypted mnemonic in IndexedDB so the user isn't prompted on every page refresh. Unlike wallet storage, the persistent-session blob is **not** encrypted under a public-data-derived key. It is encrypted under a key derived (HKDF-SHA256) from the **WebAuthn PRF extension output** — a per-credential secret the authenticator releases only during a user-verified (biometric/PIN) assertion. Nothing stored on disk can recompute it. Every real login re-evaluates the PRF (with a fixed input, so the output is deterministic per credential) and re-keys the blob; the session expiry *is* the renewal boundary.
45
+
46
+ The at-rest guarantee then depends on `requireReauth`:
47
+
48
+ - **`requireReauth: true` (default):** the key is **never stored**. Every restore re-derives it from a live assertion's PRF output. A storage-read attacker (extension, storage-exfiltrating XSS, disk image, cloud backup of the profile) gets ciphertext that cannot be decrypted without the physical authenticator — the blob is hardware-bound at rest.
49
+ - **`requireReauth: false` (silent "Remember Me"):** the PRF-derived key is stored alongside the blob as a **non-extractable `CryptoKey`**, so restore works without a prompt. Scripts (including XSS) can *use* it while it lives but can never read its bytes, and it cannot be recomputed from any stored strings. The honest limit: an attacker who copies the **full browser profile** at the storage-engine level carries the key material with it. Silent restore fundamentally requires a usable key on disk; this is the strongest form it can take.
50
+ - **No PRF support** (older authenticators, some browser/OS combos): persistent sessions are **not stored at all** — the device gets in-memory sessions only. There is deliberately no fallback to weaker encryption.
51
+
52
+ What "Remember Me" still changes is the **exposure window**, not the trust model: a compromise of the already-trusted origin (XSS, a malicious npm dependency, a compromised script/CDN) can use the live session *any time within the configured duration*, including while the tab is idle and across browser restarts — instead of only while the wallet is actively in use.
53
+
54
+ **Reasonable to accept** for a hot wallet on a user's own device — it's the same bargain as "stay logged in" everywhere. **Guidance:**
55
+
56
+ - Leave it disabled (the default) if every unlock should require a biometric/PIN prompt.
57
+ - Keep the duration short for higher-value wallets. `sessionDuration: 0` forces a prompt on every operation.
58
+ - `requireReauth: true` (default) still prompts on page refresh and keeps the blob hardware-bound; `requireReauth: false` restores silently for the whole duration.
59
+ - Do not enable it on shared or untrusted devices.
60
+
61
+ ### Origin / hostname / URL scoping
62
+
63
+ What the credential and stored data are bound to — three *different* scopes, and none of them is the full URL:
64
+
65
+ | Bound to | Scope | Enforced by |
66
+ |---|---|---|
67
+ | Stored session / wallet / credentials | **Origin** = scheme + host + port | Browser storage partitioning |
68
+ | WebAuthn passkey (RP ID) | **Hostname** (exact host) | Browser WebAuthn API, re-checked on every assertion |
69
+
70
+ - **Path and query are irrelevant.** `https://app.example.com/wallet` and `https://app.example.com/x?y=1` are the same origin; a remembered session works on every page of the site, not one URL.
71
+ - A different scheme (`http:`), port (`:3000`), or host (`other.example.com`) is a **different origin** with separate storage, and a passkey bound to one exact hostname won't work on another. This is the anti-phishing guarantee — see [Credential Scoping and Domain Isolation](#credential-scoping-and-domain-isolation).
72
+ - **The hole to understand:** origin isolation walls the data off from *other origins*, not from code running *inside* your origin. XSS, a malicious dependency, or a compromised CDN on your own site runs *as* your origin and can use the session exactly like your legitimate code. **"Only this origin" is not "only my code."**
73
+
74
+ ### Backup files (the "floppy disk")
75
+
76
+ A backup file is portable and independent of any device. What an attacker who obtains **only the file** can do depends on its type:
77
+
78
+ | Backup type | Decrypts with | Is the file alone enough? |
79
+ |---|---|---|
80
+ | `password` | User password (PBKDF2-SHA256, 310k iters, random per-file salt) | ❌ Needs the password |
81
+ | `passkey` | Key derived from `credentialId` + the credential's **full public key** | ❌ The full public key is **not** in the file (only a truncated fingerprint) |
82
+ | `hybrid` | Password **and** the passkey-derived key | ❌ Needs both |
83
+
84
+ A backup file on its own is not decryptable in any variant. Note the `passkey` type's caveat differs from the wallet-at-rest case: the full public key it needs lives in device storage or a synced authenticator, not in the file — but it *is* public metadata, so a `passkey` backup **combined with** a storage dump (or a synced device) is decryptable without a password. Password and hybrid backups stay gated by the password regardless. **For a backup that leaves the device (cloud, USB, guardians), prefer `password` or `hybrid`.**
18
85
 
19
86
  ## Enhanced Security Model (v0.8.0+)
20
87
 
@@ -308,19 +375,20 @@ console.assert(
308
375
 
309
376
  ### ✅ Protected Against
310
377
 
311
- 1. **File System Access** - Attacker with access to browser storage cannot decrypt wallet
312
- 2. **Malware/Keyloggers** - Encryption key never exists in recoverable form
313
- 3. **Memory Dumps** - Keys are ephemeral and derived on-demand
314
- 4. **Database Theft** - Encrypted wallet is useless without biometric authentication
315
- 5. **JavaScript Injection** - Cannot replay signatures (fresh challenge each time)
378
+ 1. **Cross-origin / cross-site access** - Another origin cannot read this origin's storage or use its passkey (browser-enforced)
379
+ 2. **Phishing** - Passkey is bound to the exact hostname; a look-alike domain cannot use it
380
+ 3. **Partial storage leak** - A wallet blob leaked *without* the localStorage public key is not directly decryptable
381
+ 4. **Remote network attacks** - No secret is transmitted; decryption needs local file access
382
+ 5. **Signature replay** - Fresh WebAuthn challenge each time
316
383
 
317
384
  ### ⚠️ NOT Protected Against
318
385
 
319
- 1. **Physical coercion** - Forcing user to authenticate
320
- 2. **Compromised authenticator** - If hardware is backdoored
321
- 3. **Active browser session** - If wallet is in memory and user is authenticated
322
- 4. **Offline mnemonic theft** - If attacker has browser storage files (see Threat Model below)
323
- 5. **XSS with active session** - Code injection during authenticated session
386
+ 1. **Full browser-profile read access** - An attacker who can read this origin's localStorage **and** IndexedDB derives the key and decrypts the seed offline, with no prompt (see [At-Rest Encryption](#at-rest-encryption-what-it-protects-and-what-it-doesnt))
387
+ 2. **Malicious extension / XSS exfil / disk image / profile backup** - All are forms of the above storage-read attack
388
+ 3. **Compromised origin code** - XSS, a malicious dependency, or a compromised CDN runs *as* your origin and can use an active or persistent session
389
+ 4. **Physical coercion** - Forcing the user to authenticate
390
+ 5. **Compromised authenticator** - If the hardware is backdoored
391
+ 6. **Unlocked-device / active-session theft** - Wallet reachable while a session is live
324
392
 
325
393
  ## Threat Model
326
394
 
@@ -1083,96 +1151,45 @@ Signature: ${authorization.r.substring(0, 10)}...
1083
1151
 
1084
1152
  This section tracks major security-related changes to w3pk's implementation.
1085
1153
 
1086
- ### v0.9.4+ - PRF-Based Encryption (Critical Security Fix)
1154
+ ### v0.7.0+ (Current)
1087
1155
 
1088
- #### BREAKING: `deriveEncryptionKeyFromWebAuthn()` signature changed
1089
- **Impact:** Fixes critical encryption vulnerabilities (BREAKING CHANGE)
1156
+ #### PRF-based Encryption (Optional Enhancement)
1157
+ **Impact:** Security improvement when available
1090
1158
 
1091
1159
  **What changed:**
1092
- - `deriveEncryptionKeyFromWebAuthn()` signature changed from `(credentialId, publicKey?)` to `(prfOutput, salt)`
1093
- - Now uses WebAuthn PRF extension to obtain authenticator-held secrets
1094
- - Implements random unique salts instead of hardcoded constants
1160
+ - Added `deriveEncryptionKeyFromWebAuthn()` function for PRF-based encryption
1161
+ - Added `deriveEncryptionKeyAuto()` helper with automatic PRF detection and fallback
1162
+ - Implements random unique salts when using PRF
1095
1163
  - Added `generateSalt()` helper for creating cryptographic random salts
1096
1164
 
1097
- **Security improvements:**
1098
- - ✅ Key material now derived from authenticator secret (PRF output), not public credentialId/publicKey
1099
- - ✅ Uses random 32-byte salts stored with ciphertext, not hardcoded constants
1165
+ **Security improvements (when PRF available):**
1166
+ - ✅ Key material derived from authenticator secret (PRF output), not public credentialId/publicKey
1167
+ - ✅ Uses random 32-byte salts stored with ciphertext
1100
1168
  - ✅ Eliminates offline decryption vulnerability
1101
1169
  - ✅ Prevents precomputation attacks
1102
1170
 
1103
- **Migration required:**
1171
+ **Current implementation:**
1104
1172
  ```typescript
1105
- // OLD (v0.9.3 and earlier) - INSECURE
1106
- const key = await deriveEncryptionKeyFromWebAuthn(credentialId, publicKey);
1107
-
1108
- // NEW (v0.9.4+) - SECURE
1109
- const assertion = await navigator.credentials.get({
1110
- publicKey: {
1111
- challenge: new Uint8Array(32),
1112
- extensions: {
1113
- prf: { eval: { first: new Uint8Array(32) } }
1114
- }
1115
- }
1116
- });
1117
- const prfOutput = assertion.getClientExtensionResults().prf.results.first;
1118
- const salt = generateSalt(); // Store with ciphertext
1119
- const key = await deriveEncryptionKeyFromWebAuthn(prfOutput, salt);
1173
+ // SDK automatically uses PRF when available
1174
+ const key = await deriveEncryptionKeyAuto(
1175
+ prfOutput, // undefined if PRF not available
1176
+ salt, // undefined if PRF not available
1177
+ credentialId, // used in fallback mode
1178
+ publicKey // used in fallback mode
1179
+ );
1120
1180
  ```
1121
1181
 
1122
1182
  **Status:**
1123
- - ✅ Secure implementation complete
1124
- - 🔴 Breaking change - migration required
1125
- - 📖 Migration guide below
1126
-
1127
- See: [Migration to PRF-based Encryption](#migration-to-prf-based-encryption)
1128
-
1129
- ---
1130
-
1131
- ### v0.9.4+ - ERC-5564 Stealth Scalar Reduction Fix (High Severity)
1132
-
1133
- #### Fixed stealth address scalar reduction vulnerability
1134
-
1135
- **What changed:**
1136
- - Added `reduceScalarModN()` function to properly reduce scalars modulo secp256k1 curve order
1137
- - Updated `generateStealthAddress()`, `checkStealthAddress()`, and `computeStealthPrivateKey()` to use reduced scalars
1138
- - Ensures the same reduced scalar `s_h` is used consistently in both public-key and private-key operations
1139
-
1140
- **Security improvements:**
1141
- - ✅ Prevents intermittent failures when keccak256(sharedSecret) ≥ curve order
1142
- - ✅ Eliminates risk of unspendable stealth funds caused by inconsistent scalar handling
1143
- - ✅ Ensures derived stealth address always matches derived private key
1144
- - ✅ Validates scalars are in valid range [1, n-1] and rejects zero
1145
-
1146
- **Technical details:**
1147
- - ERC-5564 computes `s_h = keccak256(sharedSecret)` and uses it as a scalar
1148
- - Previously, `s_h` was used directly without reduction modulo `n` (secp256k1 curve order)
1149
- - Public-key path: `stealthPubKey = spendingPubKey + (s_h × G)` would fail if `s_h ≥ n`
1150
- - Private-key path: `stealthPrivKey = spendingKey + s_h` reduced the sum but not `s_h` itself
1151
- - Inconsistent scalar values between paths could result in address/key mismatch
1152
- - Now both paths use the same properly-reduced scalar
1153
-
1154
- **Impact:**
1155
- - Non-breaking change (pure bugfix)
1156
- - All existing stealth addresses remain valid
1157
- - Eliminates edge-case failures and unspendable funds
1158
-
1159
- ---
1183
+ - ✅ PRF support added for Chrome 108+, Safari 17+
1184
+ - ⚠️ **Legacy fallback active**: Uses credentialId/publicKey when PRF unavailable
1185
+ - ⚠️ **Known vulnerability**: Legacy encryption vulnerable to offline decryption attacks
1186
+ - 🔴 **Firefox not supported**: Firefox does not implement PRF extension yet
1187
+ - 📖 See [BROWSER_COMPATIBILITY.md](BROWSER_COMPATIBILITY.md) for details
1160
1188
 
1161
- #### No Backward Compatibility
1162
- **Decision:** Complete removal of insecure legacy function
1163
-
1164
- **What was removed:**
1165
- - Removed `deriveEncryptionKeyFromWebAuthnLegacy()` function entirely
1166
- - No backward compatibility layer for insecure encryption
1167
-
1168
- **Why:**
1169
- - Legacy function had critical security vulnerabilities
1170
- - Offline decryption attack risk unacceptable
1171
- - Clean break ensures no accidental use of insecure method
1172
-
1173
- **Migration:** All call sites updated to use `deriveEncryptionKeyAuto()` which:
1174
- - Uses PRF-based encryption when available (secure)
1175
- - Falls back to v2 implementation internally for wallets without PRF support
1189
+ **Recommendation:**
1190
+ - Backup files use password-based encryption (portable across devices)
1191
+ - Local device storage uses PRF when available, legacy fallback otherwise
1192
+ - Users should create password-protected backups for security
1176
1193
 
1177
1194
  ### v0.7.0 - RP ID Auto-Detection (Security Hardening)
1178
1195
 
@@ -1358,59 +1375,36 @@ const encryptionKey = await crypto.subtle.deriveKey(
1358
1375
  )
1359
1376
  ```
1360
1377
 
1361
- **Important security properties:**
1362
-
1363
- - The encryption key is **deterministic** - the same credential metadata always produces the same key
1364
- - An attacker with both localStorage (credential metadata) AND IndexedDB (encrypted wallet) **can derive the encryption key**
1365
- - **The actual security boundary is SDK-enforced authentication** - the SDK requires WebAuthn authentication before allowing any operations
1366
- - This is **authentication-gated encryption**, not signature-based encryption
1367
-
1368
- **Why this approach is still secure:**
1369
-
1370
- 1. **SDK enforces WebAuthn authentication** before any operation:
1371
- ```typescript
1372
- // User must authenticate before the SDK allows decryption
1373
- await w3pk.login() // ✅ Triggers biometric/PIN prompt
1374
- // Now SDK will decrypt wallet internally
1375
- ```
1376
-
1377
- 2. **WebAuthn authentication cannot be bypassed** without:
1378
- - Physical device access AND
1379
- - User's biometric (fingerprint/face) OR device PIN/password
1380
- - Browser shows authentication prompt (user can verify domain)
1378
+ **Important security properties (read honestly):**
1381
1379
 
1382
- 3. **Even with file access, attacker must authenticate:**
1383
- ```typescript
1384
- // Attacker steals files
1385
- const stolenCredentialId = "..."
1386
- const stolenPublicKey = "..."
1387
- const stolenEncryptedWallet = "..."
1380
+ - The encryption key is **deterministic** - the same credential metadata always produces the same key.
1381
+ - Both key inputs live in the browser profile: `credentialId` in IndexedDB, `publicKey` in plaintext in localStorage, and the salt is a constant in the source. **An attacker who can read this origin's storage can derive the key and decrypt the seed offline.**
1382
+ - The biometric/PIN prompt is an **SDK control-flow gate**, not a cryptographic barrier. It stops someone from driving *your SDK* without authenticating; it does **not** stop someone who bypasses the SDK and decrypts the files directly.
1383
+ - This is **not** signature-based or PRF-based encryption — the key is not bound to any authenticator-held secret.
1388
1384
 
1389
- // Can derive the encryption key
1390
- const key = deriveEncryptionKeyFromWebAuthn(stolenCredentialId, stolenPublicKey)
1385
+ **What an attacker with storage read access can actually do:**
1391
1386
 
1392
- // Can decrypt the wallet
1393
- const mnemonic = decryptData(stolenEncryptedWallet, key)
1387
+ ```typescript
1388
+ // Attacker has copied this origin's localStorage + IndexedDB
1389
+ const stolenCredentialId = "..." // from IndexedDB (next to the ciphertext)
1390
+ const stolenPublicKey = "..." // from localStorage (plaintext)
1391
+ const stolenEncryptedSeed = "..." // from IndexedDB
1394
1392
 
1395
- // BUT: To use the wallet via w3pk SDK, must authenticate
1396
- await w3pk.login() // BLOCKED: Requires user's biometric/PIN
1397
- ```
1393
+ // Recompute the key from public metadata no authenticator, no prompt
1394
+ const key = deriveEncryptionKeyFromWebAuthn(stolenCredentialId, stolenPublicKey)
1395
+ const mnemonic = decryptData(stolenEncryptedSeed, key) // ✅ seed recovered offline
1398
1396
 
1399
- 4. **Protection from offline attacks:**
1400
- - PBKDF2 with 210,000 iterations slows down brute force
1401
- - But the real protection is that the attacker needs the actual credential metadata (not guessable)
1402
- - Credential IDs are 32+ byte random values (256+ bits of entropy)
1397
+ // The SDK's login() gate is irrelevant — they already have the mnemonic and
1398
+ // can import it into MetaMask, Ledger Live, or any other wallet.
1399
+ ```
1403
1400
 
1404
- **Trade-off: Security vs Usability**
1401
+ The only thing PBKDF2's 210k iterations add here is a mild slowdown; since the inputs are known exactly (not guessed), there is nothing to brute-force. **Treat the encrypted-at-rest seed as recoverable by anyone with read access to the browser profile for your origin.** The genuine boundaries are documented in [At-Rest Encryption](#at-rest-encryption-what-it-protects-and-what-it-doesnt) and the [Threat Model](#threat-model).
1405
1402
 
1406
- This approach enables **secure sessions**:
1407
- - After authentication, the SDK can cache the decrypted mnemonic in memory
1408
- - Operations work without repeated biometric prompts for the session duration
1409
- - Sessions expire after configured time (default: 1 hour)
1403
+ **Trade-off: usability.** After a successful `login()`, the SDK caches the decrypted mnemonic in memory (and, if persistent sessions are enabled, on disk) so operations don't re-prompt. That convenience is the reason **wallet storage** accepts the at-rest weakness above rather than binding the key to a per-operation authenticator secret. **Persistent sessions do not share this weakness**: since v0.10.2 their blob is encrypted under a key derived from the WebAuthn PRF extension — an authenticator-held secret that cannot be recomputed from stored data (see [At-Rest Encryption → "Remember Me"](#at-rest-encryption-what-it-protects-and-what-it-doesnt)).
1410
1404
 
1411
- An alternative approach (signature-based encryption) would require biometric authentication for every single operation, which is more secure but less usable.
1405
+ ### 2. What's Stored
1412
1406
 
1413
- ### 2. What's Stored (All Safe to Expose)
1407
+ > ⚠️ The values below are individually "public," but **together they are sufficient to decrypt the wallet** (see the key derivation above). "No secrets stored" does **not** mean "safe to leak the whole profile."
1414
1408
 
1415
1409
  #### LocalStorage (Credentials)
1416
1410
  ```json
@@ -1433,11 +1427,12 @@ An alternative approach (signature-based encryption) would require biometric aut
1433
1427
  }
1434
1428
  ```
1435
1429
 
1436
- **NO secrets stored:**
1430
+ **No *individually* secret values are stored:**
1437
1431
  - No private keys
1438
1432
  - No challenge values
1439
- - No decryption keys
1440
- - Only public identifiers + encrypted data
1433
+ - No stored decryption key *object*
1434
+
1435
+ **But** the stored public identifiers (`credentialId` + `publicKey`) are exactly the key-derivation inputs, so possessing both stores is equivalent to possessing the decryption key. The absence of a stored key object is not a security boundary here.
1441
1436
 
1442
1437
  ### 3. Metadata Encryption in LocalStorage (v0.7.4+)
1443
1438
 
@@ -1947,7 +1942,7 @@ await navigator.credentials.get({
1947
1942
  - **Key Size:** 256 bits
1948
1943
  - **IV:** Random 12 bytes per encryption
1949
1944
  - **Authentication Tag:** 16 bytes (automatic with GCM)
1950
- - **Additional Authenticated Data:** Ethereum address (for integrity)
1945
+ - **Additional Authenticated Data:** none integrity comes from the GCM authentication tag above; no AAD is bound to the ciphertext
1951
1946
 
1952
1947
  **Entropy Analysis:**
1953
1948
  - Credential IDs are cryptographically random (256+ bits of entropy)
@@ -1956,11 +1951,10 @@ await navigator.credentials.get({
1956
1951
  - PBKDF2 with 210k iterations provides protection against brute force
1957
1952
 
1958
1953
  **Note on Fixed Salt:**
1959
- The salt is fixed (`"w3pk-salt-v4"`) rather than random per user. This is acceptable because:
1960
- - The credential ID itself provides uniqueness (32+ random bytes)
1961
- - Preimage attacks against PBKDF2-SHA256 are not practical
1962
- - The primary threat model is online authentication bypass, not offline brute force
1963
- - An attacker needs the actual credential metadata (not guessable)
1954
+ The salt is a fixed constant (`"w3pk-salt-v4"`) rather than random per user, and it is present in the open-source code. Be clear about what this does and doesn't matter for:
1955
+ - It provides **no** per-user precomputation resistance the same code path and salt are used for everyone. A random per-ciphertext salt (stored alongside the ciphertext) would be the correct construction.
1956
+ - In this design it changes little in practice, because the key inputs (`credentialId` + `publicKey`) are **not secret** — an attacker with storage access has them exactly, so there is nothing to precompute or brute-force in the first place. The fixed salt is a symptom of the same root issue: the key is derived from public data.
1957
+ - This is **not** adequate if the key material were ever a low-entropy secret (e.g. a user password); such a KDF must use a random per-ciphertext salt.
1964
1958
 
1965
1959
  ### Backup Encryption (User-Controlled)
1966
1960
 
@@ -1977,7 +1971,7 @@ The salt is fixed (`"w3pk-salt-v4"`) rather than random per user. This is accept
1977
1971
  - **Key Size:** 256 bits
1978
1972
  - **IV:** Random 12 bytes per encryption
1979
1973
  - **Authentication Tag:** 16 bytes (automatic with GCM)
1980
- - **Additional Authenticated Data:** Ethereum address (for integrity)
1974
+ - **Additional Authenticated Data:** none integrity comes from the GCM authentication tag above; no AAD is bound to the ciphertext
1981
1975
 
1982
1976
  **Password Requirements:**
1983
1977
  Enforced by `isStrongPassword()` utility:
@@ -2036,25 +2030,23 @@ w3pk supports **two types of sessions** (v0.8.2+):
2036
2030
  - ✅ Cleared on logout
2037
2031
  - ✅ Cleared when browser tab closes
2038
2032
 
2039
- **Persistent Session (opt-in):**
2040
- - Encrypted mnemonic in IndexedDB
2041
- - Survives page refresh
2042
- - Encrypted with WebAuthn-derived keys
2043
- - Time-limited expiration
2044
- - Only for STANDARD and YOLO modes
2045
- - NEVER persisted for STRICT mode
2033
+ **Persistent Session (opt-in — "Remember Me"):**
2034
+ - Mnemonic encrypted in IndexedDB under a **WebAuthn-PRF-derived key** (HKDF-SHA256 of the authenticator's per-credential PRF secret); survives page refresh for the configured duration
2035
+ - Re-keyed at every real (prompted) login — the session expiry is the renewal boundary
2036
+ - `requireReauth: true`: the key is never stored; every restore re-derives it from a live user-verified assertion (blob is hardware-bound at rest)
2037
+ - `requireReauth: false`: the key is stored as a **non-extractable `CryptoKey`** for silent restore — usable by scripts, never readable, not recomputable from stored data
2038
+ - Requires a PRF-capable authenticator; without PRF, no persistent session is stored (in-memory only, no weaker fallback)
2039
+ - Only for STANDARD and YOLO modes; **NEVER** persisted for STRICT mode
2040
+ - Time-limited expiration; cleared on logout / `clearSession()`
2046
2041
 
2047
2042
  **What's NOT cached:**
2048
2043
  - ❌ Private keys (derived on-demand)
2049
2044
  - ❌ WebAuthn signatures (fresh each time)
2050
- - ❌ Encryption keys (derived from signatures)
2051
2045
 
2052
2046
  **Security properties:**
2053
- - Default sessions exist **only in RAM** - never persisted to disk
2054
- - Persistent sessions **encrypted at rest** with WebAuthn-derived keys
2055
- - Automatically cleared after expiration
2056
- - Cleared on logout (both RAM and persistent)
2057
- - Can be manually cleared with `clearSession()`
2047
+ - Default (in-memory) sessions exist **only in RAM** - never written to disk
2048
+ - Persistent sessions are keyed to the authenticator hardware via the PRF extension — a storage-read attacker cannot recompute the key from anything on disk (with `requireReauth: false`, the residual risk is a full browser-profile copy carrying the non-extractable key material at the storage-engine level; see [At-Rest Encryption → "Remember Me"](#at-rest-encryption-what-it-protects-and-what-it-doesnt))
2049
+ - Automatically cleared after expiration; cleared on logout (both RAM and persistent)
2058
2050
  - STRICT mode **always** requires fresh authentication (no persistence)
2059
2051
 
2060
2052
  ### Session Management API
@@ -2870,6 +2862,8 @@ IndexedDB // Scoped to "https://example.com"
2870
2862
  // - http://example.com → different origin (different protocol)
2871
2863
  ```
2872
2864
 
2865
+ > **Caveat:** origin isolation only walls the data off from *other* origins. It does **not** protect against code running *inside* your own origin — XSS, a malicious dependency, or a compromised script/CDN on `example.com` runs *as* `example.com` and can read this storage and use the session. "Scoped to this origin" is not "only my code." See [At-Rest Encryption → Origin scoping](#at-rest-encryption-what-it-protects-and-what-it-doesnt).
2866
+
2873
2867
  #### ✅ No Cross-Site Credential Replay
2874
2868
 
2875
2869
  ```typescript
@@ -3167,7 +3161,7 @@ w3pk implements a **three-layer backup and recovery system** that balances secur
3167
3161
 
3168
3162
  **Security properties:**
3169
3163
  - ✅ **Encrypted in transit** - Platform handles E2E encryption
3170
- - ✅ **Hardware-backed** - Credentials protected by Secure Enclave/TPM
3164
+ - ✅ **Hardware-backed *passkey*** - The WebAuthn credential's private key is protected by the Secure Enclave/TPM. Note this hardware backing protects the *passkey (authentication/signing)*, **not** the wallet's at-rest encryption key — that key is derived from public metadata (see [At-Rest Encryption](#at-rest-encryption-what-it-protects-and-what-it-doesnt)). The seed is not hardware-protected at rest.
3171
3165
  - ✅ **Automatic** - No user action required
3172
3166
  - ⚠️ **Platform trust** - Relies on Apple/Google/Microsoft security
3173
3167
  - ⚠️ **Ecosystem lock-in** - Cannot cross platforms (Apple → Android)
@@ -3749,159 +3743,6 @@ This democratizes security analysis and helps users make informed decisions abou
3749
3743
 
3750
3744
  ---
3751
3745
 
3752
- ## Migration to PRF-based Encryption
3753
-
3754
- ### Overview
3755
-
3756
- The secure `deriveEncryptionKeyFromWebAuthn()` uses PRF-based encryption to prevent vulnerabilities:
3757
- - **Fixed**: Now derives encryption key from authenticator-held secret (PRF output), not public credential data
3758
- - **Fixed**: Uses random unique salts, not hardcoded constants
3759
-
3760
- **Benefits**: Prevents offline decryption attacks by requiring authenticator interaction.
3761
-
3762
- ### Migration Steps
3763
-
3764
- #### 1. Enable PRF Extension in Registration
3765
-
3766
- ```typescript
3767
- // Update WebAuthn registration to enable PRF
3768
- const credential = await navigator.credentials.create({
3769
- publicKey: {
3770
- challenge: crypto.getRandomValues(new Uint8Array(32)),
3771
- rp: {
3772
- name: "Your App",
3773
- id: window.location.hostname
3774
- },
3775
- user: {
3776
- id: userId,
3777
- name: username,
3778
- displayName: username
3779
- },
3780
- pubKeyCredParams: [{ alg: -7, type: "public-key" }],
3781
- authenticatorSelection: {
3782
- authenticatorAttachment: "platform",
3783
- userVerification: "required"
3784
- },
3785
- extensions: {
3786
- prf: {} // ← Enable PRF extension
3787
- }
3788
- }
3789
- });
3790
-
3791
- // Check if PRF is supported
3792
- const prfEnabled = credential.getClientExtensionResults().prf?.enabled;
3793
- if (!prfEnabled) {
3794
- console.warn("PRF not supported on this authenticator");
3795
- // Fall back to legacy method or require different authenticator
3796
- }
3797
- ```
3798
-
3799
- #### 2. Capture PRF Output During Authentication
3800
-
3801
- ```typescript
3802
- // Get PRF output during WebAuthn assertion
3803
- const prfSalt = crypto.getRandomValues(new Uint8Array(32));
3804
-
3805
- const assertion = await navigator.credentials.get({
3806
- publicKey: {
3807
- challenge: crypto.getRandomValues(new Uint8Array(32)),
3808
- rpId: window.location.hostname,
3809
- extensions: {
3810
- prf: {
3811
- eval: {
3812
- first: prfSalt // Input to PRF (can be constant per credential)
3813
- }
3814
- }
3815
- }
3816
- }
3817
- });
3818
-
3819
- // Extract PRF output
3820
- const prfResults = assertion.getClientExtensionResults().prf;
3821
- if (!prfResults?.results?.first) {
3822
- throw new Error("PRF output not available");
3823
- }
3824
- const prfOutput = prfResults.results.first; // 32-byte secret
3825
- ```
3826
-
3827
- #### 3. Generate and Store Salt with Ciphertext
3828
-
3829
- ```typescript
3830
- import { deriveEncryptionKeyFromWebAuthn, generateSalt } from 'w3pk';
3831
-
3832
- // Generate random salt (do this once per encryption)
3833
- const salt = generateSalt(); // 32 random bytes
3834
-
3835
- // Derive secure encryption key
3836
- const encryptionKey = await deriveEncryptionKeyFromWebAuthn(
3837
- prfOutput, // From authenticator (secret)
3838
- salt // Random (store with ciphertext)
3839
- );
3840
-
3841
- // Encrypt wallet data
3842
- const encrypted = await crypto.subtle.encrypt(
3843
- { name: "AES-GCM", iv: crypto.getRandomValues(new Uint8Array(12)) },
3844
- encryptionKey,
3845
- new TextEncoder().encode(mnemonic)
3846
- );
3847
-
3848
- // Store both salt and ciphertext together
3849
- await storage.save({
3850
- encryptedMnemonic: base64(encrypted),
3851
- salt: base64(salt), // ← Must be stored for decryption
3852
- credentialId: credential.id
3853
- });
3854
- ```
3855
-
3856
- #### 4. Decrypt Using Stored Salt
3857
-
3858
- ```typescript
3859
- // Load encrypted data
3860
- const data = await storage.load();
3861
- const salt = base64Decode(data.salt);
3862
- const encryptedMnemonic = base64Decode(data.encryptedMnemonic);
3863
-
3864
- // Get PRF output (same as step 2)
3865
- const assertion = await navigator.credentials.get({
3866
- publicKey: {
3867
- challenge: crypto.getRandomValues(new Uint8Array(32)),
3868
- extensions: {
3869
- prf: { eval: { first: prfSalt } }
3870
- }
3871
- }
3872
- });
3873
- const prfOutput = assertion.getClientExtensionResults().prf.results.first;
3874
-
3875
- // Derive key with stored salt
3876
- const encryptionKey = await deriveEncryptionKeyFromWebAuthn(prfOutput, salt);
3877
-
3878
- // Decrypt
3879
- const decrypted = await crypto.subtle.decrypt(
3880
- { name: "AES-GCM", iv: extractIV(encryptedMnemonic) },
3881
- encryptionKey,
3882
- encryptedMnemonic
3883
- );
3884
- ```
3885
-
3886
- ### Browser Support
3887
-
3888
- PRF extension support (as of 2026):
3889
- - ✅ Chrome/Edge 108+ (Windows Hello, Touch ID)
3890
- - ✅ Safari 17+ (Touch ID, Face ID)
3891
- - ✅ Firefox 122+ (limited)
3892
- - ⚠️ Check with `getClientExtensionResults().prf.enabled`
3893
-
3894
- ### Impact on Existing Users
3895
-
3896
- ✅ **NO BREAKING CHANGE for SDK users**: The SDK handles migration automatically via `deriveEncryptionKeyAuto()`.
3897
-
3898
- **For direct API users:**
3899
- - **Function signature changed**: `deriveEncryptionKeyFromWebAuthn()` now requires `(prfOutput, salt)` parameters
3900
- - **Legacy function removed**: No `deriveEncryptionKeyFromWebAuthnLegacy()` available
3901
- - **Migration path**: Use PRF-based encryption or use the auto-fallback helper
3902
-
3903
- ---
3904
-
3905
3746
  ## Post-Quantum Cryptography
3906
3747
 
3907
3748
  w3pk is preparing for the future quantum computing threat. While quantum computers capable of breaking current cryptography (ECDSA, secp256k1) are estimated to be **10-15 years away**, we have a comprehensive migration roadmap in place.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "w3pk",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "WebAuthn SDK for passwordless authentication, encrypted wallets, ERC-5564 stealth addresses, and zero-knowledge proofs",
5
5
  "author": "Julien Béranger",
6
6
  "license": "GPL-3.0",