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
| Method | Path | Auth | Response |
|---|---|---|---|
POST | /authenticate | none | 200, JSON { "challenge": "<base64>" }; 503 if a challenge cannot be issued |
POST | /attest | challenge + evidence | 200, 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:
| Field | Format |
|---|---|
challenge | Standard base64 nonce returned by /authenticate |
evidence | JSON envelope { "platform": "tdx", "evidence": { … } }; the inner evidence is platform-specific and binds the CSR public key and challenge |
csr | PEM-encoded, self-signed ECDSA certificate request as a JSON string |
sandbox_token | Optional { "token": "<base64-DER>", "signature": "<base64-DER-signature>" } object binding the sandbox, CSR key, and challenge |
runtime_extensions | Optional 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
| Method | Path | Auth | Response |
|---|---|---|---|
GET | /allowlist | none | Canonical JSON and weak ETag W/"<version>"; exact matching If-None-Match returns 304 |
PUT | /allowlist | operator token | Atomically replace the whole document; CDS assigns the version; 204 |
GET | /allowlist/history | none | Retained history, oldest first: { "entries": [{ position, version, digest, published_at, signed }] } |
GET | /allowlist/objects/sha256/<hex> | none | Canonical document bytes; 404 if unavailable, 400 unless the digest is 64 lowercase hex characters |
GET | /allowlist/objects/sha256/<hex>/signature | none | Stored 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
| Method | Path | Auth | Response |
|---|---|---|---|
GET | /routes | none | { "manifest": "<base64>", "token": "<jwt>" }; 404 if none is stored |
PUT | /routes | operator token | Store 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: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefThe 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
| Method | Path | Auth | Response |
|---|---|---|---|
POST | /secrets | none | Challenge JSON, as for /authenticate, from the separate secrets pool |
GET | /secrets/<path> | mesh leaf + sandbox token + challenge | 200, { "value": "<base64>" }; 404 for an ungranted or absent path |
POST | /secrets/<path> | mesh leaf + sandbox token + challenge | Generate and store 32 random bytes at an empty path; 201 with value JSON, 409 if occupied |
PUT | /secrets/<path> | operator token | Body { "value": "<base64>", "overwrite": false }; 201 created, 200 overwritten, 409 if occupied without overwrite |
GET | /secrets-explain/<sandboxID> | operator token | JSON 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
| Method | Path | Auth | Response |
|---|---|---|---|
GET | /measurements | none | Current admitted node identities as an image-policy document |
GET | /operator-keys | none | Pinned operator public-key PEM bundle |
GET | /ca | none | Mesh CA PEM certificate; 503 without an issuing CA |
GET | /readyz | none | 200 when ready, otherwise 503: checks attestation API health, remaining CA validity (--min-ca-validity, default 1h), and HA serving state |
GET | /healthz | none | Liveness; 200 |
GET | /metrics | none | Prometheus 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:
| Method | Path | Auth | Response |
|---|---|---|---|
GET | /ha/status | none | Replica status: identity, cluster, serving state, versions, CA fingerprint, voters, and checkpoint |
GET | /ha/pins | none | Applied signed CDS pin decree; 404 until one has been applied |
GET | /ha/decrees | none | Applied decree records { decree, at_ms, version }, oldest first, retaining the newest 256 |
POST | /ha/decrees | signed 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:
| Outcome | JSON 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.
| Claim | Binds |
|---|---|
pbh | Unpadded base64url of the 32-byte SHA-256 of the exact request body; hash the empty body for secret-explain |
htm | HTTP method |
htu | URL path |
iat | Issuance time as a Unix timestamp in seconds |
exp | Expiry as a Unix timestamp in seconds; 0 < exp − iat ≤ 300 |
jti | CLI-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.