Uh oh!
There was an error while loading. Please reload this page.
fix(service-settings): fail closed on a secret the settings path cannot encrypt (#8026) - #8274
Conversation
…ot encrypt (#8026) `SettingsService` without a `cryptoProvider` + `secretStore` fell back to `NoopCryptoAdapter`, whose `encrypt()` is `'b64:' + base64(plaintext)` — encoding, not encryption, and it leaves `sys_setting.value_enc` populated so the row reads as protected. The engine's `Field.secret()` path has always refused the write instead; this aligns the settings side onto that posture. - refuse a declared-encrypted write (`encrypted: true` / manifest `type: 'password'`) with `SettingsCryptoUnavailableError` when the `sys_secret` path is unwired AND the inline adapter declares no confidentiality; - whole batch rejected (pre-flight, after `validatePatch` so caller-fixable diagnostics still win), plus the load-bearing guard at the persist site; - one deduped, operator-actionable line through the deployment logger; - new optional `CryptoAdapter.confidential` (absent means yes) + exported `providesConfidentiality`; `NoopCryptoAdapter` stays exported and its `decrypt` is untouched, so existing `b64:` rows stay readable. Not a live leak: the shipped plugin wires `LocalCryptoProvider` at `kernel:ready`. This closes the fail-open direction on the path an engine-less deployment still reaches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8026
The settings write path now fails closed on a declared-encrypted value it cannot actually encrypt, restoring parity with the engine's
Field.secret()posture.The divergence this closes
SettingsServiceconstructed without acryptoProvider+secretStorefell back toNoopCryptoAdapter, whoseencrypt()is'b64:' + base64(plaintext)— encoding, not encryption. Worse than plaintext in one specific way:sys_setting.value_enccomes back populated, so the row reads as protected to the next author and to the next audit.Field.secret()SettingsService— beforeSettingsService— after⚠ This is not a live-leak fix and is not written as one. The shipped plugin path wires a real
LocalCryptoProvideratkernel:readyonce anobjectqlengine resolves, so a default deployment never took the base64 branch. What lands here is the removal of the fail-open direction on a path an engine-less deployment can still reach. A security tightening restoring an already-declared platform posture — not a new capability, not a contract widening.Premise re-measured on
origin/mainThe card's justification held, with one refinement:
new SettingsService(sites; 147 are tests, the single production site issettings-service-plugin.ts:108and it passes onlyopts.crypto(undefined by default ⇒NoopCryptoAdapter). The realcryptoProvider+secretStorebind later, inkernel:ready, and only when anobjectqlengine resolves. So an engine-less deployment genuinely took the base64 path. Unchanged from triage..setMany(/ settings.set(finds only prose references). The runtime blast radius outside the package is real deployments only.What changed
SettingsCryptoUnavailableErroris thrown when thesys_secretpath is unwired and the inlineCryptoAdapterdeclares no confidentiality. Construction-time refusal was considered and rejected: the plugin builds the service ininit()and binds the provider atkernel:ready, so a constructor throw would refuse every shipped deployment, and a namespace with no encrypted specifier needs no provider at all. See "boot vs write" below.validatePatchdeliberately. Both refuse the whole batch, but a validation error names something the caller can fix in the form in front of them, while this one names a deployment they cannot reconfigure. Checked first, it masked every field-level diagnostic on a namespace that happens to carry a secret — measured: 6 existing tests changed their reported error before the reorder, and went back to their own diagnostics after.CryptoAdapter.confidential;NoopCryptoAdapterdeclaresfalse. Absent means yes — every adapter written before the flag is a deliberately-injected real one, so silence must not start refusing their writes; opting in to the refusal is one line. Exported predicateprovidesConfidentiality(adapter)is what the write path asks. The check is provenance-independent: explicitly passingnew NoopCryptoAdapter()is refused just the same, so the fail-open path is not reachable by one line of caller code.Boot vs write
The card asked for the refusal to be loud at boot; it lands at the write, and the two genuinely pull apart here.
SettingsServicePluginconstructs the service ininit()and binds the real provider later atkernel:ready, so at construction time "can this deployment encrypt?" is not yet knowable — a boot-time throw would refuse every shipped deployment, and a boot-time warning would fire on every shipped boot as a false alarm. The fact becomes knowable at the write, which is where the refusal sits; the loudness the card wanted is the logger line, which reaches the operator even when the caller swallows the error. Recording the tension rather than papering over it.What deliberately did not change
NoopCryptoAdapterstays exported (public API) anddecrypt()is untouched — existingb64:rows stay readable, reportable and migratable. The refusal is write-only; refusing reads too would strand exactly the data this card wants surfaced. (The engine fails closed on secret reads too; the settings side deliberately does not, and that residual asymmetry is stated here rather than silently taken.)SettingsServicePluginOptions.crypto) and thesys_secret+ICryptoProviderpath are unaffected.null) is still allowed: no plaintext to protect, and an operator must always be able to REMOVE a value on a deployment that cannot store one.Reverse verification
A temporary probe (main-compatible APIs only, not committed) drove a genuinely provider-less construction — real
ObjectQLover the realSysSetting, nocrypto, nocryptoProvider, nosecretStore— and asserted the persisted row, not the thrown error. Direction predicted before running, observed as predicted:origin/main: GREEN — onesys_settingrow persisted withvalue_encequal tob64:cmUtc2VjcmV0LTEyMw==, decoded back tore-secret-123from the stored column alone. This is the non-vacuity proof the pin needed: the fixture really reaches the Noop path.Cannot persist encrypted setting 'crypto_ns.api_key' ... Refusing to store a reversible value (fail-closed)., thrown at the pre-flight, and no row written.The committed pin file itself was also run against
origin/main's sources: 9 of its 12 cases go red. Reported honestly — 3 of those reds are import-shaped (the error class does not exist on main), so the probe above is the load-bearing measurement, not the pin file's red count.Tests
packages/services/service-settings: 426 passed / 426 (baseline onorigin/mainwas 413). New filesettings-crypto-fail-closed.test.ts(12 cases) covers the refusal and the persisted state, both encrypted flavours, the explicit-Noop case, batch atomicity, the logger line (and that it never echoes the secret), the non-encrypted control, clearing, the confidential-adapter path, legacyb64:readability, theprovidesConfidentialityarms, and the REST boundary (status 500 + codeINTERNAL_ERROR+ the actionable message, with nothing persisted and no secret echoed).Six existing tests were re-pointed, and why that is not "editing tests to fit the refusal": every one writes a declared secret only as incidental setup for a complete provider config; their subjects are the audit digest, the option table, required/visible validation and
temperature's window. None had the Noop persist path as its subject. Each fixture now declares an adapter that can hold a secret, so those assertions stay about what they claim. The one fixture that was pinned to the removed limb (persists encrypted=true values via crypto adapter, which injectednew NoopCryptoAdapter()) was replaced rather than re-spelled, because its assertion would otherwise have kept passing while nothing was stored.Wire spelling — a deliberate gap, filed separately
SETTINGS_CRYPTO_UNAVAILABLEis an in-process discriminator today. Giving it a dedicated HTTP status and code requires registering it inERROR_CODE_LEDGERunderpackages/spec, which this card's scope excludes; emitting an unregistered code on the wire would be the silent fourth state ADR-0112 forbids. So the refusal takes the same500/INTERNAL_ERRORarm every unmapped service error takes, carrying the full actionable message — no envelope change. Follow-up filed as #8273.Scope notes
packages/objectqluntouched: the divergence is measured against the engine, the fix is on the settings side only.ResolvedSettingValue.sourceseam atsettings-service.ts:1200was not reached, so the standing settings 消费缝丢弃 ResolvedSettingValue.source —— 服务无法区分「管理员写过的值」和「schema 默认值」 #5536 ride-along did not trigger and settings 消费缝丢弃 ResolvedSettingValue.source —— 服务无法区分「管理员写过的值」和「schema 默认值」 #5536 is left alone.src/**surface:packages/services/service-settings/README.md, whose paragraph on the Noop adapter this PR falsifies (2 sentences corrected, same package). Flagged rather than left lying.sys_secret/value_enc) showed no collision: nothing in this diff touches its read path.Local gates
check:docs-audit-scope,check:route-envelope,check:test-source-alias,check:type-source-resolution,check-changeset-fixed,check:error-code-casing(a new code is introduced),check:nul-bytes— all green. Re-derived against the actual changed paths, which added two convention-triggered families the dispatch list did not name:check:query-options-erasureandcheck:type-check-coverage(both green). Downstream consumer sweep (prefix filter,...@objectstack/service-settings— 10 projects): all typecheck clean.Generated by Claude Code