@coffre/vault 0.10.0 → 0.10.1
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 -0
- package/dist/cloudflare.js +1 -1
- package/dist/node.d.ts +11 -0
- package/dist/node.js +1 -1
- package/dist/{vault-COqZ12ny.js → vault-B0YiPBgi.js} +1127 -474
- package/package.json +3 -3
- package/src/accounting.ts +37 -8
- package/src/log.ts +91 -60
- package/src/replay.ts +1 -1
- package/src/store.ts +95 -21
- package/src/tally.ts +241 -0
- package/src/vault.ts +454 -209
|
@@ -1,155 +1,15 @@
|
|
|
1
1
|
import { ACCESS_ACTIONS, POLICY_REVISION, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } 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
|
-
import { createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
-
import { tablesOf } from "@coffre/db";
|
|
3
|
+
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { prepared, tablesOf } from "@coffre/db";
|
|
5
5
|
import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_ROLES, NOTHING, PROJECT_ONLY_PERMISSIONS, ROLES, ROLE_ID, UNWRITTEN_FORK_MS, allows, assignableToEnvironment, definitionProblem, givesService, grantKind, isBuiltinRole, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalDefinition, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
|
|
6
6
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
7
|
import { checkContext, ownerKey } from "@coffre/core/envelope";
|
|
8
8
|
import { shownMember } from "@coffre/core/schemas";
|
|
9
|
-
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, tombstone, truth } from "@coffre/db/dialect";
|
|
9
|
+
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation, isUnreachable, tombstone, truth } from "@coffre/db/dialect";
|
|
10
10
|
import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
|
|
11
|
-
import {
|
|
12
|
-
import { and, asc,
|
|
13
|
-
//#region src/accounting.ts
|
|
14
|
-
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
15
|
-
"secret.read",
|
|
16
|
-
"key.wrap",
|
|
17
|
-
"key.rewrap"
|
|
18
|
-
]);
|
|
19
|
-
/**
|
|
20
|
-
* Every key operation's intent and its outcomes, entry by entry, oldest
|
|
21
|
-
* first, as the full check reads the vault's entries (`add`); then what is
|
|
22
|
-
* still open (`result`). The chain can hold while a process died between an
|
|
23
|
-
* intent and its outcomes: only a missed deadline is a fault.
|
|
24
|
-
*/
|
|
25
|
-
var KeyAccounting = class {
|
|
26
|
-
#intents = /* @__PURE__ */ new Map();
|
|
27
|
-
#ids = /* @__PURE__ */ new Set();
|
|
28
|
-
#fault = null;
|
|
29
|
-
add(entry) {
|
|
30
|
-
if (this.#fault !== null || entry.author !== "vault") return;
|
|
31
|
-
const broken = (reason) => {
|
|
32
|
-
this.#fault = {
|
|
33
|
-
ok: false,
|
|
34
|
-
failedAtSeq: Number(entry.seq),
|
|
35
|
-
reason
|
|
36
|
-
};
|
|
37
|
-
};
|
|
38
|
-
if (entry.action === "key.intent") {
|
|
39
|
-
const parsed = parseIntent(entry);
|
|
40
|
-
if (typeof parsed === "string") return broken(parsed);
|
|
41
|
-
if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
|
|
42
|
-
this.#ids.add(parsed.intentId);
|
|
43
|
-
if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
|
|
44
|
-
...parsed,
|
|
45
|
-
entry,
|
|
46
|
-
seen: /* @__PURE__ */ new Set()
|
|
47
|
-
});
|
|
48
|
-
} else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
|
|
49
|
-
const parsed = parseOutcome(entry);
|
|
50
|
-
if (typeof parsed === "string") return broken(parsed);
|
|
51
|
-
const intent = this.#intents.get(parsed.relatedSeq);
|
|
52
|
-
for (const outcome of parsed.items) {
|
|
53
|
-
const key = intent?.keys[outcome.item];
|
|
54
|
-
if (intent === void 0 || key === void 0 || intent.seen.has(outcome.item)) return broken("key outcome does not identify one item of its intent");
|
|
55
|
-
if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
|
|
56
|
-
if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) return broken("key outcome does not match its intended secret");
|
|
57
|
-
intent.seen.add(outcome.item);
|
|
58
|
-
}
|
|
59
|
-
if (intent !== void 0 && intent.seen.size === intent.keys.length) this.#intents.delete(intent.entry.seq);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
/** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
|
|
63
|
-
result(at) {
|
|
64
|
-
if (this.#fault !== null) return this.#fault;
|
|
65
|
-
const intents = [...this.#intents.values()];
|
|
66
|
-
const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
|
|
67
|
-
if (overdue.length === 0) return {
|
|
68
|
-
ok: true,
|
|
69
|
-
pending: intents.length
|
|
70
|
-
};
|
|
71
|
-
const reason = overdue.map((intent) => {
|
|
72
|
-
const missing = intent.keys.length - intent.seen.size;
|
|
73
|
-
return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
|
|
74
|
-
}).join("; ");
|
|
75
|
-
return {
|
|
76
|
-
ok: false,
|
|
77
|
-
failedAtSeq: Number(overdue[0].entry.seq),
|
|
78
|
-
reason
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
};
|
|
82
|
-
function parseIntent(entry) {
|
|
83
|
-
const detail = payload(entry.metadata);
|
|
84
|
-
if (detail === null) return "key intent has no valid accounting payload";
|
|
85
|
-
const { intent, operation, expiresAt } = detail;
|
|
86
|
-
if (typeof intent !== "string") return "key intent has no operation identity";
|
|
87
|
-
if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
|
|
88
|
-
if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
|
|
89
|
-
if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
|
|
90
|
-
const keys = [];
|
|
91
|
-
for (const [index, value] of detail.keys.entries()) {
|
|
92
|
-
const key = record(value);
|
|
93
|
-
if (key === null || key.item !== index) return "key intent items are not numbered in order";
|
|
94
|
-
const { secretId, version, subject } = key;
|
|
95
|
-
if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
|
|
96
|
-
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
|
|
97
|
-
keys.push({
|
|
98
|
-
item: index,
|
|
99
|
-
secretId,
|
|
100
|
-
version,
|
|
101
|
-
subject
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
intentId: intent,
|
|
106
|
-
operation,
|
|
107
|
-
expiresAt,
|
|
108
|
-
keys
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
function parseOutcome(entry) {
|
|
112
|
-
const detail = payload(entry.metadata);
|
|
113
|
-
if (detail === null) return "key outcome has no valid accounting payload";
|
|
114
|
-
const { intent } = detail;
|
|
115
|
-
if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
|
|
116
|
-
const many = detail.secrets;
|
|
117
|
-
if (many !== void 0 && (!Array.isArray(many) || many.length === 0)) return "key outcome has no valid item list";
|
|
118
|
-
const listed = many === void 0 ? [{
|
|
119
|
-
...detail,
|
|
120
|
-
secretId: entry.secretId ?? detail.secretId
|
|
121
|
-
}] : many.map(record);
|
|
122
|
-
const items = [];
|
|
123
|
-
for (const one of listed) {
|
|
124
|
-
if (one === null) return "key outcome has no valid item list";
|
|
125
|
-
const { item, secretId, version, subject } = one;
|
|
126
|
-
if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
|
|
127
|
-
if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
|
|
128
|
-
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
|
|
129
|
-
items.push({
|
|
130
|
-
item,
|
|
131
|
-
secretId,
|
|
132
|
-
version,
|
|
133
|
-
subject
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
return {
|
|
137
|
-
intentId: intent,
|
|
138
|
-
relatedSeq: entry.relatedSeq,
|
|
139
|
-
items
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
function payload(text) {
|
|
143
|
-
try {
|
|
144
|
-
return record(JSON.parse(text));
|
|
145
|
-
} catch {
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
function record(value) {
|
|
150
|
-
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
151
|
-
}
|
|
152
|
-
//#endregion
|
|
11
|
+
import { appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, newestVaultSeq, withinBytes } from "@coffre/db/log";
|
|
12
|
+
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
153
13
|
//#region src/checkpoint.ts
|
|
154
14
|
const PKCS8_ED25519 = [
|
|
155
15
|
48,
|
|
@@ -193,10 +53,10 @@ async function boundLockWaits(tx, ms) {
|
|
|
193
53
|
}
|
|
194
54
|
/** The database's clock, now. */
|
|
195
55
|
async function now(db) {
|
|
196
|
-
const {
|
|
197
|
-
const [{ at }] = await db.select({ at: clockMillis(db) }).from(auditChainHead);
|
|
56
|
+
const [{ at }] = await clock(db);
|
|
198
57
|
return at;
|
|
199
58
|
}
|
|
59
|
+
const clock = prepared((db) => db.select({ at: clockMillis(db) }).from(tablesOf(db).auditChainHead));
|
|
200
60
|
function memberColumns(db) {
|
|
201
61
|
const { vaultMembers } = tablesOf(db);
|
|
202
62
|
return {
|
|
@@ -234,10 +94,13 @@ function storedChange(change) {
|
|
|
234
94
|
};
|
|
235
95
|
}
|
|
236
96
|
async function member(db, principal) {
|
|
237
|
-
const
|
|
238
|
-
const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
|
|
97
|
+
const [row] = await memberRow(db, { principal });
|
|
239
98
|
return row === void 0 ? void 0 : memberOf(row);
|
|
240
99
|
}
|
|
100
|
+
const memberRow = prepared((db) => {
|
|
101
|
+
const { vaultMembers } = tablesOf(db);
|
|
102
|
+
return db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, sql.placeholder("principal")));
|
|
103
|
+
});
|
|
241
104
|
async function allMembers(db) {
|
|
242
105
|
const { vaultMembers } = tablesOf(db);
|
|
243
106
|
return (await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal))).map(memberOf);
|
|
@@ -366,11 +229,17 @@ const RELEASE = "secret.read";
|
|
|
366
229
|
* several (`secrets`), a read logged once per place.
|
|
367
230
|
*/
|
|
368
231
|
async function releasesSince(db, principal, after) {
|
|
369
|
-
const {
|
|
370
|
-
|
|
371
|
-
|
|
232
|
+
const [{ n }] = await released(db, {
|
|
233
|
+
principal,
|
|
234
|
+
after
|
|
235
|
+
});
|
|
372
236
|
return Number(n ?? 0);
|
|
373
237
|
}
|
|
238
|
+
const released = prepared((db) => {
|
|
239
|
+
const { auditLog } = tablesOf(db);
|
|
240
|
+
const listed = engineOf(db) === "postgres" ? sql`jsonb_array_length((${auditLog.metadata})::jsonb -> 'secrets')` : sql`json_array_length(${auditLog.metadata}, '$.secrets')`;
|
|
241
|
+
return db.select({ n: sql`SUM(COALESCE(${listed}, 1))` }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, sql.placeholder("principal")), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, sql.placeholder("after"))));
|
|
242
|
+
});
|
|
374
243
|
/** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
|
|
375
244
|
function stored(rows) {
|
|
376
245
|
return rows;
|
|
@@ -405,16 +274,6 @@ async function hashesAt(db, seqs) {
|
|
|
405
274
|
}
|
|
406
275
|
/** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
|
|
407
276
|
const HASHES_AT_ONCE = 5e3;
|
|
408
|
-
/**
|
|
409
|
-
* Whether every entry from the first through `seq` is there. Seqs are the
|
|
410
|
-
* primary key, so the count is `seq + 1` exactly when none is missing: one
|
|
411
|
-
* pass over the key's index, never the entries.
|
|
412
|
-
*/
|
|
413
|
-
async function complete(db, seq) {
|
|
414
|
-
const { auditLog } = tablesOf(db);
|
|
415
|
-
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
|
|
416
|
-
return BigInt(n) === seq + 1n;
|
|
417
|
-
}
|
|
418
277
|
/** The entry at `seq`'s hash, or undefined when there is none. */
|
|
419
278
|
async function hashAt(db, seq) {
|
|
420
279
|
const { auditLog } = tablesOf(db);
|
|
@@ -529,6 +388,51 @@ async function latestVaultEntry(db, actions) {
|
|
|
529
388
|
const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...actions]), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(1);
|
|
530
389
|
return row === void 0 ? void 0 : stored([row])[0];
|
|
531
390
|
}
|
|
391
|
+
/**
|
|
392
|
+
* The vault's newest checkpoint that keeps a tally, allowed, or undefined:
|
|
393
|
+
* one written since 0.10.1, which names the last entry of the prefix it
|
|
394
|
+
* signs as related (`related_seq`); those before name none.
|
|
395
|
+
*/
|
|
396
|
+
async function latestTallied(db) {
|
|
397
|
+
const { auditLog } = tablesOf(db);
|
|
398
|
+
const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq))).orderBy(desc(auditLog.seq)).limit(1);
|
|
399
|
+
return row === void 0 ? void 0 : stored([row])[0];
|
|
400
|
+
}
|
|
401
|
+
/** The vault's newest `audit.checkpoint` entry, signed or refused, or undefined. */
|
|
402
|
+
async function newestCheckpointEntry(db) {
|
|
403
|
+
const { auditLog } = tablesOf(db);
|
|
404
|
+
const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"))).orderBy(desc(auditLog.seq)).limit(1);
|
|
405
|
+
return row === void 0 ? void 0 : stored([row])[0];
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The vault's checkpoints that keep a tally after entry `after` and through
|
|
409
|
+
* `through`, oldest first, whole: as many as `BATCH_BYTES` of what they say
|
|
410
|
+
* holds, but always one. A reader asks again after the last.
|
|
411
|
+
*/
|
|
412
|
+
async function talliedWithin(db, after, through, limit) {
|
|
413
|
+
const { auditLog } = tablesOf(db);
|
|
414
|
+
const tallied = and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq), gt(auditLog.seq, after), lte(auditLog.seq, through));
|
|
415
|
+
const batch = await withinBytes(db, tallied, limit);
|
|
416
|
+
if (batch === null) return [];
|
|
417
|
+
return stored(await db.select(entryColumns(db)).from(auditLog).where(and(tallied, gte(auditLog.seq, batch.from), lte(auditLog.seq, batch.to))).orderBy(asc(auditLog.seq)));
|
|
418
|
+
}
|
|
419
|
+
/** The vault's newest refusal of `action` with `code` after entry `after`, or undefined: one range of the (author, action, seq) index. */
|
|
420
|
+
async function newestRefusal(db, action, code, after) {
|
|
421
|
+
const { auditLog } = tablesOf(db);
|
|
422
|
+
const [row] = await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, action), gt(auditLog.seq, after), eq(auditLog.decision, "deny"), eq(auditLog.code, code))).orderBy(desc(auditLog.seq)).limit(1);
|
|
423
|
+
return row === void 0 ? void 0 : stored([row])[0];
|
|
424
|
+
}
|
|
425
|
+
/** Up to `limit` of the vault's checkpoints that keep a tally after entry `after`, oldest first: each one's entry, and the last entry it signs. */
|
|
426
|
+
async function talliedAfter(db, after, limit) {
|
|
427
|
+
const { auditLog } = tablesOf(db);
|
|
428
|
+
return (await db.select({
|
|
429
|
+
seq: auditLog.seq,
|
|
430
|
+
signs: auditLog.relatedSeq
|
|
431
|
+
}).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.action, "audit.checkpoint"), eq(auditLog.decision, "allow"), isNotNull(auditLog.relatedSeq), gt(auditLog.seq, after))).orderBy(asc(auditLog.seq)).limit(limit)).map(({ seq, signs }) => ({
|
|
432
|
+
seq,
|
|
433
|
+
signs
|
|
434
|
+
}));
|
|
435
|
+
}
|
|
532
436
|
/** The vault's newest allowed access entry about each member who has one. */
|
|
533
437
|
async function newestAccessEntries(db) {
|
|
534
438
|
const { auditLog } = tablesOf(db);
|
|
@@ -621,86 +525,158 @@ async function versions(db, ids) {
|
|
|
621
525
|
function vaultLogKey(signingKey) {
|
|
622
526
|
return deriveLogKey("vault", signingKey);
|
|
623
527
|
}
|
|
624
|
-
/** Before the first entry
|
|
625
|
-
const
|
|
626
|
-
|
|
627
|
-
hash: GENESIS_HASH
|
|
628
|
-
vaultEntries: 0,
|
|
629
|
-
vaultKeys: []
|
|
528
|
+
/** Before the first entry. */
|
|
529
|
+
const START = {
|
|
530
|
+
seq: -1n,
|
|
531
|
+
hash: GENESIS_HASH
|
|
630
532
|
};
|
|
631
|
-
/** The further of two
|
|
533
|
+
/** The further of two positions, when two calls walked at once. */
|
|
632
534
|
function further(a, b) {
|
|
633
|
-
return b.
|
|
535
|
+
return b.seq > a.seq ? b : a;
|
|
634
536
|
}
|
|
635
537
|
/** Entries read at once, at most, and no more than `BATCH_BYTES` of what they say: well within a Worker's memory, and a fifth of the round trips of 1000. */
|
|
636
538
|
const VERIFY_BATCH = 5e3;
|
|
637
539
|
/**
|
|
638
|
-
*
|
|
639
|
-
*
|
|
640
|
-
*
|
|
641
|
-
*
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
* of anything before it, chained again to hide, changes its hash;
|
|
645
|
-
* - every entry after the anchor, or from the first.
|
|
540
|
+
* Walk the chain on from `from`, a batch in memory at a time, through `to`
|
|
541
|
+
* or the log's last entry, and no more than `limit` entries: every link and
|
|
542
|
+
* hash recomputed, each of the vault's entries by its MAC, its keys moving
|
|
543
|
+
* only forward, and each fed to `walk`. Where it got to, and whether that
|
|
544
|
+
* is as far as it was to go: a walk cut short by `limit` goes on from there
|
|
545
|
+
* another time.
|
|
646
546
|
*
|
|
647
|
-
*
|
|
648
|
-
*
|
|
649
|
-
* not on the page: a full check, which starts from `UNVERIFIED`, finds that,
|
|
650
|
-
* as does the first view after a start, which has no anchor. `onBatch` sees
|
|
651
|
-
* each batch once it has verified, for checks that read the same entries.
|
|
547
|
+
* It takes `from` as given: whether the log still holds it is the caller's
|
|
548
|
+
* to check, with the other heads it must hold, in one read.
|
|
652
549
|
*/
|
|
653
|
-
async function
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
550
|
+
async function rehash(db, logKeys, from, walk, { to, limit = Infinity }) {
|
|
551
|
+
let at = from;
|
|
552
|
+
for (let left = limit;;) {
|
|
553
|
+
if (to !== void 0 && at.seq >= to) return {
|
|
554
|
+
ok: true,
|
|
555
|
+
at,
|
|
556
|
+
done: true
|
|
557
|
+
};
|
|
558
|
+
if (left <= 0) return {
|
|
559
|
+
ok: true,
|
|
560
|
+
at,
|
|
561
|
+
done: !(await entriesFrom(db, at.seq + 1n, 1)).some((entry) => to === void 0 || entry.seq <= to)
|
|
562
|
+
};
|
|
563
|
+
let batch = await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, left));
|
|
564
|
+
if (to !== void 0) batch = batch.filter((entry) => entry.seq <= to);
|
|
565
|
+
if (batch.length === 0) return {
|
|
566
|
+
ok: true,
|
|
567
|
+
at,
|
|
568
|
+
done: to === void 0
|
|
569
|
+
};
|
|
570
|
+
const result = verifyEntries(batch, {
|
|
571
|
+
keys: logKeys,
|
|
572
|
+
chainOnly: ["app"],
|
|
573
|
+
startSeq: at.seq + 1n,
|
|
574
|
+
startPrevHash: at.hash
|
|
671
575
|
});
|
|
672
|
-
if (!result.ok) return
|
|
576
|
+
if (!result.ok) return {
|
|
577
|
+
ok: false,
|
|
578
|
+
failedAtSeq: Number(result.failedAtSeq),
|
|
579
|
+
reason: withCause(result.reason)
|
|
580
|
+
};
|
|
581
|
+
for (const entry of batch) {
|
|
582
|
+
const moved = entry.author === "vault" ? walk.add(entry, logKeys[0].keyId) : null;
|
|
583
|
+
if (moved !== null) return {
|
|
584
|
+
ok: false,
|
|
585
|
+
failedAtSeq: Number(moved.failedAtSeq),
|
|
586
|
+
reason: moved.reason
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
at = {
|
|
590
|
+
seq: result.nextSeq - 1n,
|
|
591
|
+
hash: result.head
|
|
592
|
+
};
|
|
593
|
+
left -= batch.length;
|
|
673
594
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The chain again from `from`, through `to` and no more than `limit`
|
|
598
|
+
* entries: each hash recomputed from its entry and each link checked, and
|
|
599
|
+
* nothing else, so a stretch costs a hash an entry. What a checkpoint does
|
|
600
|
+
* in the background to the prefixes signed before (`#scrub` in vault.ts):
|
|
601
|
+
* each entry's MAC was checked when it was signed over, and its hash pins
|
|
602
|
+
* it since, so all that can change behind a signed prefix and keep the
|
|
603
|
+
* chain is an entry edited in place, its stored hash left, which its hash
|
|
604
|
+
* finds.
|
|
605
|
+
*/
|
|
606
|
+
async function relink(db, from, to, limit) {
|
|
607
|
+
let at = from;
|
|
608
|
+
while (at.seq < to && limit > 0) {
|
|
609
|
+
const batch = (await entriesFrom(db, at.seq + 1n, Math.min(VERIFY_BATCH, limit))).filter((entry) => entry.seq <= to);
|
|
678
610
|
if (batch.length === 0) break;
|
|
679
611
|
const result = verifyEntries(batch, {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
612
|
+
keys: [],
|
|
613
|
+
chainOnly: ["app", "vault"],
|
|
614
|
+
startSeq: at.seq + 1n,
|
|
615
|
+
startPrevHash: at.hash
|
|
683
616
|
});
|
|
684
|
-
if (!result.ok) return
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
verified = {
|
|
689
|
-
nextSeq: result.nextSeq,
|
|
690
|
-
hash: result.head,
|
|
691
|
-
vaultEntries: verified.vaultEntries + result.authenticated,
|
|
692
|
-
vaultKeys: moved.keys
|
|
617
|
+
if (!result.ok) return {
|
|
618
|
+
ok: false,
|
|
619
|
+
failedAtSeq: Number(result.failedAtSeq),
|
|
620
|
+
reason: result.reason
|
|
693
621
|
};
|
|
622
|
+
at = {
|
|
623
|
+
seq: result.nextSeq - 1n,
|
|
624
|
+
hash: result.head
|
|
625
|
+
};
|
|
626
|
+
limit -= batch.length;
|
|
694
627
|
}
|
|
695
628
|
return {
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
entries: verified.vaultEntries
|
|
699
|
-
},
|
|
700
|
-
anchor: verified
|
|
629
|
+
ok: true,
|
|
630
|
+
at
|
|
701
631
|
};
|
|
702
632
|
}
|
|
703
633
|
/**
|
|
634
|
+
* The vault's own entries after `after`, through `to` or its last, no more
|
|
635
|
+
* than `limit` of them, oldest first: each against its own hash, and by its
|
|
636
|
+
* MAC, over the link it names, its keys moving only forward, each fed to
|
|
637
|
+
* `walk`. The links between entries are the caller's to recompute, as the
|
|
638
|
+
* app does over the same stretch. The last it read, and whether that is as
|
|
639
|
+
* far as it was to go.
|
|
640
|
+
*/
|
|
641
|
+
async function ownEntries(db, logKeys, after, walk, { to, limit = Infinity }) {
|
|
642
|
+
for (let left = limit;;) {
|
|
643
|
+
if (left <= 0) return {
|
|
644
|
+
ok: true,
|
|
645
|
+
at: after,
|
|
646
|
+
done: !(await vaultEntriesAfter(db, after, 1)).some((entry) => to === void 0 || entry.seq <= to)
|
|
647
|
+
};
|
|
648
|
+
let batch = await vaultEntriesAfter(db, after, Math.min(VERIFY_BATCH, left));
|
|
649
|
+
if (to !== void 0) batch = batch.filter((entry) => entry.seq <= to);
|
|
650
|
+
if (batch.length === 0) return {
|
|
651
|
+
ok: true,
|
|
652
|
+
at: after,
|
|
653
|
+
done: true
|
|
654
|
+
};
|
|
655
|
+
for (const entry of batch) {
|
|
656
|
+
const result = verifyEntries([entry], {
|
|
657
|
+
startSeq: entry.seq,
|
|
658
|
+
startPrevHash: entry.prevHash,
|
|
659
|
+
keys: logKeys
|
|
660
|
+
});
|
|
661
|
+
if (!result.ok) return {
|
|
662
|
+
ok: false,
|
|
663
|
+
failedAtSeq: Number(result.failedAtSeq),
|
|
664
|
+
reason: withCause(result.reason)
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
for (const entry of batch) {
|
|
668
|
+
const moved = walk.add(entry, logKeys[0].keyId);
|
|
669
|
+
if (moved !== null) return {
|
|
670
|
+
ok: false,
|
|
671
|
+
failedAtSeq: Number(moved.failedAtSeq),
|
|
672
|
+
reason: moved.reason
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
after = batch[batch.length - 1].seq;
|
|
676
|
+
left -= batch.length;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
704
680
|
* The vault's keys only move forward. Once its entries move from one key to
|
|
705
681
|
* the next, the one before writes no more; once they reach `current`, the
|
|
706
682
|
* key it writes with now, no other key writes again. So a key it replaced,
|
|
@@ -963,59 +939,429 @@ function rowKey(signingKey) {
|
|
|
963
939
|
return Buffer.from(hkdfSync("sha256", signingKey, /* @__PURE__ */ new Uint8Array(0), "coffre.vault.rows.v1", 32));
|
|
964
940
|
}
|
|
965
941
|
/**
|
|
966
|
-
* A member's grants as the MAC covers them: each one's place, role, end and
|
|
967
|
-
* grant, as a sorted set. A grant is kept by what its row names first: its
|
|
968
|
-
* environment, its project, or neither, which is a grant on every project
|
|
969
|
-
* of 0.4, with the slug it covers, or null, until the vault replaces it. A member who holds none of those has the
|
|
970
|
-
* set, and so the MAC, they had before there were any.
|
|
942
|
+
* A member's grants as the MAC covers them: each one's place, role, end and
|
|
943
|
+
* grant, as a sorted set. A grant is kept by what its row names first: its
|
|
944
|
+
* environment, its project, or neither, which is a grant on every project
|
|
945
|
+
* of 0.4, with the slug it covers, or null, until the vault replaces it. A member who holds none of those has the
|
|
946
|
+
* set, and so the MAC, they had before there were any.
|
|
947
|
+
*/
|
|
948
|
+
function grantSet(grants) {
|
|
949
|
+
return grants.map((grant) => [
|
|
950
|
+
grant.environmentId !== null ? "environment" : grant.projectId !== null ? "project" : "every-project",
|
|
951
|
+
grant.environmentId ?? grant.projectId ?? grant.environmentSlug,
|
|
952
|
+
grant.role,
|
|
953
|
+
grant.expiresAt,
|
|
954
|
+
grant.grantedAt,
|
|
955
|
+
grant.grantedBy
|
|
956
|
+
]).map((tuple) => JSON.stringify(tuple)).sort();
|
|
957
|
+
}
|
|
958
|
+
/** Whether two reads of a member's grants are the same set, as the MAC sees them. */
|
|
959
|
+
function sameGrants(a, b) {
|
|
960
|
+
const [x, y] = [grantSet(a), grantSet(b)];
|
|
961
|
+
return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* A member's instance role and its scope, as the MAC covers them: as 0.4
|
|
965
|
+
* did, whether they are an owner, for a Member or an Admin everywhere, so
|
|
966
|
+
* that every row 0.4 sealed holds as it is; otherwise the role and the
|
|
967
|
+
* scope, which no row of 0.4 can match.
|
|
968
|
+
*/
|
|
969
|
+
function standing(member) {
|
|
970
|
+
const scope = normalScope(member.scope);
|
|
971
|
+
if (unscoped(scope) && (member.role === "member" || member.role === "admin")) return member.role === "admin";
|
|
972
|
+
return [member.role, scope];
|
|
973
|
+
}
|
|
974
|
+
function memberMac(key, member, grants) {
|
|
975
|
+
const held = grantSet(grants);
|
|
976
|
+
const tuple = [
|
|
977
|
+
"coffre.vault.member.v1",
|
|
978
|
+
member.principal,
|
|
979
|
+
member.status,
|
|
980
|
+
standing(member),
|
|
981
|
+
member.generation,
|
|
982
|
+
member.accessSeq.toString(),
|
|
983
|
+
member.createdAt,
|
|
984
|
+
member.createdBy,
|
|
985
|
+
member.statusChangedAt,
|
|
986
|
+
member.statusChangedBy,
|
|
987
|
+
held
|
|
988
|
+
];
|
|
989
|
+
return createHmac("sha256", key).update(JSON.stringify(tuple)).digest();
|
|
990
|
+
}
|
|
991
|
+
/** Whether `member`'s MAC is the vault's, over this row and these grants. */
|
|
992
|
+
function sealed(key, member, grants) {
|
|
993
|
+
const expected = memberMac(key, member, grants);
|
|
994
|
+
return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
|
|
995
|
+
}
|
|
996
|
+
//#endregion
|
|
997
|
+
//#region src/accounting.ts
|
|
998
|
+
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
999
|
+
"secret.read",
|
|
1000
|
+
"key.wrap",
|
|
1001
|
+
"key.rewrap"
|
|
1002
|
+
]);
|
|
1003
|
+
/**
|
|
1004
|
+
* Every key operation's intent and its outcomes, entry by entry, oldest
|
|
1005
|
+
* first, as a walk reads the vault's entries (`add`); then what is still
|
|
1006
|
+
* open (`result`). The chain can hold while a process died between an
|
|
1007
|
+
* intent and its outcomes: only a missed deadline is a fault.
|
|
1008
|
+
*
|
|
1009
|
+
* A walk resumes from a checkpoint's `state()`: the batches open there, and
|
|
1010
|
+
* nothing of those that closed. So an intent's identity is checked against
|
|
1011
|
+
* those still open and those since, not every one the log ever held: a
|
|
1012
|
+
* closed batch takes no more outcomes in any case, since each names its
|
|
1013
|
+
* intent's entry.
|
|
1014
|
+
*/
|
|
1015
|
+
var KeyAccounting = class {
|
|
1016
|
+
#intents = /* @__PURE__ */ new Map();
|
|
1017
|
+
#ids = /* @__PURE__ */ new Set();
|
|
1018
|
+
#fault = null;
|
|
1019
|
+
constructor(from = {
|
|
1020
|
+
intents: [],
|
|
1021
|
+
fault: null
|
|
1022
|
+
}) {
|
|
1023
|
+
for (const { seq, seen, ...open } of from.intents) {
|
|
1024
|
+
this.#intents.set(BigInt(seq), {
|
|
1025
|
+
...open,
|
|
1026
|
+
seq: BigInt(seq),
|
|
1027
|
+
seen: new Set(seen)
|
|
1028
|
+
});
|
|
1029
|
+
this.#ids.add(open.intentId);
|
|
1030
|
+
}
|
|
1031
|
+
this.#fault = from.fault === null ? null : {
|
|
1032
|
+
ok: false,
|
|
1033
|
+
...from.fault
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
/** Where it stands, to resume from: the batches open, oldest first. */
|
|
1037
|
+
state() {
|
|
1038
|
+
return {
|
|
1039
|
+
intents: [...this.#intents.values()].sort((a, b) => a.seq < b.seq ? -1 : 1).map(({ seq, seen, ...open }) => ({
|
|
1040
|
+
...open,
|
|
1041
|
+
seq: Number(seq),
|
|
1042
|
+
seen: [...seen].sort((a, b) => a - b)
|
|
1043
|
+
})),
|
|
1044
|
+
fault: this.#fault === null ? null : {
|
|
1045
|
+
failedAtSeq: this.#fault.failedAtSeq,
|
|
1046
|
+
reason: this.#fault.reason
|
|
1047
|
+
}
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
add(entry) {
|
|
1051
|
+
if (this.#fault !== null || entry.author !== "vault") return;
|
|
1052
|
+
const broken = (reason) => {
|
|
1053
|
+
this.#fault = {
|
|
1054
|
+
ok: false,
|
|
1055
|
+
failedAtSeq: Number(entry.seq),
|
|
1056
|
+
reason
|
|
1057
|
+
};
|
|
1058
|
+
};
|
|
1059
|
+
if (entry.action === "key.intent") {
|
|
1060
|
+
const parsed = parseIntent(entry);
|
|
1061
|
+
if (typeof parsed === "string") return broken(parsed);
|
|
1062
|
+
if (this.#ids.has(parsed.intentId)) return broken("key intent repeats an operation identity");
|
|
1063
|
+
this.#ids.add(parsed.intentId);
|
|
1064
|
+
if (parsed.keys.length > 0) this.#intents.set(entry.seq, {
|
|
1065
|
+
...parsed,
|
|
1066
|
+
seq: entry.seq,
|
|
1067
|
+
actor: entry.actor,
|
|
1068
|
+
seen: /* @__PURE__ */ new Set()
|
|
1069
|
+
});
|
|
1070
|
+
} else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
|
|
1071
|
+
const parsed = parseOutcome(entry);
|
|
1072
|
+
if (typeof parsed === "string") return broken(parsed);
|
|
1073
|
+
const intent = this.#intents.get(parsed.relatedSeq);
|
|
1074
|
+
for (const outcome of parsed.items) {
|
|
1075
|
+
const key = intent?.keys[outcome.item];
|
|
1076
|
+
if (intent === void 0 || key === void 0 || intent.seen.has(outcome.item)) return broken("key outcome does not identify one item of its intent");
|
|
1077
|
+
if (parsed.intentId !== intent.intentId || entry.actor !== intent.actor || entry.action !== intent.operation) return broken("key outcome belongs to another operation");
|
|
1078
|
+
if (outcome.secretId !== key.secretId || outcome.version !== key.version || outcome.subject !== key.subject) return broken("key outcome does not match its intended secret");
|
|
1079
|
+
intent.seen.add(outcome.item);
|
|
1080
|
+
}
|
|
1081
|
+
if (intent !== void 0 && intent.seen.size === intent.keys.length) this.#intents.delete(intent.seq);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
/** The first fault, or the batches still under way at `at`: live calls can finish after the snapshot. */
|
|
1085
|
+
result(at) {
|
|
1086
|
+
if (this.#fault !== null) return this.#fault;
|
|
1087
|
+
const intents = [...this.#intents.values()];
|
|
1088
|
+
const overdue = intents.filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
|
|
1089
|
+
if (overdue.length === 0) return {
|
|
1090
|
+
ok: true,
|
|
1091
|
+
pending: intents.length
|
|
1092
|
+
};
|
|
1093
|
+
const reason = overdue.map((intent) => {
|
|
1094
|
+
const missing = intent.keys.length - intent.seen.size;
|
|
1095
|
+
return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
|
|
1096
|
+
}).join("; ");
|
|
1097
|
+
return {
|
|
1098
|
+
ok: false,
|
|
1099
|
+
failedAtSeq: Number(overdue[0].seq),
|
|
1100
|
+
reason
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1104
|
+
function parseIntent(entry) {
|
|
1105
|
+
const detail = payload(entry.metadata);
|
|
1106
|
+
if (detail === null) return "key intent has no valid accounting payload";
|
|
1107
|
+
const { intent, operation, expiresAt } = detail;
|
|
1108
|
+
if (typeof intent !== "string") return "key intent has no operation identity";
|
|
1109
|
+
if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
|
|
1110
|
+
if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
|
|
1111
|
+
if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
|
|
1112
|
+
const keys = [];
|
|
1113
|
+
for (const [index, value] of detail.keys.entries()) {
|
|
1114
|
+
const key = record(value);
|
|
1115
|
+
if (key === null || key.item !== index) return "key intent items are not numbered in order";
|
|
1116
|
+
const { secretId, version, subject } = key;
|
|
1117
|
+
if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
|
|
1118
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
|
|
1119
|
+
keys.push({
|
|
1120
|
+
item: index,
|
|
1121
|
+
secretId,
|
|
1122
|
+
version,
|
|
1123
|
+
subject
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
return {
|
|
1127
|
+
intentId: intent,
|
|
1128
|
+
operation,
|
|
1129
|
+
expiresAt,
|
|
1130
|
+
keys
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
function parseOutcome(entry) {
|
|
1134
|
+
const detail = payload(entry.metadata);
|
|
1135
|
+
if (detail === null) return "key outcome has no valid accounting payload";
|
|
1136
|
+
const { intent } = detail;
|
|
1137
|
+
if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
|
|
1138
|
+
const many = detail.secrets;
|
|
1139
|
+
if (many !== void 0 && (!Array.isArray(many) || many.length === 0)) return "key outcome has no valid item list";
|
|
1140
|
+
const listed = many === void 0 ? [{
|
|
1141
|
+
...detail,
|
|
1142
|
+
secretId: entry.secretId ?? detail.secretId
|
|
1143
|
+
}] : many.map(record);
|
|
1144
|
+
const items = [];
|
|
1145
|
+
for (const one of listed) {
|
|
1146
|
+
if (one === null) return "key outcome has no valid item list";
|
|
1147
|
+
const { item, secretId, version, subject } = one;
|
|
1148
|
+
if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
|
|
1149
|
+
if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
|
|
1150
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
|
|
1151
|
+
items.push({
|
|
1152
|
+
item,
|
|
1153
|
+
secretId,
|
|
1154
|
+
version,
|
|
1155
|
+
subject
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
return {
|
|
1159
|
+
intentId: intent,
|
|
1160
|
+
relatedSeq: entry.relatedSeq,
|
|
1161
|
+
items
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
function payload(text) {
|
|
1165
|
+
try {
|
|
1166
|
+
return record(JSON.parse(text));
|
|
1167
|
+
} catch {
|
|
1168
|
+
return null;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
function record(value) {
|
|
1172
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
1173
|
+
}
|
|
1174
|
+
//#endregion
|
|
1175
|
+
//#region src/tally.ts
|
|
1176
|
+
/** Before the first entry. */
|
|
1177
|
+
const EMPTY_TALLY = {
|
|
1178
|
+
entries: 0,
|
|
1179
|
+
keys: [],
|
|
1180
|
+
accounting: {
|
|
1181
|
+
intents: [],
|
|
1182
|
+
fault: null
|
|
1183
|
+
},
|
|
1184
|
+
access: {
|
|
1185
|
+
members: [],
|
|
1186
|
+
grants: [],
|
|
1187
|
+
newest: []
|
|
1188
|
+
},
|
|
1189
|
+
replayFault: null
|
|
1190
|
+
};
|
|
1191
|
+
/** The digest of what `access` says, over its canonical form. */
|
|
1192
|
+
function accessDigest(access) {
|
|
1193
|
+
return createHash("sha256").update(canonical(access)).digest("hex");
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* What two tallies are compared by, so that they match whatever order
|
|
1197
|
+
* their maps were built in: the members and grants by their digest, which
|
|
1198
|
+
* a stretch passing many checkpoints computes once a change; and a fault by
|
|
1199
|
+
* where it was found, since the words for it may change from one release
|
|
1200
|
+
* to the next.
|
|
971
1201
|
*/
|
|
972
|
-
function
|
|
973
|
-
return
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
function sameGrants(a, b) {
|
|
984
|
-
const [x, y] = [grantSet(a), grantSet(b)];
|
|
985
|
-
return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
|
|
1202
|
+
function tallyKey({ accounting, entries, keys, replayFault }, digest) {
|
|
1203
|
+
return canonical({
|
|
1204
|
+
entries,
|
|
1205
|
+
keys,
|
|
1206
|
+
replayFault,
|
|
1207
|
+
accounting: {
|
|
1208
|
+
...accounting,
|
|
1209
|
+
fault: accounting.fault?.failedAtSeq ?? null
|
|
1210
|
+
},
|
|
1211
|
+
access: digest
|
|
1212
|
+
});
|
|
986
1213
|
}
|
|
987
1214
|
/**
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
* that every row 0.4 sealed holds as it is; otherwise the role and the
|
|
991
|
-
* scope, which no row of 0.4 can match.
|
|
1215
|
+
* What a walk must come to at a checkpoint, from the tally it keeps: its
|
|
1216
|
+
* members and grants by the digest it keeps of them, never read.
|
|
992
1217
|
*/
|
|
993
|
-
function
|
|
994
|
-
|
|
995
|
-
if (unscoped(scope) && (member.role === "member" || member.role === "admin")) return member.role === "admin";
|
|
996
|
-
return [member.role, scope];
|
|
997
|
-
}
|
|
998
|
-
function memberMac(key, member, grants) {
|
|
999
|
-
const held = grantSet(grants);
|
|
1000
|
-
const tuple = [
|
|
1001
|
-
"coffre.vault.member.v1",
|
|
1002
|
-
member.principal,
|
|
1003
|
-
member.status,
|
|
1004
|
-
standing(member),
|
|
1005
|
-
member.generation,
|
|
1006
|
-
member.accessSeq.toString(),
|
|
1007
|
-
member.createdAt,
|
|
1008
|
-
member.createdBy,
|
|
1009
|
-
member.statusChangedAt,
|
|
1010
|
-
member.statusChangedBy,
|
|
1011
|
-
held
|
|
1012
|
-
];
|
|
1013
|
-
return createHmac("sha256", key).update(JSON.stringify(tuple)).digest();
|
|
1218
|
+
function expectedKey(stored) {
|
|
1219
|
+
return tallyKey(stored, stored.access.digest);
|
|
1014
1220
|
}
|
|
1015
|
-
/**
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1221
|
+
/**
|
|
1222
|
+
* The vault's entries walked, oldest first, each after its MAC was checked,
|
|
1223
|
+
* gathered into what the checks after the walk need: the tally it started
|
|
1224
|
+
* from, carried on.
|
|
1225
|
+
*/
|
|
1226
|
+
var Walk = class {
|
|
1227
|
+
accounting;
|
|
1228
|
+
replayed;
|
|
1229
|
+
/** Each member's newest allowed access entry, by seq. */
|
|
1230
|
+
newestAccess;
|
|
1231
|
+
/** The first access entry the replay could not apply. */
|
|
1232
|
+
replayFault;
|
|
1233
|
+
/** The vault's entries so far, those before the walk included. */
|
|
1234
|
+
entries;
|
|
1235
|
+
/** The vault's log keys, as the log moved through them. */
|
|
1236
|
+
keys;
|
|
1237
|
+
/** The checkpoints walked, each to be checked against its signature and the prefix it signed once the walk ends: what it signed, and nothing it keeps beside. */
|
|
1238
|
+
checkpoints = [];
|
|
1239
|
+
/** The tallies the walk must come to as it passes them, oldest first (`expect`). */
|
|
1240
|
+
#expected = [];
|
|
1241
|
+
/** The first it did not come to, and why. */
|
|
1242
|
+
mismatch = null;
|
|
1243
|
+
/** The digest of the members and grants as the walk has them, until an access entry changes them. */
|
|
1244
|
+
#digest = null;
|
|
1245
|
+
/** The digest of the members and grants as the walk has them: the tally's it started from, until an access entry. */
|
|
1246
|
+
digest() {
|
|
1247
|
+
return this.#digest ??= accessDigest(this.tally().access);
|
|
1248
|
+
}
|
|
1249
|
+
/** The tally but for its members and grants, which `digest` stands for. */
|
|
1250
|
+
#unaccessed() {
|
|
1251
|
+
return {
|
|
1252
|
+
entries: this.entries,
|
|
1253
|
+
keys: [...this.keys],
|
|
1254
|
+
accounting: this.accounting.state(),
|
|
1255
|
+
replayFault: this.replayFault
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
/** `digest` is that of `from`'s members and grants, when the caller has it: a beat with no access entry hashes none. */
|
|
1259
|
+
constructor(from, digest) {
|
|
1260
|
+
this.#digest = digest ?? null;
|
|
1261
|
+
this.accounting = new KeyAccounting(from.accounting);
|
|
1262
|
+
this.replayed = {
|
|
1263
|
+
members: new Map(from.access.members.map((member) => [member.principal, {
|
|
1264
|
+
...member,
|
|
1265
|
+
accessSeq: BigInt(member.accessSeq)
|
|
1266
|
+
}])),
|
|
1267
|
+
held: /* @__PURE__ */ new Map()
|
|
1268
|
+
};
|
|
1269
|
+
for (const grant of from.access.grants) {
|
|
1270
|
+
const held = this.replayed.held.get(grant.principal) ?? /* @__PURE__ */ new Map();
|
|
1271
|
+
this.replayed.held.set(grant.principal, held);
|
|
1272
|
+
held.set(placeKey$1(grant), { ...grant });
|
|
1273
|
+
}
|
|
1274
|
+
this.newestAccess = new Map(from.access.newest.map(([principal, seq]) => [principal, BigInt(seq)]));
|
|
1275
|
+
this.replayFault = from.replayFault;
|
|
1276
|
+
this.entries = from.entries;
|
|
1277
|
+
this.keys = from.keys;
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* Hold the walk to these tallies, each as the walk stands once it has
|
|
1281
|
+
* read every entry through the prefix's end: before the vault's next
|
|
1282
|
+
* entry, since only the vault's change a tally, or at `finish`.
|
|
1283
|
+
*/
|
|
1284
|
+
expect(expected) {
|
|
1285
|
+
this.#expected = [...expected].sort((a, b) => a.through < b.through ? -1 : 1);
|
|
1286
|
+
}
|
|
1287
|
+
/** The tallies expected through `through`, held to the walk. */
|
|
1288
|
+
finish(through) {
|
|
1289
|
+
this.#reach(through + 1n);
|
|
1290
|
+
}
|
|
1291
|
+
#reach(before) {
|
|
1292
|
+
while (this.#expected.length > 0 && this.#expected[0].through < before) {
|
|
1293
|
+
const expected = this.#expected.shift();
|
|
1294
|
+
if (this.mismatch !== null) continue;
|
|
1295
|
+
if ("unreadable" in expected) this.mismatch = {
|
|
1296
|
+
entry: expected.entry,
|
|
1297
|
+
reason: expected.unreadable
|
|
1298
|
+
};
|
|
1299
|
+
else if (tallyKey(this.#unaccessed(), this.digest()) !== expected.key) this.mismatch = {
|
|
1300
|
+
entry: expected.entry,
|
|
1301
|
+
reason: "the tally this checkpoint keeps does not follow from the log before it"
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* One of the vault's entries, its MAC checked, in the log's order: a
|
|
1307
|
+
* fault when it is under a key the vault had moved past (`forward`).
|
|
1308
|
+
*/
|
|
1309
|
+
add(entry, current) {
|
|
1310
|
+
this.#reach(entry.seq);
|
|
1311
|
+
const moved = forward([entry], this.keys, current);
|
|
1312
|
+
if ("failedAtSeq" in moved) return moved;
|
|
1313
|
+
this.keys = moved.keys;
|
|
1314
|
+
this.entries += 1;
|
|
1315
|
+
this.accounting.add(entry);
|
|
1316
|
+
if (entry.decision !== "allow") return null;
|
|
1317
|
+
if (ACCESS.has(entry.action)) {
|
|
1318
|
+
this.#digest = null;
|
|
1319
|
+
this.replayFault ??= apply(this.replayed, entry);
|
|
1320
|
+
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
1321
|
+
} else if (entry.action === "audit.checkpoint") {
|
|
1322
|
+
const { seq, hash, signedAt, keyId, signature } = JSON.parse(entry.metadata);
|
|
1323
|
+
this.checkpoints.push({
|
|
1324
|
+
entry: {
|
|
1325
|
+
seq: entry.seq,
|
|
1326
|
+
keyId: entry.keyId,
|
|
1327
|
+
relatedSeq: entry.relatedSeq
|
|
1328
|
+
},
|
|
1329
|
+
checkpoint: {
|
|
1330
|
+
seq,
|
|
1331
|
+
hash,
|
|
1332
|
+
signedAt,
|
|
1333
|
+
keyId,
|
|
1334
|
+
signature
|
|
1335
|
+
}
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
return null;
|
|
1339
|
+
}
|
|
1340
|
+
/** Where the walk stands, to resume from. */
|
|
1341
|
+
tally() {
|
|
1342
|
+
const byPrincipal = (entries) => [...entries].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
1343
|
+
return {
|
|
1344
|
+
entries: this.entries,
|
|
1345
|
+
keys: [...this.keys],
|
|
1346
|
+
accounting: this.accounting.state(),
|
|
1347
|
+
access: {
|
|
1348
|
+
members: byPrincipal(this.replayed.members).map(([, member]) => ({
|
|
1349
|
+
...member,
|
|
1350
|
+
accessSeq: Number(member.accessSeq)
|
|
1351
|
+
})),
|
|
1352
|
+
grants: byPrincipal(this.replayed.held).flatMap(([, held]) => byPrincipal(held).map(([, grant]) => grant)),
|
|
1353
|
+
newest: byPrincipal(this.newestAccess).map(([principal, seq]) => [principal, Number(seq)])
|
|
1354
|
+
},
|
|
1355
|
+
replayFault: this.replayFault
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
};
|
|
1359
|
+
/** The action of the vault's entry that signs a prefix of the log. */
|
|
1360
|
+
const CHECKPOINT = "audit.checkpoint";
|
|
1361
|
+
const ACCESS = new Set(ACCESS_ACTIONS);
|
|
1362
|
+
/** JSON with every object's keys sorted: one text for one value, however it was built. */
|
|
1363
|
+
function canonical(value) {
|
|
1364
|
+
return JSON.stringify(value, (_, inner) => inner !== null && typeof inner === "object" && !Array.isArray(inner) ? Object.fromEntries(Object.entries(inner).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) : inner);
|
|
1019
1365
|
}
|
|
1020
1366
|
//#endregion
|
|
1021
1367
|
//#region src/vault.ts
|
|
@@ -1025,9 +1371,13 @@ async function prepareVault(config, options = {}) {
|
|
|
1025
1371
|
...await keysOf(config.signingKeys),
|
|
1026
1372
|
options: {
|
|
1027
1373
|
keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS,
|
|
1028
|
-
clockOffset: options.clockOffset ?? (() => 0)
|
|
1374
|
+
clockOffset: options.clockOffset ?? (() => 0),
|
|
1375
|
+
walks: {
|
|
1376
|
+
...WALKS,
|
|
1377
|
+
...options.walks
|
|
1378
|
+
}
|
|
1029
1379
|
},
|
|
1030
|
-
verified:
|
|
1380
|
+
verified: START,
|
|
1031
1381
|
reported: /* @__PURE__ */ new Set(),
|
|
1032
1382
|
since: null,
|
|
1033
1383
|
superseded: null,
|
|
@@ -1076,8 +1426,6 @@ const UNSEALED = {
|
|
|
1076
1426
|
};
|
|
1077
1427
|
/** Why a change to a tampered member is refused. */
|
|
1078
1428
|
const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
|
|
1079
|
-
/** The action of the vault's entry that signs a prefix of the log. */
|
|
1080
|
-
const CHECKPOINT = "audit.checkpoint";
|
|
1081
1429
|
/** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
|
|
1082
1430
|
const SETTINGS = "settings.change";
|
|
1083
1431
|
/** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
|
|
@@ -1122,12 +1470,6 @@ function declaredOwner(principal) {
|
|
|
1122
1470
|
const NOT_THEIRS = "a person gives a service account at most what they hold, where the instance lets them set one up, and only to one whose every grant they hold";
|
|
1123
1471
|
/** Who asks for checkpoints: the app's scheduled job. */
|
|
1124
1472
|
const SCHEDULER = "system:coffre-scheduler";
|
|
1125
|
-
/**
|
|
1126
|
-
* How often a checkpoint recomputes the chain from its first entry: the
|
|
1127
|
-
* first of each hour, by the vault's clock. The others resume from the
|
|
1128
|
-
* prefix the last one signed (`#recompute`).
|
|
1129
|
-
*/
|
|
1130
|
-
const FULL_RECOMPUTE_MS = 36e5;
|
|
1131
1473
|
/** A refusal and the entries that record it. */
|
|
1132
1474
|
var Refused = class {
|
|
1133
1475
|
refusal;
|
|
@@ -1372,14 +1714,17 @@ var VaultService = class {
|
|
|
1372
1714
|
}
|
|
1373
1715
|
/** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
|
|
1374
1716
|
#authentic(entry) {
|
|
1375
|
-
const { logKeys, since } = this.#prepared;
|
|
1376
|
-
const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
|
|
1377
1717
|
return verifyEntries([entry], {
|
|
1378
1718
|
startSeq: entry.seq,
|
|
1379
1719
|
startPrevHash: entry.prevHash,
|
|
1380
|
-
keys
|
|
1720
|
+
keys: this.#keysFor(entry)
|
|
1381
1721
|
}).ok;
|
|
1382
1722
|
}
|
|
1723
|
+
/** The vault's log keys an entry may be under: any of them before `since`, and only its current one from there. */
|
|
1724
|
+
#keysFor(entry) {
|
|
1725
|
+
const { logKeys, since } = this.#prepared;
|
|
1726
|
+
return since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
|
|
1727
|
+
}
|
|
1383
1728
|
/**
|
|
1384
1729
|
* Whether `row`, with these grants, carries the MAC of the vault's row
|
|
1385
1730
|
* key, or of one it replaced before the rotation sealed every row again
|
|
@@ -1933,15 +2278,19 @@ var VaultService = class {
|
|
|
1933
2278
|
if (!intent.ok) return intent;
|
|
1934
2279
|
intentSeq = intent.seq;
|
|
1935
2280
|
}
|
|
1936
|
-
|
|
2281
|
+
const late = call.remote;
|
|
2282
|
+
const decided = async (d) => {
|
|
1937
2283
|
try {
|
|
1938
2284
|
await check(d);
|
|
1939
2285
|
} catch (error) {
|
|
1940
2286
|
if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
|
|
1941
2287
|
throw error;
|
|
1942
2288
|
}
|
|
2289
|
+
};
|
|
2290
|
+
return this.#decide([principal], async (d) => {
|
|
2291
|
+
await decided(d);
|
|
1943
2292
|
const { vet } = call;
|
|
1944
|
-
if (vet !== void 0) d.underLock.push(async (locked) => {
|
|
2293
|
+
if (late && vet !== void 0) d.underLock.push(async (locked) => {
|
|
1945
2294
|
const codes = await vet(locked);
|
|
1946
2295
|
const first = codes.find((code) => code !== null);
|
|
1947
2296
|
if (first === void 0 || first === null) return;
|
|
@@ -1957,16 +2306,13 @@ var VaultService = class {
|
|
|
1957
2306
|
if (unanswered > 0) throw new Outage(new KekUnavailableError(`the key service did not answer for ${unanswered} of ${secrets.length} keys`, outcomes.some((outcome) => !outcome.ok && outcome.code === "kms_uncertain")), entries());
|
|
1958
2307
|
if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
|
|
1959
2308
|
if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
await
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
throw
|
|
2309
|
+
if (late) {
|
|
2310
|
+
await lockLogHead(d.tx);
|
|
2311
|
+
await decided(d);
|
|
2312
|
+
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
2313
|
+
const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
|
|
2314
|
+
if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
|
|
1966
2315
|
}
|
|
1967
|
-
const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
|
|
1968
|
-
const now = await environmentsById(d.tx, places.map((place) => place.environmentId));
|
|
1969
|
-
if (places.some((place) => now.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
|
|
1970
2316
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
|
|
1971
2317
|
d.log.push(...released);
|
|
1972
2318
|
const answer = result(done);
|
|
@@ -1975,7 +2321,7 @@ var VaultService = class {
|
|
|
1975
2321
|
} finally {
|
|
1976
2322
|
for (const value of done) value.wipe();
|
|
1977
2323
|
}
|
|
1978
|
-
},
|
|
2324
|
+
}, late);
|
|
1979
2325
|
}
|
|
1980
2326
|
/** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
|
|
1981
2327
|
async #overBulkLimit(d, principal, n) {
|
|
@@ -2760,7 +3106,7 @@ var VaultService = class {
|
|
|
2760
3106
|
* the default: otherwise whoever can insert rows could open it again.
|
|
2761
3107
|
*/
|
|
2762
3108
|
async #settingsAt(db, reports) {
|
|
2763
|
-
const entry = await newestAuthentic((before) => newestVaultEntries(db, SETTINGS,
|
|
3109
|
+
const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, SETTINGS, limit, before), this.#check(reports, null));
|
|
2764
3110
|
if (entry === "none") return DEFAULT_SETTINGS;
|
|
2765
3111
|
if (typeof entry === "string") return CLOSED_SETTINGS;
|
|
2766
3112
|
const { serviceAccounts } = JSON.parse(entry.metadata);
|
|
@@ -2782,7 +3128,7 @@ var VaultService = class {
|
|
|
2782
3128
|
* could unprotect them. Its edits are then unknown, not none.
|
|
2783
3129
|
*/
|
|
2784
3130
|
async #policyAt(db, reports, at) {
|
|
2785
|
-
const entry = await newestAuthentic((before) => newestVaultEntries(db, POLICY,
|
|
3131
|
+
const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
|
|
2786
3132
|
if (entry === "none") return {
|
|
2787
3133
|
policy: DEFAULT_POLICY,
|
|
2788
3134
|
edits: NO_EDITS,
|
|
@@ -3077,63 +3423,162 @@ var VaultService = class {
|
|
|
3077
3423
|
}
|
|
3078
3424
|
return checks;
|
|
3079
3425
|
}
|
|
3080
|
-
/**
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3426
|
+
/**
|
|
3427
|
+
* Where a walk of the log resumes: the first entry (`start`), a
|
|
3428
|
+
* checkpoint's entry that keeps a tally, or the newest such entry, the
|
|
3429
|
+
* first entry while there is none. The entry must be the vault's, by its
|
|
3430
|
+
* MAC, and its tally whole: the members and grants it keeps, or the
|
|
3431
|
+
* earlier checkpoint it names keeps, match the digest it carries.
|
|
3432
|
+
*
|
|
3433
|
+
* Its signature is left to the walk: the entry comes after the prefix it
|
|
3434
|
+
* signs, so a walk from there reads it, as it reads every other.
|
|
3435
|
+
*/
|
|
3436
|
+
async #resume(db, from) {
|
|
3437
|
+
if (from === "start") return FIRST_ENTRY;
|
|
3438
|
+
const entry = from === "newest" ? await latestTallied(db) : (await entriesAt(db, [from])).get(from);
|
|
3439
|
+
if (entry === void 0) return from === "newest" ? FIRST_ENTRY : {
|
|
3440
|
+
ok: false,
|
|
3441
|
+
failedAtSeq: Number(from),
|
|
3442
|
+
reason: "no checkpoint at this entry"
|
|
3087
3443
|
};
|
|
3444
|
+
return (await this.#resumeEach(db, [entry]))[0];
|
|
3445
|
+
}
|
|
3446
|
+
/** `#resume` for each of these checkpoint entries, the entries their members and grants are kept in read at once. */
|
|
3447
|
+
async #resumeEach(db, entries) {
|
|
3448
|
+
const reads = entries.map((entry) => this.#tallied(entry));
|
|
3449
|
+
const named = reads.flatMap((read) => typeof read !== "string" && "at" in read.tally.access ? [BigInt(read.tally.access.at)] : []);
|
|
3450
|
+
const carriers = new Map([...await entriesAt(db, named)].map(([seq, held]) => [seq, this.#tallied(held)]));
|
|
3451
|
+
return entries.map((entry, i) => {
|
|
3452
|
+
const broken = (reason) => ({
|
|
3453
|
+
ok: false,
|
|
3454
|
+
failedAtSeq: Number(entry.seq),
|
|
3455
|
+
reason
|
|
3456
|
+
});
|
|
3457
|
+
const read = reads[i];
|
|
3458
|
+
if (typeof read === "string") return broken(read);
|
|
3459
|
+
let carrier = entry.seq;
|
|
3460
|
+
let { access } = read.tally;
|
|
3461
|
+
if ("at" in access) {
|
|
3462
|
+
carrier = BigInt(access.at);
|
|
3463
|
+
const holder = carrier < entry.seq ? carriers.get(carrier) : void 0;
|
|
3464
|
+
if (holder === void 0 || typeof holder === "string" || "at" in holder.tally.access) return broken(`its tally keeps its members and grants in entry ${carrier}, which holds none`);
|
|
3465
|
+
access = holder.tally.access;
|
|
3466
|
+
}
|
|
3467
|
+
const kept = {
|
|
3468
|
+
members: access.members,
|
|
3469
|
+
grants: access.grants,
|
|
3470
|
+
newest: access.newest
|
|
3471
|
+
};
|
|
3472
|
+
if (keptDigest(access) !== read.tally.access.digest) return broken("its tally's members and grants do not match their digest");
|
|
3473
|
+
return {
|
|
3474
|
+
ok: true,
|
|
3475
|
+
entry: entry.seq,
|
|
3476
|
+
at: {
|
|
3477
|
+
seq: BigInt(read.seq),
|
|
3478
|
+
hash: Buffer.from(read.hash, "hex")
|
|
3479
|
+
},
|
|
3480
|
+
tally: {
|
|
3481
|
+
...read.tally,
|
|
3482
|
+
access: kept
|
|
3483
|
+
},
|
|
3484
|
+
digest: read.tally.access.digest,
|
|
3485
|
+
carrier,
|
|
3486
|
+
scrub: read.scrub === null ? null : {
|
|
3487
|
+
at: positionOf(read.scrub.at),
|
|
3488
|
+
end: positionOf(read.scrub.end)
|
|
3489
|
+
}
|
|
3490
|
+
};
|
|
3491
|
+
});
|
|
3492
|
+
}
|
|
3493
|
+
/** A checkpoint's entry that keeps a tally, as it says, or why it is not one the vault wrote. */
|
|
3494
|
+
#tallied(entry) {
|
|
3495
|
+
const not = "not a checkpoint of the vault's that keeps a tally";
|
|
3496
|
+
if (entry.author !== "vault" || entry.action !== "audit.checkpoint" || entry.decision !== "allow" || entry.relatedSeq === null) return not;
|
|
3497
|
+
const authentic = verifyEntries([entry], {
|
|
3498
|
+
startSeq: entry.seq,
|
|
3499
|
+
startPrevHash: entry.prevHash,
|
|
3500
|
+
keys: this.#keysFor(entry)
|
|
3501
|
+
});
|
|
3502
|
+
if (!authentic.ok) return withCause(authentic.reason);
|
|
3503
|
+
const read = metadataOf(entry);
|
|
3504
|
+
if (read?.tally?.v !== 1 || read.scrub === void 0 || typeof read.seq !== "number" || BigInt(read.seq) !== entry.relatedSeq) return not;
|
|
3505
|
+
return read;
|
|
3506
|
+
}
|
|
3507
|
+
/** Which of `heads` the log still holds where they were, in one read. */
|
|
3508
|
+
async #holding(db, heads) {
|
|
3509
|
+
const hashes = await hashesAt(db, [...new Set(heads.filter((head) => head.seq >= 0).map((head) => BigInt(head.seq)))]);
|
|
3510
|
+
return (head) => head.hash === GENESIS_HASH.toString("hex") || hashes.get(BigInt(head.seq))?.toString("hex") === head.hash;
|
|
3088
3511
|
}
|
|
3089
3512
|
/**
|
|
3090
|
-
*
|
|
3091
|
-
* its first entry at the first checkpoint of each hour, and otherwise from
|
|
3092
|
-
* the prefix the last checkpoint signed, if that one was signed this hour.
|
|
3093
|
-
*
|
|
3094
|
-
* Resuming never signs what the vault did not recompute. The vault
|
|
3095
|
-
* recomputed the last checkpoint's prefix itself, from entry 0 at the
|
|
3096
|
-
* hour's first checkpoint and onward at each one since, and the entry
|
|
3097
|
-
* that holds it is the first one checked again, by its MAC. The next
|
|
3098
|
-
* signature extends that same chain.
|
|
3513
|
+
* Sign the log up to its last entry, from the newest checkpoint's tally.
|
|
3099
3514
|
*
|
|
3100
|
-
*
|
|
3101
|
-
*
|
|
3102
|
-
*
|
|
3103
|
-
*
|
|
3104
|
-
*
|
|
3105
|
-
*
|
|
3106
|
-
*
|
|
3515
|
+
* In one snapshot, and without the log's lock, so that no append waits on
|
|
3516
|
+
* it: every member's row checked, since rows changed around the vault
|
|
3517
|
+
* write nothing to the log; the prefix the newest checkpoint signed still
|
|
3518
|
+
* there, its hash where it was, which a rewrite chained again changes, and
|
|
3519
|
+
* every entry of it, which a cut changes; the chain recomputed from there,
|
|
3520
|
+
* every hash from content and every vault entry by its MAC, no further
|
|
3521
|
+
* than one walk (`walks.checkpoint`); and a stretch of the prefixes signed
|
|
3522
|
+
* before rehashed (`#scrub`). Then, under the lock, the entries appended
|
|
3523
|
+
* since the snapshot, so that it signs the head.
|
|
3107
3524
|
*
|
|
3108
|
-
*
|
|
3109
|
-
*
|
|
3110
|
-
*
|
|
3525
|
+
* A walk cut short signs where it got to, behind the head: the log after
|
|
3526
|
+
* an upgrade from 0.10.0, whose checkpoints kept no tally, is signed again
|
|
3527
|
+
* from the first entry, one walk a call, and `/readyz` stays red until the
|
|
3528
|
+
* checkpoints reach the newest heartbeat.
|
|
3111
3529
|
*/
|
|
3112
|
-
async #recompute(tx) {
|
|
3113
|
-
const { logKeys } = this.#prepared;
|
|
3114
|
-
const latest = await this.#latest(tx);
|
|
3115
|
-
const hour = (ms) => Math.floor(ms / FULL_RECOMPUTE_MS);
|
|
3116
|
-
if (!(latest !== null && latest.keyId === logKeys[0].keyId && latest.seq === BigInt(latest.checkpoint.seq) + 1n && hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) && await carries(tx, latest.checkpoint) && await complete(tx, BigInt(latest.checkpoint.seq)))) return verifyChain(tx, logKeys, [], UNVERIFIED);
|
|
3117
|
-
const { seq, hash } = latest.checkpoint;
|
|
3118
|
-
return verifyChain(tx, logKeys, [], {
|
|
3119
|
-
nextSeq: BigInt(seq) + 1n,
|
|
3120
|
-
hash: Buffer.from(hash, "hex"),
|
|
3121
|
-
vaultEntries: 0,
|
|
3122
|
-
vaultKeys: [logKeys[0].keyId]
|
|
3123
|
-
});
|
|
3124
|
-
}
|
|
3125
3530
|
async checkpoint() {
|
|
3126
3531
|
await this.#settled();
|
|
3127
|
-
const { wrongKek } = this.#prepared;
|
|
3532
|
+
const { wrongKek, logKeys } = this.#prepared;
|
|
3533
|
+
const { walks } = this.#prepared.options;
|
|
3128
3534
|
const found = [];
|
|
3129
|
-
|
|
3535
|
+
let seen = null;
|
|
3536
|
+
const walked = await this.#db.transaction(async (tx) => {
|
|
3537
|
+
seen = (await newestCheckpointEntry(tx))?.seq ?? null;
|
|
3130
3538
|
await this.#sweep(tx, found);
|
|
3131
|
-
|
|
3539
|
+
const from = await this.#resume(tx, "newest");
|
|
3540
|
+
if (!from.ok) return from;
|
|
3541
|
+
const signed = headOf(from.at);
|
|
3542
|
+
if (!(await this.#holding(tx, [signed]))(signed)) return {
|
|
3543
|
+
ok: false,
|
|
3544
|
+
failedAtSeq: null,
|
|
3545
|
+
reason: `the log up to entry ${signed.seq} is not the prefix the last checkpoint signed`
|
|
3546
|
+
};
|
|
3547
|
+
const gap = await missingEntry(tx, from.at.seq);
|
|
3548
|
+
if (gap !== null) return {
|
|
3549
|
+
ok: false,
|
|
3550
|
+
failedAtSeq: Number(gap.found),
|
|
3551
|
+
reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
|
|
3552
|
+
};
|
|
3553
|
+
const walk = new Walk(from.tally, from.digest);
|
|
3554
|
+
const chain = await rehash(tx, logKeys, from.at, walk, { limit: walks.checkpoint });
|
|
3555
|
+
if (!chain.ok) return chain;
|
|
3556
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints);
|
|
3557
|
+
if (unsigned !== null) return unsigned;
|
|
3558
|
+
return {
|
|
3559
|
+
ok: true,
|
|
3560
|
+
from,
|
|
3561
|
+
tally: walk.tally(),
|
|
3562
|
+
digest: walk.digest(),
|
|
3563
|
+
at: chain.at,
|
|
3564
|
+
done: chain.done
|
|
3565
|
+
};
|
|
3132
3566
|
}, SNAPSHOT);
|
|
3567
|
+
let scrub = {
|
|
3568
|
+
ok: true,
|
|
3569
|
+
next: walked.ok ? walked.from.scrub : null
|
|
3570
|
+
};
|
|
3571
|
+
let stalled = null;
|
|
3572
|
+
if (walked.ok) try {
|
|
3573
|
+
scrub = await this.#db.transaction((tx) => this.#scrub(tx, walked.from), SNAPSHOT);
|
|
3574
|
+
} catch (error) {
|
|
3575
|
+
if (!isUnreachable(error) && !timedOut(error)) throw error;
|
|
3576
|
+
stalled = `the rehash of the past did not run, and goes on where it was next time: ${error.message}`;
|
|
3577
|
+
}
|
|
3133
3578
|
return this.#decide([], async (d) => {
|
|
3134
3579
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
3135
3580
|
const head = await lockLogHead(d.tx);
|
|
3136
|
-
const latest = await
|
|
3581
|
+
const latest = await latestVaultEntry(d.tx, [CHECKPOINT]);
|
|
3137
3582
|
const refused = (code, detail, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
|
|
3138
3583
|
actor: SCHEDULER,
|
|
3139
3584
|
action: CHECKPOINT,
|
|
@@ -3143,21 +3588,57 @@ var VaultService = class {
|
|
|
3143
3588
|
}]);
|
|
3144
3589
|
if (head.nextSeq === 0n) throw new Refused(refusal("invalid", "the log is empty"), []);
|
|
3145
3590
|
if (wrongKek !== null) throw refused("wrong_kek", { reason: wrongKek }, wrongKek);
|
|
3146
|
-
if (!
|
|
3147
|
-
failedAtSeq:
|
|
3148
|
-
reason:
|
|
3591
|
+
if (!walked.ok) throw refused("log_broken", {
|
|
3592
|
+
failedAtSeq: walked.failedAtSeq,
|
|
3593
|
+
reason: walked.reason
|
|
3149
3594
|
});
|
|
3150
|
-
if (
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
if (!held.ok) throw refused("log_broken", {
|
|
3154
|
-
failedAtSeq: held.failedAtSeq,
|
|
3155
|
-
reason: held.reason
|
|
3595
|
+
if (!scrub.ok) throw refused("log_broken", {
|
|
3596
|
+
failedAtSeq: scrub.failedAtSeq,
|
|
3597
|
+
reason: scrub.reason
|
|
3156
3598
|
});
|
|
3157
|
-
|
|
3599
|
+
const newest = latest === void 0 ? null : JSON.parse(latest.metadata);
|
|
3600
|
+
if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
|
|
3601
|
+
const standing = await this.#standingRefusal(d.tx);
|
|
3602
|
+
if (standing !== null) throw refused("log_broken", standing);
|
|
3603
|
+
if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
|
|
3604
|
+
}
|
|
3605
|
+
if (newest !== null && latest.seq === head.nextSeq - 1n && latest.relatedSeq === latest.seq - 1n) return { checkpoint: signedPart(newest) };
|
|
3606
|
+
if (newest !== null && !await carries(d.tx, newest)) throw refused("log_broken", { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
|
|
3607
|
+
const walk = new Walk(walked.tally, walked.digest);
|
|
3608
|
+
let { at } = walked;
|
|
3609
|
+
if (walked.done) {
|
|
3610
|
+
if (!await carries(d.tx, headOf(at))) throw refused("log_broken", {
|
|
3611
|
+
failedAtSeq: Number(at.seq),
|
|
3612
|
+
reason: `the log up to entry ${at.seq} changed while the vault walked it`
|
|
3613
|
+
});
|
|
3614
|
+
const rest = await rehash(d.tx, logKeys, at, walk, {});
|
|
3615
|
+
if (!rest.ok) throw refused("log_broken", {
|
|
3616
|
+
failedAtSeq: rest.failedAtSeq,
|
|
3617
|
+
reason: rest.reason
|
|
3618
|
+
});
|
|
3619
|
+
const unsigned = await this.#checkpointFault(d.tx, walk.checkpoints);
|
|
3620
|
+
if (unsigned !== null) throw refused("log_broken", {
|
|
3621
|
+
failedAtSeq: unsigned.failedAtSeq,
|
|
3622
|
+
reason: unsigned.reason
|
|
3623
|
+
});
|
|
3624
|
+
at = rest.at;
|
|
3625
|
+
if (at.seq !== head.nextSeq - 1n || !at.hash.equals(head.headHash)) throw refused("log_broken", { reason: "the chain head does not name the last entry" });
|
|
3626
|
+
} else if (!await carries(d.tx, headOf(at))) throw refused("log_broken", { reason: `the log up to entry ${at.seq} changed while the vault walked it` });
|
|
3627
|
+
if (newest !== null && latest.relatedSeq !== null && latest.relatedSeq >= at.seq) return { checkpoint: signedPart(newest) };
|
|
3628
|
+
this.#prepared.verified = further(this.#prepared.verified, at);
|
|
3629
|
+
const tally = walk.tally();
|
|
3630
|
+
const digest = walk.digest();
|
|
3631
|
+
const { from } = walked;
|
|
3632
|
+
const access = digest === from.digest && from.carrier !== null ? {
|
|
3633
|
+
digest,
|
|
3634
|
+
at: Number(from.carrier)
|
|
3635
|
+
} : {
|
|
3636
|
+
digest,
|
|
3637
|
+
...tally.access
|
|
3638
|
+
};
|
|
3158
3639
|
const signed = {
|
|
3159
|
-
seq: Number(
|
|
3160
|
-
hash:
|
|
3640
|
+
seq: Number(at.seq),
|
|
3641
|
+
hash: at.hash.toString("hex"),
|
|
3161
3642
|
signedAt: iso(d.at)
|
|
3162
3643
|
};
|
|
3163
3644
|
const checkpoint = {
|
|
@@ -3165,15 +3646,85 @@ var VaultService = class {
|
|
|
3165
3646
|
keyId: this.#prepared.signer.keyId,
|
|
3166
3647
|
signature: await this.#prepared.signer.sign(checkpointMessage(signed))
|
|
3167
3648
|
};
|
|
3649
|
+
const lap = scrub.next === null ? null : {
|
|
3650
|
+
at: headOf(scrub.next.at),
|
|
3651
|
+
end: headOf(scrub.next.end)
|
|
3652
|
+
};
|
|
3653
|
+
const kept = {
|
|
3654
|
+
...checkpoint,
|
|
3655
|
+
tally: {
|
|
3656
|
+
v: 1,
|
|
3657
|
+
...tally,
|
|
3658
|
+
access
|
|
3659
|
+
},
|
|
3660
|
+
scrub: lap
|
|
3661
|
+
};
|
|
3168
3662
|
d.log.push({
|
|
3169
3663
|
actor: SCHEDULER,
|
|
3170
3664
|
action: CHECKPOINT,
|
|
3171
3665
|
decision: "allow",
|
|
3172
|
-
|
|
3666
|
+
relatedSeq: at.seq,
|
|
3667
|
+
metadata: JSON.stringify(kept)
|
|
3173
3668
|
});
|
|
3174
|
-
return { checkpoint }
|
|
3669
|
+
return stalled === null ? { checkpoint } : {
|
|
3670
|
+
checkpoint,
|
|
3671
|
+
warning: stalled
|
|
3672
|
+
};
|
|
3175
3673
|
});
|
|
3176
3674
|
}
|
|
3675
|
+
/**
|
|
3676
|
+
* The prefixes signed before, rehashed a stretch at a time, in laps: a lap
|
|
3677
|
+
* runs from the first entry to the prefix the newest checkpoint signed
|
|
3678
|
+
* when it began, which it keeps (`end`), `walks.scrub` entries a call, each
|
|
3679
|
+
* hash recomputed from its entry and each link checked (`relink`); the
|
|
3680
|
+
* next lap runs to the prefix signed then. A checkpoint resumes from the
|
|
3681
|
+
* newest one's tally and never reads what it signed again, and an entry
|
|
3682
|
+
* before it edited in place, its stored hash left as it was, keeps the
|
|
3683
|
+
* chain: this is what finds it, within two laps at most, each the length
|
|
3684
|
+
* of the log when it began over `walks.scrub` beats, however fast the log
|
|
3685
|
+
* grows meanwhile. The lap as it goes on, null between laps.
|
|
3686
|
+
*/
|
|
3687
|
+
async #scrub(db, newest) {
|
|
3688
|
+
const lap = newest.scrub ?? {
|
|
3689
|
+
at: START,
|
|
3690
|
+
end: newest.at
|
|
3691
|
+
};
|
|
3692
|
+
if (lap.end.seq < 0n) return {
|
|
3693
|
+
ok: true,
|
|
3694
|
+
next: null
|
|
3695
|
+
};
|
|
3696
|
+
const relinked = await relink(db, lap.at, lap.end.seq, this.#prepared.options.walks.scrub);
|
|
3697
|
+
if (!relinked.ok) return relinked;
|
|
3698
|
+
if (relinked.at.seq < lap.end.seq) return {
|
|
3699
|
+
ok: true,
|
|
3700
|
+
next: {
|
|
3701
|
+
at: relinked.at,
|
|
3702
|
+
end: lap.end
|
|
3703
|
+
}
|
|
3704
|
+
};
|
|
3705
|
+
if (!relinked.at.hash.equals(lap.end.hash)) return {
|
|
3706
|
+
ok: false,
|
|
3707
|
+
failedAtSeq: Number(lap.end.seq),
|
|
3708
|
+
reason: `the log up to entry ${lap.end.seq} is not the prefix a checkpoint signed`
|
|
3709
|
+
};
|
|
3710
|
+
return {
|
|
3711
|
+
ok: true,
|
|
3712
|
+
next: null
|
|
3713
|
+
};
|
|
3714
|
+
}
|
|
3715
|
+
/**
|
|
3716
|
+
* The newest refusal for a broken log since the newest checkpoint was
|
|
3717
|
+
* signed, by the vault's MAC, as it said: it stands, for every
|
|
3718
|
+
* verification and checkpoint, until a checkpoint is signed, which takes
|
|
3719
|
+
* the check that found it running again and passing. A refusal of
|
|
3720
|
+
* another kind since, a wrong KEK, hides none.
|
|
3721
|
+
*/
|
|
3722
|
+
async #standingRefusal(db) {
|
|
3723
|
+
const signed = await latestVaultEntry(db, [CHECKPOINT]);
|
|
3724
|
+
const refused = await newestRefusal(db, CHECKPOINT, "log_broken", signed?.seq ?? -1n);
|
|
3725
|
+
if (refused === void 0 || !this.#authentic(refused)) return null;
|
|
3726
|
+
return JSON.parse(refused.metadata);
|
|
3727
|
+
}
|
|
3177
3728
|
async about() {
|
|
3178
3729
|
await this.#settled();
|
|
3179
3730
|
const { signers, since } = this.#prepared;
|
|
@@ -3202,65 +3753,181 @@ var VaultService = class {
|
|
|
3202
3753
|
checks
|
|
3203
3754
|
};
|
|
3204
3755
|
}
|
|
3205
|
-
verifyLog(input) {
|
|
3206
|
-
return this.#verifyAll(input.upTo ?? null);
|
|
3207
|
-
}
|
|
3208
3756
|
/**
|
|
3209
|
-
* The
|
|
3210
|
-
* each by its MAC, the keys it moved through, and what the
|
|
3211
|
-
* need of it
|
|
3212
|
-
* heads that must still be there,
|
|
3213
|
-
* members and grants
|
|
3757
|
+
* The log from a checkpoint's tally, in one snapshot: the vault's
|
|
3758
|
+
* entries since, each by its MAC, the keys it moved through, and what the
|
|
3759
|
+
* other checks need of it (`Walk`); then every checkpoint walked, the
|
|
3760
|
+
* heads that must still be there, and, for a walk that ends at the
|
|
3761
|
+
* present, the key batches accounted for and the members and grants
|
|
3762
|
+
* replayed against their rows.
|
|
3214
3763
|
*
|
|
3215
3764
|
* With `upTo`, the head the app verified to, the links and hashes of the
|
|
3216
|
-
* chain are the app's to recompute, as it just has, over its own
|
|
3217
|
-
* through that head; `
|
|
3218
|
-
* the vault reads only its own entries, each checked against its
|
|
3219
|
-
* and MAC over the link it names. Without `upTo`, nobody else
|
|
3220
|
-
* the chain: the vault reads it all, and rehashes every link
|
|
3765
|
+
* chain are the app's to recompute, as it just has, over its own
|
|
3766
|
+
* snapshot, through that head; holding `upTo` ties this snapshot to that
|
|
3767
|
+
* one. So the vault reads only its own entries, each checked against its
|
|
3768
|
+
* own hash and MAC over the link it names. Without `upTo`, nobody else
|
|
3769
|
+
* recomputed the chain: the vault reads it all, and rehashes every link,
|
|
3770
|
+
* no further than one walk (`walks.verify`).
|
|
3221
3771
|
*/
|
|
3222
|
-
async
|
|
3772
|
+
async verifyLog(input) {
|
|
3223
3773
|
await this.#settled();
|
|
3774
|
+
const upTo = input.upTo ?? null;
|
|
3775
|
+
const { logKeys } = this.#prepared;
|
|
3776
|
+
const { walks } = this.#prepared.options;
|
|
3224
3777
|
return this.#db.transaction(async (tx) => {
|
|
3225
3778
|
const remembered = this.#prepared.verified;
|
|
3226
|
-
const
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
const
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3779
|
+
const from = await this.#resume(tx, input.from === void 0 ? "newest" : input.from === "start" ? "start" : BigInt(input.from));
|
|
3780
|
+
if (!from.ok) return from;
|
|
3781
|
+
const to = input.to === void 0 ? null : BigInt(input.to);
|
|
3782
|
+
const ends = to === null ? null : (await entriesAt(tx, [to])).get(to)?.relatedSeq ?? -1n;
|
|
3783
|
+
if (ends !== null && (ends <= from.at.seq || upTo !== null && BigInt(upTo.seq) !== ends)) return {
|
|
3784
|
+
ok: false,
|
|
3785
|
+
failedAtSeq: Number(to),
|
|
3786
|
+
reason: "not a checkpoint after the one the walk starts from, whose prefix ends where the app verified to"
|
|
3787
|
+
};
|
|
3788
|
+
if (!(await this.#holding(tx, [headOf(from.at)]))(headOf(from.at))) return {
|
|
3789
|
+
ok: false,
|
|
3790
|
+
failedAtSeq: Number(from.entry),
|
|
3791
|
+
reason: `the log up to entry ${from.at.seq} is not the prefix this checkpoint signed: it was rewritten`
|
|
3792
|
+
};
|
|
3793
|
+
const gap = await missingEntry(tx, from.at.seq);
|
|
3794
|
+
if (gap !== null) return {
|
|
3795
|
+
ok: false,
|
|
3796
|
+
failedAtSeq: Number(gap.found),
|
|
3797
|
+
reason: `sequence gap: expected seq ${gap.expected}, found ${gap.found}`
|
|
3798
|
+
};
|
|
3799
|
+
const walk = new Walk(from.tally, from.digest);
|
|
3800
|
+
const passed = (await talliedAfter(tx, from.entry ?? -1n, walks.verify)).filter(({ seq }) => to === null || seq <= to);
|
|
3801
|
+
if (to !== null && passed.at(-1)?.seq !== to) return {
|
|
3802
|
+
ok: false,
|
|
3803
|
+
failedAtSeq: Number(to),
|
|
3804
|
+
reason: `a stretch passes no more than ${walks.verify} checkpoints: end one sooner`
|
|
3805
|
+
};
|
|
3806
|
+
const holding = /* @__PURE__ */ new Map();
|
|
3807
|
+
const carried = async (carrier) => {
|
|
3808
|
+
if (!holding.has(carrier)) {
|
|
3809
|
+
const entry = (await entriesAt(tx, [carrier])).get(carrier);
|
|
3810
|
+
const read = entry === void 0 ? "missing" : this.#tallied(entry);
|
|
3811
|
+
holding.set(carrier, typeof read === "string" || "at" in read.tally.access ? null : keptDigest(read.tally.access));
|
|
3812
|
+
}
|
|
3813
|
+
return holding.get(carrier);
|
|
3814
|
+
};
|
|
3815
|
+
const expected = [];
|
|
3816
|
+
let endSigned = null;
|
|
3817
|
+
for (let after = from.entry ?? -1n, last = passed.at(-1)?.seq; last !== void 0 && after < last;) {
|
|
3818
|
+
const batch = await talliedWithin(tx, after, last, walks.verify + 1);
|
|
3819
|
+
if (batch.length === 0) break;
|
|
3820
|
+
for (const entry of batch) {
|
|
3821
|
+
const read = this.#tallied(entry);
|
|
3822
|
+
const point = {
|
|
3823
|
+
entry: entry.seq,
|
|
3824
|
+
through: entry.relatedSeq
|
|
3825
|
+
};
|
|
3826
|
+
if (typeof read === "string") {
|
|
3827
|
+
expected.push({
|
|
3828
|
+
...point,
|
|
3829
|
+
unreadable: read
|
|
3830
|
+
});
|
|
3831
|
+
continue;
|
|
3832
|
+
}
|
|
3833
|
+
if (entry.seq === to) endSigned = read.hash;
|
|
3834
|
+
const { access } = read.tally;
|
|
3835
|
+
if (!("at" in access)) holding.set(entry.seq, keptDigest(access));
|
|
3836
|
+
const kept = "at" in access ? BigInt(access.at) < entry.seq ? await carried(BigInt(access.at)) : null : holding.get(entry.seq);
|
|
3837
|
+
expected.push(kept === access.digest ? {
|
|
3838
|
+
...point,
|
|
3839
|
+
key: expectedKey(read.tally)
|
|
3840
|
+
} : {
|
|
3841
|
+
...point,
|
|
3842
|
+
unreadable: kept === null ? `its tally keeps its members and grants in entry ${"at" in access ? access.at : entry.seq}, which holds none` : "its tally's members and grants do not match their digest"
|
|
3843
|
+
});
|
|
3844
|
+
}
|
|
3845
|
+
after = batch.at(-1).seq;
|
|
3846
|
+
}
|
|
3847
|
+
walk.expect(expected);
|
|
3848
|
+
let through = from.at.seq;
|
|
3849
|
+
let reached = headOf(from.at).hash;
|
|
3850
|
+
let present = false;
|
|
3851
|
+
if (upTo === null) {
|
|
3852
|
+
const chain = await rehash(tx, logKeys, from.at, walk, ends === null ? { limit: walks.verify } : { to: ends });
|
|
3853
|
+
if (!chain.ok) return chain;
|
|
3854
|
+
[through, reached, present] = [
|
|
3855
|
+
chain.at.seq,
|
|
3856
|
+
headOf(chain.at).hash,
|
|
3857
|
+
chain.done && ends === null
|
|
3858
|
+
];
|
|
3859
|
+
this.#prepared.verified = further(this.#prepared.verified, chain.at);
|
|
3860
|
+
} else {
|
|
3861
|
+
const own = await ownEntries(tx, logKeys, from.at.seq, walk, ends === null ? { limit: walks.verify } : { to: ends });
|
|
3862
|
+
if (!own.ok) return own;
|
|
3863
|
+
if (!own.done && input.from === void 0) return {
|
|
3864
|
+
ok: false,
|
|
3865
|
+
failedAtSeq: null,
|
|
3866
|
+
reason: `the vault verifies no more than ${walks.verify} of its entries a call, and its checkpoints are catching up: update the app`
|
|
3867
|
+
};
|
|
3868
|
+
const short = own.at < BigInt(upTo.seq) ? own.at : BigInt(upTo.seq) - 1n;
|
|
3869
|
+
[through, reached, present] = [
|
|
3870
|
+
own.done ? BigInt(upTo.seq) : short,
|
|
3871
|
+
upTo.hash,
|
|
3872
|
+
own.done && ends === null
|
|
3873
|
+
];
|
|
3874
|
+
}
|
|
3875
|
+
walk.finish(ends ?? through);
|
|
3876
|
+
const rememberedHead = remembered.seq >= 0n ? headOf(remembered) : null;
|
|
3877
|
+
const heads = [...upTo === null ? [] : [upTo], ...rememberedHead === null ? [] : [rememberedHead]];
|
|
3878
|
+
const holds = await this.#holding(tx, [...heads, ...walk.checkpoints.map(({ checkpoint }) => checkpoint)]);
|
|
3237
3879
|
if (upTo !== null && !holds(upTo)) return {
|
|
3238
3880
|
ok: false,
|
|
3239
3881
|
failedAtSeq: upTo.seq,
|
|
3240
3882
|
reason: "not the entry the app verified up to: the log changed between the two checks"
|
|
3241
3883
|
};
|
|
3242
|
-
const unsigned = await this.#checkpointFault(
|
|
3884
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
|
|
3243
3885
|
if (unsigned !== null) return unsigned;
|
|
3244
3886
|
if (rememberedHead !== null && !holds(rememberedHead)) return {
|
|
3245
3887
|
ok: false,
|
|
3246
3888
|
failedAtSeq: rememberedHead.seq,
|
|
3247
3889
|
reason: "changed since the vault last verified it"
|
|
3248
3890
|
};
|
|
3891
|
+
if (walk.mismatch !== null) return {
|
|
3892
|
+
ok: false,
|
|
3893
|
+
failedAtSeq: Number(walk.mismatch.entry),
|
|
3894
|
+
reason: walk.mismatch.reason
|
|
3895
|
+
};
|
|
3896
|
+
if (ends !== null) {
|
|
3897
|
+
if (upTo === null && through !== ends || endSigned !== reached) return {
|
|
3898
|
+
ok: false,
|
|
3899
|
+
failedAtSeq: Number(to),
|
|
3900
|
+
reason: `the log up to entry ${ends} is not the prefix this checkpoint signed: it was rewritten`
|
|
3901
|
+
};
|
|
3902
|
+
return {
|
|
3903
|
+
ok: true,
|
|
3904
|
+
entries: walk.entries,
|
|
3905
|
+
through: Number(ends)
|
|
3906
|
+
};
|
|
3907
|
+
}
|
|
3908
|
+
const standing = await this.#standingRefusal(tx);
|
|
3909
|
+
if (standing !== null) return {
|
|
3910
|
+
ok: false,
|
|
3911
|
+
failedAtSeq: standing.failedAtSeq ?? null,
|
|
3912
|
+
reason: `the last checkpoint was refused: ${standing.reason ?? "the log is broken"}`
|
|
3913
|
+
};
|
|
3914
|
+
const verified = {
|
|
3915
|
+
ok: true,
|
|
3916
|
+
entries: walk.entries,
|
|
3917
|
+
through: Number(through)
|
|
3918
|
+
};
|
|
3919
|
+
if (!present) return verified;
|
|
3249
3920
|
const at = await this.#now(tx);
|
|
3250
|
-
const accounting =
|
|
3921
|
+
const accounting = walk.accounting.result(at);
|
|
3251
3922
|
if (!accounting.ok) return accounting;
|
|
3252
3923
|
const [rows, held] = await Promise.all([allMembers(tx), grants(tx)]);
|
|
3253
|
-
const fault = this.#unsealed(rows, held,
|
|
3924
|
+
const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
|
|
3254
3925
|
if (fault !== null) return {
|
|
3255
3926
|
ok: false,
|
|
3256
3927
|
failedAtSeq: null,
|
|
3257
3928
|
reason: describeAccessFault(fault),
|
|
3258
3929
|
fault
|
|
3259
3930
|
};
|
|
3260
|
-
const verified = {
|
|
3261
|
-
ok: true,
|
|
3262
|
-
entries: findings.entries
|
|
3263
|
-
};
|
|
3264
3931
|
return accounting.pending === 0 ? verified : {
|
|
3265
3932
|
...verified,
|
|
3266
3933
|
pending: accounting.pending
|
|
@@ -3268,55 +3935,15 @@ var VaultService = class {
|
|
|
3268
3935
|
}, SNAPSHOT);
|
|
3269
3936
|
}
|
|
3270
3937
|
/**
|
|
3271
|
-
*
|
|
3272
|
-
*
|
|
3273
|
-
*
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
const { logKeys } = this.#prepared;
|
|
3277
|
-
let keys = [];
|
|
3278
|
-
for (let after = -1n;;) {
|
|
3279
|
-
const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
|
|
3280
|
-
if (batch.length === 0) return { ok: true };
|
|
3281
|
-
for (const entry of batch) {
|
|
3282
|
-
const result = verifyEntries([entry], {
|
|
3283
|
-
startSeq: entry.seq,
|
|
3284
|
-
startPrevHash: entry.prevHash,
|
|
3285
|
-
keys: logKeys
|
|
3286
|
-
});
|
|
3287
|
-
if (!result.ok) return {
|
|
3288
|
-
ok: false,
|
|
3289
|
-
failedAtSeq: Number(result.failedAtSeq),
|
|
3290
|
-
reason: withCause(result.reason)
|
|
3291
|
-
};
|
|
3292
|
-
}
|
|
3293
|
-
const moved = forward(batch, keys, logKeys[0].keyId);
|
|
3294
|
-
if ("failedAtSeq" in moved) return {
|
|
3295
|
-
ok: false,
|
|
3296
|
-
failedAtSeq: Number(moved.failedAtSeq),
|
|
3297
|
-
reason: moved.reason
|
|
3298
|
-
};
|
|
3299
|
-
keys = moved.keys;
|
|
3300
|
-
for (const entry of batch) findings.add(entry);
|
|
3301
|
-
after = batch[batch.length - 1].seq;
|
|
3302
|
-
}
|
|
3303
|
-
}
|
|
3304
|
-
/** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
|
|
3305
|
-
async #rehashed(tx, findings) {
|
|
3306
|
-
const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
|
|
3307
|
-
for (const entry of batch) if (entry.author === "vault") findings.add(entry);
|
|
3308
|
-
});
|
|
3309
|
-
if (!verification.ok) return verification;
|
|
3310
|
-
this.#prepared.verified = further(this.#prepared.verified, anchor);
|
|
3311
|
-
return { ok: true };
|
|
3312
|
-
}
|
|
3313
|
-
/**
|
|
3314
|
-
* Every checkpoint, not only the newest: each signed by the key whose log
|
|
3315
|
-
* key wrote its entry, over a prefix the log still holds (`holds`), entry
|
|
3316
|
-
* for entry. Its entry's MAC is checked by now, and one under a key the
|
|
3317
|
-
* vault replaced comes before the rotation.
|
|
3938
|
+
* Every checkpoint walked, not only the newest: each signed by the key
|
|
3939
|
+
* whose log key wrote its entry, over a prefix the log still holds,
|
|
3940
|
+
* entry for entry, which one that keeps a tally names as related. Its
|
|
3941
|
+
* entry's MAC is checked by now, and one under a key the vault replaced
|
|
3942
|
+
* comes before the rotation.
|
|
3318
3943
|
*/
|
|
3319
|
-
async #checkpointFault(
|
|
3944
|
+
async #checkpointFault(db, checkpoints, known) {
|
|
3945
|
+
if (checkpoints.length === 0) return null;
|
|
3946
|
+
const holds = known ?? await this.#holding(db, checkpoints.map(({ checkpoint }) => checkpoint));
|
|
3320
3947
|
const { logKeys, signers } = this.#prepared;
|
|
3321
3948
|
const verifiers = /* @__PURE__ */ new Map();
|
|
3322
3949
|
const signed = (checkpoint, by) => {
|
|
@@ -3324,14 +3951,13 @@ var VaultService = class {
|
|
|
3324
3951
|
if (verifier === void 0) verifiers.set(by.keyId, verifier = checkpointVerifier(by.publicKey));
|
|
3325
3952
|
return verifier.then((verify) => verify(checkpoint));
|
|
3326
3953
|
};
|
|
3327
|
-
const { checkpoints } = findings;
|
|
3328
3954
|
for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
|
|
3329
3955
|
const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
|
|
3330
3956
|
const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
|
|
3331
3957
|
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
3332
3958
|
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`;
|
|
3333
3959
|
if (!await signed(checkpoint, by)) return "a checkpoint the vault did not sign";
|
|
3334
|
-
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`;
|
|
3960
|
+
if (BigInt(checkpoint.seq) >= entry.seq || !holds(checkpoint) || entry.relatedSeq !== null && entry.relatedSeq !== BigInt(checkpoint.seq)) return `the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`;
|
|
3335
3961
|
return null;
|
|
3336
3962
|
}));
|
|
3337
3963
|
const at = faults.findIndex((fault) => fault !== null);
|
|
@@ -3345,8 +3971,8 @@ var VaultService = class {
|
|
|
3345
3971
|
}
|
|
3346
3972
|
/**
|
|
3347
3973
|
* The first member whose row fails its MAC, or names an older access
|
|
3348
|
-
* entry than the log's newest about them (`newest`, from the
|
|
3349
|
-
*
|
|
3974
|
+
* entry than the log's newest about them (`newest`, from the walk, where
|
|
3975
|
+
* each entry's MAC was checked, on from the tally).
|
|
3350
3976
|
*/
|
|
3351
3977
|
#unsealed(rows, held, newest) {
|
|
3352
3978
|
for (const row of rows) {
|
|
@@ -3365,44 +3991,71 @@ var VaultService = class {
|
|
|
3365
3991
|
return null;
|
|
3366
3992
|
}
|
|
3367
3993
|
};
|
|
3994
|
+
/** Whether a statement was cancelled for taking too long: Postgres's `query_canceled`, a statement timeout among them. */
|
|
3995
|
+
function timedOut(error) {
|
|
3996
|
+
for (let cause = error; typeof cause === "object" && cause !== null; cause = cause.cause) if (cause.code === "57014") return true;
|
|
3997
|
+
return false;
|
|
3998
|
+
}
|
|
3999
|
+
const FIRST_ENTRY = {
|
|
4000
|
+
ok: true,
|
|
4001
|
+
entry: null,
|
|
4002
|
+
at: START,
|
|
4003
|
+
tally: EMPTY_TALLY,
|
|
4004
|
+
digest: accessDigest(EMPTY_TALLY.access),
|
|
4005
|
+
carrier: null,
|
|
4006
|
+
scrub: null
|
|
4007
|
+
};
|
|
3368
4008
|
/**
|
|
3369
|
-
*
|
|
3370
|
-
*
|
|
3371
|
-
*
|
|
3372
|
-
*
|
|
4009
|
+
* A walk rehashes, MACs and replays some 40 µs of CPU an entry, and 95
|
|
4010
|
+
* with V8's optimizer off, as on a cold isolate: 10,000 entries is a beat
|
|
4011
|
+
* of catching up, about a second, and a team's instance writes fewer than
|
|
4012
|
+
* a thousand a day. The rehash of the past is a hash an entry, 14 µs warm
|
|
4013
|
+
* and 55 cold, beside two reads: 100 entries keep it near 6 ms a beat.
|
|
3373
4014
|
*/
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
held: /* @__PURE__ */ new Map()
|
|
3379
|
-
};
|
|
3380
|
-
/** The first access entry the replay could not apply. */
|
|
3381
|
-
replayFault = null;
|
|
3382
|
-
/** Each member's newest allowed access entry, by seq. */
|
|
3383
|
-
newestAccess = /* @__PURE__ */ new Map();
|
|
3384
|
-
/** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
|
|
3385
|
-
checkpoints = [];
|
|
3386
|
-
/** The vault's entries read. */
|
|
3387
|
-
entries = 0;
|
|
3388
|
-
add(entry) {
|
|
3389
|
-
this.entries += 1;
|
|
3390
|
-
this.accounting.add(entry);
|
|
3391
|
-
if (entry.decision !== "allow") return;
|
|
3392
|
-
if (ACCESS.has(entry.action)) {
|
|
3393
|
-
this.replayFault ??= apply(this.replayed, entry);
|
|
3394
|
-
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
3395
|
-
} else if (entry.action === CHECKPOINT) this.checkpoints.push({
|
|
3396
|
-
entry: {
|
|
3397
|
-
seq: entry.seq,
|
|
3398
|
-
keyId: entry.keyId
|
|
3399
|
-
},
|
|
3400
|
-
checkpoint: JSON.parse(entry.metadata)
|
|
3401
|
-
});
|
|
3402
|
-
}
|
|
4015
|
+
const WALKS = {
|
|
4016
|
+
checkpoint: 1e4,
|
|
4017
|
+
scrub: 100,
|
|
4018
|
+
verify: 1e4
|
|
3403
4019
|
};
|
|
3404
|
-
|
|
3405
|
-
|
|
4020
|
+
/** The digest of the members and grants a checkpoint keeps whole, beside the digest it says they have. */
|
|
4021
|
+
function keptDigest({ members, grants, newest }) {
|
|
4022
|
+
return accessDigest({
|
|
4023
|
+
members,
|
|
4024
|
+
grants,
|
|
4025
|
+
newest
|
|
4026
|
+
});
|
|
4027
|
+
}
|
|
4028
|
+
/** Each checkpoint entry's metadata, parsed once however many times it is asked about: one may hold every member and grant. */
|
|
4029
|
+
const parsed = /* @__PURE__ */ new WeakMap();
|
|
4030
|
+
function metadataOf(entry) {
|
|
4031
|
+
if (!parsed.has(entry)) parsed.set(entry, JSON.parse(entry.metadata));
|
|
4032
|
+
return parsed.get(entry);
|
|
4033
|
+
}
|
|
4034
|
+
/** A head, as an entry keeps it, as a walk goes on from it. */
|
|
4035
|
+
function positionOf(head) {
|
|
4036
|
+
return {
|
|
4037
|
+
seq: BigInt(head.seq),
|
|
4038
|
+
hash: Buffer.from(head.hash, "hex")
|
|
4039
|
+
};
|
|
4040
|
+
}
|
|
4041
|
+
/** A position as the log's heads are named, in hex. */
|
|
4042
|
+
function headOf(position) {
|
|
4043
|
+
return {
|
|
4044
|
+
seq: Number(position.seq),
|
|
4045
|
+
hash: position.hash.toString("hex")
|
|
4046
|
+
};
|
|
4047
|
+
}
|
|
4048
|
+
/** What a checkpoint's signature covers, and nothing it keeps beside it. */
|
|
4049
|
+
function signedPart({ seq, hash, signedAt, keyId, signature }) {
|
|
4050
|
+
return {
|
|
4051
|
+
seq,
|
|
4052
|
+
hash,
|
|
4053
|
+
signedAt,
|
|
4054
|
+
keyId,
|
|
4055
|
+
signature
|
|
4056
|
+
};
|
|
4057
|
+
}
|
|
4058
|
+
/** Checkpoint signatures checked at once. */
|
|
3406
4059
|
const SIGNATURES_AT_ONCE = 256;
|
|
3407
4060
|
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
3408
4061
|
async function settle(operations, budgetMs) {
|