w3pk 0.10.0 → 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/README.md +4 -0
- package/dist/index.d.mts +44 -12
- package/dist/index.d.ts +44 -12
- package/dist/index.js +32 -30
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +33 -31
- package/dist/index.mjs.map +1 -1
- package/dist/inspect/browser.d.mts +5 -0
- package/dist/inspect/browser.d.ts +5 -0
- package/dist/inspect/browser.js +20 -3
- package/dist/inspect/browser.js.map +1 -1
- package/dist/inspect/browser.mjs +20 -3
- package/dist/inspect/browser.mjs.map +1 -1
- package/dist/inspect/index.js +20 -3
- package/dist/inspect/index.js.map +1 -1
- package/dist/inspect/index.mjs +20 -3
- package/dist/inspect/index.mjs.map +1 -1
- package/dist/inspect/node.d.mts +5 -0
- package/dist/inspect/node.d.ts +5 -0
- package/dist/inspect/node.js +13 -2
- package/dist/inspect/node.js.map +1 -1
- package/dist/inspect/node.mjs +13 -2
- package/dist/inspect/node.mjs.map +1 -1
- package/docs/API_REFERENCE.md +10 -91
- package/docs/ARCHITECTURE.md +45 -42
- package/docs/BUILD_VERIFICATION.md +3 -3
- package/docs/INTEGRATION_GUIDELINES.md +6 -9
- package/docs/SECURITY.md +155 -314
- package/package.json +1 -1
- package/dist/chainlist/index.d.mts +0 -96
- package/dist/chainlist/index.d.ts +0 -96
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
|
|
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
|
-
|
|
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. **
|
|
312
|
-
2. **
|
|
313
|
-
3. **
|
|
314
|
-
4. **
|
|
315
|
-
5. **
|
|
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. **
|
|
320
|
-
2. **
|
|
321
|
-
3. **
|
|
322
|
-
4. **
|
|
323
|
-
5. **
|
|
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.
|
|
1154
|
+
### v0.7.0+ (Current)
|
|
1087
1155
|
|
|
1088
|
-
####
|
|
1089
|
-
**Impact:**
|
|
1156
|
+
#### PRF-based Encryption (Optional Enhancement)
|
|
1157
|
+
**Impact:** Security improvement when available
|
|
1090
1158
|
|
|
1091
1159
|
**What changed:**
|
|
1092
|
-
- `deriveEncryptionKeyFromWebAuthn()`
|
|
1093
|
-
-
|
|
1094
|
-
- Implements random unique salts
|
|
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
|
|
1099
|
-
- ✅ Uses random 32-byte salts stored with ciphertext
|
|
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
|
-
**
|
|
1171
|
+
**Current implementation:**
|
|
1104
1172
|
```typescript
|
|
1105
|
-
//
|
|
1106
|
-
const key = await
|
|
1107
|
-
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1110
|
-
publicKey
|
|
1111
|
-
|
|
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
|
-
- ✅
|
|
1124
|
-
-
|
|
1125
|
-
-
|
|
1126
|
-
|
|
1127
|
-
See
|
|
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
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
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
|
-
|
|
1390
|
-
const key = deriveEncryptionKeyFromWebAuthn(stolenCredentialId, stolenPublicKey)
|
|
1385
|
+
**What an attacker with storage read access can actually do:**
|
|
1391
1386
|
|
|
1392
|
-
|
|
1393
|
-
|
|
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
|
-
|
|
1396
|
-
|
|
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
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
1405
|
+
### 2. What's Stored
|
|
1412
1406
|
|
|
1413
|
-
|
|
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
|
-
**
|
|
1430
|
+
**No *individually* secret values are stored:**
|
|
1437
1431
|
- No private keys
|
|
1438
1432
|
- No challenge values
|
|
1439
|
-
- No decryption
|
|
1440
|
-
|
|
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:**
|
|
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
|
|
1960
|
-
-
|
|
1961
|
-
-
|
|
1962
|
-
-
|
|
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:**
|
|
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
|
-
-
|
|
2041
|
-
-
|
|
2042
|
-
-
|
|
2043
|
-
-
|
|
2044
|
-
-
|
|
2045
|
-
-
|
|
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
|
|
2054
|
-
- Persistent sessions
|
|
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
|
|
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