@ccmsg/cli 0.1.0 → 0.2.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,985 @@
1
+ import { createHash, createHmac, randomBytes, randomInt } from "node:crypto";
2
+ import type {
3
+ AuthAssertArgs,
4
+ AuthChallenge,
5
+ AuthChallengeResult,
6
+ AuthRecord,
7
+ AuthRefreshArgs,
8
+ AuthRefreshResult,
9
+ AuthRegisterArgs,
10
+ AuthResolveArgs,
11
+ AuthResolveResult,
12
+ AuthRotateArgs,
13
+ AuthRotateResult,
14
+ AuthSession,
15
+ Base64Url,
16
+ CredentialRecord,
17
+ Endpoint,
18
+ InstanceId,
19
+ RegisterClaims,
20
+ Subject,
21
+ Timestamp,
22
+ TokenFamily,
23
+ } from "@ccmsg/protocol";
24
+ import { AUTH_CHALLENGE_TTL_MS, REGISTER_TTL_MS } from "@ccmsg/protocol";
25
+ import { type HandlerInput, OpError, type Requester } from "../dispatch/index.ts";
26
+ import { AuthRecords, credentialKey, familyKey } from "./records.ts";
27
+ import {
28
+ base64UrlDecode,
29
+ base64UrlEncode,
30
+ checkPublicKey,
31
+ equalBytes,
32
+ equalStrings,
33
+ verifyAssertion,
34
+ verifyRegistration,
35
+ WebAuthnError,
36
+ } from "./webauthn.ts";
37
+ import { CborError } from "./cbor.ts";
38
+
39
+ /** How long an access token is accepted, and how long a refresh token is.
40
+ *
41
+ * Chosen rather than derived, within the DR's "hours" and "days" (§2.4). The
42
+ * access token's life is also a connection's: a client renews on the
43
+ * connection it already holds, so the period is what bounds a stolen token
44
+ * rather than how often a person is interrupted. The refresh token's life is
45
+ * how long a browser that was closed can come back without the authenticator. */
46
+ export const ACCESS_TTL_MS = 4 * 60 * 60 * 1000;
47
+ export const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
48
+
49
+ /** How long the generation before the standing one is still answered.
50
+ *
51
+ * It exists for one case: the rotation happened, the reply was lost, the client
52
+ * retries with the value it still holds. That is a round trip, not a session,
53
+ * so the window is short — long enough for a retry over a slow link and far too
54
+ * short to be a second usable token. */
55
+ export const PREVIOUS_GRACE_MS = 60_000;
56
+
57
+ /** How many times a six-digit code may be got wrong before the registration URL
58
+ * is spent.
59
+ *
60
+ * Five, because the code is what stands between a leaked URL and a
61
+ * registration: a million codes and five tries is a chance no one plays for,
62
+ * while a person mistyping twice still gets in. */
63
+ export const CODE_ATTEMPTS = 5;
64
+
65
+ /** How many `/auth/*` requests are answered per second, over all callers.
66
+ *
67
+ * The routes are reached before anything is proven, like the mesh's key
68
+ * endpoint (§6), and the work behind them is a signature verification. The cap
69
+ * is far above what a person at a keyboard produces and far below what would
70
+ * cost this instance anything. */
71
+ export const AUTH_RATE_LIMIT = 30;
72
+ export const AUTH_RATE_WINDOW_MS = 1_000;
73
+
74
+ /** One registration URL that has been issued and not yet spent.
75
+ *
76
+ * Everything here dies with the process. The secret signs one URL and nothing
77
+ * else, so there is no key to keep, rotate or protect — a restart loses it and
78
+ * the remedy is to issue another URL (DR-0001 §2.2). */
79
+ interface Pending {
80
+ readonly claims: RegisterClaims;
81
+ readonly secret: Buffer;
82
+ readonly code: string;
83
+ attempts: number;
84
+ }
85
+
86
+ /** One challenge this instance issued, good once (§2.6). */
87
+ interface Issued {
88
+ readonly expiresAt: Timestamp;
89
+ }
90
+
91
+ /** What a person's connection carries once an access token opened it. */
92
+ export interface AuthorizedConn {
93
+ readonly sub: Subject;
94
+ expiresAt: Timestamp;
95
+ /** The close scheduled for the deadline, cleared when the connection goes so
96
+ * a departed connection leaves no timer behind. */
97
+ timer?: ReturnType<typeof setTimeout>;
98
+ }
99
+
100
+ export interface AuthDeps {
101
+ readonly self: InstanceId;
102
+ readonly records: AuthRecords;
103
+ /** The pages allowed to run these exchanges: `clientDataJSON.origin` is
104
+ * compared against this, and so is the CORS answer (§2.3). */
105
+ readonly origins: () => readonly string[];
106
+ /** Where this instance is dialed, which a registration URL is issued against
107
+ * when the operator names none. */
108
+ readonly endpoint: () => Endpoint | undefined;
109
+ /** The instance's name as a person operates it, carried in the URL for
110
+ * display. */
111
+ readonly unit: string;
112
+ /** Ask another instance one of the two ops only its issuer can answer.
113
+ * Absent on an instance with no mesh, where an issuer that is not us is an
114
+ * issuer that cannot be reached. */
115
+ readonly ask?: (to: InstanceId, op: string, args: Record<string, unknown>) => Promise<unknown>;
116
+ readonly now?: () => Timestamp;
117
+ readonly log?: (msg: string, fields?: Record<string, unknown>) => void;
118
+ }
119
+
120
+ /** A person's session as this instance just minted it: what the caller is told,
121
+ * and the refresh token whoever ran the op has to put in a cookie.
122
+ *
123
+ * The two travel together rather than through a slot on this object, because
124
+ * two exchanges may be in flight at once and a slot would hand one caller the
125
+ * other's token. The contract keeps the refresh token out of the op's result on
126
+ * purpose (`AuthSession`), so it is answered here and the carrier is what
127
+ * decides where it goes. */
128
+ export interface MintedSession {
129
+ readonly session: AuthSession;
130
+ readonly refresh: { readonly value: Base64Url; readonly expires_at: Timestamp };
131
+ }
132
+
133
+ /** What a registration URL is, as the command that made it prints it. */
134
+ export interface IssuedRegistration {
135
+ readonly sub: Subject;
136
+ readonly url: string;
137
+ readonly code: string;
138
+ /** The WebAuthn user handle this subject is known by, which the page creates
139
+ * the credential against. It is also inside the URL's claims; it is stated
140
+ * here so the command that issued the URL can show what it settled. */
141
+ readonly user_id: Base64Url;
142
+ readonly expires_at: Timestamp;
143
+ readonly endpoint: Endpoint;
144
+ readonly rp_id: string;
145
+ }
146
+
147
+ /** The person's authentication: the registration URLs this instance issued, the
148
+ * challenges it holds, and the tokens it minted (DR-0001 §2.2-§2.6).
149
+ *
150
+ * What is written down is the records; everything here is memory, and every
151
+ * one of those is short-lived by design. */
152
+ export class Auth {
153
+ readonly #pending = new Map<string, Pending>();
154
+ readonly #challenges = new Map<Base64Url, Issued>();
155
+ readonly #authorized = new Map<Requester, AuthorizedConn>();
156
+ #window = 0;
157
+ #served = 0;
158
+
159
+ constructor(private readonly deps: AuthDeps) {}
160
+
161
+ get records(): AuthRecords {
162
+ return this.deps.records;
163
+ }
164
+
165
+ #now(): Timestamp {
166
+ return (this.deps.now ?? Date.now)();
167
+ }
168
+
169
+ // --- issuing a registration URL (§2.2) ---
170
+
171
+ /** Make one registration URL and the code that goes with it.
172
+ *
173
+ * The two halves reach the browser by different routes: the URL is carried
174
+ * there by whoever was given it, and the code is only ever shown on the
175
+ * terminal this ran on. Somebody holding the URL alone cannot register. */
176
+ issue(options: {
177
+ readonly endpoint?: Endpoint;
178
+ readonly rpId?: string;
179
+ readonly label?: string;
180
+ readonly sub?: Subject;
181
+ }): IssuedRegistration {
182
+ const endpoint = options.endpoint ?? this.deps.endpoint();
183
+ if (endpoint === undefined) {
184
+ throw new OpError(
185
+ "invalid_args",
186
+ "この instance には endpoint が無いので、登録先の URL を引数で渡してください",
187
+ );
188
+ }
189
+ const host = hostOf(endpoint);
190
+ const rpId = options.rpId ?? host;
191
+ // The relying party is a domain the endpoint's host belongs to, and nothing
192
+ // wider: a credential made for a suffix this instance does not sit under
193
+ // would be usable at every other host under it (§2.3).
194
+ if (!isRegistrableSuffix(rpId, host)) {
195
+ throw new OpError("invalid_args", `${rpId} は ${host} の登録可能なドメインではありません`);
196
+ }
197
+ const sub = options.sub ?? this.#nextSubject();
198
+ if (this.deps.records.removed(sub)) {
199
+ throw new OpError("forbidden", `${sub} は削除済みなので、この名前では登録できません`);
200
+ }
201
+ const at = this.#now();
202
+ const claims: RegisterClaims = {
203
+ iss: this.deps.self,
204
+ sub,
205
+ unit: this.deps.unit,
206
+ endpoint,
207
+ rp_id: rpId,
208
+ expires_at: at + REGISTER_TTL_MS,
209
+ jti: randomBytes(16).toString("base64url"),
210
+ user_id: this.#userIdFor(sub),
211
+ ...(options.label === undefined ? {} : { issued_label: options.label }),
212
+ };
213
+ const secret = randomBytes(32);
214
+ const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
215
+ this.#pending.set(claims.jti, { claims, secret, code, attempts: 0 });
216
+ return {
217
+ sub,
218
+ url: `${webOrigin(endpoint)}/#register=${sign(claims, secret)}`,
219
+ code,
220
+ user_id: claims.user_id,
221
+ expires_at: claims.expires_at,
222
+ endpoint,
223
+ rp_id: rpId,
224
+ };
225
+ }
226
+
227
+ /** The WebAuthn user handle this subject is known by.
228
+ *
229
+ * Settled once per subject and reused for every later registration of it: the
230
+ * authenticator keeps the handle beyond this instance's reach, so a second
231
+ * value for one person would show up on their device as a second account
232
+ * (contract, `RegisterClaims.user_id`). A subject that already has a
233
+ * credential is registered against the handle that credential carries; a
234
+ * subject in the middle of another registration, against the one that
235
+ * registration named.
236
+ *
237
+ * Sixteen bytes, which is what the specification recommends and what the page
238
+ * would otherwise have had to choose. */
239
+ #userIdFor(sub: Subject): Base64Url {
240
+ for (const record of this.deps.records.credentials()) {
241
+ if (record.sub === sub) return record.user_handle;
242
+ }
243
+ for (const held of this.#pending.values()) {
244
+ if (held.claims.sub === sub) return held.claims.user_id;
245
+ }
246
+ return base64UrlEncode(randomBytes(16));
247
+ }
248
+
249
+ /** The next `<unit>-N` nobody holds.
250
+ *
251
+ * Read from the records rather than counted in memory: a counter would start
252
+ * at one again after a restart and hand the next person a name somebody
253
+ * already has, which would be a second person under one subject rather than a
254
+ * new one. A name a removal took is skipped too — the tombstone over it
255
+ * refuses every later write, so issuing it would produce a URL that cannot
256
+ * complete.
257
+ *
258
+ * The pending registrations count as taken as well: two URLs made before
259
+ * either is spent are two people. */
260
+ #nextSubject(): Subject {
261
+ const prefix = `${this.deps.unit}-`;
262
+ const taken = new Set<string>();
263
+ for (const record of this.deps.records.credentials()) taken.add(record.sub);
264
+ for (const held of this.#pending.values()) taken.add(held.claims.sub);
265
+ let highest = 0;
266
+ for (const sub of taken) {
267
+ if (!sub.startsWith(prefix)) continue;
268
+ const counted = Number(sub.slice(prefix.length));
269
+ if (Number.isInteger(counted) && counted > highest) highest = counted;
270
+ }
271
+ for (let next = highest + 1; ; next += 1) {
272
+ const sub = `${prefix}${String(next)}`;
273
+ if (!taken.has(sub) && !this.deps.records.removed(sub)) return sub;
274
+ }
275
+ }
276
+
277
+ /** The credentials a person may read back, newest registration first. */
278
+ list(): CredentialRecord[] {
279
+ return this.deps.records
280
+ .credentials()
281
+ .sort((left, right) => right.registered_at - left.registered_at);
282
+ }
283
+
284
+ /** Remove one person: the tombstones, and every connection they hold. */
285
+ remove(sub: Subject): { records: AuthRecord[]; closed: number } {
286
+ const records = this.deps.records.remove(sub);
287
+ return { records, closed: this.disconnect(sub) };
288
+ }
289
+
290
+ /** Close every connection one person holds.
291
+ *
292
+ * The other half of a removal, and the half that has to run whoever decided
293
+ * it: a tombstone that arrived from a peer revokes the same person here, and
294
+ * a connection left open on a revoked credential is the removal not having
295
+ * happened (DR-0001 §2.6). Failing a family reaches this the same way. */
296
+ disconnect(sub: Subject): number {
297
+ let closed = 0;
298
+ for (const [conn, held] of this.#authorized) {
299
+ if (held.sub !== sub) continue;
300
+ clearTimeout(held.timer);
301
+ this.#authorized.delete(conn);
302
+ conn.close();
303
+ closed += 1;
304
+ }
305
+ return closed;
306
+ }
307
+
308
+ /** Take what a peer wrote on `auth_records`, and act on the removals in it. */
309
+ merge(records: readonly AuthRecord[]): void {
310
+ const { removed } = this.deps.records.merge(records);
311
+ for (const sub of removed) this.disconnect(sub);
312
+ }
313
+
314
+ // --- challenges (§2.6) ---
315
+
316
+ challenge(): AuthChallengeResult {
317
+ this.#forget();
318
+ const value = base64UrlEncode(randomBytes(32));
319
+ const expiresAt = this.#now() + AUTH_CHALLENGE_TTL_MS;
320
+ this.#challenges.set(value, { expiresAt });
321
+ return { challenge: value, issuer: this.deps.self, expires_at: expiresAt };
322
+ }
323
+
324
+ /** Spend one challenge this instance issued. Good once: the second call for
325
+ * the same value finds nothing, which is a refusal. */
326
+ spend(value: Base64Url): void {
327
+ this.#forget();
328
+ const held = this.#challenges.get(value);
329
+ if (held === undefined) throw new OpError("auth_invalid", "この challenge は使えません");
330
+ this.#challenges.delete(value);
331
+ }
332
+
333
+ /** Spend a challenge wherever it was issued: here, or at the instance the
334
+ * caller says issued it (§2.4, behind a load balancer either may be
335
+ * reached). */
336
+ async #spendAnywhere(challenge: AuthChallenge): Promise<void> {
337
+ if (challenge.issuer === this.deps.self) {
338
+ this.spend(challenge.challenge);
339
+ return;
340
+ }
341
+ await this.#atIssuer(challenge.issuer, "auth_resolve", {
342
+ kind: "challenge",
343
+ challenge: challenge.challenge,
344
+ } satisfies AuthResolveArgs);
345
+ }
346
+
347
+ /** Spend the challenge a registration answered.
348
+ *
349
+ * Stated with its issuer, it is spent wherever that is. Left unstated, it can
350
+ * only be honoured where this instance holds it — a value nobody named an
351
+ * issuer for is one there is nobody to ask about. */
352
+ async #spendStated(challenge: Base64Url, stated: AuthChallenge | undefined): Promise<void> {
353
+ if (stated === undefined) {
354
+ this.spend(challenge);
355
+ return;
356
+ }
357
+ if (!equalStrings(stated.challenge, challenge)) {
358
+ throw new OpError("auth_invalid", "答えた challenge と名乗った challenge が違います");
359
+ }
360
+ await this.#spendAnywhere(stated);
361
+ }
362
+
363
+ #atIssuer(iss: InstanceId, op: string, args: Record<string, unknown>): Promise<unknown> {
364
+ const ask = this.deps.ask;
365
+ if (ask === undefined) {
366
+ throw new OpError("auth_unknown_issuer", `${iss} には問い合わせられません`);
367
+ }
368
+ return ask(iss, op, args);
369
+ }
370
+
371
+ // --- registration (§2.2) ---
372
+
373
+ /** Verify a registration and write the credential down.
374
+ *
375
+ * The registration URL is checked where its secret is, which may be another
376
+ * instance; everything else — the WebAuthn verification, the record — is done
377
+ * here, by whoever the browser reached (§2.6). */
378
+ async register(
379
+ args: AuthRegisterArgs,
380
+ from: { ip?: string; userAgent?: string } = {},
381
+ ): Promise<MintedSession> {
382
+ // What the URL says about itself, before anything has vouched for it. It is
383
+ // read to know which relying party the credential should have been made
384
+ // under; nothing is decided by it, because the same fields come back
385
+ // authenticated below and the two are held to each other.
386
+ const stated = claimsOf(args.token);
387
+ // What the page answered, verified before anything is spent: a challenge is
388
+ // good once, so consuming it for a message that then fails to verify would
389
+ // let a caller burn challenges without ever holding a credential (m9).
390
+ const challenge = challengeIn(args.credential.client_data_json);
391
+ const verified = refusable(() =>
392
+ verifyRegistration(args.credential, {
393
+ challenge,
394
+ origins: this.deps.origins(),
395
+ rpId: stated.rp_id,
396
+ }),
397
+ );
398
+ // A key nothing can verify with is a credential that can never be used, and
399
+ // finding that out at the person's next sign-in leaves a record nobody can
400
+ // explain (M8).
401
+ await refusableAsync(() => checkPublicKey(base64UrlDecode(verified.publicKey)));
402
+ // Only now is the URL spent. It is good once, like the challenge, so
403
+ // consuming it for a message that then failed to verify would let a caller
404
+ // burn registrations without ever holding a credential (m9). What comes
405
+ // back is the authenticated form of what was read above, and the relying
406
+ // party the credential was actually checked against has to be the one the
407
+ // issuer authorized.
408
+ const claims = await this.#claimsOf(args);
409
+ if (claims.rp_id !== stated.rp_id) {
410
+ throw new OpError("auth_invalid", "登録 URL が名乗る relying party が一致しません");
411
+ }
412
+ if (this.deps.records.removed(claims.sub)) {
413
+ throw new OpError("forbidden", `${claims.sub} は削除済みです`);
414
+ }
415
+ // The challenge is stated beside the credential when the page knows who
416
+ // issued it. Where it is not, this instance is the only one that can spend
417
+ // it — and one it does not hold is a challenge from somewhere it cannot
418
+ // ask about (contract, `AuthRegisterArgs.challenge`).
419
+ await this.#spendStated(challenge, args.challenge);
420
+ if (this.deps.records.credential(verified.credentialId) !== undefined) {
421
+ throw new OpError("auth_invalid", "この credential は既に登録されています");
422
+ }
423
+ const at = this.#now();
424
+ const record: CredentialRecord = {
425
+ kind: "credential",
426
+ sub: claims.sub,
427
+ credential_id: verified.credentialId,
428
+ public_key: verified.publicKey,
429
+ user_handle: claims.user_id,
430
+ rp_id: claims.rp_id,
431
+ sign_count: verified.signCount,
432
+ ...(claims.issued_label === undefined ? {} : { issued_label: claims.issued_label }),
433
+ ...(args.device_label === undefined ? {} : { device_label: args.device_label }),
434
+ registered_at: at,
435
+ ...(from.ip === undefined ? {} : { registered_ip: from.ip }),
436
+ ...(from.userAgent === undefined ? {} : { registered_user_agent: from.userAgent }),
437
+ };
438
+ this.deps.records.write(credentialKey(claims.sub, verified.credentialId), record, at);
439
+ return this.mint(claims.sub);
440
+ }
441
+
442
+ /** What a registration URL authorized.
443
+ *
444
+ * Only its issuer can say, because only the issuer holds the secret that
445
+ * signed it — and the six digits are held beside that secret. So the digits
446
+ * travel there unjudged: an instance that decided them itself would let
447
+ * somebody spread guesses across the cluster without any of them counting
448
+ * against the URL (contract, `AuthResolveArgs`). Nothing is spent here.
449
+ *
450
+ * The claims come back from the issuer having been checked and consumed, and
451
+ * everything after this — the WebAuthn verification, the record — is done by
452
+ * whichever instance the browser actually reached (§2.6). */
453
+ async #claimsOf(args: AuthRegisterArgs): Promise<RegisterClaims> {
454
+ const stated = claimsOf(args.token);
455
+ if (stated.iss === this.deps.self) return this.resolveRegistration(args.token, args.code);
456
+ const answer = (await this.#atIssuer(stated.iss, "auth_resolve", {
457
+ kind: "register",
458
+ token: args.token,
459
+ code: args.code,
460
+ } satisfies AuthResolveArgs)) as AuthResolveResult;
461
+ if (answer.kind !== "register") {
462
+ throw new OpError("auth_invalid", "登録 URL の発行者が別のものを答えました");
463
+ }
464
+ return answer.claims;
465
+ }
466
+
467
+ /** Check a registration URL against the secret that signed it, and spend it.
468
+ *
469
+ * Only the issuer can run this, which is what `auth_resolve` is for. The code
470
+ * is checked here too: it was issued with the secret and is held beside it,
471
+ * and letting another instance check it would be putting the one defence
472
+ * against a leaked URL somewhere the URL's holder could reach. */
473
+ resolveRegistration(token: string, code?: string): RegisterClaims {
474
+ const stated = claimsOf(token);
475
+ const held = this.#pending.get(stated.jti);
476
+ if (held === undefined) {
477
+ throw new OpError("auth_expired", "この登録 URL は使えません。再発行してください");
478
+ }
479
+ if (held.claims.expires_at <= this.#now()) {
480
+ this.#pending.delete(stated.jti);
481
+ throw new OpError("auth_expired", "この登録 URL は期限切れです。再発行してください");
482
+ }
483
+ if (!equalStrings(token, sign(held.claims, held.secret))) {
484
+ throw new OpError("auth_invalid", "この登録 URL の署名が合いません");
485
+ }
486
+ if (code === undefined || !equalStrings(code, held.code)) {
487
+ held.attempts += 1;
488
+ // The URL itself is spent once the tries are gone, so guessing the code
489
+ // costs the whole registration rather than one attempt (§2.2).
490
+ if (held.attempts >= CODE_ATTEMPTS) {
491
+ this.#pending.delete(stated.jti);
492
+ throw new OpError(
493
+ "auth_expired",
494
+ "コードの入力を間違えすぎました。URL を再発行してください",
495
+ );
496
+ }
497
+ throw new OpError("auth_invalid", "コードが違います");
498
+ }
499
+ this.#pending.delete(stated.jti);
500
+ return held.claims;
501
+ }
502
+
503
+ // --- assertion (§2.5) ---
504
+
505
+ async assert(
506
+ args: AuthAssertArgs,
507
+ from: { ip?: string; userAgent?: string } = {},
508
+ ): Promise<MintedSession> {
509
+ const record = this.deps.records.credential(args.credential.raw_id);
510
+ if (record === undefined) {
511
+ throw new OpError("auth_invalid", "この credential は登録されていません");
512
+ }
513
+ // A resident credential answers with the handle it was created against,
514
+ // which is how a person is found without having named an account. It is
515
+ // held to what the registration settled: a handle naming somebody else is
516
+ // an authenticator answering for a credential that is not the one this
517
+ // record describes (contract, `RegisterClaims.user_id`).
518
+ const handle = args.credential.user_handle;
519
+ if (
520
+ handle !== undefined &&
521
+ !equalBytes(base64UrlDecode(handle), base64UrlDecode(record.user_handle))
522
+ ) {
523
+ throw new OpError("auth_invalid", "この assertion は別の利用者の handle を名乗っています");
524
+ }
525
+ // Verified before the challenge is spent, for the reason a registration is
526
+ // (m9): a good-once value burnt by a message that never verified is a value
527
+ // a caller can burn at will.
528
+ const { signCount } = await refusableAsync(() =>
529
+ verifyAssertion(
530
+ args.credential,
531
+ {
532
+ publicKey: record.public_key,
533
+ ...(record.sign_count === undefined ? {} : { signCount: record.sign_count }),
534
+ },
535
+ {
536
+ challenge: args.challenge.challenge,
537
+ origins: this.deps.origins(),
538
+ rpIds: this.#rpIdFor(record),
539
+ },
540
+ ),
541
+ );
542
+ await this.#spendAnywhere(args.challenge);
543
+ const at = this.#now();
544
+ this.deps.records.write(
545
+ credentialKey(record.sub, record.credential_id),
546
+ {
547
+ ...record,
548
+ sign_count: signCount,
549
+ last_used_at: at,
550
+ ...(from.ip === undefined ? {} : { last_used_ip: from.ip }),
551
+ ...(from.userAgent === undefined ? {} : { last_used_user_agent: from.userAgent }),
552
+ },
553
+ at,
554
+ );
555
+ return this.mint(record.sub);
556
+ }
557
+
558
+ /** The relying party an assertion is checked against.
559
+ *
560
+ * The one the credential was registered under, which the record carries: a
561
+ * passkey only ever answers for the domain it was made under, and the
562
+ * endpoint being reached says nothing about that (§2.3). Only a record
563
+ * written before the field existed falls back to the names this instance was
564
+ * configured to be, and nothing is widened to a suffix. */
565
+ #rpIdFor(record: CredentialRecord): string[] {
566
+ if (record.rp_id !== undefined) return [record.rp_id];
567
+ return this.#configuredNames();
568
+ }
569
+
570
+ #configuredNames(): string[] {
571
+ const names = new Set<string>();
572
+ for (const origin of this.deps.origins()) {
573
+ try {
574
+ names.add(new URL(origin).hostname);
575
+ } catch {
576
+ // A configured value that is not a URL names no host.
577
+ }
578
+ }
579
+ const endpoint = this.deps.endpoint();
580
+ if (endpoint !== undefined) names.add(hostOf(endpoint));
581
+ if (names.size === 0) {
582
+ throw new OpError("auth_invalid", "この instance には relying party がありません");
583
+ }
584
+ return [...names];
585
+ }
586
+
587
+ // --- tokens (§2.4) ---
588
+
589
+ /** Make a family for this person, minted by this instance. */
590
+ mint(sub: Subject): MintedSession {
591
+ const at = this.#now();
592
+ const family: TokenFamily = {
593
+ kind: "token_family",
594
+ sub,
595
+ iss: this.deps.self,
596
+ access: { value: token(), expires_at: at + ACCESS_TTL_MS },
597
+ refresh: { value: token(), expires_at: at + REFRESH_TTL_MS },
598
+ };
599
+ const id = randomBytes(8).toString("hex");
600
+ this.deps.records.write(familyKey(sub, id), family, at);
601
+ return { session: { sub, access: family.access }, refresh: family.refresh };
602
+ }
603
+
604
+ /** Rotate a family from a refresh token, wherever it was minted.
605
+ *
606
+ * A family is written by its `iss` alone, so a rotation that landed here for
607
+ * a family minted elsewhere is carried there rather than done here — two
608
+ * instances rotating one family in parallel would merge by last write and
609
+ * read exactly like a stolen token being replayed (§2.4). */
610
+ async refreshToken(value: Base64Url): Promise<MintedSession> {
611
+ const held = this.deps.records.byRefresh(value);
612
+ if (held === undefined) {
613
+ // Not the standing generation, nor the one before it. Either it never was
614
+ // one, or it is a value that has already been rotated away — which is a
615
+ // token being reused, and fails the family it belongs to.
616
+ await this.#refuseReuse(value);
617
+ throw new OpError("auth_invalid", "この refresh token は使えません");
618
+ }
619
+ if (held.body.iss !== this.deps.self) {
620
+ const answer = (await this.#atIssuer(held.body.iss, "auth_rotate", {
621
+ refresh_token: value,
622
+ } satisfies AuthRotateArgs)) as AuthRotateResult;
623
+ return { session: { sub: answer.sub, access: answer.access }, refresh: answer.refresh };
624
+ }
625
+ const rotated = this.rotate(value);
626
+ return { session: { sub: rotated.sub, access: rotated.access }, refresh: rotated.refresh };
627
+ }
628
+
629
+ /** A value that works nowhere. If it was once some family's, the family is
630
+ * failed — by its `iss`, which is the only instance that may write it.
631
+ *
632
+ * A family this instance minted is failed here. One minted elsewhere is
633
+ * failed by asking that instance to rotate the value: it will find the same
634
+ * thing this instance did, and fail its own family. Forwarding rather than
635
+ * writing is what keeps the single writer single; an issuer that cannot be
636
+ * reached leaves the refusal as the whole of the answer. */
637
+ async #refuseReuse(value: Base64Url): Promise<void> {
638
+ const owner = this.deps.records.owning(value, digestOf(value));
639
+ if (owner === undefined) return;
640
+ if (owner.body.iss === this.deps.self) {
641
+ this.#failReused(value);
642
+ return;
643
+ }
644
+ try {
645
+ await this.#atIssuer(owner.body.iss, "auth_rotate", {
646
+ refresh_token: value,
647
+ } satisfies AuthRotateArgs);
648
+ } catch {
649
+ // Whatever the issuer said, or that it said nothing: the caller is
650
+ // refused either way, and this instance has no standing to fail a family
651
+ // it does not write.
652
+ }
653
+ }
654
+
655
+ /** Rotate a family this instance minted. The one writer's own operation, and
656
+ * what `auth_rotate` runs on its behalf. */
657
+ rotate(value: Base64Url): AuthRotateResult {
658
+ const held = this.deps.records.byRefresh(value);
659
+ if (held === undefined) {
660
+ this.#failReused(value);
661
+ throw new OpError("auth_invalid", "この refresh token は使えません");
662
+ }
663
+ if (held.body.iss !== this.deps.self) {
664
+ throw new OpError("auth_invalid", `この family を書けるのは ${held.body.iss} だけです`);
665
+ }
666
+ // Answering the previous generation with the standing pair rather than
667
+ // rotating again: the client that retries is asking for the answer it
668
+ // missed, and rotating on a retry would spend a generation per lost reply.
669
+ if (held.previous) {
670
+ return { sub: held.body.sub, access: held.body.access, refresh: held.body.refresh };
671
+ }
672
+ const at = this.#now();
673
+ const rotated: TokenFamily = {
674
+ kind: "token_family",
675
+ sub: held.body.sub,
676
+ iss: this.deps.self,
677
+ access: { value: token(), expires_at: at + ACCESS_TTL_MS },
678
+ refresh: { value: token(), expires_at: at + REFRESH_TTL_MS },
679
+ previous_refresh: { value: held.body.refresh.value, expires_at: at + PREVIOUS_GRACE_MS },
680
+ // The value going out of service is remembered as a digest for as long as
681
+ // it would have been accepted, so that presenting it later is recognised
682
+ // as this family's token rather than as a stranger's. The digest travels
683
+ // with the family, so the memory survives this instance restarting and
684
+ // holds wherever the reused value is presented (contract, `TokenFamily`).
685
+ retired: retire(held.body, at),
686
+ };
687
+ this.deps.records.write(held.key, rotated, at);
688
+ return { sub: rotated.sub, access: rotated.access, refresh: rotated.refresh };
689
+ }
690
+
691
+ /** A value that is nobody's standing token but was somebody's: the family it
692
+ * belonged to is failed, because a token in use twice is a token that was
693
+ * taken (§2.4).
694
+ *
695
+ * Recognised three ways: the standing refresh token past its expiry, the one
696
+ * before it past its grace, and any generation this instance rotated away
697
+ * while it has been running. A value older than what any of those covers
698
+ * matches nothing and is refused as a stranger. */
699
+ #failReused(value: Base64Url): void {
700
+ const digest = digestOf(value);
701
+ const now = this.#now();
702
+ for (const held of this.deps.records.families()) {
703
+ if (held.body.iss !== this.deps.self) continue;
704
+ const before = held.body.previous_refresh;
705
+ const stale =
706
+ equalStrings(held.body.refresh.value, value) ||
707
+ (before !== undefined && equalStrings(before.value, value)) ||
708
+ (held.body.retired ?? []).some(
709
+ (one) => one.expires_at > now && equalStrings(one.hash, digest),
710
+ );
711
+ if (!stale) continue;
712
+ this.deps.log?.("a refresh token was reused after it was rotated away", {
713
+ sub: held.body.sub,
714
+ });
715
+ this.deps.records.fail(held.key);
716
+ // The tokens are gone, and so is what they were holding open: a
717
+ // connection that outlived the family it was admitted on would be the
718
+ // stolen token still working.
719
+ this.disconnect(held.body.sub);
720
+ }
721
+ }
722
+
723
+ // --- connections (§2.5) ---
724
+
725
+ /** Whether an access token opens a connection, and until when. */
726
+ admits(access: Base64Url): { sub: Subject; expiresAt: Timestamp } | undefined {
727
+ const family = this.deps.records.byAccess(access);
728
+ if (family === undefined) return undefined;
729
+ return { sub: family.sub, expiresAt: family.access.expires_at };
730
+ }
731
+
732
+ /** Take a connection an access token opened, and close it when the token runs
733
+ * out. The client is expected to have extended it before then; one that did
734
+ * not is the one this is for. */
735
+ hold(conn: Requester, admitted: { sub: Subject; expiresAt: Timestamp }): void {
736
+ const held: AuthorizedConn = { sub: admitted.sub, expiresAt: admitted.expiresAt };
737
+ this.#authorized.set(conn, held);
738
+ conn.onClose(() => {
739
+ // The timer goes with the connection: a close scheduled for a socket that
740
+ // is already gone is a handle kept for hours over nothing.
741
+ clearTimeout(held.timer);
742
+ this.#authorized.delete(conn);
743
+ });
744
+ this.#deadline(conn, held);
745
+ }
746
+
747
+ #deadline(conn: Requester, held: AuthorizedConn): void {
748
+ held.timer = setTimeout(
749
+ () => {
750
+ const standing = this.#authorized.get(conn);
751
+ if (standing === undefined) return;
752
+ // Extended while this was waiting: the deadline moved, so the close is
753
+ // scheduled again for where it moved to rather than run now.
754
+ if (standing.expiresAt > this.#now()) {
755
+ this.#deadline(conn, standing);
756
+ return;
757
+ }
758
+ this.#authorized.delete(conn);
759
+ conn.close();
760
+ },
761
+ Math.max(0, held.expiresAt - this.#now()),
762
+ );
763
+ held.timer.unref?.();
764
+ }
765
+
766
+ /** When this connection's authorization runs out, for `hello` to state. */
767
+ expiresAt(conn: Requester): Timestamp | undefined {
768
+ return this.#authorized.get(conn)?.expiresAt;
769
+ }
770
+
771
+ /** Extend a live connection with a token got from `/auth/refresh` (§2.5). */
772
+ extend(conn: Requester, args: AuthRefreshArgs): AuthRefreshResult {
773
+ const held = this.#authorized.get(conn);
774
+ if (held === undefined) {
775
+ throw new OpError("auth_invalid", "この接続は token で開かれたものではありません");
776
+ }
777
+ const admitted = this.admits(args.access_token);
778
+ if (admitted === undefined) throw new OpError("auth_expired", "この access token は使えません");
779
+ // A token belonging to somebody else does not extend this connection: the
780
+ // connection is one person's, and a second person's token would move its
781
+ // deadline without changing who it speaks as.
782
+ if (admitted.sub !== held.sub) {
783
+ throw new OpError("auth_invalid", "この access token は別の利用者のものです");
784
+ }
785
+ held.expiresAt = admitted.expiresAt;
786
+ return { auth_expires_at: admitted.expiresAt };
787
+ }
788
+
789
+ // --- the rate limit the unauthenticated routes share (§2.4) ---
790
+
791
+ allowRequest(): boolean {
792
+ const now = this.#now();
793
+ if (now - this.#window >= AUTH_RATE_WINDOW_MS) {
794
+ this.#window = now;
795
+ this.#served = 0;
796
+ }
797
+ this.#served += 1;
798
+ return this.#served <= AUTH_RATE_LIMIT;
799
+ }
800
+
801
+ /** Drop what has run out. Read rather than swept, like everything else that
802
+ * expires here (M3). */
803
+ #forget(): void {
804
+ const now = this.#now();
805
+ for (const [value, held] of this.#challenges) {
806
+ if (held.expiresAt <= now) this.#challenges.delete(value);
807
+ }
808
+ for (const [jti, held] of this.#pending) {
809
+ if (held.claims.expires_at <= now) this.#pending.delete(jti);
810
+ }
811
+ }
812
+
813
+ /** What is held per exchange right now, so a test can state that a finished
814
+ * registration leaves nothing behind. */
815
+ get held(): { pending: number; challenges: number; connections: number } {
816
+ this.#forget();
817
+ return {
818
+ pending: this.#pending.size,
819
+ challenges: this.#challenges.size,
820
+ connections: this.#authorized.size,
821
+ };
822
+ }
823
+ }
824
+
825
+ /** The three ops an instance answers on a person's behalf, and the two it
826
+ * answers for another instance. */
827
+ export function authHandlers(auth: Auth) {
828
+ return {
829
+ auth_refresh: (input: HandlerInput): AuthRefreshResult =>
830
+ auth.extend(input.conn, input.args as unknown as AuthRefreshArgs),
831
+ auth_resolve: (input: HandlerInput): AuthResolveResult => {
832
+ const args = input.args as unknown as AuthResolveArgs;
833
+ if (args.kind === "challenge") {
834
+ auth.spend(args.challenge);
835
+ return { kind: "challenge" };
836
+ }
837
+ // The digits arrive unjudged from wherever the browser landed, and are
838
+ // checked here — this is the instance holding both the secret that signed
839
+ // the URL and the count of tries against it (§2.2).
840
+ return { kind: "register", claims: auth.resolveRegistration(args.token, args.code) };
841
+ },
842
+ auth_rotate: (input: HandlerInput): AuthRotateResult =>
843
+ auth.rotate((input.args as unknown as AuthRotateArgs).refresh_token),
844
+ };
845
+ }
846
+
847
+ /** Run a verification, and answer a refusal in the contract's vocabulary.
848
+ *
849
+ * Everything these routes are handed is attacker-supplied, and the libraries
850
+ * they go through say so in their own languages: a `WebAuthnError` from the
851
+ * checks here, a `CborError` from a malformed structure, a `DOMException` from
852
+ * WebCrypto refusing a key, a `RangeError` from a length that does not fit.
853
+ * They are one answer to the caller — the message was not valid — and letting
854
+ * any of them out as it is would answer `internal_error` for a bad request
855
+ * (M6). */
856
+ function refusable<T>(run: () => T): T {
857
+ try {
858
+ return run();
859
+ } catch (cause) {
860
+ throw asRefusal(cause);
861
+ }
862
+ }
863
+
864
+ async function refusableAsync<T>(run: () => Promise<T>): Promise<T> {
865
+ try {
866
+ return await run();
867
+ } catch (cause) {
868
+ throw asRefusal(cause);
869
+ }
870
+ }
871
+
872
+ function asRefusal(cause: unknown): unknown {
873
+ if (cause instanceof OpError) return cause;
874
+ if (
875
+ cause instanceof WebAuthnError ||
876
+ cause instanceof CborError ||
877
+ cause instanceof RangeError ||
878
+ (typeof DOMException !== "undefined" && cause instanceof DOMException)
879
+ ) {
880
+ return new OpError("auth_invalid", cause.message);
881
+ }
882
+ return cause;
883
+ }
884
+
885
+ /** What a family remembers of the generations before the one it still names.
886
+ *
887
+ * The outgoing refresh token joins the list, and anything whose own expiry has
888
+ * passed leaves it: past that instant, remembering the value refuses nothing
889
+ * its expiry would not have refused anyway, so keeping it is only growth. */
890
+ function retire(family: TokenFamily, now: Timestamp): { hash: string; expires_at: Timestamp }[] {
891
+ return [
892
+ ...(family.retired ?? []).filter((one) => one.expires_at > now),
893
+ { hash: digestOf(family.refresh.value), expires_at: family.refresh.expires_at },
894
+ ];
895
+ }
896
+
897
+ /** The digest a retired token is remembered by. */
898
+ function digestOf(value: Base64Url): string {
899
+ return createHash("sha256").update(value).digest("hex");
900
+ }
901
+
902
+ /** A token: 32 bytes of randomness, spelled the way everything on this wire is. */
903
+ function token(): Base64Url {
904
+ return base64UrlEncode(randomBytes(32));
905
+ }
906
+
907
+ /** Sign the claims with the secret made for this one registration.
908
+ *
909
+ * A JWS with HS256, because the value travels in a URL fragment and has to
910
+ * survive being carried there: the shape is the conventional one, and the
911
+ * verifier is the issuer itself, so nothing about it is a key anyone else
912
+ * needs (§2.2). */
913
+ function sign(claims: RegisterClaims, secret: Buffer): string {
914
+ const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
915
+ const body = Buffer.from(JSON.stringify(claims)).toString("base64url");
916
+ const signing = `${header}.${body}`;
917
+ return `${signing}.${createHmac("sha256", secret).update(signing).digest("base64url")}`;
918
+ }
919
+
920
+ /** What a registration token says about itself, before anything has checked it.
921
+ *
922
+ * Read to find the issuer, which is who can check the rest. Nothing here is
923
+ * believed: an issuer a caller made up names an instance that holds no such
924
+ * registration, which is a refusal. */
925
+ export function claimsOf(token: string): RegisterClaims {
926
+ const parts = token.split(".");
927
+ if (parts.length !== 3) throw new OpError("auth_invalid", "登録 URL の token が壊れています");
928
+ let claims: unknown;
929
+ try {
930
+ claims = JSON.parse(Buffer.from(parts[1] as string, "base64url").toString("utf8"));
931
+ } catch {
932
+ throw new OpError("auth_invalid", "登録 URL の token が読めません");
933
+ }
934
+ const held = claims as Partial<RegisterClaims>;
935
+ if (
936
+ typeof held.iss !== "string" ||
937
+ typeof held.jti !== "string" ||
938
+ typeof held.sub !== "string"
939
+ ) {
940
+ throw new OpError("auth_invalid", "登録 URL の token に発行者がありません");
941
+ }
942
+ return held as RegisterClaims;
943
+ }
944
+
945
+ /** The challenge a client data object states, read before anything is verified
946
+ * so the value can be spent at whoever issued it. */
947
+ function challengeIn(clientDataJson: Base64Url): Base64Url {
948
+ let parsed: unknown;
949
+ try {
950
+ parsed = JSON.parse(Buffer.from(clientDataJson, "base64url").toString("utf8"));
951
+ } catch {
952
+ throw new OpError("auth_invalid", "client data が読めません");
953
+ }
954
+ const challenge = (parsed as { challenge?: unknown }).challenge;
955
+ if (typeof challenge !== "string") {
956
+ throw new OpError("auth_invalid", "client data に challenge がありません");
957
+ }
958
+ return challenge;
959
+ }
960
+
961
+ /** The host an endpoint names, which is the relying party by default (§2.3). */
962
+ export function hostOf(endpoint: Endpoint): string {
963
+ return new URL(endpoint).hostname;
964
+ }
965
+
966
+ /** Where the page that runs the registration is served from: the endpoint's
967
+ * origin over HTTP, which is where the web UI sits in the ordinary
968
+ * configuration (§2.3). */
969
+ export function webOrigin(endpoint: Endpoint): string {
970
+ const url = new URL(endpoint);
971
+ url.protocol = url.protocol === "wss:" ? "https:" : "http:";
972
+ const origin = url.origin;
973
+ const path = url.pathname.replace(/\/$/, "");
974
+ return `${origin}${path}`;
975
+ }
976
+
977
+ /** Whether a relying party id is the host or a domain the host sits under.
978
+ *
979
+ * The WebAuthn rule as far as this instance can check it: a credential made
980
+ * for a suffix the endpoint does not sit under would be usable at hosts this
981
+ * instance has nothing to do with. Whether the suffix is one a registrar hands
982
+ * out is the browser's to refuse, and it does. */
983
+ export function isRegistrableSuffix(rpId: string, host: string): boolean {
984
+ return host === rpId || host.endsWith(`.${rpId}`);
985
+ }