Skip to content

Commit 2df621a

Browse files
fix(verify): os verify never creates key material in the key home; the harness seals under an in-process data key (#21507)
Fixes #21499 Clause-②: no ## What changed `bootStack` (`packages/verify/src/harness.ts`) composed the settings service with no crypto provider and bound the engine to a bare `LocalCryptoProvider`. `bootStack` forces a development posture, where both providers resolve a data key the way a server does: an env key, then the key file in the key home, and with neither they mint the key file. So `os verify`, a one-shot command over an in-memory database, left key material in the key home. On a host that already had a key, the harness sealed its throwaway fixtures under that real key. The harness now holds ONE `LocalCryptoProvider` over an explicit random key (`harnessCryptoProvider`, module-private): - no env read, no key-file read, no write anywhere: the key never leaves the process's memory; - the settings service (`new SettingsServicePlugin({ cryptoProvider })`) and the engine (`setCryptoProvider`) get the same instance, so `secret` fields and encrypted settings still seal AND open on a keyless host. That is why the CLI's one-shot shape from PR #21497 ("read an existing key, or refuse every call") does not fit here; - one key per PROCESS, not per boot: two boots over one `databaseFile` (the harness's restart) open each other's secrets, as a real host's stable key would let them. ## Shape choices, measured (dispatch zone 2) - **A1, confirmed at base `6f17d1d364`.** `harness.ts:498` was `new SettingsServicePlugin()` with no provider, and `:694` was `engine.setCryptoProvider(new LocalCryptoProvider())`. `packages/cli/src/commands/verify.ts:195` and `:219` call `bootStack(config, { multiTenant })` and `bootStack(config, { multiTenant, security })`. `BootOptions` has no crypto option. - **A2: no public option is needed.** No caller of `bootStack` in this repository passes or needs a key: every boot is an in-memory database or a caller-owned temp file. So the harness owns its provider internally. `BootOptions` and every export are unchanged, `Clause-②: no` holds, and `packages/cli/src/commands/verify.ts` is untouched. - **Per process, not per boot.** Measured by ablation (below): a per-boot key breaks the restart case with an AES-GCM authentication failure. ## Evidence **Public door.** Built CLI, `examples/app-todo`, `os verify --json`, development posture, no env key, a fresh empty key home (`OS_HOME`): | `@objectstack/verify` built from | exit | stdout | key home after | stderr line announcing a minted key | |---|---|---|---|---| | the base harness (`6f17d1d364`, rebuilt; dist preflight: marker absent) | 1 | 764 B | `dev-crypto-key` | 1 | | this PR (rebuilt; dist preflight: marker present) | 1 | 764 B, byte-identical | empty | 0 | Both runs exit 1 on the same pre-existing fidelity gap on `todo_task.tags` (see Acceptance notes). It is unrelated to this change. **Pin** `packages/verify/src/harness.key-custody.test.ts`. Every boot runs in a hook, and the cases only assert. - Committed red first (`465c22036a`), against the unfixed harness: 4 failed, 3 passed. - The empty key home gained `dev-crypto-key`. - Both sealed rows opened under a pre-existing key file's key: `[true, true]`. - Both sealed rows opened under `OS_SECRET_KEY`: `[true, true]`. - The restart case's key home gained `dev-crypto-key`. - The control stayed green: the default provider reports `generated-file` in this posture and home. - With the change (`7c3a1843ce`): 7 passed. - Ablation, a per-boot key in place of the per-process one, through `scripts/ablation-replace.mjs`: - the anchor went 1 to 0 and the blob changed; - after the restore, the blob equals HEAD and `git diff HEAD` is empty; - the restart describe's hook fails with `Unsupported state or unable to authenticate data` (6 passed, 1 skipped, file red). - The pin imports `./harness.js` from source, so no `dist/` leg applies to it. **Local runs at HEAD `e8a091457c`:** - `pnpm --filter @objectstack/verify exec vitest run`: 17 files, 127 tests passed. - `pnpm --filter @objectstack/verify typecheck`: exit 0, tsc plus the test layer. `--listFiles` on the test config holds the new test (17 of 17 test files). - `node scripts/pm/dispatch-gates.mjs --commands`: 62 families derived and all 62 run. The `--ran` reconciliation reads 0 NOT-MEASURED and 0 UNRUN. - `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET: 9 packages had no `dist/`). After a cache-replay build of those 9 it exited 0. - `pnpm lint` over the whole repository, unnarrowed: exit 0. Not run locally: CI's full suites, and the CLI integration tier (no `packages/cli` file is touched). ## Serial note PR #21497 adds an enumeration pin over `packages/cli/src`. It had not landed when this PR was opened, and this branch sits on `6f17d1d364`. This diff touches no `packages/cli` file, so that pin's population is unchanged. The seat merges `main` and re-runs that pin at landing. ## Acceptance notes - `os verify --json` on `examples/app-todo` exits 1 on `main` with one fidelity gap. The derived write puts the scalar `important` into `todo_task.tags`, a `select` with `multiple: true`, and reads back the array `["important"]`. It is reported to the seat as a finding and is not touched here. - Every `bootStack` under vitest also stops minting into the runner's key home. The harness forces a development posture, so the old default minted there too. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c205b6c commit 2df621a

3 files changed

Lines changed: 411 additions & 5 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/verify": patch
3+
---
4+
5+
`bootStack` (and so `os verify`) no longer creates a data key file in the key home, and no longer seals its fixtures under a key the host already holds (#21499)
6+
7+
Clause-②: no
8+
9+
The harness composed the settings service with no crypto provider and bound the engine to a default `LocalCryptoProvider`. `bootStack` forces a development posture. In that posture, with no `OS_SECRET_KEY`, no `OS_DEV_CRYPTO_KEY` and no key file, both providers wrote a new key file into the key home. So `os verify`, a one-shot command over an in-memory database, left key material behind, and the next development-posture process on that host adopted it. On a host that already had a key, the harness sealed its throwaway fixtures under that real key.
10+
11+
- **What the harness uses now.** One `LocalCryptoProvider` over a random key held in this process's memory only. It never reads `OS_SECRET_KEY`, `OS_DEV_CRYPTO_KEY` or the key file, and it never writes anywhere. The settings service and the engine get the same instance, so `secret` fields and encrypted settings still seal and open on a host with no key at all.
12+
- **One key per process, not per boot.** Two `bootStack` calls over one `databaseFile` in the same process (the harness's restart) still open each other's secrets.
13+
- **Unchanged.** `BootOptions` and the rest of the public API, and `os verify`'s stdout and `--json` report. The one stderr line announcing the minted key file is gone.
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#21499] `bootStack` never creates key material in the key home, and never
4+
// seals under a real key the host already holds.
5+
//
6+
// `os verify` is a one-shot command: it boots this harness twice (the CRUD
7+
// stack and the RLS stack), each over an in-memory database, and exits. The
8+
// harness used to compose the settings service with no `cryptoProvider` and
9+
// bind the engine to a bare `new LocalCryptoProvider()`. `bootStack` forces a
10+
// development posture, and in that posture, with no env key and no key file,
11+
// both of those providers MINT a key file in the key home so the next restart
12+
// reuses it. That is right for `os serve`, the long-lived host. For a run that
13+
// seals nothing it keeps, it is an undeclared side effect on key custody: the
14+
// file outlives the run, and the next development-posture process on that host
15+
// adopts it and seals real secrets under it.
16+
//
17+
// The one-shot shape the CLI uses ("read an existing key, or refuse every
18+
// call") does not fit here, because the harness SEALS AND OPENS secrets in its
19+
// own database: a `secret` field write, an encrypted setting. On a keyless host
20+
// a refusing provider would break exactly those. So the harness holds its own
21+
// data key, in this process's memory only, and hands that one provider to the
22+
// settings service and to the engine. What this file pins, in a development
23+
// posture:
24+
//
25+
// 1. the control — the default provider mints a key file in this posture and
26+
// this home, so an empty home after a boot is a reading, not a vacuity;
27+
// 2. an empty key home stays empty through a boot, a secret-field write, an
28+
// encrypted-setting write and `stop()`, while both secrets still read back;
29+
// 3. a key file already in the key home is never rewritten, and nothing the
30+
// boot seals opens under it — so it was never the key in use;
31+
// 4. the same for an `OS_SECRET_KEY` in the environment;
32+
// 5. two boots in one process over one `databaseFile` — the harness's restart
33+
// — open each other's secrets, so the key is the process's, not the boot's.
34+
//
35+
// Every boot runs in a hook: a case measures behaviour, never loading.
36+
37+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
38+
import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
39+
import { tmpdir } from 'node:os';
40+
import { join } from 'node:path';
41+
import { ObjectSchema, Field } from '@objectstack/spec/data';
42+
import { LocalCryptoProvider, type SettingsManifest } from '@objectstack/service-settings';
43+
// `.js` extension deliberate: this package resolves NodeNext, so an
44+
// extensionless relative import does not resolve under its typecheck.
45+
import { bootStack, type BootOptions } from './harness.js';
46+
47+
// Booting the full in-process stack runs well past vitest's 5s default.
48+
const BOOT_TIMEOUT = 120_000;
49+
50+
/** Every variable that decides where a data key comes from, and the posture. */
51+
const KEY_ENV = [
52+
'NODE_ENV', 'OS_SECRET_KEY', 'OS_DEV_CRYPTO_KEY', 'OBJECTSTACK_DEV_CRYPTO_KEY',
53+
'OS_HOME', 'OBJECTSTACK_HOME', 'OS_CRYPTO_AUTOKEY',
54+
] as const;
55+
56+
/** The file name the default provider persists its key under, in the key home. */
57+
const KEY_FILE = 'dev-crypto-key';
58+
59+
/** A host's real key, as a key file or as `OS_SECRET_KEY`. Fixed bytes, never a secret. */
60+
const HOST_KEY_HEX = '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f';
61+
const HOST_KEY = Buffer.from(HOST_KEY_HEX, 'hex');
62+
63+
const OBJECT = 'keycustody_vault';
64+
const SECRET_FIELD = 'token';
65+
const SETTINGS_NS = 'keycustody_settings';
66+
const SETTINGS_KEY = 'api_key';
67+
const SYS = { isSystem: true } as const;
68+
69+
/** The producer scope each `sys_secret` row was sealed under, by its namespace. */
70+
const SCOPE_OF: Record<string, 'object_secret_field' | 'settings'> = {
71+
[OBJECT]: 'object_secret_field',
72+
[SETTINGS_NS]: 'settings',
73+
};
74+
75+
const app = {
76+
manifest: {
77+
id: 'com.example.key-custody',
78+
namespace: 'keycustody',
79+
version: '0.0.1',
80+
type: 'app',
81+
name: 'Key Custody Fixture',
82+
},
83+
objects: [
84+
ObjectSchema.create({
85+
name: OBJECT,
86+
sharingModel: 'public_read_write',
87+
label: 'Vault',
88+
pluralLabel: 'Vaults',
89+
fields: {
90+
name: Field.text({ label: 'Name', required: true }),
91+
[SECRET_FIELD]: Field.secret({ label: 'Token' }),
92+
},
93+
}),
94+
],
95+
};
96+
97+
/** One encrypted setting, so the settings service's provider is exercised too. */
98+
const settingsManifest: SettingsManifest = {
99+
namespace: SETTINGS_NS,
100+
version: 1,
101+
label: 'Key custody',
102+
scope: 'global',
103+
readPermission: 'setup.access',
104+
writePermission: 'setup.write',
105+
specifiers: [{ type: 'password', key: SETTINGS_KEY, label: 'API key', required: false }],
106+
};
107+
108+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
109+
type Engine = any;
110+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
111+
type Settings = any;
112+
113+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
114+
const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []);
115+
116+
interface SealedRow {
117+
id: string;
118+
namespace: string;
119+
key: string;
120+
kms_key_id: string;
121+
alg: string;
122+
version: number;
123+
ciphertext: string;
124+
}
125+
126+
/** What one boot sealed, and what it read back through its own doors. */
127+
interface BootReading {
128+
/** The secret field, read back through the engine's privileged door. */
129+
fieldReadBack: string | null;
130+
/** The encrypted setting, read back through the settings service. */
131+
settingReadBack: unknown;
132+
/** Every `sys_secret` row the boot holds. */
133+
sealed: SealedRow[];
134+
/** The key home's entries after the writes, before `stop()`. */
135+
homeBeforeStop: string[];
136+
/** The key home's entries after `stop()`. */
137+
homeAfterStop: string[];
138+
}
139+
140+
const savedEnv: Record<string, string | undefined> = {};
141+
const scratch: string[] = [];
142+
143+
function freshDir(label: string): string {
144+
const dir = mkdtempSync(join(tmpdir(), `os-21499-${label}-`));
145+
scratch.push(dir);
146+
return dir;
147+
}
148+
149+
/**
150+
* A development posture with no key anywhere, and `home` as the key home. Set
151+
* before each boot: `bootStack` forces `NODE_ENV=development` itself, and the
152+
* control below needs the same posture with no harness in the way.
153+
*/
154+
function keylessDevelopmentPosture(home: string): void {
155+
for (const k of KEY_ENV) delete process.env[k];
156+
process.env.NODE_ENV = 'development';
157+
process.env.OS_HOME = home;
158+
}
159+
160+
beforeAll(() => {
161+
for (const k of KEY_ENV) savedEnv[k] = process.env[k];
162+
});
163+
164+
afterAll(() => {
165+
for (const k of KEY_ENV) {
166+
if (savedEnv[k] === undefined) delete process.env[k];
167+
else process.env[k] = savedEnv[k];
168+
}
169+
for (const dir of scratch) rmSync(dir, { recursive: true, force: true });
170+
});
171+
172+
/** Write a secret field and an encrypted setting into a booted stack. */
173+
async function sealBoth(engine: Engine, settings: Settings): Promise<string> {
174+
const row = await engine.insert(OBJECT, { name: 'vault', [SECRET_FIELD]: 'field-plaintext' }, { context: SYS });
175+
await settings.set(SETTINGS_NS, SETTINGS_KEY, 'setting-plaintext');
176+
return row.id as string;
177+
}
178+
179+
/** Boot `bootStack(app, opts)`, seal both producers' secrets, read them back, stop. */
180+
async function bootAndSeal(home: string, opts?: BootOptions): Promise<BootReading> {
181+
const stack = await bootStack(app, opts);
182+
try {
183+
const engine: Engine = await stack.kernel.getServiceAsync('objectql');
184+
const settings: Settings = await stack.kernel.getServiceAsync('settings');
185+
settings.registerManifest(settingsManifest);
186+
const id = await sealBoth(engine, settings);
187+
const reading: Omit<BootReading, 'homeAfterStop'> = {
188+
fieldReadBack: await engine.resolveSecretField(OBJECT, id, SECRET_FIELD),
189+
settingReadBack: (await settings.get(SETTINGS_NS, SETTINGS_KEY)).value,
190+
sealed: rowsOf(await engine.find('sys_secret', { context: SYS })),
191+
homeBeforeStop: readdirSync(home),
192+
};
193+
await stack.stop();
194+
return { ...reading, homeAfterStop: readdirSync(home) };
195+
} catch (e) {
196+
await stack.stop().catch(() => undefined);
197+
throw e;
198+
}
199+
}
200+
201+
/** Does `row` open under a provider over `key`, in its producer's own context? */
202+
async function opensUnder(key: Buffer, row: SealedRow): Promise<boolean> {
203+
const provider = new LocalCryptoProvider({ key });
204+
const handle = {
205+
id: row.id, kmsKeyId: row.kms_key_id, alg: row.alg, version: row.version, ciphertext: row.ciphertext,
206+
};
207+
try {
208+
await provider.decrypt(handle, { scope: SCOPE_OF[row.namespace], namespace: row.namespace, key: row.key });
209+
return true;
210+
} catch {
211+
return false;
212+
}
213+
}
214+
215+
/** Both producers sealed exactly one row each — the population every "none opens" reads. */
216+
function expectOneRowPerProducer(sealed: SealedRow[]): void {
217+
expect(sealed.map((r) => r.namespace).sort()).toEqual([OBJECT, SETTINGS_NS].sort());
218+
}
219+
220+
describe('[#21499] the control: this posture and this home are where a key gets minted', () => {
221+
let home: string;
222+
let keySource: string;
223+
let entries: string[];
224+
225+
beforeAll(() => {
226+
home = freshDir('control-home');
227+
keylessDevelopmentPosture(home);
228+
keySource = new LocalCryptoProvider().keySource;
229+
entries = readdirSync(home);
230+
});
231+
232+
it('the default provider mints a key file in the key home', () => {
233+
expect(keySource).toBe('generated-file');
234+
expect(entries).toEqual([KEY_FILE]);
235+
});
236+
});
237+
238+
describe('[#21499] an empty key home stays empty through a whole boot', () => {
239+
let home: string;
240+
let reading: BootReading;
241+
242+
beforeAll(async () => {
243+
home = freshDir('empty-home');
244+
keylessDevelopmentPosture(home);
245+
// The options `os verify` boots its CRUD stack with on a single-tenant host.
246+
reading = await bootAndSeal(home, { multiTenant: false });
247+
}, BOOT_TIMEOUT);
248+
249+
it('no key material is created, before or after stop()', () => {
250+
expect(reading.homeBeforeStop).toEqual([]);
251+
expect(reading.homeAfterStop).toEqual([]);
252+
});
253+
254+
it('the harness still seals and opens both producers\' secrets on a keyless host', () => {
255+
expectOneRowPerProducer(reading.sealed);
256+
expect(reading.fieldReadBack).toBe('field-plaintext');
257+
expect(reading.settingReadBack).toBe('setting-plaintext');
258+
});
259+
});
260+
261+
describe('[#21499] a key file already in the key home is never the key in use', () => {
262+
let home: string;
263+
let before: string;
264+
let reading: BootReading;
265+
let opened: boolean[];
266+
267+
beforeAll(async () => {
268+
home = freshDir('keyed-home');
269+
keylessDevelopmentPosture(home);
270+
writeFileSync(join(home, KEY_FILE), HOST_KEY.toString('base64'), { mode: 0o600 });
271+
before = readFileSync(join(home, KEY_FILE), 'utf8');
272+
reading = await bootAndSeal(home);
273+
opened = await Promise.all(reading.sealed.map((row) => opensUnder(HOST_KEY, row)));
274+
}, BOOT_TIMEOUT);
275+
276+
it('the key file is left exactly as it was, and nothing joins it', () => {
277+
expect(reading.homeAfterStop).toEqual([KEY_FILE]);
278+
expect(readFileSync(join(home, KEY_FILE), 'utf8')).toBe(before);
279+
});
280+
281+
it('nothing the boot sealed opens under the key file\'s key', () => {
282+
expectOneRowPerProducer(reading.sealed);
283+
expect(opened).toEqual([false, false]);
284+
expect(reading.fieldReadBack).toBe('field-plaintext');
285+
expect(reading.settingReadBack).toBe('setting-plaintext');
286+
});
287+
});
288+
289+
describe('[#21499] an OS_SECRET_KEY in the environment is never the key in use', () => {
290+
let home: string;
291+
let reading: BootReading;
292+
let opened: boolean[];
293+
294+
beforeAll(async () => {
295+
home = freshDir('env-key-home');
296+
keylessDevelopmentPosture(home);
297+
process.env.OS_SECRET_KEY = HOST_KEY_HEX;
298+
reading = await bootAndSeal(home);
299+
opened = await Promise.all(reading.sealed.map((row) => opensUnder(HOST_KEY, row)));
300+
}, BOOT_TIMEOUT);
301+
302+
it('nothing the boot sealed opens under the environment\'s key', () => {
303+
expectOneRowPerProducer(reading.sealed);
304+
expect(opened).toEqual([false, false]);
305+
expect(reading.fieldReadBack).toBe('field-plaintext');
306+
expect(reading.settingReadBack).toBe('setting-plaintext');
307+
expect(reading.homeAfterStop).toEqual([]);
308+
});
309+
});
310+
311+
describe('[#21499] the key is the process\'s: a restart over one database file opens what the last boot sealed', () => {
312+
let home: string;
313+
let first: BootReading;
314+
let fieldAfterRestart: string | null;
315+
let settingAfterRestart: unknown;
316+
let homeAfterRestart: string[];
317+
318+
beforeAll(async () => {
319+
home = freshDir('restart-home');
320+
keylessDevelopmentPosture(home);
321+
const databaseFile = join(freshDir('restart-db'), 'verify.db');
322+
first = await bootAndSeal(home, { databaseFile });
323+
324+
const second = await bootStack(app, { databaseFile });
325+
try {
326+
const engine: Engine = await second.kernel.getServiceAsync('objectql');
327+
const settings: Settings = await second.kernel.getServiceAsync('settings');
328+
settings.registerManifest(settingsManifest);
329+
const [row] = rowsOf(await engine.find(OBJECT, { context: SYS }));
330+
fieldAfterRestart = await engine.resolveSecretField(OBJECT, row.id, SECRET_FIELD);
331+
settingAfterRestart = (await settings.get(SETTINGS_NS, SETTINGS_KEY)).value;
332+
} finally {
333+
await second.stop();
334+
}
335+
homeAfterRestart = readdirSync(home);
336+
}, BOOT_TIMEOUT * 2);
337+
338+
it('the second boot opens both secrets the first sealed, and the key home stays empty', () => {
339+
expectOneRowPerProducer(first.sealed);
340+
expect(fieldAfterRestart).toBe('field-plaintext');
341+
expect(settingAfterRestart).toBe('setting-plaintext');
342+
expect(homeAfterRestart).toEqual([]);
343+
});
344+
});

0 commit comments

Comments
 (0)