@coffre/vault 0.1.11 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
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/db": "0.1.11",
40
- "@coffre/core": "0.1.11"
39
+ "@coffre/core": "0.1.13",
40
+ "@coffre/db": "0.1.13"
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
- /** The chain can hold while a process died between an intent and its outcomes. */
17
- export async function verifyAccounting(db: Queryable, at: number): Promise<Accounting> {
18
- const intents = new Map<bigint, Intent>();
19
- const ids = new Set<string>();
20
- const broken = (entry: StoredEntry, reason: string): Accounting => ({ ok: false, failedAtSeq: Number(entry.seq), reason });
21
- let next = 0n;
22
- for (;;) {
23
- const batch = await entriesFrom(db, next, VERIFY_BATCH);
24
- if (batch.length === 0) break;
25
- for (const entry of batch) {
26
- if (entry.author !== 'vault') continue;
27
- if (entry.action === 'key.intent') {
28
- const parsed = parseIntent(entry);
29
- if (typeof parsed === 'string') return broken(entry, parsed);
30
- if (ids.has(parsed.intentId)) return broken(entry, 'key intent repeats an operation identity');
31
- ids.add(parsed.intentId);
32
- if (parsed.keys.length > 0) intents.set(entry.seq, { ...parsed, entry, seen: new Set() });
33
- } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
34
- // An outcome names its intent; a key released with no service to call has none.
35
- const parsed = parseOutcome(entry);
36
- if (typeof parsed === 'string') return broken(entry, parsed);
37
- const intent = intents.get(parsed.relatedSeq);
38
- const key = intent?.keys[parsed.item];
39
- if (intent === undefined || key === undefined || intent.seen.has(parsed.item)) {
40
- return broken(entry, 'key outcome does not identify one item of its intent');
41
- }
42
- if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) {
43
- return broken(entry, 'key outcome belongs to another operation');
44
- }
45
- if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) {
46
- return broken(entry, 'key outcome does not match its intended secret');
47
- }
48
- intent.seen.add(parsed.item);
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
- // Live calls can finish after this snapshot. Only a missed deadline is a fault.
57
- const overdue = [...intents.values()].filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
58
- if (overdue.length === 0) return { ok: true, pending: intents.size };
59
- const reason = overdue.map((intent) => {
60
- const missing = intent.keys.length - intent.seen.size;
61
- return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
62
- }).join('; ');
63
- return broken(overdue[0].entry, reason);
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
- export const VERIFY_BATCH = 1000;
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, allMembers, grants, vaultEntriesOf, type GrantRow, type Member, type Place } from './store.ts';
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
- * Call it after the chain is verified, in the same snapshot, so every entry
21
- * it replays carries the vault's MAC.
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 async function replay(db: Queryable, at: number): Promise<AccessFault | null> {
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((await allMembers(db)).map((member) => [member.principal, member]));
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((await grants(db)).filter(live).map(grantKey));
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);