@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
|
@@ -1,13 +1,171 @@
|
|
|
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";
|
|
5
|
-
import { allows, assignableToEnvironment, isRole,
|
|
5
|
+
import { allows, assignableToEnvironment, isRole, mayManageAccess } from "@coffre/core/access";
|
|
6
6
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
7
|
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",
|
|
@@ -819,6 +838,7 @@ const KEY_BUDGET_MS = 5e3;
|
|
|
819
838
|
/** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
|
|
820
839
|
const LOCK_TIMEOUT_MS = 15e3;
|
|
821
840
|
const PRINCIPAL = /^(user|token|sync):[^\s:][^\s]*$/;
|
|
841
|
+
const LIVE_PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
|
|
822
842
|
/** Who acts for the vault itself, as when it gives a root admin a member row. */
|
|
823
843
|
const VAULT_ACTOR = "system:vault";
|
|
824
844
|
/** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
|
|
@@ -1518,6 +1538,14 @@ var VaultService = class {
|
|
|
1518
1538
|
fault,
|
|
1519
1539
|
stored: grants$1
|
|
1520
1540
|
};
|
|
1541
|
+
if (!LIVE_PRINCIPAL.test(principal)) return {
|
|
1542
|
+
principal,
|
|
1543
|
+
status: "unknown",
|
|
1544
|
+
live: none,
|
|
1545
|
+
all: none,
|
|
1546
|
+
fault,
|
|
1547
|
+
stored: grants$1
|
|
1548
|
+
};
|
|
1521
1549
|
if (row?.status !== "active") return {
|
|
1522
1550
|
principal,
|
|
1523
1551
|
status: row?.status ?? "unknown",
|
|
@@ -1559,10 +1587,10 @@ var VaultService = class {
|
|
|
1559
1587
|
since: null,
|
|
1560
1588
|
by: null
|
|
1561
1589
|
};
|
|
1562
|
-
const active = !tampered && row?.status === "active";
|
|
1590
|
+
const active = LIVE_PRINCIPAL.test(principal) && !tampered && row?.status === "active";
|
|
1563
1591
|
return {
|
|
1564
1592
|
principal,
|
|
1565
|
-
status: tampered ? "tampered" : row?.status ?? "unknown",
|
|
1593
|
+
status: tampered ? "tampered" : !LIVE_PRINCIPAL.test(principal) ? "unknown" : row?.status ?? "unknown",
|
|
1566
1594
|
generation: row?.generation ?? 0,
|
|
1567
1595
|
isRootAdmin: false,
|
|
1568
1596
|
isOwner: active && row.owner && principal.startsWith("user:"),
|
|
@@ -1637,7 +1665,7 @@ var VaultService = class {
|
|
|
1637
1665
|
}]);
|
|
1638
1666
|
return this.#decide([actor, principal], async (d) => {
|
|
1639
1667
|
validateCorrelation(input);
|
|
1640
|
-
if (!
|
|
1668
|
+
if (!LIVE_PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
|
|
1641
1669
|
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
1642
1670
|
const places$1 = /* @__PURE__ */ new Set();
|
|
1643
1671
|
for (const change of input.changes) {
|
|
@@ -1653,28 +1681,12 @@ var VaultService = class {
|
|
|
1653
1681
|
for (const { projectId, environmentId } of input.changes) if (!known.projects.has(projectId) || environmentId !== null && known.environments.get(environmentId) !== projectId) throw refused("invalid", `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
|
|
1654
1682
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1655
1683
|
if (acting.status === "tampered") throw refused("tampered");
|
|
1656
|
-
if (!input.changes.every((change) => mayManageAccess(acting.live,
|
|
1684
|
+
if (!input.changes.every((change) => mayManageAccess(acting.live, change))) throw refused("not_allowed");
|
|
1657
1685
|
const row = d.members.get(principal);
|
|
1658
1686
|
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1659
1687
|
if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
|
|
1660
1688
|
if (row?.status === "removed") throw refused("removed");
|
|
1661
|
-
if (row === void 0)
|
|
1662
|
-
if (!isSyncPrincipal(principal) || input.changes.every((change) => change.role === null)) throw refused("not_a_member");
|
|
1663
|
-
d.log.push(accessEntry(actor, "member.add", principal, "allow", input, { owner: false }));
|
|
1664
|
-
d.writes.push(async (at) => {
|
|
1665
|
-
await insertMember(d.tx, {
|
|
1666
|
-
principal,
|
|
1667
|
-
status: "active",
|
|
1668
|
-
owner: false,
|
|
1669
|
-
generation: 0,
|
|
1670
|
-
createdAt: at,
|
|
1671
|
-
createdBy: actor,
|
|
1672
|
-
statusChangedAt: at,
|
|
1673
|
-
statusChangedBy: actor,
|
|
1674
|
-
...UNSEALED
|
|
1675
|
-
});
|
|
1676
|
-
});
|
|
1677
|
-
}
|
|
1689
|
+
if (row === void 0) throw refused("not_a_member");
|
|
1678
1690
|
const held = subject.stored;
|
|
1679
1691
|
d.grants.set(principal, [...held]);
|
|
1680
1692
|
const changes = input.changes.map((change) => this.#apply(d, actor, principal, held, change, input));
|
|
@@ -1738,7 +1750,7 @@ var VaultService = class {
|
|
|
1738
1750
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1739
1751
|
if (acting.status === "tampered") throw refused("tampered");
|
|
1740
1752
|
if (!acting.live.isOwner) throw refused("not_allowed", "only owners may add or restore members");
|
|
1741
|
-
if (!
|
|
1753
|
+
if (!LIVE_PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${principal}`);
|
|
1742
1754
|
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
1743
1755
|
if (input.owner === true && !principal.startsWith("user:")) throw refused("invalid", "service accounts cannot be owners");
|
|
1744
1756
|
const row = d.members.get(principal);
|
|
@@ -1792,6 +1804,7 @@ var VaultService = class {
|
|
|
1792
1804
|
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.remove", principal, "deny", input, {}, code)]);
|
|
1793
1805
|
return this.#decide([actor, principal], async (d) => {
|
|
1794
1806
|
validateCorrelation(input);
|
|
1807
|
+
if (!LIVE_PRINCIPAL.test(principal)) throw refused("invalid", `not a member: ${principal}`);
|
|
1795
1808
|
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
1796
1809
|
const row = d.members.get(principal);
|
|
1797
1810
|
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
@@ -1803,11 +1816,7 @@ var VaultService = class {
|
|
|
1803
1816
|
if (!holder.isOwner) throw refused("not_allowed", "only owners may remove a member whose record failed its check");
|
|
1804
1817
|
return this.#startOver(d, actor, principal, row, subject.fault, input, refused);
|
|
1805
1818
|
}
|
|
1806
|
-
|
|
1807
|
-
if (!(holder.isOwner || isSyncPrincipal(principal) && (held.length === 0 || places.some((place) => mayManageAccess(holder, principal, {
|
|
1808
|
-
...place,
|
|
1809
|
-
role: null
|
|
1810
|
-
}))))) throw refused("not_allowed", "only owners may remove members");
|
|
1819
|
+
if (!holder.isOwner) throw refused("not_allowed", "only owners may remove members");
|
|
1811
1820
|
if (row?.status !== "active") throw refused(row === void 0 ? "not_a_member" : "removed");
|
|
1812
1821
|
const revoked = held.filter((grant) => live(grant, d.at));
|
|
1813
1822
|
for (const grant of revoked) d.log.push({
|
|
@@ -2082,99 +2091,152 @@ var VaultService = class {
|
|
|
2082
2091
|
};
|
|
2083
2092
|
}
|
|
2084
2093
|
verifyLog(input) {
|
|
2085
|
-
return this.#verifyAll(
|
|
2086
|
-
}
|
|
2087
|
-
/** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
|
|
2088
|
-
async #verify(db, shown, anchor) {
|
|
2089
|
-
const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
|
|
2090
|
-
if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
|
|
2091
|
-
return verification;
|
|
2094
|
+
return this.#verifyAll(input.upTo ?? null);
|
|
2092
2095
|
}
|
|
2093
2096
|
/**
|
|
2094
|
-
*
|
|
2095
|
-
*
|
|
2096
|
-
*
|
|
2097
|
-
*
|
|
2098
|
-
*
|
|
2099
|
-
*
|
|
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.
|
|
2100
2109
|
*/
|
|
2101
|
-
async #verifyAll(
|
|
2110
|
+
async #verifyAll(upTo) {
|
|
2102
2111
|
await this.#settled();
|
|
2103
2112
|
return this.#db.transaction(async (tx) => {
|
|
2104
2113
|
const remembered = this.#prepared.verified;
|
|
2105
|
-
const
|
|
2106
|
-
|
|
2107
|
-
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 {
|
|
2108
2126
|
ok: false,
|
|
2109
2127
|
failedAtSeq: upTo.seq,
|
|
2110
2128
|
reason: "not the entry the app verified up to: the log changed between the two checks"
|
|
2111
2129
|
};
|
|
2112
|
-
const unsigned = await this.#checkpointFault(
|
|
2130
|
+
const unsigned = await this.#checkpointFault(findings, holds);
|
|
2113
2131
|
if (unsigned !== null) return unsigned;
|
|
2114
|
-
if (
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
})) return {
|
|
2120
|
-
ok: false,
|
|
2121
|
-
failedAtSeq: Number(seq),
|
|
2122
|
-
reason: "changed since the vault last verified it"
|
|
2123
|
-
};
|
|
2124
|
-
}
|
|
2132
|
+
if (rememberedHead !== null && !holds(rememberedHead)) return {
|
|
2133
|
+
ok: false,
|
|
2134
|
+
failedAtSeq: rememberedHead.seq,
|
|
2135
|
+
reason: "changed since the vault last verified it"
|
|
2136
|
+
};
|
|
2125
2137
|
const at = await this.#now(tx);
|
|
2126
|
-
const accounting =
|
|
2138
|
+
const accounting = findings.accounting.result(at);
|
|
2127
2139
|
if (!accounting.ok) return accounting;
|
|
2128
|
-
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);
|
|
2129
2142
|
if (fault !== null) return {
|
|
2130
2143
|
ok: false,
|
|
2131
2144
|
failedAtSeq: null,
|
|
2132
2145
|
reason: describeAccessFault(fault),
|
|
2133
2146
|
fault
|
|
2134
2147
|
};
|
|
2135
|
-
|
|
2136
|
-
|
|
2148
|
+
const verified = {
|
|
2149
|
+
ok: true,
|
|
2150
|
+
entries: findings.entries
|
|
2151
|
+
};
|
|
2152
|
+
return accounting.pending === 0 ? verified : {
|
|
2153
|
+
...verified,
|
|
2137
2154
|
pending: accounting.pending
|
|
2138
2155
|
};
|
|
2139
2156
|
}, SNAPSHOT);
|
|
2140
2157
|
}
|
|
2141
2158
|
/**
|
|
2142
|
-
*
|
|
2143
|
-
*
|
|
2144
|
-
*
|
|
2145
|
-
* 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.
|
|
2146
2162
|
*/
|
|
2147
|
-
async #
|
|
2163
|
+
async #ownEntries(tx, findings) {
|
|
2164
|
+
const { logKeys } = this.#prepared;
|
|
2165
|
+
let keys = [];
|
|
2148
2166
|
for (let after = -1n;;) {
|
|
2149
|
-
const batch = await
|
|
2167
|
+
const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
|
|
2150
2168
|
for (const entry of batch) {
|
|
2151
|
-
const
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
reason
|
|
2169
|
+
const result = verifyEntries([entry], {
|
|
2170
|
+
startSeq: entry.seq,
|
|
2171
|
+
startPrevHash: entry.prevHash,
|
|
2172
|
+
keys: logKeys
|
|
2156
2173
|
});
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2174
|
+
if (!result.ok) return {
|
|
2175
|
+
ok: false,
|
|
2176
|
+
failedAtSeq: Number(result.failedAtSeq),
|
|
2177
|
+
reason: withCause(result.reason)
|
|
2178
|
+
};
|
|
2162
2179
|
}
|
|
2163
|
-
|
|
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 };
|
|
2164
2189
|
after = batch[batch.length - 1].seq;
|
|
2165
2190
|
}
|
|
2166
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
|
+
}
|
|
2167
2234
|
/**
|
|
2168
2235
|
* The first member whose row fails its MAC, or names an older access
|
|
2169
|
-
* entry than the log's newest about them
|
|
2170
|
-
*
|
|
2236
|
+
* entry than the log's newest about them (`newest`, from the full check's
|
|
2237
|
+
* pass, where each entry's MAC was checked).
|
|
2171
2238
|
*/
|
|
2172
|
-
|
|
2173
|
-
const [rows, held, newest] = await Promise.all([
|
|
2174
|
-
allMembers(db),
|
|
2175
|
-
grants(db),
|
|
2176
|
-
newestAccessEntries(db)
|
|
2177
|
-
]);
|
|
2239
|
+
#unsealed(rows, held, newest) {
|
|
2178
2240
|
for (const row of rows) {
|
|
2179
2241
|
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
2180
2242
|
if (!this.#sealed(row, grants)) return {
|
|
@@ -2182,7 +2244,7 @@ var VaultService = class {
|
|
|
2182
2244
|
principal: row.principal,
|
|
2183
2245
|
why: "mac"
|
|
2184
2246
|
};
|
|
2185
|
-
if (newest.get(row.principal)
|
|
2247
|
+
if (newest.get(row.principal) !== row.accessSeq) return {
|
|
2186
2248
|
kind: "tampered-member",
|
|
2187
2249
|
principal: row.principal,
|
|
2188
2250
|
why: "stale"
|
|
@@ -2191,6 +2253,45 @@ var VaultService = class {
|
|
|
2191
2253
|
return null;
|
|
2192
2254
|
}
|
|
2193
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;
|
|
2194
2295
|
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
2195
2296
|
async function settle(operations, budgetMs) {
|
|
2196
2297
|
const controller = new AbortController();
|