@coffre/vault 0.14.3 → 0.14.5

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,4 +1,4 @@
1
- import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-CIq4i_KT.js";
1
+ import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-DQ-FKVF3.js";
2
2
  import { AdmitInput, ChangePolicyInput, EndReferencesInput, ReferenceInput, RemoveInput, RewrapInput, SetAccessInput, SetSettingsInput, UnwrapInput, Vault, VerifyLogInput, WrapInput } from "@coffre/core/vault";
3
3
  import { PostgresDatabase, PostgresDatabase as PostgresDatabase$1, postgres } from "@coffre/db/hyperdrive";
4
4
  import { WorkerEntrypoint } from "cloudflare:workers";
@@ -16,10 +16,7 @@ export type WorkersVaultConfig = VaultConfig & {
16
16
  */
17
17
  export declare class VaultEntrypoint extends WorkerEntrypoint implements Vault {
18
18
  #private;
19
- unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<{
20
- keys: string[];
21
- accessRevision?: string;
22
- }>>;
19
+ unwrap(input: UnwrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Unwrapped>>;
23
20
  wrap(input: WrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
24
21
  rewrap(input: RewrapInput): Promise<import("@coffre/core/vault").Outcome<import("@coffre/core/vault").Wrapped>>;
25
22
  reference(input: ReferenceInput): Promise<import("@coffre/core/vault").Outcome<{
@@ -1,5 +1,5 @@
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";
1
+ import { a as verifyCheckpoint, c as resolveVaultConfig, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-Cbi45kLB.js";
3
3
  import { createDatabase } from "@coffre/db";
4
4
  import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
5
5
  import { WorkerEntrypoint } from "cloudflare:workers";
@@ -36,7 +36,10 @@ type VaultConfig = {
36
36
  * service holds the KEK, as AWS KMS does: the vault never sees that key.
37
37
  */
38
38
  signingKey?: string;
39
- /** At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
39
+ /**
40
+ * At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. The
41
+ * window is a day at most: the releases it counts are kept a day and an hour (`prune_vault_releases`).
42
+ */
40
43
  bulkLimit?: {
41
44
  count: number;
42
45
  windowMinutes: number;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-CIq4i_KT.js";
1
+ import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-DQ-FKVF3.js";
2
2
  export type * from "@coffre/core/vault";
3
3
  export { type AwsCredentials, type AwsKmsOptions, type Kek, KekBadClaimError, type KekProvider, KekUnavailableError, type SecretContext, type VaultConfig, type WrappedDek, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, t as KekBadClaimError } from "./src-NM5NsYFs.js";
1
+ import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
2
2
  export { KekBadClaimError, KekUnavailableError, awsKms, checkpointMessage, derivedSigningKey, verifyCheckpoint };
package/dist/node.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-CIq4i_KT.js";
1
+ import { a as KekUnavailableError, c as awsKms, d as Kek, f as VaultConfig, i as KekProvider, l as checkpointMessage, n as AwsKmsOptions, o as SecretContext, p as derivedSigningKey, r as KekBadClaimError, s as WrappedDek, t as AwsCredentials, u as verifyCheckpoint } from "./index-DQ-FKVF3.js";
2
2
  import { Vault, Vault as Vault$1 } from "@coffre/core/vault";
3
3
  import { Database } from "@coffre/db";
4
4
  import "@coffre/core/access";
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
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";
1
+ import { a as verifyCheckpoint, c as resolveVaultConfig, i as checkpointMessage, n as KekUnavailableError, r as awsKms, s as derivedSigningKey, t as KekBadClaimError } from "./src-Bg0ZeMJW.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-Cbi45kLB.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";
@@ -11,6 +11,13 @@ const DEFAULT_BULK_LIMIT = {
11
11
  count: 1e3,
12
12
  windowMs: 9e5
13
13
  };
14
+ /**
15
+ * The longest bulk limit window: a day. The releases a window counts are
16
+ * kept a day and an hour (migration 0021's `prune_vault_releases`, and on
17
+ * SQLite `RELEASES_KEPT_MS`), so every window up to it, and one widened
18
+ * since, counts exactly.
19
+ */
20
+ const MAX_BULK_WINDOW_MINUTES = 1440;
14
21
  function key32(value, what) {
15
22
  const raw = Buffer.from(value, "base64");
16
23
  if (raw.length !== 32) throw new Error(`${what} must be 32 bytes, base64; got ${raw.length} bytes`);
@@ -76,7 +83,7 @@ function signingKeys(given, keks) {
76
83
  return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
77
84
  }
78
85
  function checkBulkLimit({ count, windowMinutes }) {
79
- 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");
86
+ if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > 1440) throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most`);
80
87
  return {
81
88
  count,
82
89
  windowMs: windowMinutes * 6e4
@@ -105,4 +112,4 @@ function checkOwners(emails) {
105
112
  return [...new Set(owners.map((entry) => entry.toLowerCase()))];
106
113
  }
107
114
  //#endregion
108
- export { verifyCheckpoint as a, checkpointMessage$1 as i, KekUnavailableError$1 as n, derivedSigningKey as o, awsKms as r, resolveVaultConfig as s, KekBadClaimError$1 as t };
115
+ export { verifyCheckpoint as a, resolveVaultConfig as c, checkpointMessage$1 as i, KekUnavailableError$1 as n, MAX_BULK_WINDOW_MINUTES as o, awsKms as r, derivedSigningKey as s, KekBadClaimError$1 as t };
@@ -1,4 +1,5 @@
1
- import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, accessRevision, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
1
+ import { o as MAX_BULK_WINDOW_MINUTES } from "./src-Bg0ZeMJW.js";
2
+ import { ACCESS_ACTIONS, ACCESS_ACTIONS as ACCESS_ACTIONS$1, POLICY_REVISION, USE_METADATA_LENGTH, accessRevision, checkpointMessage, checkpointVerifier, describeAccessFault, handsBack, policyRevision } from "@coffre/core/vault";
2
3
  import { DEK_BYTES, KEY_CHECK, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE, KekBadClaimError, KekCancelledError, KekUnavailableError, LocalKekProvider } from "@coffre/core/kek";
3
4
  import { prepared, tablesOf } from "@coffre/db";
4
5
  import { createHash, createHmac, hkdfSync, randomUUID, timingSafeEqual } from "node:crypto";
@@ -6,9 +7,9 @@ import { DEFAULT_POLICY, EVERYWHERE, EVERY_ENVIRONMENT, FIXED_ROLES, INSTANCE_RO
6
7
  import { GENESIS_HASH, deriveLogKey, verifyEntries } from "@coffre/core/audit";
7
8
  import { checkContext, ownerKey } from "@coffre/core/envelope";
8
9
  import { TOMBSTONE, shownMember } from "@coffre/core/schemas";
9
- import { SNAPSHOT, clockMillis, engineOf, forUpdate, isUnavailable, isUniqueViolation, rowsOf, tombstone, truth } from "@coffre/db/dialect";
10
+ import { SNAPSHOT, changedRows, clockMillis, engineOf, forUpdate, isUnavailable, isUniqueViolation, rowsOf, tombstone, truth } from "@coffre/db/dialect";
10
11
  import { accessEntriesAbout, everyProjectConversion, insertGrant, liveProjects, newestAccess, readGrants, reportStands, roleColumns, storedRole } from "@coffre/db/grants";
11
- import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
12
+ import { LogHeadMismatch, appendEntries, entryColumns, lockLogHead, lockLogHeadQueued, missingEntry, newestAuthentic, newestVaultEntries, withinBytes } from "@coffre/db/log";
12
13
  import { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, lte, or, sql } from "drizzle-orm";
13
14
  //#region src/checkpoint.ts
14
15
  const PKCS8_ED25519 = [
@@ -189,6 +190,33 @@ async function boundLockWaits(tx, ms, statements = false) {
189
190
  const bound = String(Math.trunc(ms));
190
191
  await tx.execute(statements ? sql`SELECT set_config('lock_timeout', ${bound}, true), set_config('statement_timeout', ${bound}, true)` : sql.raw(`SET LOCAL lock_timeout = ${bound}`));
191
192
  }
193
+ /**
194
+ * How long a release is kept: the longest bulk limit window
195
+ * (`MAX_BULK_WINDOW_MINUTES`, a day), and an hour, a margin for the
196
+ * database's clock stepping back between a prune and a later count, which
197
+ * is seconds. Migration 0021's `prune_vault_releases` holds the same, a day
198
+ * and an hour, in its own SQL: a longer window takes a migration too.
199
+ */
200
+ const RELEASES_KEPT_MS = MAX_BULK_WINDOW_MINUTES * 6e4 + 36e5;
201
+ /**
202
+ * Delete up to `atMost` rows of `vault_releases` released over
203
+ * `RELEASES_KEPT_MS` before the database's clock: what no bulk count can
204
+ * read again. On Postgres the vault's login deletes no row itself, and asks
205
+ * `prune_vault_releases`, which deletes only those (migration 0021). How
206
+ * many went.
207
+ */
208
+ async function pruneReleases(db, atMost) {
209
+ if (engineOf(db) === "postgres") {
210
+ const [row] = await rowsOf(db, sql`SELECT prune_vault_releases(${atMost}::int) AS pruned`);
211
+ return row.pruned;
212
+ }
213
+ const { vaultReleases } = tablesOf(db);
214
+ const old = db.select({
215
+ principal: vaultReleases.principal,
216
+ key: vaultReleases.key
217
+ }).from(vaultReleases).where(lt(vaultReleases.releasedAt, sql`${clockMillis(db)} - ${RELEASES_KEPT_MS}`)).limit(atMost);
218
+ return changedRows(await db.delete(vaultReleases).where(sql`(${vaultReleases.principal}, ${vaultReleases.key}) IN ${old}`));
219
+ }
192
220
  /** The database's clock, now. */
193
221
  async function now(db) {
194
222
  const [{ at }] = await clock(db);
@@ -1216,17 +1244,16 @@ function snapshotQuery$1(asked) {
1216
1244
  WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq = ANY(${references})
1217
1245
  ),
1218
1246
  '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
- )`}
1247
+ -- The keys released within the window, but those asked for, up to one past the cap: a row for each key the
1248
+ -- principal was ever released, however many reads of them the log holds (vault_releases, which the database
1249
+ -- keeps from it). The bound is a subquery, evaluated once: clock_timestamp() alone is volatile, and runs a row.
1250
+ SELECT count(*) FROM (
1251
+ SELECT 1 FROM vault_releases
1252
+ WHERE principal = ${released.principal} AND released_at > (SELECT ${NOW$1} + ${released.clockOffsetMs} - ${released.windowMs})
1253
+ AND key <> ALL(${sql.param([...released.keys])}::text[])
1254
+ LIMIT ${released.cap + 1}
1255
+ ) AS before
1256
+ ) + (SELECT count(DISTINCT key) FROM unnest(${sql.param([...released.keys])}::text[]) AS asked (key))`}
1230
1257
  ) AS snapshot`;
1231
1258
  }
1232
1259
  //#endregion
@@ -1307,17 +1334,14 @@ function snapshotQuery(asked) {
1307
1334
  WHERE author = 'vault' AND action = 'reference.end' AND decision = 'allow' AND related_seq IN (SELECT value FROM json_each(${references}))
1308
1335
  )),
1309
1336
  '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)})
1337
+ -- The keys released within the window, but those asked for, up to one past the cap (snapshot.postgres.ts).
1338
+ SELECT count(*) FROM (
1339
+ SELECT 1 FROM vault_releases
1340
+ WHERE principal = ${released.principal} AND released_at > ${NOW} + ${released.clockOffsetMs} - ${released.windowMs}
1341
+ AND key NOT IN (SELECT value FROM json_each(${JSON.stringify(released.keys)}))
1342
+ LIMIT ${released.cap + 1}
1319
1343
  )
1320
- )`}
1344
+ ) + (SELECT count(DISTINCT value) FROM json_each(${JSON.stringify(released.keys)}))`}
1321
1345
  ) AS snapshot`;
1322
1346
  }
1323
1347
  //#endregion
@@ -1326,7 +1350,7 @@ async function snapshot(db, asked) {
1326
1350
  const read = () => readOnce(db, asked);
1327
1351
  const first = await read();
1328
1352
  if (asked.head !== true || first.head !== null && current(first.head)) return first;
1329
- if (first.head === null) await lockLogHead(db);
1353
+ if (first.head === null) await (asked.waiters === void 0 ? lockLogHead(db) : lockLogHeadQueued(db, asked.waiters));
1330
1354
  const again = await read();
1331
1355
  if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
1332
1356
  if (!current(again.head)) throw new LogHeadMismatch(`the log's head says entry ${again.head.nextSeq} comes next, but the log ends ${again.head.last === null ? "empty" : `at entry ${again.head.last.seq}`}, or with another hash`);
@@ -1819,6 +1843,31 @@ const LOCK_TIMEOUT_MS = 15e3;
1819
1843
  * again later.
1820
1844
  */
1821
1845
  const READ_LOCK_TIMEOUT_MS = 3e3;
1846
+ /**
1847
+ * How long a whole read call may take, from its start to its answer, its
1848
+ * refusal's entries, the record of a vault key's check and, with a key
1849
+ * service, its intent and its own budget (added) included: each transaction's waits are bounded by what is left of it, and
1850
+ * past it the call fails as unavailable (`Overdue`) rather than start
1851
+ * another. So a caller hears within it, ok or 503, never tens of seconds later.
1852
+ */
1853
+ const READ_DEADLINE_MS = 5e3;
1854
+ /** How many releases a checkpoint prunes at most: one short statement, a few each scheduled run. */
1855
+ const RELEASES_PRUNED = 1e4;
1856
+ /** A read's call past its deadline (`READ_DEADLINE_MS`): unavailable, as a statement past its bound (57014) is. */
1857
+ var Overdue = class extends Error {
1858
+ code = "57014";
1859
+ constructor() {
1860
+ super("this read took longer than it may: ask again shortly");
1861
+ this.name = "Overdue";
1862
+ }
1863
+ };
1864
+ /** The bound of a transaction of a call due at `deadline`: `ms`, or what is left of the call, if less. */
1865
+ function within(deadline, ms) {
1866
+ if (deadline === void 0) return ms;
1867
+ const left = deadline - Date.now();
1868
+ if (left <= 0) throw new Overdue();
1869
+ return Math.min(ms, left);
1870
+ }
1822
1871
  /** A member: `user:<email>` or `token:<name>`. */
1823
1872
  const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
1824
1873
  /** A person, and a service account: only a person sets one up (`givesService`). */
@@ -1999,16 +2048,18 @@ var VaultService = class {
1999
2048
  return reads.at + this.#prepared.options.clockOffset();
2000
2049
  }
2001
2050
  /** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
2002
- #snapshot(tx, principals, asked, head) {
2051
+ #snapshot(tx, principals, asked, head, reading = false) {
2003
2052
  const { released, ...rest } = asked;
2004
2053
  return snapshot(tx, {
2005
2054
  ...rest,
2006
2055
  principals,
2007
2056
  head,
2057
+ ...reading ? { waiters: 16 } : {},
2008
2058
  ...released === void 0 ? {} : { released: {
2009
2059
  ...released,
2010
2060
  windowMs: this.#config.bulkLimit.windowMs,
2011
- clockOffsetMs: this.#prepared.options.clockOffset()
2061
+ clockOffsetMs: this.#prepared.options.clockOffset(),
2062
+ cap: this.#config.bulkLimit.count
2012
2063
  } }
2013
2064
  });
2014
2065
  }
@@ -2037,19 +2088,23 @@ var VaultService = class {
2037
2088
  /** `#decide`, once the vault is settled. */
2038
2089
  async #decideNow(principals, decide, how = {}) {
2039
2090
  const reports = [];
2091
+ const due = how.reads === true ? {
2092
+ ...how,
2093
+ deadline: how.deadline ?? Date.now() + 5e3
2094
+ } : how;
2040
2095
  for (let attempt = 1;; attempt += 1) try {
2041
- return await this.#decideOnce(principals, decide, reports, how);
2096
+ return await this.#decideOnce(principals, decide, reports, due);
2042
2097
  } catch (error) {
2043
2098
  if (attempt < 3 && (error instanceof Retry || isUniqueViolation(error))) continue;
2044
2099
  throw error;
2045
2100
  }
2046
2101
  }
2047
- async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false }) {
2102
+ async #decideOnce(principals, decide, reports, { late = false, asked = {}, reads: shared = false, deadline }) {
2048
2103
  try {
2049
2104
  const result = await this.#db.transaction(async (tx) => {
2050
- await boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
2105
+ await boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
2051
2106
  const members = principals.length === 0 ? /* @__PURE__ */ new Map() : await lockMembers(tx, principals, shared);
2052
- const reads = await this.#snapshot(tx, principals, asked, !late);
2107
+ const reads = await this.#snapshot(tx, principals, asked, !late, shared);
2053
2108
  const d = {
2054
2109
  tx,
2055
2110
  members,
@@ -2092,7 +2147,8 @@ var VaultService = class {
2092
2147
  if (!(error instanceof Refused || error instanceof Outage)) throw error;
2093
2148
  const entries = [...reports, ...readsByPlace(error.entries)];
2094
2149
  if (entries.length > 0) await this.#db.transaction(async (tx) => {
2095
- await boundLockWaits(tx, LOCK_TIMEOUT_MS);
2150
+ await boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
2151
+ if (shared) await lockLogHeadQueued(tx, 16);
2096
2152
  await this.#append(tx, entries);
2097
2153
  });
2098
2154
  this.#reported(reports);
@@ -2430,6 +2486,7 @@ var VaultService = class {
2430
2486
  }
2431
2487
  async unwrap(input) {
2432
2488
  const { principal } = input;
2489
+ const use = input.use === void 0 ? null : useEntry(principal, input, input.use);
2433
2490
  validateText(input.purpose);
2434
2491
  if (input.keepsSensitive !== void 0 && typeof input.keepsSensitive !== "boolean") throw new Error("keepsSensitive is true, false or absent");
2435
2492
  if (input.accessRevision !== void 0 && typeof input.accessRevision !== "string") throw new Error("accessRevision names an answer of access, or is absent");
@@ -2476,6 +2533,16 @@ var VaultService = class {
2476
2533
  };
2477
2534
  if (viaOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
2478
2535
  if (throughOk.includes(false)) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
2536
+ if (use !== null && (use.environmentId !== null || use.projectId !== null)) {
2537
+ if (!items.some((item, i) => {
2538
+ const fork = throughs[i] === null ? void 0 : forks.get(throughs[i]);
2539
+ const place = fork === void 0 ? sealed[i]?.holder ?? item.secret : {
2540
+ projectId: fork.projectId,
2541
+ environmentId: throughs[i]
2542
+ };
2543
+ return (use.environmentId === null || place.environmentId === use.environmentId) && (use.projectId === null || place.projectId === use.projectId);
2544
+ })) return this.#refuseVersions(principal, "bad_claim", items.map((item) => entry(item.secret, "deny", "bad_claim")), { asked: input.accessRevision });
2545
+ }
2479
2546
  const ends = sealed.flatMap((seal) => seal === null ? [] : [seal.seq]);
2480
2547
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
2481
2548
  return this.#keys({
@@ -2489,7 +2556,8 @@ var VaultService = class {
2489
2556
  vet: ends.length === 0 ? void 0 : async (_, reads) => sealed.map((seal) => seal !== null && reads.ended.has(seal.seq) ? "ended" : null),
2490
2557
  remote,
2491
2558
  entry,
2492
- input
2559
+ input,
2560
+ ...use === null ? {} : { also: [use] }
2493
2561
  }, (contextOf) => items.map(({ secret, wrapped }) => async (operation) => {
2494
2562
  const key = await this.#open(wrapped, contextOf(secret), operation);
2495
2563
  return key && {
@@ -2504,7 +2572,8 @@ var VaultService = class {
2504
2572
  key.fill(0);
2505
2573
  }
2506
2574
  }),
2507
- ...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision }
2575
+ ...input.accessRevision === void 0 ? {} : { accessRevision: input.accessRevision },
2576
+ ...use === null ? {} : { used: true }
2508
2577
  }));
2509
2578
  }
2510
2579
  async wrap(input) {
@@ -2685,9 +2754,13 @@ var VaultService = class {
2685
2754
  async #keys(call, operations, result) {
2686
2755
  const { action, principal, secrets, entry } = call;
2687
2756
  for (const secret of secrets) entry(secret, "allow", null);
2688
- const wrongKek = await this.#kekMismatch();
2757
+ const deadline = Date.now() + READ_DEADLINE_MS + (call.remote ? this.#prepared.options.keyBudgetMs : 0);
2758
+ const wrongKek = await this.#kekMismatch(deadline);
2689
2759
  if (wrongKek !== null) return this.#decide([], async () => {
2690
2760
  throw new Refused(refusal("wrong_kek", wrongKek), secrets.map((secret) => entry(secret, "deny", "wrong_kek")));
2761
+ }, {
2762
+ reads: true,
2763
+ deadline
2691
2764
  });
2692
2765
  const owners = /* @__PURE__ */ new Map();
2693
2766
  const check = async (d) => {
@@ -2776,7 +2849,8 @@ var VaultService = class {
2776
2849
  }], [], d.reads)).seqStart };
2777
2850
  }, {
2778
2851
  asked: reads,
2779
- reads: true
2852
+ reads: true,
2853
+ deadline
2780
2854
  });
2781
2855
  if (!intent.ok) return intent;
2782
2856
  intentSeq = intent.seq;
@@ -2799,7 +2873,7 @@ var VaultService = class {
2799
2873
  if (first === void 0 || first === null) return;
2800
2874
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, "deny", codes[item] ?? first)));
2801
2875
  });
2802
- const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
2876
+ const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
2803
2877
  const done = outcomes.flatMap((outcome) => outcome.ok ? [outcome.value] : []);
2804
2878
  try {
2805
2879
  const entries = () => secrets.map((secret, i) => outcomeEntry(secret, i, "deny", outcomes[i].ok ? "withheld" : outcomes[i].code));
@@ -2810,13 +2884,13 @@ var VaultService = class {
2810
2884
  if (expired) throw new Outage(new KekUnavailableError("key operation exceeded its deadline"), entries());
2811
2885
  if (done.length < outcomes.length) throw new Refused(refusal("bad_claim", MESSAGES.bad_claim), entries());
2812
2886
  if (late) {
2813
- d.reads = await this.#snapshot(d.tx, [principal], reads, true);
2887
+ d.reads = await this.#snapshot(d.tx, [principal], reads, true, true);
2814
2888
  d.at = this.#at(d.reads);
2815
2889
  await decided(d);
2816
2890
  if (secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]).some((place) => d.reads.environments.get(place.environmentId)?.deleted ?? false)) throw new Refused(refusal("deleted", MESSAGES.deleted), secrets.map((secret, i) => outcomeEntry(secret, i, "deny", "deleted")));
2817
2891
  }
2818
2892
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, "allow", null));
2819
- d.log.push(...released);
2893
+ d.log.push(...released, ...call.also ?? []);
2820
2894
  const answer = result(done);
2821
2895
  if (action !== "secret.read") d.after.push((seqOf) => Object.assign(answer, { seqs: released.map(seqOf) }));
2822
2896
  return answer;
@@ -2826,7 +2900,8 @@ var VaultService = class {
2826
2900
  }, {
2827
2901
  late,
2828
2902
  asked: reads,
2829
- reads: true
2903
+ reads: true,
2904
+ deadline
2830
2905
  });
2831
2906
  }
2832
2907
  /**
@@ -3854,10 +3929,10 @@ var VaultService = class {
3854
3929
  * service that cannot answer leaves it undecided: the error is thrown,
3855
3930
  * and the next call asks again.
3856
3931
  */
3857
- async #kekMismatch() {
3932
+ async #kekMismatch(deadline) {
3858
3933
  const prepared = this.#prepared;
3859
3934
  if (!prepared.kekChecked) {
3860
- const wrong = await this.#checkKeks();
3935
+ const wrong = await this.#checkKeks(deadline);
3861
3936
  if (!prepared.kekChecked) {
3862
3937
  prepared.wrongKek = wrong;
3863
3938
  prepared.kekChecked = true;
@@ -3871,15 +3946,16 @@ var VaultService = class {
3871
3946
  * keys it wrapped, if there are any; then its check value is recorded.
3872
3947
  * A KEK that opens neither is not the one that wrapped the data.
3873
3948
  */
3874
- async #checkKeks() {
3949
+ async #checkKeks(deadline) {
3875
3950
  const superseded = await this.#settled();
3876
3951
  if (superseded !== null) return superseded;
3877
3952
  const checks = await this.#checkValues(this.#db);
3878
3953
  const budget = this.#prepared.options.keyBudgetMs;
3879
3954
  for (const kek of this.#config.keks.all) {
3955
+ const spend = within(deadline, budget);
3880
3956
  const operation = {
3881
- deadline: Date.now() + budget,
3882
- signal: AbortSignal.timeout(budget)
3957
+ deadline: Date.now() + spend,
3958
+ signal: AbortSignal.timeout(spend)
3883
3959
  };
3884
3960
  const opens = async (wrapped, context, expected) => {
3885
3961
  try {
@@ -3914,6 +3990,7 @@ var VaultService = class {
3914
3990
  if (samples.length > 0 && proof === null) return mismatch;
3915
3991
  const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
3916
3992
  await this.#db.transaction(async (tx) => {
3993
+ await boundLockWaits(tx, within(deadline, READ_LOCK_TIMEOUT_MS), true);
3917
3994
  await lockLogHead(tx);
3918
3995
  if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
3919
3996
  await this.#append(tx, [{
@@ -4057,9 +4134,9 @@ var VaultService = class {
4057
4134
  const { wrongKek, logKeys } = this.#prepared;
4058
4135
  const { walks } = this.#prepared.options;
4059
4136
  const found = [];
4060
- let seen = null;
4137
+ let seen;
4061
4138
  const resumed = await this.#db.transaction(async (tx) => {
4062
- seen = (await newestCheckpointEntry(tx))?.seq ?? null;
4139
+ seen = await newestCheckpointEntry(tx);
4063
4140
  await this.#sweep(tx, found);
4064
4141
  const from = await this.#resume(tx, "newest");
4065
4142
  if (!from.ok) return from;
@@ -4089,7 +4166,7 @@ var VaultService = class {
4089
4166
  if (!isUnavailable(error)) throw error;
4090
4167
  stalled = `the rehash of the past did not run, and goes on where it was next time: ${error.message}`;
4091
4168
  }
4092
- return this.#decide([], async (d) => {
4169
+ const decided = await this.#decide([], async (d) => {
4093
4170
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
4094
4171
  const head = await lockLogHead(d.tx);
4095
4172
  const latest = await latestVaultEntry(d.tx, [CHECKPOINT]);
@@ -4111,10 +4188,14 @@ var VaultService = class {
4111
4188
  reason: scrub.reason
4112
4189
  });
4113
4190
  const newest = latest === void 0 ? null : JSON.parse(latest.metadata);
4114
- if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== seen) {
4191
+ if (stalled !== null || ((await newestCheckpointEntry(d.tx))?.seq ?? null) !== (seen?.seq ?? null)) {
4115
4192
  const standing = await this.#standingRefusal(d.tx);
4116
4193
  if (standing !== null) throw refused("log_broken", standing);
4117
4194
  if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
4195
+ } else if (seen?.decision === "deny" && walked.ok) {
4196
+ const standing = await this.#standingRefusal(d.tx);
4197
+ const named = typeof standing?.failedAtSeq === "number" ? BigInt(standing.failedAtSeq) : null;
4198
+ if (named !== null && named <= walked.from.at.seq && !await this.#entryHolds(d.tx, named)) throw refused("log_broken", standing);
4118
4199
  }
4119
4200
  if (newest !== null && latest.seq === head.nextSeq - 1n && latest.relatedSeq === latest.seq - 1n) return { checkpoint: signedPart(newest) };
4120
4201
  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` });
@@ -4185,6 +4266,13 @@ var VaultService = class {
4185
4266
  warning: stalled
4186
4267
  };
4187
4268
  });
4269
+ if (!decided.ok) return decided;
4270
+ const pruned = await pruneReleases(this.#db, RELEASES_PRUNED).then(() => null, (error) => `the releases the bulk limit counts were not pruned, and the next checkpoint tries again: ${error.message}`);
4271
+ if (pruned === null) return decided;
4272
+ return {
4273
+ ...decided,
4274
+ warning: decided.warning === void 0 ? pruned : `${decided.warning}; ${pruned}`
4275
+ };
4188
4276
  }
4189
4277
  /**
4190
4278
  * The walk on from where a checkpoint resumes, no further than `limit`
@@ -4252,6 +4340,30 @@ var VaultService = class {
4252
4340
  };
4253
4341
  }
4254
4342
  /**
4343
+ * Whether entry `seq` is as the walk checks each: there, its hash its
4344
+ * own, linked to the entry before, and the vault's under its MAC; and the
4345
+ * entry after it, if any, linked to it, for a refusal can name either side
4346
+ * of a broken link (`forward`, at the end of a batch).
4347
+ */
4348
+ async #entryHolds(db, seq) {
4349
+ const found = await entriesAt(db, seq === 0n ? [seq, seq + 1n] : [
4350
+ seq - 1n,
4351
+ seq,
4352
+ seq + 1n
4353
+ ]);
4354
+ const entry = found.get(seq);
4355
+ const before = seq === 0n ? { hash: GENESIS_HASH } : found.get(seq - 1n);
4356
+ if (entry === void 0 || before === void 0) return false;
4357
+ const after = found.get(seq + 1n);
4358
+ if (after !== void 0 && !after.prevHash.equals(entry.hash)) return false;
4359
+ return verifyEntries([entry], {
4360
+ keys: this.#keysFor(entry),
4361
+ chainOnly: ["app"],
4362
+ startSeq: seq,
4363
+ startPrevHash: before.hash
4364
+ }).ok;
4365
+ }
4366
+ /**
4255
4367
  * The newest refusal for a broken log since the newest checkpoint was
4256
4368
  * signed, by the vault's MAC, as it said: it stands, for every
4257
4369
  * verification and checkpoint, until a checkpoint is signed, which takes
@@ -4634,6 +4746,39 @@ function validateText(value) {
4634
4746
  if (typeof value !== "string" || /[\uD800-\uDFFF]/u.test(value)) throw new Error("key request strings must be well-formed Unicode");
4635
4747
  }
4636
4748
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
4749
+ /**
4750
+ * The app's `credential.use` entry, as the vault appends it beside a read
4751
+ * (`UnwrapInput.use`): what the app wrote, checked to be what an entry
4752
+ * holds, in its reader's name. The app wrote it under the head itself
4753
+ * before 0.14.5; it describes the read, and decides nothing.
4754
+ */
4755
+ function useEntry(principal, correlation, use) {
4756
+ const id = (value, name) => {
4757
+ if (value !== null && (typeof value !== "string" || !UUID.test(value))) throw new Error(`a use's ${name} must be a lowercase UUID or null`);
4758
+ return value;
4759
+ };
4760
+ if (typeof use.metadata !== "string" || use.metadata.length > USE_METADATA_LENGTH) throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
4761
+ if (use.sourceIp !== null) validateText(use.sourceIp);
4762
+ validateText(use.metadata);
4763
+ let metadata;
4764
+ try {
4765
+ metadata = JSON.parse(use.metadata);
4766
+ } catch {
4767
+ metadata = void 0;
4768
+ }
4769
+ if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
4770
+ return {
4771
+ actor: principal,
4772
+ action: "credential.use",
4773
+ decision: "allow",
4774
+ projectId: id(use.projectId, "projectId"),
4775
+ environmentId: id(use.environmentId, "environmentId"),
4776
+ operationId: correlation.operationId ?? null,
4777
+ requestId: correlation.requestId ?? null,
4778
+ sourceIp: use.sourceIp,
4779
+ metadata: use.metadata
4780
+ };
4781
+ }
4637
4782
  /** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
4638
4783
  function validateCorrelation(input) {
4639
4784
  if (input.requestId != null) validateText(input.requestId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
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.5",
40
+ "@coffre/db": "0.14.5"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
package/src/config.ts CHANGED
@@ -11,6 +11,14 @@ export type BulkLimit = { count: number; windowMs: number };
11
11
  */
12
12
  export const DEFAULT_BULK_LIMIT: BulkLimit = { count: 1000, windowMs: 15 * 60_000 };
13
13
 
14
+ /**
15
+ * The longest bulk limit window: a day. The releases a window counts are
16
+ * kept a day and an hour (migration 0021's `prune_vault_releases`, and on
17
+ * SQLite `RELEASES_KEPT_MS`), so every window up to it, and one widened
18
+ * since, counts exactly.
19
+ */
20
+ export const MAX_BULK_WINDOW_MINUTES = 24 * 60;
21
+
14
22
  /** The vault as it runs: keys decoded, emails checked. */
15
23
  export type ResolvedVaultConfig = {
16
24
  keks: KekRegistry;
@@ -61,7 +69,10 @@ export type VaultConfig = {
61
69
  * service holds the KEK, as AWS KMS does: the vault never sees that key.
62
70
  */
63
71
  signingKey?: string;
64
- /** At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
72
+ /**
73
+ * At most `count` distinct data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. The
74
+ * window is a day at most: the releases it counts are kept a day and an hour (`prune_vault_releases`).
75
+ */
65
76
  bulkLimit?: { count: number; windowMinutes: number };
66
77
  /**
67
78
  * @deprecated Gone in 0.10, and refused: each key says who sees it now. Mark
@@ -176,8 +187,8 @@ function signingKeys(given: string | undefined, keks: { provider: KekProvider; s
176
187
 
177
188
 
178
189
  function checkBulkLimit({ count, windowMinutes }: { count: number; windowMinutes: number }): BulkLimit {
179
- if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) {
180
- throw new Error('bulkLimit needs a whole count of at least 1 and a window above 0 minutes');
190
+ if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0) || windowMinutes > MAX_BULK_WINDOW_MINUTES) {
191
+ throw new Error(`bulkLimit needs a whole count of at least 1 and a window above 0 minutes, a day (${MAX_BULK_WINDOW_MINUTES}) at most`);
181
192
  }
182
193
  return { count, windowMs: windowMinutes * 60_000 };
183
194
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  import type { StoredEntry } from '@coffre/core/audit';
2
2
  import type { Queryable, Transaction } from '@coffre/db';
3
- import { LogHeadMismatch, lockLogHead } from '@coffre/db/log';
3
+ import { LogHeadMismatch, lockLogHead, lockLogHeadQueued } from '@coffre/db/log';
4
4
  import { engineOf, rowsOf } from '@coffre/db/dialect';
5
5
  import type { GrantRow } from '@coffre/db/grants';
6
6
 
@@ -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,11 +76,19 @@ 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;
86
+ /**
87
+ * Held by another, wait for the head only in one of this many places in
88
+ * line, and refuse at once when none is free (`lockLogHeadQueued`); with
89
+ * none, wait as long as the transaction's bound lets.
90
+ */
91
+ waiters?: number;
82
92
  };
83
93
 
84
94
  /** One statement's read, as `Snapshot` says; with the head, two more when another holds it. */
@@ -90,7 +100,7 @@ export async function snapshot(db: Queryable, asked: Asked): Promise<Snapshot> {
90
100
  if (asked.head !== true || (first.head !== null && current(first.head))) return first;
91
101
  // Held by another, or moved in the instant before the lock: everything again, once the head is this transaction's.
92
102
  // Asked for the head, `db` is a transaction (the overloads above).
93
- if (first.head === null) await lockLogHead(db as Transaction);
103
+ if (first.head === null) await (asked.waiters === undefined ? lockLogHead(db as Transaction) : lockLogHeadQueued(db as Transaction, asked.waiters));
94
104
  const again = await read();
95
105
  if (again.head === null) throw new Error("the log's head, held by this transaction, was read as held by another");
96
106
  // Held, nothing appends: a head that names another last entry was moved, or the log written, around it (`appendEntries`).
package/src/store.ts CHANGED
@@ -2,9 +2,11 @@ import { EVERYWHERE, ownsInstance, type InstanceRole, type Scope } from '@coffre
2
2
  import type { Author, StoredEntry } from '@coffre/core/audit';
3
3
  import { ACCESS_ACTIONS, type SecretRef, type WrappedKey } from '@coffre/core/vault';
4
4
  import { prepared, tablesOf, type Queryable, type Transaction } from '@coffre/db';
5
- import { clockMillis, engineOf, forUpdate, tombstone, truth } from '@coffre/db/dialect';
5
+ import { changedRows, clockMillis, engineOf, forUpdate, rowsOf, tombstone, truth } from '@coffre/db/dialect';
6
6
  import { readGrants, roleColumns, storedRole, type GrantRow } from '@coffre/db/grants';
7
7
  import { entryColumns, withinBytes } from '@coffre/db/log';
8
+
9
+ import { MAX_BULK_WINDOW_MINUTES } from './config.ts';
8
10
  import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, or, sql, type SQL, type SQLWrapper } from 'drizzle-orm';
9
11
 
10
12
  /**
@@ -67,6 +69,33 @@ export async function boundLockWaits(tx: Transaction, ms: number, statements = f
67
69
  : sql.raw(`SET LOCAL lock_timeout = ${bound}`));
68
70
  }
69
71
 
72
+ /**
73
+ * How long a release is kept: the longest bulk limit window
74
+ * (`MAX_BULK_WINDOW_MINUTES`, a day), and an hour, a margin for the
75
+ * database's clock stepping back between a prune and a later count, which
76
+ * is seconds. Migration 0021's `prune_vault_releases` holds the same, a day
77
+ * and an hour, in its own SQL: a longer window takes a migration too.
78
+ */
79
+ export const RELEASES_KEPT_MS = MAX_BULK_WINDOW_MINUTES * 60_000 + 3_600_000;
80
+
81
+ /**
82
+ * Delete up to `atMost` rows of `vault_releases` released over
83
+ * `RELEASES_KEPT_MS` before the database's clock: what no bulk count can
84
+ * read again. On Postgres the vault's login deletes no row itself, and asks
85
+ * `prune_vault_releases`, which deletes only those (migration 0021). How
86
+ * many went.
87
+ */
88
+ export async function pruneReleases(db: Queryable, atMost: number): Promise<number> {
89
+ if (engineOf(db) === 'postgres') {
90
+ const [row] = await rowsOf<{ pruned: number }>(db, sql`SELECT prune_vault_releases(${atMost}::int) AS pruned`);
91
+ return row!.pruned;
92
+ }
93
+ const { vaultReleases } = tablesOf(db);
94
+ const old = db.select({ principal: vaultReleases.principal, key: vaultReleases.key }).from(vaultReleases)
95
+ .where(lt(vaultReleases.releasedAt, sql`${clockMillis(db)} - ${RELEASES_KEPT_MS}`)).limit(atMost);
96
+ return changedRows(await db.delete(vaultReleases).where(sql`(${vaultReleases.principal}, ${vaultReleases.key}) IN ${old}`));
97
+ }
98
+
70
99
  /** The database's clock, now. */
71
100
  export async function now(db: Queryable): Promise<number> {
72
101
  const [{ at }] = await clock(db);
package/src/vault.ts CHANGED
@@ -91,6 +91,9 @@ import {
91
91
  type Settings,
92
92
  type Through,
93
93
  type UnwrapInput,
94
+ USE_METADATA_LENGTH,
95
+ type Unwrapped,
96
+ type UseRecord,
94
97
  type Wrapped,
95
98
  type Vault,
96
99
  type Via,
@@ -101,7 +104,7 @@ import {
101
104
  import type { Database, Queryable, Transaction } from '@coffre/db';
102
105
  import { isUnavailable, isUniqueViolation, SNAPSHOT } from '@coffre/db/dialect';
103
106
  import { everyProjectConversion, newestAccess, reportStands } from '@coffre/db/grants';
104
- import { appendEntries, lockLogHead, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
107
+ import { appendEntries, lockLogHead, lockLogHeadQueued, missingEntry, newestAuthentic, type Appended, type Authenticity, type Newest, type NewEntry } from '@coffre/db/log';
105
108
 
106
109
  import { signer, type Signer } from './checkpoint.ts';
107
110
  import { keyRefusals, placeIn, referenceRefusal, refuses, sensitiveRefusals, useOnlyRefusals, type SecretPlace } from './decide.ts';
@@ -212,6 +215,46 @@ const LOCK_TIMEOUT_MS = 15_000;
212
215
  */
213
216
  const READ_LOCK_TIMEOUT_MS = 3_000;
214
217
 
218
+ /**
219
+ * How long a whole read call may take, from its start to its answer, its
220
+ * refusal's entries, the record of a vault key's check and, with a key
221
+ * service, its intent and its own budget (added) included: each transaction's waits are bounded by what is left of it, and
222
+ * past it the call fails as unavailable (`Overdue`) rather than start
223
+ * another. So a caller hears within it, ok or 503, never tens of seconds later.
224
+ */
225
+ export const READ_DEADLINE_MS = 5_000;
226
+
227
+ /**
228
+ * How many reads may wait for the log's head at once, through all of the
229
+ * vault's connections; the next is refused at once, as unavailable, holding
230
+ * no connection while it waits (`lockLogHeadQueued`). A read holds the head
231
+ * for a few round trips, about 150 ms through Hyperdrive, so this many
232
+ * clear within a read's bound (2.4 s of its 3), and no more than this many
233
+ * of the pool's connections wait. A burst of 50 reads at a 2-vCPU database
234
+ * a 30 ms round trip away: 17 read within 2.2 s, 33 refused at once.
235
+ */
236
+ export const READ_HEAD_WAITERS = 16;
237
+
238
+ /** How many releases a checkpoint prunes at most: one short statement, a few each scheduled run. */
239
+ const RELEASES_PRUNED = 10_000;
240
+
241
+ /** A read's call past its deadline (`READ_DEADLINE_MS`): unavailable, as a statement past its bound (57014) is. */
242
+ class Overdue extends Error {
243
+ readonly code = '57014';
244
+ constructor() {
245
+ super('this read took longer than it may: ask again shortly');
246
+ this.name = 'Overdue';
247
+ }
248
+ }
249
+
250
+ /** The bound of a transaction of a call due at `deadline`: `ms`, or what is left of the call, if less. */
251
+ function within(deadline: number | undefined, ms: number): number {
252
+ if (deadline === undefined) return ms;
253
+ const left = deadline - Date.now();
254
+ if (left <= 0) throw new Overdue();
255
+ return Math.min(ms, left);
256
+ }
257
+
215
258
  /** A member: `user:<email>` or `token:<name>`. */
216
259
  const PRINCIPAL = /^(user|token):[^\s:][^\s]*$/;
217
260
 
@@ -406,7 +449,12 @@ type Reads = Pick<Asked, 'environments' | 'references'> & { released?: { princip
406
449
  * is what it reads besides its members, and `reads` says it changes none
407
450
  * of them (`#decide`).
408
451
  */
409
- type How = { late?: boolean; asked?: Reads; reads?: boolean };
452
+ /**
453
+ * How a decision is made. `deadline`, a read's (`reads`): when its whole
454
+ * call must have answered, every wait in every transaction it takes
455
+ * included (`READ_DEADLINE_MS`).
456
+ */
457
+ type How = { late?: boolean; asked?: Reads; reads?: boolean; deadline?: number };
410
458
 
411
459
  /** The actions of the entries the vault writes about keys. */
412
460
  type KeyAction = 'secret.read' | 'key.wrap' | 'key.rewrap';
@@ -492,13 +540,15 @@ class VaultService implements Vault {
492
540
  }
493
541
 
494
542
  /** What a decision about `principals` reads, and `asked` besides, in one statement: under the head when `head` is set. */
495
- #snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean): Promise<Snapshot> {
543
+ #snapshot(tx: Transaction, principals: readonly string[], asked: Reads, head: boolean, reading = false): Promise<Snapshot> {
496
544
  const { released, ...rest } = asked;
497
545
  return snapshot(tx, {
498
546
  ...rest,
499
547
  principals,
500
548
  head,
501
- ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset() } }),
549
+ // A read waits for the head in one of a few places in line, or is refused at once.
550
+ ...(reading ? { waiters: READ_HEAD_WAITERS } : {}),
551
+ ...(released === undefined ? {} : { released: { ...released, windowMs: this.#config.bulkLimit.windowMs, clockOffsetMs: this.#prepared.options.clockOffset(), cap: this.#config.bulkLimit.count } }),
502
552
  });
503
553
  }
504
554
 
@@ -525,9 +575,11 @@ class VaultService implements Vault {
525
575
  /** `#decide`, once the vault is settled. */
526
576
  async #decideNow<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, how: How = {}): Promise<Outcome<T>> {
527
577
  const reports: NewEntry[] = [];
578
+ // A read's call is due from its first decision, unless its caller set when.
579
+ const due = how.reads === true ? { ...how, deadline: how.deadline ?? Date.now() + READ_DEADLINE_MS } : how;
528
580
  for (let attempt = 1; ; attempt += 1) {
529
581
  try {
530
- return await this.#decideOnce(principals, decide, reports, how);
582
+ return await this.#decideOnce(principals, decide, reports, due);
531
583
  } catch (error) {
532
584
  // A member who had no row when this decision locked theirs, and has
533
585
  // one now: another decision admitted them meanwhile. Again, with it.
@@ -537,13 +589,13 @@ class VaultService implements Vault {
537
589
  }
538
590
  }
539
591
 
540
- async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false }: How): Promise<Outcome<T>> {
592
+ async #decideOnce<T>(principals: readonly string[], decide: (d: Decision) => Promise<T>, reports: NewEntry[], { late = false, asked = {}, reads: shared = false, deadline }: How): Promise<Outcome<T>> {
541
593
  try {
542
594
  const result = await this.#db.transaction(async (tx) => {
543
- await store.boundLockWaits(tx, shared ? READ_LOCK_TIMEOUT_MS : LOCK_TIMEOUT_MS, shared);
595
+ await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
544
596
  const members = principals.length === 0 ? new Map<string, Member>() : await store.lockMembers(tx, principals, shared);
545
597
  // Then the head, and everything the decision reads, in one statement (`snapshot` says why it holds).
546
- const reads = await this.#snapshot(tx, principals, asked, !late);
598
+ const reads = await this.#snapshot(tx, principals, asked, !late, shared);
547
599
  const d: Decision = {
548
600
  tx, members, at: this.#at(reads), reads, log: [], writes: [], touched: new Set(), grants: new Map(), reports, after: [], underLock: [],
549
601
  };
@@ -577,9 +629,12 @@ class VaultService implements Vault {
577
629
  if (!(error instanceof Refused || error instanceof Outage)) throw error;
578
630
  const entries = [...reports, ...readsByPlace(error.entries)];
579
631
  if (entries.length > 0) {
580
- // A refusal's entries are written whatever the decision's bound: it answers only once they are.
632
+ // A refusal's entries are written whatever the decision's bound: it answers only once they are. A read's, within
633
+ // its call's deadline: past it, the call fails as unavailable, having released nothing.
581
634
  await this.#db.transaction(async (tx) => {
582
- await store.boundLockWaits(tx, LOCK_TIMEOUT_MS);
635
+ await store.boundLockWaits(tx, shared ? within(deadline, READ_LOCK_TIMEOUT_MS) : LOCK_TIMEOUT_MS, shared);
636
+ // In one of the head's places in line, as the read's decision waited, or refused at once.
637
+ if (shared) await lockLogHeadQueued(tx, READ_HEAD_WAITERS);
583
638
  await this.#append(tx, entries);
584
639
  });
585
640
  }
@@ -928,8 +983,9 @@ class VaultService implements Vault {
928
983
 
929
984
  // Raw DEKs are cleared on every path. JSON and base64 leave strings that
930
985
  // cannot be wiped, so this is best-effort memory hygiene.
931
- async unwrap(input: UnwrapInput): Promise<Outcome<{ keys: string[]; accessRevision?: string }>> {
986
+ async unwrap(input: UnwrapInput): Promise<Outcome<Unwrapped>> {
932
987
  const { principal } = input;
988
+ const use = input.use === undefined ? null : useEntry(principal, input, input.use);
933
989
  validateText(input.purpose);
934
990
  if (input.keepsSensitive !== undefined && typeof input.keepsSensitive !== 'boolean') throw new Error('keepsSensitive is true, false or absent');
935
991
  if (input.accessRevision !== undefined && typeof input.accessRevision !== 'string') throw new Error('accessRevision names an answer of access, or is absent');
@@ -975,6 +1031,17 @@ class VaultService implements Vault {
975
1031
  if (throughOk.includes(false)) {
976
1032
  return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
977
1033
  }
1034
+ // The app's record of a use names where the read was, which the vault vouches for once it appends it: the place an
1035
+ // item was read at, the fork it is read in, else a reference's holder, else its own, with its project. Another is
1036
+ // a claim the read does not bear out, refused and logged as one; no use is appended.
1037
+ if (use !== null && (use.environmentId !== null || use.projectId !== null)) {
1038
+ const named = items.some((item, i) => {
1039
+ const fork = throughs[i] === null ? undefined : forks.get(throughs[i]!);
1040
+ const place = fork === undefined ? sealed[i]?.holder ?? item.secret : { projectId: fork.projectId, environmentId: throughs[i]! };
1041
+ return (use.environmentId === null || place.environmentId === use.environmentId) && (use.projectId === null || place.projectId === use.projectId);
1042
+ });
1043
+ if (!named) return this.#refuseVersions(principal, 'bad_claim', items.map((item) => entry(item.secret, 'deny', 'bad_claim')), { asked: input.accessRevision });
1044
+ }
978
1045
  const ends = sealed.flatMap((seal) => (seal === null ? [] : [seal.seq]));
979
1046
  const remote = items.some(({ wrapped }) => this.#remote(this.#config.keks.providerOf(wrapped)));
980
1047
  return this.#keys(
@@ -993,6 +1060,7 @@ class VaultService implements Vault {
993
1060
  remote,
994
1061
  entry,
995
1062
  input,
1063
+ ...(use === null ? {} : { also: [use] }),
996
1064
  },
997
1065
  (contextOf) =>
998
1066
  items.map(({ secret, wrapped }) => async (operation: KeyOperation) => {
@@ -1009,6 +1077,8 @@ class VaultService implements Vault {
1009
1077
  }),
1010
1078
  // Named back: the read was decided on that answer, which this vault checked.
1011
1079
  ...(input.accessRevision === undefined ? {} : { accessRevision: input.accessRevision }),
1080
+ // And the use, appended with the read: the app has nothing left to append.
1081
+ ...(use === null ? {} : { used: true as const }),
1012
1082
  }),
1013
1083
  );
1014
1084
  }
@@ -1224,6 +1294,8 @@ class VaultService implements Vault {
1224
1294
  viaReference?: readonly boolean[];
1225
1295
  remote: boolean;
1226
1296
  entry: (secret: SecretRef, decision: 'allow' | 'deny', code: string | null) => NewEntry;
1297
+ /** Entries appended with the release, after its own, only once the keys are released: the app's record of a use. */
1298
+ also?: readonly NewEntry[];
1227
1299
  input: Correlation & Through & { purpose?: string; keepsSensitive?: boolean; accessRevision?: string };
1228
1300
  },
1229
1301
  /**
@@ -1236,11 +1308,14 @@ class VaultService implements Vault {
1236
1308
  ): Promise<Outcome<R>> {
1237
1309
  const { action, principal, secrets, entry } = call;
1238
1310
  for (const secret of secrets) entry(secret, 'allow', null);
1239
- const wrongKek = await this.#kekMismatch();
1311
+ // One deadline for the call: the record of the KEK's check, the decisions, the intent's and the keys', and, with a
1312
+ // key service, its own budget besides, spent from it too.
1313
+ const deadline = Date.now() + READ_DEADLINE_MS + (call.remote ? this.#prepared.options.keyBudgetMs : 0);
1314
+ const wrongKek = await this.#kekMismatch(deadline);
1240
1315
  if (wrongKek !== null) {
1241
1316
  return this.#decide([], async () => {
1242
1317
  throw new Refused(refusal('wrong_kek', wrongKek), secrets.map((secret) => entry(secret, 'deny', 'wrong_kek')));
1243
- });
1318
+ }, { reads: true, deadline });
1244
1319
  }
1245
1320
  // Each personal config's owner, hashed, by environment, as the decision read them: what its keys' contexts bind.
1246
1321
  const owners = new Map<string, string>();
@@ -1319,7 +1394,7 @@ class VaultService implements Vault {
1319
1394
  }),
1320
1395
  }], [], d.reads);
1321
1396
  return { seq: appended.seqStart };
1322
- }, { asked: reads, reads: true });
1397
+ }, { asked: reads, reads: true, deadline });
1323
1398
  if (!intent.ok) return intent;
1324
1399
  intentSeq = intent.seq;
1325
1400
  }
@@ -1355,7 +1430,7 @@ class VaultService implements Vault {
1355
1430
  throw new Refused(refusal(first, MESSAGES[first]), secrets.map((secret, item) => outcomeEntry(secret, item, 'deny', codes[item] ?? first)));
1356
1431
  });
1357
1432
  }
1358
- const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), this.#prepared.options.keyBudgetMs);
1433
+ const { outcomes, expired } = await settle(operations((secret) => context(secret, owners.get(secret.environmentId))), within(deadline, this.#prepared.options.keyBudgetMs));
1359
1434
  const done = outcomes.flatMap((outcome) => (outcome.ok ? [outcome.value] : []));
1360
1435
  try {
1361
1436
  const entries = () => secrets.map((secret, i) =>
@@ -1381,7 +1456,7 @@ class VaultService implements Vault {
1381
1456
  // A read through a reference: its holder's place too, which a deletion may have taken.
1382
1457
  // And as the policy is now, under the head every change to it takes: an environment protected, or a role narrowed,
1383
1458
  // since the check above is seen here, and nothing it refuses now is released. All of it read again, holding the head.
1384
- d.reads = await this.#snapshot(d.tx, [principal], reads, true);
1459
+ d.reads = await this.#snapshot(d.tx, [principal], reads, true, true);
1385
1460
  d.at = this.#at(d.reads);
1386
1461
  await decided(d);
1387
1462
  const places = secrets.flatMap((secret, i) => [secret, call.places?.[i] ?? secret]);
@@ -1390,7 +1465,7 @@ class VaultService implements Vault {
1390
1465
  }
1391
1466
  }
1392
1467
  const released = secrets.map((secret, i) => outcomeEntry(secret, i, 'allow', null));
1393
- d.log.push(...released);
1468
+ d.log.push(...released, ...(call.also ?? []));
1394
1469
  const answer = result(done);
1395
1470
  // A wrap's answer names its entries, which the app's writes refer to.
1396
1471
  if (action !== 'secret.read') d.after.push((seqOf) => Object.assign(answer as object, { seqs: released.map(seqOf) }));
@@ -1398,7 +1473,7 @@ class VaultService implements Vault {
1398
1473
  } finally {
1399
1474
  for (const value of done) value.wipe();
1400
1475
  }
1401
- }, { late, asked: reads, reads: true });
1476
+ }, { late, asked: reads, reads: true, deadline });
1402
1477
  }
1403
1478
 
1404
1479
  /**
@@ -2404,10 +2479,10 @@ class VaultService implements Vault {
2404
2479
  * service that cannot answer leaves it undecided: the error is thrown,
2405
2480
  * and the next call asks again.
2406
2481
  */
2407
- async #kekMismatch(): Promise<string | null> {
2482
+ async #kekMismatch(deadline?: number): Promise<string | null> {
2408
2483
  const prepared = this.#prepared;
2409
2484
  if (!prepared.kekChecked) {
2410
- const wrong = await this.#checkKeks();
2485
+ const wrong = await this.#checkKeks(deadline);
2411
2486
  if (!prepared.kekChecked) {
2412
2487
  prepared.wrongKek = wrong;
2413
2488
  prepared.kekChecked = true;
@@ -2422,14 +2497,16 @@ class VaultService implements Vault {
2422
2497
  * keys it wrapped, if there are any; then its check value is recorded.
2423
2498
  * A KEK that opens neither is not the one that wrapped the data.
2424
2499
  */
2425
- async #checkKeks(): Promise<string | null> {
2500
+ async #checkKeks(deadline?: number): Promise<string | null> {
2426
2501
  // A vault that may not write cannot record a check either; why it may not is the answer.
2427
2502
  const superseded = await this.#settled();
2428
2503
  if (superseded !== null) return superseded;
2429
2504
  const checks = await this.#checkValues(this.#db);
2430
2505
  const budget = this.#prepared.options.keyBudgetMs;
2431
2506
  for (const kek of this.#config.keks.all) {
2432
- const operation = { deadline: Date.now() + budget, signal: AbortSignal.timeout(budget) };
2507
+ // A read's, within what is left of its deadline.
2508
+ const spend = within(deadline, budget);
2509
+ const operation = { deadline: Date.now() + spend, signal: AbortSignal.timeout(spend) };
2433
2510
  const opens = async (wrapped: WrappedDek, context: SecretContext, expected?: Buffer) => {
2434
2511
  try {
2435
2512
  const key = await kek.unwrap(wrapped, context, operation);
@@ -2460,6 +2537,8 @@ class VaultService implements Vault {
2460
2537
  // The key it opened, if any, is in the log, as every key the vault opens is. Another call that
2461
2538
  // checked at the same time may have recorded one first: under the log's lock, the first stays.
2462
2539
  await this.#db.transaction(async (tx) => {
2540
+ // Once a process, by its first key operation: a read's waits as the read's, within its deadline.
2541
+ await store.boundLockWaits(tx, within(deadline, READ_LOCK_TIMEOUT_MS), true);
2463
2542
  await lockLogHead(tx);
2464
2543
  if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
2465
2544
  await this.#append(tx, [
@@ -2578,9 +2657,9 @@ class VaultService implements Vault {
2578
2657
  const { walks } = this.#prepared.options;
2579
2658
  const found: NewEntry[] = [];
2580
2659
  // The newest checkpoint entry, signed or refused, as this call's snapshot found it.
2581
- let seen: bigint | null = null;
2660
+ let seen: StoredEntry | undefined;
2582
2661
  const resumed = await this.#db.transaction(async (tx) => {
2583
- seen = (await store.newestCheckpointEntry(tx))?.seq ?? null;
2662
+ seen = await store.newestCheckpointEntry(tx);
2584
2663
  await this.#sweep(tx, found);
2585
2664
  const from = await this.#resume(tx, 'newest');
2586
2665
  if (!from.ok) return from;
@@ -2607,7 +2686,7 @@ class VaultService implements Vault {
2607
2686
  stalled = `the rehash of the past did not run, and goes on where it was next time: ${(error as Error).message}`;
2608
2687
  }
2609
2688
  }
2610
- return this.#decide([], async (d) => {
2689
+ const decided = await this.#decide([], async (d) => {
2611
2690
  for (const entry of found) if (!d.reports.includes(entry)) d.reports.push(entry);
2612
2691
  // Checkpoints one at a time, each against the one before.
2613
2692
  const head = await lockLogHead(d.tx);
@@ -2625,10 +2704,17 @@ class VaultService implements Vault {
2625
2704
  // Another call checkpointed, or refused to, since this one's snapshot, or this one's rehash of the past
2626
2705
  // could not run: what it would sign over is not what it checked. A refusal for a broken log stands,
2627
2706
  // 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) {
2707
+ if (stalled !== null || ((await store.newestCheckpointEntry(d.tx))?.seq ?? null) !== (seen?.seq ?? null)) {
2629
2708
  const standing = await this.#standingRefusal(d.tx);
2630
2709
  if (standing !== null) throw refused('log_broken', standing);
2631
2710
  if (stalled === null && newest !== null) return { checkpoint: signedPart(newest) };
2711
+ } else if (seen?.decision === 'deny' && walked.ok) {
2712
+ // A refusal for a broken log stands since this call's snapshot, of an entry before where its walk began, which
2713
+ // it never read: that entry again, as the walk checks each, before anything is signed over it. Still broken,
2714
+ // the refusal stands; whole again, the signature clears it (T104).
2715
+ const standing = await this.#standingRefusal(d.tx);
2716
+ const named = typeof standing?.failedAtSeq === 'number' ? BigInt(standing.failedAtSeq) : null;
2717
+ if (named !== null && named <= walked.from.at.seq && !(await this.#entryHolds(d.tx, named))) throw refused('log_broken', standing!);
2632
2718
  }
2633
2719
  // Nothing since the last one, which signed every entry before its own: it is still the newest prefix.
2634
2720
  if (newest !== null && latest!.seq === head.nextSeq - 1n && latest!.relatedSeq === latest!.seq - 1n) return { checkpoint: signedPart(newest) };
@@ -2676,6 +2762,15 @@ class VaultService implements Vault {
2676
2762
  d.log.push({ actor: SCHEDULER, action: CHECKPOINT, decision: 'allow', relatedSeq: at.seq, metadata: JSON.stringify(kept) });
2677
2763
  return stalled === null ? { checkpoint } : { checkpoint, warning: stalled };
2678
2764
  });
2765
+ // Then the bulk count's upkeep, which no read waits on, after a checkpoint signed, never instead of a refusal: the
2766
+ // releases no window can count again, kept a day and an hour (`RELEASES_KEPT_MS`), longer than any window, up to
2767
+ // `RELEASES_PRUNED` of them, in one statement of its own, holding no lock any read takes but the rows it deletes,
2768
+ // and none an append holds. What is kept is the database's to say, not the caller's: no login forgets a release a
2769
+ // window counts. A failure leaves it to the next checkpoint, and says so.
2770
+ if (!decided.ok) return decided;
2771
+ const pruned = await store.pruneReleases(this.#db, RELEASES_PRUNED).then(() => null, (error: Error) => `the releases the bulk limit counts were not pruned, and the next checkpoint tries again: ${error.message}`);
2772
+ if (pruned === null) return decided;
2773
+ return { ...decided, warning: decided.warning === undefined ? pruned : `${decided.warning}; ${pruned}` };
2679
2774
  }
2680
2775
 
2681
2776
  /**
@@ -2722,6 +2817,22 @@ class VaultService implements Vault {
2722
2817
  return { ok: true, next: null };
2723
2818
  }
2724
2819
 
2820
+ /**
2821
+ * Whether entry `seq` is as the walk checks each: there, its hash its
2822
+ * own, linked to the entry before, and the vault's under its MAC; and the
2823
+ * entry after it, if any, linked to it, for a refusal can name either side
2824
+ * of a broken link (`forward`, at the end of a batch).
2825
+ */
2826
+ async #entryHolds(db: Queryable, seq: bigint): Promise<boolean> {
2827
+ const found = await store.entriesAt(db, seq === 0n ? [seq, seq + 1n] : [seq - 1n, seq, seq + 1n]);
2828
+ const entry = found.get(seq);
2829
+ const before = seq === 0n ? { hash: GENESIS_HASH } : found.get(seq - 1n);
2830
+ if (entry === undefined || before === undefined) return false;
2831
+ const after = found.get(seq + 1n);
2832
+ if (after !== undefined && !after.prevHash.equals(entry.hash)) return false;
2833
+ return verifyEntries([entry], { keys: this.#keysFor(entry), chainOnly: ['app'], startSeq: seq, startPrevHash: before.hash }).ok;
2834
+ }
2835
+
2725
2836
  /**
2726
2837
  * The newest refusal for a broken log since the newest checkpoint was
2727
2838
  * signed, by the vault's MAC, as it said: it stands, for every
@@ -3065,6 +3176,45 @@ function validateText(value: string): void {
3065
3176
 
3066
3177
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
3067
3178
 
3179
+ /**
3180
+ * The app's `credential.use` entry, as the vault appends it beside a read
3181
+ * (`UnwrapInput.use`): what the app wrote, checked to be what an entry
3182
+ * holds, in its reader's name. The app wrote it under the head itself
3183
+ * before 0.14.5; it describes the read, and decides nothing.
3184
+ */
3185
+ function useEntry(principal: string, correlation: Correlation, use: UseRecord): NewEntry {
3186
+ const id = (value: unknown, name: string) => {
3187
+ if (value !== null && (typeof value !== 'string' || !UUID.test(value))) throw new Error(`a use's ${name} must be a lowercase UUID or null`);
3188
+ return value;
3189
+ };
3190
+ // The bound first: nothing past it is read.
3191
+ if (typeof use.metadata !== 'string' || use.metadata.length > USE_METADATA_LENGTH) {
3192
+ throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
3193
+ }
3194
+ if (use.sourceIp !== null) validateText(use.sourceIp);
3195
+ validateText(use.metadata);
3196
+ let metadata: unknown;
3197
+ try {
3198
+ metadata = JSON.parse(use.metadata);
3199
+ } catch {
3200
+ metadata = undefined;
3201
+ }
3202
+ if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) {
3203
+ throw new Error(`a use's metadata must be a JSON object of at most ${USE_METADATA_LENGTH} characters`);
3204
+ }
3205
+ return {
3206
+ actor: principal,
3207
+ action: 'credential.use',
3208
+ decision: 'allow',
3209
+ projectId: id(use.projectId, 'projectId'),
3210
+ environmentId: id(use.environmentId, 'environmentId'),
3211
+ operationId: correlation.operationId ?? null,
3212
+ requestId: correlation.requestId ?? null,
3213
+ sourceIp: use.sourceIp,
3214
+ metadata: use.metadata,
3215
+ };
3216
+ }
3217
+
3068
3218
  /** The caller's ids go into the log as they are: a request id is text, an operation id a lowercase UUID. */
3069
3219
  function validateCorrelation(input: Correlation): void {
3070
3220
  if (input.requestId != null) validateText(input.requestId);