@passkeykit/server 2.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/dist/challenge-token.d.ts +36 -0
- package/dist/challenge-token.js +74 -0
- package/dist/esm/challenge-token.js +70 -0
- package/dist/esm/express-routes.js +127 -0
- package/dist/esm/index.js +18 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/passkey-server.js +241 -0
- package/dist/esm/password-argon2.js +30 -0
- package/dist/esm/password.js +81 -0
- package/dist/esm/stores.js +147 -0
- package/dist/esm/types.js +8 -0
- package/dist/express-routes.d.ts +43 -0
- package/dist/express-routes.js +130 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +31 -0
- package/dist/passkey-server.d.ts +80 -0
- package/dist/passkey-server.js +245 -0
- package/dist/password-argon2.d.ts +22 -0
- package/dist/password-argon2.js +38 -0
- package/dist/password.d.ts +34 -0
- package/dist/password.js +86 -0
- package/dist/stores.d.ts +50 -0
- package/dist/stores.js +154 -0
- package/dist/types.d.ts +103 -0
- package/dist/types.js +9 -0
- package/package.json +62 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateless challenge token using AES-256-GCM + HMAC-SHA256.
|
|
3
|
+
*
|
|
4
|
+
* @ai_context This is the core innovation for serverless deployments.
|
|
5
|
+
* Instead of storing challenges in a database/memory, we encrypt the
|
|
6
|
+
* challenge payload into an opaque token. The server can verify it later
|
|
7
|
+
* without any state — just the secret key.
|
|
8
|
+
*
|
|
9
|
+
* Token format: base64url(iv + ciphertext + authTag)
|
|
10
|
+
* Payload: JSON { challenge, userId?, type, exp }
|
|
11
|
+
*
|
|
12
|
+
* Security properties:
|
|
13
|
+
* - AES-256-GCM provides authenticated encryption (confidentiality + integrity)
|
|
14
|
+
* - The challenge value is hidden from the client (they only see the opaque token)
|
|
15
|
+
* - Expiry is baked into the token — no cleanup needed
|
|
16
|
+
* - Each token has a unique IV — replay is prevented by single-use consumption
|
|
17
|
+
* (the WebAuthn spec itself prevents replays via the challenge binding)
|
|
18
|
+
*/
|
|
19
|
+
export interface ChallengeTokenPayload {
|
|
20
|
+
/** The WebAuthn challenge string (base64url from @simplewebauthn) */
|
|
21
|
+
challenge: string;
|
|
22
|
+
/** User ID (present during registration, optional during auth) */
|
|
23
|
+
userId?: string;
|
|
24
|
+
/** 'registration' or 'authentication' */
|
|
25
|
+
type: 'registration' | 'authentication';
|
|
26
|
+
/** Expiry timestamp (ms since epoch) */
|
|
27
|
+
exp: number;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Encrypt a challenge payload into an opaque base64url token.
|
|
31
|
+
*/
|
|
32
|
+
export declare function sealChallengeToken(payload: ChallengeTokenPayload, secret: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Decrypt and verify a challenge token. Returns null if invalid/expired.
|
|
35
|
+
*/
|
|
36
|
+
export declare function openChallengeToken(token: string, secret: string): ChallengeTokenPayload | null;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Stateless challenge token using AES-256-GCM + HMAC-SHA256.
|
|
4
|
+
*
|
|
5
|
+
* @ai_context This is the core innovation for serverless deployments.
|
|
6
|
+
* Instead of storing challenges in a database/memory, we encrypt the
|
|
7
|
+
* challenge payload into an opaque token. The server can verify it later
|
|
8
|
+
* without any state — just the secret key.
|
|
9
|
+
*
|
|
10
|
+
* Token format: base64url(iv + ciphertext + authTag)
|
|
11
|
+
* Payload: JSON { challenge, userId?, type, exp }
|
|
12
|
+
*
|
|
13
|
+
* Security properties:
|
|
14
|
+
* - AES-256-GCM provides authenticated encryption (confidentiality + integrity)
|
|
15
|
+
* - The challenge value is hidden from the client (they only see the opaque token)
|
|
16
|
+
* - Expiry is baked into the token — no cleanup needed
|
|
17
|
+
* - Each token has a unique IV — replay is prevented by single-use consumption
|
|
18
|
+
* (the WebAuthn spec itself prevents replays via the challenge binding)
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.sealChallengeToken = sealChallengeToken;
|
|
22
|
+
exports.openChallengeToken = openChallengeToken;
|
|
23
|
+
const crypto_1 = require("crypto");
|
|
24
|
+
const ALG = 'aes-256-gcm';
|
|
25
|
+
const IV_LEN = 12;
|
|
26
|
+
const TAG_LEN = 16;
|
|
27
|
+
/**
|
|
28
|
+
* Derive a 32-byte encryption key from a secret string.
|
|
29
|
+
* Uses HMAC-SHA256 with a fixed context label (domain separation).
|
|
30
|
+
*/
|
|
31
|
+
function deriveKey(secret) {
|
|
32
|
+
return (0, crypto_1.createHmac)('sha256', 'passkey-kit-challenge-key')
|
|
33
|
+
.update(secret)
|
|
34
|
+
.digest();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Encrypt a challenge payload into an opaque base64url token.
|
|
38
|
+
*/
|
|
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');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Decrypt and verify a challenge token. Returns null if invalid/expired.
|
|
52
|
+
*/
|
|
53
|
+
function openChallengeToken(token, secret) {
|
|
54
|
+
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
|
|
67
|
+
if (Date.now() > payload.exp)
|
|
68
|
+
return null;
|
|
69
|
+
return payload;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stateless challenge token using AES-256-GCM + HMAC-SHA256.
|
|
3
|
+
*
|
|
4
|
+
* @ai_context This is the core innovation for serverless deployments.
|
|
5
|
+
* Instead of storing challenges in a database/memory, we encrypt the
|
|
6
|
+
* challenge payload into an opaque token. The server can verify it later
|
|
7
|
+
* without any state — just the secret key.
|
|
8
|
+
*
|
|
9
|
+
* Token format: base64url(iv + ciphertext + authTag)
|
|
10
|
+
* Payload: JSON { challenge, userId?, type, exp }
|
|
11
|
+
*
|
|
12
|
+
* Security properties:
|
|
13
|
+
* - AES-256-GCM provides authenticated encryption (confidentiality + integrity)
|
|
14
|
+
* - The challenge value is hidden from the client (they only see the opaque token)
|
|
15
|
+
* - Expiry is baked into the token — no cleanup needed
|
|
16
|
+
* - Each token has a unique IV — replay is prevented by single-use consumption
|
|
17
|
+
* (the WebAuthn spec itself prevents replays via the challenge binding)
|
|
18
|
+
*/
|
|
19
|
+
import { createCipheriv, createDecipheriv, randomBytes, createHmac } from 'crypto';
|
|
20
|
+
const ALG = 'aes-256-gcm';
|
|
21
|
+
const IV_LEN = 12;
|
|
22
|
+
const TAG_LEN = 16;
|
|
23
|
+
/**
|
|
24
|
+
* Derive a 32-byte encryption key from a secret string.
|
|
25
|
+
* Uses HMAC-SHA256 with a fixed context label (domain separation).
|
|
26
|
+
*/
|
|
27
|
+
function deriveKey(secret) {
|
|
28
|
+
return createHmac('sha256', 'passkey-kit-challenge-key')
|
|
29
|
+
.update(secret)
|
|
30
|
+
.digest();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Encrypt a challenge payload into an opaque base64url token.
|
|
34
|
+
*/
|
|
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');
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Decrypt and verify a challenge token. Returns null if invalid/expired.
|
|
48
|
+
*/
|
|
49
|
+
export function openChallengeToken(token, secret) {
|
|
50
|
+
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
|
|
63
|
+
if (Date.now() > payload.exp)
|
|
64
|
+
return null;
|
|
65
|
+
return payload;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ready-made Express routes for passkey registration and authentication.
|
|
3
|
+
*
|
|
4
|
+
* @ai_context This is a convenience wrapper. Apps that don't use Express
|
|
5
|
+
* can use PasskeyServer directly. These routes implement the standard
|
|
6
|
+
* challenge-response pattern with proper error handling.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const routes = createExpressRoutes(passkeyServer, { getUserInfo });
|
|
10
|
+
* app.use('/api/auth/passkey', routes);
|
|
11
|
+
*/
|
|
12
|
+
import { Router } from 'express';
|
|
13
|
+
/**
|
|
14
|
+
* Create Express router with passkey registration and authentication routes.
|
|
15
|
+
*
|
|
16
|
+
* Routes:
|
|
17
|
+
* POST /register/options — Get registration challenge options
|
|
18
|
+
* POST /register/verify — Verify registration response
|
|
19
|
+
* POST /authenticate/options — Get authentication challenge options
|
|
20
|
+
* POST /authenticate/verify — Verify authentication response
|
|
21
|
+
*/
|
|
22
|
+
export function createExpressRoutes(server, config) {
|
|
23
|
+
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
|
+
router.post('/register/options', async (req, res) => {
|
|
30
|
+
try {
|
|
31
|
+
const { userId, authenticatorAttachment, residentKey, userVerification } = req.body;
|
|
32
|
+
if (!userId) {
|
|
33
|
+
res.status(400).json({ error: 'userId is required' });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const user = await config.getUserInfo(userId);
|
|
37
|
+
if (!user) {
|
|
38
|
+
res.status(404).json({ error: 'User not found' });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const options = await server.generateRegistrationOptions(user, {
|
|
42
|
+
authenticatorAttachment,
|
|
43
|
+
residentKey,
|
|
44
|
+
userVerification,
|
|
45
|
+
});
|
|
46
|
+
res.json(options);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
console.error('[passkey-kit] Registration options error:', err);
|
|
50
|
+
res.status(500).json({ error: 'Failed to generate registration options' });
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* POST /register/verify
|
|
55
|
+
* Body: { userId: string, response: RegistrationResponseJSON, credentialName?: string, challengeToken?: string }
|
|
56
|
+
* `challengeToken` is required in stateless mode.
|
|
57
|
+
*/
|
|
58
|
+
router.post('/register/verify', async (req, res) => {
|
|
59
|
+
try {
|
|
60
|
+
const { userId, response, credentialName, challengeToken } = req.body;
|
|
61
|
+
if (!userId || !response) {
|
|
62
|
+
res.status(400).json({ error: 'userId and response are required' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const result = await server.verifyRegistration(userId, response, credentialName, challengeToken);
|
|
66
|
+
if (config.onRegistrationSuccess) {
|
|
67
|
+
await config.onRegistrationSuccess(userId, result.credential.credentialId);
|
|
68
|
+
}
|
|
69
|
+
res.json({
|
|
70
|
+
verified: result.verified,
|
|
71
|
+
credentialId: result.credential.credentialId,
|
|
72
|
+
credentialName: result.credential.name,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
const message = err instanceof Error ? err.message : 'Verification failed';
|
|
77
|
+
console.error('[passkey-kit] Registration verify error:', err);
|
|
78
|
+
res.status(400).json({ error: message });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* POST /authenticate/options
|
|
83
|
+
* Body: { userId?: string }
|
|
84
|
+
* Response includes `sessionKey` (which IS the challengeToken in stateless mode).
|
|
85
|
+
*/
|
|
86
|
+
router.post('/authenticate/options', async (req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
const { userId, userVerification } = req.body;
|
|
89
|
+
const { options, sessionKey, challengeToken } = await server.generateAuthenticationOptions(userId, { userVerification });
|
|
90
|
+
res.json({ options, sessionKey, challengeToken });
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.error('[passkey-kit] Authentication options error:', err);
|
|
94
|
+
res.status(500).json({ error: 'Failed to generate authentication options' });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
/**
|
|
98
|
+
* POST /authenticate/verify
|
|
99
|
+
* Body: { sessionKey: string, response: AuthenticationResponseJSON }
|
|
100
|
+
*/
|
|
101
|
+
router.post('/authenticate/verify', async (req, res) => {
|
|
102
|
+
try {
|
|
103
|
+
const { sessionKey, response } = req.body;
|
|
104
|
+
if (!sessionKey || !response) {
|
|
105
|
+
res.status(400).json({ error: 'sessionKey and response are required' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const result = await server.verifyAuthentication(sessionKey, response);
|
|
109
|
+
let extra = {};
|
|
110
|
+
if (config.onAuthenticationSuccess) {
|
|
111
|
+
extra = await config.onAuthenticationSuccess(result.userId, result.credentialId);
|
|
112
|
+
}
|
|
113
|
+
res.json({
|
|
114
|
+
verified: result.verified,
|
|
115
|
+
userId: result.userId,
|
|
116
|
+
credentialId: result.credentialId,
|
|
117
|
+
...extra,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
const message = err instanceof Error ? err.message : 'Verification failed';
|
|
122
|
+
console.error('[passkey-kit] Authentication verify error:', err);
|
|
123
|
+
res.status(400).json({ error: message });
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
return router;
|
|
127
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* passkey-kit-server
|
|
3
|
+
*
|
|
4
|
+
* Server-side WebAuthn passkey verification with challenge-response pattern
|
|
5
|
+
* and scrypt password hashing (pure JS, works everywhere).
|
|
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
|
+
* Two modes:
|
|
12
|
+
* - **Stateless** (default): No server-side state. Set `encryptionKey` in config.
|
|
13
|
+
* - **Stateful**: Provide a `challengeStore` (memory, file, Redis, etc).
|
|
14
|
+
*/
|
|
15
|
+
export { PasskeyServer } from './passkey-server.js';
|
|
16
|
+
export { hashPassword, verifyPassword, needsRehash } from './password.js';
|
|
17
|
+
export { sealChallengeToken, openChallengeToken } from './challenge-token.js';
|
|
18
|
+
export { MemoryChallengeStore, MemoryCredentialStore, FileChallengeStore, FileCredentialStore, } from './stores.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PasskeyServer — core WebAuthn server-side logic.
|
|
3
|
+
*
|
|
4
|
+
* @ai_context All challenge generation and attestation/assertion verification
|
|
5
|
+
* happens here. The client NEVER generates challenges — that's the key security
|
|
6
|
+
* fix over the old insecure pattern.
|
|
7
|
+
*
|
|
8
|
+
* Supports two challenge persistence modes:
|
|
9
|
+
* 1. **Stateless** (default): Challenge is encrypted into a signed token returned
|
|
10
|
+
* to the client. No server-side state required — works on Vercel, Cloudflare, etc.
|
|
11
|
+
* 2. **Stateful**: Challenge is stored in a ChallengeStore (memory, file, Redis, etc).
|
|
12
|
+
* Use this when you need server-side challenge revocation.
|
|
13
|
+
*
|
|
14
|
+
* The mode is selected automatically: if `challengeStore` is provided in config,
|
|
15
|
+
* stateful mode is used. Otherwise, `encryptionKey` must be provided for stateless.
|
|
16
|
+
*
|
|
17
|
+
* Flow:
|
|
18
|
+
* Registration: generateRegistrationOptions → client signs → verifyRegistration
|
|
19
|
+
* Authentication: generateAuthenticationOptions → client signs → verifyAuthentication
|
|
20
|
+
*/
|
|
21
|
+
import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse, } from '@simplewebauthn/server';
|
|
22
|
+
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
|
23
|
+
import { sealChallengeToken, openChallengeToken } from './challenge-token.js';
|
|
24
|
+
const DEFAULT_CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
25
|
+
export class PasskeyServer {
|
|
26
|
+
rpName;
|
|
27
|
+
rpId;
|
|
28
|
+
allowedOrigins;
|
|
29
|
+
challengeStore;
|
|
30
|
+
credentialStore;
|
|
31
|
+
challengeTTL;
|
|
32
|
+
encryptionKey;
|
|
33
|
+
constructor(config) {
|
|
34
|
+
this.rpName = config.rpName;
|
|
35
|
+
this.rpId = config.rpId;
|
|
36
|
+
this.allowedOrigins = config.allowedOrigins;
|
|
37
|
+
this.challengeStore = config.challengeStore;
|
|
38
|
+
this.credentialStore = config.credentialStore;
|
|
39
|
+
this.challengeTTL = config.challengeTTL ?? DEFAULT_CHALLENGE_TTL;
|
|
40
|
+
this.encryptionKey = config.encryptionKey;
|
|
41
|
+
if (!this.challengeStore && !this.encryptionKey) {
|
|
42
|
+
throw new Error('passkey-kit: Provide either `challengeStore` (stateful) or `encryptionKey` (stateless). ' +
|
|
43
|
+
'For serverless, set encryptionKey to a random 32+ character secret.');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Step 1 of registration: Generate options for the client.
|
|
48
|
+
* Returns PublicKeyCredentialCreationOptions (JSON-serializable)
|
|
49
|
+
* plus a `challengeToken` for stateless verification.
|
|
50
|
+
*/
|
|
51
|
+
async generateRegistrationOptions(user, opts) {
|
|
52
|
+
const existingCredentials = await this.credentialStore.getByUserId(user.id);
|
|
53
|
+
const options = await generateRegistrationOptions({
|
|
54
|
+
rpName: this.rpName,
|
|
55
|
+
rpID: this.rpId,
|
|
56
|
+
userName: user.name,
|
|
57
|
+
userDisplayName: user.displayName ?? user.name,
|
|
58
|
+
userID: new TextEncoder().encode(user.id),
|
|
59
|
+
attestationType: 'none',
|
|
60
|
+
excludeCredentials: existingCredentials.map(c => ({
|
|
61
|
+
id: c.credentialId,
|
|
62
|
+
transports: c.transports,
|
|
63
|
+
})),
|
|
64
|
+
authenticatorSelection: {
|
|
65
|
+
authenticatorAttachment: opts?.authenticatorAttachment,
|
|
66
|
+
residentKey: opts?.residentKey ?? 'preferred',
|
|
67
|
+
userVerification: opts?.userVerification ?? 'preferred',
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
let challengeToken;
|
|
71
|
+
if (this.challengeStore) {
|
|
72
|
+
// Stateful: persist challenge in store
|
|
73
|
+
await this.challengeStore.save(user.id, {
|
|
74
|
+
challenge: options.challenge,
|
|
75
|
+
userId: user.id,
|
|
76
|
+
expiresAt: Date.now() + this.challengeTTL,
|
|
77
|
+
type: 'registration',
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
// Stateless: encrypt challenge into token
|
|
82
|
+
challengeToken = sealChallengeToken({
|
|
83
|
+
challenge: options.challenge,
|
|
84
|
+
userId: user.id,
|
|
85
|
+
type: 'registration',
|
|
86
|
+
exp: Date.now() + this.challengeTTL,
|
|
87
|
+
}, this.encryptionKey);
|
|
88
|
+
}
|
|
89
|
+
return { ...options, challengeToken };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Step 2 of registration: Verify the client's attestation response.
|
|
93
|
+
*
|
|
94
|
+
* @param userId - User ID
|
|
95
|
+
* @param response - WebAuthn attestation response from the browser
|
|
96
|
+
* @param credentialName - Human-readable name for this credential
|
|
97
|
+
* @param challengeToken - The opaque token from step 1 (stateless mode)
|
|
98
|
+
*/
|
|
99
|
+
async verifyRegistration(userId, response, credentialName, challengeToken) {
|
|
100
|
+
let expectedChallenge;
|
|
101
|
+
if (this.challengeStore) {
|
|
102
|
+
const storedChallenge = await this.challengeStore.consume(userId);
|
|
103
|
+
if (!storedChallenge)
|
|
104
|
+
throw new Error('Challenge not found or expired');
|
|
105
|
+
if (storedChallenge.type !== 'registration')
|
|
106
|
+
throw new Error('Challenge type mismatch');
|
|
107
|
+
if (Date.now() > storedChallenge.expiresAt)
|
|
108
|
+
throw new Error('Challenge expired');
|
|
109
|
+
expectedChallenge = storedChallenge.challenge;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
if (!challengeToken)
|
|
113
|
+
throw new Error('challengeToken is required in stateless mode');
|
|
114
|
+
const payload = openChallengeToken(challengeToken, this.encryptionKey);
|
|
115
|
+
if (!payload)
|
|
116
|
+
throw new Error('Invalid or expired challenge token');
|
|
117
|
+
if (payload.type !== 'registration')
|
|
118
|
+
throw new Error('Challenge type mismatch');
|
|
119
|
+
if (payload.userId !== userId)
|
|
120
|
+
throw new Error('Challenge userId mismatch');
|
|
121
|
+
expectedChallenge = payload.challenge;
|
|
122
|
+
}
|
|
123
|
+
const verification = await verifyRegistrationResponse({
|
|
124
|
+
response,
|
|
125
|
+
expectedChallenge,
|
|
126
|
+
expectedOrigin: this.allowedOrigins,
|
|
127
|
+
expectedRPID: this.rpId,
|
|
128
|
+
});
|
|
129
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
130
|
+
throw new Error('Registration verification failed');
|
|
131
|
+
}
|
|
132
|
+
const { credential } = verification.registrationInfo;
|
|
133
|
+
const storedCredential = {
|
|
134
|
+
credentialId: credential.id,
|
|
135
|
+
publicKey: isoBase64URL.fromBuffer(credential.publicKey),
|
|
136
|
+
counter: credential.counter,
|
|
137
|
+
transports: response.response.transports ?? [],
|
|
138
|
+
name: credentialName ?? 'Passkey',
|
|
139
|
+
registeredAt: new Date().toISOString(),
|
|
140
|
+
userId,
|
|
141
|
+
};
|
|
142
|
+
await this.credentialStore.save(storedCredential);
|
|
143
|
+
return { credential: storedCredential, verified: true };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Step 1 of authentication: Generate options for the client.
|
|
147
|
+
* If userId is provided, only that user's credentials are allowed.
|
|
148
|
+
* If not provided, uses discoverable credentials (resident keys).
|
|
149
|
+
*/
|
|
150
|
+
async generateAuthenticationOptions(userId, opts) {
|
|
151
|
+
let allowCredentials;
|
|
152
|
+
if (userId) {
|
|
153
|
+
const credentials = await this.credentialStore.getByUserId(userId);
|
|
154
|
+
allowCredentials = credentials.map(c => ({
|
|
155
|
+
id: c.credentialId,
|
|
156
|
+
transports: c.transports,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
const options = await generateAuthenticationOptions({
|
|
160
|
+
rpID: this.rpId,
|
|
161
|
+
allowCredentials,
|
|
162
|
+
userVerification: opts?.userVerification ?? 'preferred',
|
|
163
|
+
});
|
|
164
|
+
let sessionKey;
|
|
165
|
+
let challengeToken;
|
|
166
|
+
if (this.challengeStore) {
|
|
167
|
+
// Stateful: persist challenge
|
|
168
|
+
sessionKey = userId ?? `auth:${options.challenge}`;
|
|
169
|
+
await this.challengeStore.save(sessionKey, {
|
|
170
|
+
challenge: options.challenge,
|
|
171
|
+
userId,
|
|
172
|
+
expiresAt: Date.now() + this.challengeTTL,
|
|
173
|
+
type: 'authentication',
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Stateless: encrypt into token (sessionKey IS the token)
|
|
178
|
+
challengeToken = sealChallengeToken({
|
|
179
|
+
challenge: options.challenge,
|
|
180
|
+
userId,
|
|
181
|
+
type: 'authentication',
|
|
182
|
+
exp: Date.now() + this.challengeTTL,
|
|
183
|
+
}, this.encryptionKey);
|
|
184
|
+
sessionKey = challengeToken;
|
|
185
|
+
}
|
|
186
|
+
return { options, sessionKey, challengeToken };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Step 2 of authentication: Verify the client's assertion response.
|
|
190
|
+
*/
|
|
191
|
+
async verifyAuthentication(sessionKey, response) {
|
|
192
|
+
let expectedChallenge;
|
|
193
|
+
if (this.challengeStore) {
|
|
194
|
+
const storedChallenge = await this.challengeStore.consume(sessionKey);
|
|
195
|
+
if (!storedChallenge)
|
|
196
|
+
throw new Error('Challenge not found or expired');
|
|
197
|
+
if (storedChallenge.type !== 'authentication')
|
|
198
|
+
throw new Error('Challenge type mismatch');
|
|
199
|
+
if (Date.now() > storedChallenge.expiresAt)
|
|
200
|
+
throw new Error('Challenge expired');
|
|
201
|
+
expectedChallenge = storedChallenge.challenge;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
// In stateless mode, sessionKey IS the challengeToken
|
|
205
|
+
const payload = openChallengeToken(sessionKey, this.encryptionKey);
|
|
206
|
+
if (!payload)
|
|
207
|
+
throw new Error('Invalid or expired challenge token');
|
|
208
|
+
if (payload.type !== 'authentication')
|
|
209
|
+
throw new Error('Challenge type mismatch');
|
|
210
|
+
expectedChallenge = payload.challenge;
|
|
211
|
+
}
|
|
212
|
+
const credentialId = response.id;
|
|
213
|
+
const credential = await this.credentialStore.getByCredentialId(credentialId);
|
|
214
|
+
if (!credential) {
|
|
215
|
+
throw new Error('Credential not found');
|
|
216
|
+
}
|
|
217
|
+
const verification = await verifyAuthenticationResponse({
|
|
218
|
+
response,
|
|
219
|
+
expectedChallenge,
|
|
220
|
+
expectedOrigin: this.allowedOrigins,
|
|
221
|
+
expectedRPID: this.rpId,
|
|
222
|
+
credential: {
|
|
223
|
+
id: credential.credentialId,
|
|
224
|
+
publicKey: isoBase64URL.toBuffer(credential.publicKey),
|
|
225
|
+
counter: credential.counter,
|
|
226
|
+
transports: credential.transports,
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
if (!verification.verified) {
|
|
230
|
+
throw new Error('Authentication verification failed');
|
|
231
|
+
}
|
|
232
|
+
const newCounter = verification.authenticationInfo.newCounter;
|
|
233
|
+
await this.credentialStore.updateCounter(credentialId, newCounter);
|
|
234
|
+
return {
|
|
235
|
+
credentialId,
|
|
236
|
+
userId: credential.userId,
|
|
237
|
+
verified: true,
|
|
238
|
+
newCounter,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password hashing using argon2id (native C++ bindings).
|
|
3
|
+
*
|
|
4
|
+
* @ai_context This is an OPTIONAL subpath export for users who:
|
|
5
|
+
* 1. Run on a platform with native module support (Node.js, not serverless edge)
|
|
6
|
+
* 2. Want the absolute strongest password hash (argon2id > scrypt)
|
|
7
|
+
*
|
|
8
|
+
* Import: import { hashPassword, verifyPassword } from 'passkey-kit-server/argon2'
|
|
9
|
+
*
|
|
10
|
+
* Most users should use the default scrypt export which works everywhere.
|
|
11
|
+
*/
|
|
12
|
+
import argon2 from 'argon2';
|
|
13
|
+
export async function hashPassword(password, options) {
|
|
14
|
+
return argon2.hash(password, {
|
|
15
|
+
type: argon2.argon2id,
|
|
16
|
+
memoryCost: options?.memoryCost ?? 65536,
|
|
17
|
+
timeCost: options?.timeCost ?? 3,
|
|
18
|
+
parallelism: options?.parallelism ?? 4,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
export async function verifyPassword(storedHash, password) {
|
|
22
|
+
return argon2.verify(storedHash, password);
|
|
23
|
+
}
|
|
24
|
+
export function needsRehash(storedHash, options) {
|
|
25
|
+
return argon2.needsRehash(storedHash, {
|
|
26
|
+
memoryCost: options?.memoryCost ?? 65536,
|
|
27
|
+
timeCost: options?.timeCost ?? 3,
|
|
28
|
+
parallelism: options?.parallelism ?? 4,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password hashing using scrypt (pure JavaScript via @noble/hashes).
|
|
3
|
+
*
|
|
4
|
+
* @ai_context Replaces the native argon2 module as the default.
|
|
5
|
+
* @noble/hashes is audited by Trail of Bits and works on every runtime:
|
|
6
|
+
* Node.js, Deno, Bun, Cloudflare Workers, Vercel Edge, browser.
|
|
7
|
+
*
|
|
8
|
+
* For users who want argon2id (requires native bindings), see the
|
|
9
|
+
* `passkey-kit-server/argon2` subpath export.
|
|
10
|
+
*
|
|
11
|
+
* Output format is PHC-like:
|
|
12
|
+
* $scrypt$ln=17,r=8,p=1$<base64salt>$<base64hash>
|
|
13
|
+
*/
|
|
14
|
+
import { scrypt as scryptSync } from '@noble/hashes/scrypt';
|
|
15
|
+
import { randomBytes } from 'crypto';
|
|
16
|
+
/** Default scrypt parameters (OWASP recommendations for interactive login) */
|
|
17
|
+
const DEFAULTS = {
|
|
18
|
+
N: 2 ** 17, // 131072 — CPU/memory cost
|
|
19
|
+
r: 8, // Block size
|
|
20
|
+
p: 1, // Parallelism
|
|
21
|
+
dkLen: 32, // Output key length
|
|
22
|
+
saltLen: 16, // Salt length
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Hash a password using scrypt.
|
|
26
|
+
* Returns a PHC-format string: $scrypt$ln=<log2N>,r=<r>,p=<p>$<salt>$<hash>
|
|
27
|
+
*/
|
|
28
|
+
export async function hashPassword(password, options) {
|
|
29
|
+
const N = options?.N ?? DEFAULTS.N;
|
|
30
|
+
const r = options?.r ?? DEFAULTS.r;
|
|
31
|
+
const p = options?.p ?? DEFAULTS.p;
|
|
32
|
+
const salt = randomBytes(DEFAULTS.saltLen);
|
|
33
|
+
const hash = scryptSync(password, salt, { N, r, p, dkLen: DEFAULTS.dkLen });
|
|
34
|
+
const ln = Math.log2(N);
|
|
35
|
+
const saltB64 = Buffer.from(salt).toString('base64');
|
|
36
|
+
const hashB64 = Buffer.from(hash).toString('base64');
|
|
37
|
+
return `$scrypt$ln=${ln},r=${r},p=${p}$${saltB64}$${hashB64}`;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Verify a password against a stored scrypt hash.
|
|
41
|
+
*/
|
|
42
|
+
export async function verifyPassword(storedHash, password) {
|
|
43
|
+
const parsed = parsePhc(storedHash);
|
|
44
|
+
if (!parsed)
|
|
45
|
+
return false;
|
|
46
|
+
const { N, r, p, salt, hash } = parsed;
|
|
47
|
+
const derived = scryptSync(password, salt, { N, r, p, dkLen: hash.length });
|
|
48
|
+
return timingSafeEqual(Buffer.from(derived), hash);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Check if a hash needs rehashing (params differ from current defaults).
|
|
52
|
+
*/
|
|
53
|
+
export function needsRehash(storedHash, options) {
|
|
54
|
+
const parsed = parsePhc(storedHash);
|
|
55
|
+
if (!parsed)
|
|
56
|
+
return true;
|
|
57
|
+
const N = options?.N ?? DEFAULTS.N;
|
|
58
|
+
const r = options?.r ?? DEFAULTS.r;
|
|
59
|
+
const p = options?.p ?? DEFAULTS.p;
|
|
60
|
+
return parsed.N !== N || parsed.r !== r || parsed.p !== p;
|
|
61
|
+
}
|
|
62
|
+
// --- Internal helpers ---
|
|
63
|
+
function parsePhc(phc) {
|
|
64
|
+
// $scrypt$ln=17,r=8,p=1$<salt>$<hash>
|
|
65
|
+
const match = phc.match(/^\$scrypt\$ln=(\d+),r=(\d+),p=(\d+)\$([A-Za-z0-9+/=]+)\$([A-Za-z0-9+/=]+)$/);
|
|
66
|
+
if (!match)
|
|
67
|
+
return null;
|
|
68
|
+
return {
|
|
69
|
+
N: 2 ** parseInt(match[1], 10),
|
|
70
|
+
r: parseInt(match[2], 10),
|
|
71
|
+
p: parseInt(match[3], 10),
|
|
72
|
+
salt: Buffer.from(match[4], 'base64'),
|
|
73
|
+
hash: Buffer.from(match[5], 'base64'),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function timingSafeEqual(a, b) {
|
|
77
|
+
if (a.length !== b.length)
|
|
78
|
+
return false;
|
|
79
|
+
const { timingSafeEqual: tse } = require('crypto');
|
|
80
|
+
return tse(a, b);
|
|
81
|
+
}
|