@coffre/vault 0.1.12 → 0.1.14
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 -1
- package/dist/cloudflare.js +2 -2
- package/dist/{index-DPxyTJNN.d.ts → index-CGVAd1mz.d.ts} +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +2 -2
- package/dist/{src-DHuqw-IB.js → src-1yVV2L0_.js} +2 -2
- package/dist/{vault-DTHHQw9L.js → vault-3O-ynZPg.js} +342 -231
- package/package.json +3 -3
- package/src/accounting.ts +52 -49
- package/src/log.ts +8 -4
- package/src/replay.ts +6 -19
- package/src/store.ts +29 -0
- package/src/vault.ts +135 -53
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"drizzle-orm": "0.45.2",
|
|
39
|
-
"@coffre/core": "0.1.
|
|
40
|
-
"@coffre/db": "0.1.
|
|
39
|
+
"@coffre/core": "0.1.14",
|
|
40
|
+
"@coffre/db": "0.1.14"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/accounting.ts
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import type { StoredEntry } from '@coffre/core/audit';
|
|
2
2
|
import type { LogVerification } from '@coffre/core/vault';
|
|
3
|
-
import type { Queryable } from '@coffre/db';
|
|
4
|
-
|
|
5
|
-
import { VERIFY_BATCH } from './log.ts';
|
|
6
|
-
import { entriesFrom } from './store.ts';
|
|
7
3
|
|
|
8
4
|
const KEY_ACTIONS = new Set(['secret.read', 'key.wrap', 'key.rewrap']);
|
|
9
5
|
|
|
@@ -13,54 +9,61 @@ type ParsedOutcome = Item & { intentId: string; relatedSeq: bigint };
|
|
|
13
9
|
type Intent = ParsedIntent & { entry: StoredEntry; seen: Set<number> };
|
|
14
10
|
type Accounting = { ok: true; pending: number } | Extract<LogVerification, { ok: false }>;
|
|
15
11
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// Completed batches need no state, and another outcome for one is a duplicate.
|
|
50
|
-
if (intent.seen.size === intent.keys.length) intents.delete(intent.entry.seq);
|
|
12
|
+
/**
|
|
13
|
+
* Every key operation's intent and its outcomes, entry by entry, oldest
|
|
14
|
+
* first, as the full check reads the vault's entries (`add`); then what is
|
|
15
|
+
* still open (`result`). The chain can hold while a process died between an
|
|
16
|
+
* intent and its outcomes: only a missed deadline is a fault.
|
|
17
|
+
*/
|
|
18
|
+
export class KeyAccounting {
|
|
19
|
+
readonly #intents = new Map<bigint, Intent>();
|
|
20
|
+
readonly #ids = new Set<string>();
|
|
21
|
+
#fault: Extract<Accounting, { ok: false }> | null = null;
|
|
22
|
+
|
|
23
|
+
add(entry: StoredEntry): void {
|
|
24
|
+
if (this.#fault !== null || entry.author !== 'vault') return;
|
|
25
|
+
const broken = (reason: string) => {
|
|
26
|
+
this.#fault = { ok: false, failedAtSeq: Number(entry.seq), reason };
|
|
27
|
+
};
|
|
28
|
+
if (entry.action === 'key.intent') {
|
|
29
|
+
const parsed = parseIntent(entry);
|
|
30
|
+
if (typeof parsed === 'string') return broken(parsed);
|
|
31
|
+
if (this.#ids.has(parsed.intentId)) return broken('key intent repeats an operation identity');
|
|
32
|
+
this.#ids.add(parsed.intentId);
|
|
33
|
+
if (parsed.keys.length > 0) this.#intents.set(entry.seq, { ...parsed, entry, seen: new Set() });
|
|
34
|
+
} else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
|
|
35
|
+
// An outcome names its intent; a key released with no service to call has none.
|
|
36
|
+
const parsed = parseOutcome(entry);
|
|
37
|
+
if (typeof parsed === 'string') return broken(parsed);
|
|
38
|
+
const intent = this.#intents.get(parsed.relatedSeq);
|
|
39
|
+
const key = intent?.keys[parsed.item];
|
|
40
|
+
if (intent === undefined || key === undefined || intent.seen.has(parsed.item)) {
|
|
41
|
+
return broken('key outcome does not identify one item of its intent');
|
|
42
|
+
}
|
|
43
|
+
if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) {
|
|
44
|
+
return broken('key outcome belongs to another operation');
|
|
51
45
|
}
|
|
46
|
+
if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) {
|
|
47
|
+
return broken('key outcome does not match its intended secret');
|
|
48
|
+
}
|
|
49
|
+
intent.seen.add(parsed.item);
|
|
50
|
+
// Completed batches need no state, and another outcome for one is a duplicate.
|
|
51
|
+
if (intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
|
|
52
52
|
}
|
|
53
|
-
next = batch[batch.length - 1].seq + 1n;
|
|
54
|
-
if (batch.length < VERIFY_BATCH) break;
|
|
55
53
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
54
|
+
|
|
55
|
+
/** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
|
|
56
|
+
result(at: number): Accounting {
|
|
57
|
+
if (this.#fault !== null) return this.#fault;
|
|
58
|
+
const intents = [...this.#intents.values()];
|
|
59
|
+
const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
|
|
60
|
+
if (overdue.length === 0) return { ok: true, pending: intents.length };
|
|
61
|
+
const reason = overdue.map((intent) => {
|
|
62
|
+
const missing = intent.keys.length - intent.seen.size;
|
|
63
|
+
return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
|
|
64
|
+
}).join('; ');
|
|
65
|
+
return { ok: false, failedAtSeq: Number(overdue[0]!.entry.seq), reason };
|
|
66
|
+
}
|
|
64
67
|
}
|
|
65
68
|
|
|
66
69
|
function parseIntent(entry: StoredEntry): ParsedIntent | string {
|
package/src/log.ts
CHANGED
|
@@ -38,7 +38,8 @@ export function further(a: Anchor, b: Anchor): Anchor {
|
|
|
38
38
|
return b.nextSeq > a.nextSeq ? b : a;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
/** Entries read at once: a few megabytes, well within a Worker's memory, and a fifth of the round trips of 1000. */
|
|
42
|
+
export const VERIFY_BATCH = 5000;
|
|
42
43
|
|
|
43
44
|
type Verified = { verification: LogVerification; anchor: Anchor };
|
|
44
45
|
|
|
@@ -55,13 +56,15 @@ type Verified = { verification: LogVerification; anchor: Anchor };
|
|
|
55
56
|
* So a view rehashes only what is new since the last one. What it leaves
|
|
56
57
|
* out is an entry before the anchor edited in place, not chained again, and
|
|
57
58
|
* not on the page: a full check, which starts from `UNVERIFIED`, finds that,
|
|
58
|
-
* as does the first view after a start, which has no anchor.
|
|
59
|
+
* as does the first view after a start, which has no anchor. `onBatch` sees
|
|
60
|
+
* each batch once it has verified, for checks that read the same entries.
|
|
59
61
|
*/
|
|
60
62
|
export async function verifyChain(
|
|
61
63
|
db: Queryable,
|
|
62
64
|
logKeys: readonly LogKey[],
|
|
63
65
|
shown: readonly StoredEntry[],
|
|
64
66
|
anchor: Anchor,
|
|
67
|
+
onBatch: (batch: readonly StoredEntry[]) => void = () => {},
|
|
65
68
|
): Promise<Verified> {
|
|
66
69
|
const broken = (failedAtSeq: bigint, reason: string): Verified => ({
|
|
67
70
|
verification: { ok: false, failedAtSeq: Number(failedAtSeq), reason: withCause(reason) },
|
|
@@ -86,6 +89,7 @@ export async function verifyChain(
|
|
|
86
89
|
if (!result.ok) return broken(result.failedAtSeq, result.reason);
|
|
87
90
|
const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
|
|
88
91
|
if ('failedAtSeq' in moved) return broken(moved.failedAtSeq, moved.reason);
|
|
92
|
+
onBatch(batch);
|
|
89
93
|
verified = {
|
|
90
94
|
nextSeq: result.nextSeq,
|
|
91
95
|
hash: result.head,
|
|
@@ -104,7 +108,7 @@ export async function verifyChain(
|
|
|
104
108
|
* even one that leaked, verifies what came before the rotation, and nothing
|
|
105
109
|
* after it.
|
|
106
110
|
*/
|
|
107
|
-
function forward(
|
|
111
|
+
export function forward(
|
|
108
112
|
entries: readonly StoredEntry[],
|
|
109
113
|
keys: readonly string[],
|
|
110
114
|
current: string,
|
|
@@ -130,7 +134,7 @@ function forward(
|
|
|
130
134
|
* signing key, or from its KEK when it has none. The second is the one an
|
|
131
135
|
* operator can fix.
|
|
132
136
|
*/
|
|
133
|
-
function withCause(reason: string): string {
|
|
137
|
+
export function withCause(reason: string): string {
|
|
134
138
|
if (!/^written under vault:\S+, a key this verifier does not hold$/.test(reason)) return reason;
|
|
135
139
|
return `${reason}: either it is forged, or the vault wrote it under another vault key or signing key, which must stay configured: a vault key that was replaced stays in the vault's config, in previousKeks`;
|
|
136
140
|
}
|
package/src/replay.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
import type { StoredEntry } from '@coffre/core/audit';
|
|
2
2
|
import type { AccessFault, FaultGrant } from '@coffre/core/vault';
|
|
3
|
-
import type { Queryable } from '@coffre/db';
|
|
4
3
|
|
|
5
|
-
import { ACCESS_ACTIONS,
|
|
4
|
+
import { ACCESS_ACTIONS, type GrantRow, type Member, type Place } from './store.ts';
|
|
6
5
|
|
|
7
6
|
export { ACCESS_ACTIONS };
|
|
8
7
|
|
|
9
|
-
const BATCH = 1000;
|
|
10
|
-
|
|
11
8
|
/**
|
|
12
9
|
* Why the members and grants in the database do not follow from the
|
|
13
10
|
* vault's entries, or null when they do; `describeAccessFault` words it.
|
|
@@ -17,25 +14,15 @@ const BATCH = 1000;
|
|
|
17
14
|
* written around the vault: a grant inserted with the database's own login,
|
|
18
15
|
* a removal undone.
|
|
19
16
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
17
|
+
* `state` is the allowed access entries replayed (`apply`), each after its
|
|
18
|
+
* MAC was checked, in the snapshot `rows` and `stored` were read in.
|
|
22
19
|
*
|
|
23
20
|
* Grants are compared as they are live at `at`. Clearing one that has
|
|
24
21
|
* lapsed changes nothing anyone holds, so it is not logged.
|
|
25
22
|
*/
|
|
26
|
-
export
|
|
27
|
-
const state: Replayed = { members: new Map(), held: new Map() };
|
|
28
|
-
for (let after = -1n; ; ) {
|
|
29
|
-
const batch = await vaultEntriesOf(db, ACCESS_ACTIONS, after, BATCH);
|
|
30
|
-
for (const row of batch) {
|
|
31
|
-
const fault = apply(state, row);
|
|
32
|
-
if (fault !== null) return fault;
|
|
33
|
-
}
|
|
34
|
-
if (batch.length < BATCH) break;
|
|
35
|
-
after = batch[batch.length - 1].seq;
|
|
36
|
-
}
|
|
23
|
+
export function replayFault(state: Replayed, rows: readonly Member[], storedGrants: readonly GrantRow[], at: number): AccessFault | null {
|
|
37
24
|
const { members, held } = state;
|
|
38
|
-
const stored = new Map(
|
|
25
|
+
const stored = new Map(rows.map((member) => [member.principal, member]));
|
|
39
26
|
for (const principal of [...new Set([...stored.keys(), ...members.keys()])].sort()) {
|
|
40
27
|
const [inStore, inLog] = [stored.get(principal), members.get(principal)];
|
|
41
28
|
if (inLog === undefined) return { kind: 'unlogged-member', principal };
|
|
@@ -46,7 +33,7 @@ export async function replay(db: Queryable, at: number): Promise<AccessFault | n
|
|
|
46
33
|
|
|
47
34
|
const live = (grant: GrantRow) => grant.expiresAt === null || grant.expiresAt > at;
|
|
48
35
|
const logged = new Set([...held.values()].flatMap((grantsOf) => [...grantsOf.values()]).filter(live).map(grantKey));
|
|
49
|
-
const inStore = new Set(
|
|
36
|
+
const inStore = new Set(storedGrants.filter(live).map(grantKey));
|
|
50
37
|
const extra = [...inStore].sort().find((grant) => !logged.has(grant));
|
|
51
38
|
if (extra !== undefined) return { kind: 'unlogged-grant', grant: faultGrant(extra) };
|
|
52
39
|
const missing = [...logged].sort().find((grant) => !inStore.has(grant));
|
package/src/store.ts
CHANGED
|
@@ -287,6 +287,35 @@ export async function entriesFrom(db: Queryable, fromSeq: bigint, limit: number)
|
|
|
287
287
|
return stored(rows);
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
/** Up to `limit` of the vault's entries after `afterSeq`, oldest first: every one, whatever its action or decision. */
|
|
291
|
+
export async function vaultEntriesAfter(db: Queryable, afterSeq: bigint, limit: number): Promise<StoredEntry[]> {
|
|
292
|
+
const { auditLog } = tablesOf(db);
|
|
293
|
+
const rows = await db
|
|
294
|
+
.select(entryColumns(db))
|
|
295
|
+
.from(auditLog)
|
|
296
|
+
.where(and(eq(auditLog.author, 'vault' satisfies Author), gt(auditLog.seq, afterSeq)))
|
|
297
|
+
.orderBy(asc(auditLog.seq))
|
|
298
|
+
.limit(limit);
|
|
299
|
+
return stored(rows);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The hash of the entry at each of `seqs` the log holds, by seq. */
|
|
303
|
+
export async function hashesAt(db: Queryable, seqs: readonly bigint[]): Promise<Map<bigint, Buffer>> {
|
|
304
|
+
const { auditLog } = tablesOf(db);
|
|
305
|
+
const hashes = new Map<bigint, Buffer>();
|
|
306
|
+
for (let from = 0; from < seqs.length; from += HASHES_AT_ONCE) {
|
|
307
|
+
const rows = await db
|
|
308
|
+
.select({ seq: auditLog.seq, hash: auditLog.hash })
|
|
309
|
+
.from(auditLog)
|
|
310
|
+
.where(inArray(auditLog.seq, seqs.slice(from, from + HASHES_AT_ONCE)));
|
|
311
|
+
for (const { seq, hash } of rows) hashes.set(seq, hash);
|
|
312
|
+
}
|
|
313
|
+
return hashes;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
|
|
317
|
+
const HASHES_AT_ONCE = 5000;
|
|
318
|
+
|
|
290
319
|
/** The entry at `seq`'s hash, or undefined when there is none. */
|
|
291
320
|
export async function hashAt(db: Queryable, seq: bigint): Promise<Buffer | undefined> {
|
|
292
321
|
const { auditLog } = tablesOf(db);
|
package/src/vault.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
type Permission,
|
|
10
10
|
type Role,
|
|
11
11
|
} from '@coffre/core/access';
|
|
12
|
-
import { verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
12
|
+
import { GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
|
|
13
13
|
import { checkContext, type SecretContext } from '@coffre/core/envelope';
|
|
14
14
|
import {
|
|
15
15
|
DEK_BYTES,
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from '@coffre/core/kek';
|
|
27
27
|
import {
|
|
28
28
|
checkpointMessage,
|
|
29
|
-
|
|
29
|
+
checkpointVerifier,
|
|
30
30
|
describeAccessFault,
|
|
31
31
|
type Access,
|
|
32
32
|
type AccessChange,
|
|
@@ -56,11 +56,11 @@ import type { Database, Queryable, Transaction } from '@coffre/db';
|
|
|
56
56
|
import { isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
|
|
57
57
|
import { appendEntries, lockLogHead, type Appended, type NewEntry } from '@coffre/db/log';
|
|
58
58
|
|
|
59
|
-
import {
|
|
59
|
+
import { KeyAccounting } from './accounting.ts';
|
|
60
60
|
import { signer, type Signer } from './checkpoint.ts';
|
|
61
61
|
import type { ResolvedVaultConfig } from './config.ts';
|
|
62
|
-
import { carries, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, type Anchor } from './log.ts';
|
|
63
|
-
import { apply,
|
|
62
|
+
import { carries, forward, further, UNVERIFIED, vaultLogKey, VERIFY_BATCH, verifyChain, withCause, type Anchor } from './log.ts';
|
|
63
|
+
import { apply, replayFault, type LoggedMember, type Replayed } from './replay.ts';
|
|
64
64
|
import { memberMac, rowKey, sameGrants, sealed } from './rows.ts';
|
|
65
65
|
import * as store from './store.ts';
|
|
66
66
|
import { ACCESS_ACTIONS, type GrantRow, type Member } from './store.ts';
|
|
@@ -1456,94 +1456,176 @@ class VaultService implements Vault {
|
|
|
1456
1456
|
}
|
|
1457
1457
|
|
|
1458
1458
|
verifyLog(input: VerifyLogInput): Promise<LogVerification> {
|
|
1459
|
-
return this.#verifyAll(
|
|
1460
|
-
}
|
|
1461
|
-
|
|
1462
|
-
/** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
|
|
1463
|
-
async #verify(db: Queryable, shown: readonly StoredEntry[], anchor: Anchor): Promise<LogVerification> {
|
|
1464
|
-
const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
|
|
1465
|
-
if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
|
|
1466
|
-
return verification;
|
|
1459
|
+
return this.#verifyAll(input.upTo ?? null);
|
|
1467
1460
|
}
|
|
1468
1461
|
|
|
1469
1462
|
/**
|
|
1470
|
-
*
|
|
1471
|
-
*
|
|
1472
|
-
*
|
|
1473
|
-
*
|
|
1474
|
-
*
|
|
1475
|
-
*
|
|
1463
|
+
* The whole log, in one snapshot and one pass over the vault's entries:
|
|
1464
|
+
* each by its MAC, the keys it moved through, and what the other checks
|
|
1465
|
+
* need of it, gathered as it goes (`Findings`); then every checkpoint, the
|
|
1466
|
+
* heads that must still be there, the key batches accounted for, and the
|
|
1467
|
+
* members and grants replayed.
|
|
1468
|
+
*
|
|
1469
|
+
* With `upTo`, the head the app verified to, the links and hashes of the
|
|
1470
|
+
* chain are the app's to recompute, as it just has, over its own snapshot,
|
|
1471
|
+
* through that head; `carries(upTo)` ties this snapshot to that one. So
|
|
1472
|
+
* the vault reads only its own entries, each checked against its own hash
|
|
1473
|
+
* and MAC over the link it names. Without `upTo`, nobody else recomputed
|
|
1474
|
+
* the chain: the vault reads it all, and rehashes every link.
|
|
1476
1475
|
*/
|
|
1477
|
-
async #verifyAll(
|
|
1476
|
+
async #verifyAll(upTo: LogHead | null): Promise<LogVerification> {
|
|
1478
1477
|
await this.#settled();
|
|
1479
1478
|
return this.#db.transaction(async (tx) => {
|
|
1480
1479
|
const remembered = this.#prepared.verified;
|
|
1481
|
-
const
|
|
1482
|
-
|
|
1483
|
-
if (
|
|
1480
|
+
const findings = new Findings();
|
|
1481
|
+
const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
|
|
1482
|
+
if (!read.ok) return read;
|
|
1483
|
+
// Each head the log must still hold, in one read: the app's, every checkpoint's, and the last this vault rehashed.
|
|
1484
|
+
const heads: LogHead[] = [...(upTo === null ? [] : [upTo]), ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
|
|
1485
|
+
const rememberedHead = remembered.nextSeq > 0n ? { seq: Number(remembered.nextSeq - 1n), hash: remembered.hash.toString('hex') } : null;
|
|
1486
|
+
if (rememberedHead !== null) heads.push(rememberedHead);
|
|
1487
|
+
const hashes = await store.hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
|
|
1488
|
+
const holds = (head: LogHead) => head.hash === GENESIS_HASH.toString('hex') || hashes.get(BigInt(head.seq))?.toString('hex') === head.hash;
|
|
1489
|
+
if (upTo !== null && !holds(upTo)) {
|
|
1484
1490
|
return { ok: false, failedAtSeq: upTo.seq, reason: 'not the entry the app verified up to: the log changed between the two checks' };
|
|
1485
1491
|
}
|
|
1486
|
-
const unsigned = await this.#checkpointFault(
|
|
1492
|
+
const unsigned = await this.#checkpointFault(findings, holds);
|
|
1487
1493
|
if (unsigned !== null) return unsigned;
|
|
1488
1494
|
// What this vault last verified must still be there: whoever holds
|
|
1489
1495
|
// its key can seal a rewrite, but cannot put back the head it saw. A
|
|
1490
1496
|
// checkpoint names an earlier break, so it is reported first.
|
|
1491
|
-
if (
|
|
1492
|
-
|
|
1493
|
-
if (!(await carries(tx, { seq: Number(seq), hash: remembered.hash.toString('hex') }))) {
|
|
1494
|
-
return { ok: false, failedAtSeq: Number(seq), reason: 'changed since the vault last verified it' };
|
|
1495
|
-
}
|
|
1497
|
+
if (rememberedHead !== null && !holds(rememberedHead)) {
|
|
1498
|
+
return { ok: false, failedAtSeq: rememberedHead.seq, reason: 'changed since the vault last verified it' };
|
|
1496
1499
|
}
|
|
1497
1500
|
const at = await this.#now(tx);
|
|
1498
|
-
const accounting =
|
|
1501
|
+
const accounting = findings.accounting.result(at);
|
|
1499
1502
|
if (!accounting.ok) return accounting;
|
|
1500
|
-
const
|
|
1503
|
+
const [rows, held] = await Promise.all([store.allMembers(tx), store.grants(tx)]);
|
|
1504
|
+
const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
|
|
1501
1505
|
if (fault !== null) return { ok: false, failedAtSeq: null, reason: describeAccessFault(fault), fault };
|
|
1502
|
-
|
|
1506
|
+
const verified = { ok: true as const, entries: findings.entries };
|
|
1507
|
+
return accounting.pending === 0 ? verified : { ...verified, pending: accounting.pending };
|
|
1503
1508
|
}, SNAPSHOT);
|
|
1504
1509
|
}
|
|
1505
1510
|
|
|
1506
1511
|
/**
|
|
1507
|
-
*
|
|
1508
|
-
*
|
|
1509
|
-
*
|
|
1510
|
-
* and one under a key it replaced comes before the rotation.
|
|
1512
|
+
* The vault's own entries, oldest first: each against its own hash, and by
|
|
1513
|
+
* its MAC, over the link it names, which the app checked; and the vault's
|
|
1514
|
+
* keys moving only forward through them.
|
|
1511
1515
|
*/
|
|
1512
|
-
async #
|
|
1516
|
+
async #ownEntries(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
|
|
1517
|
+
const { logKeys } = this.#prepared;
|
|
1518
|
+
let keys: readonly string[] = [];
|
|
1513
1519
|
for (let after = -1n; ; ) {
|
|
1514
|
-
const batch = await store.
|
|
1520
|
+
const batch = await store.vaultEntriesAfter(tx, after, VERIFY_BATCH);
|
|
1515
1521
|
for (const entry of batch) {
|
|
1516
|
-
const
|
|
1517
|
-
|
|
1518
|
-
|
|
1522
|
+
const result = verifyEntries([entry], { startSeq: entry.seq, startPrevHash: entry.prevHash, keys: logKeys });
|
|
1523
|
+
if (!result.ok) return { ok: false, failedAtSeq: Number(result.failedAtSeq), reason: withCause(result.reason) };
|
|
1524
|
+
}
|
|
1525
|
+
const moved = forward(batch, keys, logKeys[0]!.keyId);
|
|
1526
|
+
if ('failedAtSeq' in moved) return { ok: false, failedAtSeq: Number(moved.failedAtSeq), reason: moved.reason };
|
|
1527
|
+
keys = moved.keys;
|
|
1528
|
+
for (const entry of batch) findings.add(entry);
|
|
1529
|
+
if (batch.length < VERIFY_BATCH) return { ok: true };
|
|
1530
|
+
after = batch[batch.length - 1]!.seq;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
|
|
1535
|
+
async #rehashed(tx: Queryable, findings: Findings): Promise<{ ok: true } | Extract<LogVerification, { ok: false }>> {
|
|
1536
|
+
const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
|
|
1537
|
+
for (const entry of batch) if (entry.author === 'vault') findings.add(entry);
|
|
1538
|
+
});
|
|
1539
|
+
if (!verification.ok) return verification;
|
|
1540
|
+
this.#prepared.verified = further(this.#prepared.verified, anchor);
|
|
1541
|
+
return { ok: true };
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Every checkpoint, not only the newest: each signed by the key whose log
|
|
1546
|
+
* key wrote its entry, over a prefix the log still holds (`holds`), entry
|
|
1547
|
+
* for entry. Its entry's MAC is checked by now, and one under a key the
|
|
1548
|
+
* vault replaced comes before the rotation.
|
|
1549
|
+
*/
|
|
1550
|
+
async #checkpointFault(findings: Findings, holds: (head: LogHead) => boolean): Promise<Extract<LogVerification, { ok: false }> | null> {
|
|
1551
|
+
const { logKeys, signers } = this.#prepared;
|
|
1552
|
+
// Each signer's key imported once, for this check only.
|
|
1553
|
+
const verifiers = new Map<string, Promise<(checkpoint: Checkpoint) => Promise<boolean>>>();
|
|
1554
|
+
const signed = (checkpoint: Checkpoint, by: Signer) => {
|
|
1555
|
+
let verifier = verifiers.get(by.keyId);
|
|
1556
|
+
if (verifier === undefined) verifiers.set(by.keyId, (verifier = checkpointVerifier(by.publicKey)));
|
|
1557
|
+
return verifier.then((verify) => verify(checkpoint));
|
|
1558
|
+
};
|
|
1559
|
+
const { checkpoints } = findings;
|
|
1560
|
+
for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
|
|
1561
|
+
const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
|
|
1562
|
+
// The signatures of a chunk at once, where crypto runs beside the code; the first fault, in the log's order.
|
|
1563
|
+
const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
|
|
1519
1564
|
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
1520
|
-
if (by?.keyId !== checkpoint.keyId) return
|
|
1521
|
-
if (!(await
|
|
1522
|
-
if (BigInt(checkpoint.seq) >= entry.seq || !(
|
|
1523
|
-
return
|
|
1565
|
+
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`;
|
|
1566
|
+
if (!(await signed(checkpoint, by))) return 'a checkpoint the vault did not sign';
|
|
1567
|
+
if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) {
|
|
1568
|
+
return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
|
|
1524
1569
|
}
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1570
|
+
return null;
|
|
1571
|
+
}));
|
|
1572
|
+
const at = faults.findIndex((fault) => fault !== null);
|
|
1573
|
+
if (at !== -1) return { ok: false, failedAtSeq: Number(chunk[at]!.entry.seq), reason: faults[at]! };
|
|
1528
1574
|
}
|
|
1575
|
+
return null;
|
|
1529
1576
|
}
|
|
1530
1577
|
|
|
1531
1578
|
/**
|
|
1532
1579
|
* The first member whose row fails its MAC, or names an older access
|
|
1533
|
-
* entry than the log's newest about them
|
|
1534
|
-
*
|
|
1580
|
+
* entry than the log's newest about them (`newest`, from the full check's
|
|
1581
|
+
* pass, where each entry's MAC was checked).
|
|
1535
1582
|
*/
|
|
1536
|
-
|
|
1537
|
-
const [rows, held, newest] = await Promise.all([store.allMembers(db), store.grants(db), store.newestAccessEntries(db)]);
|
|
1583
|
+
#unsealed(rows: readonly Member[], held: readonly GrantRow[], newest: ReadonlyMap<string, bigint>): AccessFault | null {
|
|
1538
1584
|
for (const row of rows) {
|
|
1539
1585
|
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
1540
1586
|
if (!this.#sealed(row, grants)) return { kind: 'tampered-member', principal: row.principal, why: 'mac' };
|
|
1541
|
-
if (newest.get(row.principal)
|
|
1587
|
+
if (newest.get(row.principal) !== row.accessSeq) return { kind: 'tampered-member', principal: row.principal, why: 'stale' };
|
|
1542
1588
|
}
|
|
1543
1589
|
return null;
|
|
1544
1590
|
}
|
|
1545
1591
|
}
|
|
1546
1592
|
|
|
1593
|
+
/**
|
|
1594
|
+
* What one pass over the vault's entries gathers for the other checks of a
|
|
1595
|
+
* full verification, so that the log is read once: the key batches, the
|
|
1596
|
+
* access entries replayed, each member's newest, and the checkpoints. Each
|
|
1597
|
+
* entry comes after its MAC was checked.
|
|
1598
|
+
*/
|
|
1599
|
+
class Findings {
|
|
1600
|
+
readonly accounting = new KeyAccounting();
|
|
1601
|
+
readonly replayed: Replayed = { members: new Map(), held: new Map() };
|
|
1602
|
+
/** The first access entry the replay could not apply. */
|
|
1603
|
+
replayFault: AccessFault | null = null;
|
|
1604
|
+
/** Each member's newest allowed access entry, by seq. */
|
|
1605
|
+
readonly newestAccess = new Map<string, bigint>();
|
|
1606
|
+
/** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
|
|
1607
|
+
readonly checkpoints: { entry: { seq: bigint; keyId: string }; checkpoint: Checkpoint }[] = [];
|
|
1608
|
+
/** The vault's entries read. */
|
|
1609
|
+
entries = 0;
|
|
1610
|
+
|
|
1611
|
+
add(entry: StoredEntry): void {
|
|
1612
|
+
this.entries += 1;
|
|
1613
|
+
this.accounting.add(entry);
|
|
1614
|
+
if (entry.decision !== 'allow') return;
|
|
1615
|
+
if (ACCESS.has(entry.action)) {
|
|
1616
|
+
this.replayFault ??= apply(this.replayed, entry);
|
|
1617
|
+
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
1618
|
+
} else if (entry.action === CHECKPOINT) {
|
|
1619
|
+
this.checkpoints.push({ entry: { seq: entry.seq, keyId: entry.keyId }, checkpoint: JSON.parse(entry.metadata) as Checkpoint });
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
const ACCESS = new Set<string>(ACCESS_ACTIONS);
|
|
1625
|
+
|
|
1626
|
+
/** Checkpoint signatures checked at once, in a full verification. */
|
|
1627
|
+
const SIGNATURES_AT_ONCE = 256;
|
|
1628
|
+
|
|
1547
1629
|
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
1548
1630
|
async function settle<T extends { wipe: () => void }>(
|
|
1549
1631
|
operations: ((operation: KeyOperation) => Promise<T | null>)[],
|