@coffre/vault 0.9.0 → 0.10.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/src/vault.ts CHANGED
@@ -3,25 +3,39 @@ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
3
  import {
4
4
  allows,
5
5
  assignableToEnvironment,
6
+ DEFAULT_POLICY,
7
+ definitionProblem,
8
+ EVERY_ENVIRONMENT,
6
9
  EVERYWHERE,
10
+ FIXED_ROLES,
7
11
  givesService,
12
+ guarded,
8
13
  grantKind,
14
+ INSTANCE_ROLES,
15
+ isBuiltinRole,
9
16
  isCredentialScope,
10
17
  isInstanceRole,
11
18
  isRole,
12
19
  isScope,
13
20
  managesService,
14
21
  mayManageAccess,
22
+ normalDefinition,
15
23
  normalScope,
16
24
  NOTHING,
17
25
  ownsInstance,
26
+ PROJECT_ONLY_PERMISSIONS,
27
+ ROLE_ID,
28
+ ROLES,
18
29
  runsInstance,
19
30
  setsUpServices,
31
+ UNWRITTEN_FORK_MS,
20
32
  type Holdings,
21
33
  type InstanceRole,
22
34
  type Permission,
23
35
  type Place,
36
+ type Policy,
24
37
  type Role,
38
+ type RoleDefinition,
25
39
  type Scope,
26
40
  type ServiceAccount,
27
41
  withinCredential,
@@ -46,11 +60,14 @@ import {
46
60
  checkpointMessage,
47
61
  checkpointVerifier,
48
62
  describeAccessFault,
49
- HANDED_BACK,
63
+ handsBack,
64
+ POLICY_REVISION,
65
+ policyRevision,
50
66
  type Access,
51
67
  type AccessChange,
52
68
  type AccessFault,
53
69
  type AdmitInput,
70
+ type ChangePolicyInput,
54
71
  type Admitted,
55
72
  type Checkpoint,
56
73
  type CheckpointKey,
@@ -61,6 +78,7 @@ import {
61
78
  type LogHead,
62
79
  type LogVerification,
63
80
  type Outcome,
81
+ type PolicyChange,
64
82
  type Purpose,
65
83
  type ReferenceInput,
66
84
  type Refusal,
@@ -73,6 +91,7 @@ import {
73
91
  type Settings,
74
92
  type Through,
75
93
  type UnwrapInput,
94
+ type Wrapped,
76
95
  type Vault,
77
96
  type Via,
78
97
  type VerifyLogInput,
@@ -80,17 +99,17 @@ import {
80
99
  type WrappedKey,
81
100
  } from '@coffre/core/vault';
82
101
  import type { Database, Queryable, Transaction } from '@coffre/db';
83
- import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
102
+ import { isUniqueViolation, isUnreachable, SNAPSHOT } from '@coffre/db/dialect';
84
103
  import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
85
- import { appendEntries, LOOK_BACK, lockLogHead, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
104
+ import { appendEntries, lockLogHead, missingEntry, newestAuthentic, newestVaultSeq, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
86
105
 
87
- import { KeyAccounting } from './accounting.ts';
88
106
  import { signer, type Signer } from './checkpoint.ts';
89
107
  import type { ResolvedVaultConfig } from './config.ts';
90
- import { carries, forward, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, withCause, type Anchor } from './log.ts';
108
+ import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
91
109
  import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
92
110
  import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
93
111
  import * as store from './store.ts';
112
+ import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
94
113
  import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
95
114
 
96
115
  export type VaultOptions = {
@@ -101,6 +120,8 @@ export type VaultOptions = {
101
120
  keyBudgetMs?: number;
102
121
  /** Milliseconds added to the database's clock where the vault decides by it. Tests move time with it. */
103
122
  clockOffset?: () => number;
123
+ /** How far one call walks the log (`WALKS`); tests shorten them. */
124
+ walks?: Partial<Walks>;
104
125
  };
105
126
 
106
127
  /**
@@ -121,9 +142,9 @@ export type PreparedVault = {
121
142
  logKey: LogKey;
122
143
  /** Each signing key's log key: those after the first verify only entries before `since`. */
123
144
  logKeys: LogKey[];
124
- options: Required<VaultOptions>;
125
- /** How far the log is verified; `verifyChain` in log.ts. The furthest any call got to. */
126
- verified: Anchor;
145
+ options: Required<Omit<VaultOptions, 'walks'>> & { walks: Walks };
146
+ /** How far the chain is rehashed, by any call of this process: its head must still be there. */
147
+ verified: Position;
127
148
  /** What member rows are sealed under now; rows.ts. */
128
149
  rowKey: Buffer;
129
150
  /** Each signing key's row key: those after the first vouch for rows only until `since`, when the rows are sealed again. */
@@ -150,8 +171,8 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
150
171
  return {
151
172
  config,
152
173
  ...(await keysOf(config.signingKeys)),
153
- options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
154
- verified: UNVERIFIED,
174
+ options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0), walks: { ...WALKS, ...options.walks } },
175
+ verified: START,
155
176
  reported: new Set(),
156
177
  since: null,
157
178
  superseded: null,
@@ -208,15 +229,48 @@ const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
208
229
  /** Why a change to a tampered member is refused. */
209
230
  const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
210
231
 
211
- /** The action of the vault's entry that signs a prefix of the log. */
212
- const CHECKPOINT = 'audit.checkpoint';
213
-
214
232
  /** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
215
233
  const SETTINGS = 'settings.change';
216
234
 
217
235
  /** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
218
236
  const DEFAULT_SETTINGS: Settings = { serviceAccounts: EVERYWHERE };
219
237
 
238
+ /** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
239
+ const POLICY = 'policy.change';
240
+
241
+ /**
242
+ * What a `policy.change` entry keeps: the roles an Owner edited or made,
243
+ * each whole, and the protected environments. A role as it ships is kept by
244
+ * none, so a release that changes a default changes it where nobody edited it.
245
+ */
246
+ type PolicyEdits = { roles: Record<string, RoleDefinition>; instanceRoles: Partial<Record<InstanceRole, RoleDefinition>>; protected: ProtectedPlace[] };
247
+
248
+ /**
249
+ * A protected environment, with its project's id, what a grant on the whole
250
+ * project reaches of it, and when it was protected, by the vault's clock:
251
+ * an id with no row ten minutes later is a fork that was never written.
252
+ */
253
+ type ProtectedPlace = { projectId: string; environmentId: string; at: number };
254
+
255
+
256
+ const NO_EDITS: PolicyEdits = { roles: {}, instanceRoles: {}, protected: [] };
257
+
258
+ /** A role holding nothing, which every role but Owner is under the closed policy. */
259
+ const NOTHING_HELD = (definition: RoleDefinition): RoleDefinition => ({ ...definition, permissions: [], protected: [] });
260
+
261
+ /**
262
+ * The policy when the log has some, but none the vault wrote within reach:
263
+ * whoever inserted rows may have hidden an Owner's narrowing, so no role
264
+ * holds anything, a service account's neither, but Owner, which holds
265
+ * everything always and so may write the policy again.
266
+ */
267
+ const CLOSED_POLICY: Policy = {
268
+ roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])),
269
+ instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === 'owner' ? definition : NOTHING_HELD(definition)])) as Policy['instanceRoles'],
270
+ protected: [EVERY_ENVIRONMENT],
271
+ protectedIn: {},
272
+ };
273
+
220
274
  /** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
221
275
  const CLOSED_SETTINGS: Settings = { serviceAccounts: { projects: { only: [] }, environments: 'all' } };
222
276
 
@@ -234,13 +288,6 @@ const NOT_THEIRS = 'a person gives a service account at most what they hold, whe
234
288
  /** Who asks for checkpoints: the app's scheduled job. */
235
289
  const SCHEDULER = 'system:coffre-scheduler';
236
290
 
237
- /**
238
- * How often a checkpoint recomputes the chain from its first entry: the
239
- * first of each hour, by the vault's clock. The others resume from the
240
- * prefix the last one signed (`#recompute`).
241
- */
242
- const FULL_RECOMPUTE_MS = 60 * 60 * 1000;
243
-
244
291
  /** A refusal and the entries that record it. */
245
292
  class Refused {
246
293
  readonly refusal: Refusal;
@@ -269,6 +316,9 @@ class Outage {
269
316
  }
270
317
  }
271
318
 
319
+ /** A rewrap that would make a value less sensitive than it is. */
320
+ const LOOSER = 'a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more';
321
+
272
322
  function refusal(code: RefusalCode, message: string): Refusal {
273
323
  return { code, message };
274
324
  }
@@ -283,10 +333,11 @@ const MESSAGES: Record<RefusalCode, string> = {
283
333
  deleted: 'this project or environment was deleted',
284
334
  ended: 'the reference read through was ended',
285
335
  personal: "a personal config's values are its owner's alone",
286
- stand_ins: "people use this instance's values through stand-ins, never as values: `coffre run` gives your app them; a service account reads values",
336
+ sensitive: "a sensitive value is never shown to anyone, Owners included: `coffre run` gives your app a placeholder for it through the credential proxy, and a service account reads it",
287
337
  not_allowed: 'not allowed to change this',
288
338
  last_owner: 'the last Owner of the whole instance stays one: make someone else an Owner first',
289
339
  invalid: 'not something the rules allow',
340
+ policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
290
341
  log_broken: 'the vault log does not hold from the last checkpoint',
291
342
  wrong_kek: "this vault's key does not open the data it holds",
292
343
  tampered: "this member's record failed the vault's integrity check",
@@ -398,23 +449,29 @@ class VaultService implements Vault {
398
449
  }
399
450
 
400
451
  /**
401
- * Decide in one transaction, with `principals`' rows locked first. A
402
- * `Refused` or an `Outage` rolls back all but its own entries, which
403
- * commit on their own: then the refusal is the answer, and the outage
404
- * fails the call.
452
+ * Decide in one transaction, with `principals`' rows locked first, then
453
+ * the log's head. A `Refused` or an `Outage` rolls back all but its own
454
+ * entries, which commit on their own: then the refusal is the answer, and
455
+ * the outage fails the call.
456
+ *
457
+ * Every change to the policy, the settings, a member or a place appends
458
+ * under the head, so whatever the decision reads of them stands until it
459
+ * commits: no decision acts on a policy read before it. A key operation
460
+ * alone takes the head later (`late`), once its key service has answered,
461
+ * which it must not wait on holding it, and decides again then (`#keys`).
405
462
  */
406
- async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>): Promise<Outcome<T>> {
463
+ async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
407
464
  const superseded = await this.#settled();
408
465
  if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
409
- return this.#decideNow(principals, decide);
466
+ return this.#decideNow(principals, decide, late);
410
467
  }
411
468
 
412
469
  /** `#decide`, once the vault is settled. */
413
- async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>): Promise<Outcome<T>> {
470
+ async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
414
471
  const reports: NewEntry[] = [];
415
472
  for (let attempt = 1; ; attempt += 1) {
416
473
  try {
417
- return await this.#decideOnce(principals, decide, reports);
474
+ return await this.#decideOnce(principals, decide, reports, late);
418
475
  } catch (error) {
419
476
  // A member who had no row when this decision locked theirs, and has
420
477
  // one now: another decision admitted them meanwhile. Again, with it.
@@ -424,11 +481,13 @@ class VaultService implements Vault {
424
481
  }
425
482
  }
426
483
 
427
- async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[]): Promise<Outcome<T>> {
484
+ async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean): Promise<Outcome<T>> {
428
485
  try {
429
486
  const result = await this.#db.transaction(async (tx) => {
430
487
  await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
431
488
  const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
489
+ if (!late) await lockLogHead(tx);
490
+ // The clock once the locks are held, in a statement of its own: one that waited for a lock is decided as of when it got it.
432
491
  const d: Decision = {
433
492
  tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
434
493
  };
@@ -529,9 +588,13 @@ class VaultService implements Vault {
529
588
 
530
589
  /** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
531
590
  #authentic(entry: StoredEntry): boolean {
591
+ return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: this.#keysFor(entry) }).ok;
592
+ }
593
+
594
+ /** The vault's log keys an entry may be under: any of them before `since`, and only its current one from there. */
595
+ #keysFor(entry: StoredEntry): LogKey[] {
532
596
  const { logKeys, since } = this.#prepared;
533
- const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
534
- return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys }).ok;
597
+ return since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
535
598
  }
536
599
 
537
600
  /**
@@ -630,7 +693,6 @@ class VaultService implements Vault {
630
693
  if (everyProject.length === 0) return;
631
694
  if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
632
695
  // The projects there are now, read under the log's head, which a deletion holds.
633
- await lockLogHead(d.tx);
634
696
  const before = roleOf(principal, row);
635
697
  const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await store.liveProjects(d.tx));
636
698
  const after = { role: conversion.role, scope: conversion.scope };
@@ -790,6 +852,7 @@ class VaultService implements Vault {
790
852
  async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[] }>> {
791
853
  const { principal } = input;
792
854
  validateText(input.purpose);
855
+ if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
793
856
  const vias = input.items.map((item) => validateVia(item.via));
794
857
  const throughs = input.items.map((item) => validateThrough(item.through));
795
858
  const loaded = await this.#versions(input, 'secret.read', { purpose: input.purpose });
@@ -838,7 +901,8 @@ class VaultService implements Vault {
838
901
  {
839
902
  action: 'secret.read',
840
903
  principal,
841
- permission: 'secret.read',
904
+ // The proxy's use opens a value for coffre to use for its reader, never to hand back: `secret.use`. Anything else reads.
905
+ permission: input.purpose === 'use' ? 'secret.use' : 'secret.read',
842
906
  secrets: items.map((item) => item.secret),
843
907
  // Through a reference, the reader's grants on the holder's environment decide.
844
908
  places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
@@ -867,7 +931,7 @@ class VaultService implements Vault {
867
931
  );
868
932
  }
869
933
 
870
- async wrap(input: WrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>> {
934
+ async wrap(input: WrapInput): Promise<Outcome<Wrapped>> {
871
935
  const { principal, items } = input;
872
936
  validateItems(items, 'key');
873
937
  validateText(principal);
@@ -893,11 +957,11 @@ class VaultService implements Vault {
893
957
  dek.fill(0);
894
958
  }
895
959
  }),
896
- (done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[] }),
960
+ (done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[], sensitive: items.map(({ secret }) => secret.sensitive === true) }),
897
961
  );
898
962
  }
899
963
 
900
- async rewrap(input: RewrapInput): Promise<Outcome<{ wrapped: WrappedKey[]; seqs: number[] }>> {
964
+ async rewrap(input: RewrapInput): Promise<Outcome<Wrapped>> {
901
965
  const { principal } = input;
902
966
  const loaded = await this.#versions(input, 'key.rewrap');
903
967
  if (!loaded.ok) return loaded;
@@ -911,6 +975,13 @@ class VaultService implements Vault {
911
975
  ...keyEntry('key.rewrap', principal, source.secret, 'deny', 'bad_claim', input), secretVersionId: source.id,
912
976
  })));
913
977
  }
978
+ // A value is made looser only by writing it again: a sensitive version's key, or one of a secret whose newest is,
979
+ // is never wrapped again as anything but sensitive, so that a person who may not read it never makes it readable.
980
+ if (items.some((item, i) => item.secret.sensitive !== true && (loaded.versions[i].secret.sensitive || loaded.versions[i].newestSensitive))) {
981
+ return this.#refuseVersions(principal, 'invalid', loaded.versions.map((source) => ({
982
+ ...keyEntry('key.rewrap', principal, source.secret, 'deny', 'invalid', input), secretVersionId: source.id,
983
+ })), LOOSER);
984
+ }
914
985
  const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
915
986
  const entry = (secret: SecretRef, decision: 'allow' | 'deny', code: string | null): NewEntry => ({
916
987
  ...keyEntry('key.rewrap', principal, secret, decision, code, input, { from: sources.get(secret)!.secret.version }),
@@ -919,10 +990,26 @@ class VaultService implements Vault {
919
990
  const remote =
920
991
  this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
921
992
  return this.#keys(
922
- { action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input },
993
+ {
994
+ action: 'key.rewrap', principal, permission: 'secret.write', secrets: items.map((item) => item.secret), remote, entry, input,
995
+ // Again in the decision and under the log's head: a sensitive version written since it was loaded is as newest as any.
996
+ vet: async (db) => {
997
+ const newest = await store.newestSensitive(db, items.map(({ secret }) => secret.secretId));
998
+ return items.map(({ secret }) => (secret.sensitive !== true && newest.has(secret.secretId) ? 'invalid' : null));
999
+ },
1000
+ // An old value brought back, not the current one, is made live by whoever could have read it: a secret by its
1001
+ // readers, a sensitive one, which nobody reads, by those who use it, a plain one by any writer, as it was shown
1002
+ // to them. Whatever reads the key next, a reference to it included, then reads what its restorer could. Typing a
1003
+ // value, or tightening the current one, takes no more than writing.
1004
+ needs: async (db) => {
1005
+ const restored = await store.restoredNatures(db, items.map(({ secret }) => ({ secretId: secret.secretId, from: sources.get(secret)!.id })));
1006
+ return restored.map((nature) => (nature === 'secret' ? ['secret.read'] as const : nature === 'sensitive' ? ['secret.use'] as const : null));
1007
+ },
1008
+ },
923
1009
  (contextOf) =>
924
1010
  items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
925
- const key = await this.#open(wrapped, contextOf(secret), operation);
1011
+ // Opened as the source was written, sensitive or not, and wrapped as the new version is.
1012
+ const key = await this.#open(wrapped, contextOf(sources.get(secret)!.secret), operation);
926
1013
  if (key === null) return null;
927
1014
  try {
928
1015
  return { wrapped: serialisable(await this.#config.keks.wrap(key, contextOf(secret), operation)), wipe: () => {} };
@@ -930,7 +1017,7 @@ class VaultService implements Vault {
930
1017
  key.fill(0);
931
1018
  }
932
1019
  }),
933
- (done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[] }),
1020
+ (done) => ({ wrapped: done.map(({ wrapped }) => wrapped), seqs: [] as number[], sensitive: items.map(({ secret }) => secret.sensitive === true) }),
934
1021
  );
935
1022
  }
936
1023
 
@@ -968,9 +1055,9 @@ class VaultService implements Vault {
968
1055
  return { ok: true, versions: ids.map((id) => found.get(id)!) };
969
1056
  }
970
1057
 
971
- async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted', entries: NewEntry[]): Promise<Outcome<never>> {
1058
+ async #refuseVersions(principal: string, code: 'bad_claim' | 'deleted' | 'invalid', entries: NewEntry[], message = MESSAGES[code]): Promise<Outcome<never>> {
972
1059
  return this.#decide([principal], async () => {
973
- throw new Refused(refusal(code, MESSAGES[code]), entries);
1060
+ throw new Refused(refusal(code, message), entries);
974
1061
  });
975
1062
  }
976
1063
 
@@ -1013,9 +1100,14 @@ class VaultService implements Vault {
1013
1100
  * once the log's head is locked, before its entries append.
1014
1101
  */
1015
1102
  vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
1103
+ /**
1104
+ * Any one of which each secret takes besides `permission`, where it
1105
+ * is, read in the decision and again under the head; none for null.
1106
+ */
1107
+ needs?: (db: Queryable) => Promise<(readonly Permission[] | null)[]>;
1016
1108
  remote: boolean;
1017
1109
  entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
1018
- input: Correlation & Through & { purpose?: string };
1110
+ input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean };
1019
1111
  },
1020
1112
  /**
1021
1113
  * One per secret; null for a bad claim, an error for an outage or an
@@ -1048,17 +1140,35 @@ class VaultService implements Vault {
1048
1140
  const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
1049
1141
  owners.clear();
1050
1142
  for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
1143
+ const needs = call.needs === undefined ? [] : await call.needs(d.tx);
1051
1144
  const codes: (RefusalCode | null)[] = secrets.map((secret, i) => {
1052
1145
  const personal = personalRefusal(reader, principal, call.permission, secret, at[i]!, environments);
1053
1146
  if (personal !== undefined) return personal;
1147
+ const also = needs[i];
1148
+ if (also != null) {
1149
+ const place = placeIn(at[i]!, environments);
1150
+ const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i]!)));
1151
+ if (refused.every((code) => code !== null)) return refused[0]!;
1152
+ }
1153
+ // Through a reference, the holder's grants decide, its source's never: a reference takes its value and nature
1154
+ // (a sensitive one stays use-only) from its source, and its permissions from where it lives. Its maker held what
1155
+ // the source's nature asks when it was made (`reference`).
1054
1156
  return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
1055
1157
  });
1056
1158
  if (call.vet !== undefined && !codes.some((code) => code !== null)) {
1057
1159
  (await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
1058
1160
  }
1161
+ // A sensitive value is only ever used for a person, never handed back, by whatever credential of theirs asks. And
1162
+ // a fork copies one, for anyone, a service account too, only where its app says it keeps it sensitive: an app of
1163
+ // 0.9, mid-deploy, would write the copy as a secret its people read.
1164
+ const copying = action === 'secret.read' && call.input.purpose === 'copy';
1165
+ const handedBack = action === 'secret.read' && principal.startsWith('user:') && handsBack({ purpose: call.input.purpose ?? '', keepsSensitive: call.input.keepsSensitive === true });
1166
+ if (handedBack || (copying && call.input.keepsSensitive !== true)) {
1167
+ secrets.forEach((secret, i) => {
1168
+ if (codes[i] === null && secret.sensitive === true) codes[i] = 'sensitive';
1169
+ });
1170
+ }
1059
1171
  let first = codes.find((code) => code !== null) ?? null;
1060
- // A person's values are only ever used for them, where people get stand-ins: by whatever credential of theirs asks.
1061
- if (first === null && action === 'secret.read' && this.#config.people === 'stand-ins' && principal.startsWith('user:') && HANDED_BACK.includes(call.input.purpose as Purpose)) first = 'stand_ins';
1062
1172
  if (first === null && action === 'secret.read' && (await this.#overBulkLimit(d, principal, secrets.length))) first = 'bulk_limit';
1063
1173
  if (first !== null) {
1064
1174
  throw new Refused(
@@ -1087,7 +1197,6 @@ class VaultService implements Vault {
1087
1197
  if (call.remote) {
1088
1198
  const intent = await this.#decide([principal], async (d) => {
1089
1199
  await check(d);
1090
- await lockLogHead(d.tx);
1091
1200
  const at = await this.#now(d.tx);
1092
1201
  const appended = await this.#append(d.tx, [{
1093
1202
  actor: principal,
@@ -1111,7 +1220,12 @@ class VaultService implements Vault {
1111
1220
  intentSeq = intent.seq;
1112
1221
  }
1113
1222
 
1114
- return this.#decide([principal], async (d) => {
1223
+ // Under the reader's row: the check, the key operations, and their outcome. With a key in memory, under the
1224
+ // log's head too, from the start (`#decideOnce`): what the check read stands until the commit, as every change it
1225
+ // reads appends under the head, and the head waits on nothing slow. With a key service, which nothing may wait on
1226
+ // holding the head, the head is taken once the keys are open, and what the check read is read again under it.
1227
+ const late = call.remote;
1228
+ const decided = async (d: Decision) => {
1115
1229
  try {
1116
1230
  await check(d);
1117
1231
  } catch (error) {
@@ -1121,9 +1235,12 @@ class VaultService implements Vault {
1121
1235
  }
1122
1236
  throw error;
1123
1237
  }
1238
+ };
1239
+ return this.#decide([principal], async (d) => {
1240
+ await decided(d);
1124
1241
  // A reference ended while this decided refuses it, rather than a read logged after its end.
1125
1242
  const { vet } = call;
1126
- if (vet !== undefined) {
1243
+ if (late && vet !== undefined) {
1127
1244
  d.underLock.push(async (locked) => {
1128
1245
  const codes = await vet(locked);
1129
1246
  const first = codes.find((code) => code !== null);
@@ -1150,15 +1267,20 @@ class VaultService implements Vault {
1150
1267
  if (done.length < outcomes.length) {
1151
1268
  throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
1152
1269
  }
1153
- // Again under the log's head, the append's, taken a statement early: a
1154
- // deletion renames its place under it, so one that committed since the
1155
- // check above is seen here, and nothing is released after it.
1156
- // A read through a reference: its holder's place too, which a deletion may have taken.
1157
- await lockLogHead(d.tx);
1158
- const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1159
- const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
1160
- if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
1161
- throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
1270
+ if (late) {
1271
+ // Again under the log's head, the append's, taken a statement early: a
1272
+ // deletion renames its place under it, so one that committed since the
1273
+ // check above is seen here, and nothing is released after it.
1274
+ // A read through a reference: its holder's place too, which a deletion may have taken.
1275
+ await lockLogHead(d.tx);
1276
+ // And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
1277
+ // since the check above is seen here, and nothing it refuses now is released.
1278
+ await decided(d);
1279
+ const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
1280
+ const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
1281
+ if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
1282
+ throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
1283
+ }
1162
1284
  }
1163
1285
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
1164
1286
  d.log.push(...released);
@@ -1169,7 +1291,7 @@ class VaultService implements Vault {
1169
1291
  } finally {
1170
1292
  for (const value of done) value.wipe();
1171
1293
  }
1172
- });
1294
+ }, late);
1173
1295
  }
1174
1296
 
1175
1297
  /** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
@@ -1225,7 +1347,11 @@ class VaultService implements Vault {
1225
1347
  };
1226
1348
  };
1227
1349
  return this.#decide([principal], async (d) => {
1350
+ // Under the log's head, which a deletion and every change to the policy take: neither side deleted, nor protected,
1351
+ // nor a role narrowed, before this commits.
1228
1352
  const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
1353
+ // Each source's nature, its current version's, under the head: what its maker must hold there.
1354
+ const natures = await store.natures(d.tx, [...sources.values()].map((source) => source.secretId));
1229
1355
  // Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
1230
1356
  const environments = await store.environmentsById(d.tx, items.flatMap((item) => {
1231
1357
  const source = sources.get(item.source.secretId);
@@ -1238,17 +1364,15 @@ class VaultService implements Vault {
1238
1364
  if (source.secretId === item.holder.secretId) return 'invalid';
1239
1365
  // A personal config's values are bound to its owner: no reference holds one, nor reads one.
1240
1366
  if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return 'personal';
1367
+ // Making one hands its source's value to whoever reads the holder, whatever its nature becomes: its maker reads
1368
+ // it at the source, as any direct access there, and, for a sensitive one, which nobody reads, uses it there too.
1369
+ const at = placeIn(source, environments);
1241
1370
  return refuses(reader, 'secret.write', placeIn(item.holder, environments), gone(item.holder))
1242
- ?? refuses(reader, 'secret.read', placeIn(source, environments), gone(source));
1371
+ ?? refuses(reader, 'secret.read', at, gone(source))
1372
+ ?? (natures.get(source.secretId) === 'sensitive' ? refuses(reader, 'secret.use', at, gone(source)) : null);
1243
1373
  });
1244
1374
  const first = codes.find((code) => code !== null) ?? null;
1245
1375
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, 'deny', codes[i] ?? first)));
1246
- // Again under the log's head, as a read is: neither side was deleted since the check above.
1247
- d.underLock.push(async (locked) => {
1248
- const now = await store.environmentsById(locked, [...environments.keys()]);
1249
- if (![...now.values()].some((environment) => environment.deleted)) return;
1250
- throw new Refused(refusal('deleted', MESSAGES.deleted), items.map((item) => entry(item, 'deny', 'deleted')));
1251
- });
1252
1376
  const entries = items.map((item) => entry(item, 'allow', null));
1253
1377
  d.log.push(...entries);
1254
1378
  const answer = { seqs: [] as number[] };
@@ -1311,6 +1435,7 @@ class VaultService implements Vault {
1311
1435
  return seals.map((seal) => (done.has(seal.seq) ? ('ended' as const) : null));
1312
1436
  };
1313
1437
  return this.#decide([principal], async (d) => {
1438
+ // Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
1314
1439
  const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
1315
1440
  // Ending is allowed in a deleted place: deleting one ends the references into and out of it.
1316
1441
  const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
@@ -1326,11 +1451,6 @@ class VaultService implements Vault {
1326
1451
  if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => (codes[i] = code));
1327
1452
  const first = codes.find((code) => code !== null) ?? null;
1328
1453
  if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i]!, 'deny', codes[i] ?? first)));
1329
- // Two ends of one reference at once: the second finds the first under the head's lock.
1330
- d.underLock.push(async (locked) => {
1331
- if ((await ended(locked)).every((code) => code === null)) return;
1332
- throw new Refused(refusal('ended', MESSAGES.ended), vias.map((via, i) => entry(via, seals[i]!, 'deny', 'ended')));
1333
- });
1334
1454
  const entries = vias.map((via, i) => entry(via, seals[i]!, 'allow', null));
1335
1455
  d.log.push(...entries);
1336
1456
  const answer = { seqs: [] as number[] };
@@ -1412,11 +1532,11 @@ class VaultService implements Vault {
1412
1532
  /**
1413
1533
  * Refuse a change that would leave `principal`, an Owner of the whole
1414
1534
  * instance, no longer one, unless another is. Counted under the log's
1415
- * head: two changes at once, each demoting or removing a different
1416
- * Owner, lock different rows, and only the head puts one after the other.
1535
+ * head, which every decision holds: two changes at once, each demoting or
1536
+ * removing a different Owner, lock different rows, and only the head puts
1537
+ * one after the other.
1417
1538
  */
1418
1539
  async #keepAnOwner(d: Decision, actor: string, principal: string, refused: (code: RefusalCode, message?: string) => Refused): Promise<void> {
1419
- await lockLogHead(d.tx);
1420
1540
  if ((await this.#anOwner(d.tx, d.reports, principal)) !== null) return;
1421
1541
  throw refused('last_owner', actor === principal
1422
1542
  ? "you're the last Owner of the whole instance: make someone else an Owner first"
@@ -1473,17 +1593,20 @@ class VaultService implements Vault {
1473
1593
  if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
1474
1594
  const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
1475
1595
  const { role, scope } = roleOf(principal, row);
1596
+ // Every decision about them reads the policy: their roles as an Owner edited them, and which environments are protected.
1597
+ const { policy } = await this.#policyAt(db, reports, at);
1598
+ const service = !principal.startsWith('user:');
1476
1599
  return {
1477
1600
  principal,
1478
1601
  status: 'active',
1479
- live: { role, scope, grants: held.filter((grant) => live(grant, at)) },
1480
- all: { role, scope, grants: held },
1602
+ live: { role, scope, grants: held.filter((grant) => live(grant, at)), policy, service },
1603
+ all: { role, scope, grants: held, policy, service },
1481
1604
  fault,
1482
1605
  stored: grants,
1483
1606
  };
1484
1607
  }
1485
1608
 
1486
- #access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean): Access {
1609
+ #access(principal: string, row: Member | undefined, held: readonly GrantRow[], at: number, tampered: boolean, policy: Policy): Access {
1487
1610
  const active = PRINCIPAL.test(principal) && !tampered && row?.status === 'active';
1488
1611
  return {
1489
1612
  principal,
@@ -1493,6 +1616,7 @@ class VaultService implements Vault {
1493
1616
  grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
1494
1617
  since: row ? iso(row.statusChangedAt) : null,
1495
1618
  by: row?.statusChangedBy ?? null,
1619
+ policy,
1496
1620
  };
1497
1621
  }
1498
1622
 
@@ -1520,7 +1644,8 @@ class VaultService implements Vault {
1520
1644
  const read = async (db: Queryable, reports: NewEntry[]) => {
1521
1645
  const [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
1522
1646
  const fault = await this.#integrity(db, principal, row, held, reports);
1523
- return this.#access(principal, row, held, at, fault !== null);
1647
+ const { policy } = await this.#policyAt(db, reports, at);
1648
+ return this.#access(principal, row, held, at, fault !== null, policy);
1524
1649
  };
1525
1650
  const found: NewEntry[] = [];
1526
1651
  const quick = await read(this.#db, found).catch((error: unknown) => {
@@ -1588,10 +1713,7 @@ class VaultService implements Vault {
1588
1713
  const key = placeKey(change);
1589
1714
  if (places.has(key)) throw refused('invalid', 'each place may be changed once per call');
1590
1715
  places.add(key);
1591
- if (change.role !== null && !isRole(change.role)) throw refused('invalid', `no such role: ${change.role}`);
1592
- if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) {
1593
- throw refused('invalid', `${change.role} can only be granted on a project`);
1594
- }
1716
+ if (change.role !== null && typeof change.role !== 'string') throw refused('invalid', `no such role: ${String(change.role)}`);
1595
1717
  const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
1596
1718
  if (Number.isNaN(expiresAt) || (expiresAt !== null && expiresAt <= d.at)) {
1597
1719
  throw refused('invalid', 'an end date must be in the future');
@@ -1600,7 +1722,15 @@ class VaultService implements Vault {
1600
1722
  // A project or an environment must be there, and to be granted, not
1601
1723
  // deleted. Read under the log's head, which a deletion holds while it
1602
1724
  // renames the place, so the place is as it commits.
1603
- await lockLogHead(d.tx);
1725
+ // The roles as the policy defines them now, under the head every change to it takes: a role an Owner made is
1726
+ // granted as one that ships, and one deleted meanwhile is granted by nobody.
1727
+ const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
1728
+ for (const change of changes) {
1729
+ if (change.role !== null && !isRole(change.role, policy)) throw refused('invalid', `no such role: ${change.role}`);
1730
+ if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) {
1731
+ throw refused('invalid', `${change.role} can only be granted on a project`);
1732
+ }
1733
+ }
1604
1734
  const known = await store.places(
1605
1735
  d.tx,
1606
1736
  changes.map((change) => change.projectId!),
@@ -1922,7 +2052,7 @@ class VaultService implements Vault {
1922
2052
  */
1923
2053
  async #settingsAt(db: Queryable, reports: NewEntry[]): Promise<Settings> {
1924
2054
  const entry = await newestAuthentic(
1925
- (before) => store.newestVaultEntries(db, SETTINGS, LOOK_BACK.page, before),
2055
+ (limit, before) => store.newestVaultEntries(db, SETTINGS, limit, before),
1926
2056
  this.#check(reports, null),
1927
2057
  );
1928
2058
  if (entry === 'none') return DEFAULT_SETTINGS;
@@ -1931,6 +2061,143 @@ class VaultService implements Vault {
1931
2061
  return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
1932
2062
  }
1933
2063
 
2064
+ // --- the instance's access policy -------------------------------------------
2065
+
2066
+ async policy(): Promise<Policy> {
2067
+ await this.#settled();
2068
+ const reports: NewEntry[] = [];
2069
+ const { policy } = await this.#policyAt(this.#db, reports, await this.#now(this.#db));
2070
+ await this.#record(reports);
2071
+ return policy;
2072
+ }
2073
+
2074
+ /**
2075
+ * The policy the vault's newest `policy.change` says, by its MAC, and its
2076
+ * edits, as of `at`: one in its name it did not write is passed over, and
2077
+ * reported. With such entries and none of its own within reach, the
2078
+ * policy is unreadable, and closed: every environment is protected, and
2079
+ * no role holds anything but Owner, otherwise whoever can insert rows
2080
+ * could unprotect them. Its edits are then unknown, not none.
2081
+ */
2082
+ async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
2083
+ const entry = await newestAuthentic(
2084
+ (limit, before) => store.newestVaultEntries(db, POLICY, limit, before),
2085
+ this.#check(reports, null),
2086
+ );
2087
+ if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
2088
+ const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
2089
+ return edits === null ? { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false } : { policy: await livePolicy(db, edits, at), edits, readable: true };
2090
+ }
2091
+
2092
+ changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
2093
+ const { actor, change, revision } = input;
2094
+ if (revision !== undefined && (typeof revision !== 'string' || !POLICY_REVISION.test(revision))) throw new Error('revision names a policy as policyRevision writes it');
2095
+ // Where a protection is, once its rows are found: its project, and its one environment if that has a row yet. A fork's,
2096
+ // protected before its row is written, is named by the change alone, as the log's columns name only rows.
2097
+ const at: { projectId?: string; environmentId?: string } = {};
2098
+ const entry = (decision: 'allow' | 'deny', detail: Record<string, unknown>, code: RefusalCode | null = null): NewEntry => ({
2099
+ actor,
2100
+ action: POLICY,
2101
+ decision,
2102
+ code,
2103
+ operationId: input.operationId ?? null,
2104
+ requestId: input.requestId ?? null,
2105
+ ...at,
2106
+ metadata: JSON.stringify({ ...detail, ...traced(input) }),
2107
+ });
2108
+ const refused = (code: RefusalCode, message = MESSAGES[code]) =>
2109
+ new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
2110
+ return this.#decide([actor], async (d) => {
2111
+ validateCorrelation(input);
2112
+ const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
2113
+ if (acting.status === 'tampered') throw refused('tampered');
2114
+ if (acting.status !== 'active') throw refused(acting.status === 'removed' ? 'removed' : 'not_a_member');
2115
+ // Under the log's head, as the actor's standing above: two changes at once each start from the one before. One the
2116
+ // caller weighed from a revision of the policy, its newest change and its protected environments' rows, starts
2117
+ // from that one, or is refused, to be asked again: a protected prod renamed since is a policy changed too.
2118
+ const newest = await newestVaultSeq(d.tx, POLICY);
2119
+ const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
2120
+ if (revision !== undefined && policyRevision(newest, before) !== revision) throw refused('policy_changed');
2121
+ const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
2122
+ // Unreadable, its edits are unknown: a change made from none would erase every protection and role edit the forged
2123
+ // rows hide. An Owner of the whole instance alone writes it again, from the roles as they ship and nothing protected.
2124
+ if (!readable && !owner) throw refused('not_allowed', "the vault cannot read its own policy: only an Owner of the whole instance writes it again, from the roles as they ship");
2125
+ // Those the policy no longer holds stay out: a deleted environment's id, and an unwritten fork's past its window,
2126
+ // neither of which comes back, the window being one a fork writes its row well within.
2127
+ const next: PolicyEdits = {
2128
+ roles: { ...edits.roles },
2129
+ instanceRoles: { ...edits.instanceRoles },
2130
+ protected: edits.protected.filter((place) => before.protected.includes(place.environmentId)).map((place) => ({ ...place })),
2131
+ };
2132
+ if (change?.kind === 'role' || change?.kind === 'instance-role') {
2133
+ // Roles are the whole instance's: an Owner of all of it alone changes them (R1, R15), never through a scoped credential.
2134
+ if (!owner) throw refused('not_allowed', 'only an Owner of the whole instance edits roles');
2135
+ const instance = change.kind === 'instance-role';
2136
+ const fixed = instance ? FIXED_ROLES.instanceRoles.includes(change.role as InstanceRole) : FIXED_ROLES.roles.includes(change.role);
2137
+ if (typeof change.role !== 'string' || fixed) throw refused('invalid', `${change.role} is not edited: it holds ${change.role === 'member' ? 'nothing' : 'everything'}, always`);
2138
+ if (instance && !isInstanceRole(change.role)) throw refused('invalid', `no instance role ${change.role}: an Owner makes project roles`);
2139
+ if (!instance && !isBuiltinRole(change.role) && !ROLE_ID.test(change.role)) throw refused('invalid', 'a role is named by lowercase letters, digits and dashes, 2 to 32, starting with a letter');
2140
+ const table = instance ? next.instanceRoles as Record<string, RoleDefinition> : next.roles;
2141
+ if (change.definition === null) {
2142
+ // Put back as it ships, or, one an Owner made, gone: while no grant names it.
2143
+ if (!instance && !isBuiltinRole(change.role)) {
2144
+ if (table[change.role] === undefined) throw refused('invalid', `no role ${change.role}`);
2145
+ const held = await store.grantsNaming(d.tx, change.role);
2146
+ if (held > 0) throw refused('invalid', `${held === 1 ? '1 grant names' : `${held} grants name`} ${change.role}: revoke ${held === 1 ? 'it' : 'them'} first`);
2147
+ }
2148
+ // What it ships as is held to the same rule as any edit: a project-wide permission back on a role granted on environments.
2149
+ const shipped = instance ? undefined : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
2150
+ if (shipped !== undefined && shipped.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && (await store.grantsNaming(d.tx, change.role, { onEnvironments: true })) > 0) {
2151
+ throw refused('invalid', `${change.role} is granted on environments: as it ships, it holds a project-wide permission, which would make those grants incoherent`);
2152
+ }
2153
+ delete table[change.role];
2154
+ } else {
2155
+ const problem = definitionProblem(change.definition);
2156
+ if (problem !== null) throw refused('invalid', problem);
2157
+ const definition = normalDefinition(change.definition);
2158
+ // A project role granted on an environment keeps no project-wide permission while any grant there names it.
2159
+ if (!instance && definition.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && (await store.grantsNaming(d.tx, change.role, { onEnvironments: true })) > 0) {
2160
+ throw refused('invalid', `${change.role} is granted on environments: a project-wide permission would make those grants incoherent`);
2161
+ }
2162
+ const shipped = instance ? INSTANCE_ROLES[change.role as InstanceRole] : isBuiltinRole(change.role) ? ROLES[change.role] : undefined;
2163
+ if (shipped !== undefined && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
2164
+ else table[change.role] = definition;
2165
+ }
2166
+ } else if (change?.kind === 'protect') {
2167
+ if (typeof change.projectId !== 'string' || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0
2168
+ || change.environmentIds.some((id) => typeof id !== 'string' || !UUID.test(id)) || typeof change.protected !== 'boolean') {
2169
+ throw refused('invalid', 'protection names a project and some of its environments, by id, and whether they are protected');
2170
+ }
2171
+ if (!(await store.projectLive(d.tx, change.projectId))) throw refused('invalid', 'no such project');
2172
+ at.projectId = change.projectId;
2173
+ // Each a live environment of the project, or none yet: a fork's id, protected before its row is written.
2174
+ const found = await store.environmentsById(d.tx, change.environmentIds);
2175
+ if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused('invalid', 'each environment is one of the project\'s');
2176
+ if (change.environmentIds.length === 1 && found.has(change.environmentIds[0]!)) at.environmentId = change.environmentIds[0];
2177
+ if (change.protected) {
2178
+ if (!allows(acting.live, 'environment.manage', { projectId: change.projectId })) throw refused('not_allowed', 'protecting an environment takes managing its project\'s environments');
2179
+ const known = new Set(next.protected.map((place) => place.environmentId));
2180
+ next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({ projectId: change.projectId, environmentId, at: d.at }))]
2181
+ .sort((a, b) => (a.environmentId < b.environmentId ? -1 : 1));
2182
+ } else {
2183
+ // Unprotecting widens what people hold there: an Owner of the whole instance's to do (R15).
2184
+ if (!owner) throw refused('not_allowed', 'only an Owner of the whole instance unprotects an environment');
2185
+ next.protected = next.protected.filter((place) => !change.environmentIds.includes(place.environmentId));
2186
+ }
2187
+ } else {
2188
+ throw refused('invalid', 'a change is to a role, an instance role, or protection');
2189
+ }
2190
+ // The policy now, and the newest change, which it is as of: this one's, when it changed anything.
2191
+ const answer = { policy: await livePolicy(d.tx, next, d.at), seq: newest === null ? null : Number(newest) };
2192
+ if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
2193
+ const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
2194
+ d.log.push(made);
2195
+ d.after.push((seqOf) => (answer.seq = seqOf(made)));
2196
+ }
2197
+ return answer;
2198
+ });
2199
+ }
2200
+
1934
2201
  setSettings(input: SetSettingsInput): Promise<Outcome<{ settings: Settings }>> {
1935
2202
  const { actor } = input;
1936
2203
  const entry = (decision: 'allow' | 'deny', detail: Record<string, unknown>, code: RefusalCode | null = null): NewEntry => ({
@@ -1951,7 +2218,6 @@ class VaultService implements Vault {
1951
2218
  if (!runsInstance(acting.live)) throw refused('not_allowed', "only admins and owners of the whole instance change its settings");
1952
2219
  if (!isScope(input.settings?.serviceAccounts)) throw refused('invalid', 'who sets up service accounts is a scope: { projects, environments }, each all, only or except');
1953
2220
  // Under the log's head, so two changes at once each name the one before.
1954
- await lockLogHead(d.tx);
1955
2221
  const previous = await this.#settingsAt(d.tx, d.reports);
1956
2222
  const settings: Settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
1957
2223
  if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry('allow', { ...settings, previous }));
@@ -2097,73 +2363,137 @@ class VaultService implements Vault {
2097
2363
 
2098
2364
  // --- checkpoints and the log --------------------------------------------------
2099
2365
 
2100
- /** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
2101
- async #latest(db: Queryable): Promise<{ checkpoint: Checkpoint; seq: bigint; keyId: string } | null> {
2102
- const row = await store.latestVaultEntry(db, [CHECKPOINT]);
2103
- return row === undefined ? null : { checkpoint: JSON.parse(row.metadata) as Checkpoint, seq: row.seq, keyId: row.keyId };
2366
+ /**
2367
+ * Where a walk of the log resumes: the first entry (`start`), a
2368
+ * checkpoint's entry that keeps a tally, or the newest such entry, the
2369
+ * first entry while there is none. The entry must be the vault's, by its
2370
+ * MAC, and its tally whole: the members and grants it keeps, or the
2371
+ * earlier checkpoint it names keeps, match the digest it carries.
2372
+ *
2373
+ * Its signature is left to the walk: the entry comes after the prefix it
2374
+ * signs, so a walk from there reads it, as it reads every other.
2375
+ */
2376
+ async #resume(db: Queryable, from: 'start' | 'newest' | bigint): Promise<Resumed | Broken> {
2377
+ if (from === 'start') return FIRST_ENTRY;
2378
+ const entry = from === 'newest' ? await store.latestTallied(db) : (await store.entriesAt(db, [from])).get(from);
2379
+ if (entry === undefined) return from === 'newest' ? FIRST_ENTRY : { ok: false, failedAtSeq: Number(from), reason: 'no checkpoint at this entry' };
2380
+ return (await this.#resumeEach(db, [entry]))[0]!;
2381
+ }
2382
+
2383
+ /** `#resume` for each of these checkpoint entries, the entries their members and grants are kept in read at once. */
2384
+ async #resumeEach(db: Queryable, entries: readonly StoredEntry[]): Promise<(Resumed | Broken)[]> {
2385
+ const reads = entries.map((entry) => this.#tallied(entry));
2386
+ const named = reads.flatMap((read) => (typeof read !== 'string' && 'at' in read.tally.access ? [BigInt(read.tally.access.at)] : []));
2387
+ const carriers = new Map([...(await store.entriesAt(db, named))].map(([seq, held]) => [seq, this.#tallied(held)]));
2388
+ return entries.map((entry, i): Resumed | Broken => {
2389
+ const broken = (reason: string): Broken => ({ ok: false, failedAtSeq: Number(entry.seq), reason });
2390
+ const read = reads[i]!;
2391
+ if (typeof read === 'string') return broken(read);
2392
+ let carrier = entry.seq;
2393
+ let { access } = read.tally;
2394
+ if ('at' in access) {
2395
+ carrier = BigInt(access.at);
2396
+ const holder = carrier < entry.seq ? carriers.get(carrier) : undefined;
2397
+ if (holder === undefined || typeof holder === 'string' || 'at' in holder.tally.access) {
2398
+ return broken(`its tally keeps its members and grants in entry ${carrier}, which holds none`);
2399
+ }
2400
+ access = holder.tally.access;
2401
+ }
2402
+ const kept = { members: access.members, grants: access.grants, newest: access.newest };
2403
+ if (keptDigest(access) !== read.tally.access.digest) return broken("its tally's members and grants do not match their digest");
2404
+ return {
2405
+ ok: true,
2406
+ entry: entry.seq,
2407
+ at: { seq: BigInt(read.seq), hash: Buffer.from(read.hash, 'hex') },
2408
+ tally: { ...read.tally, access: kept },
2409
+ digest: read.tally.access.digest,
2410
+ carrier,
2411
+ scrub: read.scrub === null ? null : { at: positionOf(read.scrub.at), end: positionOf(read.scrub.end) },
2412
+ };
2413
+ });
2414
+ }
2415
+
2416
+ /** A checkpoint's entry that keeps a tally, as it says, or why it is not one the vault wrote. */
2417
+ #tallied(entry: StoredEntry): TalliedCheckpoint | string {
2418
+ const not = "not a checkpoint of the vault's that keeps a tally";
2419
+ if (entry.author !== 'vault' || entry.action !== CHECKPOINT || entry.decision !== 'allow' || entry.relatedSeq === null) return not;
2420
+ const authentic = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: this.#keysFor(entry) });
2421
+ if (!authentic.ok) return withCause(authentic.reason);
2422
+ const read = metadataOf(entry) as Partial<TalliedCheckpoint> | null;
2423
+ if (read?.tally?.v !== TALLY_VERSION || read.scrub === undefined || typeof read.seq !== 'number' || BigInt(read.seq) !== entry.relatedSeq) return not;
2424
+ return read as TalliedCheckpoint;
2425
+ }
2426
+
2427
+ /** Which of `heads` the log still holds where they were, in one read. */
2428
+ async #holding(db: Queryable, heads: readonly LogHead[]): Promise<(head: LogHead) => boolean> {
2429
+ const hashes = await store.hashesAt(db, [...new Set(heads.filter((head) => head.seq >= 0).map((head) => BigInt(head.seq)))]);
2430
+ return (head) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
2104
2431
  }
2105
2432
 
2106
2433
  /**
2107
- * The chain recomputed to its last entry, for a checkpoint to sign: from
2108
- * its first entry at the first checkpoint of each hour, and otherwise from
2109
- * the prefix the last checkpoint signed, if that one was signed this hour.
2110
- *
2111
- * Resuming never signs what the vault did not recompute. The vault
2112
- * recomputed the last checkpoint's prefix itself, from entry 0 at the
2113
- * hour's first checkpoint and onward at each one since, and the entry
2114
- * that holds it is the first one checked again, by its MAC. The next
2115
- * signature extends that same chain.
2434
+ * Sign the log up to its last entry, from the newest checkpoint's tally.
2116
2435
  *
2117
- * What resuming does not do is recompute the rows of that prefix again.
2118
- * They can change while the hash stored at its end stays, so it first
2119
- * checks the two things that are cheap: that hash is still there, which a
2120
- * prefix rewritten and chained again changes, and every entry of the
2121
- * prefix is, which a cut changes. Either failing, it recomputes from entry
2122
- * 0, which names what broke. An entry edited in place, its hash left as
2123
- * it was, is the one change left to the next full pass, within the hour.
2436
+ * In one snapshot, and without the log's lock, so that no append waits on
2437
+ * it: every member's row checked, since rows changed around the vault
2438
+ * write nothing to the log; the prefix the newest checkpoint signed still
2439
+ * there, its hash where it was, which a rewrite chained again changes, and
2440
+ * every entry of it, which a cut changes; the chain recomputed from there,
2441
+ * every hash from content and every vault entry by its MAC, no further
2442
+ * than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
2443
+ * before rehashed (`#scrub`). Then, under the lock, the entries appended
2444
+ * since the snapshot, so that it signs the head.
2124
2445
  *
2125
- * It resumes only from a checkpoint under the key the vault writes with
2126
- * now: once the log reaches that key, no other writes again (`forward`).
2127
- * After a rotation, the next pass is full.
2446
+ * A walk cut short signs where it got to, behind the head: the log after
2447
+ * an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
2448
+ * from the first entry, one walk a call, and `/readyz` stays red until the
2449
+ * checkpoints reach the newest heartbeat.
2128
2450
  */
2129
- async #recompute(tx: Queryable): Promise<{ verification: LogVerification; anchor: Anchor }> {
2130
- const { logKeys } = this.#prepared;
2131
- const latest = await this.#latest(tx);
2132
- const hour = (ms: number) => Math.floor(ms / FULL_RECOMPUTE_MS);
2133
- const resumable =
2134
- latest !== null &&
2135
- latest.keyId === logKeys[0].keyId &&
2136
- latest.seq === BigInt(latest.checkpoint.seq) + 1n &&
2137
- hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) &&
2138
- (await carries(tx, latest.checkpoint)) &&
2139
- (await store.complete(tx, BigInt(latest.checkpoint.seq)));
2140
- if (!resumable) return verifyChain(tx, logKeys, [], UNVERIFIED);
2141
- const { seq, hash } = latest.checkpoint;
2142
- const signed: Anchor = { nextSeq: BigInt(seq) + 1n, hash: Buffer.from(hash, 'hex'), vaultEntries: 0, vaultKeys: [logKeys[0].keyId] };
2143
- return verifyChain(tx, logKeys, [], signed);
2144
- }
2145
-
2146
- async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
2451
+ async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint; warning?: string }>> {
2147
2452
  await this.#settled();
2148
2453
  // A KEK found wrong turns readiness red: the checkpoint is refused. It does no key work of its
2149
2454
  // own, so it writes nothing more and never waits on a key service; a check under way, or none yet, is no verdict.
2150
- const { wrongKek } = this.#prepared;
2151
- // Then, in one snapshot and without the log's lock, so that no append
2152
- // waits on it: every member's row checked, since rows changed around the
2153
- // vault write nothing to the log; and the chain recomputed, every hash
2154
- // from content and every vault entry by its MAC, from the first entry
2155
- // once an hour, since an entry cut from the middle leaves the hashes
2156
- // around a later checkpoint as they were (`#recompute`).
2455
+ const { wrongKek, logKeys } = this.#prepared;
2456
+ const { walks } = this.#prepared.options;
2157
2457
  const found: NewEntry[] = [];
2158
- const whole = await this.#db.transaction(async (tx) => {
2458
+ // The newest checkpoint entry, signed or refused, as this call's snapshot found it.
2459
+ let seen: bigint | null = null;
2460
+ const walked = await this.#db.transaction(async (tx) => {
2461
+ seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
2159
2462
  await this.#sweep(tx, found);
2160
- return this.#recompute(tx);
2463
+ const from = await this.#resume(tx, 'newest');
2464
+ if (!from.ok) return from;
2465
+ const signed = headOf(from.at);
2466
+ if (!(await this.#holding(tx, [signed]))(signed)) {
2467
+ return { ok: false as const, failedAtSeq: null, reason: `the log up to entry ${signed.seq} is not the prefix the last checkpoint signed` };
2468
+ }
2469
+ const gap = await missingEntry(tx, from.at.seq);
2470
+ if (gap !== null) return { ok: false as const, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
2471
+ const walk = new Walk(from.tally, from.digest);
2472
+ const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
2473
+ if (!chain.ok) return chain;
2474
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
2475
+ if (unsigned !== null) return unsigned;
2476
+ return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
2161
2477
  }, SNAPSHOT);
2478
+ // The past rehashed, in a snapshot of its own. The database failing it leaves the stretch for the
2479
+ // next call, where it was, and the checkpoint is signed all the same, saying why (`warning`); a bug
2480
+ // throws, as anywhere else.
2481
+ let scrub: Scrubbed = { ok: true, next: walked.ok ? walked.from.scrub : null };
2482
+ let stalled: string | null = null;
2483
+ if (walked.ok) {
2484
+ try {
2485
+ scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
2486
+ } catch (error) {
2487
+ // Only the database not answering, or a statement it gave up on: anything else is coffre's to fix.
2488
+ if (!isUnreachable(error) && !timedOut(error)) throw error;
2489
+ stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
2490
+ }
2491
+ }
2162
2492
  return this.#decide([], async (d) => {
2163
2493
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
2164
2494
  // Checkpoints one at a time, each against the one before.
2165
2495
  const head = await lockLogHead(d.tx);
2166
- const latest = await this.#latest(d.tx);
2496
+ const latest = await store.latestVaultEntry(d.tx, [CHECKPOINT]);
2167
2497
  const refused = (code: RefusalCode, detail: Record<string, unknown>, message = MESSAGES[code]) =>
2168
2498
  new Refused(refusal(code, message), [
2169
2499
  { actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
@@ -2171,33 +2501,104 @@ class VaultService implements Vault {
2171
2501
  // Logging the refusal would give the next call something to sign.
2172
2502
  if (head.nextSeq === 0n) throw new Refused(refusal('invalid', 'the log is empty'), []);
2173
2503
  if (wrongKek !== null) throw refused('wrong_kek', { reason: wrongKek }, wrongKek);
2174
- if (!whole.verification.ok) {
2175
- throw refused('log_broken', { failedAtSeq: whole.verification.failedAtSeq, reason: whole.verification.reason });
2504
+ if (!walked.ok) throw refused('log_broken', { failedAtSeq: walked.failedAtSeq, reason: walked.reason });
2505
+ if (!scrub.ok) throw refused('log_broken', { failedAtSeq: scrub.failedAtSeq, reason: scrub.reason });
2506
+ const newest = latest === undefined ? null : (JSON.parse(latest.metadata) as Checkpoint);
2507
+ // Another call checkpointed, or refused to, since this one's snapshot, or this one's rehash of the past
2508
+ // could not run: what it would sign over is not what it checked. A refusal for a broken log stands,
2509
+ // and a checkpoint signed meanwhile is the newest prefix; the next call starts from them.
2510
+ if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
2511
+ const standing = await this.#standingRefusal(d.tx);
2512
+ if (standing !== null) throw refused('log_broken', standing);
2513
+ if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
2176
2514
  }
2177
- // Nothing since the last one: it is still the newest prefix.
2178
- if (latest !== null && latest.seq === head.nextSeq - 1n) return { checkpoint: latest.checkpoint };
2515
+ // Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
2516
+ if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
2179
2517
  // Under the lock, the rest: the prefix signed last still where it was,
2180
- // and every entry since the snapshot. A rewrite is never signed over.
2181
- if (latest !== null && !(await carries(d.tx, latest.checkpoint))) {
2182
- throw refused('log_broken', { reason: `the log up to entry ${latest.checkpoint.seq} is not the prefix the last checkpoint signed` });
2518
+ // and every entry since the snapshot, unless the walk is catching up,
2519
+ // when what it walked must still be there. A rewrite is never signed over.
2520
+ if (newest !== null && !(await carries(d.tx, newest))) {
2521
+ throw refused('log_broken', { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
2183
2522
  }
2184
- const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKeys, [], whole.anchor);
2185
- if (!held.ok) throw refused('log_broken', { failedAtSeq: held.failedAtSeq, reason: held.reason });
2186
- // It signs the entry it verified to, which the head, locked, must name.
2187
- if (verified.nextSeq !== head.nextSeq || !verified.hash.equals(head.headHash)) {
2188
- throw refused('log_broken', { reason: 'the chain head does not name the last entry' });
2523
+ const walk = new Walk(walked.tally, walked.digest);
2524
+ let { at } = walked;
2525
+ if (walked.done) {
2526
+ // Where the snapshot's walk ended still has the hash it found there: what follows links to it.
2527
+ if (!(await carries(d.tx, headOf(at)))) {
2528
+ throw refused('log_broken', { failedAtSeq: Number(at.seq), reason: `the log up to entry ${at.seq} changed while the vault walked it` });
2529
+ }
2530
+ const rest = await rehash(d.tx, logKeys, at, walk, {});
2531
+ if (!rest.ok) throw refused('log_broken', { failedAtSeq: rest.failedAtSeq, reason: rest.reason });
2532
+ const unsigned = await this.#checkpointFault(d.tx, walk.checkpoints);
2533
+ if (unsigned !== null) throw refused('log_broken', { failedAtSeq: unsigned.failedAtSeq, reason: unsigned.reason });
2534
+ at = rest.at;
2535
+ // It signs the entry it verified to, which the head, locked, must name.
2536
+ if (at.seq !== head.nextSeq - 1n || !at.hash.equals(head.headHash)) {
2537
+ throw refused('log_broken', { reason: 'the chain head does not name the last entry' });
2538
+ }
2539
+ } else if (!(await carries(d.tx, headOf(at)))) {
2540
+ throw refused('log_broken', { reason: `the log up to entry ${at.seq} changed while the vault walked it` });
2189
2541
  }
2190
- const signed = { seq: Number(verified.nextSeq - 1n), hash: verified.hash.toString('hex'), signedAt: iso(d.at) };
2542
+ // Another call signed as far, or further, while this one walked behind the head: that is the newest prefix, never one shorter.
2543
+ if (newest !== null && latest!.relatedSeq !== null && latest!.relatedSeq >= at.seq) return { checkpoint: signedPart(newest) };
2544
+ this.#prepared.verified = further(this.#prepared.verified, at);
2545
+ const tally = walk.tally();
2546
+ // Its members and grants once, in the first checkpoint after they change; the others name that one.
2547
+ const digest = walk.digest();
2548
+ const { from } = walked;
2549
+ const access = digest === from.digest && from.carrier !== null ? { digest, at: Number(from.carrier) } : { digest, ...tally.access };
2550
+ const signed = { seq: Number(at.seq), hash: at.hash.toString('hex'), signedAt: iso(d.at) };
2191
2551
  const checkpoint = {
2192
2552
  ...signed,
2193
2553
  keyId: this.#prepared.signer.keyId,
2194
2554
  signature: await this.#prepared.signer.sign(checkpointMessage(signed)),
2195
2555
  };
2196
- d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', metadata: JSON.stringify(checkpoint) });
2197
- return { checkpoint };
2556
+ const lap = scrub.next === null ? null : { at: headOf(scrub.next.at), end: headOf(scrub.next.end) };
2557
+ const kept: TalliedCheckpoint = { ...checkpoint, tally: { v: TALLY_VERSION, ...tally, access }, scrub: lap };
2558
+ d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
2559
+ return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
2198
2560
  });
2199
2561
  }
2200
2562
 
2563
+ /**
2564
+ * The prefixes signed before, rehashed a stretch at a time, in laps: a lap
2565
+ * runs from the first entry to the prefix the newest checkpoint signed
2566
+ * when it began, which it keeps (`end`), `walks.scrub` entries a call, each
2567
+ * hash recomputed from its entry and each link checked (`relink`); the
2568
+ * next lap runs to the prefix signed then. A checkpoint resumes from the
2569
+ * newest one's tally and never reads what it signed again, and an entry
2570
+ * before it edited in place, its stored hash left as it was, keeps the
2571
+ * chain: this is what finds it, within two laps at most, each the length
2572
+ * of the log when it began over `walks.scrub` beats, however fast the log
2573
+ * grows meanwhile. The lap as it goes on, null between laps.
2574
+ */
2575
+ async #scrub(db: Queryable, newest: Resumed): Promise<Scrubbed> {
2576
+ const lap = newest.scrub ?? { at: START, end: newest.at };
2577
+ if (lap.end.seq < 0n) return { ok: true, next: null };
2578
+ const relinked = await relink(db, lap.at, lap.end.seq, this.#prepared.options.walks.scrub);
2579
+ if (!relinked.ok) return relinked;
2580
+ if (relinked.at.seq < lap.end.seq) return { ok: true, next: { at: relinked.at, end: lap.end } };
2581
+ // A lap ends at the prefix a checkpoint signed, or it was not the chain the checkpoints signed.
2582
+ if (!relinked.at.hash.equals(lap.end.hash)) {
2583
+ return { ok: false, failedAtSeq: Number(lap.end.seq), reason: `the log up to entry ${lap.end.seq} is not the prefix a checkpoint signed` };
2584
+ }
2585
+ return { ok: true, next: null };
2586
+ }
2587
+
2588
+ /**
2589
+ * The newest refusal for a broken log since the newest checkpoint was
2590
+ * signed, by the vault's MAC, as it said: it stands, for every
2591
+ * verification and checkpoint, until a checkpoint is signed, which takes
2592
+ * the check that found it running again and passing. A refusal of
2593
+ * another kind since, a wrong KEK, hides none.
2594
+ */
2595
+ async #standingRefusal(db: Queryable): Promise<{ failedAtSeq?: number | null; reason?: string } | null> {
2596
+ const signed = await store.latestVaultEntry(db, [CHECKPOINT]);
2597
+ const refused = await store.newestRefusal(db, CHECKPOINT, 'log_broken', signed?.seq ?? -1n);
2598
+ if (refused === undefined || !this.#authentic(refused)) return null;
2599
+ return JSON.parse(refused.metadata) as { failedAtSeq?: number | null; reason?: string };
2600
+ }
2601
+
2201
2602
  async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: [] }> {
2202
2603
  await this.#settled();
2203
2604
  const { signers, since } = this.#prepared;
@@ -2221,41 +2622,123 @@ class VaultService implements Vault {
2221
2622
  return { current: { kekProvider: provider, kekId: keyId }, checks };
2222
2623
  }
2223
2624
 
2224
- verifyLog(input: VerifyLogInput): Promise<LogVerification> {
2225
- return this.#verifyAll(input.upTo ?? null);
2226
- }
2227
-
2228
2625
  /**
2229
- * The whole log, in one snapshot and one pass over the vault's entries:
2230
- * each by its MAC, the keys it moved through, and what the other checks
2231
- * need of it, gathered as it goes (`Findings`); then every checkpoint, the
2232
- * heads that must still be there, the key batches accounted for, and the
2233
- * members and grants replayed.
2626
+ * The log from a checkpoint's tally, in one snapshot: the vault's
2627
+ * entries since, each by its MAC, the keys it moved through, and what the
2628
+ * other checks need of it (`Walk`); then every checkpoint walked, the
2629
+ * heads that must still be there, and, for a walk that ends at the
2630
+ * present, the key batches accounted for and the members and grants
2631
+ * replayed against their rows.
2234
2632
  *
2235
2633
  * With `upTo`, the head the app verified to, the links and hashes of the
2236
- * chain are the app's to recompute, as it just has, over its own snapshot,
2237
- * through that head; `carries(upTo)` ties this snapshot to that one. So
2238
- * the vault reads only its own entries, each checked against its own hash
2239
- * and MAC over the link it names. Without `upTo`, nobody else recomputed
2240
- * the chain: the vault reads it all, and rehashes every link.
2634
+ * chain are the app's to recompute, as it just has, over its own
2635
+ * snapshot, through that head; holding `upTo` ties this snapshot to that
2636
+ * one. So the vault reads only its own entries, each checked against its
2637
+ * own hash and MAC over the link it names. Without `upTo`, nobody else
2638
+ * recomputed the chain: the vault reads it all, and rehashes every link,
2639
+ * no further than one walk (`walks.verify`).
2241
2640
  */
2242
- async #verifyAll(upTo: LogHead | null): Promise<LogVerification> {
2641
+ async verifyLog(input: VerifyLogInput): Promise<LogVerification> {
2243
2642
  await this.#settled();
2244
- return this.#db.transaction(async (tx) => {
2643
+ const upTo = input.upTo ?? null;
2644
+ const { logKeys } = this.#prepared;
2645
+ const { walks } = this.#prepared.options;
2646
+ return this.#db.transaction(async (tx): Promise<LogVerification> => {
2245
2647
  const remembered = this.#prepared.verified;
2246
- const findings = new Findings();
2247
- const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
2248
- if (!read.ok) return read;
2249
- // Each head the log must still hold, in one read: the app's, every checkpoint's, and the last this vault rehashed.
2250
- const heads: LogHead[] = [...(upTo === null ? [] : [upTo]), ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
2251
- const rememberedHead = remembered.nextSeq > 0n ? { seq: Number(remembered.nextSeq - 1n), hash: remembered.hash.toString('hex') } : null;
2252
- if (rememberedHead !== null) heads.push(rememberedHead);
2253
- const hashes = await store.hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
2254
- const holds = (head: LogHead) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
2648
+ const from = await this.#resume(tx, input.from === undefined ? 'newest' : input.from === 'start' ? 'start' : BigInt(input.from));
2649
+ if (!from.ok) return from;
2650
+ // Where a stretch ends, the last entry its checkpoint names: that checkpoint, and each it passes on the
2651
+ // way, are held to the walk as it gets there, so a fault on the way is named where it is.
2652
+ const to = input.to === undefined ? null : BigInt(input.to);
2653
+ const ends = to === null ? null : (await store.entriesAt(tx, [to])).get(to)?.relatedSeq ?? -1n;
2654
+ if (ends !== null && (ends <= from.at.seq || (upTo !== null && BigInt(upTo.seq) !== ends))) {
2655
+ return { ok: false, failedAtSeq: Number(to), reason: 'not a checkpoint after the one the walk starts from, whose prefix ends where the app verified to' };
2656
+ }
2657
+ // The prefix the walk starts from still there, as it was signed, and whole: a rewrite chained again
2658
+ // changes its hash, and a cut its count.
2659
+ if (!(await this.#holding(tx, [headOf(from.at)]))(headOf(from.at))) {
2660
+ return { ok: false, failedAtSeq: Number(from.entry), reason: `the log up to entry ${from.at.seq} is not the prefix this checkpoint signed: it was rewritten` };
2661
+ }
2662
+ const gap = await missingEntry(tx, from.at.seq);
2663
+ if (gap !== null) return { ok: false, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
2664
+ // Every checkpoint after it whose prefix the walk may pass, a stretch's end among them, signed while
2665
+ // the app walked or not: each tally is held to the walk where it gets there (`Walk.expect`).
2666
+ const walk = new Walk(from.tally, from.digest);
2667
+ const later = await store.talliedAfter(tx, from.entry ?? -1n, walks.verify);
2668
+ const passed = later.filter(({ seq }) => to === null || seq <= to);
2669
+ if (to !== null && passed.at(-1)?.seq !== to) {
2670
+ return { ok: false, failedAtSeq: Number(to), reason: `a stretch passes no more than ${walks.verify} checkpoints: end one sooner` };
2671
+ }
2672
+ // Each one's tally as what the walk must come to, a few at a time, so that members and grants kept whole
2673
+ // in many of them are never all in memory: the digest it keeps of them stands for them.
2674
+ // Each one's members and grants are as its digest says, kept whole in it or in the earlier checkpoint it
2675
+ // names, each such checkpoint read once: what a walk resuming from it would find.
2676
+ const holding = new Map<bigint, string | null>();
2677
+ const carried = async (carrier: bigint): Promise<string | null> => {
2678
+ if (!holding.has(carrier)) {
2679
+ const entry = (await store.entriesAt(tx, [carrier])).get(carrier);
2680
+ const read = entry === undefined ? 'missing' : this.#tallied(entry);
2681
+ holding.set(carrier, typeof read === 'string' || 'at' in read.tally.access ? null : keptDigest(read.tally.access));
2682
+ }
2683
+ return holding.get(carrier)!;
2684
+ };
2685
+ const expected: Expected[] = [];
2686
+ // The hash the stretch's own checkpoint signed, as it is read among the others.
2687
+ let endSigned: string | null = null;
2688
+ for (let after = from.entry ?? -1n, last = passed.at(-1)?.seq; last !== undefined && after < last; ) {
2689
+ const batch = await store.talliedWithin(tx, after, last, walks.verify + 1);
2690
+ if (batch.length === 0) break;
2691
+ for (const entry of batch) {
2692
+ const read = this.#tallied(entry);
2693
+ const point = { entry: entry.seq, through: entry.relatedSeq! };
2694
+ if (typeof read === 'string') {
2695
+ expected.push({ ...point, unreadable: read });
2696
+ continue;
2697
+ }
2698
+ if (entry.seq === to) endSigned = read.hash;
2699
+ const { access } = read.tally;
2700
+ if (!('at' in access)) holding.set(entry.seq, keptDigest(access));
2701
+ const kept = 'at' in access ? (BigInt(access.at) < entry.seq ? await carried(BigInt(access.at)) : null) : holding.get(entry.seq)!;
2702
+ expected.push(kept === access.digest
2703
+ ? { ...point, key: expectedKey(read.tally) }
2704
+ : { ...point, unreadable: kept === null ? `its tally keeps its members and grants in entry ${'at' in access ? access.at : entry.seq}, which holds none` : "its tally's members and grants do not match their digest" });
2705
+ }
2706
+ after = batch.at(-1)!.seq;
2707
+ }
2708
+ walk.expect(expected);
2709
+ // How far the walk got, the hash there, and whether that is the present: the rows and the key batches
2710
+ // are held to the log only when the walk read every entry of the vault's there is.
2711
+ let through = from.at.seq;
2712
+ let reached = headOf(from.at).hash;
2713
+ let present = false;
2714
+ if (upTo === null) {
2715
+ const chain = await rehash(tx, logKeys, from.at, walk, ends === null ? { limit: walks.verify } : { to: ends });
2716
+ if (!chain.ok) return chain;
2717
+ [through, reached, present] = [chain.at.seq, headOf(chain.at).hash, chain.done && ends === null];
2718
+ this.#prepared.verified = further(this.#prepared.verified, chain.at);
2719
+ } else {
2720
+ // On past the app's head, to the vault's last entry in this snapshot: the rows are as they are at
2721
+ // this one's. The app's head bounds what is verified; the vault's own walk, what the rows meet.
2722
+ const own = await ownEntries(tx, logKeys, from.at.seq, walk, ends === null ? { limit: walks.verify } : { to: ends });
2723
+ if (!own.ok) return own;
2724
+ // An app older than 0.10.1 names no `from`, and takes an ok for the whole of `upTo`: it gets one only for that.
2725
+ if (!own.done && input.from === undefined) {
2726
+ return { ok: false, failedAtSeq: null, reason: `the vault verifies no more than ${walks.verify} of its entries a call, and its checkpoints are catching up: update the app` };
2727
+ }
2728
+ // Short of its own last entry, it has not held the rows to the log: never say it verified all the app did.
2729
+ const short = own.at < BigInt(upTo.seq) ? own.at : BigInt(upTo.seq) - 1n;
2730
+ [through, reached, present] = [own.done ? BigInt(upTo.seq) : short, upTo.hash, own.done && ends === null];
2731
+ }
2732
+ walk.finish(ends ?? through);
2733
+ // Each head the log must still hold, in one read: the app's, every
2734
+ // checkpoint's walked, and the last this vault rehashed.
2735
+ const rememberedHead = remembered.seq >= 0n ? headOf(remembered) : null;
2736
+ const heads = [...(upTo === null ? [] : [upTo]), ...(rememberedHead === null ? [] : [rememberedHead])];
2737
+ const holds = await this.#holding(tx, [...heads, ...walk.checkpoints.map(({ checkpoint }) => checkpoint)]);
2255
2738
  if (upTo !== null && !holds(upTo)) {
2256
2739
  return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
2257
2740
  }
2258
- const unsigned = await this.#checkpointFault(findings, holds);
2741
+ const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
2259
2742
  if (unsigned !== null) return unsigned;
2260
2743
  // What this vault last verified must still be there: whoever holds
2261
2744
  // its key can seal a rewrite, but cannot put back the head it saw. A
@@ -2263,57 +2746,46 @@ class VaultService implements Vault {
2263
2746
  if (rememberedHead !== null && !holds(rememberedHead)) {
2264
2747
  return { ok: false, failedAtSeq: rememberedHead.seq, reason: 'changed since the vault last verified it' };
2265
2748
  }
2749
+ if (walk.mismatch !== null) return { ok: false, failedAtSeq: Number(walk.mismatch.entry), reason: walk.mismatch.reason };
2750
+ if (ends !== null) {
2751
+ // The walk ends at the prefix the stretch's checkpoint signed, by its hash too: a chain made again from before it is not.
2752
+ if ((upTo === null && through !== ends) || endSigned !== reached) {
2753
+ return { ok: false, failedAtSeq: Number(to), reason: `the log up to entry ${ends} is not the prefix this checkpoint signed: it was rewritten` };
2754
+ }
2755
+ return { ok: true, entries: walk.entries, through: Number(ends) };
2756
+ }
2757
+ // A checkpoint refused since the newest was signed, for a broken log: what it found, the stretch of the
2758
+ // past it rehashed among it, which no walk from a tally reads again.
2759
+ const standing = await this.#standingRefusal(tx);
2760
+ if (standing !== null) {
2761
+ return { ok: false, failedAtSeq: standing.failedAtSeq ?? null, reason: `the last checkpoint was refused: ${standing.reason ?? 'the log is broken'}` };
2762
+ }
2763
+ const verified = { ok: true as const, entries: walk.entries, through: Number(through) };
2764
+ if (!present) return verified;
2266
2765
  const at = await this.#now(tx);
2267
- const accounting = findings.accounting.result(at);
2766
+ const accounting = walk.accounting.result(at);
2268
2767
  if (!accounting.ok) return accounting;
2269
2768
  const [rows, held] = await Promise.all([store.allMembers(tx), store.grants(tx)]);
2270
- const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
2769
+ const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
2271
2770
  if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
2272
- const verified = { ok: true as const, entries: findings.entries };
2273
2771
  return accounting.pending === 0 ? verified : { ...verified, pending: accounting.pending };
2274
2772
  }, SNAPSHOT);
2275
2773
  }
2276
2774
 
2277
2775
  /**
2278
- * The vault's own entries, oldest first: each against its own hash, and by
2279
- * its MAC, over the link it names, which the app checked; and the vault's
2280
- * keys moving only forward through them.
2281
- */
2282
- async #ownEntries(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
2283
- const { logKeys } = this.#prepared;
2284
- let keys: readonly string[] = [];
2285
- for (let after = -1n; ; ) {
2286
- const batch = await store.vaultEntriesAfter(tx, after, VERIFY_BATCH);
2287
- if (batch.length === 0) return { ok: true };
2288
- for (const entry of batch) {
2289
- const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
2290
- if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
2291
- }
2292
- const moved = forward(batch, keys, logKeys[0]!.keyId);
2293
- if ('failedAtSeq' in moved) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
2294
- keys = moved.keys;
2295
- for (const entry of batch) findings.add(entry);
2296
- after = batch[batch.length - 1]!.seq;
2297
- }
2298
- }
2299
-
2300
- /** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
2301
- async #rehashed(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
2302
- const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
2303
- for (const entry of batch) if (entry.author === 'vault') findings.add(entry);
2304
- });
2305
- if (!verification.ok) return verification;
2306
- this.#prepared.verified = further(this.#prepared.verified, anchor);
2307
- return { ok: true };
2308
- }
2309
-
2310
- /**
2311
- * Every checkpoint, not only the newest: each signed by the key whose log
2312
- * key wrote its entry, over a prefix the log still holds (`holds`), entry
2313
- * for entry. Its entry's MAC is checked by now, and one under a key the
2314
- * vault replaced comes before the rotation.
2776
+ * Every checkpoint walked, not only the newest: each signed by the key
2777
+ * whose log key wrote its entry, over a prefix the log still holds,
2778
+ * entry for entry, which one that keeps a tally names as related. Its
2779
+ * entry's MAC is checked by now, and one under a key the vault replaced
2780
+ * comes before the rotation.
2315
2781
  */
2316
- async #checkpointFault(findings: Findings, holds: (head: LogHead) => boolean): Promise<Extract<LogVerification, { ok: false }> | null> {
2782
+ async #checkpointFault(
2783
+ db: Queryable,
2784
+ checkpoints: Walk['checkpoints'],
2785
+ known?: (head: LogHead) => boolean,
2786
+ ): Promise<Broken | null> {
2787
+ if (checkpoints.length === 0) return null;
2788
+ const holds = known ?? (await this.#holding(db, checkpoints.map(({ checkpoint }) => checkpoint)));
2317
2789
  const { logKeys, signers } = this.#prepared;
2318
2790
  // Each signer's key imported once, for this check only.
2319
2791
  const verifiers = new Map<string, Promise<(checkpoint: Checkpoint) => Promise<boolean>>>();
@@ -2322,7 +2794,6 @@ class VaultService implements Vault {
2322
2794
  if (verifier === undefined) verifiers.set(by.keyId, (verifier = checkpointVerifier(by.publicKey)));
2323
2795
  return verifier.then((verify) => verify(checkpoint));
2324
2796
  };
2325
- const { checkpoints } = findings;
2326
2797
  for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
2327
2798
  const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
2328
2799
  // The signatures of a chunk at once, where crypto runs beside the code; the first fault, in the log's order.
@@ -2330,7 +2801,7 @@ class VaultService implements Vault {
2330
2801
  const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
2331
2802
  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`;
2332
2803
  if (!(await signed(checkpoint, by))) return 'a checkpoint the vault did not sign';
2333
- if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) {
2804
+ if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint) || (entry.relatedSeq !== null && entry.relatedSeq !== BigInt(checkpoint.seq))) {
2334
2805
  return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
2335
2806
  }
2336
2807
  return null;
@@ -2343,8 +2814,8 @@ class VaultService implements Vault {
2343
2814
 
2344
2815
  /**
2345
2816
  * The first member whose row fails its MAC, or names an older access
2346
- * entry than the log's newest about them (`newest`, from the full check's
2347
- * pass, where each entry's MAC was checked).
2817
+ * entry than the log's newest about them (`newest`, from the walk, where
2818
+ * each entry's MAC was checked, on from the tally).
2348
2819
  */
2349
2820
  #unsealed(rows: readonly Member[], held: readonly GrantRow[], newest: ReadonlyMap<string, bigint>): AccessFault | null {
2350
2821
  for (const row of rows) {
@@ -2356,40 +2827,80 @@ class VaultService implements Vault {
2356
2827
  }
2357
2828
  }
2358
2829
 
2830
+ type Broken = Extract<LogVerification, { ok: false }>;
2831
+
2359
2832
  /**
2360
- * What one pass over the vault's entries gathers for the other checks of a
2361
- * full verification, so that the log is read once: the key batches, the
2362
- * access entries replayed, each member's newest, and the checkpoints. Each
2363
- * entry comes after its MAC was checked.
2833
+ * Where a walk of the log resumes: the checkpoint's entry (null for the
2834
+ * first entry), the end of the prefix it signed, the tally through there,
2835
+ * its members and grants' digest and the entry that keeps them, and where
2836
+ * the background rehash goes on from (`#scrub`).
2364
2837
  */
2365
- class Findings {
2366
- readonly accounting = new KeyAccounting();
2367
- readonly replayed: Replayed = { members: new Map(), held: new Map() };
2368
- /** The first access entry the replay could not apply. */
2369
- replayFault: AccessFault | null = null;
2370
- /** Each member's newest allowed access entry, by seq. */
2371
- readonly newestAccess = new Map<string, bigint>();
2372
- /** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
2373
- readonly checkpoints: { entry: { seq: bigint; keyId: string }; checkpoint: Checkpoint }[] = [];
2374
- /** The vault's entries read. */
2375
- entries = 0;
2376
-
2377
- add(entry: StoredEntry): void {
2378
- this.entries += 1;
2379
- this.accounting.add(entry);
2380
- if (entry.decision !== 'allow') return;
2381
- if (ACCESS.has(entry.action)) {
2382
- this.replayFault ??= apply(this.replayed, entry);
2383
- if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
2384
- } else if (entry.action === CHECKPOINT) {
2385
- this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId }, checkpoint: JSON.parse(entry.metadata) as Checkpoint });
2386
- }
2838
+ type Resumed = {
2839
+ ok: true;
2840
+ entry: bigint | null;
2841
+ at: Position;
2842
+ tally: Tally;
2843
+ digest: string;
2844
+ carrier: bigint | null;
2845
+ scrub: Lap | null;
2846
+ };
2847
+
2848
+ /** A lap of the rehash of the past: the last entry rehashed, and the prefix it rehashes to, signed when it began. */
2849
+ type Lap = { at: Position; end: Position };
2850
+
2851
+ /** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
2852
+ function timedOut(error: unknown): boolean {
2853
+ for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
2854
+ if ((cause as { code?: unknown }).code === '57014') return true;
2387
2855
  }
2856
+ return false;
2388
2857
  }
2389
2858
 
2390
- const ACCESS = new Set<string>(ACCESS_ACTIONS);
2859
+ /** Where the next rehash of the past goes on from, null for the first entry; or what it found. */
2860
+ type Scrubbed = { ok: true; next: Lap | null } | Broken;
2861
+
2862
+ const FIRST_ENTRY: Resumed = { ok: true, entry: null, at: START, tally: EMPTY_TALLY, digest: accessDigest(EMPTY_TALLY.access), carrier: null, scrub: null };
2863
+
2864
+ /** How far one call walks the log, in entries: a checkpoint's walk from the last, its rehash of the past, and a verification's own walk. */
2865
+ export type Walks = { checkpoint: number; scrub: number; verify: number };
2866
+
2867
+ /**
2868
+ * A walk rehashes, MACs and replays some 40 µs of CPU an entry, and 95
2869
+ * with V8's optimizer off, as on a cold isolate: 10,000 entries is a beat
2870
+ * of catching up, about a second, and a team's instance writes fewer than
2871
+ * a thousand a day. The rehash of the past is a hash an entry, 14 µs warm
2872
+ * and 55 cold, beside two reads: 100 entries keep it near 6 ms a beat.
2873
+ */
2874
+ const WALKS: Walks = { checkpoint: 10_000, scrub: 100, verify: 10_000 };
2391
2875
 
2392
- /** Checkpoint signatures checked at once, in a full verification. */
2876
+ /** The digest of the members and grants a checkpoint keeps whole, beside the digest it says they have. */
2877
+ function keptDigest({ members, grants, newest }: AccessTally): string {
2878
+ return accessDigest({ members, grants, newest });
2879
+ }
2880
+
2881
+ /** Each checkpoint entry's metadata, parsed once however many times it is asked about: one may hold every member and grant. */
2882
+ const parsed = new WeakMap<StoredEntry, unknown>();
2883
+ function metadataOf(entry: StoredEntry): unknown {
2884
+ if (!parsed.has(entry)) parsed.set(entry, JSON.parse(entry.metadata));
2885
+ return parsed.get(entry);
2886
+ }
2887
+
2888
+ /** A head, as an entry keeps it, as a walk goes on from it. */
2889
+ function positionOf(head: LogHead): Position {
2890
+ return { seq: BigInt(head.seq), hash: Buffer.from(head.hash, 'hex') };
2891
+ }
2892
+
2893
+ /** A position as the log's heads are named, in hex. */
2894
+ function headOf(position: Position): LogHead {
2895
+ return { seq: Number(position.seq), hash: position.hash.toString('hex') };
2896
+ }
2897
+
2898
+ /** What a checkpoint's signature covers, and nothing it keeps beside it. */
2899
+ function signedPart({ seq, hash, signedAt, keyId, signature }: Checkpoint): Checkpoint {
2900
+ return { seq, hash, signedAt, keyId, signature };
2901
+ }
2902
+
2903
+ /** Checkpoint signatures checked at once. */
2393
2904
  const SIGNATURES_AT_ONCE = 256;
2394
2905
 
2395
2906
  /** Settle every operation before releasing the member lock, including cancelled requests. */
@@ -2442,6 +2953,7 @@ function validateItems(items: readonly { secret: SecretRef; key?: string; wrappe
2442
2953
  if (field === 'key' && typeof key !== 'string') throw new Error('DEK must be base64');
2443
2954
  if (field === 'wrapped' && (wrapped === undefined || wrapped === null)) throw new Error('wrapped key is required');
2444
2955
  checkContext(context(secret));
2956
+ if (secret.sensitive !== undefined && typeof secret.sensitive !== 'boolean') throw new Error('a secret is sensitive or not: true, false or absent');
2445
2957
  if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error('secret version must be a positive integer');
2446
2958
  if (typeof secret.path !== 'string' || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error('secret path must be a well-formed string');
2447
2959
  if (key !== undefined) {
@@ -2578,10 +3090,13 @@ function personalRefusal(
2578
3090
  if (own?.owner == null && held?.owner == null) return undefined;
2579
3091
  if (own !== held || principal !== own?.owner || own.parentId === null) return 'personal';
2580
3092
  const parent = placeIn({ projectId: secret.projectId, environmentId: own.parentId }, environments);
2581
- const refused = refuses(reader, 'secret.read', parent, own.deleted || (environments.get(own.parentId)?.deleted ?? true));
3093
+ // Its owner reads what they wrote while they read or use the environment it configures (R8).
3094
+ const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
3095
+ const refused = refuses(reader, 'secret.use', parent, gone) === null ? null : refuses(reader, 'secret.read', parent, gone);
2582
3096
  if (refused !== null) return refused;
2583
- // The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key.
2584
- return withinCredential(reader.live, permission, parent) ? null : 'no_grant';
3097
+ // The credential's scope caps the owner still: their viewer token on the parent reads, and wraps no key. Reading their
3098
+ // own values takes use there: a Contributor's handle reads what its owner wrote, and nothing of the parent's.
3099
+ return withinCredential(reader.live, permission === 'secret.read' ? 'secret.use' : permission, parent) ? null : 'no_grant';
2585
3100
  }
2586
3101
 
2587
3102
  function refuses(reader: Standing, permission: Permission, where: Place, deleted: boolean): RefusalCode | null {
@@ -2781,9 +3296,71 @@ function iso(ms: number): string {
2781
3296
  return new Date(ms).toISOString();
2782
3297
  }
2783
3298
 
2784
- /** A secret's binding context: its ids, and a personal config's owner, hashed, as the decision read it. */
3299
+ /** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
3300
+ function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
3301
+ const edits = stored.policy as Partial<PolicyEdits> | undefined;
3302
+ if (typeof edits !== 'object' || edits === null) return null;
3303
+ const { roles, instanceRoles, protected: protectedIds } = edits;
3304
+ if (typeof roles !== 'object' || roles === null || typeof instanceRoles !== 'object' || instanceRoles === null || !Array.isArray(protectedIds)) return null;
3305
+ const defined = (table: object) => Object.values(table).every((definition) => definitionProblem(definition as RoleDefinition) === null);
3306
+ if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
3307
+ if (protectedIds.some((place) => typeof place !== 'object' || place === null || !UUID.test((place as ProtectedPlace).projectId) || !UUID.test((place as ProtectedPlace).environmentId)
3308
+ || !Number.isSafeInteger((place as ProtectedPlace).at))) return null;
3309
+ return { roles: roles as Record<string, RoleDefinition>, instanceRoles: instanceRoles as PolicyEdits['instanceRoles'], protected: protectedIds as ProtectedPlace[] };
3310
+ }
3311
+
3312
+ /**
3313
+ * The policy `edits` say as of `at`: the roles as they ship, each edited
3314
+ * one replaced and each made one added; and the protected environments, but
3315
+ * for those deleted since, the project around one's included: a deleted
3316
+ * prod protects nothing, and holds nobody back from giving a service
3317
+ * account a role on all of its project (`givesService`). A fork's id with
3318
+ * no row yet stays, protected before it is written, ten minutes: one with
3319
+ * none after that is a fork refused or abandoned, never written. Each
3320
+ * keeps its slug, which a scoped instance role's filters read. The edits
3321
+ * keep them all: no write is needed.
3322
+ */
3323
+ async function livePolicy(db: Queryable, edits: PolicyEdits, at: number): Promise<Policy> {
3324
+ const rows = await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
3325
+ const live = edits.protected.filter((place) => {
3326
+ const row = rows.get(place.environmentId);
3327
+ return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
3328
+ });
3329
+ const slugOf = (place: ProtectedPlace) => rows.get(place.environmentId)?.slug ?? null;
3330
+ return {
3331
+ roles: { ...ROLES, ...edits.roles },
3332
+ instanceRoles: { ...INSTANCE_ROLES, ...edits.instanceRoles },
3333
+ protected: live.map((place) => place.environmentId),
3334
+ protectedIn: live.reduce<Record<string, { environmentId: string; slug: string | null }[]>>(
3335
+ (byProject, place) => ({ ...byProject, [place.projectId]: [...(byProject[place.projectId] ?? []), { environmentId: place.environmentId, slug: slugOf(place) }] }),
3336
+ {},
3337
+ ),
3338
+ };
3339
+ }
3340
+
3341
+ /** A change, as its entry names it. */
3342
+ function describeChange(change: PolicyChange | undefined): Record<string, unknown> | null {
3343
+ if (change === undefined || change === null || typeof change !== 'object') return null;
3344
+ if (change.kind === 'protect') return { kind: 'protect', projectId: change.projectId, environmentIds: change.environmentIds, protected: change.protected };
3345
+ return { kind: change.kind, role: change.role, definition: change.definition };
3346
+ }
3347
+
3348
+ /** What a change changed, as it was: the role's definition, or which environments were protected. */
3349
+ function describeBefore(change: PolicyChange, before: Policy): Record<string, unknown> {
3350
+ if (change.kind === 'protect') return { protected: change.environmentIds.filter((id) => before.protected.includes(id)) };
3351
+ const table = change.kind === 'instance-role' ? before.instanceRoles as Record<string, RoleDefinition> : before.roles;
3352
+ return { definition: Object.hasOwn(table, change.role) ? table[change.role] : null };
3353
+ }
3354
+
3355
+ /** A secret's binding context: its ids, a personal config's owner, hashed, as the decision read it, and whether it is sensitive. */
2785
3356
  function context(secret: SecretRef, owner?: string): SecretContext {
2786
- return { projectId: secret.projectId, environmentId: secret.environmentId, secretId: secret.secretId, ...(owner === undefined ? {} : { owner }) };
3357
+ return {
3358
+ projectId: secret.projectId,
3359
+ environmentId: secret.environmentId,
3360
+ secretId: secret.secretId,
3361
+ ...(owner === undefined ? {} : { owner }),
3362
+ ...(secret.sensitive === true ? { sensitive: true as const } : {}),
3363
+ };
2787
3364
  }
2788
3365
 
2789
3366
  function unwrappable(wrapped: WrappedKey) {