@coffre/vault 0.1.1 → 0.1.2
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/README.md +1 -2
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/{index-qXbB_vlp.d.ts → index-CapZlhbz.d.ts} +15 -5
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -3
- package/dist/node.d.ts +2 -2
- package/dist/node.js +3 -3
- package/dist/src-D1C9S6VS.js +113 -0
- package/dist/{vault-DvTsSAOX.js → vault-D5ZahBN1.js} +231 -107
- package/package.json +3 -3
- package/src/cloudflare.ts +1 -2
- package/src/config.ts +60 -11
- package/src/index.ts +2 -0
- package/src/log.ts +54 -7
- package/src/store.ts +16 -0
- package/src/vault.ts +196 -27
|
@@ -1,87 +1,13 @@
|
|
|
1
1
|
import { ACCESS_ACTIONS, checkpointMessage, describeAccessFault, verifyCheckpoint } from "@coffre/core/vault";
|
|
2
|
-
import { DEK_BYTES, KekBadClaimError, KekCancelledError,
|
|
3
|
-
import { tablesOf } from "@coffre/db";
|
|
2
|
+
import { DEK_BYTES, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
|
|
4
3
|
import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { tablesOf } from "@coffre/db";
|
|
5
5
|
import { allows, assignableToEnvironment, isRole, isSyncPrincipal, mayManageAccess } from "@coffre/core/access";
|
|
6
6
|
import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
|
|
7
7
|
import { checkContext } from "@coffre/core/envelope";
|
|
8
8
|
import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUniqueViolation } from "@coffre/db/dialect";
|
|
9
9
|
import { appendEntries, lockLogHead } from "@coffre/db/log";
|
|
10
|
-
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
|
|
10
|
+
import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, sql } from "drizzle-orm";
|
|
85
11
|
//#region src/store.ts
|
|
86
12
|
/**
|
|
87
13
|
* On Postgres, fail a lock wait in this transaction after `ms`, rather than
|
|
@@ -258,6 +184,21 @@ async function hashAt(db, seq) {
|
|
|
258
184
|
const [row] = await db.select({ hash: auditLog.hash }).from(auditLog).where(eq(auditLog.seq, seq));
|
|
259
185
|
return row?.hash;
|
|
260
186
|
}
|
|
187
|
+
/** Up to `limit` of the vault's entries before `before` (the newest when undefined), newest first. */
|
|
188
|
+
async function vaultPage(db, before, limit) {
|
|
189
|
+
const { auditLog } = tablesOf(db);
|
|
190
|
+
return stored(await db.select(entryColumns(db)).from(auditLog).where(and(eq(auditLog.author, "vault"), before === void 0 ? void 0 : lt(auditLog.seq, before))).orderBy(desc(auditLog.seq)).limit(limit));
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The seq of the vault's first entry under `keyId`, or undefined. No index
|
|
194
|
+
* serves it: it reads the log from its start up to that entry, which for
|
|
195
|
+
* the key a log began under is among its first.
|
|
196
|
+
*/
|
|
197
|
+
async function firstVaultEntryUnder(db, keyId) {
|
|
198
|
+
const { auditLog } = tablesOf(db);
|
|
199
|
+
const [row] = await db.select({ seq: auditLog.seq }).from(auditLog).where(and(eq(auditLog.author, "vault"), eq(auditLog.keyId, keyId))).orderBy(asc(auditLog.seq)).limit(1);
|
|
200
|
+
return row?.seq;
|
|
201
|
+
}
|
|
261
202
|
/** The vault's newest entry of any of these actions, allowed, or undefined. */
|
|
262
203
|
async function latestVaultEntry(db, actions) {
|
|
263
204
|
const { auditLog } = tablesOf(db);
|
|
@@ -335,7 +276,8 @@ function vaultLogKey(signingKey) {
|
|
|
335
276
|
const UNVERIFIED = {
|
|
336
277
|
nextSeq: 0n,
|
|
337
278
|
hash: GENESIS_HASH,
|
|
338
|
-
vaultEntries: 0
|
|
279
|
+
vaultEntries: 0,
|
|
280
|
+
vaultKeys: []
|
|
339
281
|
};
|
|
340
282
|
/** The further of two anchors, when two calls verified at once. */
|
|
341
283
|
function further(a, b) {
|
|
@@ -357,17 +299,17 @@ const VERIFY_BATCH = 1e3;
|
|
|
357
299
|
* not on the page: a full check, which starts from `UNVERIFIED`, finds that,
|
|
358
300
|
* as does the first view after a start, which has no anchor.
|
|
359
301
|
*/
|
|
360
|
-
async function verifyChain(db,
|
|
302
|
+
async function verifyChain(db, logKeys, shown, anchor) {
|
|
361
303
|
const broken = (failedAtSeq, reason) => ({
|
|
362
304
|
verification: {
|
|
363
305
|
ok: false,
|
|
364
306
|
failedAtSeq: Number(failedAtSeq),
|
|
365
|
-
reason
|
|
307
|
+
reason: withCause(reason)
|
|
366
308
|
},
|
|
367
309
|
anchor
|
|
368
310
|
});
|
|
369
311
|
const keys = {
|
|
370
|
-
keys:
|
|
312
|
+
keys: logKeys,
|
|
371
313
|
chainOnly: ["app"]
|
|
372
314
|
};
|
|
373
315
|
for (const row of [...shown].sort((a, b) => a.seq < b.seq ? -1 : 1)) {
|
|
@@ -389,10 +331,13 @@ async function verifyChain(db, key, shown, anchor) {
|
|
|
389
331
|
startPrevHash: verified.hash
|
|
390
332
|
});
|
|
391
333
|
if (!result.ok) return broken(result.failedAtSeq, result.reason);
|
|
334
|
+
const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
|
|
335
|
+
if ("failedAtSeq" in moved) return broken(moved.failedAtSeq, moved.reason);
|
|
392
336
|
verified = {
|
|
393
337
|
nextSeq: result.nextSeq,
|
|
394
338
|
hash: result.head,
|
|
395
|
-
vaultEntries: verified.vaultEntries + result.authenticated
|
|
339
|
+
vaultEntries: verified.vaultEntries + result.authenticated,
|
|
340
|
+
vaultKeys: moved.keys
|
|
396
341
|
};
|
|
397
342
|
if (batch.length < 1e3) break;
|
|
398
343
|
}
|
|
@@ -405,6 +350,36 @@ async function verifyChain(db, key, shown, anchor) {
|
|
|
405
350
|
};
|
|
406
351
|
}
|
|
407
352
|
/**
|
|
353
|
+
* The vault's keys only move forward. Once its entries move from one key to
|
|
354
|
+
* the next, the one before writes no more; once they reach `current`, the
|
|
355
|
+
* key it writes with now, no other key writes again. So a key it replaced,
|
|
356
|
+
* even one that leaked, verifies what came before the rotation, and nothing
|
|
357
|
+
* after it.
|
|
358
|
+
*/
|
|
359
|
+
function forward(entries, keys, current) {
|
|
360
|
+
let moved = keys;
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
const last = moved.at(-1);
|
|
363
|
+
if (entry.author !== "vault" || entry.keyId === last) continue;
|
|
364
|
+
if (last === current || moved.includes(entry.keyId)) return {
|
|
365
|
+
failedAtSeq: entry.seq,
|
|
366
|
+
reason: `written under ${entry.keyId} after the vault moved to ${last}: a key it replaced verifies only what came before`
|
|
367
|
+
};
|
|
368
|
+
moved = [...moved, entry.keyId];
|
|
369
|
+
}
|
|
370
|
+
return { keys: moved };
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* A vault entry under a key the vault does not hold is a forgery, or one
|
|
374
|
+
* written under a key it was configured with then: its keys come from its
|
|
375
|
+
* signing key, or from its KEK when it has none. The second is the one an
|
|
376
|
+
* operator can fix.
|
|
377
|
+
*/
|
|
378
|
+
function withCause(reason) {
|
|
379
|
+
if (!/^written under vault:\S+, a key this verifier does not hold$/.test(reason)) return reason;
|
|
380
|
+
return `${reason}: either it is forged, or the vault wrote it under another KEK or signing key, which must stay configured: a KEK that was replaced stays in previousKeks`;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
408
383
|
* Whether the log still holds `head` where it was: not rewritten, nor cut
|
|
409
384
|
* back before it. A head of 64 zeros is before the first entry, which any
|
|
410
385
|
* log holds.
|
|
@@ -807,20 +782,35 @@ function sealed(key, member, grants) {
|
|
|
807
782
|
async function prepareVault(config, options = {}) {
|
|
808
783
|
return {
|
|
809
784
|
config,
|
|
810
|
-
|
|
811
|
-
logKey: vaultLogKey(config.signingKey),
|
|
785
|
+
...await keysOf(config.signingKeys),
|
|
812
786
|
options: {
|
|
813
787
|
keyBudgetMs: options.keyBudgetMs ?? KEY_BUDGET_MS,
|
|
814
788
|
clockOffset: options.clockOffset ?? (() => 0)
|
|
815
789
|
},
|
|
816
790
|
verified: UNVERIFIED,
|
|
817
791
|
rooted: /* @__PURE__ */ new Set(),
|
|
818
|
-
rowKey: rowKey(config.signingKey),
|
|
819
792
|
reported: /* @__PURE__ */ new Set(),
|
|
793
|
+
since: null,
|
|
794
|
+
superseded: null,
|
|
795
|
+
settling: null,
|
|
820
796
|
kekCheck: null,
|
|
821
797
|
wrongKek: null
|
|
822
798
|
};
|
|
823
799
|
}
|
|
800
|
+
/** The vault's own keys, from each of its signing keys, the one it signs with first. */
|
|
801
|
+
async function keysOf(seeds) {
|
|
802
|
+
const signers = await Promise.all(seeds.map((seed) => signer(seed)));
|
|
803
|
+
const logKeys = seeds.map((seed) => vaultLogKey(seed));
|
|
804
|
+
const rowKeys = seeds.map((seed) => rowKey(seed));
|
|
805
|
+
return {
|
|
806
|
+
signer: signers[0],
|
|
807
|
+
signers,
|
|
808
|
+
logKey: logKeys[0],
|
|
809
|
+
logKeys,
|
|
810
|
+
rowKey: rowKeys[0],
|
|
811
|
+
rowKeys
|
|
812
|
+
};
|
|
813
|
+
}
|
|
824
814
|
/** The vault over `db`. Cheap: on Workers, one per call, over that call's connections. */
|
|
825
815
|
function openVault(db, prepared) {
|
|
826
816
|
return new VaultService(db, prepared);
|
|
@@ -848,6 +838,8 @@ const KEY_CHECK_CONTEXT = {
|
|
|
848
838
|
};
|
|
849
839
|
/** How many stored keys a KEK with no check yet is tried on: one that opens proves it. */
|
|
850
840
|
const KEY_CHECK_SAMPLE = 3;
|
|
841
|
+
/** The vault's first entry under a new key, which the keys it replaced count only before (`#settle`). */
|
|
842
|
+
const KEY_ROTATE = "key.rotate";
|
|
851
843
|
/** A new member row, before the decision seals it (`#seal`). */
|
|
852
844
|
const UNSEALED = {
|
|
853
845
|
accessSeq: 0n,
|
|
@@ -936,6 +928,11 @@ var VaultService = class {
|
|
|
936
928
|
* fails the call.
|
|
937
929
|
*/
|
|
938
930
|
async #decide(principals, decide) {
|
|
931
|
+
const superseded = await this.#settled();
|
|
932
|
+
if (superseded !== null) return {
|
|
933
|
+
ok: false,
|
|
934
|
+
refusal: refusal("wrong_kek", superseded)
|
|
935
|
+
};
|
|
939
936
|
const reports = [];
|
|
940
937
|
for (let attempt = 1;; attempt += 1) try {
|
|
941
938
|
return await this.#decideOnce(principals, decide, reports);
|
|
@@ -964,7 +961,7 @@ var VaultService = class {
|
|
|
964
961
|
let at = d.at;
|
|
965
962
|
const accessSeq = /* @__PURE__ */ new Map();
|
|
966
963
|
if (entries.length > 0) {
|
|
967
|
-
const appended = await
|
|
964
|
+
const appended = await this.#append(tx, entries);
|
|
968
965
|
at = appended.occurredAt;
|
|
969
966
|
entries.forEach((entry, i) => {
|
|
970
967
|
if (isAccessEntry(entry)) accessSeq.set(entry.subjectPrincipal, appended.seqStart + BigInt(i));
|
|
@@ -986,7 +983,7 @@ var VaultService = class {
|
|
|
986
983
|
const entries = [...reports, ...error.entries];
|
|
987
984
|
if (entries.length > 0) await this.#db.transaction(async (tx) => {
|
|
988
985
|
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
989
|
-
await
|
|
986
|
+
await this.#append(tx, entries);
|
|
990
987
|
});
|
|
991
988
|
this.#reported(reports);
|
|
992
989
|
if (error instanceof Outage) throw error.error;
|
|
@@ -1030,7 +1027,7 @@ var VaultService = class {
|
|
|
1030
1027
|
* could lock any member out.
|
|
1031
1028
|
*/
|
|
1032
1029
|
async #integrity(db, principal, row, grants, reports) {
|
|
1033
|
-
if (row !== void 0 && !sealed(
|
|
1030
|
+
if (row !== void 0 && !this.#sealed(row, grants)) {
|
|
1034
1031
|
this.#report(reports, principal, "mac", row.mac.toString("hex"));
|
|
1035
1032
|
return "mac";
|
|
1036
1033
|
}
|
|
@@ -1048,13 +1045,25 @@ var VaultService = class {
|
|
|
1048
1045
|
}
|
|
1049
1046
|
return null;
|
|
1050
1047
|
}
|
|
1048
|
+
/** Whether `entry` carries the vault's MAC: under any of its keys before `since`, and only its current one from there. */
|
|
1051
1049
|
#authentic(entry) {
|
|
1050
|
+
const { logKeys, since } = this.#prepared;
|
|
1051
|
+
const keys = since !== null && entry.seq >= since ? logKeys.slice(0, 1) : logKeys;
|
|
1052
1052
|
return verifyEntries([entry], {
|
|
1053
1053
|
startSeq: entry.seq,
|
|
1054
1054
|
startPrevHash: entry.prevHash,
|
|
1055
|
-
keys
|
|
1055
|
+
keys
|
|
1056
1056
|
}).ok;
|
|
1057
1057
|
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Whether `row`, with these grants, carries the MAC of the vault's row
|
|
1060
|
+
* key, or of one it replaced before the rotation sealed every row again
|
|
1061
|
+
* (`#rotate`).
|
|
1062
|
+
*/
|
|
1063
|
+
#sealed(row, grants) {
|
|
1064
|
+
const { rowKeys, since } = this.#prepared;
|
|
1065
|
+
return (since === null ? rowKeys : rowKeys.slice(0, 1)).some((key) => sealed(key, row, grants));
|
|
1066
|
+
}
|
|
1058
1067
|
/** A `vault.tampered` entry, once per process for each thing found: `#reported` marks it once committed. */
|
|
1059
1068
|
#report(reports, principal, code, detail, relatedSeq = null) {
|
|
1060
1069
|
const key = `${principal}|${code}|${detail}`;
|
|
@@ -1080,10 +1089,110 @@ var VaultService = class {
|
|
|
1080
1089
|
}
|
|
1081
1090
|
/** Reports found outside a decision, committed on their own. */
|
|
1082
1091
|
async #record(reports) {
|
|
1083
|
-
if (reports.length === 0) return;
|
|
1084
|
-
await this.#db.transaction((tx) =>
|
|
1092
|
+
if (reports.length === 0 || await this.#settled() !== null) return;
|
|
1093
|
+
await this.#db.transaction((tx) => this.#append(tx, reports));
|
|
1085
1094
|
this.#reported(reports);
|
|
1086
1095
|
}
|
|
1096
|
+
/**
|
|
1097
|
+
* Null when the vault may write, once its keys are settled (`#settle`);
|
|
1098
|
+
* otherwise why not. Settled by the first call of a process; one that
|
|
1099
|
+
* fails leaves it to the next.
|
|
1100
|
+
*/
|
|
1101
|
+
async #settled() {
|
|
1102
|
+
const prepared = this.#prepared;
|
|
1103
|
+
if (prepared.since === null && prepared.superseded === null) {
|
|
1104
|
+
prepared.settling ??= this.#settle().finally(() => {
|
|
1105
|
+
prepared.settling = null;
|
|
1106
|
+
});
|
|
1107
|
+
await prepared.settling;
|
|
1108
|
+
}
|
|
1109
|
+
return prepared.superseded;
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* Which of its keys count where. Those it replaced, from the KEKs in
|
|
1113
|
+
* previousKeks, verify only what came before its first entry under its
|
|
1114
|
+
* current key, `since`, and vouch for no member row after it. The first
|
|
1115
|
+
* call finds that entry, or makes it when every entry of the vault's is
|
|
1116
|
+
* still under a key it replaced (`#rotate`). A vault whose key the log has
|
|
1117
|
+
* moved on from, or that holds no key its newest entries are under,
|
|
1118
|
+
* writes nothing: `superseded` says why.
|
|
1119
|
+
*/
|
|
1120
|
+
async #settle() {
|
|
1121
|
+
const prepared = this.#prepared;
|
|
1122
|
+
const current = prepared.logKey.keyId;
|
|
1123
|
+
for (let attempt = 1;; attempt += 1) {
|
|
1124
|
+
const rotation = await latestVaultEntry(this.#db, [KEY_ROTATE]);
|
|
1125
|
+
if (rotation?.keyId === current && this.#authentic(rotation)) {
|
|
1126
|
+
prepared.since = rotation.seq;
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
const [newest] = await vaultPage(this.#db, void 0, 1);
|
|
1130
|
+
if (newest === void 0) return;
|
|
1131
|
+
const first = await firstVaultEntryUnder(this.#db, current);
|
|
1132
|
+
if (first !== void 0 && newest.keyId === current) {
|
|
1133
|
+
prepared.since = first;
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (first === void 0 && prepared.logKeys.some((key) => key.keyId === newest.keyId)) try {
|
|
1137
|
+
prepared.since = await this.#rotate(newest.keyId);
|
|
1138
|
+
return;
|
|
1139
|
+
} catch (error) {
|
|
1140
|
+
if (attempt < 3 && error instanceof Retry) continue;
|
|
1141
|
+
throw error;
|
|
1142
|
+
}
|
|
1143
|
+
prepared.superseded = first === void 0 ? `the vault's entries are under ${newest.keyId}, a key this vault does not hold: it was given the wrong KEK or signing key, or a KEK it replaced is missing from previousKeks` : `the log moved on from this vault's key, ${current}, to ${newest.keyId}: a vault given a newer KEK replaced it, and a replaced key writes nothing more`;
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Move the vault to its current key: every member row it can vouch for
|
|
1149
|
+
* sealed again under the current row key, then a `key.rotate` entry, its
|
|
1150
|
+
* first under the key, after which the keys it replaced count for
|
|
1151
|
+
* nothing. One transaction, which locks every row as a decision does,
|
|
1152
|
+
* then the log's head. Another instance that rotated first, or a member
|
|
1153
|
+
* admitted since the rows were read, sends it back to `#settle`.
|
|
1154
|
+
*/
|
|
1155
|
+
async #rotate(from) {
|
|
1156
|
+
const reports = [];
|
|
1157
|
+
const since = await this.#db.transaction(async (tx) => {
|
|
1158
|
+
await boundLockWaits(tx, LOCK_TIMEOUT_MS);
|
|
1159
|
+
const principals = (await allMembers(tx)).map((row) => row.principal);
|
|
1160
|
+
const rows = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals);
|
|
1161
|
+
await lockLogHead(tx);
|
|
1162
|
+
const [newest] = await vaultPage(tx, void 0, 1);
|
|
1163
|
+
if (newest?.keyId !== from || (await allMembers(tx)).length !== rows.size) throw new Retry();
|
|
1164
|
+
const held = await grants(tx);
|
|
1165
|
+
for (const row of rows.values()) {
|
|
1166
|
+
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
1167
|
+
if (await this.#integrity(tx, row.principal, row, grants, reports) === null) await updateMember(tx, row.principal, { mac: memberMac(this.#prepared.rowKey, row, grants) });
|
|
1168
|
+
}
|
|
1169
|
+
const rotated = {
|
|
1170
|
+
actor: VAULT_ACTOR,
|
|
1171
|
+
action: KEY_ROTATE,
|
|
1172
|
+
decision: "allow",
|
|
1173
|
+
metadata: JSON.stringify({ from })
|
|
1174
|
+
};
|
|
1175
|
+
return (await this.#append(tx, [rotated, ...reports])).seqStart;
|
|
1176
|
+
});
|
|
1177
|
+
this.#reported(reports);
|
|
1178
|
+
return since;
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Append under the vault's current key, unless the log's newest rotation
|
|
1182
|
+
* is to a key it does not hold: a vault given a newer KEK has replaced
|
|
1183
|
+
* this one, which writes nothing more. Checked under the head's lock, so
|
|
1184
|
+
* that an instance still running with the replaced KEK, as during a
|
|
1185
|
+
* deploy, cannot write after the rotation.
|
|
1186
|
+
*/
|
|
1187
|
+
#append(tx, entries) {
|
|
1188
|
+
const prepared = this.#prepared;
|
|
1189
|
+
return appendEntries(tx, prepared.logKey, entries, async (locked) => {
|
|
1190
|
+
const rotation = await latestVaultEntry(locked, [KEY_ROTATE]);
|
|
1191
|
+
if (rotation === void 0 || prepared.logKeys.some((key) => key.keyId === rotation.keyId)) return;
|
|
1192
|
+
prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer KEK replaced it, and a replaced key writes nothing more`;
|
|
1193
|
+
throw new Error(prepared.superseded);
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1087
1196
|
async unwrap(input) {
|
|
1088
1197
|
const { principal } = input;
|
|
1089
1198
|
validateText(input.purpose);
|
|
@@ -1288,7 +1397,7 @@ var VaultService = class {
|
|
|
1288
1397
|
await check(d);
|
|
1289
1398
|
await lockLogHead(d.tx);
|
|
1290
1399
|
const at = await this.#now(d.tx);
|
|
1291
|
-
return { seq: (await
|
|
1400
|
+
return { seq: (await this.#append(d.tx, [{
|
|
1292
1401
|
actor: principal,
|
|
1293
1402
|
action: "key.intent",
|
|
1294
1403
|
decision: "allow",
|
|
@@ -1355,11 +1464,11 @@ var VaultService = class {
|
|
|
1355
1464
|
* anyone a root admin; the configuration does.
|
|
1356
1465
|
*/
|
|
1357
1466
|
async #rootRow(principal) {
|
|
1358
|
-
if (this.#prepared.rooted.has(principal)) return;
|
|
1467
|
+
if (this.#prepared.rooted.has(principal) || await this.#settled() !== null) return;
|
|
1359
1468
|
await this.#db.transaction(async (tx) => {
|
|
1360
1469
|
await lockLogHead(tx);
|
|
1361
1470
|
if (await member(tx, principal) !== void 0) return;
|
|
1362
|
-
const { occurredAt: at, seqStart } = await
|
|
1471
|
+
const { occurredAt: at, seqStart } = await this.#append(tx, [{
|
|
1363
1472
|
actor: VAULT_ACTOR,
|
|
1364
1473
|
action: "member.add",
|
|
1365
1474
|
decision: "allow",
|
|
@@ -1483,6 +1592,7 @@ var VaultService = class {
|
|
|
1483
1592
|
* looks like one.
|
|
1484
1593
|
*/
|
|
1485
1594
|
async access(principal) {
|
|
1595
|
+
await this.#settled();
|
|
1486
1596
|
if (this.#isRootAdmin(principal)) await this.#rootRow(principal);
|
|
1487
1597
|
const read = async (db, reports) => {
|
|
1488
1598
|
const [row, held, at] = await Promise.all([
|
|
@@ -1525,7 +1635,7 @@ var VaultService = class {
|
|
|
1525
1635
|
if (this.#isRootAdmin(row.principal)) continue;
|
|
1526
1636
|
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
1527
1637
|
const entry = newest.get(row.principal);
|
|
1528
|
-
if (!(entry !== void 0 && this.#authentic(entry) && sealed(
|
|
1638
|
+
if (!(entry !== void 0 && this.#authentic(entry) && this.#sealed(row, grants) && entry.seq === row.accessSeq)) await this.#integrity(db, row.principal, row, grants, reports);
|
|
1529
1639
|
}
|
|
1530
1640
|
}
|
|
1531
1641
|
setAccess(input) {
|
|
@@ -1821,6 +1931,8 @@ var VaultService = class {
|
|
|
1821
1931
|
* A KEK that opens neither is not the one that wrapped the data.
|
|
1822
1932
|
*/
|
|
1823
1933
|
async #checkKeks() {
|
|
1934
|
+
const superseded = await this.#settled();
|
|
1935
|
+
if (superseded !== null) return superseded;
|
|
1824
1936
|
const checks = /* @__PURE__ */ new Map();
|
|
1825
1937
|
for (const entry of await vaultEntriesOf(this.#db, [KEY_CHECK], -1n, VERIFY_BATCH)) {
|
|
1826
1938
|
if (!this.#authentic(entry)) continue;
|
|
@@ -1865,7 +1977,7 @@ var VaultService = class {
|
|
|
1865
1977
|
}
|
|
1866
1978
|
if (samples.length > 0 && proof === null) return mismatch;
|
|
1867
1979
|
const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
|
|
1868
|
-
await this.#db.transaction((tx) =>
|
|
1980
|
+
await this.#db.transaction((tx) => this.#append(tx, [{
|
|
1869
1981
|
actor: VAULT_ACTOR,
|
|
1870
1982
|
action: KEY_CHECK,
|
|
1871
1983
|
decision: "allow",
|
|
@@ -1886,11 +1998,12 @@ var VaultService = class {
|
|
|
1886
1998
|
};
|
|
1887
1999
|
}
|
|
1888
2000
|
async checkpoint() {
|
|
2001
|
+
await this.#settled();
|
|
1889
2002
|
const { wrongKek } = this.#prepared;
|
|
1890
2003
|
const found = [];
|
|
1891
2004
|
const whole = await this.#db.transaction(async (tx) => {
|
|
1892
2005
|
await this.#sweep(tx, found);
|
|
1893
|
-
return verifyChain(tx, this.#prepared.
|
|
2006
|
+
return verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED);
|
|
1894
2007
|
}, SNAPSHOT);
|
|
1895
2008
|
return this.#decide([], async (d) => {
|
|
1896
2009
|
for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
|
|
@@ -1911,7 +2024,7 @@ var VaultService = class {
|
|
|
1911
2024
|
});
|
|
1912
2025
|
if (latest !== null && latest.seq === head.nextSeq - 1n) return { checkpoint: latest.checkpoint };
|
|
1913
2026
|
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.
|
|
2027
|
+
const { verification: held, anchor: verified } = await verifyChain(d.tx, this.#prepared.logKeys, [], whole.anchor);
|
|
1915
2028
|
if (!held.ok) throw refused("log_broken", {
|
|
1916
2029
|
failedAtSeq: held.failedAtSeq,
|
|
1917
2030
|
reason: held.reason
|
|
@@ -1937,8 +2050,14 @@ var VaultService = class {
|
|
|
1937
2050
|
});
|
|
1938
2051
|
}
|
|
1939
2052
|
async about() {
|
|
2053
|
+
await this.#settled();
|
|
2054
|
+
const { signers, since } = this.#prepared;
|
|
2055
|
+
const until = Number(since ?? 0n);
|
|
1940
2056
|
return {
|
|
1941
|
-
|
|
2057
|
+
checkpointKeys: Object.fromEntries(signers.map(({ keyId, publicKey }, i) => [keyId, {
|
|
2058
|
+
publicKey,
|
|
2059
|
+
until: i === 0 ? null : until
|
|
2060
|
+
}])),
|
|
1942
2061
|
rootAdmins: this.#config.rootAdmins.map((email) => `user:${email}`)
|
|
1943
2062
|
};
|
|
1944
2063
|
}
|
|
@@ -1947,7 +2066,7 @@ var VaultService = class {
|
|
|
1947
2066
|
}
|
|
1948
2067
|
/** `shown`, and the chain from `anchor`; the furthest verified is kept for the next check. */
|
|
1949
2068
|
async #verify(db, shown, anchor) {
|
|
1950
|
-
const { verification, anchor: reached } = await verifyChain(db, this.#prepared.
|
|
2069
|
+
const { verification, anchor: reached } = await verifyChain(db, this.#prepared.logKeys, shown, anchor);
|
|
1951
2070
|
if (verification.ok) this.#prepared.verified = further(this.#prepared.verified, reached);
|
|
1952
2071
|
return verification;
|
|
1953
2072
|
}
|
|
@@ -1959,7 +2078,8 @@ var VaultService = class {
|
|
|
1959
2078
|
* checkpoint (`through`), and the last checkpoint's; and the members and
|
|
1960
2079
|
* grants replayed from it.
|
|
1961
2080
|
*/
|
|
1962
|
-
#verifyAll(shown, upTo) {
|
|
2081
|
+
async #verifyAll(shown, upTo) {
|
|
2082
|
+
await this.#settled();
|
|
1963
2083
|
return this.#db.transaction(async (tx) => {
|
|
1964
2084
|
const remembered = this.#prepared.verified;
|
|
1965
2085
|
const verification = await this.#verify(tx, shown, UNVERIFIED);
|
|
@@ -1999,9 +2119,10 @@ var VaultService = class {
|
|
|
1999
2119
|
}, SNAPSHOT);
|
|
2000
2120
|
}
|
|
2001
2121
|
/**
|
|
2002
|
-
* Every checkpoint, not only the newest: each signed by the
|
|
2003
|
-
* over a prefix the log still holds, entry for entry.
|
|
2004
|
-
* verified by now, so each checkpoint entry is the vault's
|
|
2122
|
+
* Every checkpoint, not only the newest: each signed by the key whose log
|
|
2123
|
+
* key wrote its entry, over a prefix the log still holds, entry for entry.
|
|
2124
|
+
* The chain is verified by now, so each checkpoint entry is the vault's,
|
|
2125
|
+
* and one under a key it replaced comes before the rotation.
|
|
2005
2126
|
*/
|
|
2006
2127
|
async #checkpointFault(db) {
|
|
2007
2128
|
for (let after = -1n;;) {
|
|
@@ -2013,7 +2134,10 @@ var VaultService = class {
|
|
|
2013
2134
|
failedAtSeq: Number(entry.seq),
|
|
2014
2135
|
reason
|
|
2015
2136
|
});
|
|
2016
|
-
|
|
2137
|
+
const { logKeys, signers } = this.#prepared;
|
|
2138
|
+
const by = signers[logKeys.findIndex((key) => key.keyId === entry.keyId)];
|
|
2139
|
+
if (by?.keyId !== checkpoint.keyId) return broken(`a checkpoint signed under ${checkpoint.keyId}, in an entry written under ${entry.keyId}: each key signs only its own`);
|
|
2140
|
+
if (!await verifyCheckpoint(checkpoint, by.publicKey)) return broken("a checkpoint the vault did not sign");
|
|
2017
2141
|
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
2142
|
}
|
|
2019
2143
|
if (batch.length < 1e3) return null;
|
|
@@ -2033,7 +2157,7 @@ var VaultService = class {
|
|
|
2033
2157
|
]);
|
|
2034
2158
|
for (const row of rows) {
|
|
2035
2159
|
const grants = held.filter((grant) => grant.principal === row.principal);
|
|
2036
|
-
if (!sealed(
|
|
2160
|
+
if (!this.#sealed(row, grants)) return {
|
|
2037
2161
|
kind: "tampered-member",
|
|
2038
2162
|
principal: row.principal,
|
|
2039
2163
|
why: "mac"
|
|
@@ -2222,4 +2346,4 @@ function base64(bytes) {
|
|
|
2222
2346
|
return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).toString("base64");
|
|
2223
2347
|
}
|
|
2224
2348
|
//#endregion
|
|
2225
|
-
export { prepareVault as n,
|
|
2349
|
+
export { prepareVault as n, openVault as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/vault",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"drizzle-orm": "0.45.2",
|
|
39
|
-
"@coffre/core": "0.1.
|
|
40
|
-
"@coffre/db": "0.1.
|
|
39
|
+
"@coffre/core": "0.1.2",
|
|
40
|
+
"@coffre/db": "0.1.2"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "26.1.1",
|
package/src/cloudflare.ts
CHANGED
|
@@ -8,9 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* export default vault((env: Env) => ({
|
|
10
10
|
* database: postgres(env.HYPERDRIVE),
|
|
11
|
-
* kek: { id: 'kek-1', key: env.KEK }, // or awsKms({ keyArn, credentials })
|
|
11
|
+
* kek: { id: 'kek-1', key: env.KEK }, // or awsKms({ keyArn, credentials }), with a signingKey
|
|
12
12
|
* rootAdmins: ['admin@acme.example'],
|
|
13
|
-
* signingKey: env.SIGNING_KEY,
|
|
14
13
|
* }));
|
|
15
14
|
*
|
|
16
15
|
* Any number of isolates run it side by side: every decision locks what it
|