@coffre/vault 0.1.12 → 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-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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ACCESS_ACTIONS, checkpointMessage,
|
|
1
|
+
import { ACCESS_ACTIONS, checkpointMessage, checkpointVerifier, describeAccessFault } from "@coffre/core/vault";
|
|
2
2
|
import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
|
|
3
3
|
import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { tablesOf } from "@coffre/db";
|
|
@@ -8,6 +8,164 @@ import { checkContext } from "@coffre/core/envelope";
|
|
|
8
8
|
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation } from "@coffre/db/dialect";
|
|
9
9
|
import { appendEntries, lockLogHead } from "@coffre/db/log";
|
|
10
10
|
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, sql } from "drizzle-orm";
|
|
11
|
+
//#region src/accounting.ts
|
|
12
|
+
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
13
|
+
"secret.read",
|
|
14
|
+
"key.wrap",
|
|
15
|
+
"key.rewrap"
|
|
16
|
+
]);
|
|
17
|
+
/**
|
|
18
|
+
* Every key operation's intent and its outcomes, entry by entry, oldest
|
|
19
|
+
* first, as the full check reads the vault's entries (`add`); then what is
|
|
20
|
+
* still open (`result`). The chain can hold while a process died between an
|
|
21
|
+
* intent and its outcomes: only a missed deadline is a fault.
|
|
22
|
+
*/
|
|
23
|
+
var KeyAccounting = class {
|
|
24
|
+
#intents = /* @__PURE__ */ new Map();
|
|
25
|
+
#ids = /* @__PURE__ */ new Set();
|
|
26
|
+
#fault = null;
|
|
27
|
+
add(entry) {
|
|
28
|
+
if (this.#fault !== null || entry.author !== "vault") return;
|
|
29
|
+
const broken = (reason) => {
|
|
30
|
+
this.#fault = {
|
|
31
|
+
ok: false,
|
|
32
|
+
failedAtSeq: Number(entry.seq),
|
|
33
|
+
reason
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
if (entry.action === "key.intent") {
|
|
37
|
+
const parsed = parseIntent(entry);
|
|
38
|
+
if (typeof parsed === "string") return broken(parsed);
|
|
39
|
+
if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
|
|
40
|
+
this.#ids.add(parsed.intentId);
|
|
41
|
+
if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
|
|
42
|
+
...parsed,
|
|
43
|
+
entry,
|
|
44
|
+
seen: /* @__PURE__ */ new Set()
|
|
45
|
+
});
|
|
46
|
+
} else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
|
|
47
|
+
const parsed = parseOutcome(entry);
|
|
48
|
+
if (typeof parsed === "string") return broken(parsed);
|
|
49
|
+
const intent = this.#intents.get(parsed.relatedSeq);
|
|
50
|
+
const key = intent?.keys[parsed.item];
|
|
51
|
+
if (intent === void 0 || key === void 0 || intent.seen.has(parsed.item)) return broken("key outcome does not identify one item of its intent");
|
|
52
|
+
if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
|
|
53
|
+
if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) return broken("key outcome does not match its intended secret");
|
|
54
|
+
intent.seen.add(parsed.item);
|
|
55
|
+
if (intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
|
|
59
|
+
result(at) {
|
|
60
|
+
if (this.#fault !== null) return this.#fault;
|
|
61
|
+
const intents = [...this.#intents.values()];
|
|
62
|
+
const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
|
|
63
|
+
if (overdue.length === 0) return {
|
|
64
|
+
ok: true,
|
|
65
|
+
pending: intents.length
|
|
66
|
+
};
|
|
67
|
+
const reason = overdue.map((intent) => {
|
|
68
|
+
const missing = intent.keys.length - intent.seen.size;
|
|
69
|
+
return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
|
|
70
|
+
}).join("; ");
|
|
71
|
+
return {
|
|
72
|
+
ok: false,
|
|
73
|
+
failedAtSeq: Number(overdue[0].entry.seq),
|
|
74
|
+
reason
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
function parseIntent(entry) {
|
|
79
|
+
const detail = payload(entry.metadata);
|
|
80
|
+
if (detail === null) return "key intent has no valid accounting payload";
|
|
81
|
+
const { intent, operation, expiresAt } = detail;
|
|
82
|
+
if (typeof intent !== "string") return "key intent has no operation identity";
|
|
83
|
+
if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
|
|
84
|
+
if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
|
|
85
|
+
if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
|
|
86
|
+
const keys = [];
|
|
87
|
+
for (const [index, value] of detail.keys.entries()) {
|
|
88
|
+
const key = record(value);
|
|
89
|
+
if (key === null || key.item !== index) return "key intent items are not numbered in order";
|
|
90
|
+
const { secretId, version, subject } = key;
|
|
91
|
+
if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
|
|
92
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
|
|
93
|
+
keys.push({
|
|
94
|
+
item: index,
|
|
95
|
+
secretId,
|
|
96
|
+
version,
|
|
97
|
+
subject
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
intentId: intent,
|
|
102
|
+
operation,
|
|
103
|
+
expiresAt,
|
|
104
|
+
keys
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function parseOutcome(entry) {
|
|
108
|
+
const detail = payload(entry.metadata);
|
|
109
|
+
if (detail === null) return "key outcome has no valid accounting payload";
|
|
110
|
+
const { intent, item, version, subject } = detail;
|
|
111
|
+
if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
|
|
112
|
+
if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
|
|
113
|
+
const secretId = entry.secretId ?? detail.secretId;
|
|
114
|
+
if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
|
|
115
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
|
|
116
|
+
return {
|
|
117
|
+
intentId: intent,
|
|
118
|
+
relatedSeq: entry.relatedSeq,
|
|
119
|
+
item,
|
|
120
|
+
secretId,
|
|
121
|
+
version,
|
|
122
|
+
subject
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function payload(text) {
|
|
126
|
+
try {
|
|
127
|
+
return record(JSON.parse(text));
|
|
128
|
+
} catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
function record(value) {
|
|
133
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
134
|
+
}
|
|
135
|
+
//#endregion
|
|
136
|
+
//#region src/checkpoint.ts
|
|
137
|
+
const PKCS8_ED25519 = [
|
|
138
|
+
48,
|
|
139
|
+
46,
|
|
140
|
+
2,
|
|
141
|
+
1,
|
|
142
|
+
0,
|
|
143
|
+
48,
|
|
144
|
+
5,
|
|
145
|
+
6,
|
|
146
|
+
3,
|
|
147
|
+
43,
|
|
148
|
+
101,
|
|
149
|
+
112,
|
|
150
|
+
4,
|
|
151
|
+
34,
|
|
152
|
+
4,
|
|
153
|
+
32
|
|
154
|
+
];
|
|
155
|
+
/** An Ed25519 signer from a 32-byte seed, over WebCrypto so it runs in Node and in workerd alike. */
|
|
156
|
+
async function signer(seed) {
|
|
157
|
+
const pkcs8 = new Uint8Array([...PKCS8_ED25519, ...seed]);
|
|
158
|
+
const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
|
|
159
|
+
const { x } = await crypto.subtle.exportKey("jwk", privateKey);
|
|
160
|
+
const publicKey = Buffer.from(x, "base64url");
|
|
161
|
+
const digest = Buffer.from(await crypto.subtle.digest("SHA-256", publicKey));
|
|
162
|
+
return {
|
|
163
|
+
publicKey: publicKey.toString("base64"),
|
|
164
|
+
keyId: digest.toString("hex").slice(0, 16),
|
|
165
|
+
sign: async (message) => Buffer.from(await crypto.subtle.sign("Ed25519", privateKey, message)).toString("base64")
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
//#endregion
|
|
11
169
|
//#region src/store.ts
|
|
12
170
|
/**
|
|
13
171
|
* On Postgres, fail a lock wait in this transaction after `ms`, rather than
|
|
@@ -178,6 +336,26 @@ async function entriesFrom(db, fromSeq, limit) {
|
|
|
178
336
|
const { auditLog } = tablesOf(db);
|
|
179
337
|
return stored(await db.select(entryColumns(db)).from(auditLog).where(gte(auditLog.seq, fromSeq)).orderBy(asc(auditLog.seq)).limit(limit));
|
|
180
338
|
}
|
|
339
|
+
/** Up to `limit` of the vault's entries after `afterSeq`, oldest first: every one, whatever its action or decision. */
|
|
340
|
+
async function vaultEntriesAfter(db, afterSeq, limit) {
|
|
341
|
+
const { auditLog } = tablesOf(db);
|
|
342
|
+
return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), gt(auditLog.seq, afterSeq))).orderBy(asc(auditLog.seq)).limit(limit));
|
|
343
|
+
}
|
|
344
|
+
/** The hash of the entry at each of `seqs` the log holds, by seq. */
|
|
345
|
+
async function hashesAt(db, seqs) {
|
|
346
|
+
const { auditLog } = tablesOf(db);
|
|
347
|
+
const hashes = /* @__PURE__ */ new Map();
|
|
348
|
+
for (let from = 0; from < seqs.length; from += HASHES_AT_ONCE) {
|
|
349
|
+
const rows = await db.select({
|
|
350
|
+
seq: auditLog.seq,
|
|
351
|
+
hash: auditLog.hash
|
|
352
|
+
}).from(auditLog).where(inArray(auditLog.seq, seqs.slice(from, from + HASHES_AT_ONCE)));
|
|
353
|
+
for (const { seq, hash } of rows) hashes.set(seq, hash);
|
|
354
|
+
}
|
|
355
|
+
return hashes;
|
|
356
|
+
}
|
|
357
|
+
/** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
|
|
358
|
+
const HASHES_AT_ONCE = 5e3;
|
|
181
359
|
/** The entry at `seq`'s hash, or undefined when there is none. */
|
|
182
360
|
async function hashAt(db, seq) {
|
|
183
361
|
const { auditLog } = tablesOf(db);
|
|
@@ -283,7 +461,8 @@ const UNVERIFIED = {
|
|
|
283
461
|
function further(a, b) {
|
|
284
462
|
return b.nextSeq > a.nextSeq ? b : a;
|
|
285
463
|
}
|
|
286
|
-
|
|
464
|
+
/** Entries read at once: a few megabytes, well within a Worker's memory, and a fifth of the round trips of 1000. */
|
|
465
|
+
const VERIFY_BATCH = 5e3;
|
|
287
466
|
/**
|
|
288
467
|
* Check the chain, a batch in memory at a time, and return where it is now
|
|
289
468
|
* verified to. Three parts:
|
|
@@ -297,9 +476,10 @@ const VERIFY_BATCH = 1e3;
|
|
|
297
476
|
* So a view rehashes only what is new since the last one. What it leaves
|
|
298
477
|
* out is an entry before the anchor edited in place, not chained again, and
|
|
299
478
|
* not on the page: a full check, which starts from `UNVERIFIED`, finds that,
|
|
300
|
-
* as does the first view after a start, which has no anchor.
|
|
479
|
+
* as does the first view after a start, which has no anchor. `onBatch` sees
|
|
480
|
+
* each batch once it has verified, for checks that read the same entries.
|
|
301
481
|
*/
|
|
302
|
-
async function verifyChain(db, logKeys, shown, anchor) {
|
|
482
|
+
async function verifyChain(db, logKeys, shown, anchor, onBatch = () => {}) {
|
|
303
483
|
const broken = (failedAtSeq, reason) => ({
|
|
304
484
|
verification: {
|
|
305
485
|
ok: false,
|
|
@@ -333,13 +513,14 @@ async function verifyChain(db, logKeys, shown, anchor) {
|
|
|
333
513
|
if (!result.ok) return broken(result.failedAtSeq, result.reason);
|
|
334
514
|
const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
|
|
335
515
|
if ("failedAtSeq" in moved) return broken(moved.failedAtSeq, moved.reason);
|
|
516
|
+
onBatch(batch);
|
|
336
517
|
verified = {
|
|
337
518
|
nextSeq: result.nextSeq,
|
|
338
519
|
hash: result.head,
|
|
339
520
|
vaultEntries: verified.vaultEntries + result.authenticated,
|
|
340
521
|
vaultKeys: moved.keys
|
|
341
522
|
};
|
|
342
|
-
if (batch.length <
|
|
523
|
+
if (batch.length < 5e3) break;
|
|
343
524
|
}
|
|
344
525
|
return {
|
|
345
526
|
verification: {
|
|
@@ -389,156 +570,7 @@ async function carries(db, head) {
|
|
|
389
570
|
return (await hashAt(db, BigInt(head.seq)))?.toString("hex") === head.hash;
|
|
390
571
|
}
|
|
391
572
|
//#endregion
|
|
392
|
-
//#region src/accounting.ts
|
|
393
|
-
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
394
|
-
"secret.read",
|
|
395
|
-
"key.wrap",
|
|
396
|
-
"key.rewrap"
|
|
397
|
-
]);
|
|
398
|
-
/** The chain can hold while a process died between an intent and its outcomes. */
|
|
399
|
-
async function verifyAccounting(db, at) {
|
|
400
|
-
const intents = /* @__PURE__ */ new Map();
|
|
401
|
-
const ids = /* @__PURE__ */ new Set();
|
|
402
|
-
const broken = (entry, reason) => ({
|
|
403
|
-
ok: false,
|
|
404
|
-
failedAtSeq: Number(entry.seq),
|
|
405
|
-
reason
|
|
406
|
-
});
|
|
407
|
-
let next = 0n;
|
|
408
|
-
for (;;) {
|
|
409
|
-
const batch = await entriesFrom(db, next, VERIFY_BATCH);
|
|
410
|
-
if (batch.length === 0) break;
|
|
411
|
-
for (const entry of batch) {
|
|
412
|
-
if (entry.author !== "vault") continue;
|
|
413
|
-
if (entry.action === "key.intent") {
|
|
414
|
-
const parsed = parseIntent(entry);
|
|
415
|
-
if (typeof parsed === "string") return broken(entry, parsed);
|
|
416
|
-
if (ids.has(parsed.intentId)) return broken(entry, "key intent repeats an operation identity");
|
|
417
|
-
ids.add(parsed.intentId);
|
|
418
|
-
if (parsed.keys.length > 0) intents.set(entry.seq, {
|
|
419
|
-
...parsed,
|
|
420
|
-
entry,
|
|
421
|
-
seen: /* @__PURE__ */ new Set()
|
|
422
|
-
});
|
|
423
|
-
} else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
|
|
424
|
-
const parsed = parseOutcome(entry);
|
|
425
|
-
if (typeof parsed === "string") return broken(entry, parsed);
|
|
426
|
-
const intent = intents.get(parsed.relatedSeq);
|
|
427
|
-
const key = intent?.keys[parsed.item];
|
|
428
|
-
if (intent === void 0 || key === void 0 || intent.seen.has(parsed.item)) return broken(entry, "key outcome does not identify one item of its intent");
|
|
429
|
-
if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken(entry, "key outcome belongs to another operation");
|
|
430
|
-
if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) return broken(entry, "key outcome does not match its intended secret");
|
|
431
|
-
intent.seen.add(parsed.item);
|
|
432
|
-
if (intent.seen.size === intent.keys.length) intents.delete(intent.entry.seq);
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
next = batch[batch.length - 1].seq + 1n;
|
|
436
|
-
if (batch.length < 1e3) break;
|
|
437
|
-
}
|
|
438
|
-
const overdue = [...intents.values()].filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
|
|
439
|
-
if (overdue.length === 0) return {
|
|
440
|
-
ok: true,
|
|
441
|
-
pending: intents.size
|
|
442
|
-
};
|
|
443
|
-
const reason = overdue.map((intent) => {
|
|
444
|
-
const missing = intent.keys.length - intent.seen.size;
|
|
445
|
-
return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
|
|
446
|
-
}).join("; ");
|
|
447
|
-
return broken(overdue[0].entry, reason);
|
|
448
|
-
}
|
|
449
|
-
function parseIntent(entry) {
|
|
450
|
-
const detail = payload(entry.metadata);
|
|
451
|
-
if (detail === null) return "key intent has no valid accounting payload";
|
|
452
|
-
const { intent, operation, expiresAt } = detail;
|
|
453
|
-
if (typeof intent !== "string") return "key intent has no operation identity";
|
|
454
|
-
if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
|
|
455
|
-
if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
|
|
456
|
-
if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
|
|
457
|
-
const keys = [];
|
|
458
|
-
for (const [index, value] of detail.keys.entries()) {
|
|
459
|
-
const key = record(value);
|
|
460
|
-
if (key === null || key.item !== index) return "key intent items are not numbered in order";
|
|
461
|
-
const { secretId, version, subject } = key;
|
|
462
|
-
if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
|
|
463
|
-
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
|
|
464
|
-
keys.push({
|
|
465
|
-
item: index,
|
|
466
|
-
secretId,
|
|
467
|
-
version,
|
|
468
|
-
subject
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
return {
|
|
472
|
-
intentId: intent,
|
|
473
|
-
operation,
|
|
474
|
-
expiresAt,
|
|
475
|
-
keys
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
function parseOutcome(entry) {
|
|
479
|
-
const detail = payload(entry.metadata);
|
|
480
|
-
if (detail === null) return "key outcome has no valid accounting payload";
|
|
481
|
-
const { intent, item, version, subject } = detail;
|
|
482
|
-
if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
|
|
483
|
-
if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
|
|
484
|
-
const secretId = entry.secretId ?? detail.secretId;
|
|
485
|
-
if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
|
|
486
|
-
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
|
|
487
|
-
return {
|
|
488
|
-
intentId: intent,
|
|
489
|
-
relatedSeq: entry.relatedSeq,
|
|
490
|
-
item,
|
|
491
|
-
secretId,
|
|
492
|
-
version,
|
|
493
|
-
subject
|
|
494
|
-
};
|
|
495
|
-
}
|
|
496
|
-
function payload(text) {
|
|
497
|
-
try {
|
|
498
|
-
return record(JSON.parse(text));
|
|
499
|
-
} catch {
|
|
500
|
-
return null;
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
function record(value) {
|
|
504
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
505
|
-
}
|
|
506
|
-
//#endregion
|
|
507
|
-
//#region src/checkpoint.ts
|
|
508
|
-
const PKCS8_ED25519 = [
|
|
509
|
-
48,
|
|
510
|
-
46,
|
|
511
|
-
2,
|
|
512
|
-
1,
|
|
513
|
-
0,
|
|
514
|
-
48,
|
|
515
|
-
5,
|
|
516
|
-
6,
|
|
517
|
-
3,
|
|
518
|
-
43,
|
|
519
|
-
101,
|
|
520
|
-
112,
|
|
521
|
-
4,
|
|
522
|
-
34,
|
|
523
|
-
4,
|
|
524
|
-
32
|
|
525
|
-
];
|
|
526
|
-
/** An Ed25519 signer from a 32-byte seed, over WebCrypto so it runs in Node and in workerd alike. */
|
|
527
|
-
async function signer(seed) {
|
|
528
|
-
const pkcs8 = new Uint8Array([...PKCS8_ED25519, ...seed]);
|
|
529
|
-
const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
|
|
530
|
-
const { x } = await crypto.subtle.exportKey("jwk", privateKey);
|
|
531
|
-
const publicKey = Buffer.from(x, "base64url");
|
|
532
|
-
const digest = Buffer.from(await crypto.subtle.digest("SHA-256", publicKey));
|
|
533
|
-
return {
|
|
534
|
-
publicKey: publicKey.toString("base64"),
|
|
535
|
-
keyId: digest.toString("hex").slice(0, 16),
|
|
536
|
-
sign: async (message) => Buffer.from(await crypto.subtle.sign("Ed25519", privateKey, message)).toString("base64")
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
|
-
//#endregion
|
|
540
573
|
//#region src/replay.ts
|
|
541
|
-
const BATCH = 1e3;
|
|
542
574
|
/**
|
|
543
575
|
* Why the members and grants in the database do not follow from the
|
|
544
576
|
* vault's entries, or null when they do; `describeAccessFault` words it.
|
|
@@ -548,28 +580,15 @@ const BATCH = 1e3;
|
|
|
548
580
|
* written around the vault: a grant inserted with the database's own login,
|
|
549
581
|
* a removal undone.
|
|
550
582
|
*
|
|
551
|
-
*
|
|
552
|
-
*
|
|
583
|
+
* `state` is the allowed access entries replayed (`apply`), each after its
|
|
584
|
+
* MAC was checked, in the snapshot `rows` and `stored` were read in.
|
|
553
585
|
*
|
|
554
586
|
* Grants are compared as they are live at `at`. Clearing one that has
|
|
555
587
|
* lapsed changes nothing anyone holds, so it is not logged.
|
|
556
588
|
*/
|
|
557
|
-
|
|
558
|
-
const state = {
|
|
559
|
-
members: /* @__PURE__ */ new Map(),
|
|
560
|
-
held: /* @__PURE__ */ new Map()
|
|
561
|
-
};
|
|
562
|
-
for (let after = -1n;;) {
|
|
563
|
-
const batch = await vaultEntriesOf(db, ACCESS_ACTIONS, after, BATCH);
|
|
564
|
-
for (const row of batch) {
|
|
565
|
-
const fault = apply(state, row);
|
|
566
|
-
if (fault !== null) return fault;
|
|
567
|
-
}
|
|
568
|
-
if (batch.length < BATCH) break;
|
|
569
|
-
after = batch[batch.length - 1].seq;
|
|
570
|
-
}
|
|
589
|
+
function replayFault(state, rows, storedGrants, at) {
|
|
571
590
|
const { members, held } = state;
|
|
572
|
-
const stored = new Map(
|
|
591
|
+
const stored = new Map(rows.map((member) => [member.principal, member]));
|
|
573
592
|
for (const principal of [.../* @__PURE__ */ new Set([...stored.keys(), ...members.keys()])].sort()) {
|
|
574
593
|
const [inStore, inLog] = [stored.get(principal), members.get(principal)];
|
|
575
594
|
if (inLog === void 0) return {
|
|
@@ -589,7 +608,7 @@ async function replay(db, at) {
|
|
|
589
608
|
}
|
|
590
609
|
const live = (grant) => grant.expiresAt === null || grant.expiresAt > at;
|
|
591
610
|
const logged = new Set([...held.values()].flatMap((grantsOf) => [...grantsOf.values()]).filter(live).map(grantKey));
|
|
592
|
-
const inStore = new Set(
|
|
611
|
+
const inStore = new Set(storedGrants.filter(live).map(grantKey));
|
|
593
612
|
const extra = [...inStore].sort().find((grant) => !logged.has(grant));
|
|
594
613
|
if (extra !== void 0) return {
|
|
595
614
|
kind: "unlogged-grant",
|
|
@@ -2072,99 +2091,152 @@ var VaultService = class {
|
|
|
2072
2091
|
};
|
|
2073
2092
|
}
|
|
2074
2093
|
verifyLog(input) {
|
|
2075
|
-
return this.#verifyAll(
|
|
2076
|
-
}
|
|
2077
|
-
/** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
|
|
2078
|
-
async #verify(db, shown, anchor) {
|
|
2079
|
-
const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
|
|
2080
|
-
if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
|
|
2081
|
-
return verification;
|
|
2094
|
+
return this.#verifyAll(input.upTo ?? null);
|
|
2082
2095
|
}
|
|
2083
2096
|
/**
|
|
2084
|
-
*
|
|
2085
|
-
*
|
|
2086
|
-
*
|
|
2087
|
-
*
|
|
2088
|
-
*
|
|
2089
|
-
*
|
|
2097
|
+
* The whole log, in one snapshot and one pass over the vault's entries:
|
|
2098
|
+
* each by its MAC, the keys it moved through, and what the other checks
|
|
2099
|
+
* need of it, gathered as it goes (`Findings`); then every checkpoint, the
|
|
2100
|
+
* heads that must still be there, the key batches accounted for, and the
|
|
2101
|
+
* members and grants replayed.
|
|
2102
|
+
*
|
|
2103
|
+
* With `upTo`, the head the app verified to, the links and hashes of the
|
|
2104
|
+
* chain are the app's to recompute, as it just has, over its own snapshot,
|
|
2105
|
+
* through that head; `carries(upTo)` ties this snapshot to that one. So
|
|
2106
|
+
* the vault reads only its own entries, each checked against its own hash
|
|
2107
|
+
* and MAC over the link it names. Without `upTo`, nobody else recomputed
|
|
2108
|
+
* the chain: the vault reads it all, and rehashes every link.
|
|
2090
2109
|
*/
|
|
2091
|
-
async #verifyAll(
|
|
2110
|
+
async #verifyAll(upTo) {
|
|
2092
2111
|
await this.#settled();
|
|
2093
2112
|
return this.#db.transaction(async (tx) => {
|
|
2094
2113
|
const remembered = this.#prepared.verified;
|
|
2095
|
-
const
|
|
2096
|
-
|
|
2097
|
-
if (
|
|
2114
|
+
const findings = new Findings();
|
|
2115
|
+
const read = upTo === null ? await this.#rehashed(tx, findings) : await this.#ownEntries(tx, findings);
|
|
2116
|
+
if (!read.ok) return read;
|
|
2117
|
+
const heads = [...upTo === null ? [] : [upTo], ...findings.checkpoints.map(({ checkpoint }) => checkpoint)];
|
|
2118
|
+
const rememberedHead = remembered.nextSeq > 0n ? {
|
|
2119
|
+
seq: Number(remembered.nextSeq - 1n),
|
|
2120
|
+
hash: remembered.hash.toString("hex")
|
|
2121
|
+
} : null;
|
|
2122
|
+
if (rememberedHead !== null) heads.push(rememberedHead);
|
|
2123
|
+
const hashes = await hashesAt(tx, [...new Set(heads.map((head) => BigInt(head.seq)))]);
|
|
2124
|
+
const holds = (head) => head.hash === GENESIS_HASH.toString("hex") || hashes.get(BigInt(head.seq))?.toString("hex") === head.hash;
|
|
2125
|
+
if (upTo !== null && !holds(upTo)) return {
|
|
2098
2126
|
ok: false,
|
|
2099
2127
|
failedAtSeq: upTo.seq,
|
|
2100
2128
|
reason: "not the entry the app verified up to: the log changed between the two checks"
|
|
2101
2129
|
};
|
|
2102
|
-
const unsigned = await this.#checkpointFault(
|
|
2130
|
+
const unsigned = await this.#checkpointFault(findings, holds);
|
|
2103
2131
|
if (unsigned !== null) return unsigned;
|
|
2104
|
-
if (
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
})) return {
|
|
2110
|
-
ok: false,
|
|
2111
|
-
failedAtSeq: Number(seq),
|
|
2112
|
-
reason: "changed since the vault last verified it"
|
|
2113
|
-
};
|
|
2114
|
-
}
|
|
2132
|
+
if (rememberedHead !== null && !holds(rememberedHead)) return {
|
|
2133
|
+
ok: false,
|
|
2134
|
+
failedAtSeq: rememberedHead.seq,
|
|
2135
|
+
reason: "changed since the vault last verified it"
|
|
2136
|
+
};
|
|
2115
2137
|
const at = await this.#now(tx);
|
|
2116
|
-
const accounting =
|
|
2138
|
+
const accounting = findings.accounting.result(at);
|
|
2117
2139
|
if (!accounting.ok) return accounting;
|
|
2118
|
-
const
|
|
2140
|
+
const [rows, held] = await Promise.all([allMembers(tx), grants(tx)]);
|
|
2141
|
+
const fault = this.#unsealed(rows, held, findings.newestAccess) ?? findings.replayFault ?? replayFault(findings.replayed, rows, held, at);
|
|
2119
2142
|
if (fault !== null) return {
|
|
2120
2143
|
ok: false,
|
|
2121
2144
|
failedAtSeq: null,
|
|
2122
2145
|
reason: describeAccessFault(fault),
|
|
2123
2146
|
fault
|
|
2124
2147
|
};
|
|
2125
|
-
|
|
2126
|
-
|
|
2148
|
+
const verified = {
|
|
2149
|
+
ok: true,
|
|
2150
|
+
entries: findings.entries
|
|
2151
|
+
};
|
|
2152
|
+
return accounting.pending === 0 ? verified : {
|
|
2153
|
+
...verified,
|
|
2127
2154
|
pending: accounting.pending
|
|
2128
2155
|
};
|
|
2129
2156
|
}, SNAPSHOT);
|
|
2130
2157
|
}
|
|
2131
2158
|
/**
|
|
2132
|
-
*
|
|
2133
|
-
*
|
|
2134
|
-
*
|
|
2135
|
-
* and one under a key it replaced comes before the rotation.
|
|
2159
|
+
* The vault's own entries, oldest first: each against its own hash, and by
|
|
2160
|
+
* its MAC, over the link it names, which the app checked; and the vault's
|
|
2161
|
+
* keys moving only forward through them.
|
|
2136
2162
|
*/
|
|
2137
|
-
async #
|
|
2163
|
+
async #ownEntries(tx, findings) {
|
|
2164
|
+
const { logKeys } = this.#prepared;
|
|
2165
|
+
let keys = [];
|
|
2138
2166
|
for (let after = -1n;;) {
|
|
2139
|
-
const batch = await
|
|
2167
|
+
const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
|
|
2140
2168
|
for (const entry of batch) {
|
|
2141
|
-
const
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
reason
|
|
2169
|
+
const result = verifyEntries([entry], {
|
|
2170
|
+
startSeq: entry.seq,
|
|
2171
|
+
startPrevHash: entry.prevHash,
|
|
2172
|
+
keys: logKeys
|
|
2146
2173
|
});
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2174
|
+
if (!result.ok) return {
|
|
2175
|
+
ok: false,
|
|
2176
|
+
failedAtSeq: Number(result.failedAtSeq),
|
|
2177
|
+
reason: withCause(result.reason)
|
|
2178
|
+
};
|
|
2152
2179
|
}
|
|
2153
|
-
|
|
2180
|
+
const moved = forward(batch, keys, logKeys[0].keyId);
|
|
2181
|
+
if ("failedAtSeq" in moved) return {
|
|
2182
|
+
ok: false,
|
|
2183
|
+
failedAtSeq: Number(moved.failedAtSeq),
|
|
2184
|
+
reason: moved.reason
|
|
2185
|
+
};
|
|
2186
|
+
keys = moved.keys;
|
|
2187
|
+
for (const entry of batch) findings.add(entry);
|
|
2188
|
+
if (batch.length < 5e3) return { ok: true };
|
|
2154
2189
|
after = batch[batch.length - 1].seq;
|
|
2155
2190
|
}
|
|
2156
2191
|
}
|
|
2192
|
+
/** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
|
|
2193
|
+
async #rehashed(tx, findings) {
|
|
2194
|
+
const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
|
|
2195
|
+
for (const entry of batch) if (entry.author === "vault") findings.add(entry);
|
|
2196
|
+
});
|
|
2197
|
+
if (!verification.ok) return verification;
|
|
2198
|
+
this.#prepared.verified = further(this.#prepared.verified, anchor);
|
|
2199
|
+
return { ok: true };
|
|
2200
|
+
}
|
|
2201
|
+
/**
|
|
2202
|
+
* Every checkpoint, not only the newest: each signed by the key whose log
|
|
2203
|
+
* key wrote its entry, over a prefix the log still holds (`holds`), entry
|
|
2204
|
+
* for entry. Its entry's MAC is checked by now, and one under a key the
|
|
2205
|
+
* vault replaced comes before the rotation.
|
|
2206
|
+
*/
|
|
2207
|
+
async #checkpointFault(findings, holds) {
|
|
2208
|
+
const { logKeys, signers } = this.#prepared;
|
|
2209
|
+
const verifiers = /* @__PURE__ */ new Map();
|
|
2210
|
+
const signed = (checkpoint, by) => {
|
|
2211
|
+
let verifier = verifiers.get(by.keyId);
|
|
2212
|
+
if (verifier === void 0) verifiers.set(by.keyId, verifier = checkpointVerifier(by.publicKey));
|
|
2213
|
+
return verifier.then((verify) => verify(checkpoint));
|
|
2214
|
+
};
|
|
2215
|
+
const { checkpoints } = findings;
|
|
2216
|
+
for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
|
|
2217
|
+
const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
|
|
2218
|
+
const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
|
|
2219
|
+
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
2220
|
+
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`;
|
|
2221
|
+
if (!await signed(checkpoint, by)) return "a checkpoint the vault did not sign";
|
|
2222
|
+
if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint)) return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
|
|
2223
|
+
return null;
|
|
2224
|
+
}));
|
|
2225
|
+
const at = faults.findIndex((fault) => fault !== null);
|
|
2226
|
+
if (at !== -1) return {
|
|
2227
|
+
ok: false,
|
|
2228
|
+
failedAtSeq: Number(chunk[at].entry.seq),
|
|
2229
|
+
reason: faults[at]
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
return null;
|
|
2233
|
+
}
|
|
2157
2234
|
/**
|
|
2158
2235
|
* The first member whose row fails its MAC, or names an older access
|
|
2159
|
-
* entry than the log's newest about them
|
|
2160
|
-
*
|
|
2236
|
+
* entry than the log's newest about them (`newest`, from the full check's
|
|
2237
|
+
* pass, where each entry's MAC was checked).
|
|
2161
2238
|
*/
|
|
2162
|
-
|
|
2163
|
-
const [rows, held, newest] = await Promise.all([
|
|
2164
|
-
allMembers(db),
|
|
2165
|
-
grants(db),
|
|
2166
|
-
newestAccessEntries(db)
|
|
2167
|
-
]);
|
|
2239
|
+
#unsealed(rows, held, newest) {
|
|
2168
2240
|
for (const row of rows) {
|
|
2169
2241
|
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
2170
2242
|
if (!this.#sealed(row, grants)) return {
|
|
@@ -2172,7 +2244,7 @@ var VaultService = class {
|
|
|
2172
2244
|
principal: row.principal,
|
|
2173
2245
|
why: "mac"
|
|
2174
2246
|
};
|
|
2175
|
-
if (newest.get(row.principal)
|
|
2247
|
+
if (newest.get(row.principal) !== row.accessSeq) return {
|
|
2176
2248
|
kind: "tampered-member",
|
|
2177
2249
|
principal: row.principal,
|
|
2178
2250
|
why: "stale"
|
|
@@ -2181,6 +2253,45 @@ var VaultService = class {
|
|
|
2181
2253
|
return null;
|
|
2182
2254
|
}
|
|
2183
2255
|
};
|
|
2256
|
+
/**
|
|
2257
|
+
* What one pass over the vault's entries gathers for the other checks of a
|
|
2258
|
+
* full verification, so that the log is read once: the key batches, the
|
|
2259
|
+
* access entries replayed, each member's newest, and the checkpoints. Each
|
|
2260
|
+
* entry comes after its MAC was checked.
|
|
2261
|
+
*/
|
|
2262
|
+
var Findings = class {
|
|
2263
|
+
accounting = new KeyAccounting();
|
|
2264
|
+
replayed = {
|
|
2265
|
+
members: /* @__PURE__ */ new Map(),
|
|
2266
|
+
held: /* @__PURE__ */ new Map()
|
|
2267
|
+
};
|
|
2268
|
+
/** The first access entry the replay could not apply. */
|
|
2269
|
+
replayFault = null;
|
|
2270
|
+
/** Each member's newest allowed access entry, by seq. */
|
|
2271
|
+
newestAccess = /* @__PURE__ */ new Map();
|
|
2272
|
+
/** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
|
|
2273
|
+
checkpoints = [];
|
|
2274
|
+
/** The vault's entries read. */
|
|
2275
|
+
entries = 0;
|
|
2276
|
+
add(entry) {
|
|
2277
|
+
this.entries += 1;
|
|
2278
|
+
this.accounting.add(entry);
|
|
2279
|
+
if (entry.decision !== "allow") return;
|
|
2280
|
+
if (ACCESS.has(entry.action)) {
|
|
2281
|
+
this.replayFault ??= apply(this.replayed, entry);
|
|
2282
|
+
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
2283
|
+
} else if (entry.action === CHECKPOINT) this.checkpoints.push({
|
|
2284
|
+
entry: {
|
|
2285
|
+
seq: entry.seq,
|
|
2286
|
+
keyId: entry.keyId
|
|
2287
|
+
},
|
|
2288
|
+
checkpoint: JSON.parse(entry.metadata)
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
2291
|
+
};
|
|
2292
|
+
const ACCESS = new Set(ACCESS_ACTIONS);
|
|
2293
|
+
/** Checkpoint signatures checked at once, in a full verification. */
|
|
2294
|
+
const SIGNATURES_AT_ONCE = 256;
|
|
2184
2295
|
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
2185
2296
|
async function settle(operations, budgetMs) {
|
|
2186
2297
|
const controller = new AbortController();
|