@coffre/vault 0.1.3 → 0.1.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,12 +1,16 @@
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-DHuqw-IB.js";
2
- import { n as prepareVault, t as openVault } from "./vault-pexzy7Z-.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-PQvFopA6.js";
3
3
  import { createDatabase } from "@coffre/db";
4
4
  import { HyperdrivePool, postgres } from "@coffre/db/hyperdrive";
5
5
  import { WorkerEntrypoint } from "cloudflare:workers";
6
6
  //#region src/cloudflare.ts
7
7
  /** Set when the Worker's module runs `vault(…)`, before any call comes in. */
8
8
  let configure = null;
9
- /** The configuration, checked and made ready once per `env`, which the isolate keeps. */
9
+ /**
10
+ * The configuration, checked and made ready once per `env`, which the
11
+ * isolate keeps: the ready value itself, never the work of making it, which
12
+ * would belong to the call that started it (vault.ts, `PreparedVault`).
13
+ */
10
14
  const ready = /* @__PURE__ */ new WeakMap();
11
15
  /**
12
16
  * What the app's `VAULT` service binding calls. Each call gets a database of
@@ -19,14 +23,14 @@ var VaultEntrypoint = class extends WorkerEntrypoint {
19
23
  if (entry === void 0) {
20
24
  if (configure === null) throw new Error("the vault Worker must export default vault(…)");
21
25
  const config = configure(env);
22
- entry = prepareVault(resolveVaultConfig(config)).then((prepared) => ({
26
+ const prepared = await prepareVault(resolveVaultConfig(config));
27
+ entry = ready.get(env) ?? {
23
28
  database: config.database,
24
29
  prepared
25
- }));
30
+ };
26
31
  ready.set(env, entry);
27
- entry.catch(() => ready.delete(env));
28
32
  }
29
- const { database, prepared } = await entry;
33
+ const { database, prepared } = entry;
30
34
  return openVault(createDatabase(new HyperdrivePool(database.hyperdrive.connectionString)), prepared);
31
35
  }
32
36
  async unwrap(input) {
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-DHuqw-IB.js";
2
- import { n as prepareVault, t as openVault } from "./vault-pexzy7Z-.js";
2
+ import { n as prepareVault, t as openVault } from "./vault-PQvFopA6.js";
3
3
  import { chmodSync, existsSync, lstatSync, rmSync } from "node:fs";
4
4
  import { Agent, createServer, request } from "node:http";
5
5
  import { openDatabase } from "@coffre/db/connect";
@@ -792,8 +792,7 @@ async function prepareVault(config, options = {}) {
792
792
  reported: /* @__PURE__ */ new Set(),
793
793
  since: null,
794
794
  superseded: null,
795
- settling: null,
796
- kekCheck: null,
795
+ kekChecked: false,
797
796
  wrongKek: null
798
797
  };
799
798
  }
@@ -1100,12 +1099,7 @@ var VaultService = class {
1100
1099
  */
1101
1100
  async #settled() {
1102
1101
  const prepared = this.#prepared;
1103
- if (prepared.since === null && prepared.superseded === null) {
1104
- prepared.settling ??= this.#settle().finally(() => {
1105
- prepared.settling = null;
1106
- });
1107
- await prepared.settling;
1108
- }
1102
+ if (prepared.since === null && prepared.superseded === null) await this.#settle();
1109
1103
  return prepared.superseded;
1110
1104
  }
1111
1105
  /**
@@ -1910,19 +1904,21 @@ var VaultService = class {
1910
1904
  /**
1911
1905
  * Null when every KEK the vault is given opens what it wrapped; otherwise
1912
1906
  * why not, naming the KEK, never its key. Decided once per process, before
1913
- * its first key operation or checkpoint. A key service that cannot answer
1914
- * leaves it undecided: the error is thrown, and the next call asks again.
1907
+ * its first key operation or checkpoint: until then, each call checks for
1908
+ * itself, over its own database, and the first verdict is kept. A key
1909
+ * service that cannot answer leaves it undecided: the error is thrown,
1910
+ * and the next call asks again.
1915
1911
  */
1916
- #kekMismatch() {
1912
+ async #kekMismatch() {
1917
1913
  const prepared = this.#prepared;
1918
- if (prepared.kekCheck === null) {
1919
- const check = this.#checkKeks();
1920
- prepared.kekCheck = check;
1921
- check.then((wrong) => void (prepared.wrongKek = wrong), () => {
1922
- if (prepared.kekCheck === check) prepared.kekCheck = null;
1923
- });
1914
+ if (!prepared.kekChecked) {
1915
+ const wrong = await this.#checkKeks();
1916
+ if (!prepared.kekChecked) {
1917
+ prepared.wrongKek = wrong;
1918
+ prepared.kekChecked = true;
1919
+ }
1924
1920
  }
1925
- return prepared.kekCheck;
1921
+ return prepared.wrongKek;
1926
1922
  }
1927
1923
  /**
1928
1924
  * Each KEK opens its check value, or, with none recorded yet (a fresh
@@ -1933,12 +1929,7 @@ var VaultService = class {
1933
1929
  async #checkKeks() {
1934
1930
  const superseded = await this.#settled();
1935
1931
  if (superseded !== null) return superseded;
1936
- const checks = /* @__PURE__ */ new Map();
1937
- for (const entry of await vaultEntriesOf(this.#db, [KEY_CHECK], -1n, VERIFY_BATCH)) {
1938
- if (!this.#authentic(entry)) continue;
1939
- const wrapped = JSON.parse(entry.metadata);
1940
- checks.set(`${wrapped.kekProvider}:${wrapped.kekId}`, unwrappable(wrapped));
1941
- }
1932
+ const checks = await this.#checkValues(this.#db);
1942
1933
  const budget = this.#prepared.options.keyBudgetMs;
1943
1934
  for (const kek of this.#config.keks.all) {
1944
1935
  const operation = {
@@ -1977,18 +1968,32 @@ var VaultService = class {
1977
1968
  }
1978
1969
  if (samples.length > 0 && proof === null) return mismatch;
1979
1970
  const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
1980
- await this.#db.transaction((tx) => this.#append(tx, [{
1981
- actor: VAULT_ACTOR,
1982
- action: KEY_CHECK,
1983
- decision: "allow",
1984
- metadata: JSON.stringify({
1985
- ...serialisable(wrapped),
1986
- proof
1987
- })
1988
- }]));
1971
+ await this.#db.transaction(async (tx) => {
1972
+ await lockLogHead(tx);
1973
+ if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
1974
+ await this.#append(tx, [{
1975
+ actor: VAULT_ACTOR,
1976
+ action: KEY_CHECK,
1977
+ decision: "allow",
1978
+ metadata: JSON.stringify({
1979
+ ...serialisable(wrapped),
1980
+ proof
1981
+ })
1982
+ }]);
1983
+ });
1989
1984
  }
1990
1985
  return null;
1991
1986
  }
1987
+ /** Each KEK's recorded check value, by `provider:keyId`. */
1988
+ async #checkValues(db) {
1989
+ const checks = /* @__PURE__ */ new Map();
1990
+ for (const entry of await vaultEntriesOf(db, [KEY_CHECK], -1n, VERIFY_BATCH)) {
1991
+ if (!this.#authentic(entry)) continue;
1992
+ const wrapped = JSON.parse(entry.metadata);
1993
+ checks.set(`${wrapped.kekProvider}:${wrapped.kekId}`, unwrappable(wrapped));
1994
+ }
1995
+ return checks;
1996
+ }
1992
1997
  /** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */
1993
1998
  async #latest(db) {
1994
1999
  const row = await latestVaultEntry(db, [CHECKPOINT]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.1.3",
3
+ "version": "0.1.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,15 +36,16 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "drizzle-orm": "0.45.2",
39
- "@coffre/core": "0.1.3",
40
- "@coffre/db": "0.1.3"
39
+ "@coffre/core": "0.1.5",
40
+ "@coffre/db": "0.1.5"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "26.1.1",
44
44
  "@types/pg": "8.20.0",
45
45
  "pg": "8.16.3",
46
46
  "tsdown": "0.23.0",
47
- "typescript": "5.9.3"
47
+ "typescript": "5.9.3",
48
+ "wrangler": "4.118.0"
48
49
  },
49
50
  "scripts": {
50
51
  "build": "tsdown",
package/src/cloudflare.ts CHANGED
@@ -42,8 +42,12 @@ export type WorkersVaultConfig = VaultConfig & { database: PostgresDatabase };
42
42
  /** Set when the Worker's module runs `vault(…)`, before any call comes in. */
43
43
  let configure: ((env: never) => WorkersVaultConfig) | null = null;
44
44
 
45
- /** The configuration, checked and made ready once per `env`, which the isolate keeps. */
46
- const ready = new WeakMap<object, Promise<{ database: PostgresDatabase; prepared: PreparedVault }>>();
45
+ /**
46
+ * The configuration, checked and made ready once per `env`, which the
47
+ * isolate keeps: the ready value itself, never the work of making it, which
48
+ * would belong to the call that started it (vault.ts, `PreparedVault`).
49
+ */
50
+ const ready = new WeakMap<object, { database: PostgresDatabase; prepared: PreparedVault }>();
47
51
 
48
52
  /**
49
53
  * What the app's `VAULT` service binding calls. Each call gets a database of
@@ -56,11 +60,12 @@ export class VaultEntrypoint extends WorkerEntrypoint implements Vault {
56
60
  if (entry === undefined) {
57
61
  if (configure === null) throw new Error('the vault Worker must export default vault(…)');
58
62
  const config = configure(env as never);
59
- entry = prepareVault(resolveVaultConfig(config)).then((prepared) => ({ database: config.database, prepared }));
63
+ const prepared = await prepareVault(resolveVaultConfig(config));
64
+ // Calls that raced here each made one; the first kept serves them all from now on.
65
+ entry = ready.get(env) ?? { database: config.database, prepared };
60
66
  ready.set(env, entry);
61
- entry.catch(() => ready.delete(env));
62
67
  }
63
- const { database, prepared } = await entry;
68
+ const { database, prepared } = entry;
64
69
  return openVault(createDatabase(new HyperdrivePool(database.hyperdrive.connectionString)), prepared);
65
70
  }
66
71
 
package/src/vault.ts CHANGED
@@ -75,7 +75,9 @@ export type VaultOptions = {
75
75
  /**
76
76
  * A vault's configuration made ready to use, once per process, or once per
77
77
  * isolate on Workers: its signer, its log key, and how far it has verified
78
- * the log. Everything else is in the database, so any number of instances
78
+ * the log. It holds settled values only, never work under way: on Workers,
79
+ * a call's I/O is that call's, and a call that waited on another's would be
80
+ * cancelled with it, as hung (isolate.test.ts). Everything else is in the database, so any number of instances
79
81
  * share one set of members, one log and one bulk count.
80
82
  */
81
83
  export type PreparedVault = {
@@ -101,12 +103,10 @@ export type PreparedVault = {
101
103
  since: bigint | null;
102
104
  /** Why it writes nothing, once settled that it may not. */
103
105
  superseded: string | null;
104
- /** The settling under way. */
105
- settling: Promise<void> | null;
106
106
  /** Tampering this process has logged already, so that a forged row is one entry, not one per request. */
107
107
  reported: Set<string>;
108
- /** Whether its KEKs open what they wrapped, once asked: `#kekMismatch`. */
109
- kekCheck: Promise<string | null> | null;
108
+ /** Whether its KEKs are decided to open what they wrapped, or not: `#kekMismatch`. */
109
+ kekChecked: boolean;
110
110
  /** Why not, once decided that they do not. */
111
111
  wrongKek: string | null;
112
112
  };
@@ -121,8 +121,7 @@ export async function prepareVault(config: ResolvedVaultConfig, options: VaultOp
121
121
  reported: new Set(),
122
122
  since: null,
123
123
  superseded: null,
124
- settling: null,
125
- kekCheck: null,
124
+ kekChecked: false,
126
125
  wrongKek: null,
127
126
  };
128
127
  }
@@ -485,12 +484,10 @@ class VaultService implements Vault {
485
484
  */
486
485
  async #settled(): Promise<string | null> {
487
486
  const prepared = this.#prepared;
488
- if (prepared.since === null && prepared.superseded === null) {
489
- prepared.settling ??= this.#settle().finally(() => {
490
- prepared.settling = null;
491
- });
492
- await prepared.settling;
493
- }
487
+ // Each call that finds it unsettled reads for itself, over its own
488
+ // database; calls that race settle it alike, and a rotation they race
489
+ // to write goes in once (`#rotate`).
490
+ if (prepared.since === null && prepared.superseded === null) await this.#settle();
494
491
  return prepared.superseded;
495
492
  }
496
493
 
@@ -1310,22 +1307,21 @@ class VaultService implements Vault {
1310
1307
  /**
1311
1308
  * Null when every KEK the vault is given opens what it wrapped; otherwise
1312
1309
  * why not, naming the KEK, never its key. Decided once per process, before
1313
- * its first key operation or checkpoint. A key service that cannot answer
1314
- * leaves it undecided: the error is thrown, and the next call asks again.
1310
+ * its first key operation or checkpoint: until then, each call checks for
1311
+ * itself, over its own database, and the first verdict is kept. A key
1312
+ * service that cannot answer leaves it undecided: the error is thrown,
1313
+ * and the next call asks again.
1315
1314
  */
1316
- #kekMismatch(): Promise<string | null> {
1315
+ async #kekMismatch(): Promise<string | null> {
1317
1316
  const prepared = this.#prepared;
1318
- if (prepared.kekCheck === null) {
1319
- const check = this.#checkKeks();
1320
- prepared.kekCheck = check;
1321
- check.then(
1322
- (wrong) => void (prepared.wrongKek = wrong),
1323
- () => {
1324
- if (prepared.kekCheck === check) prepared.kekCheck = null;
1325
- },
1326
- );
1317
+ if (!prepared.kekChecked) {
1318
+ const wrong = await this.#checkKeks();
1319
+ if (!prepared.kekChecked) {
1320
+ prepared.wrongKek = wrong;
1321
+ prepared.kekChecked = true;
1322
+ }
1327
1323
  }
1328
- return prepared.kekCheck;
1324
+ return prepared.wrongKek;
1329
1325
  }
1330
1326
 
1331
1327
  /**
@@ -1338,13 +1334,7 @@ class VaultService implements Vault {
1338
1334
  // A vault that may not write cannot record a check either; why it may not is the answer.
1339
1335
  const superseded = await this.#settled();
1340
1336
  if (superseded !== null) return superseded;
1341
- const checks = new Map<string, WrappedDek>();
1342
- for (const entry of await store.vaultEntriesOf(this.#db, [KEY_CHECK], -1n, VERIFY_BATCH)) {
1343
- // A check in the vault's name that the vault did not write proves nothing either way.
1344
- if (!this.#authentic(entry)) continue;
1345
- const wrapped = JSON.parse(entry.metadata) as WrappedKey;
1346
- checks.set(`${wrapped.kekProvider}:${wrapped.kekId}`, unwrappable(wrapped));
1347
- }
1337
+ const checks = await this.#checkValues(this.#db);
1348
1338
  const budget = this.#prepared.options.keyBudgetMs;
1349
1339
  for (const kek of this.#config.keks.all) {
1350
1340
  const operation = { deadline: Date.now() + budget, signal: AbortSignal.timeout(budget) };
@@ -1375,16 +1365,31 @@ class VaultService implements Vault {
1375
1365
  }
1376
1366
  if (samples.length > 0 && proof === null) return mismatch;
1377
1367
  const wrapped = await kek.wrap(Buffer.from(KEY_CHECK_VALUE), KEY_CHECK_CONTEXT, operation);
1378
- // The key it opened, if any, is in the log, as every key the vault opens is.
1379
- await this.#db.transaction((tx) =>
1380
- this.#append(tx, [
1368
+ // The key it opened, if any, is in the log, as every key the vault opens is. Another call that
1369
+ // checked at the same time may have recorded one first: under the log's lock, the first stays.
1370
+ await this.#db.transaction(async (tx) => {
1371
+ await lockLogHead(tx);
1372
+ if ((await this.#checkValues(tx)).has(`${kek.provider}:${kek.keyId}`)) return;
1373
+ await this.#append(tx, [
1381
1374
  { actor: VAULT_ACTOR, action: KEY_CHECK, decision: 'allow', metadata: JSON.stringify({ ...serialisable(wrapped), proof }) },
1382
- ]),
1383
- );
1375
+ ]);
1376
+ });
1384
1377
  }
1385
1378
  return null;
1386
1379
  }
1387
1380
 
1381
+ /** Each KEK's recorded check value, by `provider:keyId`. */
1382
+ async #checkValues(db: Queryable): Promise<Map<string, WrappedDek>> {
1383
+ const checks = new Map<string, WrappedDek>();
1384
+ for (const entry of await store.vaultEntriesOf(db, [KEY_CHECK], -1n, VERIFY_BATCH)) {
1385
+ // A check in the vault's name that the vault did not write proves nothing either way.
1386
+ if (!this.#authentic(entry)) continue;
1387
+ const wrapped = JSON.parse(entry.metadata) as WrappedKey;
1388
+ checks.set(`${wrapped.kekProvider}:${wrapped.kekId}`, unwrappable(wrapped));
1389
+ }
1390
+ return checks;
1391
+ }
1392
+
1388
1393
  // --- checkpoints and the log --------------------------------------------------
1389
1394
 
1390
1395
  /** The last checkpoint the vault signed, and the entry that holds it: its newest allowed `audit.checkpoint`. */