@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 +1 -1
- package/dist/cloudflare.d.ts +1 -1
- package/dist/cloudflare.js +11 -7
- package/dist/{index-CapZlhbz.d.ts → index-DPxyTJNN.d.ts} +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/node.js +2 -2
- package/dist/{src-D1C9S6VS.js → src-DHuqw-IB.js} +7 -7
- package/dist/{vault-D5ZahBN1.js → vault-PQvFopA6.js} +43 -38
- package/package.json +5 -4
- package/src/cloudflare.ts +11 -6
- package/src/config.ts +9 -9
- package/src/log.ts +1 -1
- package/src/vault.ts +48 -43
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.
|
|
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
|
```
|
package/dist/cloudflare.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-
|
|
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";
|
package/dist/cloudflare.js
CHANGED
|
@@ -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-
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 } =
|
|
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: '
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
2
|
-
import { n as prepareVault, t as openVault } from "./vault-
|
|
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(`
|
|
34
|
-
const raw = key32(key, `
|
|
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
|
|
42
|
-
if (typeof keyId !== "string" || !KEK_NAME.test(keyId) || typeof keyVersion !== "string" || !KEK_NAME.test(keyVersion)) throw new Error(`
|
|
43
|
-
if (typeof entry.wrap !== "function" || typeof entry.unwrap !== "function") throw new Error(`
|
|
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
|
|
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}
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1914
|
-
*
|
|
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.
|
|
1919
|
-
const
|
|
1920
|
-
prepared.
|
|
1921
|
-
|
|
1922
|
-
|
|
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.
|
|
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 =
|
|
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 = `
|
|
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) =>
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
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
|
+
"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.
|
|
40
|
-
"@coffre/db": "0.1.
|
|
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:
|
|
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
|
-
/**
|
|
46
|
-
|
|
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
|
-
|
|
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 } =
|
|
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: '
|
|
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(`
|
|
91
|
-
const raw = key32(key, `
|
|
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
|
|
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(`
|
|
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(`
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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.
|
|
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,
|
|
109
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
535
|
-
: `the log moved on from this vault's key, ${current}, to ${newest.keyId}: a vault given a newer
|
|
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
|
|
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
|
|
1314
|
-
*
|
|
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.
|
|
1319
|
-
const
|
|
1320
|
-
prepared.
|
|
1321
|
-
|
|
1322
|
-
|
|
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.
|
|
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 =
|
|
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 = `
|
|
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
|
-
|
|
1380
|
-
|
|
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`. */
|