@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.
- package/package.json +2 -2
- package/src/auth/admin.ts +64 -0
- package/src/auth/auth.ts +991 -0
- package/src/auth/cbor.ts +150 -0
- package/src/auth/http.ts +280 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/records.ts +394 -0
- package/src/auth/topic.ts +30 -0
- package/src/auth/webauthn.ts +389 -0
- package/src/cli.ts +91 -0
- package/src/dispatch/dispatch.ts +9 -0
- package/src/instance/instance.ts +128 -14
- package/src/mesh/mesh.ts +53 -4
- package/src/mesh/relay.ts +7 -0
- package/src/sessions/registry.ts +18 -16
- package/src/transport/entry.ts +10 -0
- package/src/transport/ws.ts +22 -5
package/src/auth/auth.ts
ADDED
|
@@ -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
|
+
}
|