pgkiln 0.59.0 → 0.61.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/src/mfa.ts ADDED
@@ -0,0 +1,333 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import type pg from 'pg';
3
+ import { owner } from './db.ts';
4
+ import { decryptSecret, encryptSecret } from './secrets.ts';
5
+
6
+ // (sprint 126, SEC-2 and SEC-6) Two-factor sign-in for developers: authenticator apps (TOTP, RFC 6238:
7
+ // HMAC-SHA1, 30-second steps, 6 digits; one step either side is accepted for clock drift, and a step is
8
+ // accepted only once) and one-time recovery codes. The policy (off, administrators, all developers) is a
9
+ // setting; it can only be required while two administrators (or every administrator, when there are fewer)
10
+ // have a working second factor, so it never locks the instance out. The command line resets a developer's
11
+ // factors and turns the policy off without signing in (src/cli/main.ts, `pgkiln admin`).
12
+ // (sprint 127) Passkeys and security keys (WebAuthn, src/webauthn.ts) are the second kind of factor, and a
13
+ // developer may trust a browser for a number of days (meta.setting mfa_trust_days; 0 = never).
14
+
15
+ export const TOTP_STEP = 30;
16
+ export const TOTP_DIGITS = 6;
17
+ export const RECOVERY_CODES = 10;
18
+ export type MfaPolicy = 'off' | 'admins' | 'all';
19
+ export const MFA_POLICIES: MfaPolicy[] = ['off', 'admins', 'all'];
20
+
21
+ const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
22
+ export function base32(buf: Buffer): string {
23
+ let bits = 0, value = 0, out = '';
24
+ for (const b of buf) {
25
+ value = (value << 8) | b;
26
+ bits += 8;
27
+ while (bits >= 5) {
28
+ out += B32[(value >>> (bits - 5)) & 31];
29
+ bits -= 5;
30
+ }
31
+ }
32
+ if (bits > 0) out += B32[(value << (5 - bits)) & 31];
33
+ return out;
34
+ }
35
+ export function unbase32(text: string): Buffer {
36
+ const clean = text.toUpperCase().replace(/[\s=]/g, '');
37
+ let bits = 0, value = 0;
38
+ const out: number[] = [];
39
+ for (const ch of clean) {
40
+ const i = B32.indexOf(ch);
41
+ if (i < 0) throw new Error('not base32');
42
+ value = (value << 5) | i;
43
+ bits += 5;
44
+ if (bits >= 8) {
45
+ out.push((value >>> (bits - 8)) & 255);
46
+ bits -= 8;
47
+ }
48
+ }
49
+ return Buffer.from(out);
50
+ }
51
+
52
+ /** A new authenticator secret: 160 random bits, base32 (what apps expect). */
53
+ export const newTotpSecret = () => base32(randomBytes(20));
54
+
55
+ /** The code of one 30-second step (RFC 4226 dynamic truncation). */
56
+ export function totpAt(secret: Buffer, step: number, digits = TOTP_DIGITS): string {
57
+ const msg = Buffer.alloc(8);
58
+ msg.writeBigUInt64BE(BigInt(step));
59
+ const h = createHmac('sha1', secret).update(msg).digest();
60
+ const o = h[h.length - 1] & 15;
61
+ const n = ((h[o] & 127) << 24) | (h[o + 1] << 16) | (h[o + 2] << 8) | h[o + 3];
62
+ return String(n % 10 ** digits).padStart(digits, '0');
63
+ }
64
+
65
+ export const stepNow = (ms = Date.now()) => Math.floor(ms / 1000 / TOTP_STEP);
66
+
67
+ /** The step a code matches (now, or one step either side), newer than `after`; null otherwise. */
68
+ export function matchTotp(secret: Buffer, code: string, after: number | null, now = stepNow()): number | null {
69
+ if (!/^\d{6}$/.test(code)) return null;
70
+ const given = Buffer.from(code);
71
+ let found: number | null = null;
72
+ for (const step of [now - 1, now, now + 1]) {
73
+ if (after !== null && step <= after) continue;
74
+ if (timingSafeEqual(Buffer.from(totpAt(secret, step)), given)) found = step;
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /** The otpauth URI an authenticator app reads from the QR code. */
80
+ export const otpauthUri = (secret: string, account: string, issuer = 'pgkiln') =>
81
+ `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=${TOTP_DIGITS}&period=${TOTP_STEP}`;
82
+
83
+ // ---------------------------------------------------------------- recovery codes
84
+
85
+ const CODE_ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789'; // no 0/o, 1/l/i
86
+ /** A recovery code: ten characters in two groups (xxxxx-xxxxx), about 50 bits. */
87
+ export function newRecoveryCode(): string {
88
+ const bytes = randomBytes(10);
89
+ const chars = [...bytes].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
90
+ return `${chars.slice(0, 5)}-${chars.slice(5)}`;
91
+ }
92
+ export const normaliseCode = (code: string) => code.toLowerCase().replace(/[^a-z0-9]/g, '');
93
+ export const hashCode = (code: string) => createHash('sha256').update(normaliseCode(code)).digest('hex');
94
+
95
+ // ---------------------------------------------------------------- the developer's factors
96
+
97
+ export interface Factor { id: number; kind: 'totp' | 'passkey'; name: string; created_at: string; last_used_at: string | null; rp_id: string | null }
98
+
99
+ export async function factorsOf(username: string, db: Pick<pg.ClientBase, 'query'> = owner.pool): Promise<Factor[]> {
100
+ return (await db.query<Factor>('select id, kind, name, created_at, last_used_at, rp_id from meta.developer_factor where username = $1 order by kind desc, id', [username])).rows;
101
+ }
102
+
103
+ /** Recovery codes left (confirmed, unused). */
104
+ export async function codesLeft(username: string): Promise<number> {
105
+ return (await owner.one<{ n: number }>('select count(*)::int as n from meta.developer_recovery_code where username = $1 and confirmed and used_at is null', [username]))!.n;
106
+ }
107
+
108
+ /** Whether the developer has a working second factor (an authenticator app; recovery codes back it up). */
109
+ export async function hasFactor(username: string): Promise<boolean> {
110
+ return !!(await owner.one('select 1 as ok from meta.developer_factor where username = $1', [username]));
111
+ }
112
+
113
+ /** Add an authenticator app (after the developer confirmed a code from it). */
114
+ export async function addTotp(username: string, name: string, secret: string, step: number) {
115
+ await owner.query(
116
+ `insert into meta.developer_factor (username, kind, name, secret_enc, last_step, last_used_at) values ($1, 'totp', $2, $3, $4, now())`,
117
+ [username, name.trim().slice(0, 60) || 'Authenticator', encryptSecret(secret), step],
118
+ );
119
+ }
120
+
121
+ // ---------------------------------------------------------------- passkeys (sprint 127)
122
+
123
+ export interface Passkey { id: number; name: string; credential_id: string; public_key: Record<string, string>; alg: number; sign_count: string; transports: string[] }
124
+
125
+ /** Add a passkey (after src/webauthn.ts checked the browser's answer). */
126
+ export async function addPasskey(username: string, name: string, p: { credentialId: string; jwk: Record<string, string>; alg: number; signCount: number; rpId: string; transports: string[] }) {
127
+ await owner.query(
128
+ `insert into meta.developer_factor (username, kind, name, credential_id, public_key, alg, sign_count, rp_id, transports, last_used_at)
129
+ values ($1, 'passkey', $2, $3, $4, $5, $6, $7, $8, now())`,
130
+ [username, name.trim().slice(0, 60) || 'Passkey', p.credentialId, JSON.stringify(p.jwk), p.alg, p.signCount, p.rpId,
131
+ p.transports.filter((t) => /^[a-z-]{1,20}$/.test(t)).slice(0, 8)],
132
+ );
133
+ }
134
+
135
+ /** The developer's passkeys for this relying party (a passkey only works on the domain it was made for). */
136
+ export async function passkeysOf(username: string, rpId: string): Promise<Passkey[]> {
137
+ return (await owner.query<Passkey>(
138
+ `select id, name, credential_id, public_key, alg, sign_count, transports from meta.developer_factor
139
+ where username = $1 and kind = 'passkey' and rp_id = $2 order by id`, [username, rpId])).rows;
140
+ }
141
+
142
+ /** Record a sign-in with a passkey; false when another sign-in moved the counter first (a replay or a clone). */
143
+ export async function usePasskey(id: number, oldCount: string, newCount: number): Promise<boolean> {
144
+ return !!(await owner.query(
145
+ 'update meta.developer_factor set sign_count = $3, last_used_at = now() where id = $1 and sign_count = $2::bigint',
146
+ [id, oldCount, newCount])).rowCount;
147
+ }
148
+
149
+ // ---------------------------------------------------------------- trusted browsers (sprint 127)
150
+
151
+ /** Days a browser may skip the second step after one (0 = never). */
152
+ export async function trustDays(): Promise<number> {
153
+ const v = Number((await owner.one<{ value: string }>(`select value from meta.setting where name = 'mfa_trust_days'`))?.value);
154
+ return Number.isInteger(v) && v >= 0 && v <= 90 ? v : 0;
155
+ }
156
+
157
+ export async function setTrustDays(days: number) {
158
+ await owner.query(`insert into meta.setting (name, value) values ('mfa_trust_days', $1) on conflict (name) do update set value = excluded.value`, [String(days)]);
159
+ if (days === 0) await owner.query('delete from meta.developer_trusted_browser');
160
+ else await owner.query(`delete from meta.developer_trusted_browser where expires_at > now() + make_interval(days => $1)`, [days]);
161
+ }
162
+
163
+ const tokenHash = (token: string) => createHash('sha256').update(token).digest('hex');
164
+
165
+ /** Trust this browser: a random token for the cookie (only its hash is stored). */
166
+ export async function trustBrowser(username: string, days: number, userAgent: string): Promise<string> {
167
+ const token = randomBytes(32).toString('base64url');
168
+ await owner.query(
169
+ `insert into meta.developer_trusted_browser (token_hash, username, expires_at, user_agent) values ($1, $2, now() + make_interval(days => $3), $4)`,
170
+ [tokenHash(token), username, days, userAgent.slice(0, 200)]);
171
+ return token;
172
+ }
173
+
174
+ /** Whether this browser's token is trusted for this developer (and trusting is still on). */
175
+ export async function isTrustedBrowser(username: string, token: string | undefined): Promise<boolean> {
176
+ if (!token || token.length > 100) return false;
177
+ return !!(await owner.one(
178
+ `select 1 as ok from meta.developer_trusted_browser t where token_hash = $1 and username = $2 and expires_at > now()
179
+ and exists (select 1 from meta.setting where name = 'mfa_trust_days' and value <> '0')`,
180
+ [tokenHash(token), username]));
181
+ }
182
+
183
+ export async function trustedBrowsers(username: string): Promise<number> {
184
+ return (await owner.one<{ n: number }>('select count(*)::int as n from meta.developer_trusted_browser where username = $1 and expires_at > now()', [username]))!.n;
185
+ }
186
+
187
+ export async function forgetBrowsers(username: string) {
188
+ await owner.query('delete from meta.developer_trusted_browser where username = $1', [username]);
189
+ }
190
+
191
+ /** New recovery codes (the old ones stop working); shown once, counted only when confirmed. */
192
+ export async function newRecoveryCodes(username: string): Promise<string[]> {
193
+ const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
194
+ await owner.tx(async (c) => {
195
+ await c.query('delete from meta.developer_recovery_code where username = $1', [username]);
196
+ for (const code of codes) await c.query('insert into meta.developer_recovery_code (username, code_hash) values ($1, $2)', [username, hashCode(code)]);
197
+ });
198
+ return codes;
199
+ }
200
+
201
+ export async function confirmRecoveryCodes(username: string) {
202
+ await owner.query('update meta.developer_recovery_code set confirmed = true where username = $1 and used_at is null', [username]);
203
+ }
204
+
205
+ /**
206
+ * Check a second-factor code at sign-in: an authenticator code (each step once) or a confirmed, unused
207
+ * recovery code (then used up). Returns what matched, or null.
208
+ */
209
+ export async function checkSecondFactor(username: string, code: string): Promise<{ kind: 'totp' | 'recovery'; name?: string } | null> {
210
+ const digits = code.replace(/\s/g, '');
211
+ if (/^\d{6}$/.test(digits)) {
212
+ const rows = (await owner.query<{ id: number; name: string; secret_enc: string; last_step: string | null }>(
213
+ 'select id, name, secret_enc, last_step from meta.developer_factor where username = $1 and kind = $2', [username, 'totp'])).rows;
214
+ for (const f of rows) {
215
+ let secret: Buffer;
216
+ try {
217
+ secret = unbase32(decryptSecret(f.secret_enc));
218
+ } catch {
219
+ continue; // the instance key changed: recovery codes still work
220
+ }
221
+ const step = matchTotp(secret, digits, f.last_step === null ? null : Number(f.last_step));
222
+ // the update only wins once per step, also with two requests at the same time
223
+ if (step !== null && (await owner.query('update meta.developer_factor set last_step = $2, last_used_at = now() where id = $1 and (last_step is null or last_step < $2)', [f.id, step])).rowCount)
224
+ return { kind: 'totp', name: f.name };
225
+ }
226
+ return null;
227
+ }
228
+ if (normaliseCode(code).length !== 10) return null;
229
+ const used = await owner.query(
230
+ 'update meta.developer_recovery_code set used_at = now() where username = $1 and code_hash = $2 and confirmed and used_at is null',
231
+ [username, hashCode(code)]);
232
+ return used.rowCount ? { kind: 'recovery' } : null;
233
+ }
234
+
235
+ // ---------------------------------------------------------------- policy
236
+
237
+ export async function mfaPolicy(): Promise<{ policy: MfaPolicy; since: Date | null; graceDays: number }> {
238
+ const rows = (await owner.query<{ name: string; value: string }>(`select name, value from meta.setting where name in ('mfa_required', 'mfa_required_since', 'mfa_grace_days')`)).rows;
239
+ const get = (n: string) => rows.find((r) => r.name === n)?.value;
240
+ const p = get('mfa_required');
241
+ const since = get('mfa_required_since');
242
+ const grace = Number(get('mfa_grace_days'));
243
+ return { policy: MFA_POLICIES.includes(p as MfaPolicy) ? (p as MfaPolicy) : 'off', since: since ? new Date(since) : null, graceDays: Number.isInteger(grace) && grace >= 0 && grace <= 90 ? grace : 7 };
244
+ }
245
+
246
+ /** Does the policy require a second factor of this developer? */
247
+ export const requiredFor = (policy: MfaPolicy, isAdmin: boolean) => policy === 'all' || (policy === 'admins' && isAdmin);
248
+
249
+ /** The administrators, and how many of them have a working second factor. */
250
+ export async function adminFactors(): Promise<{ admins: number; enrolled: number }> {
251
+ return (await owner.one<{ admins: number; enrolled: number }>(
252
+ `select count(*)::int as admins,
253
+ count(*) filter (where exists (select 1 from meta.developer_factor f where f.username = d.username))::int as enrolled
254
+ from meta.developer d where d.is_admin`))!;
255
+ }
256
+
257
+ /** Why the policy can't be required now (two administrators with a factor, or every one when fewer), or null. */
258
+ export async function policyProblem(policy: MfaPolicy): Promise<'admins' | null> {
259
+ if (policy === 'off') return null;
260
+ const { admins, enrolled } = await adminFactors();
261
+ return enrolled >= Math.min(2, admins) && enrolled > 0 ? null : 'admins';
262
+ }
263
+
264
+ /** (SEC-6) Would taking `username` out of the administrators (a role change, removal) break the rule above? */
265
+ export async function leavesTooFewAdmins(username: string): Promise<boolean> {
266
+ const { policy } = await mfaPolicy();
267
+ if (policy === 'off') return false;
268
+ const r = (await owner.one<{ admins: number; enrolled: number }>(
269
+ `select count(*)::int as admins,
270
+ count(*) filter (where exists (select 1 from meta.developer_factor f where f.username = d.username))::int as enrolled
271
+ from meta.developer d where d.is_admin and d.username <> $1`, [username]))!;
272
+ return !(r.enrolled >= Math.min(2, r.admins) && r.enrolled > 0);
273
+ }
274
+
275
+ /** Set the policy; `force` skips the two-administrator rule (only the command line uses it, to turn it off). */
276
+ export async function setPolicy(policy: MfaPolicy, graceDays: number) {
277
+ await owner.tx(async (c) => {
278
+ const cur = (await c.query<{ value: string }>(`select value from meta.setting where name = 'mfa_required'`)).rows[0]?.value ?? 'off';
279
+ await c.query(`insert into meta.setting (name, value) values ('mfa_required', $1), ('mfa_grace_days', $2) on conflict (name) do update set value = excluded.value`, [policy, String(graceDays)]);
280
+ // the grace period counts from when a stricter policy was turned on
281
+ if (policy !== 'off' && (cur === 'off' || (cur === 'admins' && policy === 'all')))
282
+ await c.query(`insert into meta.setting (name, value) values ('mfa_required_since', now()::text) on conflict (name) do update set value = excluded.value`);
283
+ if (policy === 'off') await c.query(`delete from meta.setting where name = 'mfa_required_since'`);
284
+ });
285
+ }
286
+
287
+ /**
288
+ * A developer without a factor while the policy requires one: 'grace' until the grace period ends (a
289
+ * banner), then 'enrol' (only the Security page and signing out until they enrol); null otherwise.
290
+ */
291
+ export async function enrolmentDue(username: string, isAdmin: boolean): Promise<{ due: 'grace' | 'enrol'; until: Date } | null> {
292
+ const { policy, since, graceDays } = await mfaPolicy();
293
+ if (!requiredFor(policy, isAdmin) || (await hasFactor(username))) return null;
294
+ const until = new Date((since ?? new Date()).getTime() + graceDays * 86_400_000);
295
+ return { due: Date.now() < until.getTime() ? 'grace' : 'enrol', until };
296
+ }
297
+
298
+ /** Remove every second factor of a developer (an administrator, or the command line) and end their sessions. */
299
+ export async function resetFactors(username: string): Promise<boolean> {
300
+ return owner.tx(async (c) => {
301
+ const known = (await c.query('select 1 from meta.developer where username = $1', [username])).rowCount;
302
+ if (!known) return false;
303
+ await c.query('delete from meta.developer_factor where username = $1', [username]);
304
+ await c.query('delete from meta.developer_recovery_code where username = $1', [username]);
305
+ await c.query('delete from meta.developer_trusted_browser where username = $1', [username]);
306
+ await c.query('delete from meta.session where app_id is null and username = $1', [username]);
307
+ return true;
308
+ });
309
+ }
310
+
311
+ /** The e-mail addresses of the administrators (to tell them about a reset or a policy change). */
312
+ export async function adminEmails(): Promise<string[]> {
313
+ return (await owner.query<{ email: string }>(`select email from meta.developer where is_admin and email is not null`)).rows.map((r) => r.email);
314
+ }
315
+
316
+ /** Tell people about a reset or a policy change by mail, when the builder has a mail service (best effort). */
317
+ export async function notifyMfa(to: string[], subject: string, text: string): Promise<number> {
318
+ const list = [...new Set(to.filter(Boolean))];
319
+ if (!list.length) return 0;
320
+ const { builderMail } = await import('./builder/find-workspace.ts');
321
+ const { sendWith } = await import('./mail/mail.ts');
322
+ const mail = await builderMail().catch(() => null);
323
+ if (!mail) return 0;
324
+ let sent = 0;
325
+ for (const address of list)
326
+ try {
327
+ await sendWith(mail.service, { from: mail.from, to: [address], cc: [], bcc: [], replyTo: null, subject, text, html: null, attachments: [] });
328
+ sent++;
329
+ } catch {
330
+ // the activity log has the event; a mail that fails doesn't undo it
331
+ }
332
+ return sent;
333
+ }
@@ -0,0 +1,205 @@
1
+ import { createHash, createPublicKey, randomBytes, timingSafeEqual, verify, type KeyObject } from 'node:crypto';
2
+
3
+ // (sprint 127, SEC-2) Passkeys and security keys (WebAuthn level 2) for developers, without a library: the
4
+ // server makes a one-time challenge, the browser asks the authenticator (navigator.credentials), and the
5
+ // server checks what comes back. Registration takes attestation "none": pgkiln stores the credential's public
6
+ // key (as a JWK) and trusts the authenticator the developer chose, as most sites do. Sign-in checks the
7
+ // challenge, the origin, the relying party (RP ID hash), user presence, the signature over authenticatorData
8
+ // and the hash of clientDataJSON, and the signature counter (a clone gives itself away when it goes back).
9
+ // Algorithms: ES256 (-7), EdDSA (-8) and RS256 (-257).
10
+
11
+ export const ALGORITHMS = [-7, -8, -257];
12
+
13
+ export class WebAuthnError extends Error {}
14
+
15
+ export const b64url = (b: Buffer | Uint8Array) => Buffer.from(b).toString('base64url');
16
+ export const unb64url = (s: string) => Buffer.from(String(s ?? ''), 'base64url');
17
+ export const newChallenge = () => b64url(randomBytes(32));
18
+
19
+ // ------------------------------------------------------------------ CBOR (RFC 8949), only what WebAuthn uses
20
+
21
+ type Cbor = number | bigint | string | Buffer | boolean | null | undefined | Cbor[] | Map<Cbor, Cbor>;
22
+
23
+ /** Decode one CBOR item from `buf` at `pos`; returns the item and the position after it. */
24
+ export function cborDecode(buf: Buffer, pos = 0, depth = 0): [Cbor, number] {
25
+ if (depth > 16) throw new WebAuthnError('CBOR nested too deep');
26
+ if (pos >= buf.length) throw new WebAuthnError('CBOR ends early');
27
+ const first = buf[pos++];
28
+ const major = first >> 5;
29
+ const info = first & 31;
30
+ let len: number | bigint;
31
+ if (info < 24) len = info;
32
+ else if (info === 24) len = buf.readUInt8(pos), pos += 1;
33
+ else if (info === 25) len = buf.readUInt16BE(pos), pos += 2;
34
+ else if (info === 26) len = buf.readUInt32BE(pos), pos += 4;
35
+ else if (info === 27) len = buf.readBigUInt64BE(pos), pos += 8;
36
+ else throw new WebAuthnError('CBOR: indefinite lengths are not used by WebAuthn');
37
+ const n = () => {
38
+ if (typeof len === 'bigint') throw new WebAuthnError('CBOR length too large');
39
+ if (pos + len > buf.length && (major === 2 || major === 3)) throw new WebAuthnError('CBOR ends early');
40
+ return len;
41
+ };
42
+ switch (major) {
43
+ case 0: return [typeof len === 'bigint' ? len : len, pos];
44
+ case 1: return [typeof len === 'bigint' ? -1n - len : -1 - len, pos];
45
+ case 2: { const l = n(); return [buf.subarray(pos, pos + l), pos + l]; }
46
+ case 3: { const l = n(); return [buf.subarray(pos, pos + l).toString('utf8'), pos + l]; }
47
+ case 4: {
48
+ const l = n();
49
+ if (l > 1000) throw new WebAuthnError('CBOR array too long');
50
+ const out: Cbor[] = [];
51
+ for (let i = 0; i < l; i++) { const [v, p] = cborDecode(buf, pos, depth + 1); out.push(v); pos = p; }
52
+ return [out, pos];
53
+ }
54
+ case 5: {
55
+ const l = n();
56
+ if (l > 1000) throw new WebAuthnError('CBOR map too long');
57
+ const out = new Map<Cbor, Cbor>();
58
+ for (let i = 0; i < l; i++) {
59
+ const [k, p1] = cborDecode(buf, pos, depth + 1);
60
+ const [v, p2] = cborDecode(buf, p1, depth + 1);
61
+ out.set(k, v); pos = p2;
62
+ }
63
+ return [out, pos];
64
+ }
65
+ case 7:
66
+ if (info === 20) return [false, pos];
67
+ if (info === 21) return [true, pos];
68
+ if (info === 22) return [null, pos];
69
+ if (info === 23) return [undefined, pos];
70
+ throw new WebAuthnError('CBOR: floats are not used by WebAuthn');
71
+ default:
72
+ throw new WebAuthnError('CBOR: tags are not used by WebAuthn');
73
+ }
74
+ }
75
+
76
+ // ------------------------------------------------------------------ COSE keys (RFC 9053) → Node key objects
77
+
78
+ /** A credential public key (COSE_Key) as a JWK, with its algorithm. */
79
+ export function coseToJwk(cose: Map<Cbor, Cbor>): { alg: number; jwk: Record<string, string> } {
80
+ const kty = cose.get(1);
81
+ const alg = Number(cose.get(3));
82
+ const bytes = (k: number) => {
83
+ const v = cose.get(k);
84
+ if (!Buffer.isBuffer(v)) throw new WebAuthnError('the key is missing a part');
85
+ return b64url(v);
86
+ };
87
+ if (kty === 2 && alg === -7 && cose.get(-1) === 1) return { alg, jwk: { kty: 'EC', crv: 'P-256', x: bytes(-2), y: bytes(-3) } };
88
+ if (kty === 1 && alg === -8 && cose.get(-1) === 6) return { alg, jwk: { kty: 'OKP', crv: 'Ed25519', x: bytes(-2) } };
89
+ if (kty === 3 && alg === -257) return { alg, jwk: { kty: 'RSA', n: bytes(-1), e: bytes(-2) } };
90
+ throw new WebAuthnError(`unsupported key type or algorithm (${String(kty)}, ${alg})`);
91
+ }
92
+
93
+ const keyOf = (jwk: Record<string, string>): KeyObject => createPublicKey({ key: jwk, format: 'jwk' });
94
+
95
+ // ------------------------------------------------------------------ authenticator data
96
+
97
+ interface AuthData {
98
+ rpIdHash: Buffer;
99
+ flags: number;
100
+ signCount: number;
101
+ credentialId?: Buffer;
102
+ publicKey?: Map<Cbor, Cbor>;
103
+ }
104
+
105
+ export const FLAG_UP = 0x01; // user present
106
+ export const FLAG_UV = 0x04; // user verified
107
+ const FLAG_AT = 0x40; // attested credential data included
108
+
109
+ export function parseAuthData(buf: Buffer): AuthData {
110
+ if (buf.length < 37) throw new WebAuthnError('authenticator data too short');
111
+ const out: AuthData = { rpIdHash: buf.subarray(0, 32), flags: buf[32], signCount: buf.readUInt32BE(33) };
112
+ if (out.flags & FLAG_AT) {
113
+ if (buf.length < 55) throw new WebAuthnError('authenticator data too short');
114
+ const idLen = buf.readUInt16BE(53);
115
+ if (idLen > 1023 || buf.length < 55 + idLen) throw new WebAuthnError('bad credential id');
116
+ out.credentialId = buf.subarray(55, 55 + idLen);
117
+ const [key] = cborDecode(buf, 55 + idLen);
118
+ if (!(key instanceof Map)) throw new WebAuthnError('bad credential public key');
119
+ out.publicKey = key;
120
+ }
121
+ return out;
122
+ }
123
+
124
+ // ------------------------------------------------------------------ checks
125
+
126
+ export interface Expected { challenge: string; origin: string; rpId: string }
127
+
128
+ function checkClientData(clientDataJSON: Buffer, type: string, exp: Expected) {
129
+ let c: { type?: string; challenge?: string; origin?: string; crossOrigin?: boolean };
130
+ try {
131
+ c = JSON.parse(clientDataJSON.toString('utf8'));
132
+ } catch {
133
+ throw new WebAuthnError('client data is not JSON');
134
+ }
135
+ if (c.type !== type) throw new WebAuthnError(`expected ${type}`);
136
+ const a = Buffer.from(String(c.challenge ?? ''));
137
+ const b = Buffer.from(exp.challenge);
138
+ if (a.length !== b.length || !timingSafeEqual(a, b)) throw new WebAuthnError('the challenge does not match');
139
+ if (c.origin !== exp.origin) throw new WebAuthnError(`the origin ${c.origin} is not ${exp.origin}`);
140
+ if (c.crossOrigin) throw new WebAuthnError('cross-origin use is not allowed');
141
+ }
142
+
143
+ const rpHash = (rpId: string) => createHash('sha256').update(rpId).digest();
144
+
145
+ /** A new passkey from navigator.credentials.create(): the credential to store. */
146
+ export function verifyRegistration(input: { clientDataJSON: string; attestationObject: string }, exp: Expected) {
147
+ const clientData = unb64url(input.clientDataJSON);
148
+ checkClientData(clientData, 'webauthn.create', exp);
149
+ const [att] = cborDecode(unb64url(input.attestationObject));
150
+ if (!(att instanceof Map)) throw new WebAuthnError('bad attestation object');
151
+ const authData = att.get('authData');
152
+ if (!Buffer.isBuffer(authData)) throw new WebAuthnError('bad attestation object');
153
+ const a = parseAuthData(authData);
154
+ if (!a.rpIdHash.equals(rpHash(exp.rpId))) throw new WebAuthnError('the passkey is for another site');
155
+ if (!(a.flags & FLAG_UP)) throw new WebAuthnError('the user was not present');
156
+ if (!a.credentialId || !a.publicKey) throw new WebAuthnError('no credential in the response');
157
+ const { alg, jwk } = coseToJwk(a.publicKey);
158
+ keyOf(jwk); // a key Node can read
159
+ return { credentialId: b64url(a.credentialId), alg, jwk, signCount: a.signCount, userVerified: !!(a.flags & FLAG_UV) };
160
+ }
161
+
162
+ /** A sign-in from navigator.credentials.get(): the new signature counter, or an error. */
163
+ export function verifyAssertion(
164
+ input: { clientDataJSON: string; authenticatorData: string; signature: string },
165
+ stored: { alg: number; jwk: Record<string, string>; signCount: number },
166
+ exp: Expected,
167
+ ) {
168
+ const clientData = unb64url(input.clientDataJSON);
169
+ checkClientData(clientData, 'webauthn.get', exp);
170
+ const authData = unb64url(input.authenticatorData);
171
+ const a = parseAuthData(authData);
172
+ if (!a.rpIdHash.equals(rpHash(exp.rpId))) throw new WebAuthnError('the passkey is for another site');
173
+ if (!(a.flags & FLAG_UP)) throw new WebAuthnError('the user was not present');
174
+ const data = Buffer.concat([authData, createHash('sha256').update(clientData).digest()]);
175
+ const sig = unb64url(input.signature);
176
+ const ok = stored.alg === -8 ? verify(null, data, keyOf(stored.jwk), sig) : verify('sha256', data, keyOf(stored.jwk), sig);
177
+ if (!ok) throw new WebAuthnError('the signature does not match');
178
+ // counters only go up; 0 and 0 means the authenticator keeps none (synced passkeys)
179
+ if ((a.signCount !== 0 || stored.signCount !== 0) && a.signCount <= stored.signCount) throw new WebAuthnError('the signature counter went back (a cloned key?)');
180
+ return { signCount: a.signCount, userVerified: !!(a.flags & FLAG_UV) };
181
+ }
182
+
183
+ // ------------------------------------------------------------------ the relying party
184
+
185
+ /**
186
+ * The relying party a passkey belongs to and the origin the browser must report. With PUBLIC_URL set (a
187
+ * production server), that address; otherwise the address the browser used (development, tests).
188
+ * PGKILN_RP_ID may widen the RP ID to a parent domain (builder.example.com → example.com), which must be a
189
+ * suffix of the origin's host; passkeys made for one RP ID don't work for another, so changing the domain
190
+ * means adding them again (authenticator apps and recovery codes keep working).
191
+ */
192
+ export function relyingParty(req: { protocol: string; host: string }): { rpId: string; origin: string } {
193
+ let origin = `${req.protocol}://${req.host}`;
194
+ if (process.env.PUBLIC_URL) {
195
+ try {
196
+ origin = new URL(process.env.PUBLIC_URL).origin;
197
+ } catch {
198
+ // a bad PUBLIC_URL: the browser's address
199
+ }
200
+ }
201
+ const host = new URL(origin).hostname;
202
+ const wanted = (process.env.PGKILN_RP_ID ?? '').trim().toLowerCase();
203
+ const rpId = wanted && (host === wanted || host.endsWith(`.${wanted}`)) ? wanted : host;
204
+ return { rpId, origin };
205
+ }