The trust root

How the Certificate Distribution Service comes up and what it vouches for — the in-memory mesh CA, EAR signing and JWKS rotation, the challenge/attest flow, sandbox identity, leaf signing, CA handoff, and why clients verify the CDS before trusting it.

The Certificate Distribution Service (CDS) is the trust root of a c8s cluster. In one process it verifies TEE attestation evidence, issues short-lived EARs (Entity Attestation Results), manages an in-memory mesh CA, signs leaf certificates, gates issuance on what the requesting pod is actually running, and — optionally — hands its CA off to peer replicas. There is no internal RA-TLS hop to an external signer, so the CA private key never leaves CDS memory.

Mesh CA bootstrap

At startup the CDS obtains its ECDSA P-384 mesh CA entirely in memory:

  • The private key is in-process only and is never written to disk or to a Kubernetes Secret.
  • Validity defaults to 8760h (one year), configurable with --ca-cert-validity. The CN defaults to c8s Mesh CA (--ca-common-name).
  • With no --handoff-peer-url the CDS generates a fresh self-signed CA. With one set it adopts a surviving peer's CA over the attested handoff flow instead, and fails startup if that peer is unreachable, refuses, or attests a different operator-key policy — so a partition can never mint a divergent trust root.
  • The public CA bundle is published so peers and clients can chain to it; the CA fingerprint and its source (self-generated or adopted-from-peer) are logged at startup.

Because the key lives only in memory, a CDS restart without a handoff peer generates a new CA and invalidates previously issued leaves. That is acceptable for the chart-managed default; the production direction keeps the CA in attested CVM memory and replicates it via handoff.

EAR signing and JWKS rotation

The EAR is a JWT, signed with ES256 (ECDSA P-256), that the CDS mints via /attest-key after a successful attestation. It is a TEE-bound credential for flows that attest a key rather than obtain a certificate: CDS's own handoff machinery uses it to authorize CA handoff, and a caller already holding one can have a CSR signed via POST /sign-csr. Workload certificate issuance does not use it — /attest verifies evidence and returns the signed leaf in one step. (Allowlist writes use an operator token, not an EAR.) Key claims:

ClaimMeaning
ississuer (--ear-issuer, default cds)
iat / expissued-at and expiry. the lifetime follows --cert-ttl (default 24h)
submods.attesterthe attestation result, including ear_status, the trustworthiness vector, raw evidence, and the normalized launch_digest
tee_public_keybase64url PKIX DER of the ECDSA key the TEE attested — used for key binding
operator_keys_hash (optional)the canonical hash of the CDS operator public-key set, folded into REPORTDATA so two handoff replicas can require the same write policy
pbh (optional)payload-body hash, binding the token to a specific request body

The signing key rotates with overlap so verification never breaks mid-rotation:

  • --token-signer-rotation-interval (default 720h) — how often a new P-256 key becomes active.
  • --token-signer-overlap (default 25h) — how long a retired key stays valid and published.
  • --token-signer-rotation-jitter (default 0.1) — jitter on the first tick.

Each key's kid is its RFC 7638 JWK thumbprint. The current JWKS is served at GET /.well-known/jwks.json. A retiring key is rejected the moment it passes its overlap deadline, and drops out of the JWKS at the same point — not at the next rotation, which with the defaults is weeks later.

The attestation flow

The CDS uses a nonce-challenge model so attestation evidence is cryptographically bound to a CSR's public key, preventing evidence replay or substitution. One challenge covers the whole issuance: it binds the evidence and the sandbox token, so neither can be replayed into a different request.

   get-cert                 CDS                    attestation-api
   [in the pod]             [trust root]           [same TCB as CDS]
      │                       │                       │
      │  POST /authenticate   │                       │
      │──────────────────────►│                       │
      │                       │                       │
      │  challenge            │                       │
      │  (single-use, 32 B)   │                       │
      │◄ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│                       │
      │                       │                       │
  ┌───┴─────────────────────────────────────┐         │
  │ redeem a sandbox token from the local   │         │
  │ inventory (see Sandbox identity)        │         │
  │ report_data =                           │         │
  │   SHA-384(CSR pubkey ‖ challenge)       │         │
  └───┬─────────────────────────────────────┘         │
      │                       │                       │
      │  POST /attest         │                       │
      │  challenge + evidence + CSR + sandbox_token   │
      │──────────────────────►│                       │
      │                       │  verify(evidence,     │
      │                       │         report_data)  │
      │                       │──────────────────────►│
      │                       │                       │
      │                       │  valid · launch_digest│
      │                       │◄ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│
      │                       │                       │
      │              ┌────────┴─────────────────────┐ │
      │              │ measurement pin · CSR policy │ │
      │              │ sandbox images allowlisted   │ │
      │              │ sign (mesh CA)               │ │
      │              └────────┬─────────────────────┘ │
      │                       │                       │
      │  leaf certificate + CA chain                  │
      │◄ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│                       │
      │                       │                       │

The challenge is the freshness proof; the evidence proves the key lives in a measured TEE; the sandbox token proves which pod is asking.

Step by step, on POST /attest the CDS:

  1. consumes the one-time challenge (decoded from base64; --challenge-ttl, default 60s);
  2. parses the CSR and verifies its self-signature;
  3. verifies the sandbox token, if one was sent — see Sandbox identity;
  4. computes the expected report_data = SHA-384(CSR_public_key ‖ challenge) (first 48 bytes);
  5. calls the attestation-api /verify with the evidence and that expected report data;
  6. requires signature_valid == true and report_data_match == true;
  7. if --measurements is set, requires the evidence launch_digest to be in that allowlist;
  8. validates the CSR's DNS/IP SANs and CN against policy (--dns-san-pattern, --san-validation, --allowed-cn-pattern);
  9. asks the sandbox's inventory what it is running and requires every image to be allowlisted;
  10. signs the CSR with the mesh CA and returns the PEM chain.

--measurements defaults to empty, which means no pinning — any TEE that produces valid evidence is accepted. The CDS logs a warning at startup in this mode. Pin it in production — see Obtain launch measurements for how to get the values to put here.

What an issued leaf carries

Leaves are short-lived: /attest issues at --cert-ttl, hard-capped at 24h, and /sign-csr honors the caller's requested TTL up to --max-ttl (default 24h). Every leaf carries, in its signed area:

OIDExtensionContents
1.3.6.1.4.1.59888.1.1RA-TLS attestationthe requester's own hardware evidence, copied verbatim from the CSR
1.3.6.1.4.1.59888.1.2audit digestSHA-256 of the evidence the CDS accepted at issuance
1.3.6.1.4.1.59888.1.4pod sandbox IDpresent only when the requester presented a sandbox token

The .1.1 extension is what makes an issued cert re-verifiable: get-cert binds evidence to its bare CSR key with no nonce, and the CDS copies that extension onto the leaf, so a relying party can re-check the hardware evidence itself rather than trusting the CA chain alone. The challenge-bound evidence verified at issuance is deliberately not embedded — its REPORTDATA includes the consumed challenge, so it could never re-verify against the bare key.

Sandbox identity

Hardware attestation binds a key to a launch measurement. Under Node-as-CVM every pod on a node shares that one measurement, so it says nothing about which pod stands behind a mesh key. Sandbox identity narrows that: a leaf names the CRI pod sandbox the CDS issued it to, and the CDS issues only after asking that sandbox's own admission record what it is running.

The inventory is the component that admitted the pod's containers — nri-image-policy on Node-as-CVM, policy-monitor inside the Kata guest. It is the arbiter of both which sandbox a process belongs to and what runs in that sandbox, and it serves two deliberately disjoint surfaces: a local token route that only the pod's own get-cert reaches, and a network identity/digests route on port 1019 that only the CDS reaches. The token route cannot enumerate other sandboxes; the network route cannot mint identity.

   ╔═ one TEE: the node under Node-as-CVM, the guest under Pod-as-CVM ═╗
   ║                                                                   ║
   ║  ┌────────────┐  1  CSR pubkey +    ┌────────────────────────┐    ║
   ║  │  get-cert  │──── CDS challenge ─►│  inventory:            │    ║
   ║  │ [c8s-cert] │◄─── signed sandbox ─│  nri-image-policy on   │    ║
   ║  └─────┬──────┘  2  token           │  Node-as-CVM, or       │    ║
   ║        │                            │  policy-monitor in the │    ║
   ║        │                            │  Kata guest            │    ║
   ║        │                            └───────────▲────────────┘    ║
   ╚════════║════════════════════════════════════════║═════════════════╝
            │                                        │  4  GET /identity
            │  3  sandbox_token, forwarded           │     GET /digests/<id>
            │     verbatim on POST /attest           │     on :1019, mutually
            ▼                                        │     attested RA-TLS
   ┌──────────────────────────────────────────┐      │
   │  CDS — verifies the token under the key  ├──────┘
   │  it fetched, requires every reported     │
   │  image to be allowlisted, then stamps    │
   │  the sandbox ID onto the leaf            │
   └──────────────────────────────────────────┘

The requester never names its pod and never reports its images; both answers come from the component that made the admission decision.

  1. get-cert asks anonymously. It POSTs /sandbox carrying only its CSR public key and the CDS challenge — no PID, pod name, or container ID. On Node-as-CVM this is a node-local Unix socket, where the kernel stamps the caller's credentials (SO_PEERCRED plus SO_PEERPIDFD) and the inventory resolves them PID → cgroup → container → sandbox from its own admission record. Inside a Kata guest it is the guest's loopback 127.0.0.1:8401, where the one-pod- per-guest boundary leaves nothing to disambiguate. The webhook injects --workload-claims into the c8s-cert sidecar (plus --workload-claims-guest under Kata); both addresses are compiled into get-cert, so that flag selects the shape, never an address, and a wrong setting fails closed against a port nothing serves.
  2. The inventory signs a token over the sandbox ID, SHA-256 of the requester's public key, the CDS challenge, and the IP of the node or guest serving its own identity endpoint. The envelope carries no credential for the signing key.
  3. get-cert forwards it opaquely in the /attest body as sandbox_token.
  4. The CDS resolves the key itself. It reads the inventory host out of the unverified token — the only thing that value ever does is pick a dial target — requires it to be a routable unicast IP literal (never a name, never loopback, link-local, or multicast) inside a CIDR the operator configured with --sandbox-inventory-cidr, then fetches the signing key from GET /identity at <host>:1019 over mutually-attested RA-TLS. Only then does it verify the signature, require the token's nonce to be the challenge it is consuming, and require the key digest to name the CSR key.
  5. The CDS asks what the sandbox runs. GET /digests/<sandboxID> at the same endpoint returns the sorted, deduplicated image digests the inventory currently tracks in that sandbox. Every one must be in the allowlist. An empty answer is refused, not treated as "nothing to check" — a sandbox always runs at least the sidecar that is asking.

Port 1019 is the whole identity argument. It is a compiled constant, not a deployment value, and it is privileged: binding it requires the node's own network namespace, which the chart's deny-host-namespaces ValidatingAdmissionPolicy (hostNamespacePolicy.enabled, on by default) withholds from tenant pods. Measurement cannot make this distinction on Node-as-CVM — every pod there shares the node's launch digest — but "answers on :1019 in the node's netns, at an address inside the operator's node range" can.

--sandbox-inventory-cidr is required for any of this. Unset, the CDS refuses every request carrying a sandbox token and logs a warning at startup; pods still get mesh certificates, but with no sandbox ID and no issuance-time image gate. c8s install --node-cidr sets the chart value cds.sandboxInventoryCIDRs, and with the flag omitted c8s install fills it in from the cluster as one host route per node — a point-in-time snapshot, so a node added later is not covered until the value is refreshed. A CDS with no --ratls-platform has no RA-TLS identity to present to an inventory, so it also refuses every token.

What vouches for a sandbox ID — and what does not

The sandbox ID rides the leaf's signed area; it is not folded into REPORTDATA. The mesh CA signature, not the hardware evidence, is what authenticates it. Everything downstream encodes that:

  • A relying party pins it with c8s verify --sandbox-id <id> --mesh-ca <bundle>. --mesh-ca is mandatory with --sandbox-id. Without it the verdict still reports the ID, but alongside a sandbox_id_note saying it is not verified — an unqualified ID never reads as attested.
  • In-mesh, a SandboxID pin is enforced only on the CA-verified branch of the dual verifier. Pure RA-TLS verification of a self-signed peer fails closed on a pin, because a self-signed leaf's extension is whatever its holder chose.

The gate is membership, not composition: every running image must be allowlisted, but the running set is not required to match a whole workload entry. Issuance lands at arbitrary points in a pod's lifecycle — an init container running, main containers coming up one at a time, one restarting, completed init containers reaped — and in each of those the running set is a strict subset of what the pod declares. Requiring the whole set would deny certificates for ordinary states, permanently so once init containers are reaped. So a leaf's sandbox ID means this key belongs to pod X, not pod X runs exactly workload Y. Per-container digest and argv policy is enforced continuously at admission by nri-image-policy / policy-monitor instead — see The Allowlist.

The residual trust is the inventory itself: the key's provenance narrows to a node, not to a process, so anything able to bind :1019 on a node — the inventory, or a privileged node DaemonSet — can sign for any sandbox that node admitted. Under Kata each guest holds one pod and the token's host selects which guest the CDS asks, which is tighter. See Limitations.

The image allowlist

The CDS also owns and serves the image allowlist — the digests of the container images permitted to run (a SQLite store at --allowlist-db, served at /allowlist). Updating it always goes through the CDS; there is no side channel. It is the same store the sandbox gate above checks at issuance.

Who may update it is decided by the operator at CDS bootstrap: the install-time value cds.operatorKeys (--operator-keys, set by c8s install --operator-keys) pins the operator EC public key(s) authorized to write; empty rejects every write while reads keep serving. For each write, the c8s allowlist CLI signs a short-lived token with the operator's private key, bound to the exact method, path, and request body; the CDS verifies the signature against its pinned keys and re-checks each binding before committing. The pinned bundle is public material (served back at GET /operator-keys), but it is host-supplied config — it is committed to REPORTDATA only on the /attest-key and /handoff paths, not by the serving certificate. Cross-check it with c8s cds verify --operator-keys <bundle>, which fails closed on a swapped key set.

See The Allowlist for the data model, the full write flow, seeding, and enforcement.

CA handoff

The in-memory-only CA is exactly what makes high availability tricky. Because the private key never touches disk (see Mesh CA bootstrap), the usual HA tactics don't apply: a cold-standby replica or a simple pod restart each comes up generating a fresh CA, which signs a different chain and invalidates every leaf already issued. There is no shared key on a PersistentVolume to fail over to — that's the whole point of keeping it in memory.

Handoff resolves this by moving the live CA from one attested CDS to another, in memory, over an attested channel — never through disk or a third party:

  1. A successor replica starts, generates an ephemeral X25519 keypair, attests itself, and calls POST /handoff on the active CDS, presenting its EAR and that ephemeral public key.
  2. The active CDS verifies the EAR against its own JWKS, checks the recipient's launch_digest against --handoff-measurements, and requires the recipient's REPORTDATA-bound operator_keys_hash to equal its own — so the CA only ever leaves for another CDS whose exact launch measurement you have pinned and whose allowlist-write policy matches.
  3. It returns the payload — the CA cert and private key, plus the current allowlist snapshot (digest floor and workload entries) — AES-256-GCM-encrypted to the recipient's X25519 key (X25519-ECDH → HKDF-SHA256 → AES-256-GCM). The key crosses the wire only as ciphertext that one attested enclave can open, and it is written to disk on neither side.

The successor now holds the same CA and the same runtime policy state, so every certificate the cluster has already issued stays valid across the changeover — a rolling upgrade or a failover happens with no trust-on-first-use gap and no mass re-issuance.

Handoff is opt-in (cds.handoff.enabled=true, which passes cds.measurements through as --handoff-measurements) and requires --operator-keys, since the operator policy is what both sides attest. With no measurements listed, POST /handoff is not mounted at all. Pair it with the CA's validity window (--ca-cert-validity) and the /readyz gate — which starts failing once remaining validity drops below --min-ca-validity (default 1h) — so a successor is brought up and handed the CA well before the active one's certificate expires.

Verifying the CDS itself (RA-TLS)

Clients verify the CDS before trusting anything it returns. The CDS serves its API over RA-TLS: its serving certificate carries hardware evidence (SEV-SNP or TDX) in an X.509 extension, minted for the platform named by --ratls-platform (default sev-snp). A client (for example get-cert --cds-measurements <sha384-csv>) verifies that evidence through its local attestation-api and checks the CDS launch digest against the supplied allowlist before completing the handshake. With no measurements supplied the client accepts any RA-TLS-attested CDS and logs a warning — pin --cds-measurements in production (the CDS's own launch digest; see Obtain launch measurements).

To check the CDS on demand rather than as part of a client handshake — after install, or on a schedule from CI — run c8s cds verify.

Browsers cannot do RA-TLS at all — they cannot inspect a certificate mid-handshake — so external clients get a different proof, and it terminates in the same place: the cds-attest sidecar beside tls-lb signs its post-quantum session transcript with the CDS-issued mesh leaf and commits the mesh CA alongside it, so the identity a browser ends up trusting is the mesh identity this CA issued. See consumer & browser verification.

See also