@coffre/vault 0.0.0 → 0.1.0
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/LICENSE +21 -0
- package/README.md +25 -3
- package/dist/cloudflare.d.ts +55 -0
- package/dist/cloudflare.js +73 -0
- package/dist/index-qXbB_vlp.d.ts +40 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/node.d.ts +48 -0
- package/dist/node.js +142 -0
- package/dist/vault-DvTsSAOX.js +2225 -0
- package/package.json +51 -5
- package/src/accounting.ts +110 -0
- package/src/checkpoint.ts +25 -0
- package/src/cloudflare-workers.d.ts +14 -0
- package/src/cloudflare.ts +89 -0
- package/src/config.ts +146 -0
- package/src/index.ts +6 -0
- package/src/local.ts +48 -0
- package/src/log.ts +140 -0
- package/src/node.ts +141 -0
- package/src/replay.ts +150 -0
- package/src/rows.ts +67 -0
- package/src/store.ts +420 -0
- package/src/vault.ts +1564 -0
|
@@ -0,0 +1,2225 @@
|
|
|
1
|
+
import { ACCESS_ACTIONS, checkpointMessage, describeAccessFault, verifyCheckpoint } from "@coffre/core/vault";
|
|
2
|
+
import { DEK_BYTES, KekBadClaimError, KekCancelledError, KekRegistry, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
|
|
3
|
+
import { tablesOf } from "@coffre/db";
|
|
4
|
+
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { allows, assignableToEnvironment, isRole, isSyncPrincipal, mayManageAccess } from "@coffre/core/access";
|
|
6
|
+
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
|
+
import { checkContext } from "@coffre/core/envelope";
|
|
8
|
+
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation } from "@coffre/db/dialect";
|
|
9
|
+
import { appendEntries, lockLogHead } from "@coffre/db/log";
|
|
10
|
+
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, sql } from "drizzle-orm";
|
|
11
|
+
//#region src/config.ts
|
|
12
|
+
/**
|
|
13
|
+
* 1000 keys in 15 minutes: twenty `coffre run`s of a 50-key environment
|
|
14
|
+
* back to back, which no person or deploy pipeline does, while a script
|
|
15
|
+
* pulling every secret it can reach stops within a few seconds.
|
|
16
|
+
*/
|
|
17
|
+
const DEFAULT_BULK_LIMIT = {
|
|
18
|
+
count: 1e3,
|
|
19
|
+
windowMs: 9e5
|
|
20
|
+
};
|
|
21
|
+
function key32(value, what) {
|
|
22
|
+
const raw = Buffer.from(value, "base64");
|
|
23
|
+
if (raw.length !== 32) throw new Error(`${what} must be 32 bytes, base64; got ${raw.length} bytes`);
|
|
24
|
+
return raw;
|
|
25
|
+
}
|
|
26
|
+
const KEK_ID = /^[A-Za-z0-9._-]{1,64}$/;
|
|
27
|
+
const KEK_PROVIDER = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
28
|
+
/** Every row records it, and `provider:keyId` finds the KEK again: visible ASCII, bounded. */
|
|
29
|
+
const KEK_NAME = /^[\x21-\x7e]{1,255}$/;
|
|
30
|
+
function kek(entry) {
|
|
31
|
+
if (!("wrap" in entry)) {
|
|
32
|
+
const { id, key } = entry;
|
|
33
|
+
if (!KEK_ID.test(id)) throw new Error(`KEK id "${id}" must be 1-64 letters, digits, dots, dashes or underscores`);
|
|
34
|
+
return new LocalKekProvider(key32(key, `KEK ${id}`), id);
|
|
35
|
+
}
|
|
36
|
+
const { provider, keyId, keyVersion } = entry;
|
|
37
|
+
if (typeof provider !== "string" || !KEK_PROVIDER.test(provider)) throw new Error(`a KEK provider's name must be 1-32 lowercase letters, digits or dashes; got "${String(provider)}"`);
|
|
38
|
+
if (typeof keyId !== "string" || !KEK_NAME.test(keyId) || typeof keyVersion !== "string" || !KEK_NAME.test(keyVersion)) throw new Error(`KEK ${provider}: keyId and keyVersion must be 1-255 visible ASCII characters`);
|
|
39
|
+
if (typeof entry.wrap !== "function" || typeof entry.unwrap !== "function") throw new Error(`KEK ${provider}:${keyId} needs wrap() and unwrap()`);
|
|
40
|
+
return entry;
|
|
41
|
+
}
|
|
42
|
+
/** Check a deployment's vault configuration, failing on the first problem. */
|
|
43
|
+
function resolveVaultConfig(config) {
|
|
44
|
+
const [current, ...previous] = [config.kek, ...config.previousKeks ?? []].map(kek);
|
|
45
|
+
const refs = [current, ...previous].map(({ provider, keyId }) => `${provider}:${keyId}`);
|
|
46
|
+
const twice = refs.find((ref, i) => refs.indexOf(ref) !== i);
|
|
47
|
+
if (twice !== void 0) throw new Error(`two KEKs share an id: ${twice}`);
|
|
48
|
+
return {
|
|
49
|
+
keks: new KekRegistry(current, previous),
|
|
50
|
+
rootAdmins: checkRootAdmins(config.rootAdmins),
|
|
51
|
+
signingKey: key32(config.signingKey, "the signing key"),
|
|
52
|
+
bulkLimit: config.bulkLimit === void 0 ? DEFAULT_BULK_LIMIT : checkBulkLimit(config.bulkLimit)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function checkBulkLimit({ count, windowMinutes }) {
|
|
56
|
+
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) throw new Error("bulkLimit needs a whole count of at least 1 and a window above 0 minutes");
|
|
57
|
+
return {
|
|
58
|
+
count,
|
|
59
|
+
windowMs: windowMinutes * 6e4
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function isHumanEmail(value) {
|
|
63
|
+
if (value.length > 254) return false;
|
|
64
|
+
const parts = value.split("@");
|
|
65
|
+
if (parts.length !== 2) return false;
|
|
66
|
+
const [local, domain] = parts;
|
|
67
|
+
if (local.length === 0 || local.length > 64 || local.startsWith(".") || local.endsWith(".") || local.includes("..") || !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local)) return false;
|
|
68
|
+
const labels = domain.split(".");
|
|
69
|
+
if (labels.length < 2) return false;
|
|
70
|
+
return labels.every((label) => label.length > 0 && label.length <= 63 && /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Root admins, lowercased the way they are compared. At least one, in every
|
|
74
|
+
* mode: they are the only way into a fresh instance, and the only members
|
|
75
|
+
* nobody can remove.
|
|
76
|
+
*/
|
|
77
|
+
function checkRootAdmins(emails) {
|
|
78
|
+
const rootAdmins = [...new Set(emails.map((entry) => entry.trim()).filter((entry) => entry.length > 0))];
|
|
79
|
+
if (rootAdmins.length === 0) throw new Error("rootAdmins must name at least one email, or nobody can manage coffre");
|
|
80
|
+
const invalid = rootAdmins.find((entry) => !isHumanEmail(entry));
|
|
81
|
+
if (invalid) throw new Error(`rootAdmins must be human email identities; invalid: ${invalid}`);
|
|
82
|
+
return rootAdmins.map((entry) => entry.toLowerCase());
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/store.ts
|
|
86
|
+
/**
|
|
87
|
+
* On Postgres, fail a lock wait in this transaction after `ms`, rather than
|
|
88
|
+
* wait for ever behind a transaction that never ends.
|
|
89
|
+
*/
|
|
90
|
+
async function boundLockWaits(tx, ms) {
|
|
91
|
+
if (engineOf(tx) === "postgres") await tx.execute(sql.raw(`SET LOCAL lock_timeout = ${Math.trunc(ms)}`));
|
|
92
|
+
}
|
|
93
|
+
/** The database's clock, now. */
|
|
94
|
+
async function now(db) {
|
|
95
|
+
const { auditChainHead } = tablesOf(db);
|
|
96
|
+
const [{ at }] = await db.select({ at: clockMillis(db) }).from(auditChainHead);
|
|
97
|
+
return at;
|
|
98
|
+
}
|
|
99
|
+
function memberColumns(db) {
|
|
100
|
+
const { vaultMembers } = tablesOf(db);
|
|
101
|
+
return {
|
|
102
|
+
principal: vaultMembers.principal,
|
|
103
|
+
status: vaultMembers.status,
|
|
104
|
+
owner: vaultMembers.owner,
|
|
105
|
+
generation: vaultMembers.generation,
|
|
106
|
+
createdAt: vaultMembers.createdAt,
|
|
107
|
+
createdBy: vaultMembers.createdBy,
|
|
108
|
+
statusChangedAt: vaultMembers.statusChangedAt,
|
|
109
|
+
statusChangedBy: vaultMembers.statusChangedBy,
|
|
110
|
+
accessSeq: vaultMembers.accessSeq,
|
|
111
|
+
mac: vaultMembers.mac
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
async function member(db, principal) {
|
|
115
|
+
const { vaultMembers } = tablesOf(db);
|
|
116
|
+
const [row] = await db.select(memberColumns(db)).from(vaultMembers).where(eq(vaultMembers.principal, principal));
|
|
117
|
+
return row;
|
|
118
|
+
}
|
|
119
|
+
async function allMembers(db) {
|
|
120
|
+
const { vaultMembers } = tablesOf(db);
|
|
121
|
+
return await db.select(memberColumns(db)).from(vaultMembers).orderBy(asc(vaultMembers.principal));
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Lock these members' rows for the rest of the transaction, in one order
|
|
125
|
+
* everywhere, and read them: those that have none are not in the map. Every
|
|
126
|
+
* decision about a member takes its row first, so two of them, from any
|
|
127
|
+
* process, never interleave.
|
|
128
|
+
*/
|
|
129
|
+
async function lockMembers(tx, principals) {
|
|
130
|
+
const { vaultMembers } = tablesOf(tx);
|
|
131
|
+
const rows = await forUpdate(tx, tx.select(memberColumns(tx)).from(vaultMembers).where(inArray(vaultMembers.principal, [...new Set(principals)])).orderBy(asc(vaultMembers.principal)), "no key update");
|
|
132
|
+
return new Map(rows.map((row) => [row.principal, row]));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Add `row`. A principal has one row: when two decisions admit the same
|
|
136
|
+
* one at once, the second fails here, and rolls back with its entries.
|
|
137
|
+
*/
|
|
138
|
+
async function insertMember(tx, row) {
|
|
139
|
+
const { vaultMembers } = tablesOf(tx);
|
|
140
|
+
await tx.insert(vaultMembers).values(row);
|
|
141
|
+
}
|
|
142
|
+
async function updateMember(tx, principal, change) {
|
|
143
|
+
const { vaultMembers } = tablesOf(tx);
|
|
144
|
+
await tx.update(vaultMembers).set(change).where(eq(vaultMembers.principal, principal));
|
|
145
|
+
}
|
|
146
|
+
/** Grants, lapsed ones too, each with its project, which an environment's grant finds through `environments`. */
|
|
147
|
+
async function grants(db, principal) {
|
|
148
|
+
const { vaultGrants, environments } = tablesOf(db);
|
|
149
|
+
return db.select({
|
|
150
|
+
principal: vaultGrants.principal,
|
|
151
|
+
projectId: sql`coalesce(${vaultGrants.projectId}, ${environments.projectId})`,
|
|
152
|
+
environmentId: vaultGrants.environmentId,
|
|
153
|
+
role: vaultGrants.role,
|
|
154
|
+
expiresAt: vaultGrants.expiresAt,
|
|
155
|
+
grantedAt: vaultGrants.grantedAt,
|
|
156
|
+
grantedBy: vaultGrants.grantedBy
|
|
157
|
+
}).from(vaultGrants).leftJoin(environments, eq(environments.id, vaultGrants.environmentId)).where(principal === void 0 ? void 0 : eq(vaultGrants.principal, principal));
|
|
158
|
+
}
|
|
159
|
+
/** A grant names its environment, or its project when it has none: exactly one. */
|
|
160
|
+
function at(db, place) {
|
|
161
|
+
const { vaultGrants } = tablesOf(db);
|
|
162
|
+
return place.environmentId === null ? and(eq(vaultGrants.projectId, place.projectId), isNull(vaultGrants.environmentId)) : eq(vaultGrants.environmentId, place.environmentId);
|
|
163
|
+
}
|
|
164
|
+
async function insertGrant(tx, grant) {
|
|
165
|
+
const { vaultGrants } = tablesOf(tx);
|
|
166
|
+
await tx.insert(vaultGrants).values({
|
|
167
|
+
...grant,
|
|
168
|
+
projectId: grant.environmentId === null ? grant.projectId : null
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
async function deleteGrant(tx, principal, place) {
|
|
172
|
+
const { vaultGrants } = tablesOf(tx);
|
|
173
|
+
await tx.delete(vaultGrants).where(and(eq(vaultGrants.principal, principal), at(tx, place)));
|
|
174
|
+
}
|
|
175
|
+
async function deleteGrants(tx, principal) {
|
|
176
|
+
const { vaultGrants } = tablesOf(tx);
|
|
177
|
+
await tx.delete(vaultGrants).where(eq(vaultGrants.principal, principal));
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Up to `limit` of the newest stored data keys wrapped under one KEK, each
|
|
181
|
+
* with the secret it opens for: what proves a KEK is the one the data was
|
|
182
|
+
* wrapped with, before the vault records a check value for it.
|
|
183
|
+
*/
|
|
184
|
+
async function wrappedUnder(db, provider, keyId, limit) {
|
|
185
|
+
const { secretVersions, secrets } = tablesOf(db);
|
|
186
|
+
return (await db.select({
|
|
187
|
+
projectId: secrets.projectId,
|
|
188
|
+
environmentId: secrets.environmentId,
|
|
189
|
+
secretId: secretVersions.secretId,
|
|
190
|
+
version: secretVersions.version,
|
|
191
|
+
kekProvider: secretVersions.kekProvider,
|
|
192
|
+
kekId: secretVersions.kekId,
|
|
193
|
+
kekVersion: secretVersions.kekVersion,
|
|
194
|
+
bytes: secretVersions.wrappedDek
|
|
195
|
+
}).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).where(and(eq(secretVersions.kekProvider, provider), eq(secretVersions.kekId, keyId))).orderBy(desc(secretVersions.createdAt)).limit(limit)).map((row) => ({
|
|
196
|
+
...row,
|
|
197
|
+
bytes: Buffer.from(row.bytes)
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
/** Of these projects and environments, the ones that exist, each environment with its project. */
|
|
201
|
+
async function places(db, projectIds, environmentIds) {
|
|
202
|
+
const { projects, environments } = tablesOf(db);
|
|
203
|
+
const [foundProjects, foundEnvironments] = await Promise.all([projectIds.length === 0 ? [] : db.select({ id: projects.id }).from(projects).where(inArray(projects.id, [...projectIds])), environmentIds.length === 0 ? [] : db.select({
|
|
204
|
+
id: environments.id,
|
|
205
|
+
projectId: environments.projectId
|
|
206
|
+
}).from(environments).where(inArray(environments.id, [...environmentIds]))]);
|
|
207
|
+
return {
|
|
208
|
+
projects: new Set(foundProjects.map((row) => row.id)),
|
|
209
|
+
environments: new Map(foundEnvironments.map((row) => [row.id, row.projectId]))
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** The action of an entry that releases a key, which the bulk limit counts. */
|
|
213
|
+
const RELEASE = "secret.read";
|
|
214
|
+
/** How many keys the vault has released to `principal` since `after`. */
|
|
215
|
+
async function releasesSince(db, principal, after) {
|
|
216
|
+
const { auditLog } = tablesOf(db);
|
|
217
|
+
const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.actor, principal), eq(auditLog.action, RELEASE), eq(auditLog.decision, "allow"), gt(auditLog.occurredAt, after)));
|
|
218
|
+
return Number(n);
|
|
219
|
+
}
|
|
220
|
+
function entryColumns(db) {
|
|
221
|
+
const { auditLog } = tablesOf(db);
|
|
222
|
+
return {
|
|
223
|
+
seq: auditLog.seq,
|
|
224
|
+
author: auditLog.author,
|
|
225
|
+
keyId: auditLog.keyId,
|
|
226
|
+
occurredAt: auditLog.occurredAt,
|
|
227
|
+
actor: auditLog.actor,
|
|
228
|
+
action: auditLog.action,
|
|
229
|
+
decision: auditLog.decision,
|
|
230
|
+
code: auditLog.code,
|
|
231
|
+
subjectPrincipal: auditLog.subjectPrincipal,
|
|
232
|
+
projectId: auditLog.projectId,
|
|
233
|
+
environmentId: auditLog.environmentId,
|
|
234
|
+
secretId: auditLog.secretId,
|
|
235
|
+
secretVersionId: auditLog.secretVersionId,
|
|
236
|
+
operationId: auditLog.operationId,
|
|
237
|
+
requestId: auditLog.requestId,
|
|
238
|
+
sourceIp: auditLog.sourceIp,
|
|
239
|
+
relatedSeq: auditLog.relatedSeq,
|
|
240
|
+
metadata: auditLog.metadata,
|
|
241
|
+
prevHash: auditLog.prevHash,
|
|
242
|
+
mac: auditLog.mac,
|
|
243
|
+
hash: auditLog.hash
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** Rows of the log as the chain's codec reads them: `author` and `decision` are checked by the table. */
|
|
247
|
+
function stored(rows) {
|
|
248
|
+
return rows;
|
|
249
|
+
}
|
|
250
|
+
/** Up to `limit` entries of either author from `fromSeq`, oldest first: the chain as it runs. */
|
|
251
|
+
async function entriesFrom(db, fromSeq, limit) {
|
|
252
|
+
const { auditLog } = tablesOf(db);
|
|
253
|
+
return stored(await db.select(entryColumns(db)).from(auditLog).where(gte(auditLog.seq, fromSeq)).orderBy(asc(auditLog.seq)).limit(limit));
|
|
254
|
+
}
|
|
255
|
+
/** The entry at `seq`'s hash, or undefined when there is none. */
|
|
256
|
+
async function hashAt(db, seq) {
|
|
257
|
+
const { auditLog } = tablesOf(db);
|
|
258
|
+
const [row] = await db.select({ hash: auditLog.hash }).from(auditLog).where(eq(auditLog.seq, seq));
|
|
259
|
+
return row?.hash;
|
|
260
|
+
}
|
|
261
|
+
/** The vault's newest entry of any of these actions, allowed, or undefined. */
|
|
262
|
+
async function latestVaultEntry(db, actions) {
|
|
263
|
+
const { auditLog } = tablesOf(db);
|
|
264
|
+
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);
|
|
265
|
+
return row === void 0 ? void 0 : stored([row])[0];
|
|
266
|
+
}
|
|
267
|
+
/** Up to `limit` of the vault's allowed access entries about `principal`, newest first. */
|
|
268
|
+
async function accessEntriesAbout(db, principal, limit) {
|
|
269
|
+
const { auditLog } = tablesOf(db);
|
|
270
|
+
return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.subjectPrincipal, principal), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"))).orderBy(desc(auditLog.seq)).limit(limit));
|
|
271
|
+
}
|
|
272
|
+
/** The vault's newest allowed access entry about each member who has one. */
|
|
273
|
+
async function newestAccessEntries(db) {
|
|
274
|
+
const { auditLog } = tablesOf(db);
|
|
275
|
+
const newest = db.select({ seq: sql`max(${auditLog.seq})` }).from(auditLog).where(and(eq(auditLog.author, "vault"), inArray(auditLog.action, [...ACCESS_ACTIONS]), eq(auditLog.decision, "allow"))).groupBy(auditLog.subjectPrincipal);
|
|
276
|
+
const rows = stored(await db.select(entryColumns(db)).from(auditLog).where(inArray(auditLog.seq, newest)));
|
|
277
|
+
return new Map(rows.flatMap((row) => row.subjectPrincipal === null ? [] : [[row.subjectPrincipal, row]]));
|
|
278
|
+
}
|
|
279
|
+
/** Up to `limit` of the vault's allowed entries of these actions after `afterSeq`, oldest first. */
|
|
280
|
+
async function vaultEntriesOf(db, actions, afterSeq, limit) {
|
|
281
|
+
const { auditLog } = tablesOf(db);
|
|
282
|
+
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));
|
|
283
|
+
}
|
|
284
|
+
async function versions(db, ids) {
|
|
285
|
+
if (ids.length === 0) return [];
|
|
286
|
+
const { secretVersions, secrets, environments, projects } = tablesOf(db);
|
|
287
|
+
return (await db.select({
|
|
288
|
+
id: secretVersions.id,
|
|
289
|
+
version: secretVersions.version,
|
|
290
|
+
secretId: secrets.id,
|
|
291
|
+
projectId: secrets.projectId,
|
|
292
|
+
environmentId: secrets.environmentId,
|
|
293
|
+
project: projects.slug,
|
|
294
|
+
environment: environments.slug,
|
|
295
|
+
key: secrets.key,
|
|
296
|
+
kekProvider: secretVersions.kekProvider,
|
|
297
|
+
kekId: secretVersions.kekId,
|
|
298
|
+
kekVersion: secretVersions.kekVersion,
|
|
299
|
+
wrappedDek: secretVersions.wrappedDek
|
|
300
|
+
}).from(secretVersions).innerJoin(secrets, eq(secrets.id, secretVersions.secretId)).innerJoin(environments, eq(environments.id, secrets.environmentId)).innerJoin(projects, eq(projects.id, secrets.projectId)).where(inArray(secretVersions.id, [...new Set(ids)]))).map((row) => ({
|
|
301
|
+
id: row.id,
|
|
302
|
+
secret: {
|
|
303
|
+
projectId: row.projectId,
|
|
304
|
+
environmentId: row.environmentId,
|
|
305
|
+
secretId: row.secretId,
|
|
306
|
+
version: row.version,
|
|
307
|
+
path: `${row.project}/${row.environment}/${row.key}`
|
|
308
|
+
},
|
|
309
|
+
wrapped: {
|
|
310
|
+
kekProvider: row.kekProvider,
|
|
311
|
+
kekId: row.kekId,
|
|
312
|
+
kekVersion: row.kekVersion,
|
|
313
|
+
bytes: row.wrappedDek.toString("base64")
|
|
314
|
+
}
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
//#endregion
|
|
318
|
+
//#region src/log.ts
|
|
319
|
+
/**
|
|
320
|
+
* The vault's half of the one audit log: its entries are the ones it
|
|
321
|
+
* writes, `author = 'vault'`, each under its MAC, in the chain the app's
|
|
322
|
+
* entries share (@coffre/core/audit says what an entry covers). The app
|
|
323
|
+
* checks its own entries by its key; this checks the vault's by the vault's,
|
|
324
|
+
* and the app's only for their place in the chain.
|
|
325
|
+
*/
|
|
326
|
+
/**
|
|
327
|
+
* The vault's log key, derived from its signing key, so that it is one more
|
|
328
|
+
* secret to hold, not one more to keep. Whoever can write the database but
|
|
329
|
+
* does not hold the vault's configuration cannot write an entry it accepts.
|
|
330
|
+
*/
|
|
331
|
+
function vaultLogKey(signingKey) {
|
|
332
|
+
return deriveLogKey("vault", signingKey);
|
|
333
|
+
}
|
|
334
|
+
/** Before the first entry: nothing verified yet. */
|
|
335
|
+
const UNVERIFIED = {
|
|
336
|
+
nextSeq: 0n,
|
|
337
|
+
hash: GENESIS_HASH,
|
|
338
|
+
vaultEntries: 0
|
|
339
|
+
};
|
|
340
|
+
/** The further of two anchors, when two calls verified at once. */
|
|
341
|
+
function further(a, b) {
|
|
342
|
+
return b.nextSeq > a.nextSeq ? b : a;
|
|
343
|
+
}
|
|
344
|
+
const VERIFY_BATCH = 1e3;
|
|
345
|
+
/**
|
|
346
|
+
* Check the chain, a batch in memory at a time, and return where it is now
|
|
347
|
+
* verified to. Three parts:
|
|
348
|
+
*
|
|
349
|
+
* - `shown`, the page a reader is looking at: each entry against its own
|
|
350
|
+
* hash and MAC;
|
|
351
|
+
* - `anchor`, the head at the last check: still there, unchanged. A rewrite
|
|
352
|
+
* of anything before it, chained again to hide, changes its hash;
|
|
353
|
+
* - every entry after the anchor, or from the first.
|
|
354
|
+
*
|
|
355
|
+
* So a view rehashes only what is new since the last one. What it leaves
|
|
356
|
+
* out is an entry before the anchor edited in place, not chained again, and
|
|
357
|
+
* not on the page: a full check, which starts from `UNVERIFIED`, finds that,
|
|
358
|
+
* as does the first view after a start, which has no anchor.
|
|
359
|
+
*/
|
|
360
|
+
async function verifyChain(db, key, shown, anchor) {
|
|
361
|
+
const broken = (failedAtSeq, reason) => ({
|
|
362
|
+
verification: {
|
|
363
|
+
ok: false,
|
|
364
|
+
failedAtSeq: Number(failedAtSeq),
|
|
365
|
+
reason
|
|
366
|
+
},
|
|
367
|
+
anchor
|
|
368
|
+
});
|
|
369
|
+
const keys = {
|
|
370
|
+
keys: [key],
|
|
371
|
+
chainOnly: ["app"]
|
|
372
|
+
};
|
|
373
|
+
for (const row of [...shown].sort((a, b) => a.seq < b.seq ? -1 : 1)) {
|
|
374
|
+
const result = verifyEntries([row], {
|
|
375
|
+
...keys,
|
|
376
|
+
startSeq: row.seq,
|
|
377
|
+
startPrevHash: row.prevHash
|
|
378
|
+
});
|
|
379
|
+
if (!result.ok) return broken(result.failedAtSeq, result.reason);
|
|
380
|
+
}
|
|
381
|
+
if (anchor.nextSeq > 0n && !(await hashAt(db, anchor.nextSeq - 1n))?.equals(anchor.hash)) return broken(anchor.nextSeq - 1n, "changed since the vault last verified it");
|
|
382
|
+
let verified = anchor;
|
|
383
|
+
for (;;) {
|
|
384
|
+
const batch = await entriesFrom(db, verified.nextSeq, VERIFY_BATCH);
|
|
385
|
+
if (batch.length === 0) break;
|
|
386
|
+
const result = verifyEntries(batch, {
|
|
387
|
+
...keys,
|
|
388
|
+
startSeq: verified.nextSeq,
|
|
389
|
+
startPrevHash: verified.hash
|
|
390
|
+
});
|
|
391
|
+
if (!result.ok) return broken(result.failedAtSeq, result.reason);
|
|
392
|
+
verified = {
|
|
393
|
+
nextSeq: result.nextSeq,
|
|
394
|
+
hash: result.head,
|
|
395
|
+
vaultEntries: verified.vaultEntries + result.authenticated
|
|
396
|
+
};
|
|
397
|
+
if (batch.length < 1e3) break;
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
verification: {
|
|
401
|
+
ok: true,
|
|
402
|
+
entries: verified.vaultEntries
|
|
403
|
+
},
|
|
404
|
+
anchor: verified
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Whether the log still holds `head` where it was: not rewritten, nor cut
|
|
409
|
+
* back before it. A head of 64 zeros is before the first entry, which any
|
|
410
|
+
* log holds.
|
|
411
|
+
*/
|
|
412
|
+
async function carries(db, head) {
|
|
413
|
+
if (head.hash === GENESIS_HASH.toString("hex")) return true;
|
|
414
|
+
return (await hashAt(db, BigInt(head.seq)))?.toString("hex") === head.hash;
|
|
415
|
+
}
|
|
416
|
+
//#endregion
|
|
417
|
+
//#region src/accounting.ts
|
|
418
|
+
const KEY_ACTIONS = /* @__PURE__ */ new Set([
|
|
419
|
+
"secret.read",
|
|
420
|
+
"key.wrap",
|
|
421
|
+
"key.rewrap"
|
|
422
|
+
]);
|
|
423
|
+
/** The chain can hold while a process died between an intent and its outcomes. */
|
|
424
|
+
async function verifyAccounting(db, at) {
|
|
425
|
+
const intents = /* @__PURE__ */ new Map();
|
|
426
|
+
const ids = /* @__PURE__ */ new Set();
|
|
427
|
+
const broken = (entry, reason) => ({
|
|
428
|
+
ok: false,
|
|
429
|
+
failedAtSeq: Number(entry.seq),
|
|
430
|
+
reason
|
|
431
|
+
});
|
|
432
|
+
let next = 0n;
|
|
433
|
+
for (;;) {
|
|
434
|
+
const batch = await entriesFrom(db, next, VERIFY_BATCH);
|
|
435
|
+
if (batch.length === 0) break;
|
|
436
|
+
for (const entry of batch) {
|
|
437
|
+
if (entry.author !== "vault") continue;
|
|
438
|
+
if (entry.action === "key.intent") {
|
|
439
|
+
const parsed = parseIntent(entry);
|
|
440
|
+
if (typeof parsed === "string") return broken(entry, parsed);
|
|
441
|
+
if (ids.has(parsed.intentId)) return broken(entry, "key intent repeats an operation identity");
|
|
442
|
+
ids.add(parsed.intentId);
|
|
443
|
+
if (parsed.keys.length > 0) intents.set(entry.seq, {
|
|
444
|
+
...parsed,
|
|
445
|
+
entry,
|
|
446
|
+
seen: /* @__PURE__ */ new Set()
|
|
447
|
+
});
|
|
448
|
+
} else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
|
|
449
|
+
const parsed = parseOutcome(entry);
|
|
450
|
+
if (typeof parsed === "string") return broken(entry, parsed);
|
|
451
|
+
const intent = intents.get(parsed.relatedSeq);
|
|
452
|
+
const key = intent?.keys[parsed.item];
|
|
453
|
+
if (intent === void 0 || key === void 0 || intent.seen.has(parsed.item)) return broken(entry, "key outcome does not identify one item of its intent");
|
|
454
|
+
if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) return broken(entry, "key outcome belongs to another operation");
|
|
455
|
+
if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) return broken(entry, "key outcome does not match its intended secret");
|
|
456
|
+
intent.seen.add(parsed.item);
|
|
457
|
+
if (intent.seen.size === intent.keys.length) intents.delete(intent.entry.seq);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
next = batch[batch.length - 1].seq + 1n;
|
|
461
|
+
if (batch.length < 1e3) break;
|
|
462
|
+
}
|
|
463
|
+
const overdue = [...intents.values()].filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
|
|
464
|
+
if (overdue.length === 0) return {
|
|
465
|
+
ok: true,
|
|
466
|
+
pending: intents.size
|
|
467
|
+
};
|
|
468
|
+
const reason = overdue.map((intent) => {
|
|
469
|
+
const missing = intent.keys.length - intent.seen.size;
|
|
470
|
+
return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
|
|
471
|
+
}).join("; ");
|
|
472
|
+
return broken(overdue[0].entry, reason);
|
|
473
|
+
}
|
|
474
|
+
function parseIntent(entry) {
|
|
475
|
+
const detail = payload(entry.metadata);
|
|
476
|
+
if (detail === null) return "key intent has no valid accounting payload";
|
|
477
|
+
const { intent, operation, expiresAt } = detail;
|
|
478
|
+
if (typeof intent !== "string") return "key intent has no operation identity";
|
|
479
|
+
if (typeof operation !== "string" || !KEY_ACTIONS.has(operation)) return "key intent has an invalid operation";
|
|
480
|
+
if (typeof expiresAt !== "number" || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) return "key intent has no valid deadline";
|
|
481
|
+
if (!Array.isArray(detail.keys)) return "key intent has no valid item list";
|
|
482
|
+
const keys = [];
|
|
483
|
+
for (const [index, value] of detail.keys.entries()) {
|
|
484
|
+
const key = record(value);
|
|
485
|
+
if (key === null || key.item !== index) return "key intent items are not numbered in order";
|
|
486
|
+
const { secretId, version, subject } = key;
|
|
487
|
+
if (typeof secretId !== "string" || typeof subject !== "string") return "key intent item has no secret identity";
|
|
488
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key intent item has no valid version";
|
|
489
|
+
keys.push({
|
|
490
|
+
item: index,
|
|
491
|
+
secretId,
|
|
492
|
+
version,
|
|
493
|
+
subject
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
intentId: intent,
|
|
498
|
+
operation,
|
|
499
|
+
expiresAt,
|
|
500
|
+
keys
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function parseOutcome(entry) {
|
|
504
|
+
const detail = payload(entry.metadata);
|
|
505
|
+
if (detail === null) return "key outcome has no valid accounting payload";
|
|
506
|
+
const { intent, item, version, subject } = detail;
|
|
507
|
+
if (typeof intent !== "string" || entry.relatedSeq === null) return "key outcome has no intent identity";
|
|
508
|
+
if (typeof item !== "number" || !Number.isSafeInteger(item) || item < 0) return "key outcome has no valid item number";
|
|
509
|
+
const secretId = entry.secretId ?? detail.secretId;
|
|
510
|
+
if (typeof secretId !== "string" || typeof subject !== "string") return "key outcome has no secret identity";
|
|
511
|
+
if (typeof version !== "number" || !Number.isSafeInteger(version) || version < 1) return "key outcome has no valid version";
|
|
512
|
+
return {
|
|
513
|
+
intentId: intent,
|
|
514
|
+
relatedSeq: entry.relatedSeq,
|
|
515
|
+
item,
|
|
516
|
+
secretId,
|
|
517
|
+
version,
|
|
518
|
+
subject
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function payload(text) {
|
|
522
|
+
try {
|
|
523
|
+
return record(JSON.parse(text));
|
|
524
|
+
} catch {
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function record(value) {
|
|
529
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
530
|
+
}
|
|
531
|
+
//#endregion
|
|
532
|
+
//#region src/checkpoint.ts
|
|
533
|
+
const PKCS8_ED25519 = [
|
|
534
|
+
48,
|
|
535
|
+
46,
|
|
536
|
+
2,
|
|
537
|
+
1,
|
|
538
|
+
0,
|
|
539
|
+
48,
|
|
540
|
+
5,
|
|
541
|
+
6,
|
|
542
|
+
3,
|
|
543
|
+
43,
|
|
544
|
+
101,
|
|
545
|
+
112,
|
|
546
|
+
4,
|
|
547
|
+
34,
|
|
548
|
+
4,
|
|
549
|
+
32
|
|
550
|
+
];
|
|
551
|
+
/** An Ed25519 signer from a 32-byte seed, over WebCrypto so it runs in Node and in workerd alike. */
|
|
552
|
+
async function signer(seed) {
|
|
553
|
+
const pkcs8 = new Uint8Array([...PKCS8_ED25519, ...seed]);
|
|
554
|
+
const privateKey = await crypto.subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
|
|
555
|
+
const { x } = await crypto.subtle.exportKey("jwk", privateKey);
|
|
556
|
+
const publicKey = Buffer.from(x, "base64url");
|
|
557
|
+
const digest = Buffer.from(await crypto.subtle.digest("SHA-256", publicKey));
|
|
558
|
+
return {
|
|
559
|
+
publicKey: publicKey.toString("base64"),
|
|
560
|
+
keyId: digest.toString("hex").slice(0, 16),
|
|
561
|
+
sign: async (message) => Buffer.from(await crypto.subtle.sign("Ed25519", privateKey, message)).toString("base64")
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
//#endregion
|
|
565
|
+
//#region src/replay.ts
|
|
566
|
+
const BATCH = 1e3;
|
|
567
|
+
/**
|
|
568
|
+
* Why the members and grants in the database do not follow from the
|
|
569
|
+
* vault's entries, or null when they do; `describeAccessFault` words it.
|
|
570
|
+
* Every change to either is logged in the transaction that makes it, with
|
|
571
|
+
* the row's times taken from its entry, so replaying the allowed ones from
|
|
572
|
+
* the first gives the tables back, and a row the log does not explain was
|
|
573
|
+
* written around the vault: a grant inserted with the database's own login,
|
|
574
|
+
* a removal undone.
|
|
575
|
+
*
|
|
576
|
+
* Call it after the chain is verified, in the same snapshot, so every entry
|
|
577
|
+
* it replays carries the vault's MAC.
|
|
578
|
+
*
|
|
579
|
+
* Grants are compared as they are live at `at`. Clearing one that has
|
|
580
|
+
* lapsed changes nothing anyone holds, so it is not logged.
|
|
581
|
+
*/
|
|
582
|
+
async function replay(db, at) {
|
|
583
|
+
const state = {
|
|
584
|
+
members: /* @__PURE__ */ new Map(),
|
|
585
|
+
held: /* @__PURE__ */ new Map()
|
|
586
|
+
};
|
|
587
|
+
for (let after = -1n;;) {
|
|
588
|
+
const batch = await vaultEntriesOf(db, ACCESS_ACTIONS, after, BATCH);
|
|
589
|
+
for (const row of batch) {
|
|
590
|
+
const fault = apply(state, row);
|
|
591
|
+
if (fault !== null) return fault;
|
|
592
|
+
}
|
|
593
|
+
if (batch.length < BATCH) break;
|
|
594
|
+
after = batch[batch.length - 1].seq;
|
|
595
|
+
}
|
|
596
|
+
const { members, held } = state;
|
|
597
|
+
const stored = new Map((await allMembers(db)).map((member) => [member.principal, member]));
|
|
598
|
+
for (const principal of [.../* @__PURE__ */ new Set([...stored.keys(), ...members.keys()])].sort()) {
|
|
599
|
+
const [inStore, inLog] = [stored.get(principal), members.get(principal)];
|
|
600
|
+
if (inLog === void 0) return {
|
|
601
|
+
kind: "unlogged-member",
|
|
602
|
+
principal
|
|
603
|
+
};
|
|
604
|
+
if (inStore === void 0) return {
|
|
605
|
+
kind: "missing-member",
|
|
606
|
+
principal
|
|
607
|
+
};
|
|
608
|
+
const fields = MEMBER_FIELDS.filter(([field]) => inStore[field] !== inLog[field]).map(([, column]) => column);
|
|
609
|
+
if (fields.length > 0) return {
|
|
610
|
+
kind: "member-differs",
|
|
611
|
+
principal,
|
|
612
|
+
fields
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const live = (grant) => grant.expiresAt === null || grant.expiresAt > at;
|
|
616
|
+
const logged = new Set([...held.values()].flatMap((grantsOf) => [...grantsOf.values()]).filter(live).map(grantKey));
|
|
617
|
+
const inStore = new Set((await grants(db)).filter(live).map(grantKey));
|
|
618
|
+
const extra = [...inStore].sort().find((grant) => !logged.has(grant));
|
|
619
|
+
if (extra !== void 0) return {
|
|
620
|
+
kind: "unlogged-grant",
|
|
621
|
+
grant: faultGrant(extra)
|
|
622
|
+
};
|
|
623
|
+
const missing = [...logged].sort().find((grant) => !inStore.has(grant));
|
|
624
|
+
if (missing !== void 0) return {
|
|
625
|
+
kind: "missing-grant",
|
|
626
|
+
grant: faultGrant(missing)
|
|
627
|
+
};
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
/** One access entry, authenticated, applied to `state`; a fault when it changes someone never admitted. */
|
|
631
|
+
function apply(state, row) {
|
|
632
|
+
const principal = row.subjectPrincipal;
|
|
633
|
+
const detail = JSON.parse(row.metadata);
|
|
634
|
+
const place = {
|
|
635
|
+
projectId: row.projectId,
|
|
636
|
+
environmentId: row.environmentId
|
|
637
|
+
};
|
|
638
|
+
const grantsOf = state.held.get(principal) ?? /* @__PURE__ */ new Map();
|
|
639
|
+
state.held.set(principal, grantsOf);
|
|
640
|
+
const before = state.members.get(principal);
|
|
641
|
+
const changed = {
|
|
642
|
+
statusChangedAt: row.occurredAt,
|
|
643
|
+
statusChangedBy: row.actor
|
|
644
|
+
};
|
|
645
|
+
switch (row.action) {
|
|
646
|
+
case "member.add":
|
|
647
|
+
case "member.restore":
|
|
648
|
+
state.members.set(principal, {
|
|
649
|
+
principal,
|
|
650
|
+
status: "active",
|
|
651
|
+
owner: detail.owner === true,
|
|
652
|
+
generation: before?.generation ?? 0,
|
|
653
|
+
createdAt: before?.createdAt ?? row.occurredAt,
|
|
654
|
+
createdBy: before?.createdBy ?? row.actor,
|
|
655
|
+
accessSeq: row.seq,
|
|
656
|
+
...changed
|
|
657
|
+
});
|
|
658
|
+
return null;
|
|
659
|
+
case "member.owner":
|
|
660
|
+
if (before === void 0) return {
|
|
661
|
+
kind: "unadmitted-change",
|
|
662
|
+
seq: Number(row.seq),
|
|
663
|
+
principal
|
|
664
|
+
};
|
|
665
|
+
state.members.set(principal, {
|
|
666
|
+
...before,
|
|
667
|
+
owner: detail.owner === true,
|
|
668
|
+
accessSeq: row.seq
|
|
669
|
+
});
|
|
670
|
+
return null;
|
|
671
|
+
case "member.remove": {
|
|
672
|
+
if (before === void 0) return {
|
|
673
|
+
kind: "unadmitted-change",
|
|
674
|
+
seq: Number(row.seq),
|
|
675
|
+
principal
|
|
676
|
+
};
|
|
677
|
+
const generation = typeof detail.generation === "number" ? detail.generation : before.generation + 1;
|
|
678
|
+
state.members.set(principal, {
|
|
679
|
+
...before,
|
|
680
|
+
status: "removed",
|
|
681
|
+
owner: false,
|
|
682
|
+
generation,
|
|
683
|
+
accessSeq: row.seq,
|
|
684
|
+
...changed
|
|
685
|
+
});
|
|
686
|
+
grantsOf.clear();
|
|
687
|
+
return null;
|
|
688
|
+
}
|
|
689
|
+
case "access.grant":
|
|
690
|
+
grantsOf.set(placeKey(place), {
|
|
691
|
+
principal,
|
|
692
|
+
...place,
|
|
693
|
+
role: detail.role,
|
|
694
|
+
expiresAt: detail.expiresAt === null ? null : Date.parse(detail.expiresAt),
|
|
695
|
+
grantedAt: row.occurredAt,
|
|
696
|
+
grantedBy: row.actor
|
|
697
|
+
});
|
|
698
|
+
if (before !== void 0) state.members.set(principal, {
|
|
699
|
+
...before,
|
|
700
|
+
accessSeq: row.seq
|
|
701
|
+
});
|
|
702
|
+
return null;
|
|
703
|
+
case "access.revoke":
|
|
704
|
+
grantsOf.delete(placeKey(place));
|
|
705
|
+
if (before !== void 0) state.members.set(principal, {
|
|
706
|
+
...before,
|
|
707
|
+
accessSeq: row.seq
|
|
708
|
+
});
|
|
709
|
+
return null;
|
|
710
|
+
}
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
/** A member's fields, and the columns a fault names them by. */
|
|
714
|
+
const MEMBER_FIELDS = [
|
|
715
|
+
["status", "status"],
|
|
716
|
+
["owner", "owner"],
|
|
717
|
+
["generation", "generation"],
|
|
718
|
+
["createdAt", "created_at"],
|
|
719
|
+
["createdBy", "created_by"],
|
|
720
|
+
["statusChangedAt", "status_changed_at"],
|
|
721
|
+
["statusChangedBy", "status_changed_by"],
|
|
722
|
+
["accessSeq", "access_seq"]
|
|
723
|
+
];
|
|
724
|
+
/** A grant's place: its environment, or its project when it has none. */
|
|
725
|
+
function placeKey(place) {
|
|
726
|
+
return place.environmentId ?? place.projectId;
|
|
727
|
+
}
|
|
728
|
+
function grantKey(grant) {
|
|
729
|
+
return JSON.stringify([
|
|
730
|
+
grant.principal,
|
|
731
|
+
grant.projectId,
|
|
732
|
+
grant.environmentId,
|
|
733
|
+
grant.role,
|
|
734
|
+
grant.expiresAt,
|
|
735
|
+
grant.grantedAt,
|
|
736
|
+
grant.grantedBy
|
|
737
|
+
]);
|
|
738
|
+
}
|
|
739
|
+
function faultGrant(key) {
|
|
740
|
+
const [principal, projectId, environmentId, role] = JSON.parse(key);
|
|
741
|
+
return {
|
|
742
|
+
principal,
|
|
743
|
+
projectId,
|
|
744
|
+
environmentId,
|
|
745
|
+
role
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
//#endregion
|
|
749
|
+
//#region src/rows.ts
|
|
750
|
+
/**
|
|
751
|
+
* The MAC over each member's row and grants: what makes a row written
|
|
752
|
+
* around the vault, by anyone who can write the database but does not hold
|
|
753
|
+
* its signing key, one the vault refuses rather than trusts.
|
|
754
|
+
*
|
|
755
|
+
* One MAC per member, over the row and the member's grants as a sorted set,
|
|
756
|
+
* lapsed ones included, rather than one per row: a grant deleted fails it as
|
|
757
|
+
* surely as one added or edited. `access_seq` is in it too, so a genuine row
|
|
758
|
+
* put back from before a later change still names the older entry, which the
|
|
759
|
+
* log has moved past (vault.ts, `#integrity`).
|
|
760
|
+
*
|
|
761
|
+
* A change to what is covered is a new version of the tuple, never an edit.
|
|
762
|
+
*/
|
|
763
|
+
/** The key member rows are sealed under, derived from the signing key: one more secret to hold, not one more to keep. */
|
|
764
|
+
function rowKey(signingKey) {
|
|
765
|
+
return Buffer.from(hkdfSync("sha256", signingKey, /* @__PURE__ */ new Uint8Array(0), "coffre.vault.rows.v1", 32));
|
|
766
|
+
}
|
|
767
|
+
/** A member's grants as the MAC covers them: each one's place, role, end and grant, as a sorted set. */
|
|
768
|
+
function grantSet(grants) {
|
|
769
|
+
return grants.map((grant) => [
|
|
770
|
+
grant.environmentId === null ? "project" : "environment",
|
|
771
|
+
grant.environmentId ?? grant.projectId,
|
|
772
|
+
grant.role,
|
|
773
|
+
grant.expiresAt,
|
|
774
|
+
grant.grantedAt,
|
|
775
|
+
grant.grantedBy
|
|
776
|
+
]).map((tuple) => JSON.stringify(tuple)).sort();
|
|
777
|
+
}
|
|
778
|
+
/** Whether two reads of a member's grants are the same set, as the MAC sees them. */
|
|
779
|
+
function sameGrants(a, b) {
|
|
780
|
+
const [x, y] = [grantSet(a), grantSet(b)];
|
|
781
|
+
return x.length === y.length && x.every((tuple, i) => tuple === y[i]);
|
|
782
|
+
}
|
|
783
|
+
function memberMac(key, member, grants) {
|
|
784
|
+
const held = grantSet(grants);
|
|
785
|
+
const tuple = [
|
|
786
|
+
"coffre.vault.member.v1",
|
|
787
|
+
member.principal,
|
|
788
|
+
member.status,
|
|
789
|
+
member.owner,
|
|
790
|
+
member.generation,
|
|
791
|
+
member.accessSeq.toString(),
|
|
792
|
+
member.createdAt,
|
|
793
|
+
member.createdBy,
|
|
794
|
+
member.statusChangedAt,
|
|
795
|
+
member.statusChangedBy,
|
|
796
|
+
held
|
|
797
|
+
];
|
|
798
|
+
return createHmac("sha256", key).update(JSON.stringify(tuple)).digest();
|
|
799
|
+
}
|
|
800
|
+
/** Whether `member`'s MAC is the vault's, over this row and these grants. */
|
|
801
|
+
function sealed(key, member, grants) {
|
|
802
|
+
const expected = memberMac(key, member, grants);
|
|
803
|
+
return member.mac.length === expected.length && timingSafeEqual(member.mac, expected);
|
|
804
|
+
}
|
|
805
|
+
//#endregion
|
|
806
|
+
//#region src/vault.ts
|
|
807
|
+
async function prepareVault(config, options = {}) {
|
|
808
|
+
return {
|
|
809
|
+
config,
|
|
810
|
+
signer: await signer(config.signingKey),
|
|
811
|
+
logKey: vaultLogKey(config.signingKey),
|
|
812
|
+
options: {
|
|
813
|
+
keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS,
|
|
814
|
+
clockOffset: options.clockOffset ?? (() => 0)
|
|
815
|
+
},
|
|
816
|
+
verified: UNVERIFIED,
|
|
817
|
+
rooted: /* @__PURE__ */ new Set(),
|
|
818
|
+
rowKey: rowKey(config.signingKey),
|
|
819
|
+
reported: /* @__PURE__ */ new Set(),
|
|
820
|
+
kekCheck: null,
|
|
821
|
+
wrongKek: null
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
/** The vault over `db`. Cheap: on Workers, one per call, over that call's connections. */
|
|
825
|
+
function openVault(db, prepared) {
|
|
826
|
+
return new VaultService(db, prepared);
|
|
827
|
+
}
|
|
828
|
+
/** Every key operation of one call, together: a removal waits at most this long for a read at KMS. */
|
|
829
|
+
const KEY_BUDGET_MS = 5e3;
|
|
830
|
+
/** How long a decision waits for a lock: above the key budget, so a removal outwaits a read in flight. */
|
|
831
|
+
const LOCK_TIMEOUT_MS = 15e3;
|
|
832
|
+
const PRINCIPAL = /^(user|token|sync):[^\s:][^\s]*$/;
|
|
833
|
+
/** Who acts for the vault itself, as when it gives a root admin a member row. */
|
|
834
|
+
const VAULT_ACTOR = "system:vault";
|
|
835
|
+
/**
|
|
836
|
+
* A KEK's check: a known value, the size of a data key, wrapped under it in
|
|
837
|
+
* a context no secret has (the nil UUID), and kept in a `key.check` entry.
|
|
838
|
+
* Opening it again tells the vault its KEK is the one that wrapped the
|
|
839
|
+
* data, without opening any data.
|
|
840
|
+
*/
|
|
841
|
+
const KEY_CHECK = "key.check";
|
|
842
|
+
const KEY_CHECK_VALUE = createHash("sha256").update("coffre.kek.check.v1").digest();
|
|
843
|
+
const NIL = "00000000-0000-0000-0000-000000000000";
|
|
844
|
+
const KEY_CHECK_CONTEXT = {
|
|
845
|
+
projectId: NIL,
|
|
846
|
+
environmentId: NIL,
|
|
847
|
+
secretId: NIL
|
|
848
|
+
};
|
|
849
|
+
/** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
|
|
850
|
+
const KEY_CHECK_SAMPLE = 3;
|
|
851
|
+
/** A new member row, before the decision seals it (`#seal`). */
|
|
852
|
+
const UNSEALED = {
|
|
853
|
+
accessSeq: 0n,
|
|
854
|
+
mac: Buffer.alloc(32)
|
|
855
|
+
};
|
|
856
|
+
/** Why a change to a tampered member is refused. */
|
|
857
|
+
const TAMPERED_SUBJECT = "this member's record failed the vault's integrity check: remove them to start over";
|
|
858
|
+
/** The action of the vault's entry that signs a prefix of the log. */
|
|
859
|
+
const CHECKPOINT = "audit.checkpoint";
|
|
860
|
+
/** Who asks for checkpoints: the app's scheduled job. */
|
|
861
|
+
const SCHEDULER = "system:coffre-scheduler";
|
|
862
|
+
/** A refusal and the entries that record it. */
|
|
863
|
+
var Refused = class {
|
|
864
|
+
refusal;
|
|
865
|
+
entries;
|
|
866
|
+
constructor(refusal, entries) {
|
|
867
|
+
this.refusal = refusal;
|
|
868
|
+
this.entries = entries;
|
|
869
|
+
}
|
|
870
|
+
};
|
|
871
|
+
/** A decision that read a member as absent who has been admitted since: it is made again. */
|
|
872
|
+
var Retry = class {};
|
|
873
|
+
/** What each `vault.tampered` entry reports, to mark it logged once committed. */
|
|
874
|
+
const REPORTED = /* @__PURE__ */ new WeakMap();
|
|
875
|
+
/** A key service that did not answer, and the entries that record what it did do. */
|
|
876
|
+
var Outage = class {
|
|
877
|
+
error;
|
|
878
|
+
entries;
|
|
879
|
+
constructor(error, entries) {
|
|
880
|
+
this.error = error;
|
|
881
|
+
this.entries = entries;
|
|
882
|
+
}
|
|
883
|
+
};
|
|
884
|
+
function refusal(code, message) {
|
|
885
|
+
return {
|
|
886
|
+
code,
|
|
887
|
+
message
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
const MESSAGES = {
|
|
891
|
+
removed: "this member was removed",
|
|
892
|
+
not_a_member: "not a member",
|
|
893
|
+
no_grant: "no grant covers this",
|
|
894
|
+
expired: "the grant that covered this has expired",
|
|
895
|
+
bulk_limit: "too many secrets read in too short a time",
|
|
896
|
+
bad_claim: "the key does not belong to this secret",
|
|
897
|
+
not_allowed: "not allowed to change this",
|
|
898
|
+
root_admin: "root admins are set in the vault configuration",
|
|
899
|
+
invalid: "not something the rules allow",
|
|
900
|
+
log_broken: "the vault log does not hold from the last checkpoint",
|
|
901
|
+
wrong_kek: "this vault's KEK does not open the data it holds",
|
|
902
|
+
tampered: "this member's record failed the vault's integrity check"
|
|
903
|
+
};
|
|
904
|
+
/**
|
|
905
|
+
* The one implementation of `Vault`. Every decision is one transaction on
|
|
906
|
+
* the shared database: it locks the rows of the members it is about, reads
|
|
907
|
+
* what it needs, decides, appends its entries under the log's lock, and
|
|
908
|
+
* only then changes members and grants. Locks come in one order
|
|
909
|
+
* everywhere, a member row, then the log's head, then the app's rows, so
|
|
910
|
+
* any number of vault instances and app servers decide side by side
|
|
911
|
+
* without a cycle (docs/design/single-database.md, question 2).
|
|
912
|
+
*
|
|
913
|
+
* Key operations run inside the decision, after the check, for a call the
|
|
914
|
+
* rules allow: a KMS logs each one, and should never show a key opened for
|
|
915
|
+
* a read coffre refused. With a key service, the call's intent is logged
|
|
916
|
+
* first, in its own transaction, so a vault that dies at KMS leaves a
|
|
917
|
+
* record that pairs with what KMS logged; and every key's outcome is
|
|
918
|
+
* logged, a partial outage included.
|
|
919
|
+
*/
|
|
920
|
+
var VaultService = class {
|
|
921
|
+
#db;
|
|
922
|
+
#prepared;
|
|
923
|
+
#config;
|
|
924
|
+
constructor(db, prepared) {
|
|
925
|
+
this.#db = db;
|
|
926
|
+
this.#prepared = prepared;
|
|
927
|
+
this.#config = prepared.config;
|
|
928
|
+
}
|
|
929
|
+
async #now(db) {
|
|
930
|
+
return await now(db) + this.#prepared.options.clockOffset();
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Decide in one transaction, with `principals`' rows locked first. A
|
|
934
|
+
* `Refused` or an `Outage` rolls back all but its own entries, which
|
|
935
|
+
* commit on their own: then the refusal is the answer, and the outage
|
|
936
|
+
* fails the call.
|
|
937
|
+
*/
|
|
938
|
+
async #decide(principals, decide) {
|
|
939
|
+
const reports = [];
|
|
940
|
+
for (let attempt = 1;; attempt += 1) try {
|
|
941
|
+
return await this.#decideOnce(principals, decide, reports);
|
|
942
|
+
} catch (error) {
|
|
943
|
+
if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
|
|
944
|
+
throw error;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
async #decideOnce(principals, decide, reports) {
|
|
948
|
+
try {
|
|
949
|
+
const result = await this.#db.transaction(async (tx) => {
|
|
950
|
+
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
951
|
+
const d = {
|
|
952
|
+
tx,
|
|
953
|
+
members: principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals),
|
|
954
|
+
at: await this.#now(tx),
|
|
955
|
+
log: [],
|
|
956
|
+
writes: [],
|
|
957
|
+
touched: /* @__PURE__ */ new Set(),
|
|
958
|
+
grants: /* @__PURE__ */ new Map(),
|
|
959
|
+
reports,
|
|
960
|
+
after: []
|
|
961
|
+
};
|
|
962
|
+
const result = await decide(d);
|
|
963
|
+
const entries = [...reports, ...d.log];
|
|
964
|
+
let at = d.at;
|
|
965
|
+
const accessSeq = /* @__PURE__ */ new Map();
|
|
966
|
+
if (entries.length > 0) {
|
|
967
|
+
const appended = await appendEntries(tx, this.#prepared.logKey, entries);
|
|
968
|
+
at = appended.occurredAt;
|
|
969
|
+
entries.forEach((entry, i) => {
|
|
970
|
+
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal, appended.seqStart + BigInt(i));
|
|
971
|
+
});
|
|
972
|
+
const seqs = new Map(entries.map((entry, i) => [entry, Number(appended.seqStart) + i]));
|
|
973
|
+
for (const then of d.after) then((entry) => seqs.get(entry));
|
|
974
|
+
}
|
|
975
|
+
for (const write of d.writes) await write(at);
|
|
976
|
+
for (const principal of /* @__PURE__ */ new Set([...d.touched, ...accessSeq.keys()])) await this.#seal(d, principal, accessSeq.get(principal));
|
|
977
|
+
return result;
|
|
978
|
+
});
|
|
979
|
+
this.#reported(reports);
|
|
980
|
+
return {
|
|
981
|
+
ok: true,
|
|
982
|
+
...result
|
|
983
|
+
};
|
|
984
|
+
} catch (error) {
|
|
985
|
+
if (!(error instanceof Refused || error instanceof Outage)) throw error;
|
|
986
|
+
const entries = [...reports, ...error.entries];
|
|
987
|
+
if (entries.length > 0) await this.#db.transaction(async (tx) => {
|
|
988
|
+
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
989
|
+
await appendEntries(tx, this.#prepared.logKey, entries);
|
|
990
|
+
});
|
|
991
|
+
this.#reported(reports);
|
|
992
|
+
if (error instanceof Outage) throw error.error;
|
|
993
|
+
return {
|
|
994
|
+
ok: false,
|
|
995
|
+
refusal: error.refusal
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Seal `principal`'s row again over what this decision left them holding,
|
|
1001
|
+
* naming `accessSeq`, their newest access entry, when it wrote one. The
|
|
1002
|
+
* table must hold exactly that: a grant written around the vault while it
|
|
1003
|
+
* decided (its row lock does not stop one) is refused, with the decision,
|
|
1004
|
+
* rather than sealed in.
|
|
1005
|
+
*/
|
|
1006
|
+
async #seal(d, principal, accessSeq) {
|
|
1007
|
+
const row = await member(d.tx, principal);
|
|
1008
|
+
if (row === void 0) return;
|
|
1009
|
+
const decided = d.grants.get(principal);
|
|
1010
|
+
if (decided === void 0) throw new Error(`sealing ${principal} without the grants this decision verified`);
|
|
1011
|
+
if (!sameGrants(await grants(d.tx, principal), decided)) {
|
|
1012
|
+
this.#report(d.reports, principal, "mac", row.mac.toString("hex"));
|
|
1013
|
+
throw new Refused(refusal("tampered", MESSAGES.tampered), []);
|
|
1014
|
+
}
|
|
1015
|
+
const next = {
|
|
1016
|
+
...row,
|
|
1017
|
+
accessSeq: accessSeq ?? row.accessSeq
|
|
1018
|
+
};
|
|
1019
|
+
await updateMember(d.tx, principal, {
|
|
1020
|
+
accessSeq: next.accessSeq,
|
|
1021
|
+
mac: memberMac(this.#prepared.rowKey, next, decided)
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Whether `row`, with these grants, is the row the vault last wrote: its
|
|
1026
|
+
* MAC holds, and it names the newest access entry the log has about the
|
|
1027
|
+
* member. A genuine row put back from before a later change passes the
|
|
1028
|
+
* first and fails the second. Entries in the vault's name that fail their
|
|
1029
|
+
* MAC are passed over, and reported: otherwise whoever can insert a row
|
|
1030
|
+
* could lock any member out.
|
|
1031
|
+
*/
|
|
1032
|
+
async #integrity(db, principal, row, grants, reports) {
|
|
1033
|
+
if (row !== void 0 && !sealed(this.#prepared.rowKey, row, grants)) {
|
|
1034
|
+
this.#report(reports, principal, "mac", row.mac.toString("hex"));
|
|
1035
|
+
return "mac";
|
|
1036
|
+
}
|
|
1037
|
+
const newest = await this.#newestAccessSeq(db, principal, reports);
|
|
1038
|
+
if (row === void 0 ? newest === null : newest === row.accessSeq) return null;
|
|
1039
|
+
if (row === void 0 && await member(db, principal) !== void 0) throw new Retry();
|
|
1040
|
+
this.#report(reports, principal, "stale", `${row?.accessSeq ?? "none"}<${newest}`);
|
|
1041
|
+
return "stale";
|
|
1042
|
+
}
|
|
1043
|
+
/** The seq of the newest access entry about `principal` that carries the vault's MAC, or null. */
|
|
1044
|
+
async #newestAccessSeq(db, principal, reports) {
|
|
1045
|
+
for (const entry of await accessEntriesAbout(db, principal, 32)) {
|
|
1046
|
+
if (this.#authentic(entry)) return entry.seq;
|
|
1047
|
+
this.#report(reports, principal, "forged_entry", String(entry.seq), entry.seq);
|
|
1048
|
+
}
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
#authentic(entry) {
|
|
1052
|
+
return verifyEntries([entry], {
|
|
1053
|
+
startSeq: entry.seq,
|
|
1054
|
+
startPrevHash: entry.prevHash,
|
|
1055
|
+
keys: [this.#prepared.logKey]
|
|
1056
|
+
}).ok;
|
|
1057
|
+
}
|
|
1058
|
+
/** A `vault.tampered` entry, once per process for each thing found: `#reported` marks it once committed. */
|
|
1059
|
+
#report(reports, principal, code, detail, relatedSeq = null) {
|
|
1060
|
+
const key = `${principal}|${code}|${detail}`;
|
|
1061
|
+
if (this.#prepared.reported.has(key) || reports.some((entry) => REPORTED.get(entry) === key)) return;
|
|
1062
|
+
const entry = {
|
|
1063
|
+
actor: VAULT_ACTOR,
|
|
1064
|
+
action: "vault.tampered",
|
|
1065
|
+
decision: "deny",
|
|
1066
|
+
code,
|
|
1067
|
+
subjectPrincipal: principal,
|
|
1068
|
+
relatedSeq,
|
|
1069
|
+
metadata: "{}"
|
|
1070
|
+
};
|
|
1071
|
+
REPORTED.set(entry, key);
|
|
1072
|
+
reports.push(entry);
|
|
1073
|
+
}
|
|
1074
|
+
/** Mark these reports as logged, once their transaction has committed. */
|
|
1075
|
+
#reported(reports) {
|
|
1076
|
+
for (const entry of reports) {
|
|
1077
|
+
const key = REPORTED.get(entry);
|
|
1078
|
+
if (key !== void 0) this.#prepared.reported.add(key);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
/** Reports found outside a decision, committed on their own. */
|
|
1082
|
+
async #record(reports) {
|
|
1083
|
+
if (reports.length === 0) return;
|
|
1084
|
+
await this.#db.transaction((tx) => appendEntries(tx, this.#prepared.logKey, reports));
|
|
1085
|
+
this.#reported(reports);
|
|
1086
|
+
}
|
|
1087
|
+
async unwrap(input) {
|
|
1088
|
+
const { principal } = input;
|
|
1089
|
+
validateText(input.purpose);
|
|
1090
|
+
const loaded = await this.#versions(input, "secret.read", { purpose: input.purpose });
|
|
1091
|
+
if (!loaded.ok) return loaded;
|
|
1092
|
+
const items = loaded.versions;
|
|
1093
|
+
validateItems(items, "wrapped");
|
|
1094
|
+
const versionIds = new Map(items.map((item) => [item.secret, item.id]));
|
|
1095
|
+
const entry = (secret, decision, code) => ({
|
|
1096
|
+
...keyEntry("secret.read", principal, secret, decision, code, input, { purpose: input.purpose }),
|
|
1097
|
+
secretVersionId: versionIds.get(secret)
|
|
1098
|
+
});
|
|
1099
|
+
const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
1100
|
+
return this.#keys({
|
|
1101
|
+
action: "secret.read",
|
|
1102
|
+
principal,
|
|
1103
|
+
permission: "secret.read",
|
|
1104
|
+
secrets: items.map((item) => item.secret),
|
|
1105
|
+
remote,
|
|
1106
|
+
entry,
|
|
1107
|
+
input
|
|
1108
|
+
}, () => items.map(({ secret, wrapped }) => async (operation) => {
|
|
1109
|
+
const key = await this.#open(wrapped, secret, operation);
|
|
1110
|
+
return key && {
|
|
1111
|
+
key,
|
|
1112
|
+
wipe: () => key.fill(0)
|
|
1113
|
+
};
|
|
1114
|
+
}), (opened) => ({ keys: opened.map(({ key }) => {
|
|
1115
|
+
try {
|
|
1116
|
+
return base64(key);
|
|
1117
|
+
} finally {
|
|
1118
|
+
key.fill(0);
|
|
1119
|
+
}
|
|
1120
|
+
}) }));
|
|
1121
|
+
}
|
|
1122
|
+
async wrap(input) {
|
|
1123
|
+
const { principal, items } = input;
|
|
1124
|
+
validateItems(items, "key");
|
|
1125
|
+
validateText(principal);
|
|
1126
|
+
validateCorrelation(input);
|
|
1127
|
+
const entry = (secret, decision, code) => keyEntry("key.wrap", principal, secret, decision, code, input);
|
|
1128
|
+
return this.#keys({
|
|
1129
|
+
action: "key.wrap",
|
|
1130
|
+
principal,
|
|
1131
|
+
permission: "secret.write",
|
|
1132
|
+
secrets: items.map((item) => item.secret),
|
|
1133
|
+
remote: this.#remote(this.#config.keks.primary),
|
|
1134
|
+
entry,
|
|
1135
|
+
input
|
|
1136
|
+
}, () => items.map(({ secret, key }) => async (operation) => {
|
|
1137
|
+
const dek = Buffer.from(key, "base64");
|
|
1138
|
+
try {
|
|
1139
|
+
return {
|
|
1140
|
+
wrapped: serialisable(await this.#config.keks.wrap(dek, context(secret), operation)),
|
|
1141
|
+
wipe: () => {}
|
|
1142
|
+
};
|
|
1143
|
+
} finally {
|
|
1144
|
+
dek.fill(0);
|
|
1145
|
+
}
|
|
1146
|
+
}), (done) => ({
|
|
1147
|
+
wrapped: done.map(({ wrapped }) => wrapped),
|
|
1148
|
+
seqs: []
|
|
1149
|
+
}));
|
|
1150
|
+
}
|
|
1151
|
+
async rewrap(input) {
|
|
1152
|
+
const { principal } = input;
|
|
1153
|
+
const loaded = await this.#versions(input, "key.rewrap");
|
|
1154
|
+
if (!loaded.ok) return loaded;
|
|
1155
|
+
const items = input.items.map((item, i) => ({
|
|
1156
|
+
secret: { ...item.secret },
|
|
1157
|
+
wrapped: loaded.versions[i].wrapped
|
|
1158
|
+
}));
|
|
1159
|
+
validateItems(items, "wrapped");
|
|
1160
|
+
const sameSecret = (secret, source) => secret.projectId === source.projectId && secret.environmentId === source.environmentId && secret.secretId === source.secretId;
|
|
1161
|
+
if (items.some((item, i) => !sameSecret(item.secret, loaded.versions[i].secret))) return this.#badVersions(principal, loaded.versions.map((source) => ({
|
|
1162
|
+
...keyEntry("key.rewrap", principal, source.secret, "deny", "bad_claim", input),
|
|
1163
|
+
secretVersionId: source.id
|
|
1164
|
+
})));
|
|
1165
|
+
const sources = new Map(items.map((item, i) => [item.secret, loaded.versions[i]]));
|
|
1166
|
+
const entry = (secret, decision, code) => ({
|
|
1167
|
+
...keyEntry("key.rewrap", principal, secret, decision, code, input, { from: sources.get(secret).secret.version }),
|
|
1168
|
+
secretVersionId: sources.get(secret).id
|
|
1169
|
+
});
|
|
1170
|
+
const remote = this.#remote(this.#config.keks.primary) || items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
|
|
1171
|
+
return this.#keys({
|
|
1172
|
+
action: "key.rewrap",
|
|
1173
|
+
principal,
|
|
1174
|
+
permission: "secret.write",
|
|
1175
|
+
secrets: items.map((item) => item.secret),
|
|
1176
|
+
remote,
|
|
1177
|
+
entry,
|
|
1178
|
+
input
|
|
1179
|
+
}, () => items.map(({ secret, wrapped }) => async (operation) => {
|
|
1180
|
+
const key = await this.#open(wrapped, secret, operation);
|
|
1181
|
+
if (key === null) return null;
|
|
1182
|
+
try {
|
|
1183
|
+
return {
|
|
1184
|
+
wrapped: serialisable(await this.#config.keks.wrap(key, context(secret), operation)),
|
|
1185
|
+
wipe: () => {}
|
|
1186
|
+
};
|
|
1187
|
+
} finally {
|
|
1188
|
+
key.fill(0);
|
|
1189
|
+
}
|
|
1190
|
+
}), (done) => ({
|
|
1191
|
+
wrapped: done.map(({ wrapped }) => wrapped),
|
|
1192
|
+
seqs: []
|
|
1193
|
+
}));
|
|
1194
|
+
}
|
|
1195
|
+
/** Immutable versions need no lock; their ids determine the whole batch before any key call. */
|
|
1196
|
+
async #versions(input, action, detail = {}) {
|
|
1197
|
+
validateText(input.principal);
|
|
1198
|
+
validateCorrelation(input);
|
|
1199
|
+
const ids = input.items.map((item) => item.secretVersionId);
|
|
1200
|
+
for (const id of ids) if (typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) throw new Error("secret version id must be a lowercase UUID");
|
|
1201
|
+
const found = new Map((await versions(this.#db, ids)).map((version) => [version.id, version]));
|
|
1202
|
+
if (ids.some((id) => !found.has(id))) return this.#badVersions(input.principal, ids.map((id) => {
|
|
1203
|
+
const version = found.get(id);
|
|
1204
|
+
if (version === void 0) return {
|
|
1205
|
+
actor: input.principal,
|
|
1206
|
+
action,
|
|
1207
|
+
decision: "deny",
|
|
1208
|
+
code: "bad_claim",
|
|
1209
|
+
operationId: input.operationId,
|
|
1210
|
+
requestId: input.requestId,
|
|
1211
|
+
metadata: JSON.stringify({
|
|
1212
|
+
...detail,
|
|
1213
|
+
secretVersionId: id
|
|
1214
|
+
})
|
|
1215
|
+
};
|
|
1216
|
+
return {
|
|
1217
|
+
...keyEntry(action, input.principal, version.secret, "deny", "bad_claim", input, detail),
|
|
1218
|
+
secretVersionId: id
|
|
1219
|
+
};
|
|
1220
|
+
}));
|
|
1221
|
+
return {
|
|
1222
|
+
ok: true,
|
|
1223
|
+
versions: ids.map((id) => found.get(id))
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
async #badVersions(principal, entries) {
|
|
1227
|
+
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1228
|
+
return this.#decide([principal], async () => {
|
|
1229
|
+
throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries);
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* The data key, or null when it does not open as this secret's: a claim
|
|
1234
|
+
* that is not what it says. A key service that cannot answer is an outage,
|
|
1235
|
+
* not a verdict on the claim, and throws.
|
|
1236
|
+
*/
|
|
1237
|
+
async #open(wrapped, secret, operation) {
|
|
1238
|
+
try {
|
|
1239
|
+
return await this.#config.keks.unwrap(unwrappable(wrapped), context(secret), operation);
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
if (error instanceof KekBadClaimError) return null;
|
|
1242
|
+
throw error;
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
/** Whether a key operation under `kek` leaves the process: a key service, not a key in memory. */
|
|
1246
|
+
#remote(kek) {
|
|
1247
|
+
return kek !== void 0 && !(kek instanceof LocalKekProvider);
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* One call's key operations, decided as one. With a key service, the
|
|
1251
|
+
* intent commits first; then, under the reader's row, the check, every
|
|
1252
|
+
* operation within the budget, and each key's outcome. With a key in
|
|
1253
|
+
* memory there is nothing to pair with outside, and it is all one short
|
|
1254
|
+
* transaction.
|
|
1255
|
+
*/
|
|
1256
|
+
async #keys(call, operations, result) {
|
|
1257
|
+
const { action, principal, secrets, entry } = call;
|
|
1258
|
+
for (const secret of secrets) entry(secret, "allow", null);
|
|
1259
|
+
const wrongKek = await this.#kekMismatch();
|
|
1260
|
+
if (wrongKek !== null) return this.#decide([], async () => {
|
|
1261
|
+
throw new Refused(refusal("wrong_kek", wrongKek), secrets.map((secret) => entry(secret, "deny", "wrong_kek")));
|
|
1262
|
+
});
|
|
1263
|
+
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1264
|
+
const check = async (d) => {
|
|
1265
|
+
const reader = await this.#standing(d.tx, principal, d.members.get(principal), d.at, d.reports);
|
|
1266
|
+
const codes = secrets.map((secret) => refuses(reader, call.permission, secret));
|
|
1267
|
+
let first = codes.find((code) => code !== null) ?? null;
|
|
1268
|
+
if (first === null && action === "secret.read" && await this.#overBulkLimit(d, principal, secrets.length)) first = "bulk_limit";
|
|
1269
|
+
if (first !== null) throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, i) => entry(secret, "deny", codes[i] ?? first)));
|
|
1270
|
+
};
|
|
1271
|
+
const intentId = call.remote ? randomUUID() : null;
|
|
1272
|
+
let intentSeq = null;
|
|
1273
|
+
const outcomeEntry = (secret, item, decision, code) => {
|
|
1274
|
+
const outcome = entry(secret, decision, code);
|
|
1275
|
+
return intentSeq === null ? outcome : {
|
|
1276
|
+
...outcome,
|
|
1277
|
+
relatedSeq: intentSeq,
|
|
1278
|
+
metadata: JSON.stringify({
|
|
1279
|
+
...JSON.parse(outcome.metadata ?? "{}"),
|
|
1280
|
+
intent: intentId,
|
|
1281
|
+
item,
|
|
1282
|
+
...["key_error", "kms_uncertain"].includes(code ?? "") ? { uncertain: true } : {}
|
|
1283
|
+
})
|
|
1284
|
+
};
|
|
1285
|
+
};
|
|
1286
|
+
if (call.remote) {
|
|
1287
|
+
const intent = await this.#decide([principal], async (d) => {
|
|
1288
|
+
await check(d);
|
|
1289
|
+
await lockLogHead(d.tx);
|
|
1290
|
+
const at = await this.#now(d.tx);
|
|
1291
|
+
return { seq: (await appendEntries(d.tx, this.#prepared.logKey, [{
|
|
1292
|
+
actor: principal,
|
|
1293
|
+
action: "key.intent",
|
|
1294
|
+
decision: "allow",
|
|
1295
|
+
operationId: call.input.operationId ?? null,
|
|
1296
|
+
requestId: call.input.requestId ?? null,
|
|
1297
|
+
metadata: JSON.stringify({
|
|
1298
|
+
intent: intentId,
|
|
1299
|
+
operation: action,
|
|
1300
|
+
expiresAt: at + this.#prepared.options.keyBudgetMs + 2 * LOCK_TIMEOUT_MS,
|
|
1301
|
+
...call.input.purpose === void 0 ? {} : { purpose: call.input.purpose },
|
|
1302
|
+
keys: secrets.map((secret, item) => ({
|
|
1303
|
+
item,
|
|
1304
|
+
subject: secret.path,
|
|
1305
|
+
secretId: secret.secretId,
|
|
1306
|
+
version: secret.version
|
|
1307
|
+
}))
|
|
1308
|
+
})
|
|
1309
|
+
}])).seqStart };
|
|
1310
|
+
});
|
|
1311
|
+
if (!intent.ok) return intent;
|
|
1312
|
+
intentSeq = intent.seq;
|
|
1313
|
+
}
|
|
1314
|
+
return this.#decide([principal], async (d) => {
|
|
1315
|
+
try {
|
|
1316
|
+
await check(d);
|
|
1317
|
+
} catch (error) {
|
|
1318
|
+
if (error instanceof Refused) throw new Refused(error.refusal, secrets.map((secret, item) => outcomeEntry(secret, item, "deny", error.entries[item].code ?? error.refusal.code)));
|
|
1319
|
+
throw error;
|
|
1320
|
+
}
|
|
1321
|
+
const { outcomes, expired } = await settle(operations(), this.#prepared.options.keyBudgetMs);
|
|
1322
|
+
const done = outcomes.flatMap((outcome) => outcome.ok ? [outcome.value] : []);
|
|
1323
|
+
try {
|
|
1324
|
+
const entries = () => secrets.map((secret, i) => outcomeEntry(secret, i, "deny", outcomes[i].ok ? "withheld" : outcomes[i].code));
|
|
1325
|
+
const fault = outcomes.find((outcome) => !outcome.ok && outcome.code === "key_error");
|
|
1326
|
+
if (fault !== void 0 && !fault.ok) throw new Outage(fault.error, entries());
|
|
1327
|
+
const unanswered = outcomes.filter((outcome) => !outcome.ok && outcome.code !== "bad_claim").length;
|
|
1328
|
+
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());
|
|
1329
|
+
if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
|
|
1330
|
+
if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
|
|
1331
|
+
const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
|
|
1332
|
+
d.log.push(...released);
|
|
1333
|
+
const answer = result(done);
|
|
1334
|
+
if (action !== "secret.read") d.after.push((seqOf) => Object.assign(answer, { seqs: released.map(seqOf) }));
|
|
1335
|
+
return answer;
|
|
1336
|
+
} finally {
|
|
1337
|
+
for (const value of done) value.wipe();
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
/** Whether `n` more keys would take `principal` past the bulk limit; counted under their row's lock, so exactly. */
|
|
1342
|
+
async #overBulkLimit(d, principal, n) {
|
|
1343
|
+
const { count, windowMs } = this.#config.bulkLimit;
|
|
1344
|
+
return await releasesSince(d.tx, principal, d.at - windowMs) + n > count;
|
|
1345
|
+
}
|
|
1346
|
+
#isRootAdmin(principal) {
|
|
1347
|
+
return principal.startsWith("user:") && this.#config.rootAdmins.includes(principal.slice(5));
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Give a root admin a member row the first time anyone asks about them:
|
|
1351
|
+
* the app's sign-ins and sessions point at it, and their reads queue on
|
|
1352
|
+
* it like anyone's. Logged, and made in a transaction of its own, under
|
|
1353
|
+
* the log's lock: a decision that held the head and then waited for a
|
|
1354
|
+
* member row would take the locks out of order. The row never makes
|
|
1355
|
+
* anyone a root admin; the configuration does.
|
|
1356
|
+
*/
|
|
1357
|
+
async #rootRow(principal) {
|
|
1358
|
+
if (this.#prepared.rooted.has(principal)) return;
|
|
1359
|
+
await this.#db.transaction(async (tx) => {
|
|
1360
|
+
await lockLogHead(tx);
|
|
1361
|
+
if (await member(tx, principal) !== void 0) return;
|
|
1362
|
+
const { occurredAt: at, seqStart } = await appendEntries(tx, this.#prepared.logKey, [{
|
|
1363
|
+
actor: VAULT_ACTOR,
|
|
1364
|
+
action: "member.add",
|
|
1365
|
+
decision: "allow",
|
|
1366
|
+
subjectPrincipal: principal,
|
|
1367
|
+
metadata: JSON.stringify({
|
|
1368
|
+
owner: false,
|
|
1369
|
+
rootAdmin: true
|
|
1370
|
+
})
|
|
1371
|
+
}]);
|
|
1372
|
+
const row = {
|
|
1373
|
+
principal,
|
|
1374
|
+
status: "active",
|
|
1375
|
+
owner: false,
|
|
1376
|
+
generation: 0,
|
|
1377
|
+
createdAt: at,
|
|
1378
|
+
createdBy: VAULT_ACTOR,
|
|
1379
|
+
statusChangedAt: at,
|
|
1380
|
+
statusChangedBy: VAULT_ACTOR,
|
|
1381
|
+
accessSeq: seqStart
|
|
1382
|
+
};
|
|
1383
|
+
await insertMember(tx, {
|
|
1384
|
+
...row,
|
|
1385
|
+
mac: memberMac(this.#prepared.rowKey, row, [])
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
this.#prepared.rooted.add(principal);
|
|
1389
|
+
}
|
|
1390
|
+
/**
|
|
1391
|
+
* What `principal` holds, their row checked first (`#integrity`): a row
|
|
1392
|
+
* that fails holds nothing, and is `tampered`. A root admin's come from the
|
|
1393
|
+
* configuration, which no row can change.
|
|
1394
|
+
*/
|
|
1395
|
+
async #standing(db, principal, row, at, reports) {
|
|
1396
|
+
const none = {
|
|
1397
|
+
isRootAdmin: false,
|
|
1398
|
+
isOwner: false,
|
|
1399
|
+
grants: []
|
|
1400
|
+
};
|
|
1401
|
+
if (this.#isRootAdmin(principal)) {
|
|
1402
|
+
const root = {
|
|
1403
|
+
isRootAdmin: true,
|
|
1404
|
+
isOwner: true,
|
|
1405
|
+
grants: []
|
|
1406
|
+
};
|
|
1407
|
+
return {
|
|
1408
|
+
principal,
|
|
1409
|
+
status: "active",
|
|
1410
|
+
live: root,
|
|
1411
|
+
all: root,
|
|
1412
|
+
fault: null,
|
|
1413
|
+
stored: []
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
const grants$1 = row === void 0 ? [] : await grants(db, principal);
|
|
1417
|
+
const fault = await this.#integrity(db, principal, row, grants$1, reports);
|
|
1418
|
+
if (fault !== null) return {
|
|
1419
|
+
principal,
|
|
1420
|
+
status: "tampered",
|
|
1421
|
+
live: none,
|
|
1422
|
+
all: none,
|
|
1423
|
+
fault,
|
|
1424
|
+
stored: grants$1
|
|
1425
|
+
};
|
|
1426
|
+
if (row?.status !== "active") return {
|
|
1427
|
+
principal,
|
|
1428
|
+
status: row?.status ?? "unknown",
|
|
1429
|
+
live: none,
|
|
1430
|
+
all: none,
|
|
1431
|
+
fault,
|
|
1432
|
+
stored: grants$1
|
|
1433
|
+
};
|
|
1434
|
+
const held = grants$1.map((grant) => ({
|
|
1435
|
+
...grant,
|
|
1436
|
+
role: grant.role
|
|
1437
|
+
}));
|
|
1438
|
+
const isOwner = row.owner && principal.startsWith("user:");
|
|
1439
|
+
return {
|
|
1440
|
+
principal,
|
|
1441
|
+
status: "active",
|
|
1442
|
+
live: {
|
|
1443
|
+
isRootAdmin: false,
|
|
1444
|
+
isOwner,
|
|
1445
|
+
grants: held.filter((grant) => live(grant, at))
|
|
1446
|
+
},
|
|
1447
|
+
all: {
|
|
1448
|
+
isRootAdmin: false,
|
|
1449
|
+
isOwner,
|
|
1450
|
+
grants: held
|
|
1451
|
+
},
|
|
1452
|
+
fault,
|
|
1453
|
+
stored: grants$1
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
#access(principal, row, held, at, tampered) {
|
|
1457
|
+
if (this.#isRootAdmin(principal)) return {
|
|
1458
|
+
principal,
|
|
1459
|
+
status: "active",
|
|
1460
|
+
generation: row?.generation ?? 0,
|
|
1461
|
+
isRootAdmin: true,
|
|
1462
|
+
isOwner: true,
|
|
1463
|
+
grants: [],
|
|
1464
|
+
since: null,
|
|
1465
|
+
by: null
|
|
1466
|
+
};
|
|
1467
|
+
const active = !tampered && row?.status === "active";
|
|
1468
|
+
return {
|
|
1469
|
+
principal,
|
|
1470
|
+
status: tampered ? "tampered" : row?.status ?? "unknown",
|
|
1471
|
+
generation: row?.generation ?? 0,
|
|
1472
|
+
isRootAdmin: false,
|
|
1473
|
+
isOwner: active && row.owner && principal.startsWith("user:"),
|
|
1474
|
+
grants: active ? held.filter((grant) => live(grant, at)).map(view) : [],
|
|
1475
|
+
since: row ? iso(row.statusChangedAt) : null,
|
|
1476
|
+
by: row?.statusChangedBy ?? null
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* What `principal` holds now. Read without locks, the fast way; a row that
|
|
1481
|
+
* seems to fail its check is read again in one snapshot before anyone is
|
|
1482
|
+
* called tampered, since a change committed between two of the reads
|
|
1483
|
+
* looks like one.
|
|
1484
|
+
*/
|
|
1485
|
+
async access(principal) {
|
|
1486
|
+
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1487
|
+
const read = async (db, reports) => {
|
|
1488
|
+
const [row, held, at] = await Promise.all([
|
|
1489
|
+
member(db, principal),
|
|
1490
|
+
grants(db, principal),
|
|
1491
|
+
this.#now(db)
|
|
1492
|
+
]);
|
|
1493
|
+
const fault = this.#isRootAdmin(principal) ? null : await this.#integrity(db, principal, row, held, reports);
|
|
1494
|
+
return this.#access(principal, row, held, at, fault !== null);
|
|
1495
|
+
};
|
|
1496
|
+
const found = [];
|
|
1497
|
+
const quick = await read(this.#db, found).catch((error) => {
|
|
1498
|
+
if (error instanceof Retry) return null;
|
|
1499
|
+
throw error;
|
|
1500
|
+
});
|
|
1501
|
+
if (quick !== null && quick.status !== "tampered") {
|
|
1502
|
+
await this.#record(found);
|
|
1503
|
+
return quick;
|
|
1504
|
+
}
|
|
1505
|
+
const reports = [];
|
|
1506
|
+
const access = await this.#db.transaction((tx) => read(tx, reports), SNAPSHOT);
|
|
1507
|
+
await this.#record(reports);
|
|
1508
|
+
return access;
|
|
1509
|
+
}
|
|
1510
|
+
/**
|
|
1511
|
+
* Every member's row checked as `access` checks it, each finding
|
|
1512
|
+
* reported: their newest access entries read in one query, and the slow
|
|
1513
|
+
* way only for a row that does not match. Lists of members read the rows
|
|
1514
|
+
* without the vault, so this is what finds a row changed around it before
|
|
1515
|
+
* its member next asks for anything. The checkpoint runs it in one
|
|
1516
|
+
* snapshot: a change committed between two of its reads, such as a lapsed
|
|
1517
|
+
* grant cleared, would otherwise pair a row with grants it was never
|
|
1518
|
+
* sealed over.
|
|
1519
|
+
*/
|
|
1520
|
+
async #sweep(db, reports) {
|
|
1521
|
+
const rows = await allMembers(db);
|
|
1522
|
+
const held = await grants(db);
|
|
1523
|
+
const newest = await newestAccessEntries(db);
|
|
1524
|
+
for (const row of rows) {
|
|
1525
|
+
if (this.#isRootAdmin(row.principal)) continue;
|
|
1526
|
+
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
1527
|
+
const entry = newest.get(row.principal);
|
|
1528
|
+
if (!(entry !== void 0 && this.#authentic(entry) && sealed(this.#prepared.rowKey, row, grants) && entry.seq === row.accessSeq)) await this.#integrity(db, row.principal, row, grants, reports);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
setAccess(input) {
|
|
1532
|
+
const { actor, principal } = input;
|
|
1533
|
+
const action = input.changes.every((change) => change.role === null) ? "access.revoke" : "access.grant";
|
|
1534
|
+
const [only] = input.changes.length === 1 ? input.changes : [];
|
|
1535
|
+
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
|
|
1536
|
+
...accessEntry(actor, action, principal, "deny", input, { changes: input.changes }, code),
|
|
1537
|
+
...only === void 0 || code === "invalid" ? {} : {
|
|
1538
|
+
projectId: only.projectId,
|
|
1539
|
+
environmentId: only.environmentId
|
|
1540
|
+
}
|
|
1541
|
+
}]);
|
|
1542
|
+
return this.#decide([actor, principal], async (d) => {
|
|
1543
|
+
validateCorrelation(input);
|
|
1544
|
+
if (!PRINCIPAL.test(principal)) throw refused("invalid", `not a principal: ${principal}`);
|
|
1545
|
+
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
1546
|
+
const places$1 = /* @__PURE__ */ new Set();
|
|
1547
|
+
for (const change of input.changes) {
|
|
1548
|
+
const key = `${change.projectId}/${change.environmentId ?? ""}`;
|
|
1549
|
+
if (places$1.has(key)) throw refused("invalid", "each place may be changed once per call");
|
|
1550
|
+
places$1.add(key);
|
|
1551
|
+
if (change.role !== null && !isRole(change.role)) throw refused("invalid", `no such role: ${change.role}`);
|
|
1552
|
+
if (change.role !== null && change.environmentId !== null && !assignableToEnvironment(change.role)) throw refused("invalid", `${change.role} can only be granted on a project`);
|
|
1553
|
+
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
1554
|
+
if (Number.isNaN(expiresAt) || expiresAt !== null && expiresAt <= d.at) throw refused("invalid", "an end date must be in the future");
|
|
1555
|
+
}
|
|
1556
|
+
const known = await places(d.tx, input.changes.map((change) => change.projectId), input.changes.flatMap((change) => change.environmentId === null ? [] : [change.environmentId]));
|
|
1557
|
+
for (const { projectId, environmentId } of input.changes) if (!known.projects.has(projectId) || environmentId !== null && known.environments.get(environmentId) !== projectId) throw refused("invalid", `no such place: ${environmentId === null ? projectId : `${projectId}/${environmentId}`}`);
|
|
1558
|
+
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1559
|
+
if (acting.status === "tampered") throw refused("tampered");
|
|
1560
|
+
if (!input.changes.every((change) => mayManageAccess(acting.live, principal, change))) throw refused("not_allowed");
|
|
1561
|
+
const row = d.members.get(principal);
|
|
1562
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1563
|
+
if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
|
|
1564
|
+
if (row?.status === "removed") throw refused("removed");
|
|
1565
|
+
if (row === void 0) {
|
|
1566
|
+
if (!isSyncPrincipal(principal) || input.changes.every((change) => change.role === null)) throw refused("not_a_member");
|
|
1567
|
+
d.log.push(accessEntry(actor, "member.add", principal, "allow", input, { owner: false }));
|
|
1568
|
+
d.writes.push(async (at) => {
|
|
1569
|
+
await insertMember(d.tx, {
|
|
1570
|
+
principal,
|
|
1571
|
+
status: "active",
|
|
1572
|
+
owner: false,
|
|
1573
|
+
generation: 0,
|
|
1574
|
+
createdAt: at,
|
|
1575
|
+
createdBy: actor,
|
|
1576
|
+
statusChangedAt: at,
|
|
1577
|
+
statusChangedBy: actor,
|
|
1578
|
+
...UNSEALED
|
|
1579
|
+
});
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
const held = subject.stored;
|
|
1583
|
+
d.grants.set(principal, [...held]);
|
|
1584
|
+
const changes = input.changes.map((change) => this.#apply(d, actor, principal, held, change, input));
|
|
1585
|
+
if (d.writes.length > 0) d.touched.add(principal);
|
|
1586
|
+
return { changes };
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
/** One place's change, logged when it changes what is live. */
|
|
1590
|
+
#apply(d, actor, principal, held, change, correlation) {
|
|
1591
|
+
const existing = held.find((grant) => grant.projectId === change.projectId && grant.environmentId === change.environmentId);
|
|
1592
|
+
const current = existing !== void 0 && live(existing, d.at) ? existing : void 0;
|
|
1593
|
+
const expiresAt = change.expiresAt === null ? null : Date.parse(change.expiresAt);
|
|
1594
|
+
const place = {
|
|
1595
|
+
projectId: change.projectId,
|
|
1596
|
+
environmentId: change.environmentId
|
|
1597
|
+
};
|
|
1598
|
+
const entry = (action, role) => d.log.push({
|
|
1599
|
+
...accessEntry(actor, action, principal, "allow", correlation, {
|
|
1600
|
+
role,
|
|
1601
|
+
expiresAt: expiresAt === null ? null : iso(expiresAt),
|
|
1602
|
+
previousRole: current?.role ?? null
|
|
1603
|
+
}),
|
|
1604
|
+
...place
|
|
1605
|
+
});
|
|
1606
|
+
const clear = () => {
|
|
1607
|
+
if (existing === void 0) return;
|
|
1608
|
+
d.writes.push(async () => {
|
|
1609
|
+
await deleteGrant(d.tx, principal, place);
|
|
1610
|
+
d.grants.set(principal, d.grants.get(principal).filter((grant) => grant !== existing));
|
|
1611
|
+
});
|
|
1612
|
+
};
|
|
1613
|
+
if (change.role === null) {
|
|
1614
|
+
clear();
|
|
1615
|
+
if (current === void 0) return "unchanged";
|
|
1616
|
+
entry("access.revoke", null);
|
|
1617
|
+
return "revoked";
|
|
1618
|
+
}
|
|
1619
|
+
if (current !== void 0 && current.role === change.role && current.expiresAt === expiresAt) return "unchanged";
|
|
1620
|
+
const role = change.role;
|
|
1621
|
+
clear();
|
|
1622
|
+
d.writes.push(async (at) => {
|
|
1623
|
+
const grant = {
|
|
1624
|
+
principal,
|
|
1625
|
+
...place,
|
|
1626
|
+
role,
|
|
1627
|
+
expiresAt,
|
|
1628
|
+
grantedAt: at,
|
|
1629
|
+
grantedBy: actor
|
|
1630
|
+
};
|
|
1631
|
+
await insertGrant(d.tx, grant);
|
|
1632
|
+
d.grants.get(principal).push(grant);
|
|
1633
|
+
});
|
|
1634
|
+
entry("access.grant", role);
|
|
1635
|
+
return current === void 0 ? "created" : "updated";
|
|
1636
|
+
}
|
|
1637
|
+
admit(input) {
|
|
1638
|
+
const { actor, principal } = input;
|
|
1639
|
+
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.add", principal, "deny", input, { owner: input.owner ?? null }, code)]);
|
|
1640
|
+
return this.#decide([actor, principal], async (d) => {
|
|
1641
|
+
validateCorrelation(input);
|
|
1642
|
+
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1643
|
+
if (acting.status === "tampered") throw refused("tampered");
|
|
1644
|
+
if (!acting.live.isOwner) throw refused("not_allowed", "only owners may add or restore members");
|
|
1645
|
+
if (!PRINCIPAL.test(principal) || isSyncPrincipal(principal)) throw refused("invalid", `not a member: ${principal}`);
|
|
1646
|
+
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
1647
|
+
if (input.owner === true && !principal.startsWith("user:")) throw refused("invalid", "service accounts cannot be owners");
|
|
1648
|
+
const row = d.members.get(principal);
|
|
1649
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1650
|
+
if (subject.status === "tampered") throw refused("tampered", TAMPERED_SUBJECT);
|
|
1651
|
+
d.touched.add(principal);
|
|
1652
|
+
d.grants.set(principal, [...subject.stored]);
|
|
1653
|
+
const entry = (action, owner) => d.log.push(accessEntry(actor, action, principal, "allow", input, { owner }));
|
|
1654
|
+
if (row === void 0 || row.status === "removed") {
|
|
1655
|
+
const owner = input.owner ?? false;
|
|
1656
|
+
entry(row === void 0 ? "member.add" : "member.restore", owner);
|
|
1657
|
+
d.writes.push(async (at) => {
|
|
1658
|
+
if (row === void 0) await insertMember(d.tx, {
|
|
1659
|
+
principal,
|
|
1660
|
+
status: "active",
|
|
1661
|
+
owner,
|
|
1662
|
+
generation: 0,
|
|
1663
|
+
createdAt: at,
|
|
1664
|
+
createdBy: actor,
|
|
1665
|
+
statusChangedAt: at,
|
|
1666
|
+
statusChangedBy: actor,
|
|
1667
|
+
...UNSEALED
|
|
1668
|
+
});
|
|
1669
|
+
else await updateMember(d.tx, principal, {
|
|
1670
|
+
status: "active",
|
|
1671
|
+
owner,
|
|
1672
|
+
statusChangedAt: at,
|
|
1673
|
+
statusChangedBy: actor
|
|
1674
|
+
});
|
|
1675
|
+
});
|
|
1676
|
+
return {
|
|
1677
|
+
created: true,
|
|
1678
|
+
owner,
|
|
1679
|
+
generation: row?.generation ?? 0
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
const owner = input.owner ?? row.owner;
|
|
1683
|
+
if (owner !== row.owner) {
|
|
1684
|
+
entry("member.owner", owner);
|
|
1685
|
+
d.writes.push(() => updateMember(d.tx, principal, { owner }));
|
|
1686
|
+
}
|
|
1687
|
+
return {
|
|
1688
|
+
created: false,
|
|
1689
|
+
owner,
|
|
1690
|
+
generation: row.generation
|
|
1691
|
+
};
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
remove(input) {
|
|
1695
|
+
const { actor, principal } = input;
|
|
1696
|
+
const refused = (code, message = MESSAGES[code]) => new Refused(refusal(code, message), [accessEntry(actor, "member.remove", principal, "deny", input, {}, code)]);
|
|
1697
|
+
return this.#decide([actor, principal], async (d) => {
|
|
1698
|
+
validateCorrelation(input);
|
|
1699
|
+
if (this.#isRootAdmin(principal)) throw refused("root_admin");
|
|
1700
|
+
const row = d.members.get(principal);
|
|
1701
|
+
const acting = await this.#standing(d.tx, actor, d.members.get(actor), d.at, d.reports);
|
|
1702
|
+
if (acting.status === "tampered") throw refused("tampered");
|
|
1703
|
+
const holder = acting.live;
|
|
1704
|
+
const subject = await this.#standing(d.tx, principal, row, d.at, d.reports);
|
|
1705
|
+
const held = subject.stored;
|
|
1706
|
+
if (subject.status === "tampered") {
|
|
1707
|
+
if (!holder.isOwner) throw refused("not_allowed", "only owners may remove a member whose record failed its check");
|
|
1708
|
+
return this.#startOver(d, actor, principal, row, subject.fault, input, refused);
|
|
1709
|
+
}
|
|
1710
|
+
const places = input.source === void 0 ? held : [...held, input.source];
|
|
1711
|
+
if (!(holder.isOwner || isSyncPrincipal(principal) && (held.length === 0 || places.some((place) => mayManageAccess(holder, principal, {
|
|
1712
|
+
...place,
|
|
1713
|
+
role: null
|
|
1714
|
+
}))))) throw refused("not_allowed", "only owners may remove members");
|
|
1715
|
+
if (row?.status !== "active") throw refused(row === void 0 ? "not_a_member" : "removed");
|
|
1716
|
+
const revoked = held.filter((grant) => live(grant, d.at));
|
|
1717
|
+
for (const grant of revoked) d.log.push({
|
|
1718
|
+
...accessEntry(actor, "access.revoke", principal, "allow", input, {
|
|
1719
|
+
role: null,
|
|
1720
|
+
expiresAt: null,
|
|
1721
|
+
previousRole: grant.role
|
|
1722
|
+
}),
|
|
1723
|
+
projectId: grant.projectId,
|
|
1724
|
+
environmentId: grant.environmentId
|
|
1725
|
+
});
|
|
1726
|
+
const generation = row.generation + 1;
|
|
1727
|
+
d.log.push(accessEntry(actor, "member.remove", principal, "allow", input, {
|
|
1728
|
+
revoked: revoked.length,
|
|
1729
|
+
generation
|
|
1730
|
+
}));
|
|
1731
|
+
d.touched.add(principal);
|
|
1732
|
+
d.writes.push(async (at) => {
|
|
1733
|
+
await deleteGrants(d.tx, principal);
|
|
1734
|
+
d.grants.set(principal, []);
|
|
1735
|
+
await updateMember(d.tx, principal, {
|
|
1736
|
+
status: "removed",
|
|
1737
|
+
owner: false,
|
|
1738
|
+
generation,
|
|
1739
|
+
statusChangedAt: at,
|
|
1740
|
+
statusChangedBy: actor
|
|
1741
|
+
});
|
|
1742
|
+
});
|
|
1743
|
+
return {
|
|
1744
|
+
revoked: revoked.map(view),
|
|
1745
|
+
generation
|
|
1746
|
+
};
|
|
1747
|
+
});
|
|
1748
|
+
}
|
|
1749
|
+
/**
|
|
1750
|
+
* Remove a member whose row failed its check, from what the log says of
|
|
1751
|
+
* them rather than what the row does: their grants go, whatever they were,
|
|
1752
|
+
* and their generation moves past both the row's and the log's, so no
|
|
1753
|
+
* session or token from any earlier membership comes back with a row put
|
|
1754
|
+
* back. Admitted again, they start from nothing, as any removed member.
|
|
1755
|
+
*/
|
|
1756
|
+
async #startOver(d, actor, principal, row, fault, correlation, refused) {
|
|
1757
|
+
const logged = await this.#logged(d.tx, principal);
|
|
1758
|
+
if (logged === void 0) throw refused("not_a_member", "the log never admitted them: their row was written around the vault");
|
|
1759
|
+
const generation = Math.max(row?.generation ?? 0, logged.generation) + 1;
|
|
1760
|
+
d.log.push(accessEntry(actor, "member.remove", principal, "allow", correlation, {
|
|
1761
|
+
revoked: 0,
|
|
1762
|
+
generation,
|
|
1763
|
+
tampered: fault
|
|
1764
|
+
}));
|
|
1765
|
+
d.touched.add(principal);
|
|
1766
|
+
d.writes.push(async (at) => {
|
|
1767
|
+
await deleteGrants(d.tx, principal);
|
|
1768
|
+
d.grants.set(principal, []);
|
|
1769
|
+
const fresh = {
|
|
1770
|
+
status: "removed",
|
|
1771
|
+
owner: false,
|
|
1772
|
+
generation,
|
|
1773
|
+
createdAt: logged.createdAt,
|
|
1774
|
+
createdBy: logged.createdBy,
|
|
1775
|
+
statusChangedAt: at,
|
|
1776
|
+
statusChangedBy: actor
|
|
1777
|
+
};
|
|
1778
|
+
if (row === void 0) await insertMember(d.tx, {
|
|
1779
|
+
principal,
|
|
1780
|
+
...fresh,
|
|
1781
|
+
...UNSEALED
|
|
1782
|
+
});
|
|
1783
|
+
else await updateMember(d.tx, principal, fresh);
|
|
1784
|
+
});
|
|
1785
|
+
return {
|
|
1786
|
+
revoked: [],
|
|
1787
|
+
generation
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
/** `principal` as the log says they are: their authenticated access entries, replayed. */
|
|
1791
|
+
async #logged(db, principal) {
|
|
1792
|
+
const state = {
|
|
1793
|
+
members: /* @__PURE__ */ new Map(),
|
|
1794
|
+
held: /* @__PURE__ */ new Map()
|
|
1795
|
+
};
|
|
1796
|
+
const entries = (await accessEntriesAbout(db, principal, 1e5)).filter((entry) => this.#authentic(entry));
|
|
1797
|
+
for (const entry of entries.reverse()) apply(state, entry);
|
|
1798
|
+
return state.members.get(principal);
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Null when every KEK the vault is given opens what it wrapped; otherwise
|
|
1802
|
+
* why not, naming the KEK, never its key. Decided once per process, before
|
|
1803
|
+
* its first key operation or checkpoint. A key service that cannot answer
|
|
1804
|
+
* leaves it undecided: the error is thrown, and the next call asks again.
|
|
1805
|
+
*/
|
|
1806
|
+
#kekMismatch() {
|
|
1807
|
+
const prepared = this.#prepared;
|
|
1808
|
+
if (prepared.kekCheck === null) {
|
|
1809
|
+
const check = this.#checkKeks();
|
|
1810
|
+
prepared.kekCheck = check;
|
|
1811
|
+
check.then((wrong) => void (prepared.wrongKek = wrong), () => {
|
|
1812
|
+
if (prepared.kekCheck === check) prepared.kekCheck = null;
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
return prepared.kekCheck;
|
|
1816
|
+
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Each KEK opens its check value, or, with none recorded yet (a fresh
|
|
1819
|
+
* database, a new KEK, data from before checks), opens one of the newest
|
|
1820
|
+
* keys it wrapped, if there are any; then its check value is recorded.
|
|
1821
|
+
* A KEK that opens neither is not the one that wrapped the data.
|
|
1822
|
+
*/
|
|
1823
|
+
async #checkKeks() {
|
|
1824
|
+
const checks = /* @__PURE__ */ new Map();
|
|
1825
|
+
for (const entry of await vaultEntriesOf(this.#db, [KEY_CHECK], -1n, VERIFY_BATCH)) {
|
|
1826
|
+
if (!this.#authentic(entry)) continue;
|
|
1827
|
+
const wrapped = JSON.parse(entry.metadata);
|
|
1828
|
+
checks.set(`${wrapped.kekProvider}:${wrapped.kekId}`, unwrappable(wrapped));
|
|
1829
|
+
}
|
|
1830
|
+
const budget = this.#prepared.options.keyBudgetMs;
|
|
1831
|
+
for (const kek of this.#config.keks.all) {
|
|
1832
|
+
const operation = {
|
|
1833
|
+
deadline: Date.now() + budget,
|
|
1834
|
+
signal: AbortSignal.timeout(budget)
|
|
1835
|
+
};
|
|
1836
|
+
const opens = async (wrapped, context, expected) => {
|
|
1837
|
+
try {
|
|
1838
|
+
const key = await kek.unwrap(wrapped, context, operation);
|
|
1839
|
+
const right = expected === void 0 || key.length === expected.length && timingSafeEqual(key, expected);
|
|
1840
|
+
key.fill(0);
|
|
1841
|
+
return right;
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
if (error instanceof KekBadClaimError) return false;
|
|
1844
|
+
throw error;
|
|
1845
|
+
}
|
|
1846
|
+
};
|
|
1847
|
+
const mismatch = `this vault's ${kek.provider} KEK ${kek.keyId} does not open the data it holds: it is not the key that wrapped it`;
|
|
1848
|
+
const check = checks.get(`${kek.provider}:${kek.keyId}`);
|
|
1849
|
+
if (check !== void 0) {
|
|
1850
|
+
if (!await opens(check, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE)) return mismatch;
|
|
1851
|
+
continue;
|
|
1852
|
+
}
|
|
1853
|
+
const samples = await wrappedUnder(this.#db, kek.provider, kek.keyId, KEY_CHECK_SAMPLE);
|
|
1854
|
+
let proof = null;
|
|
1855
|
+
for (const { projectId, environmentId, secretId, version, ...wrapped } of samples) if (await opens(wrapped, {
|
|
1856
|
+
projectId,
|
|
1857
|
+
environmentId,
|
|
1858
|
+
secretId
|
|
1859
|
+
})) {
|
|
1860
|
+
proof = {
|
|
1861
|
+
secretId,
|
|
1862
|
+
version
|
|
1863
|
+
};
|
|
1864
|
+
break;
|
|
1865
|
+
}
|
|
1866
|
+
if (samples.length > 0 && proof === null) return mismatch;
|
|
1867
|
+
const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
|
|
1868
|
+
await this.#db.transaction((tx) => appendEntries(tx, this.#prepared.logKey, [{
|
|
1869
|
+
actor: VAULT_ACTOR,
|
|
1870
|
+
action: KEY_CHECK,
|
|
1871
|
+
decision: "allow",
|
|
1872
|
+
metadata: JSON.stringify({
|
|
1873
|
+
...serialisable(wrapped),
|
|
1874
|
+
proof
|
|
1875
|
+
})
|
|
1876
|
+
}]));
|
|
1877
|
+
}
|
|
1878
|
+
return null;
|
|
1879
|
+
}
|
|
1880
|
+
/** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
|
|
1881
|
+
async #latest(db) {
|
|
1882
|
+
const row = await latestVaultEntry(db, [CHECKPOINT]);
|
|
1883
|
+
return row === void 0 ? null : {
|
|
1884
|
+
checkpoint: JSON.parse(row.metadata),
|
|
1885
|
+
seq: row.seq
|
|
1886
|
+
};
|
|
1887
|
+
}
|
|
1888
|
+
async checkpoint() {
|
|
1889
|
+
const { wrongKek } = this.#prepared;
|
|
1890
|
+
const found = [];
|
|
1891
|
+
const whole = await this.#db.transaction(async (tx) => {
|
|
1892
|
+
await this.#sweep(tx, found);
|
|
1893
|
+
return verifyChain(tx, this.#prepared.logKey, [], UNVERIFIED);
|
|
1894
|
+
}, SNAPSHOT);
|
|
1895
|
+
return this.#decide([], async (d) => {
|
|
1896
|
+
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
1897
|
+
const head = await lockLogHead(d.tx);
|
|
1898
|
+
const latest = await this.#latest(d.tx);
|
|
1899
|
+
const refused = (code, detail, message = MESSAGES[code]) => new Refused(refusal(code, message), [{
|
|
1900
|
+
actor: SCHEDULER,
|
|
1901
|
+
action: CHECKPOINT,
|
|
1902
|
+
decision: "deny",
|
|
1903
|
+
code,
|
|
1904
|
+
metadata: JSON.stringify(detail)
|
|
1905
|
+
}]);
|
|
1906
|
+
if (head.nextSeq === 0n) throw new Refused(refusal("invalid", "the log is empty"), []);
|
|
1907
|
+
if (wrongKek !== null) throw refused("wrong_kek", { reason: wrongKek }, wrongKek);
|
|
1908
|
+
if (!whole.verification.ok) throw refused("log_broken", {
|
|
1909
|
+
failedAtSeq: whole.verification.failedAtSeq,
|
|
1910
|
+
reason: whole.verification.reason
|
|
1911
|
+
});
|
|
1912
|
+
if (latest !== null && latest.seq === head.nextSeq - 1n) return { checkpoint: latest.checkpoint };
|
|
1913
|
+
if (latest !== null && !await carries(d.tx, latest.checkpoint)) throw refused("log_broken", { reason: `the log up to entry ${latest.checkpoint.seq} is not the prefix the last checkpoint signed` });
|
|
1914
|
+
const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKey, [], whole.anchor);
|
|
1915
|
+
if (!held.ok) throw refused("log_broken", {
|
|
1916
|
+
failedAtSeq: held.failedAtSeq,
|
|
1917
|
+
reason: held.reason
|
|
1918
|
+
});
|
|
1919
|
+
if (verified.nextSeq !== head.nextSeq || !verified.hash.equals(head.headHash)) throw refused("log_broken", { reason: "the chain head does not name the last entry" });
|
|
1920
|
+
const signed = {
|
|
1921
|
+
seq: Number(verified.nextSeq - 1n),
|
|
1922
|
+
hash: verified.hash.toString("hex"),
|
|
1923
|
+
signedAt: iso(d.at)
|
|
1924
|
+
};
|
|
1925
|
+
const checkpoint = {
|
|
1926
|
+
...signed,
|
|
1927
|
+
keyId: this.#prepared.signer.keyId,
|
|
1928
|
+
signature: await this.#prepared.signer.sign(checkpointMessage(signed))
|
|
1929
|
+
};
|
|
1930
|
+
d.log.push({
|
|
1931
|
+
actor: SCHEDULER,
|
|
1932
|
+
action: CHECKPOINT,
|
|
1933
|
+
decision: "allow",
|
|
1934
|
+
metadata: JSON.stringify(checkpoint)
|
|
1935
|
+
});
|
|
1936
|
+
return { checkpoint };
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
async about() {
|
|
1940
|
+
return {
|
|
1941
|
+
publicKey: this.#prepared.signer.publicKey,
|
|
1942
|
+
rootAdmins: this.#config.rootAdmins.map((email) => `user:${email}`)
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1945
|
+
verifyLog(input) {
|
|
1946
|
+
return this.#verifyAll([], input.upTo ?? null);
|
|
1947
|
+
}
|
|
1948
|
+
/** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
|
|
1949
|
+
async #verify(db, shown, anchor) {
|
|
1950
|
+
const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKey, shown, anchor);
|
|
1951
|
+
if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
|
|
1952
|
+
return verification;
|
|
1953
|
+
}
|
|
1954
|
+
/**
|
|
1955
|
+
* `shown`, and the chain from its first entry, in one snapshot: every
|
|
1956
|
+
* link and hash, the vault's MACs; then the heads that must still be
|
|
1957
|
+
* there: the one the app verified up to (`upTo`), so both authors are
|
|
1958
|
+
* checked over the same entries, the one the app last recorded from a
|
|
1959
|
+
* checkpoint (`through`), and the last checkpoint's; and the members and
|
|
1960
|
+
* grants replayed from it.
|
|
1961
|
+
*/
|
|
1962
|
+
#verifyAll(shown, upTo) {
|
|
1963
|
+
return this.#db.transaction(async (tx) => {
|
|
1964
|
+
const remembered = this.#prepared.verified;
|
|
1965
|
+
const verification = await this.#verify(tx, shown, UNVERIFIED);
|
|
1966
|
+
if (!verification.ok) return verification;
|
|
1967
|
+
if (upTo !== null && !await carries(tx, upTo)) return {
|
|
1968
|
+
ok: false,
|
|
1969
|
+
failedAtSeq: upTo.seq,
|
|
1970
|
+
reason: "not the entry the app verified up to: the log changed between the two checks"
|
|
1971
|
+
};
|
|
1972
|
+
const unsigned = await this.#checkpointFault(tx);
|
|
1973
|
+
if (unsigned !== null) return unsigned;
|
|
1974
|
+
if (remembered.nextSeq > 0n) {
|
|
1975
|
+
const seq = remembered.nextSeq - 1n;
|
|
1976
|
+
if (!await carries(tx, {
|
|
1977
|
+
seq: Number(seq),
|
|
1978
|
+
hash: remembered.hash.toString("hex")
|
|
1979
|
+
})) return {
|
|
1980
|
+
ok: false,
|
|
1981
|
+
failedAtSeq: Number(seq),
|
|
1982
|
+
reason: "changed since the vault last verified it"
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
const at = await this.#now(tx);
|
|
1986
|
+
const accounting = await verifyAccounting(tx, at);
|
|
1987
|
+
if (!accounting.ok) return accounting;
|
|
1988
|
+
const fault = await this.#unsealed(tx) ?? await replay(tx, at);
|
|
1989
|
+
if (fault !== null) return {
|
|
1990
|
+
ok: false,
|
|
1991
|
+
failedAtSeq: null,
|
|
1992
|
+
reason: describeAccessFault(fault),
|
|
1993
|
+
fault
|
|
1994
|
+
};
|
|
1995
|
+
return accounting.pending === 0 ? verification : {
|
|
1996
|
+
...verification,
|
|
1997
|
+
pending: accounting.pending
|
|
1998
|
+
};
|
|
1999
|
+
}, SNAPSHOT);
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* Every checkpoint, not only the newest: each signed by the vault's key,
|
|
2003
|
+
* over a prefix the log still holds, entry for entry. The chain is
|
|
2004
|
+
* verified by now, so each checkpoint entry is the vault's.
|
|
2005
|
+
*/
|
|
2006
|
+
async #checkpointFault(db) {
|
|
2007
|
+
for (let after = -1n;;) {
|
|
2008
|
+
const batch = await vaultEntriesOf(db, [CHECKPOINT], after, VERIFY_BATCH);
|
|
2009
|
+
for (const entry of batch) {
|
|
2010
|
+
const checkpoint = JSON.parse(entry.metadata);
|
|
2011
|
+
const broken = (reason) => ({
|
|
2012
|
+
ok: false,
|
|
2013
|
+
failedAtSeq: Number(entry.seq),
|
|
2014
|
+
reason
|
|
2015
|
+
});
|
|
2016
|
+
if (!await verifyCheckpoint(checkpoint, this.#prepared.signer.publicKey)) return broken("a checkpoint the vault did not sign");
|
|
2017
|
+
if (BigInt(checkpoint.seq) >= entry.seq || !await carries(db, checkpoint)) return broken(`the log up to entry ${checkpoint.seq} is not the prefix this checkpoint signed: it was rewritten`);
|
|
2018
|
+
}
|
|
2019
|
+
if (batch.length < 1e3) return null;
|
|
2020
|
+
after = batch[batch.length - 1].seq;
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
/**
|
|
2024
|
+
* The first member whose row fails its MAC, or names an older access
|
|
2025
|
+
* entry than the log's newest about them. The chain is verified by now,
|
|
2026
|
+
* so every entry read here carries the vault's MAC.
|
|
2027
|
+
*/
|
|
2028
|
+
async #unsealed(db) {
|
|
2029
|
+
const [rows, held, newest] = await Promise.all([
|
|
2030
|
+
allMembers(db),
|
|
2031
|
+
grants(db),
|
|
2032
|
+
newestAccessEntries(db)
|
|
2033
|
+
]);
|
|
2034
|
+
for (const row of rows) {
|
|
2035
|
+
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
2036
|
+
if (!sealed(this.#prepared.rowKey, row, grants)) return {
|
|
2037
|
+
kind: "tampered-member",
|
|
2038
|
+
principal: row.principal,
|
|
2039
|
+
why: "mac"
|
|
2040
|
+
};
|
|
2041
|
+
if (newest.get(row.principal)?.seq !== row.accessSeq) return {
|
|
2042
|
+
kind: "tampered-member",
|
|
2043
|
+
principal: row.principal,
|
|
2044
|
+
why: "stale"
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
return null;
|
|
2048
|
+
}
|
|
2049
|
+
};
|
|
2050
|
+
/** Settle every operation before releasing the member lock, including cancelled requests. */
|
|
2051
|
+
async function settle(operations, budgetMs) {
|
|
2052
|
+
const controller = new AbortController();
|
|
2053
|
+
const operation = {
|
|
2054
|
+
deadline: Date.now() + budgetMs,
|
|
2055
|
+
signal: controller.signal
|
|
2056
|
+
};
|
|
2057
|
+
const timer = setTimeout(() => controller.abort(), budgetMs);
|
|
2058
|
+
try {
|
|
2059
|
+
return {
|
|
2060
|
+
outcomes: await Promise.all(operations.map(async (work) => {
|
|
2061
|
+
try {
|
|
2062
|
+
if (operation.signal.aborted || Date.now() >= operation.deadline) throw new KekCancelledError();
|
|
2063
|
+
const value = await work(operation);
|
|
2064
|
+
return value === null ? {
|
|
2065
|
+
ok: false,
|
|
2066
|
+
code: "bad_claim"
|
|
2067
|
+
} : {
|
|
2068
|
+
ok: true,
|
|
2069
|
+
value
|
|
2070
|
+
};
|
|
2071
|
+
} catch (error) {
|
|
2072
|
+
if (error instanceof KekUnavailableError) return {
|
|
2073
|
+
ok: false,
|
|
2074
|
+
code: error.uncertain ? "kms_uncertain" : error instanceof KekCancelledError ? "cancelled" : "kms_unavailable"
|
|
2075
|
+
};
|
|
2076
|
+
return {
|
|
2077
|
+
ok: false,
|
|
2078
|
+
code: "key_error",
|
|
2079
|
+
error
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
})),
|
|
2083
|
+
expired: controller.signal.aborted || Date.now() >= operation.deadline
|
|
2084
|
+
};
|
|
2085
|
+
} finally {
|
|
2086
|
+
clearTimeout(timer);
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
function validateText(value) {
|
|
2090
|
+
if (typeof value !== "string" || /[\uD800-\uDFFF]/u.test(value)) throw new Error("key request strings must be well-formed Unicode");
|
|
2091
|
+
}
|
|
2092
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
2093
|
+
/** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
|
|
2094
|
+
function validateCorrelation(input) {
|
|
2095
|
+
if (input.requestId != null) validateText(input.requestId);
|
|
2096
|
+
if (input.operationId != null && (typeof input.operationId !== "string" || !UUID.test(input.operationId))) throw new Error(`operationId must be a lowercase UUID, got: ${String(input.operationId)}`);
|
|
2097
|
+
}
|
|
2098
|
+
/** Validate the entire batch before any provider sees a key. */
|
|
2099
|
+
function validateItems(items, field) {
|
|
2100
|
+
for (const { secret, key, wrapped } of items) {
|
|
2101
|
+
if (field === "key" && typeof key !== "string") throw new Error("DEK must be base64");
|
|
2102
|
+
if (field === "wrapped" && (wrapped === void 0 || wrapped === null)) throw new Error("wrapped key is required");
|
|
2103
|
+
checkContext(context(secret));
|
|
2104
|
+
if (!Number.isSafeInteger(secret.version) || secret.version < 1) throw new Error("secret version must be a positive integer");
|
|
2105
|
+
if (typeof secret.path !== "string" || /[\uD800-\uDFFF]/u.test(secret.path)) throw new Error("secret path must be a well-formed string");
|
|
2106
|
+
if (key !== void 0) {
|
|
2107
|
+
const dek = Buffer.from(key, "base64");
|
|
2108
|
+
try {
|
|
2109
|
+
if (dek.length !== DEK_BYTES) throw new Error(`DEK must be ${DEK_BYTES} bytes, got ${dek.length}`);
|
|
2110
|
+
if (base64(dek) !== key) throw new Error("DEK must be canonical base64");
|
|
2111
|
+
} finally {
|
|
2112
|
+
dek.fill(0);
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
if (wrapped !== void 0) {
|
|
2116
|
+
for (const field of [
|
|
2117
|
+
wrapped.kekProvider,
|
|
2118
|
+
wrapped.kekId,
|
|
2119
|
+
wrapped.kekVersion
|
|
2120
|
+
]) if (typeof field !== "string" || field.length === 0 || /[\uD800-\uDFFF]/u.test(field)) throw new Error("wrapped key metadata must be nonempty well-formed strings");
|
|
2121
|
+
const bytes = Buffer.from(wrapped.bytes, "base64");
|
|
2122
|
+
if (bytes.length === 0 || base64(bytes) !== wrapped.bytes) throw new Error("wrapped key must be nonempty canonical base64");
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
/** Why `reader` may not do `permission` on `secret`, or null if they may. */
|
|
2127
|
+
function refuses(reader, permission, secret) {
|
|
2128
|
+
if (reader.status === "tampered") return "tampered";
|
|
2129
|
+
if (reader.status === "removed") return "removed";
|
|
2130
|
+
if (reader.status === "unknown") return "not_a_member";
|
|
2131
|
+
const where = {
|
|
2132
|
+
projectId: secret.projectId,
|
|
2133
|
+
environmentId: secret.environmentId
|
|
2134
|
+
};
|
|
2135
|
+
if (allows(reader.live, permission, where)) return null;
|
|
2136
|
+
return allows(reader.all, permission, where) ? "expired" : "no_grant";
|
|
2137
|
+
}
|
|
2138
|
+
/**
|
|
2139
|
+
* An entry about a key. A new secret's row is not committed when its key is
|
|
2140
|
+
* wrapped, so a wrap names the secret in its payload; the others name it.
|
|
2141
|
+
*/
|
|
2142
|
+
function keyEntry(action, principal, secret, decision, code, correlation, detail = {}) {
|
|
2143
|
+
return {
|
|
2144
|
+
actor: principal,
|
|
2145
|
+
action,
|
|
2146
|
+
decision,
|
|
2147
|
+
code,
|
|
2148
|
+
projectId: secret.projectId,
|
|
2149
|
+
environmentId: secret.environmentId,
|
|
2150
|
+
secretId: action === "key.wrap" ? null : secret.secretId,
|
|
2151
|
+
operationId: correlation.operationId ?? null,
|
|
2152
|
+
requestId: correlation.requestId ?? null,
|
|
2153
|
+
metadata: JSON.stringify({
|
|
2154
|
+
subject: secret.path,
|
|
2155
|
+
...action === "key.wrap" ? { secretId: secret.secretId } : {},
|
|
2156
|
+
version: secret.version,
|
|
2157
|
+
...detail
|
|
2158
|
+
})
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
/** Whether `entry` changes a member's access: what their row's `access_seq` names. */
|
|
2162
|
+
function isAccessEntry(entry) {
|
|
2163
|
+
return entry.decision === "allow" && entry.subjectPrincipal !== void 0 && entry.subjectPrincipal !== null && ACCESS_ACTIONS.includes(entry.action);
|
|
2164
|
+
}
|
|
2165
|
+
/** An entry about a member's access. */
|
|
2166
|
+
function accessEntry(actor, action, principal, decision, correlation, detail, code = null) {
|
|
2167
|
+
return {
|
|
2168
|
+
actor,
|
|
2169
|
+
action,
|
|
2170
|
+
decision,
|
|
2171
|
+
code,
|
|
2172
|
+
subjectPrincipal: PRINCIPAL.test(principal) ? principal : null,
|
|
2173
|
+
operationId: correlation.operationId ?? null,
|
|
2174
|
+
requestId: correlation.requestId ?? null,
|
|
2175
|
+
metadata: JSON.stringify(PRINCIPAL.test(principal) ? detail : {
|
|
2176
|
+
subject: principal,
|
|
2177
|
+
...detail
|
|
2178
|
+
})
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
function live(grant, at) {
|
|
2182
|
+
return grant.expiresAt === null || grant.expiresAt > at;
|
|
2183
|
+
}
|
|
2184
|
+
function view(grant) {
|
|
2185
|
+
return {
|
|
2186
|
+
projectId: grant.projectId,
|
|
2187
|
+
environmentId: grant.environmentId,
|
|
2188
|
+
role: grant.role,
|
|
2189
|
+
expiresAt: grant.expiresAt === null ? null : iso(grant.expiresAt),
|
|
2190
|
+
grantedAt: iso(grant.grantedAt),
|
|
2191
|
+
grantedBy: grant.grantedBy
|
|
2192
|
+
};
|
|
2193
|
+
}
|
|
2194
|
+
function iso(ms) {
|
|
2195
|
+
return new Date(ms).toISOString();
|
|
2196
|
+
}
|
|
2197
|
+
function context(secret) {
|
|
2198
|
+
return {
|
|
2199
|
+
projectId: secret.projectId,
|
|
2200
|
+
environmentId: secret.environmentId,
|
|
2201
|
+
secretId: secret.secretId
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
function unwrappable(wrapped) {
|
|
2205
|
+
return {
|
|
2206
|
+
...wrapped,
|
|
2207
|
+
bytes: Buffer.from(wrapped.bytes, "base64")
|
|
2208
|
+
};
|
|
2209
|
+
}
|
|
2210
|
+
function serialisable(wrapped) {
|
|
2211
|
+
return {
|
|
2212
|
+
...wrapped,
|
|
2213
|
+
bytes: base64(wrapped.bytes)
|
|
2214
|
+
};
|
|
2215
|
+
}
|
|
2216
|
+
/**
|
|
2217
|
+
* Bytes as base64. Through `Buffer.from`, a view of the same memory, because
|
|
2218
|
+
* Workers' types declare their own `Buffer` and a bare one loses its
|
|
2219
|
+
* `toString(encoding)` to them.
|
|
2220
|
+
*/
|
|
2221
|
+
function base64(bytes) {
|
|
2222
|
+
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
2223
|
+
}
|
|
2224
|
+
//#endregion
|
|
2225
|
+
export { prepareVault as n, resolveVaultConfig as r, openVault as t };
|