@coffre/vault 0.10.0 → 0.10.2
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/dist/cloudflare.d.ts +2 -1
- package/dist/cloudflare.js +2 -2
- package/dist/{index-CByXzyi-.d.ts → index-zkW6DEm9.d.ts} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node.d.ts +12 -1
- package/dist/node.js +2 -2
- package/dist/{src-D82jyprb.js → src-B0d-W-SI.js} +4 -3
- package/dist/{vault-COqZ12ny.js → vault-66Xf0Hw1.js} +1474 -543
- package/package.json +3 -3
- package/src/accounting.ts +37 -8
- package/src/config.ts +6 -5
- package/src/log.ts +91 -60
- package/src/replay.ts +1 -1
- package/src/snapshot.postgres.ts +97 -0
- package/src/snapshot.sqlite.ts +103 -0
- package/src/snapshot.ts +191 -0
- package/src/store.ts +88 -46
- package/src/tally.ts +241 -0
- package/src/vault.ts +580 -273
package/src/vault.ts
CHANGED
|
@@ -99,17 +99,18 @@ import {
|
|
|
99
99
|
type WrappedKey,
|
|
100
100
|
} from '@coffre/core/vault';
|
|
101
101
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
102
|
-
import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
102
|
+
import { isUniqueViolation, isUnreachable, SNAPSHOT } from '@coffre/db/dialect';
|
|
103
103
|
import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
|
|
104
|
-
import { appendEntries,
|
|
104
|
+
import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
|
|
105
105
|
|
|
106
|
-
import { KeyAccounting } from './accounting.ts';
|
|
107
106
|
import { signer, type Signer } from './checkpoint.ts';
|
|
108
107
|
import type { ResolvedVaultConfig } from './config.ts';
|
|
109
|
-
import { carries,
|
|
108
|
+
import { carries, further, ownEntries, rehash, relink, START, vaultLogKey, VERIFY_BATCH, withCause, type Position } from './log.ts';
|
|
110
109
|
import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
|
|
111
110
|
import { memberMac, rowKey, sameGrants, sealed, standing } from './rows.ts';
|
|
111
|
+
import { snapshot, type Asked, type Snapshot } from './snapshot.ts';
|
|
112
112
|
import * as store from './store.ts';
|
|
113
|
+
import { accessDigest, CHECKPOINT, EMPTY_TALLY, expectedKey, TALLY_VERSION, Walk, type AccessTally, type Expected, type TalliedCheckpoint, type Tally } from './tally.ts';
|
|
113
114
|
import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
|
|
114
115
|
|
|
115
116
|
export type VaultOptions = {
|
|
@@ -120,6 +121,8 @@ export type VaultOptions = {
|
|
|
120
121
|
keyBudgetMs?: number;
|
|
121
122
|
/** Milliseconds added to the database's clock where the vault decides by it. Tests move time with it. */
|
|
122
123
|
clockOffset?: () => number;
|
|
124
|
+
/** How far one call walks the log (`WALKS`); tests shorten them. */
|
|
125
|
+
walks?: Partial<Walks>;
|
|
123
126
|
};
|
|
124
127
|
|
|
125
128
|
/**
|
|
@@ -140,9 +143,9 @@ export type PreparedVault = {
|
|
|
140
143
|
logKey: LogKey;
|
|
141
144
|
/** Each signing key's log key: those after the first verify only entries before `since`. */
|
|
142
145
|
logKeys: LogKey[];
|
|
143
|
-
options: Required<VaultOptions
|
|
144
|
-
/** How far the
|
|
145
|
-
verified:
|
|
146
|
+
options: Required<Omit<VaultOptions, 'walks'>> & { walks: Walks };
|
|
147
|
+
/** How far the chain is rehashed, by any call of this process: its head must still be there. */
|
|
148
|
+
verified: Position;
|
|
146
149
|
/** What member rows are sealed under now; rows.ts. */
|
|
147
150
|
rowKey: Buffer;
|
|
148
151
|
/** Each signing key's row key: those after the first vouch for rows only until `since`, when the rows are sealed again. */
|
|
@@ -169,8 +172,8 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
|
|
|
169
172
|
return {
|
|
170
173
|
config,
|
|
171
174
|
...(await keysOf(config.signingKeys)),
|
|
172
|
-
options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
|
|
173
|
-
verified:
|
|
175
|
+
options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0), walks: { ...WALKS, ...options.walks } },
|
|
176
|
+
verified: START,
|
|
174
177
|
reported: new Set(),
|
|
175
178
|
since: null,
|
|
176
179
|
superseded: null,
|
|
@@ -227,9 +230,6 @@ const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
|
|
|
227
230
|
/** Why a change to a tampered member is refused. */
|
|
228
231
|
const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
|
|
229
232
|
|
|
230
|
-
/** The action of the vault's entry that signs a prefix of the log. */
|
|
231
|
-
const CHECKPOINT = 'audit.checkpoint';
|
|
232
|
-
|
|
233
233
|
/** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
|
|
234
234
|
const SETTINGS = 'settings.change';
|
|
235
235
|
|
|
@@ -289,13 +289,6 @@ const NOT_THEIRS = 'a person gives a service account at most what they hold, whe
|
|
|
289
289
|
/** Who asks for checkpoints: the app's scheduled job. */
|
|
290
290
|
const SCHEDULER = 'system:coffre-scheduler';
|
|
291
291
|
|
|
292
|
-
/**
|
|
293
|
-
* How often a checkpoint recomputes the chain from its first entry: the
|
|
294
|
-
* first of each hour, by the vault's clock. The others resume from the
|
|
295
|
-
* prefix the last one signed (`#recompute`).
|
|
296
|
-
*/
|
|
297
|
-
const FULL_RECOMPUTE_MS = 60 * 60 * 1000;
|
|
298
|
-
|
|
299
292
|
/** A refusal and the entries that record it. */
|
|
300
293
|
class Refused {
|
|
301
294
|
readonly refusal: Refusal;
|
|
@@ -362,6 +355,8 @@ type Decision = {
|
|
|
362
355
|
members: Map<string, Member>;
|
|
363
356
|
/** The database's clock, read once the members are locked. */
|
|
364
357
|
at: number;
|
|
358
|
+
/** What the decision reads, in one statement once the members are locked: under the head, unless it takes the head late. */
|
|
359
|
+
reads: Snapshot;
|
|
365
360
|
log: NewEntry[];
|
|
366
361
|
writes: ((at: number) => Promise<void>)[];
|
|
367
362
|
/** Members whose row or grants the writes change: sealed again once they have run. */
|
|
@@ -386,6 +381,13 @@ type Decision = {
|
|
|
386
381
|
underLock: ((locked: Queryable) => Promise<void>)[];
|
|
387
382
|
};
|
|
388
383
|
|
|
384
|
+
/**
|
|
385
|
+
* What a decision reads besides its members' standing, with it (`Snapshot`):
|
|
386
|
+
* places, references' ends, and the distinct keys a principal would hold
|
|
387
|
+
* released in the vault's window, with `keys` too: the bulk limit's count.
|
|
388
|
+
*/
|
|
389
|
+
type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { principal: string; keys: readonly string[] } };
|
|
390
|
+
|
|
389
391
|
/** The actions of the entries the vault writes about keys. */
|
|
390
392
|
type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
|
|
391
393
|
|
|
@@ -456,6 +458,22 @@ class VaultService implements Vault {
|
|
|
456
458
|
return (await store.now(db)) + this.#prepared.options.clockOffset();
|
|
457
459
|
}
|
|
458
460
|
|
|
461
|
+
/** A snapshot's clock, as the vault keeps time (`#now`). */
|
|
462
|
+
#at(reads: Snapshot): number {
|
|
463
|
+
return reads.at + this.#prepared.options.clockOffset();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
|
|
467
|
+
#snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
|
|
468
|
+
const { released, ...rest } = asked;
|
|
469
|
+
return snapshot(tx, {
|
|
470
|
+
...rest,
|
|
471
|
+
principals,
|
|
472
|
+
head,
|
|
473
|
+
...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset() } }),
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
459
477
|
/**
|
|
460
478
|
* Decide in one transaction, with `principals`' rows locked first, then
|
|
461
479
|
* the log's head. A `Refused` or an `Outage` rolls back all but its own
|
|
@@ -468,18 +486,18 @@ class VaultService implements Vault {
|
|
|
468
486
|
* alone takes the head later (`late`), once its key service has answered,
|
|
469
487
|
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
470
488
|
*/
|
|
471
|
-
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
|
|
489
|
+
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
|
|
472
490
|
const superseded = await this.#settled();
|
|
473
491
|
if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
|
|
474
|
-
return this.#decideNow(principals, decide, late);
|
|
492
|
+
return this.#decideNow(principals, decide, late, asked);
|
|
475
493
|
}
|
|
476
494
|
|
|
477
495
|
/** `#decide`, once the vault is settled. */
|
|
478
|
-
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false): Promise<Outcome<T>> {
|
|
496
|
+
async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, late = false, asked: Reads = {}): Promise<Outcome<T>> {
|
|
479
497
|
const reports: NewEntry[] = [];
|
|
480
498
|
for (let attempt = 1; ; attempt += 1) {
|
|
481
499
|
try {
|
|
482
|
-
return await this.#decideOnce(principals, decide, reports, late);
|
|
500
|
+
return await this.#decideOnce(principals, decide, reports, late, asked);
|
|
483
501
|
} catch (error) {
|
|
484
502
|
// A member who had no row when this decision locked theirs, and has
|
|
485
503
|
// one now: another decision admitted them meanwhile. Again, with it.
|
|
@@ -489,14 +507,15 @@ class VaultService implements Vault {
|
|
|
489
507
|
}
|
|
490
508
|
}
|
|
491
509
|
|
|
492
|
-
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean): Promise<Outcome<T>> {
|
|
510
|
+
async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], late: boolean, asked: Reads): Promise<Outcome<T>> {
|
|
493
511
|
try {
|
|
494
512
|
const result = await this.#db.transaction(async (tx) => {
|
|
495
513
|
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
496
514
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
497
|
-
|
|
515
|
+
// Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
|
|
516
|
+
const reads = await this.#snapshot(tx, principals, asked, !late);
|
|
498
517
|
const d: Decision = {
|
|
499
|
-
tx, members, at:
|
|
518
|
+
tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
500
519
|
};
|
|
501
520
|
const result = await decide(d);
|
|
502
521
|
// A read is logged once per place, whichever way it ends (`readsByPlace`).
|
|
@@ -505,7 +524,9 @@ class VaultService implements Vault {
|
|
|
505
524
|
// Each member's newest access entry, which their row names (rows.ts).
|
|
506
525
|
const accessSeq = new Map<string, bigint>();
|
|
507
526
|
if (entries.length > 0) {
|
|
508
|
-
|
|
527
|
+
// Under the head the decision's reads took: a remote key's decision takes it late, and reads again with it.
|
|
528
|
+
if (d.reads.head === null) throw new Error('a decision appends only under the head it read with');
|
|
529
|
+
const appended = await this.#append(tx, entries, d.underLock, d.reads);
|
|
509
530
|
at = appended.occurredAt;
|
|
510
531
|
entries.forEach((entry, i) => {
|
|
511
532
|
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal!, appended.seqStart + BigInt(i));
|
|
@@ -564,12 +585,12 @@ class VaultService implements Vault {
|
|
|
564
585
|
* MAC are passed over, and reported: otherwise whoever can insert a row
|
|
565
586
|
* could lock any member out.
|
|
566
587
|
*/
|
|
567
|
-
async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[]): Promise<Fault | null> {
|
|
588
|
+
async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[], reads?: Snapshot): Promise<Fault | null> {
|
|
568
589
|
if (row !== undefined && !this.#sealed(row, grants)) {
|
|
569
590
|
this.#report(reports, principal, 'mac', row.mac.toString('hex'));
|
|
570
591
|
return 'mac';
|
|
571
592
|
}
|
|
572
|
-
const newest = await this.#newestAccess(db, principal, reports);
|
|
593
|
+
const newest = await this.#newestAccess(db, principal, reports, reads);
|
|
573
594
|
// No row, and no entry of the vault's own: someone never admitted. No
|
|
574
595
|
// row, but one: one deleted, unless it was admitted since the row was
|
|
575
596
|
// read. Too many forged to see past: neither a row nor the lack of one holds.
|
|
@@ -581,8 +602,14 @@ class VaultService implements Vault {
|
|
|
581
602
|
}
|
|
582
603
|
|
|
583
604
|
/** The newest access entry about `principal` that carries the vault's MAC (`newestAccess` in @coffre/db/grants). */
|
|
584
|
-
#newestAccess(db: Queryable, principal: string, reports: NewEntry[]): Promise<Newest> {
|
|
585
|
-
return newestAccess(db, principal, this.#check(reports, principal));
|
|
605
|
+
#newestAccess(db: Queryable, principal: string, reports: NewEntry[], reads?: Snapshot): Promise<Newest> {
|
|
606
|
+
if (reads === undefined || !reads.grants.has(principal)) return newestAccess(db, principal, this.#check(reports, principal));
|
|
607
|
+
// The snapshot's newest first, read with the rest; the pages before it, as they are asked.
|
|
608
|
+
const newest = reads.newestAccess.get(principal);
|
|
609
|
+
return newestAuthentic(
|
|
610
|
+
(limit, before) => (before === undefined ? Promise.resolve(newest === undefined ? [] : [newest]) : store.accessEntriesAbout(db, principal, limit, before)),
|
|
611
|
+
this.#check(reports, principal),
|
|
612
|
+
);
|
|
586
613
|
}
|
|
587
614
|
|
|
588
615
|
/** Entries in the vault's name told apart by their MACs, each it did not write reported, about `principal`. */
|
|
@@ -595,9 +622,13 @@ class VaultService implements Vault {
|
|
|
595
622
|
|
|
596
623
|
/** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
|
|
597
624
|
#authentic(entry: StoredEntry): boolean {
|
|
625
|
+
return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: this.#keysFor(entry) }).ok;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The vault's log keys an entry may be under: any of them before `since`, and only its current one from there. */
|
|
629
|
+
#keysFor(entry: StoredEntry): LogKey[] {
|
|
598
630
|
const { logKeys, since } = this.#prepared;
|
|
599
|
-
|
|
600
|
-
return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys }).ok;
|
|
631
|
+
return since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
|
|
601
632
|
}
|
|
602
633
|
|
|
603
634
|
/**
|
|
@@ -690,7 +721,7 @@ class VaultService implements Vault {
|
|
|
690
721
|
|
|
691
722
|
async #replaceEveryProject(d: Decision, principal: string): Promise<void> {
|
|
692
723
|
const row = d.members.get(principal);
|
|
693
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
724
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
694
725
|
if (row === undefined || subject.status !== 'active') return;
|
|
695
726
|
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
696
727
|
if (everyProject.length === 0) return;
|
|
@@ -836,16 +867,29 @@ class VaultService implements Vault {
|
|
|
836
867
|
* that an instance still running with the replaced KEK, as during a
|
|
837
868
|
* deploy, cannot write after the rotation.
|
|
838
869
|
*/
|
|
839
|
-
#append(
|
|
870
|
+
async #append(
|
|
871
|
+
tx: Transaction,
|
|
872
|
+
entries: readonly NewEntry[],
|
|
873
|
+
underLock: readonly ((locked: Queryable) => Promise<void>)[] = [],
|
|
874
|
+
/** A snapshot that holds the head: what it read under it is read again by nothing. */
|
|
875
|
+
held?: Snapshot,
|
|
876
|
+
): Promise<Appended> {
|
|
840
877
|
const prepared = this.#prepared;
|
|
841
|
-
|
|
842
|
-
|
|
878
|
+
const head = held?.head ?? undefined;
|
|
879
|
+
const appended = await appendEntries(tx, prepared.logKey, entries, async (locked) => {
|
|
880
|
+
const rotation = head === undefined ? await store.latestVaultEntry(locked, [KEY_ROTATE]) : head.rotation ?? undefined;
|
|
843
881
|
if (rotation !== undefined && !prepared.logKeys.some((key) => key.keyId === rotation.keyId)) {
|
|
844
882
|
prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
|
|
845
883
|
throw new Error(prepared.superseded);
|
|
846
884
|
}
|
|
847
885
|
for (const check of underLock) await check(locked);
|
|
848
|
-
});
|
|
886
|
+
}, head === undefined ? undefined : { head, last: head.last, at: held!.at });
|
|
887
|
+
// The head still held, as this append left it, for the next append of the same transaction: a remote key's intent,
|
|
888
|
+
// then the decision's reports.
|
|
889
|
+
if (head !== undefined) {
|
|
890
|
+
held!.head = { ...head, nextSeq: appended.nextSeq, headHash: appended.headHash, last: { seq: appended.nextSeq - 1n, hash: appended.headHash } };
|
|
891
|
+
}
|
|
892
|
+
return appended;
|
|
849
893
|
}
|
|
850
894
|
|
|
851
895
|
// --- keys -------------------------------------------------------------------
|
|
@@ -909,10 +953,8 @@ class VaultService implements Vault {
|
|
|
909
953
|
secrets: items.map((item) => item.secret),
|
|
910
954
|
// Through a reference, the reader's grants on the holder's environment decide.
|
|
911
955
|
places: sealed.map((seal, i) => seal?.holder ?? items[i]!.secret),
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
return sealed.map((seal) => (seal !== null && ended.has(seal.seq) ? 'ended' : null));
|
|
915
|
-
},
|
|
956
|
+
references: ends,
|
|
957
|
+
vet: ends.length === 0 ? undefined : async (_, reads) => sealed.map((seal) => (seal !== null && reads.ended.has(seal.seq) ? 'ended' : null)),
|
|
916
958
|
remote,
|
|
917
959
|
entry,
|
|
918
960
|
input,
|
|
@@ -1098,11 +1140,14 @@ class VaultService implements Vault {
|
|
|
1098
1140
|
secrets: readonly SecretRef[];
|
|
1099
1141
|
/** Where each secret's permission is checked, when not where it is: a reference's holder. */
|
|
1100
1142
|
places?: readonly SecretPlace[];
|
|
1143
|
+
/** The `reference.create` entries `vet` asks about: read with the decision (`Snapshot`'s `ended`). */
|
|
1144
|
+
references?: readonly bigint[];
|
|
1101
1145
|
/**
|
|
1102
1146
|
* What else refuses each secret, checked in the decision and again
|
|
1103
|
-
* once the log's head is locked, before its entries append
|
|
1147
|
+
* once the log's head is locked, before its entries append: from the
|
|
1148
|
+
* decision's reads, or the database.
|
|
1104
1149
|
*/
|
|
1105
|
-
vet?: (db: Queryable) => Promise<(RefusalCode | null)[]>;
|
|
1150
|
+
vet?: (db: Queryable, reads: Snapshot) => Promise<(RefusalCode | null)[]>;
|
|
1106
1151
|
/**
|
|
1107
1152
|
* Any one of which each secret takes besides `permission`, where it
|
|
1108
1153
|
* is, read in the decision and again under the head; none for null.
|
|
@@ -1131,15 +1176,13 @@ class VaultService implements Vault {
|
|
|
1131
1176
|
// Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
|
|
1132
1177
|
const owners = new Map<string, string>();
|
|
1133
1178
|
const check = async (d: Decision) => {
|
|
1134
|
-
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), call.input);
|
|
1179
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), call.input);
|
|
1135
1180
|
// One read of the environments the decision is about, as the store has them, not as the app sent them:
|
|
1136
1181
|
// each secret's and, for a read through a reference, its holder's. A grant on one slug in every project
|
|
1137
1182
|
// matches the slug there, and a place deleted on either side opens for none.
|
|
1138
1183
|
const at = secrets.map((secret, i) => call.places?.[i] ?? secret);
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
const parents = [...environments.values()].flatMap((environment) => (environment.owner === null || environment.parentId === null ? [] : [environment.parentId]));
|
|
1142
|
-
for (const [id, parent] of await store.environmentsById(d.tx, parents)) environments.set(id, parent);
|
|
1184
|
+
// Each a personal config is a config of too, whose read its owner needs (`places`).
|
|
1185
|
+
const environments = d.reads.environments;
|
|
1143
1186
|
const gone = (place: SecretPlace) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1144
1187
|
owners.clear();
|
|
1145
1188
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
@@ -1159,7 +1202,7 @@ class VaultService implements Vault {
|
|
|
1159
1202
|
return refuses(reader, call.permission, placeIn(at[i]!, environments), gone(secret) || gone(at[i]!));
|
|
1160
1203
|
});
|
|
1161
1204
|
if (call.vet !== undefined && !codes.some((code) => code !== null)) {
|
|
1162
|
-
(await call.vet(d.tx)).forEach((code, i) => (codes[i] = code));
|
|
1205
|
+
(await call.vet(d.tx, d.reads)).forEach((code, i) => (codes[i] = code));
|
|
1163
1206
|
}
|
|
1164
1207
|
// A sensitive value is only ever used for a person, never handed back, by whatever credential of theirs asks. And
|
|
1165
1208
|
// a fork copies one, for anyone, a service account too, only where its app says it keeps it sensitive: an app of
|
|
@@ -1172,7 +1215,7 @@ class VaultService implements Vault {
|
|
|
1172
1215
|
});
|
|
1173
1216
|
}
|
|
1174
1217
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1175
|
-
if (first === null && action === 'secret.read' &&
|
|
1218
|
+
if (first === null && action === 'secret.read' && this.#overBulkLimit(d)) first = 'bulk_limit';
|
|
1176
1219
|
if (first !== null) {
|
|
1177
1220
|
throw new Refused(
|
|
1178
1221
|
refusal(first, MESSAGES[first]),
|
|
@@ -1181,6 +1224,12 @@ class VaultService implements Vault {
|
|
|
1181
1224
|
}
|
|
1182
1225
|
};
|
|
1183
1226
|
|
|
1227
|
+
// Every decision below reads the places it is about with the reader's standing; a read, the reader's releases too.
|
|
1228
|
+
const reads: Reads = {
|
|
1229
|
+
environments: [...secrets, ...secrets.map((secret, i) => call.places?.[i] ?? secret)].map((place) => place.environmentId),
|
|
1230
|
+
references: call.references ?? [],
|
|
1231
|
+
...(action === 'secret.read' ? { released: { principal, keys: secrets.map((secret) => secret.secretId) } } : {}),
|
|
1232
|
+
};
|
|
1184
1233
|
// The intent's own identity; `operationId` is the app's, for the whole action.
|
|
1185
1234
|
const intentId = call.remote ? randomUUID() : null;
|
|
1186
1235
|
let intentSeq: bigint | null = null;
|
|
@@ -1200,7 +1249,7 @@ class VaultService implements Vault {
|
|
|
1200
1249
|
if (call.remote) {
|
|
1201
1250
|
const intent = await this.#decide([principal], async (d) => {
|
|
1202
1251
|
await check(d);
|
|
1203
|
-
const at =
|
|
1252
|
+
const at = d.at;
|
|
1204
1253
|
const appended = await this.#append(d.tx, [{
|
|
1205
1254
|
actor: principal,
|
|
1206
1255
|
action: 'key.intent',
|
|
@@ -1216,14 +1265,19 @@ class VaultService implements Vault {
|
|
|
1216
1265
|
...traced(call.input),
|
|
1217
1266
|
keys: secrets.map((secret, item) => ({ item, subject: secret.path, secretId: secret.secretId, version: secret.version })),
|
|
1218
1267
|
}),
|
|
1219
|
-
}]);
|
|
1268
|
+
}], [], d.reads);
|
|
1220
1269
|
return { seq: appended.seqStart };
|
|
1221
|
-
});
|
|
1270
|
+
}, false, reads);
|
|
1222
1271
|
if (!intent.ok) return intent;
|
|
1223
1272
|
intentSeq = intent.seq;
|
|
1224
1273
|
}
|
|
1225
1274
|
|
|
1226
|
-
|
|
1275
|
+
// Under the reader's row: the check, the key operations, and their outcome. With a key in memory, under the
|
|
1276
|
+
// log's head too, from the start (`#decideOnce`): what the check read stands until the commit, as every change it
|
|
1277
|
+
// reads appends under the head, and the head waits on nothing slow. With a key service, which nothing may wait on
|
|
1278
|
+
// holding the head, the head is taken once the keys are open, and what the check read is read again under it.
|
|
1279
|
+
const late = call.remote;
|
|
1280
|
+
const decided = async (d: Decision) => {
|
|
1227
1281
|
try {
|
|
1228
1282
|
await check(d);
|
|
1229
1283
|
} catch (error) {
|
|
@@ -1233,11 +1287,14 @@ class VaultService implements Vault {
|
|
|
1233
1287
|
}
|
|
1234
1288
|
throw error;
|
|
1235
1289
|
}
|
|
1290
|
+
};
|
|
1291
|
+
return this.#decide([principal], async (d) => {
|
|
1292
|
+
await decided(d);
|
|
1236
1293
|
// A reference ended while this decided refuses it, rather than a read logged after its end.
|
|
1237
1294
|
const { vet } = call;
|
|
1238
|
-
if (vet !== undefined) {
|
|
1295
|
+
if (late && vet !== undefined) {
|
|
1239
1296
|
d.underLock.push(async (locked) => {
|
|
1240
|
-
const codes = await vet(locked);
|
|
1297
|
+
const codes = await vet(locked, d.reads);
|
|
1241
1298
|
const first = codes.find((code) => code !== null);
|
|
1242
1299
|
if (first === undefined || first === null) return;
|
|
1243
1300
|
throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
|
|
@@ -1262,25 +1319,20 @@ class VaultService implements Vault {
|
|
|
1262
1319
|
if (done.length < outcomes.length) {
|
|
1263
1320
|
throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
|
|
1264
1321
|
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1322
|
+
if (late) {
|
|
1323
|
+
// Again under the log's head, the append's, taken a statement early: a
|
|
1324
|
+
// deletion renames its place under it, so one that committed since the
|
|
1325
|
+
// check above is seen here, and nothing is released after it.
|
|
1326
|
+
// A read through a reference: its holder's place too, which a deletion may have taken.
|
|
1327
|
+
// And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
|
|
1328
|
+
// since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
|
|
1329
|
+
d.reads = await this.#snapshot(d.tx, [principal], reads, true);
|
|
1330
|
+
d.at = this.#at(d.reads);
|
|
1331
|
+
await decided(d);
|
|
1332
|
+
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1333
|
+
if (places.some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) {
|
|
1334
|
+
throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
|
|
1277
1335
|
}
|
|
1278
|
-
throw error;
|
|
1279
|
-
}
|
|
1280
|
-
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1281
|
-
const now = await store.environmentsById(d.tx, places.map((place) => place.environmentId));
|
|
1282
|
-
if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) {
|
|
1283
|
-
throw new Refused(refusal('deleted', MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, 'deny', 'deleted')));
|
|
1284
1336
|
}
|
|
1285
1337
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
|
|
1286
1338
|
d.log.push(...released);
|
|
@@ -1291,13 +1343,17 @@ class VaultService implements Vault {
|
|
|
1291
1343
|
} finally {
|
|
1292
1344
|
for (const value of done) value.wipe();
|
|
1293
1345
|
}
|
|
1294
|
-
},
|
|
1346
|
+
}, late, reads);
|
|
1295
1347
|
}
|
|
1296
1348
|
|
|
1297
|
-
/**
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1349
|
+
/**
|
|
1350
|
+
* Whether this read's keys would take the reader past the bulk limit: the
|
|
1351
|
+
* distinct keys released to them in the window, these among them. A key
|
|
1352
|
+
* read again exposes nothing new, so it counts once, whatever its version.
|
|
1353
|
+
* Counted under their row's lock, so exactly (`Reads`).
|
|
1354
|
+
*/
|
|
1355
|
+
#overBulkLimit(d: Decision): boolean {
|
|
1356
|
+
return d.reads.released > this.#config.bulkLimit.count;
|
|
1301
1357
|
}
|
|
1302
1358
|
|
|
1303
1359
|
// --- references ---------------------------------------------------------------
|
|
@@ -1349,7 +1405,7 @@ class VaultService implements Vault {
|
|
|
1349
1405
|
return this.#decide([principal], async (d) => {
|
|
1350
1406
|
// Under the log's head, which a deletion and every change to the policy take: neither side deleted, nor protected,
|
|
1351
1407
|
// nor a role narrowed, before this commits.
|
|
1352
|
-
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1408
|
+
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
1353
1409
|
// Each source's nature, its current version's, under the head: what its maker must hold there.
|
|
1354
1410
|
const natures = await store.natures(d.tx, [...sources.values()].map((source) => source.secretId));
|
|
1355
1411
|
// Both sides as the store has them: a grant on a slug matches there, and neither side may be deleted.
|
|
@@ -1436,7 +1492,7 @@ class VaultService implements Vault {
|
|
|
1436
1492
|
};
|
|
1437
1493
|
return this.#decide([principal], async (d) => {
|
|
1438
1494
|
// Under the log's head: two ends of one reference at once, the second finds the first; and the actor's standing stands.
|
|
1439
|
-
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
1495
|
+
const actor = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports, d.reads), input);
|
|
1440
1496
|
// Ending is allowed in a deleted place: deleting one ends the references into and out of it.
|
|
1441
1497
|
const environments = await store.environmentsById(d.tx, seals.map((seal) => seal.holder.environmentId));
|
|
1442
1498
|
const codes: (RefusalCode | null)[] = seals.map((seal) => {
|
|
@@ -1552,7 +1608,7 @@ class VaultService implements Vault {
|
|
|
1552
1608
|
*/
|
|
1553
1609
|
async #makeOwner(d: Decision, principal: string, reason: 'owners'): Promise<boolean> {
|
|
1554
1610
|
const row = d.members.get(principal);
|
|
1555
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1611
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1556
1612
|
if (subject.status === 'active' && ownsInstance(subject.live)) return false;
|
|
1557
1613
|
const tampered = subject.status === 'tampered';
|
|
1558
1614
|
if (tampered && (await this.#logged(d.tx, principal)) === undefined) return false;
|
|
@@ -1584,17 +1640,17 @@ class VaultService implements Vault {
|
|
|
1584
1640
|
* What `principal` holds, their row checked first (`#integrity`): a row
|
|
1585
1641
|
* that fails holds nothing, and is `tampered`.
|
|
1586
1642
|
*/
|
|
1587
|
-
async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[]): Promise<Standing> {
|
|
1643
|
+
async #standing(db: Queryable, principal: string, row: Member | undefined, at: number, reports: NewEntry[], reads?: Snapshot): Promise<Standing> {
|
|
1588
1644
|
const none = NOTHING;
|
|
1589
|
-
const grants = row === undefined ? [] : await store.grants(db, principal);
|
|
1590
|
-
const fault = await this.#integrity(db, principal, row, grants, reports);
|
|
1645
|
+
const grants = row === undefined ? [] : reads?.grants.get(principal) ?? (await store.grants(db, principal));
|
|
1646
|
+
const fault = await this.#integrity(db, principal, row, grants, reports, reads);
|
|
1591
1647
|
if (fault !== null) return { principal, status: 'tampered', live: none, all: none, fault, stored: grants };
|
|
1592
1648
|
if (!PRINCIPAL.test(principal)) return { principal, status: 'unknown', live: none, all: none, fault, stored: grants };
|
|
1593
1649
|
if (row?.status !== 'active') return { principal, status: row?.status ?? 'unknown', live: none, all: none, fault, stored: grants };
|
|
1594
1650
|
const held = grants.map((grant) => ({ ...grant, role: grant.role as Role }));
|
|
1595
1651
|
const { role, scope } = roleOf(principal, row);
|
|
1596
1652
|
// 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);
|
|
1653
|
+
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
1598
1654
|
const service = !principal.startsWith('user:');
|
|
1599
1655
|
return {
|
|
1600
1656
|
principal,
|
|
@@ -1642,9 +1698,10 @@ class VaultService implements Vault {
|
|
|
1642
1698
|
/** `access`, as the rows are now. */
|
|
1643
1699
|
async #read(principal: string, superseded: string | null): Promise<Access> {
|
|
1644
1700
|
const read = async (db: Queryable, reports: NewEntry[]) => {
|
|
1645
|
-
const
|
|
1646
|
-
const
|
|
1647
|
-
const
|
|
1701
|
+
const reads = await snapshot(db, { principals: [principal], members: true });
|
|
1702
|
+
const [row, held, at] = [reads.members.get(principal), reads.grants.get(principal)!, this.#at(reads)];
|
|
1703
|
+
const fault = await this.#integrity(db, principal, row, held, reports, reads);
|
|
1704
|
+
const { policy } = await this.#policyAt(db, reports, at, reads);
|
|
1648
1705
|
return this.#access(principal, row, held, at, fault !== null, policy);
|
|
1649
1706
|
};
|
|
1650
1707
|
const found: NewEntry[] = [];
|
|
@@ -1724,7 +1781,7 @@ class VaultService implements Vault {
|
|
|
1724
1781
|
// renames the place, so the place is as it commits.
|
|
1725
1782
|
// The roles as the policy defines them now, under the head every change to it takes: a role an Owner made is
|
|
1726
1783
|
// 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);
|
|
1784
|
+
const { policy } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
|
|
1728
1785
|
for (const change of changes) {
|
|
1729
1786
|
if (change.role !== null && !isRole(change.role, policy)) throw refused('invalid', `no such role: ${change.role}`);
|
|
1730
1787
|
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) {
|
|
@@ -1748,7 +1805,7 @@ class VaultService implements Vault {
|
|
|
1748
1805
|
// An environment by its slug as the store has it, which scopes match.
|
|
1749
1806
|
return environmentId === null ? { projectId: projectId! } : { projectId: projectId!, environmentId, environmentSlug: environment!.slug };
|
|
1750
1807
|
});
|
|
1751
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1808
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
1752
1809
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1753
1810
|
// Where they manage access, anything; otherwise only a person to a service account (`givesService`).
|
|
1754
1811
|
const manages = where.every((place) => mayManageAccess(acting.live, place));
|
|
@@ -1759,7 +1816,7 @@ class VaultService implements Vault {
|
|
|
1759
1816
|
}
|
|
1760
1817
|
|
|
1761
1818
|
const row = d.members.get(principal);
|
|
1762
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1819
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1763
1820
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1764
1821
|
// A service account admitted with its first grants, as `admit` would: all of it, or none.
|
|
1765
1822
|
const admitted = input.admit === true && row?.status !== 'active' ? await this.#admitWith(d, acting, principal, row, input) : undefined;
|
|
@@ -1859,7 +1916,7 @@ class VaultService implements Vault {
|
|
|
1859
1916
|
]);
|
|
1860
1917
|
return this.#decide([actor, principal], async (d) => {
|
|
1861
1918
|
validateCorrelation(input);
|
|
1862
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
1919
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
1863
1920
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1864
1921
|
// A service account, with no grant yet, any person who could give it one sets up (`setsUpServices`).
|
|
1865
1922
|
const service = SERVICE.test(principal) && (input.role ?? 'member') === 'member' && input.scope === undefined;
|
|
@@ -1872,7 +1929,7 @@ class VaultService implements Vault {
|
|
|
1872
1929
|
throw refused('invalid', 'service accounts hold project grants only, never an instance role');
|
|
1873
1930
|
}
|
|
1874
1931
|
const row = d.members.get(principal);
|
|
1875
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1932
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1876
1933
|
if (subject.status === 'tampered') throw refused('tampered', TAMPERED_SUBJECT);
|
|
1877
1934
|
d.touched.add(principal);
|
|
1878
1935
|
d.grants.set(principal, [...subject.stored]);
|
|
@@ -1964,10 +2021,10 @@ class VaultService implements Vault {
|
|
|
1964
2021
|
validateCorrelation(input);
|
|
1965
2022
|
if (!PRINCIPAL.test(principal)) throw refused('invalid', `not a member: ${shownMember(principal)}`);
|
|
1966
2023
|
const row = d.members.get(principal);
|
|
1967
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2024
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
1968
2025
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
1969
2026
|
const holder = acting.live;
|
|
1970
|
-
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
2027
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports, d.reads);
|
|
1971
2028
|
const held = subject.stored;
|
|
1972
2029
|
if (this.#listed(principal)) throw refused('not_allowed', declaredOwner(principal));
|
|
1973
2030
|
if (subject.status === 'tampered') {
|
|
@@ -2052,7 +2109,7 @@ class VaultService implements Vault {
|
|
|
2052
2109
|
*/
|
|
2053
2110
|
async #settingsAt(db: Queryable, reports: NewEntry[]): Promise<Settings> {
|
|
2054
2111
|
const entry = await newestAuthentic(
|
|
2055
|
-
(before) => store.newestVaultEntries(db, SETTINGS,
|
|
2112
|
+
(limit, before) => store.newestVaultEntries(db, SETTINGS, limit, before),
|
|
2056
2113
|
this.#check(reports, null),
|
|
2057
2114
|
);
|
|
2058
2115
|
if (entry === 'none') return DEFAULT_SETTINGS;
|
|
@@ -2079,14 +2136,19 @@ class VaultService implements Vault {
|
|
|
2079
2136
|
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
2080
2137
|
* could unprotect them. Its edits are then unknown, not none.
|
|
2081
2138
|
*/
|
|
2082
|
-
async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
|
|
2139
|
+
async #policyAt(db: Queryable, reports: NewEntry[], at: number, reads?: Snapshot): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
|
|
2140
|
+
// The snapshot's newest first, with the rows it protects; the pages before it, as they are asked.
|
|
2083
2141
|
const entry = await newestAuthentic(
|
|
2084
|
-
(before) =>
|
|
2142
|
+
(limit, before) => (reads !== undefined && before === undefined
|
|
2143
|
+
? Promise.resolve(reads.newestPolicy === null ? [] : [reads.newestPolicy])
|
|
2144
|
+
: store.newestVaultEntries(db, POLICY, limit, before)),
|
|
2085
2145
|
this.#check(reports, null),
|
|
2086
2146
|
);
|
|
2087
2147
|
if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
|
|
2088
2148
|
const edits = typeof entry === 'string' ? null : readEdits(JSON.parse(entry.metadata) as { policy?: unknown });
|
|
2089
|
-
|
|
2149
|
+
if (edits === null) return { policy: CLOSED_POLICY, edits: NO_EDITS, readable: false };
|
|
2150
|
+
const rows = entry === reads?.newestPolicy ? reads.environments : await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
2151
|
+
return { policy: livePolicy(rows, edits, at), edits, readable: true };
|
|
2090
2152
|
}
|
|
2091
2153
|
|
|
2092
2154
|
changePolicy(input: ChangePolicyInput): Promise<Outcome<{ policy: Policy; seq: number | null }>> {
|
|
@@ -2109,14 +2171,15 @@ class VaultService implements Vault {
|
|
|
2109
2171
|
new Refused(refusal(code, message), [entry('deny', { change: describeChange(change) }, code)]);
|
|
2110
2172
|
return this.#decide([actor], async (d) => {
|
|
2111
2173
|
validateCorrelation(input);
|
|
2112
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2174
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2113
2175
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
2114
2176
|
if (acting.status !== 'active') throw refused(acting.status === 'removed' ? 'removed' : 'not_a_member');
|
|
2115
2177
|
// Under the log's head, as the actor's standing above: two changes at once each start from the one before. One the
|
|
2116
2178
|
// caller weighed from a revision of the policy, its newest change and its protected environments' rows, starts
|
|
2117
2179
|
// from that one, or is refused, to be asked again: a protected prod renamed since is a policy changed too.
|
|
2118
|
-
|
|
2119
|
-
const
|
|
2180
|
+
// The newest change in the vault's name, read with the decision, as the app reads it: by its number, unchecked.
|
|
2181
|
+
const newest = d.reads.newestPolicy?.seq ?? null;
|
|
2182
|
+
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at, d.reads);
|
|
2120
2183
|
if (revision !== undefined && policyRevision(newest, before) !== revision) throw refused('policy_changed');
|
|
2121
2184
|
const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
|
|
2122
2185
|
// Unreadable, its edits are unknown: a change made from none would erase every protection and role edit the forged
|
|
@@ -2188,7 +2251,7 @@ class VaultService implements Vault {
|
|
|
2188
2251
|
throw refused('invalid', 'a change is to a role, an instance role, or protection');
|
|
2189
2252
|
}
|
|
2190
2253
|
// The policy now, and the newest change, which it is as of: this one's, when it changed anything.
|
|
2191
|
-
const answer = { policy: await
|
|
2254
|
+
const answer = { policy: livePolicy(await store.environmentsById(d.tx, next.protected.map((place) => place.environmentId)), next, d.at), seq: newest === null ? null : Number(newest) };
|
|
2192
2255
|
if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
|
|
2193
2256
|
const made = entry('allow', { policy: next, change: describeChange(change), before: describeBefore(change, before) });
|
|
2194
2257
|
d.log.push(made);
|
|
@@ -2213,7 +2276,7 @@ class VaultService implements Vault {
|
|
|
2213
2276
|
new Refused(refusal(code, message), [entry('deny', { serviceAccounts: input.settings?.serviceAccounts ?? null }, code)]);
|
|
2214
2277
|
return this.#decide([actor], async (d) => {
|
|
2215
2278
|
validateCorrelation(input);
|
|
2216
|
-
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
2279
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports, d.reads), input);
|
|
2217
2280
|
if (acting.status === 'tampered') throw refused('tampered');
|
|
2218
2281
|
if (!runsInstance(acting.live)) throw refused('not_allowed', "only admins and owners of the whole instance change its settings");
|
|
2219
2282
|
if (!isScope(input.settings?.serviceAccounts)) throw refused('invalid', 'who sets up service accounts is a scope: { projects, environments }, each all, only or except');
|
|
@@ -2363,73 +2426,137 @@ class VaultService implements Vault {
|
|
|
2363
2426
|
|
|
2364
2427
|
// --- checkpoints and the log --------------------------------------------------
|
|
2365
2428
|
|
|
2366
|
-
/**
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2429
|
+
/**
|
|
2430
|
+
* Where a walk of the log resumes: the first entry (`start`), a
|
|
2431
|
+
* checkpoint's entry that keeps a tally, or the newest such entry, the
|
|
2432
|
+
* first entry while there is none. The entry must be the vault's, by its
|
|
2433
|
+
* MAC, and its tally whole: the members and grants it keeps, or the
|
|
2434
|
+
* earlier checkpoint it names keeps, match the digest it carries.
|
|
2435
|
+
*
|
|
2436
|
+
* Its signature is left to the walk: the entry comes after the prefix it
|
|
2437
|
+
* signs, so a walk from there reads it, as it reads every other.
|
|
2438
|
+
*/
|
|
2439
|
+
async #resume(db: Queryable, from: 'start' | 'newest' | bigint): Promise<Resumed | Broken> {
|
|
2440
|
+
if (from === 'start') return FIRST_ENTRY;
|
|
2441
|
+
const entry = from === 'newest' ? await store.latestTallied(db) : (await store.entriesAt(db, [from])).get(from);
|
|
2442
|
+
if (entry === undefined) return from === 'newest' ? FIRST_ENTRY : { ok: false, failedAtSeq: Number(from), reason: 'no checkpoint at this entry' };
|
|
2443
|
+
return (await this.#resumeEach(db, [entry]))[0]!;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/** `#resume` for each of these checkpoint entries, the entries their members and grants are kept in read at once. */
|
|
2447
|
+
async #resumeEach(db: Queryable, entries: readonly StoredEntry[]): Promise<(Resumed | Broken)[]> {
|
|
2448
|
+
const reads = entries.map((entry) => this.#tallied(entry));
|
|
2449
|
+
const named = reads.flatMap((read) => (typeof read !== 'string' && 'at' in read.tally.access ? [BigInt(read.tally.access.at)] : []));
|
|
2450
|
+
const carriers = new Map([...(await store.entriesAt(db, named))].map(([seq, held]) => [seq, this.#tallied(held)]));
|
|
2451
|
+
return entries.map((entry, i): Resumed | Broken => {
|
|
2452
|
+
const broken = (reason: string): Broken => ({ ok: false, failedAtSeq: Number(entry.seq), reason });
|
|
2453
|
+
const read = reads[i]!;
|
|
2454
|
+
if (typeof read === 'string') return broken(read);
|
|
2455
|
+
let carrier = entry.seq;
|
|
2456
|
+
let { access } = read.tally;
|
|
2457
|
+
if ('at' in access) {
|
|
2458
|
+
carrier = BigInt(access.at);
|
|
2459
|
+
const holder = carrier < entry.seq ? carriers.get(carrier) : undefined;
|
|
2460
|
+
if (holder === undefined || typeof holder === 'string' || 'at' in holder.tally.access) {
|
|
2461
|
+
return broken(`its tally keeps its members and grants in entry ${carrier}, which holds none`);
|
|
2462
|
+
}
|
|
2463
|
+
access = holder.tally.access;
|
|
2464
|
+
}
|
|
2465
|
+
const kept = { members: access.members, grants: access.grants, newest: access.newest };
|
|
2466
|
+
if (keptDigest(access) !== read.tally.access.digest) return broken("its tally's members and grants do not match their digest");
|
|
2467
|
+
return {
|
|
2468
|
+
ok: true,
|
|
2469
|
+
entry: entry.seq,
|
|
2470
|
+
at: { seq: BigInt(read.seq), hash: Buffer.from(read.hash, 'hex') },
|
|
2471
|
+
tally: { ...read.tally, access: kept },
|
|
2472
|
+
digest: read.tally.access.digest,
|
|
2473
|
+
carrier,
|
|
2474
|
+
scrub: read.scrub === null ? null : { at: positionOf(read.scrub.at), end: positionOf(read.scrub.end) },
|
|
2475
|
+
};
|
|
2476
|
+
});
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
/** A checkpoint's entry that keeps a tally, as it says, or why it is not one the vault wrote. */
|
|
2480
|
+
#tallied(entry: StoredEntry): TalliedCheckpoint | string {
|
|
2481
|
+
const not = "not a checkpoint of the vault's that keeps a tally";
|
|
2482
|
+
if (entry.author !== 'vault' || entry.action !== CHECKPOINT || entry.decision !== 'allow' || entry.relatedSeq === null) return not;
|
|
2483
|
+
const authentic = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: this.#keysFor(entry) });
|
|
2484
|
+
if (!authentic.ok) return withCause(authentic.reason);
|
|
2485
|
+
const read = metadataOf(entry) as Partial<TalliedCheckpoint> | null;
|
|
2486
|
+
if (read?.tally?.v !== TALLY_VERSION || read.scrub === undefined || typeof read.seq !== 'number' || BigInt(read.seq) !== entry.relatedSeq) return not;
|
|
2487
|
+
return read as TalliedCheckpoint;
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
/** Which of `heads` the log still holds where they were, in one read. */
|
|
2491
|
+
async #holding(db: Queryable, heads: readonly LogHead[]): Promise<(head: LogHead) => boolean> {
|
|
2492
|
+
const hashes = await store.hashesAt(db, [...new Set(heads.filter((head) => head.seq >= 0).map((head) => BigInt(head.seq)))]);
|
|
2493
|
+
return (head) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
|
|
2370
2494
|
}
|
|
2371
2495
|
|
|
2372
2496
|
/**
|
|
2373
|
-
*
|
|
2374
|
-
* its first entry at the first checkpoint of each hour, and otherwise from
|
|
2375
|
-
* the prefix the last checkpoint signed, if that one was signed this hour.
|
|
2497
|
+
* Sign the log up to its last entry, from the newest checkpoint's tally.
|
|
2376
2498
|
*
|
|
2377
|
-
*
|
|
2378
|
-
*
|
|
2379
|
-
*
|
|
2380
|
-
*
|
|
2381
|
-
*
|
|
2499
|
+
* In one snapshot, and without the log's lock, so that no append waits on
|
|
2500
|
+
* it: every member's row checked, since rows changed around the vault
|
|
2501
|
+
* write nothing to the log; the prefix the newest checkpoint signed still
|
|
2502
|
+
* there, its hash where it was, which a rewrite chained again changes, and
|
|
2503
|
+
* every entry of it, which a cut changes; the chain recomputed from there,
|
|
2504
|
+
* every hash from content and every vault entry by its MAC, no further
|
|
2505
|
+
* than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
|
|
2506
|
+
* before rehashed (`#scrub`). Then, under the lock, the entries appended
|
|
2507
|
+
* since the snapshot, so that it signs the head.
|
|
2382
2508
|
*
|
|
2383
|
-
*
|
|
2384
|
-
*
|
|
2385
|
-
*
|
|
2386
|
-
*
|
|
2387
|
-
* prefix is, which a cut changes. Either failing, it recomputes from entry
|
|
2388
|
-
* 0, which names what broke. An entry edited in place, its hash left as
|
|
2389
|
-
* it was, is the one change left to the next full pass, within the hour.
|
|
2390
|
-
*
|
|
2391
|
-
* It resumes only from a checkpoint under the key the vault writes with
|
|
2392
|
-
* now: once the log reaches that key, no other writes again (`forward`).
|
|
2393
|
-
* After a rotation, the next pass is full.
|
|
2509
|
+
* A walk cut short signs where it got to, behind the head: the log after
|
|
2510
|
+
* an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
|
|
2511
|
+
* from the first entry, one walk a call, and `/readyz` stays red until the
|
|
2512
|
+
* checkpoints reach the newest heartbeat.
|
|
2394
2513
|
*/
|
|
2395
|
-
async
|
|
2396
|
-
const { logKeys } = this.#prepared;
|
|
2397
|
-
const latest = await this.#latest(tx);
|
|
2398
|
-
const hour = (ms: number) => Math.floor(ms / FULL_RECOMPUTE_MS);
|
|
2399
|
-
const resumable =
|
|
2400
|
-
latest !== null &&
|
|
2401
|
-
latest.keyId === logKeys[0].keyId &&
|
|
2402
|
-
latest.seq === BigInt(latest.checkpoint.seq) + 1n &&
|
|
2403
|
-
hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) &&
|
|
2404
|
-
(await carries(tx, latest.checkpoint)) &&
|
|
2405
|
-
(await store.complete(tx, BigInt(latest.checkpoint.seq)));
|
|
2406
|
-
if (!resumable) return verifyChain(tx, logKeys, [], UNVERIFIED);
|
|
2407
|
-
const { seq, hash } = latest.checkpoint;
|
|
2408
|
-
const signed: Anchor = { nextSeq: BigInt(seq) + 1n, hash: Buffer.from(hash, 'hex'), vaultEntries: 0, vaultKeys: [logKeys[0].keyId] };
|
|
2409
|
-
return verifyChain(tx, logKeys, [], signed);
|
|
2410
|
-
}
|
|
2411
|
-
|
|
2412
|
-
async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
|
|
2514
|
+
async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint; warning?: string }>> {
|
|
2413
2515
|
await this.#settled();
|
|
2414
2516
|
// A KEK found wrong turns readiness red: the checkpoint is refused. It does no key work of its
|
|
2415
2517
|
// own, so it writes nothing more and never waits on a key service; a check under way, or none yet, is no verdict.
|
|
2416
|
-
const { wrongKek } = this.#prepared;
|
|
2417
|
-
|
|
2418
|
-
// waits on it: every member's row checked, since rows changed around the
|
|
2419
|
-
// vault write nothing to the log; and the chain recomputed, every hash
|
|
2420
|
-
// from content and every vault entry by its MAC, from the first entry
|
|
2421
|
-
// once an hour, since an entry cut from the middle leaves the hashes
|
|
2422
|
-
// around a later checkpoint as they were (`#recompute`).
|
|
2518
|
+
const { wrongKek, logKeys } = this.#prepared;
|
|
2519
|
+
const { walks } = this.#prepared.options;
|
|
2423
2520
|
const found: NewEntry[] = [];
|
|
2424
|
-
|
|
2521
|
+
// The newest checkpoint entry, signed or refused, as this call's snapshot found it.
|
|
2522
|
+
let seen: bigint | null = null;
|
|
2523
|
+
const walked = await this.#db.transaction(async (tx) => {
|
|
2524
|
+
seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
|
|
2425
2525
|
await this.#sweep(tx, found);
|
|
2426
|
-
|
|
2526
|
+
const from = await this.#resume(tx, 'newest');
|
|
2527
|
+
if (!from.ok) return from;
|
|
2528
|
+
const signed = headOf(from.at);
|
|
2529
|
+
if (!(await this.#holding(tx, [signed]))(signed)) {
|
|
2530
|
+
return { ok: false as const, failedAtSeq: null, reason: `the log up to entry ${signed.seq} is not the prefix the last checkpoint signed` };
|
|
2531
|
+
}
|
|
2532
|
+
const gap = await missingEntry(tx, from.at.seq);
|
|
2533
|
+
if (gap !== null) return { ok: false as const, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
|
|
2534
|
+
const walk = new Walk(from.tally, from.digest);
|
|
2535
|
+
const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
|
|
2536
|
+
if (!chain.ok) return chain;
|
|
2537
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
|
|
2538
|
+
if (unsigned !== null) return unsigned;
|
|
2539
|
+
return { ok: true as const, from, tally: walk.tally(), digest: walk.digest(), at: chain.at, done: chain.done };
|
|
2427
2540
|
}, SNAPSHOT);
|
|
2541
|
+
// The past rehashed, in a snapshot of its own. The database failing it leaves the stretch for the
|
|
2542
|
+
// next call, where it was, and the checkpoint is signed all the same, saying why (`warning`); a bug
|
|
2543
|
+
// throws, as anywhere else.
|
|
2544
|
+
let scrub: Scrubbed = { ok: true, next: walked.ok ? walked.from.scrub : null };
|
|
2545
|
+
let stalled: string | null = null;
|
|
2546
|
+
if (walked.ok) {
|
|
2547
|
+
try {
|
|
2548
|
+
scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
|
|
2549
|
+
} catch (error) {
|
|
2550
|
+
// Only the database not answering, or a statement it gave up on: anything else is coffre's to fix.
|
|
2551
|
+
if (!isUnreachable(error) && !timedOut(error)) throw error;
|
|
2552
|
+
stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2428
2555
|
return this.#decide([], async (d) => {
|
|
2429
2556
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
2430
2557
|
// Checkpoints one at a time, each against the one before.
|
|
2431
2558
|
const head = await lockLogHead(d.tx);
|
|
2432
|
-
const latest = await
|
|
2559
|
+
const latest = await store.latestVaultEntry(d.tx, [CHECKPOINT]);
|
|
2433
2560
|
const refused = (code: RefusalCode, detail: Record<string, unknown>, message = MESSAGES[code]) =>
|
|
2434
2561
|
new Refused(refusal(code, message), [
|
|
2435
2562
|
{ actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
|
|
@@ -2437,33 +2564,104 @@ class VaultService implements Vault {
|
|
|
2437
2564
|
// Logging the refusal would give the next call something to sign.
|
|
2438
2565
|
if (head.nextSeq === 0n) throw new Refused(refusal('invalid', 'the log is empty'), []);
|
|
2439
2566
|
if (wrongKek !== null) throw refused('wrong_kek', { reason: wrongKek }, wrongKek);
|
|
2440
|
-
if (!
|
|
2441
|
-
|
|
2567
|
+
if (!walked.ok) throw refused('log_broken', { failedAtSeq: walked.failedAtSeq, reason: walked.reason });
|
|
2568
|
+
if (!scrub.ok) throw refused('log_broken', { failedAtSeq: scrub.failedAtSeq, reason: scrub.reason });
|
|
2569
|
+
const newest = latest === undefined ? null : (JSON.parse(latest.metadata) as Checkpoint);
|
|
2570
|
+
// Another call checkpointed, or refused to, since this one's snapshot, or this one's rehash of the past
|
|
2571
|
+
// could not run: what it would sign over is not what it checked. A refusal for a broken log stands,
|
|
2572
|
+
// and a checkpoint signed meanwhile is the newest prefix; the next call starts from them.
|
|
2573
|
+
if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
|
|
2574
|
+
const standing = await this.#standingRefusal(d.tx);
|
|
2575
|
+
if (standing !== null) throw refused('log_broken', standing);
|
|
2576
|
+
if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
|
|
2442
2577
|
}
|
|
2443
|
-
// Nothing since the last one: it is still the newest prefix.
|
|
2444
|
-
if (
|
|
2578
|
+
// Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
|
|
2579
|
+
if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
|
|
2445
2580
|
// Under the lock, the rest: the prefix signed last still where it was,
|
|
2446
|
-
// and every entry since the snapshot
|
|
2447
|
-
|
|
2448
|
-
|
|
2581
|
+
// and every entry since the snapshot, unless the walk is catching up,
|
|
2582
|
+
// when what it walked must still be there. A rewrite is never signed over.
|
|
2583
|
+
if (newest !== null && !(await carries(d.tx, newest))) {
|
|
2584
|
+
throw refused('log_broken', { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
|
|
2449
2585
|
}
|
|
2450
|
-
const
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2586
|
+
const walk = new Walk(walked.tally, walked.digest);
|
|
2587
|
+
let { at } = walked;
|
|
2588
|
+
if (walked.done) {
|
|
2589
|
+
// Where the snapshot's walk ended still has the hash it found there: what follows links to it.
|
|
2590
|
+
if (!(await carries(d.tx, headOf(at)))) {
|
|
2591
|
+
throw refused('log_broken', { failedAtSeq: Number(at.seq), reason: `the log up to entry ${at.seq} changed while the vault walked it` });
|
|
2592
|
+
}
|
|
2593
|
+
const rest = await rehash(d.tx, logKeys, at, walk, {});
|
|
2594
|
+
if (!rest.ok) throw refused('log_broken', { failedAtSeq: rest.failedAtSeq, reason: rest.reason });
|
|
2595
|
+
const unsigned = await this.#checkpointFault(d.tx, walk.checkpoints);
|
|
2596
|
+
if (unsigned !== null) throw refused('log_broken', { failedAtSeq: unsigned.failedAtSeq, reason: unsigned.reason });
|
|
2597
|
+
at = rest.at;
|
|
2598
|
+
// It signs the entry it verified to, which the head, locked, must name.
|
|
2599
|
+
if (at.seq !== head.nextSeq - 1n || !at.hash.equals(head.headHash)) {
|
|
2600
|
+
throw refused('log_broken', { reason: 'the chain head does not name the last entry' });
|
|
2601
|
+
}
|
|
2602
|
+
} else if (!(await carries(d.tx, headOf(at)))) {
|
|
2603
|
+
throw refused('log_broken', { reason: `the log up to entry ${at.seq} changed while the vault walked it` });
|
|
2455
2604
|
}
|
|
2456
|
-
|
|
2605
|
+
// Another call signed as far, or further, while this one walked behind the head: that is the newest prefix, never one shorter.
|
|
2606
|
+
if (newest !== null && latest!.relatedSeq !== null && latest!.relatedSeq >= at.seq) return { checkpoint: signedPart(newest) };
|
|
2607
|
+
this.#prepared.verified = further(this.#prepared.verified, at);
|
|
2608
|
+
const tally = walk.tally();
|
|
2609
|
+
// Its members and grants once, in the first checkpoint after they change; the others name that one.
|
|
2610
|
+
const digest = walk.digest();
|
|
2611
|
+
const { from } = walked;
|
|
2612
|
+
const access = digest === from.digest && from.carrier !== null ? { digest, at: Number(from.carrier) } : { digest, ...tally.access };
|
|
2613
|
+
const signed = { seq: Number(at.seq), hash: at.hash.toString('hex'), signedAt: iso(d.at) };
|
|
2457
2614
|
const checkpoint = {
|
|
2458
2615
|
...signed,
|
|
2459
2616
|
keyId: this.#prepared.signer.keyId,
|
|
2460
2617
|
signature: await this.#prepared.signer.sign(checkpointMessage(signed)),
|
|
2461
2618
|
};
|
|
2462
|
-
|
|
2463
|
-
|
|
2619
|
+
const lap = scrub.next === null ? null : { at: headOf(scrub.next.at), end: headOf(scrub.next.end) };
|
|
2620
|
+
const kept: TalliedCheckpoint = { ...checkpoint, tally: { v: TALLY_VERSION, ...tally, access }, scrub: lap };
|
|
2621
|
+
d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
|
|
2622
|
+
return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
|
|
2464
2623
|
});
|
|
2465
2624
|
}
|
|
2466
2625
|
|
|
2626
|
+
/**
|
|
2627
|
+
* The prefixes signed before, rehashed a stretch at a time, in laps: a lap
|
|
2628
|
+
* runs from the first entry to the prefix the newest checkpoint signed
|
|
2629
|
+
* when it began, which it keeps (`end`), `walks.scrub` entries a call, each
|
|
2630
|
+
* hash recomputed from its entry and each link checked (`relink`); the
|
|
2631
|
+
* next lap runs to the prefix signed then. A checkpoint resumes from the
|
|
2632
|
+
* newest one's tally and never reads what it signed again, and an entry
|
|
2633
|
+
* before it edited in place, its stored hash left as it was, keeps the
|
|
2634
|
+
* chain: this is what finds it, within two laps at most, each the length
|
|
2635
|
+
* of the log when it began over `walks.scrub` beats, however fast the log
|
|
2636
|
+
* grows meanwhile. The lap as it goes on, null between laps.
|
|
2637
|
+
*/
|
|
2638
|
+
async #scrub(db: Queryable, newest: Resumed): Promise<Scrubbed> {
|
|
2639
|
+
const lap = newest.scrub ?? { at: START, end: newest.at };
|
|
2640
|
+
if (lap.end.seq < 0n) return { ok: true, next: null };
|
|
2641
|
+
const relinked = await relink(db, lap.at, lap.end.seq, this.#prepared.options.walks.scrub);
|
|
2642
|
+
if (!relinked.ok) return relinked;
|
|
2643
|
+
if (relinked.at.seq < lap.end.seq) return { ok: true, next: { at: relinked.at, end: lap.end } };
|
|
2644
|
+
// A lap ends at the prefix a checkpoint signed, or it was not the chain the checkpoints signed.
|
|
2645
|
+
if (!relinked.at.hash.equals(lap.end.hash)) {
|
|
2646
|
+
return { ok: false, failedAtSeq: Number(lap.end.seq), reason: `the log up to entry ${lap.end.seq} is not the prefix a checkpoint signed` };
|
|
2647
|
+
}
|
|
2648
|
+
return { ok: true, next: null };
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
/**
|
|
2652
|
+
* The newest refusal for a broken log since the newest checkpoint was
|
|
2653
|
+
* signed, by the vault's MAC, as it said: it stands, for every
|
|
2654
|
+
* verification and checkpoint, until a checkpoint is signed, which takes
|
|
2655
|
+
* the check that found it running again and passing. A refusal of
|
|
2656
|
+
* another kind since, a wrong KEK, hides none.
|
|
2657
|
+
*/
|
|
2658
|
+
async #standingRefusal(db: Queryable): Promise<{ failedAtSeq?: number | null; reason?: string } | null> {
|
|
2659
|
+
const signed = await store.latestVaultEntry(db, [CHECKPOINT]);
|
|
2660
|
+
const refused = await store.newestRefusal(db, CHECKPOINT, 'log_broken', signed?.seq ?? -1n);
|
|
2661
|
+
if (refused === undefined || !this.#authentic(refused)) return null;
|
|
2662
|
+
return JSON.parse(refused.metadata) as { failedAtSeq?: number | null; reason?: string };
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2467
2665
|
async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: [] }> {
|
|
2468
2666
|
await this.#settled();
|
|
2469
2667
|
const { signers, since } = this.#prepared;
|
|
@@ -2487,41 +2685,123 @@ class VaultService implements Vault {
|
|
|
2487
2685
|
return { current: { kekProvider: provider, kekId: keyId }, checks };
|
|
2488
2686
|
}
|
|
2489
2687
|
|
|
2490
|
-
verifyLog(input: VerifyLogInput): Promise<LogVerification> {
|
|
2491
|
-
return this.#verifyAll(input.upTo ?? null);
|
|
2492
|
-
}
|
|
2493
|
-
|
|
2494
2688
|
/**
|
|
2495
|
-
* The
|
|
2496
|
-
* each by its MAC, the keys it moved through, and what the
|
|
2497
|
-
* need of it
|
|
2498
|
-
* heads that must still be there,
|
|
2499
|
-
* members and grants
|
|
2689
|
+
* The log from a checkpoint's tally, in one snapshot: the vault's
|
|
2690
|
+
* entries since, each by its MAC, the keys it moved through, and what the
|
|
2691
|
+
* other checks need of it (`Walk`); then every checkpoint walked, the
|
|
2692
|
+
* heads that must still be there, and, for a walk that ends at the
|
|
2693
|
+
* present, the key batches accounted for and the members and grants
|
|
2694
|
+
* replayed against their rows.
|
|
2500
2695
|
*
|
|
2501
2696
|
* With `upTo`, the head the app verified to, the links and hashes of the
|
|
2502
|
-
* chain are the app's to recompute, as it just has, over its own
|
|
2503
|
-
* through that head; `
|
|
2504
|
-
* the vault reads only its own entries, each checked against its
|
|
2505
|
-
* and MAC over the link it names. Without `upTo`, nobody else
|
|
2506
|
-
* the chain: the vault reads it all, and rehashes every link
|
|
2697
|
+
* chain are the app's to recompute, as it just has, over its own
|
|
2698
|
+
* snapshot, through that head; holding `upTo` ties this snapshot to that
|
|
2699
|
+
* one. So the vault reads only its own entries, each checked against its
|
|
2700
|
+
* own hash and MAC over the link it names. Without `upTo`, nobody else
|
|
2701
|
+
* recomputed the chain: the vault reads it all, and rehashes every link,
|
|
2702
|
+
* no further than one walk (`walks.verify`).
|
|
2507
2703
|
*/
|
|
2508
|
-
async
|
|
2704
|
+
async verifyLog(input: VerifyLogInput): Promise<LogVerification> {
|
|
2509
2705
|
await this.#settled();
|
|
2510
|
-
|
|
2706
|
+
const upTo = input.upTo ?? null;
|
|
2707
|
+
const { logKeys } = this.#prepared;
|
|
2708
|
+
const { walks } = this.#prepared.options;
|
|
2709
|
+
return this.#db.transaction(async (tx): Promise<LogVerification> => {
|
|
2511
2710
|
const remembered = this.#prepared.verified;
|
|
2512
|
-
const
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
//
|
|
2516
|
-
const
|
|
2517
|
-
const
|
|
2518
|
-
if (
|
|
2519
|
-
|
|
2520
|
-
|
|
2711
|
+
const from = await this.#resume(tx, input.from === undefined ? 'newest' : input.from === 'start' ? 'start' : BigInt(input.from));
|
|
2712
|
+
if (!from.ok) return from;
|
|
2713
|
+
// Where a stretch ends, the last entry its checkpoint names: that checkpoint, and each it passes on the
|
|
2714
|
+
// way, are held to the walk as it gets there, so a fault on the way is named where it is.
|
|
2715
|
+
const to = input.to === undefined ? null : BigInt(input.to);
|
|
2716
|
+
const ends = to === null ? null : (await store.entriesAt(tx, [to])).get(to)?.relatedSeq ?? -1n;
|
|
2717
|
+
if (ends !== null && (ends <= from.at.seq || (upTo !== null && BigInt(upTo.seq) !== ends))) {
|
|
2718
|
+
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' };
|
|
2719
|
+
}
|
|
2720
|
+
// The prefix the walk starts from still there, as it was signed, and whole: a rewrite chained again
|
|
2721
|
+
// changes its hash, and a cut its count.
|
|
2722
|
+
if (!(await this.#holding(tx, [headOf(from.at)]))(headOf(from.at))) {
|
|
2723
|
+
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` };
|
|
2724
|
+
}
|
|
2725
|
+
const gap = await missingEntry(tx, from.at.seq);
|
|
2726
|
+
if (gap !== null) return { ok: false, failedAtSeq: Number(gap.found), reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}` };
|
|
2727
|
+
// Every checkpoint after it whose prefix the walk may pass, a stretch's end among them, signed while
|
|
2728
|
+
// the app walked or not: each tally is held to the walk where it gets there (`Walk.expect`).
|
|
2729
|
+
const walk = new Walk(from.tally, from.digest);
|
|
2730
|
+
const later = await store.talliedAfter(tx, from.entry ?? -1n, walks.verify);
|
|
2731
|
+
const passed = later.filter(({ seq }) => to === null || seq <= to);
|
|
2732
|
+
if (to !== null && passed.at(-1)?.seq !== to) {
|
|
2733
|
+
return { ok: false, failedAtSeq: Number(to), reason: `a stretch passes no more than ${walks.verify} checkpoints: end one sooner` };
|
|
2734
|
+
}
|
|
2735
|
+
// Each one's tally as what the walk must come to, a few at a time, so that members and grants kept whole
|
|
2736
|
+
// in many of them are never all in memory: the digest it keeps of them stands for them.
|
|
2737
|
+
// Each one's members and grants are as its digest says, kept whole in it or in the earlier checkpoint it
|
|
2738
|
+
// names, each such checkpoint read once: what a walk resuming from it would find.
|
|
2739
|
+
const holding = new Map<bigint, string | null>();
|
|
2740
|
+
const carried = async (carrier: bigint): Promise<string | null> => {
|
|
2741
|
+
if (!holding.has(carrier)) {
|
|
2742
|
+
const entry = (await store.entriesAt(tx, [carrier])).get(carrier);
|
|
2743
|
+
const read = entry === undefined ? 'missing' : this.#tallied(entry);
|
|
2744
|
+
holding.set(carrier, typeof read === 'string' || 'at' in read.tally.access ? null : keptDigest(read.tally.access));
|
|
2745
|
+
}
|
|
2746
|
+
return holding.get(carrier)!;
|
|
2747
|
+
};
|
|
2748
|
+
const expected: Expected[] = [];
|
|
2749
|
+
// The hash the stretch's own checkpoint signed, as it is read among the others.
|
|
2750
|
+
let endSigned: string | null = null;
|
|
2751
|
+
for (let after = from.entry ?? -1n, last = passed.at(-1)?.seq; last !== undefined && after < last; ) {
|
|
2752
|
+
const batch = await store.talliedWithin(tx, after, last, walks.verify + 1);
|
|
2753
|
+
if (batch.length === 0) break;
|
|
2754
|
+
for (const entry of batch) {
|
|
2755
|
+
const read = this.#tallied(entry);
|
|
2756
|
+
const point = { entry: entry.seq, through: entry.relatedSeq! };
|
|
2757
|
+
if (typeof read === 'string') {
|
|
2758
|
+
expected.push({ ...point, unreadable: read });
|
|
2759
|
+
continue;
|
|
2760
|
+
}
|
|
2761
|
+
if (entry.seq === to) endSigned = read.hash;
|
|
2762
|
+
const { access } = read.tally;
|
|
2763
|
+
if (!('at' in access)) holding.set(entry.seq, keptDigest(access));
|
|
2764
|
+
const kept = 'at' in access ? (BigInt(access.at) < entry.seq ? await carried(BigInt(access.at)) : null) : holding.get(entry.seq)!;
|
|
2765
|
+
expected.push(kept === access.digest
|
|
2766
|
+
? { ...point, key: expectedKey(read.tally) }
|
|
2767
|
+
: { ...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" });
|
|
2768
|
+
}
|
|
2769
|
+
after = batch.at(-1)!.seq;
|
|
2770
|
+
}
|
|
2771
|
+
walk.expect(expected);
|
|
2772
|
+
// How far the walk got, the hash there, and whether that is the present: the rows and the key batches
|
|
2773
|
+
// are held to the log only when the walk read every entry of the vault's there is.
|
|
2774
|
+
let through = from.at.seq;
|
|
2775
|
+
let reached = headOf(from.at).hash;
|
|
2776
|
+
let present = false;
|
|
2777
|
+
if (upTo === null) {
|
|
2778
|
+
const chain = await rehash(tx, logKeys, from.at, walk, ends === null ? { limit: walks.verify } : { to: ends });
|
|
2779
|
+
if (!chain.ok) return chain;
|
|
2780
|
+
[through, reached, present] = [chain.at.seq, headOf(chain.at).hash, chain.done && ends === null];
|
|
2781
|
+
this.#prepared.verified = further(this.#prepared.verified, chain.at);
|
|
2782
|
+
} else {
|
|
2783
|
+
// On past the app's head, to the vault's last entry in this snapshot: the rows are as they are at
|
|
2784
|
+
// this one's. The app's head bounds what is verified; the vault's own walk, what the rows meet.
|
|
2785
|
+
const own = await ownEntries(tx, logKeys, from.at.seq, walk, ends === null ? { limit: walks.verify } : { to: ends });
|
|
2786
|
+
if (!own.ok) return own;
|
|
2787
|
+
// 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.
|
|
2788
|
+
if (!own.done && input.from === undefined) {
|
|
2789
|
+
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` };
|
|
2790
|
+
}
|
|
2791
|
+
// Short of its own last entry, it has not held the rows to the log: never say it verified all the app did.
|
|
2792
|
+
const short = own.at < BigInt(upTo.seq) ? own.at : BigInt(upTo.seq) - 1n;
|
|
2793
|
+
[through, reached, present] = [own.done ? BigInt(upTo.seq) : short, upTo.hash, own.done && ends === null];
|
|
2794
|
+
}
|
|
2795
|
+
walk.finish(ends ?? through);
|
|
2796
|
+
// Each head the log must still hold, in one read: the app's, every
|
|
2797
|
+
// checkpoint's walked, and the last this vault rehashed.
|
|
2798
|
+
const rememberedHead = remembered.seq >= 0n ? headOf(remembered) : null;
|
|
2799
|
+
const heads = [...(upTo === null ? [] : [upTo]), ...(rememberedHead === null ? [] : [rememberedHead])];
|
|
2800
|
+
const holds = await this.#holding(tx, [...heads, ...walk.checkpoints.map(({ checkpoint }) => checkpoint)]);
|
|
2521
2801
|
if (upTo !== null && !holds(upTo)) {
|
|
2522
2802
|
return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
|
|
2523
2803
|
}
|
|
2524
|
-
const unsigned = await this.#checkpointFault(
|
|
2804
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
|
|
2525
2805
|
if (unsigned !== null) return unsigned;
|
|
2526
2806
|
// What this vault last verified must still be there: whoever holds
|
|
2527
2807
|
// its key can seal a rewrite, but cannot put back the head it saw. A
|
|
@@ -2529,57 +2809,46 @@ class VaultService implements Vault {
|
|
|
2529
2809
|
if (rememberedHead !== null && !holds(rememberedHead)) {
|
|
2530
2810
|
return { ok: false, failedAtSeq: rememberedHead.seq, reason: 'changed since the vault last verified it' };
|
|
2531
2811
|
}
|
|
2812
|
+
if (walk.mismatch !== null) return { ok: false, failedAtSeq: Number(walk.mismatch.entry), reason: walk.mismatch.reason };
|
|
2813
|
+
if (ends !== null) {
|
|
2814
|
+
// The walk ends at the prefix the stretch's checkpoint signed, by its hash too: a chain made again from before it is not.
|
|
2815
|
+
if ((upTo === null && through !== ends) || endSigned !== reached) {
|
|
2816
|
+
return { ok: false, failedAtSeq: Number(to), reason: `the log up to entry ${ends} is not the prefix this checkpoint signed: it was rewritten` };
|
|
2817
|
+
}
|
|
2818
|
+
return { ok: true, entries: walk.entries, through: Number(ends) };
|
|
2819
|
+
}
|
|
2820
|
+
// A checkpoint refused since the newest was signed, for a broken log: what it found, the stretch of the
|
|
2821
|
+
// past it rehashed among it, which no walk from a tally reads again.
|
|
2822
|
+
const standing = await this.#standingRefusal(tx);
|
|
2823
|
+
if (standing !== null) {
|
|
2824
|
+
return { ok: false, failedAtSeq: standing.failedAtSeq ?? null, reason: `the last checkpoint was refused: ${standing.reason ?? 'the log is broken'}` };
|
|
2825
|
+
}
|
|
2826
|
+
const verified = { ok: true as const, entries: walk.entries, through: Number(through) };
|
|
2827
|
+
if (!present) return verified;
|
|
2532
2828
|
const at = await this.#now(tx);
|
|
2533
|
-
const accounting =
|
|
2829
|
+
const accounting = walk.accounting.result(at);
|
|
2534
2830
|
if (!accounting.ok) return accounting;
|
|
2535
2831
|
const [rows, held] = await Promise.all([store.allMembers(tx), store.grants(tx)]);
|
|
2536
|
-
const fault = this.#unsealed(rows, held,
|
|
2832
|
+
const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
|
|
2537
2833
|
if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
|
|
2538
|
-
const verified = { ok: true as const, entries: findings.entries };
|
|
2539
2834
|
return accounting.pending === 0 ? verified : { ...verified, pending: accounting.pending };
|
|
2540
2835
|
}, SNAPSHOT);
|
|
2541
2836
|
}
|
|
2542
2837
|
|
|
2543
2838
|
/**
|
|
2544
|
-
*
|
|
2545
|
-
*
|
|
2546
|
-
*
|
|
2839
|
+
* Every checkpoint walked, not only the newest: each signed by the key
|
|
2840
|
+
* whose log key wrote its entry, over a prefix the log still holds,
|
|
2841
|
+
* entry for entry, which one that keeps a tally names as related. Its
|
|
2842
|
+
* entry's MAC is checked by now, and one under a key the vault replaced
|
|
2843
|
+
* comes before the rotation.
|
|
2547
2844
|
*/
|
|
2548
|
-
async #
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
|
|
2556
|
-
if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
|
|
2557
|
-
}
|
|
2558
|
-
const moved = forward(batch, keys, logKeys[0]!.keyId);
|
|
2559
|
-
if ('failedAtSeq' in moved) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
|
|
2560
|
-
keys = moved.keys;
|
|
2561
|
-
for (const entry of batch) findings.add(entry);
|
|
2562
|
-
after = batch[batch.length - 1]!.seq;
|
|
2563
|
-
}
|
|
2564
|
-
}
|
|
2565
|
-
|
|
2566
|
-
/** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
|
|
2567
|
-
async #rehashed(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
|
|
2568
|
-
const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
|
|
2569
|
-
for (const entry of batch) if (entry.author === 'vault') findings.add(entry);
|
|
2570
|
-
});
|
|
2571
|
-
if (!verification.ok) return verification;
|
|
2572
|
-
this.#prepared.verified = further(this.#prepared.verified, anchor);
|
|
2573
|
-
return { ok: true };
|
|
2574
|
-
}
|
|
2575
|
-
|
|
2576
|
-
/**
|
|
2577
|
-
* Every checkpoint, not only the newest: each signed by the key whose log
|
|
2578
|
-
* key wrote its entry, over a prefix the log still holds (`holds`), entry
|
|
2579
|
-
* for entry. Its entry's MAC is checked by now, and one under a key the
|
|
2580
|
-
* vault replaced comes before the rotation.
|
|
2581
|
-
*/
|
|
2582
|
-
async #checkpointFault(findings: Findings, holds: (head: LogHead) => boolean): Promise<Extract<LogVerification, { ok: false }> | null> {
|
|
2845
|
+
async #checkpointFault(
|
|
2846
|
+
db: Queryable,
|
|
2847
|
+
checkpoints: Walk['checkpoints'],
|
|
2848
|
+
known?: (head: LogHead) => boolean,
|
|
2849
|
+
): Promise<Broken | null> {
|
|
2850
|
+
if (checkpoints.length === 0) return null;
|
|
2851
|
+
const holds = known ?? (await this.#holding(db, checkpoints.map(({ checkpoint }) => checkpoint)));
|
|
2583
2852
|
const { logKeys, signers } = this.#prepared;
|
|
2584
2853
|
// Each signer's key imported once, for this check only.
|
|
2585
2854
|
const verifiers = new Map<string, Promise<(checkpoint: Checkpoint) => Promise<boolean>>>();
|
|
@@ -2588,7 +2857,6 @@ class VaultService implements Vault {
|
|
|
2588
2857
|
if (verifier === undefined) verifiers.set(by.keyId, (verifier = checkpointVerifier(by.publicKey)));
|
|
2589
2858
|
return verifier.then((verify) => verify(checkpoint));
|
|
2590
2859
|
};
|
|
2591
|
-
const { checkpoints } = findings;
|
|
2592
2860
|
for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
|
|
2593
2861
|
const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
|
|
2594
2862
|
// The signatures of a chunk at once, where crypto runs beside the code; the first fault, in the log's order.
|
|
@@ -2596,7 +2864,7 @@ class VaultService implements Vault {
|
|
|
2596
2864
|
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
2597
2865
|
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`;
|
|
2598
2866
|
if (!(await signed(checkpoint, by))) return 'a checkpoint the vault did not sign';
|
|
2599
|
-
if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) {
|
|
2867
|
+
if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint) || (entry.relatedSeq !== null && entry.relatedSeq !== BigInt(checkpoint.seq))) {
|
|
2600
2868
|
return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
|
|
2601
2869
|
}
|
|
2602
2870
|
return null;
|
|
@@ -2609,8 +2877,8 @@ class VaultService implements Vault {
|
|
|
2609
2877
|
|
|
2610
2878
|
/**
|
|
2611
2879
|
* The first member whose row fails its MAC, or names an older access
|
|
2612
|
-
* entry than the log's newest about them (`newest`, from the
|
|
2613
|
-
*
|
|
2880
|
+
* entry than the log's newest about them (`newest`, from the walk, where
|
|
2881
|
+
* each entry's MAC was checked, on from the tally).
|
|
2614
2882
|
*/
|
|
2615
2883
|
#unsealed(rows: readonly Member[], held: readonly GrantRow[], newest: ReadonlyMap<string, bigint>): AccessFault | null {
|
|
2616
2884
|
for (const row of rows) {
|
|
@@ -2622,40 +2890,80 @@ class VaultService implements Vault {
|
|
|
2622
2890
|
}
|
|
2623
2891
|
}
|
|
2624
2892
|
|
|
2893
|
+
type Broken = Extract<LogVerification, { ok: false }>;
|
|
2894
|
+
|
|
2625
2895
|
/**
|
|
2626
|
-
*
|
|
2627
|
-
*
|
|
2628
|
-
*
|
|
2629
|
-
*
|
|
2896
|
+
* Where a walk of the log resumes: the checkpoint's entry (null for the
|
|
2897
|
+
* first entry), the end of the prefix it signed, the tally through there,
|
|
2898
|
+
* its members and grants' digest and the entry that keeps them, and where
|
|
2899
|
+
* the background rehash goes on from (`#scrub`).
|
|
2630
2900
|
*/
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
if (
|
|
2648
|
-
this.replayFault ??= apply(this.replayed, entry);
|
|
2649
|
-
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
2650
|
-
} else if (entry.action === CHECKPOINT) {
|
|
2651
|
-
this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId }, checkpoint: JSON.parse(entry.metadata) as Checkpoint });
|
|
2652
|
-
}
|
|
2901
|
+
type Resumed = {
|
|
2902
|
+
ok: true;
|
|
2903
|
+
entry: bigint | null;
|
|
2904
|
+
at: Position;
|
|
2905
|
+
tally: Tally;
|
|
2906
|
+
digest: string;
|
|
2907
|
+
carrier: bigint | null;
|
|
2908
|
+
scrub: Lap | null;
|
|
2909
|
+
};
|
|
2910
|
+
|
|
2911
|
+
/** A lap of the rehash of the past: the last entry rehashed, and the prefix it rehashes to, signed when it began. */
|
|
2912
|
+
type Lap = { at: Position; end: Position };
|
|
2913
|
+
|
|
2914
|
+
/** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
|
|
2915
|
+
function timedOut(error: unknown): boolean {
|
|
2916
|
+
for (let cause = error; typeof cause === 'object' && cause !== null; cause = (cause as { cause?: unknown }).cause) {
|
|
2917
|
+
if ((cause as { code?: unknown }).code === '57014') return true;
|
|
2653
2918
|
}
|
|
2919
|
+
return false;
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
/** Where the next rehash of the past goes on from, null for the first entry; or what it found. */
|
|
2923
|
+
type Scrubbed = { ok: true; next: Lap | null } | Broken;
|
|
2924
|
+
|
|
2925
|
+
const FIRST_ENTRY: Resumed = { ok: true, entry: null, at: START, tally: EMPTY_TALLY, digest: accessDigest(EMPTY_TALLY.access), carrier: null, scrub: null };
|
|
2926
|
+
|
|
2927
|
+
/** 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. */
|
|
2928
|
+
export type Walks = { checkpoint: number; scrub: number; verify: number };
|
|
2929
|
+
|
|
2930
|
+
/**
|
|
2931
|
+
* A walk rehashes, MACs and replays some 40 µs of CPU an entry, and 95
|
|
2932
|
+
* with V8's optimizer off, as on a cold isolate: 10,000 entries is a beat
|
|
2933
|
+
* of catching up, about a second, and a team's instance writes fewer than
|
|
2934
|
+
* a thousand a day. The rehash of the past is a hash an entry, 14 µs warm
|
|
2935
|
+
* and 55 cold, beside two reads: 100 entries keep it near 6 ms a beat.
|
|
2936
|
+
*/
|
|
2937
|
+
const WALKS: Walks = { checkpoint: 10_000, scrub: 100, verify: 10_000 };
|
|
2938
|
+
|
|
2939
|
+
/** The digest of the members and grants a checkpoint keeps whole, beside the digest it says they have. */
|
|
2940
|
+
function keptDigest({ members, grants, newest }: AccessTally): string {
|
|
2941
|
+
return accessDigest({ members, grants, newest });
|
|
2654
2942
|
}
|
|
2655
2943
|
|
|
2656
|
-
|
|
2944
|
+
/** Each checkpoint entry's metadata, parsed once however many times it is asked about: one may hold every member and grant. */
|
|
2945
|
+
const parsed = new WeakMap<StoredEntry, unknown>();
|
|
2946
|
+
function metadataOf(entry: StoredEntry): unknown {
|
|
2947
|
+
if (!parsed.has(entry)) parsed.set(entry, JSON.parse(entry.metadata));
|
|
2948
|
+
return parsed.get(entry);
|
|
2949
|
+
}
|
|
2950
|
+
|
|
2951
|
+
/** A head, as an entry keeps it, as a walk goes on from it. */
|
|
2952
|
+
function positionOf(head: LogHead): Position {
|
|
2953
|
+
return { seq: BigInt(head.seq), hash: Buffer.from(head.hash, 'hex') };
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
/** A position as the log's heads are named, in hex. */
|
|
2957
|
+
function headOf(position: Position): LogHead {
|
|
2958
|
+
return { seq: Number(position.seq), hash: position.hash.toString('hex') };
|
|
2959
|
+
}
|
|
2960
|
+
|
|
2961
|
+
/** What a checkpoint's signature covers, and nothing it keeps beside it. */
|
|
2962
|
+
function signedPart({ seq, hash, signedAt, keyId, signature }: Checkpoint): Checkpoint {
|
|
2963
|
+
return { seq, hash, signedAt, keyId, signature };
|
|
2964
|
+
}
|
|
2657
2965
|
|
|
2658
|
-
/** Checkpoint signatures checked at once
|
|
2966
|
+
/** Checkpoint signatures checked at once. */
|
|
2659
2967
|
const SIGNATURES_AT_ONCE = 256;
|
|
2660
2968
|
|
|
2661
2969
|
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
@@ -3075,8 +3383,7 @@ function readEdits(stored: { policy?: unknown }): PolicyEdits | null {
|
|
|
3075
3383
|
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
3076
3384
|
* keep them all: no write is needed.
|
|
3077
3385
|
*/
|
|
3078
|
-
|
|
3079
|
-
const rows = await store.environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
3386
|
+
function livePolicy(rows: ReadonlyMap<string, store.EnvironmentRow>, edits: PolicyEdits, at: number): Policy {
|
|
3080
3387
|
const live = edits.protected.filter((place) => {
|
|
3081
3388
|
const row = rows.get(place.environmentId);
|
|
3082
3389
|
return row === undefined ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|