@coffre/vault 0.1.1 → 0.1.3
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 -2
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/{index-qXbB_vlp.d.ts → index-DPxyTJNN.d.ts} +16 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -3
- package/dist/node.d.ts +2 -2
- package/dist/node.js +3 -3
- package/dist/src-DHuqw-IB.js +113 -0
- package/dist/{vault-DvTsSAOX.js → vault-pexzy7Z-.js} +233 -109
- package/package.json +3 -3
- package/src/cloudflare.ts +1 -2
- package/src/config.ts +66 -17
- package/src/index.ts +2 -0
- package/src/log.ts +54 -7
- package/src/store.ts +16 -0
- package/src/vault.ts +198 -29
package/src/config.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { hkdfSync } from 'node:crypto';
|
|
2
|
+
|
|
1
3
|
import { KekRegistry, LocalKekProvider, type KekProvider } from '@coffre/core/kek';
|
|
2
4
|
|
|
3
5
|
/** At most `count` data keys unwrapped per principal in any `windowMs`. */
|
|
@@ -15,8 +17,12 @@ export type ResolvedVaultConfig = {
|
|
|
15
17
|
keks: KekRegistry;
|
|
16
18
|
/** Emails, lowercased: always active, always owners, never changed through the API. */
|
|
17
19
|
rootAdmins: readonly string[];
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* The seeds of the vault's own keys, which MAC its log entries, seal member
|
|
22
|
+
* rows and sign checkpoints: the first signs; every one verifies what it
|
|
23
|
+
* signed, so a rotation leaves earlier records verifying.
|
|
24
|
+
*/
|
|
25
|
+
signingKeys: readonly Uint8Array[];
|
|
20
26
|
bulkLimit: BulkLimit;
|
|
21
27
|
};
|
|
22
28
|
|
|
@@ -32,20 +38,28 @@ export type Kek = { id: string; key: string } | KekProvider;
|
|
|
32
38
|
*
|
|
33
39
|
* {
|
|
34
40
|
* kek: awsKms({ keyArn: env.KMS_KEY_ARN, credentials: { … } }),
|
|
35
|
-
* previousKeks: [{ id: '
|
|
41
|
+
* previousKeks: [{ id: 'vault-2025-01-10-k7q2xm', key: env.OLD_VAULT_KEY }],
|
|
36
42
|
* rootAdmins: ['admin@acme.example'],
|
|
37
|
-
* signingKey: env.SIGNING_KEY,
|
|
43
|
+
* signingKey: env.SIGNING_KEY, // required with a key service; derived from a local KEK otherwise
|
|
38
44
|
* }
|
|
39
45
|
*/
|
|
40
46
|
export type VaultConfig = {
|
|
41
47
|
/** Wraps every new data key. */
|
|
42
48
|
kek: Kek;
|
|
43
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Older KEKs, still unwrapping what they wrapped until a rewrap moves it
|
|
51
|
+
* on, and verifying what the vault wrote under them before the rotation.
|
|
52
|
+
*/
|
|
44
53
|
previousKeks?: readonly Kek[];
|
|
45
54
|
/** At least one email: the only way into a fresh instance, and the only members nobody can remove. */
|
|
46
55
|
rootAdmins: readonly string[];
|
|
47
|
-
/**
|
|
48
|
-
|
|
56
|
+
/**
|
|
57
|
+
* 32 random bytes, base64: the seed of the vault's own keys, which MAC its
|
|
58
|
+
* log entries, seal member rows and sign checkpoints. Leave it out with a
|
|
59
|
+
* local KEK, and the vault derives it from the KEK. Required when a key
|
|
60
|
+
* service holds the KEK, as AWS KMS does: the vault never sees that key.
|
|
61
|
+
*/
|
|
62
|
+
signingKey?: string;
|
|
49
63
|
/** At most `count` data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
|
|
50
64
|
bulkLimit?: { count: number; windowMinutes: number };
|
|
51
65
|
};
|
|
@@ -61,39 +75,74 @@ const KEK_PROVIDER = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
|
61
75
|
/** Every row records it, and `provider:keyId` finds the KEK again: visible ASCII, bounded. */
|
|
62
76
|
const KEK_NAME = /^[\x21-\x7e]{1,255}$/;
|
|
63
77
|
|
|
64
|
-
|
|
78
|
+
/** The label no other use of a KEK shares: a local KEK is otherwise only ever an AES-256-GCM key. */
|
|
79
|
+
const SIGNING_KEY_LABEL = 'coffre.vault.signing-key.v1';
|
|
80
|
+
|
|
81
|
+
/** The signing key a local KEK stands for, when the vault is given none: HKDF-SHA-256 of the KEK. */
|
|
82
|
+
export function derivedSigningKey(kek: Uint8Array): Buffer {
|
|
83
|
+
return Buffer.from(hkdfSync('sha256', kek, new Uint8Array(0), SIGNING_KEY_LABEL, 32));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A KEK as the registry takes it, and the signing key it stands for when it is one the vault holds. */
|
|
87
|
+
function kek(entry: Kek): { provider: KekProvider; signingKey: Buffer | null } {
|
|
65
88
|
if (!('wrap' in entry)) {
|
|
66
89
|
const { id, key } = entry;
|
|
67
|
-
if (!KEK_ID.test(id)) throw new Error(`
|
|
68
|
-
|
|
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
|
+
return { provider: new LocalKekProvider(raw, id), signingKey: derivedSigningKey(raw) };
|
|
69
93
|
}
|
|
70
94
|
const { provider, keyId, keyVersion } = entry;
|
|
71
95
|
if (typeof provider !== 'string' || !KEK_PROVIDER.test(provider)) {
|
|
72
|
-
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)}"`);
|
|
73
97
|
}
|
|
74
98
|
if (typeof keyId !== 'string' || !KEK_NAME.test(keyId) || typeof keyVersion !== 'string' || !KEK_NAME.test(keyVersion)) {
|
|
75
|
-
throw new Error(`
|
|
99
|
+
throw new Error(`vault key ${provider}: keyId and keyVersion must be 1-255 visible ASCII characters`);
|
|
76
100
|
}
|
|
77
101
|
if (typeof entry.wrap !== 'function' || typeof entry.unwrap !== 'function') {
|
|
78
|
-
throw new Error(`
|
|
102
|
+
throw new Error(`vault key ${provider}:${keyId} needs wrap() and unwrap()`);
|
|
79
103
|
}
|
|
80
|
-
return entry;
|
|
104
|
+
return { provider: entry, signingKey: null };
|
|
81
105
|
}
|
|
82
106
|
|
|
83
107
|
/** Check a deployment's vault configuration, failing on the first problem. */
|
|
84
108
|
export function resolveVaultConfig(config: VaultConfig): ResolvedVaultConfig {
|
|
85
|
-
const
|
|
109
|
+
const keks = [config.kek, ...(config.previousKeks ?? [])].map(kek);
|
|
110
|
+
const [current, ...previous] = keks.map((entry) => entry.provider);
|
|
86
111
|
const refs = [current, ...previous].map(({ provider, keyId }) => `${provider}:${keyId}`);
|
|
87
112
|
const twice = refs.find((ref, i) => refs.indexOf(ref) !== i);
|
|
88
|
-
if (twice !== undefined) throw new Error(`two
|
|
113
|
+
if (twice !== undefined) throw new Error(`two vault keys share an id: ${twice}`);
|
|
89
114
|
return {
|
|
90
115
|
keks: new KekRegistry(current, previous),
|
|
91
116
|
rootAdmins: checkRootAdmins(config.rootAdmins),
|
|
92
|
-
|
|
117
|
+
signingKeys: signingKeys(config.signingKey, keks),
|
|
93
118
|
bulkLimit: config.bulkLimit === undefined ? DEFAULT_BULK_LIMIT : checkBulkLimit(config.bulkLimit),
|
|
94
119
|
};
|
|
95
120
|
}
|
|
96
121
|
|
|
122
|
+
/**
|
|
123
|
+
* The key the vault signs with, then every other it verifies with. It signs
|
|
124
|
+
* with `signingKey` when it is given one, and otherwise with the key the
|
|
125
|
+
* primary KEK stands for. It verifies with the keys every local KEK stands
|
|
126
|
+
* for too, the previous ones included: a KEK rotation changes the derived
|
|
127
|
+
* signing key, and what the old one signed before the rotation must still
|
|
128
|
+
* verify, for as long as the old KEK stays configured. Nothing after it does
|
|
129
|
+
* (`#settle` in vault.ts).
|
|
130
|
+
*/
|
|
131
|
+
function signingKeys(given: string | undefined, keks: { provider: KekProvider; signingKey: Buffer | null }[]): Buffer[] {
|
|
132
|
+
const derived = keks.flatMap((entry) => (entry.signingKey === null ? [] : [entry.signingKey]));
|
|
133
|
+
let signing: Buffer;
|
|
134
|
+
if (given !== undefined) signing = key32(given, 'the signing key');
|
|
135
|
+
else if (keks[0].signingKey !== null) signing = keks[0].signingKey;
|
|
136
|
+
else {
|
|
137
|
+
const { provider, keyId } = keks[0].provider;
|
|
138
|
+
throw new Error(
|
|
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
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return [signing, ...derived].filter((key, i, all) => all.findIndex((other) => other.equals(key)) === i);
|
|
144
|
+
}
|
|
145
|
+
|
|
97
146
|
function checkBulkLimit({ count, windowMinutes }: { count: number; windowMinutes: number }): BulkLimit {
|
|
98
147
|
if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) {
|
|
99
148
|
throw new Error('bulkLimit needs a whole count of at least 1 and a window above 0 minutes');
|
package/src/index.ts
CHANGED
|
@@ -4,3 +4,5 @@ export type { SecretContext } from '@coffre/core/envelope';
|
|
|
4
4
|
export { awsKms, KekBadClaimError, KekUnavailableError } from '@coffre/core/kek';
|
|
5
5
|
export type { AwsCredentials, AwsKmsOptions, KekProvider, WrappedDek } from '@coffre/core/kek';
|
|
6
6
|
export type { Kek, VaultConfig } from './config.ts';
|
|
7
|
+
/** The signing key a local KEK stands for, for tools that check the vault's records from outside. */
|
|
8
|
+
export { derivedSigningKey } from './config.ts';
|
package/src/log.ts
CHANGED
|
@@ -24,12 +24,14 @@ export function vaultLogKey(signingKey: Uint8Array): LogKey {
|
|
|
24
24
|
/**
|
|
25
25
|
* How far this process has verified the chain: every entry before
|
|
26
26
|
* `nextSeq`, the last of which has `hash`, with `vaultEntries` of the
|
|
27
|
-
* vault's among them
|
|
27
|
+
* vault's among them, written under `vaultKeys`, in the order the log moved
|
|
28
|
+
* through them (`forward`). In memory only: the database cannot vouch for
|
|
29
|
+
* itself.
|
|
28
30
|
*/
|
|
29
|
-
export type Anchor = { nextSeq: bigint; hash: Buffer; vaultEntries: number };
|
|
31
|
+
export type Anchor = { nextSeq: bigint; hash: Buffer; vaultEntries: number; vaultKeys: readonly string[] };
|
|
30
32
|
|
|
31
33
|
/** Before the first entry: nothing verified yet. */
|
|
32
|
-
export const UNVERIFIED: Anchor = { nextSeq: 0n, hash: GENESIS_HASH, vaultEntries: 0 };
|
|
34
|
+
export const UNVERIFIED: Anchor = { nextSeq: 0n, hash: GENESIS_HASH, vaultEntries: 0, vaultKeys: [] };
|
|
33
35
|
|
|
34
36
|
/** The further of two anchors, when two calls verified at once. */
|
|
35
37
|
export function further(a: Anchor, b: Anchor): Anchor {
|
|
@@ -57,15 +59,15 @@ type Verified = { verification: LogVerification; anchor: Anchor };
|
|
|
57
59
|
*/
|
|
58
60
|
export async function verifyChain(
|
|
59
61
|
db: Queryable,
|
|
60
|
-
|
|
62
|
+
logKeys: readonly LogKey[],
|
|
61
63
|
shown: readonly StoredEntry[],
|
|
62
64
|
anchor: Anchor,
|
|
63
65
|
): Promise<Verified> {
|
|
64
66
|
const broken = (failedAtSeq: bigint, reason: string): Verified => ({
|
|
65
|
-
verification: { ok: false, failedAtSeq: Number(failedAtSeq), reason },
|
|
67
|
+
verification: { ok: false, failedAtSeq: Number(failedAtSeq), reason: withCause(reason) },
|
|
66
68
|
anchor,
|
|
67
69
|
});
|
|
68
|
-
const keys = { keys:
|
|
70
|
+
const keys = { keys: logKeys, chainOnly: ['app' as const] };
|
|
69
71
|
|
|
70
72
|
for (const row of [...shown].sort((a, b) => (a.seq < b.seq ? -1 : 1))) {
|
|
71
73
|
const result = verifyEntries([row], { ...keys, startSeq: row.seq, startPrevHash: row.prevHash });
|
|
@@ -82,12 +84,57 @@ export async function verifyChain(
|
|
|
82
84
|
if (batch.length === 0) break;
|
|
83
85
|
const result = verifyEntries(batch, { ...keys, startSeq: verified.nextSeq, startPrevHash: verified.hash });
|
|
84
86
|
if (!result.ok) return broken(result.failedAtSeq, result.reason);
|
|
85
|
-
|
|
87
|
+
const moved = forward(batch, verified.vaultKeys, logKeys[0].keyId);
|
|
88
|
+
if ('failedAtSeq' in moved) return broken(moved.failedAtSeq, moved.reason);
|
|
89
|
+
verified = {
|
|
90
|
+
nextSeq: result.nextSeq,
|
|
91
|
+
hash: result.head,
|
|
92
|
+
vaultEntries: verified.vaultEntries + result.authenticated,
|
|
93
|
+
vaultKeys: moved.keys,
|
|
94
|
+
};
|
|
86
95
|
if (batch.length < VERIFY_BATCH) break;
|
|
87
96
|
}
|
|
88
97
|
return { verification: { ok: true, entries: verified.vaultEntries }, anchor: verified };
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
/**
|
|
101
|
+
* The vault's keys only move forward. Once its entries move from one key to
|
|
102
|
+
* the next, the one before writes no more; once they reach `current`, the
|
|
103
|
+
* key it writes with now, no other key writes again. So a key it replaced,
|
|
104
|
+
* even one that leaked, verifies what came before the rotation, and nothing
|
|
105
|
+
* after it.
|
|
106
|
+
*/
|
|
107
|
+
function forward(
|
|
108
|
+
entries: readonly StoredEntry[],
|
|
109
|
+
keys: readonly string[],
|
|
110
|
+
current: string,
|
|
111
|
+
): { keys: readonly string[] } | { failedAtSeq: bigint; reason: string } {
|
|
112
|
+
let moved = keys;
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
const last = moved.at(-1);
|
|
115
|
+
if (entry.author !== 'vault' || entry.keyId === last) continue;
|
|
116
|
+
if (last === current || moved.includes(entry.keyId)) {
|
|
117
|
+
return {
|
|
118
|
+
failedAtSeq: entry.seq,
|
|
119
|
+
reason: `written under ${entry.keyId} after the vault moved to ${last}: a key it replaced verifies only what came before`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
moved = [...moved, entry.keyId];
|
|
123
|
+
}
|
|
124
|
+
return { keys: moved };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A vault entry under a key the vault does not hold is a forgery, or one
|
|
129
|
+
* written under a key it was configured with then: its keys come from its
|
|
130
|
+
* signing key, or from its KEK when it has none. The second is the one an
|
|
131
|
+
* operator can fix.
|
|
132
|
+
*/
|
|
133
|
+
function withCause(reason: string): string {
|
|
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 vault key or signing key, which must stay configured: a vault key that was replaced stays in the vault's config, in previousKeks`;
|
|
136
|
+
}
|
|
137
|
+
|
|
91
138
|
/**
|
|
92
139
|
* Whether the log still holds `head` where it was: not rewritten, nor cut
|
|
93
140
|
* back before it. A head of 64 zeros is before the first entry, which any
|
package/src/store.ts
CHANGED
|
@@ -306,6 +306,22 @@ export async function vaultPage(db: Queryable, before: bigint | undefined, limit
|
|
|
306
306
|
return stored(rows);
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/**
|
|
310
|
+
* The seq of the vault's first entry under `keyId`, or undefined. No index
|
|
311
|
+
* serves it: it reads the log from its start up to that entry, which for
|
|
312
|
+
* the key a log began under is among its first.
|
|
313
|
+
*/
|
|
314
|
+
export async function firstVaultEntryUnder(db: Queryable, keyId: string): Promise<bigint | undefined> {
|
|
315
|
+
const { auditLog } = tablesOf(db);
|
|
316
|
+
const [row] = await db
|
|
317
|
+
.select({ seq: auditLog.seq })
|
|
318
|
+
.from(auditLog)
|
|
319
|
+
.where(and(eq(auditLog.author, 'vault' satisfies Author), eq(auditLog.keyId, keyId)))
|
|
320
|
+
.orderBy(asc(auditLog.seq))
|
|
321
|
+
.limit(1);
|
|
322
|
+
return row?.seq;
|
|
323
|
+
}
|
|
324
|
+
|
|
309
325
|
/** The vault's newest entry of any of these actions, allowed, or undefined. */
|
|
310
326
|
export async function latestVaultEntry(db: Queryable, actions: readonly string[]): Promise<StoredEntry | undefined> {
|
|
311
327
|
const { auditLog } = tablesOf(db);
|