@coffre/vault 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ import { postgres, vault } from '@coffre/vault/cloudflare';
10
10
 
11
11
  export default vault((env) => ({
12
12
  database: postgres(env.VAULT_HYPERDRIVE), // coffre_vault_runtime, caching disabled
13
- kek: { id: env.KEK_ID, key: env.KEK }, // or awsKms({ keyArn, credentials }), with a signingKey
13
+ kek: { id: env.VAULT_KEY_ID, key: env.VAULT_KEY }, // or awsKms({ keyArn, credentials }), with a signingKey
14
14
  rootAdmins: env.ROOT_ADMINS.split(','),
15
15
  }));
16
16
  ```
@@ -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-CapZlhbz.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-DPxyTJNN.js";
2
2
  import { AdmitInput, RemoveInput, RewrapInput, SetAccessInput, 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";
@@ -1,12 +1,16 @@
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-D1C9S6VS.js";
2
- import { n as prepareVault, t as openVault } from "./vault-D5ZahBN1.js";
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-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) {
@@ -16,7 +16,7 @@ type Kek = {
16
16
  *
17
17
  * {
18
18
  * kek: awsKms({ keyArn: env.KMS_KEY_ARN, credentials: { … } }),
19
- * previousKeks: [{ id: 'kek-2025-01', key: env.KEK_2025_01 }],
19
+ * previousKeks: [{ id: 'vault-2025-01-10-k7q2xm', key: env.OLD_VAULT_KEY }],
20
20
  * rootAdmins: ['admin@acme.example'],
21
21
  * signingKey: env.SIGNING_KEY, // required with a key service; derived from a local KEK otherwise
22
22
  * }
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-CapZlhbz.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-DPxyTJNN.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-D1C9S6VS.js";
1
+ import { a as verifyCheckpoint, i as checkpointMessage, n as KekUnavailableError, o as derivedSigningKey, r as awsKms, t as KekBadClaimError } from "./src-DHuqw-IB.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-CapZlhbz.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-DPxyTJNN.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/audit";
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-D1C9S6VS.js";
2
- import { n as prepareVault, t as openVault } from "./vault-D5ZahBN1.js";
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-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";
@@ -30,17 +30,17 @@ function derivedSigningKey(kek) {
30
30
  function kek(entry) {
31
31
  if (!("wrap" in entry)) {
32
32
  const { id, key } = entry;
33
- if (!KEK_ID.test(id)) throw new Error(`KEK id "${id}" must be 1-64 letters, digits, dots, dashes or underscores`);
34
- const raw = key32(key, `KEK ${id}`);
33
+ if (!KEK_ID.test(id)) throw new Error(`the vault key's ID "${id}" (kek.id) must be 1-64 letters, digits, dots, dashes or underscores`);
34
+ const raw = key32(key, `the vault key ${id}`);
35
35
  return {
36
36
  provider: new LocalKekProvider(raw, id),
37
37
  signingKey: derivedSigningKey(raw)
38
38
  };
39
39
  }
40
40
  const { provider, keyId, keyVersion } = entry;
41
- if (typeof provider !== "string" || !KEK_PROVIDER.test(provider)) throw new Error(`a KEK provider's name must be 1-32 lowercase letters, digits or dashes; got "${String(provider)}"`);
42
- if (typeof keyId !== "string" || !KEK_NAME.test(keyId) || typeof keyVersion !== "string" || !KEK_NAME.test(keyVersion)) throw new Error(`KEK ${provider}: keyId and keyVersion must be 1-255 visible ASCII characters`);
43
- if (typeof entry.wrap !== "function" || typeof entry.unwrap !== "function") throw new Error(`KEK ${provider}:${keyId} needs wrap() and unwrap()`);
41
+ if (typeof provider !== "string" || !KEK_PROVIDER.test(provider)) throw new Error(`a key service's name (kek.provider) must be 1-32 lowercase letters, digits or dashes; got "${String(provider)}"`);
42
+ if (typeof keyId !== "string" || !KEK_NAME.test(keyId) || typeof keyVersion !== "string" || !KEK_NAME.test(keyVersion)) throw new Error(`vault key ${provider}: keyId and keyVersion must be 1-255 visible ASCII characters`);
43
+ if (typeof entry.wrap !== "function" || typeof entry.unwrap !== "function") throw new Error(`vault key ${provider}:${keyId} needs wrap() and unwrap()`);
44
44
  return {
45
45
  provider: entry,
46
46
  signingKey: null
@@ -52,7 +52,7 @@ function resolveVaultConfig(config) {
52
52
  const [current, ...previous] = keks.map((entry) => entry.provider);
53
53
  const refs = [current, ...previous].map(({ provider, keyId }) => `${provider}:${keyId}`);
54
54
  const twice = refs.find((ref, i) => refs.indexOf(ref) !== i);
55
- if (twice !== void 0) throw new Error(`two KEKs share an id: ${twice}`);
55
+ if (twice !== void 0) throw new Error(`two vault keys share an id: ${twice}`);
56
56
  return {
57
57
  keks: new KekRegistry(current, previous),
58
58
  rootAdmins: checkRootAdmins(config.rootAdmins),
@@ -76,7 +76,7 @@ function signingKeys(given, keks) {
76
76
  else if (keks[0].signingKey !== null) signing = keks[0].signingKey;
77
77
  else {
78
78
  const { provider, keyId } = keks[0].provider;
79
- throw new Error(`signingKey is required with the ${provider} KEK ${keyId}: the vault derives its signing key only from a KEK it holds, and a key service never hands its key over. Set signingKey to 32 random bytes, base64 (openssl rand -base64 32), and keep it with the KEK.`);
79
+ throw new Error(`signingKey is required with the ${provider} vault key ${keyId}: the vault derives its signing key only from a vault key it holds, and a key service never hands its key over. Set signingKey to 32 random bytes, base64 (openssl rand -base64 32), and keep it with the vault key.`);
80
80
  }
81
81
  return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
82
82
  }
@@ -377,7 +377,7 @@ function forward(entries, keys, current) {
377
377
  */
378
378
  function withCause(reason) {
379
379
  if (!/^written under vault:\S+, a key this verifier does not hold$/.test(reason)) return reason;
380
- return `${reason}: either it is forged, or the vault wrote it under another KEK or signing key, which must stay configured: a KEK that was replaced stays in previousKeks`;
380
+ return `${reason}: either it is forged, or the vault wrote it under another vault key or signing key, which must stay configured: a vault key that was replaced stays in the vault's config, in previousKeks`;
381
381
  }
382
382
  /**
383
383
  * Whether the log still holds `head` where it was: not rewritten, nor cut
@@ -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
  }
@@ -890,7 +889,7 @@ const MESSAGES = {
890
889
  root_admin: "root admins are set in the vault configuration",
891
890
  invalid: "not something the rules allow",
892
891
  log_broken: "the vault log does not hold from the last checkpoint",
893
- wrong_kek: "this vault's KEK does not open the data it holds",
892
+ wrong_kek: "this vault's key does not open the data it holds",
894
893
  tampered: "this member's record failed the vault's integrity check"
895
894
  };
896
895
  /**
@@ -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
  /**
@@ -1140,7 +1134,7 @@ var VaultService = class {
1140
1134
  if (attempt < 3 && error instanceof Retry) continue;
1141
1135
  throw error;
1142
1136
  }
1143
- prepared.superseded = first === void 0 ? `the vault's entries are under ${newest.keyId}, a key this vault does not hold: it was given the wrong KEK or signing key, or a KEK it replaced is missing from previousKeks` : `the log moved on from this vault's key, ${current}, to ${newest.keyId}: a vault given a newer KEK replaced it, and a replaced key writes nothing more`;
1137
+ prepared.superseded = first === void 0 ? `the vault's entries are under ${newest.keyId}, a key this vault does not hold: it was given the wrong vault key or signing key, or a vault key it replaced is missing from previousKeks in its config` : `the log moved on from this vault's key, ${current}, to ${newest.keyId}: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
1144
1138
  return;
1145
1139
  }
1146
1140
  }
@@ -1189,7 +1183,7 @@ var VaultService = class {
1189
1183
  return appendEntries(tx, prepared.logKey, entries, async (locked) => {
1190
1184
  const rotation = await latestVaultEntry(locked, [KEY_ROTATE]);
1191
1185
  if (rotation === void 0 || prepared.logKeys.some((key) => key.keyId === rotation.keyId)) return;
1192
- prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer KEK replaced it, and a replaced key writes nothing more`;
1186
+ prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
1193
1187
  throw new Error(prepared.superseded);
1194
1188
  });
1195
1189
  }
@@ -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 = {
@@ -1956,7 +1947,7 @@ var VaultService = class {
1956
1947
  throw error;
1957
1948
  }
1958
1949
  };
1959
- const mismatch = `this vault's ${kek.provider} KEK ${kek.keyId} does not open the data it holds: it is not the key that wrapped it`;
1950
+ const mismatch = `the vault key ${kek.keyId}${kek.provider === "local" ? "" : ` (${kek.provider})`}, kek or previousKeks in the vault's config, is not the one that wrapped these values`;
1960
1951
  const check = checks.get(`${kek.provider}:${kek.keyId}`);
1961
1952
  if (check !== void 0) {
1962
1953
  if (!await opens(check, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE)) return mismatch;
@@ -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.2",
3
+ "version": "0.1.4",
4
4
  "description": "coffre's vault: keys, grants and members, in the database the app uses, as a Cloudflare Worker or on Node.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,15 +36,16 @@
36
36
  },
37
37
  "dependencies": {
38
38
  "drizzle-orm": "0.45.2",
39
- "@coffre/core": "0.1.2",
40
- "@coffre/db": "0.1.2"
39
+ "@coffre/core": "0.1.4",
40
+ "@coffre/db": "0.1.4"
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
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * export default vault((env: Env) => ({
10
10
  * database: postgres(env.HYPERDRIVE),
11
- * kek: { id: 'kek-1', key: env.KEK }, // or awsKms({ keyArn, credentials }), with a signingKey
11
+ * kek: { id: env.VAULT_KEY_ID, key: env.VAULT_KEY }, // or awsKms({ keyArn, credentials }), with a signingKey
12
12
  * rootAdmins: ['admin@acme.example'],
13
13
  * }));
14
14
  *
@@ -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/config.ts CHANGED
@@ -38,7 +38,7 @@ export type Kek = { id: string; key: string } | KekProvider;
38
38
  *
39
39
  * {
40
40
  * kek: awsKms({ keyArn: env.KMS_KEY_ARN, credentials: { … } }),
41
- * previousKeks: [{ id: 'kek-2025-01', key: env.KEK_2025_01 }],
41
+ * previousKeks: [{ id: 'vault-2025-01-10-k7q2xm', key: env.OLD_VAULT_KEY }],
42
42
  * rootAdmins: ['admin@acme.example'],
43
43
  * signingKey: env.SIGNING_KEY, // required with a key service; derived from a local KEK otherwise
44
44
  * }
@@ -87,19 +87,19 @@ export function derivedSigningKey(kek: Uint8Array): Buffer {
87
87
  function kek(entry: Kek): { provider: KekProvider; signingKey: Buffer | null } {
88
88
  if (!('wrap' in entry)) {
89
89
  const { id, key } = entry;
90
- if (!KEK_ID.test(id)) throw new Error(`KEK id "${id}" must be 1-64 letters, digits, dots, dashes or underscores`);
91
- const raw = key32(key, `KEK ${id}`);
90
+ if (!KEK_ID.test(id)) throw new Error(`the vault key's ID "${id}" (kek.id) must be 1-64 letters, digits, dots, dashes or underscores`);
91
+ const raw = key32(key, `the vault key ${id}`);
92
92
  return { provider: new LocalKekProvider(raw, id), signingKey: derivedSigningKey(raw) };
93
93
  }
94
94
  const { provider, keyId, keyVersion } = entry;
95
95
  if (typeof provider !== 'string' || !KEK_PROVIDER.test(provider)) {
96
- throw new Error(`a KEK provider's name must be 1-32 lowercase letters, digits or dashes; got "${String(provider)}"`);
96
+ throw new Error(`a key service's name (kek.provider) must be 1-32 lowercase letters, digits or dashes; got "${String(provider)}"`);
97
97
  }
98
98
  if (typeof keyId !== 'string' || !KEK_NAME.test(keyId) || typeof keyVersion !== 'string' || !KEK_NAME.test(keyVersion)) {
99
- throw new Error(`KEK ${provider}: keyId and keyVersion must be 1-255 visible ASCII characters`);
99
+ throw new Error(`vault key ${provider}: keyId and keyVersion must be 1-255 visible ASCII characters`);
100
100
  }
101
101
  if (typeof entry.wrap !== 'function' || typeof entry.unwrap !== 'function') {
102
- throw new Error(`KEK ${provider}:${keyId} needs wrap() and unwrap()`);
102
+ throw new Error(`vault key ${provider}:${keyId} needs wrap() and unwrap()`);
103
103
  }
104
104
  return { provider: entry, signingKey: null };
105
105
  }
@@ -110,7 +110,7 @@ export function resolveVaultConfig(config: VaultConfig): ResolvedVaultConfig {
110
110
  const [current, ...previous] = keks.map((entry) => entry.provider);
111
111
  const refs = [current, ...previous].map(({ provider, keyId }) => `${provider}:${keyId}`);
112
112
  const twice = refs.find((ref, i) => refs.indexOf(ref) !== i);
113
- if (twice !== undefined) throw new Error(`two KEKs share an id: ${twice}`);
113
+ if (twice !== undefined) throw new Error(`two vault keys share an id: ${twice}`);
114
114
  return {
115
115
  keks: new KekRegistry(current, previous),
116
116
  rootAdmins: checkRootAdmins(config.rootAdmins),
@@ -136,8 +136,8 @@ function signingKeys(given: string | undefined, keks: { provider: KekProvider; s
136
136
  else {
137
137
  const { provider, keyId } = keks[0].provider;
138
138
  throw new Error(
139
- `signingKey is required with the ${provider} KEK ${keyId}: the vault derives its signing key only from a KEK it holds, ` +
140
- 'and a key service never hands its key over. Set signingKey to 32 random bytes, base64 (openssl rand -base64 32), and keep it with the KEK.',
139
+ `signingKey is required with the ${provider} vault key ${keyId}: the vault derives its signing key only from a vault key it holds, ` +
140
+ 'and a key service never hands its key over. Set signingKey to 32 random bytes, base64 (openssl rand -base64 32), and keep it with the vault key.',
141
141
  );
142
142
  }
143
143
  return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
package/src/log.ts CHANGED
@@ -132,7 +132,7 @@ function forward(
132
132
  */
133
133
  function withCause(reason: string): string {
134
134
  if (!/^written under vault:\S+, a key this verifier does not hold$/.test(reason)) return reason;
135
- return `${reason}: either it is forged, or the vault wrote it under another KEK or signing key, which must stay configured: a KEK that was replaced stays in previousKeks`;
135
+ return `${reason}: either it is forged, or the vault wrote it under another vault key or signing key, which must stay configured: a vault key that was replaced stays in the vault's config, in previousKeks`;
136
136
  }
137
137
 
138
138
  /**
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
  }
@@ -223,7 +222,7 @@ const MESSAGES: Record<RefusalCode, string> = {
223
222
  root_admin: 'root admins are set in the vault configuration',
224
223
  invalid: 'not something the rules allow',
225
224
  log_broken: 'the vault log does not hold from the last checkpoint',
226
- wrong_kek: "this vault's KEK does not open the data it holds",
225
+ wrong_kek: "this vault's key does not open the data it holds",
227
226
  tampered: "this member's record failed the vault's integrity check",
228
227
  };
229
228
 
@@ -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
 
@@ -531,8 +528,8 @@ class VaultService implements Vault {
531
528
  }
532
529
  prepared.superseded =
533
530
  first === undefined
534
- ? `the vault's entries are under ${newest.keyId}, a key this vault does not hold: it was given the wrong KEK or signing key, or a KEK it replaced is missing from previousKeks`
535
- : `the log moved on from this vault's key, ${current}, to ${newest.keyId}: a vault given a newer KEK replaced it, and a replaced key writes nothing more`;
531
+ ? `the vault's entries are under ${newest.keyId}, a key this vault does not hold: it was given the wrong vault key or signing key, or a vault key it replaced is missing from previousKeks in its config`
532
+ : `the log moved on from this vault's key, ${current}, to ${newest.keyId}: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
536
533
  return;
537
534
  }
538
535
  }
@@ -581,7 +578,7 @@ class VaultService implements Vault {
581
578
  return appendEntries(tx, prepared.logKey, entries, async (locked) => {
582
579
  const rotation = await store.latestVaultEntry(locked, [KEY_ROTATE]);
583
580
  if (rotation === undefined || prepared.logKeys.some((key) => key.keyId === rotation.keyId)) return;
584
- prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer KEK replaced it, and a replaced key writes nothing more`;
581
+ prepared.superseded = `the log moved on to ${rotation.keyId}, a key this vault does not hold: a vault given a newer vault key replaced it, and a replaced key writes nothing more`;
585
582
  throw new Error(prepared.superseded);
586
583
  });
587
584
  }
@@ -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) };
@@ -1359,7 +1349,7 @@ class VaultService implements Vault {
1359
1349
  throw error;
1360
1350
  }
1361
1351
  };
1362
- const mismatch = `this vault's ${kek.provider} KEK ${kek.keyId} does not open the data it holds: it is not the key that wrapped it`;
1352
+ const mismatch = `the vault key ${kek.keyId}${kek.provider === 'local' ? '' : ` (${kek.provider})`}, kek or previousKeks in the vault's config, is not the one that wrapped these values`;
1363
1353
  const check = checks.get(`${kek.provider}:${kek.keyId}`);
1364
1354
  if (check !== undefined) {
1365
1355
  if (!(await opens(check, KEY_CHECK_CONTEXT, KEY_CHECK_VALUE))) return mismatch;
@@ -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`. */