diff --git a/.changeset/30948-host-crypto-provider.md b/.changeset/30948-host-crypto-provider.md new file mode 100644 index 00000000000..bf2fd75379f --- /dev/null +++ b/.changeset/30948-host-crypto-provider.md @@ -0,0 +1,22 @@ +--- +'@objectstack/cli': patch +'@objectstack/types': patch +--- + +feat(cli): `os serve` seals secrets with the crypto provider a plugin registers, so a KMS- or Vault-backed provider can hold the keys + +Clause-②: yes (widening) +Metadata-protocol: none + +A plugin supplies the host's secret crypto provider by registering an `ICryptoProvider` as the kernel service `crypto-provider` — `CRYPTO_PROVIDER_SERVICE`, new in `@objectstack/types` — with `ctx.registerService` in its `init()`, and declaring it in `providesServices`. `os serve` then hands that one provider to everything that seals a `sys_secret` row: the data engine (secret fields), the datasource secret binder (datasource credentials) and the default settings service (encrypted settings). Before, `os serve` built `LocalCryptoProvider` itself after every plugin hook and overwrote whatever a plugin had set. + +Without a registration nothing changes: the host keeps `LocalCryptoProvider`, keyed off `OS_SECRET_KEY` or the persisted key file exactly as before, and a production boot without a stable key still refuses at `kernel:ready` with the same message. The one difference is that the default settings service now shares that local provider instead of building a second one from the same key source; what either sealed, the other opens. + +The provider holds every secret's key, so the boot refuses rather than guess: + +- two plugins that both declare `providesServices: ['crypto-provider']` refuse before either initializes, both named (an undeclared second `registerService` is refused by the kernel, naming the service); +- a provider registered after plugin init, or the registered one replaced, refuses once the boot is up; +- a value registered under the name that is not an `ICryptoProvider` refuses, naming the missing methods; +- a secret sealed or opened while plugins are still initializing is refused, never sealed by a provider the boot has not chosen yet. + +No refusal prints a registered value. diff --git a/packages/cli/src/commands/serve-host-crypto-provider.test.ts b/packages/cli/src/commands/serve-host-crypto-provider.test.ts new file mode 100644 index 00000000000..e17c5131661 --- /dev/null +++ b/packages/cli/src/commands/serve-host-crypto-provider.test.ts @@ -0,0 +1,573 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `os serve` seals every `sys_secret` row with ONE crypto provider: the one a + * plugin registered as the kernel service `crypto-provider` during its init, or + * the host's own `LocalCryptoProvider` when none did. + * + * ## The gap + * + * `serve.ts` built `LocalCryptoProvider` itself after `runtime.start()` and + * always handed it to `setCryptoProvider`, and the datasource binder and the + * default settings service each held a local provider too. A provider a plugin + * supplied — a KMS/Vault one, for customer-managed keys — was overwritten, so + * no host could mount one. + * + * ## What this file pins + * + * Each case runs a REAL `ObjectKernel` with the host plugin + * `createHostCryptoProvider` returns, composed the way `run()` composes it, and + * a REAL `ObjectQL` engine / datasource binder / settings service over + * `host.provider`: + * + * 1. a provider a plugin registers during init seals secret-field writes, + * datasource credentials and encrypted settings — the binder's and the + * settings service's writes through the same `host.provider` the engine + * holds — and the local fallback is never built; + * 2. with no registration the provider is the `LocalCryptoProvider` serve + * always built, from the same key source: what it seals, a provider built + * the original way (`new LocalCryptoProvider()`, the settings service's + * own default) opens, and the other way round; + * 3. two registrations refuse at boot — two declaring plugins named before + * either initializes, an undeclared second one by the kernel; + * 4. the deferred path: a credential written before the post-start wiring is + * sealed by the registered provider; a seal attempted during plugin init + * refuses and records nothing; a registration after init, or a replaced + * one, refuses the boot; + * 5. the wiring inside `run()`: the one host provider reaches the binder, the + * default settings service and the data engine, and the declaration check + * runs before the boot. + * + * ⛔ No key material reaches a refusal: every refusal is checked against the + * test keys' bytes. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ObjectKernel } from '@objectstack/core'; +import { ObjectQL } from '@objectstack/objectql'; +import { createDatasourceSecretBinder } from '@objectstack/service-datasource'; +import { deferredLocalCryptoProvider, LocalCryptoProvider, SettingsService } from '@objectstack/service-settings'; +import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; +import type { SettingsManifest } from '@objectstack/spec/system'; +import { CRYPTO_PROVIDER_SERVICE } from '@objectstack/types'; + +// The repo's ONE code/prose separator: a comment naming a call must not answer +// for code that does not make it. +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; + +import { + createHostCryptoProvider, + HostCryptoProviderRefusal, + refuseTwoCryptoProviderPlugins, +} from './serve.js'; + +// [#10126] Pay the first load of what `serve.ts` reaches through dynamic +// `import()`s at MODULE LOAD, outside any clocked case. +import '@objectstack/metadata-protocol'; +import '@objectstack/metadata'; +import '@objectstack/plugin-hono-server'; +import '@objectstack/rest'; +import '@objectstack/driver-turso'; +import '@objectstack/driver-sql'; +import '@objectstack/platform-objects/plugin'; +import '@objectstack/plugin-security'; + +/** …/packages/cli/src/commands — seeded from `import.meta.url`. */ +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** `serve.ts`'s CODE, with every comment span blanked. */ +const SERVE = maskComments(readFileSync(resolve(HERE, 'serve.ts'), 'utf8')); + +/** The local key the no-registration cases read from `OS_SECRET_KEY`. */ +const LOCAL_KEY_HEX = '606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f'; +/** The key behind the registered ("KMS") provider. Never the local one. */ +const KMS_KEY = Buffer.from('404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f', 'hex'); + +/** A refusal carries no byte of either key, in any spelling a log would print. */ +function expectNoKeyMaterial(text: string): void { + for (const spelling of [LOCAL_KEY_HEX, KMS_KEY.toString('hex'), KMS_KEY.toString('base64')]) { + expect(text).not.toContain(spelling); + } +} + +/** + * The provider a test plugin registers: AES-GCM under {@link KMS_KEY}, every + * handle stamped with its own `kmsKeyId`, every seal recorded by scope. + */ +function kmsProvider() { + const inner = new LocalCryptoProvider({ key: KMS_KEY }); + const sealed: string[] = []; + const provider: ICryptoProvider = { + async encrypt(plain: string, ctx: CryptoContext): Promise { + sealed.push(ctx.scope); + return { ...(await inner.encrypt(plain, ctx)), kmsKeyId: 'test-kms' }; + }, + decrypt: (handle, ctx) => inner.decrypt(handle, ctx), + rotateKey: (handle, ctx) => inner.rotateKey(handle, ctx), + digest: (plain) => inner.digest(plain), + keyedDigest: (plain) => inner.keyedDigest(plain), + }; + return { provider, sealed }; +} + +/** A plugin that registers `provider` as the host's crypto provider during init. */ +function providerPlugin(name: string, provider: unknown, opts: { declare?: boolean } = {}) { + return { + name, + version: '1.0.0', + ...(opts.declare === false ? {} : { providesServices: [CRYPTO_PROVIDER_SERVICE] }), + init: async (ctx: { registerService(name: string, service: unknown): void }) => { + ctx.registerService(CRYPTO_PROVIDER_SERVICE, provider); + }, + }; +} + +/** The one service a kernel refuses to start without. Nothing here reads data. */ +const DATA_STUB = { + name: 'com.example.host-crypto-provider.data-stub', + version: '1.0.0', + init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('data', {}); }, +}; + +/** Throws if called: the local fallback must not be built when a provider is registered. */ +const NO_LOCAL = (): ICryptoProvider => { + throw new Error('the local fallback was built although a provider is registered'); +}; + +// --------------------------------------------------------------------------- +// A real ObjectQL engine over an in-memory driver, with one secret field. +// --------------------------------------------------------------------------- + +type Row = Record; + +function memoryDriver() { + const stores = new Map>(); + const storeFor = (object: string) => { + let s = stores.get(object); + if (!s) { s = new Map(); stores.set(object, s); } + return s; + }; + const matches = (row: Row, where: unknown): boolean => { + if (!where || typeof where !== 'object') return true; + for (const [k, v] of Object.entries(where as Row)) { + if (k.startsWith('$')) continue; + if ((row[k] ?? null) !== (v ?? null)) return false; + } + return true; + }; + const copy = (r: Row): Row => ({ ...r }); + let n = 0; + const driver = { + name: 'memory', + version: '0.0.0', + supports: {}, + async connect() {}, + async disconnect() {}, + async checkHealth() { return true; }, + async execute() { return null; }, + async find(object: string, ast?: Row) { + const matched = Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)); + // The caller's bound, held by presence after the filter and before the + // copy (`check:objectql-double-limit`). + const page = typeof ast?.limit === 'number' ? matched.slice(0, ast.limit) : matched; + return page.map(copy); + }, + async create(object: string, data: Row) { + n += 1; + const id = (data.id as string) ?? `r_${n}`; + const row = { ...data, id }; + storeFor(object).set(id, row); + return copy(row); + }, + async count(object: string, ast?: Row) { + return (await this.find(object, ast)).length; + }, + async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; }, + async commit() {}, + async rollback() {}, + }; + return { driver, rowsOf: (object: string) => Array.from(storeFor(object).values()).map(copy) }; +} + +const TEST_PACKAGE_ID = 'com.objectstack.test.host-crypto-provider'; +const textField = (name: string) => ({ name, label: name, type: 'text' as const }); + +async function secretFieldEngine() { + const store = memoryDriver(); + const engine = new ObjectQL(); + engine.registerDriver(store.driver as never, true); + await engine.init(); + engine.registry.registerObject({ + name: 'sys_secret', + label: 'Secret', + fields: { + ...Object.fromEntries( + ['id', 'namespace', 'key', 'kms_key_id', 'alg', 'ciphertext', 'created_at', 'rotated_at'] + .map((f) => [f, textField(f)]), + ), + version: { name: 'version', label: 'version', type: 'number' as const }, + }, + } as never, TEST_PACKAGE_ID); + engine.registry.registerObject({ + name: 'vault_item', + label: 'Vault item', + fields: { id: textField('id'), token: { name: 'token', label: 'token', type: 'secret' as const } }, + } as never, TEST_PACKAGE_ID); + return { engine, store }; +} + +/** The `sys_secret` row behind a secret-field write, as a handle the providers open. */ +function sealedHandle(store: ReturnType, ref: unknown): CryptoHandle { + const id = String(ref).slice('secret:'.length); + const row = store.rowsOf('sys_secret').find((r) => r.id === id)!; + return { + id, + kmsKeyId: String(row.kms_key_id), + alg: String(row.alg), + version: Number(row.version), + ciphertext: String(row.ciphertext), + }; +} + +/** An in-memory `sys_secret` table with the surface the binder's `bind` / `resolve` call. */ +function secretRows() { + const rows = new Map(); + return { + rows, + async insert(_object: string, data: Row) { rows.set(String(data.id), data); return data; }, + async find(_object: string, query: { where?: Row; limit?: number }) { + const hits = [...rows.values()].filter((row) => + Object.entries(query.where ?? {}).every(([k, v]) => { + // Scalar equality only: a combinator or operator is refused, never read as a field name. + if (k.startsWith('$')) throw new Error(`secretRows double: no '${k}' support`); + return row[k] === v; + })); + return typeof query.limit === 'number' ? hits.slice(0, query.limit) : hits; + }, + }; +} + +/** One encrypted settings key — minimal, as in service-settings' crypto tests. */ +const SETTINGS_MANIFEST: SettingsManifest = { + namespace: 'crypto_ns', + version: 1, + label: 'Crypto', + scope: 'global', + readPermission: 'setup.access', + writePermission: 'setup.write', + specifiers: [{ type: 'password', key: 'api_key', label: 'API key', required: false }], +} as SettingsManifest; + +/** An in-memory `sys_secret` store with the surface `SettingsService` writes through. */ +function settingsSecretStore() { + const rows = new Map(); + return { + rows, + async insert(row: { id: string; namespace: string; key: string; kms_key_id: string; alg: string; version: number; ciphertext: string }) { + rows.set(row.id, { id: row.id, namespace: row.namespace, key: row.key, kmsKeyId: row.kms_key_id, alg: row.alg, version: row.version, ciphertext: row.ciphertext }); + return { id: row.id }; + }, + async get(id: string) { + const r = rows.get(id); + return r ? { id: r.id, namespace: r.namespace, key: r.key, kms_key_id: r.kmsKeyId, alg: r.alg, version: r.version, ciphertext: r.ciphertext } : null; + }, + async update() {}, + }; +} + +/** + * Boot a kernel with `plugins` and the host plugin. `run()` mounts the stack's + * plugins before the host plugin and the capability providers after it, so + * both orders occur; `hostFirst` puts the host plugin ahead of `plugins`. + */ +async function bootIn( + order: { hostFirst: boolean }, + host: ReturnType, + ...plugins: object[] +) { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + await kernel.use(DATA_STUB); + if (order.hostFirst) await kernel.use(host.plugin as never); + for (const plugin of plugins) await kernel.use(plugin as never); + if (!order.hostFirst) await kernel.use(host.plugin as never); + await kernel.bootstrap(); + return kernel; +} + +const boot = (host: ReturnType, ...plugins: object[]) => + bootIn({ hostFirst: false }, host, ...plugins); + +describe('a provider a plugin registers during init seals every secret', () => { + it('seals secret-field writes, datasource credentials and encrypted settings, and never builds the local one', async () => { + const kms = kmsProvider(); + const host = createHostCryptoProvider(NO_LOCAL); + // Both composed BEFORE the boot, as `run()` composes them. + const binderRows = secretRows(); + const binder = createDatasourceSecretBinder({ engine: binderRows as never, cryptoProvider: host.provider }); + const settingsStore = settingsSecretStore(); + const settings = new SettingsService({ env: {}, cryptoProvider: host.provider, secretStore: settingsStore } as never); + settings.registerManifest(SETTINGS_MANIFEST); + + const kernel = await boot(host, providerPlugin('com.example.kms-crypto-provider', kms.provider)); + try { + expect(host.provider.source).toBe('registered'); + expect(host.provider.resolve()).toBe(kms.provider); + + // The post-start wiring, as `run()` does it. + const { engine, store } = await secretFieldEngine(); + engine.setCryptoProvider(host.provider); + host.provider.confirm(); + + await engine.insert('vault_item', { id: 'v1', token: 'hunter2' }); + const ref = store.rowsOf('vault_item')[0].token; + expect(String(ref)).toMatch(/^secret:/); + const handle = sealedHandle(store, ref); + expect(handle.kmsKeyId).toBe('test-kms'); + expect(await kms.provider.decrypt(handle, { scope: 'object_secret_field', namespace: 'vault_item', key: 'token' })) + .toBe('hunter2'); + + const credentialsRef = await binder.bind({ value: 'pg-password' }, { name: 'warehouse' }); + expect(credentialsRef).toMatch(/^sys_secret:/); + expect(await binder.resolve(credentialsRef)).toBe('pg-password'); + + await settings.set('crypto_ns', 'api_key', 'smtp-app-password'); + expect([...settingsStore.rows.values()].map((r) => r.kmsKeyId)).toEqual(['test-kms']); + + // One provider sealed all three producers' secrets. + expect(kms.sealed).toEqual(['object_secret_field', 'datasource_credential', 'settings']); + } finally { + await kernel.shutdown(); + } + }); +}); + +describe('with no registration, the provider is the LocalCryptoProvider serve always built', () => { + beforeEach(() => { vi.stubEnv('OS_SECRET_KEY', LOCAL_KEY_HEX); }); + afterEach(() => { vi.unstubAllEnvs(); }); + + /** The host as `run()` composes it: the deferred local provider as its fallback. */ + const serveHost = () => { + const local = deferredLocalCryptoProvider(); + return createHostCryptoProvider(() => local.resolve()); + }; + + it('chooses the local provider at kernel:ready, from the same key source as before', async () => { + const host = serveHost(); + expect(host.provider.source).toBeUndefined(); + const kernel = await boot(host); + try { + expect(host.provider.source).toBe('local'); + const chosen = host.provider.resolve(); + expect(chosen).toBeInstanceOf(LocalCryptoProvider); + expect((chosen as LocalCryptoProvider).keySource).toBe(new LocalCryptoProvider().keySource); + expect(host.provider.confirm()).toBe(chosen); + } finally { + await kernel.shutdown(); + } + }); + + it('an encrypted setting written through it still opens the original way, and the other way round', async () => { + const host = serveHost(); + const store = settingsSecretStore(); + const settings = new SettingsService({ env: {}, cryptoProvider: host.provider, secretStore: store } as never); + settings.registerManifest(SETTINGS_MANIFEST); + const kernel = await boot(host); + try { + await settings.set('crypto_ns', 'api_key', 'smtp-app-password'); + const [row] = [...store.rows.values()]; + // The original way: the provider the settings service built for itself + // when it was handed none (`new LocalCryptoProvider()`). + const original = new LocalCryptoProvider(); + expect(await original.decrypt(row, { scope: 'settings', namespace: 'crypto_ns', key: 'api_key' })) + .toBe('smtp-app-password'); + expect((await settings.get('crypto_ns', 'api_key')).value).toBe('smtp-app-password'); + + // And a value the original provider sealed opens through the shared one. + const before = await original.encrypt('sealed-before-upgrade', { scope: 'settings', namespace: 'crypto_ns', key: 'api_key' }); + expect(await host.provider.decrypt(before, { scope: 'settings', namespace: 'crypto_ns', key: 'api_key' })) + .toBe('sealed-before-upgrade'); + } finally { + await kernel.shutdown(); + } + }); + + it('a secret field sealed by it opens under a LocalCryptoProvider built the original way', async () => { + const host = serveHost(); + const kernel = await boot(host); + try { + const { engine, store } = await secretFieldEngine(); + host.provider.confirm(); + engine.setCryptoProvider(host.provider); + await engine.insert('vault_item', { id: 'v1', token: 'hunter2' }); + const handle = sealedHandle(store, store.rowsOf('vault_item')[0].token); + expect(await new LocalCryptoProvider().decrypt(handle, { scope: 'object_secret_field', namespace: 'vault_item', key: 'token' })) + .toBe('hunter2'); + } finally { + await kernel.shutdown(); + } + }); +}); + +describe('two registrations refuse at boot', () => { + it('two declaring plugins are refused before either initializes, both named', () => { + const a = providerPlugin('com.example.aws-kms-crypto', kmsProvider().provider); + const b = providerPlugin('com.example.vault-crypto', kmsProvider().provider); + const refusal = (() => { + try { refuseTwoCryptoProviderPlugins([a, { name: 'com.example.unrelated', init: async () => {} }, b]); } + catch (err) { return err as Error; } + return undefined; + })(); + expect(refusal).toBeInstanceOf(HostCryptoProviderRefusal); + expect(refusal!.message).toContain("'com.example.aws-kms-crypto' and 'com.example.vault-crypto'"); + expect(refusal!.message).toContain(`'${CRYPTO_PROVIDER_SERVICE}'`); + expectNoKeyMaterial(refusal!.message); + }); + + it('one declaring plugin, or one plugin composed twice under its name, is not refused', () => { + const a = providerPlugin('com.example.aws-kms-crypto', kmsProvider().provider); + expect(() => refuseTwoCryptoProviderPlugins([a])).not.toThrow(); + expect(() => refuseTwoCryptoProviderPlugins([a, providerPlugin('com.example.aws-kms-crypto', kmsProvider().provider)])) + .not.toThrow(); + expect(() => refuseTwoCryptoProviderPlugins([])).not.toThrow(); + }); + + it('an undeclared second registration is refused by the kernel at boot', async () => { + const host = createHostCryptoProvider(NO_LOCAL); + await expect(boot( + host, + providerPlugin('com.example.first-crypto', kmsProvider().provider, { declare: false }), + providerPlugin('com.example.second-crypto', kmsProvider().provider, { declare: false }), + )).rejects.toThrow(`Service '${CRYPTO_PROVIDER_SERVICE}' already registered`); + expect(host.provider.source).toBeUndefined(); + }); +}); + +describe('the deferred path: nothing is sealed by a provider the boot did not choose', () => { + it('a credential bound before the post-start wiring is sealed by the registered provider', async () => { + const kms = kmsProvider(); + const host = createHostCryptoProvider(NO_LOCAL); + const binder = createDatasourceSecretBinder({ engine: secretRows() as never, cryptoProvider: host.provider }); + // A plugin that binds a credential from its own kernel:ready hook — the + // datasource admin's rehydration window, before serve's post-start wiring. + let boundAtReady: string | undefined; + const early = { + name: 'com.example.binds-at-ready', + version: '1.0.0', + init: async (ctx: { hook(name: string, fn: () => Promise): void }) => { + ctx.hook('kernel:ready', async () => { boundAtReady = await binder.bind({ value: 'early-secret' }, { name: 'early' }); }); + }, + }; + const kernel = await boot(host, early, providerPlugin('com.example.kms-crypto-provider', kms.provider)); + try { + expect(boundAtReady).toMatch(/^sys_secret:/); + expect(kms.sealed).toEqual(['datasource_credential']); + expect(host.provider.source).toBe('registered'); + } finally { + await kernel.shutdown(); + } + }); + + it.each([ + { hostFirst: false, when: 'before the host plugin initializes' }, + { hostFirst: true, when: 'after the host plugin, while plugin init is still running' }, + ])('a seal attempted during plugin init ($when) refuses, records nothing, and the boot still chooses the registered one', async ({ hostFirst }) => { + const kms = kmsProvider(); + const host = createHostCryptoProvider(NO_LOCAL); + let duringInit: unknown; + const tooEarly = { + name: 'com.example.seals-in-init', + version: '1.0.0', + init: async () => { + duringInit = await host.provider.encrypt('x', { scope: 'settings', namespace: 'n', key: 'k' }).catch((e) => e); + // Nothing was chosen: the provider below has not registered yet. + expect(host.provider.source).toBeUndefined(); + }, + }; + const kernel = await bootIn( + { hostFirst }, host, tooEarly, providerPlugin('com.example.kms-crypto-provider', kms.provider), + ); + try { + expect(duringInit).toBeInstanceOf(HostCryptoProviderRefusal); + expectNoKeyMaterial((duringInit as Error).message); + expect(host.provider.source).toBe('registered'); + expect(kms.sealed).toEqual([]); + } finally { + await kernel.shutdown(); + } + }); + + it('a provider registered after init refuses the boot', async () => { + const host = createHostCryptoProvider(() => new LocalCryptoProvider({ key: KMS_KEY })); + const late = { + name: 'com.example.registers-in-start', + version: '1.0.0', + init: async () => {}, + start: async (ctx: { hook(name: string, fn: () => void): void; registerService(name: string, s: unknown): void }) => { + ctx.hook('kernel:bootstrapped', () => { ctx.registerService(CRYPTO_PROVIDER_SERVICE, kmsProvider().provider); }); + }, + }; + const kernel = await boot(host, late); + try { + expect(host.provider.source).toBe('local'); + expect(() => host.provider.confirm()).toThrow(HostCryptoProviderRefusal); + expect(() => host.provider.confirm()).toThrow(/after every plugin had initialized/); + } finally { + await kernel.shutdown(); + } + }); + + it('a registered provider replaced after the choice refuses the boot', async () => { + const host = createHostCryptoProvider(NO_LOCAL); + const replacer = { + name: 'com.example.replaces-provider', + version: '1.0.0', + init: async () => {}, + start: async (ctx: { hook(name: string, fn: () => void): void; replaceService(name: string, s: unknown): void }) => { + ctx.hook('kernel:bootstrapped', () => { ctx.replaceService(CRYPTO_PROVIDER_SERVICE, kmsProvider().provider); }); + }, + }; + const kernel = await boot(host, replacer, providerPlugin('com.example.kms-crypto-provider', kmsProvider().provider)); + try { + expect(host.provider.source).toBe('registered'); + expect(() => host.provider.confirm()).toThrow(/was replaced after this boot chose it/); + } finally { + await kernel.shutdown(); + } + }); + + it('a registered value that is not a crypto provider refuses the boot, naming what it lacks and nothing it holds', async () => { + const host = createHostCryptoProvider(NO_LOCAL); + const notAProvider = { encrypt: async () => null, keyMaterial: KMS_KEY.toString('hex') }; + const refusal = await boot(host, providerPlugin('com.example.bad-crypto', notAProvider)).then(() => undefined, (e) => e as Error); + expect(refusal).toBeInstanceOf(HostCryptoProviderRefusal); + expect(refusal!.message).toContain('decrypt(), rotateKey(), digest(), keyedDigest()'); + expectNoKeyMaterial(refusal!.message); + }); +}); + +describe('the wiring inside run()', () => { + it('builds one host provider over the deferred local one, and mounts its plugin', () => { + expect(SERVE.match(/createHostCryptoProvider\(/g)).toHaveLength(2); // the definition and the one call + expect(SERVE).toContain('hostCrypto = createHostCryptoProvider(() => localCryptoProvider.resolve());'); + expect(SERVE).toContain('await kernel.use(hostCrypto.plugin);'); + }); + + it('hands that one provider to the binder, the default settings service and the data engine', () => { + expect(SERVE).toMatch(/createDatasourceSecretBinder\(\{\s*engine: lazySecretEngine,\s*cryptoProvider: hostCrypto\.provider,\s*\}\)/); + expect(SERVE).toMatch(/cap === 'settings' && hostCrypto\s*\?\s*\{ \.\.\.\(arg \?\? \{\}\), cryptoProvider: hostCrypto\.provider \}/); + expect(SERVE).toMatch(/hostCrypto\.provider\.confirm\(\);\s*dataEngine\.setCryptoProvider\(hostCrypto\.provider\);/); + // No consumer is handed a provider of its own any more. + expect(SERVE).not.toMatch(/new LocalCryptoProvider\(/); + expect(SERVE.match(/setCryptoProvider\(/g)).toHaveLength(1); + }); + + it('checks the declared providers before the boot', () => { + const check = SERVE.indexOf('refuseTwoCryptoProviderPlugins([...stackMountedPlugins, ...resolverMounted]);'); + const start = SERVE.indexOf('await runtime.start();'); + expect(check).toBeGreaterThan(0); + expect(start).toBeGreaterThan(check); + }); +}); diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 2e0f0ce1f1f..22534a487d4 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -30,7 +30,7 @@ import { // posture is the authoritative knob and `resolveTenancyPosture()` already folds // the legacy boolean in as its unset-fallback. serve's last direct reader of the // boolean was the banner, and that was exactly the drift #4801 fixed. -import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError, DATASOURCE_SECRET_RESOLVER_SERVICE, type IDatasourceSecretResolver } from '@objectstack/types'; +import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError, DATASOURCE_SECRET_RESOLVER_SERVICE, CRYPTO_PROVIDER_SERVICE, type IDatasourceSecretResolver } from '@objectstack/types'; import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_ALWAYS_ON_CAPABILITIES, RETIRED_PLATFORM_CAPABILITY_GUIDANCE } from '@objectstack/spec/kernel'; // [#22301] The `requires` token → provider table and its exact identity match, // shared with `@objectstack/verify`'s `bootStack` — see `Serve.CAPABILITY_PROVIDERS` @@ -65,6 +65,7 @@ import { TENANCY_POSTURES, type TenancyPosture } from '@objectstack/spec/securit // tally could certify itself. import { SEED_SETTLEMENT_SERVICE, + type ICryptoProvider, type ISeedSettlementService, type SeedSettlementSnapshot, type SeedSuppressionReason, @@ -4707,6 +4708,10 @@ export default class Serve extends Command { } } + // The stack's plugins as the kernel received them — instances, after + // `materializeStackPlugin` — so the crypto-provider composition check + // before `runtime.start()` reads what each one declares. + const stackMountedPlugins: unknown[] = []; if (plugins.length > 0) { for (const plugin of plugins) { try { @@ -4742,6 +4747,7 @@ export default class Serve extends Command { // with a `warn` naming both versions. That is how an app supplies // options to a plugin this CLI mounts without them. await kernel.use(pluginToLoad); + stackMountedPlugins.push(pluginToLoad); const pluginName = plugin.name || plugin.constructor?.name || 'unnamed'; trackPlugin(pluginName); } catch (e: any) { @@ -5085,6 +5091,39 @@ export default class Serve extends Command { } } + // ── The ONE secret crypto provider (host composition root) ───────── + // Every `sys_secret` row is sealed by one ICryptoProvider: the data + // engine's secret fields (wired after `runtime.start()` below), the + // datasource secret binder and the default settings service (both + // composed before it). A plugin supplies that provider — a KMS/Vault one, + // for customer-managed keys — by registering it as the kernel service + // CRYPTO_PROVIDER_SERVICE during its init; without one this host keeps + // its own LocalCryptoProvider (AES-256-GCM keyed off `OS_SECRET_KEY` or a + // persisted key file), exactly as before the seam existed. All three + // consumers hold `hostCrypto.provider`, which chooses once every plugin + // has initialized (`createHostCryptoProvider`). The fallback is the + // deferred LocalCryptoProvider: built at that choice, not here, because + // the key file is settled only once the database is open (the settings + // service's `start()`). + let hostCrypto: ReturnType | undefined; + let hostCryptoUnavailable: unknown; + try { + const { deferredLocalCryptoProvider } = await import( + /* webpackIgnore: true */ '@objectstack/service-settings' + ); + const localCryptoProvider = deferredLocalCryptoProvider(); + hostCrypto = createHostCryptoProvider(() => localCryptoProvider.resolve()); + // ⛔ Not `trackPlugin`ed: it is the host's own seam, not a capability. + await kernel.use(hostCrypto.plugin); + } catch (cryptoErr: unknown) { + // Reached only when the settings package (which ships the local + // provider) does not load. The consumers below then report it where + // they did before: the datasource admin builds no binder, and the + // secret-field wiring warns that writing a secret field fails closed. + hostCrypto = undefined; + hostCryptoUnavailable = cryptoErr; + } + // 5. Capability resolver — auto-load service plugins declared in // `requires: [...]` that are NOT tier-gated. Each entry of // Serve.CAPABILITY_PROVIDERS maps a token to a package + factory; if the @@ -5184,7 +5223,15 @@ export default class Serve extends Command { ` ⚠ StorageServicePlugin using local driver (${resolved.localStorageRoot}) — switch to S3/GCS/Azure for production (set OS_STORAGE_* or configure storage in Setup → Settings).`, )); } - const provider = arg !== undefined ? new Ctor(arg) : new Ctor(); + // The default settings service seals its encrypted settings with the + // host's one crypto provider (above), not a LocalCryptoProvider of its + // own: a provider a plugin registers then holds the settings' keys + // too. Without a registration the shared one is that same local + // provider — same constructor, same key source. + const ctorArg: any = cap === 'settings' && hostCrypto + ? { ...(arg ?? {}), cryptoProvider: hostCrypto.provider } + : arg; + const provider = ctorArg !== undefined ? new Ctor(ctorArg) : new Ctor(); await kernel.use(provider); resolverMounted.push(provider); trackPlugin(spec.export); @@ -5235,12 +5282,6 @@ export default class Serve extends Command { } } - // Shared dev crypto provider for ALL of sys_secret (datasource creds - // below + secret fields after start). One instance ⇒ one key, so every - // encrypted secret decrypts under the same provider. Created lazily by - // whichever block runs first. - let sharedCryptoProvider: any = undefined; - // ── External Datasource Federation (ADR-0015) ───────────────── // Federation (introspect / draft / import / validate of external // tables) ships in the open framework. @@ -5283,11 +5324,12 @@ export default class Serve extends Command { // whether the UI can manage datasources. Mounted by default so a // self-host runtime is a complete low-code platform out of the box. // - // Credentials are bound through the SAME crypto provider used for - // `secret` fields below (`sharedCryptoProvider`), so every secret in - // `sys_secret` (settings, secret fields, datasource creds) shares one - // key. Wired BEFORE runtime.start() so the plugin's kernel:ready boot - // rehydration (which decrypts persisted creds) has its binder ready. + // Credentials are bound through the host's ONE crypto provider + // (`hostCrypto`, above), the one `secret` fields and the settings service + // use, so every secret in `sys_secret` (settings, secret fields, + // datasource creds) is sealed by one provider. Wired BEFORE + // runtime.start() so the plugin's kernel:ready boot rehydration (which + // decrypts persisted creds) has its binder ready. try { const adminMod: any = await import('@objectstack/service-datasource'); const { @@ -5320,36 +5362,32 @@ export default class Serve extends Command { find: (o: string, q?: any) => resolveEngine()?.find(o, q), }; - // Fail-closed binder over the shared dev crypto provider. If the + // Fail-closed binder over the host's one crypto provider. If the // binder can't be built, leave `secrets` undefined — the plugin // then rejects secret-bearing create/update instead of storing // cleartext (by design). let secrets: any = undefined; try { - const { deferredLocalCryptoProvider } = await import( - /* webpackIgnore: true */ '@objectstack/service-settings' - ); - // First block to touch `sharedCryptoProvider` (still undefined - // here); the secret-field wiring below reuses this instance so - // every sys_secret shares one key. Built at its FIRST USE, not - // here: the database is not open yet, and when two key files hold - // different keys the boot chooses between them only once it is - // (the settings service's `start()`). The binder first decrypts at - // `kernel:ready`, after that choice. - sharedCryptoProvider = deferredLocalCryptoProvider(); + if (!hostCrypto) throw hostCryptoUnavailable; + // The host provider chooses nothing here: plugins have not + // initialized, and a plugin may still register the provider. The + // binder first seals or opens a credential at `kernel:ready`, after + // every init and after the key file is settled, so a credential + // written before the post-start wiring below is sealed by the + // registered provider when there is one, never by the local one. secrets = createDatasourceSecretBinder({ engine: lazySecretEngine, - cryptoProvider: sharedCryptoProvider, + cryptoProvider: hostCrypto.provider, }); } catch (cryptoErr: any) { - // Reached only when the module or the binder fails to load: the - // deferred provider constructs nothing here, so a key problem - // never lands in this catch. Leave `secrets` undefined so the - // plugin rejects secret-bearing create/update rather than storing - // cleartext. A key problem surfaces where the provider is first - // built, the secret-field wiring below (`resolve()`), which - // rethrows a production "Refusing to start in production" error, - // so the boot still refuses and nothing is duplicated here. + // Reached only when the settings package or the binder fails to + // load: the host provider constructs nothing here, so a key + // problem never lands in this catch. Leave `secrets` undefined so + // the plugin rejects secret-bearing create/update rather than + // storing cleartext. A key problem surfaces where the provider is + // first built (`kernel:ready`, or the secret-field wiring below), + // which rethrows a production "Refusing to start in production" + // error, so the boot still refuses and nothing is duplicated here. console.warn( chalk.yellow( ` ⚠ datasource admin: no CryptoProvider (${cryptoErr?.message ?? cryptoErr}); secret-bearing datasource create/update will fail closed`, @@ -5605,6 +5643,12 @@ export default class Serve extends Command { } as any); } + // Two plugins that each declare the crypto provider (`providesServices`) + // refuse here, both named, before either initializes; see + // `refuseTwoCryptoProviderPlugins`. An undeclared second registration is + // refused by the kernel itself when it happens. + refuseTwoCryptoProviderPlugins([...stackMountedPlugins, ...resolverMounted]); + // Boot the runtime await runtime.start(); @@ -5616,46 +5660,48 @@ export default class Serve extends Command { // objectql's `secret` field type encrypts on write to `sys_secret` // and fails closed when no ICryptoProvider is registered. objectql // must NOT depend on a crypto implementation (layering), so the - // host injects one here. Dev/self-host gets a LocalCryptoProvider - // (AES-256-GCM keyed off `OS_SECRET_KEY` or a persisted dev key); - // production hosts swap this for a KMS/Vault-backed provider (e.g. - // via an env-gated branch or a dedicated plugin) before secrets are - // written. We resolve the data engine by its registered service name - // and feature-detect `setCryptoProvider` so older engines / alternate - // data services degrade gracefully (writing a secret then fails - // closed, as designed, rather than silently storing cleartext). + // host injects one here: its ONE crypto provider (`hostCrypto`, composed + // before the capability resolver), which the datasource binder and the + // default settings service already hold. That is the provider a plugin + // registered as CRYPTO_PROVIDER_SERVICE during init (a KMS/Vault one), + // else this host's LocalCryptoProvider (AES-256-GCM keyed off + // `OS_SECRET_KEY` or a persisted dev key). We resolve the data engine by + // its registered service name and feature-detect `setCryptoProvider` so + // older engines / alternate data services degrade gracefully (writing a + // secret then fails closed, as designed, rather than silently storing + // cleartext). try { const dataEngine: any = kernel.getService?.('data') ?? kernel.getService?.('objectql'); if (dataEngine && typeof dataEngine.setCryptoProvider === 'function') { - if (!sharedCryptoProvider) { - const { LocalCryptoProvider } = await import( - /* webpackIgnore: true */ '@objectstack/service-settings' - ); - // In production LocalCryptoProvider throws when no stable key - // (OS_SECRET_KEY / persisted file) is available — the fail-loud - // guard against silently minting an ephemeral key and losing - // every sys_secret value after a restart. Let that error be loud: - // secret writes must not proceed under an unstable key. - sharedCryptoProvider = new LocalCryptoProvider(); - } else if (typeof sharedCryptoProvider.resolve === 'function') { - // The deferred instance the datasource binder shares: build it now - // (the key file was chosen during `runtime.start()`), so the same - // production refusal stays at boot rather than at the first write. - sharedCryptoProvider.resolve(); + if (!hostCrypto) { + throw hostCryptoUnavailable instanceof Error + ? hostCryptoUnavailable + : new Error(String(hostCryptoUnavailable)); } - dataEngine.setCryptoProvider(sharedCryptoProvider); + // Chosen by now (at `kernel:ready`) and held to it: a provider + // registered after init, or the registered one replaced, refuses the + // boot here. On the local path, LocalCryptoProvider throws in + // production when no stable key (OS_SECRET_KEY / persisted file) is + // available — the fail-loud guard against silently minting an + // ephemeral key and losing every sys_secret value after a restart. + // Let that error be loud: secret writes must not proceed under an + // unstable key. + hostCrypto.provider.confirm(); + dataEngine.setCryptoProvider(hostCrypto.provider); if (isDev) { printDiagnostic( chalk.dim( - ' ↪ secret fields: LocalCryptoProvider wired (dev) — set OS_SECRET_KEY and swap for KMS/Vault in production', + hostCrypto.provider.source === 'registered' + ? ` ↪ secret fields: the crypto provider a plugin registered as '${CRYPTO_PROVIDER_SERVICE}' wired` + : ' ↪ secret fields: LocalCryptoProvider wired (dev) — set OS_SECRET_KEY and swap for KMS/Vault in production', ), ); } } } catch (err: any) { const msg = String(err?.message ?? err); - if (msg.includes('Refusing to start in production')) { + if (err instanceof HostCryptoProviderRefusal || msg.includes('Refusing to start in production')) { // Fail-loud config error: print the actionable guidance verbatim. console.error(chalk.red(msg)); throw err; @@ -6139,6 +6185,209 @@ export function createDatasourceSecretResolverPlugin( }; } +/** + * Which provider seals this boot's secrets: the one a plugin registered as + * {@link CRYPTO_PROVIDER_SERVICE}, or the host's own `LocalCryptoProvider`. + */ +export type HostCryptoProviderSource = 'registered' | 'local'; + +/** + * The ONE crypto provider `os serve` hands every producer of a `sys_secret` + * row — the data engine (secret fields), the datasource secret binder + * (datasource credentials) and the default `SettingsServicePlugin` (encrypted + * settings) — so every secret opens under the provider that sealed it. + * + * Those consumers are composed before any plugin initializes, and the provider + * is decided by what a plugin registers DURING init. So this object is handed + * out first and chooses later — at its first use, or at `kernel:ready`, + * whichever comes first — and never while plugin init is still running + * (AGENTS.md, "Startup registry reads": resolve where it is used). + */ +export interface HostCryptoProvider extends ICryptoProvider { + /** + * The provider every call goes to, chosen at the first call and then kept: + * the registered one, or the local fallback when no plugin registered one. + * While plugin init is still running it refuses and chooses nothing: a + * provider may still register, and a secret another provider sealed now + * would not open under it. A fallback whose constructor refuses (production + * without a stable key) throws that refusal from every call. + */ + resolve(): ICryptoProvider; + /** + * {@link resolve}, then refuse when the registry no longer holds what was + * chosen: a provider registered after plugin init (this boot already chose + * the local one), or the registered one replaced. Called once the boot is + * over, before the data engine is handed the provider. + */ + confirm(): ICryptoProvider; + /** What {@link resolve} chose, or `undefined` before it has chosen. */ + readonly source: HostCryptoProviderSource | undefined; +} + +/** + * A refusal of the host's crypto-provider composition. It names the service + * and the plugins, ⛔ never a registered value: the provider holds every + * secret's key. + */ +export class HostCryptoProviderRefusal extends Error { + constructor(message: string) { + super(message); + this.name = 'HostCryptoProviderRefusal'; + } +} + +/** The plugin context members {@link createHostCryptoProvider}'s plugin reads. */ +interface HostCryptoProviderContext { + getServices(): Map; + getKernel?: () => { getState?: () => string } | undefined; + hook(name: string, handler: () => Promise | void): void; +} + +/** What a value registered as {@link CRYPTO_PROVIDER_SERVICE} must carry, as callable members. */ +const CRYPTO_PROVIDER_MEMBERS = ['encrypt', 'decrypt', 'rotateKey', 'digest', 'keyedDigest'] as const; + +/** `'a'`, `'a' and 'b'`, `'a', 'b' and 'c'`. */ +function quotedNameList(names: readonly string[]): string { + const quoted = names.map((name) => `'${name}'`); + return quoted.length <= 1 ? quoted.join('') : `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`; +} + +/** + * Build the host's one crypto provider ({@link HostCryptoProvider}) and the + * plugin that lets it read the kernel's service registry. + * + * - `local` builds the fallback, `LocalCryptoProvider`. It is called only when + * no plugin registered a provider, so its production refusal for an unstable + * key is reached exactly where it was before a plugin could supply one. + * - The plugin reads nothing in `init()`: it keeps the context and resolves + * the provider from a `kernel:ready` hook. Registered during init, that hook + * runs before every hook registered in a `start()` — the settings service's + * among them — so a production boot without a stable local key still stops + * at `kernel:ready`. + */ +export function createHostCryptoProvider(local: () => ICryptoProvider): { + provider: HostCryptoProvider; + plugin: { name: string; version: string; init(ctx: HostCryptoProviderContext): Promise }; +} { + let registry: { read(): unknown; initDone(): boolean } | undefined; + let chosen: { provider: ICryptoProvider; source: HostCryptoProviderSource; registered: unknown } | undefined; + + const asCryptoProvider = (value: unknown): ICryptoProvider => { + const missing = CRYPTO_PROVIDER_MEMBERS.filter( + (member) => typeof (value as Record | null)?.[member] !== 'function', + ); + if (missing.length > 0) { + throw new HostCryptoProviderRefusal( + `Refusing to boot: the service '${CRYPTO_PROVIDER_SERVICE}' a plugin registered is not a crypto provider — ` + + `it has no ${missing.map((member) => `${member}()`).join(', ')}. Register an ICryptoProvider there.`, + ); + } + return value as ICryptoProvider; + }; + + const resolve = (): ICryptoProvider => { + if (chosen) return chosen.provider; + if (!registry?.initDone()) { + throw new HostCryptoProviderRefusal( + 'Refusing to seal or open a secret while plugins are still initializing: a plugin may still register ' + + `the crypto provider ('${CRYPTO_PROVIDER_SERVICE}'), and a secret another provider sealed now would ` + + 'not open under it. Seal and open secrets from start() or later, never from init().', + ); + } + const registered = registry.read(); + chosen = registered === undefined + ? { provider: local(), source: 'local', registered } + : { provider: asCryptoProvider(registered), source: 'registered', registered }; + return chosen.provider; + }; + + const confirm = (): ICryptoProvider => { + const provider = resolve(); + if (registry!.read() !== chosen!.registered) { + throw new HostCryptoProviderRefusal( + chosen!.source === 'local' + ? `Refusing to boot: a plugin registered the crypto provider ('${CRYPTO_PROVIDER_SERVICE}') after every ` + + 'plugin had initialized, when this boot had already chosen LocalCryptoProvider for its secrets. ' + + "Register it with ctx.registerService in the plugin's init()." + : `Refusing to boot: the crypto provider registered as '${CRYPTO_PROVIDER_SERVICE}' was replaced after ` + + 'this boot chose it. Every secret is sealed by one provider, so it is registered once, in init(), ' + + 'and never replaced.', + ); + } + return provider; + }; + + const provider: HostCryptoProvider = { + encrypt: async (plain, ctx) => resolve().encrypt(plain, ctx), + decrypt: async (handle, ctx) => resolve().decrypt(handle, ctx), + rotateKey: async (handle, ctx) => resolve().rotateKey(handle, ctx), + digest: (plain) => resolve().digest(plain), + keyedDigest: async (plain) => resolve().keyedDigest(plain), + resolve, + confirm, + get source() { + return chosen?.source; + }, + }; + + const plugin = { + name: 'com.objectstack.cli.host-crypto-provider', + version: '1.0.0', + init: async (ctx: HostCryptoProviderContext): Promise => { + let ready = false; + registry = { + read: () => ctx.getServices().get(CRYPTO_PROVIDER_SERVICE), + // Every init() has run once the kernel leaves `initializing` (Phase 2 + // begins) — read where the question is asked, never recorded here. + // Assumes `ctx.getKernel` (ObjectKernel's context has it); without it + // nothing is chosen before this plugin's own `kernel:ready` hook. + initDone: () => { + const state = ctx.getKernel?.()?.getState?.(); + return ready || (state !== undefined && state !== 'idle' && state !== 'initializing'); + }, + }; + ctx.hook('kernel:ready', async () => { + ready = true; + resolve(); + }); + }, + }; + + return { provider, plugin }; +} + +/** + * Refuse a composition in which more than one plugin declares, in + * `providesServices` (ADR-0116), that it registers {@link CRYPTO_PROVIDER_SERVICE}. + * + * The provider seals every secret field, datasource credential and encrypted + * setting, so letting either one win would leave secrets the other sealed + * unreadable. The kernel already refuses a second `registerService` under one + * name, but cannot say who registered first; a declaration can, so this names + * every declaring plugin before any of them initializes. A plugin registered + * twice under one `name` boots once (the later one), and is counted once. + */ +export function refuseTwoCryptoProviderPlugins(composed: Iterable): void { + const byName = new Map(); + for (const candidate of composed) { + const name = (candidate as { name?: unknown } | null)?.name; + if (typeof name === 'string') byName.set(name, candidate); + } + const declaring = [...byName] + .filter(([, plugin]) => { + const provides = (plugin as { providesServices?: unknown }).providesServices; + return Array.isArray(provides) && provides.includes(CRYPTO_PROVIDER_SERVICE); + }) + .map(([name]) => name); + if (declaring.length > 1) { + throw new HostCryptoProviderRefusal( + `Refusing to boot: ${declaring.length} plugins provide the crypto provider ('${CRYPTO_PROVIDER_SERVICE}'): ` + + `${quotedNameList(declaring)}. It seals every secret field, datasource credential and encrypted setting, ` + + "so exactly one plugin may provide it — keep one of them in the stack's plugins.", + ); + } +} + /** * What {@link createUnknownHostnameGuardPlugin} is constructed with. */ diff --git a/packages/types/src/crypto-provider-service.ts b/packages/types/src/crypto-provider-service.ts new file mode 100644 index 00000000000..e034bb70da3 --- /dev/null +++ b/packages/types/src/crypto-provider-service.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * # The host's secret crypto provider, supplied by a plugin + * + * Every `sys_secret` row a host writes is sealed by ONE `ICryptoProvider` + * (`@objectstack/spec/contracts`): an object's `secret` fields (the data + * engine), a datasource's credentials (the datasource secret binder) and the + * encrypted settings (`SettingsServicePlugin`). One provider means one key + * custody, so every secret opens under the provider that sealed it. + * + * `os serve` builds that provider itself — `LocalCryptoProvider`, keyed off + * `OS_SECRET_KEY` or a persisted key file — unless a plugin supplies one. A + * plugin supplies one (a KMS- or Vault-backed provider, for customer-managed + * keys) by registering it as the kernel service + * {@link CRYPTO_PROVIDER_SERVICE}: + * + * - register it with `ctx.registerService` in the plugin's `init()`, never + * later: the host chooses once every `init()` has run, and a provider + * registered after that refuses the boot rather than being used for some + * secrets and not others; + * - declare it in the plugin's `providesServices`, so a composition that holds + * two such plugins refuses at boot naming both (ADR-0116's declaration + * vocabulary). An undeclared second registration is refused by the kernel + * itself, without the names; + * - register exactly one: the provider holds every secret's key, so two + * registrations refuse the boot rather than letting either one win. + * + * No registration leaves the host on `LocalCryptoProvider`, exactly as before + * the seam existed, its production refusal for an unstable key included. + * + * ⛔ A provider's key material never appears in a log line, an error or a + * refusal: the host's refusals name the service and the plugins, never the + * registered value. + */ + +/** The kernel service name a plugin registers the host's secret crypto provider under. */ +export const CRYPTO_PROVIDER_SERVICE = 'crypto-provider'; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 0dc3ffdf547..f341bf75ee4 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -107,6 +107,10 @@ export * from './unique-scope-install-gate.js'; // this package depends only on `@objectstack/spec`, so a driver (in this // repository or out of it) adopting the seam takes no heavier edge. export * from './datasource-secret-resolver.js'; +// The service a plugin registers the host's ONE secret crypto provider under +// (a KMS/Vault-backed `ICryptoProvider`): `os serve` reads it once every plugin +// has initialized and falls back to `LocalCryptoProvider` when none is there. +export * from './crypto-provider-service.js'; // [#20919] The REST door's error CLASSIFICATION half (`mapDataError` through // `classifyDataError`), moved here from `@objectstack/rest` beside the // primitives it composes, so the bulk-import runner in `@objectstack/core`