ADR-034 — Stella-native short-lived, deployment-scoped secret-access broker

Numbering note: the implementation plan referred to this decision as “ADR-033”. ADR-033 was 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:

FieldTypePurpose
DeploymentIdGuidThe single deployment this capability authorises.
TenantIdstringTenant binding (folded into the AAD context).
ReleaseIdGuidThe release whose secrets are in scope (audit + provenance).
AllowedRefHashesIReadOnlyList<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.
IssuedAtDateTimeOffsetMint time.
ExpiresAtDateTimeOffsetHard 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:

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:

  1. 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 returns null; expired ⇒ IsExpired true ⇒ reject.)
  2. 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.
  3. 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.
  4. Resolve only those refs via the orchestrator’s ISecretProvider (RoutingSecretProvider; builtin/vault/openbao server-side — the same resolve path push uses).
  5. 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:

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

ThreatMitigation
Stolen agent / static token reads all secretsNo standing token on the agent; only a per-deployment, self-expiring capability.
Capability replayed against another deploymentAAD binds ciphertext to deployment:<id> + tenant; opening under any other ownerKey/tenant fails authentication.
Capability replayed after the deploy finishedActive-check rejects terminal deployments (revocation); ExpiresAt is the hard backstop.
Capability used to pull an out-of-scope secretAllowedRefHashes membership check; out-of-scope ⇒ fail-closed.
Capability forged or widened by the agentSealed with the master-KEK-rooted CredentialAead; the agent cannot mint or alter it.
Refs leaked from the capability at restOnly SHA-256 hashes of canonical refs travel; the plaintext refs are never embedded.
Master key compromiseOut of scope here — inherited from ADR-031/007; a fail-closed crypto boot denies mint/open.
Orchestrator memory disclosure during resolveAccepted trade-off (D5); mitigated by on-demand resolve, buffer zeroing, no logging, audit.

Rejected alternatives

Consequences