@coffre/vault 0.0.0 → 0.1.0

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/package.json CHANGED
@@ -1,8 +1,54 @@
1
1
  {
2
2
  "name": "@coffre/vault",
3
- "version": "0.0.0",
4
- "description": "Reserved for coffre, a self-hosted secrets manager. The first release replaces this placeholder.",
3
+ "version": "0.1.0",
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
- "homepage": "https://github.com/erwinkn/coffre",
7
- "repository": { "type": "git", "url": "git+https://github.com/erwinkn/coffre.git", "directory": "packages/vault" }
8
- }
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/erwinkn/coffre.git",
9
+ "directory": "packages/vault"
10
+ },
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "src"
18
+ ],
19
+ "exports": {
20
+ ".": {
21
+ "coffre:source": "./src/index.ts",
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ },
25
+ "./cloudflare": {
26
+ "coffre:source": "./src/cloudflare.ts",
27
+ "types": "./dist/cloudflare.d.ts",
28
+ "default": "./dist/cloudflare.js"
29
+ },
30
+ "./node": {
31
+ "coffre:source": "./src/node.ts",
32
+ "types": "./dist/node.d.ts",
33
+ "default": "./dist/node.js"
34
+ },
35
+ "./package.json": "./package.json"
36
+ },
37
+ "dependencies": {
38
+ "drizzle-orm": "0.45.2",
39
+ "@coffre/core": "0.1.0",
40
+ "@coffre/db": "0.1.0"
41
+ },
42
+ "devDependencies": {
43
+ "@types/node": "26.1.1",
44
+ "@types/pg": "8.20.0",
45
+ "pg": "8.16.3",
46
+ "tsdown": "0.23.0",
47
+ "typescript": "5.9.3"
48
+ },
49
+ "scripts": {
50
+ "build": "tsdown",
51
+ "typecheck": "tsc --noEmit",
52
+ "test": "node --conditions=coffre:source --test"
53
+ }
54
+ }
@@ -0,0 +1,110 @@
1
+ import type { StoredEntry } from '@coffre/core/audit';
2
+ import type { LogVerification } from '@coffre/core/vault';
3
+ import type { Queryable } from '@coffre/db';
4
+
5
+ import { VERIFY_BATCH } from './log.ts';
6
+ import { entriesFrom } from './store.ts';
7
+
8
+ const KEY_ACTIONS = new Set(['secret.read', 'key.wrap', 'key.rewrap']);
9
+
10
+ type Item = { item: number; secretId: string; version: number; subject: string };
11
+ type ParsedIntent = { intentId: string; operation: string; expiresAt: number; keys: Item[] };
12
+ type ParsedOutcome = Item & { intentId: string; relatedSeq: bigint };
13
+ type Intent = ParsedIntent & { entry: StoredEntry; seen: Set<number> };
14
+ type Accounting = { ok: true; pending: number } | Extract<LogVerification, { ok: false }>;
15
+
16
+ /** The chain can hold while a process died between an intent and its outcomes. */
17
+ export async function verifyAccounting(db: Queryable, at: number): Promise<Accounting> {
18
+ const intents = new Map<bigint, Intent>();
19
+ const ids = new Set<string>();
20
+ const broken = (entry: StoredEntry, reason: string): Accounting => ({ ok: false, failedAtSeq: Number(entry.seq), reason });
21
+ let next = 0n;
22
+ for (;;) {
23
+ const batch = await entriesFrom(db, next, VERIFY_BATCH);
24
+ if (batch.length === 0) break;
25
+ for (const entry of batch) {
26
+ if (entry.author !== 'vault') continue;
27
+ if (entry.action === 'key.intent') {
28
+ const parsed = parseIntent(entry);
29
+ if (typeof parsed === 'string') return broken(entry, parsed);
30
+ if (ids.has(parsed.intentId)) return broken(entry, 'key intent repeats an operation identity');
31
+ ids.add(parsed.intentId);
32
+ if (parsed.keys.length > 0) intents.set(entry.seq, { ...parsed, entry, seen: new Set() });
33
+ } else if (KEY_ACTIONS.has(entry.action) && entry.relatedSeq !== null) {
34
+ // An outcome names its intent; a key released with no service to call has none.
35
+ const parsed = parseOutcome(entry);
36
+ if (typeof parsed === 'string') return broken(entry, parsed);
37
+ const intent = intents.get(parsed.relatedSeq);
38
+ const key = intent?.keys[parsed.item];
39
+ if (intent === undefined || key === undefined || intent.seen.has(parsed.item)) {
40
+ return broken(entry, 'key outcome does not identify one item of its intent');
41
+ }
42
+ if (parsed.intentId !== intent.intentId || entry.actor !== intent.entry.actor || entry.action !== intent.operation) {
43
+ return broken(entry, 'key outcome belongs to another operation');
44
+ }
45
+ if (parsed.secretId !== key.secretId || parsed.version !== key.version || parsed.subject !== key.subject) {
46
+ return broken(entry, 'key outcome does not match its intended secret');
47
+ }
48
+ intent.seen.add(parsed.item);
49
+ // Completed batches need no state, and another outcome for one is a duplicate.
50
+ if (intent.seen.size === intent.keys.length) intents.delete(intent.entry.seq);
51
+ }
52
+ }
53
+ next = batch[batch.length - 1].seq + 1n;
54
+ if (batch.length < VERIFY_BATCH) break;
55
+ }
56
+ // Live calls can finish after this snapshot. Only a missed deadline is a fault.
57
+ const overdue = [...intents.values()].filter((intent) => intent.expiresAt <= at).sort((a, b) => a.expiresAt - b.expiresAt);
58
+ if (overdue.length === 0) return { ok: true, pending: intents.size };
59
+ const reason = overdue.map((intent) => {
60
+ const missing = intent.keys.length - intent.seen.size;
61
+ return `key intent ${intent.intentId} is overdue: ${missing} of ${intent.keys.length} outcomes missing`;
62
+ }).join('; ');
63
+ return broken(overdue[0].entry, reason);
64
+ }
65
+
66
+ function parseIntent(entry: StoredEntry): ParsedIntent | string {
67
+ const detail = payload(entry.metadata);
68
+ if (detail === null) return 'key intent has no valid accounting payload';
69
+ const { intent, operation, expiresAt } = detail;
70
+ if (typeof intent !== 'string') return 'key intent has no operation identity';
71
+ if (typeof operation !== 'string' || !KEY_ACTIONS.has(operation)) return 'key intent has an invalid operation';
72
+ if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt) || expiresAt < entry.occurredAt) {
73
+ return 'key intent has no valid deadline';
74
+ }
75
+ if (!Array.isArray(detail.keys)) return 'key intent has no valid item list';
76
+ const keys: Item[] = [];
77
+ for (const [index, value] of detail.keys.entries()) {
78
+ const key = record(value);
79
+ if (key === null || key.item !== index) return 'key intent items are not numbered in order';
80
+ const { secretId, version, subject } = key;
81
+ if (typeof secretId !== 'string' || typeof subject !== 'string') return 'key intent item has no secret identity';
82
+ if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1) return 'key intent item has no valid version';
83
+ keys.push({ item: index, secretId, version, subject });
84
+ }
85
+ return { intentId: intent, operation, expiresAt, keys };
86
+ }
87
+
88
+ function parseOutcome(entry: StoredEntry): ParsedOutcome | string {
89
+ const detail = payload(entry.metadata);
90
+ if (detail === null) return 'key outcome has no valid accounting payload';
91
+ const { intent, item, version, subject } = detail;
92
+ if (typeof intent !== 'string' || entry.relatedSeq === null) return 'key outcome has no intent identity';
93
+ if (typeof item !== 'number' || !Number.isSafeInteger(item) || item < 0) return 'key outcome has no valid item number';
94
+ const secretId = entry.secretId ?? detail.secretId;
95
+ if (typeof secretId !== 'string' || typeof subject !== 'string') return 'key outcome has no secret identity';
96
+ if (typeof version !== 'number' || !Number.isSafeInteger(version) || version < 1) return 'key outcome has no valid version';
97
+ return { intentId: intent, relatedSeq: entry.relatedSeq, item, secretId, version, subject };
98
+ }
99
+
100
+ function payload(text: string): Record<string, unknown> | null {
101
+ try {
102
+ return record(JSON.parse(text));
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ function record(value: unknown): Record<string, unknown> | null {
109
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
110
+ }
@@ -0,0 +1,25 @@
1
+ const PKCS8_ED25519 = [0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20];
2
+
3
+ export type Signer = {
4
+ /** The raw Ed25519 public key, base64. */
5
+ publicKey: string;
6
+ /** The first 16 hex digits of the public key's SHA-256. */
7
+ keyId: string;
8
+ sign(message: Uint8Array<ArrayBuffer>): Promise<string>;
9
+ };
10
+
11
+ /** An Ed25519 signer from a 32-byte seed, over WebCrypto so it runs in Node and in workerd alike. */
12
+ export async function signer(seed: Uint8Array): Promise<Signer> {
13
+ const pkcs8 = new Uint8Array([...PKCS8_ED25519, ...seed]);
14
+ const privateKey = await crypto.subtle.importKey('pkcs8', pkcs8, { name: 'Ed25519' }, true, ['sign']);
15
+ // A private JWK carries its public half, `x`; the raw private key does not export it.
16
+ const { x } = (await crypto.subtle.exportKey('jwk', privateKey)) as JsonWebKey;
17
+ const publicKey = Buffer.from(x!, 'base64url');
18
+ const digest = Buffer.from(await crypto.subtle.digest('SHA-256', publicKey));
19
+ return {
20
+ publicKey: publicKey.toString('base64'),
21
+ keyId: digest.toString('hex').slice(0, 16),
22
+ sign: async (message) =>
23
+ Buffer.from(await crypto.subtle.sign('Ed25519', privateKey, message)).toString('base64'),
24
+ };
25
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The one thing the vault takes from the Workers runtime, declared here so
3
+ * that its Worker typechecks with the rest of the package, under Node's
4
+ * types, as the server's does. Workers' own types would shadow Node's
5
+ * `Buffer` in every module the Worker imports: the store, the log, Drizzle.
6
+ * A deployment that builds the Worker brings the real ones.
7
+ */
8
+ declare module 'cloudflare:workers' {
9
+ export abstract class WorkerEntrypoint<Env = unknown> {
10
+ protected env: Env;
11
+ protected ctx: { waitUntil(promise: Promise<unknown>): void };
12
+ constructor(ctx: unknown, env: Env);
13
+ }
14
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The vault as a Worker, behind an entrypoint the app's `VAULT` service
3
+ * binding calls over RPC. It has no route and no HTTP surface. It decides
4
+ * in the database the app uses, through a Hyperdrive binding of its own,
5
+ * whose login is the vault's:
6
+ *
7
+ * import { postgres, vault } from '@coffre/vault/cloudflare';
8
+ *
9
+ * export default vault((env: Env) => ({
10
+ * database: postgres(env.HYPERDRIVE),
11
+ * kek: { id: 'kek-1', key: env.KEK }, // or awsKms({ keyArn, credentials })
12
+ * rootAdmins: ['admin@acme.example'],
13
+ * signingKey: env.SIGNING_KEY,
14
+ * }));
15
+ *
16
+ * Any number of isolates run it side by side: every decision locks what it
17
+ * is about in the database, so they share one log and one bulk limit.
18
+ */
19
+ import type {
20
+ AdmitInput,
21
+ RemoveInput,
22
+ RewrapInput,
23
+ SetAccessInput,
24
+ UnwrapInput,
25
+ Vault,
26
+ VerifyLogInput,
27
+ WrapInput,
28
+ } from '@coffre/core/vault';
29
+ import { createDatabase } from '@coffre/db';
30
+ import { HyperdrivePool, type PostgresDatabase } from '@coffre/db/hyperdrive';
31
+ import { WorkerEntrypoint } from 'cloudflare:workers';
32
+
33
+ import { resolveVaultConfig, type VaultConfig } from './config.ts';
34
+ import { openVault, prepareVault, type PreparedVault } from './vault.ts';
35
+
36
+ export type { Vault, VaultConfig };
37
+ export { postgres, type PostgresDatabase } from '@coffre/db/hyperdrive';
38
+ export * from './index.ts';
39
+
40
+ /** What the vault Worker's `vault(env => …)` returns. */
41
+ export type WorkersVaultConfig = VaultConfig & { database: PostgresDatabase };
42
+
43
+ /** Set when the Worker's module runs `vault(…)`, before any call comes in. */
44
+ let configure: ((env: never) => WorkersVaultConfig) | null = null;
45
+
46
+ /** The configuration, checked and made ready once per `env`, which the isolate keeps. */
47
+ const ready = new WeakMap<object, Promise<{ database: PostgresDatabase; prepared: PreparedVault }>>();
48
+
49
+ /**
50
+ * What the app's `VAULT` service binding calls. Each call gets a database of
51
+ * its own, since a Worker's connections belong to the call that made them.
52
+ */
53
+ export class VaultEntrypoint extends WorkerEntrypoint implements Vault {
54
+ async #vault(): Promise<Vault> {
55
+ const env = this.env as object;
56
+ let entry = ready.get(env);
57
+ if (entry === undefined) {
58
+ if (configure === null) throw new Error('the vault Worker must export default vault(…)');
59
+ const config = configure(env as never);
60
+ entry = prepareVault(resolveVaultConfig(config)).then((prepared) => ({ database: config.database, prepared }));
61
+ ready.set(env, entry);
62
+ entry.catch(() => ready.delete(env));
63
+ }
64
+ const { database, prepared } = await entry;
65
+ return openVault(createDatabase(new HyperdrivePool(database.hyperdrive.connectionString)), prepared);
66
+ }
67
+
68
+ async unwrap(input: UnwrapInput) { return (await this.#vault()).unwrap(input); }
69
+ async wrap(input: WrapInput) { return (await this.#vault()).wrap(input); }
70
+ async rewrap(input: RewrapInput) { return (await this.#vault()).rewrap(input); }
71
+ async access(principal: string) { return (await this.#vault()).access(principal); }
72
+ async setAccess(input: SetAccessInput) { return (await this.#vault()).setAccess(input); }
73
+ async admit(input: AdmitInput) { return (await this.#vault()).admit(input); }
74
+ async remove(input: RemoveInput) { return (await this.#vault()).remove(input); }
75
+ async checkpoint() { return (await this.#vault()).checkpoint(); }
76
+ async about() { return (await this.#vault()).about(); }
77
+ async verifyLog(input: VerifyLogInput) { return (await this.#vault()).verifyLog(input); }
78
+
79
+ /** No HTTP surface: only the app's service binding reaches the vault. */
80
+ fetch() {
81
+ return new Response('not found', { status: 404 });
82
+ }
83
+ }
84
+
85
+ /** The vault Worker's default export, `VaultEntrypoint`, configured from its `env`. */
86
+ export function vault<Env>(configure_: (env: Env) => WorkersVaultConfig): typeof VaultEntrypoint {
87
+ configure = configure_ as (env: never) => WorkersVaultConfig;
88
+ return VaultEntrypoint;
89
+ }
package/src/config.ts ADDED
@@ -0,0 +1,146 @@
1
+ import { KekRegistry, LocalKekProvider, type KekProvider } from '@coffre/core/kek';
2
+
3
+ /** At most `count` data keys unwrapped per principal in any `windowMs`. */
4
+ export type BulkLimit = { count: number; windowMs: number };
5
+
6
+ /**
7
+ * 1000 keys in 15 minutes: twenty `coffre run`s of a 50-key environment
8
+ * back to back, which no person or deploy pipeline does, while a script
9
+ * pulling every secret it can reach stops within a few seconds.
10
+ */
11
+ export const DEFAULT_BULK_LIMIT: BulkLimit = { count: 1000, windowMs: 15 * 60_000 };
12
+
13
+ /** The vault as it runs: keys decoded, emails checked. */
14
+ export type ResolvedVaultConfig = {
15
+ keks: KekRegistry;
16
+ /** Emails, lowercased: always active, always owners, never changed through the API. */
17
+ rootAdmins: readonly string[];
18
+ /** The Ed25519 seed checkpoints are signed with. */
19
+ signingKey: Uint8Array;
20
+ bulkLimit: BulkLimit;
21
+ };
22
+
23
+ /**
24
+ * A key-encryption key: 32 random bytes, base64, and the id envelopes record
25
+ * it under; or one a key service holds, such as `awsKms({ keyArn, … })`.
26
+ */
27
+ export type Kek = { id: string; key: string } | KekProvider;
28
+
29
+ /**
30
+ * What a deployment writes: every key coffre has, and who may always get
31
+ * in. The app holds none of it.
32
+ *
33
+ * {
34
+ * kek: awsKms({ keyArn: env.KMS_KEY_ARN, credentials: { … } }),
35
+ * previousKeks: [{ id: 'kek-2025-01', key: env.KEK_2025_01 }],
36
+ * rootAdmins: ['admin@acme.example'],
37
+ * signingKey: env.SIGNING_KEY,
38
+ * }
39
+ */
40
+ export type VaultConfig = {
41
+ /** Wraps every new data key. */
42
+ kek: Kek;
43
+ /** Older KEKs, still unwrapping what they wrapped until a rewrap moves it on. */
44
+ previousKeks?: readonly Kek[];
45
+ /** At least one email: the only way into a fresh instance, and the only members nobody can remove. */
46
+ rootAdmins: readonly string[];
47
+ /** 32 random bytes, base64: the Ed25519 seed audit checkpoints are signed with. */
48
+ signingKey: string;
49
+ /** At most `count` data keys unwrapped per principal in any `windowMinutes`; 1000 in 15 unless set. */
50
+ bulkLimit?: { count: number; windowMinutes: number };
51
+ };
52
+
53
+ function key32(value: string, what: string): Buffer {
54
+ const raw = Buffer.from(value, 'base64');
55
+ if (raw.length !== 32) throw new Error(`${what} must be 32 bytes, base64; got ${raw.length} bytes`);
56
+ return raw;
57
+ }
58
+
59
+ const KEK_ID = /^[A-Za-z0-9._-]{1,64}$/;
60
+ const KEK_PROVIDER = /^[a-z0-9][a-z0-9-]{0,31}$/;
61
+ /** Every row records it, and `provider:keyId` finds the KEK again: visible ASCII, bounded. */
62
+ const KEK_NAME = /^[\x21-\x7e]{1,255}$/;
63
+
64
+ function kek(entry: Kek): KekProvider {
65
+ if (!('wrap' in entry)) {
66
+ const { id, key } = entry;
67
+ if (!KEK_ID.test(id)) throw new Error(`KEK id "${id}" must be 1-64 letters, digits, dots, dashes or underscores`);
68
+ return new LocalKekProvider(key32(key, `KEK ${id}`), id);
69
+ }
70
+ const { provider, keyId, keyVersion } = entry;
71
+ if (typeof provider !== 'string' || !KEK_PROVIDER.test(provider)) {
72
+ throw new Error(`a KEK provider's name must be 1-32 lowercase letters, digits or dashes; got "${String(provider)}"`);
73
+ }
74
+ if (typeof keyId !== 'string' || !KEK_NAME.test(keyId) || typeof keyVersion !== 'string' || !KEK_NAME.test(keyVersion)) {
75
+ throw new Error(`KEK ${provider}: keyId and keyVersion must be 1-255 visible ASCII characters`);
76
+ }
77
+ if (typeof entry.wrap !== 'function' || typeof entry.unwrap !== 'function') {
78
+ throw new Error(`KEK ${provider}:${keyId} needs wrap() and unwrap()`);
79
+ }
80
+ return entry;
81
+ }
82
+
83
+ /** Check a deployment's vault configuration, failing on the first problem. */
84
+ export function resolveVaultConfig(config: VaultConfig): ResolvedVaultConfig {
85
+ const [current, ...previous] = [config.kek, ...(config.previousKeks ?? [])].map(kek);
86
+ const refs = [current, ...previous].map(({ provider, keyId }) => `${provider}:${keyId}`);
87
+ const twice = refs.find((ref, i) => refs.indexOf(ref) !== i);
88
+ if (twice !== undefined) throw new Error(`two KEKs share an id: ${twice}`);
89
+ return {
90
+ keks: new KekRegistry(current, previous),
91
+ rootAdmins: checkRootAdmins(config.rootAdmins),
92
+ signingKey: key32(config.signingKey, 'the signing key'),
93
+ bulkLimit: config.bulkLimit === undefined ? DEFAULT_BULK_LIMIT : checkBulkLimit(config.bulkLimit),
94
+ };
95
+ }
96
+
97
+ function checkBulkLimit({ count, windowMinutes }: { count: number; windowMinutes: number }): BulkLimit {
98
+ if (!Number.isInteger(count) || count < 1 || !(windowMinutes > 0)) {
99
+ throw new Error('bulkLimit needs a whole count of at least 1 and a window above 0 minutes');
100
+ }
101
+ return { count, windowMs: windowMinutes * 60_000 };
102
+ }
103
+
104
+ function isHumanEmail(value: string): boolean {
105
+ if (value.length > 254) return false;
106
+
107
+ const parts = value.split('@');
108
+ if (parts.length !== 2) return false;
109
+ const [local, domain] = parts;
110
+ if (
111
+ local.length === 0 ||
112
+ local.length > 64 ||
113
+ local.startsWith('.') ||
114
+ local.endsWith('.') ||
115
+ local.includes('..') ||
116
+ !/^[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+$/.test(local)
117
+ ) {
118
+ return false;
119
+ }
120
+
121
+ const labels = domain.split('.');
122
+ if (labels.length < 2) return false;
123
+ return labels.every(
124
+ (label) =>
125
+ label.length > 0 &&
126
+ label.length <= 63 &&
127
+ /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label),
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Root admins, lowercased the way they are compared. At least one, in every
133
+ * mode: they are the only way into a fresh instance, and the only members
134
+ * nobody can remove.
135
+ */
136
+ export function checkRootAdmins(emails: readonly string[]): string[] {
137
+ const rootAdmins = [...new Set(emails.map((entry) => entry.trim()).filter((entry) => entry.length > 0))];
138
+ if (rootAdmins.length === 0) {
139
+ throw new Error('rootAdmins must name at least one email, or nobody can manage coffre');
140
+ }
141
+ const invalid = rootAdmins.find((entry) => !isHumanEmail(entry));
142
+ if (invalid) {
143
+ throw new Error(`rootAdmins must be human email identities; invalid: ${invalid}`);
144
+ }
145
+ return rootAdmins.map((entry) => entry.toLowerCase());
146
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export type * from '@coffre/core/vault';
2
+ export { checkpointMessage, verifyCheckpoint } from '@coffre/core/vault';
3
+ export type { SecretContext } from '@coffre/core/envelope';
4
+ export { awsKms, KekBadClaimError, KekUnavailableError } from '@coffre/core/kek';
5
+ export type { AwsCredentials, AwsKmsOptions, KekProvider, WrappedDek } from '@coffre/core/kek';
6
+ export type { Kek, VaultConfig } from './config.ts';
package/src/local.ts ADDED
@@ -0,0 +1,48 @@
1
+ import type { Vault } from '@coffre/core/vault';
2
+ import type { Database } from '@coffre/db';
3
+
4
+ import type { ResolvedVaultConfig } from './config.ts';
5
+ import { openVault, prepareVault, type VaultOptions } from './vault.ts';
6
+
7
+ /** Every call the vault answers, in the order of the `Vault` interface. */
8
+ export const METHODS = [
9
+ 'unwrap',
10
+ 'wrap',
11
+ 'rewrap',
12
+ 'access',
13
+ 'setAccess',
14
+ 'admit',
15
+ 'remove',
16
+ 'checkpoint',
17
+ 'about',
18
+ 'verifyLog',
19
+ ] as const satisfies readonly (keyof Vault)[];
20
+
21
+ export type LocalVault = Vault & { close(): Promise<void> };
22
+
23
+ /**
24
+ * The vault in this process, over `db`. Every argument and result goes
25
+ * through JSON on the way, as it would over RPC or a socket, so what works
26
+ * here does not rely on sharing objects with the caller. `close` is
27
+ * whatever lets go of the database, when the vault opened it.
28
+ */
29
+ export async function openLocalVault(
30
+ db: Database,
31
+ config: ResolvedVaultConfig,
32
+ options: VaultOptions = {},
33
+ close: () => Promise<void> = async () => {},
34
+ ): Promise<LocalVault> {
35
+ const vault = openVault(db, await prepareVault(config, options));
36
+ const local = Object.fromEntries(
37
+ METHODS.map((name) => [
38
+ name,
39
+ async (...args: unknown[]) =>
40
+ json(await (vault[name] as (...args: unknown[]) => Promise<unknown>)(...(json(args) as unknown[]))),
41
+ ]),
42
+ ) as unknown as Vault;
43
+ return Object.assign(local, { close });
44
+ }
45
+
46
+ function json(value: unknown): unknown {
47
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
48
+ }
package/src/log.ts ADDED
@@ -0,0 +1,140 @@
1
+ import { deriveLogKey, GENESIS_HASH, verifyEntries, type LogKey, type StoredEntry } from '@coffre/core/audit';
2
+ import type { LogHead, LogVerification } from '@coffre/core/vault';
3
+ import type { Queryable } from '@coffre/db';
4
+
5
+ import { entriesFrom, hashAt } from './store.ts';
6
+
7
+ /**
8
+ * The vault's half of the one audit log: its entries are the ones it
9
+ * writes, `author = 'vault'`, each under its MAC, in the chain the app's
10
+ * entries share (@coffre/core/audit says what an entry covers). The app
11
+ * checks its own entries by its key; this checks the vault's by the vault's,
12
+ * and the app's only for their place in the chain.
13
+ */
14
+
15
+ /**
16
+ * The vault's log key, derived from its signing key, so that it is one more
17
+ * secret to hold, not one more to keep. Whoever can write the database but
18
+ * does not hold the vault's configuration cannot write an entry it accepts.
19
+ */
20
+ export function vaultLogKey(signingKey: Uint8Array): LogKey {
21
+ return deriveLogKey('vault', signingKey);
22
+ }
23
+
24
+ /**
25
+ * How far this process has verified the chain: every entry before
26
+ * `nextSeq`, the last of which has `hash`, with `vaultEntries` of the
27
+ * vault's among them. In memory only: the database cannot vouch for itself.
28
+ */
29
+ export type Anchor = { nextSeq: bigint; hash: Buffer; vaultEntries: number };
30
+
31
+ /** Before the first entry: nothing verified yet. */
32
+ export const UNVERIFIED: Anchor = { nextSeq: 0n, hash: GENESIS_HASH, vaultEntries: 0 };
33
+
34
+ /** The further of two anchors, when two calls verified at once. */
35
+ export function further(a: Anchor, b: Anchor): Anchor {
36
+ return b.nextSeq > a.nextSeq ? b : a;
37
+ }
38
+
39
+ export const VERIFY_BATCH = 1000;
40
+
41
+ type Verified = { verification: LogVerification; anchor: Anchor };
42
+
43
+ /**
44
+ * Check the chain, a batch in memory at a time, and return where it is now
45
+ * verified to. Three parts:
46
+ *
47
+ * - `shown`, the page a reader is looking at: each entry against its own
48
+ * hash and MAC;
49
+ * - `anchor`, the head at the last check: still there, unchanged. A rewrite
50
+ * of anything before it, chained again to hide, changes its hash;
51
+ * - every entry after the anchor, or from the first.
52
+ *
53
+ * So a view rehashes only what is new since the last one. What it leaves
54
+ * out is an entry before the anchor edited in place, not chained again, and
55
+ * not on the page: a full check, which starts from `UNVERIFIED`, finds that,
56
+ * as does the first view after a start, which has no anchor.
57
+ */
58
+ export async function verifyChain(
59
+ db: Queryable,
60
+ key: LogKey,
61
+ shown: readonly StoredEntry[],
62
+ anchor: Anchor,
63
+ ): Promise<Verified> {
64
+ const broken = (failedAtSeq: bigint, reason: string): Verified => ({
65
+ verification: { ok: false, failedAtSeq: Number(failedAtSeq), reason },
66
+ anchor,
67
+ });
68
+ const keys = { keys: [key], chainOnly: ['app' as const] };
69
+
70
+ for (const row of [...shown].sort((a, b) => (a.seq < b.seq ? -1 : 1))) {
71
+ const result = verifyEntries([row], { ...keys, startSeq: row.seq, startPrevHash: row.prevHash });
72
+ if (!result.ok) return broken(result.failedAtSeq, result.reason);
73
+ }
74
+
75
+ if (anchor.nextSeq > 0n && !(await hashAt(db, anchor.nextSeq - 1n))?.equals(anchor.hash)) {
76
+ return broken(anchor.nextSeq - 1n, 'changed since the vault last verified it');
77
+ }
78
+
79
+ let verified = anchor;
80
+ for (;;) {
81
+ const batch = await entriesFrom(db, verified.nextSeq, VERIFY_BATCH);
82
+ if (batch.length === 0) break;
83
+ const result = verifyEntries(batch, { ...keys, startSeq: verified.nextSeq, startPrevHash: verified.hash });
84
+ if (!result.ok) return broken(result.failedAtSeq, result.reason);
85
+ verified = { nextSeq: result.nextSeq, hash: result.head, vaultEntries: verified.vaultEntries + result.authenticated };
86
+ if (batch.length < VERIFY_BATCH) break;
87
+ }
88
+ return { verification: { ok: true, entries: verified.vaultEntries }, anchor: verified };
89
+ }
90
+
91
+ /**
92
+ * Whether the log still holds `head` where it was: not rewritten, nor cut
93
+ * back before it. A head of 64 zeros is before the first entry, which any
94
+ * log holds.
95
+ */
96
+ export async function carries(db: Queryable, head: LogHead): Promise<boolean> {
97
+ if (head.hash === GENESIS_HASH.toString('hex')) return true;
98
+ return (await hashAt(db, BigInt(head.seq)))?.toString('hex') === head.hash;
99
+ }
100
+
101
+ /** One of the vault's entries, as its tests read them. */
102
+ export type LogEntry = {
103
+ seq: number;
104
+ at: string;
105
+ actor: string;
106
+ action: string;
107
+ outcome: 'allow' | 'refuse';
108
+ code: string | null;
109
+ subject: string | null;
110
+ detail: Record<string, unknown>;
111
+ hash: string;
112
+ };
113
+
114
+ /**
115
+ * An entry as the vault log's readers see it. `subject` is the member an
116
+ * access change is about, or what else the entry names: a secret's path,
117
+ * the audit log, the vault's own.
118
+ */
119
+ export function entryView(row: StoredEntry): LogEntry {
120
+ const { subject, ...detail } = JSON.parse(row.metadata) as Record<string, unknown>;
121
+ const ids = {
122
+ projectId: row.projectId,
123
+ environmentId: row.environmentId,
124
+ secretId: row.secretId,
125
+ requestId: row.requestId,
126
+ operationId: row.operationId,
127
+ relatedSeq: row.relatedSeq === null ? null : row.relatedSeq.toString(),
128
+ };
129
+ return {
130
+ seq: Number(row.seq),
131
+ at: new Date(row.occurredAt).toISOString(),
132
+ actor: row.actor,
133
+ action: row.action,
134
+ outcome: row.decision === 'allow' ? 'allow' : 'refuse',
135
+ code: row.code,
136
+ subject: row.subjectPrincipal ?? (typeof subject === 'string' ? subject : null),
137
+ detail: { ...Object.fromEntries(Object.entries(ids).filter(([, value]) => value !== null)), ...detail },
138
+ hash: row.hash.toString('hex'),
139
+ };
140
+ }