ADR-031: A kbs-attested Key Is Bound to Code Identity Through Init-Data, and the Kubernetes Operator Only Carries It

Status: Accepted Date: 2026-10-21 Authors: MCP Hangar Team

Context

ADR-030 (an upstream pinned by its key at the connection) lets a key list declare issuance: kbs-attested: a Key Broker Service released each key only to an attested guest "for the workload it was provisioned for" (Guarantee A). Hangar records the declaration and verifies none of it (ADR-030 §3). ADR-030 §2 assumed the key pair is provisioned as a KBS resource and its SPKI read back from that resource, and left that premise to core #1936.

The finding on #1936 shows the premise does not hold as written. These are the facts that shape this decision (Trustee 7001db235568b4b989c035b6a53015fbd3b33b7f, kata-containers a7fce8cbe1ab68310c200243f4a73428172c22fb):

  • No read-back. A KBS resource cannot be read back by an admin. The resource plugin admits the admin credential only to upload, delete and list names. A GET of one resource needs an attestation token, and its response is encrypted to the guest.
  • What identifies the workload. The only per-workload value a release can be bound to is the hash of the guest's init-data: SNP HOSTDATA, TDX MRCONFIGID. That binding means something only on TEE evidence whose guest stack was appraised against reference values. Trustee's sample verifier copies whatever hash the guest supplies.
  • Only the whole hash covers the rules. Trustee keeps only the data block of the agent policy in the parsed init-data claims and drops its rules. What the guest may do is decided by the rules, so only the hash of the whole document binds them.
  • How the init-data and the agent policy reach the guest. One pod annotation carries the init-data to the guest: io.katacontainers.config.hypervisor.cc_init_data, gzip-compressed and base64-encoded. Kata's genpolicy, which produces the agent policy, writes into that policy:
    • the value of every environment variable taken from a Secret;
    • the content of no volume, because CopyFile is confined to a path prefix, not to bytes;
    • no image digest unless the image is referenced by one (image_layer_verification defaults to none).

Under these facts kbs-attested cannot mean "this instance". It can mean "this exact code, in an appraised TEE". This record decides what Hangar does about that: what the declaration means, and what the Kubernetes operator carries and refuses.

The KBS resource policy, the agent policy and key rotation belong to whoever provisions the key, and Hangar sees none of them. guides/KEY_PINNED_UPSTREAMS.md describes them under "Attested keys" (mcp-hangar/docs#505).

Decision

1. No Hangar component generates, holds or fetches a kbs-attested key

The key pair is generated by its provisioner, who uploads the private key to the KBS and lists the public half. Neither core nor the Kubernetes operator holds a private key or a KBS credential, or connects to a KBS. The SPKI never comes from the running pod (ADR-030 §2).

2. What kbs-attested declares: code identity, not instance identity

issuance: kbs-attested declares that a KBS released each listed key only to a guest on appraised TEE hardware whose init-data hash was the one bound to that key.

The binding is code identity, not instance identity. Any party that launches a guest with the same init-data, on hardware the reference values accept, receives the key. That party can be a pod creator on the cluster, the untrusted host, or another platform that reaches the KBS.

A clone can take over the address. Such a clone runs exactly the same code, and it can take over the address of the server it copies. Against kbs-attested, ADR-030 §4(a), "a pod that inherits the IP does not hold the listed key", therefore holds only for a pod that runs different init-data.

The init-data must name its key_ref. For the same reason, each init-data is tied to one key lineage. Otherwise two servers with identical renderings would have identical init-data and would receive each other's keys.

3. The Kubernetes operator carries the init-data and refuses what would make it meaningless

The field. MCPServerSpec gains initData, the init-data document (TOML) the pod's guest is launched with.

What the operator does with it.

  • Copies it exactly. It writes the document, byte for byte, into the pod's io.katacontainers.config.hypervisor.cc_init_data annotation. It never synthesises, edits, reformats or merges it. A change to it rolls the pod.
  • Hashes, never parses. The operator publishes status.initDataDigests, the SHA-256, SHA-384 and SHA-512 of the bytes it rendered, so a provisioner can check the hash it binds against the hash the guest will present. Hashing is not parsing: the operator does not read the document's contents.

CEL rules on a container-mode CR whose spec.tls.peerIdentity.issuance is kbs-attested. Each rule exists because without it the declaration says less than it seems to:

  • initData is required, and it contains the keyRef, so each lineage has its own hash (§2).
  • image contains @sha256:. Guest image pull goes through the host's network, and a tag resolves to whatever the host serves. CRD CEL reads the spec, so this needs no annotation. The admission policy for the opt-out annotation (ADR-030 §4) stays as it is.
  • No env entry takes its value from a Secret. genpolicy would write the secret's value into the agent policy, and the init-data is public.
  • volumes holds only emptyDir, and automountServiceAccountToken is false. The contents of ConfigMap, Secret and projected volumes reach the guest through CopyFile, which the agent policy confines by path, not by content. Those contents would be the host's input.

CEL rules on every other CR, and on discovery templates. initData is refused on any other CR, and on a discovery template (MCPDiscoverySource.providerTemplate.spec), which already refuses tls.peerIdentity.

Size. initData has a maxLength of 98,304 bytes. The binding limit is the 256 KiB on the CR's own annotations, because client-side apply copies the whole spec into kubectl.kubernetes.io/last-applied-configuration.

4. The rendered pod is a published artefact

The agent policy admits only the exact container specs the pod is rendered with, so the provisioner generates it against the operator's rendering. A change to that rendering is an outage for every kbs-attested user. The Kubernetes operator therefore:

  • offers a dry-run that prints, for a CR, exactly the Deployment it would apply: the input genpolicy takes;
  • keeps a golden-file test of that rendering for a kbs-attested CR;
  • treats a change to the golden file as a change its users must act on, with an upgrade note.

5. Acceptance tests

#TestTier
T26 (as run under this record)End-to-end on kata-qemu-coco-dev with Trustee's sample attester. The KBS policy admits sample evidence, which a production policy must not do, and binds the key to the CR's init-data hash. A pod from the CR gets the key, serves TLS with it, T1 passes, and the event records declared_key_issuance. A pod launched with other init-data gets no key. kubectl exec into the pod is refused. The docs say this proves wiring, not confidentiality.nightly e2e (KVM)
T28CRD: each rule in §3 refuses a violating CR, and a CR that satisfies all of them is admitted. The violations are: no initData; initData without the keyRef; a tagged image; an env from a Secret; a ConfigMap, Secret or projected volume; a mounted service-account token; initData on another CR or on a discovery template; initData over maxLength.operator unit (envtest)
T29The pod carries cc_init_data whose decoded content is byte-identical to spec.initData, including a trailing newline. status.initDataDigests equals the digests of those bytes. A change to initData rolls the pod.operator unit
T30The dry-run output for a kbs-attested CR equals the golden file.operator unit

ADR-030's T14 (no Secret reads in the operator's RBAC) and T22 (import boundary) cover §1 and are re-run unchanged.

Consequences

Positive

  • kbs-attested can be offered on Trustee as it is, with no Hangar component in the key's path and no KBS credential in the cluster's control plane.
  • The declaration says what it delivers: code identity in an appraised TEE. The CEL rules keep a tag, a secret in the environment or host-supplied volume content from undoing what the init-data guarantees.
  • A broken binding is diagnosable. status.initDataDigests shows the hash the guest will present, and the dry-run shows the input genpolicy needs.

Negative

  • ADR-030 is narrowed for kbs-attested.
    • Guarantee A moves from "the workload it was provisioned for" to code identity.
    • ADR-030 §4(a) holds only against a pod with different init-data.
    • Trust root (a) grows to include the provisioner's resource policy, reference values and init-data.
  • A clone of a kbs-attested server can take over its address. It runs the same code, but where the attacker chooses. That is the threat ADR-030 was written against, now reduced to "same code" rather than closed.
  • A kbs-attested server takes configuration only through its image and emptyDir. Secrets in env, ConfigMap and Secret volumes, and a service-account token are refused.
  • The binding is brittle. A change to init-data, a pinned image or the operator's rendering changes the hash or what the policy admits. Unless the binding is updated first, the next pod gets no key: fail-closed, but an outage.
  • The provisioner owns all of the binding, and Hangar cannot detect an error in any of it.

Neutral

  • ADR-030 itself is unchanged. The register records this record as replacing part of it: §2's provisioning premise, the meaning of Guarantee A for kbs-attested, and T26. The rest of ADR-030 stays in force.
  • The provisioner's requirements are in the guide. guides/KEY_PINNED_UPSTREAMS.md covers:
    • the KBS resource policy: TEE evidence, an affirming appraisal and the whole init-data hash;
    • the agent policy: genpolicy defaults plus AllowRequestsFailingPolicy := false;
    • rotation.

Alternatives Considered

1. The Kubernetes operator generates the key and writes the KBS resource and policy

  • Rejected: it would need the KBS admin credential, which is enough to release any resource to any guest, and it would hold private keys.

2. Bind the release to a claim in the init-data that names the resource path

  • Rejected: the parsed claims omit the agent policy's rules, so a guest whose agent policy allows exec would receive the key.

3. The Kubernetes operator synthesises the init-data

  • Deferred: the operator would author the agent policy, which is the security-relevant half of the binding. The dry-run of §4 makes generation on the provisioner's side workable, and this can be revisited on top of it.

4. A full successor to ADR-030

  • Rejected: only §2's premise, the meaning of Guarantee A for one value of issuance, and T26 change. The register's decision relationships record a partial replacement without rewriting the shipped key-pin decision.

5. Drop kbs-attested

  • Rejected: code identity in an appraised TEE is a real and checkable property, and core records the declaration (#1998). Its limit is now stated.

References