Threat model
A Daevix-wide security threat model — the whole platform (controlplane, enclaves, and the untrusted agents they govern), organized by trust boundary.
This is an informal threat model in the sense of Soatok’s guide: a design-time artifact that answers the seven questions plainly, states its assumptions out loud, and names what it deliberately does not defend. It is a living document — when an assumption below stops holding (a broken vendor root, a new instance family, a changed invariant, a mechanism that ships or is withdrawn), this doc is wrong until it is updated. Where a defense is designed-but-unbuilt, the section says so and names the tracking bead rather than asserting the mechanism exists.
Read architecture.md (the sovereignty invariants) and
agent-identity.md (the three enforcement planes) first;
this model builds on both. The subsystem docs — iam.md,
enclave-broker.md, client-interaction.md,
controlplane-data-minimization.md — are the
per-area deep-dives that the boundary sections here summarize adversarially.
How this document is structured
Soatok’s seven questions are the spine, adapted for a system with many boundaries:
- What are we protecting? — §1, a system-wide asset catalog.
- Who wants to harm it? — §2, the adversary catalog.
- How are the assets connected? — §3, the trust graph and boundary map. 3 & 4. How might they attack, and what do we do about it? — §4, one analysis per trust boundary. Each boundary answers both questions for its own surface, since the attack and its mitigation only make sense together, and each closes with its own residual-trust and out-of-scope notes.
- What assumptions are we making? — §5, the load-bearing system-wide ones.
- What are we deliberately not addressing? — §6, system-wide.
The boundary sections (§4) are the substance; §§1–3 and §§5–6 aggregate across them so the whole is legible without reading every section.
1. What are we protecting?
Every asset below reduces to protecting one property: that an action is attributed to, and authorized for, exactly the principal that actually took it — agent, operator, or enclave — and that sovereign data never leaves the boundary it belongs to. The concrete assets, by theme:
- The identity bindings. The provisioning binding this measured guest is agent N of org M (attestation, §4.2); the runtime attribution this socket / this Cilium identity is that agent (§4.1); the enclave’s single bound CP identity (§4.3); and the operator’s capability × scope authority (§4.4). Everything downstream trusts these bindings, so forging one is the highest-value attack.
- Sovereign data and its custody. Tenant payload never transiting the controlplane (§4.5, invariant 1); secret values encrypted at rest under keys the operator cannot be compelled to surrender (§4.6); and key custody that never crosses a boundary the data itself may not cross.
- Total mediation. No uninspected agent egress — protocol-complete, including QUIC/UDP (§4.1).
- The platform’s trust surfaces. The controlplane’s minimal per-enclave trust
(one identity, not N, §4.3); confinement of identity-minting authority to the
controlplane (invariant 5); cross-tenant isolation between orgs sharing one
issuer (§4.4). An enclave serves exactly one org (
bd_2_0-j9r), so there is no in-enclave co-tenant boundary to defend — isolation between orgs is topological (separate enclaves), not a shared-plane partition. - The honesty of every claim. A
TierNoneenvironment must never present as hardware-attested (§4.2); audit must be complete and un-reworded evidence that sovereign ops stayed in the enclave (§4.7); reduced-assurance and designed-but-unbuilt states must be labelled, never silent.
Each §4 section enumerates the specific assets at its boundary.
2. Who wants to harm it?
The adversary set spans the full trust gradient, from the untrusted workload inward to a coerced operator. Ranked roughly by how much of the design each one forces:
A compromised / hostile agent — the baseline adversary. Arbitrary, untrusted BYO customer code that can read its own memory, filesystem, and env, make syscalls, and probe the network. Wants to escape observation or be attributed as a different (higher-privileged) agent (§4.1, §4.2).
A malicious substrate operator — whoever owns the layer below the attested boundary: the cloud provider’s hypervisor/firmware, or physical/BMC access on bare metal. The adversary the choice of substrate is really about (§4.2).
A network / MITM adversary — on the guest→verifier path (§4.2), the enclave↔controlplane path (§4.3), or the relay→broker hop (§4.5).
A low-privilege but authenticated operator — a
member, or anadminin one org, trying to widen authority or reach another tenant (§4.4).A compromised or coerced platform operator / controlplane — the party that publishes reference values and policy, and holds controlplane metadata. Not fully trusted: the whole design keeps it off the sovereign-data path (§4.3, §4.6).
A compromised in-enclave platform component — a broker, gateway, or relay an attacker has taken over, trying to widen blast radius, forge audit, or escalate into authority it should not have (§4.3, §4.5, §4.7).
An insider with build-pipeline or KMS/store access — who can influence what the “known-good” measured envelope is (§4.2), or who holds a key or direct write access to the audit store (§4.6, §4.7). Often below a given boundary — see each section’s residual-trust note.
3. How are the assets connected? (trust graph & boundary map)
Daevix is two planes joined by one metadata-only seam, with the agent as the untrusted innermost ring. Sovereign data stays in the enclave; only platform-owned metadata flows CP→enclave, and only ever enclave-initiated — pulled by the enclave, or delivered out-of-band as a file at rest — never pushed by a CP-initiated dial (invariants 1 & 2). The seven boundaries this model covers:
operator/client ┌──────────── controlplane ────────────┐
│ │ identity · policy · audit · registry │
│ §4.4 IAM: bearer, capability×scope (never sees sovereign data) │
└─────────────────────────────────▶│ Zitadel (authn) · authz (grants) │
└───────────────┬───────────────────────┘
│ §4.3 metadata DOWN only,
│ enclave-initiated pull
│ (policy, refvalues, intents)
┌─────────────────────────────▼─────────────────────────────┐
operator/client │ enclave (customer cluster) │
│ │ broker ── sole CP egress · local issuer · provisioning │
│ §4.5 relay edge │ │ │
└──────────────────────▶ relay ─mTLS─▶ broker §4.6 data at rest / KMS │
(sovereign-data │ │ §4.2 attest guest at provisioning │
path: bd_2_0-kzb) │ ▼ (Plane 3 root; tier discovered) │
│ per-agent namespace (Kata VM) │
│ ┌──────────────────────────────┐ │
│ │ arbitrary BYO agent (hostile)│ │
│ │ §4.1 mounts IMDS sock (P1) │ │
│ │ egress → gateway (P2) │ │
│ └──────────────────────────────┘ │
│ §4.7 audit store (evidence sovereign ops stayed in) │
└────────────────────────────────────────────────────────┘
Boundaries chain: §4.2 (attestation) anchors §4.1 (runtime attribution) — the runtime planes are only as trustworthy as the Plane-3 root beneath them. §4.3 carries the metadata §4.1/§4.2 enforce, without ever carrying sovereign data. §4.6 and §4.7 are cross-cutting: key custody and audit apply within every enclave and the controlplane alike.
4. Boundary analyses
Each boundary answers “how might they attack it / what do we do” for its own surface, and closes with residual trust and out-of-scope notes local to it.
4.1 · Agent ↔ enclave runtime (Planes 1 & 2: attribution + total mediation)
This boundary is the surface between arbitrary bring-your-own agent code and the Daevix-owned runtime that observes and governs it — the per-agent IMDS unix socket (Plane 1) and the default-deny egress path terminated at the enclave network/LLM gateway (Plane 2). It matters because the agent is assumed hostile (docs/agent-identity.md, “Threat model”): it can read its own memory, filesystem, and env, and has a direct incentive to escape observation or impersonate a more-privileged peer. The design’s answer is inversion — the agent presents nothing; identity is attributed from a layer beneath the agent (the mount, the Cilium datapath, the TEE), and every byte of egress is terminated and inspected or dropped.
What it protects. The no-standing-secrets invariant (no credential ever placed where the agent can exfiltrate it); the integrity of attribution (this flow is agent N of org M, not some other agent); the total-mediation guarantee (no uninspected egress, protocol-complete including QUIC/UDP); the gateway CA private key and forged-leaf trust; per-agent isolation of the IMDS socket; and the audit stream’s fidelity (gateway.egress with a structured policy decision).
Adversaries. A hostile agent — arbitrary untrusted customer code running inside the Kata VM — trying to reach the network uninspected, be attributed as a different/higher-priv agent, forge or strip its Cilium identity, defeat the TLS-terminating gateway, abuse or remove the injected CA, or reach another agent’s IMDS socket. (An adversary who has broken out of the Kata VM, or who owns the CNI/kernel/hypervisor, is below this boundary — see out of scope.)
Attacks & mitigations.
| Attack | How | Mitigation (fail-closed default) |
|---|---|---|
| Direct/uninspected egress (“forgot to proxy”) | Agent dials the internet directly, or unsets HTTPS_PROXY, to escape inspection. | Egress is not HTTPS_PROXY; a CiliumNetworkPolicy on app=agent redirects all agent TCP (port "0") through Cilium’s embedded Envoy to the node-local dvx-gateway over a Unix socket (internal/enclave/gateway/gateway.go package doc; deploy/policy/agent-egress-redirect.yaml, deploy/enclave/gateway-envoy-config.yaml). Any egress rule flips the endpoint to Cilium default-deny, so direct/lateral egress is dropped by the datapath, not by agent cooperation (invariant 3). Not expressible from inside the namespace. |
| Impersonate another agent | Agent forges a token or asserts a higher-priv identity. | No token exists to forge (invariant 1). Network identity is attributed from the pod’s real source IP joined to the Cilium security identity via CiliumEndpoint listing (internal/enclave/attribution/attribution.go), not from anything the agent presents (invariant 2). Local identity is which per-agent socket the request arrived on, owned by the broker (docs/agent-identity.md, Plane 1). |
| Forge / strip the Cilium identity | Agent spoofs its source IP or rewrites the security identity on its packets. | The security identity is derived from k8s identity and validated in-kernel, unforgeable by the workload (docs/agent-identity.md, Plane 2; internal/enclave/attribution/attribution.go doc). East-west traffic is not masqueraded, so the gateway sees the real pod IP; the PROXY-protocol v2 header carries that unforgeable source through the redirect (internal/enclave/gateway/proxyproto.go). |
| Egress a flow we cannot name | Agent races provisioning or exploits an attribution gap so its flow resolves to no agent, hoping to slip through under IP-only audit. | PolicyEngine.Evaluate is checked identity-first: when attribution is in force (requireIdentity = cfg.Attributor != nil, internal/enclave/gateway/gateway.go), an unresolved flow is denied with reason no_identity (internal/enclave/gateway/policyengine.go, staticEngine.Evaluate). “We will not govern a flow we cannot name.” Attributor Lookup is itself fail-closed: an IP with no app=agent endpoint returns not-found. |
| Cross-enclave mis-attribution / mediation (shared cluster) | On a node shared by two enclaves, enclave B’s gateway indexes enclave A’s app=agent pods (also app=agent) and mis-attributes them as B’s, or B’s IMDS vsock relay serves A’s kata sandbox — one enclave mediating another’s agents, a fail-open cross-enclave data path. | Attribution is enclave-scoped (bd_2_0-6l2.2): the broker stamps the full enclave id as dvx.io/enclave-id on every agent pod (bd_2_0-6l2.1, internal/naming), Cilium mirrors it onto the CiliumEndpoint, and buildIndex skips any endpoint whose id ≠ this enclave’s (internal/enclave/attribution/attribution.go) — plus a server-side labelSelector on the CEP list as blast-radius reduction (k8sclient.go). The vsock relay only binds clh.sock_<port> in sandboxes whose enclave-prefixed pod namespace is this enclave’s (internal/enclave/imds/relay.go, ownsSandbox). Both fail closed on a foreign or absent id. The label excludes, never grants — placement confers no authority (decision bd_2_0-ihz) — and is broker-authored, not agent-settable (no pod/API access from the namespace). |
| Bypass TLS termination | Agent tunnels TLS-over-TCP hoping the gateway splices it opaquely. | When a CA is configured the gateway MITM-terminates: forges a per-SNI leaf (internal/enclave/gateway/certstore.go), decrypts, evaluates policy on the SNI, then re-originates a validated TLS connection upstream (internal/enclave/gateway/gateway.go, mediateTLS; internal/enclave/gateway/mitm.go, detectTLS). Plaintext egress is governed by best-effort Host and, under an enforced allowlist with no derivable hostname, fails closed (mediatePlaintext). |
| QUIC/HTTP-3 evasion | Agent speaks HTTP/3 over UDP, which cannot be leaf-forged like TLS-over-TCP, to get an opaque tunnel. | Protocol-complete mediation: QUIC is only permitted by terminating it with a forged leaf (internal/enclave/gateway/quicmitm.go), decrypting HTTP/3 and applying the same PolicyEngine (a denied authority gets HTTP 403, never a byte upstream). If --udp-listen is set without a gateway CA, the UDP/QUIC datapath is disabled rather than carrying QUIC it cannot terminate (gateway.go, Service.Run: “cannot terminate QUIC”). Anything not terminable stays Cilium default-deny. |
| Abuse or remove the injected CA | Agent deletes the CA bundle or swaps trust roots to dodge the MITM. | The CA makes mediated egress usable, not enforced: removing it only breaks the agent’s own TLS — egress is still default-denied to everything but the gateway (docs/agent-identity.md, “CA injection”, invariant 3). Only the CA public cert is ever in the agent’s view; the private key never leaves the gateway (internal/enclave/gateway/certstore.go). |
| Reach another agent’s IMDS socket | Agent tries to read a peer’s metadata / per-use tokens by touching another socket. | The broker mounts only that agent’s socket into that one pod at /run/dvx/imds.sock; pod/VM isolation plus single-socket mount means no other agent’s listener is reachable, and the identity is the listener the connection arrived on (docs/agent-identity.md, Plane 1). SO_PEERCRED is only defense-in-depth; the mount is the signal. |
| Steal / replay a standing credential | Agent extracts a long-lived secret from env/disk/memory and replays it later. | There is none to steal (invariant 1). External credentials are injected at the gateway on the way out (agent never holds one) or fetched per-use, seconds-to-minutes, from IMDS (docs/agent-identity.md, “Credentials for external systems”; internal/enclave/gateway/gateway.go, injectHTTP, which audits the credential name, never its value). Identity == liveness: tearing down the pod unmaps and unmounts in one operation (“Lifetime equals liveness”). |
| Spoof / bypass identity on a force-routed LLM call | The gateway classifies a decrypted egress as an LLM inference call and force-routes it to the llmproxy satellite (classifier + routeLLM, internal/enclave/gateway, bd_2_0-b06.1; verified at internal/enclave/llmproxy, bd_2_0-b06.2); the agent forges identity in the HTTP request — e.g. in X-Dvx-* headers plus a service JWT it hopes the llmproxy will trust — to be seen as a different/higher-priv agent, or dials llmproxy directly to skip classification. | The gateway force-routes over mTLS presenting the agent’s own attested SVID (reusing the assert-identity tunnel; the signing key stays in the gateway, invariant 6), and llmproxy verifies that peer cert (agentid.Verifier, echoverify pattern; ingress.go) into agent/org/enclave — trusting nothing in the HTTP bytes (no header, no service JWT): a forwarded header or in-band service JWT can be forged by a compromised agent, so it is never the basis for attribution — llmproxy sees the agent’s real origin only in the verified peer cert. Classification runs only after the destination policy has already allowed the flow, and is fail-closed (decision B): an unattributed agent, an agent with no held SVID, or an unreachable llmproxy is denied (mediation:llm-route deny), never sent direct — a governed inference call cannot slip past the satellite. At llmproxy a missing/invalid SVID or below---min-tier agent is likewise dropped. Identity is out-of-band (peer cert), so the agent’s HTTP is raw-spliced byte-faithful yet fully attributable. The provider (which upstream the call resolves to) is derived from the gateway-verified SNI, conveyed out-of-band in a PROXY-v2 custom TLV (proxyproto.EncodeV2 → Parse, bd_2_0-izh) and cross-checked against the agent’s Host (a mismatch fails closed) — so a lying Host cannot steer the enclave’s egress, nor the injected sovereign credential, away from where the connection actually went. The host→provider→upstream mapping is an explicit allowlist (the shared internal/enclave/llmreg registry); an unknown host has no provider and resolves only to an operator-configured catch-all or fails closed. This strengthened the earlier path-only derivation: an OpenAI-wire-compatible third party (Hetzner serves OpenAI’s /v1/… paths) would otherwise have been misclassified as OpenAI by path alone and its sovereign credential egressed to api.openai.com. The request path now selects only the wire format, never the provider. Only the exact inference method+path is routed; other calls to the same provider host (token counting, model list) pass through unchanged. |
Residual trust / assumptions. We trust the layer below the agent that the agent’s code cannot reach: the Cilium eBPF datapath correctly stamps and in-kernel-validates the security identity and enforces default-deny; the Kata VM boundary isolates the agent from the out-of-VM gateway/IMDS/broker (invariant 6); and the TEE measurement (Plane 3) anchors the socket→agent and Cilium-identity→agent mappings established at provisioning, when the agent does not participate. The attribution index is a few-seconds-stale poll of CiliumEndpoints (attribution.go), which is acceptable only because a miss is fail-closed. An attacker who owns the CNI, kernel, or hypervisor could forge the identity or read plaintext and defeat this boundary entirely — that is Plane 3’s job, not this one. We also trust that one Cilium security identity maps to exactly one agent (an open question in the doc). Where multiple enclaves share a cluster, enclave-scoped attribution and vsock relay (above) additionally trust that Cilium faithfully mirrors the broker-stamped dvx.io/enclave-id pod label onto the CiliumEndpoint — the same datapath trust already relied on for the other dvx.io/* attribution labels — and that the kata sandbox’s persisted pod-namespace annotation (enclave-prefixed) is not guest-forgeable; the label/prefix only ever excludes foreign agents (fail-closed), so a mirroring failure denies attribution rather than crossing enclaves. The opt-in interactive-access tunnel does not puncture this: with the trusted-proxy design (§4.5) WireGuard is terminated by a trusted node-local terminator and the agent VM only listens on host-mediated vsock, so the sovereign guest opens no uninspected egress of its own — the relay dial-out is the trusted terminator’s, not the agent’s.
llmproxy datapath (bd_2_0-b06), shipped + live-verified through b06.7. The full ingress→forward path is built, deployed, and verified end-to-end on daevix-dev (bd_2_0-b06.7): the gateway classifies inference calls and routes them to the llmproxy satellite over agent-SVID mTLS (bd_2_0-b06.1); llmproxy verifies the SVID, reads the attested-upstream preamble, and parses the request (bd_2_0-b06.2); it then forwards the request unchanged to the resolved provider (bd_2_0-b06.3 — by default; the flag-gated translation mode below is the single documented exception) under a governed upstream-auth mode (bd_2_0-b06.4). The live verify confirmed the fail-closed properties on a real datapath: a governed model block returns an RFC 9457 403 and is never forwarded; inject with no resolvable llm.api_key fails closed (403); passthrough carries the agent’s own credential to the real provider; and gateway.egress records mediation:llm-route. Three threat-relevant properties are enforced (internal/enclave/llmproxy):
- Direct egress is destination-disciplined. llmproxy egresses directly to the provider by design (decision C), bypassing the network gateway — so its own egress is not mediated. To keep that from becoming an open redirect, the provider is resolved from the gateway-attested SNI (a PROXY-v2 TLV, bd_2_0-izh), cross-checked against the agent’s
Host(a mismatch fails closed), then mapped host→provider→canonical upstream through the sharedinternal/enclave/llmregallowlist — never dialing the agent-controlledHost/URL. An unknown host resolves only to an operator-configured startup default or fails closed (502) rather than dialing an arbitrary destination (upstream.go,trustedProvider/resolve). By default a call with no gateway SNI preamble at all fails closed too (bd_2_0-1c0): a gateway-fronted deployment always attests the SNI, so an unattested call is a direct dial bypassing the gateway and is rejected — theAllowUnattestedHostopt-out restores the (still credential-safe, canonical-only) Host fallback for a deployment that dials llmproxy directly. A governance override is https-only (a cleartext override is refused). The direct hop authenticates the provider by its serving cert (system roots, or an operator CA). - Auth mode is fail-safe by default. The mode is resolved per-agent→org from the governance snapshot (
llm.credential_mode), defaulting to passthrough for an ungoverned/unset/malformed value — the ToS-safe direction, where the agent’s ownAuthorization/x-api-keyflow through untouched (load-bearing for Claude OAuth plans, whose ToS forbids the platform performing the auth). Inject strips the agent’s inbound auth and substitutes thellm.api_keyresolved from the sovereign agent-secret domain (agent scope, org fallback) over the broker mTLS channel — the same no-standing-secret injection posture as the gateway (bd_2_0-7ha.4/lai); the agent never holds the key and the controlplane never sees the bytes. Inject fails closed (403) when no key resolves or no secret channel is wired — never sending unauthenticated, never silently falling back to the agent’s credential (auth.go,mutation). - Governance travels the file channel; secret bytes travel the broker. The credential mode and upstream override come from the published policy snapshot resolved locally (a file, same out-of-band delivery as the gateway — no controlplane dial, invariant 2); only the sovereign secret bytes cross the broker’s existing mTLS satellite channel (
internal/enclave/brokersecrets, shared with the imds). No new external trust dependency and no second agent-auth root: the agent is authenticated solely by its attested SVID at ingress;llm.credential_mode/llm.upstreamgovern only what llmproxy does after it already knows who the agent is.
Model governance (bd_2_0-b06.6) adds a fourth enforced property: a ratchet model allow/blocklist resolved on the same scope resolver as destination policy (internal/policy, KeyLLMModels/ResolveModels/EvaluateModel). Across platform→org→group→agent, denies are unioned and each scope’s allowlist is an independent gate — a lower scope can only further restrict, never re-permit a model a higher scope blocked (the platform deny is the immutable floor) nor widen a higher allowlist. llmproxy extracts the model from the request body (the top-level "model" common to all three formats) only when model policy is active — the ungoverned path never buffers and streams the body straight through — and a blocked model is fail-closed: an RFC 9457 403 to the agent, audited with the deny reason and severity, never forwarded. A Severity ladder (log→warn→redact→block) is wired with only its ends active in v1, reserved for the future content-inspection layer (bd_2_0-apa.7). Provider governance (bd_2_0-1x1) is the sibling ratchet on the same resolver (KeyLLMProviders/ResolveProviders/EvaluateProvider): a per-org allow/blocklist over the shared llmreg provider set that further restricts which known providers a scope may reach on top of the code allowlist. It is evaluated on the host-resolved provider (no body buffering needed) before the upstream is resolved, dialed, or credentialed, and a blocked provider is fail-closed (RFC 9457 403, audited with the deny reason, never forwarded). Like the model ratchet it can only narrow the reachable set — a lower scope never re-permits a provider a higher scope denied — so it only ever tightens the trust boundary, never widens it. The other llmproxy-governed fields — llm.credential_mode and llm.upstream — are sibling keys in the same snapshot resolved via the scalar resolver; all are enclave-local keys pending promotion to authored well-known keys with a controlplane authoring UI.
SSE streaming (bd_2_0-b06.5) is transparent passthrough, not buffer-then-replay, whenever translation is not engaged (flag off, formats match): a text/event-stream response is forwarded byte-faithfully with a per-chunk flush (reverse-proxy FlushInterval -1), and the gateway’s return-leg splice is a raw io.Copy, so neither hop introduces head-of-line buffering — no new exposure, and no reassembly (full-conversation audit/content inspection is deferred to bd_2_0-8kl.6 / bd_2_0-apa.7, which need the reassembled message). When the translation mode below IS engaged on a streaming response, the events are translated per-chunk in the same flush-per-event cadence — still not buffer-then-replay — but the stream is no longer byte-faithful; see below.
Response inspection (llm.inspection, feature llmproxy-response-inspection) adds a policy-gated response leg: when (and only when) the llm.inspection snapshot key resolves for the agent’s scope, llmproxy buffers the ENTIRE upstream response (JSON or SSE, bounded by the same 25 MiB maxModelBodyBytes cap; over-limit fails closed with a problem, never a truncated forward) before the agent sees a byte, runs the plane-neutral internal/inspection pipeline (detectors → ordered inspectors), and applies the verdict: allow forwards the buffered bytes byte-identical; redact (v1 JSON-only — a redact on an SSE stream fails closed rather than attempting in-stream rewriting) forwards the replacement with a recomputed length; reject/deny/timeout return an RFC 9457 problem shaped by the operator disclosure flag (--inspection-disclosure: detailed names the reason, opaque sends a generic problem — operators and the audit always see the detail). An escalate verdict holds the response and hands it to the escalation backend; M1 ships a fail-closed LOCAL backend that denies at its timeout (--escalation-timeout, default 5m), so an ungated hold always resolves to a deny the agent can react to (the M2 broker client replaces it). Trust-boundary implications: no new boundary crossing — the pipeline runs in-process in llmproxy over bytes it already terminates (§4.1 footing), reads no new secrets, adds no egress, and the controlplane learns nothing (the key is authored CP-side, but only its enforcement is enclave-side, like every snapshot key). Ungoverned agents are untouched: key absent ⇒ no directive on the forward plan ⇒ today’s byte-faithful streaming, zero buffering, zero audit events (the fail-safe default, matching model/provider verdicts). Fail-closed surfaces: a malformed llm.inspection value denies the call (never partially applied), a pipeline/backend error denies, and a held response never leaks a byte pre-verdict. Audit: per governed run one llmproxy.inspection.verdict event (allow/redact telemetry, reject/deny authority per the J2 pattern), plus one llmproxy.escalation.register and exactly one llmproxy.escalation.decision (authority tier) per escalation; attrs carry identity, coordinates, verdict, and tool NAMES only — tool arguments and prompt/response content never appear in event attrs (invariant 2; asserted by test). The buffering cost for governed agents is opt-in with the key, bounded, and the deliberate ceiling documented in internal/enclave/llmproxy/inspect.go.
Broker approvals service (feature broker-approvals-api) turns an escalate hold into a human decision loop, entirely inside the enclave. The satellite leg rides the broker’s existing mTLS channel (§4.3’s local-issuer channel, the secret-read pattern): llmproxy presents a broker-issued client cert and that cert IS the authentication — no bearer exists or is accepted on POST /api/v1/internal/approvals (registers a pending approval; the broker mints the uuid id) or GET /api/v1/internal/approvals/{id}/decision (long-poll until the row is terminal). A caller without a broker-issued cert cannot complete the TLS handshake, so the registration surface is unnameable from outside the enclave namespace. The operator leg rides the broker operator listener (§4.5’s bearer + org-gated surface, fronted by the relay once allow-listed): GET /api/v1/approvals (list), POST /api/v1/approvals/{id}/decision (approve/deny; decided_by is taken from the validated bearer principal, never the body; an already-decided or expired approval conflicts with 409, an unknown id is a 404 problem), and GET /api/v1/approvals/stream (an SSE live feed of pending + decisions, mirroring the audit stream’s heartbeat + unbuffered flush). Trust-boundary implications: no new boundary and no new trust dependency — the approval rows live in the enclave’s own Postgres (enclave KMS/DB trust domain, §4.6), the decision loop never dials the controlplane (invariant 2), and tool-call arguments (sovereign content) are visible only to the in-enclave operator views and SSE feed, never to the controlplane and never in audit attrs (audit events carry tool NAMES and ids only; invariant 2). Fail-closed: expiry is enforced by a broker-side sweep (pending past expires_at becomes expired, timeout = deny), a decision applies only from pending (terminal state is never rewritten), and the operator’s approve/deny is durably recorded at AUTHORITY tier (capture-or-deny: broker.approval.decide is spooled before the decision applies — the same discipline as grant authoring); registration and expiry transitions are telemetry-tier events in the same registry. The disclosure-toggle assumption (what the AGENT sees on deny/timeout is covered above in the M1 paragraph) is unchanged by this feature — the broker surfaces detailed reasons to OPERATORS in both modes.
Cross-format translation (bd_2_0-llmproxy-integration) — a documented shift from byte-faithful passthrough. Originally llmproxy never translated between wire formats: every request/response body crossed it byte-for-byte. A config flag (--translate / DVX_TRANSLATE, config.LLMProxy.Translate, bd_2_0-llmproxy-integration) now admits an opt-in cross-format translation mode: when the classified client format (the shared internal/enclave/llmreg route table) differs from the resolved upstream’s expected format, llmproxy reads the sovereign request body, translates it through internal/translation (a plane-neutral package, no new trust dependency, stdlib-only), rewrites the request path to the upstream format’s endpoint, forwards it, and translates the upstream response, stream, and error back into the client’s format. Trust-boundary implications:
- The flag is the control. Off (the default) is byte-identical to the historical passthrough proxy; same-format traffic stays byte-transparent even when the flag is on. An operator turning the flag on is the sole act that widens what llmproxy does to sovereign bytes — there is no silent engagement, and no remote or controlplane-driven switch to flip it (
llmproxyreads no config from the CP; invariant 2). - Translation reads and rewrites sovereign bodies in the enclave plane. This is an in-sovereign-boundary intensification of an already-accepted exposure, not a new boundary crossing: llmproxy already sees plaintext prompt bytes (the same footing as the gateway’s MITM, §4.1). The translation code reads the full body (buffered under the existing
maxModelBodyBytescap for requests, per-chunk for SSE), so the parsed conversation is transiently in llmproxy memory — it is not persisted, logged, or sealed anywhere new, and capture still sees exactly what the agent sent and received. Structured translation errors fail closed as an RFC 9457problem.Problemto the agent — a body llmproxy cannot parse or translate is never forwarded half-translated. - Sovereign data still never reaches the controlplane. Translation adds no new trust dependency, egress path, or secret: it is an in-process transformation between the agent and the provider, both already in the sovereign plane (invariants 1/2 unchanged). All existing governance still applies and applies to the translated flow: the model is extracted from the (translated-or-not) body before forwarding, provider governance is unchanged (host-derived, pre-dial), credential inject/passthrough operates on the outbound request after translation, and the capture tee shadows what the agent actually receives.
- Residual shift. A bug in the translation code is a new failure mode the passthrough proxy did not have: a mistranslated body could alter what the provider sees (integrity of the agent’s conversation), and the buffering for non-streaming translation temporarily holds a full body in memory (bounded by the same 25 MiB cap as model governance). Mitigation is differential testing of
internal/translationagainst committed litelm 0.5.3 corpus fixtures and the fail-closed error mapping above; the trade is accepted because without translation a format-mismatched call simply cannot use a governed upstream at all.
What remains: gateway classification is still cheap host+path only — an inference call to an unknown host is not yet recognized (it splices to the real upstream as before — the fail-safe direction, not a regression), pending the body-shape tier. The known-provider set now includes factory.ai (bd_2_0-4bf) alongside Anthropic and OpenAI: its /api/llm/a/v1/messages and /api/llm/o/v1/{chat/completions,responses} paths front Anthropic-wire (Bedrock) and OpenAI-wire (Baseten) inference on one host, so they are classified + force-routed to llmproxy exactly like the first-party providers — the same SVID-mTLS attribution, the same fail-closed posture — with llmproxy resolving the api.factory.ai upstream from the gateway-attested SNI (never the agent Host) and preserving the /api/llm/… prefix. It also now includes Hetzner (bd_2_0-izh, inference.hetzner.com), an OpenAI-wire-compatible provider. Hetzner forced the routing to become host-derived: it serves the OpenAI chat wire under both /api/v1/… and the bare /v1/…, so its paths collide with OpenAI’s — a path-only classifier would have misrouted a Hetzner call to api.openai.com and leaked the injected sovereign llm.api_key. The provider is now resolved from the gateway-verified SNI (cross-checked against the agent Host, fail-closed on mismatch) through the shared internal/enclave/llmreg allowlist; the path selects only the wire format. Hetzner authenticates with Authorization: Bearer <token>, so it rides the default passthrough mode untouched and needs no new standing secret; an org that opts into inject supplies the Hetzner key as the sovereign llm.api_key. Factory’s real auth is the agent’s own Authorization: Bearer fk-…, so it rides the default passthrough mode untouched (no new standing secret; the X-Api-Key: placeholder the agent also sends is a dummy, not a credential). Factory’s non-inference control plane (/api/sessions/*, /api/cli/whoami, /api/billing/*, …) is deliberately not classified: those stay direct, destination-policy-gated egress rather than being force-routed. This graduates factory.ai from the bd_2_0-uwi HTTP-capture path (now the general fallback for unsupported upstreams) to a first-class governed provider. llmproxy sees the agent’s plaintext prompt bytes and, in inject mode, the sovereign llm.api_key — an enclave-plane exposure acceptable only because it is sovereign-plane and never dials the controlplane (invariants 1/2), the same footing as the gateway’s own plaintext MITM.
Residuals opened by the deploy (bd_2_0-b06.7). Three, each tracked rather than hidden:
- A second enclave egress path. By decision C llmproxy egresses directly to the provider, not through the agent gateway (it attributes to llmproxy, not an agent, so re-routing it through the gateway would loop). This is a real trust shift: the enclave now has an egress the gateway’s destination policy does not mediate. It is bounded by llmproxy’s own discipline — the upstream is resolved from the format llmproxy parsed (never the agent
Host), an unresolvable upstream fails closed (502), overrides arehttps-only, and a Cilium policy scopes llmproxy’s world egress to:443— and it is narrower than an agent’s own would be (one process, provider endpoints only). The deeper content-inspection of these prompts is the deferred work below. World egress is therefore not confined to llmproxy: the network gateway is itself the enclave’s primary world-egress mediator (bd_2_0-7ie), holding its owntoEntities: worldon:80/:443(dvx-gateway-world-egress). That is intended and policy-gated, not an anomaly — the gateway MITMs the flow and its layered destination engine (default-deny + org allowlist) is the control point deciding which world destinations are reachable, so the datapath allows the dial and the policy engine restricts it (the two must agree; aworldCNP that second-guessed the engine would only turn an allowed flow into a silent Cilium drop — the exactdial upstream … i/o timeoutbug this fixed). The two world paths thus have distinct control points: the gateway’s is its destination policy over arbitrary allowed egress; llmproxy’sworld:443is specifically for governed inference to supported providers (Anthropic, OpenAI, factory.ai, Hetzner) that were classified and force-routed to it. Supported-inference destinations are the one carve-out the gateway does not dial directly — they are force-routed so llmproxy’s model/credential governance applies. (Sealing captured bodies would give the gateway a further egress to the enclave KMS:8200; HTTP capture is off by default and undeployed, so that CNP is deferred until it is enabled —bd_2_0-uwi.) - Single-node agent-CA trust (dev). llmproxy is a Deployment that verifies force-routed SVIDs against the node-local agent-identity CA the imds mints, so a live deploy co-schedules it with the imds/gateway (podAffinity). A multi-node enclave would need a shared agent CA (
bd_2_0-d5n) or an llmproxy DaemonSet, so a satellite on node A can verify an SVID minted by node B’s imds. The dev single-node-agent placement sidesteps this; production does not. - Audit completeness (§4.7). The live verify surfaced that the gateway’s
gateway.egressevents (includingmediation:llm-route) are visible on the stdout audit stream but are not landing in the ClickHouse audit store (WAL empty; other components’ rows present) — an audit-evidence gap filed asbd_2_0-xd1, not a datapath fault. Until fixed, the durable network-flow evidence for force-routed calls is incomplete. Separately, the “audit the name, never the value” guarantee this boundary relies on for credentials is now extended to all request headers (bd_2_0-xs0): both mediation points capture the agent’s egress headers, obfuscate well-known secrets to name(+salted hash), and seal the rest per-org — see §4.7 for the full treatment.
Governance authoring is also still partial: llmproxy enforces llm.credential_mode/llm.models/llm.upstream (verified live), but there is no operator CLI to author those keys yet (bd_2_0-vj6), so during the verify they were injected by hand into the pulled snapshot. This is an authoring-surface gap, not an enforcement weakness — the enforced fail-closed behavior is what the boundary depends on.
Deliberately out of scope. Kata VM / hypervisor / TEE escape and CNI-datapath compromise (below this boundary — Plane 3 / substrate). Attestation freshness and the no-TEE fallback posture (open questions in docs/agent-identity.md). The controlplane→enclave metadata channel that delivers governance policy and reference values (a different boundary; only platform-owned metadata crosses, never sovereign data). Correctness of the authored policy content itself (layered resolver in internal/policy) versus its enforcement here. Side channels and covert-timing exfiltration through otherwise-allowed, inspected destinations. HTTP/2 and non-HTTP injected-destination handling on the TCP MITM path is a named follow-up (gateway.go, mediateTLS: forged leaf offers no ALPN, agents speak HTTP/1.1); the llmproxy ingress is likewise HTTP/1.1-only in v1 (the gateway’s forged leaves offer no ALPN, so agents speak 1.1).
The VM mediation datapath (mission bd_2_0-cwnz M4) — off-guest enforcement for the local micro-VM driver. The K8s rows above describe the Cilium→Envoy→gateway path. On the local host-process enclave (M3 substrate, §4.2) there is no Cilium; the same invariants are enforced by an nftables datapath, and the trust shifts it introduces are these:
- The enforcement point is nftables on the host bridge, not Cilium eBPF. The persistent bridge
dvx-br0carries one tap per agent VM; a TPROXY rule (iifname "dvx-br0" meta l4proto tcp … tproxy, themeta l4proto tcpmatch is REQUIRED on this host’s nft) steers all agent TCP egress into the gateway’s transparent TCP listener (--tcp-listen,tcp_linux.go,IP_TRANSPARENT), which recovers the original destination from the accepted socket’s local address (TPROXY preserves it; plainredirectwould not) and reuses the SAME mediation core (mediate: MITM, policy, audit) as the Envoy front end — full K8s parity, zero payload-side configuration. Guest DNS rides a sibling udp/53 TPROXY rule into a transparent forwarder (infrastructure, not governed egress — the same posture as Cilium’s DNS proxy). Because the datapath rules are the enforcement, they are managed ONLY by the dvx root helper (sudo -n dvx net-helper …), never by ad-hoc privileged commands from driver code, and the driver installs them before any VM with a network device boots (a helper failure fails the provisioning pass — an unmediated egress path never comes up half-built). - The root helper is the one privileged entry point.
dvx net-helper(bridge/tap/nft verbs,internal/vmnet) is the ONLY dvx surface that requires privilege; it is minimal, idempotent, auditable, and invoked exclusively assudo -n <dvx> net-helper …by the unprivileged driver. It joins the local TCB (with KVM + the CH binary + the guest kernel, §4.2’s M3 entry): a compromised host defeats it like everything else local; a compromised driver cannot gain privilege beyond whatsudo -n dvx net-helperitself grants, and the verb set cannot create generic network state (only the dvx-br0 datapath pieces). The gateway’s transparent listeners needCAP_NET_ADMIN(IP_TRANSPARENT), so on this shape the gateway process runs with that capability — the listener is the second dvx-owned privileged surface; both are disclosed here rather than hidden. - Attribution comes from the driver’s own VM→IP records (no Cilium). The broker assigns each guest its IP at provisioning (the same provenance-by-construction as its vsock CID); the mapping’s
vm-ipmetadata is the gateway’s attribution index (internal/enclave/attribution/filevm.go), and attribution stays fail-closed: an IP no record claims resolves to no identity and is denied under the sameno_identityfloor as the K8s path. The addresses are host-local bridge subnet addresses, never routed off-host, so IP-spoofing from inside a guest can at worst mis-attribute within that guest’s own enclave datapath — the same residual the Cilium path accepts (in-kernel validation) is here bounded by the guest boundary: the spoofer must already be a VM the broker provisioned, and mediation still applies the (now foreign) identity’s policy and audit, which is visible. - Lifecycle leaves zero residue. Tap removal rides agent teardown, and the enclave-shutdown verb (
net-helper teardown, called byvm-enclave-down) removes the bridge, taps, and nft rules — after shutdown,ip link+nft list rulesetcarry no dvx state (VAL-MED-004), so the privileged datapath exists exactly as long as the enclave does.
4.2 · Attestation provisioning root (Plane 3) & substrate trust
Plane 3 is the one moment the workload is trustworthy: before the broker mints an agent’s IMDS socket (Plane 1) or installs its Cilium-identity mapping (Plane 2), it demands a TEE remote-attestation report and verifies the measured guest against published reference values. Only a pod whose measured guest matches gets a socket and a mapping. Planes 1 and 2 are attribution mechanisms — cheap and ambient at runtime; Plane 3 is what makes their answer true. The strength of the whole agent-identity story is exactly the strength of this anchor, and that strength varies by substrate because the customer, not the platform, chooses where the enclave runs (docs/agent-identity.md, Plane 3). The governing principle is discovered, not assumed: attestation capability is discovered per enclave at runtime and carried honestly as a Tier inside the minted credential, never assumed to be stronger than the hardware proves.
What it protects. The provisioning binding — this measured guest, in genuine confidential hardware, is agent N of org M and nothing else; the measured Daevix envelope (Kata guest kernel, guest agent, injected CA/egress runtime), never the unmeasured BYO payload; the attestation policy itself (ReferenceValues: allowed TEE types, allowed measurements, TCB/SVN floors, internal/attest/attest.go); and the honesty of the assurance label — a TierNone environment must never present itself as hardware-attested (internal/enclave/agentid/tier.go).
Adversaries. A compromised/hostile agent that wants a look-alike guest attributed as the measured envelope; a malicious or careless cloud/substrate operator who owns the layer below the attested boundary (hypervisor/firmware on cloud, physical/BMC on bare metal); a network MITM on the guest→verifier path; a coerced/compromised controlplane operator who authors reference values; and a build-pipeline insider who can influence what “known good” means.
Attacks & mitigations.
| Attack | How it is tried | Mitigation (fail-closed default) |
|---|---|---|
| Forge a report | Present a fabricated TEE quote for a guest that is not the measured envelope. | Stage-1 hardware-trust step parseAndCheckSignature chains the signature to the silicon-vendor root; a non-verifying signature is ErrBadSignature (internal/enclave/attest/verify.go). A report we cannot even attempt to trust — an unregistered tee_type — is ErrUnsupportedTEE, never accepted by default (Set.Verify). |
| Replay a genuine report | Capture a real, valid quote and replay it for a different guest or a later boot. | The verifier issues a live challenge nonce bound into report_data; a mismatch is ErrStaleNonce. Fails closed when the expected nonce is empty (verify.go). |
| Rollback / downgrade | Present a genuine quote from a guest on known-vulnerable firmware/TCB. | Stage-2 evaluate enforces MinTCB/MinSVN floors → ErrTCBTooLow / ErrSVNTooLow (verify.go); floors carried in ReferenceValues (internal/attest/attest.go). |
| Substitute the substrate | Run the enclave on a no-TEE box (or plain cloud instance) and hope software-signed “sample” evidence is accepted as hardware. | TEESample maps to TierNone unconditionally (TierFromClaims, tier.go), and TierNone fails any hardware floor closed (AtLeast, zero value is weakest). |
| Widen the allowlist | Get TEESample or SampleDevMeasurement into a production ReferenceValues, or add an attacker-controlled measurement. Highest-leverage attack — turns the gate off without touching a crypto primitive. | Defended organizationally plus fail-closed defaults: an empty Measurements list matches nothing, so every report is rejected — and separately containsFold also returns false on an empty measurement (want == ""), so both fail closed (verify.go); TEESample/SampleDevMeasurement are documented as MUST-NEVER-appear-in-production (internal/attest/attest.go); reference-value distribution is authenticated, one-directional pull with keep-cache-on-error (internal/controlplane/attestpolicy, internal/cpchannel, internal/enclave/cpsync). |
| Attack from below the boundary | Own the hypervisor/firmware (cloud) or physical host/BMC (bare metal) and read/tamper with guest memory beneath what the TEE measures. | Not “defeated” — scoped and disclosed per substrate (see root-of-trust below). On node-cvm the cloud provider is in the TCB; a customer who will not extend that trust runs bare metal (vendor-PKI-only). Residual trust, accepted and labelled, not defended. |
| Node-vs-agent scope confusion | On a node-level CVM, use the fact that one hardware root covers many agent VMs to move laterally, or claim per-agent assurance the report does not prove. | Conservative tier derivation: TierFromClaims maps any genuine hardware TEEType to TierNodeCVM, never straight to per-agent-tee (tier.go); only per-environment verifiers that know each agent VM was individually quoted refine upward. Kata VM isolation is defense-in-depth within the node. |
| Strip the report entirely | Provision with no evidence and hope for default-allow. | Fail-closed dispatch: absent/unregistered tee_type is ErrUnsupportedTEE; no verified Claims, no socket, no agent (verify.go). |
Reduced-assurance is honest, never silent. The only verifier that ships today is sampleVerifier (internal/enclave/attest/sample.go, TEESample): it exercises the real freshness + evaluate policy logic against a local test key but proves nothing about hardware. Both planes announce it in logs, and TEESample / SampleDevMeasurement must never appear in a production ReferenceValues. Real hardware/token verifiers (SEV-SNP, TDX, GCP/Azure/AWS) land in bd_2_0-che and refine tiers upward — designed-but-unbuilt today.
The two-stage verifier (one seam, every substrate). Verification is pure enclave-local; the controlplane only ever publishes policy, never verifies (invariant 2). Stage 1 (parseAndCheckSignature) is vendor-specific hardware trust; stage 2 (evaluate) is the shared, TEE-agnostic reference-value check, so the policy logic has exactly one home and cannot drift per provider (internal/enclave/attest/verify.go). The neutral wire vocabulary (Evidence/ReferenceValues/Claims/TEEType in internal/attest/attest.go) lets a raw-quote verifier and a cloud token-validating verifier emit the same attest.Claims; the provider blob rides verbatim in Evidence.Raw. The policy layer never learns which silicon produced the report.
Root of trust, per substrate. The binding chains downward and differs at exactly one link — who signs the root:
- Bare metal (customer-run). Root of trust is the silicon vendor PKI only — Intel PCS (TDX) or AMD KDS (SEV-SNP); measurement is the MRTD (TDX) or launch digest (SEV-SNP). Nothing below the guest is trusted that the vendor root does not cover. Smallest TCB, strongest posture, and the only substrate where a per-agent hardware quote is straightforward.
- Cloud confidential VM node. Root of trust is silicon vendor plus the cloud provider’s firmware/verifier: the cloud’s virtualization and firmware below the CVM boundary join the TCB. Reference values must include cloud-published firmware measurements, or you trust a cloud attestation-service signing key. Either way the cloud provider is inside your trust boundary.
- Local host (the micro-VM driver, mission bd_2_0-cwnz M3). The local-shape enclave runs agents as one cloud-hypervisor VM each on the operator’s own machine (KVM + the CH binary + the guest kernel join the local TCB), satellites as bare host processes. Provenance is deliberately NOT cryptographic: the broker’s own provisioning record IS the instance identity (it created the VM, assigned the vsock CID, and the IMDS channel is per-agent by construction — attribution by channel, the same plane-1 logic as the socket mount). There is no local provenance-CA theater: for the laptop-local model the host is in the TCB by definition, and the model says so honestly instead of minting credentials that claim otherwise. The class/tier story is unchanged: a
vm-class local enclave attests SAMPLE → assurancenone≤ classvm. Two deltas versus the K8s shape, both disclosed: (1) there is no Cilium — egress mediation for VM agents is the M4 bridge/tap + nftables TPROXY datapath (§4.1, “The VM mediation datapath”; it landed in M4 and removed the interim no-network-device posture); (2) the vsock host side is a plain unix socket (<vsock.sock>_<port>) in the enclave’s own state dir, so the “IMDS delivery channel” is filesystem-scoped by the state dir’s 0700 permissions rather than a kernel boundary — acceptable precisely because everything holding those files is already the TCB.
Nested virtualization ≠ nested confidentiality. Can we run Kata at all (nested virt) is a different question from does the inner Kata VM get its own hardware quote (nested confidential). A cloud enabling nested virt (to run Kata off .metal) does not by itself make the inner guest attestable. Two options: (1) attest the node, isolate the pod in software via Kata (the realistic near-term cloud target); (2) Confidential Containers / Trustee, attesting the pod-VM itself via SEV-SNP VMPLs / TDX partitioning.
Requested isolation class vs discovered assurance tier (bd_2_0-cwnz.6). Plane 3’s tier is discovered evidence about the guest that booted. It is now complemented — never replaced — by the isolation class: the operator’s requested, provision-time, structural envelope for the whole enclave (plain < vm < node-cvm < per-agent-tee, internal/isoclass), authored controlplane-side and delivered to the broker over the authenticated cpsync channel like reference values. The trust properties:
- The broker’s IsolationGate is fail-closed and exact-class-or-refuse (
internal/enclave/cpbroker, driver seam): a driver provisions only when it can satisfy the enclave’s requested class exactly — no silent downgrade (anode-cvmrequest is never served as a runc pod) AND no silent upgrade (aplainrequest is never served at kata grade just because the node could; ordering exists only for assuranceAtLeastfloors). A missing class (never authored) and an unknown token are refused the same way — a garbled class is never interpreted as a weaker default. The K8s driver’s capabilities areplainandnode-cvm(the kata RuntimeClass check remains a hard precondition for the kata-grade class, not a downgrade opportunity); the local micro-VM driver’s capability isvm. - The refusal is operator-queryable status, not a log line. Because the class is per-enclave, one unsatisfiable class stalls ALL provisioning for the enclave — exactly why the broker’s operator API exposes the verdict (
GET /api/v1/status/isolation) naming the mismatch:requested=X, driver-capability=Y. An attacker or misconfiguration that renders the class unsatisfiable therefore produces visible, queryable silence, never a quietly weaker fleet. - The invariant
assurance ≤ classfails loudly. An attested tier can never honestly exceed the structural envelope the agent was provisioned into (isoclass.AssuranceExceedsClass); a violation is a bug and must be surfaced loudly, never absorbed. The inverse (class exceeds assurance — a TEE present but unverified) is by design. Deriving assurance FROM the class record is forbidden (attribution-as-auth: what was asked for is never evidence of what was proved). - The class travels as its own SVID/JWT claim, never as an overwrite of the attested tier. The broker authors the enclave’s class onto each AgentMapping, and the node-local mint carries it as a DISTINCT dvx-extension claim (
class) beside the attestedtier— the relying party always sees two independent facts (requested vs proved), and the SVID verify paths on both planes (agentid/agentcert) reject a credential missing or garbling either. Were the class allowed to overwrite the tier, a misconfigured (or lying) broker could mint a credential claiming hardware assurance the guest never proved; the distinct-claim rule closes exactly that laundering path. The policy floor default is unchanged: tiernoneMAY use LLM inference (thetier.goexample floor is not a shipped default).
Assurance tiers (the honest label, in code). Tier (internal/enclave/agentid/tier.go) is the discovered assurance carried in the credential. Higher is stronger; AtLeast(floor) is the relying party’s check; the zero value fails hardware floors closed.
| Tier (token) | What the hardware root covers | Typical substrate | What it proves / does NOT prove |
|---|---|---|---|
none (TierNone, zero value) | nothing — software-signed only | this dev box, plain EC2, any no-TEE node | Proves the plumbing, not the guest. Dev-grade. Must never satisfy a production floor. |
node-cvm (TierNodeCVM) | the whole node is a genuine SEV-SNP/TDX VM | GCP Confidential GKE | Hardware root for the node; Kata isolates agents within. Does not prove each agent VM was individually quoted. The realistic cloud tier. |
per-agent-tee (TierPerAgentTEE) | each agent’s Kata VM has its own hardware quote | Azure AKS Confidential Containers; bare metal | Strongest. Per-agent hardware isolation and per-agent attestation. |
Conservative derivation is deliberate. TierFromClaims maps any genuine hardware TEEType (SEV-SNP/TDX) to TierNodeCVM, never straight to per-agent-tee: a bare quote proves at least node-scope confidential computing, but the Claims alone cannot distinguish a node-level report from a per-pod guest quote — that depends on how the evidence was collected. The per-environment hardware verifiers (bd_2_0-che) refine the tier upward to per-agent-tee only when they know each agent VM was individually quoted. Under-claiming is the safe direction: a relying party never over-trusts because we guessed high.
Per-provider matrix. Knowledge cutoff 2026-01; instance families evolve — this table is an assumption, not a constant.
| Provider | Best attainable tier | Primitive | Notes |
|---|---|---|---|
| GCP | node-cvm | Confidential VM / Confidential GKE (SEV-SNP/TDX) + Confidential Space OIDC (Google-signed) | Strongest cloud story today; realistic near-term target. Token-validating verifier. |
| Azure | per-agent-tee | AKS Confidential Containers (per-pod SEV-SNP) + MAA tokens | Best fit for our BYO + Kata shape — per-pod hardware quotes. |
| AWS | (neither cleanly) | Nitro Enclaves (COSE/CBOR, vsock-only, no arbitrary OCI, no normal net); general EC2 exposes no SEV-SNP/TDX guest attestation | Nitro does not fit BYO agents. Nested virt is metal-ish and gives nested virt, not nested confidential. Weakest of the three. |
| Bare metal (customer) | per-agent-tee | Raw SEV-SNP / TDX quotes, vendor PKI only | Best case: smallest TCB, per-agent hardware quotes. |
Residual trust / assumptions. We trust the silicon vendor root (a break invalidates every tier at once). On a node-cvm deployment the cloud provider’s firmware/verifier is inside the TCB: an attacker who owns the layer below the attested boundary can read or tamper with guest memory beneath what the TEE measures — disclosed residual trust, which is precisely why a customer who will not extend that trust runs bare metal (vendor-PKI-only) or their own enclave. We trust that the measured envelope is the right one (uncompromised build pipeline), that reference-value distribution is authentic and one-directional, that boot-time measurement suffices for Kata VM-isolated pods (lifetime == liveness), and that production paths issue real freshness challenges. At node-cvm, a Kata/hypervisor escape breaks lateral isolation between co-located agents sharing one hardware root — the tier label says so honestly.
Deliberately out of scope. A compromised silicon vendor root (no layer below to appeal to). The customer attacking their own enclave (they are the data owner; every enclave is single-org, bd_2_0-j9r, so there are no shared-enclave co-tenants). Physical attacks on customer bare metal (customer’s physical-security responsibility; we rely on the TEE’s memory-encryption only). Malicious payload detection — we attest the envelope, never the BYO image; a hostile payload is contained by Planes 1–2, never detected by Plane 3. Microarchitectural side channels between co-located agents on a node-cvm node. AWS as a first-class per-agent-TEE substrate. Denial of service against the attestation service — fail-closed (no report ⇒ no socket ⇒ no agent) is an availability concern, not a trust-boundary breach; running agents are unaffected.
4.3 · Enclave ↔ controlplane boundary (broker identity, sole CP egress, metadata channel)
This boundary is the single seam between the central controlplane and a sovereign enclave running in a customer cluster. Every crossing is enclave-initiated (outbound SSE nudge + authenticated per-enclave pull) and carries platform-owned metadata down — governance policy, attestation reference values, control intents — plus, since bd_2_0-bvw, two enclave-owned credential-class crossings up (each node’s public agent-identity CA root for pinning, and the broker-relayed agent-SVID mint requests; see the mitigation entries below — never sovereign data). The target model narrows the enclave’s CP-facing trust surface to exactly one identity per enclave: the broker’s Zitadel machine user, to which enclave.subject binds (the gateway’s bvw.5 data-hop exception is bounded below). The controlplane never dials an enclave; the enclave never mints an identity the controlplane trusts; and sovereign data never crosses in either direction (invariants 2 and 5).
What it protects. The per-enclave scoping of pulled metadata (one enclave must not read another’s snapshot), the controlplane’s small per-enclave trust surface (one identity, not N), the integrity of the CP→enclave metadata cache the data plane serves from, and the one-way, no-inbound-reachability shape that keeps a NAT’d customer cluster sealed.
Adversaries. A malicious or compromised peer enclave holding a valid enclaves:read credential; a compromised broker inside one enclave trying to widen its blast radius; a network attacker between enclave and controlplane; an operator misconfiguration that leans on the dev shared-key shortcut; and anything trying to induce a controlplane→enclave dial-in.
Attacks & mitigations.
- Cross-enclave metadata read — enclave A, holding platform-scoped
enclaves:read, requests enclave B’s{resource}/snapshot. Mitigation:bindEnclaveIdentityruns inside the capability chain and requires the token subject to equal the subject bound to the{enclave}in the URL (internal/controlplane/iam/snapshot.go,Store.EnclaveSubject,enclave.subject, bd_2_0-30a). Fail-closed: subject mismatch is a 404, unbound enclave a 403, unknown enclave a 404. Capability alone is necessary but not sufficient. - Enclave enumeration / existence probing — a caller probes enclave IDs to learn which exist. Mitigation: a mismatch returns 404 (“no snapshot for enclave”), identical to the unknown-enclave response, so the endpoint never confirms another enclave exists to a caller not bound to it (
snapshot.go). - Broker impersonating another enclave to the CP — the broker tries to pull a different enclave’s scoped metadata. Mitigation: the broker holds exactly one Zitadel machine identity, self-minting an RFC 7523 JWT-profile
id_token(internal/authn/jwtprofile, consumed viacpsyncTokenSource); that subject binds to its ownenclave.subjectonly, and the samebindEnclaveIdentitycheck scopes every pull. Per-enclave CP trust surface stays at one (invariant 5;docs/enclave-broker.mdproperty 1). - Enclave impersonating the controlplane (shared key) — if an enclave authenticated with a clone of the controlplane’s own Zitadel machine key it would literally authenticate as the controlplane: one shared credential, wrong identity, huge blast radius. Mitigation: built (aoq.2).
Service.RegisterBroker(internal/controlplane/iam/broker.go) mints each enclave a distinct Zitadel machine userbroker-<name>(the enclave’s unique friendly handle; its canonical id is a random, never-reused token, bd_2_0-49z), grants itenclaves:read, bindsenclave.subjectto it, and returns the key once;POST /api/v1/enclaves(api.go) anddvx enclaves register-broker(cmd/dvx/enclaves.go) drive it. Deleting an enclave tears the identity back down:Service.DeprovisionBrokerrevokes the broker principal’s grants and deletes its Zitadel machine user before the row is dropped (deleteEnclave, bd_2_0-10e), so no orphaned identity or danglingenclaves:readgrant survives and the login name frees for reuse. It is the canonical dev path —make register-broker-devis a prerequisite ofdeploy-enclave, and the shared-key clone path has no make recipe. Key delivery is out-of-band, never a cross-boundary ConfigMap. Still deferred (aoq.3): the dev namespace + Cilium NetworkPolicy split, and CA-verifying the register-broker mint step itself, which today passes--insecure-skip-verifyto the CP (Makefile). - Compromised broker forging CP-trusted identities — an attacker owning the broker tries to mint Zitadel/CP-trusted credentials (for sub-services, or to impersonate peers). Mitigation: invariant 5 — the broker is a local CA only.
internal/enclave/cpbroker/localca.goissues short-lived local mTLS leaves (CN = service name) that the controlplane never trusts; the CA key never leaves the broker. Adepguardfence (.golangci.yml, invariant 4/5) forbids anyinternal/enclavepackage from importing the Zitadel management client, so the minting authority structurally cannot cross the boundary. Blast radius of a broker compromise is bounded to that one enclave’s own metadata — never CP identity issuance or peer enclaves. - Poisoning the CP→enclave metadata cache — inject or replay a snapshot (e.g. one meant for another enclave) to corrupt what the data plane serves. Mitigation: snapshots always cross the authenticated per-enclave pull endpoint (the SSE stream carries only a nudge,
internal/enclave/cpsync/cpsync.go);FileSink.Validatecan reject a misrouted/unparseable snapshot before write, and a rejected or failed pull keeps the last-known copy (filesink.go,bootPull/Fetch), writing atomically via temp+rename. A bad pull never displaces a good one. - Sovereign data exfiltration up the channel — attempt to route tenant/sovereign payload to or through the controlplane. Mitigation: invariant 2 — the channel is metadata-down only; the enclave never sends sovereign data up, and its data plane has no hard dependency on CP reachability (keep-cache-on-error,
cpsync.go). A CP outage never stalls the sovereign path. - The agent-identity crossings are credential-class, never sovereign data (bd_2_0-bvw). Two new enclave-initiated crossings ride the same broker channel: each node’s imds publishes its agent-identity CA root UP for CP pinning (
POST /enclaves/{enclave}/agentid-ca, bd_2_0-bvw.4 — the root is public material, and pinning is a verification input: the enclave never gains issuance authority, invariant 5); and the broker relays the gateway’s agent-SVID mint requests UP (POST /enclaves/{enclave}/agent-tokens, bd_2_0-bvw.5) carrying only the SVID cert (the key never leaves the gateway), receiving a 60s token DOWN. Both are authenticated by the broker’s bound identity, gatedbindEnclaveIdentity-style by the path enclave, and audited (agentid_ca.pin,agenttoken.mintwith actor/actor_kind). Nothing sovereign crosses in either direction — a credential and a public root, the same class as the existing sshca channel. - The gateway’s CP-bound data hop — the one deliberate widening, bounded (bd_2_0-bvw.5). For agent→operator-API egress the gateway (not the broker) dials the controlplane directly on the re-originated hop, admitted by a narrow CiliumNetworkPolicy (
dvx-gateway-controlplane-egress, 8443 only; CP ingress admits the gateway alongside the broker). The widening is data-path-only: the broker remains the enclave’s sole identity/policy/metadata egress, and the gateway hop is credential-carrying (the minted agent-operator bearer, gateway-presented) but never carries sovereign data (it fronts the operator API — CP metadata by construction, invariant 2). The agent itself still cannot reach the CP: the per-agent egress-redirect forces its CP-bound TCP into the gateway listener (an explicit east-west rule, bd_2_0-bvw.7’s live-verify fix), and a CP-bound plaintext hop is denied outright (must be TLS-mediated). A contained agent is refused at the mint (broker gate, before the CP) and the gateway drops the cached token — the hop fails closed on every leg (live-verified, bd_2_0-bvw.7). - Forcing an inbound CP→enclave dial — try to make the controlplane push to, or spoof control into, the NAT’d enclave. Mitigation: enclave-initiated-only (invariant 2); control intents are pulled + reconciled, never dialed (
docs/client-interaction.md§3). Even address discovery is enclave-announced and pinned trust-on-first-use to the bound subject —POST /enclaves/{enclave}/addressis gated by the samebindEnclaveIdentity(internal/controlplane/iam/api.go,announce.go), best-effort, with an operator override authoritative. - Rogue intra-enclave satellite pulling redistributed metadata — a pod in the enclave namespace pulls the broker’s re-served
{resource}/snapshot. Mitigation: aoq.5 puts local mTLS (RequireAndVerifyClientCert) on the broker’s local channel, admitting only satellites presenting a broker-issued client cert (localca.go). Note this is inside the enclave and layered onto the same endpoint aoq.4 shipped as one-way TLS sealed by the egress NetworkPolicy — so mTLS is the in-progress hardening of an already-network-sealed hop. Delivery shape (bd_2_0-cwnz m3): in the non-K8s (host-process) enclave shape the broker’s local-issuer material — the persistent CA and each service’s client identity — is delivered as0600files under the enclave’s state dir (--issuer-state-dir, atomic temp+rename writes) instead of Kubernetes Secrets; the same local-CA trust properties hold, with filesystem permissions replacing the Secrets/RBAC boundary, and a host-process satellite presenting a broker-minted client cert is the identical admission decision. - Enclave leaning on the controlplane for an authorization decision (the boundary this epic strengthens, bd_2_0-bs1). The enclave now has its own authorization plane for its operator-facing management surface (audit read, secret authoring, and — new — enclave-local grant authoring). Property (invariant preserved/strengthened): the broker authorizes every such request from validated token claims + its OWN enclave-local
grant_store (internal/enclave/enclaveauthz, bd_2_0-bs1.2), evaluated by the same plane-neutral resolver the controlplane uses (internal/authz) — one resolver, two physically separate stores that never cross. It never consults or trusts the controlplane grant table, never accepts a controlplane authz assertion, and still never dials the controlplane (invariant 2; adepguardfence keepsinternal/enclavefrom importinginternal/controlplane). A platform operator’s controlplane-sideplatform:audit:readgrant therefore confers nothing here: with no covering enclave-local grant the answer is a fail-closed deny (enclaveauthz.EnclaveCapabilitiesdeliberately excludes everyplatform:*verb). - Coarse “any authenticated org member reads the sovereign audit” (bd_2_0-zb4.3). Previously the broker’s audit API was gated by bearer + audience only. Now it is gated by the same server-forced persona scoping the controlplane applies (
cpbroker/auditscope.go, the enclave sibling ofRequireAuditRead):audit:read→ own-actor forced (Actor="user:<sub>", un-attributed rows excluded),audit:read:all→ all-actor within this enclave, no enclave-local audit grant → 403. There is deliberately no cross-enclave/all-orgs tier —platform:audit:readis inert here and “own enclaves” is a per-broker reachability property (a client fans out one call per broker; no aggregation). Fail-closed all the way down: without the enclave grant store the audit API is not mounted at all (a bearer alone no longer reads sovereign audit). - Minting controlplane-only or platform authority into an enclave via grant authoring (bd_2_0-bs1.3). An org admin authors enclave-local grants over the broker operator API. Mitigation: the authorization org is always the token claim (never a body field; the coordinate discipline of §4.4 carried to this plane),
members:read/members:managegate list vs author, andvalidateEnclaveGrantrefuses any capability outsideenclaveauthz.EnclaveAllowed(the CP-only verbs stay inert) and any role other thanmember/admin(theplatform-operatorrole is refused) — somembers:managecan never mint authority the enclave plane does not recognize, and never platform authority. Cross-tenant revoke surfaces a 404. Every authoring action and every denied attempt is audited to the enclave’s own stream (local, customer-visible ACL history) — the enclave-plane half of reciprocal transparency (acb.6) is inherent: reads and grant-authoring are pull-to-the-enclave, so they land in the owning org’s own audit, visible to it. The initial org-admin grant is seeded at provisioning time from broker config (SeedAdminPrincipal/OwningOrg), which is deploy-time configuration, not a runtime controlplane trust or dial. - Foreign-org operator token reaching an enclave it does not own — an operator holding a valid token for org A calls the operator API of an enclave owned by org B. Mitigation (built, bd_2_0-bs1.5): two layers. Every enclave operator route force-scopes to the caller’s own org claim (an org-A token could only ever touch org-A coordinates, meaningless in org-B’s store — never org-B’s rows); and, on top of that, an explicit single-tenant gate (
requireEnclaveOrg,internal/enclave/cpbroker/orggate.go) wraps every operator route and refuses outright with a 403 when the validated token’s org claim ≠ the enclave’s configuredOwningOrg— a foreign-org token is turned away at the door, not merely scoped to nothing. The 403 is indistinguishable from an unauthorized one (it never confirms the enclave’s owning org to a foreign caller). The gate is inert only whenOwningOrgis unset (a dev broker started without a provisioning seed), where the force-scoping alone still applies; a production broker setsDVX_ENCLAVE_OWNING_ORGand gets the explicit check. - Secret-VALUE floor — preserved unchanged. Enclave-local grants govern only the management / audit / metadata plane; there is no token-reachable secret-value read path, and none is added by this epic. Sovereign secret values are served only to attested agents over the IMDS socket (§4.1) and are never IAM-governed — no grant at any scope yields sovereign plaintext (§4.6). The grant-authoring and audit surfaces touch metadata about authority and events, never secret bytes.
Residual trust / assumptions. We trust that the broker’s key is delivered out-of-band and custodied inside the enclave; a party that owns the broker host owns the metadata that enclave already holds and can mint local certs, but cannot mint CP-trusted identities or read a peer enclave. For the bvw credential-class crossings we trust the agent-identity CA root pinning (bd_2_0-bvw.4): the CP pins each announced root trust-on-first-announce, so a root swap would be identity forgery — rotation must announce-and-pin the new root before the old expires, and a pinned-root change is audited (agentid_ca.pin events, visible per-enclave). We trust Zitadel as the CP issuer and that the enclave verifies the CP’s TLS/CA (aoq.3 replaces the dev INSECURE skips with a CA-verified pull). In dev the bound subject is a dedicated per-enclave broker machine user minted by register-broker — identity separation is in place, and bindEnclaveIdentity passes on genuine per-enclave identity, not a shared one. The remaining accepted dev-only weakness is that the register-broker mint bootstrap still uses --insecure-skip-verify against the CP (aoq.3), not that the identity is shared. For the enclave’s operator-facing surface we today trust the same Zitadel issuer the controlplane uses to authenticate operator tokens (authorization is already enclave-local, above). Enclave-local authn (bd_2_0-bs1.4, future) moves that issuer trust to a customer-controlled IdP, further shrinking the TCB: a compromised controlplane operator who tampered with the platform IdP could then gain no enclave access at all, since neither the enclave’s authentication nor its authorization would depend on any platform-side identity system — a strengthened boundary, tracked, not yet built.
Deliberately out of scope. Agent identity and attestation (the agent↔enclave/IMDS boundary, invariant 6, agent-identity.md); the direct client↔enclave sovereign-data transport (bd_2_0-kzb); data-at-rest key custody and the enclave/controlplane KMS split (covered under Data at rest); the internal correctness of controlplane capability authorization beyond the per-enclave binding; and full intra-enclave broker→satellite issuance mechanics beyond noting the local-CA/mTLS boundary.
4.4 · Operator ↔ controlplane (IAM: authn, authz, operator API)
This boundary is the human/service-principal front door to the controlplane’s own management surface: the operator API (internal/controlplane/iam) that reads and mutates governance-coordinate rows (org, "group"), grants, enclave registrations, and policy/config. Authentication is delegated to a self-hosted Zitadel (internal/authn/oidc); authorization — the capability × scope decision — is owned entirely by the controlplane (internal/authz). The boundary matters because it is the one place a caller’s asserted identity is turned into authority over tenant coordinate rows, and it is IAM-only: it never touches sovereign data and the enclave never calls up here for a per-request decision (docs/iam.md §1, §8).
What it protects. The controlplane’s authorization state and coordinate space: grant_ rows (who holds the platform-operator role, admin, or any capability at which scope), the org/"group" coordinate rows — now carrying a kind enum (platform | customer) that marks the single, non-data-bearing platform org — and their zitadel_org_id/zitadel_group_id mappings, enclave-registry rows, the service_account rows (which machine principals exist, who owns them, and the key_id/retiring_key_id bookkeeping that keeps every live upstream credential tracked, bd_2_0-ugb), and the integrity of the “membership is Zitadel’s, authorization is ours” split. Cross-tenant isolation between orgs is the headline asset, and — since bd_2_0-acb.2 — the absence of any standing cross-org authority is a first-class property: no principal holds a capability that reaches inside another org’s data authority without an explicit, covering grant.
Adversaries. An unauthenticated network caller with a forged or stolen bearer token; a legitimately-authenticated low-privilege operator (a member, or an admin in one org) trying to widen authority or reach another tenant; a malicious insider racing destructive grant operations; an agent (or the enclave components fronting it) trying to exceed the agent-operator read-only blast radius — a forged or replayed agent-operator token, a compromised gateway presenting an agent’s SVID, or an agent named in a grant that scopes too wide; and — as residual trust — anyone who compromises the Zitadel deployment or its signing keys, or the controlplane’s agent-token signing key.
Attacks & mitigations.
- Token forgery via
alg:noneor HMAC confusion — attacker submits an unsigned token, or an HS256 token signed with the (public) JWKS modulus treated as a shared secret. Mitigation:verifySignature(internal/authn/oidc/token.go) accepts only theRS*andES*families;alg: noneand HMAC fall through tounsupported or unsafe algand are refused. Fail-closed: any signature failure collapses toauthn.ErrUnauthenticated→ RFC 9457 401, cause never leaked to the client (docs/iam.md§6). - Forged signature / unknown key — attacker mints a token with their own key, or an off-curve EC key to trip the verifier. Mitigation: the JWS is verified against
keys[kid]from the issuer’s JWKS; an unknownkiderrors (internal/authn/oidc/token.go), and EC keys are rebuilt only after an explicit on-curve check viacrypto/ecdh.NewPublicKey(internal/authn/oidc/jwks.go,onCurve). Non-sigkeys are dropped, and an empty usable-key set is a hard error rather than an empty allow. - Issuer / audience / replay confusion — attacker replays a token from another relying party or a stale token. Mitigation: the trusted issuer is checked exactly against
iss(per authenticator — there are now two, composed fail-closed: the Zitadel authenticator foruser:principals, and the controlplane’s ownagenttokenissuer for the agent-operator class,authn.Anyininternal/controlplane/controlplane.go; each token validates against exactly one and the resultingPrincipal.Kindcomes from which authenticator accepted it, never from a claim a caller could choose — the dvx one structurally pinsRequireKind=agent-operator);audmust contain the configured audience when one is set;exp/nbfchecked against the clock with bounded leeway (default 60s; the agent-operator class is 60s-issued, so leeway ≈ its whole life — replay inside the TTL is the accepted, bounded residual below); emptysubrejected (docs/iam.md§6, §2). No issuer-enumeration surface: neither authenticator accepts tokens for the other’s class. - Privilege escalation across capability × scope — a
member, or a capability holder at group scope, tries to act on a broader target. Mitigation:authz.Authorized(internal/authz/resolver.go) is true only if some grant’s scopeCoversthe target andconfersthe capability, using the sharedpolicy.Target.Coverscover rule; default-deny with no covering grant. Roles are fixed Go constants (internal/authz/roles.go), so no seed row can inject an unexpected capability. Agent-path targets participate in the hierarchy with its designed semantics (bd_2_0-ugb.8):RequireCapabilityenriches an agent-path target with the agent’s stored group from the agent registry before the grant load, so a group-scope grant covers agent routes for agents in the group — for human and service-account principals alike, including agents created after the grant. The enrichment is server-derived (never client input, so the path-only-target discipline holds) and fail-closed: an unknown agent, a cross-tenant id, an agent with no group, or a registry error all yield the un-enriched target, so a group grant never covers what the registry cannot vouch for. The agent collection routes (POST/GET /api/v1/orgs/{org}/agents, bd_2_0-oxk) name only the org, so they own the group-aware decision in-handler: create authorizesagents:manageover the union of the bodygroupbeing authored and — on an Upsert — the existing agent’s stored group, and list filtersListForOrgto the agents whose stored group the caller’s grants cover (org-scope read sees all; a group-scope reader never sees a groupless agent). The body group here is attribute-authoring gated by managed-group cover, so it only ever narrows (a caller creates only into groups it manages, and cannot overwrite or re-parent an agent stored in a group it does not manage — the takeover hole); both routes fail closed on a grant-load error. - Standing cross-org god-mode (the
platform:adminsuper-capability) — removed (strengthened boundary, bd_2_0-acb.2). Previouslyauthz.Grant.confersmatchedplatform:adminagainst every capability check, so a platform operator implicitly held god-mode over every org’s internal data authority — a large standing cross-tenant surface. Now there is no super-capability:confersis an exact match (internal/authz/resolver.go),CapPlatformAdmin/RolePlatformAdminare deleted, and platform authority is an explicit, closed set ofplatform:*verbs held by theplatform-operatorrole (internal/authz/roles.go): org lifecycle (platform:orgs), enclave registration (enclaves:manage), the platform policy floor (platform:policy), platform config (platform:config), managing other platform operators (platform:operators), and standing cross-org controlplane audit read (platform:audit:read). A platform operator therefore has zero standingagents:read/audit:read/policy:write/config:write/members:manageinside any customer org. The dual-use floor verbs got their ownplatform:*names precisely so granting them at platform scope cannot leak the org-scopedpolicy:write/config:writeinto every tenant. Reaching into a customer org’s management plane is only ever via JIT customer-approved shadow access (bd_2_0-acb.3, designed; the sole path, no unilateral break-glass — bd_2_0-acb.4 retired), and reaching sovereign data is only ever a customer-authored enclave-local grant (§4.3, bd_2_0-bs1), never platform-minted. - Escalation to platform operator via org-scoped grant API — an org
admin(holdingmembers:manage) POSTs a grant conferring theplatform-operatorrole or aplatform:*capability to themselves. Mitigation:validateGrant(internal/controlplane/iam/api.go) returns 403 for theplatform-operatorrole or anyauthz.IsPlatformCapabilityverb at org scope; platform authority is mintable only via the separatePOST /api/v1/grantsroute, itself gated byCapPlatformOperatorsatPlatformTarget(). The last-platform-operator break-glass guard (DeletePlatformOperatorGrantGuarded) is preserved, and the platform org is undeletable (deleteOrg403s akind='platform'org) and a singleton (a DB partial-unique index rejects a second platform-kind org). - Cross-org controlplane audit read is standing but non-sovereign, and never invisible (bd_2_0-zb4.2 / acb.6). A
platform:audit:readholder reads all orgs’ controlplane audit in one call — a deliberate standing authority (the platform operates the controlplane, so its identity/policy audit is non-sovereign observability; this is exactly why sovereign data never lives in the CP). Fail-closed shape: the server computesEffectiveScope{OrgAll}only from the verified platform grant atPlatformTarget()(RequireAuditRead), never from client input; the audit store drops its mandatory per-tenant org predicate only whenFilter.AllOrgsis set alongside an emptyOrgID, and still errors “requires an org scope” otherwise — so a non-platform caller can never reach a cross-org read. Reciprocal transparency: every cross-org read emits a per-affected-orgaudit.read.cross_orgevent (actor = the operator,home_orgin attrs) into each target org’s own audit view, so an org always sees who-outside-it read its audit. The platform grant confers nothing on the enclave plane (§4.3). - Audit-read actor-scope leak (bd_2_0-zb4.1). A plain org
member(holding onlyaudit:read) tries to read another actor’s — or un-attributed/system — audit records. Mitigation: audit visibility is a server-forced authorization decision, not a UI filter:RequireAuditReadderivesEffectiveScope{Actor}from the caller’s grants (audit:read→ forced toActor="user:<sub>";audit:read:all→ any-actor within the org) in the same single grant load as the coarse gate, and both the paged read AND the SSE stream AND it into every query (auditapi.scopedFilter). A self-scoped caller can narrow within its scope but never widen it, and un-attributed rows are excluded (fail-closed). - Agent-operator caller class (agent-as-operator, bd_2_0-bvw — IMPLEMENTED, live-verified). A running agent’s egress toward the operator API must become an authenticated, least-privilege caller without the agent ever holding a controlplane credential. The shape (the bd_2_0-bvw.1 decision): the agent holds nothing; its TCP toward any
--cp-api-hostshost is redirected into the enclave gateway like all agent egress (the per-agent egress-redirect policy carries an explicit east-west rule for the controlplane), the gateway terminates the TLS, relays the agent’s held SVID cert (cert only — the key never leaves the gateway) through the broker’s local mTLS channel to the CP mint endpoint, and the controlplane verifies the SVID against the pinned enclave agent-identity CA (bd_2_0-bvw.4: a verification input pinned at announce time — never delegated issuance, invariant 5 intact) plus the agent’s containment state, then mints a 60s, ES256, audience-scoped token under its own KMS-custodied key (internal/controlplane/agenttoken, migration 00019; JWKS with kid rotation serves every stored key). The gateway caches per-agent (≤60s, drop-on-any-failure) and presents the bearer itself on the re-originated CP hop — the token never enters the agent’s view, so pod death stops presentation. On the operator API the token authenticates via the second authenticator above withPrincipal.Kindstructurallyagent-operator. Authorization:agent:<uid>principals are grantable only at org scope — the grant API validates the agent belongs to the path org (unknown/cross-org = 404, the xew.2 discipline; platform scope = 403) — and the v1 role isauditor(audit:read, own-actor) exactly; a kind gate precedes the grant load on every capability-gated route, so even an agent deliberately grantedadminreaches no manage/read surface (proven in bd_2_0-bvw.6’s blast-radius test) — the entire v1 agent surface is the own-actor audit read. Containment is enforced by mediation, not TTL: the mint endpoint refuses contained agents (bounded new access to zero), the gateway drops the cached token and the SVID presentation, a deny-all policy flip is immediate, and grant deletion removes authority outright. The levers are composed into one reversible operator verb (POST /api/v1/orgs/{org}/agents/{agent}/contain,bd_2_0-0qp; release restores): it flips the CP flag the mint/relay gates read, authors theagent.containedsnapshot key the gateway’s layered engine resolves AHEAD of every allow rule and the assert-identity floor (severing all egress + LLM inference), has the broker stamp a deny-allegressDenyCiliumNetworkPolicy in the agent’s namespace as defence in depth, and — deliberately — does NOT tear the VM down, so memory/disk evidence survives for forensics (audited authority-tier asagent.contain/agent.release; capabilityagents:manage, org-path-scoped, body-less, never callable by an agent-operator). Audited both sides: gatewaymitm-agent-tokenflow rows and CPagenttoken.mintevents withactor=agent:<id>,actor_kind=agent-operator(the first-class audit column, migration 00009). All of it live-verified on daevix-dev (bd_2_0-bvw.7), including the fail-closed paths: ungranted agent 403, kind-gated route 403, contained agent denied at the relay before the CP, pinned audience rejected at the mint. Residuals, accepted and bounded: (a) a minted token is replayable within its 60s TTL wherever its audience is accepted — mitigated by tight per-route-class/per-cloud audiences (advx-operator-apitoken is not a cloud credential; see WIF,docs/wif.mdoutbound section) and by TTL; (b) ≤60s of already-minted token can outlive containment (containment levers stop new presentation; the tail is the TTL); (c) attribution correctness at the gateway/broker becomes CP-authoritative for this caller class — the same trust as the enclave’s own operator surfaces. - Node forensics daemon reading contained VM memory (new node TCB member, bounded — bd_2_0-24z). Containment preserves evidence by freeze-in-place; the forensic COPY is taken by a new node-local DaemonSet (
dvx serve forensics,internal/enclave/forensics): it watches for the broker’sagent-containmentCNP (read-onlyget/list/watchonciliumnetworkpolicies— its entire RBAC,deploy/enclave/forensics-rbac.yaml; it cannot contain, release, or author anything), and when a contained agent’s Kata sandbox lives on its node it pauses the CLH VM, copies the guest RAM (the VMM’s/memfd:ch_ramfd, read through the host/procmount), the guest rootfs (each container’s merged rootfs tree, read through the sandbox virtiofsd’s mount namespace — bd_2_0-c4y), andpersist.jsoninto the enclave’s own/run/dvx/<prefix>/forensics/<ns>/<cnp-uid>/(per-enclave hostPath subtree, bd_2_0-ktd), resumes the VM, and auditsforensics.snapshot(TierCompletion, error outcomes recorded; failed captures keep partial evidence and say so in the manifest warnings). Why the TCB expansion is accepted: the daemon exposes no network surface (no listener, no CP egress — it polls the K8s API and writes node-local files), and the power it exercises — reading a sandbox’s guest RAM and container rootfs trees from the node — is one node-root already had by construction (the RAM backing is a host memfd; the rootfs trees are bind mounts inside the virtiofsd’s namespace; §4.2 substrate trust already places the node in the TCB). It formalizes an existing power behind an audited, least-privilege mechanism rather than widening it. Custody stays sovereign (invariants 1/2): snapshots live only in the enclave’s node subtree, never shipped; retrieval is a deliberate operator act, not a channel. Residual, accepted: the daemon can in principle read any sandbox’s RAM on its node, so a compromised forensics DaemonSet ≈ node-root compromise — the same blast radius the substrate already implies, with thereadOnlyRootFilesystem+ dropped-capabilities pod spec (plus the one requiredSYS_PTRACEread-class grant) as hygiene, not the boundary. The host-/procfd read is CLH-only (qemu/other runtimes are reported, not captured). Substrate caveat (live-verified, bd_2_0-9n4; mechanism pinned in bd_2_0-asx): on kata-clh the pause→resume quiesce cycle can itself terminate the guest — the shim-side agent monitor (virtcontainers/monitor) pings the in-guest agent every 5s with aCheckRPC whose 30s deadline is hardcoded (checkRequestTimeout), and a ping hung across the pause window expires after resume, whereupon the shim marks the agent dead and tears the sandbox down (every container exits 255); there is no configuration knob, and CLH snapshot-based quiesce pauses vCPUs the same way, so neither alternative avoids it — on that substrate “freeze-in-place” is superseded by the capture rather than composed with it — the evidence (RAM hash-sealed at pause) lands BEFORE any teardown, which preserves the property that matters for a contained agent. Rootfs capture (bd_2_0-c4y, live-verified on kata 3.32.0): kata 3.x no longer stages the containers’ rootfs under the host’s virtiofs shared dir (it is an empty staging root — the per-container trees are bind-mounted only inside the per-sandbox virtiofsd’s private mount namespace), so the daemon reads them through/proc/<virtiofsd-pid>/root, onedisk/<container-id>/artifact per container. That needs exactly the access the RAM read already holds (hostPID +SYS_PTRACE⇒ ptrace permission on the VMM/virtiofsd processes) — no new capability, mount, or trust — and deliberately does NOT copy the single-file mounts beside the rootfs trees (hosts, resolv.conf, serviceaccount tokens: live credentials, not disk evidence). - Cross-tenant access by smuggling org/group id in the request body — attacker with authority in org A puts org B’s id in a POST/DELETE JSON body. Mitigation: the authorization target is derived exclusively from the principal and validated path params via
TargetFunc(PlatformTarget/OrgFromPath/GroupFromPath/OrgFromPrincipal,internal/controlplane/iam/authz.go); handlers readr.PathValue(...), never the decoded body, for the coordinate (createOrgGrantscopes tor.PathValue("org")) — with the one documented, narrowing-only exception called out at the end of this bullet (group-aware agent create authoring its bodygroup). A smuggled id is simply never consulted, so it cannot widen authority. Group principals are namespaced by the caller’s own org claim (principalsOf→ the now-plane-neutralauthz.Principals/authz.GroupPrincipal, relocated in bd_2_0-bs1.2 so both planes build identical principals; stillgroup:<p.OrgID>:<g>), so a group claim can never mint a principal in another org. The invariant holds unchanged across the endpoints this IAM work added: the cross-org audit?org=narrowing param is honored only within an already-authorizedOrgAllscope and can only narrow (never widen) it; and the enclave-plane grant-authoring API takes its authorization org from the token claim, never a body field (§4.3, bd_2_0-bs1.3). The RBAC/IAM-evolution epic (bd_2_0-xew) carries the same discipline onto every new operator-API surface, verified in each bead’s tests: agent-scoped grants (bd_2_0-xew.2) authorize onOrgFromPathand validate the body’s agent id belongs to the path org before scoping to it (createOrgGrant→AgentExistsInOrg), so a smuggled agent id from another org is a 404, never a widening;agent:<uid>PRINCIPALS (bd_2_0-bvw.6) follow the identical discipline — the named agent is validated against the path org (unknown/cross-org 404, bare prefix 400) and refused outright at platform scope (403, org-scoped by design) — and are grantable only theauditorrole in practice, since the kind gate (above) makes any broader role inert for the agent-operator class; custom per-org roles (bd_2_0-xew.7) authorize onOrgFromPathand are org-scoped rows (a role in org A is invisible in org B), and a custom role can only ever bundle existing, non-platform capabilities (validateRoleCaps) so authoring one can never mint a new verb or reach platform scope; upstream IdP federation (bd_2_0-xew.5) and the membership/effective-permissions queries (bd_2_0-xew.6) both authorize onOrgFromPathwith the connector/subject in the path, never the body. The one place a request-body field enters an authorization decision is group-aware agent create (createAgent,bd_2_0-oxk): the bodygroupis consulted because it is the agent’s group attribute being authored, and the check requires the caller to already holdagents:manageover that group (plus, on an Upsert, over the existing agent’s stored group) — so it is strictly narrowing, never a widening or cross-tenant reach. A group the caller does not manage is a 403, and the authorization org stays path-derived, so a smuggled org id in the body is still never consulted; the body group only ever authors into groups the caller already manages. This is the documented exception to “never the request body”; every other coordinate stays path- or claim-derived. - Cross-tenant existence probing — attacker enumerates grant ids or coordinate rows to confirm another tenant’s resources exist. Mitigation:
deleteOrgGrantre-checksg.OrgID.String != organd returns 404 (iam.ErrNotFound), anddeletePlatformGrant404s a non-platform grant — a cross-tenant reference is indistinguishable from a nonexistent one, never a leak (docs/iam.md§8,internal/controlplane/iam/api.go). - Last-admin / self-demotion race — two concurrent deletes each observe two admins, then both revoke, stranding an org (or the platform) with zero admins. Mitigation: the check and the revoke are one atomic operation (
bd_2_0-a9e, fixed).Store.guardedDelete(internal/controlplane/iam/store.go) opens a transaction,SELECT ... FOR UPDATE-locks the whole admin set for the scope (LockOrgAdminGrants/LockPlatformAdminGrants), refuses with a 409 (ErrLastAdmin) when one or fewer remain, and otherwise deletes the target grant in the same transaction. Two concurrent revokes of distinct admin grants now serialize on those row locks — the second one, after the first commits, sees the reduced set and is refused — so the scope can never be stranded (internal/controlplane/iam/api.go,docs/iam.md§8). - Stale-membership authority after removal — a user removed from a group in Zitadel keeps acting on old authority. Mitigation: membership is JIT-from-claims, never stored (
docs/iam.md§3, §6); there is no membership table in00003_iam.sql, and every request re-derives org/groups from the token. A removal takes effect on the user’s next token — no cache to invalidate, but note the window is the token lifetime (see residual trust). - Standing service-account credentials held by customers (new standing-secret class, bd_2_0-ugb). Service accounts are machine operators that authenticate with their own credential (private_key_jwt key file) and hold their own grants — the first long-lived, customer-held credential class that wields operator-API authority (the agent side deliberately has none, §4.1). The threat: a stolen or over-broad SA key file authenticates as
kind=serviceand wields exactly the SA’s grants until rotation or deletion; and self-service minting could proliferate credentials that quietly clone a human’s authority. Mitigations, layered: the credential crosses the boundary exactly once at create/rotation and is never stored or logged by dvx; every create, mint, and transfer emits an AUTHORITY-tier audit event (serviceaccount.create/.key.mint/.transfer), so issuance is fully attributable; rotation (POST .../serviceaccounts/{sa}/keys) retires the old key id at the provider, and the store’skey_id/retiring_key_idbookkeeping guarantees no key live at the provider is ever untracked (an interrupted rotation converges on retry, it never orphans a credential); deletion and the offboarding cascade tear down the upstream machine user and revoke every grant keyed on its principal. Self-service proliferation is bounded by the delegation ceiling (denylist of the org-manage verbs and allplatform:*, owner-subset rule, group/agent scope cap — capability-only grants, so no role bundle smuggles a denylisted verb), the human-only kind gate (no SA can mint or administer a self-service SA — credential lineage cannot recurse), and theiam.sa_creationkill-switch (org-scope, platform-lockable, unset=allow, malformed/error=fail-closed). The no-leak discipline holds on the whole surface: opaque dvx ids only,provider_subnever returned, grants managed via SA-scoped endpoints (never a rawuser:<sub>string) — the provider stays an internal concern (docs/iam.md§13). Residual: the key file is a standing secret outside the platform’s custody — storage hygiene on the customer side is the customer’s (§6); the platform levers are rotation, deletion, and the audit trail, and use is attributable viaactor/actor_kind=service. - The token facade: a new UNAUTHENTICATED credential-exchange surface (bd_2_0-0kj).
POST /oauth/tokenis the controlplane’s first unauthenticated, abuse-callable endpoint: anyone who can reach the operator API’s address can present credential-exchange requests. It exists so service-account callers never contact the identity issuer directly — daevix is deliberately on the token-issuance path. The threat: an unauthenticated network caller floods it (DoS amplification toward the issuer), uses it as an open proxy into the issuer’s other endpoints, sprays stolen/guessed assertions (an online oracle for credential validity), or tries to smuggle assertion/token material into logs and audit. Mitigations, layered: the facade is proxy-only — dvx performs no assertion verification (signature verification, key lookup, and revocation stay with the issuer; the facade re-implements no RFC 7523 crypto), so a dvx bug cannot mint acceptance the issuer would deny, and a forged assertion is answered by the issuer’sinvalid_grant, forwarded verbatim — never a dvx verdict. The grant-type allowlist is fail-closed: onlyjwt-beareris forwarded, and any other grant type is refused locally with a standard OAuth error and no upstream call, so the facade cannot become an open proxy to the issuer. The abuse posture is enforced at three points: a token-bucket rate limit (default 5 rps / burst 20 — the firsthttpxrate-limit middleware; a brake, not a quota) refuses bursts with a 429 OAuth error before any upstream call; the form body is capped at 16 KiB; and the upstream call is bounded (15s client timeout, no retries), so a hung issuer cannot pin facade resources. Nothing is cached and nothing is persisted except audit events — no assertion or token is ever stored. No credential material is logged: assertion contents and issued token bytes never enter the application or audit streams (test-enforced); the per-requesttoken.issueAUTHORITY-tier audit event carries only bounded metadata, with the assertion’ssubrecorded as the clearly-labeled, length-cappedassertion_sub_unverified— unverified client input kept for correlation, never trusted. Emission is capture-or-deny through the shared authority gate: when durable capture fails, an issuance stands down (the issuer’s token response is not delivered) rather than crossing the boundary unrecorded; refusals proceed regardless. Errors on this route are OAuth 2.0 error bodies (RFC 6749 §5.2), the one documented exception to the problem-document fence (docs/http-api-conventions.md), so the facade adds no issuer-internal detail beyond what the issuer itself returned. Residual, accepted: the controlplane’s availability now gates SA token acquisition (direct-to-issuer acquisition remains supported as the escape hatch,docs/iam.md§13); the rate limiter is global-per-route, not per-caller, so a flood can crowd out legitimate exchanges (availability, not confidentiality — accepted for v1, deployment-level limits sit in front); and the assertion’saudmust name the issuer as the issuer sees it (the facade cannot rewrite signed bytes), so the issuer identifier remains a value callers must hold even though they never dial it. - The user-offboard route is a new reachable destructive surface — bounded twice (bd_2_0-ugb.1).
POST /api/v1/orgs/{org}/users/{sub}/offboardlets onemembers:manageholder sever another principal’s self-service SAs — revoking their grants and deleting the upstream machine users, not merely a grant row. Mitigation: the route is bounded by both the capability at the path org and an org-scoped data lookup — the teardown is keyed on (path org,owner_subject), so an org-A admin can reap only the subject’s SAs in org A; that subject’s SAs in org B stay untouched (scrutiny round-2 fix — an earlier build looked the owner up globally). The subject comes from the path, never the body; a subject with no self-service SAs in the org is an idempotent no-op; and the bulk last-org-admin guard refuses the cascade with a 409 before any upstream or database side effect when the batch would strip some org’s last admin grant. Deliberate residual: this is intended admin power (offboarding requires the ability to reap a departed member’s credentials) — it is the same trust already placed inmembers:managefor grant revocation, extended to machine identities, and every step lands in the audit stream. - Ownership transfer keeps inherited group authority (deliberate, accepted).
POST .../serviceaccounts/{sa}/transfer(serviceaccounts:manage, human-only) re-points a self-service SA’s owner for offboarding, but does not re-validate the SA’sdvx:groupsmembership against the new owner: an SA transferred to a principal who is not a member of one of its groups keeps that inherited group authority, which can exceed the new owner’s own. Accepted because the alternative — silently stripping group membership on transfer — would change what running automation can do without anyone asking; later grant changes are still bounded by the new owner’s ceiling, and the administrator’s remedy for unwanted inherited authority is deletion (or out-of-band membership correction). Documented so a reviewer does not mistake the gap for an oversight (docs/iam.md§13). - Federate a rogue upstream IdP, or federate one org’s IdP to inject another org’s group claims (new external trust dependency,
bd_2_0-xew.5) — an operator binds their org to an upstream IdP (Google/Entra/Okta/OIDC/SAML) throughPUT /orgs/{org}/idp, so the org now trusts that IdP for authorization-relevant group claims. Two concerns. (a) Secret exfiltration from dvx: the connector’s client secret could leak from dvx’s store. Mitigation: secret custody is pass-through — the secret flows to the identity backend and is never persisted by dvx; theidp_connectortable (migrations/controlplane/00017_idp_connector.sql) has no secret column, so there is structurally nothing to leak, and the secret never appears in any API response (idpConnectorViewomits it; proven inidp_test.go). (b) Cross-org group injection: an org’s federation minting a sibling org’s group principals. Mitigation: group principals are namespaced by the caller’s ownurn:dvx:orgclaim (authz.Principals→group:<callerOrg>:<grp>), so whatever an org’s IdP names a group it can only ever becomegroup:<thatOrg>:*— never a sibling’s (proven inidp_test.go,TestFederationCrossOrgGroupIsolation). The verb is its own least-privilegeidp:manage(org-scoped), not the broaderconfig:manage, so federation authority is granted deliberately and separately. Residual: by design the org now trusts its chosen IdP for group truth exactly as it trusts Zitadel forsub/org (residual trust above); a compromised upstream IdP can assert any group within that org, bounded by the namespacing — never cross-tenant, and never any sovereign-data path (IAM never governs sovereign values, §4.3/§4.6). - Turn the membership/effective-permissions read model into an enforcement side channel (fail-open drift,
bd_2_0-xew.6) — the newGET /orgs/{org}/groups/{group}/membersand.../principals/{sub}/effectivequeries compose backend membership with dvx grants; if enforcement ever consulted this displayed view, a stale/forged membership answer could widen real authority (displayed-vs-enforced drift). Mitigation: the read model never feeds an authorization decision — it is a separate, live read-proxy (internal/controlplane/iam/queryapi.go), while enforcement (RequireCapability→authz.Authorized) reads only the caller’s validated token claims + stored grants and stays fail-closed. The query endpoints are themselves gated (members:read+OrgFromPath, org-scoped) and degrade to direct grants when no backend is wired — never failing open. It is a live proxy, not a synced projection, so there is also no membership table to drift. - Group-principal grant reactivation via coordinate reuse — an operator grants a capability to a group principal (
group:<org>:<grp>); the group is later deleted and a new group is created reusing the same coordinate; the new group’s members would inherit the old grant (the principal-recreation confused-deputy AWS canonicalises principal ids to avoid). Mitigated (bd_2_0-u61). The grantprincipalis a free-text coordinate with no foreign key to"group", so — unlike the org/group scope columns, whoseON DELETE CASCADEFKs drop scoped grants when the coordinate is deleted (00003_iam.sql) — a grant that names a group as its principal does not cascade at the DB level. It is now cascade-cleaned at the application level, atomically:Store.DeleteGrouprevokes every grant held by that group principal (DeleteGrantsForPrincipal) before deleting the group, andStore.DeleteOrgdoes the same for each of the org’s groups before the org (and its cascading group rows) are removed — both inside one transaction (Store.inTx), so a crash cannot strand the grants (internal/controlplane/iam/store.go). The principal string is built through one sharedGroupPrincipal(org, id)helper (internal/controlplane/iam/authz.go), so the authz matcher and the cleanup can never drift. A recreated coordinate therefore inherits nothing.user:<sub>principals were always inert-by-immutability (Zitadelsubis never reissued); the brokeruser:<sub>grants an enclave holds are actively cleaned on enclave delete (Service.DeprovisionBroker,bd_2_0-10e); orgs are only ever a scope, which cascades. Defense-in-depth still open (bd_2_0-ihz): canonicalising group principals to an immutable group id (as the enclave-identity work did for enclaves,bd_2_0-49z) would make the coordinate non-reusable even absent cleanup — deferred with the coordinate-grammar decision.
Residual trust / assumptions. We trust Zitadel completely for authentication, credential storage, and the truthfulness of the iss-matching signing keys and the urn:dvx:org / urn:dvx:groups claims it stamps (docs/iam.md §2, §6). An attacker who compromises the Zitadel deployment or its signing keys can mint tokens asserting any org/groups and any subject, and the controlplane will honor them — authorization is only as sound as the claims are authentic. This covers service-account machine users unchanged (they are subjects like any other); what bd_2_0-ugb adds on top is the customer-held SA key file: a leaked SA credential authenticates as that SA (kind=service) until rotation or deletion, with the compromise window bounded by those levers and every issuance/rotation recorded at AUTHORITY tier — but the file’s at-rest custody is the customer’s (§6). For the agent-operator class we correspondingly trust the controlplane’s agent-token signing key (own KMS-custodied key, envelope-encrypted at rest, migration 00019; rotated via JWKS kid with serving of all stored keys for the overlap) and the pinned enclave agent-identity CA roots (a swapped root would be identity forgery; rotation is announce-then-pin before the old root expires, bd_2_0-bvw.4) — a compromise of either mints agent-operator tokens the CP will honor, bounded by the class’s read-only blast radius (the mitigation entry above). We assume the org/groups Zitadel-action mapping is configured correctly (it is deployment configuration, not code in this repo). We accept a membership-change propagation delay bounded by token/exp lifetime, since revocation is not pushed. We assume the bootstrap seed (SeedPlatform: the platform org row with kind='platform', a data-bearing platform-ops org, and the first platform-operator grant, done in-boundary at deploy time, docs/iam.md §7, §11, bd_2_0-26i.6/acb.2) and the dvx-controlplane machine-user credential are provisioned by a trusted deployer. Reaching a customer org’s management plane is deliberately not a standing platform authority: it comes only via JIT customer-approved, time-boxed, fully-audited shadow access (bd_2_0-acb.3, designed — not yet implemented), whose grant expiry is fail-closed; there is no unilateral platform break-glass (a customer-configured emergency path, if ever wanted, is additive and never yields sovereign data). Sovereign/secret values are never IAM-governed on either plane — no grant at any scope yields sovereign plaintext (§4.3, §4.6).
Deliberately out of scope. This boundary does not defend Zitadel’s own internal security (login, MFA, upstream federation/WIF, refresh-token lifecycle) — that is Zitadel’s surface by adoption (docs/iam.md §1). It does not cover the enclave’s consumption of published governance policy (a separate agent-attribution problem, docs/iam.md §1) nor any sovereign-data path. The controlplane→Zitadel management-API sync (drive-Zitadel-on-write, bd_2_0-26i.12) is partially-built and forward-looking; its provisioning failures are best-effort and never fail an authorization-bearing write (createOrg, provisionOrgClaim in internal/controlplane/iam/api.go), so the DB grant — not the Zitadel side — remains authoritative for the decision. Transport security (TLS termination) and rate-limiting/DoS are handled at the server layer, not here — with one exception: the token facade’s abuse posture (rate limit, body cap, bounded upstream call) is analyzed above as part of its boundary, because it is the controlplane’s first unauthenticated, abuse-callable surface. Agent-as-operator (bd_2_0-bvw) is now IMPLEMENTED and analyzed above (the agent-operator caller-class mitigation entry) — the enclave-side legs of that path (the gateway’s CP-bound re-origination hop and the broker’s mint relay) are analyzed at their own boundary (§4.3), and the outbound cloud federation of these tokens (WIF) is a proof, out of v1 core (docs/wif.md). Cross-enclave agent identity trust (enclave B trusting enclave A’s agents) remains an explicit non-goal, deferred with audience room left in the token design (bd_2_0-bvw.9).
4.5 · Client ↔ enclave sovereign-data path & edge ingress (relay)
This boundary is where an external operator or client first touches an enclave. The enclave’s broker — which authors secrets and answers audit queries — is dial-out-only and in-cluster-private, so it is never directly reachable. The relay (dvx serve relay, internal/enclave/relay/relay.go, bd_2_0-ib4.1) is the enclave’s single publicly-exposable surface: it terminates the external operator connection, revalidates the caller at the edge, and reverse-proxies an allow-listed subset of the broker’s operator API over mTLS. It is enclave-owned, not a controlplane service, and enforced against importing internal/controlplane (invariant 4, depguard). The relay also hosts the interactive-access tunnel (bd_2_0-kzb / bd_2_0-0f7.1): an opt-in GET /v1/rendezvous/{session} WSS rendezvous that pairs an operator with a running agent’s trusted node-local terminator (dvx serve terminator), which forwards on to the services the agent bound on its own localhost over a keyless in-guest vsock pipe. Both ends dial out to the relay (no inbound port to the customer cluster); the tunnel is end-to-end WireGuard (userspace, internal/tunnel/wgtunnel) riding as opaque frames over the rendezvous, so the relay shuttles ciphertext and never holds a key or sees tunnel plaintext. The relay pairs peers by an authenticated CP-minted pairing ticket (internal/tunnel/ticket, ECDSA, short-TTL; the relay verifies it offline against the CP’s published public key — the enclave never dials the CP), deriving role/session/identity from the signed ticket rather than any client hint, and enforces the transport leash (exactly two peers, join/idle/max-lifetime). The surface is default-off (--rendezvous) and, per-agent, governed by the tunnel.enabled capability (§4.8-adjacent governance) enforced twice: at CP ticket issuance and re-checked enclave-local by the terminator at session open; the terminator additionally enforces the trusted tunnel.ports per-port policy. The termination point is analyzed in detail below (“Trusted node-local terminator”).
What it protects. The private broker’s operator API (secret authoring, audit read/tail, human-in-the-loop tool-call approvals: list, decide, SSE stream) and, in future, the direct sovereign-data payload path. The load-bearing property is invariant 1: sovereign data never transits the controlplane — the client↔enclave path stays enclave-local, and the controlplane’s only roles are discovery and short-lived credential issuance, never the data path (docs/client-interaction.md §4).
Adversaries. An unauthenticated internet caller probing the public relay; an operator with a valid Zitadel bearer for one org trying to act on another org’s enclave; a forged/expired/wrong-audience bearer; a compromised in-cluster workload trying to reach the broker directly (bypassing the relay); a malicious or compromised relay trying to escalate into authorization authority; a rogue enclave announcing a false client-reachable address.
Attacks & mitigations.
- Reach the broker’s private operator API from the internet — dial the broker’s secret/audit endpoints directly to bypass the edge. Mitigation: the broker is dial-out-only and in-cluster-private; the relay is the only public surface (
docs/architecture.mdrelay section). The relay proxy is default-deny: only the six explicitly registered method+path routes inoperatorRoutes(internal/enclave/relay/relay.go) forward; every other path 404s at the relay and never touches the broker. Extending the surface means adding a list entry, never a catch-all. - Unauthenticated / expired / forged operator bearer at the edge — present no token, a stale token, or a token this issuer never signed. Mitigation: the relay builds its own OIDC authenticator (
oidc.New,internal/authn/oidc) and wraps every proxied route inauthn.Require(internal/authn), independently validating the Zitadel bearer before anything reaches the broker (defence in depth). Fail-closed:config.Relay.Validate(internal/config/config.go) refuses to start without--issuer, so the relay can never front the broker unauthenticated. - Cross-org action with a valid bearer — an operator authenticated for org A drives an enclave bound to org B. Mitigation: the relay is deliberately not the authorization authority — it forwards the bearer untouched and the broker re-validates and enforces org scope (
docs/architecture.mdrelay section; relay.go trust-model doc). The relay is a fail-fast shield; org-scope authz is the broker’s. - Tunnel peer authentication / session hijack — an unauthorized party dials the
/v1/tunnel/{session}rendezvous to impersonate a peer. Mitigation: the tunnel route authenticates each peer by the CP-minted pairing ticket (verified offline against the CP’s published key), NOT the edge OIDC bearer the proxied broker routes use — the enclave-role terminator peer has no Zitadel identity, so the ticket is the shared auth (relay.go routes,rendezvous.authorize). The ticket is short-TTL, role-bound (operator|enclave), session-bound, and carries the operator’s WireGuard public key; because the WG private key never leaves the operator, a stolen ticket alone cannot establish the tunnel — the WireGuard handshake requires the matching private key. Where a deployment additionally fronts the route with a bearer, its subject must match the ticket (defence in depth). Verified end-to-end on daevix-dev (bd_2_0-kzb). - Audience confusion (any-token-passes) — abuse a relay that pins no
--operator-audience, so any token the issuer signed clears the edge. Mitigation: accepted only as a dev posture; the relay logs an explicitWarnthat audience is unpinned and directs production to set--operator-audience(relay.goRun; bd_2_0-ib4.1). The broker’s re-validation + org scope remains the backstop, so an unpinned edge is a weakened shield, not an open door. - relay→broker hop compromise / broker impersonation — a network attacker MITMs the proxied hop, or an unauthorized in-cluster caller dials the broker’s operator listener. Mitigation: the hop is mTLS (
brokerTLS, relay.go): the relay presents its broker-minted client identitydvx-relay-broker-clientand pins the broker’s serving cert to the broker’s published local CA; the broker’s operator listener requires that client cert (config.Broker.OperatorRequireClientCert), so the relay is the only caller admitted. ACiliumNetworkPolicyadditionally admits broker port 8451 only from the relay (docs/architecture.md). Fail-closed: a bad cert/CA is a startup error (Relay.Validaterequires--broker-ca,--broker-client-cert/key), never an unverified proxy. - Sovereign data smuggled through the controlplane — route a sovereign-data operation via a CP endpoint for convenience. Mitigation: invariant 1 forbids it structurally — sovereign-data operations go directly to an enclave component; the controlplane is never in the data path and may only do discovery + short-lived creds (
docs/client-interaction.md§2, §4). The deferred direct transport (bd_2_0-kzb) is fixed to the same rule: enclave-hosted, CP never proxies sovereign data. - Rogue enclave discovery / address spoof — an attacker gets a client to dial an attacker-controlled endpoint for a target enclave. Mitigation: clients resolve which enclave and how to reach it only from the controlplane registry, never hardcoded (
docs/client-interaction.md§5). Because a NAT’d enclave dials out, it announces its client-reachable address over its authenticated outbound channel, pinned trust-on-first-use to its bound identity (§5, §6; identity binding enforced byiam.Service.bindEnclaveIdentity, bd_2_0-30a), with authoritative operator override. The registry address surface + announce is itself deferred (bd_2_0-cal). - Relay tries to become the authz authority or mint identity — a compromised relay asserts scope decisions or issues CP-trusted credentials. Mitigation: authorization authority stays with the broker by design (above); and invariant 5 makes identity-minting a controlplane-only power — an enclave-side component (the relay included) can never mint controlplane-trusted identities (depguard fence blocks importing the Zitadel management client from
internal/enclave).
Residual trust / assumptions. We trust that TLS termination on the operator-facing side is server-TLS with a correct cert (mandatory via Common.validate); that the broker’s local CA and the dvx-relay-broker-client material are provisioned honestly out-of-band; that Cilium enforces the port-8451 admit policy; and that the Zitadel issuer and its signing keys are sound. An attacker who owns the relay host can see and drop proxied operator traffic and can present the relay’s client cert to the broker — but still cannot forge org scope (the broker re-checks), cannot reach beyond the allow-listed routes without the broker also honoring them, and cannot mint a CP-trusted identity (invariant 5). An attacker who owns the broker owns the enclave’s operator surface outright — that is a different, deeper boundary.
Accepted residual (interactive-access tunnel, bd_2_0-0f7.4). The tunnel adds a session-scoped, single-peer WireGuard channel between the operator and the agent, rendezvous’d at the relay. With the trusted-proxy pivot the sovereign agent VM no longer opens an egress channel at all — WireGuard is dialled out and terminated by the trusted node-local terminator, and the guest only listens on host-mediated vsock — so the §4.1 total-egress-mediation weakening that the earlier in-VM companion introduced is retired for the agent VM. What remains is bounded by: the authorized-session window only (the CP pairing ticket is short-TTL and the relay stops pairing past expiry — a tunnel cannot outlive the operator’s entitlement even though WireGuard is stateless at rest); role/expiry/pairing enforced at the trusted relay; and the trusted per-port policy enforced at the terminator (tunnel.ports), a real boundary now that enforcement sits on the node rather than inside the agent VM. Because the keyless pipe shares the agent’s netns to reach localhost, a compromised agent can still inject into a live tunnel to its own localhost services — inherent to reaching the agent’s own ports — so security rests on the relay pairing (authorized operator session only) and the trusted terminator’s port policy, not on anything held in the guest. The terminator’s WireGuard key is transport-only, node-local, and never an agent-identity/attribution root (invariant 6 preserved — attribution stays below the VM); no WireGuard key or session descriptor is ever delivered into the agent VM.
Trusted node-local terminator (bd_2_0-kzb — datapath IMPLEMENTED, exchange seam deferred). WireGuard termination has moved OUT of the agent VM to a trusted node-local terminator (dvx serve terminator, internal/enclave/terminator), a per-node component in the imds/gateway plane that never runs inside a guest. The operator reaches it through the same relay rendezvous; the in-guest piece is demoted to a keyless dumb vsock→localhost pipe (dvx serve companion now runs internal/tunnel/vsockpipe, holding no key and enforcing no policy). This is strictly stronger and largely retires the residual above:
- The WireGuard key left the untrusted domain. It is held by the node-local terminator (invariant-6-consistent, node-local like the SVID) and never enters the guest, so the standing-secret-in-the-agent-VM risk is gone. The in-guest pipe carries no key and no descriptor — the pod injection mounts no tunnel/key volume (
cpbroker/pod.go). - Port policy is now a real boundary. The terminator terminates WireGuard on the trusted host and sees the plaintext destination port, so a wildcard forwarder (
wgtunnel.Config.Forward, over an owned gVisor stack +tcp.Forwarder) gives the operator any-port access to the agent and a trusted per-port chokepoint enforced before anything is opened into the VM. The policy is thetunnel.portsgovernance key (policy.KeyTunnelPorts, default allow-all, malformed fails closed). - The un-MITM’d egress puncture largely closes. The agent guest no longer dials the relay over an uninspected encrypted channel; it only listens on host-mediated vsock. The dial-out to the relay is now the trusted terminator’s, not the sovereign guest’s — so §4.1’s total-egress-mediation weakening no longer applies to the agent VM.
The datapath is built and proven end-to-end (operator → relay rendezvous → terminator wildcard forwarder → keyless vsock pipe → agent localhost, on arbitrary ports, with the denied-port RST verified).
Automatic key/session exchange (bd_2_0-kzb.2) is now built, and is simpler and stronger than the originally-planned node key mint + announce-up. Two changes retire that plan:
- Keys are exchanged over the rendezvous, not the controlplane. After the relay pairs the two peers, each sends its WireGuard public key as a one-frame preamble (
wgtunnel.ExchangePublicKey) and mints an ephemeral per-session keypair — so there is no node-local key store, no public-key announce-up, and the controlplane never sees or routes key material (it also never needs to know which node an agent runs on, keeping placement enclave-internal). Ephemeral keys give forward secrecy across sessions. - Only the enclave-role ticket + session flows down, over the existing enclave-pull channel. On tunnel open the CP mints a matching enclave-role ticket and records a per-enclave pending session (
iamtunnel-session registry, in-memory + TTL-swept); it rides the samecpchannelmechanism as the agents/policy/config snapshots (a newtunnelsessionsresource, re-served by the broker). Each node’s terminator (folded into the per-nodeimdsDaemonSet) pulls the snapshot, and for a session whose agent’s Kata guest is on THIS node — resolved by matching the agent’s pod namespace to a live sandbox (katart, the inverse of the IMDS relay’s map) — stands a terminator up; a session that leaves the set (torn down or expired) is stopped. The CP ticket TTL remains the entitlement leash.
The production wiring is now complete (bd_2_0-kzb.3):
- Enclave-local governance is enforced at the node (chokepoint #2): the node terminator resolves the published policy snapshot (
policysnap, a--policy-snapshotfile apolicy-pullsidecar writes) and applies thetunnel.enabledre-check at session open and the trustedtunnel.portsallow-list per connection, keyed to each agent’splatform/org/group/agentTarget from the node registry. An agent whose Target cannot be resolved fails closed. Without a snapshot the node falls back to the CP ticket gate + allow-all ports. - The terminator’s dial-out to the relay is a pinned egress carve-out (
dvx-imds-tunnel-relay-egress, imds→dvx-relay:8452), and the sovereign agent VM opens no such egress itself. - The keyless pipe runs for every Kata agent (
cpbroker/pod.go): it is inert until a CP-authorized session reaches it through the trusted terminator, so governance — not the sidecar’s presence — is the access control. - Delivery is the standard cp-pull-to-file pattern: a
rendezvous-sessions-pullsidecar writes the pending sessions the node reconciler watches, both sidecars riding the per-node imds DaemonSet.
Operator UX is a local SOCKS5 proxy by default (any agent port through one address) or a single forwarded port, with --terminator-pubkey optional (learned over the preamble). SSH (bd_2_0-0f7.3) and interactive PTY/exec (bd_2_0-0f7.2) are both IMPLEMENTED (SSH verified end-to-end on daevix-dev) — analyzed next.
SSH into a running agent (bd_2_0-0f7.3 — IMPLEMENTED). SSH reuses the tunnel datapath unchanged (an ssh-kind session pinned to the agent’s sshd port) and adds stock in-guest OpenSSH so an operator can explore the agent’s filesystem and transfer files (sftp/scp/rsync/sshfs/VS Code Remote-SSH). It shifts the model in ways that must be reconciled:
- New trust dependency: the agent’s sshd trusts a controlplane-custodied per-enclave SSH CA (accepted, bounded). The CP holds each enclave’s Ed25519 SSH CA private key (envelope-encrypted at rest under the owning org’s KMS key,
internal/controlplane/sshissuer, mirroring policy-value at-rest) and mints ~short-TTL operator user certificates onPOST /api/v1/orgs/{org}/agents/{agent}/ssh; the agent’s sshd trusts only the CA’s public half (delivered CP→enclave over asshcametadata channel →dvx-enclave-ssh-ca-pubConfigMap →TrustedUserCAKeys). This is a genuine new dependency — a compromised CP could mint SSH access — accepted for now because: the cert is a credential, not sovereign data (invariant 1 intact; the session rides the enclave datapath, the CP never sees SSH plaintext); it is not categorically new (the CP already gates interactive access via the tunnel ticket); and it is contained by chokepoint #2 (the enclave terminator re-resolvesssh.enabledfrom its own published snapshot at session open, fail-closed, honoring higher-scope locks) plus full audit (ssh.cert.mint). The CA is scoped per-enclave (tightest blast radius, matching the agentid SVID homing). A future bs1-gated change moves CA custody enclave-local/customer-controlled so even a compromised CP cannot mint SSH; because the sshd trust anchor is the per-enclave public key, that is a backend swap behind theIssuerseam with zero agent re-provisioning. - No standing agent secret. Auth is a dvx-brokered, short-lived cert served by the operator CLI’s in-process ssh-agent (
dvx ssh, ephemeral per-session Ed25519 key, cert re-minted through the CP gate before expiry) — never a staticauthorized_keyinstalled in the agent (which the epic explicitly set out to avoid). Each cert’sValidPrincipalsbinds it to exactly one agent (agent:<id>), cross-checked by the sshdAuthorizedPrincipalsCommand, so a cert minted for one agent cannot authenticate to another. - New reachable surface, multiply gated + default-off. sshd listens on the agent’s pod-localhost
:2222, reachable only through the trusted terminator. Access requires ALL of: thessh.enabledgovernance key (a separate gate fromtunnel.enabled, default DISABLED — SSH grants a shell + filesystem, materially more than a port tunnel, so it is opt-in), theCapSSHAccesscapability at the CP mint (distinct fromCapTunnelOpen), a live CP-minted cert under the enclave CA, and the terminator’s per-session port leash (an ssh session carriesAllowedPorts=[2222], which is authoritative for a session-pinned kind and is enforced before any connection enters the VM). The sidecar’s mere presence is not the control — three governance chokepoints are. - New privileged in-guest sidecar, VM-bounded. A
dvx-sshdsidecar runs in the agent’s Kata VM as root withSYS_ADMIN/SYS_PTRACE/SYS_CHROOTandshareProcessNamespace, so its forced login wrapper cannsenter -minto the BYO agent container’s mount namespace and give the operator the agent’s filesystem. This privilege — and the operator landing as root in that mount namespace (full read/write, as file transfer requires) — is contained by the per-agent Kata VM, which already isolates the sovereign agent from the out-of-VM plane (invariant 6): the sidecar shares the VM with the agent it fronts, so its blast radius is that one agent’s VM, never the node or another agent. To serve any BYO image (even shell-less/distroless), the login stages a static busybox + sftp-server into the agent rootfs via/proc/<pid>/root— a privileged write into the guest FS, but by the same VM-bounded sidecar and only on an already-authorized session. Attribution stays below the VM: the sidecar holds no agent-identity/attribution root, and SSH adds no new agent egress (the agent VM still only listens).
Interactive PTY/exec into a running agent (bd_2_0-0f7.2 — IMPLEMENTED). Exec gives an operator a kube-exec -it-style terminal (allocate a TTY, stream stdin/stdout/stderr, propagate resize, exit code on close) over the same relay rendezvous as the tunnel, driven by dvx exec and — the reason it exists as a separate mode from SSH — reachable by a browser over the same WSS. It reuses the SSH datapath’s privileged surface (the exec host runs inside the same dvx-sshd sidecar process, reusing its nsenter-into-the-agent primitive and a real /dev/ptmx PTY) but changes the transport in one trust-relevant way that must be reconciled:
- The relay now sees exec plaintext (a bounded, in-sovereign-boundary shift — the one material change vs SSH/tunnel). A browser cannot run WireGuard, so — unlike the tunnel and SSH, which ride end-to-end WireGuard and let the relay shuttle only ciphertext — an exec session carries the exec channel protocol itself (
internal/tunnel/execproto: length-prefixed stdin/resize/stdout/stderr/exit frames) which the relay copies verbatim. The relay therefore sees the PTY plaintext it never sees for a tunnel. This is accepted because the relay is enclave-owned and inside the sovereign boundary (the customer’s own cluster; it never dials the CP, invariants 1/2 intact — the CP is never on this path), so it is the same class of in-enclave plaintext exposure already accepted for the gateway and llmproxy MITM (§4.1/§4.6), not a new cross-boundary leak. It is nonetheless a real reduction from the tunnel/SSH E2E-ciphertext posture and is called out as such: the exposure is one enclave-local component on a short-TTL, governed, audited session, both hops are still TLS (operator↔relay and relay↔node-bridge WSS), and the enclave never forwards it outward. A future hardening could restore E2E by app-layer-encrypting the exec frames (a Noise handshake the relay copies opaquely); it is not built because it forfeits the browser-native path for v1. - No WireGuard, no key, no cert — the ticket is the authorization. The enclave side is a no-WireGuard bridge (
internal/enclave/execbridge), the exec sibling of the terminator: it dials the relayrole=enclave, re-checks governance, and splices the relay frames into the agent’s in-guest exec host over the same keyless host→guest vsock pipe. It terminates nothing and holds no key. There is no brokered cert (unlike SSH): the CP-minted, short-TTLexec-kind pairing ticket is the sole authorization, so exec adds no new standing-secret or CA dependency beyond the tunnel ticket the relay already trusts. - New reachable surface, multiply gated + default-off — a peer of the SSH gate. The exec host listens on the agent’s pod-localhost
:2223, reachable only through the trusted node bridge. Access requires ALL of: theexec.enabledgovernance key (a separate gate fromssh.enabledandtunnel.enabled, default DISABLED — exec grants a shell, so it is opt-in), theCapExecAccesscapability at the CP mint (distinct fromCapSSHAccess/CapTunnelOpen), a live CP-mintedexec-kind ticket, and the bridge’s per-session port leash (AllowedPorts=[2223], authoritative for the session kind, enforced before anything enters the VM). Enforcement is the same two chokepoints: the CP mint gate (exec.openaudit) and the enclave-local re-check (GovernedHooksre-resolvesexec.enabledfrom the node’s own published snapshot at session open, fail-closed, honoring higher-scope locks). It adds no new privileged sidecar — it shares the existingdvx-sshdVM-bounded process — so it opens no privilege surface beyond what SSH already established, and (like SSH) adds no new agent egress: the agent VM still only listens. - Provisioned independently of the WireGuard modes. Because exec rides the relay directly and needs no WireGuard, its CP mint endpoint is provisioned on its own switch (
ExecAccessEnabled), separate from the WireGuard access modes (tunnel + SSH,WireGuardAccessEnabled) — both over the one shared rendezvous signer (RendezvousSignerKey; the mode-neutral name reflects that the signer, the relay’s--rendezvousWSS hub, and the node session-watcher are transport shared by all modes, not WireGuard-specific). An operator can therefore stand up an enclave that offers exec with the tunnel/SSH endpoints never mounted at all (a smaller reachable surface than merely governance-denying them), or the reverse. This is a surface-reduction lever on top of the per-agent governance gates, not a replacement for them.
Deliberately out of scope. Agent-facing authentication and attestation-bound credentials (invariants 3 and 6) belong to the IMDS/gateway boundary, not this edge. Key custody and encryption-at-rest sit under the KMS-trust-domain boundary. The controlplane operator API’s own authn/authz (identity, policy, registry) is a separate boundary; the relay only concerns the enclave-hosted edge.
4.6 · Data at rest & key custody (envelope encryption, KMS trust domains, BYOK)
This boundary governs how sovereign rows and control-plane metadata are encrypted at rest and — more importantly — who holds the keys. The governing rule is a direct corollary of the sovereignty invariants: key custody never crosses a boundary the data itself may not cross (docs/architecture.md, “Data at rest & key custody”). Two KMS trust domains exist and never touch: the enclave KMS (customer/enclave-operator’s, guards sovereign rows and the attestation signing key) and the controlplane KMS (operator’s, guards controlplane metadata). The mechanism underneath both is standard envelope encryption in the one-stop-shop internal/kms package; the security properties come less from the AEAD than from where each kms.Provider is constructed and dialed.
What it protects. Sovereign secret values stored in enclave databases; the enclave’s attestation/SVID CA signing key; control-plane metadata at rest (policy rule bodies, grant principals, the enclave auth subject — see docs/controlplane-data-minimization.md); the wrapped DEKs and the KEK material in each plane’s KMS; and the ability to erase an org’s data on demand (GDPR crypto-shred).
Adversaries. A compromised controlplane (or its operator) attempting to reach into an enclave’s key custody; an attacker who exfiltrates ciphertext + key_ref from a database or backup and tries to move/replay it; an insider or legal-compulsion adversary demanding “the key” to sovereign data; a corrupted/truncated-ciphertext or downgrade attacker probing the decrypt path. (There is no in-enclave co-tenant adversary: an enclave serves exactly one org, bd_2_0-j9r.)
Attacks & mitigations.
| Attack | How | Mitigation |
|---|---|---|
| Controlplane unwraps enclave DEKs | A compromised CP tries to decrypt sovereign rows by dialing the enclave’s KMS | Structurally impossible: the enclave KMS binding (endpoint/auth/transit key) is local deploy config the operator sets when standing the enclave up (internal/kms/factory.go, kmsfactory.New, DVX_KMS_*), exactly like the DB DSN — the CP never learns it, holds no credential to it, performs no crypto op against it (docs/architecture.md, invariants 1/2/5; “Two KMS trust domains, never crossing”). Each plane constructs its own kms.Provider; the package is boundary-safe and encodes no cross-plane path (internal/kms/kms.go package doc). |
Replay a stolen (ciphertext, key_ref) onto another row/org | Exfiltrate an envelope + its key_ref and paste it onto a different agent, group, secret, or org row | AAD binds every ciphertext to its logical identity — the scope coordinate (org, secret), (org, group, secret), or (org, agent, secret) (OrgSecretAAD / GroupSecretAAD / AgentSecretAAD, internal/kms/envelope.go); GCM Open authenticates the AAD, so a copied pair fails the auth check and returns ErrDecryptionFailed (internal/kms/errors.go). The AAD is over the logical coordinate, not the physical row PK (unknown at INSERT). |
| Lower scope overrides an org-mandated secret | An org locks a shared secret (e.g. one llm.api_key), but a group/agent operator — or someone injecting bytes enclave-side — plants a lower-scope value to substitute their own key | Scope-hierarchy resolution is lock-aware and enforced at read (bd_2_0-7ha.4.1): the IMDS fills a secret from only the scope the shared resolver picked (policy.ResolveScalar → fillSecret, internal/enclave/imds), so a locked higher scope pins which coordinate’s bytes are read and a lower-scope value is never consulted. The broker’s authoring API additionally refuses the write of a key locked at a broader scope (409, bd_2_0-7ha.4.3, refuseIfLocked in internal/enclave/cpbroker/secretapi.go) — the byte-side mirror of the CP’s configvalue.Author.authorize refusal for locked declarations. |
| Downgrade / corrupted-ciphertext / truncation | Feed a mangled envelope, unknown version byte, or short buffer to coax out plaintext or a fallback path | Decrypt is fail-closed: unknown version byte (!= 0x02), too-short/truncated envelope, wrong-size unwrapped DEK, or AAD mismatch all return an error, never plaintext (internal/kms/envelope.go, EnvelopeDecrypter.Decrypt). 2.0 is greenfield — there is no legacy direct-KMS ciphertext, so no downgrade/pass-through path exists (EnvelopeVersion comment). |
| Compelled/legal disclosure of sovereign data | Adversary compels the operator to hand over a key or a decryptable blob | Answered by deployment, not escrow: hard tiers move the data and deployment into the customer boundary — BYO-enclave (nothing sovereign reaches the CP; no ciphertext to compel, no key to compel) and BYO-controlplane (bd_2_0-k5t.10). See the BYOK spectrum, docs/architecture.md. |
| GDPR erasure incomplete / slow | Regulator requires provable, irreversible deletion of an org’s data | Crypto-shred: keys are partitioned per org by name (org-<id>-secrets, OrgKeyName) inside the one provider that plane owns; Crypter.DestroyOrgKey destroys the key material and every value wrapped under it becomes permanently unreadable (internal/kms/crypter.go, KeyDestroyer). The shred is complete the instant the key is gone; ciphertext rows become tombstones dropped lazily. With one org per enclave (bd_2_0-j9r) the per-org key name is effectively the enclave’s key; the naming is retained (forward-compat, free) and still scopes crypto-shred. Crypto-shred also covers the encrypted inference-audit bodies (below): they are wrapped under the SAME per-org key, so destroying it erases stored conversations too. Providers that cannot delete keys return ErrUnsupportedOperation (honest failure, not a silent no-op). Erasure is invokable over the operator API (bd_2_0-bxx.1): POST /api/v1/orgs/{org}/erasure opens the grace window (nothing destroyed) and POST /api/v1/orgs/{org}/erasure/complete executes the irreversible shred — reachable only by a platform:orgs holder at platform scope over the bearer-authenticated operator API (§4.4), never by an agent-operator (fail-closed kind gate), with the platform org itself refused (the shred would destroy the operator’s own key material) and every step authority-tier audited (bd_2_0-4p8.3, above). |
| Read sovereign conversation bodies from the audit store | The inference-audit producer (llmproxy) stores the full conversation body for every governed call — the customer’s most sensitive data — into a store that was previously metadata-only + plaintext (bd_2_0-8kl.6). A compromised ClickHouse admin, a DSN holder, or a backup thief reads it | Bodies are ciphertext only, sealed BEFORE they reach the store (auditstore.SealBody, called in llmproxy before RecordInference): zstd then a per-org DEK AEAD envelope (kms.EncryptScoped, random DEK + random nonce), AAD-bound to (org, agent, request_id, form) (kms.InferenceBodyAAD) so a copied (envelope, key_ref) fails the auth check on any other row/agent/org/form. Metadata (model, tokens, verdict, credential mode) stays plaintext + queryable; only the body columns are encrypted. Encrypt-before-sink means the WAL and ClickHouse hold ciphertext only — a plaintext body never touches disk. A read returns ciphertext + metadata; decryption is a separate, KMS-audited forensic op (auditstore.OpenBody), not part of the query path. A compromised ClickHouse admin (or its DSN) therefore sees only ciphertext, and crypto-shred (above) erases the corpus. |
| Per-tenant controlplane BYOK breaks the plane | Customer demands their own key on control-plane metadata | Deliberately not built: the CP must read most metadata to function (evaluate policy, resolve identity), so a customer-only key forces a CP→customer-KMS dial on every read — a CP→customer-boundary dependency (breaks invariant 2) and an availability inversion (customer KMS down ⇒ their control plane down). Design rule: the CP may apply a customer key only to fields it stores and returns but never interprets (docs/architecture.md). Per docs/controlplane-data-minimization.md, the only columns meeting that bar are org.name/"group".name, better relocated to Zitadel; everything interpreted is bounded by operator-CMK-at-rest (tier 2, bd_2_0-k5t.7). |
| CA signing key exfiltration | Steal the SVID CA private key to forge attested agent identities | The KMS-backed SVID CA (agentid.KMSMinter, bd_2_0-k5t.6) self-signs and mints every leaf through the provider via crypto.Signer (Crypter.CASigner, internal/kms/crypter.go; CryptoSigner in internal/kms/kms.go) — the CA private key never leaves the KMS and is never written to disk; only the public ca.crt is persisted. The sign key shares the enclave’s one provider with the data-at-rest KEK, differing only in primitive. |
Residual trust / assumptions. We trust that each plane’s kms.Provider is configured to point at the correct KMS trust domain — a cloud enclave KMS must be the customer’s own account/region, or key material crosses the sovereignty boundary (docs/architecture.md, “Two KMS trust domains”). We trust the KMS backend (Vault Transit today; internal/kms/vault) to keep KEK material inside itself and honor destroy semantics. An attacker who owns the KMS itself can unwrap every DEK in that plane; the design confines that blast radius to one plane’s provider (the two never share a key store, internal/kms/crypter.go) and to the org whose key is compromised (per-org key names). The AAD’s replay protection assumes the logical coordinate (org, [group|agent], secret) is itself trustworthy, which is established by the identity/attestation boundary, not here. The broker’s write-time secret-lock refusal is an early control on top of read-time enforcement, not a hard guarantee on its own: when the broker has no cached config snapshot it lets the write through, and the read-time scope-driven fill (bd_2_0-7ha.4.1) remains the guarantee that a locked secret’s lower-scope bytes are never served. DEK best-effort zeroing (zeroBytes) is not a hard guarantee against memory-scraping. New trust dependency (bd_2_0-8kl.6.2): the llmproxy satellite now holds a kms.Crypter so it can seal inference bodies before they reach the store. This is a deliberate extension of the component §4.1 already trusts least with plaintext (it sees prompts in flight): sealing there is what keeps a plaintext body off disk (encrypt-before-sink). It is bounded — each record uses a fresh random DEK wrapped by the enclave KMS, so a passive memory scrape of llmproxy (a core dump / snapshot) yields the in-flight request’s plaintext (already disclosed) plus at most that one record’s DEK, never a standing key that decrypts the at-rest corpus (none is held across requests; the structural-dedup design that would have required one was rejected in bd_2_0-8kl.6.1). A live code-execution compromise of the running satellite is stronger and this bound does not cover it: the attacker inherits the live Crypter’s authority to call DecryptScoped (internal/kms/crypter.go), i.e. to ask the enclave KMS to unwrap at-rest records on demand — retroactive decryption capability, gated not by key custody here but by the AAD binding (they must supply the correct (org, [group|agent], secret) coordinate per record, established at the identity boundary) and by the fact that the satellite does not itself hold the at-rest ciphertext (it lives in the audit store, which llmproxy writes but does not read back). That a live process wields its own process’s crypto authority is the expected posture, not a new hole; the fresh-per-record DEK design buys the passive-scrape bound, not immunity to live compromise. llmproxy reaches the same enclave KMS the broker uses; a compromised enclave KMS was already in-scope-out (the customer’s own boundary), unchanged by this. Extended trust dependency (bd_2_0-xs0.3): the network gateway may now also hold a kms.Crypter so it can seal captured request headers on MITM’d HTTP egress before they reach the store. It is the same bounded posture as llmproxy, with the same distinction: the gateway already sees this plaintext (it terminates TLS to mediate, §4.1), sealing keeps the header bytes off disk, and each seal uses a fresh random per-record DEK, so a passive memory scrape yields no standing key over the at-rest corpus — while a live compromise likewise inherits the Crypter’s DecryptScoped authority, gated by the same AAD binding and by the gateway not holding the at-rest ciphertext. It is opt-in: without a configured enclave KMS the gateway captures no headers and audits flows exactly as before, so header sealing is a capability an operator adds deliberately, not a new standing dependency forced on every deployment. Widened to request/response BODIES (bd_2_0-uwi): the gateway’s HTTP-capture path seals not just headers but the (size-capped) request and response bodies of an allowed MITM’d HTTP flow under the same per-org DEK and per-exchange AAD coordinate. This does not extend the gateway’s trust — it already holds the Crypter and already sees these plaintext bytes (it terminates TLS to mediate, §4.1) — it only widens what that same bounded authority seals; the passive-scrape and live-compromise bounds are unchanged (fresh per-record DEK; the gateway does not read back the at-rest ciphertext). Body capture is default-ON but rides the SAME KMS gate as header capture: it is a no-op unless the gateway holds a Crypter, so it activates exactly where header capture already does (wherever an operator gives the gateway a KMS) and adds no new standing dependency of its own. A deployment can still keep header capture while disabling bodies (--http-capture=false).
Deliberately out of scope. This boundary does not defend against a compromised enclave KMS or a malicious enclave operator over their own tenants’ data — that is the customer’s own boundary by construction. It does not cover in-transit protection (TLS/mTLS, the relay and gateway boundaries) or agent-identity forgery below the AAD coordinate (the attestation boundary). It does not claim controlplane-side BYOK for interpreted metadata — that is answered structurally by BYO-enclave/BYO-controlplane, not by key escrow. Tier-2 operator-CMK at-rest encryption of controlplane columns (bd_2_0-k5t.7) and the self-hosted controlplane (bd_2_0-k5t.10) are designed but tracked as separate beads; where a mechanism is designed-but-unbuilt this section names the bead rather than asserting it exists.
4.7 · Audit-stream integrity (evidence that sovereign ops stayed in the enclave)
In an enclave the audit stream is the primary evidence that sovereign-data operations stayed within the enclave, so it is treated as an API, not convenience logging (internal/audit/audit.go package doc; docs/architecture.md, “Two log streams”). This boundary governs the integrity, completeness, and per-plane separation of that record: audit is a distinct first-class stream (stream=audit + audit_schema), separate from application logs (stream=app), and — unlike app logs — it may not be dropped or reworded. The threat is any agent or compromised component that forges, suppresses, or rewords records, plus loss of records across a ClickHouse outage or restart.
What it protects. The audit record itself — the (action, actor, resource, outcome, reason, scope, org) claims in internal/audit Event — its completeness (no silent gaps), its stable machine-parseable shape, and the invariant that each plane’s audit lives in its own store (enclave = sovereign agent signals; controlplane = platform operator actions), never mixed. For the inference-audit table (bd_2_0-8kl.6) it also protects the byte-identical reconstructability of the stored conversation (a stored request must reconstruct to exactly what was sent upstream) and the rule that the injected sovereign credential is never persisted. For request headers (bd_2_0-xs0) it protects the guarantee that the full captured header set is sealed at rest under the per-org DEK (headers can carry sovereign data) and that well-known secret headers are obfuscated to name + a salted keyed hash — their value is never persisted, and no header name or value reaches /metrics or app logs.
Adversaries. An untrusted in-enclave agent trying to author or erase its own trail; a compromised platform component (gateway, broker, imds) trying to suppress, reword, or downgrade audit to a diagnostic line; a flooding attacker trying to force buffer drops that bury one real event; an operator or bug that cross-contaminates the two planes’ stores. Anyone with direct write access to ClickHouse is explicitly below this boundary (see residual trust).
Attacks & mitigations.
- Reword/downgrade an audit record to a droppable diagnostic line — a component emits security-relevant facts through the app logger where format/level knobs can mute or rewrite them. Mitigation: audit is a separate stream with a JSON-only encoder and stable field names, not subject to app-log format/level knobs; every record carries
stream=audit+audit_schema(internal/audit/audit.goNew). Theforbidigologger fence in.golangci.ymlblocks directsloglogger construction outsideinternal/applog/internal/audit— though the fence is lifted wholesale in the other one-stop-shop owner packages (server/httpx/problem/kms,cmd/), which are trusted platform code, not agent-facing (docs/architecture.md, “Preventing drift”). - Suppress audit by treating emission failure as a control-flow branch — a caller “handles” an audit error by skipping the record (or the action). Mitigation:
Logger.Logand theSink.Recordcontract never return an error — audit emission is deliberately not a branch at call sites, so a component cannot conditionally skip it (internal/audit/audit.go,Sink/Logdocs). - Silently drop audit when no store is wired — a component with no
--audit-dsnemits into a void. Mitigation: fail-safe fallback — a logger with no registered sink writes to itsio.Writer(stdout), so audit is never silently dropped when the store is unconfigured (internal/audit/audit.go,AddSink/Log;docs/architecture.md). - Agent forges its own audit trail — a BYO agent tries to author records attributing benign actions to itself or erase egress. Mitigation: agents are untrusted and never author audit; the platform mediates. Egress is recorded as
gateway.egressby the gateway, which enriches the event with below-agent network context (identified,agent_id,proto,dst_ip…) that the store projects into the typednetwork_flowtable (internal/auditstore/clickhouse.go,networkFlowFromEvent). The agent sets none of these fields. - Sovereign conversation body leaks through the audit metadata seam — the inference producer must store the full prompt/completion, but the audit event carries a
map[string]anyAttrsbag whose documented invariant is “never put sovereign data or secret material here” (internal/audit), landing as plaintext columns. Routing a body throughAttrswould violate it. Mitigation: theAttrsinvariant is reaffirmed and bodies bypass it entirely — they never travel as anaudit.Event. The producer seals each body (ciphertext, §4.6) and hands it to a dedicated typed path (Store.RecordInference→ theinference_requesttable), separate from theSink.Record/Attrspath that only ever carries plaintext metadata. Body ciphertext columns are the only sovereign-bearing columns and they are encrypted; the plaintext metadata (model, tokens,verdict,credential_mode) is the same class as today’s audit. - Reconstruct a stored inference request that is not what was actually sent — a forensic reader (or a tampering admin) replays a stored body that silently differs from the bytes forwarded upstream. Mitigation: the row carries
sha256(PLAINTEXT body)+ length;auditstore.OpenBodydecrypts, decompresses, and verifies the digest, returning an error on any mismatch rather than unverified bytes — a byte-identity proof, not a hope.llmproxyforwards the body verbatim (httputil.ReverseProxy, never re-serialized) and captures those exact bytes, so the digest is over what actually crossed the wire. - Exfiltrate or signal through request headers with no audit trail — total mediation captured request/response bodies but not the request headers (only a names-only
header_diff, populated solely undercredential_mode=injectand empty in passthrough), so an agent could smuggle data or metadata out in a header value with nothing recorded to review. Mitigation (bd_2_0-xs0): both enclave mediation points now capture the full request header set and seal it at rest under the per-org DEK exactly like a body — ciphertext-onlyreceived_headers/forwarded_headerscolumns (agent-sent set and upstream-forwarded set; no header mutation stores once viaheaders_identical), byte-identity digest and all. The llmproxy inference producer captures on every forwarded inference call (inference_request,bd_2_0-xs0.2); the network gateway captures on the MITM’d HTTP egress it decrypts and parses — today the credential-inject path (network_flow,bd_2_0-xs0.3), sealing on the enclave KMS it now holds (§4.6). Egress the gateway only splices byte-for-byte (opaque TLS / non-inject MITM) never exposes headers to the gateway, so those flows record what IS knowable (SNI/dest) and mark headers unavailable — a bounded, stated gap, not a silent one. Headers can carry sovereign data, so they are encrypted, never exported to/metricsor app logs. Both points redact through a single shared one-stop-shop (internal/hdraudit, aforbidigofence blocks hand-rolling a header snapshot outside it) so the gateway and llmproxy cannot drift. A reviewer can now read the audit to see exactly what an agent SENT — and, for the policy-authoring workflow (bd_2_0-xs0.4), what was BLOCKED: a denied llmproxy call (model blocked, unresolvable upstream, credential unavailable) records a denyinference_requestrow carrying the sealed request headers, so an operator can stand up a new agent, read what it tried to send and why it was refused, and iterate policy without shell access to the agent. Gateway destination-policy denies fire on the SNI before the HTTP request is decrypted, so those rows record SNI/dest and mark headers unavailable (the gateway does not speculatively parse every denied connection). - No audit trail for the CONTENT of unclassified allowed egress — total mediation captured bodies only at
llmproxy, and only for calls the gateway classified as inference (known provider host+path) and force-routed there. An allowed HTTP flow to any other host — an unknown/undocumented inference provider, a generic API — was MITM’d but then spliced byte-for-byte, so the gateway recorded SNI/dest and marked headers/body unavailable: a reviewer could see that an agent talked to host X, never what it sent or received. That is the fail-safe direction (a stated gap, not a silent one), but it leaves an agent’s actual request/response shape on allowed non-inference egress unobservable. Mitigation (bd_2_0-uwi): an opt-in protocol detector on the gateway MITM path. When enabled, an allowed flow whose decrypted stream peeks as HTTP/1.x is parsed instead of spliced, and each exchange records the request line + headers + a size-capped request and response body; the full stream is still forwarded byte-exact (a capture tee), so enabling it does not alter the proxied traffic. Method/path/status are plaintext columns — the request-target is inspectable per the standing “scope from path, never body” invariant (whose sole documented, narrowing-only exception — group-aware agent create authoring its bodygroupunder managed-group cover, the §4.4 body-smuggling bullet anddocs/iam.md§8 — is a controlplane IAM authorization concern that does not reach this gateway egress-audit surface) — while headers and bodies are sealed at rest under the per-org DEK exactly like the inference bodies (§4.6), AAD-bound to a per-exchange trace id, ciphertext-only, never to/metricsor app logs, and byte-identity-verified on read (auditstore.OpenBody). It is default-on but gated by the KMS Crypter — a no-op wherever the gateway holds no KMS (bodies must be sealable to be captured), so it activates exactly where the KMS-gated header capture already does rather than being a separate opt-in; a deployment keeps headers-only by setting--http-capture=false. Because it only ever adds sealed, crypto-shreddable ciphertext under the per-org DEK (§4.6), turning it on by default widens what is recorded but not what is exposed. A non-HTTP stream falls through to the opaque splice unchanged (the peek consumes nothing); HTTP/2 is out of scope on this path for the same forged-leaf-no-ALPN reason as the inject path. - Persist a secret header value in the audit trail — capturing headers naively would write
Authorization/x-api-key/Cookievalues — including, undercredential_mode=inject, the sovereignllm.api_keythe enclave injects after stripping the agent’s own auth — into the store. Mitigation:internal/hdrauditobfuscates a case-insensitive denylist of well-known secret headers (Authorization, Proxy-Authorization, X-Api-Key, Api-Key, Cookie, Set-Cookie, X-Amz-Security-Token, …) to name + a salted keyed HMAC of the value — the name and the presence stay auditable, and a repeat/rotation is detectable via the stable hash, but the value is never persisted. This preserves and extends the gateway’s “audit the name, never the value” rule (§4.1) from credentials to all headers. The enclave-held HMAC salt (--audit-header-hmac-salt-file) is optional: without it secret values are still dropped to a redaction marker, only the hash is omitted. The record still also carriescredential_mode+ the credential NAME (llm.api_key) + theheader_diff. The gateway injects its per-request egress credential into a header whose name may be a custom (non-denylisted) one; it is added to the sensitive set for that capture (hdraudit.Policy.CaptureExtra), so the injected value is reduced to name+hash there too and never captured verbatim. A read of the store never yields a secret value. - Lose audit on a ClickHouse outage or restart — the store is down, or the process restarts with buffered records. Mitigation: a mandatory crash-safe on-disk WAL (
--audit-wal-dir/Config.WALDir,bd_2_0-8kl.10/bd_2_0-8kl.15): each batch is written temp-file →fsync→ atomic rename before flushing, removed only after it lands in ClickHouse, and replayed oldest-first on start (internal/auditstore/wal.go;internal/auditstore/auditstore.gorunWAL). Fail-closed durability: a component that wires the audit store with no WAL dir, or with a WAL it cannot open, refuses to start (auditstore.New,config.validateAuditWAL) — there is no silently-lossy in-memory deployment mode. The bare in-memory buffer survives only as a direct-unit-test path, unreachable throughNew. After mandatory WAL, the durability posture is layered in three rungs (bd_2_0-8kl.16/.17/.19, runbookdocs/runbooks/audit-durability.md): rung 1 is the mandatory WAL above; rung 2 bounds the remaining loss and makes it observable — the per-table spool is bounded by a configurable disk budget (--audit-wal-max-bytes, default 512 MiB) and, past it, batches drop counted and alertable (dvx_auditstore_dropped/wal_depth/wal_byteson/metrics), never silent; rung 3 is capture-or-deny for the only-copy class, the sovereign inference request body, and for the authority tier ofdocs/audit-evidence-tiers.md(bd_2_0-4p8.3: credential/secret disclosures, grant and policy authoring, deny rows, gated cross-org reads, erasure) — the emitter spools the record DURABLY before the gated effect (RecordInferenceDurable/auditstore.Store.RecordDurable→ the batcher blocks until the row isfsync+renamed) and denies the effect (HTTP 503) if it cannot within--audit-capture-timeout, rather than let it stand unrecorded (llmproxy:internal/enclave/llmproxy/handler.go,producer.gocaptureRequest; authority tier:internal/audit.AuthorityGaterouted from every component’semitAuthority, counted bydvx_auditstore_authority_capture_denied_total{action}plusdvx_llmproxy_inference_capture_denied_total). A captured call is split into two rows sharingrequest_id(record_form): the gatedrequestrow (received body + received headers, durable before forwarding) and a best-effortcompletionrow (response body + status/tokens, written after the response streams — an inference response cannot be denied over, so it stays under rungs 1+2). Capture-or-deny is on by default and revertible per deployment (--audit-capture-or-deny=false) back to best-effort recording. - Flood audit to force drops that bury one record — saturate the hot path so the batcher sheds load and hides an event. Mitigation: back-pressure drops rather than blocks (audit must never stall a component’s hot path) but loss is bounded and observable, not silent: in-memory overflow and WAL-disk-bound (
MaxWALBytes, default 512 MiB) drops are counted (Stats.Dropped/LostToErr/FlushErrs) and logged; the WAL raises edge-triggered back-pressure once the spool passesMaxWALBytes/2, exposed per-table viaStore.Stats().{NetworkFlow,AuditEvent,Inference}.WALDepthso producers can ease off (internal/auditstore/auditstore.gorecord,checkBackpressure;internal/auditstore/wal.goerrWALFull). - Leak sovereign data (or open a new attack surface) through the operational metrics endpoint — the bounded, counted loss above is only useful if it is monitorable, so each component can expose a Prometheus
/metricsendpoint (bd_2_0-pmy) surfacing audit-store health (dvx_auditstore_*: per-tabledropped/lost/flush_errors/wal_depth/wal_bytes/backpressure, flush + seal latency/size histograms). Two threats: a metric label could carry sovereign data into a scrapeable surface, and the endpoint itself is a new reachable surface. Mitigation: (1) labels are content-free and bounded-cardinality —table(a fixed enum) andform(received/response) only;orgis acceptable post-bd_2_0-j9r(one org per enclave) but there is no per-agent/per-request/prompt label; sizes and latencies are exported, never body bytes (the fence lives in theinternal/metricsone-stop-shop package doc, andVictoriaMetrics/metricsis confined there by adepguardfence so metric construction cannot sprawl). (2)/metricsis served on a dedicated TLS port (:9464), separate from the component’s primary API (so a scraper reaches only metrics, never the API), reusing the component’s serving cert where it has one — and the enclavedvx-tlsserving cert for the gateway and broker, which have no serving-cert file of their own (the gateway’s datapath is the cilium-envoy Unix socket; the broker mints its channel/operator certs in-memory via its LocalIssuer). For the gateway this:9464listener is its first and only TCP ingress surface — previously it had none — so it is scoped by a purpose-built default-denydvx-gateway-ingressCNP admitting only the node-local scraper; the Unix-socket datapath is L7 traffic a Cilium L3/L4 policy does not govern and is unaffected. Every component’s/metricsis network-policy-scoped to the scraper by a Cilium ingress rule (not open), admitting only node-localhost/remote-node(neverworld) — the same posture as the kubelet health probe; an in-cluster Prometheus would be admitted by its ownapp:selector instead. (3) Each plane scrapes its own components; the controlplane never dials an enclave’s/metrics(invariant 2), so this opens no enclave→controlplane or controlplane→enclave path. (4) The endpoint is fail-safe off the datapath: a bind/serve error is logged and the component keeps running (internal/serverStartMetrics), and it runs no request middleware, so it can neither stall nor be used to pivot into a component’s hot path. - Store unreachable makes the enforcement path fail — audit dependency stalls the component that must still deny/allow. Mitigation: deliberate decoupling — a connectivity failure at
Newonly warns and starts the batchers anyway; a component’s audit/enforcement path must not depend on store availability (internal/auditstore/clickhouse.goNew). - Reachable store whose SCHEMA is behind the binary silently drops audit — a rollout onto a newer binary against an audit database whose (out-of-band, goose/Make) migrations have not been applied yet. The writes target columns that do not exist, so batcher flushes error, retry, and then bounded-drop (a completeness gap), while every audit read 500s — all diagnosable only by reading the error. Mitigation (
bd_2_0-6p3): a schema mismatch is a permanent misconfiguration, unlike the transient unreachability above, so it is checked and fails closed the moment the store is reachable.auditstore.NewrunsverifyAuditSchemaafter a successful ping: it checkssystem.columnsfor the head migration’s canary column (network_flow.response_body,00007) and, if absent, refuses to start with an actionable error naming the exact migration to run (make cp-audit-migrate/make audit-migrate) — on both audit planes. A component will not serve a broken/silently-lossy audit API; it crash-loops loudly until the operator migrates. (An unreachable store still only warns and retries, unchanged — availability stays decoupled from the enforcement path.) - Mix sovereign and platform audit in one store — enclave agent signals leak into the controlplane’s platform store (or vice-versa). Mitigation: separate stores per plane (each component wires its own
--audit-dsn); the two never share a store (invariants 1/2,docs/architecture.md, “Two log streams”). Every record carries aScopediscriminator (platformvsagent, defaulting toplatform) tagged at write time (internal/audit/audit.goScope;internal/auditstore/clickhouse.goscopeOf), and the query path mandates an org scope (internal/auditstore/unified.goQueryAuditRecordsrejects an emptyOrgID). - Interleave or read two enclaves’ node files on a shared node — when two enclaves co-schedule on one node, their imds/gateway DaemonSets both live on the node’s
/run/dvxand could spool audit WAL into the same dir (corrupting or cross-mixing records) or read each other’s per-agent SVIDs (short-lived mTLS creds). Mitigation: each enclave’s imds + gateway DaemonSet mounts only that enclave’s own/run/dvx/<prefix>subtree (12-hex enclave prefix) at/run/dvx(bd_2_0-ktd), stamped in at apply time fromdvx-enclave-identity. A co-scheduled enclave’s WAL/SVIDs are therefore not even nameable inside this pod’s mount namespace — isolation is by the mount boundary, so it holds even though the containers run as root and regardless of how the operator schedules. Because that stamping is applied by a deploy-time step, a deploy that bypasses it would silently ship the shared-parent/run/dvxand re-open this cross-read; so the imds and gateway now verify the mount is their own subtree at startup and fail closed otherwise (internal/enclave/noderoot,bd_2_0-8pc) — reading/proc/self/mountinfoand refusing to run unless the mount source root ends with this enclave’s prefix. The mount-boundary isolation is thus enforced at runtime, not merely assumed from correct stamping. The broker still names the host-absolute per-agent socket path viainternal/namingScopeNodeDir(it authors the agent pod’s hostPath but is not itself narrowed); the imds/gateway no longer scope in-path. This prevents cross-read, not merely collision.
Residual trust / assumptions. The store is not cryptographically tamper-evident today — there is no signing or hash-chaining in internal/audit or internal/auditstore; the package doc’s “tamper-evident and complete” is an aspiration, not a built mechanism, and no bead is cited in the source for it. Actor/Scope are set by the emitting component and trusted, not independently verified at the store. Consequently anyone who owns the layer below — direct write access to ClickHouse, the WAL directory on the node, or a component holding the audit DSN — can insert, delete, or rewrite rows and forge attribution. We trust: the ClickHouse instance and its network path, the node’s filesystem (WAL fsync/rename durability), and the correctness of the components that author records. Durability is now fail-closed: a component with the audit store wired but no openable WAL refuses to start (bd_2_0-8kl.15), so there is no standing no-WAL deployment that silently accepts in-memory loss. When two enclaves co-schedule on one node, each enclave’s imds + gateway DaemonSet mounts only its own /run/dvx/<prefix> subtree (bd_2_0-ktd), so one enclave’s platform components can neither clobber nor read a co-scheduled enclave’s node files (per-agent SVIDs, WAL): same-node multi-tenancy is a supported posture, not merely a testing convenience. The remaining residual trust is unchanged and sits below this boundary: the imds/gateway DaemonSets run as root with a /run/dvx hostPath, so a container escape (or any node-level actor with direct filesystem access) reaches the whole node’s /run/dvx regardless of the per-enclave mount — that is the same node-filesystem trust already listed above (direct WAL access), not a new exposure, and it is why production still prefers separating high-sensitivity tenants onto different nodes/clusters as defence in depth.
Deliberately out of scope. Encryption at rest of the audit store and KMS key custody (covered by the “Data at rest & key custody” boundary). Immutable/append-only enforcement at the ClickHouse table level (no such engine config appears in source). Query-side authorization beyond the mandatory org scope, and operator access control to the audit-query API (a relay/operator-auth boundary concern). Cryptographic non-repudiation / signed audit chains (designed-but-unbuilt).
5. Assumptions (system-wide)
Each boundary lists its own assumptions; these are the load-bearing ones that cut across the whole system. If one is wrong, more than one boundary is incomplete.
- The silicon vendor roots are sound. Intel PCS / AMD KDS signing keys and the SEV-SNP/TDX implementations have no attestation-forging break. A vendor-root break invalidates every attestation tier at once (§4.2).
- On a cloud CVM, the cloud provider is in the TCB — and behaves. Its firmware/verifier below the CVM boundary is honest; an attacker who owns that layer can read guest memory beneath the measurement. Accepted, disclosed residual trust — the reason a customer who won’t extend it runs bare metal or their own enclave (§4.2, §4.6).
- Zitadel is the sound, sole issuer of controlplane-trusted principal identities; the controlplane itself is the sole issuer of the agent-operator token class. Zitadel’s signing keys are not attacker-controlled and it authentically stamps org/group claims. A Zitadel compromise mints authority the controlplane will honor (§4.3, §4.4). Since bd_2_0-bvw the controlplane additionally issues its own 60s ES256 agent-operator tokens under a separate KMS-custodied key (
internal/controlplane/agenttoken) — a credential class for one caller kind, never principal identities: it cannot turn an agent into auser:principal (the kind is structural to which authenticator accepted the token), and its blast radius is the read-only auditor surface (§4.4). Both issuers are controlplane-plane custody; the enclave mints neither (invariant 5 unchanged — the pinned enclave CA is a verification input). - Identity-minting authority stays in the controlplane (invariant 5), enforced structurally by the
depguardfence that barsinternal/enclavefrom importing the Zitadel management client. A compromised broker mints only local creds (§4.3). - Sovereign data never crosses to the controlplane (invariants 1 & 2), and the enclave’s data plane serves indefinitely from last-known metadata during a CP outage — the CP is never on the sovereign-data path (§4.3, §4.5).
- Each KMS trust domain is dialed only by its own plane, its binding set as local deploy config the other plane never learns; a cloud enclave KMS is the customer’s own account/region (§4.6).
- The measured envelope is the right one — an uncompromised build pipeline produces the guest kernel + agent + injected runtime the reference values name; the BYO payload is unmeasured by design (§4.2).
- Boot-time attestation suffices for Kata VM-isolated pods (lifetime == liveness); continuous re-attestation is an open question, not a guarantee (§4.1, §4.2).
- Fail-closed is the default for every access decision. No covering grant →
deny (§4.4); unresolved egress identity → deny (§4.1); empty measurement
allowlist → reject all (§4.2); failed decrypt / AAD mismatch → error, never
plaintext (§4.6); a failed metadata pull keeps the last-known-good copy (§4.3);
audit durability requires a WAL — a component that wires the audit store with
no
--audit-wal-dir, or with a WAL it cannot open, refuses to start rather than silently downgrading to a lossy in-memory buffer (bd_2_0-8kl.15, §4.7). This fail-closed start requirement does not stall the enforcement path: writes remain async and non-blocking (the batcher drops-not-blocks under saturation, with bounded, counted, back-pressured loss); “fail closed” means “refuse to start without a durable spool”, not “block the hot path on ClickHouse”. One deliberate, signed-off exception (bd_2_0-8kl.17/.19, capture-or-deny): for the record classes that are the only copy of what an agent sent or the authority evidence that a privileged effect was authorized — the sovereign inference request body, and the authority tier ofdocs/audit-evidence-tiers.md(credential/secret disclosures, grant and policy authoring, deny rows, gated cross-org reads, erasure) — the emitter spools the record durably before the effect (forwarding, the 2xx, the commit, the disclosure) and denies the effect (HTTP 503) if it cannot within a bounded timeout (--audit-capture-timeout, default 5 s), rather than let it stand unrecorded (bd_2_0-4p8.3). This is a deliberate, bounded coupling of those paths to audit durability, revertible to best-effort with--audit-capture-or-deny=falseper component (a temporary operator choice, alerted on when used — the refusal counterdvx_auditstore_authority_capture_denied_total{action}also fires when the gate itself refuses). Ordinary telemetry/completion records still drop-not-block under rungs 1+2. See §4.7 and the tiers doc for the per-record classification.
Dev-only weaknesses accepted today (not production posture): the register-broker
mint bootstrap still uses --insecure-skip-verify TLS against the CP (§4.3, aoq.3);
and a relay with no --operator-audience pinned accepts any issuer-signed token,
backstopped by the broker’s re-validation (§4.5).
6. What we are deliberately NOT addressing (system-wide)
- A compromised silicon vendor root — no layer below it to appeal to; hardware attestation is off the table platform-wide (§4.2).
- The customer attacking their own enclave — every enclave serves exactly one
org (
bd_2_0-j9r), so the operator is the data owner and this collapses to the customer attacking itself. There are no shared-enclave co-tenants (§4.2, §4.6). - Physical attacks on customer bare metal — cold-boot, bus interposers, BMC compromise are the customer’s physical-security responsibility; we rely on the TEE’s memory-encryption only (§4.2).
- Malicious payload detection — we attest the envelope, never the BYO application image. A hostile payload is contained (total mediation, default-deny egress, audit), never detected (§4.1, §4.2).
- Zitadel’s own internal security — login, MFA, credential storage, upstream federation/WIF, refresh-token lifecycle are Zitadel’s surface by adoption (§4.4).
- Customer-side custody of delivered credentials — service-account key files (bd_2_0-ugb) and broker registration keys are delivered exactly once and held by the customer; their at-rest hygiene on the customer’s side is the customer’s responsibility. The platform levers are rotation, deletion, and the audit trail (§4.4).
- Cryptographic non-repudiation of audit — signed / hash-chained audit records are designed-but-unbuilt; the store is not tamper-evident today, and anyone with direct write access to it is below that boundary (§4.7).
- Microarchitectural side channels between agents co-located on a
node-cvmnode, beyond what hardware + Kata provide (§4.2). - Denial of service — a flood that forces bounded, observable audit drops (§4.7); an attestation-service outage that fails provisioning closed (§4.2). We treat these as availability, not trust-boundary breaches; running agents are unaffected.
- The deferred direct sovereign-data transport (§4.5,
bd_2_0-kzb) — its payload-confidentiality and NAT-traversal threats are named but not yet defended.
Where this is enforced (primary homes)
| Concern | Home |
|---|---|
| Runtime attribution (Cilium identity ↔ agent) | internal/enclave/attribution |
| Egress mediation / TLS+QUIC termination | internal/enclave/gateway |
| Attestation wire vocabulary / verifier / tiers | internal/attest, internal/enclave/attest, internal/enclave/agentid/tier.go |
| CP→enclave metadata channel (pull + per-enclave binding) | internal/cpchannel, internal/enclave/cpsync, internal/controlplane/iam |
| Broker identity + local issuance | internal/enclave/cpbroker |
| Authn (OIDC) / authz (capability × scope) | internal/authn/oidc, internal/authz, internal/controlplane/iam |
| Edge ingress (relay) | internal/enclave/relay |
| Envelope encryption / KMS custody | internal/kms |
| Audit stream + store | internal/audit, internal/auditstore |
| Structural fences (import boundary, one-stop-shop) | .golangci.yml (depguard, forbidigo) + drift-review skill |
Related
agent-identity.md— the three enforcement planes in full.architecture.md— sovereignty invariants (1–6), data-at-rest and key custody, the two log streams.iam.md,enclave-broker.md,client-interaction.md,controlplane-data-minimization.md— per-area deep-dives.- Beads shaping unbuilt defenses named above:
bd_2_0-che(real HW attestation),bd_2_0-aoq(broker identity / out-of-band key),bd_2_0-kzb(direct sovereign-data transport),bd_2_0-k5t.6/.7/.10(KMS-backed CA, operator-CMK at-rest, self-hosted controlplane),bd_2_0-8kl.10(audit WAL).