@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/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-DfviNOts.js → vault-3O-ynZPg.js} +360 -259
- 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 +146 -88
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.1.
|
|
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/
|
|
40
|
-
"@coffre/
|
|
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
|
-
/**
|
|
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);
|