ADR-031 — Unified Secret Provider (built-in floor, Vault/OpenBao, master-key-at-setup)
- Status: Accepted (design-only; binding for the SPRINT_20260622_* implementation stream)
- Date: 2026-06-22
- Owners: Lead architect / Cryptography + Platform + Integrations + ReleaseOrchestrator
- Supersedes: none
- Superseded in part by: ADR-032 — §D2 of this ADR (one backend bound globally at DI via
Crypto:SecretProvider:Backend) is superseded by URL-routed multi-provider selection (RoutingSecretProvider+Crypto:SecretProviders[]). All other decisions in this ADR (D1, D3, D4, D5, D6) remain in force. - Amended: 2026-07-04 — §D5 reframed: OpenBao is a TEST-ONLY dev/CI convenience, not a shipped/bundled broker (operator decision, Option A of the OpenBao positioning memo). See D5.
- Relates to: ADR-007, ADR-008, ADR-012, ADR-032
Context
Sprint 027/028 (ADR-007, ADR-008) delivered a working master-KEK abstraction (IKekSource: env / file / vault / hsm) and regional AEAD routing (ICredentialAlgorithmSelector + ICredentialAeadPrimitive via the plugin router). However, a read-only audit of every secret-handling surface found the following gaps against the locked secret-provider charter:
- No unified consumer-facing abstraction. Three credential stores (Platform connector credentials, Integrations inline credentials, ReleaseOrchestrator deployment bundles) each hard-wire
IKekSourcein their ownProgram.cs. Registry secret resolution forks further intoIAuthRefResolver(Integrations/SbomService) and a staticSecretRefResolver(ReleaseOrchestrator) — at least six divergent pathways with inconsistent fail-closed semantics. - No master-key provisioning at setup.
Crypto:Kek:Sourceis bound butReadSelectedKekSourceKind()is never called; all services hard-registerEnvKekSource. The setup wizard has no Master Key step. Boot succeeds even with no key configured; the failure surfaces lazily on first seal/open. - Symmetric only. Every source resolves to
byte[]. There is no asymmetric (RSA/EC PEM/PKCS#8) wrap/unwrap path, despite the charter requiring “SUPPORT BOTH symmetric (DEFAULT) AND asymmetric”. - No bundled broker. External HashiCorp Vault is the only non-env backend. The charter requires an OPTIONAL bundled broker.
- Weak dev defaults persist in
devops/compose/*(e.g.stellaops-dev-root-token-2026,stellaops-dev-bootstrap-key) which let a service boot without operator provisioning — a fail-closed violation.
Decision
D1 — The built-in store is the mandatory default floor
The default secret backend is builtin: secret VALUES live envelope-encrypted in each service’s own PostgreSQL via the existing ICredentialStore, with each per-row DEK sealed by a master KEK resolved from IKekSource. No operator action beyond provisioning a master key (D3) is required to run Stella Ops. An operator-run external Vault (or operator-run OpenBao) is a strictly optional upgrade — the in-repo OpenBao profile is test-only (D5). There is no cloud-managed KMS default, stub, or DI registration (AWS/Azure/GCP KMS are out of scope per the on-prem/air-gap posture).
D2 — A single ISecretProvider abstraction fronts three backends
SUPERSEDED IN PART by ADR-032. The abstraction (
ISecretProviderfronting builtin/vault/openbao) stands. The one-backend-bound-globally-at-DI selection described below (Crypto:SecretProvider:Backend) is replaced by URL-routed multi-provider selection:RoutingSecretProviderholds all configured providers (Crypto:SecretProviders[]) and dispatches per reference URL. The single-backend form remains valid as the degenerate one-provider case.
A new abstraction ISecretProvider (see the locked interface spec at docs/implplan/specs/secret-provider-interface.md) fronts exactly three backend kinds:
builtin(default) — composesICredentialStore+IKekSource+ICredentialAlgorithmSelector. Tenant-scoped seal/open/rotate/revoke.vault— operator’s own external HashiCorp Vault (or compatible), reusing the existingHttpVaultKvReader/IVaultKvReader.openbao— an operator-run OpenBao, or the TEST-ONLY dev/CI profile (D5, as amended), reusing the same Vault HTTP client (OpenBao is API-compatible with Vault KV v2 / login).
Selection is via Crypto:SecretProvider:Backend ∈ {builtin, vault, openbao} (default builtin). Registry integrations (Integrations, SbomService) AND the deployment process (ReleaseOrchestrator) both consume this single abstraction. The existing IKekSource, ICredentialStore, and ICredentialAlgorithmSelector contracts are NOT replaced — ISecretProvider composes them; the builtin adapter is a thin façade over the Sprint 027/028 machinery, preserving byte-for-byte on-disk format and read-time per-row algorithm dispatch.
D3 — Master key is provisioned at the Compliance Setup step, fail-closed
A new required setup-wizard step (SetupStepId.MasterKey, ordered immediately before Crypto) provisions the master key:
- Generate — produces a strong random symmetric key (default) OR an asymmetric keypair, via the certified crypto plugin (never BCL
RandomNumberGenerator). - Import — operator pastes a symmetric secret OR a PEM/PKCS#8 RSA/EC private key.
- The step performs a round-trip seal/open probe before it can complete.
- Fail-closed boot: every consuming service (Platform, Integrations, ReleaseOrchestrator) probes
IKekSource.ResolveAsyncduring startup. If the selected source is unset/unreachable, the host fails startup with an operator-actionable error (preserving and extending the existingEnvKekSourcethrow). No silent fallback between sources.
D4 — Both key formats route through ADR-008 regional crypto
- Symmetric keys feed the existing HKDF→DEK→regional-AEAD path unchanged.
- Asymmetric keys are KMS-transit style: the RSA/EC private key never derives DEKs directly (HKDF on raw asymmetric material is unsound). Instead a per-store symmetric intermediate KEK is wrapped under the asymmetric key (RSA-OAEP or ECIES via the certified plugin) and the wrapped blob is persisted; on boot the private key unwraps the intermediate KEK, which then roots HKDF exactly as the symmetric path does. All wrap/unwrap and AEAD operations route through the certified crypto plugin per ADR-008 — never BCL
RSA/ECDsa/AesGcmoutside the sanctioned crypto assembly (enforced by the existingAeadBclCallSiteConformanceTests).
D5 — OpenBao is TEST-ONLY (dev/CI convenience); Vault stays the flagship external backend
AMENDED 2026-07-04 (operator decision — Option A of the OpenBao positioning memo, applied by Sprint 20260703_005 D4). The original D5 positioned OpenBao as “the OPTIONAL bundled broker”; the operator ruled it test-only. The
builtindefault (D1) is unaffected.
The in-repo OpenBao profile (devops/compose/docker-compose.openbao.yml) exists only for local development and automated tests. It is opt-in (compose profile openbao, never started by the default up), is not pinned or shipped in any release bundle, and is not a supported production backend. The supported backends are the builtin floor (D1, the default) and an operator-run external HashiCorp Vault — the flagship external backend — or an operator-run OpenBao the operator stands up and operates themselves. Both external kinds reuse the existing HttpVaultKvReader (OpenBao is API-compatible with Vault KV v2 / login), so no new NuGet dependency is added either way.
Why the test container is OpenBao and not Vault (rationale retained): HashiCorp Vault is BUSL-1.1; even for a test fixture, depending on a second BUSL-1.1 product is avoided. OpenBao (Linux Foundation fork) is MPL-2.0. Because the OpenBao image is not distributed as part of the product — it is pulled at dev/test time only — MPL-2.0 distribution obligations do not attach: OpenBao is classified as dev/test tooling in NOTICE.md and docs/legal/THIRD-PARTY-DEPENDENCIES.md, and no license text is redistributed under third-party-licenses/ (the former courtesy copy was removed with this amendment).
D6 — Relationship to existing ADRs
- ADR-007 (KEK lifecycle/rotation) — unchanged.
ISecretProvider.RotateAsyncdelegates to the existingCredentialReSealWalker+crypto.kek_versionsledger + control plane. Asymmetric sources add a newSourceKind(asymmetric) to the ledger. - ADR-008 (regional routing) — unchanged and now mandatory for the asymmetric wrap/unwrap path too (D4).
- ADR-012 (per-tenant system accounts) — unaffected;
ISecretProviderremains tenant-scoped viaownerKey/tenantId, leaving per-tenant KEK isolation out of scope for this initiative (master KEK stays installation-wide per ADR-005/007).
Rejected alternatives
- Require an external HashiCorp Vault. Rejected: violates the built-in floor (D1) and the air-gap/zero-dependency posture; many operators have no Vault.
- Bundle HashiCorp Vault. Rejected: Vault is BUSL-1.1; bundling it inside a BUSL-1.1 product is a licensing trap and forecloses redistribution flexibility. OpenBao (MPL-2.0) is the deliberate substitute for the in-repo test-only profile (D5, as amended); nothing Vault-shaped is bundled or shipped.
- Cloud-managed KMS (AWS/Azure/GCP). Rejected outright: on-prem/sovereign-first; no cloud-managed default, not even a DI-registered stub.
- Feed asymmetric private keys straight into HKDF. Rejected as cryptographically unsound (D4); the intermediate-symmetric-KEK wrap is mandatory.
- Replace
IKekSource/ICredentialStorewith one big interface. Rejected: it would break Sprint 027/028 on-disk format and read-time dispatch invariants.ISecretProvidercomposes the existing contracts instead.
Consequences
- One audit/rotation/fail-closed surface for connectors, integrations, and deployment secrets.
- Operators get a guided, fail-closed master-key ceremony at install.
- Weak dev defaults are purged; services that lack a provisioned key fail to boot loudly rather than silently using
dev-*fallbacks. - A second Vault-compatible provider kind (
openbao) exists without a new NuGet and without licensing risk — supported only as an operator-run external backend; the in-repo OpenBao container is test-only and never shipped (D5, as amended). - Risk: master-key loss before first rotation == ciphertext loss; mitigated by a mandatory backup prompt + runbook (
docs/runbooks/secret-provider-master-key-rotation.md).
