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.
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.
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.
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 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.
Intune / MDM enrollment (F56)
When a mobile-device-management platform (Microsoft Intune, JAMF) pushes a SCEP
profile to a managed phone, you want only MDM-provisioned devices to enroll, not
anyone who can reach the SCEP endpoint. trstctl's MDM integration issues a stateless,
HMAC-signed challenge token the MDM embeds in the device's SCEP profile
challengePassword; the SCEP server validates it (constant-time MAC check, expiry)
before issuing, fail-closed on any defect. The HMAC key is the only shared secret — no
database lookup on the hot path — and is held in wipeable []byte memory, zeroed
after use, never a copyable string.
For Microsoft Intune, trstctl validates the Intune JWS challenge against policy-backed trust anchors, checks tenant and CSR subject/SAN binding, and consumes the nonce through a single-use replay cache for the token TTL, so a captured challenge can't be replayed. The gate wires into the served SCEP server's challenge hook.
Operators manage MDM SCEP policy records through the served control plane:
POST/GET/PUT/DELETE /api/v1/mdm/scep/policies, POST /api/v1/mdm/scep/policies/{id}/rotate-challenge, GET /api/v1/mdm/scep/status, and
the matching trstctl mdm scep ... commands, keeping profile guidance, challenge mode,
trust-anchor references, rotation version, and telemetry visible in API, CLI, and the
UI 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.
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 "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 |
| MDM challenge (F56) | Served — policy management (API/CLI/UI), challenge rotation, Intune JWS validation, tenant/CSR binding, single-use replay cache, 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.
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). - 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