@coffre/vault 0.9.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 +11 -11
- package/dist/cloudflare.js +8 -2
- package/dist/{index-BkHJR9Gt.d.ts → index-CByXzyi-.d.ts} +7 -12
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/node.d.ts +13 -2
- package/dist/node.js +4 -2
- package/dist/{src-BcttALFQ.js → src-D82jyprb.js} +2 -6
- package/dist/{vault-lBbyZ3kQ.js → vault-B0YiPBgi.js} +1460 -471
- package/package.json +3 -3
- package/src/accounting.ts +37 -8
- package/src/cloudflare.ts +3 -0
- package/src/config.ts +12 -19
- package/src/index.ts +1 -1
- package/src/local.ts +2 -0
- package/src/log.ts +91 -60
- package/src/replay.ts +1 -1
- package/src/store.ts +179 -22
- package/src/tally.ts +241 -0
- package/src/vault.ts +831 -254
|
@@ -1,155 +1,15 @@
|
|
|
1
|
-
import { ACCESS_ACTIONS,
|
|
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";
|
|
5
|
-
import { EVERYWHERE, NOTHING, allows, assignableToEnvironment, givesService, grantKind, isCredentialScope, isInstanceRole, isRole, isScope, managesService, mayManageAccess, normalScope, ownsInstance, runsInstance, setsUpServices, unscoped, withinCredential } from "@coffre/core/access";
|
|
3
|
+
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { prepared, tablesOf } from "@coffre/db";
|
|
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);
|
|
@@ -456,6 +315,12 @@ async function endedReferences(db, seqs) {
|
|
|
456
315
|
return new Set(rows.map((row) => row.relatedSeq));
|
|
457
316
|
}
|
|
458
317
|
/** The project each environment is in: a holder's ids, checked against each other. */
|
|
318
|
+
/** Whether a project of this id is there, and not deleted. */
|
|
319
|
+
async function projectLive(db, id) {
|
|
320
|
+
const { projects } = tablesOf(db);
|
|
321
|
+
const rows = await db.select({ deleted: truth(tombstone(projects.slug)) }).from(projects).where(eq(projects.id, id));
|
|
322
|
+
return rows.length > 0 && !rows[0].deleted;
|
|
323
|
+
}
|
|
459
324
|
async function projectsOfEnvironments(db, ids) {
|
|
460
325
|
if (ids.length === 0) return /* @__PURE__ */ new Map();
|
|
461
326
|
const { environments } = tablesOf(db);
|
|
@@ -482,12 +347,92 @@ async function secretsByIds(db, ids) {
|
|
|
482
347
|
path: `${project}/${environment}/${key}`
|
|
483
348
|
}]));
|
|
484
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Each secret's nature, as its current version has it: plain where its
|
|
352
|
+
* value is stored as it is, sensitive where it is bound so, and otherwise
|
|
353
|
+
* secret, as is one with no value of its own.
|
|
354
|
+
*/
|
|
355
|
+
async function natures(db, secretIds) {
|
|
356
|
+
if (secretIds.length === 0) return /* @__PURE__ */ new Map();
|
|
357
|
+
const { secrets, secretVersions } = tablesOf(db);
|
|
358
|
+
const rows = await db.select({
|
|
359
|
+
id: secrets.id,
|
|
360
|
+
sensitive: secretVersions.sensitive,
|
|
361
|
+
plain: truth(isNotNull(secretVersions.plaintext))
|
|
362
|
+
}).from(secrets).innerJoin(secretVersions, eq(secretVersions.id, secrets.currentVersionId)).where(inArray(secrets.id, [...new Set(secretIds)]));
|
|
363
|
+
return new Map(rows.map((row) => [row.id, Boolean(row.sensitive) ? "sensitive" : Boolean(row.plain) ? "plain" : "secret"]));
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* For each version a rewrap would bring back into its secret, its nature
|
|
367
|
+
* as stored when it is an old value, not the secret's current one; null
|
|
368
|
+
* for the current one, which a mark wraps again with no value changing.
|
|
369
|
+
*/
|
|
370
|
+
async function restoredNatures(db, items) {
|
|
371
|
+
if (items.length === 0) return [];
|
|
372
|
+
const { secrets, secretVersions } = tablesOf(db);
|
|
373
|
+
const [current, versions] = await Promise.all([db.select({
|
|
374
|
+
id: secrets.id,
|
|
375
|
+
version: secrets.currentVersionId
|
|
376
|
+
}).from(secrets).where(inArray(secrets.id, [...new Set(items.map((item) => item.secretId))])), db.select({
|
|
377
|
+
id: secretVersions.id,
|
|
378
|
+
sensitive: secretVersions.sensitive,
|
|
379
|
+
plain: truth(isNotNull(secretVersions.plaintext))
|
|
380
|
+
}).from(secretVersions).where(inArray(secretVersions.id, [...new Set(items.map((item) => item.from))]))]);
|
|
381
|
+
const currentOf = new Map(current.map((row) => [row.id, row.version]));
|
|
382
|
+
const natureOf = new Map(versions.map((row) => [row.id, Boolean(row.sensitive) ? "sensitive" : Boolean(row.plain) ? "plain" : "secret"]));
|
|
383
|
+
return items.map(({ secretId, from }) => currentOf.get(secretId) === from ? null : natureOf.get(from) ?? "secret");
|
|
384
|
+
}
|
|
485
385
|
/** The vault's newest entry of any of these actions, allowed, or undefined. */
|
|
486
386
|
async function latestVaultEntry(db, actions) {
|
|
487
387
|
const { auditLog } = tablesOf(db);
|
|
488
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);
|
|
489
389
|
return row === void 0 ? void 0 : stored([row])[0];
|
|
490
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
|
+
}
|
|
491
436
|
/** The vault's newest allowed access entry about each member who has one. */
|
|
492
437
|
async function newestAccessEntries(db) {
|
|
493
438
|
const { auditLog } = tablesOf(db);
|
|
@@ -500,6 +445,19 @@ async function vaultEntriesOf(db, actions, afterSeq, limit) {
|
|
|
500
445
|
const { auditLog } = tablesOf(db);
|
|
501
446
|
return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...actions]), eq(auditLog.decision, "allow"), gt(auditLog.seq, afterSeq))).orderBy(asc(auditLog.seq)).limit(limit));
|
|
502
447
|
}
|
|
448
|
+
/** Which of these secrets' newest versions are sensitive, by the versions' own order. */
|
|
449
|
+
async function newestSensitive(db, secretIds) {
|
|
450
|
+
if (secretIds.length === 0) return /* @__PURE__ */ new Set();
|
|
451
|
+
const { secretVersions } = tablesOf(db);
|
|
452
|
+
const rows = await db.select({ secretId: secretVersions.secretId }).from(secretVersions).where(and(inArray(secretVersions.secretId, [...new Set(secretIds)]), eq(secretVersions.sensitive, true), sql`${secretVersions.version} = (SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`));
|
|
453
|
+
return new Set(rows.map((row) => row.secretId));
|
|
454
|
+
}
|
|
455
|
+
/** How many grants name `role`: all of them, or those on environments. */
|
|
456
|
+
async function grantsNaming(db, role, filter = {}) {
|
|
457
|
+
const { vaultGrants } = tablesOf(db);
|
|
458
|
+
const rows = await db.select({ count: sql`count(*)` }).from(vaultGrants).where(and(eq(vaultGrants.role, role), filter.onEnvironments === true ? or(isNotNull(vaultGrants.environmentId), isNotNull(vaultGrants.environmentSlug)) : void 0));
|
|
459
|
+
return Number(rows[0]?.count ?? 0);
|
|
460
|
+
}
|
|
503
461
|
/**
|
|
504
462
|
* A version that still holds its wrapped key. A deleted place's versions are
|
|
505
463
|
* erased, their keys emptied: there is nothing left to open, or to try a
|
|
@@ -526,7 +484,9 @@ async function versions(db, ids) {
|
|
|
526
484
|
wrappedDek: secretVersions.wrappedDek,
|
|
527
485
|
deleted: truth(or(tombstone(environments.slug), tombstone(projects.slug))),
|
|
528
486
|
currentVersionId: secrets.currentVersionId,
|
|
529
|
-
|
|
487
|
+
sensitive: secretVersions.sensitive,
|
|
488
|
+
newest: sql`(SELECT max(newest.version) FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId})`,
|
|
489
|
+
newestSensitive: sql`(SELECT newest.sensitive FROM secret_versions newest WHERE newest.secret_id = ${secretVersions.secretId} ORDER BY newest.version DESC LIMIT 1)`
|
|
530
490
|
}).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).innerJoin(environments, eq(environments.id, secrets.environmentId)).innerJoin(projects, eq(projects.id, secrets.projectId)).where(and(inArray(secretVersions.id, [...new Set(ids)]), or(sealed$1(secretVersions.wrappedDek), tombstone(environments.slug), tombstone(projects.slug))))).map((row) => ({
|
|
531
491
|
id: row.id,
|
|
532
492
|
deleted: row.deleted,
|
|
@@ -535,7 +495,8 @@ async function versions(db, ids) {
|
|
|
535
495
|
environmentId: row.environmentId,
|
|
536
496
|
secretId: row.secretId,
|
|
537
497
|
version: row.version,
|
|
538
|
-
path: `${row.project}/${row.environment}/${row.key}
|
|
498
|
+
path: `${row.project}/${row.environment}/${row.key}`,
|
|
499
|
+
sensitive: row.sensitive
|
|
539
500
|
},
|
|
540
501
|
wrapped: {
|
|
541
502
|
kekProvider: row.kekProvider,
|
|
@@ -543,7 +504,8 @@ async function versions(db, ids) {
|
|
|
543
504
|
kekVersion: row.kekVersion,
|
|
544
505
|
bytes: row.wrappedDek.toString("base64")
|
|
545
506
|
},
|
|
546
|
-
current: row.currentVersionId === row.id && Number(row.newest) === row.version
|
|
507
|
+
current: row.currentVersionId === row.id && Number(row.newest) === row.version,
|
|
508
|
+
newestSensitive: Boolean(row.newestSensitive)
|
|
547
509
|
}));
|
|
548
510
|
}
|
|
549
511
|
//#endregion
|
|
@@ -563,86 +525,158 @@ async function versions(db, ids) {
|
|
|
563
525
|
function vaultLogKey(signingKey) {
|
|
564
526
|
return deriveLogKey("vault", signingKey);
|
|
565
527
|
}
|
|
566
|
-
/** Before the first entry
|
|
567
|
-
const
|
|
568
|
-
|
|
569
|
-
hash: GENESIS_HASH
|
|
570
|
-
vaultEntries: 0,
|
|
571
|
-
vaultKeys: []
|
|
528
|
+
/** Before the first entry. */
|
|
529
|
+
const START = {
|
|
530
|
+
seq: -1n,
|
|
531
|
+
hash: GENESIS_HASH
|
|
572
532
|
};
|
|
573
|
-
/** The further of two
|
|
533
|
+
/** The further of two positions, when two calls walked at once. */
|
|
574
534
|
function further(a, b) {
|
|
575
|
-
return b.
|
|
535
|
+
return b.seq > a.seq ? b : a;
|
|
576
536
|
}
|
|
577
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. */
|
|
578
538
|
const VERIFY_BATCH = 5e3;
|
|
579
539
|
/**
|
|
580
|
-
*
|
|
581
|
-
*
|
|
582
|
-
*
|
|
583
|
-
*
|
|
584
|
-
*
|
|
585
|
-
*
|
|
586
|
-
* of anything before it, chained again to hide, changes its hash;
|
|
587
|
-
* - 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.
|
|
588
546
|
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
* not on the page: a full check, which starts from `UNVERIFIED`, finds that,
|
|
592
|
-
* as does the first view after a start, which has no anchor. `onBatch` sees
|
|
593
|
-
* 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.
|
|
594
549
|
*/
|
|
595
|
-
async function
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
|
613
575
|
});
|
|
614
|
-
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;
|
|
615
594
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
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);
|
|
620
610
|
if (batch.length === 0) break;
|
|
621
611
|
const result = verifyEntries(batch, {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
612
|
+
keys: [],
|
|
613
|
+
chainOnly: ["app", "vault"],
|
|
614
|
+
startSeq: at.seq + 1n,
|
|
615
|
+
startPrevHash: at.hash
|
|
625
616
|
});
|
|
626
|
-
if (!result.ok) return
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
verified = {
|
|
631
|
-
nextSeq: result.nextSeq,
|
|
632
|
-
hash: result.head,
|
|
633
|
-
vaultEntries: verified.vaultEntries + result.authenticated,
|
|
634
|
-
vaultKeys: moved.keys
|
|
617
|
+
if (!result.ok) return {
|
|
618
|
+
ok: false,
|
|
619
|
+
failedAtSeq: Number(result.failedAtSeq),
|
|
620
|
+
reason: result.reason
|
|
635
621
|
};
|
|
622
|
+
at = {
|
|
623
|
+
seq: result.nextSeq - 1n,
|
|
624
|
+
hash: result.head
|
|
625
|
+
};
|
|
626
|
+
limit -= batch.length;
|
|
636
627
|
}
|
|
637
628
|
return {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
entries: verified.vaultEntries
|
|
641
|
-
},
|
|
642
|
-
anchor: verified
|
|
629
|
+
ok: true,
|
|
630
|
+
at
|
|
643
631
|
};
|
|
644
632
|
}
|
|
645
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
|
+
/**
|
|
646
680
|
* The vault's keys only move forward. Once its entries move from one key to
|
|
647
681
|
* the next, the one before writes no more; once they reach `current`, the
|
|
648
682
|
* key it writes with now, no other key writes again. So a key it replaced,
|
|
@@ -960,6 +994,376 @@ function sealed(key, member, grants) {
|
|
|
960
994
|
return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
|
|
961
995
|
}
|
|
962
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.
|
|
1201
|
+
*/
|
|
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
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
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.
|
|
1217
|
+
*/
|
|
1218
|
+
function expectedKey(stored) {
|
|
1219
|
+
return tallyKey(stored, stored.access.digest);
|
|
1220
|
+
}
|
|
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);
|
|
1365
|
+
}
|
|
1366
|
+
//#endregion
|
|
963
1367
|
//#region src/vault.ts
|
|
964
1368
|
async function prepareVault(config, options = {}) {
|
|
965
1369
|
return {
|
|
@@ -967,9 +1371,13 @@ async function prepareVault(config, options = {}) {
|
|
|
967
1371
|
...await keysOf(config.signingKeys),
|
|
968
1372
|
options: {
|
|
969
1373
|
keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS,
|
|
970
|
-
clockOffset: options.clockOffset ?? (() => 0)
|
|
1374
|
+
clockOffset: options.clockOffset ?? (() => 0),
|
|
1375
|
+
walks: {
|
|
1376
|
+
...WALKS,
|
|
1377
|
+
...options.walks
|
|
1378
|
+
}
|
|
971
1379
|
},
|
|
972
|
-
verified:
|
|
1380
|
+
verified: START,
|
|
973
1381
|
reported: /* @__PURE__ */ new Set(),
|
|
974
1382
|
since: null,
|
|
975
1383
|
superseded: null,
|
|
@@ -1018,12 +1426,35 @@ const UNSEALED = {
|
|
|
1018
1426
|
};
|
|
1019
1427
|
/** Why a change to a tampered member is refused. */
|
|
1020
1428
|
const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
|
|
1021
|
-
/** The action of the vault's entry that signs a prefix of the log. */
|
|
1022
|
-
const CHECKPOINT = "audit.checkpoint";
|
|
1023
1429
|
/** The action of the vault's entry that holds the instance's settings, and changes them (`settings`). */
|
|
1024
1430
|
const SETTINGS = "settings.change";
|
|
1025
1431
|
/** The settings before anyone changed them: people set up service accounts anywhere they hold access. */
|
|
1026
1432
|
const DEFAULT_SETTINGS = { serviceAccounts: EVERYWHERE };
|
|
1433
|
+
/** The action of the vault's entry that holds the instance's access policy, and changes it (`policy`). */
|
|
1434
|
+
const POLICY = "policy.change";
|
|
1435
|
+
const NO_EDITS = {
|
|
1436
|
+
roles: {},
|
|
1437
|
+
instanceRoles: {},
|
|
1438
|
+
protected: []
|
|
1439
|
+
};
|
|
1440
|
+
/** A role holding nothing, which every role but Owner is under the closed policy. */
|
|
1441
|
+
const NOTHING_HELD = (definition) => ({
|
|
1442
|
+
...definition,
|
|
1443
|
+
permissions: [],
|
|
1444
|
+
protected: []
|
|
1445
|
+
});
|
|
1446
|
+
/**
|
|
1447
|
+
* The policy when the log has some, but none the vault wrote within reach:
|
|
1448
|
+
* whoever inserted rows may have hidden an Owner's narrowing, so no role
|
|
1449
|
+
* holds anything, a service account's neither, but Owner, which holds
|
|
1450
|
+
* everything always and so may write the policy again.
|
|
1451
|
+
*/
|
|
1452
|
+
const CLOSED_POLICY = {
|
|
1453
|
+
roles: Object.fromEntries(Object.entries(ROLES).map(([id, definition]) => [id, id === "owner" ? definition : NOTHING_HELD(definition)])),
|
|
1454
|
+
instanceRoles: Object.fromEntries(Object.entries(INSTANCE_ROLES).map(([id, definition]) => [id, id === "owner" ? definition : NOTHING_HELD(definition)])),
|
|
1455
|
+
protected: [EVERY_ENVIRONMENT],
|
|
1456
|
+
protectedIn: {}
|
|
1457
|
+
};
|
|
1027
1458
|
/** The settings when the log has some, but none the vault wrote within reach: people set up no service accounts. */
|
|
1028
1459
|
const CLOSED_SETTINGS = { serviceAccounts: {
|
|
1029
1460
|
projects: { only: [] },
|
|
@@ -1039,12 +1470,6 @@ function declaredOwner(principal) {
|
|
|
1039
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";
|
|
1040
1471
|
/** Who asks for checkpoints: the app's scheduled job. */
|
|
1041
1472
|
const SCHEDULER = "system:coffre-scheduler";
|
|
1042
|
-
/**
|
|
1043
|
-
* How often a checkpoint recomputes the chain from its first entry: the
|
|
1044
|
-
* first of each hour, by the vault's clock. The others resume from the
|
|
1045
|
-
* prefix the last one signed (`#recompute`).
|
|
1046
|
-
*/
|
|
1047
|
-
const FULL_RECOMPUTE_MS = 36e5;
|
|
1048
1473
|
/** A refusal and the entries that record it. */
|
|
1049
1474
|
var Refused = class {
|
|
1050
1475
|
refusal;
|
|
@@ -1067,6 +1492,8 @@ var Outage = class {
|
|
|
1067
1492
|
this.entries = entries;
|
|
1068
1493
|
}
|
|
1069
1494
|
};
|
|
1495
|
+
/** A rewrap that would make a value less sensitive than it is. */
|
|
1496
|
+
const LOOSER = "a value is made less sensitive only by writing it again: an old version comes back, and a key is made stricter, as sensitive as its newest version or more";
|
|
1070
1497
|
function refusal(code, message) {
|
|
1071
1498
|
return {
|
|
1072
1499
|
code,
|
|
@@ -1083,10 +1510,11 @@ const MESSAGES = {
|
|
|
1083
1510
|
deleted: "this project or environment was deleted",
|
|
1084
1511
|
ended: "the reference read through was ended",
|
|
1085
1512
|
personal: "a personal config's values are its owner's alone",
|
|
1086
|
-
|
|
1513
|
+
sensitive: "a sensitive value is never shown to anyone, Owners included: `coffre run` gives your app a placeholder for it through the credential proxy, and a service account reads it",
|
|
1087
1514
|
not_allowed: "not allowed to change this",
|
|
1088
1515
|
last_owner: "the last Owner of the whole instance stays one: make someone else an Owner first",
|
|
1089
1516
|
invalid: "not something the rules allow",
|
|
1517
|
+
policy_changed: "the instance's roles or protected environments changed while this was asked: try again",
|
|
1090
1518
|
log_broken: "the vault log does not hold from the last checkpoint",
|
|
1091
1519
|
wrong_kek: "this vault's key does not open the data it holds",
|
|
1092
1520
|
tampered: "this member's record failed the vault's integrity check"
|
|
@@ -1144,36 +1572,44 @@ var VaultService = class {
|
|
|
1144
1572
|
return await now(db) + this.#prepared.options.clockOffset();
|
|
1145
1573
|
}
|
|
1146
1574
|
/**
|
|
1147
|
-
* Decide in one transaction, with `principals`' rows locked first
|
|
1148
|
-
* `Refused` or an `Outage` rolls back all but its own
|
|
1149
|
-
* commit on their own: then the refusal is the answer, and
|
|
1150
|
-
* fails the call.
|
|
1575
|
+
* Decide in one transaction, with `principals`' rows locked first, then
|
|
1576
|
+
* the log's head. A `Refused` or an `Outage` rolls back all but its own
|
|
1577
|
+
* entries, which commit on their own: then the refusal is the answer, and
|
|
1578
|
+
* the outage fails the call.
|
|
1579
|
+
*
|
|
1580
|
+
* Every change to the policy, the settings, a member or a place appends
|
|
1581
|
+
* under the head, so whatever the decision reads of them stands until it
|
|
1582
|
+
* commits: no decision acts on a policy read before it. A key operation
|
|
1583
|
+
* alone takes the head later (`late`), once its key service has answered,
|
|
1584
|
+
* which it must not wait on holding it, and decides again then (`#keys`).
|
|
1151
1585
|
*/
|
|
1152
|
-
async #decide(principals, decide) {
|
|
1586
|
+
async #decide(principals, decide, late = false) {
|
|
1153
1587
|
const superseded = await this.#settled();
|
|
1154
1588
|
if (superseded !== null) return {
|
|
1155
1589
|
ok: false,
|
|
1156
1590
|
refusal: refusal("wrong_kek", superseded)
|
|
1157
1591
|
};
|
|
1158
|
-
return this.#decideNow(principals, decide);
|
|
1592
|
+
return this.#decideNow(principals, decide, late);
|
|
1159
1593
|
}
|
|
1160
1594
|
/** `#decide`, once the vault is settled. */
|
|
1161
|
-
async #decideNow(principals, decide) {
|
|
1595
|
+
async #decideNow(principals, decide, late = false) {
|
|
1162
1596
|
const reports = [];
|
|
1163
1597
|
for (let attempt = 1;; attempt += 1) try {
|
|
1164
|
-
return await this.#decideOnce(principals, decide, reports);
|
|
1598
|
+
return await this.#decideOnce(principals, decide, reports, late);
|
|
1165
1599
|
} catch (error) {
|
|
1166
1600
|
if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
|
|
1167
1601
|
throw error;
|
|
1168
1602
|
}
|
|
1169
1603
|
}
|
|
1170
|
-
async #decideOnce(principals, decide, reports) {
|
|
1604
|
+
async #decideOnce(principals, decide, reports, late) {
|
|
1171
1605
|
try {
|
|
1172
1606
|
const result = await this.#db.transaction(async (tx) => {
|
|
1173
1607
|
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
1608
|
+
const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
|
|
1609
|
+
if (!late) await lockLogHead(tx);
|
|
1174
1610
|
const d = {
|
|
1175
1611
|
tx,
|
|
1176
|
-
members
|
|
1612
|
+
members,
|
|
1177
1613
|
at: await this.#now(tx),
|
|
1178
1614
|
log: [],
|
|
1179
1615
|
writes: [],
|
|
@@ -1278,14 +1714,17 @@ var VaultService = class {
|
|
|
1278
1714
|
}
|
|
1279
1715
|
/** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
|
|
1280
1716
|
#authentic(entry) {
|
|
1281
|
-
const { logKeys, since } = this.#prepared;
|
|
1282
|
-
const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
|
|
1283
1717
|
return verifyEntries([entry], {
|
|
1284
1718
|
startSeq: entry.seq,
|
|
1285
1719
|
startPrevHash: entry.prevHash,
|
|
1286
|
-
keys
|
|
1720
|
+
keys: this.#keysFor(entry)
|
|
1287
1721
|
}).ok;
|
|
1288
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
|
+
}
|
|
1289
1728
|
/**
|
|
1290
1729
|
* Whether `row`, with these grants, carries the MAC of the vault's row
|
|
1291
1730
|
* key, or of one it replaced before the rotation sealed every row again
|
|
@@ -1367,7 +1806,6 @@ var VaultService = class {
|
|
|
1367
1806
|
const everyProject = subject.stored.filter((grant) => grant.projectId === null && grant.environmentId === null);
|
|
1368
1807
|
if (everyProject.length === 0) return;
|
|
1369
1808
|
if (await reportStands(d.tx, principal, this.#check(d.reports, principal))) return;
|
|
1370
|
-
await lockLogHead(d.tx);
|
|
1371
1809
|
const before = roleOf(principal, row);
|
|
1372
1810
|
const conversion = everyProjectConversion(principal, before.role, subject.stored, d.at, await liveProjects(d.tx));
|
|
1373
1811
|
const after = {
|
|
@@ -1529,6 +1967,7 @@ var VaultService = class {
|
|
|
1529
1967
|
async unwrap(input) {
|
|
1530
1968
|
const { principal } = input;
|
|
1531
1969
|
validateText(input.purpose);
|
|
1970
|
+
if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
|
|
1532
1971
|
const vias = input.items.map((item) => validateVia(item.via));
|
|
1533
1972
|
const throughs = input.items.map((item) => validateThrough(item.through));
|
|
1534
1973
|
const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
|
|
@@ -1577,7 +2016,7 @@ var VaultService = class {
|
|
|
1577
2016
|
return this.#keys({
|
|
1578
2017
|
action: "secret.read",
|
|
1579
2018
|
principal,
|
|
1580
|
-
permission: "secret.read",
|
|
2019
|
+
permission: input.purpose === "use" ? "secret.use" : "secret.read",
|
|
1581
2020
|
secrets: items.map((item) => item.secret),
|
|
1582
2021
|
places: sealed.map((seal, i) => seal?.holder ?? items[i].secret),
|
|
1583
2022
|
vet: ends.length === 0 ? void 0 : async (db) => {
|
|
@@ -1627,7 +2066,8 @@ var VaultService = class {
|
|
|
1627
2066
|
}
|
|
1628
2067
|
}), (done) => ({
|
|
1629
2068
|
wrapped: done.map(({ wrapped }) => wrapped),
|
|
1630
|
-
seqs: []
|
|
2069
|
+
seqs: [],
|
|
2070
|
+
sensitive: items.map(({ secret }) => secret.sensitive === true)
|
|
1631
2071
|
}));
|
|
1632
2072
|
}
|
|
1633
2073
|
async rewrap(input) {
|
|
@@ -1644,6 +2084,10 @@ var VaultService = class {
|
|
|
1644
2084
|
...keyEntry("key.rewrap", principal, source.secret, "deny", "bad_claim", input),
|
|
1645
2085
|
secretVersionId: source.id
|
|
1646
2086
|
})));
|
|
2087
|
+
if (items.some((item, i) => item.secret.sensitive !== true && (loaded.versions[i].secret.sensitive || loaded.versions[i].newestSensitive))) return this.#refuseVersions(principal, "invalid", loaded.versions.map((source) => ({
|
|
2088
|
+
...keyEntry("key.rewrap", principal, source.secret, "deny", "invalid", input),
|
|
2089
|
+
secretVersionId: source.id
|
|
2090
|
+
})), LOOSER);
|
|
1647
2091
|
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
1648
2092
|
const entry = (secret, decision, code) => ({
|
|
1649
2093
|
...keyEntry("key.rewrap", principal, secret, decision, code, input, { from: sources.get(secret).secret.version }),
|
|
@@ -1657,9 +2101,19 @@ var VaultService = class {
|
|
|
1657
2101
|
secrets: items.map((item) => item.secret),
|
|
1658
2102
|
remote,
|
|
1659
2103
|
entry,
|
|
1660
|
-
input
|
|
2104
|
+
input,
|
|
2105
|
+
vet: async (db) => {
|
|
2106
|
+
const newest = await newestSensitive(db, items.map(({ secret }) => secret.secretId));
|
|
2107
|
+
return items.map(({ secret }) => secret.sensitive !== true && newest.has(secret.secretId) ? "invalid" : null);
|
|
2108
|
+
},
|
|
2109
|
+
needs: async (db) => {
|
|
2110
|
+
return (await restoredNatures(db, items.map(({ secret }) => ({
|
|
2111
|
+
secretId: secret.secretId,
|
|
2112
|
+
from: sources.get(secret).id
|
|
2113
|
+
})))).map((nature) => nature === "secret" ? ["secret.read"] : nature === "sensitive" ? ["secret.use"] : null);
|
|
2114
|
+
}
|
|
1661
2115
|
}, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
|
|
1662
|
-
const key = await this.#open(wrapped, contextOf(secret), operation);
|
|
2116
|
+
const key = await this.#open(wrapped, contextOf(sources.get(secret).secret), operation);
|
|
1663
2117
|
if (key === null) return null;
|
|
1664
2118
|
try {
|
|
1665
2119
|
return {
|
|
@@ -1671,7 +2125,8 @@ var VaultService = class {
|
|
|
1671
2125
|
}
|
|
1672
2126
|
}), (done) => ({
|
|
1673
2127
|
wrapped: done.map(({ wrapped }) => wrapped),
|
|
1674
|
-
seqs: []
|
|
2128
|
+
seqs: [],
|
|
2129
|
+
sensitive: items.map(({ secret }) => secret.sensitive === true)
|
|
1675
2130
|
}));
|
|
1676
2131
|
}
|
|
1677
2132
|
/** Versions need no lock: they change only by being erased, once their place is archived, and their ids determine the whole batch before any key call. */
|
|
@@ -1710,9 +2165,9 @@ var VaultService = class {
|
|
|
1710
2165
|
versions: ids.map((id) => found.get(id))
|
|
1711
2166
|
};
|
|
1712
2167
|
}
|
|
1713
|
-
async #refuseVersions(principal, code, entries) {
|
|
2168
|
+
async #refuseVersions(principal, code, entries, message = MESSAGES[code]) {
|
|
1714
2169
|
return this.#decide([principal], async () => {
|
|
1715
|
-
throw new Refused(refusal(code,
|
|
2170
|
+
throw new Refused(refusal(code, message), entries);
|
|
1716
2171
|
});
|
|
1717
2172
|
}
|
|
1718
2173
|
/**
|
|
@@ -1756,14 +2211,27 @@ var VaultService = class {
|
|
|
1756
2211
|
const gone = (place) => environments.get(place.environmentId)?.deleted ?? false;
|
|
1757
2212
|
owners.clear();
|
|
1758
2213
|
for (const [id, environment] of environments) if (environment.owner !== null) owners.set(id, ownerKey(environment.owner));
|
|
2214
|
+
const needs = call.needs === void 0 ? [] : await call.needs(d.tx);
|
|
1759
2215
|
const codes = secrets.map((secret, i) => {
|
|
1760
2216
|
const personal = personalRefusal(reader, principal, call.permission, secret, at[i], environments);
|
|
1761
2217
|
if (personal !== void 0) return personal;
|
|
2218
|
+
const also = needs[i];
|
|
2219
|
+
if (also != null) {
|
|
2220
|
+
const place = placeIn(at[i], environments);
|
|
2221
|
+
const refused = also.map((permission) => refuses(reader, permission, place, gone(secret) || gone(at[i])));
|
|
2222
|
+
if (refused.every((code) => code !== null)) return refused[0];
|
|
2223
|
+
}
|
|
1762
2224
|
return refuses(reader, call.permission, placeIn(at[i], environments), gone(secret) || gone(at[i]));
|
|
1763
2225
|
});
|
|
1764
2226
|
if (call.vet !== void 0 && !codes.some((code) => code !== null)) (await call.vet(d.tx)).forEach((code, i) => codes[i] = code);
|
|
2227
|
+
const copying = action === "secret.read" && call.input.purpose === "copy";
|
|
2228
|
+
if (action === "secret.read" && principal.startsWith("user:") && handsBack({
|
|
2229
|
+
purpose: call.input.purpose ?? "",
|
|
2230
|
+
keepsSensitive: call.input.keepsSensitive === true
|
|
2231
|
+
}) || copying && call.input.keepsSensitive !== true) secrets.forEach((secret, i) => {
|
|
2232
|
+
if (codes[i] === null && secret.sensitive === true) codes[i] = "sensitive";
|
|
2233
|
+
});
|
|
1765
2234
|
let first = codes.find((code) => code !== null) ?? null;
|
|
1766
|
-
if (first === null && action === "secret.read" && this.#config.people === "stand-ins" && principal.startsWith("user:") && HANDED_BACK.includes(call.input.purpose)) first = "stand_ins";
|
|
1767
2235
|
if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
|
|
1768
2236
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
|
|
1769
2237
|
};
|
|
@@ -1785,7 +2253,6 @@ var VaultService = class {
|
|
|
1785
2253
|
if (call.remote) {
|
|
1786
2254
|
const intent = await this.#decide([principal], async (d) => {
|
|
1787
2255
|
await check(d);
|
|
1788
|
-
await lockLogHead(d.tx);
|
|
1789
2256
|
const at = await this.#now(d.tx);
|
|
1790
2257
|
return { seq: (await this.#append(d.tx, [{
|
|
1791
2258
|
actor: principal,
|
|
@@ -1811,15 +2278,19 @@ var VaultService = class {
|
|
|
1811
2278
|
if (!intent.ok) return intent;
|
|
1812
2279
|
intentSeq = intent.seq;
|
|
1813
2280
|
}
|
|
1814
|
-
|
|
2281
|
+
const late = call.remote;
|
|
2282
|
+
const decided = async (d) => {
|
|
1815
2283
|
try {
|
|
1816
2284
|
await check(d);
|
|
1817
2285
|
} catch (error) {
|
|
1818
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)));
|
|
1819
2287
|
throw error;
|
|
1820
2288
|
}
|
|
2289
|
+
};
|
|
2290
|
+
return this.#decide([principal], async (d) => {
|
|
2291
|
+
await decided(d);
|
|
1821
2292
|
const { vet } = call;
|
|
1822
|
-
if (vet !== void 0) d.underLock.push(async (locked) => {
|
|
2293
|
+
if (late && vet !== void 0) d.underLock.push(async (locked) => {
|
|
1823
2294
|
const codes = await vet(locked);
|
|
1824
2295
|
const first = codes.find((code) => code !== null);
|
|
1825
2296
|
if (first === void 0 || first === null) return;
|
|
@@ -1835,10 +2306,13 @@ var VaultService = class {
|
|
|
1835
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());
|
|
1836
2307
|
if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
|
|
1837
2308
|
if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
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")));
|
|
2315
|
+
}
|
|
1842
2316
|
const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
|
|
1843
2317
|
d.log.push(...released);
|
|
1844
2318
|
const answer = result(done);
|
|
@@ -1847,7 +2321,7 @@ var VaultService = class {
|
|
|
1847
2321
|
} finally {
|
|
1848
2322
|
for (const value of done) value.wipe();
|
|
1849
2323
|
}
|
|
1850
|
-
});
|
|
2324
|
+
}, late);
|
|
1851
2325
|
}
|
|
1852
2326
|
/** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
|
|
1853
2327
|
async #overBulkLimit(d, principal, n) {
|
|
@@ -1903,6 +2377,7 @@ var VaultService = class {
|
|
|
1903
2377
|
};
|
|
1904
2378
|
return this.#decide([principal], async (d) => {
|
|
1905
2379
|
const reader = through(await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports), input);
|
|
2380
|
+
const natures$1 = await natures(d.tx, [...sources.values()].map((source) => source.secretId));
|
|
1906
2381
|
const environments = await environmentsById(d.tx, items.flatMap((item) => {
|
|
1907
2382
|
const source = sources.get(item.source.secretId);
|
|
1908
2383
|
return source === void 0 ? [item.holder.environmentId] : [item.holder.environmentId, source.environmentId];
|
|
@@ -1913,14 +2388,11 @@ var VaultService = class {
|
|
|
1913
2388
|
if (source === void 0 || projectOf.get(item.holder.environmentId) !== item.holder.projectId) return "bad_claim";
|
|
1914
2389
|
if (source.secretId === item.holder.secretId) return "invalid";
|
|
1915
2390
|
if (environments.get(item.holder.environmentId)?.owner != null || environments.get(source.environmentId)?.owner != null) return "personal";
|
|
1916
|
-
|
|
2391
|
+
const at = placeIn(source, environments);
|
|
2392
|
+
return refuses(reader, "secret.write", placeIn(item.holder, environments), gone(item.holder)) ?? refuses(reader, "secret.read", at, gone(source)) ?? (natures$1.get(source.secretId) === "sensitive" ? refuses(reader, "secret.use", at, gone(source)) : null);
|
|
1917
2393
|
});
|
|
1918
2394
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1919
2395
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), items.map((item, i) => entry(item, "deny", codes[i] ?? first)));
|
|
1920
|
-
d.underLock.push(async (locked) => {
|
|
1921
|
-
if (![...(await environmentsById(locked, [...environments.keys()])).values()].some((environment) => environment.deleted)) return;
|
|
1922
|
-
throw new Refused(refusal("deleted", MESSAGES.deleted), items.map((item) => entry(item, "deny", "deleted")));
|
|
1923
|
-
});
|
|
1924
2396
|
const entries = items.map((item) => entry(item, "allow", null));
|
|
1925
2397
|
d.log.push(...entries);
|
|
1926
2398
|
const answer = { seqs: [] };
|
|
@@ -1994,10 +2466,6 @@ var VaultService = class {
|
|
|
1994
2466
|
if (!codes.some((code) => code !== null)) (await ended(d.tx)).forEach((code, i) => codes[i] = code);
|
|
1995
2467
|
const first = codes.find((code) => code !== null) ?? null;
|
|
1996
2468
|
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), vias.map((via, i) => entry(via, seals[i], "deny", codes[i] ?? first)));
|
|
1997
|
-
d.underLock.push(async (locked) => {
|
|
1998
|
-
if ((await ended(locked)).every((code) => code === null)) return;
|
|
1999
|
-
throw new Refused(refusal("ended", MESSAGES.ended), vias.map((via, i) => entry(via, seals[i], "deny", "ended")));
|
|
2000
|
-
});
|
|
2001
2469
|
const entries = vias.map((via, i) => entry(via, seals[i], "allow", null));
|
|
2002
2470
|
d.log.push(...entries);
|
|
2003
2471
|
const answer = { seqs: [] };
|
|
@@ -2071,11 +2539,11 @@ var VaultService = class {
|
|
|
2071
2539
|
/**
|
|
2072
2540
|
* Refuse a change that would leave `principal`, an Owner of the whole
|
|
2073
2541
|
* instance, no longer one, unless another is. Counted under the log's
|
|
2074
|
-
* head: two changes at once, each demoting or
|
|
2075
|
-
* Owner, lock different rows, and only the head puts
|
|
2542
|
+
* head, which every decision holds: two changes at once, each demoting or
|
|
2543
|
+
* removing a different Owner, lock different rows, and only the head puts
|
|
2544
|
+
* one after the other.
|
|
2076
2545
|
*/
|
|
2077
2546
|
async #keepAnOwner(d, actor, principal, refused) {
|
|
2078
|
-
await lockLogHead(d.tx);
|
|
2079
2547
|
if (await this.#anOwner(d.tx, d.reports, principal) !== null) return;
|
|
2080
2548
|
throw refused("last_owner", actor === principal ? "you're the last Owner of the whole instance: make someone else an Owner first" : `${principal.slice(5)} is the last Owner of the whole instance: make someone else an Owner first`);
|
|
2081
2549
|
}
|
|
@@ -2175,24 +2643,30 @@ var VaultService = class {
|
|
|
2175
2643
|
role: grant.role
|
|
2176
2644
|
}));
|
|
2177
2645
|
const { role, scope } = roleOf(principal, row);
|
|
2646
|
+
const { policy } = await this.#policyAt(db, reports, at);
|
|
2647
|
+
const service = !principal.startsWith("user:");
|
|
2178
2648
|
return {
|
|
2179
2649
|
principal,
|
|
2180
2650
|
status: "active",
|
|
2181
2651
|
live: {
|
|
2182
2652
|
role,
|
|
2183
2653
|
scope,
|
|
2184
|
-
grants: held.filter((grant) => live(grant, at))
|
|
2654
|
+
grants: held.filter((grant) => live(grant, at)),
|
|
2655
|
+
policy,
|
|
2656
|
+
service
|
|
2185
2657
|
},
|
|
2186
2658
|
all: {
|
|
2187
2659
|
role,
|
|
2188
2660
|
scope,
|
|
2189
|
-
grants: held
|
|
2661
|
+
grants: held,
|
|
2662
|
+
policy,
|
|
2663
|
+
service
|
|
2190
2664
|
},
|
|
2191
2665
|
fault,
|
|
2192
2666
|
stored: grants$1
|
|
2193
2667
|
};
|
|
2194
2668
|
}
|
|
2195
|
-
#access(principal, row, held, at, tampered) {
|
|
2669
|
+
#access(principal, row, held, at, tampered, policy) {
|
|
2196
2670
|
const active = PRINCIPAL.test(principal) && !tampered && row?.status === "active";
|
|
2197
2671
|
return {
|
|
2198
2672
|
principal,
|
|
@@ -2204,7 +2678,8 @@ var VaultService = class {
|
|
|
2204
2678
|
},
|
|
2205
2679
|
grants: active ? held.filter((grant) => live(grant, at) && grantKind(grant) !== null).map(view) : [],
|
|
2206
2680
|
since: row ? iso(row.statusChangedAt) : null,
|
|
2207
|
-
by: row?.statusChangedBy ?? null
|
|
2681
|
+
by: row?.statusChangedBy ?? null,
|
|
2682
|
+
policy
|
|
2208
2683
|
};
|
|
2209
2684
|
}
|
|
2210
2685
|
/**
|
|
@@ -2234,7 +2709,8 @@ var VaultService = class {
|
|
|
2234
2709
|
this.#now(db)
|
|
2235
2710
|
]);
|
|
2236
2711
|
const fault = await this.#integrity(db, principal, row, held, reports);
|
|
2237
|
-
|
|
2712
|
+
const { policy } = await this.#policyAt(db, reports, at);
|
|
2713
|
+
return this.#access(principal, row, held, at, fault !== null, policy);
|
|
2238
2714
|
};
|
|
2239
2715
|
const found = [];
|
|
2240
2716
|
const quick = await read(this.#db, found).catch((error) => {
|
|
@@ -2304,12 +2780,15 @@ var VaultService = class {
|
|
|
2304
2780
|
const key = placeKey(change);
|
|
2305
2781
|
if (places$1.has(key)) throw refused("invalid", "each place may be changed once per call");
|
|
2306
2782
|
places$1.add(key);
|
|
2307
|
-
if (change.role !== null &&
|
|
2308
|
-
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project`);
|
|
2783
|
+
if (change.role !== null && typeof change.role !== "string") throw refused("invalid", `no such role: ${String(change.role)}`);
|
|
2309
2784
|
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
2310
2785
|
if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
|
|
2311
2786
|
}
|
|
2312
|
-
await
|
|
2787
|
+
const { policy } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
2788
|
+
for (const change of changes) {
|
|
2789
|
+
if (change.role !== null && !isRole(change.role, policy)) throw refused("invalid", `no such role: ${change.role}`);
|
|
2790
|
+
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role, policy)) throw refused("invalid", `${change.role} can only be granted on a project`);
|
|
2791
|
+
}
|
|
2313
2792
|
const known = await places(d.tx, changes.map((change) => change.projectId), changes.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
|
|
2314
2793
|
const where = changes.map(({ projectId, environmentId, role }) => {
|
|
2315
2794
|
const environment = environmentId === null ? void 0 : known.environments.get(environmentId);
|
|
@@ -2627,12 +3106,140 @@ var VaultService = class {
|
|
|
2627
3106
|
* the default: otherwise whoever can insert rows could open it again.
|
|
2628
3107
|
*/
|
|
2629
3108
|
async #settingsAt(db, reports) {
|
|
2630
|
-
const entry = await newestAuthentic((before) => newestVaultEntries(db, SETTINGS,
|
|
3109
|
+
const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, SETTINGS, limit, before), this.#check(reports, null));
|
|
2631
3110
|
if (entry === "none") return DEFAULT_SETTINGS;
|
|
2632
3111
|
if (typeof entry === "string") return CLOSED_SETTINGS;
|
|
2633
3112
|
const { serviceAccounts } = JSON.parse(entry.metadata);
|
|
2634
3113
|
return isScope(serviceAccounts) ? { serviceAccounts } : CLOSED_SETTINGS;
|
|
2635
3114
|
}
|
|
3115
|
+
async policy() {
|
|
3116
|
+
await this.#settled();
|
|
3117
|
+
const reports = [];
|
|
3118
|
+
const { policy } = await this.#policyAt(this.#db, reports, await this.#now(this.#db));
|
|
3119
|
+
await this.#record(reports);
|
|
3120
|
+
return policy;
|
|
3121
|
+
}
|
|
3122
|
+
/**
|
|
3123
|
+
* The policy the vault's newest `policy.change` says, by its MAC, and its
|
|
3124
|
+
* edits, as of `at`: one in its name it did not write is passed over, and
|
|
3125
|
+
* reported. With such entries and none of its own within reach, the
|
|
3126
|
+
* policy is unreadable, and closed: every environment is protected, and
|
|
3127
|
+
* no role holds anything but Owner, otherwise whoever can insert rows
|
|
3128
|
+
* could unprotect them. Its edits are then unknown, not none.
|
|
3129
|
+
*/
|
|
3130
|
+
async #policyAt(db, reports, at) {
|
|
3131
|
+
const entry = await newestAuthentic((limit, before) => newestVaultEntries(db, POLICY, limit, before), this.#check(reports, null));
|
|
3132
|
+
if (entry === "none") return {
|
|
3133
|
+
policy: DEFAULT_POLICY,
|
|
3134
|
+
edits: NO_EDITS,
|
|
3135
|
+
readable: true
|
|
3136
|
+
};
|
|
3137
|
+
const edits = typeof entry === "string" ? null : readEdits(JSON.parse(entry.metadata));
|
|
3138
|
+
return edits === null ? {
|
|
3139
|
+
policy: CLOSED_POLICY,
|
|
3140
|
+
edits: NO_EDITS,
|
|
3141
|
+
readable: false
|
|
3142
|
+
} : {
|
|
3143
|
+
policy: await livePolicy(db, edits, at),
|
|
3144
|
+
edits,
|
|
3145
|
+
readable: true
|
|
3146
|
+
};
|
|
3147
|
+
}
|
|
3148
|
+
changePolicy(input) {
|
|
3149
|
+
const { actor, change, revision } = input;
|
|
3150
|
+
if (revision !== void 0 && (typeof revision !== "string" || !POLICY_REVISION.test(revision))) throw new Error("revision names a policy as policyRevision writes it");
|
|
3151
|
+
const at = {};
|
|
3152
|
+
const entry = (decision, detail, code = null) => ({
|
|
3153
|
+
actor,
|
|
3154
|
+
action: POLICY,
|
|
3155
|
+
decision,
|
|
3156
|
+
code,
|
|
3157
|
+
operationId: input.operationId ?? null,
|
|
3158
|
+
requestId: input.requestId ?? null,
|
|
3159
|
+
...at,
|
|
3160
|
+
metadata: JSON.stringify({
|
|
3161
|
+
...detail,
|
|
3162
|
+
...traced(input)
|
|
3163
|
+
})
|
|
3164
|
+
});
|
|
3165
|
+
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [entry("deny", { change: describeChange(change) }, code)]);
|
|
3166
|
+
return this.#decide([actor], async (d) => {
|
|
3167
|
+
validateCorrelation(input);
|
|
3168
|
+
const acting = through(await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports), input);
|
|
3169
|
+
if (acting.status === "tampered") throw refused("tampered");
|
|
3170
|
+
if (acting.status !== "active") throw refused(acting.status === "removed" ? "removed" : "not_a_member");
|
|
3171
|
+
const newest = await newestVaultSeq(d.tx, POLICY);
|
|
3172
|
+
const { policy: before, edits, readable } = await this.#policyAt(d.tx, d.reports, d.at);
|
|
3173
|
+
if (revision !== void 0 && policyRevision(newest, before) !== revision) throw refused("policy_changed");
|
|
3174
|
+
const owner = acting.live.credentialScope == null && ownsInstance(acting.live);
|
|
3175
|
+
if (!readable && !owner) throw refused("not_allowed", "the vault cannot read its own policy: only an Owner of the whole instance writes it again, from the roles as they ship");
|
|
3176
|
+
const next = {
|
|
3177
|
+
roles: { ...edits.roles },
|
|
3178
|
+
instanceRoles: { ...edits.instanceRoles },
|
|
3179
|
+
protected: edits.protected.filter((place) => before.protected.includes(place.environmentId)).map((place) => ({ ...place }))
|
|
3180
|
+
};
|
|
3181
|
+
if (change?.kind === "role" || change?.kind === "instance-role") {
|
|
3182
|
+
if (!owner) throw refused("not_allowed", "only an Owner of the whole instance edits roles");
|
|
3183
|
+
const instance = change.kind === "instance-role";
|
|
3184
|
+
const fixed = instance ? FIXED_ROLES.instanceRoles.includes(change.role) : FIXED_ROLES.roles.includes(change.role);
|
|
3185
|
+
if (typeof change.role !== "string" || fixed) throw refused("invalid", `${change.role} is not edited: it holds ${change.role === "member" ? "nothing" : "everything"}, always`);
|
|
3186
|
+
if (instance && !isInstanceRole(change.role)) throw refused("invalid", `no instance role ${change.role}: an Owner makes project roles`);
|
|
3187
|
+
if (!instance && !isBuiltinRole(change.role) && !ROLE_ID.test(change.role)) throw refused("invalid", "a role is named by lowercase letters, digits and dashes, 2 to 32, starting with a letter");
|
|
3188
|
+
const table = instance ? next.instanceRoles : next.roles;
|
|
3189
|
+
if (change.definition === null) {
|
|
3190
|
+
if (!instance && !isBuiltinRole(change.role)) {
|
|
3191
|
+
if (table[change.role] === void 0) throw refused("invalid", `no role ${change.role}`);
|
|
3192
|
+
const held = await grantsNaming(d.tx, change.role);
|
|
3193
|
+
if (held > 0) throw refused("invalid", `${held === 1 ? "1 grant names" : `${held} grants name`} ${change.role}: revoke ${held === 1 ? "it" : "them"} first`);
|
|
3194
|
+
}
|
|
3195
|
+
const shipped = instance ? void 0 : isBuiltinRole(change.role) ? ROLES[change.role] : void 0;
|
|
3196
|
+
if (shipped !== void 0 && shipped.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && await grantsNaming(d.tx, change.role, { onEnvironments: true }) > 0) throw refused("invalid", `${change.role} is granted on environments: as it ships, it holds a project-wide permission, which would make those grants incoherent`);
|
|
3197
|
+
delete table[change.role];
|
|
3198
|
+
} else {
|
|
3199
|
+
const problem = definitionProblem(change.definition);
|
|
3200
|
+
if (problem !== null) throw refused("invalid", problem);
|
|
3201
|
+
const definition = normalDefinition(change.definition);
|
|
3202
|
+
if (!instance && definition.permissions.some((permission) => PROJECT_ONLY_PERMISSIONS.includes(permission)) && await grantsNaming(d.tx, change.role, { onEnvironments: true }) > 0) throw refused("invalid", `${change.role} is granted on environments: a project-wide permission would make those grants incoherent`);
|
|
3203
|
+
const shipped = instance ? INSTANCE_ROLES[change.role] : isBuiltinRole(change.role) ? ROLES[change.role] : void 0;
|
|
3204
|
+
if (shipped !== void 0 && JSON.stringify(normalDefinition(shipped)) === JSON.stringify(definition)) delete table[change.role];
|
|
3205
|
+
else table[change.role] = definition;
|
|
3206
|
+
}
|
|
3207
|
+
} else if (change?.kind === "protect") {
|
|
3208
|
+
if (typeof change.projectId !== "string" || !UUID.test(change.projectId) || !Array.isArray(change.environmentIds) || change.environmentIds.length === 0 || change.environmentIds.some((id) => typeof id !== "string" || !UUID.test(id)) || typeof change.protected !== "boolean") throw refused("invalid", "protection names a project and some of its environments, by id, and whether they are protected");
|
|
3209
|
+
if (!await projectLive(d.tx, change.projectId)) throw refused("invalid", "no such project");
|
|
3210
|
+
at.projectId = change.projectId;
|
|
3211
|
+
const found = await environmentsById(d.tx, change.environmentIds);
|
|
3212
|
+
if ([...found.values()].some((environment) => environment.projectId !== change.projectId)) throw refused("invalid", "each environment is one of the project's");
|
|
3213
|
+
if (change.environmentIds.length === 1 && found.has(change.environmentIds[0])) at.environmentId = change.environmentIds[0];
|
|
3214
|
+
if (change.protected) {
|
|
3215
|
+
if (!allows(acting.live, "environment.manage", { projectId: change.projectId })) throw refused("not_allowed", "protecting an environment takes managing its project's environments");
|
|
3216
|
+
const known = new Set(next.protected.map((place) => place.environmentId));
|
|
3217
|
+
next.protected = [...next.protected, ...change.environmentIds.filter((id) => !known.has(id)).map((environmentId) => ({
|
|
3218
|
+
projectId: change.projectId,
|
|
3219
|
+
environmentId,
|
|
3220
|
+
at: d.at
|
|
3221
|
+
}))].sort((a, b) => a.environmentId < b.environmentId ? -1 : 1);
|
|
3222
|
+
} else {
|
|
3223
|
+
if (!owner) throw refused("not_allowed", "only an Owner of the whole instance unprotects an environment");
|
|
3224
|
+
next.protected = next.protected.filter((place) => !change.environmentIds.includes(place.environmentId));
|
|
3225
|
+
}
|
|
3226
|
+
} else throw refused("invalid", "a change is to a role, an instance role, or protection");
|
|
3227
|
+
const answer = {
|
|
3228
|
+
policy: await livePolicy(d.tx, next, d.at),
|
|
3229
|
+
seq: newest === null ? null : Number(newest)
|
|
3230
|
+
};
|
|
3231
|
+
if (!readable || JSON.stringify(next) !== JSON.stringify(edits)) {
|
|
3232
|
+
const made = entry("allow", {
|
|
3233
|
+
policy: next,
|
|
3234
|
+
change: describeChange(change),
|
|
3235
|
+
before: describeBefore(change, before)
|
|
3236
|
+
});
|
|
3237
|
+
d.log.push(made);
|
|
3238
|
+
d.after.push((seqOf) => answer.seq = seqOf(made));
|
|
3239
|
+
}
|
|
3240
|
+
return answer;
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
2636
3243
|
setSettings(input) {
|
|
2637
3244
|
const { actor } = input;
|
|
2638
3245
|
const entry = (decision, detail, code = null) => ({
|
|
@@ -2654,7 +3261,6 @@ var VaultService = class {
|
|
|
2654
3261
|
if (acting.status === "tampered") throw refused("tampered");
|
|
2655
3262
|
if (!runsInstance(acting.live)) throw refused("not_allowed", "only admins and owners of the whole instance change its settings");
|
|
2656
3263
|
if (!isScope(input.settings?.serviceAccounts)) throw refused("invalid", "who sets up service accounts is a scope: { projects, environments }, each all, only or except");
|
|
2657
|
-
await lockLogHead(d.tx);
|
|
2658
3264
|
const previous = await this.#settingsAt(d.tx, d.reports);
|
|
2659
3265
|
const settings = { serviceAccounts: normalScope(input.settings.serviceAccounts) };
|
|
2660
3266
|
if (JSON.stringify(settings) !== JSON.stringify(previous)) d.log.push(entry("allow", {
|
|
@@ -2817,63 +3423,162 @@ var VaultService = class {
|
|
|
2817
3423
|
}
|
|
2818
3424
|
return checks;
|
|
2819
3425
|
}
|
|
2820
|
-
/**
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
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"
|
|
2827
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;
|
|
2828
3511
|
}
|
|
2829
3512
|
/**
|
|
2830
|
-
*
|
|
2831
|
-
* its first entry at the first checkpoint of each hour, and otherwise from
|
|
2832
|
-
* the prefix the last checkpoint signed, if that one was signed this hour.
|
|
2833
|
-
*
|
|
2834
|
-
* Resuming never signs what the vault did not recompute. The vault
|
|
2835
|
-
* recomputed the last checkpoint's prefix itself, from entry 0 at the
|
|
2836
|
-
* hour's first checkpoint and onward at each one since, and the entry
|
|
2837
|
-
* that holds it is the first one checked again, by its MAC. The next
|
|
2838
|
-
* signature extends that same chain.
|
|
3513
|
+
* Sign the log up to its last entry, from the newest checkpoint's tally.
|
|
2839
3514
|
*
|
|
2840
|
-
*
|
|
2841
|
-
*
|
|
2842
|
-
*
|
|
2843
|
-
*
|
|
2844
|
-
*
|
|
2845
|
-
*
|
|
2846
|
-
*
|
|
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.
|
|
2847
3524
|
*
|
|
2848
|
-
*
|
|
2849
|
-
*
|
|
2850
|
-
*
|
|
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.
|
|
2851
3529
|
*/
|
|
2852
|
-
async #recompute(tx) {
|
|
2853
|
-
const { logKeys } = this.#prepared;
|
|
2854
|
-
const latest = await this.#latest(tx);
|
|
2855
|
-
const hour = (ms) => Math.floor(ms / FULL_RECOMPUTE_MS);
|
|
2856
|
-
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);
|
|
2857
|
-
const { seq, hash } = latest.checkpoint;
|
|
2858
|
-
return verifyChain(tx, logKeys, [], {
|
|
2859
|
-
nextSeq: BigInt(seq) + 1n,
|
|
2860
|
-
hash: Buffer.from(hash, "hex"),
|
|
2861
|
-
vaultEntries: 0,
|
|
2862
|
-
vaultKeys: [logKeys[0].keyId]
|
|
2863
|
-
});
|
|
2864
|
-
}
|
|
2865
3530
|
async checkpoint() {
|
|
2866
3531
|
await this.#settled();
|
|
2867
|
-
const { wrongKek } = this.#prepared;
|
|
3532
|
+
const { wrongKek, logKeys } = this.#prepared;
|
|
3533
|
+
const { walks } = this.#prepared.options;
|
|
2868
3534
|
const found = [];
|
|
2869
|
-
|
|
3535
|
+
let seen = null;
|
|
3536
|
+
const walked = await this.#db.transaction(async (tx) => {
|
|
3537
|
+
seen = (await newestCheckpointEntry(tx))?.seq ?? null;
|
|
2870
3538
|
await this.#sweep(tx, found);
|
|
2871
|
-
|
|
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
|
+
};
|
|
2872
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
|
+
}
|
|
2873
3578
|
return this.#decide([], async (d) => {
|
|
2874
3579
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
2875
3580
|
const head = await lockLogHead(d.tx);
|
|
2876
|
-
const latest = await
|
|
3581
|
+
const latest = await latestVaultEntry(d.tx, [CHECKPOINT]);
|
|
2877
3582
|
const refused = (code, detail, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
|
|
2878
3583
|
actor: SCHEDULER,
|
|
2879
3584
|
action: CHECKPOINT,
|
|
@@ -2883,21 +3588,57 @@ var VaultService = class {
|
|
|
2883
3588
|
}]);
|
|
2884
3589
|
if (head.nextSeq === 0n) throw new Refused(refusal("invalid", "the log is empty"), []);
|
|
2885
3590
|
if (wrongKek !== null) throw refused("wrong_kek", { reason: wrongKek }, wrongKek);
|
|
2886
|
-
if (!
|
|
2887
|
-
failedAtSeq:
|
|
2888
|
-
reason:
|
|
3591
|
+
if (!walked.ok) throw refused("log_broken", {
|
|
3592
|
+
failedAtSeq: walked.failedAtSeq,
|
|
3593
|
+
reason: walked.reason
|
|
2889
3594
|
});
|
|
2890
|
-
if (
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
if (!held.ok) throw refused("log_broken", {
|
|
2894
|
-
failedAtSeq: held.failedAtSeq,
|
|
2895
|
-
reason: held.reason
|
|
3595
|
+
if (!scrub.ok) throw refused("log_broken", {
|
|
3596
|
+
failedAtSeq: scrub.failedAtSeq,
|
|
3597
|
+
reason: scrub.reason
|
|
2896
3598
|
});
|
|
2897
|
-
|
|
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
|
+
};
|
|
2898
3639
|
const signed = {
|
|
2899
|
-
seq: Number(
|
|
2900
|
-
hash:
|
|
3640
|
+
seq: Number(at.seq),
|
|
3641
|
+
hash: at.hash.toString("hex"),
|
|
2901
3642
|
signedAt: iso(d.at)
|
|
2902
3643
|
};
|
|
2903
3644
|
const checkpoint = {
|
|
@@ -2905,15 +3646,85 @@ var VaultService = class {
|
|
|
2905
3646
|
keyId: this.#prepared.signer.keyId,
|
|
2906
3647
|
signature: await this.#prepared.signer.sign(checkpointMessage(signed))
|
|
2907
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
|
+
};
|
|
2908
3662
|
d.log.push({
|
|
2909
3663
|
actor: SCHEDULER,
|
|
2910
3664
|
action: CHECKPOINT,
|
|
2911
3665
|
decision: "allow",
|
|
2912
|
-
|
|
3666
|
+
relatedSeq: at.seq,
|
|
3667
|
+
metadata: JSON.stringify(kept)
|
|
2913
3668
|
});
|
|
2914
|
-
return { checkpoint }
|
|
3669
|
+
return stalled === null ? { checkpoint } : {
|
|
3670
|
+
checkpoint,
|
|
3671
|
+
warning: stalled
|
|
3672
|
+
};
|
|
2915
3673
|
});
|
|
2916
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
|
+
}
|
|
2917
3728
|
async about() {
|
|
2918
3729
|
await this.#settled();
|
|
2919
3730
|
const { signers, since } = this.#prepared;
|
|
@@ -2942,65 +3753,181 @@ var VaultService = class {
|
|
|
2942
3753
|
checks
|
|
2943
3754
|
};
|
|
2944
3755
|
}
|
|
2945
|
-
verifyLog(input) {
|
|
2946
|
-
return this.#verifyAll(input.upTo ?? null);
|
|
2947
|
-
}
|
|
2948
3756
|
/**
|
|
2949
|
-
* The
|
|
2950
|
-
* each by its MAC, the keys it moved through, and what the
|
|
2951
|
-
* need of it
|
|
2952
|
-
* heads that must still be there,
|
|
2953
|
-
* 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.
|
|
2954
3763
|
*
|
|
2955
3764
|
* With `upTo`, the head the app verified to, the links and hashes of the
|
|
2956
|
-
* chain are the app's to recompute, as it just has, over its own
|
|
2957
|
-
* through that head; `
|
|
2958
|
-
* the vault reads only its own entries, each checked against its
|
|
2959
|
-
* and MAC over the link it names. Without `upTo`, nobody else
|
|
2960
|
-
* 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`).
|
|
2961
3771
|
*/
|
|
2962
|
-
async
|
|
3772
|
+
async verifyLog(input) {
|
|
2963
3773
|
await this.#settled();
|
|
3774
|
+
const upTo = input.upTo ?? null;
|
|
3775
|
+
const { logKeys } = this.#prepared;
|
|
3776
|
+
const { walks } = this.#prepared.options;
|
|
2964
3777
|
return this.#db.transaction(async (tx) => {
|
|
2965
3778
|
const remembered = this.#prepared.verified;
|
|
2966
|
-
const
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
const
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
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)]);
|
|
2977
3879
|
if (upTo !== null && !holds(upTo)) return {
|
|
2978
3880
|
ok: false,
|
|
2979
3881
|
failedAtSeq: upTo.seq,
|
|
2980
3882
|
reason: "not the entry the app verified up to: the log changed between the two checks"
|
|
2981
3883
|
};
|
|
2982
|
-
const unsigned = await this.#checkpointFault(
|
|
3884
|
+
const unsigned = await this.#checkpointFault(tx, walk.checkpoints, holds);
|
|
2983
3885
|
if (unsigned !== null) return unsigned;
|
|
2984
3886
|
if (rememberedHead !== null && !holds(rememberedHead)) return {
|
|
2985
3887
|
ok: false,
|
|
2986
3888
|
failedAtSeq: rememberedHead.seq,
|
|
2987
3889
|
reason: "changed since the vault last verified it"
|
|
2988
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;
|
|
2989
3920
|
const at = await this.#now(tx);
|
|
2990
|
-
const accounting =
|
|
3921
|
+
const accounting = walk.accounting.result(at);
|
|
2991
3922
|
if (!accounting.ok) return accounting;
|
|
2992
3923
|
const [rows, held] = await Promise.all([allMembers(tx), grants(tx)]);
|
|
2993
|
-
const fault = this.#unsealed(rows, held,
|
|
3924
|
+
const fault = this.#unsealed(rows, held, walk.newestAccess) ?? walk.replayFault ?? replayFault(walk.replayed, rows, held, at);
|
|
2994
3925
|
if (fault !== null) return {
|
|
2995
3926
|
ok: false,
|
|
2996
3927
|
failedAtSeq: null,
|
|
2997
3928
|
reason: describeAccessFault(fault),
|
|
2998
3929
|
fault
|
|
2999
3930
|
};
|
|
3000
|
-
const verified = {
|
|
3001
|
-
ok: true,
|
|
3002
|
-
entries: findings.entries
|
|
3003
|
-
};
|
|
3004
3931
|
return accounting.pending === 0 ? verified : {
|
|
3005
3932
|
...verified,
|
|
3006
3933
|
pending: accounting.pending
|
|
@@ -3008,55 +3935,15 @@ var VaultService = class {
|
|
|
3008
3935
|
}, SNAPSHOT);
|
|
3009
3936
|
}
|
|
3010
3937
|
/**
|
|
3011
|
-
*
|
|
3012
|
-
*
|
|
3013
|
-
*
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
const { logKeys } = this.#prepared;
|
|
3017
|
-
let keys = [];
|
|
3018
|
-
for (let after = -1n;;) {
|
|
3019
|
-
const batch = await vaultEntriesAfter(tx, after, VERIFY_BATCH);
|
|
3020
|
-
if (batch.length === 0) return { ok: true };
|
|
3021
|
-
for (const entry of batch) {
|
|
3022
|
-
const result = verifyEntries([entry], {
|
|
3023
|
-
startSeq: entry.seq,
|
|
3024
|
-
startPrevHash: entry.prevHash,
|
|
3025
|
-
keys: logKeys
|
|
3026
|
-
});
|
|
3027
|
-
if (!result.ok) return {
|
|
3028
|
-
ok: false,
|
|
3029
|
-
failedAtSeq: Number(result.failedAtSeq),
|
|
3030
|
-
reason: withCause(result.reason)
|
|
3031
|
-
};
|
|
3032
|
-
}
|
|
3033
|
-
const moved = forward(batch, keys, logKeys[0].keyId);
|
|
3034
|
-
if ("failedAtSeq" in moved) return {
|
|
3035
|
-
ok: false,
|
|
3036
|
-
failedAtSeq: Number(moved.failedAtSeq),
|
|
3037
|
-
reason: moved.reason
|
|
3038
|
-
};
|
|
3039
|
-
keys = moved.keys;
|
|
3040
|
-
for (const entry of batch) findings.add(entry);
|
|
3041
|
-
after = batch[batch.length - 1].seq;
|
|
3042
|
-
}
|
|
3043
|
-
}
|
|
3044
|
-
/** The whole chain from its first entry: every link and hash, the vault's MACs; the furthest verified is kept for checkpoints. */
|
|
3045
|
-
async #rehashed(tx, findings) {
|
|
3046
|
-
const { verification, anchor } = await verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED, (batch) => {
|
|
3047
|
-
for (const entry of batch) if (entry.author === "vault") findings.add(entry);
|
|
3048
|
-
});
|
|
3049
|
-
if (!verification.ok) return verification;
|
|
3050
|
-
this.#prepared.verified = further(this.#prepared.verified, anchor);
|
|
3051
|
-
return { ok: true };
|
|
3052
|
-
}
|
|
3053
|
-
/**
|
|
3054
|
-
* Every checkpoint, not only the newest: each signed by the key whose log
|
|
3055
|
-
* key wrote its entry, over a prefix the log still holds (`holds`), entry
|
|
3056
|
-
* for entry. Its entry's MAC is checked by now, and one under a key the
|
|
3057
|
-
* 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.
|
|
3058
3943
|
*/
|
|
3059
|
-
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));
|
|
3060
3947
|
const { logKeys, signers } = this.#prepared;
|
|
3061
3948
|
const verifiers = /* @__PURE__ */ new Map();
|
|
3062
3949
|
const signed = (checkpoint, by) => {
|
|
@@ -3064,14 +3951,13 @@ var VaultService = class {
|
|
|
3064
3951
|
if (verifier === void 0) verifiers.set(by.keyId, verifier = checkpointVerifier(by.publicKey));
|
|
3065
3952
|
return verifier.then((verify) => verify(checkpoint));
|
|
3066
3953
|
};
|
|
3067
|
-
const { checkpoints } = findings;
|
|
3068
3954
|
for (let from = 0; from < checkpoints.length; from += SIGNATURES_AT_ONCE) {
|
|
3069
3955
|
const chunk = checkpoints.slice(from, from + SIGNATURES_AT_ONCE);
|
|
3070
3956
|
const faults = await Promise.all(chunk.map(async ({ entry, checkpoint }) => {
|
|
3071
3957
|
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
3072
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`;
|
|
3073
3959
|
if (!await signed(checkpoint, by)) return "a checkpoint the vault did not sign";
|
|
3074
|
-
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`;
|
|
3075
3961
|
return null;
|
|
3076
3962
|
}));
|
|
3077
3963
|
const at = faults.findIndex((fault) => fault !== null);
|
|
@@ -3085,8 +3971,8 @@ var VaultService = class {
|
|
|
3085
3971
|
}
|
|
3086
3972
|
/**
|
|
3087
3973
|
* The first member whose row fails its MAC, or names an older access
|
|
3088
|
-
* entry than the log's newest about them (`newest`, from the
|
|
3089
|
-
*
|
|
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).
|
|
3090
3976
|
*/
|
|
3091
3977
|
#unsealed(rows, held, newest) {
|
|
3092
3978
|
for (const row of rows) {
|
|
@@ -3105,44 +3991,71 @@ var VaultService = class {
|
|
|
3105
3991
|
return null;
|
|
3106
3992
|
}
|
|
3107
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
|
+
};
|
|
3108
4008
|
/**
|
|
3109
|
-
*
|
|
3110
|
-
*
|
|
3111
|
-
*
|
|
3112
|
-
*
|
|
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.
|
|
3113
4014
|
*/
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
held: /* @__PURE__ */ new Map()
|
|
3119
|
-
};
|
|
3120
|
-
/** The first access entry the replay could not apply. */
|
|
3121
|
-
replayFault = null;
|
|
3122
|
-
/** Each member's newest allowed access entry, by seq. */
|
|
3123
|
-
newestAccess = /* @__PURE__ */ new Map();
|
|
3124
|
-
/** Every allowed checkpoint, with its entry's seq and key: tens of thousands a year, so nothing more of it. */
|
|
3125
|
-
checkpoints = [];
|
|
3126
|
-
/** The vault's entries read. */
|
|
3127
|
-
entries = 0;
|
|
3128
|
-
add(entry) {
|
|
3129
|
-
this.entries += 1;
|
|
3130
|
-
this.accounting.add(entry);
|
|
3131
|
-
if (entry.decision !== "allow") return;
|
|
3132
|
-
if (ACCESS.has(entry.action)) {
|
|
3133
|
-
this.replayFault ??= apply(this.replayed, entry);
|
|
3134
|
-
if (entry.subjectPrincipal !== null) this.newestAccess.set(entry.subjectPrincipal, entry.seq);
|
|
3135
|
-
} else if (entry.action === CHECKPOINT) this.checkpoints.push({
|
|
3136
|
-
entry: {
|
|
3137
|
-
seq: entry.seq,
|
|
3138
|
-
keyId: entry.keyId
|
|
3139
|
-
},
|
|
3140
|
-
checkpoint: JSON.parse(entry.metadata)
|
|
3141
|
-
});
|
|
3142
|
-
}
|
|
4015
|
+
const WALKS = {
|
|
4016
|
+
checkpoint: 1e4,
|
|
4017
|
+
scrub: 100,
|
|
4018
|
+
verify: 1e4
|
|
3143
4019
|
};
|
|
3144
|
-
|
|
3145
|
-
|
|
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. */
|
|
3146
4059
|
const SIGNATURES_AT_ONCE = 256;
|
|
3147
4060
|
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
3148
4061
|
async function settle(operations, budgetMs) {
|
|
@@ -3199,6 +4112,7 @@ function validateItems(items, field) {
|
|
|
3199
4112
|
if (field === "key" && typeof key !== "string") throw new Error("DEK must be base64");
|
|
3200
4113
|
if (field === "wrapped" && (wrapped === void 0 || wrapped === null)) throw new Error("wrapped key is required");
|
|
3201
4114
|
checkContext(context(secret));
|
|
4115
|
+
if (secret.sensitive !== void 0 && typeof secret.sensitive !== "boolean") throw new Error("a secret is sensitive or not: true, false or absent");
|
|
3202
4116
|
if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error("secret version must be a positive integer");
|
|
3203
4117
|
if (typeof secret.path !== "string" || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error("secret path must be a well-formed string");
|
|
3204
4118
|
if (key !== void 0) {
|
|
@@ -3346,9 +4260,10 @@ function personalRefusal(reader, principal, permission, secret, at, environments
|
|
|
3346
4260
|
projectId: secret.projectId,
|
|
3347
4261
|
environmentId: own.parentId
|
|
3348
4262
|
}, environments);
|
|
3349
|
-
const
|
|
4263
|
+
const gone = own.deleted || (environments.get(own.parentId)?.deleted ?? true);
|
|
4264
|
+
const refused = refuses(reader, "secret.use", parent, gone) === null ? null : refuses(reader, "secret.read", parent, gone);
|
|
3350
4265
|
if (refused !== null) return refused;
|
|
3351
|
-
return withinCredential(reader.live, permission, parent) ? null : "no_grant";
|
|
4266
|
+
return withinCredential(reader.live, permission === "secret.read" ? "secret.use" : permission, parent) ? null : "no_grant";
|
|
3352
4267
|
}
|
|
3353
4268
|
function refuses(reader, permission, where, deleted) {
|
|
3354
4269
|
if (reader.status === "tampered") return "tampered";
|
|
@@ -3545,13 +4460,87 @@ function view(grant) {
|
|
|
3545
4460
|
function iso(ms) {
|
|
3546
4461
|
return new Date(ms).toISOString();
|
|
3547
4462
|
}
|
|
3548
|
-
/** A
|
|
4463
|
+
/** A policy entry's edits, checked as the vault wrote them, or null for any that does not hold. */
|
|
4464
|
+
function readEdits(stored) {
|
|
4465
|
+
const edits = stored.policy;
|
|
4466
|
+
if (typeof edits !== "object" || edits === null) return null;
|
|
4467
|
+
const { roles, instanceRoles, protected: protectedIds } = edits;
|
|
4468
|
+
if (typeof roles !== "object" || roles === null || typeof instanceRoles !== "object" || instanceRoles === null || !Array.isArray(protectedIds)) return null;
|
|
4469
|
+
const defined = (table) => Object.values(table).every((definition) => definitionProblem(definition) === null);
|
|
4470
|
+
if (!defined(roles) || !defined(instanceRoles) || Object.keys(instanceRoles).some((role) => !isInstanceRole(role))) return null;
|
|
4471
|
+
if (protectedIds.some((place) => typeof place !== "object" || place === null || !UUID.test(place.projectId) || !UUID.test(place.environmentId) || !Number.isSafeInteger(place.at))) return null;
|
|
4472
|
+
return {
|
|
4473
|
+
roles,
|
|
4474
|
+
instanceRoles,
|
|
4475
|
+
protected: protectedIds
|
|
4476
|
+
};
|
|
4477
|
+
}
|
|
4478
|
+
/**
|
|
4479
|
+
* The policy `edits` say as of `at`: the roles as they ship, each edited
|
|
4480
|
+
* one replaced and each made one added; and the protected environments, but
|
|
4481
|
+
* for those deleted since, the project around one's included: a deleted
|
|
4482
|
+
* prod protects nothing, and holds nobody back from giving a service
|
|
4483
|
+
* account a role on all of its project (`givesService`). A fork's id with
|
|
4484
|
+
* no row yet stays, protected before it is written, ten minutes: one with
|
|
4485
|
+
* none after that is a fork refused or abandoned, never written. Each
|
|
4486
|
+
* keeps its slug, which a scoped instance role's filters read. The edits
|
|
4487
|
+
* keep them all: no write is needed.
|
|
4488
|
+
*/
|
|
4489
|
+
async function livePolicy(db, edits, at) {
|
|
4490
|
+
const rows = await environmentsById(db, edits.protected.map((place) => place.environmentId));
|
|
4491
|
+
const live = edits.protected.filter((place) => {
|
|
4492
|
+
const row = rows.get(place.environmentId);
|
|
4493
|
+
return row === void 0 ? at - place.at < UNWRITTEN_FORK_MS : !row.deleted;
|
|
4494
|
+
});
|
|
4495
|
+
const slugOf = (place) => rows.get(place.environmentId)?.slug ?? null;
|
|
4496
|
+
return {
|
|
4497
|
+
roles: {
|
|
4498
|
+
...ROLES,
|
|
4499
|
+
...edits.roles
|
|
4500
|
+
},
|
|
4501
|
+
instanceRoles: {
|
|
4502
|
+
...INSTANCE_ROLES,
|
|
4503
|
+
...edits.instanceRoles
|
|
4504
|
+
},
|
|
4505
|
+
protected: live.map((place) => place.environmentId),
|
|
4506
|
+
protectedIn: live.reduce((byProject, place) => ({
|
|
4507
|
+
...byProject,
|
|
4508
|
+
[place.projectId]: [...byProject[place.projectId] ?? [], {
|
|
4509
|
+
environmentId: place.environmentId,
|
|
4510
|
+
slug: slugOf(place)
|
|
4511
|
+
}]
|
|
4512
|
+
}), {})
|
|
4513
|
+
};
|
|
4514
|
+
}
|
|
4515
|
+
/** A change, as its entry names it. */
|
|
4516
|
+
function describeChange(change) {
|
|
4517
|
+
if (change === void 0 || change === null || typeof change !== "object") return null;
|
|
4518
|
+
if (change.kind === "protect") return {
|
|
4519
|
+
kind: "protect",
|
|
4520
|
+
projectId: change.projectId,
|
|
4521
|
+
environmentIds: change.environmentIds,
|
|
4522
|
+
protected: change.protected
|
|
4523
|
+
};
|
|
4524
|
+
return {
|
|
4525
|
+
kind: change.kind,
|
|
4526
|
+
role: change.role,
|
|
4527
|
+
definition: change.definition
|
|
4528
|
+
};
|
|
4529
|
+
}
|
|
4530
|
+
/** What a change changed, as it was: the role's definition, or which environments were protected. */
|
|
4531
|
+
function describeBefore(change, before) {
|
|
4532
|
+
if (change.kind === "protect") return { protected: change.environmentIds.filter((id) => before.protected.includes(id)) };
|
|
4533
|
+
const table = change.kind === "instance-role" ? before.instanceRoles : before.roles;
|
|
4534
|
+
return { definition: Object.hasOwn(table, change.role) ? table[change.role] : null };
|
|
4535
|
+
}
|
|
4536
|
+
/** A secret's binding context: its ids, a personal config's owner, hashed, as the decision read it, and whether it is sensitive. */
|
|
3549
4537
|
function context(secret, owner) {
|
|
3550
4538
|
return {
|
|
3551
4539
|
projectId: secret.projectId,
|
|
3552
4540
|
environmentId: secret.environmentId,
|
|
3553
4541
|
secretId: secret.secretId,
|
|
3554
|
-
...owner === void 0 ? {} : { owner }
|
|
4542
|
+
...owner === void 0 ? {} : { owner },
|
|
4543
|
+
...secret.sensitive === true ? { sensitive: true } : {}
|
|
3555
4544
|
};
|
|
3556
4545
|
}
|
|
3557
4546
|
function unwrappable(wrapped) {
|