@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,394 @@
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type {
4
+ AuthRecord,
5
+ AuthTombstone,
6
+ Base64Url,
7
+ CredentialRecord,
8
+ InstanceId,
9
+ Subject,
10
+ Timestamp,
11
+ TokenFamily,
12
+ } from "@ccmsg/protocol";
13
+ import { FAMILY_TOMBSTONE_RETENTION_MS } from "@ccmsg/protocol";
14
+ import { base64UrlDecode, equalBytes, equalStrings } from "./webauthn.ts";
15
+
16
+ export const AUTH_DIR = "auth";
17
+ const RECORDS_FILE = "records.json";
18
+
19
+ /** Where a credential and a family live in the replicated set.
20
+ *
21
+ * Both are under the subject they belong to, which is what makes a removal
22
+ * expressible: a person is removed as a person, and the mark that says so has
23
+ * to refuse every key their credentials and tokens could be written under —
24
+ * including ones this instance has never seen, held by a peer that is
25
+ * partitioned right now (DR-0001 §2.6). A tombstone therefore stands for a
26
+ * prefix rather than for one key. */
27
+ /** A subject as a path segment.
28
+ *
29
+ * A `Subject` is whatever the operator asked for, separators included, and the
30
+ * keys here are matched by prefix — so an unescaped `a/b` would sit under the
31
+ * mark for `a`, and removing one person would remove another. Escaping is what
32
+ * keeps one subject to one segment. */
33
+ function segment(sub: Subject): string {
34
+ return encodeURIComponent(sub);
35
+ }
36
+
37
+ export function credentialKey(sub: Subject, credentialId: Base64Url): string {
38
+ return `${credentialPrefix(sub)}/${credentialId}`;
39
+ }
40
+
41
+ export function familyKey(sub: Subject, id: string): string {
42
+ return `${familyPrefix(sub)}/${id}`;
43
+ }
44
+
45
+ export function credentialPrefix(sub: Subject): string {
46
+ return `credential/${segment(sub)}`;
47
+ }
48
+
49
+ export function familyPrefix(sub: Subject): string {
50
+ return `family/${segment(sub)}`;
51
+ }
52
+
53
+ /** Whether a tombstone's key covers another key: the key itself, or anything
54
+ * written beneath it. */
55
+ function covers(tombstone: string, key: string): boolean {
56
+ return key === tombstone || key.startsWith(`${tombstone}/`);
57
+ }
58
+
59
+ export interface RecordsDeps {
60
+ /** Where the set is written down (§3.6). */
61
+ readonly dir: string;
62
+ /** This instance's id, which is the one thing that makes a record arriving
63
+ * from a peer refusable on sight: a family this instance minted is written by
64
+ * this instance alone (DR-0001 §2.4). */
65
+ readonly self: InstanceId;
66
+ /** Hand what this instance wrote to the peers, on the `auth_records` topic. */
67
+ readonly publish: (records: readonly AuthRecord[]) => void;
68
+ readonly now?: () => Timestamp;
69
+ }
70
+
71
+ /** The credentials, token families and removals every instance holds a copy of
72
+ * (DR-0001 §2.6).
73
+ *
74
+ * Last write wins per key, with one exception that is the whole reason a
75
+ * removal is a record rather than an absence: a tombstone refuses every later
76
+ * write to the keys it covers, so a peer coming back from a partition cannot
77
+ * carry a revoked credential in as news.
78
+ *
79
+ * Written down for the same reason the store is (§3.6): none of it is derived
80
+ * from anything else this instance holds. A credential exists nowhere but here
81
+ * and in the authenticator, and losing a family logs its person out. */
82
+ export class AuthRecords {
83
+ readonly #records = new Map<string, AuthRecord>();
84
+ #loaded = false;
85
+
86
+ constructor(private readonly deps: RecordsDeps) {}
87
+
88
+ #now(): Timestamp {
89
+ return (this.deps.now ?? Date.now)();
90
+ }
91
+
92
+ /** Take one record from a peer, or from this instance's own file.
93
+ *
94
+ * Answers whether the set moved, which is what decides whether the change is
95
+ * worth writing down and passing on. */
96
+ accept(record: AuthRecord): boolean {
97
+ this.#load();
98
+ const held = this.#records.get(record.key);
99
+ if (held !== undefined && held.updated_at >= record.updated_at) return false;
100
+ if (this.#refused(record)) return false;
101
+ this.#records.set(record.key, record);
102
+ if (record.body.kind === "tombstone") this.#sweepUnder(record.key);
103
+ return true;
104
+ }
105
+
106
+ /** Whether a tombstone standing over this key refuses it.
107
+ *
108
+ * A tombstone is itself refused by another tombstone covering it, so two
109
+ * removals of the same subject do not fight; the newer one simply does not
110
+ * displace the older, which `accept` has already settled by instant. */
111
+ #refused(record: AuthRecord): boolean {
112
+ for (const held of this.#records.values()) {
113
+ if (held.body.kind !== "tombstone") continue;
114
+ if (held.key === record.key) continue;
115
+ if (covers(held.key, record.key)) return true;
116
+ }
117
+ return false;
118
+ }
119
+
120
+ /** Drop what a fresh tombstone covers. The mark alone would be enough to
121
+ * refuse later writes, but leaving the records themselves in place would
122
+ * leave a removed person's key usable by this instance. */
123
+ #sweepUnder(tombstone: string): void {
124
+ for (const [key, held] of this.#records) {
125
+ if (held.body.kind === "tombstone") continue;
126
+ if (covers(tombstone, key)) this.#records.delete(key);
127
+ }
128
+ }
129
+
130
+ /** Write one record of this instance's own, and tell the cluster.
131
+ *
132
+ * A local write always displaces what the key holds. Last-write-wins settles
133
+ * a disagreement between instances; this is not one — the writer is the
134
+ * authority for what it writes — and two writes landing in the same
135
+ * millisecond, which a rotation and the mint before it easily do, must not
136
+ * silently drop the second. So the instant is moved past what is held rather
137
+ * than compared against it. */
138
+ write(key: string, body: AuthRecord["body"], now: Timestamp = this.#now()): boolean {
139
+ this.#load();
140
+ const held = this.#records.get(key);
141
+ const at = held === undefined ? now : Math.max(now, held.updated_at + 1);
142
+ const record: AuthRecord = { key, updated_at: at, body };
143
+ if (!this.accept(record)) return false;
144
+ this.#persist();
145
+ this.deps.publish([record]);
146
+ return true;
147
+ }
148
+
149
+ /** Take a batch a peer sent.
150
+ *
151
+ * Answers the subjects a removal arrived for, because a tombstone means more
152
+ * than a record going away: the person it names has connections open here,
153
+ * and they are theirs no longer (DR-0001 §2.6).
154
+ *
155
+ * A family this instance minted is refused whatever the peer says about it.
156
+ * This instance is its only writer, so a copy coming back is a copy of an
157
+ * older state — which is exactly what a failed family looks like from a peer
158
+ * that has not heard yet, and taking it would undo the failure. */
159
+ merge(records: readonly AuthRecord[]): { changed: number; removed: Subject[] } {
160
+ let changed = 0;
161
+ const removed: Subject[] = [];
162
+ for (const record of records) {
163
+ if (record.body.kind === "token_family" && record.body.iss === this.deps.self) continue;
164
+ if (!this.accept(record)) continue;
165
+ changed += 1;
166
+ if (record.body.kind === "tombstone") removed.push(record.body.sub);
167
+ }
168
+ if (changed > 0) this.#persist();
169
+ return { changed, removed };
170
+ }
171
+
172
+ /** Remove one person: their credentials and every token they hold.
173
+ *
174
+ * Two marks rather than one because the two halves are kept for different
175
+ * lengths of time. A family expires with its refresh token, so the mark over
176
+ * it only has to outlive the longest one; a credential has no expiry of its
177
+ * own, so the mark over it has none either (DR-0001 §2.6). */
178
+ remove(sub: Subject): AuthRecord[] {
179
+ const at = this.#now();
180
+ const credential: AuthTombstone = { kind: "tombstone", sub, deleted_at: at };
181
+ const family: AuthTombstone = {
182
+ kind: "tombstone",
183
+ sub,
184
+ deleted_at: at,
185
+ expires_at: at + FAMILY_TOMBSTONE_RETENTION_MS,
186
+ };
187
+ const marks: AuthRecord[] = [
188
+ { key: credentialPrefix(sub), updated_at: at, body: credential },
189
+ { key: familyPrefix(sub), updated_at: at, body: family },
190
+ ];
191
+ for (const mark of marks) this.accept(mark);
192
+ this.#persist();
193
+ this.deps.publish(marks);
194
+ return marks;
195
+ }
196
+
197
+ /** Whether this subject has been removed, which is what a registration for
198
+ * one has to be refused by. */
199
+ removed(sub: Subject): boolean {
200
+ this.#load();
201
+ const held = this.#records.get(credentialPrefix(sub));
202
+ return held?.body.kind === "tombstone";
203
+ }
204
+
205
+ credentials(): CredentialRecord[] {
206
+ this.#load();
207
+ const found: CredentialRecord[] = [];
208
+ for (const record of this.#records.values()) {
209
+ if (record.body.kind === "credential") found.push(record.body);
210
+ }
211
+ return found;
212
+ }
213
+
214
+ /** The credential an assertion names. Looked up by the id the authenticator
215
+ * signed, which is what lets a person authenticate without naming a subject
216
+ * (DR-0001 §2.5).
217
+ *
218
+ * Compared as bytes rather than as text: base64url is not a canonical
219
+ * spelling — padding may or may not be there, and a decoder accepts more than
220
+ * one string for the same value — so two spellings of one credential id would
221
+ * otherwise be two credentials, and a person would be turned away from their
222
+ * own. */
223
+ credential(credentialId: Base64Url): CredentialRecord | undefined {
224
+ const wanted = base64UrlDecode(credentialId);
225
+ return this.credentials().find((record) =>
226
+ equalBytes(base64UrlDecode(record.credential_id), wanted),
227
+ );
228
+ }
229
+
230
+ families(): { key: string; body: TokenFamily }[] {
231
+ this.#load();
232
+ const found: { key: string; body: TokenFamily }[] = [];
233
+ for (const record of this.#records.values()) {
234
+ if (record.body.kind === "token_family") found.push({ key: record.key, body: record.body });
235
+ }
236
+ return found;
237
+ }
238
+
239
+ /** The family an access token belongs to, if it is still the standing one and
240
+ * has not run out. */
241
+ byAccess(value: Base64Url, now: Timestamp = this.#now()): TokenFamily | undefined {
242
+ return this.families().find(
243
+ ({ body }) => equalStrings(body.access.value, value) && body.access.expires_at > now,
244
+ )?.body;
245
+ }
246
+
247
+ /** The family a refresh token belongs to, and which generation it was.
248
+ *
249
+ * The generation before the standing one is answered as `previous` rather
250
+ * than refused: a client whose rotation was lost on the way retries with the
251
+ * value it still holds, and that is not a replay (contract, `TokenFamily`).
252
+ * Anything older matches nothing here, and the caller fails the family. */
253
+ byRefresh(
254
+ value: Base64Url,
255
+ now: Timestamp = this.#now(),
256
+ ): { key: string; body: TokenFamily; previous: boolean } | undefined {
257
+ for (const held of this.families()) {
258
+ if (equalStrings(held.body.refresh.value, value) && held.body.refresh.expires_at > now) {
259
+ return { ...held, previous: false };
260
+ }
261
+ const before = held.body.previous_refresh;
262
+ if (before !== undefined && equalStrings(before.value, value) && before.expires_at > now) {
263
+ return { ...held, previous: true };
264
+ }
265
+ }
266
+ return undefined;
267
+ }
268
+
269
+ /** Fail one family, which is what a reused token does to the whole of it.
270
+ *
271
+ * A tombstone rather than an expired record. The family's `iss` is its only
272
+ * writer, but a peer that was partitioned when this happened still holds the
273
+ * live copy, and an ordinary record would let that copy come back as the
274
+ * newer write when the partition heals. A mark refuses every later write to
275
+ * the key, which is exactly what a revoked family needs. It is kept for the
276
+ * same seven days a removal's is: past the longest refresh token, there is
277
+ * nothing left for a returning peer to revive. */
278
+ fail(key: string): void {
279
+ this.#load();
280
+ const held = this.#records.get(key);
281
+ if (held === undefined || held.body.kind !== "token_family") return;
282
+ const at = this.#now();
283
+ this.write(key, {
284
+ kind: "tombstone",
285
+ sub: held.body.sub,
286
+ deleted_at: at,
287
+ expires_at: at + FAMILY_TOMBSTONE_RETENTION_MS,
288
+ });
289
+ }
290
+
291
+ /** The family a value once belonged to, in any generation and whether or not
292
+ * that generation has run out.
293
+ *
294
+ * What `byRefresh` answers is a token that still works. This answers a token
295
+ * that was this family's — which is what a reused value looks like, and what
296
+ * says which instance is allowed to do anything about it (DR-0001 §2.4). */
297
+ owning(value: Base64Url, digest: string): { key: string; body: TokenFamily } | undefined {
298
+ for (const held of this.families()) {
299
+ const before = held.body.previous_refresh;
300
+ if (equalStrings(held.body.refresh.value, value)) return held;
301
+ if (before !== undefined && equalStrings(before.value, value)) return held;
302
+ if ((held.body.retired ?? []).some((one) => equalStrings(one.hash, digest))) return held;
303
+ }
304
+ return undefined;
305
+ }
306
+
307
+ /** Every record, for the snapshot a peer's subscription is answered with. */
308
+ all(): AuthRecord[] {
309
+ this.#load();
310
+ this.#expire();
311
+ return [...this.#records.values()];
312
+ }
313
+
314
+ /** Drop what has run out: a family past its refresh token, and a family's
315
+ * tombstone past its retention. A credential's tombstone is kept without end.
316
+ *
317
+ * Read rather than swept, like the store's own removals: nothing here runs on
318
+ * a timer (M3), and a record that outlives its window until the next read is
319
+ * one no read can see anyway. */
320
+ #expire(): void {
321
+ const now = this.#now();
322
+ for (const [key, record] of this.#records) {
323
+ const body = record.body;
324
+ if (body.kind === "token_family" && body.refresh.expires_at <= now) {
325
+ this.#records.delete(key);
326
+ }
327
+ if (body.kind === "tombstone" && body.expires_at !== undefined && body.expires_at <= now) {
328
+ this.#records.delete(key);
329
+ }
330
+ }
331
+ }
332
+
333
+ #load(): void {
334
+ if (this.#loaded) return;
335
+ this.#loaded = true;
336
+ let text: string;
337
+ try {
338
+ text = readFileSync(this.#file(), "utf8");
339
+ } catch {
340
+ return;
341
+ }
342
+ let parsed: unknown;
343
+ try {
344
+ parsed = JSON.parse(text);
345
+ } catch {
346
+ // A file a kill damaged states nothing this instance can act on, and the
347
+ // records it held will come back from the peers that also hold them.
348
+ return;
349
+ }
350
+ if (!Array.isArray(parsed)) return;
351
+ for (const record of parsed as AuthRecord[]) {
352
+ if (typeof record?.key === "string" && typeof record.updated_at === "number") {
353
+ this.#records.set(record.key, record);
354
+ }
355
+ }
356
+ this.#expire();
357
+ }
358
+
359
+ #persist(): void {
360
+ this.#expire();
361
+ mkdirSync(this.deps.dir, { recursive: true, mode: 0o700 });
362
+ const file = this.#file();
363
+ const temporary = `${file}.ccmsg-${String(process.pid)}-${String(Date.now())}`;
364
+ // The set holds tokens, so the file is the instance's own to read: it is
365
+ // created with the mode rather than fixed afterwards, so there is no
366
+ // instant at which it stands readable by anyone else.
367
+ writeFileSync(temporary, JSON.stringify([...this.#records.values()]), { mode: 0o600 });
368
+ try {
369
+ renameSync(temporary, file);
370
+ } catch (cause) {
371
+ unlinkSync(temporary);
372
+ throw cause;
373
+ }
374
+ }
375
+
376
+ #file(): string {
377
+ return join(this.deps.dir, RECORDS_FILE);
378
+ }
379
+ }
380
+
381
+ /** Where the records live under a state directory. */
382
+ export function recordsDir(stateDir: string): string {
383
+ return join(stateDir, AUTH_DIR);
384
+ }
385
+
386
+ /** The parent of a path, made when a caller wants to write into it. */
387
+ export function ensureDir(file: string): void {
388
+ mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
389
+ }
390
+
391
+ /** Whether an instance is the one allowed to write this family (DR-0001 §2.4). */
392
+ export function writes(family: TokenFamily, self: InstanceId): boolean {
393
+ return family.iss === self;
394
+ }
@@ -0,0 +1,30 @@
1
+ import type { AuthRecord, InstanceId } from "@ccmsg/protocol";
2
+ import type { Requester } from "../dispatch/index.ts";
3
+ import type { TopicValue, UpstreamResource } from "../topics/index.ts";
4
+ import type { AuthRecords } from "./records.ts";
5
+
6
+ /** `auth_records` as the topic mechanism sees it (DR-0001 §2.6).
7
+ *
8
+ * The records are here whether anyone is subscribed or not — they are what
9
+ * authenticates a person, not a watch on something — so there is nothing to
10
+ * start or stop. What subscription decides is only who is told when one
11
+ * changes.
12
+ *
13
+ * The whole set is the snapshot. The topic is `element`-granular, so a frame
14
+ * carries the entries that moved and a subscriber folds them into what it
15
+ * holds; a peer joining folds the lot, which is how a returning instance
16
+ * catches up on what it missed. */
17
+ export class AuthTopic implements UpstreamResource {
18
+ constructor(
19
+ private readonly self: InstanceId,
20
+ private readonly records: AuthRecords,
21
+ ) {}
22
+
23
+ start(): void {}
24
+ stop(): void {}
25
+
26
+ snapshot(_topic: string, _conn: Requester): readonly TopicValue[] {
27
+ const data: { records: AuthRecord[] } = { records: this.records.all() };
28
+ return [{ instance: this.self, data }];
29
+ }
30
+ }