@passkeykit/server 2.1.0 → 3.1.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 +31 -1
- package/dist/challenge-token.d.ts +13 -5
- package/dist/challenge-token.js +66 -35
- package/dist/esm/challenge-token.js +66 -35
- package/dist/esm/express-routes.js +56 -30
- package/dist/esm/passkey-server.js +9 -7
- package/dist/esm/password.js +30 -12
- package/dist/esm/stores.js +34 -26
- package/dist/express-routes.d.ts +1 -1
- package/dist/express-routes.js +56 -30
- package/dist/passkey-server.js +9 -7
- package/dist/password.d.ts +2 -0
- package/dist/password.js +30 -12
- package/dist/stores.js +33 -25
- package/dist/types.d.ts +7 -2
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -215,7 +215,7 @@ interface PasskeyServerConfig {
|
|
|
215
215
|
credentialStore: CredentialStore;
|
|
216
216
|
|
|
217
217
|
// Stateless mode (default — pick one):
|
|
218
|
-
encryptionKey?: string; //
|
|
218
|
+
encryptionKey?: string | string[]; // AES-256-GCM secret(s) — see Key Rotation below
|
|
219
219
|
|
|
220
220
|
// Stateful mode (alternative):
|
|
221
221
|
challengeStore?: ChallengeStore;
|
|
@@ -225,6 +225,36 @@ interface PasskeyServerConfig {
|
|
|
225
225
|
}
|
|
226
226
|
```
|
|
227
227
|
|
|
228
|
+
## Key Rotation
|
|
229
|
+
|
|
230
|
+
Pass an array of keys to rotate secrets without breaking in-flight ceremonies:
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
const server = new PasskeyServer({
|
|
234
|
+
// ...
|
|
235
|
+
encryptionKey: [
|
|
236
|
+
process.env.PASSKEY_SECRET_NEW!, // Current — used for encryption
|
|
237
|
+
process.env.PASSKEY_SECRET_OLD!, // Previous — still accepted for decryption
|
|
238
|
+
],
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
- **Encryption** always uses the **first** key.
|
|
243
|
+
- **Decryption** tries each key in order until one succeeds.
|
|
244
|
+
- Once all in-flight tokens have expired (default: 5 minutes), remove the old key.
|
|
245
|
+
|
|
246
|
+
## Runtime Compatibility
|
|
247
|
+
|
|
248
|
+
v3.0 uses the **Web Crypto API** (`crypto.subtle`) instead of `node:crypto`. This means the library runs natively on:
|
|
249
|
+
|
|
250
|
+
- ✅ Node.js 18+
|
|
251
|
+
- ✅ Deno
|
|
252
|
+
- ✅ Bun
|
|
253
|
+
- ✅ Cloudflare Workers
|
|
254
|
+
- ✅ Vercel Edge Runtime
|
|
255
|
+
|
|
256
|
+
> **Breaking change in v3.0:** `sealChallengeToken` and `openChallengeToken` are now **async** (return `Promise`). If you use PasskeyServer directly, this is handled internally. If you imported these functions directly, add `await`.
|
|
257
|
+
|
|
228
258
|
## Exports
|
|
229
259
|
|
|
230
260
|
| Import Path | Contents | Requires |
|
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Stateless challenge token using AES-256-GCM
|
|
2
|
+
* Stateless challenge token using AES-256-GCM (Web Crypto API).
|
|
3
3
|
*
|
|
4
4
|
* @ai_context This is the core innovation for serverless deployments.
|
|
5
5
|
* Instead of storing challenges in a database/memory, we encrypt the
|
|
6
6
|
* challenge payload into an opaque token. The server can verify it later
|
|
7
7
|
* without any state — just the secret key.
|
|
8
8
|
*
|
|
9
|
+
* Uses the standard Web Crypto API (`crypto.subtle`) so it runs natively
|
|
10
|
+
* in Node 18+, Deno, Bun, Cloudflare Workers, and Vercel Edge Runtime.
|
|
11
|
+
*
|
|
9
12
|
* Token format: base64url(iv + ciphertext + authTag)
|
|
10
13
|
* Payload: JSON { challenge, userId?, type, exp }
|
|
11
14
|
*
|
|
12
15
|
* Security properties:
|
|
13
16
|
* - AES-256-GCM provides authenticated encryption (confidentiality + integrity)
|
|
17
|
+
* - HKDF-SHA256 derives the encryption key from the secret (domain separation)
|
|
14
18
|
* - The challenge value is hidden from the client (they only see the opaque token)
|
|
15
19
|
* - Expiry is baked into the token — no cleanup needed
|
|
16
20
|
* - Each token has a unique IV — replay is prevented by single-use consumption
|
|
17
|
-
*
|
|
21
|
+
*
|
|
22
|
+
* Key rotation: accepts multiple keys. Always encrypts with the first key.
|
|
23
|
+
* Decryption tries each key in order until one succeeds.
|
|
18
24
|
*/
|
|
19
25
|
export interface ChallengeTokenPayload {
|
|
20
26
|
/** The WebAuthn challenge string (base64url from @simplewebauthn) */
|
|
@@ -29,8 +35,10 @@ export interface ChallengeTokenPayload {
|
|
|
29
35
|
/**
|
|
30
36
|
* Encrypt a challenge payload into an opaque base64url token.
|
|
31
37
|
*/
|
|
32
|
-
export declare function sealChallengeToken(payload: ChallengeTokenPayload, secret: string): string
|
|
38
|
+
export declare function sealChallengeToken(payload: ChallengeTokenPayload, secret: string): Promise<string>;
|
|
33
39
|
/**
|
|
34
|
-
* Decrypt and verify a challenge token.
|
|
40
|
+
* Decrypt and verify a challenge token. Supports key rotation —
|
|
41
|
+
* if `secret` is an array, tries each key in order until one works.
|
|
42
|
+
* Returns null if all keys fail or the token is expired.
|
|
35
43
|
*/
|
|
36
|
-
export declare function openChallengeToken(token: string, secret: string): ChallengeTokenPayload | null
|
|
44
|
+
export declare function openChallengeToken(token: string, secret: string | string[]): Promise<ChallengeTokenPayload | null>;
|
package/dist/challenge-token.js
CHANGED
|
@@ -1,69 +1,77 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* Stateless challenge token using AES-256-GCM
|
|
3
|
+
* Stateless challenge token using AES-256-GCM (Web Crypto API).
|
|
4
4
|
*
|
|
5
5
|
* @ai_context This is the core innovation for serverless deployments.
|
|
6
6
|
* Instead of storing challenges in a database/memory, we encrypt the
|
|
7
7
|
* challenge payload into an opaque token. The server can verify it later
|
|
8
8
|
* without any state — just the secret key.
|
|
9
9
|
*
|
|
10
|
+
* Uses the standard Web Crypto API (`crypto.subtle`) so it runs natively
|
|
11
|
+
* in Node 18+, Deno, Bun, Cloudflare Workers, and Vercel Edge Runtime.
|
|
12
|
+
*
|
|
10
13
|
* Token format: base64url(iv + ciphertext + authTag)
|
|
11
14
|
* Payload: JSON { challenge, userId?, type, exp }
|
|
12
15
|
*
|
|
13
16
|
* Security properties:
|
|
14
17
|
* - AES-256-GCM provides authenticated encryption (confidentiality + integrity)
|
|
18
|
+
* - HKDF-SHA256 derives the encryption key from the secret (domain separation)
|
|
15
19
|
* - The challenge value is hidden from the client (they only see the opaque token)
|
|
16
20
|
* - Expiry is baked into the token — no cleanup needed
|
|
17
21
|
* - Each token has a unique IV — replay is prevented by single-use consumption
|
|
18
|
-
*
|
|
22
|
+
*
|
|
23
|
+
* Key rotation: accepts multiple keys. Always encrypts with the first key.
|
|
24
|
+
* Decryption tries each key in order until one succeeds.
|
|
19
25
|
*/
|
|
20
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
27
|
exports.sealChallengeToken = sealChallengeToken;
|
|
22
28
|
exports.openChallengeToken = openChallengeToken;
|
|
23
|
-
const crypto_1 = require("crypto");
|
|
24
|
-
const ALG = 'aes-256-gcm';
|
|
25
29
|
const IV_LEN = 12;
|
|
26
|
-
const
|
|
30
|
+
const HKDF_INFO = new TextEncoder().encode('passkey-kit-challenge-key');
|
|
27
31
|
/**
|
|
28
|
-
* Derive a
|
|
29
|
-
* Uses HMAC-SHA256 with a fixed context label (domain separation).
|
|
32
|
+
* Derive a 256-bit AES-GCM CryptoKey from a secret string using HKDF.
|
|
30
33
|
*/
|
|
31
|
-
function deriveKey(secret) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
async function deriveKey(secret) {
|
|
35
|
+
const rawKey = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), 'HKDF', false, ['deriveKey']);
|
|
36
|
+
return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: HKDF_INFO }, rawKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
37
|
+
}
|
|
38
|
+
// --- Base64url helpers (no Buffer dependency) ---
|
|
39
|
+
function toBase64Url(buf) {
|
|
40
|
+
const binStr = Array.from(buf, b => String.fromCharCode(b)).join('');
|
|
41
|
+
return btoa(binStr).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
42
|
+
}
|
|
43
|
+
function fromBase64Url(str) {
|
|
44
|
+
const padded = str.replace(/-/g, '+').replace(/_/g, '/') +
|
|
45
|
+
'='.repeat((4 - (str.length % 4)) % 4);
|
|
46
|
+
const binStr = atob(padded);
|
|
47
|
+
return Uint8Array.from(binStr, c => c.charCodeAt(0));
|
|
35
48
|
}
|
|
36
49
|
/**
|
|
37
50
|
* Encrypt a challenge payload into an opaque base64url token.
|
|
38
51
|
*/
|
|
39
|
-
function sealChallengeToken(payload, secret) {
|
|
40
|
-
const key = deriveKey(secret);
|
|
41
|
-
const iv = (
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
const encrypted =
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
async function sealChallengeToken(payload, secret) {
|
|
53
|
+
const key = await deriveKey(secret);
|
|
54
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
55
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
56
|
+
// AES-GCM encrypt (returns ciphertext + 16-byte auth tag appended)
|
|
57
|
+
const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext));
|
|
58
|
+
// Combine: iv (12) + ciphertext+tag (variable)
|
|
59
|
+
const combined = new Uint8Array(IV_LEN + encrypted.length);
|
|
60
|
+
combined.set(iv, 0);
|
|
61
|
+
combined.set(encrypted, IV_LEN);
|
|
62
|
+
return toBase64Url(combined);
|
|
49
63
|
}
|
|
50
64
|
/**
|
|
51
|
-
* Decrypt and verify a challenge token
|
|
65
|
+
* Decrypt and verify a challenge token with a single key.
|
|
66
|
+
* Returns null if invalid/expired.
|
|
52
67
|
*/
|
|
53
|
-
function
|
|
68
|
+
async function openWithKey(buf, secret) {
|
|
54
69
|
try {
|
|
55
|
-
const key = deriveKey(secret);
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
const tag = buf.subarray(buf.length - TAG_LEN);
|
|
61
|
-
const ciphertext = buf.subarray(IV_LEN, buf.length - TAG_LEN);
|
|
62
|
-
const decipher = (0, crypto_1.createDecipheriv)(ALG, key, iv);
|
|
63
|
-
decipher.setAuthTag(tag);
|
|
64
|
-
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
65
|
-
const payload = JSON.parse(decrypted.toString('utf8'));
|
|
66
|
-
// Check expiry
|
|
70
|
+
const key = await deriveKey(secret);
|
|
71
|
+
const iv = buf.slice(0, IV_LEN);
|
|
72
|
+
const ciphertextWithTag = buf.slice(IV_LEN);
|
|
73
|
+
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertextWithTag);
|
|
74
|
+
const payload = JSON.parse(new TextDecoder().decode(decrypted));
|
|
67
75
|
if (Date.now() > payload.exp)
|
|
68
76
|
return null;
|
|
69
77
|
return payload;
|
|
@@ -72,3 +80,26 @@ function openChallengeToken(token, secret) {
|
|
|
72
80
|
return null;
|
|
73
81
|
}
|
|
74
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Decrypt and verify a challenge token. Supports key rotation —
|
|
85
|
+
* if `secret` is an array, tries each key in order until one works.
|
|
86
|
+
* Returns null if all keys fail or the token is expired.
|
|
87
|
+
*/
|
|
88
|
+
async function openChallengeToken(token, secret) {
|
|
89
|
+
try {
|
|
90
|
+
const buf = fromBase64Url(token);
|
|
91
|
+
// AES-GCM tag is 16 bytes, so minimum length is IV + 1 byte ciphertext + 16 tag
|
|
92
|
+
if (buf.length < IV_LEN + 17)
|
|
93
|
+
return null;
|
|
94
|
+
const secrets = Array.isArray(secret) ? secret : [secret];
|
|
95
|
+
for (const s of secrets) {
|
|
96
|
+
const result = await openWithKey(buf, s);
|
|
97
|
+
if (result)
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -1,65 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Stateless challenge token using AES-256-GCM
|
|
2
|
+
* Stateless challenge token using AES-256-GCM (Web Crypto API).
|
|
3
3
|
*
|
|
4
4
|
* @ai_context This is the core innovation for serverless deployments.
|
|
5
5
|
* Instead of storing challenges in a database/memory, we encrypt the
|
|
6
6
|
* challenge payload into an opaque token. The server can verify it later
|
|
7
7
|
* without any state — just the secret key.
|
|
8
8
|
*
|
|
9
|
+
* Uses the standard Web Crypto API (`crypto.subtle`) so it runs natively
|
|
10
|
+
* in Node 18+, Deno, Bun, Cloudflare Workers, and Vercel Edge Runtime.
|
|
11
|
+
*
|
|
9
12
|
* Token format: base64url(iv + ciphertext + authTag)
|
|
10
13
|
* Payload: JSON { challenge, userId?, type, exp }
|
|
11
14
|
*
|
|
12
15
|
* Security properties:
|
|
13
16
|
* - AES-256-GCM provides authenticated encryption (confidentiality + integrity)
|
|
17
|
+
* - HKDF-SHA256 derives the encryption key from the secret (domain separation)
|
|
14
18
|
* - The challenge value is hidden from the client (they only see the opaque token)
|
|
15
19
|
* - Expiry is baked into the token — no cleanup needed
|
|
16
20
|
* - Each token has a unique IV — replay is prevented by single-use consumption
|
|
17
|
-
*
|
|
21
|
+
*
|
|
22
|
+
* Key rotation: accepts multiple keys. Always encrypts with the first key.
|
|
23
|
+
* Decryption tries each key in order until one succeeds.
|
|
18
24
|
*/
|
|
19
|
-
import { createCipheriv, createDecipheriv, randomBytes, createHmac } from 'crypto';
|
|
20
|
-
const ALG = 'aes-256-gcm';
|
|
21
25
|
const IV_LEN = 12;
|
|
22
|
-
const
|
|
26
|
+
const HKDF_INFO = new TextEncoder().encode('passkey-kit-challenge-key');
|
|
23
27
|
/**
|
|
24
|
-
* Derive a
|
|
25
|
-
* Uses HMAC-SHA256 with a fixed context label (domain separation).
|
|
28
|
+
* Derive a 256-bit AES-GCM CryptoKey from a secret string using HKDF.
|
|
26
29
|
*/
|
|
27
|
-
function deriveKey(secret) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
async function deriveKey(secret) {
|
|
31
|
+
const rawKey = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), 'HKDF', false, ['deriveKey']);
|
|
32
|
+
return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: HKDF_INFO }, rawKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
33
|
+
}
|
|
34
|
+
// --- Base64url helpers (no Buffer dependency) ---
|
|
35
|
+
function toBase64Url(buf) {
|
|
36
|
+
const binStr = Array.from(buf, b => String.fromCharCode(b)).join('');
|
|
37
|
+
return btoa(binStr).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
38
|
+
}
|
|
39
|
+
function fromBase64Url(str) {
|
|
40
|
+
const padded = str.replace(/-/g, '+').replace(/_/g, '/') +
|
|
41
|
+
'='.repeat((4 - (str.length % 4)) % 4);
|
|
42
|
+
const binStr = atob(padded);
|
|
43
|
+
return Uint8Array.from(binStr, c => c.charCodeAt(0));
|
|
31
44
|
}
|
|
32
45
|
/**
|
|
33
46
|
* Encrypt a challenge payload into an opaque base64url token.
|
|
34
47
|
*/
|
|
35
|
-
export function sealChallengeToken(payload, secret) {
|
|
36
|
-
const key = deriveKey(secret);
|
|
37
|
-
const iv =
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const encrypted =
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
48
|
+
export async function sealChallengeToken(payload, secret) {
|
|
49
|
+
const key = await deriveKey(secret);
|
|
50
|
+
const iv = crypto.getRandomValues(new Uint8Array(IV_LEN));
|
|
51
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
52
|
+
// AES-GCM encrypt (returns ciphertext + 16-byte auth tag appended)
|
|
53
|
+
const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext));
|
|
54
|
+
// Combine: iv (12) + ciphertext+tag (variable)
|
|
55
|
+
const combined = new Uint8Array(IV_LEN + encrypted.length);
|
|
56
|
+
combined.set(iv, 0);
|
|
57
|
+
combined.set(encrypted, IV_LEN);
|
|
58
|
+
return toBase64Url(combined);
|
|
45
59
|
}
|
|
46
60
|
/**
|
|
47
|
-
* Decrypt and verify a challenge token
|
|
61
|
+
* Decrypt and verify a challenge token with a single key.
|
|
62
|
+
* Returns null if invalid/expired.
|
|
48
63
|
*/
|
|
49
|
-
|
|
64
|
+
async function openWithKey(buf, secret) {
|
|
50
65
|
try {
|
|
51
|
-
const key = deriveKey(secret);
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
const tag = buf.subarray(buf.length - TAG_LEN);
|
|
57
|
-
const ciphertext = buf.subarray(IV_LEN, buf.length - TAG_LEN);
|
|
58
|
-
const decipher = createDecipheriv(ALG, key, iv);
|
|
59
|
-
decipher.setAuthTag(tag);
|
|
60
|
-
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
61
|
-
const payload = JSON.parse(decrypted.toString('utf8'));
|
|
62
|
-
// Check expiry
|
|
66
|
+
const key = await deriveKey(secret);
|
|
67
|
+
const iv = buf.slice(0, IV_LEN);
|
|
68
|
+
const ciphertextWithTag = buf.slice(IV_LEN);
|
|
69
|
+
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertextWithTag);
|
|
70
|
+
const payload = JSON.parse(new TextDecoder().decode(decrypted));
|
|
63
71
|
if (Date.now() > payload.exp)
|
|
64
72
|
return null;
|
|
65
73
|
return payload;
|
|
@@ -68,3 +76,26 @@ export function openChallengeToken(token, secret) {
|
|
|
68
76
|
return null;
|
|
69
77
|
}
|
|
70
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* Decrypt and verify a challenge token. Supports key rotation —
|
|
81
|
+
* if `secret` is an array, tries each key in order until one works.
|
|
82
|
+
* Returns null if all keys fail or the token is expired.
|
|
83
|
+
*/
|
|
84
|
+
export async function openChallengeToken(token, secret) {
|
|
85
|
+
try {
|
|
86
|
+
const buf = fromBase64Url(token);
|
|
87
|
+
// AES-GCM tag is 16 bytes, so minimum length is IV + 1 byte ciphertext + 16 tag
|
|
88
|
+
if (buf.length < IV_LEN + 17)
|
|
89
|
+
return null;
|
|
90
|
+
const secrets = Array.isArray(secret) ? secret : [secret];
|
|
91
|
+
for (const s of secrets) {
|
|
92
|
+
const result = await openWithKey(buf, s);
|
|
93
|
+
if (result)
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -3,13 +3,50 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @ai_context This is a convenience wrapper. Apps that don't use Express
|
|
5
5
|
* can use PasskeyServer directly. These routes implement the standard
|
|
6
|
-
* challenge-response pattern with proper error handling.
|
|
6
|
+
* challenge-response pattern with proper error handling and Zod validation.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
9
|
* const routes = createExpressRoutes(passkeyServer, { getUserInfo });
|
|
10
10
|
* app.use('/api/auth/passkey', routes);
|
|
11
11
|
*/
|
|
12
12
|
import { Router } from 'express';
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
// ============================================================
|
|
15
|
+
// Zod Schemas — strict input validation for every route
|
|
16
|
+
// ============================================================
|
|
17
|
+
const registerOptionsSchema = z.object({
|
|
18
|
+
userId: z.string().min(1),
|
|
19
|
+
authenticatorAttachment: z.enum(['platform', 'cross-platform']).optional(),
|
|
20
|
+
residentKey: z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
21
|
+
userVerification: z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
22
|
+
}).strict();
|
|
23
|
+
const registerVerifySchema = z.object({
|
|
24
|
+
userId: z.string().min(1),
|
|
25
|
+
response: z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
rawId: z.string(),
|
|
28
|
+
type: z.literal('public-key'),
|
|
29
|
+
response: z.record(z.string(), z.unknown()),
|
|
30
|
+
clientExtensionResults: z.record(z.string(), z.unknown()),
|
|
31
|
+
authenticatorAttachment: z.string().optional(),
|
|
32
|
+
}).passthrough(),
|
|
33
|
+
credentialName: z.string().optional(),
|
|
34
|
+
challengeToken: z.string().optional(),
|
|
35
|
+
}).strict();
|
|
36
|
+
const authenticateOptionsSchema = z.object({
|
|
37
|
+
userId: z.string().min(1).optional(),
|
|
38
|
+
userVerification: z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
39
|
+
}).strict();
|
|
40
|
+
const authenticateVerifySchema = z.object({
|
|
41
|
+
sessionKey: z.string().min(1),
|
|
42
|
+
response: z.object({
|
|
43
|
+
id: z.string(),
|
|
44
|
+
rawId: z.string(),
|
|
45
|
+
type: z.literal('public-key'),
|
|
46
|
+
response: z.record(z.string(), z.unknown()),
|
|
47
|
+
clientExtensionResults: z.record(z.string(), z.unknown()),
|
|
48
|
+
}).passthrough(),
|
|
49
|
+
}).strict();
|
|
13
50
|
/**
|
|
14
51
|
* Create Express router with passkey registration and authentication routes.
|
|
15
52
|
*
|
|
@@ -21,18 +58,14 @@ import { Router } from 'express';
|
|
|
21
58
|
*/
|
|
22
59
|
export function createExpressRoutes(server, config) {
|
|
23
60
|
const router = Router();
|
|
24
|
-
/**
|
|
25
|
-
* POST /register/options
|
|
26
|
-
* Body: { userId: string, authenticatorAttachment?: 'platform' | 'cross-platform' }
|
|
27
|
-
* Response includes `challengeToken` in stateless mode.
|
|
28
|
-
*/
|
|
29
61
|
router.post('/register/options', async (req, res) => {
|
|
30
62
|
try {
|
|
31
|
-
const
|
|
32
|
-
if (!
|
|
33
|
-
res.status(400).json({ error: '
|
|
63
|
+
const parsed = registerOptionsSchema.safeParse(req.body);
|
|
64
|
+
if (!parsed.success) {
|
|
65
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
34
66
|
return;
|
|
35
67
|
}
|
|
68
|
+
const { userId, authenticatorAttachment, residentKey, userVerification } = parsed.data;
|
|
36
69
|
const user = await config.getUserInfo(userId);
|
|
37
70
|
if (!user) {
|
|
38
71
|
res.status(404).json({ error: 'User not found' });
|
|
@@ -50,18 +83,14 @@ export function createExpressRoutes(server, config) {
|
|
|
50
83
|
res.status(500).json({ error: 'Failed to generate registration options' });
|
|
51
84
|
}
|
|
52
85
|
});
|
|
53
|
-
/**
|
|
54
|
-
* POST /register/verify
|
|
55
|
-
* Body: { userId: string, response: RegistrationResponseJSON, credentialName?: string, challengeToken?: string }
|
|
56
|
-
* `challengeToken` is required in stateless mode.
|
|
57
|
-
*/
|
|
58
86
|
router.post('/register/verify', async (req, res) => {
|
|
59
87
|
try {
|
|
60
|
-
const
|
|
61
|
-
if (!
|
|
62
|
-
res.status(400).json({ error: '
|
|
88
|
+
const parsed = registerVerifySchema.safeParse(req.body);
|
|
89
|
+
if (!parsed.success) {
|
|
90
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
63
91
|
return;
|
|
64
92
|
}
|
|
93
|
+
const { userId, response, credentialName, challengeToken } = parsed.data;
|
|
65
94
|
const result = await server.verifyRegistration(userId, response, credentialName, challengeToken);
|
|
66
95
|
if (config.onRegistrationSuccess) {
|
|
67
96
|
await config.onRegistrationSuccess(userId, result.credential.credentialId);
|
|
@@ -78,14 +107,14 @@ export function createExpressRoutes(server, config) {
|
|
|
78
107
|
res.status(400).json({ error: message });
|
|
79
108
|
}
|
|
80
109
|
});
|
|
81
|
-
/**
|
|
82
|
-
* POST /authenticate/options
|
|
83
|
-
* Body: { userId?: string }
|
|
84
|
-
* Response includes `sessionKey` (which IS the challengeToken in stateless mode).
|
|
85
|
-
*/
|
|
86
110
|
router.post('/authenticate/options', async (req, res) => {
|
|
87
111
|
try {
|
|
88
|
-
const
|
|
112
|
+
const parsed = authenticateOptionsSchema.safeParse(req.body);
|
|
113
|
+
if (!parsed.success) {
|
|
114
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const { userId, userVerification } = parsed.data;
|
|
89
118
|
const { options, sessionKey, challengeToken } = await server.generateAuthenticationOptions(userId, { userVerification });
|
|
90
119
|
res.json({ options, sessionKey, challengeToken });
|
|
91
120
|
}
|
|
@@ -94,17 +123,14 @@ export function createExpressRoutes(server, config) {
|
|
|
94
123
|
res.status(500).json({ error: 'Failed to generate authentication options' });
|
|
95
124
|
}
|
|
96
125
|
});
|
|
97
|
-
/**
|
|
98
|
-
* POST /authenticate/verify
|
|
99
|
-
* Body: { sessionKey: string, response: AuthenticationResponseJSON }
|
|
100
|
-
*/
|
|
101
126
|
router.post('/authenticate/verify', async (req, res) => {
|
|
102
127
|
try {
|
|
103
|
-
const
|
|
104
|
-
if (!
|
|
105
|
-
res.status(400).json({ error: '
|
|
128
|
+
const parsed = authenticateVerifySchema.safeParse(req.body);
|
|
129
|
+
if (!parsed.success) {
|
|
130
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
106
131
|
return;
|
|
107
132
|
}
|
|
133
|
+
const { sessionKey, response } = parsed.data;
|
|
108
134
|
const result = await server.verifyAuthentication(sessionKey, response);
|
|
109
135
|
let extra = {};
|
|
110
136
|
if (config.onAuthenticationSuccess) {
|
|
@@ -78,13 +78,14 @@ export class PasskeyServer {
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
else {
|
|
81
|
-
// Stateless: encrypt challenge into token
|
|
82
|
-
|
|
81
|
+
// Stateless: encrypt challenge into token (use first key for encryption)
|
|
82
|
+
const primaryKey = Array.isArray(this.encryptionKey) ? this.encryptionKey[0] : this.encryptionKey;
|
|
83
|
+
challengeToken = await sealChallengeToken({
|
|
83
84
|
challenge: options.challenge,
|
|
84
85
|
userId: user.id,
|
|
85
86
|
type: 'registration',
|
|
86
87
|
exp: Date.now() + this.challengeTTL,
|
|
87
|
-
},
|
|
88
|
+
}, primaryKey);
|
|
88
89
|
}
|
|
89
90
|
return { ...options, challengeToken };
|
|
90
91
|
}
|
|
@@ -111,7 +112,7 @@ export class PasskeyServer {
|
|
|
111
112
|
else {
|
|
112
113
|
if (!challengeToken)
|
|
113
114
|
throw new Error('challengeToken is required in stateless mode');
|
|
114
|
-
const payload = openChallengeToken(challengeToken, this.encryptionKey);
|
|
115
|
+
const payload = await openChallengeToken(challengeToken, this.encryptionKey);
|
|
115
116
|
if (!payload)
|
|
116
117
|
throw new Error('Invalid or expired challenge token');
|
|
117
118
|
if (payload.type !== 'registration')
|
|
@@ -175,12 +176,13 @@ export class PasskeyServer {
|
|
|
175
176
|
}
|
|
176
177
|
else {
|
|
177
178
|
// Stateless: encrypt into token (sessionKey IS the token)
|
|
178
|
-
|
|
179
|
+
const primaryKey = Array.isArray(this.encryptionKey) ? this.encryptionKey[0] : this.encryptionKey;
|
|
180
|
+
challengeToken = await sealChallengeToken({
|
|
179
181
|
challenge: options.challenge,
|
|
180
182
|
userId,
|
|
181
183
|
type: 'authentication',
|
|
182
184
|
exp: Date.now() + this.challengeTTL,
|
|
183
|
-
},
|
|
185
|
+
}, primaryKey);
|
|
184
186
|
sessionKey = challengeToken;
|
|
185
187
|
}
|
|
186
188
|
return { options, sessionKey, challengeToken };
|
|
@@ -202,7 +204,7 @@ export class PasskeyServer {
|
|
|
202
204
|
}
|
|
203
205
|
else {
|
|
204
206
|
// In stateless mode, sessionKey IS the challengeToken
|
|
205
|
-
const payload = openChallengeToken(sessionKey, this.encryptionKey);
|
|
207
|
+
const payload = await openChallengeToken(sessionKey, this.encryptionKey);
|
|
206
208
|
if (!payload)
|
|
207
209
|
throw new Error('Invalid or expired challenge token');
|
|
208
210
|
if (payload.type !== 'authentication')
|
package/dist/esm/password.js
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* @noble/hashes is audited by Trail of Bits and works on every runtime:
|
|
6
6
|
* Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge, browser.
|
|
7
7
|
*
|
|
8
|
+
* Uses Web Crypto API for random salt generation — no node:crypto dependency.
|
|
9
|
+
*
|
|
8
10
|
* For users who want argon2id (requires native bindings), see the
|
|
9
11
|
* `@passkeykit/server/argon2` subpath export.
|
|
10
12
|
*
|
|
@@ -12,7 +14,6 @@
|
|
|
12
14
|
* $scrypt$ln=17,r=8,p=1$<base64salt>$<base64hash>
|
|
13
15
|
*/
|
|
14
16
|
import { scrypt as scryptSync } from '@noble/hashes/scrypt';
|
|
15
|
-
import { randomBytes, timingSafeEqual as tse } from 'crypto';
|
|
16
17
|
/** Default scrypt parameters (OWASP recommendations for interactive login) */
|
|
17
18
|
const DEFAULTS = {
|
|
18
19
|
N: 2 ** 17, // 131072 — CPU/memory cost
|
|
@@ -21,6 +22,28 @@ const DEFAULTS = {
|
|
|
21
22
|
dkLen: 32, // Output key length
|
|
22
23
|
saltLen: 16, // Salt length
|
|
23
24
|
};
|
|
25
|
+
// --- Base64 helpers (no Buffer dependency) ---
|
|
26
|
+
function uint8ToBase64(buf) {
|
|
27
|
+
const binStr = Array.from(buf, b => String.fromCharCode(b)).join('');
|
|
28
|
+
return btoa(binStr);
|
|
29
|
+
}
|
|
30
|
+
function base64ToUint8(str) {
|
|
31
|
+
const binStr = atob(str);
|
|
32
|
+
return Uint8Array.from(binStr, c => c.charCodeAt(0));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Constant-time comparison — prevents timing attacks on hash verification.
|
|
36
|
+
* Works on all runtimes (no node:crypto dependency).
|
|
37
|
+
*/
|
|
38
|
+
function constantTimeEqual(a, b) {
|
|
39
|
+
if (a.length !== b.length)
|
|
40
|
+
return false;
|
|
41
|
+
let result = 0;
|
|
42
|
+
for (let i = 0; i < a.length; i++) {
|
|
43
|
+
result |= a[i] ^ b[i];
|
|
44
|
+
}
|
|
45
|
+
return result === 0;
|
|
46
|
+
}
|
|
24
47
|
/**
|
|
25
48
|
* Hash a password using scrypt.
|
|
26
49
|
* Returns a PHC-format string: $scrypt$ln=<log2N>,r=<r>,p=<p>$<salt>$<hash>
|
|
@@ -29,11 +52,11 @@ export async function hashPassword(password, options) {
|
|
|
29
52
|
const N = options?.N ?? DEFAULTS.N;
|
|
30
53
|
const r = options?.r ?? DEFAULTS.r;
|
|
31
54
|
const p = options?.p ?? DEFAULTS.p;
|
|
32
|
-
const salt =
|
|
55
|
+
const salt = crypto.getRandomValues(new Uint8Array(DEFAULTS.saltLen));
|
|
33
56
|
const hash = scryptSync(password, salt, { N, r, p, dkLen: DEFAULTS.dkLen });
|
|
34
57
|
const ln = Math.log2(N);
|
|
35
|
-
const saltB64 =
|
|
36
|
-
const hashB64 =
|
|
58
|
+
const saltB64 = uint8ToBase64(salt);
|
|
59
|
+
const hashB64 = uint8ToBase64(hash);
|
|
37
60
|
return `$scrypt$ln=${ln},r=${r},p=${p}$${saltB64}$${hashB64}`;
|
|
38
61
|
}
|
|
39
62
|
/**
|
|
@@ -45,7 +68,7 @@ export async function verifyPassword(storedHash, password) {
|
|
|
45
68
|
return false;
|
|
46
69
|
const { N, r, p, salt, hash } = parsed;
|
|
47
70
|
const derived = scryptSync(password, salt, { N, r, p, dkLen: hash.length });
|
|
48
|
-
return
|
|
71
|
+
return constantTimeEqual(new Uint8Array(derived), hash);
|
|
49
72
|
}
|
|
50
73
|
/**
|
|
51
74
|
* Check if a hash needs rehashing (params differ from current defaults).
|
|
@@ -69,12 +92,7 @@ function parsePhc(phc) {
|
|
|
69
92
|
N: 2 ** parseInt(match[1], 10),
|
|
70
93
|
r: parseInt(match[2], 10),
|
|
71
94
|
p: parseInt(match[3], 10),
|
|
72
|
-
salt:
|
|
73
|
-
hash:
|
|
95
|
+
salt: base64ToUint8(match[4]),
|
|
96
|
+
hash: base64ToUint8(match[5]),
|
|
74
97
|
};
|
|
75
98
|
}
|
|
76
|
-
function timingSafeEqual(a, b) {
|
|
77
|
-
if (a.length !== b.length)
|
|
78
|
-
return false;
|
|
79
|
-
return tse(a, b);
|
|
80
|
-
}
|
package/dist/esm/stores.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* For production with multiple server instances, implement the ChallengeStore
|
|
5
5
|
* and CredentialStore interfaces with a shared backend (Redis, database, etc).
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { readFile, writeFile } from 'fs/promises';
|
|
8
|
+
import { mkdirSync, existsSync } from 'fs';
|
|
8
9
|
import { dirname } from 'path';
|
|
9
10
|
// ============================================================
|
|
10
11
|
// In-Memory Stores (good for development and single-process)
|
|
@@ -63,35 +64,39 @@ export class FileChallengeStore {
|
|
|
63
64
|
if (!existsSync(dir))
|
|
64
65
|
mkdirSync(dir, { recursive: true });
|
|
65
66
|
}
|
|
66
|
-
load() {
|
|
67
|
+
async load() {
|
|
67
68
|
try {
|
|
68
|
-
|
|
69
|
+
const raw = await readFile(this.filePath, 'utf-8');
|
|
70
|
+
return JSON.parse(raw);
|
|
69
71
|
}
|
|
70
|
-
catch {
|
|
71
|
-
|
|
72
|
+
catch (err) {
|
|
73
|
+
// File not yet created — valid initial state
|
|
74
|
+
if (err?.code === 'ENOENT')
|
|
75
|
+
return {};
|
|
76
|
+
// Anything else (permission denied, corrupted JSON) must surface
|
|
77
|
+
throw err;
|
|
72
78
|
}
|
|
73
79
|
}
|
|
74
|
-
persist(data) {
|
|
75
|
-
// Clean expired
|
|
80
|
+
async persist(data) {
|
|
76
81
|
const now = Date.now();
|
|
77
82
|
for (const [key, val] of Object.entries(data)) {
|
|
78
83
|
if (now > val.expiresAt)
|
|
79
84
|
delete data[key];
|
|
80
85
|
}
|
|
81
|
-
|
|
86
|
+
await writeFile(this.filePath, JSON.stringify(data, null, 2));
|
|
82
87
|
}
|
|
83
88
|
async save(key, challenge) {
|
|
84
|
-
const data = this.load();
|
|
89
|
+
const data = await this.load();
|
|
85
90
|
data[key] = challenge;
|
|
86
|
-
this.persist(data);
|
|
91
|
+
await this.persist(data);
|
|
87
92
|
}
|
|
88
93
|
async consume(key) {
|
|
89
|
-
const data = this.load();
|
|
94
|
+
const data = await this.load();
|
|
90
95
|
const challenge = data[key];
|
|
91
96
|
if (!challenge)
|
|
92
97
|
return null;
|
|
93
98
|
delete data[key];
|
|
94
|
-
this.persist(data);
|
|
99
|
+
await this.persist(data);
|
|
95
100
|
if (Date.now() > challenge.expiresAt)
|
|
96
101
|
return null;
|
|
97
102
|
return challenge;
|
|
@@ -108,38 +113,41 @@ export class FileCredentialStore {
|
|
|
108
113
|
if (!existsSync(dir))
|
|
109
114
|
mkdirSync(dir, { recursive: true });
|
|
110
115
|
}
|
|
111
|
-
load() {
|
|
116
|
+
async load() {
|
|
112
117
|
try {
|
|
113
|
-
|
|
118
|
+
const raw = await readFile(this.filePath, 'utf-8');
|
|
119
|
+
return JSON.parse(raw);
|
|
114
120
|
}
|
|
115
|
-
catch {
|
|
116
|
-
|
|
121
|
+
catch (err) {
|
|
122
|
+
if (err?.code === 'ENOENT')
|
|
123
|
+
return [];
|
|
124
|
+
throw err;
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
|
-
persist(data) {
|
|
120
|
-
|
|
127
|
+
async persist(data) {
|
|
128
|
+
await writeFile(this.filePath, JSON.stringify(data, null, 2));
|
|
121
129
|
}
|
|
122
130
|
async save(credential) {
|
|
123
|
-
const data = this.load();
|
|
131
|
+
const data = await this.load();
|
|
124
132
|
data.push(credential);
|
|
125
|
-
this.persist(data);
|
|
133
|
+
await this.persist(data);
|
|
126
134
|
}
|
|
127
135
|
async getByUserId(userId) {
|
|
128
|
-
return this.load().filter(c => c.userId === userId);
|
|
136
|
+
return (await this.load()).filter(c => c.userId === userId);
|
|
129
137
|
}
|
|
130
138
|
async getByCredentialId(credentialId) {
|
|
131
|
-
return this.load().find(c => c.credentialId === credentialId) ?? null;
|
|
139
|
+
return (await this.load()).find(c => c.credentialId === credentialId) ?? null;
|
|
132
140
|
}
|
|
133
141
|
async updateCounter(credentialId, newCounter) {
|
|
134
|
-
const data = this.load();
|
|
142
|
+
const data = await this.load();
|
|
135
143
|
const cred = data.find(c => c.credentialId === credentialId);
|
|
136
144
|
if (cred) {
|
|
137
145
|
cred.counter = newCounter;
|
|
138
|
-
this.persist(data);
|
|
146
|
+
await this.persist(data);
|
|
139
147
|
}
|
|
140
148
|
}
|
|
141
149
|
async delete(credentialId) {
|
|
142
|
-
const data = this.load().filter(c => c.credentialId !== credentialId);
|
|
143
|
-
this.persist(data);
|
|
150
|
+
const data = (await this.load()).filter(c => c.credentialId !== credentialId);
|
|
151
|
+
await this.persist(data);
|
|
144
152
|
}
|
|
145
153
|
}
|
package/dist/express-routes.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @ai_context This is a convenience wrapper. Apps that don't use Express
|
|
5
5
|
* can use PasskeyServer directly. These routes implement the standard
|
|
6
|
-
* challenge-response pattern with proper error handling.
|
|
6
|
+
* challenge-response pattern with proper error handling and Zod validation.
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
9
|
* const routes = createExpressRoutes(passkeyServer, { getUserInfo });
|
package/dist/express-routes.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* @ai_context This is a convenience wrapper. Apps that don't use Express
|
|
6
6
|
* can use PasskeyServer directly. These routes implement the standard
|
|
7
|
-
* challenge-response pattern with proper error handling.
|
|
7
|
+
* challenge-response pattern with proper error handling and Zod validation.
|
|
8
8
|
*
|
|
9
9
|
* Usage:
|
|
10
10
|
* const routes = createExpressRoutes(passkeyServer, { getUserInfo });
|
|
@@ -13,6 +13,43 @@
|
|
|
13
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
14
|
exports.createExpressRoutes = createExpressRoutes;
|
|
15
15
|
const express_1 = require("express");
|
|
16
|
+
const zod_1 = require("zod");
|
|
17
|
+
// ============================================================
|
|
18
|
+
// Zod Schemas — strict input validation for every route
|
|
19
|
+
// ============================================================
|
|
20
|
+
const registerOptionsSchema = zod_1.z.object({
|
|
21
|
+
userId: zod_1.z.string().min(1),
|
|
22
|
+
authenticatorAttachment: zod_1.z.enum(['platform', 'cross-platform']).optional(),
|
|
23
|
+
residentKey: zod_1.z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
24
|
+
userVerification: zod_1.z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
25
|
+
}).strict();
|
|
26
|
+
const registerVerifySchema = zod_1.z.object({
|
|
27
|
+
userId: zod_1.z.string().min(1),
|
|
28
|
+
response: zod_1.z.object({
|
|
29
|
+
id: zod_1.z.string(),
|
|
30
|
+
rawId: zod_1.z.string(),
|
|
31
|
+
type: zod_1.z.literal('public-key'),
|
|
32
|
+
response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
33
|
+
clientExtensionResults: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
34
|
+
authenticatorAttachment: zod_1.z.string().optional(),
|
|
35
|
+
}).passthrough(),
|
|
36
|
+
credentialName: zod_1.z.string().optional(),
|
|
37
|
+
challengeToken: zod_1.z.string().optional(),
|
|
38
|
+
}).strict();
|
|
39
|
+
const authenticateOptionsSchema = zod_1.z.object({
|
|
40
|
+
userId: zod_1.z.string().min(1).optional(),
|
|
41
|
+
userVerification: zod_1.z.enum(['required', 'preferred', 'discouraged']).optional(),
|
|
42
|
+
}).strict();
|
|
43
|
+
const authenticateVerifySchema = zod_1.z.object({
|
|
44
|
+
sessionKey: zod_1.z.string().min(1),
|
|
45
|
+
response: zod_1.z.object({
|
|
46
|
+
id: zod_1.z.string(),
|
|
47
|
+
rawId: zod_1.z.string(),
|
|
48
|
+
type: zod_1.z.literal('public-key'),
|
|
49
|
+
response: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
50
|
+
clientExtensionResults: zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()),
|
|
51
|
+
}).passthrough(),
|
|
52
|
+
}).strict();
|
|
16
53
|
/**
|
|
17
54
|
* Create Express router with passkey registration and authentication routes.
|
|
18
55
|
*
|
|
@@ -24,18 +61,14 @@ const express_1 = require("express");
|
|
|
24
61
|
*/
|
|
25
62
|
function createExpressRoutes(server, config) {
|
|
26
63
|
const router = (0, express_1.Router)();
|
|
27
|
-
/**
|
|
28
|
-
* POST /register/options
|
|
29
|
-
* Body: { userId: string, authenticatorAttachment?: 'platform' | 'cross-platform' }
|
|
30
|
-
* Response includes `challengeToken` in stateless mode.
|
|
31
|
-
*/
|
|
32
64
|
router.post('/register/options', async (req, res) => {
|
|
33
65
|
try {
|
|
34
|
-
const
|
|
35
|
-
if (!
|
|
36
|
-
res.status(400).json({ error: '
|
|
66
|
+
const parsed = registerOptionsSchema.safeParse(req.body);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
37
69
|
return;
|
|
38
70
|
}
|
|
71
|
+
const { userId, authenticatorAttachment, residentKey, userVerification } = parsed.data;
|
|
39
72
|
const user = await config.getUserInfo(userId);
|
|
40
73
|
if (!user) {
|
|
41
74
|
res.status(404).json({ error: 'User not found' });
|
|
@@ -53,18 +86,14 @@ function createExpressRoutes(server, config) {
|
|
|
53
86
|
res.status(500).json({ error: 'Failed to generate registration options' });
|
|
54
87
|
}
|
|
55
88
|
});
|
|
56
|
-
/**
|
|
57
|
-
* POST /register/verify
|
|
58
|
-
* Body: { userId: string, response: RegistrationResponseJSON, credentialName?: string, challengeToken?: string }
|
|
59
|
-
* `challengeToken` is required in stateless mode.
|
|
60
|
-
*/
|
|
61
89
|
router.post('/register/verify', async (req, res) => {
|
|
62
90
|
try {
|
|
63
|
-
const
|
|
64
|
-
if (!
|
|
65
|
-
res.status(400).json({ error: '
|
|
91
|
+
const parsed = registerVerifySchema.safeParse(req.body);
|
|
92
|
+
if (!parsed.success) {
|
|
93
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
66
94
|
return;
|
|
67
95
|
}
|
|
96
|
+
const { userId, response, credentialName, challengeToken } = parsed.data;
|
|
68
97
|
const result = await server.verifyRegistration(userId, response, credentialName, challengeToken);
|
|
69
98
|
if (config.onRegistrationSuccess) {
|
|
70
99
|
await config.onRegistrationSuccess(userId, result.credential.credentialId);
|
|
@@ -81,14 +110,14 @@ function createExpressRoutes(server, config) {
|
|
|
81
110
|
res.status(400).json({ error: message });
|
|
82
111
|
}
|
|
83
112
|
});
|
|
84
|
-
/**
|
|
85
|
-
* POST /authenticate/options
|
|
86
|
-
* Body: { userId?: string }
|
|
87
|
-
* Response includes `sessionKey` (which IS the challengeToken in stateless mode).
|
|
88
|
-
*/
|
|
89
113
|
router.post('/authenticate/options', async (req, res) => {
|
|
90
114
|
try {
|
|
91
|
-
const
|
|
115
|
+
const parsed = authenticateOptionsSchema.safeParse(req.body);
|
|
116
|
+
if (!parsed.success) {
|
|
117
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const { userId, userVerification } = parsed.data;
|
|
92
121
|
const { options, sessionKey, challengeToken } = await server.generateAuthenticationOptions(userId, { userVerification });
|
|
93
122
|
res.json({ options, sessionKey, challengeToken });
|
|
94
123
|
}
|
|
@@ -97,17 +126,14 @@ function createExpressRoutes(server, config) {
|
|
|
97
126
|
res.status(500).json({ error: 'Failed to generate authentication options' });
|
|
98
127
|
}
|
|
99
128
|
});
|
|
100
|
-
/**
|
|
101
|
-
* POST /authenticate/verify
|
|
102
|
-
* Body: { sessionKey: string, response: AuthenticationResponseJSON }
|
|
103
|
-
*/
|
|
104
129
|
router.post('/authenticate/verify', async (req, res) => {
|
|
105
130
|
try {
|
|
106
|
-
const
|
|
107
|
-
if (!
|
|
108
|
-
res.status(400).json({ error: '
|
|
131
|
+
const parsed = authenticateVerifySchema.safeParse(req.body);
|
|
132
|
+
if (!parsed.success) {
|
|
133
|
+
res.status(400).json({ error: 'Invalid request body', details: parsed.error.flatten().fieldErrors });
|
|
109
134
|
return;
|
|
110
135
|
}
|
|
136
|
+
const { sessionKey, response } = parsed.data;
|
|
111
137
|
const result = await server.verifyAuthentication(sessionKey, response);
|
|
112
138
|
let extra = {};
|
|
113
139
|
if (config.onAuthenticationSuccess) {
|
package/dist/passkey-server.js
CHANGED
|
@@ -81,13 +81,14 @@ class PasskeyServer {
|
|
|
81
81
|
});
|
|
82
82
|
}
|
|
83
83
|
else {
|
|
84
|
-
// Stateless: encrypt challenge into token
|
|
85
|
-
|
|
84
|
+
// Stateless: encrypt challenge into token (use first key for encryption)
|
|
85
|
+
const primaryKey = Array.isArray(this.encryptionKey) ? this.encryptionKey[0] : this.encryptionKey;
|
|
86
|
+
challengeToken = await (0, challenge_token_js_1.sealChallengeToken)({
|
|
86
87
|
challenge: options.challenge,
|
|
87
88
|
userId: user.id,
|
|
88
89
|
type: 'registration',
|
|
89
90
|
exp: Date.now() + this.challengeTTL,
|
|
90
|
-
},
|
|
91
|
+
}, primaryKey);
|
|
91
92
|
}
|
|
92
93
|
return { ...options, challengeToken };
|
|
93
94
|
}
|
|
@@ -114,7 +115,7 @@ class PasskeyServer {
|
|
|
114
115
|
else {
|
|
115
116
|
if (!challengeToken)
|
|
116
117
|
throw new Error('challengeToken is required in stateless mode');
|
|
117
|
-
const payload = (0, challenge_token_js_1.openChallengeToken)(challengeToken, this.encryptionKey);
|
|
118
|
+
const payload = await (0, challenge_token_js_1.openChallengeToken)(challengeToken, this.encryptionKey);
|
|
118
119
|
if (!payload)
|
|
119
120
|
throw new Error('Invalid or expired challenge token');
|
|
120
121
|
if (payload.type !== 'registration')
|
|
@@ -178,12 +179,13 @@ class PasskeyServer {
|
|
|
178
179
|
}
|
|
179
180
|
else {
|
|
180
181
|
// Stateless: encrypt into token (sessionKey IS the token)
|
|
181
|
-
|
|
182
|
+
const primaryKey = Array.isArray(this.encryptionKey) ? this.encryptionKey[0] : this.encryptionKey;
|
|
183
|
+
challengeToken = await (0, challenge_token_js_1.sealChallengeToken)({
|
|
182
184
|
challenge: options.challenge,
|
|
183
185
|
userId,
|
|
184
186
|
type: 'authentication',
|
|
185
187
|
exp: Date.now() + this.challengeTTL,
|
|
186
|
-
},
|
|
188
|
+
}, primaryKey);
|
|
187
189
|
sessionKey = challengeToken;
|
|
188
190
|
}
|
|
189
191
|
return { options, sessionKey, challengeToken };
|
|
@@ -205,7 +207,7 @@ class PasskeyServer {
|
|
|
205
207
|
}
|
|
206
208
|
else {
|
|
207
209
|
// In stateless mode, sessionKey IS the challengeToken
|
|
208
|
-
const payload = (0, challenge_token_js_1.openChallengeToken)(sessionKey, this.encryptionKey);
|
|
210
|
+
const payload = await (0, challenge_token_js_1.openChallengeToken)(sessionKey, this.encryptionKey);
|
|
209
211
|
if (!payload)
|
|
210
212
|
throw new Error('Invalid or expired challenge token');
|
|
211
213
|
if (payload.type !== 'authentication')
|
package/dist/password.d.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
* @noble/hashes is audited by Trail of Bits and works on every runtime:
|
|
6
6
|
* Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge, browser.
|
|
7
7
|
*
|
|
8
|
+
* Uses Web Crypto API for random salt generation — no node:crypto dependency.
|
|
9
|
+
*
|
|
8
10
|
* For users who want argon2id (requires native bindings), see the
|
|
9
11
|
* `@passkeykit/server/argon2` subpath export.
|
|
10
12
|
*
|
package/dist/password.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* @noble/hashes is audited by Trail of Bits and works on every runtime:
|
|
7
7
|
* Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge, browser.
|
|
8
8
|
*
|
|
9
|
+
* Uses Web Crypto API for random salt generation — no node:crypto dependency.
|
|
10
|
+
*
|
|
9
11
|
* For users who want argon2id (requires native bindings), see the
|
|
10
12
|
* `@passkeykit/server/argon2` subpath export.
|
|
11
13
|
*
|
|
@@ -17,7 +19,6 @@ exports.hashPassword = hashPassword;
|
|
|
17
19
|
exports.verifyPassword = verifyPassword;
|
|
18
20
|
exports.needsRehash = needsRehash;
|
|
19
21
|
const scrypt_1 = require("@noble/hashes/scrypt");
|
|
20
|
-
const crypto_1 = require("crypto");
|
|
21
22
|
/** Default scrypt parameters (OWASP recommendations for interactive login) */
|
|
22
23
|
const DEFAULTS = {
|
|
23
24
|
N: 2 ** 17, // 131072 — CPU/memory cost
|
|
@@ -26,6 +27,28 @@ const DEFAULTS = {
|
|
|
26
27
|
dkLen: 32, // Output key length
|
|
27
28
|
saltLen: 16, // Salt length
|
|
28
29
|
};
|
|
30
|
+
// --- Base64 helpers (no Buffer dependency) ---
|
|
31
|
+
function uint8ToBase64(buf) {
|
|
32
|
+
const binStr = Array.from(buf, b => String.fromCharCode(b)).join('');
|
|
33
|
+
return btoa(binStr);
|
|
34
|
+
}
|
|
35
|
+
function base64ToUint8(str) {
|
|
36
|
+
const binStr = atob(str);
|
|
37
|
+
return Uint8Array.from(binStr, c => c.charCodeAt(0));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Constant-time comparison — prevents timing attacks on hash verification.
|
|
41
|
+
* Works on all runtimes (no node:crypto dependency).
|
|
42
|
+
*/
|
|
43
|
+
function constantTimeEqual(a, b) {
|
|
44
|
+
if (a.length !== b.length)
|
|
45
|
+
return false;
|
|
46
|
+
let result = 0;
|
|
47
|
+
for (let i = 0; i < a.length; i++) {
|
|
48
|
+
result |= a[i] ^ b[i];
|
|
49
|
+
}
|
|
50
|
+
return result === 0;
|
|
51
|
+
}
|
|
29
52
|
/**
|
|
30
53
|
* Hash a password using scrypt.
|
|
31
54
|
* Returns a PHC-format string: $scrypt$ln=<log2N>,r=<r>,p=<p>$<salt>$<hash>
|
|
@@ -34,11 +57,11 @@ async function hashPassword(password, options) {
|
|
|
34
57
|
const N = options?.N ?? DEFAULTS.N;
|
|
35
58
|
const r = options?.r ?? DEFAULTS.r;
|
|
36
59
|
const p = options?.p ?? DEFAULTS.p;
|
|
37
|
-
const salt = (
|
|
60
|
+
const salt = crypto.getRandomValues(new Uint8Array(DEFAULTS.saltLen));
|
|
38
61
|
const hash = (0, scrypt_1.scrypt)(password, salt, { N, r, p, dkLen: DEFAULTS.dkLen });
|
|
39
62
|
const ln = Math.log2(N);
|
|
40
|
-
const saltB64 =
|
|
41
|
-
const hashB64 =
|
|
63
|
+
const saltB64 = uint8ToBase64(salt);
|
|
64
|
+
const hashB64 = uint8ToBase64(hash);
|
|
42
65
|
return `$scrypt$ln=${ln},r=${r},p=${p}$${saltB64}$${hashB64}`;
|
|
43
66
|
}
|
|
44
67
|
/**
|
|
@@ -50,7 +73,7 @@ async function verifyPassword(storedHash, password) {
|
|
|
50
73
|
return false;
|
|
51
74
|
const { N, r, p, salt, hash } = parsed;
|
|
52
75
|
const derived = (0, scrypt_1.scrypt)(password, salt, { N, r, p, dkLen: hash.length });
|
|
53
|
-
return
|
|
76
|
+
return constantTimeEqual(new Uint8Array(derived), hash);
|
|
54
77
|
}
|
|
55
78
|
/**
|
|
56
79
|
* Check if a hash needs rehashing (params differ from current defaults).
|
|
@@ -74,12 +97,7 @@ function parsePhc(phc) {
|
|
|
74
97
|
N: 2 ** parseInt(match[1], 10),
|
|
75
98
|
r: parseInt(match[2], 10),
|
|
76
99
|
p: parseInt(match[3], 10),
|
|
77
|
-
salt:
|
|
78
|
-
hash:
|
|
100
|
+
salt: base64ToUint8(match[4]),
|
|
101
|
+
hash: base64ToUint8(match[5]),
|
|
79
102
|
};
|
|
80
103
|
}
|
|
81
|
-
function timingSafeEqual(a, b) {
|
|
82
|
-
if (a.length !== b.length)
|
|
83
|
-
return false;
|
|
84
|
-
return (0, crypto_1.timingSafeEqual)(a, b);
|
|
85
|
-
}
|
package/dist/stores.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
9
|
exports.FileCredentialStore = exports.FileChallengeStore = exports.MemoryCredentialStore = exports.MemoryChallengeStore = void 0;
|
|
10
|
+
const promises_1 = require("fs/promises");
|
|
10
11
|
const fs_1 = require("fs");
|
|
11
12
|
const path_1 = require("path");
|
|
12
13
|
// ============================================================
|
|
@@ -68,35 +69,39 @@ class FileChallengeStore {
|
|
|
68
69
|
if (!(0, fs_1.existsSync)(dir))
|
|
69
70
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
70
71
|
}
|
|
71
|
-
load() {
|
|
72
|
+
async load() {
|
|
72
73
|
try {
|
|
73
|
-
|
|
74
|
+
const raw = await (0, promises_1.readFile)(this.filePath, 'utf-8');
|
|
75
|
+
return JSON.parse(raw);
|
|
74
76
|
}
|
|
75
|
-
catch {
|
|
76
|
-
|
|
77
|
+
catch (err) {
|
|
78
|
+
// File not yet created — valid initial state
|
|
79
|
+
if (err?.code === 'ENOENT')
|
|
80
|
+
return {};
|
|
81
|
+
// Anything else (permission denied, corrupted JSON) must surface
|
|
82
|
+
throw err;
|
|
77
83
|
}
|
|
78
84
|
}
|
|
79
|
-
persist(data) {
|
|
80
|
-
// Clean expired
|
|
85
|
+
async persist(data) {
|
|
81
86
|
const now = Date.now();
|
|
82
87
|
for (const [key, val] of Object.entries(data)) {
|
|
83
88
|
if (now > val.expiresAt)
|
|
84
89
|
delete data[key];
|
|
85
90
|
}
|
|
86
|
-
(0,
|
|
91
|
+
await (0, promises_1.writeFile)(this.filePath, JSON.stringify(data, null, 2));
|
|
87
92
|
}
|
|
88
93
|
async save(key, challenge) {
|
|
89
|
-
const data = this.load();
|
|
94
|
+
const data = await this.load();
|
|
90
95
|
data[key] = challenge;
|
|
91
|
-
this.persist(data);
|
|
96
|
+
await this.persist(data);
|
|
92
97
|
}
|
|
93
98
|
async consume(key) {
|
|
94
|
-
const data = this.load();
|
|
99
|
+
const data = await this.load();
|
|
95
100
|
const challenge = data[key];
|
|
96
101
|
if (!challenge)
|
|
97
102
|
return null;
|
|
98
103
|
delete data[key];
|
|
99
|
-
this.persist(data);
|
|
104
|
+
await this.persist(data);
|
|
100
105
|
if (Date.now() > challenge.expiresAt)
|
|
101
106
|
return null;
|
|
102
107
|
return challenge;
|
|
@@ -114,39 +119,42 @@ class FileCredentialStore {
|
|
|
114
119
|
if (!(0, fs_1.existsSync)(dir))
|
|
115
120
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
|
116
121
|
}
|
|
117
|
-
load() {
|
|
122
|
+
async load() {
|
|
118
123
|
try {
|
|
119
|
-
|
|
124
|
+
const raw = await (0, promises_1.readFile)(this.filePath, 'utf-8');
|
|
125
|
+
return JSON.parse(raw);
|
|
120
126
|
}
|
|
121
|
-
catch {
|
|
122
|
-
|
|
127
|
+
catch (err) {
|
|
128
|
+
if (err?.code === 'ENOENT')
|
|
129
|
+
return [];
|
|
130
|
+
throw err;
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
|
-
persist(data) {
|
|
126
|
-
(0,
|
|
133
|
+
async persist(data) {
|
|
134
|
+
await (0, promises_1.writeFile)(this.filePath, JSON.stringify(data, null, 2));
|
|
127
135
|
}
|
|
128
136
|
async save(credential) {
|
|
129
|
-
const data = this.load();
|
|
137
|
+
const data = await this.load();
|
|
130
138
|
data.push(credential);
|
|
131
|
-
this.persist(data);
|
|
139
|
+
await this.persist(data);
|
|
132
140
|
}
|
|
133
141
|
async getByUserId(userId) {
|
|
134
|
-
return this.load().filter(c => c.userId === userId);
|
|
142
|
+
return (await this.load()).filter(c => c.userId === userId);
|
|
135
143
|
}
|
|
136
144
|
async getByCredentialId(credentialId) {
|
|
137
|
-
return this.load().find(c => c.credentialId === credentialId) ?? null;
|
|
145
|
+
return (await this.load()).find(c => c.credentialId === credentialId) ?? null;
|
|
138
146
|
}
|
|
139
147
|
async updateCounter(credentialId, newCounter) {
|
|
140
|
-
const data = this.load();
|
|
148
|
+
const data = await this.load();
|
|
141
149
|
const cred = data.find(c => c.credentialId === credentialId);
|
|
142
150
|
if (cred) {
|
|
143
151
|
cred.counter = newCounter;
|
|
144
|
-
this.persist(data);
|
|
152
|
+
await this.persist(data);
|
|
145
153
|
}
|
|
146
154
|
}
|
|
147
155
|
async delete(credentialId) {
|
|
148
|
-
const data = this.load().filter(c => c.credentialId !== credentialId);
|
|
149
|
-
this.persist(data);
|
|
156
|
+
const data = (await this.load()).filter(c => c.credentialId !== credentialId);
|
|
157
|
+
await this.persist(data);
|
|
150
158
|
}
|
|
151
159
|
}
|
|
152
160
|
exports.FileCredentialStore = FileCredentialStore;
|
package/dist/types.d.ts
CHANGED
|
@@ -24,11 +24,16 @@ export interface PasskeyServerConfig {
|
|
|
24
24
|
/** Challenge TTL in ms (default: 5 minutes) */
|
|
25
25
|
challengeTTL?: number;
|
|
26
26
|
/**
|
|
27
|
-
* Secret key for stateless challenge tokens (AES-256-GCM).
|
|
27
|
+
* Secret key(s) for stateless challenge tokens (AES-256-GCM).
|
|
28
28
|
* Required when `challengeStore` is not provided.
|
|
29
|
+
*
|
|
30
|
+
* **Key rotation:** Pass an array of secrets. The first key is used to encrypt
|
|
31
|
+
* new tokens. All keys are tried when decrypting, so you can rotate secrets
|
|
32
|
+
* without breaking in-flight registration/authentication flows.
|
|
33
|
+
*
|
|
29
34
|
* Must be at least 32 characters. Derive from env: process.env.PASSKEY_SECRET
|
|
30
35
|
*/
|
|
31
|
-
encryptionKey?: string;
|
|
36
|
+
encryptionKey?: string | string[];
|
|
32
37
|
}
|
|
33
38
|
/** A stored WebAuthn credential (persisted per-user) */
|
|
34
39
|
export interface StoredCredential {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passkeykit/server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Server-side WebAuthn passkey verification — stateless or stateful, pure JS, works on serverless",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -54,12 +54,13 @@
|
|
|
54
54
|
],
|
|
55
55
|
"license": "MIT",
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@noble/hashes": "^1.7.0"
|
|
57
|
+
"@noble/hashes": "^1.7.0",
|
|
58
|
+
"zod": "^4.3.6"
|
|
58
59
|
},
|
|
59
60
|
"peerDependencies": {
|
|
60
61
|
"@simplewebauthn/server": "^13.0.0",
|
|
61
|
-
"
|
|
62
|
-
"
|
|
62
|
+
"argon2": "^0.41.0",
|
|
63
|
+
"express": "^4.0.0 || ^5.0.0"
|
|
63
64
|
},
|
|
64
65
|
"peerDependenciesMeta": {
|
|
65
66
|
"@simplewebauthn/server": {
|