@ccmsg/cli 0.1.0 → 0.2.1

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