ADR-034 — Stella-native short-lived, deployment-scoped secret-access broker
- Status: Accepted (binding for the deployment secret-broker implementation stream)
- Date: 2026-06-23
- Owners: Lead architect / Cryptography + ReleaseOrchestrator + Agent
- Supersedes / Amends: Supersedes the agent-side direct-Vault edge resolution introduced in
1968790f5c(the agent’sRoutingSecretProvider+ vault provider + the long-livedAGENT_CORE_VAULT_TOKEN). The orchestrator-side resolve-by-tenant fix (337ec2c8cf/f4261f4929) stays — the broker resolves server-side through that same path. - Relates to: ADR-031 (unified secret provider — built-in floor, master-key-at-setup), ADR-032 (URL secret references + multi-provider routing), ADR-007 (KEK lifecycle), ADR-008 (regional AEAD routing)
Numbering note: the implementation plan referred to this decision as “ADR-033”.
ADR-033was already taken (ADR-033-unified-registry-access.md), so this decision lands as ADR-034. All cross-references should use ADR-034.
Context
ADR-031 established the unified ISecretProvider (built-in floor + Vault/OpenBao) and the master-key-at-setup ceremony; ADR-032 made every secret a URL reference routed per-resource by RoutingSecretProvider. Both are about where a secret lives and how it is named. This ADR is about a narrower, orthogonal question: how the deployment agent obtains a release’s secrets at deploy time without holding a standing backend credential.
Pull-mode edge secret resolution (1968790f5c) let the deployment agent resolve vault:// references at the edge — but it authenticated to Vault with a static, long-lived token (AGENT_CORE_VAULT_TOKEN; the Vault root token in the lab; no TTL, no scoping, no revocation). A compromised agent could read every secret on every target. The operator requirement is: the agent must extract only a release’s secrets, only during its deployment, via a short-lived, deployment-scoped credential issued with Stella Ops’ own crypto — NOT Vault’s token / response-wrapping primitives — so it works uniformly with the built-in store (the mandatory floor per ADR-031 §D1; Vault/OpenBao are optional upgrades).
Push mode already solves a related problem the right way: the orchestrator resolves credentials server-side and ships them AEAD-sealed inside the deployment bundle (DeploymentVarsBundleAssembler), using the shared CredentialAead rooted in the master KEK. The agent unseals locally and holds no backend token. This ADR brings the same Stella-native sealing posture to pull mode, but on-demand and scoped per deployment rather than baked into a bundle.
Decision
D1 — A Stella-native deployment secret capability
The orchestrator mints a short-lived, deployment-scoped capability that the agent presents back over its existing mTLS channel during the deploy. The agent holds no backend token. The capability is a value type (DeploymentSecretCapability) bound to:
| Field | Type | Purpose |
|---|---|---|
DeploymentId | Guid | The single deployment this capability authorises. |
TenantId | string | Tenant binding (folded into the AAD context). |
ReleaseId | Guid | The release whose secrets are in scope (audit + provenance). |
AllowedRefHashes | IReadOnlyList<string> | SHA-256 (lower-case hex) of each canonical secret reference the release ships. The broker enforces “only these refs” without the refs ever travelling in clear. |
IssuedAt | DateTimeOffset | Mint time. |
ExpiresAt | DateTimeOffset | Hard self-expiry = deployment timeout + margin, capped. |
AllowedRefHashes is computed from the canonical form of each reference (SecretReference.Parse(raw).Format()), so the same logical secret hashes identically regardless of incidental formatting/casing in how it was authored. The broker can then check membership of a requested ref’s hash against the set without the plaintext reference ever being embedded in the capability.
D2 — Seal/verify reuses the push-bundle KEK chain (no new crypto)
The capability is serialized to a deterministic JSON payload (sorted keys, invariant culture, round-trip-stable) and sealed with the same Stella crypto mechanism the push deployment bundle uses:
the SHA-256-rooted master KEK (
CredentialAead’smasterKekSecret→SHA-256("{kdfLabel}:{secret}")),a per-tenant DEK via HKDF-SHA-256,
AES-256-GCM (regional-algorithm-routed per ADR-008 via the same
ICredentialAeadPrimitive), with a 12-byte random nonce per seal,an AAD context that pins the ciphertext to its domain + tenant + deployment so a capability for one deployment cannot be replayed against another:
deployment-secret-capability|<tenantId:D>|deployment:<deploymentId:D>|capability.v1
This is the identical CredentialAead(aead, masterKekSecret, aadPrefix, kdfLabel, kekIdPrefix) abstraction the assembler is constructed with — the capability is sealed under a dedicated aadPrefix(deployment-secret-capability) and kekIdPrefix (deployments:secret-capability:v1) so its ciphertext is in a different cryptographic domain from the deployment bundle (cross-domain ciphertext re-use fails authentication). No keys are hardcoded; no new algorithm, KDF, or key-management path is introduced. On a fail-closed crypto boot (no master key provisioned, per ADR-031 §D3) the broker correctly refuses to mint or open capabilities.
Seal maps to CredentialAead.Seal(tenantId, ownerKey, kind, plaintext) with ownerKey = "deployment:<deploymentId:D>" and kind = CredentialKind.ApiKey, which makes the deployment id part of the AAD binding (the same ownerKey shape the bundle assembler uses). Open maps to CredentialAead.Open(...), which returns null on tamper / wrong-KEK / wrong-AAD (never throws a plaintext-bearing exception). The capability follows the codebase convention: null on any authentication failure (tamper, wrong tenant, wrong deployment, wrong KEK); a successfully decrypted but expired capability is surfaced via an IsExpired guard the caller checks (decrypt succeeds, the time bound is a policy check on the recovered payload, not a crypto failure).
D3 — Broker contract: validate → active-check → scope-check → resolve → AEAD-seal
The broker endpoint (a separate deliverable, mirroring the mTLS-only, tenant-checked, cross-tenant-404 DeploymentBundleEndpoints.FetchBundle) processes a resolve request in a strict, fail-closed order:
- Validate the capability with Stella crypto — integrity (AEAD tag), tenant + deployment binding (AAD), and
ExpiresAt(not yet elapsed). Any failure ⇒ reject. (Invalid/tampered/wrong-domain ⇒ open returnsnull; expired ⇒IsExpiredtrue ⇒ reject.) - Active-check — confirm the deployment is active (not terminal). A terminal deployment ⇒ reject. This is the revocation mechanism: once a deployment completes/fails, its capability stops resolving even before
ExpiresAt. - Scope-check — confirm each requested canonical ref’s SHA-256 is a member of
AllowedRefHashes. An out-of-scope ref ⇒ reject the whole request (fail-closed); the broker never resolves a ref the capability does not name. - Resolve only those refs via the orchestrator’s
ISecretProvider(RoutingSecretProvider; builtin/vault/openbao server-side — the same resolve path push uses). - AEAD-seal the resolved values with Stella crypto over the agent’s mTLS channel. Never log values; audit each resolution (who / which deployment / which refs — never the values).
D4 — Fail-closed posture
Every gap in the chain denies secrets:
- no capability / invalid / tampered / wrong-domain (AAD mismatch) ⇒ open returns
null⇒ no secrets; - expired capability ⇒
IsExpired⇒ no secrets; - deployment terminal ⇒ no secrets (revocation);
- requested ref not in
AllowedRefHashes⇒ no secrets for any ref in the request; - master key unavailable (fail-closed crypto boot) ⇒ the broker cannot mint/open ⇒ no secrets.
The agent’s existing pull-mode behavior is preserved: an unresolvable secret aborts the deploy (never launches with a missing secret).
D5 — Accepted trade-off: the orchestrator resolves (sees plaintext transiently)
Because the broker resolves the references server-side and returns sealed values, the orchestrator sees the plaintext transiently during a resolve call. This is a deliberate, scoped form of the push model: on-demand, per request, never bulk-persisted. The agent in turn holds no backend token at all — its blast radius shrinks from “every secret on every target” to “this deployment’s named refs, until this deployment ends or the short TTL elapses.”
Mitigations: resolve on-demand per request (not bulk); zero plaintext buffers after use; never log values; audit every call; bind the capability tightly (deployment + tenant + ref-hashes + short expiry) and accept it only over the agent’s mTLS channel, so the mTLS identity is a second factor on top of the bearer-ish capability.
Threat model
| Threat | Mitigation |
|---|---|
| Stolen agent / static token reads all secrets | No standing token on the agent; only a per-deployment, self-expiring capability. |
| Capability replayed against another deployment | AAD binds ciphertext to deployment:<id> + tenant; opening under any other ownerKey/tenant fails authentication. |
| Capability replayed after the deploy finished | Active-check rejects terminal deployments (revocation); ExpiresAt is the hard backstop. |
| Capability used to pull an out-of-scope secret | AllowedRefHashes membership check; out-of-scope ⇒ fail-closed. |
| Capability forged or widened by the agent | Sealed with the master-KEK-rooted CredentialAead; the agent cannot mint or alter it. |
| Refs leaked from the capability at rest | Only SHA-256 hashes of canonical refs travel; the plaintext refs are never embedded. |
| Master key compromise | Out of scope here — inherited from ADR-031/007; a fail-closed crypto boot denies mint/open. |
| Orchestrator memory disclosure during resolve | Accepted trade-off (D5); mitigated by on-demand resolve, buffer zeroing, no logging, audit. |
Rejected alternatives
- Keep the static
AGENT_CORE_VAULT_TOKEN(status quo). Rejected: no scoping/TTL/revocation; a compromised agent reads everything. - Use Vault’s response-wrapping / short-TTL tokens. Rejected: Vault-specific; does not work with the built-in floor (ADR-031 §D1) and breaks air-gap installs with no Vault. The capability must be Stella-native.
- Agent resolves directly with a per-deploy backend token. Rejected: still puts a backend credential on the agent and forks per-backend; the broker keeps the agent backend-token-free and provider-agnostic.
- Invent a new signing/sealing scheme for the capability. Rejected: reuses the proven push-bundle
CredentialAead/ master-KEK chain — no new crypto, no new key management, offline + built-in-store compatible by construction.
Consequences
- The agent holds no backend token; its secret-access blast radius is one deployment’s named refs, for a short window.
- Pull-mode secret access is provider-agnostic (builtin/vault/openbao) and air-gap-friendly because it rides the master-KEK chain, not a backend’s token primitives.
- One fail-closed surface: invalid / expired / post-terminal / out-of-scope all deny secrets, consistent with the existing assembler + agent fail-closed loops.
- Trade-off: the orchestrator sees plaintext transiently per resolve (D5) — accepted, mitigated, audited.
- Net agent surface shrinks versus
1968790f5c(noRoutingSecretProvider, no vault provider, no token env).
