w3pk 0.9.3 → 0.10.0
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 +10 -0
- package/dist/index.d.mts +213 -1
- package/dist/index.d.ts +213 -1
- package/dist/index.js +102 -102
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +102 -102
- package/dist/index.mjs.map +1 -1
- package/docs/API_REFERENCE.md +269 -1
- package/docs/ARCHITECTURE.md +42 -45
- package/docs/POST_QUANTUM.md +288 -3
- package/docs/SECURITY.md +239 -20
- package/package.json +13 -10
package/docs/POST_QUANTUM.md
CHANGED
|
@@ -1290,14 +1290,299 @@ const delegation = await w3pk.requestExternalWalletDelegation({
|
|
|
1290
1290
|
|
|
1291
1291
|
---
|
|
1292
1292
|
|
|
1293
|
+
## ML-KEM Encryption
|
|
1294
|
+
|
|
1295
|
+
w3pk supports ML-KEM-1024 encryption for quantum-resistant data protection with multi-recipient support and deterministic key derivation from private keys.
|
|
1296
|
+
|
|
1297
|
+
### Deterministic Key Derivation
|
|
1298
|
+
|
|
1299
|
+
Derive ML-KEM keypairs deterministically from any private key material (Ethereum keys, random seeds, etc.):
|
|
1300
|
+
|
|
1301
|
+
```typescript
|
|
1302
|
+
import { deriveMLKemKeypair, mlkemEncryptWithKey, mlkemDecryptWithKey } from 'w3pk';
|
|
1303
|
+
|
|
1304
|
+
// Derive ML-KEM keypair from Ethereum private key
|
|
1305
|
+
const ethPrivateKey = '0x1234...'; // Your wallet private key
|
|
1306
|
+
const keypair = await deriveMLKemKeypair(ethPrivateKey, 'my-app');
|
|
1307
|
+
// Returns: { publicKey: Uint8Array(1568), privateKey: Uint8Array(3168) }
|
|
1308
|
+
|
|
1309
|
+
// Share your public key with others
|
|
1310
|
+
const publicKeyBase64 = Buffer.from(keypair.publicKey).toString('base64');
|
|
1311
|
+
|
|
1312
|
+
// Later: Recreate keypair from same private key (deterministic!)
|
|
1313
|
+
const sameKeypair = await deriveMLKemKeypair(ethPrivateKey, 'my-app');
|
|
1314
|
+
// sameKeypair.publicKey === keypair.publicKey ✅
|
|
1315
|
+
```
|
|
1316
|
+
|
|
1317
|
+
### Encrypt/Decrypt with w3pk Instance (Recommended)
|
|
1318
|
+
|
|
1319
|
+
The easiest way to use ML-KEM encryption - no private key exposure:
|
|
1320
|
+
|
|
1321
|
+
```typescript
|
|
1322
|
+
import { createWeb3Passkey } from 'w3pk';
|
|
1323
|
+
|
|
1324
|
+
const w3pk = createWeb3Passkey();
|
|
1325
|
+
await w3pk.login();
|
|
1326
|
+
|
|
1327
|
+
// 1. Share your public key with others
|
|
1328
|
+
const myPublicKey = await w3pk.deriveMLKemPublicKey();
|
|
1329
|
+
// Send myPublicKey to the server or publish it
|
|
1330
|
+
|
|
1331
|
+
// 2. Get server's public key
|
|
1332
|
+
const serverPubKey = await fetch('/api/mlkem-public-key').then(r => r.text());
|
|
1333
|
+
|
|
1334
|
+
// 3. Encrypt for yourself + server
|
|
1335
|
+
const encrypted = await w3pk.mlkemEncrypt(
|
|
1336
|
+
'my secret data',
|
|
1337
|
+
[serverPubKey] // Server can decrypt
|
|
1338
|
+
);
|
|
1339
|
+
// You can also decrypt because you're auto-added as recipient
|
|
1340
|
+
|
|
1341
|
+
// 4. Later: Decrypt locally (server never sees plaintext!)
|
|
1342
|
+
const plaintext = await w3pk.mlkemDecrypt(encrypted);
|
|
1343
|
+
```
|
|
1344
|
+
|
|
1345
|
+
**Security:** Your Ethereum private key never leaves the w3pk instance, matching the security model of `signMessage()` and `sendTransaction()`.
|
|
1346
|
+
|
|
1347
|
+
**Supported Modes:**
|
|
1348
|
+
- ✅ **STANDARD** (default) - Private key derived internally, not exposed to app
|
|
1349
|
+
- ✅ **STRICT** - Same as STANDARD but always requires WebAuthn re-authentication
|
|
1350
|
+
- ✅ **YOLO** - Private key available to app
|
|
1351
|
+
- ❌ **PRIMARY** - Not supported (uses P-256 WebAuthn keys, not Ethereum keys)
|
|
1352
|
+
|
|
1353
|
+
### Encrypt/Decrypt with Key Derivation (Low-level API)
|
|
1354
|
+
|
|
1355
|
+
For advanced use cases where you manage private keys directly:
|
|
1356
|
+
|
|
1357
|
+
```typescript
|
|
1358
|
+
// Client encrypts for themselves + server
|
|
1359
|
+
const serverKeypair = await deriveMLKemKeypair(serverEthKey, 'server');
|
|
1360
|
+
const encrypted = await mlkemEncryptWithKey(
|
|
1361
|
+
'my secret data',
|
|
1362
|
+
myEthPrivateKey, // Sender's private key (auto-added as recipient)
|
|
1363
|
+
[serverKeypair.publicKey], // Additional recipients
|
|
1364
|
+
'client' // Context for sender's key derivation
|
|
1365
|
+
);
|
|
1366
|
+
|
|
1367
|
+
// Later: Client decrypts locally (no server needed!)
|
|
1368
|
+
const plaintext = await mlkemDecryptWithKey(
|
|
1369
|
+
encrypted,
|
|
1370
|
+
myEthPrivateKey, // Same private key used for encryption
|
|
1371
|
+
'client' // Same context
|
|
1372
|
+
);
|
|
1373
|
+
|
|
1374
|
+
// Or: Server decrypts for operations
|
|
1375
|
+
const plaintext = await mlkemDecryptWithKey(
|
|
1376
|
+
encrypted,
|
|
1377
|
+
serverEthKey,
|
|
1378
|
+
'server'
|
|
1379
|
+
);
|
|
1380
|
+
```
|
|
1381
|
+
|
|
1382
|
+
### Direct Encryption (Without Key Derivation)
|
|
1383
|
+
|
|
1384
|
+
For maximum flexibility, use raw public keys directly:
|
|
1385
|
+
|
|
1386
|
+
```typescript
|
|
1387
|
+
import { mlkemEncrypt, mlkemDecrypt } from 'w3pk';
|
|
1388
|
+
|
|
1389
|
+
// Single recipient
|
|
1390
|
+
const publicKey = 'base64_encoded_public_key...'; // 1568 bytes
|
|
1391
|
+
const encrypted = await mlkemEncrypt('my secret data', publicKey);
|
|
1392
|
+
|
|
1393
|
+
// Multiple recipients (encrypt once, multiple people can decrypt)
|
|
1394
|
+
const encrypted = await mlkemEncrypt('my secret data', [
|
|
1395
|
+
publicKey1,
|
|
1396
|
+
publicKey2,
|
|
1397
|
+
publicKey3
|
|
1398
|
+
]);
|
|
1399
|
+
// Returns: { recipients: [...], encryptedData, iv, authTag }
|
|
1400
|
+
|
|
1401
|
+
// Decrypt with your private key (auto-detects which recipient you are)
|
|
1402
|
+
const privateKey = 'base64_encoded_private_key...'; // 3168 bytes
|
|
1403
|
+
const plaintext = await mlkemDecrypt(encrypted, privateKey);
|
|
1404
|
+
// Returns: 'my secret data'
|
|
1405
|
+
|
|
1406
|
+
// Or specify your public key for faster lookup
|
|
1407
|
+
const plaintext = await mlkemDecrypt(encrypted, privateKey, publicKey);
|
|
1408
|
+
```
|
|
1409
|
+
|
|
1410
|
+
### Security Properties
|
|
1411
|
+
|
|
1412
|
+
- ✅ **ML-KEM-1024** (NIST FIPS 203) - Post-quantum secure
|
|
1413
|
+
- ✅ **AES-256-GCM** - 128-bit quantum security (sufficient)
|
|
1414
|
+
- ✅ **Key zeroization** - Shared secrets securely wiped from memory
|
|
1415
|
+
- ✅ **Hybrid encryption** - KEM + symmetric cipher for efficiency
|
|
1416
|
+
- ✅ **Cross-platform** - Works in browser and Node.js
|
|
1417
|
+
|
|
1418
|
+
### API Reference
|
|
1419
|
+
|
|
1420
|
+
#### Instance Methods (Recommended)
|
|
1421
|
+
|
|
1422
|
+
These methods work with your w3pk wallet instance - no private key exposure:
|
|
1423
|
+
|
|
1424
|
+
#### `w3pk.deriveMLKemPublicKey(options?): Promise<string>`
|
|
1425
|
+
|
|
1426
|
+
Derives your ML-KEM-1024 public key that can be shared for encryption.
|
|
1427
|
+
|
|
1428
|
+
**Parameters:**
|
|
1429
|
+
- `options.context` - Context string for domain separation (default: `'mlkem-v1'`)
|
|
1430
|
+
- `options.mode` - Security mode: `'STANDARD'`, `'STRICT'`, or `'YOLO'` (default: `'STANDARD'`)
|
|
1431
|
+
- `options.tag` - Tag for address derivation (default: `'MAIN'`)
|
|
1432
|
+
- `options.origin` - Origin for derivation (default: current origin)
|
|
1433
|
+
- `options.requireAuth` - Force WebAuthn re-authentication (default: false, always true in STRICT mode)
|
|
1434
|
+
|
|
1435
|
+
**Note:** PRIMARY mode is not supported as it uses P-256 WebAuthn keys instead of Ethereum keys.
|
|
1436
|
+
|
|
1437
|
+
**Returns:** Base64-encoded ML-KEM public key (1568 bytes)
|
|
1438
|
+
|
|
1439
|
+
**Example:**
|
|
1440
|
+
```typescript
|
|
1441
|
+
const w3pk = createWeb3Passkey();
|
|
1442
|
+
await w3pk.login();
|
|
1443
|
+
const pubKey = await w3pk.deriveMLKemPublicKey();
|
|
1444
|
+
```
|
|
1445
|
+
|
|
1446
|
+
#### `w3pk.mlkemEncrypt(plaintext: string, recipientPublicKeys: Array<string | Uint8Array>, options?): Promise<EncryptedPayload>`
|
|
1447
|
+
|
|
1448
|
+
Encrypts data for yourself and additional recipients.
|
|
1449
|
+
|
|
1450
|
+
**Parameters:**
|
|
1451
|
+
- `plaintext` - The data to encrypt
|
|
1452
|
+
- `recipientPublicKeys` - Array of recipient ML-KEM public keys (base64 or Uint8Array)
|
|
1453
|
+
- `options.context` - Context string (default: `'mlkem-v1'`)
|
|
1454
|
+
- `options.mode` - Security mode: `'STANDARD'`, `'STRICT'`, or `'YOLO'` (default: `'STANDARD'`)
|
|
1455
|
+
- `options.tag` - Tag for address derivation (default: `'MAIN'`)
|
|
1456
|
+
- `options.origin` - Origin (default: current origin)
|
|
1457
|
+
- `options.requireAuth` - Force re-authentication (default: false, always true in STRICT mode)
|
|
1458
|
+
|
|
1459
|
+
**Returns:** Encrypted payload
|
|
1460
|
+
|
|
1461
|
+
**Example:**
|
|
1462
|
+
```typescript
|
|
1463
|
+
const encrypted = await w3pk.mlkemEncrypt('secret', [serverPubKey]);
|
|
1464
|
+
```
|
|
1465
|
+
|
|
1466
|
+
#### `w3pk.mlkemDecrypt(payload: EncryptedPayload, options?): Promise<string>`
|
|
1467
|
+
|
|
1468
|
+
Decrypts data encrypted for your wallet.
|
|
1469
|
+
|
|
1470
|
+
**Parameters:**
|
|
1471
|
+
- `payload` - The encrypted payload
|
|
1472
|
+
- `options.context` - Context string (default: `'mlkem-v1'`, must match encryption)
|
|
1473
|
+
- `options.mode` - Security mode: `'STANDARD'`, `'STRICT'`, or `'YOLO'` (default: `'STANDARD'`)
|
|
1474
|
+
- `options.tag` - Tag (default: `'MAIN'`)
|
|
1475
|
+
- `options.origin` - Origin (default: current origin)
|
|
1476
|
+
- `options.requireAuth` - Force re-authentication (default: false, always true in STRICT mode)
|
|
1477
|
+
|
|
1478
|
+
**Returns:** Decrypted plaintext
|
|
1479
|
+
|
|
1480
|
+
**Example:**
|
|
1481
|
+
```typescript
|
|
1482
|
+
const plaintext = await w3pk.mlkemDecrypt(encrypted);
|
|
1483
|
+
```
|
|
1484
|
+
|
|
1485
|
+
---
|
|
1486
|
+
|
|
1487
|
+
#### Low-Level Functions
|
|
1488
|
+
|
|
1489
|
+
These functions require managing private keys directly:
|
|
1490
|
+
|
|
1491
|
+
#### `deriveMLKemKeypair(privateKey: string | Uint8Array, context?: string): Promise<MLKemKeypair>`
|
|
1492
|
+
|
|
1493
|
+
Derives a deterministic ML-KEM-1024 keypair from any private key material using HKDF-SHA256.
|
|
1494
|
+
|
|
1495
|
+
**Parameters:**
|
|
1496
|
+
- `privateKey` - Private key material (hex string with optional `0x` prefix, or Uint8Array)
|
|
1497
|
+
- `context` - Optional context string for domain separation (default: `'mlkem-v1'`)
|
|
1498
|
+
|
|
1499
|
+
**Returns:** `MLKemKeypair` object containing:
|
|
1500
|
+
- `publicKey` - ML-KEM-1024 public key (Uint8Array, 1568 bytes)
|
|
1501
|
+
- `privateKey` - ML-KEM-1024 private key (Uint8Array, 3168 bytes)
|
|
1502
|
+
|
|
1503
|
+
**Security:** Uses HKDF with salt `"mlkem-keypair-v1"` and the provided context to derive a 64-byte seed, then generates a deterministic ML-KEM keypair. Same input always produces same output. All sensitive material is zeroized after use.
|
|
1504
|
+
|
|
1505
|
+
#### `mlkemEncryptWithKey(plaintext: string, senderPrivateKey: string | Uint8Array, recipientPublicKeys: Array<string | Uint8Array>, senderContext?: string): Promise<EncryptedPayload>`
|
|
1506
|
+
|
|
1507
|
+
Convenience function that derives the sender's ML-KEM keypair, then encrypts for sender + recipients.
|
|
1508
|
+
|
|
1509
|
+
**Parameters:**
|
|
1510
|
+
- `plaintext` - The data to encrypt
|
|
1511
|
+
- `senderPrivateKey` - Sender's private key (hex string or Uint8Array)
|
|
1512
|
+
- `recipientPublicKeys` - Array of recipient ML-KEM public keys
|
|
1513
|
+
- `senderContext` - Optional context for sender's key derivation (default: `'mlkem-v1'`)
|
|
1514
|
+
|
|
1515
|
+
**Returns:** `EncryptedPayload` (sender's public key is automatically included as first recipient)
|
|
1516
|
+
|
|
1517
|
+
**Use case:** Encrypt data so both you and a server can decrypt independently.
|
|
1518
|
+
|
|
1519
|
+
#### `mlkemDecryptWithKey(payload: EncryptedPayload, privateKey: string | Uint8Array, context?: string): Promise<string>`
|
|
1520
|
+
|
|
1521
|
+
Convenience function that derives an ML-KEM keypair, then decrypts the payload.
|
|
1522
|
+
|
|
1523
|
+
**Parameters:**
|
|
1524
|
+
- `payload` - The encrypted payload
|
|
1525
|
+
- `privateKey` - Private key material (hex string or Uint8Array)
|
|
1526
|
+
- `context` - Optional context for key derivation (default: `'mlkem-v1'`)
|
|
1527
|
+
|
|
1528
|
+
**Returns:** Decrypted plaintext string
|
|
1529
|
+
|
|
1530
|
+
**Use case:** Decrypt data using your Ethereum private key without managing separate ML-KEM keys.
|
|
1531
|
+
|
|
1532
|
+
#### `mlkemEncrypt(plaintext: string, publicKeys: (string | Uint8Array) | Array<string | Uint8Array>): Promise<EncryptedPayload>`
|
|
1533
|
+
|
|
1534
|
+
Encrypts data using ML-KEM-1024 + AES-256-GCM for one or more recipients.
|
|
1535
|
+
|
|
1536
|
+
**Parameters:**
|
|
1537
|
+
- `plaintext` - The data to encrypt
|
|
1538
|
+
- `publicKeys` - Single public key or array of ML-KEM-1024 public keys (base64 strings or Uint8Arrays, 1568 bytes each)
|
|
1539
|
+
|
|
1540
|
+
**Returns:** `EncryptedPayload` object containing:
|
|
1541
|
+
- `recipients` - Array of recipient entries, each containing:
|
|
1542
|
+
- `publicKey` - Base64 recipient public key (1568 bytes)
|
|
1543
|
+
- `ciphertext` - Base64 ML-KEM ciphertext for this recipient (1600 bytes: 1568 KEM + 32 encrypted AES key)
|
|
1544
|
+
- `encryptedData` - Base64 AES-encrypted data (shared across all recipients)
|
|
1545
|
+
- `iv` - Base64 initialization vector (12 bytes)
|
|
1546
|
+
- `authTag` - Base64 authentication tag (16 bytes)
|
|
1547
|
+
|
|
1548
|
+
**How it works:**
|
|
1549
|
+
1. Generates a random AES-256 key
|
|
1550
|
+
2. Encrypts plaintext with AES-256-GCM using that key
|
|
1551
|
+
3. For each recipient: encapsulates a shared secret with their ML-KEM public key, then XOR-encrypts the AES key with the shared secret
|
|
1552
|
+
4. Each recipient can decrypt using their private key to recover the AES key, then decrypt the data
|
|
1553
|
+
|
|
1554
|
+
#### `mlkemDecrypt(payload: EncryptedPayload, privateKey: string | Uint8Array, publicKey?: string | Uint8Array): Promise<string>`
|
|
1555
|
+
|
|
1556
|
+
Decrypts data encrypted with `mlkemEncrypt()`.
|
|
1557
|
+
|
|
1558
|
+
**Parameters:**
|
|
1559
|
+
- `payload` - The encrypted payload from `mlkemEncrypt()`
|
|
1560
|
+
- `privateKey` - ML-KEM-1024 private key (base64 string or Uint8Array, 3168 bytes)
|
|
1561
|
+
- `publicKey` - (Optional) Your ML-KEM-1024 public key for faster recipient lookup (base64 string or Uint8Array, 1568 bytes)
|
|
1562
|
+
|
|
1563
|
+
**Returns:** Decrypted plaintext string
|
|
1564
|
+
|
|
1565
|
+
**Throws:** Error if decryption fails (invalid key, no matching recipient, corrupted data, or tampered auth tag)
|
|
1566
|
+
|
|
1567
|
+
**Note:** If `publicKey` is not provided, the function will try each recipient until it finds a match (slower but convenient)
|
|
1568
|
+
|
|
1569
|
+
---
|
|
1570
|
+
|
|
1293
1571
|
## Document Maintenance
|
|
1294
1572
|
|
|
1295
|
-
**Version:** 1.
|
|
1296
|
-
**Last Updated:** 2026-
|
|
1297
|
-
**Next Review:** 2026-
|
|
1573
|
+
**Version:** 1.2
|
|
1574
|
+
**Last Updated:** 2026-03-21
|
|
1575
|
+
**Next Review:** 2026-09-21 (6 months)
|
|
1298
1576
|
**Maintained By:** Julien Béranger ([@julienbrg](https://github.com/julienbrg))
|
|
1299
1577
|
|
|
1300
1578
|
**Changelog:**
|
|
1579
|
+
- 2026-03-21: Added ML-KEM encryption utilities with deterministic key derivation
|
|
1580
|
+
- Documented `deriveMLKemKeypair()` function for HKDF-based key derivation from private keys
|
|
1581
|
+
- Documented `mlkemEncryptWithKey()` and `mlkemDecryptWithKey()` convenience functions
|
|
1582
|
+
- Documented `mlkemEncrypt()` function for direct post-quantum encryption
|
|
1583
|
+
- Documented `mlkemDecrypt()` function for decryption
|
|
1584
|
+
- Added security properties and comprehensive API reference
|
|
1585
|
+
- Linked to NIST FIPS 203 (ML-KEM) standard
|
|
1301
1586
|
- 2026-02-27: **Updated to align with [Ethereum quantum resistance roadmap](https://x.com/VitalikButerin/status/2027075026378543132)** (February 2026)
|
|
1302
1587
|
- Added Ethereum's four quantum-vulnerable components
|
|
1303
1588
|
- Integrated [EIP-8141](https://eips.ethereum.org/EIPS/eip-8141) (native AA) as long-term solution
|
package/docs/SECURITY.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
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
|
+
|
|
5
7
|
## Overview
|
|
6
8
|
|
|
7
9
|
w3pk provides **multiple layers of security** to protect user wallets:
|
|
@@ -12,7 +14,7 @@ w3pk provides **multiple layers of security** to protect user wallets:
|
|
|
12
14
|
4. **Mode-based access control** - STANDARD/STRICT modes are view-only, YOLO mode provides full access
|
|
13
15
|
5. **Encrypted storage** - AES-256-GCM encryption at rest
|
|
14
16
|
6. **Secure sessions** - In-memory and optional persistent sessions (disabled in STRICT mode)
|
|
15
|
-
7. **
|
|
17
|
+
7. **PRF-based encryption** - WebAuthn PRF extension for secure key derivation (v0.9.4+)
|
|
16
18
|
|
|
17
19
|
## Enhanced Security Model (v0.8.0+)
|
|
18
20
|
|
|
@@ -1081,32 +1083,96 @@ Signature: ${authorization.r.substring(0, 10)}...
|
|
|
1081
1083
|
|
|
1082
1084
|
This section tracks major security-related changes to w3pk's implementation.
|
|
1083
1085
|
|
|
1084
|
-
### v0.
|
|
1086
|
+
### v0.9.4+ - PRF-Based Encryption (Critical Security Fix)
|
|
1085
1087
|
|
|
1086
|
-
####
|
|
1087
|
-
**Impact:**
|
|
1088
|
+
#### BREAKING: `deriveEncryptionKeyFromWebAuthn()` signature changed
|
|
1089
|
+
**Impact:** Fixes critical encryption vulnerabilities (BREAKING CHANGE)
|
|
1088
1090
|
|
|
1089
1091
|
**What changed:**
|
|
1090
|
-
-
|
|
1091
|
-
-
|
|
1092
|
-
-
|
|
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
|
|
1095
|
+
- Added `generateSalt()` helper for creating cryptographic random salts
|
|
1093
1096
|
|
|
1094
|
-
**
|
|
1095
|
-
-
|
|
1096
|
-
-
|
|
1097
|
-
-
|
|
1098
|
-
- Prevents
|
|
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
|
|
1100
|
+
- ✅ Eliminates offline decryption vulnerability
|
|
1101
|
+
- ✅ Prevents precomputation attacks
|
|
1102
|
+
|
|
1103
|
+
**Migration required:**
|
|
1104
|
+
```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);
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
**Status:**
|
|
1123
|
+
- ✅ Secure implementation complete
|
|
1124
|
+
- 🔴 Breaking change - migration required
|
|
1125
|
+
- 📖 Migration guide below
|
|
1099
1126
|
|
|
1100
|
-
|
|
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
|
+
---
|
|
1101
1160
|
|
|
1102
|
-
####
|
|
1103
|
-
**
|
|
1104
|
-
- Deterministic key derivation from credential metadata
|
|
1105
|
-
- PBKDF2 with 210,000 iterations (OWASP 2023)
|
|
1106
|
-
- Fixed salt: `"w3pk-salt-v4"`
|
|
1107
|
-
- Security relies on SDK authentication-gating
|
|
1161
|
+
#### No Backward Compatibility
|
|
1162
|
+
**Decision:** Complete removal of insecure legacy function
|
|
1108
1163
|
|
|
1109
|
-
**
|
|
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
|
|
1110
1176
|
|
|
1111
1177
|
### v0.7.0 - RP ID Auto-Detection (Security Hardening)
|
|
1112
1178
|
|
|
@@ -3683,6 +3749,159 @@ This democratizes security analysis and helps users make informed decisions abou
|
|
|
3683
3749
|
|
|
3684
3750
|
---
|
|
3685
3751
|
|
|
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
|
+
|
|
3686
3905
|
## Post-Quantum Cryptography
|
|
3687
3906
|
|
|
3688
3907
|
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.
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|
|
@@ -84,30 +84,34 @@
|
|
|
84
84
|
"scripts/compute-build-hash.mjs"
|
|
85
85
|
],
|
|
86
86
|
"devDependencies": {
|
|
87
|
-
"@types/node": "^
|
|
87
|
+
"@types/node": "^25.9.2",
|
|
88
88
|
"@types/qrcode": "^1.5.5",
|
|
89
89
|
"circomlib": "^2.0.5",
|
|
90
90
|
"tsup": "^8.5.1",
|
|
91
|
-
"tsx": "^4.
|
|
92
|
-
"typescript": "^
|
|
91
|
+
"tsx": "^4.22.4",
|
|
92
|
+
"typescript": "^6.0.3"
|
|
93
93
|
},
|
|
94
94
|
"peerDependencies": {
|
|
95
95
|
"ethers": "^6.0.0"
|
|
96
96
|
},
|
|
97
97
|
"optionalDependencies": {
|
|
98
|
-
"@ipld/car": "^5.4.
|
|
99
|
-
"blockstore-core": "^
|
|
98
|
+
"@ipld/car": "^5.4.6",
|
|
99
|
+
"blockstore-core": "^7.0.1",
|
|
100
100
|
"circomlibjs": "^0.1.7",
|
|
101
|
-
"ipfs-unixfs-importer": "^
|
|
101
|
+
"ipfs-unixfs-importer": "^17.0.1",
|
|
102
102
|
"qrcode": "^1.5.4",
|
|
103
|
-
"snarkjs": "^0.7.
|
|
103
|
+
"snarkjs": "^0.7.6"
|
|
104
|
+
},
|
|
105
|
+
"dependencies": {
|
|
106
|
+
"@noble/hashes": "^2.2.0",
|
|
107
|
+
"mlkem": "^2.7.0"
|
|
104
108
|
},
|
|
105
109
|
"scripts": {
|
|
106
110
|
"build": "tsup",
|
|
107
111
|
"build:zk": "npm run build && npm run compile:circuits",
|
|
108
112
|
"compile:circuits": "node scripts/compile-circuits.js",
|
|
109
113
|
"dev": "tsup --watch",
|
|
110
|
-
"test": "tsx test/test.ts && tsx test/comprehensive.test.ts && tsx test/backup.test.ts && tsx test/social-recovery.test.ts && tsx test/recovery-education.test.ts && tsx test/zk/zk.test.ts && tsx test/nft-ownership.test.ts && tsx test/chainlist.test.ts && tsx test/eip7702.test.ts && tsx test/external-wallet.test.ts && tsx test/erc5564.test.ts && tsx test/zk/key-stretching.test.ts && tsx test/username-encoding.test.ts && tsx test/username-validation.test.ts && tsx test/origin-derivation.test.ts && tsx test/build-hash.test.ts && tsx test/credential-checking.test.ts && tsx test/webauthn-native.test.ts && tsx test/persistent-session.test.ts && tsx test/sign-message.test.ts && tsx test/siwe.test.ts && tsx test/requirereauth.test.ts && tsx test/eip7951.test.ts && tsx test/send-transaction.test.ts && tsx test/eip1193-provider.test.ts",
|
|
114
|
+
"test": "tsx test/test.ts && tsx test/comprehensive.test.ts && tsx test/backup.test.ts && tsx test/social-recovery.test.ts && tsx test/recovery-education.test.ts && tsx test/zk/zk.test.ts && tsx test/nft-ownership.test.ts && tsx test/chainlist.test.ts && tsx test/eip7702.test.ts && tsx test/external-wallet.test.ts && tsx test/erc5564.test.ts && tsx test/zk/key-stretching.test.ts && tsx test/username-encoding.test.ts && tsx test/username-validation.test.ts && tsx test/origin-derivation.test.ts && tsx test/build-hash.test.ts && tsx test/credential-checking.test.ts && tsx test/webauthn-native.test.ts && tsx test/persistent-session.test.ts && tsx test/sign-message.test.ts && tsx test/siwe.test.ts && tsx test/requirereauth.test.ts && tsx test/eip7951.test.ts && tsx test/send-transaction.test.ts && tsx test/eip1193-provider.test.ts && tsx test/mlkem.test.ts",
|
|
111
115
|
"test:basic": "tsx test/test.ts",
|
|
112
116
|
"test:comprehensive": "tsx test/comprehensive.test.ts",
|
|
113
117
|
"test:backup": "tsx test/backup.test.ts",
|
|
@@ -125,7 +129,6 @@
|
|
|
125
129
|
"test:sign-message": "tsx test/sign-message.test.ts",
|
|
126
130
|
"test:siwe": "tsx test/siwe.test.ts",
|
|
127
131
|
"build:hash": "node scripts/compute-build-hash.mjs",
|
|
128
|
-
"release:notes": "node scripts/generate-release-notes.mjs",
|
|
129
132
|
"html": "lsof -ti:3000 | xargs kill -9 2>/dev/null || true && tsup && npx serve . -l 3000 & sleep 5 && open http://localhost:3000/standalone/checker.html"
|
|
130
133
|
}
|
|
131
134
|
}
|