@coffre/vault 0.10.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/dist/cloudflare.d.ts +1 -0
- package/dist/cloudflare.js +1 -1
- package/dist/node.d.ts +11 -0
- package/dist/node.js +1 -1
- package/dist/{vault-COqZ12ny.js → vault-B0YiPBgi.js} +1127 -474
- package/package.json +3 -3
- package/src/accounting.ts +37 -8
- package/src/log.ts +91 -60
- package/src/replay.ts +1 -1
- package/src/store.ts +95 -21
- package/src/tally.ts +241 -0
- package/src/vault.ts +454 -209
package/src/vault.ts
CHANGED
|
@@ -99,17 +99,17 @@ 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, newestVaultSeq, 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';
|
|
112
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';
|
|
113
113
|
import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
|
|
114
114
|
|
|
115
115
|
export type VaultOptions = {
|
|
@@ -120,6 +120,8 @@ export type VaultOptions = {
|
|
|
120
120
|
keyBudgetMs?: number;
|
|
121
121
|
/** Milliseconds added to the database's clock where the vault decides by it. Tests move time with it. */
|
|
122
122
|
clockOffset?: () => number;
|
|
123
|
+
/** How far one call walks the log (`WALKS`); tests shorten them. */
|
|
124
|
+
walks?: Partial<Walks>;
|
|
123
125
|
};
|
|
124
126
|
|
|
125
127
|
/**
|
|
@@ -140,9 +142,9 @@ export type PreparedVault = {
|
|
|
140
142
|
logKey: LogKey;
|
|
141
143
|
/** Each signing key's log key: those after the first verify only entries before `since`. */
|
|
142
144
|
logKeys: LogKey[];
|
|
143
|
-
options: Required<VaultOptions
|
|
144
|
-
/** How far the
|
|
145
|
-
verified:
|
|
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;
|
|
146
148
|
/** What member rows are sealed under now; rows.ts. */
|
|
147
149
|
rowKey: Buffer;
|
|
148
150
|
/** Each signing key's row key: those after the first vouch for rows only until `since`, when the rows are sealed again. */
|
|
@@ -169,8 +171,8 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
|
|
|
169
171
|
return {
|
|
170
172
|
config,
|
|
171
173
|
...(await keysOf(config.signingKeys)),
|
|
172
|
-
options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
|
|
173
|
-
verified:
|
|
174
|
+
options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0), walks: { ...WALKS, ...options.walks } },
|
|
175
|
+
verified: START,
|
|
174
176
|
reported: new Set(),
|
|
175
177
|
since: null,
|
|
176
178
|
superseded: null,
|
|
@@ -227,9 +229,6 @@ const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
|
|
|
227
229
|
/** Why a change to a tampered member is refused. */
|
|
228
230
|
const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
|
|
229
231
|
|
|
230
|
-
/** The action of the vault's entry that signs a prefix of the log. */
|
|
231
|
-
const CHECKPOINT = 'audit.checkpoint';
|
|
232
|
-
|
|
233
232
|
/** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
|
|
234
233
|
const SETTINGS = 'settings.change';
|
|
235
234
|
|
|
@@ -289,13 +288,6 @@ const NOT_THEIRS = 'a person gives a service account at most what they hold, whe
|
|
|
289
288
|
/** Who asks for checkpoints: the app's scheduled job. */
|
|
290
289
|
const SCHEDULER = 'system:coffre-scheduler';
|
|
291
290
|
|
|
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
291
|
/** A refusal and the entries that record it. */
|
|
300
292
|
class Refused {
|
|
301
293
|
readonly refusal: Refusal;
|
|
@@ -495,6 +487,7 @@ class VaultService implements Vault {
|
|
|
495
487
|
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
496
488
|
const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
497
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.
|
|
498
491
|
const d: Decision = {
|
|
499
492
|
tx, members, at: await this.#now(tx), log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
|
|
500
493
|
};
|
|
@@ -595,9 +588,13 @@ class VaultService implements Vault {
|
|
|
595
588
|
|
|
596
589
|
/** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
|
|
597
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[] {
|
|
598
596
|
const { logKeys, since } = this.#prepared;
|
|
599
|
-
|
|
600
|
-
return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys }).ok;
|
|
597
|
+
return since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
|
|
601
598
|
}
|
|
602
599
|
|
|
603
600
|
/**
|
|
@@ -1223,7 +1220,12 @@ class VaultService implements Vault {
|
|
|
1223
1220
|
intentSeq = intent.seq;
|
|
1224
1221
|
}
|
|
1225
1222
|
|
|
1226
|
-
|
|
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) => {
|
|
1227
1229
|
try {
|
|
1228
1230
|
await check(d);
|
|
1229
1231
|
} catch (error) {
|
|
@@ -1233,9 +1235,12 @@ class VaultService implements Vault {
|
|
|
1233
1235
|
}
|
|
1234
1236
|
throw error;
|
|
1235
1237
|
}
|
|
1238
|
+
};
|
|
1239
|
+
return this.#decide([principal], async (d) => {
|
|
1240
|
+
await decided(d);
|
|
1236
1241
|
// A reference ended while this decided refuses it, rather than a read logged after its end.
|
|
1237
1242
|
const { vet } = call;
|
|
1238
|
-
if (vet !== undefined) {
|
|
1243
|
+
if (late && vet !== undefined) {
|
|
1239
1244
|
d.underLock.push(async (locked) => {
|
|
1240
1245
|
const codes = await vet(locked);
|
|
1241
1246
|
const first = codes.find((code) => code !== null);
|
|
@@ -1262,25 +1267,20 @@ class VaultService implements Vault {
|
|
|
1262
1267
|
if (done.length < outcomes.length) {
|
|
1263
1268
|
throw new Refused(refusal('bad_claim', MESSAGES.bad_claim), entries());
|
|
1264
1269
|
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
await
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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')));
|
|
1277
1283
|
}
|
|
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
1284
|
}
|
|
1285
1285
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
|
|
1286
1286
|
d.log.push(...released);
|
|
@@ -1291,7 +1291,7 @@ class VaultService implements Vault {
|
|
|
1291
1291
|
} finally {
|
|
1292
1292
|
for (const value of done) value.wipe();
|
|
1293
1293
|
}
|
|
1294
|
-
},
|
|
1294
|
+
}, late);
|
|
1295
1295
|
}
|
|
1296
1296
|
|
|
1297
1297
|
/** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
|
|
@@ -2052,7 +2052,7 @@ class VaultService implements Vault {
|
|
|
2052
2052
|
*/
|
|
2053
2053
|
async #settingsAt(db: Queryable, reports: NewEntry[]): Promise<Settings> {
|
|
2054
2054
|
const entry = await newestAuthentic(
|
|
2055
|
-
(before) => store.newestVaultEntries(db, SETTINGS,
|
|
2055
|
+
(limit, before) => store.newestVaultEntries(db, SETTINGS, limit, before),
|
|
2056
2056
|
this.#check(reports, null),
|
|
2057
2057
|
);
|
|
2058
2058
|
if (entry === 'none') return DEFAULT_SETTINGS;
|
|
@@ -2081,7 +2081,7 @@ class VaultService implements Vault {
|
|
|
2081
2081
|
*/
|
|
2082
2082
|
async #policyAt(db: Queryable, reports: NewEntry[], at: number): Promise<{ policy: Policy; edits: PolicyEdits; readable: boolean }> {
|
|
2083
2083
|
const entry = await newestAuthentic(
|
|
2084
|
-
(before) => store.newestVaultEntries(db, POLICY,
|
|
2084
|
+
(limit, before) => store.newestVaultEntries(db, POLICY, limit, before),
|
|
2085
2085
|
this.#check(reports, null),
|
|
2086
2086
|
);
|
|
2087
2087
|
if (entry === 'none') return { policy: DEFAULT_POLICY, edits: NO_EDITS, readable: true };
|
|
@@ -2363,73 +2363,137 @@ class VaultService implements Vault {
|
|
|
2363
2363
|
|
|
2364
2364
|
// --- checkpoints and the log --------------------------------------------------
|
|
2365
2365
|
|
|
2366
|
-
/**
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
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;
|
|
2370
2431
|
}
|
|
2371
2432
|
|
|
2372
2433
|
/**
|
|
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.
|
|
2376
|
-
*
|
|
2377
|
-
* Resuming never signs what the vault did not recompute. The vault
|
|
2378
|
-
* recomputed the last checkpoint's prefix itself, from entry 0 at the
|
|
2379
|
-
* hour's first checkpoint and onward at each one since, and the entry
|
|
2380
|
-
* that holds it is the first one checked again, by its MAC. The next
|
|
2381
|
-
* signature extends that same chain.
|
|
2434
|
+
* Sign the log up to its last entry, from the newest checkpoint's tally.
|
|
2382
2435
|
*
|
|
2383
|
-
*
|
|
2384
|
-
*
|
|
2385
|
-
*
|
|
2386
|
-
*
|
|
2387
|
-
*
|
|
2388
|
-
*
|
|
2389
|
-
*
|
|
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.
|
|
2390
2445
|
*
|
|
2391
|
-
*
|
|
2392
|
-
*
|
|
2393
|
-
*
|
|
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.
|
|
2394
2450
|
*/
|
|
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 }>> {
|
|
2451
|
+
async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint; warning?: string }>> {
|
|
2413
2452
|
await this.#settled();
|
|
2414
2453
|
// A KEK found wrong turns readiness red: the checkpoint is refused. It does no key work of its
|
|
2415
2454
|
// 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`).
|
|
2455
|
+
const { wrongKek, logKeys } = this.#prepared;
|
|
2456
|
+
const { walks } = this.#prepared.options;
|
|
2423
2457
|
const found: NewEntry[] = [];
|
|
2424
|
-
|
|
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;
|
|
2425
2462
|
await this.#sweep(tx, found);
|
|
2426
|
-
|
|
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 };
|
|
2427
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
|
+
}
|
|
2428
2492
|
return this.#decide([], async (d) => {
|
|
2429
2493
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
2430
2494
|
// Checkpoints one at a time, each against the one before.
|
|
2431
2495
|
const head = await lockLogHead(d.tx);
|
|
2432
|
-
const latest = await
|
|
2496
|
+
const latest = await store.latestVaultEntry(d.tx, [CHECKPOINT]);
|
|
2433
2497
|
const refused = (code: RefusalCode, detail: Record<string, unknown>, message = MESSAGES[code]) =>
|
|
2434
2498
|
new Refused(refusal(code, message), [
|
|
2435
2499
|
{ actor: SCHEDULER, action: CHECKPOINT, decision: 'deny', code, metadata: JSON.stringify(detail) },
|
|
@@ -2437,33 +2501,104 @@ class VaultService implements Vault {
|
|
|
2437
2501
|
// Logging the refusal would give the next call something to sign.
|
|
2438
2502
|
if (head.nextSeq === 0n) throw new Refused(refusal('invalid', 'the log is empty'), []);
|
|
2439
2503
|
if (wrongKek !== null) throw refused('wrong_kek', { reason: wrongKek }, wrongKek);
|
|
2440
|
-
if (!
|
|
2441
|
-
|
|
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) };
|
|
2442
2514
|
}
|
|
2443
|
-
// Nothing since the last one: it is still the newest prefix.
|
|
2444
|
-
if (
|
|
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) };
|
|
2445
2517
|
// Under the lock, the rest: the prefix signed last still where it was,
|
|
2446
|
-
// and every entry since the snapshot
|
|
2447
|
-
|
|
2448
|
-
|
|
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` });
|
|
2449
2522
|
}
|
|
2450
|
-
const
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
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` });
|
|
2455
2541
|
}
|
|
2456
|
-
|
|
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) };
|
|
2457
2551
|
const checkpoint = {
|
|
2458
2552
|
...signed,
|
|
2459
2553
|
keyId: this.#prepared.signer.keyId,
|
|
2460
2554
|
signature: await this.#prepared.signer.sign(checkpointMessage(signed)),
|
|
2461
2555
|
};
|
|
2462
|
-
|
|
2463
|
-
|
|
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 };
|
|
2464
2560
|
});
|
|
2465
2561
|
}
|
|
2466
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
|
+
|
|
2467
2602
|
async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; owners: string[]; rootAdmins: [] }> {
|
|
2468
2603
|
await this.#settled();
|
|
2469
2604
|
const { signers, since } = this.#prepared;
|
|
@@ -2487,41 +2622,123 @@ class VaultService implements Vault {
|
|
|
2487
2622
|
return { current: { kekProvider: provider, kekId: keyId }, checks };
|
|
2488
2623
|
}
|
|
2489
2624
|
|
|
2490
|
-
verifyLog(input: VerifyLogInput): Promise<LogVerification> {
|
|
2491
|
-
return this.#verifyAll(input.upTo ?? null);
|
|
2492
|
-
}
|
|
2493
|
-
|
|
2494
2625
|
/**
|
|
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
|
|
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.
|
|
2500
2632
|
*
|
|
2501
2633
|
* 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
|
|
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`).
|
|
2507
2640
|
*/
|
|
2508
|
-
async
|
|
2641
|
+
async verifyLog(input: VerifyLogInput): Promise<LogVerification> {
|
|
2509
2642
|
await this.#settled();
|
|
2510
|
-
|
|
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> => {
|
|
2511
2647
|
const remembered = this.#prepared.verified;
|
|
2512
|
-
const
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
//
|
|
2516
|
-
const
|
|
2517
|
-
const
|
|
2518
|
-
if (
|
|
2519
|
-
|
|
2520
|
-
|
|
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)]);
|
|
2521
2738
|
if (upTo !== null && !holds(upTo)) {
|
|
2522
2739
|
return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
|
|
2523
2740
|
}
|
|
2524
|
-
const unsigned = await this.#checkpointFault(
|
|
2741
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
|
|
2525
2742
|
if (unsigned !== null) return unsigned;
|
|
2526
2743
|
// What this vault last verified must still be there: whoever holds
|
|
2527
2744
|
// its key can seal a rewrite, but cannot put back the head it saw. A
|
|
@@ -2529,57 +2746,46 @@ class VaultService implements Vault {
|
|
|
2529
2746
|
if (rememberedHead !== null && !holds(rememberedHead)) {
|
|
2530
2747
|
return { ok: false, failedAtSeq: rememberedHead.seq, reason: 'changed since the vault last verified it' };
|
|
2531
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;
|
|
2532
2765
|
const at = await this.#now(tx);
|
|
2533
|
-
const accounting =
|
|
2766
|
+
const accounting = walk.accounting.result(at);
|
|
2534
2767
|
if (!accounting.ok) return accounting;
|
|
2535
2768
|
const [rows, held] = await Promise.all([store.allMembers(tx), store.grants(tx)]);
|
|
2536
|
-
const fault = this.#unsealed(rows, held,
|
|
2769
|
+
const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
|
|
2537
2770
|
if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
|
|
2538
|
-
const verified = { ok: true as const, entries: findings.entries };
|
|
2539
2771
|
return accounting.pending === 0 ? verified : { ...verified, pending: accounting.pending };
|
|
2540
2772
|
}, SNAPSHOT);
|
|
2541
2773
|
}
|
|
2542
2774
|
|
|
2543
2775
|
/**
|
|
2544
|
-
*
|
|
2545
|
-
*
|
|
2546
|
-
*
|
|
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.
|
|
2547
2781
|
*/
|
|
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> {
|
|
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)));
|
|
2583
2789
|
const { logKeys, signers } = this.#prepared;
|
|
2584
2790
|
// Each signer's key imported once, for this check only.
|
|
2585
2791
|
const verifiers = new Map<string, Promise<(checkpoint: Checkpoint) => Promise<boolean>>>();
|
|
@@ -2588,7 +2794,6 @@ class VaultService implements Vault {
|
|
|
2588
2794
|
if (verifier === undefined) verifiers.set(by.keyId, (verifier = checkpointVerifier(by.publicKey)));
|
|
2589
2795
|
return verifier.then((verify) => verify(checkpoint));
|
|
2590
2796
|
};
|
|
2591
|
-
const { checkpoints } = findings;
|
|
2592
2797
|
for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
|
|
2593
2798
|
const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
|
|
2594
2799
|
// The signatures of a chunk at once, where crypto runs beside the code; the first fault, in the log's order.
|
|
@@ -2596,7 +2801,7 @@ class VaultService implements Vault {
|
|
|
2596
2801
|
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
2597
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`;
|
|
2598
2803
|
if (!(await signed(checkpoint, by))) return 'a checkpoint the vault did not sign';
|
|
2599
|
-
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))) {
|
|
2600
2805
|
return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
|
|
2601
2806
|
}
|
|
2602
2807
|
return null;
|
|
@@ -2609,8 +2814,8 @@ class VaultService implements Vault {
|
|
|
2609
2814
|
|
|
2610
2815
|
/**
|
|
2611
2816
|
* 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
|
-
*
|
|
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).
|
|
2614
2819
|
*/
|
|
2615
2820
|
#unsealed(rows: readonly Member[], held: readonly GrantRow[], newest: ReadonlyMap<string, bigint>): AccessFault | null {
|
|
2616
2821
|
for (const row of rows) {
|
|
@@ -2622,40 +2827,80 @@ class VaultService implements Vault {
|
|
|
2622
2827
|
}
|
|
2623
2828
|
}
|
|
2624
2829
|
|
|
2830
|
+
type Broken = Extract<LogVerification, { ok: false }>;
|
|
2831
|
+
|
|
2625
2832
|
/**
|
|
2626
|
-
*
|
|
2627
|
-
*
|
|
2628
|
-
*
|
|
2629
|
-
*
|
|
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`).
|
|
2630
2837
|
*/
|
|
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
|
-
}
|
|
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;
|
|
2653
2855
|
}
|
|
2856
|
+
return false;
|
|
2857
|
+
}
|
|
2858
|
+
|
|
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 };
|
|
2875
|
+
|
|
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);
|
|
2654
2886
|
}
|
|
2655
2887
|
|
|
2656
|
-
|
|
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
|
+
}
|
|
2657
2902
|
|
|
2658
|
-
/** Checkpoint signatures checked at once
|
|
2903
|
+
/** Checkpoint signatures checked at once. */
|
|
2659
2904
|
const SIGNATURES_AT_ONCE = 256;
|
|
2660
2905
|
|
|
2661
2906
|
/** Settle every operation before releasing the member lock, including cancelled requests. */
|