@coffre/vault 0.1.11 → 0.1.13

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.
@@ -1,13 +1,171 @@
1
- import { ACCESS_ACTIONS, checkpointMessage, describeAccessFault, verifyCheckpoint } from "@coffre/core/vault";
1
+ import { ACCESS_ACTIONS, checkpointMessage, checkpointVerifier, describeAccessFault } from "@coffre/core/vault";
2
2
  import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
3
3
  import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { tablesOf } from "@coffre/db";
5
- import { allows, assignableToEnvironment, isRole, isSyncPrincipal, mayManageAccess } from "@coffre/core/access";
5
+ import { allows, assignableToEnvironment, isRole, mayManageAccess } from "@coffre/core/access";
6
6
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
7
  import { checkContext } from "@coffre/core/envelope";
8
8
  import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation } from "@coffre/db/dialect";
9
9
  import { appendEntries, lockLogHead } from "@coffre/db/log";
10
10
  import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, sql } from "drizzle-orm";
11
+ //#region src/accounting.ts
12
+ const KEY_ACTIONS = /* @__PURE__ */ new Set([
13
+ "secret.read",
14
+ "key.wrap",
15
+ "key.rewrap"
16
+ ]);
17
+ /**
18
+ * Every key operation's intent and its outcomes, entry by entry, oldest
19
+ * first, as the full check reads the vault's entries (`add`); then what is
20
+ * still open (`result`). The chain can hold while a process died between an
21
+ * intent and its outcomes: only a missed deadline is a fault.
22
+ */
23
+ var KeyAccounting = class {
24
+ #intents = /* @__PURE__ */ new Map();
25
+ #ids = /* @__PURE__ */ new Set();
26
+ #fault = null;
27
+ add(entry) {
28
+ if (this.#fault !== null || entry.author !== "vault") return;
29
+ const broken = (reason) => {
30
+ this.#fault = {
31
+ ok: false,
32
+ failedAtSeq: Number(entry.seq),
33
+ reason
34
+ };
35
+ };
36
+ if (entry.action === "key.intent") {
37
+ const parsed = parseIntent(entry);
38
+ if (typeof parsed === "string") return broken(parsed);
39
+ if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
40
+ this.#ids.add(parsed.intentId);
41
+ if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
42
+ ...parsed,
43
+ entry,
44
+ seen: /* @__PURE__ */ new Set()
45
+ });
46
+ } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
47
+ const parsed = parseOutcome(entry);
48
+ if (typeof parsed === "string") return broken(parsed);
49
+ const intent = this.#intents.get(parsed.relatedSeq);
50
+ const key = intent?.keys[parsed.item];
51
+ if (intent === void 0 || key === void 0 || intent.seen.has(parsed.item)) return broken("key outcome does not identify one item of its intent");
52
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
53
+ if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) return broken("key outcome does not match its intended secret");
54
+ intent.seen.add(parsed.item);
55
+ if (intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
56
+ }
57
+ }
58
+ /** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
59
+ result(at) {
60
+ if (this.#fault !== null) return this.#fault;
61
+ const intents = [...this.#intents.values()];
62
+ const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
63
+ if (overdue.length === 0) return {
64
+ ok: true,
65
+ pending: intents.length
66
+ };
67
+ const reason = overdue.map((intent) => {
68
+ const missing = intent.keys.length - intent.seen.size;
69
+ return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
70
+ }).join("; ");
71
+ return {
72
+ ok: false,
73
+ failedAtSeq: Number(overdue[0].entry.seq),
74
+ reason
75
+ };
76
+ }
77
+ };
78
+ function parseIntent(entry) {
79
+ const detail = payload(entry.metadata);
80
+ if (detail === null) return "key intent has no valid accounting payload";
81
+ const { intent, operation, expiresAt } = detail;
82
+ if (typeof intent !== "string") return "key intent has no operation identity";
83
+ if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
84
+ if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
85
+ if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
86
+ const keys = [];
87
+ for (const [index, value] of detail.keys.entries()) {
88
+ const key = record(value);
89
+ if (key === null || key.item !== index) return "key intent items are not numbered in order";
90
+ const { secretId, version, subject } = key;
91
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
92
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
93
+ keys.push({
94
+ item: index,
95
+ secretId,
96
+ version,
97
+ subject
98
+ });
99
+ }
100
+ return {
101
+ intentId: intent,
102
+ operation,
103
+ expiresAt,
104
+ keys
105
+ };
106
+ }
107
+ function parseOutcome(entry) {
108
+ const detail = payload(entry.metadata);
109
+ if (detail === null) return "key outcome has no valid accounting payload";
110
+ const { intent, item, version, subject } = detail;
111
+ if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
112
+ if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
113
+ const secretId = entry.secretId ?? detail.secretId;
114
+ if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
115
+ if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
116
+ return {
117
+ intentId: intent,
118
+ relatedSeq: entry.relatedSeq,
119
+ item,
120
+ secretId,
121
+ version,
122
+ subject
123
+ };
124
+ }
125
+ function payload(text) {
126
+ try {
127
+ return record(JSON.parse(text));
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+ function record(value) {
133
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
134
+ }
135
+ //#endregion
136
+ //#region src/checkpoint.ts
137
+ const PKCS8_ED25519 = [
138
+ 48,
139
+ 46,
140
+ 2,
141
+ 1,
142
+ 0,
143
+ 48,
144
+ 5,
145
+ 6,
146
+ 3,
147
+ 43,
148
+ 101,
149
+ 112,
150
+ 4,
151
+ 34,
152
+ 4,
153
+ 32
154
+ ];
155
+ /** An Ed25519 signer from a 32-byte seed, over WebCrypto so it runs in Node and in workerd alike. */
156
+ async function signer(seed) {
157
+ const pkcs8 = new Uint8Array([...PKCS8_ED25519, ...seed]);
158
+ const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
159
+ const { x } = await crypto.subtle.exportKey("jwk", privateKey);
160
+ const publicKey = Buffer.from(x, "base64url");
161
+ const digest = Buffer.from(await crypto.subtle.digest("SHA-256", publicKey));
162
+ return {
163
+ publicKey: publicKey.toString("base64"),
164
+ keyId: digest.toString("hex").slice(0, 16),
165
+ sign: async (message) => Buffer.from(await crypto.subtle.sign("Ed25519", privateKey, message)).toString("base64")
166
+ };
167
+ }
168
+ //#endregion
11
169
  //#region src/store.ts
12
170
  /**
13
171
  * On Postgres, fail a lock wait in this transaction after `ms`, rather than
@@ -178,6 +336,26 @@ async function entriesFrom(db, fromSeq, limit) {
178
336
  const { auditLog } = tablesOf(db);
179
337
  return stored(await db.select(entryColumns(db)).from(auditLog).where(gte(auditLog.seq, fromSeq)).orderBy(asc(auditLog.seq)).limit(limit));
180
338
  }
339
+ /** Up to `limit` of the vault's entries after `afterSeq`, oldest first: every one, whatever its action or decision. */
340
+ async function vaultEntriesAfter(db, afterSeq, limit) {
341
+ const { auditLog } = tablesOf(db);
342
+ return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), gt(auditLog.seq, afterSeq))).orderBy(asc(auditLog.seq)).limit(limit));
343
+ }
344
+ /** The hash of the entry at each of `seqs` the log holds, by seq. */
345
+ async function hashesAt(db, seqs) {
346
+ const { auditLog } = tablesOf(db);
347
+ const hashes = /* @__PURE__ */ new Map();
348
+ for (let from = 0; from < seqs.length; from += HASHES_AT_ONCE) {
349
+ const rows = await db.select({
350
+ seq: auditLog.seq,
351
+ hash: auditLog.hash
352
+ }).from(auditLog).where(inArray(auditLog.seq, seqs.slice(from, from + HASHES_AT_ONCE)));
353
+ for (const { seq, hash } of rows) hashes.set(seq, hash);
354
+ }
355
+ return hashes;
356
+ }
357
+ /** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
358
+ const HASHES_AT_ONCE = 5e3;
181
359
  /** The entry at `seq`'s hash, or undefined when there is none. */
182
360
  async function hashAt(db, seq) {
183
361
  const { auditLog } = tablesOf(db);
@@ -283,7 +461,8 @@ const UNVERIFIED = {
283
461
  function further(a, b) {
284
462
  return b.nextSeq > a.nextSeq ? b : a;
285
463
  }
286
- const VERIFY_BATCH = 1e3;
464
+ /** Entries read at once: a few megabytes, well within a Worker's memory, and a fifth of the round trips of 1000. */
465
+ const VERIFY_BATCH = 5e3;
287
466
  /**
288
467
  * Check the chain, a batch in memory at a time, and return where it is now
289
468
  * verified to. Three parts:
@@ -297,9 +476,10 @@ const VERIFY_BATCH = 1e3;
297
476
  * So a view rehashes only what is new since the last one. What it leaves
298
477
  * out is an entry before the anchor edited in place, not chained again, and
299
478
  * not on the page: a full check, which starts from `UNVERIFIED`, finds that,
300
- * as does the first view after a start, which has no anchor.
479
+ * as does the first view after a start, which has no anchor. `onBatch` sees
480
+ * each batch once it has verified, for checks that read the same entries.
301
481
  */
302
- async function verifyChain(db, logKeys, shown, anchor) {
482
+ async function verifyChain(db, logKeys, shown, anchor, onBatch = () => {}) {
303
483
  const broken = (failedAtSeq, reason) => ({
304
484
  verification: {
305
485
  ok: false,
@@ -333,13 +513,14 @@ async function verifyChain(db, logKeys, shown, anchor) {
333
513
  if (!result.ok) return broken(result.failedAtSeq, result.reason);
334
514
  const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
335
515
  if ("failedAtSeq" in moved) return broken(moved.failedAtSeq, moved.reason);
516
+ onBatch(batch);
336
517
  verified = {
337
518
  nextSeq: result.nextSeq,
338
519
  hash: result.head,
339
520
  vaultEntries: verified.vaultEntries + result.authenticated,
340
521
  vaultKeys: moved.keys
341
522
  };
342
- if (batch.length < 1e3) break;
523
+ if (batch.length < 5e3) break;
343
524
  }
344
525
  return {
345
526
  verification: {
@@ -389,156 +570,7 @@ async function carries(db, head) {
389
570
  return (await hashAt(db, BigInt(head.seq)))?.toString("hex") === head.hash;
390
571
  }
391
572
  //#endregion
392
- //#region src/accounting.ts
393
- const KEY_ACTIONS = /* @__PURE__ */ new Set([
394
- "secret.read",
395
- "key.wrap",
396
- "key.rewrap"
397
- ]);
398
- /** The chain can hold while a process died between an intent and its outcomes. */
399
- async function verifyAccounting(db, at) {
400
- const intents = /* @__PURE__ */ new Map();
401
- const ids = /* @__PURE__ */ new Set();
402
- const broken = (entry, reason) => ({
403
- ok: false,
404
- failedAtSeq: Number(entry.seq),
405
- reason
406
- });
407
- let next = 0n;
408
- for (;;) {
409
- const batch = await entriesFrom(db, next, VERIFY_BATCH);
410
- if (batch.length === 0) break;
411
- for (const entry of batch) {
412
- if (entry.author !== "vault") continue;
413
- if (entry.action === "key.intent") {
414
- const parsed = parseIntent(entry);
415
- if (typeof parsed === "string") return broken(entry, parsed);
416
- if (ids.has(parsed.intentId)) return broken(entry, "key intent repeats an operation identity");
417
- ids.add(parsed.intentId);
418
- if (parsed.keys.length > 0) intents.set(entry.seq, {
419
- ...parsed,
420
- entry,
421
- seen: /* @__PURE__ */ new Set()
422
- });
423
- } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
424
- const parsed = parseOutcome(entry);
425
- if (typeof parsed === "string") return broken(entry, parsed);
426
- const intent = intents.get(parsed.relatedSeq);
427
- const key = intent?.keys[parsed.item];
428
- if (intent === void 0 || key === void 0 || intent.seen.has(parsed.item)) return broken(entry, "key outcome does not identify one item of its intent");
429
- if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken(entry, "key outcome belongs to another operation");
430
- if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) return broken(entry, "key outcome does not match its intended secret");
431
- intent.seen.add(parsed.item);
432
- if (intent.seen.size === intent.keys.length) intents.delete(intent.entry.seq);
433
- }
434
- }
435
- next = batch[batch.length - 1].seq + 1n;
436
- if (batch.length < 1e3) break;
437
- }
438
- const overdue = [...intents.values()].filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
439
- if (overdue.length === 0) return {
440
- ok: true,
441
- pending: intents.size
442
- };
443
- const reason = overdue.map((intent) => {
444
- const missing = intent.keys.length - intent.seen.size;
445
- return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
446
- }).join("; ");
447
- return broken(overdue[0].entry, reason);
448
- }
449
- function parseIntent(entry) {
450
- const detail = payload(entry.metadata);
451
- if (detail === null) return "key intent has no valid accounting payload";
452
- const { intent, operation, expiresAt } = detail;
453
- if (typeof intent !== "string") return "key intent has no operation identity";
454
- if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
455
- if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
456
- if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
457
- const keys = [];
458
- for (const [index, value] of detail.keys.entries()) {
459
- const key = record(value);
460
- if (key === null || key.item !== index) return "key intent items are not numbered in order";
461
- const { secretId, version, subject } = key;
462
- if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
463
- if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
464
- keys.push({
465
- item: index,
466
- secretId,
467
- version,
468
- subject
469
- });
470
- }
471
- return {
472
- intentId: intent,
473
- operation,
474
- expiresAt,
475
- keys
476
- };
477
- }
478
- function parseOutcome(entry) {
479
- const detail = payload(entry.metadata);
480
- if (detail === null) return "key outcome has no valid accounting payload";
481
- const { intent, item, version, subject } = detail;
482
- if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
483
- if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
484
- const secretId = entry.secretId ?? detail.secretId;
485
- if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
486
- if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
487
- return {
488
- intentId: intent,
489
- relatedSeq: entry.relatedSeq,
490
- item,
491
- secretId,
492
- version,
493
- subject
494
- };
495
- }
496
- function payload(text) {
497
- try {
498
- return record(JSON.parse(text));
499
- } catch {
500
- return null;
501
- }
502
- }
503
- function record(value) {
504
- return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
505
- }
506
- //#endregion
507
- //#region src/checkpoint.ts
508
- const PKCS8_ED25519 = [
509
- 48,
510
- 46,
511
- 2,
512
- 1,
513
- 0,
514
- 48,
515
- 5,
516
- 6,
517
- 3,
518
- 43,
519
- 101,
520
- 112,
521
- 4,
522
- 34,
523
- 4,
524
- 32
525
- ];
526
- /** An Ed25519 signer from a 32-byte seed, over WebCrypto so it runs in Node and in workerd alike. */
527
- async function signer(seed) {
528
- const pkcs8 = new Uint8Array([...PKCS8_ED25519, ...seed]);
529
- const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
530
- const { x } = await crypto.subtle.exportKey("jwk", privateKey);
531
- const publicKey = Buffer.from(x, "base64url");
532
- const digest = Buffer.from(await crypto.subtle.digest("SHA-256", publicKey));
533
- return {
534
- publicKey: publicKey.toString("base64"),
535
- keyId: digest.toString("hex").slice(0, 16),
536
- sign: async (message) => Buffer.from(await crypto.subtle.sign("Ed25519", privateKey, message)).toString("base64")
537
- };
538
- }
539
- //#endregion
540
573
  //#region src/replay.ts
541
- const BATCH = 1e3;
542
574
  /**
543
575
  * Why the members and grants in the database do not follow from the
544
576
  * vault's entries, or null when they do; `describeAccessFault` words it.
@@ -548,28 +580,15 @@ const BATCH = 1e3;
548
580
  * written around the vault: a grant inserted with the database's own login,
549
581
  * a removal undone.
550
582
  *
551
- * Call it after the chain is verified, in the same snapshot, so every entry
552
- * it replays carries the vault's MAC.
583
+ * `state` is the allowed access entries replayed (`apply`), each after its
584
+ * MAC was checked, in the snapshot `rows` and `stored` were read in.
553
585
  *
554
586
  * Grants are compared as they are live at `at`. Clearing one that has
555
587
  * lapsed changes nothing anyone holds, so it is not logged.
556
588
  */
557
- async function replay(db, at) {
558
- const state = {
559
- members: /* @__PURE__ */ new Map(),
560
- held: /* @__PURE__ */ new Map()
561
- };
562
- for (let after = -1n;;) {
563
- const batch = await vaultEntriesOf(db, ACCESS_ACTIONS, after, BATCH);
564
- for (const row of batch) {
565
- const fault = apply(state, row);
566
- if (fault !== null) return fault;
567
- }
568
- if (batch.length < BATCH) break;
569
- after = batch[batch.length - 1].seq;
570
- }
589
+ function replayFault(state, rows, storedGrants, at) {
571
590
  const { members, held } = state;
572
- const stored = new Map((await allMembers(db)).map((member) => [member.principal, member]));
591
+ const stored = new Map(rows.map((member) => [member.principal, member]));
573
592
  for (const principal of [.../* @__PURE__ */ new Set([...stored.keys(), ...members.keys()])].sort()) {
574
593
  const [inStore, inLog] = [stored.get(principal), members.get(principal)];
575
594
  if (inLog === void 0) return {
@@ -589,7 +608,7 @@ async function replay(db, at) {
589
608
  }
590
609
  const live = (grant) => grant.expiresAt === null || grant.expiresAt > at;
591
610
  const logged = new Set([...held.values()].flatMap((grantsOf) => [...grantsOf.values()]).filter(live).map(grantKey));
592
- const inStore = new Set((await grants(db)).filter(live).map(grantKey));
611
+ const inStore = new Set(storedGrants.filter(live).map(grantKey));
593
612
  const extra = [...inStore].sort().find((grant) => !logged.has(grant));
594
613
  if (extra !== void 0) return {
595
614
  kind: "unlogged-grant",
@@ -819,6 +838,7 @@ const KEY_BUDGET_MS = 5e3;
819
838
  /** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
820
839
  const LOCK_TIMEOUT_MS = 15e3;
821
840
  const PRINCIPAL = /^(user|token|sync):[^\s:][^\s]*$/;
841
+ const LIVE_PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
822
842
  /** Who acts for the vault itself, as when it gives a root admin a member row. */
823
843
  const VAULT_ACTOR = "system:vault";
824
844
  /** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
@@ -1518,6 +1538,14 @@ var VaultService = class {
1518
1538
  fault,
1519
1539
  stored: grants$1
1520
1540
  };
1541
+ if (!LIVE_PRINCIPAL.test(principal)) return {
1542
+ principal,
1543
+ status: "unknown",
1544
+ live: none,
1545
+ all: none,
1546
+ fault,
1547
+ stored: grants$1
1548
+ };
1521
1549
  if (row?.status !== "active") return {
1522
1550
  principal,
1523
1551
  status: row?.status ?? "unknown",
@@ -1559,10 +1587,10 @@ var VaultService = class {
1559
1587
  since: null,
1560
1588
  by: null
1561
1589
  };
1562
- const active = !tampered && row?.status === "active";
1590
+ const active = LIVE_PRINCIPAL.test(principal) && !tampered && row?.status === "active";
1563
1591
  return {
1564
1592
  principal,
1565
- status: tampered ? "tampered" : row?.status ?? "unknown",
1593
+ status: tampered ? "tampered" : !LIVE_PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
1566
1594
  generation: row?.generation ?? 0,
1567
1595
  isRootAdmin: false,
1568
1596
  isOwner: active && row.owner && principal.startsWith("user:"),
@@ -1637,7 +1665,7 @@ var VaultService = class {
1637
1665
  }]);
1638
1666
  return this.#decide([actor, principal], async (d) => {
1639
1667
  validateCorrelation(input);
1640
- if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
1668
+ if (!LIVE_PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
1641
1669
  if (this.#isRootAdmin(principal)) throw refused("root_admin");
1642
1670
  const places$1 = /* @__PURE__ */ new Set();
1643
1671
  for (const change of input.changes) {
@@ -1653,28 +1681,12 @@ var VaultService = class {
1653
1681
  for (const { projectId, environmentId } of input.changes) if (!known.projects.has(projectId) || environmentId !== null && known.environments.get(environmentId) !== projectId) throw refused("invalid", `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
1654
1682
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1655
1683
  if (acting.status === "tampered") throw refused("tampered");
1656
- if (!input.changes.every((change) => mayManageAccess(acting.live, principal, change))) throw refused("not_allowed");
1684
+ if (!input.changes.every((change) => mayManageAccess(acting.live, change))) throw refused("not_allowed");
1657
1685
  const row = d.members.get(principal);
1658
1686
  const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
1659
1687
  if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
1660
1688
  if (row?.status === "removed") throw refused("removed");
1661
- if (row === void 0) {
1662
- if (!isSyncPrincipal(principal) || input.changes.every((change) => change.role === null)) throw refused("not_a_member");
1663
- d.log.push(accessEntry(actor, "member.add", principal, "allow", input, { owner: false }));
1664
- d.writes.push(async (at) => {
1665
- await insertMember(d.tx, {
1666
- principal,
1667
- status: "active",
1668
- owner: false,
1669
- generation: 0,
1670
- createdAt: at,
1671
- createdBy: actor,
1672
- statusChangedAt: at,
1673
- statusChangedBy: actor,
1674
- ...UNSEALED
1675
- });
1676
- });
1677
- }
1689
+ if (row === void 0) throw refused("not_a_member");
1678
1690
  const held = subject.stored;
1679
1691
  d.grants.set(principal, [...held]);
1680
1692
  const changes = input.changes.map((change) => this.#apply(d, actor, principal, held, change, input));
@@ -1738,7 +1750,7 @@ var VaultService = class {
1738
1750
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
1739
1751
  if (acting.status === "tampered") throw refused("tampered");
1740
1752
  if (!acting.live.isOwner) throw refused("not_allowed", "only owners may add or restore members");
1741
- if (!PRINCIPAL.test(principal) || isSyncPrincipal(principal)) throw refused("invalid", `not a member: ${principal}`);
1753
+ if (!LIVE_PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${principal}`);
1742
1754
  if (this.#isRootAdmin(principal)) throw refused("root_admin");
1743
1755
  if (input.owner === true && !principal.startsWith("user:")) throw refused("invalid", "service accounts cannot be owners");
1744
1756
  const row = d.members.get(principal);
@@ -1792,6 +1804,7 @@ var VaultService = class {
1792
1804
  const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.remove", principal, "deny", input, {}, code)]);
1793
1805
  return this.#decide([actor, principal], async (d) => {
1794
1806
  validateCorrelation(input);
1807
+ if (!LIVE_PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${principal}`);
1795
1808
  if (this.#isRootAdmin(principal)) throw refused("root_admin");
1796
1809
  const row = d.members.get(principal);
1797
1810
  const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
@@ -1803,11 +1816,7 @@ var VaultService = class {
1803
1816
  if (!holder.isOwner) throw refused("not_allowed", "only owners may remove a member whose record failed its check");
1804
1817
  return this.#startOver(d, actor, principal, row, subject.fault, input, refused);
1805
1818
  }
1806
- const places = input.source === void 0 ? held : [...held, input.source];
1807
- if (!(holder.isOwner || isSyncPrincipal(principal) && (held.length === 0 || places.some((place) => mayManageAccess(holder, principal, {
1808
- ...place,
1809
- role: null
1810
- }))))) throw refused("not_allowed", "only owners may remove members");
1819
+ if (!holder.isOwner) throw refused("not_allowed", "only owners may remove members");
1811
1820
  if (row?.status !== "active") throw refused(row === void 0 ? "not_a_member" : "removed");
1812
1821
  const revoked = held.filter((grant) => live(grant, d.at));
1813
1822
  for (const grant of revoked) d.log.push({
@@ -2082,99 +2091,152 @@ var VaultService = class {
2082
2091
  };
2083
2092
  }
2084
2093
  verifyLog(input) {
2085
- return this.#verifyAll([], input.upTo ?? null);
2086
- }
2087
- /** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
2088
- async #verify(db, shown, anchor) {
2089
- const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
2090
- if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
2091
- return verification;
2094
+ return this.#verifyAll(input.upTo ?? null);
2092
2095
  }
2093
2096
  /**
2094
- * `shown`, and the chain from its first entry, in one snapshot: every
2095
- * link and hash, the vault's MACs; then the heads that must still be
2096
- * there: the one the app verified up to (`upTo`), so both authors are
2097
- * checked over the same entries, the one the app last recorded from a
2098
- * checkpoint (`through`), and the last checkpoint's; and the members and
2099
- * grants replayed from it.
2097
+ * The whole log, in one snapshot and one pass over the vault's entries:
2098
+ * each by its MAC, the keys it moved through, and what the other checks
2099
+ * need of it, gathered as it goes (`Findings`); then every checkpoint, the
2100
+ * heads that must still be there, the key batches accounted for, and the
2101
+ * members and grants replayed.
2102
+ *
2103
+ * With `upTo`, the head the app verified to, the links and hashes of the
2104
+ * chain are the app's to recompute, as it just has, over its own snapshot,
2105
+ * through that head; `carries(upTo)` ties this snapshot to that one. So
2106
+ * the vault reads only its own entries, each checked against its own hash
2107
+ * and MAC over the link it names. Without `upTo`, nobody else recomputed
2108
+ * the chain: the vault reads it all, and rehashes every link.
2100
2109
  */
2101
- async #verifyAll(shown, upTo) {
2110
+ async #verifyAll(upTo) {
2102
2111
  await this.#settled();
2103
2112
  return this.#db.transaction(async (tx) => {
2104
2113
  const remembered = this.#prepared.verified;
2105
- const verification = await this.#verify(tx, shown, UNVERIFIED);
2106
- if (!verification.ok) return verification;
2107
- if (upTo !== null && !await carries(tx, upTo)) return {
2114
+ const findings = new Findings();
2115
+ const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
2116
+ if (!read.ok) return read;
2117
+ const heads = [...upTo === null ? [] : [upTo], ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
2118
+ const rememberedHead = remembered.nextSeq > 0n ? {
2119
+ seq: Number(remembered.nextSeq - 1n),
2120
+ hash: remembered.hash.toString("hex")
2121
+ } : null;
2122
+ if (rememberedHead !== null) heads.push(rememberedHead);
2123
+ const hashes = await hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
2124
+ const holds = (head) => head.hash === GENESIS_HASH.toString("hex") || hashes.get(BigInt(head.seq))?.toString("hex") === head.hash;
2125
+ if (upTo !== null && !holds(upTo)) return {
2108
2126
  ok: false,
2109
2127
  failedAtSeq: upTo.seq,
2110
2128
  reason: "not the entry the app verified up to: the log changed between the two checks"
2111
2129
  };
2112
- const unsigned = await this.#checkpointFault(tx);
2130
+ const unsigned = await this.#checkpointFault(findings, holds);
2113
2131
  if (unsigned !== null) return unsigned;
2114
- if (remembered.nextSeq > 0n) {
2115
- const seq = remembered.nextSeq - 1n;
2116
- if (!await carries(tx, {
2117
- seq: Number(seq),
2118
- hash: remembered.hash.toString("hex")
2119
- })) return {
2120
- ok: false,
2121
- failedAtSeq: Number(seq),
2122
- reason: "changed since the vault last verified it"
2123
- };
2124
- }
2132
+ if (rememberedHead !== null && !holds(rememberedHead)) return {
2133
+ ok: false,
2134
+ failedAtSeq: rememberedHead.seq,
2135
+ reason: "changed since the vault last verified it"
2136
+ };
2125
2137
  const at = await this.#now(tx);
2126
- const accounting = await verifyAccounting(tx, at);
2138
+ const accounting = findings.accounting.result(at);
2127
2139
  if (!accounting.ok) return accounting;
2128
- const fault = await this.#unsealed(tx) ?? await replay(tx, at);
2140
+ const [rows, held] = await Promise.all([allMembers(tx), grants(tx)]);
2141
+ const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
2129
2142
  if (fault !== null) return {
2130
2143
  ok: false,
2131
2144
  failedAtSeq: null,
2132
2145
  reason: describeAccessFault(fault),
2133
2146
  fault
2134
2147
  };
2135
- return accounting.pending === 0 ? verification : {
2136
- ...verification,
2148
+ const verified = {
2149
+ ok: true,
2150
+ entries: findings.entries
2151
+ };
2152
+ return accounting.pending === 0 ? verified : {
2153
+ ...verified,
2137
2154
  pending: accounting.pending
2138
2155
  };
2139
2156
  }, SNAPSHOT);
2140
2157
  }
2141
2158
  /**
2142
- * Every checkpoint, not only the newest: each signed by the key whose log
2143
- * key wrote its entry, over a prefix the log still holds, entry for entry.
2144
- * The chain is verified by now, so each checkpoint entry is the vault's,
2145
- * and one under a key it replaced comes before the rotation.
2159
+ * The vault's own entries, oldest first: each against its own hash, and by
2160
+ * its MAC, over the link it names, which the app checked; and the vault's
2161
+ * keys moving only forward through them.
2146
2162
  */
2147
- async #checkpointFault(db) {
2163
+ async #ownEntries(tx, findings) {
2164
+ const { logKeys } = this.#prepared;
2165
+ let keys = [];
2148
2166
  for (let after = -1n;;) {
2149
- const batch = await vaultEntriesOf(db, [CHECKPOINT], after, VERIFY_BATCH);
2167
+ const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
2150
2168
  for (const entry of batch) {
2151
- const checkpoint = JSON.parse(entry.metadata);
2152
- const broken = (reason) => ({
2153
- ok: false,
2154
- failedAtSeq: Number(entry.seq),
2155
- reason
2169
+ const result = verifyEntries([entry], {
2170
+ startSeq: entry.seq,
2171
+ startPrevHash: entry.prevHash,
2172
+ keys: logKeys
2156
2173
  });
2157
- const { logKeys, signers } = this.#prepared;
2158
- const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
2159
- if (by?.keyId !== checkpoint.keyId) return broken(`a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`);
2160
- if (!await verifyCheckpoint(checkpoint, by.publicKey)) return broken("a checkpoint the vault did not sign");
2161
- if (BigInt(checkpoint.seq) >= entry.seq || !await carries(db, checkpoint)) return broken(`the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`);
2174
+ if (!result.ok) return {
2175
+ ok: false,
2176
+ failedAtSeq: Number(result.failedAtSeq),
2177
+ reason: withCause(result.reason)
2178
+ };
2162
2179
  }
2163
- if (batch.length < 1e3) return null;
2180
+ const moved = forward(batch, keys, logKeys[0].keyId);
2181
+ if ("failedAtSeq" in moved) return {
2182
+ ok: false,
2183
+ failedAtSeq: Number(moved.failedAtSeq),
2184
+ reason: moved.reason
2185
+ };
2186
+ keys = moved.keys;
2187
+ for (const entry of batch) findings.add(entry);
2188
+ if (batch.length < 5e3) return { ok: true };
2164
2189
  after = batch[batch.length - 1].seq;
2165
2190
  }
2166
2191
  }
2192
+ /** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
2193
+ async #rehashed(tx, findings) {
2194
+ const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
2195
+ for (const entry of batch) if (entry.author === "vault") findings.add(entry);
2196
+ });
2197
+ if (!verification.ok) return verification;
2198
+ this.#prepared.verified = further(this.#prepared.verified, anchor);
2199
+ return { ok: true };
2200
+ }
2201
+ /**
2202
+ * Every checkpoint, not only the newest: each signed by the key whose log
2203
+ * key wrote its entry, over a prefix the log still holds (`holds`), entry
2204
+ * for entry. Its entry's MAC is checked by now, and one under a key the
2205
+ * vault replaced comes before the rotation.
2206
+ */
2207
+ async #checkpointFault(findings, holds) {
2208
+ const { logKeys, signers } = this.#prepared;
2209
+ const verifiers = /* @__PURE__ */ new Map();
2210
+ const signed = (checkpoint, by) => {
2211
+ let verifier = verifiers.get(by.keyId);
2212
+ if (verifier === void 0) verifiers.set(by.keyId, verifier = checkpointVerifier(by.publicKey));
2213
+ return verifier.then((verify) => verify(checkpoint));
2214
+ };
2215
+ const { checkpoints } = findings;
2216
+ for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
2217
+ const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
2218
+ const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
2219
+ const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
2220
+ if (by?.keyId !== checkpoint.keyId) return `a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`;
2221
+ if (!await signed(checkpoint, by)) return "a checkpoint the vault did not sign";
2222
+ if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
2223
+ return null;
2224
+ }));
2225
+ const at = faults.findIndex((fault) => fault !== null);
2226
+ if (at !== -1) return {
2227
+ ok: false,
2228
+ failedAtSeq: Number(chunk[at].entry.seq),
2229
+ reason: faults[at]
2230
+ };
2231
+ }
2232
+ return null;
2233
+ }
2167
2234
  /**
2168
2235
  * The first member whose row fails its MAC, or names an older access
2169
- * entry than the log's newest about them. The chain is verified by now,
2170
- * so every entry read here carries the vault's MAC.
2236
+ * entry than the log's newest about them (`newest`, from the full check's
2237
+ * pass, where each entry's MAC was checked).
2171
2238
  */
2172
- async #unsealed(db) {
2173
- const [rows, held, newest] = await Promise.all([
2174
- allMembers(db),
2175
- grants(db),
2176
- newestAccessEntries(db)
2177
- ]);
2239
+ #unsealed(rows, held, newest) {
2178
2240
  for (const row of rows) {
2179
2241
  const grants = held.filter((grant) => grant.principal === row.principal);
2180
2242
  if (!this.#sealed(row, grants)) return {
@@ -2182,7 +2244,7 @@ var VaultService = class {
2182
2244
  principal: row.principal,
2183
2245
  why: "mac"
2184
2246
  };
2185
- if (newest.get(row.principal)?.seq !== row.accessSeq) return {
2247
+ if (newest.get(row.principal) !== row.accessSeq) return {
2186
2248
  kind: "tampered-member",
2187
2249
  principal: row.principal,
2188
2250
  why: "stale"
@@ -2191,6 +2253,45 @@ var VaultService = class {
2191
2253
  return null;
2192
2254
  }
2193
2255
  };
2256
+ /**
2257
+ * What one pass over the vault's entries gathers for the other checks of a
2258
+ * full verification, so that the log is read once: the key batches, the
2259
+ * access entries replayed, each member's newest, and the checkpoints. Each
2260
+ * entry comes after its MAC was checked.
2261
+ */
2262
+ var Findings = class {
2263
+ accounting = new KeyAccounting();
2264
+ replayed = {
2265
+ members: /* @__PURE__ */ new Map(),
2266
+ held: /* @__PURE__ */ new Map()
2267
+ };
2268
+ /** The first access entry the replay could not apply. */
2269
+ replayFault = null;
2270
+ /** Each member's newest allowed access entry, by seq. */
2271
+ newestAccess = /* @__PURE__ */ new Map();
2272
+ /** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
2273
+ checkpoints = [];
2274
+ /** The vault's entries read. */
2275
+ entries = 0;
2276
+ add(entry) {
2277
+ this.entries += 1;
2278
+ this.accounting.add(entry);
2279
+ if (entry.decision !== "allow") return;
2280
+ if (ACCESS.has(entry.action)) {
2281
+ this.replayFault ??= apply(this.replayed, entry);
2282
+ if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
2283
+ } else if (entry.action === CHECKPOINT) this.checkpoints.push({
2284
+ entry: {
2285
+ seq: entry.seq,
2286
+ keyId: entry.keyId
2287
+ },
2288
+ checkpoint: JSON.parse(entry.metadata)
2289
+ });
2290
+ }
2291
+ };
2292
+ const ACCESS = new Set(ACCESS_ACTIONS);
2293
+ /** Checkpoint signatures checked at once, in a full verification. */
2294
+ const SIGNATURES_AT_ONCE = 256;
2194
2295
  /** Settle every operation before releasing the member lock, including cancelled requests. */
2195
2296
  async function settle(operations, budgetMs) {
2196
2297
  const controller = new AbortController();