pgkiln 0.60.0 → 0.62.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.
@@ -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
+ }