From 1c289f82d48b295335ac08eb7c4fc4744dd66c2f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 02:37:59 +0000 Subject: [PATCH 1/2] fix(crypto): fail loud instead of silently minting an ephemeral key (#1507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default ICryptoProvider backs every secret-at-rest in the platform (encrypted settings, ObjectQL secret fields, datasource credentials). Its key resolution silently fell back to a per-process random key (or auto-minted a new on-disk key each boot) when no stable key was available, making every sys_secret value undecryptable after a restart in containers or on a second node — with no error at encrypt or boot time. - Rename InMemoryCryptoProvider -> LocalCryptoProvider (old name wrongly implied an ephemeral key); keep InMemoryCryptoProvider as a deprecated alias. - Add OS_SECRET_KEY as the canonical production master key (32-byte hex/base64), the documented production default. - Fail loud in production: when NODE_ENV=production and no stable key source (env var or pre-existing persisted file) exists, throw an actionable error at construction instead of generating a key. Never auto-mint a key in production. Dev/test keep the ergonomic fallback. - serve surfaces the production-key error verbatim and refuses to wire an unstable provider for secret fields. - Document the env-key default and reframe the provider in the README; update spec contract and downstream comments. - Add LocalCryptoProvider test coverage for every key-resolution tier. https://claude.ai/code/session_01Qzri77Foepw1hnw52Vm3pP --- .../local-crypto-provider-fail-loud-1507.md | 38 ++ packages/cli/src/commands/serve.ts | 39 +- packages/objectql/src/engine.ts | 4 +- .../src/datasource-secret-binder.ts | 2 +- packages/services/service-settings/README.md | 42 +- .../src/in-memory-crypto-provider.ts | 294 +----------- .../services/service-settings/src/index.ts | 17 +- .../src/local-crypto-provider.test.ts | 147 ++++++ .../src/local-crypto-provider.ts | 434 ++++++++++++++++++ .../src/settings-service-plugin.ts | 12 +- .../spec/src/contracts/crypto-provider.ts | 13 +- 11 files changed, 727 insertions(+), 315 deletions(-) create mode 100644 .changeset/local-crypto-provider-fail-loud-1507.md create mode 100644 packages/services/service-settings/src/local-crypto-provider.test.ts create mode 100644 packages/services/service-settings/src/local-crypto-provider.ts diff --git a/.changeset/local-crypto-provider-fail-loud-1507.md b/.changeset/local-crypto-provider-fail-loud-1507.md new file mode 100644 index 000000000..5f2975fc1 --- /dev/null +++ b/.changeset/local-crypto-provider-fail-loud-1507.md @@ -0,0 +1,38 @@ +--- +"@objectstack/service-settings": minor +"@objectstack/cli": patch +"@objectstack/spec": patch +--- + +Fail loud instead of silently minting an ephemeral encryption key; ship a persistent env-master-key provider as the default (#1507). + +The default `ICryptoProvider` backs every secret-at-rest in the platform — +encrypted settings (`sys_setting.value_enc`), ObjectQL `secret` fields, and +runtime datasource credentials. Its key resolution previously fell back, +**silently**, to a fresh per-process `randomBytes(32)` key (or auto-minted a +new on-disk key on every boot) when no stable key was available. In an +ephemeral-FS container or a multi-node cluster, each restart / each node then +encrypts under a different key, and every previously-written `sys_secret` value +becomes undecryptable. The failure was invisible at encrypt and boot time and +only surfaced later as "all my saved passwords / API keys / DB credentials +fail to decrypt". + +- **Renamed `InMemoryCryptoProvider` → `LocalCryptoProvider`.** The old name + implied an ephemeral key when the provider in fact persists one. + `InMemoryCryptoProvider` stays as a deprecated alias for backward + compatibility. +- **Added `OS_SECRET_KEY`** as the canonical production master key (32-byte + hex or base64), the documented production default. `OS_DEV_CRYPTO_KEY` + remains the dev convenience key. +- **Fail-loud in production.** When `NODE_ENV=production` and no stable key + source (env var or a pre-existing persisted file) is available, the provider + now throws an actionable error at construction instead of generating a key — + turning silent data-loss into a config error at boot. It never auto-mints a + key in production. Development and test keep the ergonomic fallback + (persisted dev key / ephemeral test key). +- `serve` surfaces the production-key error verbatim and refuses to wire an + unstable provider for `secret` fields. + +KMS / Vault providers (managed custody, per-tenant keys, automatic rotation) +remain future/enterprise plug-ins behind the same `ICryptoProvider` seam; +"your stored secret is still there after a reboot" stays open-source. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index fef6ac747..7697a9a0c 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1682,37 +1682,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 an independent - // InMemoryCryptoProvider; 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. 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). try { const dataEngine: any = kernel.getService?.('data') ?? kernel.getService?.('objectql'); if (dataEngine && typeof dataEngine.setCryptoProvider === 'function') { - const { InMemoryCryptoProvider } = await import( + const { LocalCryptoProvider } = await import( /* webpackIgnore: true */ '@objectstack/service-settings' ); - dataEngine.setCryptoProvider(new InMemoryCryptoProvider()); + // In production LocalCryptoProvider throws when no stable key + // (OS_SECRET_KEY / persisted file) is available — that is 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. + dataEngine.setCryptoProvider(new LocalCryptoProvider()); if (isDev) { console.log( chalk.dim( - ' ↪ secret fields: InMemoryCryptoProvider wired (dev) — swap for KMS/Vault in production', + ' ↪ secret fields: LocalCryptoProvider wired (dev) — set OS_SECRET_KEY and swap for KMS/Vault in production', ), ); } } } catch (err: any) { - // Non-fatal: without a provider, secret writes fail closed by - // design. Surface a hint so operators know why a `secret` field + const msg = String(err?.message ?? err); + if (msg.includes('Refusing to start in production')) { + // Fail-loud config error: print the actionable guidance verbatim. + console.error(chalk.red(msg)); + throw err; + } + // Otherwise non-fatal: without a provider, secret writes fail closed + // by design. Surface a hint so operators know why a `secret` field // write might reject. console.warn( chalk.yellow( - ` ⚠ secret fields: no CryptoProvider wired (${err?.message ?? err}); writing a secret field will fail closed`, + ` ⚠ secret fields: no CryptoProvider wired (${msg}); writing a secret field will fail closed`, ), ); } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index eb4a6f56e..0293c1a1c 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -1076,7 +1076,7 @@ export class ObjectQL implements IDataEngine { * **fail-closed** — the write throws rather than persist cleartext. * * Mirrors the Settings subsystem's ICryptoProvider wiring; the host (e.g. - * `serve`) injects `InMemoryCryptoProvider` in dev and a KMS/Vault-backed + * `serve`) injects `LocalCryptoProvider` in dev and a KMS/Vault-backed * provider in production. */ setCryptoProvider(provider: ICryptoProvider): void { @@ -1126,7 +1126,7 @@ export class ObjectQL implements IDataEngine { if (!this.cryptoProvider) { throw new Error( `Cannot persist secret field "${object}.${field}": no CryptoProvider is registered. ` - + 'Wire one via engine.setCryptoProvider(...) (e.g. InMemoryCryptoProvider in dev, ' + + 'Wire one via engine.setCryptoProvider(...) (e.g. LocalCryptoProvider in dev, ' + 'a KMS/Vault provider in production). Refusing to store cleartext (fail-closed).', ); } diff --git a/packages/services/service-datasource-admin/src/datasource-secret-binder.ts b/packages/services/service-datasource-admin/src/datasource-secret-binder.ts index 7b4f87581..695e4efc8 100644 --- a/packages/services/service-datasource-admin/src/datasource-secret-binder.ts +++ b/packages/services/service-datasource-admin/src/datasource-secret-binder.ts @@ -12,7 +12,7 @@ * touches metadata. * * This is the dev/self-host wiring; production hosts swap the - * `InMemoryCryptoProvider` for a KMS-backed `ICryptoProvider` and pass it here. + * `LocalCryptoProvider` for a KMS-backed `ICryptoProvider` and pass it here. */ import type { CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; diff --git a/packages/services/service-settings/README.md b/packages/services/service-settings/README.md index 294bbe8de..0f0bea63b 100644 --- a/packages/services/service-settings/README.md +++ b/packages/services/service-settings/README.md @@ -32,10 +32,44 @@ true` and writes (service or REST) fail with HTTP 409. ## Encryption -`Specifier.encrypted: true` (implicit for `password`) round-trips the -value through a pluggable `CryptoAdapter`. The default -`NoopCryptoAdapter` is a base64 wrapper — production deployments must -provide a real KMS adapter via plugin options. +`Specifier.encrypted: true` (implicit for `password`) routes the value +through a pluggable `ICryptoProvider` into `sys_secret`; only an opaque +handle id lands in `sys_setting.value_enc`. The same provider backs every +secret-at-rest in the platform: encrypted settings, ObjectQL `secret` +fields, and runtime datasource credentials. + +### Default provider: `LocalCryptoProvider` + +The default is `LocalCryptoProvider` — AES-256-GCM keyed off a single +32-byte data key. It resolves its key in order: + +1. **`OS_SECRET_KEY`** — the canonical production master key (32-byte hex + or base64). Set this in any container / multi-node deployment. + Generate one with `openssl rand -hex 32`. It **must be identical** + across every restart and every node, or previously-encrypted secrets + become undecryptable. +2. `OS_DEV_CRYPTO_KEY` — dev convenience key (same format). +3. A persisted file at `~/.objectstack/dev-crypto-key` (mode 0600). In + development this is auto-created so single-host dev loops survive + restarts; in production it is only *read*, never minted. + +**Fail-loud in production.** When `NODE_ENV=production` and no stable key +source (env var or pre-existing file) is available, the provider refuses +to start with an actionable error instead of silently generating an +ephemeral key. This turns the old silent-data-loss footgun — every +`sys_secret` value becoming undecryptable after a container restart or on +a second node — into a config error at boot. + +Secrets surviving a restart is **correctness, not a premium feature**, so +`LocalCryptoProvider` and the env-key path are open-source. KMS / Vault +providers (managed custody, per-tenant keys, automatic rotation) plug in +behind the same `ICryptoProvider` seam via `cryptoProvider` plugin option. + +> `InMemoryCryptoProvider` is a deprecated alias for `LocalCryptoProvider` +> (the old name wrongly implied an ephemeral key). + +The legacy `CryptoAdapter` / `NoopCryptoAdapter` (a base64 wrapper) remains +only as a pre-Phase-3 backward-compat path when no `cryptoProvider` is wired. ## Audit diff --git a/packages/services/service-settings/src/in-memory-crypto-provider.ts b/packages/services/service-settings/src/in-memory-crypto-provider.ts index 1547f8ed0..b88d55005 100644 --- a/packages/services/service-settings/src/in-memory-crypto-provider.ts +++ b/packages/services/service-settings/src/in-memory-crypto-provider.ts @@ -1,285 +1,19 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { - CryptoContext, - CryptoHandle, - ICryptoProvider, -} from '@objectstack/spec/contracts'; -import { readEnvWithDeprecation } from '@objectstack/types'; -import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; - /** - * InMemoryCryptoProvider — default ICryptoProvider used by the - * SettingsService when the host application does not wire a real KMS. - * - * Encryption: AES-256-GCM with a per-process random data key. The data - * key lives only in memory; restarting the process loses the ability - * to decrypt previously-written rows. This is intentional — operators - * MUST replace this with a KMS-backed provider before relying on - * `sys_secret` for production secrets. The provider's purpose is to: - * - * - exercise the round-trip in unit tests and dev kernels; - * - provide a "real-looking" handle format so consumers don't depend - * on accidental implementation details of a no-op adapter; - * - serve as a reference for what AwsKmsCryptoProvider / - * GcpKmsCryptoProvider implementations need to satisfy. - * - * Handle format: - * id — `sec_` + 32 hex chars (122 bits of entropy) - * kmsKeyId — `local:in-memory:v` - * alg — `aes-256-gcm` - * version — bumps on rotateKey() - * ciphertext— base64(iv (12) || authTag (16) || cipher) + * Backward-compatibility shim. The provider was renamed to + * `LocalCryptoProvider` (see ./local-crypto-provider.ts) because the old + * `InMemoryCryptoProvider` name implied an ephemeral key when it actually + * persists one (env var or on-disk file). This module re-exports the new + * implementation so existing deep imports keep working. * - * AAD binding: the CryptoContext (namespace + key + tenantId) is - * folded into AES-GCM AAD so a ciphertext rewrapped from a different - * (ns, key) tuple fails decryption — guards against operators - * accidentally copying rows between namespaces. - * - * WebContainer (StackBlitz) note: `node:crypto.createCipheriv('aes-256-gcm', …)` - * is not implemented in WebContainer. When we detect that runtime, we - * swap to a pure-JS AES-GCM from `@noble/ciphers/aes.js`, producing the - * same `iv || tag || ciphertext` byte layout so the handle shape is - * unchanged. The swap is best-effort: if the dependency is missing, - * we fall back to the Node implementation and let it throw, surfacing - * the configuration problem clearly. - * - * Dev key persistence: in long-running dev sessions, a per-process - * random key means previously-encrypted rows (e.g. an AI provider API - * key the operator typed yesterday) become undecryptable on the next - * `pnpm dev` — Node throws "Unsupported state or unable to authenticate - * data". To make the dev loop ergonomic without changing the production - * contract (still KMS-only), the provider honours `OS_DEV_CRYPTO_KEY` - * (legacy `OBJECTSTACK_DEV_CRYPTO_KEY` still honoured with a deprecation - * warning) (base64 or hex, 32 bytes after decode) as a stable data key. - * When the env var is unset we generate an ephemeral key AND log the - * base64 once so operators can paste it back into their `.env` to - * survive restarts. - */ -const DEV_KEY_ENV = 'OS_DEV_CRYPTO_KEY'; -const DEV_KEY_LEGACY_ENV = 'OBJECTSTACK_DEV_CRYPTO_KEY'; - -/** - * Per-user persistent fallback location. When `OS_DEV_CRYPTO_KEY` - * is unset, we lazily create + cache a key here so dev sessions survive - * process restarts without operator action. Honours `OS_HOME` - * (legacy `OBJECTSTACK_HOME` still honoured with a deprecation warning) - * for projects that pin a non-default config dir. - */ -const devKeyFallbackPath = (): string => { - const proc = (globalThis as any)?.process; - const home = - readEnvWithDeprecation('OS_HOME', 'OBJECTSTACK_HOME') || - (proc?.env?.HOME ? join(proc.env.HOME, '.objectstack') : undefined) || - join(homedir(), '.objectstack'); - return join(home, 'dev-crypto-key'); -}; - -/** - * Load (or generate-then-persist) the dev key from the per-user fallback - * file. Returns `undefined` on any I/O error so the caller can degrade - * to an ephemeral key without breaking boot. - */ -const loadOrCreateDevKey = (): { key: Buffer; path: string; generated: boolean } | undefined => { - try { - const path = devKeyFallbackPath(); - if (existsSync(path)) { - const raw = readFileSync(path, 'utf8').trim(); - const parsed = parseDevKey(raw); - if (parsed) return { key: parsed, path, generated: false }; - } - const key = randomBytes(32); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, key.toString('base64'), { mode: 0o600 }); - return { key, path, generated: true }; - } catch { - return undefined; - } -}; - -/** - * Parse an `OS_DEV_CRYPTO_KEY` value (hex or base64) into a - * 32-byte Buffer. Returns `undefined` (with a console warning) when the - * value is present but unusable — the caller falls back to an ephemeral - * key so the process still boots. + * @deprecated Import from './local-crypto-provider.js' instead. */ -const parseDevKey = (raw: string | undefined): Buffer | undefined => { - if (!raw) return undefined; - const trimmed = raw.trim(); - if (!trimmed) return undefined; - // hex: 64 chars of [0-9a-f] - if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return Buffer.from(trimmed, 'hex'); - // base64 (standard or url-safe): decode and check length - try { - const normalised = trimmed.replace(/-/g, '+').replace(/_/g, '/'); - const buf = Buffer.from(normalised, 'base64'); - if (buf.length === 32) return buf; - } catch { - /* fall through */ - } - console.warn( - `[InMemoryCryptoProvider] ${DEV_KEY_ENV} is set but is not 32 bytes (hex or base64). Ignoring and generating an ephemeral key.`, - ); - return undefined; -}; -const isWebContainerRuntime = (): boolean => { - const g = globalThis as any; - return ( - typeof g !== 'undefined' && - (Boolean(g.process?.versions?.webcontainer) || - Boolean(g.process?.env?.SHELL?.includes?.('jsh')) || - Boolean(g.process?.env?.STACKBLITZ)) - ); -}; - -type GcmFactory = (key: Uint8Array, nonce: Uint8Array, aad?: Uint8Array) => { - encrypt: (plain: Uint8Array) => Uint8Array; - decrypt: (cipher: Uint8Array) => Uint8Array; -}; - -let nobleGcmPromise: Promise | undefined; -const loadNobleGcm = (): Promise => { - if (!nobleGcmPromise) { - nobleGcmPromise = (async () => { - try { - const mod = await import('@noble/ciphers/aes.js'); - return mod.gcm as unknown as GcmFactory; - } catch (err: any) { - console.warn( - `[InMemoryCryptoProvider] WebContainer detected but @noble/ciphers not installed: ${err?.message ?? err}. Falling back to node:crypto (will throw).`, - ); - return undefined; - } - })(); - } - return nobleGcmPromise; -}; - -export class InMemoryCryptoProvider implements ICryptoProvider { - private readonly key: Buffer; - private readonly useNoble: boolean; - - constructor(opts: { key?: Buffer } = {}) { - if (opts.key) { - this.key = opts.key; - } else { - const fromEnv = parseDevKey( - readEnvWithDeprecation(DEV_KEY_ENV, DEV_KEY_LEGACY_ENV), - ); - if (fromEnv) { - this.key = fromEnv; - } else { - const isTest = Boolean( - (globalThis as any)?.process?.env?.VITEST || - (globalThis as any)?.process?.env?.NODE_ENV === 'test', - ); - const persisted = isTest ? undefined : loadOrCreateDevKey(); - if (persisted) { - this.key = persisted.key; - if (persisted.generated) { - console.warn( - `[InMemoryCryptoProvider] No ${DEV_KEY_ENV} set — generated a new AES-256-GCM key and persisted it to ${persisted.path} (mode 0600). Future restarts will reuse it automatically. For shared/CI environments, set ${DEV_KEY_ENV} explicitly in your environment.`, - ); - } - } else { - this.key = randomBytes(32); - // Last-resort ephemeral key. Surface the base64 once so dev - // operators can pin it across restarts via the env var. - if (!isTest) { - console.warn( - `[InMemoryCryptoProvider] No ${DEV_KEY_ENV} set and could not persist a fallback key — generated an ephemeral AES-256-GCM key. Existing encrypted settings (e.g. AI API keys) will fail to decrypt on next restart. To make the key survive restarts, add this to your .env:\n ${DEV_KEY_ENV}=${this.key.toString('base64')}`, - ); - } - } - } - } - this.useNoble = isWebContainerRuntime(); - } - - async encrypt(plain: string, ctx: CryptoContext): Promise { - const iv = randomBytes(12); - const aad = Buffer.from(this.aadOf(ctx), 'utf8'); - const plainBytes = Buffer.from(plain, 'utf8'); - - let blob: string; - if (this.useNoble) { - const gcm = await loadNobleGcm(); - if (gcm) { - const cipher = gcm(this.key, iv, aad); - const ctWithTag = cipher.encrypt(plainBytes); // ciphertext || tag(16) - const ct = ctWithTag.subarray(0, ctWithTag.length - 16); - const tag = ctWithTag.subarray(ctWithTag.length - 16); - blob = Buffer.concat([iv, Buffer.from(tag), Buffer.from(ct)]).toString('base64'); - } else { - blob = this.encryptNode(plainBytes, iv, aad); - } - } else { - blob = this.encryptNode(plainBytes, iv, aad); - } - - return { - id: 'sec_' + randomBytes(16).toString('hex'), - kmsKeyId: 'local:in-memory:v1', - alg: 'aes-256-gcm', - version: 1, - ciphertext: blob, - }; - } - - async decrypt(handle: CryptoHandle, ctx: CryptoContext): Promise { - const buf = Buffer.from(handle.ciphertext, 'base64'); - const iv = buf.subarray(0, 12); - const tag = buf.subarray(12, 28); - const data = buf.subarray(28); - const aad = Buffer.from(this.aadOf(ctx), 'utf8'); - - if (this.useNoble) { - const gcm = await loadNobleGcm(); - if (gcm) { - const cipher = gcm(this.key, iv, aad); - const ctWithTag = Buffer.concat([data, tag]); // noble expects ciphertext || tag - const out = cipher.decrypt(ctWithTag); - return Buffer.from(out).toString('utf8'); - } - } - const decipher = createDecipheriv('aes-256-gcm', this.key, iv); - decipher.setAAD(aad); - decipher.setAuthTag(tag); - return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8'); - } - - async rotateKey(handle: CryptoHandle, ctx: CryptoContext): Promise { - const plain = await this.decrypt(handle, ctx); - const next = await this.encrypt(plain, ctx); - return { - ...next, - id: handle.id, - kmsKeyId: `local:in-memory:v${handle.version + 1}`, - version: handle.version + 1, - }; - } - - digest(plain: string): string { - return 'sha256:' + createHash('sha256').update(plain, 'utf8').digest('hex'); - } - - private encryptNode(plainBytes: Buffer, iv: Buffer, aad: Buffer): string { - const cipher = createCipheriv('aes-256-gcm', this.key, iv); - cipher.setAAD(aad); - const enc = Buffer.concat([cipher.update(plainBytes), cipher.final()]); - const tag = cipher.getAuthTag(); - return Buffer.concat([iv, tag, enc]).toString('base64'); - } - - private aadOf(ctx: CryptoContext): string { - // Bind ciphertext to (namespace,key) so a row cannot be moved across - // specifiers. Tenant binding is intentionally omitted because the - // handle is dereferenced from a `sys_setting` row already scoped to - // its tenant — adding tenant here would force the decrypt path to - // re-read that scope. - return [ctx.namespace, ctx.key].join('|'); - } -} +export { + LocalCryptoProvider, + InMemoryCryptoProvider, + type LocalCryptoProviderOptions, + type InMemoryCryptoProviderOptions, + type CryptoMode, + type KeySource, +} from './local-crypto-provider.js'; diff --git a/packages/services/service-settings/src/index.ts b/packages/services/service-settings/src/index.ts index 560afc24b..bdf0a3975 100644 --- a/packages/services/service-settings/src/index.ts +++ b/packages/services/service-settings/src/index.ts @@ -10,10 +10,19 @@ export { type CryptoAdapter, NoopCryptoAdapter, } from './crypto-adapter.js'; -// Default ICryptoProvider for dev / self-host kernels (no KMS). Hosts swap in -// a KMS-backed provider for production; exported so other subsystems (e.g. the -// runtime-UI datasource secret binder) can reuse the same dev wrapping. -export { InMemoryCryptoProvider } from './in-memory-crypto-provider.js'; +// Default, KMS-free ICryptoProvider. AES-256-GCM keyed off `OS_SECRET_KEY` +// (production) or a persisted dev key; fails loud in production rather than +// silently minting an ephemeral key. Hosts swap in a KMS/Vault provider for +// managed custody. Exported so other subsystems (e.g. the runtime-UI +// datasource secret binder) can reuse the same wrapping. `InMemoryCryptoProvider` +// remains a deprecated alias for backward compatibility. +export { + LocalCryptoProvider, + InMemoryCryptoProvider, + type LocalCryptoProviderOptions, + type CryptoMode, + type KeySource, +} from './local-crypto-provider.js'; export { type SettingsActionHandler, type SettingsAuditSink, diff --git a/packages/services/service-settings/src/local-crypto-provider.test.ts b/packages/services/service-settings/src/local-crypto-provider.test.ts new file mode 100644 index 000000000..ed0274539 --- /dev/null +++ b/packages/services/service-settings/src/local-crypto-provider.test.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { + LocalCryptoProvider, + InMemoryCryptoProvider, +} from './local-crypto-provider'; + +const ctx = { namespace: 'mail', key: 'api_key' }; + +describe('LocalCryptoProvider — key resolution', () => { + let home: string; + + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), 'os-crypto-')); + }); + afterEach(() => { + rmSync(home, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('round-trips with an explicit key (source=explicit)', async () => { + const p = new LocalCryptoProvider({ key: randomBytes(32) }); + expect(p.keySource).toBe('explicit'); + const h = await p.encrypt('hello', ctx); + expect(h.ciphertext).not.toContain('hello'); + expect(await p.decrypt(h, ctx)).toBe('hello'); + }); + + it('resolves OS_SECRET_KEY (hex) and survives a fresh instance', async () => { + const hex = randomBytes(32).toString('hex'); + const env = { NODE_ENV: 'production', OS_SECRET_KEY: hex, OS_HOME: home }; + const a = new LocalCryptoProvider({ env }); + expect(a.keySource).toBe('env:OS_SECRET_KEY'); + const h = await a.encrypt('secret-value', ctx); + // A brand-new instance with the same env key must decrypt prior ciphertext. + const b = new LocalCryptoProvider({ env }); + expect(await b.decrypt(h, ctx)).toBe('secret-value'); + }); + + it('resolves OS_SECRET_KEY (base64)', async () => { + const b64 = randomBytes(32).toString('base64'); + const p = new LocalCryptoProvider({ env: { OS_SECRET_KEY: b64, NODE_ENV: 'production', OS_HOME: home } }); + expect(p.keySource).toBe('env:OS_SECRET_KEY'); + }); + + it('throws on an invalid OS_SECRET_KEY (not 32 bytes)', () => { + expect( + () => new LocalCryptoProvider({ env: { OS_SECRET_KEY: 'too-short', NODE_ENV: 'production', OS_HOME: home } }), + ).toThrow(/not a 32-byte key/); + }); + + it('fails loud in production when no key source is available', () => { + expect( + () => new LocalCryptoProvider({ env: { NODE_ENV: 'production', OS_HOME: home } }), + ).toThrow(/Refusing to start in production/); + }); + + it('uses a pre-existing persisted file in production (but never mints one)', async () => { + const keyPath = join(home, '.objectstack', 'dev-crypto-key'); + // Simulate an operator-provisioned key file on a mounted volume. + const { mkdirSync } = await import('node:fs'); + mkdirSync(join(home, '.objectstack'), { recursive: true }); + writeFileSync(keyPath, randomBytes(32).toString('base64'), { mode: 0o600 }); + + const env = { NODE_ENV: 'production', HOME: home }; + const a = new LocalCryptoProvider({ env }); + expect(a.keySource).toBe('file'); + const h = await a.encrypt('v', ctx); + expect(await new LocalCryptoProvider({ env }).decrypt(h, ctx)).toBe('v'); + }); + + it('does NOT create a key file in production', () => { + const keyPath = join(home, '.objectstack', 'dev-crypto-key'); + expect(() => new LocalCryptoProvider({ env: { NODE_ENV: 'production', HOME: home } })).toThrow(); + expect(existsSync(keyPath)).toBe(false); + }); + + it('auto-creates + persists a key in development', async () => { + const env = { NODE_ENV: 'development', HOME: home }; + const a = new LocalCryptoProvider({ env }); + expect(a.keySource).toBe('generated-file'); + const h = await a.encrypt('dev-secret', ctx); + // Second instance reads the persisted file (source=file) and decrypts. + const b = new LocalCryptoProvider({ env }); + expect(b.keySource).toBe('file'); + expect(await b.decrypt(h, ctx)).toBe('dev-secret'); + }); + + it('uses an ephemeral key in test mode without touching disk', () => { + const keyPath = join(home, '.objectstack', 'dev-crypto-key'); + const p = new LocalCryptoProvider({ env: { NODE_ENV: 'test', HOME: home } }); + expect(p.keySource).toBe('ephemeral'); + expect(existsSync(keyPath)).toBe(false); + }); + + it('honours the legacy OBJECTSTACK_DEV_CRYPTO_KEY alias', () => { + const hex = randomBytes(32).toString('hex'); + const p = new LocalCryptoProvider({ + env: { OBJECTSTACK_DEV_CRYPTO_KEY: hex, NODE_ENV: 'development', HOME: home }, + }); + expect(p.keySource).toBe('env:OS_DEV_CRYPTO_KEY'); + }); +}); + +describe('LocalCryptoProvider — crypto semantics', () => { + it('AAD binding rejects ciphertexts swapped across (namespace,key)', async () => { + const p = new LocalCryptoProvider({ key: randomBytes(32) }); + const handle = await p.encrypt('value', { namespace: 'mail', key: 'api_key' }); + await expect( + p.decrypt(handle, { namespace: 'mail', key: 'smtp_password' }), + ).rejects.toThrow(); + }); + + it('rotateKey bumps version while preserving plaintext + handle id', async () => { + const p = new LocalCryptoProvider({ key: randomBytes(32) }); + const h1 = await p.encrypt('hello', ctx); + const h2 = await p.rotateKey(h1, ctx); + expect(h2.id).toBe(h1.id); + expect(h2.version).toBe(h1.version + 1); + expect(h2.ciphertext).not.toBe(h1.ciphertext); + expect(await p.decrypt(h2, ctx)).toBe('hello'); + }); + + it('digest is a non-reversible sha256 tag', () => { + const p = new LocalCryptoProvider({ key: randomBytes(32) }); + const d = p.digest('super-secret'); + expect(d).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(d).not.toContain('super-secret'); + }); +}); + +describe('InMemoryCryptoProvider backward-compat alias', () => { + it('is the same class as LocalCryptoProvider', () => { + expect(InMemoryCryptoProvider).toBe(LocalCryptoProvider); + }); + + it('still constructs and round-trips', async () => { + const p = new InMemoryCryptoProvider({ key: randomBytes(32) }); + const h = await p.encrypt('x', ctx); + expect(await p.decrypt(h, ctx)).toBe('x'); + }); +}); diff --git a/packages/services/service-settings/src/local-crypto-provider.ts b/packages/services/service-settings/src/local-crypto-provider.ts new file mode 100644 index 000000000..ebabafee9 --- /dev/null +++ b/packages/services/service-settings/src/local-crypto-provider.ts @@ -0,0 +1,434 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { + CryptoContext, + CryptoHandle, + ICryptoProvider, +} from '@objectstack/spec/contracts'; +import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +/** + * LocalCryptoProvider — the default, KMS-free `ICryptoProvider`. It is an + * AES-256-GCM provider keyed off a single 32-byte data key, suitable for + * single-operator / self-host deployments where a managed KMS or Vault is + * overkill. KMS / Vault providers (per-tenant keys, automatic rotation, + * managed custody) plug in behind the same `ICryptoProvider` seam. + * + * Key resolution (first match wins): + * + * 1. `opts.key` — explicit Buffer (tests / embedders). + * 2. `OS_SECRET_KEY` — canonical production master key + * (32-byte hex or base64). + * 3. `OS_DEV_CRYPTO_KEY` — dev convenience key (legacy + * (legacy `OBJECTSTACK_DEV_CRYPTO_KEY`) `OBJECTSTACK_DEV_CRYPTO_KEY` + * still honoured). + * 4. Persisted file — `~/.objectstack/dev-crypto-key` + * (mode 0600). In development it is + * auto-created; in production it is + * only *read* (never minted). + * 5. Ephemeral random key — development/test only. + * + * ## Fail-loud guarantee (the reason this class exists) + * + * The original provider would *silently* fall back to a fresh per-process + * `randomBytes(32)` key whenever no env key and no readable file were + * available — or auto-mint a new on-disk key on every boot. In an + * ephemeral-FS container or a multi-node cluster that means each + * restart / each node encrypts under a different key, and **every** + * previously-written `sys_secret` value (encrypted settings, `secret` + * fields, datasource credentials) becomes undecryptable. The failure was + * invisible at encrypt and boot time and only surfaced later as + * "all my saved passwords/API keys/DB creds fail to decrypt". + * + * To turn that silent data-loss into a config error at boot, the provider + * REFUSES to mint a key in production: when `mode === 'production'` and no + * stable key source (env var or pre-existing key file) is available, the + * constructor throws an actionable error instead of generating one. + * Development and test keep the ergonomic fallback so local loops and unit + * tests stay frictionless. + * + * `mode` is auto-detected from `NODE_ENV` (`production` → strict; + * `test`/`VITEST` → ephemeral, no disk; otherwise `development`) and can be + * overridden via `opts.mode` for embedders that manage their own lifecycle. + * + * ## Handle format + * id — `sec_` + 32 hex chars (122 bits of entropy) + * kmsKeyId — `local:v` + * alg — `aes-256-gcm` + * version — bumps on rotateKey() + * ciphertext— base64(iv (12) || authTag (16) || cipher) + * + * ## AAD binding + * The CryptoContext (namespace + key) is folded into AES-GCM AAD so a + * ciphertext rewrapped from a different (ns, key) tuple fails decryption — + * guards against operators accidentally copying rows between namespaces. + * + * ## WebContainer (StackBlitz) note + * `node:crypto.createCipheriv('aes-256-gcm', …)` is not implemented in + * WebContainer. When we detect that runtime, we swap to a pure-JS AES-GCM + * from `@noble/ciphers/aes.js`, producing the same `iv || tag || ciphertext` + * byte layout so the handle shape is unchanged. The swap is best-effort: if + * the dependency is missing, we fall back to the Node implementation and let + * it throw, surfacing the configuration problem clearly. + */ +const SECRET_KEY_ENV = 'OS_SECRET_KEY'; +const DEV_KEY_ENV = 'OS_DEV_CRYPTO_KEY'; +const DEV_KEY_LEGACY_ENV = 'OBJECTSTACK_DEV_CRYPTO_KEY'; + +type EnvMap = Record; + +/** Where the provider resolved its data key from (for diagnostics). */ +export type KeySource = + | 'explicit' + | 'env:OS_SECRET_KEY' + | 'env:OS_DEV_CRYPTO_KEY' + | 'file' + | 'generated-file' + | 'ephemeral'; + +export type CryptoMode = 'production' | 'development' | 'test'; + +export interface LocalCryptoProviderOptions { + /** Explicit 32-byte data key. Overrides all env / file resolution. */ + key?: Buffer; + /** + * Env source. Defaults to `process.env`. Injectable so embedders and + * tests can drive key resolution deterministically. + */ + env?: EnvMap; + /** + * Deployment mode. Controls whether an ephemeral / auto-generated key is + * tolerated. Defaults to auto-detection from `NODE_ENV`: + * - `production` → a stable key (env var or pre-existing file) is + * REQUIRED; construction throws otherwise (fail loud). + * - `development` → persists an auto-generated key to disk so restarts + * reuse it; falls back to an ephemeral key (loud warning) if disk is + * unwritable. + * - `test` → never touches disk; uses an ephemeral key silently. + */ + mode?: CryptoMode; +} + +const processEnv = (): EnvMap => + ((globalThis as { process?: { env?: EnvMap } }).process?.env ?? {}) as EnvMap; + +const detectMode = (env: EnvMap): CryptoMode => { + if (env.VITEST || env.NODE_ENV === 'test') return 'test'; + if (env.NODE_ENV === 'production') return 'production'; + return 'development'; +}; + +/** + * Per-user persistent key location. Honours `OS_HOME` + * (legacy `OBJECTSTACK_HOME`) for projects that pin a non-default config dir. + */ +const keyFilePath = (env: EnvMap): string => { + const home = + env.OS_HOME || + env.OBJECTSTACK_HOME || + (env.HOME ? join(env.HOME, '.objectstack') : undefined) || + join(homedir(), '.objectstack'); + return join(home, 'dev-crypto-key'); +}; + +/** + * Parse an env key value (hex or base64) into a 32-byte Buffer. Returns + * `undefined` when the value is unusable so the caller can decide whether to + * fall through (dev) or throw (production / explicit master key). + */ +const parseKey = (raw: string | undefined): Buffer | undefined => { + if (!raw) return undefined; + const trimmed = raw.trim(); + if (!trimmed) return undefined; + // hex: 64 chars of [0-9a-f] + if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return Buffer.from(trimmed, 'hex'); + // base64 (standard or url-safe): decode and check length + try { + const normalised = trimmed.replace(/-/g, '+').replace(/_/g, '/'); + const buf = Buffer.from(normalised, 'base64'); + if (buf.length === 32) return buf; + } catch { + /* fall through */ + } + return undefined; +}; + +/** Read an existing key file (no creation). Returns `undefined` on miss / IO error. */ +const loadExistingKey = (path: string): Buffer | undefined => { + try { + if (!existsSync(path)) return undefined; + return parseKey(readFileSync(path, 'utf8').trim()); + } catch { + return undefined; + } +}; + +/** + * Load (or generate-then-persist) the key file. Returns `undefined` on any + * I/O error so the caller can degrade to an ephemeral key without breaking + * boot. Only used in development. + */ +const loadOrCreateKey = (path: string): { key: Buffer; generated: boolean } | undefined => { + try { + const existing = loadExistingKey(path); + if (existing) return { key: existing, generated: false }; + const key = randomBytes(32); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, key.toString('base64'), { mode: 0o600 }); + return { key, generated: true }; + } catch { + return undefined; + } +}; + +const INVALID_KEY_MSG = (name: string): string => + `[LocalCryptoProvider] ${name} is set but is not a 32-byte key (expected 64 hex chars or base64 of 32 bytes). ` + + `Generate one with \`openssl rand -hex 32\`.`; + +const MISSING_PROD_KEY_MSG = (path: string): string => + `[LocalCryptoProvider] Refusing to start in production without a stable encryption key.\n` + + ` No ${SECRET_KEY_ENV} (or ${DEV_KEY_ENV}) is set and no persisted key file was found at:\n` + + ` ${path}\n` + + ` Minting a key here would make every sys_secret value (encrypted settings, secret\n` + + ` fields, datasource credentials) undecryptable after the next restart or on another node.\n` + + ` Fix: generate a 32-byte key and set it in the environment (identical across every\n` + + ` restart and every node), e.g.\n` + + ` ${SECRET_KEY_ENV}=$(openssl rand -hex 32)`; + +interface ResolvedKey { + key: Buffer; + source: KeySource; +} + +const warn = (msg: string): void => { + try { + (globalThis as { console?: { warn?: (m: string) => void } }).console?.warn?.(msg); + } catch { + /* exotic runtime without console — ignore */ + } +}; + +const legacyDeprecationWarned = { value: false }; + +function resolveDataKey(opts: LocalCryptoProviderOptions): ResolvedKey { + if (opts.key) return { key: opts.key, source: 'explicit' }; + + const env = opts.env ?? processEnv(); + const mode = opts.mode ?? detectMode(env); + + // 1) Canonical production master key. + if (env[SECRET_KEY_ENV] !== undefined) { + const parsed = parseKey(env[SECRET_KEY_ENV]); + if (parsed) return { key: parsed, source: 'env:OS_SECRET_KEY' }; + // Present-but-invalid is an explicit operator error — never silently + // fall through to a different key (that would be silent data divergence). + throw new Error(INVALID_KEY_MSG(SECRET_KEY_ENV)); + } + + // 2) Dev convenience key (legacy alias honoured with a deprecation note). + let devRaw = env[DEV_KEY_ENV]; + if (devRaw === undefined && env[DEV_KEY_LEGACY_ENV] !== undefined) { + devRaw = env[DEV_KEY_LEGACY_ENV]; + if (!legacyDeprecationWarned.value) { + legacyDeprecationWarned.value = true; + warn( + `[ObjectStack] Env var \`${DEV_KEY_LEGACY_ENV}\` is deprecated; rename it to \`${DEV_KEY_ENV}\`.`, + ); + } + } + if (devRaw !== undefined) { + const parsed = parseKey(devRaw); + if (parsed) return { key: parsed, source: 'env:OS_DEV_CRYPTO_KEY' }; + if (mode === 'production') throw new Error(INVALID_KEY_MSG(DEV_KEY_ENV)); + warn(`${INVALID_KEY_MSG(DEV_KEY_ENV)} Ignoring and generating a local key.`); + } + + // 3) No usable env key — behaviour depends on mode. + if (mode === 'test') { + // Tests never touch disk; an ephemeral key round-trips within the process. + return { key: randomBytes(32), source: 'ephemeral' }; + } + + const path = keyFilePath(env); + + if (mode === 'production') { + // Honour a pre-existing, operator-provisioned key file, but NEVER mint + // one here — auto-generation is the silent-data-loss footgun this guard + // exists to prevent. + const existing = loadExistingKey(path); + if (existing) { + warn( + `[LocalCryptoProvider] No ${SECRET_KEY_ENV} set — using the persisted key at ${path}. ` + + `For containers / multi-node, prefer setting ${SECRET_KEY_ENV} so every node shares one key.`, + ); + return { key: existing, source: 'file' }; + } + throw new Error(MISSING_PROD_KEY_MSG(path)); + } + + // development: persist an auto-generated key so restarts reuse it. + const persisted = loadOrCreateKey(path); + if (persisted) { + if (persisted.generated) { + warn( + `[LocalCryptoProvider] No ${SECRET_KEY_ENV}/${DEV_KEY_ENV} set — generated a new AES-256-GCM key ` + + `and persisted it to ${path} (mode 0600). Restarts on this host reuse it automatically. ` + + `For containers, CI, or multi-node, set ${SECRET_KEY_ENV} explicitly so the key survives.`, + ); + } + return { key: persisted.key, source: persisted.generated ? 'generated-file' : 'file' }; + } + + // Last-resort ephemeral key (e.g. $HOME unwritable). Loud warning: this is + // the dangerous tier — secrets will NOT survive a restart. + const key = randomBytes(32); + warn( + `[LocalCryptoProvider] No ${SECRET_KEY_ENV} set and could not persist a fallback key at ${path} — ` + + `generated an EPHEMERAL key. Existing encrypted settings/secrets will fail to decrypt after restart. ` + + `Set ${SECRET_KEY_ENV} to a stable 32-byte key:\n ${SECRET_KEY_ENV}=${key.toString('base64')}`, + ); + return { key, source: 'ephemeral' }; +} + +const isWebContainerRuntime = (): boolean => { + const g = globalThis as any; + return ( + typeof g !== 'undefined' && + (Boolean(g.process?.versions?.webcontainer) || + Boolean(g.process?.env?.SHELL?.includes?.('jsh')) || + Boolean(g.process?.env?.STACKBLITZ)) + ); +}; + +type GcmFactory = (key: Uint8Array, nonce: Uint8Array, aad?: Uint8Array) => { + encrypt: (plain: Uint8Array) => Uint8Array; + decrypt: (cipher: Uint8Array) => Uint8Array; +}; + +let nobleGcmPromise: Promise | undefined; +const loadNobleGcm = (): Promise => { + if (!nobleGcmPromise) { + nobleGcmPromise = (async () => { + try { + const mod = await import('@noble/ciphers/aes.js'); + return mod.gcm as unknown as GcmFactory; + } catch (err: any) { + warn( + `[LocalCryptoProvider] WebContainer detected but @noble/ciphers not installed: ${err?.message ?? err}. Falling back to node:crypto (will throw).`, + ); + return undefined; + } + })(); + } + return nobleGcmPromise; +}; + +export class LocalCryptoProvider implements ICryptoProvider { + private readonly key: Buffer; + private readonly useNoble: boolean; + /** Where the active data key came from. Exposed for diagnostics/tests. */ + readonly keySource: KeySource; + + constructor(opts: LocalCryptoProviderOptions = {}) { + const resolved = resolveDataKey(opts); + this.key = resolved.key; + this.keySource = resolved.source; + this.useNoble = isWebContainerRuntime(); + } + + async encrypt(plain: string, ctx: CryptoContext): Promise { + const iv = randomBytes(12); + const aad = Buffer.from(this.aadOf(ctx), 'utf8'); + const plainBytes = Buffer.from(plain, 'utf8'); + + let blob: string; + if (this.useNoble) { + const gcm = await loadNobleGcm(); + if (gcm) { + const cipher = gcm(this.key, iv, aad); + const ctWithTag = cipher.encrypt(plainBytes); // ciphertext || tag(16) + const ct = ctWithTag.subarray(0, ctWithTag.length - 16); + const tag = ctWithTag.subarray(ctWithTag.length - 16); + blob = Buffer.concat([iv, Buffer.from(tag), Buffer.from(ct)]).toString('base64'); + } else { + blob = this.encryptNode(plainBytes, iv, aad); + } + } else { + blob = this.encryptNode(plainBytes, iv, aad); + } + + return { + id: 'sec_' + randomBytes(16).toString('hex'), + kmsKeyId: 'local:v1', + alg: 'aes-256-gcm', + version: 1, + ciphertext: blob, + }; + } + + async decrypt(handle: CryptoHandle, ctx: CryptoContext): Promise { + const buf = Buffer.from(handle.ciphertext, 'base64'); + const iv = buf.subarray(0, 12); + const tag = buf.subarray(12, 28); + const data = buf.subarray(28); + const aad = Buffer.from(this.aadOf(ctx), 'utf8'); + + if (this.useNoble) { + const gcm = await loadNobleGcm(); + if (gcm) { + const cipher = gcm(this.key, iv, aad); + const ctWithTag = Buffer.concat([data, tag]); // noble expects ciphertext || tag + const out = cipher.decrypt(ctWithTag); + return Buffer.from(out).toString('utf8'); + } + } + const decipher = createDecipheriv('aes-256-gcm', this.key, iv); + decipher.setAAD(aad); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8'); + } + + async rotateKey(handle: CryptoHandle, ctx: CryptoContext): Promise { + const plain = await this.decrypt(handle, ctx); + const next = await this.encrypt(plain, ctx); + return { + ...next, + id: handle.id, + kmsKeyId: `local:v${handle.version + 1}`, + version: handle.version + 1, + }; + } + + digest(plain: string): string { + return 'sha256:' + createHash('sha256').update(plain, 'utf8').digest('hex'); + } + + private encryptNode(plainBytes: Buffer, iv: Buffer, aad: Buffer): string { + const cipher = createCipheriv('aes-256-gcm', this.key, iv); + cipher.setAAD(aad); + const enc = Buffer.concat([cipher.update(plainBytes), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, enc]).toString('base64'); + } + + private aadOf(ctx: CryptoContext): string { + // Bind ciphertext to (namespace,key) so a row cannot be moved across + // specifiers. Tenant binding is intentionally omitted because the + // handle is dereferenced from a `sys_setting` row already scoped to + // its tenant — adding tenant here would force the decrypt path to + // re-read that scope. + return [ctx.namespace, ctx.key].join('|'); + } +} + +/** + * @deprecated Renamed to {@link LocalCryptoProvider}. The old name implied an + * "in-memory / ephemeral" key when the provider in fact persists its key + * (env var or on-disk file). Kept as an alias for backward compatibility. + */ +export const InMemoryCryptoProvider = LocalCryptoProvider; +/** @deprecated Use {@link LocalCryptoProviderOptions}. */ +export type InMemoryCryptoProviderOptions = LocalCryptoProviderOptions; diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 1e51fc41c..21a0f6496 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -7,7 +7,7 @@ import { SettingsService } from './settings-service.js'; import type { ICryptoProvider } from '@objectstack/spec/contracts'; import type { SettingsAuditSink, SettingsAuditWriter, SettingsEngine, SettingsSecretStore } from './settings-service.types.js'; import type { CryptoAdapter } from './crypto-adapter.js'; -import { InMemoryCryptoProvider } from './in-memory-crypto-provider.js'; +import { LocalCryptoProvider } from './local-crypto-provider.js'; import { registerSettingsRoutes } from './settings-routes.js'; import { settingsObjects, @@ -38,9 +38,11 @@ export interface SettingsServicePluginOptions { /** * Phase 3 KMS hook. When provided, encrypted specifier values are * routed through this provider into `sys_secret`; `sys_setting.value_enc` - * holds the handle id only. Defaults to `InMemoryCryptoProvider` - * (NOT suitable for production secrets — replace with an AWS / GCP - * KMS-backed implementation). + * holds the handle id only. Defaults to `LocalCryptoProvider`, an + * AES-256-GCM provider keyed off `OS_SECRET_KEY` (or a persisted dev key). + * In production it refuses to boot without a stable key rather than + * silently minting an ephemeral one. Swap in an AWS / GCP / Vault + * KMS-backed implementation for managed custody and per-tenant keys. */ cryptoProvider?: ICryptoProvider; /** Override the default base path (`/api/settings`). */ @@ -176,7 +178,7 @@ export class SettingsServicePlugin implements Plugin { { secretStore: this.buildSecretStore(engine), auditWriter: this.buildAuditWriter(ctx, engine), - cryptoProvider: this.opts.cryptoProvider ?? new InMemoryCryptoProvider(), + cryptoProvider: this.opts.cryptoProvider ?? new LocalCryptoProvider(), }, ); } diff --git a/packages/spec/src/contracts/crypto-provider.ts b/packages/spec/src/contracts/crypto-provider.ts index aa2974fb4..757423bfe 100644 --- a/packages/spec/src/contracts/crypto-provider.ts +++ b/packages/spec/src/contracts/crypto-provider.ts @@ -11,11 +11,14 @@ * * Why an interface (not a concrete class): * - * - **Local dev** ships an `InMemoryCryptoProvider` that wraps the - * existing `NoopCryptoAdapter` semantics so unit tests stay fast. - * - **Production deployments** plug in `AwsKmsCryptoProvider`, - * `GcpKmsCryptoProvider`, or `HashicorpVaultCryptoProvider` without - * touching `SettingsService`. + * - **Default / self-host** ships a `LocalCryptoProvider`: AES-256-GCM + * keyed off `OS_SECRET_KEY` (or a persisted dev key). Secrets surviving + * a restart is correctness, not a premium feature, so this provider is + * open-source and fails loud rather than silently minting an ephemeral + * key in production. + * - **Managed custody** plugs in `AwsKmsCryptoProvider`, + * `GcpKmsCryptoProvider`, or `HashicorpVaultCryptoProvider` (per-tenant + * keys, automatic rotation) without touching `SettingsService`. * - Custom KMS providers (PKCS#11 HSMs, customer-managed keys) can be * registered by the host application via `SettingsServiceOptions`. * From 795c620d99742e922f3052a17235f7559d046efa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 2 Jun 2026 03:24:27 +0000 Subject: [PATCH 2/2] fix(cli): drop dead `??` guard on sharedCryptoProvider (CodeQL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The datasource-admin block is the first to touch `sharedCryptoProvider` (declared `undefined` just above), so the `?? new LocalCryptoProvider()` left operand was always nullish — a useless conditional. Assign directly; the secret-field wiring below still reuses the one instance. https://claude.ai/code/session_01Qzri77Foepw1hnw52Vm3pP --- packages/cli/src/commands/serve.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index be1949029..be492aba8 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1687,7 +1687,10 @@ export default class Serve extends Command { const { LocalCryptoProvider } = await import( /* webpackIgnore: true */ '@objectstack/service-settings' ); - sharedCryptoProvider = sharedCryptoProvider ?? new LocalCryptoProvider(); + // First block to touch `sharedCryptoProvider` (still undefined + // here), so create it directly; the secret-field wiring below + // reuses this instance so every sys_secret shares one key. + sharedCryptoProvider = new LocalCryptoProvider(); secrets = createDatasourceSecretBinder({ engine: lazySecretEngine, cryptoProvider: sharedCryptoProvider,