Enrollment protocols — how existing devices ask for certificates
What it is
"Enrollment" is the moment a device asks a CA for a certificate and gets one back. ACME is the modern way, but the world is full of routers, switches, printers, phones, factory controllers, and 5G base stations that already speak older protocols baked into their firmware. trstctl serves those too — EST, SCEP, and CMP — plus a tiny client for constrained IoT devices and an integration so mobile-device-management (MDM) platforms can enroll managed phones and laptops.
The point: you shouldn't have to re-flash a million devices to bring them under trstctl. If a device can already enroll over EST, SCEP, or CMP, it can enroll against trstctl unchanged.
Why it exists
Every certificate eventually expires, so every device needs a repeatable way to renew without a human visiting it. Different industries standardized on different protocols: enterprise/IoT gear speaks EST, network and mobile-device management speaks SCEP, telecom and industrial systems speak CMP. Supporting all three lets trstctl become the issuing authority for an existing fleet on day one, instead of being limited to greenfield ACME-aware workloads.
How it works
All three protocol servers share the same trstctl spine: each parses its protocol's
request format through the isolated cryptography path, authenticates the caller, then
hands the CSR to the same issuance path every other feature uses —
with an Idempotency-Key so a retry never mints twice, the outbox
delivering calls at-least-once, and an immutable audit event for every allow/deny/shed
decision. Each runs in its own bounded, bulkheaded lane and sheds
load with HTTP 503 when saturated, so an enrollment storm can't starve the rest of
the system.
EST (F22) — the modern enrollment protocol
EST (RFC 7030) is a small set of HTTPS endpoints under /.well-known/est/...: a client
fetches the CA chain from /cacerts (no auth, to bootstrap trust), then POSTs a CSR to
/simpleenroll (first time) or /simplereenroll (renewal) for a PKCS#7-wrapped
certificate. trstctl implements all four endpoints (including /csrattrs),
authenticates via an injected authenticator, caps request bodies, verifies the CSR's
self-signature, and honors an Idempotency-Key header (or one derived from the CSR)
so a retry never mints twice. The served tenant route accepts scoped API tokens and
answers an unauthenticated enrollment with WWW-Authenticate: Bearer realm="est", scope="certs:request". Invalid tokens receive RFC 6750 invalid_token; recognized
tokens without enrollment scope receive insufficient_scope. A Basic-authenticator
deployment still advertises Basic because the authenticator, not the EST handler,
owns the challenge.
Device TLS floor
The served listener negotiates TLS 1.3 only. Many device enrollment stacks cap at
TLS 1.2 — cisco libest's estclient does (SSL_CTX_set_max_proto_version(TLS1_2_VERSION))
— and such a client fails the handshake with a protocol version alert before EST
starts. For those fleets set TRSTCTL_SERVER_TLS_MIN_VERSION=1.2; the listener then
offers TLS 1.2 with AEAD suites only and still prefers TLS 1.3 for capable clients.
Found by the cold design-partner run 20260906t143500z (DP2-036).
Re-enrollment by client certificate
A device should renew with the certificate it already holds, not with a bootstrap
token it has to keep. Set TRSTCTL_PROTOCOLS_EST_MTLS_ADDR (for example :9445)
and trstctl serves EST on a second HTTPS listener that requires a client certificate
issued by the enrollment CA. On that listener /.well-known/est/simplereenroll
authenticates the device by the certificate it presents and binds the renewal CSR
to that certificate's identifiers: a CSR for another name is refused with 403,
a connection without a certificate fails the TLS handshake, and the ordinary
token-authenticated routes stay on the main listener, which never requests a
client certificate (so browsers are not prompted). The listener reuses the main
listener's server identity and TLS floor, so cisco libest estclient -r -c <cert> -k <key> renews against it with TRSTCTL_SERVER_TLS_MIN_VERSION=1.2.
Closes DP2-037.
Check EST safely from the console
Open Certificates → Enrollment methods, expand Set up and operate methods, and use EST connection check before connecting a real device. The screen previews and then runs three same-origin checks:
GET /.well-known/est/cacertsmust return a structurally valid, base64 PKCS#7 CA chain.GET /.well-known/est/csrattrsmust return the server's CSR rules. trstctl's default “no extra attributes” answer is HTTP 204 and is valid.POST /.well-known/est/simpleenrolldeliberately sends no credentials and no body. It passes only when the server refuses it with HTTP 401 and the expected Bearer challenge.
The third request is a security negative control, not an enrollment attempt. The browser omits session cookies, authorization, CSR bytes, and private-key material. The workflow cannot issue a certificate. If any row fails, keep the authentication requirement in place, repair the named responder, tenant, CA, or signer configuration, and run the same check again. A real EST client still creates and protects its private key locally, sends its CSR with a scoped bootstrap token, and keeps the idempotency key for safe retries.
EST also serves the C3 parity extensions: EST /serverkeygen (when a profile opts in)
has the signer generate the key, returning the certificate plus encrypted private key
material as CMS EnvelopedData, with the raw key never entering logs or audit events.
RFC 9266 channel binding via tls-server-end-point binds a CSR to the server TLS
certificate so a relayed enrollment fails closed. Profiles can split by per-profile
PathID under /.well-known/est/<PathID>/..., with a separate mTLS sibling route under
/.well-known/est-mtls/<PathID>/... for 802.1X/Wi-Fi bootstrap, plus per-IP and
per-principal rate limits.
SCEP (F23) — the one network and MDM gear still speaks
SCEP (RFC 8894) is ancient but ubiquitous in routers, printers, and mobile-device
management, wrapping requests in CMS (signed, encrypted ASN.1 envelopes). trstctl
advertises capabilities at GetCACaps, returns the chain at GetCACert, and on
PKIOperation decrypts the envelope and extracts the CSR — all through the isolated
cryptography path, with the SCEP transaction ID as the idempotency key. The SCEP
RA transport key is deliberately separate from the platform CA signing key and
never enters the isolated signing service: it's sealed at rest under
protocols.ra_key_file and shared across replicas, so a device that cached GetCACert
material can still enroll after a restart or rolling deploy.
When that RA is separate, GetCACert is an application/x-x509-ca-ra-cert bundle,
not one misleadingly named certificate. The required stock sscep gate accepts its
numbered output files in either order, identifies the exact public issuing CA by
certificate fingerprint and constraints, uses the remaining signing certificate as
the protocol RA for PKIOperation, and archives both public certificates beside the
request and response transcript.
SCEP also has per-profile SCEP RA material (distinct RA certificates and keys per
profile, same issuance path), a per-device rate limiter capping repeated attempts, and
a challenge hook that can require an MDM-issued challenge before any CSR is signed.
Routes /scep, /scep/pkiclient.exe.
Check SCEP safely from the console
Open Certificates → Enrollment methods, expand Set up and operate methods, and use SCEP connection check before connecting a real device or MDM profile. The screen shows the exact plan before it makes three same-origin requests:
GET /scep?operation=GetCACapsmust return HTTP 200,text/plain, and advertisePOSTPKIOperation,SHA-256, andSCEPStandard.GET /scep?operation=GetCACertmust return HTTP 200 and a structurally valid DER CA certificate or CA/RA bundle with the matching SCEP media type.POST /scep?operation=PKIOperationdeliberately sends no body, browser session, authorization header, challenge, CSR, or private-key material. It passes only when the responder returns the exact fail-closed HTTP 400 empty message response.
The third request is a broken-input control, not an enrollment attempt. If it ever succeeds, the whole check is red. The workflow cannot issue a certificate. If a row fails, keep the MDM challenge gate and CMS validation strict, repair the named responder, CA/RA, or signer configuration, and run the same check again. A real SCEP device still creates and protects its private key locally, then sends a CMS-wrapped CSR with an approved, short-lived MDM challenge.
CMP (F55) — for telecom and industrial PKI
CMP (RFC 4210, over HTTP per RFC 6712) is common in 5G and industrial systems. trstctl
serves the p10cr flow at POST /cmp: it reads the DER PKIMessage, extracts the
transaction ID and CSR through the isolated cryptography path, and returns a signed
pkixcmp response. As with SCEP, the CMP protection key is the sealed
protocols.ra_key_file transport identity, distinct from the CA key in the isolated
signing service. The certificate that protects a client PKIMessage must chain to
protocols.cmp_client_trust_anchor_file. By default, the CSR may request only names
already asserted by that authenticated protection certificate. Third-party
registration-authority enrollment is possible only when
protocols.cmp_allow_ra_enrollment is explicitly enabled.
Check CMP safely before connecting a client
The method list's public-responder count proves only that an exact unauthenticated
probe reached each named doorway. A mounted /cmp can answer that probe while
client protection trust is absent. The list therefore says Responder answered;
it does not call CMP ready for enrollment. Run the CMP readiness check below and
then use a stock CMP client for the wire proof.
Open Certificates → How machines request credentials, select View setup on Existing PKI enrollment (CMP), then find CMP readiness check. Before any request, the page explains the four groups it will inspect: endpoint and tenant, protection identity and trust, issuing profile and isolated signer, and bounded worker capacity.
Choosing Run safe CMP check calls the authenticated, tenant-scoped
POST /api/v1/protocols/cmp/qualification endpoint. This is a read-only POST because
it asks the running process to assemble one answer from its in-memory mount state. It
does not query the database, call a network target or signer, append an event,
enqueue an outbox row, or create, parse, transmit, or retain a CSR, PKIMessage,
certificate, protection credential, or private key. The response contains eight
named gates, exact recovery for every red gate, and three empty effect lists. The
same check is available to automation:
trstctl protocols cmp qualify
A green readiness check is not a pretend enrollment. The final wire proof still
comes from a real CMP client that owns its private key. The console provides this
copy-safe OpenSSL p10cr handoff:
openssl cmp -config "" -cmd p10cr \
-server https://trstctl.example.test -path /cmp \
-csr device.csr \
-cert cmp-client.pem -key cmp-client.key -extracerts cmp-client.pem \
-srvcert cmp-ra.pem -ignore_keyusage -disable_confirm \
-certout device.pem -reqout request.der -rspout response.der \
-batch -verbosity 7
The client and RA files stay on the operator's machine; they are never pasted into the browser. A refused request creates only a bounded, tenant-scoped diagnostic receipt. When the server knows the exact branch, it distinguishes a rejected protection certificate, a CSR/name binding violation, and a full local worker pool. When it cannot safely tell whether policy, signer, persistence, parsing, or response encoding failed, it says unknown instead of guessing. Repair the named gate and run the identical safe check again before retrying the same CMP transaction.
The embedded / IoT enrollment agent (F54)
The smallest devices can't run a Go agent, so trstctl ships two pieces: a
control-plane enrollment authority issuing single-use bootstrap tokens and signing the
device's first mTLS certificate (the device keeps its own private
key, sending only a CSR), and a POSIX C client (est_client.c) needing only libc and
openssl — small enough for constrained hardware, compiled and run against a real EST
server by the test suite. A bootstrap token is checked-and-deleted atomically, so it
works exactly once.
Status: the running control plane mounts POST /enroll/bootstrap on the
control-plane HTTPS listener and, when agent_channel.enabled, serves
POST /enroll/renewal on a dedicated agent-CA mTLS HTTPS listener
(agent_channel.http_renewal_addr, default :9444). Bootstrap consumes the one-time
token. Renewal accepts only a verified client certificate from the current agent
identity, rejects missing or expired peer certificates, and signs a fresh CSR without
ever receiving the device's private key. The steady-state agent channel is also served
when agent_channel.enabled, so larger agents can renew over mTLS gRPC while embedded
clients use the HTTP renewal surface.
Enroll one machine from the console
Open Workloads & Machines → Agents → Add agent. The workflow is deliberately split into two steps:
- Choose the exact machine identity and certificate role, then select Review exact
enrollment plan. This preview is effect-free: it creates no token and contacts no
machine. It shows the public agent address, the TLS name the machine must verify,
POST /enroll/renewal, and that renewal requires the machine's current verified certificate over mTLS. - Select Mint one-time token only when the preview says the whole lifecycle is ready. The server refuses this mutation too—not just the preview—if the public address, TLS name, or verified-mTLS renewal listener is unavailable. The token is shown once and is never saved in browser storage. The machine creates its private key locally; trstctl receives only its CSR.
If setup fails, dismiss the token, review the current plan again, and mint one
replacement. A lost, expired, or already-used token cannot be recovered. If a machine
that enrolled successfully is no longer trusted, revoke its current certificate or
offboard it from Agents before enrolling a replacement. Headless operators use
trstctl agents enroll-token-preview before trstctl agents enroll-token; the same
server-owned blockers protect both console and direct API mutation.
Intune / MDM enrollment (F56)
When a mobile-device-management platform pushes a SCEP profile to a managed
device, only that MDM's provisioned devices should enroll. The served Intune path
expects a JWS challenge in the CSR's challengePassword. The MDM signs it;
trstctl does not issue that challenge. The SCEP server verifies the signature
against the tenant policy's referenced public trust anchor, checks its audience,
time window, subject and SAN binding, and consumes its nonce before issuing.
For Microsoft Intune, trstctl validates the Intune JWS challenge against policy-backed
trust anchors, checks tenant and CSR subject/SAN binding, requires an expiry and
nonce, and atomically consumes the tenant-scoped nonce in PostgreSQL. The
single-use fence survives process restarts and concurrent control planes; if the
database cannot reserve it, enrollment fails closed. The gate wires into the served SCEP
server's challenge hook. When a policy sets expected_audience, a JWS without
that exact aud is refused. After validation, the verified policy's
scep_profile is used for signer-backed issuance; the deployment's default
profile is not substituted for a policy-backed enrollment.
The Protocols → Intune / MDM SCEP policies section handles the first policy as well as later edits. Its three short steps ask: which MDM and certificate profile, how the device proves it came from that MDM, and what the server will do. The last step is not a browser estimate. It calls the effect-free server preview and shows the normalized provider, profile, challenge check, reference names, planned durable writes, blockers, and recovery steps. It never renders reference values, certificates, keys, tokens, or other secret material. Save stays disabled unless the server says the same plan used by the mutation is ready and effect-free.
Automation has the same safety rail. Preview a create with POST /api/v1/mdm/scep/policies/preview, an update with POST /api/v1/mdm/scep/policies/{id}/preview, and a challenge rotation with POST /api/v1/mdm/scep/policies/{id}/rotate-challenge/preview. Those calls make zero
policy writes, event writes, outside calls, and signer calls. The matching CLI
commands are trstctl mdm scep policies preview-create --file policy.json,
preview-update --id POLICY_ID --file policy.json, and preview-rotation --id POLICY_ID. Execute only after reviewing the plan with the existing create,
update, or rotate-challenge command. Mutations still require an idempotency key
and run the identical readiness planner again, so a caller cannot bypass the gate.
The other served operations are POST/GET/PUT/DELETE /api/v1/mdm/scep/policies, GET /api/v1/mdm/scep/status, and their matching
trstctl mdm scep ... commands. They keep profile guidance, challenge mode,
trust-anchor references, rotation version, and telemetry visible without storing
raw MDM secrets. At runtime the validator resolves enabled policy
trust_anchor_refs from the served secret store (secret://...) per decision, so
anchor changes take effect without restarting the handler; the static
protocols.scep.intune_challenge anchors remain a bootstrap/fallback path.
If policy setup is blocked, fix the named prerequisite and run the preview again; no partial policy exists to clean up. If a save is interrupted, retry with the same idempotency key. The rotation endpoint records a version and audit timestamp; it does not mint an MDM challenge or rotate the MDM's signing key. Rotate that key in the MDM, update the policy's trust-anchor reference, and refresh the device profile. An old signed challenge can remain valid until its expiry if its signing anchor is still trusted. If the version write fails, keep the dialog open, review the refreshed plan, and retry. After rotation, use the device trace to distinguish a challenge refusal from an offline device. Repair the named refusal, or bring an offline device online and trigger an MDM check-in. Do not weaken challenge, tenant, CSR, or replay validation to make enrollment appear green.
The device-correlation surface is evidence-backed end to end. The SCEP handler
records an immutable request fact before challenge validation and a terminal
issuance fact for every success or refusal, keyed by tenant, CSR common-name
device serial, and transaction. A success includes the signer-minted certificate
serial, fingerprint, and expiry. The leader commits a durable mdm.sync intent;
a NETWORK relay redeems the secret:// bearer for that attempt and reads
Intune's fixed CertificatesByRAPolicy report or Jamf's CERTIFICATES
inventory. Only its bounded typed, signed observation returns to the control
plane, which binds it to the exact job payload before correlation and completes
the claim plus outbox row atomically after projection. There is no control-plane
MDM HTTP/token fallback. Installation is successful only when the exact device
contains that exact serial with an active status. Device registration, a
correlation ID, or an identity row is never treated as proof. GET /api/v1/mdm/devices and
GET /api/v1/mdm/{mdm}/devices/{id}/trace expose requested, issued, installed,
and renewing evidence; the console opens the same trace from each device row and
keeps missing evidence unknown. A later SCEP transaction is renewal evidence;
an offline device inside the renewal window with no later attempt gets actionable
check-in guidance instead of a made-up failure or success.
For first use, open How machines request credentials → Set up and operate methods → MDM device correlation. The
schedule form remains visible when no devices or schedules exist. Choose Intune
or Jamf, enter the relay-reachable MDM API base URL and a secret:// reference
to an existing bearer, select the exact enrolled network relay that can reach
that MDM, choose an interval of at least 300 seconds, and review the provider,
relay ID, and cadence before saving. An enabled schedule requires a fresh
authenticated heartbeat from that relay. A disabled draft can name an offline
relay. On upgrade, older enabled schedules without an exact relay are disabled
until an operator selects one; pending unbound jobs are retired before a bearer
can be redeemed. The same tenant-scoped operation
is trstctl-cli mdm poll-schedule set --body-file schedule.json; the readback is
trstctl-cli mdm poll-schedule show or GET /api/v1/mdm/poll-schedule.
The JSON body sets required_agent_id to the enrolled agent UUID shown in
GET /api/v1/agents or trstctl-cli agents list.
certs:write is required to save; read-only operators can still inspect the
schedule, last run and last error. A saved schedule is configuration, not a
completed relay read or proof that any certificate was installed. Check the last
run and each device trace before treating the MDM estate as observed.
Use it
A device using a standard EST client enrolls like this:
# 1) fetch the CA chain (no auth) to establish trust
curl -s https://trstctl.example.com/.well-known/est/cacerts -o cacerts.p7
# 2) enroll: POST a base64 PKCS#10 CSR, get back a PKCS#7 cert
curl -s -H "Content-Type: application/pkcs10" \
-H "Authorization: Bearer $TRSTCTL_TOKEN" \
-H "Idempotency-Key: $(uuidgen)" \
--data-binary @request.b64 \
https://trstctl.example.com/.well-known/est/simpleenroll
A constrained IoT device instead bootstraps with a one-time token:
curl -s -X POST https://trstctl.example.com/enroll/bootstrap \
-d '{"token":"<one-time-token>","csr":"<base64-DER-CSR>"}'
# -> {"certificate":"<PEM chain>"}
Pitfalls & limits
Be precise about what's mounted in the running server today:
| Surface | Status |
|---|---|
Embedded bootstrap (POST /enroll/bootstrap, F54) |
Served by the control plane |
Embedded renewal (POST /enroll/renewal, F54) |
Served on the dedicated agent-CA mTLS HTTPS listener when agent_channel.enabled; requires the current verified client certificate and rejects missing or expired peers |
| EST server (F22) | Served at /.well-known/est/... (protocols.est.enabled + protocols.est.tenant_id) — Bearer-token + TLS auth, orchestrator-backed, tenant-scoped |
| EST serverkeygen / channel binding / profile routes | Served when configured — /serverkeygen, RFC 9266 tls-server-end-point, per-profile PathID, and the mTLS sibling route |
| SCEP server (F23) | Served at /scep (protocols.scep.enabled + protocols.scep.tenant_id) — CMS transport, orchestrator-backed, tenant-scoped |
| SCEP per-profile RA and rate limits | Served when configured — per-profile SCEP RA cert/key plus per-device rate limiter |
| CMP server (F55) | Served at /cmp (protocols.cmp.enabled + protocols.cmp.tenant_id) — orchestrator-backed, tenant-scoped, client-anchor authenticated, subject-bound by default; safe qualification is served by API, CLI, and console |
| MDM challenge (F56) | Served — policy management (API/CLI/UI), challenge rotation, Intune JWS validation, tenant/CSR binding, durable tenant-scoped single-use nonce fences, and live trust-anchor resolution via trust_anchor_refs from the served secret store |
The protocol servers each expose a Handler() and mount on the control-plane TLS
listener at startup, behind the same issuance seam the API mint uses — backed by the
isolated signing service, scoped to one tenant, event-sourced, idempotent, and
profile-gated. Each is gated by protocols.<name>.enabled and binds a tenant via
protocols.<name>.tenant_id; toggles default off until an operator supplies that
binding, and startup validation fails when an enabled protocol has no tenant, so a
server can never come up serving an unscoped, cross-tenant path. They activate only
when an issuing CA is provisioned. EST and SCEP both rely on the device trusting the
/cacerts/GetCACert chain first; SCEP's security depends on the challenge gate (F56)
since the protocol itself is weakly authenticated. For SCEP/CMP, keep
protocols.ra_key_file on shared persistent storage in HA so all replicas use the same
transport identity. Disabled /.well-known/est/, /scep, and /cmp namespaces are
still reserved by the control-plane mux and return 404 application/problem+json;
they never fall through to the browser SPA as HTTP 200. Unknown protocol children fail
the same machine-route boundary rather than becoming console routes.
An evaluation stack can instead select protocols.profile=eval with one
eval_tenant_id, assembling ACME, EST, SCEP, CMP, SSH, TSA, and SPIFFE but keeping
them unreachable until an authenticated first-run call to
POST /api/v1/setup/protocols/activate appends a tenant-scoped activation event and
opens the shared HTTP gate/SPIFFE UDS; event replay restores that state after restart.
KMIP stays a separately licensed, mTLS-configured listener the eval shortcut never
enables.
Reference
- EST:
GET /.well-known/est/cacerts,POST /.well-known/est/simpleenroll,/simplereenroll,GET /.well-known/est/csrattrs,POST /.well-known/est/serverkeygen(RFC 7030); profile PathID and mTLS sibling route variants mount under/.well-known/est/<PathID>/...and/.well-known/est-mtls/<PathID>/.... - SCEP:
/scep?operation=GetCACaps|GetCACert|PKIOperation(RFC 8894). - CMP:
POST /cmp(RFC 4210 / RFC 6712) for protectedp10crenrollment; authenticated read-onlyPOST /api/v1/protocols/cmp/qualificationandtrstctl protocols cmp qualifyfor effect-free runtime qualification. - Embedded:
POST /enroll/bootstrap(one-time token) andPOST /enroll/renewal(verified client certificate) are served by the running control plane. - Events:
protocol.est.est-enroll,protocol.scep.*,protocol.cmp.enroll,mdm.scep_policy.*,mdm.scep_challenge.rotated, andmdm.intune_scep_challenge*.
See also
Issuance & certificate authorities (the shared issuance path) · ACME & DNS (the modern alternative) · Current limitations · glossary: EST/SCEP/CMP, CSR, mTLS
Covers: F22, F23, F55, F54, F56