@coffre/vault 0.1.0 → 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.
@@ -1,87 +1,13 @@
1
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";
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, key, shown, anchor) {
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: [key],
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
- signer: await signer(config.signingKey),
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 appendEntries(tx, this.#prepared.logKey, entries);
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 appendEntries(tx, this.#prepared.logKey, entries);
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(this.#prepared.rowKey, row, grants)) {
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: [this.#prepared.logKey]
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) => appendEntries(tx, this.#prepared.logKey, reports));
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 appendEntries(d.tx, this.#prepared.logKey, [{
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 appendEntries(tx, this.#prepared.logKey, [{
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(this.#prepared.rowKey, row, grants) && entry.seq === row.accessSeq)) await this.#integrity(db, row.principal, row, grants, reports);
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) => appendEntries(tx, this.#prepared.logKey, [{
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.logKey, [], UNVERIFIED);
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.logKey, [], whole.anchor);
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
- publicKey: this.#prepared.signer.publicKey,
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.logKey, shown, anchor);
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 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.
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
- if (!await verifyCheckpoint(checkpoint, this.#prepared.signer.publicKey)) return broken("a checkpoint the vault did not sign");
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(this.#prepared.rowKey, row, grants)) return {
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, resolveVaultConfig as r, openVault as t };
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.0",
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.0",
40
- "@coffre/db": "0.1.0"
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