Skip to content

fix(crypto): fail loud instead of silently minting an ephemeral key (#1507) - #1509

Merged
os-zhuang merged 3 commits into
mainfrom
claude/upbeat-allen-qVGl6
Jun 2, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/upbeat-allen-qVGl6

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #1507.

Problem

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 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".

Changes

Implements all three proposals from the issue:

  1. Fail loud instead of going ephemeral. 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. It never auto-mints a key in production. Development and test keep the ergonomic fallback (persisted dev key / ephemeral test key, with loud warnings).

  2. Ship a persistent env-master-key default. 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. KMS / Vault stay as future plug-ins behind the same ICryptoProvider seam.

  3. Reframed the provider. Renamed InMemoryCryptoProvider → LocalCryptoProvider (the old name wrongly implied an ephemeral key). InMemoryCryptoProvider stays as a deprecated alias for backward compatibility, so existing importers (serve, datasource binder, tests) keep working.

Key resolution order (first match wins)

Tier Source Prod Dev Test
1 opts.key (explicit) ✅ ✅ ✅
2 OS_SECRET_KEY ✅ ✅ ✅
3 OS_DEV_CRYPTO_KEY (legacy OBJECTSTACK_DEV_CRYPTO_KEY) ✅ ✅ ✅
4 persisted file ~/.objectstack/dev-crypto-key read-only read or auto-create skipped
5 ephemeral random throws last-resort (loud warn) silent

A present-but-malformed OS_SECRET_KEY is treated as an explicit operator error and throws in every mode (never silently diverges to a different key).

Boundary note

Per the issue: secrets surviving a restart is correctness, not a premium feature. LocalCryptoProvider and the env-key path stay open-source; KMS, automatic rotation, and per-tenant key isolation remain the paid layer.

Files

  • packages/services/service-settings/src/local-crypto-provider.ts — new implementation (renamed + fail-loud + OS_SECRET_KEY).
  • …/in-memory-crypto-provider.ts — now a backward-compat re-export shim.
  • …/local-crypto-provider.test.ts — coverage for every key-resolution tier (env hex/base64, prod fail-loud, prod pre-existing file, no-mint-in-prod, dev persist, test ephemeral, legacy alias, AAD, rotation, digest).
  • settings-service-plugin.ts, cli/serve.ts, objectql/engine.ts, datasource-secret-binder.ts, spec/crypto-provider.ts — wiring + docs updated to LocalCryptoProvider; serve surfaces the production-key error verbatim.
  • README.md — documents the OS_SECRET_KEY default and fail-loud behaviour.

Testing

  • @objectstack/service-settings: 93 tests pass (incl. the new suite).
  • @objectstack/service-datasource-admin: 29 pass.
  • Full @objectstack/cli build (51 packages, incl. serve.ts) and @objectstack/objectql build succeed.

Compatibility / behaviour change

Only deployments explicitly running NODE_ENV=production without a key and without a pre-existing key file will now refuse to boot (with actionable guidance) — which is exactly the silently-broken case this fixes. Dev (NODE_ENV unset/development) is unchanged apart from louder warnings. InMemoryCryptoProvider imports keep working via the alias.

https://claude.ai/code/session_01Qzri77Foepw1hnw52Vm3pP


Generated by Claude Code

…1507)

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
@vercel

vercel Bot commented Jun 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jun 2, 2026 3:26am

Request Review

…VGl6

# Conflicts:
#	packages/cli/src/commands/serve.ts
@os-zhuang
os-zhuang marked this pull request as ready for review June 2, 2026 03:20
Comment thread packages/cli/src/commands/serve.ts Fixed
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

This branch was successfully deployed

1 active deployment
Preview — 795c620d Deployed Jun 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

3 participants