@passkeykit/server 2.0.2 → 3.0.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 CHANGED
@@ -10,9 +10,19 @@ Handles challenge generation, attestation/assertion verification, and includes s
10
10
  ## Install
11
11
 
12
12
  ```bash
13
- npm install @passkeykit/server
13
+ npm install @passkeykit/server @simplewebauthn/server
14
14
  ```
15
15
 
16
+ > `@simplewebauthn/server` is a **peer dependency** — you control the version. This keeps the package itself lightweight while giving you full WebAuthn verification.
17
+ >
18
+ > **Password-only?** If you only need `hashPassword` / `verifyPassword`, import from the subpath — no WebAuthn dependency required:
19
+ > ```bash
20
+ > npm install @passkeykit/server
21
+ > ```
22
+ > ```typescript
23
+ > import { hashPassword, verifyPassword } from '@passkeykit/server/password';
24
+ > ```
25
+
16
26
  ## Quick Start
17
27
 
18
28
  ### Stateless (Serverless / Vercel / Cloudflare)
@@ -205,7 +215,7 @@ interface PasskeyServerConfig {
205
215
  credentialStore: CredentialStore;
206
216
 
207
217
  // Stateless mode (default — pick one):
208
- encryptionKey?: string; // 32+ char secret for AES-256-GCM challenge tokens
218
+ encryptionKey?: string | string[]; // AES-256-GCM secret(s) — see Key Rotation below
209
219
 
210
220
  // Stateful mode (alternative):
211
221
  challengeStore?: ChallengeStore;
@@ -215,13 +225,44 @@ interface PasskeyServerConfig {
215
225
  }
216
226
  ```
217
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
+
218
258
  ## Exports
219
259
 
220
- | Import Path | Contents |
221
- |-------------|----------|
222
- | `@passkeykit/server` | `PasskeyServer`, stores, password hashing, types |
223
- | `@passkeykit/server/express` | `createExpressRoutes()` — ready-made Express router |
224
- | `@passkeykit/server/argon2` | `hashPassword()`, `verifyPassword()`native argon2id |
260
+ | Import Path | Contents | Requires |
261
+ |-------------|----------|----------|
262
+ | `@passkeykit/server` | `PasskeyServer`, stores, password hashing, types | `@simplewebauthn/server` |
263
+ | `@passkeykit/server/password` | `hashPassword()`, `verifyPassword()`, `needsRehash()` scrypt | None (pure JS) |
264
+ | `@passkeykit/server/express` | `createExpressRoutes()` — ready-made Express router | `express` |
265
+ | `@passkeykit/server/argon2` | `hashPassword()`, `verifyPassword()` — native argon2id | `argon2` |
225
266
 
226
267
  ## Client Pairing
227
268
 
@@ -1,20 +1,26 @@
1
1
  /**
2
- * Stateless challenge token using AES-256-GCM + HMAC-SHA256.
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
- * (the WebAuthn spec itself prevents replays via the challenge binding)
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. Returns null if invalid/expired.
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>;
@@ -1,69 +1,77 @@
1
1
  "use strict";
2
2
  /**
3
- * Stateless challenge token using AES-256-GCM + HMAC-SHA256.
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
- * (the WebAuthn spec itself prevents replays via the challenge binding)
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 TAG_LEN = 16;
30
+ const HKDF_INFO = new TextEncoder().encode('passkey-kit-challenge-key');
27
31
  /**
28
- * Derive a 32-byte encryption key from a secret string.
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
- return (0, crypto_1.createHmac)('sha256', 'passkey-kit-challenge-key')
33
- .update(secret)
34
- .digest();
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 = (0, crypto_1.randomBytes)(IV_LEN);
42
- const cipher = (0, crypto_1.createCipheriv)(ALG, key, iv);
43
- const json = JSON.stringify(payload);
44
- const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
45
- const tag = cipher.getAuthTag();
46
- // iv (12) + ciphertext (variable) + tag (16)
47
- const combined = Buffer.concat([iv, encrypted, tag]);
48
- return combined.toString('base64url');
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. Returns null if invalid/expired.
65
+ * Decrypt and verify a challenge token with a single key.
66
+ * Returns null if invalid/expired.
52
67
  */
53
- function openChallengeToken(token, secret) {
68
+ async function openWithKey(buf, secret) {
54
69
  try {
55
- const key = deriveKey(secret);
56
- const buf = Buffer.from(token, 'base64url');
57
- if (buf.length < IV_LEN + TAG_LEN + 1)
58
- return null;
59
- const iv = buf.subarray(0, IV_LEN);
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 + HMAC-SHA256.
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
- * (the WebAuthn spec itself prevents replays via the challenge binding)
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 TAG_LEN = 16;
26
+ const HKDF_INFO = new TextEncoder().encode('passkey-kit-challenge-key');
23
27
  /**
24
- * Derive a 32-byte encryption key from a secret string.
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
- return createHmac('sha256', 'passkey-kit-challenge-key')
29
- .update(secret)
30
- .digest();
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 = randomBytes(IV_LEN);
38
- const cipher = createCipheriv(ALG, key, iv);
39
- const json = JSON.stringify(payload);
40
- const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()]);
41
- const tag = cipher.getAuthTag();
42
- // iv (12) + ciphertext (variable) + tag (16)
43
- const combined = Buffer.concat([iv, encrypted, tag]);
44
- return combined.toString('base64url');
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. Returns null if invalid/expired.
61
+ * Decrypt and verify a challenge token with a single key.
62
+ * Returns null if invalid/expired.
48
63
  */
49
- export function openChallengeToken(token, secret) {
64
+ async function openWithKey(buf, secret) {
50
65
  try {
51
- const key = deriveKey(secret);
52
- const buf = Buffer.from(token, 'base64url');
53
- if (buf.length < IV_LEN + TAG_LEN + 1)
54
- return null;
55
- const iv = buf.subarray(0, IV_LEN);
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
+ }
package/dist/esm/index.js CHANGED
@@ -4,10 +4,6 @@
4
4
  * Server-side WebAuthn passkey verification with challenge-response pattern
5
5
  * and scrypt password hashing (pure JS, works everywhere).
6
6
  *
7
- * @ai_context This is the core auth library used across all dnldev apps.
8
- * Challenge generation and verification MUST happen server-side.
9
- * Client never sees raw challenges — only attestation/assertion responses.
10
- *
11
7
  * Two modes:
12
8
  * - **Stateless** (default): No server-side state. Set `encryptionKey` in config.
13
9
  * - **Stateful**: Provide a `challengeStore` (memory, file, Redis, etc).
@@ -78,13 +78,14 @@ export class PasskeyServer {
78
78
  });
79
79
  }
80
80
  else {
81
- // Stateless: encrypt challenge into token
82
- challengeToken = sealChallengeToken({
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
- }, this.encryptionKey);
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
- challengeToken = sealChallengeToken({
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
- }, this.encryptionKey);
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')
@@ -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 = randomBytes(DEFAULTS.saltLen);
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 = Buffer.from(salt).toString('base64');
36
- const hashB64 = Buffer.from(hash).toString('base64');
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 timingSafeEqual(Buffer.from(derived), hash);
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: Buffer.from(match[4], 'base64'),
73
- hash: Buffer.from(match[5], 'base64'),
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
- }
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Built-in store implementations for common backends.
3
3
  *
4
- * @ai_context These are convenience implementations. For production with
5
- * multiple server instances, use a shared store (Redis, database, Firestore).
6
- * For single-server apps (like MovieBox, MediaBox), FileStore works great.
4
+ * For production with multiple server instances, implement the ChallengeStore
5
+ * and CredentialStore interfaces with a shared backend (Redis, database, etc).
7
6
  */
8
7
  import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
9
8
  import { dirname } from 'path';
@@ -54,7 +53,6 @@ export class MemoryCredentialStore {
54
53
  * File-based challenge store. Challenges are stored in a JSON file.
55
54
  * Auto-cleans expired challenges on every operation.
56
55
  *
57
- * @ai_context Used by MovieBox/MediaBox which store auth in auth.json.
58
56
  * Not suitable for multi-process servers (race conditions on file writes).
59
57
  */
60
58
  export class FileChallengeStore {
package/dist/esm/types.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Type definitions for @passkeykit/server
3
3
  *
4
- * @ai_context These types define the storage interface abstraction.
4
+ * These types define the storage interface abstraction.
5
5
  * Apps provide their own ChallengeStore and CredentialStore implementations
6
- * so the library works with any backend (Firestore, file JSON, SQLite, etc).
6
+ * so the library works with any backend (Firestore, file JSON, SQLite, Redis, etc).
7
7
  */
8
8
  export {};
package/dist/index.d.ts CHANGED
@@ -4,10 +4,6 @@
4
4
  * Server-side WebAuthn passkey verification with challenge-response pattern
5
5
  * and scrypt password hashing (pure JS, works everywhere).
6
6
  *
7
- * @ai_context This is the core auth library used across all dnldev apps.
8
- * Challenge generation and verification MUST happen server-side.
9
- * Client never sees raw challenges — only attestation/assertion responses.
10
- *
11
7
  * Two modes:
12
8
  * - **Stateless** (default): No server-side state. Set `encryptionKey` in config.
13
9
  * - **Stateful**: Provide a `challengeStore` (memory, file, Redis, etc).
package/dist/index.js CHANGED
@@ -5,10 +5,6 @@
5
5
  * Server-side WebAuthn passkey verification with challenge-response pattern
6
6
  * and scrypt password hashing (pure JS, works everywhere).
7
7
  *
8
- * @ai_context This is the core auth library used across all dnldev apps.
9
- * Challenge generation and verification MUST happen server-side.
10
- * Client never sees raw challenges — only attestation/assertion responses.
11
- *
12
8
  * Two modes:
13
9
  * - **Stateless** (default): No server-side state. Set `encryptionKey` in config.
14
10
  * - **Stateful**: Provide a `challengeStore` (memory, file, Redis, etc).
@@ -81,13 +81,14 @@ class PasskeyServer {
81
81
  });
82
82
  }
83
83
  else {
84
- // Stateless: encrypt challenge into token
85
- challengeToken = (0, challenge_token_js_1.sealChallengeToken)({
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
- }, this.encryptionKey);
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
- challengeToken = (0, challenge_token_js_1.sealChallengeToken)({
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
- }, this.encryptionKey);
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')
@@ -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 = (0, crypto_1.randomBytes)(DEFAULTS.saltLen);
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 = Buffer.from(salt).toString('base64');
41
- const hashB64 = Buffer.from(hash).toString('base64');
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 timingSafeEqual(Buffer.from(derived), hash);
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: Buffer.from(match[4], 'base64'),
78
- hash: Buffer.from(match[5], 'base64'),
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.d.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  /**
2
2
  * Built-in store implementations for common backends.
3
3
  *
4
- * @ai_context These are convenience implementations. For production with
5
- * multiple server instances, use a shared store (Redis, database, Firestore).
6
- * For single-server apps (like MovieBox, MediaBox), FileStore works great.
4
+ * For production with multiple server instances, implement the ChallengeStore
5
+ * and CredentialStore interfaces with a shared backend (Redis, database, etc).
7
6
  */
8
7
  import type { ChallengeStore, CredentialStore, StoredChallenge, StoredCredential } from './types.js';
9
8
  export declare class MemoryChallengeStore implements ChallengeStore {
@@ -23,7 +22,6 @@ export declare class MemoryCredentialStore implements CredentialStore {
23
22
  * File-based challenge store. Challenges are stored in a JSON file.
24
23
  * Auto-cleans expired challenges on every operation.
25
24
  *
26
- * @ai_context Used by MovieBox/MediaBox which store auth in auth.json.
27
25
  * Not suitable for multi-process servers (race conditions on file writes).
28
26
  */
29
27
  export declare class FileChallengeStore implements ChallengeStore {
package/dist/stores.js CHANGED
@@ -2,9 +2,8 @@
2
2
  /**
3
3
  * Built-in store implementations for common backends.
4
4
  *
5
- * @ai_context These are convenience implementations. For production with
6
- * multiple server instances, use a shared store (Redis, database, Firestore).
7
- * For single-server apps (like MovieBox, MediaBox), FileStore works great.
5
+ * For production with multiple server instances, implement the ChallengeStore
6
+ * and CredentialStore interfaces with a shared backend (Redis, database, etc).
8
7
  */
9
8
  Object.defineProperty(exports, "__esModule", { value: true });
10
9
  exports.FileCredentialStore = exports.FileChallengeStore = exports.MemoryCredentialStore = exports.MemoryChallengeStore = void 0;
@@ -59,7 +58,6 @@ exports.MemoryCredentialStore = MemoryCredentialStore;
59
58
  * File-based challenge store. Challenges are stored in a JSON file.
60
59
  * Auto-cleans expired challenges on every operation.
61
60
  *
62
- * @ai_context Used by MovieBox/MediaBox which store auth in auth.json.
63
61
  * Not suitable for multi-process servers (race conditions on file writes).
64
62
  */
65
63
  class FileChallengeStore {
package/dist/types.d.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * Type definitions for @passkeykit/server
3
3
  *
4
- * @ai_context These types define the storage interface abstraction.
4
+ * These types define the storage interface abstraction.
5
5
  * Apps provide their own ChallengeStore and CredentialStore implementations
6
- * so the library works with any backend (Firestore, file JSON, SQLite, etc).
6
+ * so the library works with any backend (Firestore, file JSON, SQLite, Redis, etc).
7
7
  */
8
8
  import type { AuthenticatorTransportFuture } from '@simplewebauthn/server';
9
9
  /** Configuration for PasskeyServer */
10
10
  export interface PasskeyServerConfig {
11
- /** Relying Party name shown to users (e.g. "MovieBox", "SafeHarbor") */
11
+ /** Relying Party name shown to users (e.g. "My App") */
12
12
  rpName: string;
13
- /** Relying Party ID — must be a valid domain (e.g. "movies.danieltech.dev") */
13
+ /** Relying Party ID — must be a valid domain (e.g. "auth.example.com") */
14
14
  rpId: string;
15
- /** Allowed origins for WebAuthn (e.g. ["https://movies.danieltech.dev"]) */
15
+ /** Allowed origins for WebAuthn (e.g. ["https://example.com"]) */
16
16
  allowedOrigins: string[];
17
17
  /**
18
18
  * Challenge store implementation (stateful mode).
@@ -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/dist/types.js CHANGED
@@ -2,8 +2,8 @@
2
2
  /**
3
3
  * Type definitions for @passkeykit/server
4
4
  *
5
- * @ai_context These types define the storage interface abstraction.
5
+ * These types define the storage interface abstraction.
6
6
  * Apps provide their own ChallengeStore and CredentialStore implementations
7
- * so the library works with any backend (Firestore, file JSON, SQLite, etc).
7
+ * so the library works with any backend (Firestore, file JSON, SQLite, Redis, etc).
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passkeykit/server",
3
- "version": "2.0.2",
3
+ "version": "3.0.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",
@@ -11,6 +11,11 @@
11
11
  "require": "./dist/index.js",
12
12
  "types": "./dist/index.d.ts"
13
13
  },
14
+ "./password": {
15
+ "import": "./dist/esm/password.js",
16
+ "require": "./dist/password.js",
17
+ "types": "./dist/password.d.ts"
18
+ },
14
19
  "./express": {
15
20
  "import": "./dist/esm/express-routes.js",
16
21
  "require": "./dist/express-routes.js",
@@ -49,14 +54,17 @@
49
54
  ],
50
55
  "license": "MIT",
51
56
  "dependencies": {
52
- "@simplewebauthn/server": "^13.1.1",
53
57
  "@noble/hashes": "^1.7.0"
54
58
  },
55
59
  "peerDependencies": {
60
+ "@simplewebauthn/server": "^13.0.0",
56
61
  "express": "^4.0.0 || ^5.0.0",
57
62
  "argon2": "^0.41.0"
58
63
  },
59
64
  "peerDependenciesMeta": {
65
+ "@simplewebauthn/server": {
66
+ "optional": false
67
+ },
60
68
  "express": {
61
69
  "optional": true
62
70
  },