@coffre/vault 0.1.14 → 0.1.16

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,5 +1,5 @@
1
1
  import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-1yVV2L0_.js";
2
- import { n as prepareVault, t as openVault } from "./vault-3O-ynZPg.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-BexBMpZL.js";
3
3
  import { createDatabase } from "@coffre/db";
4
4
  import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
5
5
  import { WorkerEntrypoint } from "cloudflare:workers";
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, s as resolveVaultConfig, t as KekBadClaimError } from "./src-1yVV2L0_.js";
2
- import { n as prepareVault, t as openVault } from "./vault-3O-ynZPg.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-BexBMpZL.js";
3
3
  import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
4
4
  import { Agent, createServer, request } from "node:http";
5
5
  import { openDatabase } from "@coffre/db/connect";
@@ -7,7 +7,7 @@ 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, lt, sql } from "drizzle-orm";
10
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, sql } from "drizzle-orm";
11
11
  //#region src/accounting.ts
12
12
  const KEY_ACTIONS = /* @__PURE__ */ new Set([
13
13
  "secret.read",
@@ -356,6 +356,16 @@ async function hashesAt(db, seqs) {
356
356
  }
357
357
  /** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
358
358
  const HASHES_AT_ONCE = 5e3;
359
+ /**
360
+ * Whether every entry from the first through `seq` is there. Seqs are the
361
+ * primary key, so the count is `seq + 1` exactly when none is missing: one
362
+ * pass over the key's index, never the entries.
363
+ */
364
+ async function complete(db, seq) {
365
+ const { auditLog } = tablesOf(db);
366
+ const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
367
+ return BigInt(n) === seq + 1n;
368
+ }
359
369
  /** The entry at `seq`'s hash, or undefined when there is none. */
360
370
  async function hashAt(db, seq) {
361
371
  const { auditLog } = tablesOf(db);
@@ -856,6 +866,12 @@ const TAMPERED_SUBJECT = "this member's record failed the vault's integrity chec
856
866
  const CHECKPOINT = "audit.checkpoint";
857
867
  /** Who asks for checkpoints: the app's scheduled job. */
858
868
  const SCHEDULER = "system:coffre-scheduler";
869
+ /**
870
+ * How often a checkpoint recomputes the chain from its first entry: the
871
+ * first of each hour, by the vault's clock. The others resume from the
872
+ * prefix the last one signed (`#recompute`).
873
+ */
874
+ const FULL_RECOMPUTE_MS = 36e5;
859
875
  /** A refusal and the entries that record it. */
860
876
  var Refused = class {
861
877
  refusal;
@@ -898,6 +914,10 @@ const MESSAGES = {
898
914
  wrong_kek: "this vault's key does not open the data it holds",
899
915
  tampered: "this member's record failed the vault's integrity check"
900
916
  };
917
+ /** The credential a CI run's request came in on, as its entries record it: copied, never decided on. */
918
+ function traced(correlation) {
919
+ return correlation.credentialId == null ? {} : { credentialId: correlation.credentialId };
920
+ }
901
921
  /**
902
922
  * The one implementation of `Vault`. Every decision is one transaction on
903
923
  * the shared database: it locks the rows of the members it is about, reads
@@ -1319,7 +1339,8 @@ var VaultService = class {
1319
1339
  requestId: input.requestId,
1320
1340
  metadata: JSON.stringify({
1321
1341
  ...detail,
1322
- secretVersionId: id
1342
+ secretVersionId: id,
1343
+ ...traced(input)
1323
1344
  })
1324
1345
  };
1325
1346
  return {
@@ -1408,6 +1429,7 @@ var VaultService = class {
1408
1429
  operation: action,
1409
1430
  expiresAt: at + this.#prepared.options.keyBudgetMs + 2 * LOCK_TIMEOUT_MS,
1410
1431
  ...call.input.purpose === void 0 ? {} : { purpose: call.input.purpose },
1432
+ ...traced(call.input),
1411
1433
  keys: secrets.map((secret, item) => ({
1412
1434
  item,
1413
1435
  subject: secret.path,
@@ -2008,16 +2030,53 @@ var VaultService = class {
2008
2030
  const row = await latestVaultEntry(db, [CHECKPOINT]);
2009
2031
  return row === void 0 ? null : {
2010
2032
  checkpoint: JSON.parse(row.metadata),
2011
- seq: row.seq
2033
+ seq: row.seq,
2034
+ keyId: row.keyId
2012
2035
  };
2013
2036
  }
2037
+ /**
2038
+ * The chain recomputed to its last entry, for a checkpoint to sign: from
2039
+ * its first entry at the first checkpoint of each hour, and otherwise from
2040
+ * the prefix the last checkpoint signed, if that one was signed this hour.
2041
+ *
2042
+ * Resuming never signs what the vault did not recompute. The vault
2043
+ * recomputed the last checkpoint's prefix itself, from entry 0 at the
2044
+ * hour's first checkpoint and onward at each one since, and the entry
2045
+ * that holds it is the first one checked again, by its MAC. The next
2046
+ * signature extends that same chain.
2047
+ *
2048
+ * What resuming does not do is recompute the rows of that prefix again.
2049
+ * They can change while the hash stored at its end stays, so it first
2050
+ * checks the two things that are cheap: that hash is still there, which a
2051
+ * prefix rewritten and chained again changes, and every entry of the
2052
+ * prefix is, which a cut changes. Either failing, it recomputes from entry
2053
+ * 0, which names what broke. An entry edited in place, its hash left as
2054
+ * it was, is the one change left to the next full pass, within the hour.
2055
+ *
2056
+ * It resumes only from a checkpoint under the key the vault writes with
2057
+ * now: once the log reaches that key, no other writes again (`forward`).
2058
+ * After a rotation, the next pass is full.
2059
+ */
2060
+ async #recompute(tx) {
2061
+ const { logKeys } = this.#prepared;
2062
+ const latest = await this.#latest(tx);
2063
+ const hour = (ms) => Math.floor(ms / FULL_RECOMPUTE_MS);
2064
+ if (!(latest !== null && latest.keyId === logKeys[0].keyId && latest.seq === BigInt(latest.checkpoint.seq) + 1n && hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) && await carries(tx, latest.checkpoint) && await complete(tx, BigInt(latest.checkpoint.seq)))) return verifyChain(tx, logKeys, [], UNVERIFIED);
2065
+ const { seq, hash } = latest.checkpoint;
2066
+ return verifyChain(tx, logKeys, [], {
2067
+ nextSeq: BigInt(seq) + 1n,
2068
+ hash: Buffer.from(hash, "hex"),
2069
+ vaultEntries: 0,
2070
+ vaultKeys: [logKeys[0].keyId]
2071
+ });
2072
+ }
2014
2073
  async checkpoint() {
2015
2074
  await this.#settled();
2016
2075
  const { wrongKek } = this.#prepared;
2017
2076
  const found = [];
2018
2077
  const whole = await this.#db.transaction(async (tx) => {
2019
2078
  await this.#sweep(tx, found);
2020
- return verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED);
2079
+ return this.#recompute(tx);
2021
2080
  }, SNAPSHOT);
2022
2081
  return this.#decide([], async (d) => {
2023
2082
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
@@ -2339,6 +2398,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
2339
2398
  function validateCorrelation(input) {
2340
2399
  if (input.requestId != null) validateText(input.requestId);
2341
2400
  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)}`);
2401
+ if (input.credentialId != null && (typeof input.credentialId !== "string" || !UUID.test(input.credentialId))) throw new Error(`credentialId must be a lowercase UUID, got: ${String(input.credentialId)}`);
2342
2402
  }
2343
2403
  /** Validate the entire batch before any provider sees a key. */
2344
2404
  function validateItems(items, field) {
@@ -2399,7 +2459,8 @@ function keyEntry(action, principal, secret, decision, code, correlation, detail
2399
2459
  subject: secret.path,
2400
2460
  ...action === "key.wrap" ? { secretId: secret.secretId } : {},
2401
2461
  version: secret.version,
2402
- ...detail
2462
+ ...detail,
2463
+ ...traced(correlation)
2403
2464
  })
2404
2465
  };
2405
2466
  }
@@ -2417,9 +2478,13 @@ function accessEntry(actor, action, principal, decision, correlation, detail, co
2417
2478
  subjectPrincipal: PRINCIPAL.test(principal) ? principal : null,
2418
2479
  operationId: correlation.operationId ?? null,
2419
2480
  requestId: correlation.requestId ?? null,
2420
- metadata: JSON.stringify(PRINCIPAL.test(principal) ? detail : {
2481
+ metadata: JSON.stringify(PRINCIPAL.test(principal) ? {
2482
+ ...detail,
2483
+ ...traced(correlation)
2484
+ } : {
2421
2485
  subject: principal,
2422
- ...detail
2486
+ ...detail,
2487
+ ...traced(correlation)
2423
2488
  })
2424
2489
  };
2425
2490
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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.14",
40
- "@coffre/db": "0.1.14"
39
+ "@coffre/core": "0.1.16",
40
+ "@coffre/db": "0.1.16"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/store.ts CHANGED
@@ -2,7 +2,7 @@ import type { Author, StoredEntry } from '@coffre/core/audit';
2
2
  import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
3
3
  import { tablesOf, type Queryable, type Transaction } from '@coffre/db';
4
4
  import { clockMillis, engineOf, forUpdate } from '@coffre/db/dialect';
5
- import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, sql } from 'drizzle-orm';
5
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNull, lt, lte, sql } from 'drizzle-orm';
6
6
 
7
7
  /**
8
8
  * Every query the vault makes, and the only code in it that holds them.
@@ -316,6 +316,17 @@ export async function hashesAt(db: Queryable, seqs: readonly bigint[]): Promise<
316
316
  /** How many entries' hashes one query asks for: a checkpoint every five minutes is tens of thousands of a year. */
317
317
  const HASHES_AT_ONCE = 5000;
318
318
 
319
+ /**
320
+ * Whether every entry from the first through `seq` is there. Seqs are the
321
+ * primary key, so the count is `seq + 1` exactly when none is missing: one
322
+ * pass over the key's index, never the entries.
323
+ */
324
+ export async function complete(db: Queryable, seq: bigint): Promise<boolean> {
325
+ const { auditLog } = tablesOf(db);
326
+ const [{ n }] = await db.select({ n: count() }).from(auditLog).where(and(gte(auditLog.seq, 0n), lte(auditLog.seq, seq)));
327
+ return BigInt(n) === seq + 1n;
328
+ }
329
+
319
330
  /** The entry at `seq`'s hash, or undefined when there is none. */
320
331
  export async function hashAt(db: Queryable, seq: bigint): Promise<Buffer | undefined> {
321
332
  const { auditLog } = tablesOf(db);
package/src/vault.ts CHANGED
@@ -179,6 +179,13 @@ const CHECKPOINT = 'audit.checkpoint';
179
179
  /** Who asks for checkpoints: the app's scheduled job. */
180
180
  const SCHEDULER = 'system:coffre-scheduler';
181
181
 
182
+ /**
183
+ * How often a checkpoint recomputes the chain from its first entry: the
184
+ * first of each hour, by the vault's clock. The others resume from the
185
+ * prefix the last one signed (`#recompute`).
186
+ */
187
+ const FULL_RECOMPUTE_MS = 60 * 60 * 1000;
188
+
182
189
  /** A refusal and the entries that record it. */
183
190
  class Refused {
184
191
  readonly refusal: Refusal;
@@ -258,7 +265,12 @@ type Decision = {
258
265
  type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
259
266
 
260
267
  /** What ties an entry to the app's request, and to the one action it is part of. */
261
- type Correlation = { requestId?: string | null; operationId?: string | null };
268
+ type Correlation = { requestId?: string | null; operationId?: string | null; credentialId?: string | null };
269
+
270
+ /** The credential a CI run's request came in on, as its entries record it: copied, never decided on. */
271
+ function traced(correlation: Correlation): { credentialId?: string } {
272
+ return correlation.credentialId == null ? {} : { credentialId: correlation.credentialId };
273
+ }
262
274
 
263
275
  /** Why a member's row is not the one the vault last wrote; rows.ts. */
264
276
  type Fault = 'mac' | 'stale';
@@ -707,7 +719,7 @@ class VaultService implements Vault {
707
719
  if (version === undefined) return {
708
720
  actor: input.principal, action, decision: 'deny', code: 'bad_claim',
709
721
  operationId: input.operationId, requestId: input.requestId,
710
- metadata: JSON.stringify({ ...detail, secretVersionId: id }),
722
+ metadata: JSON.stringify({ ...detail, secretVersionId: id, ...traced(input) }),
711
723
  };
712
724
  return { ...keyEntry(action, input.principal, version.secret, 'deny', 'bad_claim', input, detail), secretVersionId: id };
713
725
  }));
@@ -817,6 +829,7 @@ class VaultService implements Vault {
817
829
  // Member and outcome locks can each wait before accounting is overdue.
818
830
  expiresAt: at + this.#prepared.options.keyBudgetMs + 2 * LOCK_TIMEOUT_MS,
819
831
  ...(call.input.purpose === undefined ? {} : { purpose: call.input.purpose }),
832
+ ...traced(call.input),
820
833
  keys: secrets.map((secret, item) => ({ item, subject: secret.path, secretId: secret.secretId, version: secret.version })),
821
834
  }),
822
835
  }]);
@@ -1374,9 +1387,49 @@ class VaultService implements Vault {
1374
1387
  // --- checkpoints and the log --------------------------------------------------
1375
1388
 
1376
1389
  /** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
1377
- async #latest(db: Queryable): Promise<{ checkpoint: Checkpoint; seq: bigint } | null> {
1390
+ async #latest(db: Queryable): Promise<{ checkpoint: Checkpoint; seq: bigint; keyId: string } | null> {
1378
1391
  const row = await store.latestVaultEntry(db, [CHECKPOINT]);
1379
- return row === undefined ? null : { checkpoint: JSON.parse(row.metadata) as Checkpoint, seq: row.seq };
1392
+ return row === undefined ? null : { checkpoint: JSON.parse(row.metadata) as Checkpoint, seq: row.seq, keyId: row.keyId };
1393
+ }
1394
+
1395
+ /**
1396
+ * The chain recomputed to its last entry, for a checkpoint to sign: from
1397
+ * its first entry at the first checkpoint of each hour, and otherwise from
1398
+ * the prefix the last checkpoint signed, if that one was signed this hour.
1399
+ *
1400
+ * Resuming never signs what the vault did not recompute. The vault
1401
+ * recomputed the last checkpoint's prefix itself, from entry 0 at the
1402
+ * hour's first checkpoint and onward at each one since, and the entry
1403
+ * that holds it is the first one checked again, by its MAC. The next
1404
+ * signature extends that same chain.
1405
+ *
1406
+ * What resuming does not do is recompute the rows of that prefix again.
1407
+ * They can change while the hash stored at its end stays, so it first
1408
+ * checks the two things that are cheap: that hash is still there, which a
1409
+ * prefix rewritten and chained again changes, and every entry of the
1410
+ * prefix is, which a cut changes. Either failing, it recomputes from entry
1411
+ * 0, which names what broke. An entry edited in place, its hash left as
1412
+ * it was, is the one change left to the next full pass, within the hour.
1413
+ *
1414
+ * It resumes only from a checkpoint under the key the vault writes with
1415
+ * now: once the log reaches that key, no other writes again (`forward`).
1416
+ * After a rotation, the next pass is full.
1417
+ */
1418
+ async #recompute(tx: Queryable): Promise<{ verification: LogVerification; anchor: Anchor }> {
1419
+ const { logKeys } = this.#prepared;
1420
+ const latest = await this.#latest(tx);
1421
+ const hour = (ms: number) => Math.floor(ms / FULL_RECOMPUTE_MS);
1422
+ const resumable =
1423
+ latest !== null &&
1424
+ latest.keyId === logKeys[0].keyId &&
1425
+ latest.seq === BigInt(latest.checkpoint.seq) + 1n &&
1426
+ hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) &&
1427
+ (await carries(tx, latest.checkpoint)) &&
1428
+ (await store.complete(tx, BigInt(latest.checkpoint.seq)));
1429
+ if (!resumable) return verifyChain(tx, logKeys, [], UNVERIFIED);
1430
+ const { seq, hash } = latest.checkpoint;
1431
+ const signed: Anchor = { nextSeq: BigInt(seq) + 1n, hash: Buffer.from(hash, 'hex'), vaultEntries: 0, vaultKeys: [logKeys[0].keyId] };
1432
+ return verifyChain(tx, logKeys, [], signed);
1380
1433
  }
1381
1434
 
1382
1435
  async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
@@ -1386,14 +1439,14 @@ class VaultService implements Vault {
1386
1439
  const { wrongKek } = this.#prepared;
1387
1440
  // Then, in one snapshot and without the log's lock, so that no append
1388
1441
  // waits on it: every member's row checked, since rows changed around the
1389
- // vault write nothing to the log; and the whole chain recomputed from its
1390
- // first entry, every hash from content and every vault entry by its MAC,
1391
- // since an entry cut from the middle leaves the hashes around a later
1392
- // checkpoint as they were.
1442
+ // vault write nothing to the log; and the chain recomputed, every hash
1443
+ // from content and every vault entry by its MAC, from the first entry
1444
+ // once an hour, since an entry cut from the middle leaves the hashes
1445
+ // around a later checkpoint as they were (`#recompute`).
1393
1446
  const found: NewEntry[] = [];
1394
1447
  const whole = await this.#db.transaction(async (tx) => {
1395
1448
  await this.#sweep(tx, found);
1396
- return verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED);
1449
+ return this.#recompute(tx);
1397
1450
  }, SNAPSHOT);
1398
1451
  return this.#decide([], async (d) => {
1399
1452
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
@@ -1665,6 +1718,9 @@ function validateCorrelation(input: Correlation): void {
1665
1718
  if (input.operationId != null && (typeof input.operationId !== 'string' || !UUID.test(input.operationId))) {
1666
1719
  throw new Error(`operationId must be a lowercase UUID, got: ${String(input.operationId)}`);
1667
1720
  }
1721
+ if (input.credentialId != null && (typeof input.credentialId !== 'string' || !UUID.test(input.credentialId))) {
1722
+ throw new Error(`credentialId must be a lowercase UUID, got: ${String(input.credentialId)}`);
1723
+ }
1668
1724
  }
1669
1725
 
1670
1726
  /** Validate the entire batch before any provider sees a key. */
@@ -1733,6 +1789,7 @@ function keyEntry(
1733
1789
  ...(action === 'key.wrap' ? { secretId: secret.secretId } : {}),
1734
1790
  version: secret.version,
1735
1791
  ...detail,
1792
+ ...traced(correlation),
1736
1793
  }),
1737
1794
  };
1738
1795
  }
@@ -1766,7 +1823,7 @@ function accessEntry(
1766
1823
  subjectPrincipal: PRINCIPAL.test(principal) ? principal : null,
1767
1824
  operationId: correlation.operationId ?? null,
1768
1825
  requestId: correlation.requestId ?? null,
1769
- metadata: JSON.stringify(PRINCIPAL.test(principal) ? detail : { subject: principal, ...detail }),
1826
+ metadata: JSON.stringify(PRINCIPAL.test(principal) ? { ...detail, ...traced(correlation) } : { subject: principal, ...detail, ...traced(correlation) }),
1770
1827
  };
1771
1828
  }
1772
1829