@coffre/vault 0.14.3 → 0.14.4

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-NM5NsYFs.js";
2
- import { n as prepareVault, t as openVault } from "./vault-B4IivgU8.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-BBdXsny3.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-NM5NsYFs.js";
2
- import { n as prepareVault, t as openVault } from "./vault-B4IivgU8.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-BBdXsny3.js";
3
3
  import { isUnavailable } from "@coffre/db/dialect";
4
4
  import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
5
5
  import { Agent, createServer, request } from "node:http";
@@ -1216,17 +1216,16 @@ function snapshotQuery$1(asked) {
1216
1216
  WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
1217
1217
  ),
1218
1218
  'released', ${released === void 0 ? sql`0` : sql`(
1219
- SELECT count(DISTINCT key) FROM (
1220
- -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
1221
- -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
1222
- SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
1223
- -- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
1224
- LEFT JOIN LATERAL jsonb_path_query(CASE WHEN e.secret_id IS NULL THEN e.metadata::jsonb END, 'strict $.secrets ? (@.type() == "array")[*]', '{}', true) AS one ON true
1225
- WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
1226
- AND e.occurred_at > ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs}
1227
- UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
1228
- ) AS released (key)
1229
- )`}
1219
+ -- The keys released within the window, but those asked for, up to one past the cap: a row for each key the
1220
+ -- principal was ever released, however many reads of them the log holds (vault_releases, which the database
1221
+ -- keeps from it). The bound is a subquery, evaluated once: clock_timestamp() alone is volatile, and runs a row.
1222
+ SELECT count(*) FROM (
1223
+ SELECT 1 FROM vault_releases
1224
+ WHERE principal = ${released.principal} AND released_at > (SELECT ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs})
1225
+ AND key <> ALL(${sql.param([...released.keys])}::text[])
1226
+ LIMIT ${released.cap + 1}
1227
+ ) AS before
1228
+ ) + (SELECT count(DISTINCT key) FROM unnest(${sql.param([...released.keys])}::text[]) AS asked (key))`}
1230
1229
  ) AS snapshot`;
1231
1230
  }
1232
1231
  //#endregion
@@ -1307,17 +1306,14 @@ function snapshotQuery(asked) {
1307
1306
  WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
1308
1307
  )),
1309
1308
  'released', ${released === void 0 ? sql`0` : sql`(
1310
- SELECT count(DISTINCT key) FROM (
1311
- -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
1312
- -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
1313
- SELECT coalesce(e.secret_id, CASE WHEN one.type = 'object' AND json_type(one.value, '$.secretId') = 'text' THEN json_extract(one.value, '$.secretId') END, 'entry:' || e.seq) AS key
1314
- FROM audit_log e
1315
- LEFT JOIN json_each(CASE WHEN e.secret_id IS NULL AND json_type(e.metadata, '$.secrets') = 'array' THEN e.metadata END, '$.secrets') AS one
1316
- WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
1317
- AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
1318
- UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
1309
+ -- The keys released within the window, but those asked for, up to one past the cap (snapshot.postgres.ts).
1310
+ SELECT count(*) FROM (
1311
+ SELECT 1 FROM vault_releases
1312
+ WHERE principal = ${released.principal} AND released_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
1313
+ AND key NOT IN (SELECT value FROM json_each(${JSON.stringify(released.keys)}))
1314
+ LIMIT ${released.cap + 1}
1319
1315
  )
1320
- )`}
1316
+ ) + (SELECT count(DISTINCT value) FROM json_each(${JSON.stringify(released.keys)}))`}
1321
1317
  ) AS snapshot`;
1322
1318
  }
1323
1319
  //#endregion
@@ -2008,7 +2004,8 @@ var VaultService = class {
2008
2004
  ...released === void 0 ? {} : { released: {
2009
2005
  ...released,
2010
2006
  windowMs: this.#config.bulkLimit.windowMs,
2011
- clockOffsetMs: this.#prepared.options.clockOffset()
2007
+ clockOffsetMs: this.#prepared.options.clockOffset(),
2008
+ cap: this.#config.bulkLimit.count
2012
2009
  } }
2013
2010
  });
2014
2011
  }
@@ -4057,9 +4054,9 @@ var VaultService = class {
4057
4054
  const { wrongKek, logKeys } = this.#prepared;
4058
4055
  const { walks } = this.#prepared.options;
4059
4056
  const found = [];
4060
- let seen = null;
4057
+ let seen;
4061
4058
  const resumed = await this.#db.transaction(async (tx) => {
4062
- seen = (await newestCheckpointEntry(tx))?.seq ?? null;
4059
+ seen = await newestCheckpointEntry(tx);
4063
4060
  await this.#sweep(tx, found);
4064
4061
  const from = await this.#resume(tx, "newest");
4065
4062
  if (!from.ok) return from;
@@ -4111,10 +4108,14 @@ var VaultService = class {
4111
4108
  reason: scrub.reason
4112
4109
  });
4113
4110
  const newest = latest === void 0 ? null : JSON.parse(latest.metadata);
4114
- if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
4111
+ if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== (seen?.seq ?? null)) {
4115
4112
  const standing = await this.#standingRefusal(d.tx);
4116
4113
  if (standing !== null) throw refused("log_broken", standing);
4117
4114
  if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
4115
+ } else if (seen?.decision === "deny" && walked.ok) {
4116
+ const standing = await this.#standingRefusal(d.tx);
4117
+ const named = typeof standing?.failedAtSeq === "number" ? BigInt(standing.failedAtSeq) : null;
4118
+ if (named !== null && named <= walked.from.at.seq && !await this.#entryHolds(d.tx, named)) throw refused("log_broken", standing);
4118
4119
  }
4119
4120
  if (newest !== null && latest.seq === head.nextSeq - 1n && latest.relatedSeq === latest.seq - 1n) return { checkpoint: signedPart(newest) };
4120
4121
  if (newest !== null && !await carries(d.tx, newest)) throw refused("log_broken", { reason: `the log up to entry ${newest.seq} is not the prefix the last checkpoint signed` });
@@ -4252,6 +4253,30 @@ var VaultService = class {
4252
4253
  };
4253
4254
  }
4254
4255
  /**
4256
+ * Whether entry `seq` is as the walk checks each: there, its hash its
4257
+ * own, linked to the entry before, and the vault's under its MAC; and the
4258
+ * entry after it, if any, linked to it, for a refusal can name either side
4259
+ * of a broken link (`forward`, at the end of a batch).
4260
+ */
4261
+ async #entryHolds(db, seq) {
4262
+ const found = await entriesAt(db, seq === 0n ? [seq, seq + 1n] : [
4263
+ seq - 1n,
4264
+ seq,
4265
+ seq + 1n
4266
+ ]);
4267
+ const entry = found.get(seq);
4268
+ const before = seq === 0n ? { hash: GENESIS_HASH } : found.get(seq - 1n);
4269
+ if (entry === void 0 || before === void 0) return false;
4270
+ const after = found.get(seq + 1n);
4271
+ if (after !== void 0 && !after.prevHash.equals(entry.hash)) return false;
4272
+ return verifyEntries([entry], {
4273
+ keys: this.#keysFor(entry),
4274
+ chainOnly: ["app"],
4275
+ startSeq: seq,
4276
+ startPrevHash: before.hash
4277
+ }).ok;
4278
+ }
4279
+ /**
4255
4280
  * The newest refusal for a broken log since the newest checkpoint was
4256
4281
  * signed, by the vault's MAC, as it said: it stands, for every
4257
4282
  * verification and checkpoint, until a checkpoint is signed, which takes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.14.3",
3
+ "version": "0.14.4",
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.14.3",
40
- "@coffre/db": "0.14.3"
39
+ "@coffre/core": "0.14.4",
40
+ "@coffre/db": "0.14.4"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
@@ -82,16 +82,15 @@ export function snapshotQuery(asked: Asked): SQL {
82
82
  'released', ${released === undefined
83
83
  ? sql`0`
84
84
  : sql`(
85
- SELECT count(DISTINCT key) FROM (
86
- -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
87
- -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
88
- SELECT coalesce(e.secret_id::text, CASE jsonb_typeof(one -> 'secretId') WHEN 'string' THEN one ->> 'secretId' END, 'entry:' || e.seq) FROM audit_log e
89
- -- Each item of a list, never what lax paths would make of an object; silent, as strict paths fail on what is missing.
90
- LEFT JOIN LATERAL jsonb_path_query(CASE WHEN e.secret_id IS NULL THEN e.metadata::jsonb END, 'strict $.secrets ? (@.type() == "array")[*]', '{}', true) AS one ON true
91
- WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
92
- AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
93
- UNION ALL SELECT unnest(${sql.param([...released.keys])}::text[])
94
- ) AS released (key)
95
- )`}
85
+ -- The keys released within the window, but those asked for, up to one past the cap: a row for each key the
86
+ -- principal was ever released, however many reads of them the log holds (vault_releases, which the database
87
+ -- keeps from it). The bound is a subquery, evaluated once: clock_timestamp() alone is volatile, and runs a row.
88
+ SELECT count(*) FROM (
89
+ SELECT 1 FROM vault_releases
90
+ WHERE principal = ${released.principal} AND released_at > (SELECT ${NOW} + ${released.clockOffsetMs} - ${released.windowMs})
91
+ AND key <> ALL(${sql.param([...released.keys])}::text[])
92
+ LIMIT ${released.cap + 1}
93
+ ) AS before
94
+ ) + (SELECT count(DISTINCT key) FROM unnest(${sql.param([...released.keys])}::text[]) AS asked (key))`}
96
95
  ) AS snapshot`;
97
96
  }
@@ -88,16 +88,13 @@ export function snapshotQuery(asked: Asked): SQL {
88
88
  'released', ${released === undefined
89
89
  ? sql`0`
90
90
  : sql`(
91
- SELECT count(DISTINCT key) FROM (
92
- -- A read of one key names it; of several at one place, lists them (readsByPlace, vault.ts). An entry, or an
93
- -- item of its list, that names no key, which only a forged entry is, counts as one key of its own.
94
- SELECT coalesce(e.secret_id, CASE WHEN one.type = 'object' AND json_type(one.value, '$.secretId') = 'text' THEN json_extract(one.value, '$.secretId') END, 'entry:' || e.seq) AS key
95
- FROM audit_log e
96
- LEFT JOIN json_each(CASE WHEN e.secret_id IS NULL AND json_type(e.metadata, '$.secrets') = 'array' THEN e.metadata END, '$.secrets') AS one
97
- WHERE e.author = 'vault' AND e.actor = ${released.principal} AND e.action = 'secret.read' AND e.decision = 'allow'
98
- AND e.occurred_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
99
- UNION ALL SELECT value FROM json_each(${JSON.stringify(released.keys)})
91
+ -- The keys released within the window, but those asked for, up to one past the cap (snapshot.postgres.ts).
92
+ SELECT count(*) FROM (
93
+ SELECT 1 FROM vault_releases
94
+ WHERE principal = ${released.principal} AND released_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
95
+ AND key NOT IN (SELECT value FROM json_each(${JSON.stringify(released.keys)}))
96
+ LIMIT ${released.cap + 1}
100
97
  )
101
- )`}
98
+ ) + (SELECT count(DISTINCT value) FROM json_each(${JSON.stringify(released.keys)}))`}
102
99
  ) AS snapshot`;
103
100
  }
package/src/snapshot.ts CHANGED
@@ -49,7 +49,9 @@ export type Snapshot = {
49
49
  /**
50
50
  * How many distinct keys `asked.released.principal` holds released within
51
51
  * its window once it is given `asked.released.keys` too: a key released
52
- * again, or asked for again, counts once.
52
+ * again, or asked for again, counts once. Those released before are
53
+ * counted up to one past `asked.released.cap`, which is enough to say
54
+ * whether the read goes past it.
53
55
  */
54
56
  released: number;
55
57
  /** The log's head, locked, with the last entry and the newest `key.rotate`; null unless asked, or held by another. */
@@ -74,9 +76,11 @@ export type Asked = {
74
76
  references?: readonly bigint[];
75
77
  /**
76
78
  * One principal's releases within the last `windowMs` of the clock, moved
77
- * on by `clockOffsetMs` as the vault's is, with `keys`, secret ids, besides.
79
+ * on by `clockOffsetMs` as the vault's is, with `keys`, secret ids, besides;
80
+ * read from `vault_releases`, which the database keeps from the log, up to
81
+ * `cap` + 1 of them.
78
82
  */
79
- released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number };
83
+ released?: { principal: string; keys: readonly string[]; windowMs: number; clockOffsetMs: number; cap: number };
80
84
  /** Lock the log's head first. */
81
85
  head?: boolean;
82
86
  };
package/src/vault.ts CHANGED
@@ -498,7 +498,7 @@ class VaultService implements Vault {
498
498
  ...rest,
499
499
  principals,
500
500
  head,
501
- ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset() } }),
501
+ ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
502
502
  });
503
503
  }
504
504
 
@@ -2578,9 +2578,9 @@ class VaultService implements Vault {
2578
2578
  const { walks } = this.#prepared.options;
2579
2579
  const found: NewEntry[] = [];
2580
2580
  // The newest checkpoint entry, signed or refused, as this call's snapshot found it.
2581
- let seen: bigint | null = null;
2581
+ let seen: StoredEntry | undefined;
2582
2582
  const resumed = await this.#db.transaction(async (tx) => {
2583
- seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
2583
+ seen = await store.newestCheckpointEntry(tx);
2584
2584
  await this.#sweep(tx, found);
2585
2585
  const from = await this.#resume(tx, 'newest');
2586
2586
  if (!from.ok) return from;
@@ -2625,10 +2625,17 @@ class VaultService implements Vault {
2625
2625
  // Another call checkpointed, or refused to, since this one's snapshot, or this one's rehash of the past
2626
2626
  // could not run: what it would sign over is not what it checked. A refusal for a broken log stands,
2627
2627
  // and a checkpoint signed meanwhile is the newest prefix; the next call starts from them.
2628
- if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
2628
+ if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== (seen?.seq ?? null)) {
2629
2629
  const standing = await this.#standingRefusal(d.tx);
2630
2630
  if (standing !== null) throw refused('log_broken', standing);
2631
2631
  if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
2632
+ } else if (seen?.decision === 'deny' && walked.ok) {
2633
+ // A refusal for a broken log stands since this call's snapshot, of an entry before where its walk began, which
2634
+ // it never read: that entry again, as the walk checks each, before anything is signed over it. Still broken,
2635
+ // the refusal stands; whole again, the signature clears it (T104).
2636
+ const standing = await this.#standingRefusal(d.tx);
2637
+ const named = typeof standing?.failedAtSeq === 'number' ? BigInt(standing.failedAtSeq) : null;
2638
+ if (named !== null && named <= walked.from.at.seq && !(await this.#entryHolds(d.tx, named))) throw refused('log_broken', standing!);
2632
2639
  }
2633
2640
  // Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
2634
2641
  if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
@@ -2722,6 +2729,22 @@ class VaultService implements Vault {
2722
2729
  return { ok: true, next: null };
2723
2730
  }
2724
2731
 
2732
+ /**
2733
+ * Whether entry `seq` is as the walk checks each: there, its hash its
2734
+ * own, linked to the entry before, and the vault's under its MAC; and the
2735
+ * entry after it, if any, linked to it, for a refusal can name either side
2736
+ * of a broken link (`forward`, at the end of a batch).
2737
+ */
2738
+ async #entryHolds(db: Queryable, seq: bigint): Promise<boolean> {
2739
+ const found = await store.entriesAt(db, seq === 0n ? [seq, seq + 1n] : [seq - 1n, seq, seq + 1n]);
2740
+ const entry = found.get(seq);
2741
+ const before = seq === 0n ? { hash: GENESIS_HASH } : found.get(seq - 1n);
2742
+ if (entry === undefined || before === undefined) return false;
2743
+ const after = found.get(seq + 1n);
2744
+ if (after !== undefined && !after.prevHash.equals(entry.hash)) return false;
2745
+ return verifyEntries([entry], { keys: this.#keysFor(entry), chainOnly: ['app'], startSeq: seq, startPrevHash: before.hash }).ok;
2746
+ }
2747
+
2725
2748
  /**
2726
2749
  * The newest refusal for a broken log since the newest checkpoint was
2727
2750
  * signed, by the vault's MAC, as it said: it stands, for every