@coffre/vault 0.1.14 → 0.1.15

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-BmEa1SIQ.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-BmEa1SIQ.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;
@@ -2008,16 +2024,53 @@ var VaultService = class {
2008
2024
  const row = await latestVaultEntry(db, [CHECKPOINT]);
2009
2025
  return row === void 0 ? null : {
2010
2026
  checkpoint: JSON.parse(row.metadata),
2011
- seq: row.seq
2027
+ seq: row.seq,
2028
+ keyId: row.keyId
2012
2029
  };
2013
2030
  }
2031
+ /**
2032
+ * The chain recomputed to its last entry, for a checkpoint to sign: from
2033
+ * its first entry at the first checkpoint of each hour, and otherwise from
2034
+ * the prefix the last checkpoint signed, if that one was signed this hour.
2035
+ *
2036
+ * Resuming never signs what the vault did not recompute. The vault
2037
+ * recomputed the last checkpoint's prefix itself, from entry 0 at the
2038
+ * hour's first checkpoint and onward at each one since, and the entry
2039
+ * that holds it is the first one checked again, by its MAC. The next
2040
+ * signature extends that same chain.
2041
+ *
2042
+ * What resuming does not do is recompute the rows of that prefix again.
2043
+ * They can change while the hash stored at its end stays, so it first
2044
+ * checks the two things that are cheap: that hash is still there, which a
2045
+ * prefix rewritten and chained again changes, and every entry of the
2046
+ * prefix is, which a cut changes. Either failing, it recomputes from entry
2047
+ * 0, which names what broke. An entry edited in place, its hash left as
2048
+ * it was, is the one change left to the next full pass, within the hour.
2049
+ *
2050
+ * It resumes only from a checkpoint under the key the vault writes with
2051
+ * now: once the log reaches that key, no other writes again (`forward`).
2052
+ * After a rotation, the next pass is full.
2053
+ */
2054
+ async #recompute(tx) {
2055
+ const { logKeys } = this.#prepared;
2056
+ const latest = await this.#latest(tx);
2057
+ const hour = (ms) => Math.floor(ms / FULL_RECOMPUTE_MS);
2058
+ 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);
2059
+ const { seq, hash } = latest.checkpoint;
2060
+ return verifyChain(tx, logKeys, [], {
2061
+ nextSeq: BigInt(seq) + 1n,
2062
+ hash: Buffer.from(hash, "hex"),
2063
+ vaultEntries: 0,
2064
+ vaultKeys: [logKeys[0].keyId]
2065
+ });
2066
+ }
2014
2067
  async checkpoint() {
2015
2068
  await this.#settled();
2016
2069
  const { wrongKek } = this.#prepared;
2017
2070
  const found = [];
2018
2071
  const whole = await this.#db.transaction(async (tx) => {
2019
2072
  await this.#sweep(tx, found);
2020
- return verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED);
2073
+ return this.#recompute(tx);
2021
2074
  }, SNAPSHOT);
2022
2075
  return this.#decide([], async (d) => {
2023
2076
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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.15",
40
+ "@coffre/db": "0.1.15"
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;
@@ -1374,9 +1381,49 @@ class VaultService implements Vault {
1374
1381
  // --- checkpoints and the log --------------------------------------------------
1375
1382
 
1376
1383
  /** 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> {
1384
+ async #latest(db: Queryable): Promise<{ checkpoint: Checkpoint; seq: bigint; keyId: string } | null> {
1378
1385
  const row = await store.latestVaultEntry(db, [CHECKPOINT]);
1379
- return row === undefined ? null : { checkpoint: JSON.parse(row.metadata) as Checkpoint, seq: row.seq };
1386
+ return row === undefined ? null : { checkpoint: JSON.parse(row.metadata) as Checkpoint, seq: row.seq, keyId: row.keyId };
1387
+ }
1388
+
1389
+ /**
1390
+ * The chain recomputed to its last entry, for a checkpoint to sign: from
1391
+ * its first entry at the first checkpoint of each hour, and otherwise from
1392
+ * the prefix the last checkpoint signed, if that one was signed this hour.
1393
+ *
1394
+ * Resuming never signs what the vault did not recompute. The vault
1395
+ * recomputed the last checkpoint's prefix itself, from entry 0 at the
1396
+ * hour's first checkpoint and onward at each one since, and the entry
1397
+ * that holds it is the first one checked again, by its MAC. The next
1398
+ * signature extends that same chain.
1399
+ *
1400
+ * What resuming does not do is recompute the rows of that prefix again.
1401
+ * They can change while the hash stored at its end stays, so it first
1402
+ * checks the two things that are cheap: that hash is still there, which a
1403
+ * prefix rewritten and chained again changes, and every entry of the
1404
+ * prefix is, which a cut changes. Either failing, it recomputes from entry
1405
+ * 0, which names what broke. An entry edited in place, its hash left as
1406
+ * it was, is the one change left to the next full pass, within the hour.
1407
+ *
1408
+ * It resumes only from a checkpoint under the key the vault writes with
1409
+ * now: once the log reaches that key, no other writes again (`forward`).
1410
+ * After a rotation, the next pass is full.
1411
+ */
1412
+ async #recompute(tx: Queryable): Promise<{ verification: LogVerification; anchor: Anchor }> {
1413
+ const { logKeys } = this.#prepared;
1414
+ const latest = await this.#latest(tx);
1415
+ const hour = (ms: number) => Math.floor(ms / FULL_RECOMPUTE_MS);
1416
+ const resumable =
1417
+ latest !== null &&
1418
+ latest.keyId === logKeys[0].keyId &&
1419
+ latest.seq === BigInt(latest.checkpoint.seq) + 1n &&
1420
+ hour(Date.parse(latest.checkpoint.signedAt)) === hour(await this.#now(tx)) &&
1421
+ (await carries(tx, latest.checkpoint)) &&
1422
+ (await store.complete(tx, BigInt(latest.checkpoint.seq)));
1423
+ if (!resumable) return verifyChain(tx, logKeys, [], UNVERIFIED);
1424
+ const { seq, hash } = latest.checkpoint;
1425
+ const signed: Anchor = { nextSeq: BigInt(seq) + 1n, hash: Buffer.from(hash, 'hex'), vaultEntries: 0, vaultKeys: [logKeys[0].keyId] };
1426
+ return verifyChain(tx, logKeys, [], signed);
1380
1427
  }
1381
1428
 
1382
1429
  async checkpoint(): Promise<Outcome<{ checkpoint: Checkpoint }>> {
@@ -1386,14 +1433,14 @@ class VaultService implements Vault {
1386
1433
  const { wrongKek } = this.#prepared;
1387
1434
  // Then, in one snapshot and without the log's lock, so that no append
1388
1435
  // 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.
1436
+ // vault write nothing to the log; and the chain recomputed, every hash
1437
+ // from content and every vault entry by its MAC, from the first entry
1438
+ // once an hour, since an entry cut from the middle leaves the hashes
1439
+ // around a later checkpoint as they were (`#recompute`).
1393
1440
  const found: NewEntry[] = [];
1394
1441
  const whole = await this.#db.transaction(async (tx) => {
1395
1442
  await this.#sweep(tx, found);
1396
- return verifyChain(tx, this.#prepared.logKeys, [], UNVERIFIED);
1443
+ return this.#recompute(tx);
1397
1444
  }, SNAPSHOT);
1398
1445
  return this.#decide([], async (d) => {
1399
1446
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);