CDS HTTP API

CDS endpoints, authentication, request and response formats, conditional policy writes, and replicated-state behavior.

The CDS (Certificate Distribution Service) serves its HTTP API over RA-TLS from an external confidential VM, listening on port 8443 by default. Its required c8s-cds/v1 launch configuration is verified byte for byte through the VM's local attestation API before startup. The host-readable document contains public inputs: node identities, mandatory nodeCIDRs, operator public keys, workload policy, and DNS SAN rules. An empty operator-key set prevents startup. Keep private keys and secret values out of this file. See c8s cds config.

Clients authenticate CDS with a pin binding both its image and exact launch configuration. That identity is independent of the node identities CDS admits and the node server/router identities clients pin. Direct CDS commands (cds ca, secrets, volume) require a launch-file-bound image policy; ordinary allowlist and routes commands also retain flat measurement flags. Router discovery is supported, but unpinned HTTPS reads are refused.

The router front door proxies only /allowlist and paths under /allowlist/ to CDS. Other routes use the CDS endpoint directly. In the tables, none means no HTTP credential or client certificate is required; clients still verify the server's attested identity.

Attestation and issuance

MethodPathAuthResponse
POST/authenticatenone200, JSON { "challenge": "<base64>" }; 503 if a challenge cannot be issued
POST/attestchallenge + evidence200, application/x-pem-file: issued leaf followed by the mesh CA certificate

Send POST /authenticate and POST /secrets with an empty body; their challenge handlers do not decode request JSON.

/attest accepts these JSON fields:

FieldFormat
challengeStandard base64 nonce returned by /authenticate
evidenceJSON envelope { "platform": "tdx", "evidence": { … } }; the inner evidence is platform-specific and binds the CSR public key and challenge
csrPEM-encoded, self-signed ECDSA certificate request as a JSON string
sandbox_tokenOptional { "token": "<base64-DER>", "signature": "<base64-DER-signature>" } object binding the sandbox, CSR key, and challenge
runtime_extensionsOptional array of sha256: followed by 64 lowercase hex characters, in extension order, replayed against TDX RTMR[3]

The request type and evidence-envelope type reference and sandbox-token wire and signing definition define these objects. The token fields use standard base64; the inventory signs the domain-separated DER token with ECDSA-SHA256.

For example, this constructs the payload from a challenge response, an evidence envelope produced for that challenge and CSR key, and a PEM CSR; --rawfile preserves the PEM newlines as escaped JSON string content:

jq -n --slurpfile challenge challenge.json --slurpfile evidence evidence.json \
  --rawfile csr csr.pem \
  '{challenge: $challenge[0].challenge, evidence: $evidence[0], csr: $csr,
    runtime_extensions: []}'

Unknown fields and a nonempty runtime_extensions list on evidence without measurement registers return 422. An invalid, expired, or already consumed challenge, or an invalid CSR, returns 400. Node-image, sandbox, or CSR-policy rejection returns 403. An empty node-image set denies all issuance. A missing issuing CA returns 503.

The two endpoints have separate per-source-IP rate limiters (--rate-limit, --rate-burst), returning 429 when limited, and use --max-request-size (default 64 KiB). Challenge TTL is --challenge-ttl (default 1m). Issuance and secret challenges belong to separate pools. Singleton challenges are consumed once locally. HA challenges can be verified by any serving replica, but each replica tracks consumption independently: consumption is once per replica, not a cluster-wide single-use guarantee.

Allowlist

MethodPathAuthResponse
GET/allowlistnoneCanonical JSON and weak ETag W/"<version>"; exact matching If-None-Match returns 304
PUT/allowlistoperator tokenAtomically replace the whole document; CDS assigns the version; 204
GET/allowlist/historynoneRetained history, oldest first: { "entries": [{ position, version, digest, published_at, signed }] }
GET/allowlist/objects/sha256/<hex>noneCanonical document bytes; 404 if unavailable, 400 unless the digest is 64 lowercase hex characters
GET/allowlist/objects/sha256/<hex>/signaturenoneStored operator JWT as plain text; 404 if the object or signature is unavailable

The PUT body follows the c8s.allowlist/v1 data model. Its canonical encoding is Go json.Marshal of the normalized document: fixed struct-field order, sorted map keys and normalized lists, compact JSON, and no trailing newline.

For a conditional replacement, convert GET's ETag: W/"42" to If-Match: "42" on PUT. The condition names the current numeric version. A mismatched version, weak tag, or invalid condition (including If-Match: *) returns 412. Omitting the header makes the replacement unconditional. Invalid policy JSON returns 422; the write body is capped at 1 MiB. See Operator tokens for authorization errors.

Only a request whose exact body bytes equal the document's canonical encoding retains its operator token as a retrievable signature. Valid noncanonical JSON can be accepted without a stored signature; seeded documents can also be unsigned. There are no per-workload PUT or DELETE routes. CLI entry mutations compose, canonicalize, and sign full-document replacements; add/apply/edit/delete retry conflicts within a bound, while upload refuses a race for review. See Manage the allowlist.

Each successful replacement increments the version; an unchanged seed leaves it unchanged. HA history is pruned to the newest 1,000 documents and a 64 MiB budget, preserving at least the current document. Pruned objects and signatures become unavailable. This endpoint is a retained history, not an eternal archive.

Route manifest

MethodPathAuthResponse
GET/routesnone{ "manifest": "<base64>", "token": "<jwt>" }; 404 if none is stored
PUT/routesoperator tokenStore the manifest supplied as the request body; 204

The PUT body is a c8s.routes/v1 JSON document with schema, revision, notBefore, maxAgeSeconds, and hosts; see the manifest schema and validation and complete example.

The router polls this signed manifest to route application requests. It shares the allowlist store's lifetime; an empty store returns 404 until an operator writes a manifest.

PUT returns 400 for a body exceeding 1 MiB, 422 for an invalid manifest or a backend CA anchored on the CDS mesh CA, 409 for a revision no greater than the stored revision, and 412 when If-Match differs from the stored manifest's lowercase-hex SHA-256 digest or no manifest exists. Send the digest as unquoted hex, for example:

If-Match: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

The value must equal SHA-256 of the exact stored manifest bytes; quotes or a sha256: prefix cause a mismatch. Omitting the header leaves only the revision condition. A missing issuing mesh CA returns 503. See c8s routes.

Secrets

MethodPathAuthResponse
POST/secretsnoneChallenge JSON, as for /authenticate, from the separate secrets pool
GET/secrets/<path>mesh leaf + sandbox token + challenge200, { "value": "<base64>" }; 404 for an ungranted or absent path
POST/secrets/<path>mesh leaf + sandbox token + challengeGenerate and store 32 random bytes at an empty path; 201 with value JSON, 409 if occupied
PUT/secrets/<path>operator tokenBody { "value": "<base64>", "overwrite": false }; 201 created, 200 overwritten, 409 if occupied without overwrite
GET/secrets-explain/<sandboxID>operator tokenJSON release diagnostic, including match, grant, or refusal; contains no secret values

Workload requests carry Authorization: SandboxToken <base64-of-signed-token-JSON> and X-C8s-Challenge: <base64-nonce>, plus a verified mesh client certificate carrying their sandbox ID. Each workload GET or POST needs a fresh secrets challenge and an inventory-signed token bound to that nonce, the client certificate's public key, and the same sandbox ID. After path validation, CDS consumes a valid challenge before authorization: authorization failures, ungranted/absent paths, and store errors still consume it. Retry with a fresh challenge and matching token. Invalid paths or challenges return 400; failed workload authorization returns 403. An authorized workload receives the same 404 for an ungranted path and an absent value. Operator PUT returns { "path": "…", "created": true|false, "existing": "…" }, with existing present when a prior value exists; malformed, empty, or invalid-base64 values return 422.

Challenge and workload routes are limited per verified sandbox. Missing or unverified client identity falls back to source-address limiting; obtaining a challenge grants no secret access. Release rechecks the current policy against attested inventory and requires one matching workload, including its argv and mount rules. Image-digest membership alone is insufficient. See Application secrets.

Secret routes are registered only when node identities and inventory CIDRs are nonempty. The measured configuration supplies the mandatory CIDRs. A full store or workload path quota returns 507; HA writes can return 503 when unavailable or 507 with state_capacity when the replicated-state bound is reached.

Discovery

MethodPathAuthResponse
GET/measurementsnoneCurrent admitted node identities as an image-policy document
GET/operator-keysnonePinned operator public-key PEM bundle
GET/canoneMesh CA PEM certificate; 503 without an issuing CA
GET/readyznone200 when ready, otherwise 503: checks attestation API health, remaining CA validity (--min-ca-validity, default 1h), and HA serving state
GET/healthznoneLiveness; 200
GET/metricsnonePrometheus metrics

/measurements is rendered from the current node-image policy on every request, including signed HA changes. Its tee identifies the node policy's platform, which can differ from the CDS VM's platform. An empty image set admits nobody. The measured configuration supplies singleton node policy and the default HA Genesis node set. Signed Genesis selects HA's initial set, including any --nodes override; later signed node-policy commands can replace it.

c8s verify --kind cds --image-policy-file <file> pins CDS's own identity. Explicit --served-policy-file <file> additionally requires exact equality with the node identities served by /measurements. See Pin multiple measurements.

HA endpoints and state

These routes exist when the measured configuration contains ha:

MethodPathAuthResponse
GET/ha/statusnoneReplica status: identity, cluster, serving state, versions, CA fingerprint, voters, and checkpoint
GET/ha/pinsnoneApplied signed CDS pin decree; 404 until one has been applied
GET/ha/decreesnoneApplied decree records { decree, at_ms, version }, oldest first, retaining the newest 256
POST/ha/decreessigned body{ "decree": { … }, "signature": "…" }; response { intent, held?, applied_version?, refused? }

The decree schema and signing serialization define the request. signature is standard base64 of an ASN.1 DER ECDSA signature over SHA-256 of c8s-cds-decree-v1\n followed by Go's JSON encoding of the decoded Decree struct; detached signing defines the signature encoding. Preserve the schema's field order and omitted fields when reproducing those signed bytes.

POST authenticates the signed decree against the cluster's operator keys and validates its cluster, fields, and target/state as applicable. It does not use an operator request JWT. The body cap is 1 MiB; parse, signature, target, or delivery errors return 400. A 200 response can report these delivery variants:

OutcomeJSON response shape
Held by target{ "intent": "<intent-id>", "held": true }
Applied by cluster{ "intent": "<intent-id>", "applied_version": 7 }
Refused by cluster{ "intent": "<intent-id>", "refused": "<reason>" }

Only the applicable outcome field is present. Held means the named process retained the decree, not that the cluster applied it. Genesis and Admit have runtime consumers. Recover delivery only holds the decree; operational survivor recovery and automatic Recover-based doubt/fencing are unsupported by the current runtime.

Only leaders report roll_safe and divergence; followers also report voters and checkpoints. The c8s cds ha status command compares replicas and fails for unreachable/nonserving replicas, wrong clusters, or version/CA disagreement. Other printed alarms do not all change its exit status.

HA requires explicit Genesis and replicates volatile CA, secret, allowlist, route, and sandbox ledger state. Serving requires a leader-confirmed lease and the corresponding applied state. Unjoined or nonserving replicas return 503 on ordinary APIs; health, readiness, metrics, and HA routes remain reachable. Quorum loss stops serving after lease validity expires and leaves surviving memory intact. Permanent quorum loss has no supported Recover workflow. Preserve surviving VMs during a halt; loss of all replica memory loses the volatile cluster state. The 256 MiB replicated-state bound includes headroom and is not usable secret capacity.

A singleton generates a new mesh CA at each start and holds secrets and sandbox bindings in memory. Allowlist documents, history, spent tokens, and routes follow its database lifetime. An empty database seeds the measured workloads and awaits a route-manifest write. HA replicas rejoin from surviving cluster state rather than treating each process restart as an empty singleton.

Operator tokens

Allowlist writes, route writes, operator secret PUTs, and secret-explain GETs carry Authorization: Bearer <jwt>. The CLI signs each request with the operator's ECDSA key (ES256, ES384, or ES512, matching the curve) and a 60-second lifetime.

ClaimBinds
pbhUnpadded base64url of the 32-byte SHA-256 of the exact request body; hash the empty body for secret-explain
htmHTTP method
htuURL path
iatIssuance time as a Unix timestamp in seconds
expExpiry as a Unix timestamp in seconds; 0 < exp − iat ≤ 300
jtiCLI-generated unique ID: unpadded base64url of 16 random bytes; optional to the verifier

The first five claims are required. This complete decoded payload illustrates an empty-body GET for a sandbox whose ID is 64 a characters. The timestamps and ID are examples; mint fresh timestamps and a new jti for each request, then sign the JWT with the operator key.

{
  "jti": "AAECAwQFBgcICQoLDA0ODw",
  "iat": 1793620800,
  "exp": 1793620860,
  "htm": "GET",
  "htu": "/secrets-explain/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "pbh": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU"
}

The signer and verifier define the complete claim contract. CDS verifies the pinned key, expiry and issuance times, a maximum validity of five minutes, method/path, and body hash. Every authorized operator request spends its token, including secret-explain and requests that subsequently fail validation. Missing, invalid, or too-early tokens return 401; reused tokens return 409; inability to record a token returns 503. Allowlist/route writes can also return 503 for unavailable state or 507 for capacity.

Tokens must be issued at or after the state-start floor plus --jwt-clock-skew (default 30 seconds): the singleton start when its store is declared nonpersistent, or the HA cluster's start. Spent-token pruning uses the highest spent issuance timestamp minus one hour, not host wall time; tokens whose expiry predates that boundary remain refused after their records are pruned. See Authorizing mutations.