Skip to the content.

Design: Federated & Attestation-Based Enrollment

Status: implemented (see federated-enrollment.md for the operator how-to and per-environment recipes). Problem: apd’s original enrollment gates — operator-minted single-use tokens (token) or none (open) — require a secret in flight or no gate at all. Dynamic fleets (Kubernetes pods, autoscaled workers, CI jobs, devices) need a way for an agent, or the orchestrator that spawns it, to obtain identities without any per-agent secret ever existing, gated by cryptographic evidence apd can verify.


1. Principle: never mint a secret — present a proof

A bearer enrollment secret must be transported to the workload, and any secret copied to where a workload runs eventually leaks — the exact failure mode AAuth exists to eliminate. So the design rule is:

The agent self-generates its key. Something apd already trusts vouches for it by signing a short-lived, audience-scoped assertion. apd verifies the assertion cryptographically. No per-agent secret exists at any point.

The trust anchor shifts from N per-agent tokens in flight to a small set of issuer keys apd is configured to trust — a strictly smaller attack surface.

Every enrollment method in the design space reduces to three ingredients:

  1. Trust anchor — what apd trusts: an OIDC issuer’s JWKS, a static JWKS, a CA root bundle, or a key thumbprint allow-list.
  2. Proof-of-possession (PoP) binding — does the evidence bind the agent’s self-generated durable key (strong), or merely prove “a legitimate workload is present” (weaker, often sufficient)?
  3. Freshness / replay defense — short expiry, aud scoped to apd, single-use jti.

2. The unification: all federated evidence is a JWS

Rather than one verifier per credential type, every form of evidence is carried as a JWS/JWT (enrollment_assertion in the /enroll body). Only the key resolution differs, selected per configured trusted issuer:

Issuer type Where the verification key comes from Covers
oidc OIDC discovery: {issuer}/.well-known/openid-configurationjwks_uri → JWKS (cached, egress-admitted) Kubernetes projected ServiceAccount tokens (EKS/GKE/AKS public issuers), cloud workload identity, CI OIDC (GitHub Actions, GitLab), SPIRE OIDC discovery provider
jwks_uri a directly configured JWKS URL (no discovery step) static hosting, custom issuers
jwks_file / jwks a JWKS loaded from disk / inlined in config air-gapped and on-prem clusters (kubectl get --raw /openid/v1/jwks), operator public keys mounted via ConfigMap, SPIFFE JWT-SVID bundles
x5c the JWS header’s x5c certificate chain, validated to a configured CA bundle (optional CRLs), signature verified with the leaf key corporate PKI (step-ca, AD CS, Vault PKI), SPIFFE X.509-style deployments, hardware/vendor attestation chains

This means Kubernetes, cloud IdPs, CI providers, custom operators, SPIFFE, and corporate CAs are all the same code path with different key resolution — and the assertion validation (aud, exp, claims, binding, replay) is uniform.

The verification pipeline, fail-cheap and mutating no state until every check passes — the diamonds are the only per-issuer-type divergence:

flowchart TD
    IN["enrollment_assertion (JWS)"] --> P["parse header + payload"]
    P --> ROUTE{"iss matches a<br/>configured issuer?"}
    ROUTE -->|no| DENY["403 invalid_assertion<br/>(audit enroll_denied)"]
    ROUTE -->|yes| TYPE{"issuer type?"}
    TYPE -->|"oidc / jwks_*"| K1["resolve key from JWKS<br/>(cached, egress-admitted)"]
    TYPE -->|x5c| K2["validate cert chain to CA roots<br/>(usage, expiry, CRLs) → leaf key"]
    K1 --> SIG["verify JWS signature"]
    K2 --> SIG
    SIG -->|fail| DENY
    SIG -->|ok| CLAIMS["check aud, exp/iat/nbf,<br/>required_claims, required_sans"]
    CLAIMS -->|fail| DENY
    CLAIMS -->|ok| CNF{"cnf binds the<br/>enrolling key?"}
    CNF -->|"required but missing/mismatch"| DENY
    CNF -->|ok / not required| JTI["jti replay guard<br/>(single-use if not key-bound)"]
    JTI -->|replay| DENY
    JTI -->|fresh| ISSUE["issue identity +<br/>stamp embed_claims<br/>(audit enroll)"]

Assertion requirements (uniform)

PoP binding (cnf)

An assertion MAY bind the agent’s durable key via RFC 7800-style confirmation: cnf.jwk (full JWK) or cnf.jkt (RFC 7638 thumbprint). When the issuer entry sets require_cnf_binding: true, apd rejects assertions whose cnf does not match the key that signed the enrollment HTTP request.

Claim policy & propagation

Each trusted issuer configures:

x5c specifics (PKI family)

3. The thumbprint allow-list (no-assertion delegation)

For orchestrators that prefer API-driven pre-registration over signing assertions: the orchestrator calls apd’s admin API to register the durable-key thumbprint it just provisioned (POST /admin/allowed-keys), optionally with ps, label, and a TTL. The agent then enrolls with only its key; apd accepts if the thumbprint is registered (consumed on success; re-enrollment of the same key remains idempotent). No secret travels to the pod — the “credential” is the public key’s hash, registered out-of-band over the authenticated admin channel.

4. Method composition

enrollment.methods is a set: any of "token", "federated", "allowlist", "open" (legacy enrollment.mode still accepted). Evaluation order per request: assertion (if present) → enrollment token (if present) → allow-list → open. A presented but invalid assertion or token is a hard 403 — it never falls through to a weaker method. federated requires at least one trusted issuer; open composes only with itself (config validation enforces sanity).

5. Environment → method matrix (what to deploy where)

Environment Method Entry type PoP
EKS / GKE / AKS (public OIDC issuer) projected SA token oidc aud+jti
On-prem / air-gapped Kubernetes SA token, JWKS exported to file jwks_file aud+jti
Custom operator (strongest) operator-minted, cnf-bound assertion jwks_file/jwks_uri cnf
Service mesh / SPIFFE JWT-SVID (bundle) or X.509 chain + SPIFFE SAN jwks_file/oidc or x5c aud / cert
Corporate PKI cert-holder-signed JWS with x5c x5c (+ CRL) cert (+cnf)
CI (GitHub Actions / GitLab) CI OIDC token oidc aud+jti
Cloud VMs instance-identity JWT (GCP) via oidc; others via operator assertion oidc/jwks_* varies
Small known fleet, scripted thumbprint allow-list key itself
Humans / invitations enrollment token (existing) possession

6. Security model

7. Alternatives considered

8. Spec alignment

The AAuth Bootstrap draft explicitly leaves enrollment AP-internal and evidence-based (“a signed-in account, an attested device, a published JWKS”), and its Workload section points at SPIFFE/WIMSE/cloud-attestation as the intended direction while remaining TBD. This design implements that direction: platform workload identity, operator delegation, and PKI attestation as first-class enrollment evidence, with the AAuth-native cnf binding providing the proof-of-possession the draft’s two-key model expects.