@coffre/vault 0.1.1 → 0.1.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/README.md +1 -2
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/{index-qXbB_vlp.d.ts → index-CapZlhbz.d.ts} +15 -5
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -3
- package/dist/node.d.ts +2 -2
- package/dist/node.js +3 -3
- package/dist/src-D1C9S6VS.js +113 -0
- package/dist/{vault-DvTsSAOX.js → vault-D5ZahBN1.js} +231 -107
- package/package.json +3 -3
- package/src/cloudflare.ts +1 -2
- package/src/config.ts +60 -11
- package/src/index.ts +2 -0
- package/src/log.ts +54 -7
- package/src/store.ts +16 -0
- package/src/vault.ts +196 -27
package/src/vault.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type AccessFault,
|
|
32
32
|
type AdmitInput,
|
|
33
33
|
type Checkpoint,
|
|
34
|
+
type CheckpointKey,
|
|
34
35
|
type Grant,
|
|
35
36
|
type GrantChange,
|
|
36
37
|
type LogHead,
|
|
@@ -50,7 +51,7 @@ import {
|
|
|
50
51
|
} from '@coffre/core/vault';
|
|
51
52
|
import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
52
53
|
import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
53
|
-
import { appendEntries, lockLogHead, type NewEntry } from '@coffre/db/log';
|
|
54
|
+
import { appendEntries, lockLogHead, type Appended, type NewEntry } from '@coffre/db/log';
|
|
54
55
|
|
|
55
56
|
import { verifyAccounting } from './accounting.ts';
|
|
56
57
|
import { signer, type Signer } from './checkpoint.ts';
|
|
@@ -79,15 +80,29 @@ export type VaultOptions = {
|
|
|
79
80
|
*/
|
|
80
81
|
export type PreparedVault = {
|
|
81
82
|
config: ResolvedVaultConfig;
|
|
83
|
+
/** What signs checkpoints now: the first of `signers`. */
|
|
82
84
|
signer: Signer;
|
|
85
|
+
/** A signer for each of its signing keys: a checkpoint is signed by the one whose log key wrote its entry. */
|
|
86
|
+
signers: Signer[];
|
|
87
|
+
/** What the vault's entries are MACed with now: the first of `logKeys`. */
|
|
83
88
|
logKey: LogKey;
|
|
89
|
+
/** Each signing key's log key: those after the first verify only entries before `since`. */
|
|
90
|
+
logKeys: LogKey[];
|
|
84
91
|
options: Required<VaultOptions>;
|
|
85
92
|
/** How far the log is verified; `verifyChain` in log.ts. The furthest any call got to. */
|
|
86
93
|
verified: Anchor;
|
|
87
94
|
/** Root admins known to have a member row; rows are never deleted. */
|
|
88
95
|
rooted: Set<string>;
|
|
89
|
-
/** What member rows are sealed under; rows.ts. */
|
|
96
|
+
/** What member rows are sealed under now; rows.ts. */
|
|
90
97
|
rowKey: Buffer;
|
|
98
|
+
/** Each signing key's row key: those after the first vouch for rows only until `since`, when the rows are sealed again. */
|
|
99
|
+
rowKeys: Buffer[];
|
|
100
|
+
/** Its first entry under its current key, where the keys it replaced stop counting, once settled (`#settle`). */
|
|
101
|
+
since: bigint | null;
|
|
102
|
+
/** Why it writes nothing, once settled that it may not. */
|
|
103
|
+
superseded: string | null;
|
|
104
|
+
/** The settling under way. */
|
|
105
|
+
settling: Promise<void> | null;
|
|
91
106
|
/** Tampering this process has logged already, so that a forged row is one entry, not one per request. */
|
|
92
107
|
reported: Set<string>;
|
|
93
108
|
/** Whether its KEKs open what they wrapped, once asked: `#kekMismatch`. */
|
|
@@ -99,18 +114,27 @@ export type PreparedVault = {
|
|
|
99
114
|
export async function prepareVault(config: ResolvedVaultConfig, options: VaultOptions = {}): Promise<PreparedVault> {
|
|
100
115
|
return {
|
|
101
116
|
config,
|
|
102
|
-
|
|
103
|
-
logKey: vaultLogKey(config.signingKey),
|
|
117
|
+
...(await keysOf(config.signingKeys)),
|
|
104
118
|
options: { keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS, clockOffset: options.clockOffset ?? (() => 0) },
|
|
105
119
|
verified: UNVERIFIED,
|
|
106
120
|
rooted: new Set(),
|
|
107
|
-
rowKey: rowKey(config.signingKey),
|
|
108
121
|
reported: new Set(),
|
|
122
|
+
since: null,
|
|
123
|
+
superseded: null,
|
|
124
|
+
settling: null,
|
|
109
125
|
kekCheck: null,
|
|
110
126
|
wrongKek: null,
|
|
111
127
|
};
|
|
112
128
|
}
|
|
113
129
|
|
|
130
|
+
/** The vault's own keys, from each of its signing keys, the one it signs with first. */
|
|
131
|
+
async function keysOf(seeds: readonly Uint8Array[]) {
|
|
132
|
+
const signers = await Promise.all(seeds.map((seed) => signer(seed)));
|
|
133
|
+
const logKeys = seeds.map((seed) => vaultLogKey(seed));
|
|
134
|
+
const rowKeys = seeds.map((seed) => rowKey(seed));
|
|
135
|
+
return { signer: signers[0], signers, logKey: logKeys[0], logKeys, rowKey: rowKeys[0], rowKeys };
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
/** The vault over `db`. Cheap: on Workers, one per call, over that call's connections. */
|
|
115
139
|
export function openVault(db: Database, prepared: PreparedVault): Vault {
|
|
116
140
|
return new VaultService(db, prepared);
|
|
@@ -141,6 +165,9 @@ const KEY_CHECK_CONTEXT: SecretContext = { projectId: NIL, environmentId: NIL, s
|
|
|
141
165
|
/** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
|
|
142
166
|
const KEY_CHECK_SAMPLE = 3;
|
|
143
167
|
|
|
168
|
+
/** The vault's first entry under a new key, which the keys it replaced count only before (`#settle`). */
|
|
169
|
+
const KEY_ROTATE = 'key.rotate';
|
|
170
|
+
|
|
144
171
|
/** A new member row, before the decision seals it (`#seal`). */
|
|
145
172
|
const UNSEALED = { accessSeq: 0n, mac: Buffer.alloc(32) };
|
|
146
173
|
|
|
@@ -289,6 +316,8 @@ class VaultService implements Vault {
|
|
|
289
316
|
* fails the call.
|
|
290
317
|
*/
|
|
291
318
|
async #decide<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>): Promise<Outcome<T>> {
|
|
319
|
+
const superseded = await this.#settled();
|
|
320
|
+
if (superseded !== null) return { ok: false, refusal: refusal('wrong_kek', superseded) };
|
|
292
321
|
const reports: NewEntry[] = [];
|
|
293
322
|
for (let attempt = 1; ; attempt += 1) {
|
|
294
323
|
try {
|
|
@@ -316,7 +345,7 @@ class VaultService implements Vault {
|
|
|
316
345
|
// Each member's newest access entry, which their row names (rows.ts).
|
|
317
346
|
const accessSeq = new Map<string, bigint>();
|
|
318
347
|
if (entries.length > 0) {
|
|
319
|
-
const appended = await
|
|
348
|
+
const appended = await this.#append(tx, entries);
|
|
320
349
|
at = appended.occurredAt;
|
|
321
350
|
entries.forEach((entry, i) => {
|
|
322
351
|
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal!, appended.seqStart + BigInt(i));
|
|
@@ -338,7 +367,7 @@ class VaultService implements Vault {
|
|
|
338
367
|
if (entries.length > 0) {
|
|
339
368
|
await this.#db.transaction(async (tx) => {
|
|
340
369
|
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
341
|
-
await
|
|
370
|
+
await this.#append(tx, entries);
|
|
342
371
|
});
|
|
343
372
|
}
|
|
344
373
|
this.#reported(reports);
|
|
@@ -376,7 +405,7 @@ class VaultService implements Vault {
|
|
|
376
405
|
* could lock any member out.
|
|
377
406
|
*/
|
|
378
407
|
async #integrity(db: Queryable, principal: string, row: Member | undefined, grants: readonly GrantRow[], reports: NewEntry[]): Promise<Fault | null> {
|
|
379
|
-
if (row !== undefined && !sealed(
|
|
408
|
+
if (row !== undefined && !this.#sealed(row, grants)) {
|
|
380
409
|
this.#report(reports, principal, 'mac', row.mac.toString('hex'));
|
|
381
410
|
return 'mac';
|
|
382
411
|
}
|
|
@@ -398,8 +427,21 @@ class VaultService implements Vault {
|
|
|
398
427
|
return null;
|
|
399
428
|
}
|
|
400
429
|
|
|
430
|
+
/** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
|
|
401
431
|
#authentic(entry: StoredEntry): boolean {
|
|
402
|
-
|
|
432
|
+
const { logKeys, since } = this.#prepared;
|
|
433
|
+
const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
|
|
434
|
+
return verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys }).ok;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Whether `row`, with these grants, carries the MAC of the vault's row
|
|
439
|
+
* key, or of one it replaced before the rotation sealed every row again
|
|
440
|
+
* (`#rotate`).
|
|
441
|
+
*/
|
|
442
|
+
#sealed(row: Member, grants: readonly GrantRow[]): boolean {
|
|
443
|
+
const { rowKeys, since } = this.#prepared;
|
|
444
|
+
return (since === null ? rowKeys : rowKeys.slice(0, 1)).some((key) => sealed(key, row, grants));
|
|
403
445
|
}
|
|
404
446
|
|
|
405
447
|
/** A `vault.tampered` entry, once per process for each thing found: `#reported` marks it once committed. */
|
|
@@ -429,11 +471,121 @@ class VaultService implements Vault {
|
|
|
429
471
|
|
|
430
472
|
/** Reports found outside a decision, committed on their own. */
|
|
431
473
|
async #record(reports: NewEntry[]): Promise<void> {
|
|
432
|
-
if (reports.length === 0) return;
|
|
433
|
-
await this.#db.transaction((tx) =>
|
|
474
|
+
if (reports.length === 0 || (await this.#settled()) !== null) return;
|
|
475
|
+
await this.#db.transaction((tx) => this.#append(tx, reports));
|
|
434
476
|
this.#reported(reports);
|
|
435
477
|
}
|
|
436
478
|
|
|
479
|
+
// --- its keys over time -------------------------------------------------------
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Null when the vault may write, once its keys are settled (`#settle`);
|
|
483
|
+
* otherwise why not. Settled by the first call of a process; one that
|
|
484
|
+
* fails leaves it to the next.
|
|
485
|
+
*/
|
|
486
|
+
async #settled(): Promise<string | null> {
|
|
487
|
+
const prepared = this.#prepared;
|
|
488
|
+
if (prepared.since === null && prepared.superseded === null) {
|
|
489
|
+
prepared.settling ??= this.#settle().finally(() => {
|
|
490
|
+
prepared.settling = null;
|
|
491
|
+
});
|
|
492
|
+
await prepared.settling;
|
|
493
|
+
}
|
|
494
|
+
return prepared.superseded;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Which of its keys count where. Those it replaced, from the KEKs in
|
|
499
|
+
* previousKeks, verify only what came before its first entry under its
|
|
500
|
+
* current key, `since`, and vouch for no member row after it. The first
|
|
501
|
+
* call finds that entry, or makes it when every entry of the vault's is
|
|
502
|
+
* still under a key it replaced (`#rotate`). A vault whose key the log has
|
|
503
|
+
* moved on from, or that holds no key its newest entries are under,
|
|
504
|
+
* writes nothing: `superseded` says why.
|
|
505
|
+
*/
|
|
506
|
+
async #settle(): Promise<void> {
|
|
507
|
+
const prepared = this.#prepared;
|
|
508
|
+
const current = prepared.logKey.keyId;
|
|
509
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
510
|
+
const rotation = await store.latestVaultEntry(this.#db, [KEY_ROTATE]);
|
|
511
|
+
if (rotation?.keyId === current && this.#authentic(rotation)) {
|
|
512
|
+
prepared.since = rotation.seq;
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const [newest] = await store.vaultPage(this.#db, undefined, 1);
|
|
516
|
+
// A log the vault has not written to: nothing to settle until it does.
|
|
517
|
+
if (newest === undefined) return;
|
|
518
|
+
const first = await store.firstVaultEntryUnder(this.#db, current);
|
|
519
|
+
if (first !== undefined && newest.keyId === current) {
|
|
520
|
+
prepared.since = first;
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
if (first === undefined && prepared.logKeys.some((key) => key.keyId === newest.keyId)) {
|
|
524
|
+
try {
|
|
525
|
+
prepared.since = await this.#rotate(newest.keyId);
|
|
526
|
+
return;
|
|
527
|
+
} catch (error) {
|
|
528
|
+
if (attempt < 3 && error instanceof Retry) continue;
|
|
529
|
+
throw error;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
prepared.superseded =
|
|
533
|
+
first === undefined
|
|
534
|
+
? `the vault's entries are under ${newest.keyId}, a key this vault does not hold: it was given the wrong KEK or signing key, or a KEK it replaced is missing from previousKeks`
|
|
535
|
+
: `the log moved on from this vault's key, ${current}, to ${newest.keyId}: a vault given a newer KEK replaced it, and a replaced key writes nothing more`;
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Move the vault to its current key: every member row it can vouch for
|
|
542
|
+
* sealed again under the current row key, then a `key.rotate` entry, its
|
|
543
|
+
* first under the key, after which the keys it replaced count for
|
|
544
|
+
* nothing. One transaction, which locks every row as a decision does,
|
|
545
|
+
* then the log's head. Another instance that rotated first, or a member
|
|
546
|
+
* admitted since the rows were read, sends it back to `#settle`.
|
|
547
|
+
*/
|
|
548
|
+
async #rotate(from: string): Promise<bigint> {
|
|
549
|
+
const reports: NewEntry[] = [];
|
|
550
|
+
const since = await this.#db.transaction(async (tx) => {
|
|
551
|
+
await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
552
|
+
const principals = (await store.allMembers(tx)).map((row) => row.principal);
|
|
553
|
+
const rows = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals);
|
|
554
|
+
await lockLogHead(tx);
|
|
555
|
+
const [newest] = await store.vaultPage(tx, undefined, 1);
|
|
556
|
+
if (newest?.keyId !== from || (await store.allMembers(tx)).length !== rows.size) throw new Retry();
|
|
557
|
+
const held = await store.grants(tx);
|
|
558
|
+
for (const row of rows.values()) {
|
|
559
|
+
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
560
|
+
// A row that fails is reported, and left under the key it was sealed with, which fails it from now on.
|
|
561
|
+
if ((await this.#integrity(tx, row.principal, row, grants, reports)) === null) {
|
|
562
|
+
await store.updateMember(tx, row.principal, { mac: memberMac(this.#prepared.rowKey, row, grants) });
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
const rotated = { actor: VAULT_ACTOR, action: KEY_ROTATE, decision: 'allow' as const, metadata: JSON.stringify({ from }) };
|
|
566
|
+
return (await this.#append(tx, [rotated, ...reports])).seqStart;
|
|
567
|
+
});
|
|
568
|
+
this.#reported(reports);
|
|
569
|
+
return since;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Append under the vault's current key, unless the log's newest rotation
|
|
574
|
+
* is to a key it does not hold: a vault given a newer KEK has replaced
|
|
575
|
+
* this one, which writes nothing more. Checked under the head's lock, so
|
|
576
|
+
* that an instance still running with the replaced KEK, as during a
|
|
577
|
+
* deploy, cannot write after the rotation.
|
|
578
|
+
*/
|
|
579
|
+
#append(tx: Transaction, entries: readonly NewEntry[]): Promise<Appended> {
|
|
580
|
+
const prepared = this.#prepared;
|
|
581
|
+
return appendEntries(tx, prepared.logKey, entries, async (locked) => {
|
|
582
|
+
const rotation = await store.latestVaultEntry(locked, [KEY_ROTATE]);
|
|
583
|
+
if (rotation === undefined || prepared.logKeys.some((key) => key.keyId === rotation.keyId)) return;
|
|
584
|
+
prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer KEK replaced it, and a replaced key writes nothing more`;
|
|
585
|
+
throw new Error(prepared.superseded);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
437
589
|
// --- keys -------------------------------------------------------------------
|
|
438
590
|
|
|
439
591
|
// Raw DEKs are cleared on every path. JSON and base64 leave strings that
|
|
@@ -656,7 +808,7 @@ class VaultService implements Vault {
|
|
|
656
808
|
await check(d);
|
|
657
809
|
await lockLogHead(d.tx);
|
|
658
810
|
const at = await this.#now(d.tx);
|
|
659
|
-
const appended = await
|
|
811
|
+
const appended = await this.#append(d.tx, [{
|
|
660
812
|
actor: principal,
|
|
661
813
|
action: 'key.intent',
|
|
662
814
|
decision: 'allow',
|
|
@@ -739,11 +891,11 @@ class VaultService implements Vault {
|
|
|
739
891
|
* anyone a root admin; the configuration does.
|
|
740
892
|
*/
|
|
741
893
|
async #rootRow(principal: string): Promise<void> {
|
|
742
|
-
if (this.#prepared.rooted.has(principal)) return;
|
|
894
|
+
if (this.#prepared.rooted.has(principal) || (await this.#settled()) !== null) return;
|
|
743
895
|
await this.#db.transaction(async (tx) => {
|
|
744
896
|
await lockLogHead(tx);
|
|
745
897
|
if ((await store.member(tx, principal)) !== undefined) return;
|
|
746
|
-
const appended = await
|
|
898
|
+
const appended = await this.#append(tx, [
|
|
747
899
|
{
|
|
748
900
|
actor: VAULT_ACTOR,
|
|
749
901
|
action: 'member.add',
|
|
@@ -820,6 +972,7 @@ class VaultService implements Vault {
|
|
|
820
972
|
* looks like one.
|
|
821
973
|
*/
|
|
822
974
|
async access(principal: string): Promise<Access> {
|
|
975
|
+
await this.#settled();
|
|
823
976
|
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
824
977
|
const read = async (db: Queryable, reports: NewEntry[]) => {
|
|
825
978
|
const [row, held, at] = await Promise.all([store.member(db, principal), store.grants(db, principal), this.#now(db)]);
|
|
@@ -859,7 +1012,7 @@ class VaultService implements Vault {
|
|
|
859
1012
|
if (this.#isRootAdmin(row.principal)) continue;
|
|
860
1013
|
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
861
1014
|
const entry = newest.get(row.principal);
|
|
862
|
-
const holds = entry !== undefined && this.#authentic(entry) && sealed(
|
|
1015
|
+
const holds = entry !== undefined && this.#authentic(entry) && this.#sealed(row, grants) && entry.seq === row.accessSeq;
|
|
863
1016
|
if (!holds) await this.#integrity(db, row.principal, row, grants, reports);
|
|
864
1017
|
}
|
|
865
1018
|
}
|
|
@@ -1182,6 +1335,9 @@ class VaultService implements Vault {
|
|
|
1182
1335
|
* A KEK that opens neither is not the one that wrapped the data.
|
|
1183
1336
|
*/
|
|
1184
1337
|
async #checkKeks(): Promise<string | null> {
|
|
1338
|
+
// A vault that may not write cannot record a check either; why it may not is the answer.
|
|
1339
|
+
const superseded = await this.#settled();
|
|
1340
|
+
if (superseded !== null) return superseded;
|
|
1185
1341
|
const checks = new Map<string, WrappedDek>();
|
|
1186
1342
|
for (const entry of await store.vaultEntriesOf(this.#db, [KEY_CHECK], -1n, VERIFY_BATCH)) {
|
|
1187
1343
|
// A check in the vault's name that the vault did not write proves nothing either way.
|
|
@@ -1221,7 +1377,7 @@ class VaultService implements Vault {
|
|
|
1221
1377
|
const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
|
|
1222
1378
|
// The key it opened, if any, is in the log, as every key the vault opens is.
|
|
1223
1379
|
await this.#db.transaction((tx) =>
|
|
1224
|
-
|
|
1380
|
+
this.#append(tx, [
|
|
1225
1381
|
{ actor: VAULT_ACTOR, action: KEY_CHECK, decision: 'allow', metadata: JSON.stringify({ ...serialisable(wrapped), proof }) },
|
|
1226
1382
|
]),
|
|
1227
1383
|
);
|
|
@@ -1238,6 +1394,7 @@ class VaultService implements Vault {
|
|
|
1238
1394
|
}
|
|
1239
1395
|
|
|
1240
1396
|
async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
|
|
1397
|
+
await this.#settled();
|
|
1241
1398
|
// A KEK found wrong turns readiness red: the checkpoint is refused. It does no key work of its
|
|
1242
1399
|
// own, so it writes nothing more and never waits on a key service; a check under way, or none yet, is no verdict.
|
|
1243
1400
|
const { wrongKek } = this.#prepared;
|
|
@@ -1250,7 +1407,7 @@ class VaultService implements Vault {
|
|
|
1250
1407
|
const found: NewEntry[] = [];
|
|
1251
1408
|
const whole = await this.#db.transaction(async (tx) => {
|
|
1252
1409
|
await this.#sweep(tx, found);
|
|
1253
|
-
return verifyChain(tx, this.#prepared.
|
|
1410
|
+
return verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED);
|
|
1254
1411
|
}, SNAPSHOT);
|
|
1255
1412
|
return this.#decide([], async (d) => {
|
|
1256
1413
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
@@ -1274,7 +1431,7 @@ class VaultService implements Vault {
|
|
|
1274
1431
|
if (latest !== null && !(await carries(d.tx, latest.checkpoint))) {
|
|
1275
1432
|
throw refused('log_broken', { reason: `the log up to entry ${latest.checkpoint.seq} is not the prefix the last checkpoint signed` });
|
|
1276
1433
|
}
|
|
1277
|
-
const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.
|
|
1434
|
+
const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKeys, [], whole.anchor);
|
|
1278
1435
|
if (!held.ok) throw refused('log_broken', { failedAtSeq: held.failedAtSeq, reason: held.reason });
|
|
1279
1436
|
// It signs the entry it verified to, which the head, locked, must name.
|
|
1280
1437
|
if (verified.nextSeq !== head.nextSeq || !verified.hash.equals(head.headHash)) {
|
|
@@ -1291,8 +1448,15 @@ class VaultService implements Vault {
|
|
|
1291
1448
|
});
|
|
1292
1449
|
}
|
|
1293
1450
|
|
|
1294
|
-
async about(): Promise<{
|
|
1295
|
-
|
|
1451
|
+
async about(): Promise<{ checkpointKeys: Record<string, CheckpointKey>; rootAdmins: string[] }> {
|
|
1452
|
+
await this.#settled();
|
|
1453
|
+
const { signers, since } = this.#prepared;
|
|
1454
|
+
// A key it replaced vouches only for prefixes before its first entry under the current one: none, before it has one.
|
|
1455
|
+
const until = Number(since ?? 0n);
|
|
1456
|
+
return {
|
|
1457
|
+
checkpointKeys: Object.fromEntries(signers.map(({ keyId, publicKey }, i) => [keyId, { publicKey, until: i === 0 ? null : until }])),
|
|
1458
|
+
rootAdmins: this.#config.rootAdmins.map((email) => `user:${email}`),
|
|
1459
|
+
};
|
|
1296
1460
|
}
|
|
1297
1461
|
|
|
1298
1462
|
verifyLog(input: VerifyLogInput): Promise<LogVerification> {
|
|
@@ -1301,7 +1465,7 @@ class VaultService implements Vault {
|
|
|
1301
1465
|
|
|
1302
1466
|
/** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
|
|
1303
1467
|
async #verify(db: Queryable, shown: readonly StoredEntry[], anchor: Anchor): Promise<LogVerification> {
|
|
1304
|
-
const { verification, anchor: reached } = await verifyChain(db, this.#prepared.
|
|
1468
|
+
const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
|
|
1305
1469
|
if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
|
|
1306
1470
|
return verification;
|
|
1307
1471
|
}
|
|
@@ -1314,7 +1478,8 @@ class VaultService implements Vault {
|
|
|
1314
1478
|
* checkpoint (`through`), and the last checkpoint's; and the members and
|
|
1315
1479
|
* grants replayed from it.
|
|
1316
1480
|
*/
|
|
1317
|
-
#verifyAll(shown: readonly StoredEntry[], upTo: LogHead | null): Promise<LogVerification> {
|
|
1481
|
+
async #verifyAll(shown: readonly StoredEntry[], upTo: LogHead | null): Promise<LogVerification> {
|
|
1482
|
+
await this.#settled();
|
|
1318
1483
|
return this.#db.transaction(async (tx) => {
|
|
1319
1484
|
const remembered = this.#prepared.verified;
|
|
1320
1485
|
const verification = await this.#verify(tx, shown, UNVERIFIED);
|
|
@@ -1343,9 +1508,10 @@ class VaultService implements Vault {
|
|
|
1343
1508
|
}
|
|
1344
1509
|
|
|
1345
1510
|
/**
|
|
1346
|
-
* Every checkpoint, not only the newest: each signed by the
|
|
1347
|
-
* over a prefix the log still holds, entry for entry.
|
|
1348
|
-
* verified by now, so each checkpoint entry is the vault's
|
|
1511
|
+
* Every checkpoint, not only the newest: each signed by the key whose log
|
|
1512
|
+
* key wrote its entry, over a prefix the log still holds, entry for entry.
|
|
1513
|
+
* The chain is verified by now, so each checkpoint entry is the vault's,
|
|
1514
|
+
* and one under a key it replaced comes before the rotation.
|
|
1349
1515
|
*/
|
|
1350
1516
|
async #checkpointFault(db: Queryable): Promise<Extract<LogVerification, { ok: false }> | null> {
|
|
1351
1517
|
for (let after = -1n; ; ) {
|
|
@@ -1353,7 +1519,10 @@ class VaultService implements Vault {
|
|
|
1353
1519
|
for (const entry of batch) {
|
|
1354
1520
|
const checkpoint = JSON.parse(entry.metadata) as Checkpoint;
|
|
1355
1521
|
const broken = (reason: string) => ({ ok: false as const, failedAtSeq: Number(entry.seq), reason });
|
|
1356
|
-
|
|
1522
|
+
const { logKeys, signers } = this.#prepared;
|
|
1523
|
+
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
1524
|
+
if (by?.keyId !== checkpoint.keyId) return broken(`a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`);
|
|
1525
|
+
if (!(await verifyCheckpoint(checkpoint, by.publicKey))) return broken('a checkpoint the vault did not sign');
|
|
1357
1526
|
if (BigInt(checkpoint.seq) >= entry.seq || !(await carries(db, checkpoint))) {
|
|
1358
1527
|
return broken(`the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`);
|
|
1359
1528
|
}
|
|
@@ -1372,7 +1541,7 @@ class VaultService implements Vault {
|
|
|
1372
1541
|
const [rows, held, newest] = await Promise.all([store.allMembers(db), store.grants(db), store.newestAccessEntries(db)]);
|
|
1373
1542
|
for (const row of rows) {
|
|
1374
1543
|
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
1375
|
-
if (!sealed(
|
|
1544
|
+
if (!this.#sealed(row, grants)) return { kind: 'tampered-member', principal: row.principal, why: 'mac' };
|
|
1376
1545
|
if (newest.get(row.principal)?.seq !== row.accessSeq) return { kind: 'tampered-member', principal: row.principal, why: 'stale' };
|
|
1377
1546
|
}
|
|
1378
1547
|
return null;
|