diff --git a/.changeset/21499-verify-never-mints-key.md b/.changeset/21499-verify-never-mints-key.md new file mode 100644 index 00000000000..1098289b248 --- /dev/null +++ b/.changeset/21499-verify-never-mints-key.md @@ -0,0 +1,13 @@ +--- +"@objectstack/verify": patch +--- + +`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) + +Clause-②: no + +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. + +- **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. +- **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. +- **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. diff --git a/packages/verify/src/harness.key-custody.test.ts b/packages/verify/src/harness.key-custody.test.ts new file mode 100644 index 00000000000..7561de5df8e --- /dev/null +++ b/packages/verify/src/harness.key-custody.test.ts @@ -0,0 +1,344 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#21499] `bootStack` never creates key material in the key home, and never +// seals under a real key the host already holds. +// +// `os verify` is a one-shot command: it boots this harness twice (the CRUD +// stack and the RLS stack), each over an in-memory database, and exits. The +// harness used to compose the settings service with no `cryptoProvider` and +// bind the engine to a bare `new LocalCryptoProvider()`. `bootStack` forces a +// development posture, and in that posture, with no env key and no key file, +// both of those providers MINT a key file in the key home so the next restart +// reuses it. That is right for `os serve`, the long-lived host. For a run that +// seals nothing it keeps, it is an undeclared side effect on key custody: the +// file outlives the run, and the next development-posture process on that host +// adopts it and seals real secrets under it. +// +// The one-shot shape the CLI uses ("read an existing key, or refuse every +// call") does not fit here, because the harness SEALS AND OPENS secrets in its +// own database: a `secret` field write, an encrypted setting. On a keyless host +// a refusing provider would break exactly those. So the harness holds its own +// data key, in this process's memory only, and hands that one provider to the +// settings service and to the engine. What this file pins, in a development +// posture: +// +// 1. the control — the default provider mints a key file in this posture and +// this home, so an empty home after a boot is a reading, not a vacuity; +// 2. an empty key home stays empty through a boot, a secret-field write, an +// encrypted-setting write and `stop()`, while both secrets still read back; +// 3. a key file already in the key home is never rewritten, and nothing the +// boot seals opens under it — so it was never the key in use; +// 4. the same for an `OS_SECRET_KEY` in the environment; +// 5. two boots in one process over one `databaseFile` — the harness's restart +// — open each other's secrets, so the key is the process's, not the boot's. +// +// Every boot runs in a hook: a case measures behaviour, never loading. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectSchema, Field } from '@objectstack/spec/data'; +import { LocalCryptoProvider, type SettingsManifest } from '@objectstack/service-settings'; +// `.js` extension deliberate: this package resolves NodeNext, so an +// extensionless relative import does not resolve under its typecheck. +import { bootStack, type BootOptions } from './harness.js'; + +// Booting the full in-process stack runs well past vitest's 5s default. +const BOOT_TIMEOUT = 120_000; + +/** Every variable that decides where a data key comes from, and the posture. */ +const KEY_ENV = [ + 'NODE_ENV', 'OS_SECRET_KEY', 'OS_DEV_CRYPTO_KEY', 'OBJECTSTACK_DEV_CRYPTO_KEY', + 'OS_HOME', 'OBJECTSTACK_HOME', 'OS_CRYPTO_AUTOKEY', +] as const; + +/** The file name the default provider persists its key under, in the key home. */ +const KEY_FILE = 'dev-crypto-key'; + +/** A host's real key, as a key file or as `OS_SECRET_KEY`. Fixed bytes, never a secret. */ +const HOST_KEY_HEX = '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f'; +const HOST_KEY = Buffer.from(HOST_KEY_HEX, 'hex'); + +const OBJECT = 'keycustody_vault'; +const SECRET_FIELD = 'token'; +const SETTINGS_NS = 'keycustody_settings'; +const SETTINGS_KEY = 'api_key'; +const SYS = { isSystem: true } as const; + +/** The producer scope each `sys_secret` row was sealed under, by its namespace. */ +const SCOPE_OF: Record = { + [OBJECT]: 'object_secret_field', + [SETTINGS_NS]: 'settings', +}; + +const app = { + manifest: { + id: 'com.example.key-custody', + namespace: 'keycustody', + version: '0.0.1', + type: 'app', + name: 'Key Custody Fixture', + }, + objects: [ + ObjectSchema.create({ + name: OBJECT, + sharingModel: 'public_read_write', + label: 'Vault', + pluralLabel: 'Vaults', + fields: { + name: Field.text({ label: 'Name', required: true }), + [SECRET_FIELD]: Field.secret({ label: 'Token' }), + }, + }), + ], +}; + +/** One encrypted setting, so the settings service's provider is exercised too. */ +const settingsManifest: SettingsManifest = { + namespace: SETTINGS_NS, + version: 1, + label: 'Key custody', + scope: 'global', + readPermission: 'setup.access', + writePermission: 'setup.write', + specifiers: [{ type: 'password', key: SETTINGS_KEY, label: 'API key', required: false }], +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Engine = any; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Settings = any; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rowsOf = (r: any): any[] => (Array.isArray(r) ? r : Array.isArray(r?.records) ? r.records : []); + +interface SealedRow { + id: string; + namespace: string; + key: string; + kms_key_id: string; + alg: string; + version: number; + ciphertext: string; +} + +/** What one boot sealed, and what it read back through its own doors. */ +interface BootReading { + /** The secret field, read back through the engine's privileged door. */ + fieldReadBack: string | null; + /** The encrypted setting, read back through the settings service. */ + settingReadBack: unknown; + /** Every `sys_secret` row the boot holds. */ + sealed: SealedRow[]; + /** The key home's entries after the writes, before `stop()`. */ + homeBeforeStop: string[]; + /** The key home's entries after `stop()`. */ + homeAfterStop: string[]; +} + +const savedEnv: Record = {}; +const scratch: string[] = []; + +function freshDir(label: string): string { + const dir = mkdtempSync(join(tmpdir(), `os-21499-${label}-`)); + scratch.push(dir); + return dir; +} + +/** + * A development posture with no key anywhere, and `home` as the key home. Set + * before each boot: `bootStack` forces `NODE_ENV=development` itself, and the + * control below needs the same posture with no harness in the way. + */ +function keylessDevelopmentPosture(home: string): void { + for (const k of KEY_ENV) delete process.env[k]; + process.env.NODE_ENV = 'development'; + process.env.OS_HOME = home; +} + +beforeAll(() => { + for (const k of KEY_ENV) savedEnv[k] = process.env[k]; +}); + +afterAll(() => { + for (const k of KEY_ENV) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + for (const dir of scratch) rmSync(dir, { recursive: true, force: true }); +}); + +/** Write a secret field and an encrypted setting into a booted stack. */ +async function sealBoth(engine: Engine, settings: Settings): Promise { + const row = await engine.insert(OBJECT, { name: 'vault', [SECRET_FIELD]: 'field-plaintext' }, { context: SYS }); + await settings.set(SETTINGS_NS, SETTINGS_KEY, 'setting-plaintext'); + return row.id as string; +} + +/** Boot `bootStack(app, opts)`, seal both producers' secrets, read them back, stop. */ +async function bootAndSeal(home: string, opts?: BootOptions): Promise { + const stack = await bootStack(app, opts); + try { + const engine: Engine = await stack.kernel.getServiceAsync('objectql'); + const settings: Settings = await stack.kernel.getServiceAsync('settings'); + settings.registerManifest(settingsManifest); + const id = await sealBoth(engine, settings); + const reading: Omit = { + fieldReadBack: await engine.resolveSecretField(OBJECT, id, SECRET_FIELD), + settingReadBack: (await settings.get(SETTINGS_NS, SETTINGS_KEY)).value, + sealed: rowsOf(await engine.find('sys_secret', { context: SYS })), + homeBeforeStop: readdirSync(home), + }; + await stack.stop(); + return { ...reading, homeAfterStop: readdirSync(home) }; + } catch (e) { + await stack.stop().catch(() => undefined); + throw e; + } +} + +/** Does `row` open under a provider over `key`, in its producer's own context? */ +async function opensUnder(key: Buffer, row: SealedRow): Promise { + const provider = new LocalCryptoProvider({ key }); + const handle = { + id: row.id, kmsKeyId: row.kms_key_id, alg: row.alg, version: row.version, ciphertext: row.ciphertext, + }; + try { + await provider.decrypt(handle, { scope: SCOPE_OF[row.namespace], namespace: row.namespace, key: row.key }); + return true; + } catch { + return false; + } +} + +/** Both producers sealed exactly one row each — the population every "none opens" reads. */ +function expectOneRowPerProducer(sealed: SealedRow[]): void { + expect(sealed.map((r) => r.namespace).sort()).toEqual([OBJECT, SETTINGS_NS].sort()); +} + +describe('[#21499] the control: this posture and this home are where a key gets minted', () => { + let home: string; + let keySource: string; + let entries: string[]; + + beforeAll(() => { + home = freshDir('control-home'); + keylessDevelopmentPosture(home); + keySource = new LocalCryptoProvider().keySource; + entries = readdirSync(home); + }); + + it('the default provider mints a key file in the key home', () => { + expect(keySource).toBe('generated-file'); + expect(entries).toEqual([KEY_FILE]); + }); +}); + +describe('[#21499] an empty key home stays empty through a whole boot', () => { + let home: string; + let reading: BootReading; + + beforeAll(async () => { + home = freshDir('empty-home'); + keylessDevelopmentPosture(home); + // The options `os verify` boots its CRUD stack with on a single-tenant host. + reading = await bootAndSeal(home, { multiTenant: false }); + }, BOOT_TIMEOUT); + + it('no key material is created, before or after stop()', () => { + expect(reading.homeBeforeStop).toEqual([]); + expect(reading.homeAfterStop).toEqual([]); + }); + + it('the harness still seals and opens both producers\' secrets on a keyless host', () => { + expectOneRowPerProducer(reading.sealed); + expect(reading.fieldReadBack).toBe('field-plaintext'); + expect(reading.settingReadBack).toBe('setting-plaintext'); + }); +}); + +describe('[#21499] a key file already in the key home is never the key in use', () => { + let home: string; + let before: string; + let reading: BootReading; + let opened: boolean[]; + + beforeAll(async () => { + home = freshDir('keyed-home'); + keylessDevelopmentPosture(home); + writeFileSync(join(home, KEY_FILE), HOST_KEY.toString('base64'), { mode: 0o600 }); + before = readFileSync(join(home, KEY_FILE), 'utf8'); + reading = await bootAndSeal(home); + opened = await Promise.all(reading.sealed.map((row) => opensUnder(HOST_KEY, row))); + }, BOOT_TIMEOUT); + + it('the key file is left exactly as it was, and nothing joins it', () => { + expect(reading.homeAfterStop).toEqual([KEY_FILE]); + expect(readFileSync(join(home, KEY_FILE), 'utf8')).toBe(before); + }); + + it('nothing the boot sealed opens under the key file\'s key', () => { + expectOneRowPerProducer(reading.sealed); + expect(opened).toEqual([false, false]); + expect(reading.fieldReadBack).toBe('field-plaintext'); + expect(reading.settingReadBack).toBe('setting-plaintext'); + }); +}); + +describe('[#21499] an OS_SECRET_KEY in the environment is never the key in use', () => { + let home: string; + let reading: BootReading; + let opened: boolean[]; + + beforeAll(async () => { + home = freshDir('env-key-home'); + keylessDevelopmentPosture(home); + process.env.OS_SECRET_KEY = HOST_KEY_HEX; + reading = await bootAndSeal(home); + opened = await Promise.all(reading.sealed.map((row) => opensUnder(HOST_KEY, row))); + }, BOOT_TIMEOUT); + + it('nothing the boot sealed opens under the environment\'s key', () => { + expectOneRowPerProducer(reading.sealed); + expect(opened).toEqual([false, false]); + expect(reading.fieldReadBack).toBe('field-plaintext'); + expect(reading.settingReadBack).toBe('setting-plaintext'); + expect(reading.homeAfterStop).toEqual([]); + }); +}); + +describe('[#21499] the key is the process\'s: a restart over one database file opens what the last boot sealed', () => { + let home: string; + let first: BootReading; + let fieldAfterRestart: string | null; + let settingAfterRestart: unknown; + let homeAfterRestart: string[]; + + beforeAll(async () => { + home = freshDir('restart-home'); + keylessDevelopmentPosture(home); + const databaseFile = join(freshDir('restart-db'), 'verify.db'); + first = await bootAndSeal(home, { databaseFile }); + + const second = await bootStack(app, { databaseFile }); + try { + const engine: Engine = await second.kernel.getServiceAsync('objectql'); + const settings: Settings = await second.kernel.getServiceAsync('settings'); + settings.registerManifest(settingsManifest); + const [row] = rowsOf(await engine.find(OBJECT, { context: SYS })); + fieldAfterRestart = await engine.resolveSecretField(OBJECT, row.id, SECRET_FIELD); + settingAfterRestart = (await settings.get(SETTINGS_NS, SETTINGS_KEY)).value; + } finally { + await second.stop(); + } + homeAfterRestart = readdirSync(home); + }, BOOT_TIMEOUT * 2); + + it('the second boot opens both secrets the first sealed, and the key home stays empty', () => { + expectOneRowPerProducer(first.sealed); + expect(fieldAfterRestart).toBe('field-plaintext'); + expect(settingAfterRestart).toBe('setting-plaintext'); + expect(homeAfterRestart).toEqual([]); + }); +}); diff --git a/packages/verify/src/harness.ts b/packages/verify/src/harness.ts index 01fd2633232..6edabfac1b8 100644 --- a/packages/verify/src/harness.ts +++ b/packages/verify/src/harness.ts @@ -18,8 +18,11 @@ // Posture: development / in-memory. `NODE_ENV` is forced to `development` so the // auth plugin's dev-admin bootstrap provisions a known, loginable admin (mirrors // `objectstack dev`). This is a verification harness — it never touches a real -// database or production data. +// database or production data, and it never touches the host's key custody +// either: it seals under a data key held in this process's memory only (see +// `harnessCryptoProvider`). +import { randomBytes } from 'node:crypto'; import { ObjectKernel, AppPlugin, DefaultDatasourcePlugin, createDispatcherPlugin } from '@objectstack/runtime'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; @@ -97,6 +100,47 @@ const DEFAULT_AUTH_SECRET = 'objectstack-verify-secret'; */ export const ORGANIZATIONS_PKG = '@objectstack/organizations'; +/** This process's harness data key provider; created on the first boot, never persisted. */ +let processCryptoProvider: LocalCryptoProvider | undefined; + +/** + * [#21499] The one crypto provider every `bootStack` in this process hands to + * the settings service and to the engine. + * + * ## Why not the default provider + * + * `SettingsServicePlugin` handed no `cryptoProvider`, and a bare + * `new LocalCryptoProvider()`, both resolve a data key the way a SERVER does: + * `OS_SECRET_KEY`, then the dev env key, then the key file in the key home — + * and, in the development posture `bootStack` forces, with none of those they + * MINT the key file so the next restart reuses it. That is right for + * `os serve`. For this harness it was an undeclared side effect on key + * custody: `os verify` is a one-shot command that seals nothing it keeps, yet + * it left a key file behind that the next development-posture process on that + * host adopted and sealed real secrets under. And where the host already HAD + * a key, the harness sealed its throwaway fixtures under the host's real key — + * reading key material it never needed. + * + * ## Why not "read an existing key, or refuse" (the CLI's one-shot shape) + * + * The harness seals AND opens secrets in its own database — `secret` fields, + * encrypted settings — so a provider that refuses on a keyless host would + * break exactly what the harness exists to exercise. + * + * ## What it is + * + * A `LocalCryptoProvider` over an explicit random key: no env read, no key + * file read, no write anywhere. ⛔ The key never leaves this process's memory. + * It is the PROCESS's key, not the boot's, so two boots over one + * `BootOptions.databaseFile` — the harness's restart — open each other's + * secrets, as a real host's stable key would let them. Pinned by + * `harness.key-custody.test.ts`. + */ +function harnessCryptoProvider(): LocalCryptoProvider { + processCryptoProvider ??= new LocalCryptoProvider({ key: randomBytes(32) }); + return processCryptoProvider; +} + /** * A booted stack: the HTTP surface (`api` / `raw` / `signIn` / `signUp` / * `apiAs`) plus the in-process handle (`hooks` / `validate` / `flows` / @@ -495,7 +539,10 @@ export async function bootStack( await kernel.use(new PlatformObjectsPlugin()); // Service plugins `objectstack dev` auto-loads for an app of this shape. - await kernel.use(new SettingsServicePlugin()); + // [#21499] The settings service seals under the harness's in-process key, + // never under a provider of its own (see `harnessCryptoProvider`). + const cryptoProvider = harnessCryptoProvider(); + await kernel.use(new SettingsServicePlugin({ cryptoProvider })); await kernel.use(opts.analytics ?? new AnalyticsServicePlugin()); // `autoDefaultOrganization: false` (cloud ADR-0081 D1): the harness proves the two // ENDS of the isolation spectrum — pure single-tenant (no org, no scoping) @@ -686,12 +733,14 @@ export async function bootStack( await kernel.bootstrap(); // Secret fields (Field.secret) refuse to persist without a crypto provider — - // mirror `objectstack dev`, which wires LocalCryptoProvider in development so - // an app with an encrypted field is exercisable end-to-end. + // mirror `objectstack dev`, which wires one in development so an app with an + // encrypted field is exercisable end-to-end. [#21499] The same instance the + // settings service holds, so every `sys_secret` row shares one key, as in + // `serve` — but the harness's in-process key, never the host's. try { const engine = await kernel.getServiceAsync<{ setCryptoProvider?: (p: unknown) => void }>('objectql'); if (engine && typeof engine.setCryptoProvider === 'function') { - engine.setCryptoProvider(new LocalCryptoProvider()); + engine.setCryptoProvider(cryptoProvider); } } catch { /* no engine / no crypto support — secret fields will fail closed, as in prod */