trstctl /docs GitHub ↗

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 enrollment protocols baked into their firmware. trstctl serves those protocols too — EST, SCEP, and CMP — plus a tiny enrollment client for constrained IoT devices and an integration so mobile-device-management (MDM) platforms can enroll managed phones and laptops.

The point: you should not 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 get a fresh one without a human visiting it. Different industries standardized on different protocols years ago: enterprise and IoT gear tend to speak EST (RFC 7030); network hardware and mobile-device management speak SCEP (RFC 8894); telecom and industrial systems speak CMP (RFC 4210). Supporting all three means trstctl can 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 one parses its protocol's request format inside the crypto boundary internal/crypto (AN-3), authenticates the caller, then hands the CSR to the same issuance path every other feature uses — with an idempotency key (AN-5), the outbox (AN-6), and an audit event for every allow/deny/shed decision (AN-2). Each runs on its own bulkhead and sheds load with HTTP 503 when saturated (AN-7), so an enrollment storm can't starve the rest of the system.

EST (F22) — the modern enrollment protocol

EST (Enrollment over Secure Transport, RFC 7030) is a small set of HTTPS endpoints. A client fetches the CA chain from /cacerts (no auth, so it can bootstrap trust), then POSTs a CSR to /simpleenroll (first time) or /simplereenroll (renewal) and gets back 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 in internal/crypto, and honors an Idempotency-Key header (or derives one from the CSR) so a retried enroll never mints twice.

Code: internal/protocols/est. Routes under /.well-known/est/....

SCEP (F23) — the one network and MDM gear still speaks

SCEP (Simple Certificate Enrollment Protocol, RFC 8894) is ancient but ubiquitous in routers, printers, and mobile-device management. It wraps requests in CMS (signed, encrypted ASN.1 envelopes). trstctl advertises its capabilities at GetCACaps, returns the chain at GetCACert, and on PKIOperation decrypts the CMS envelope and extracts the CSR — all CMS handling inside internal/crypto (AN-3). The SCEP transaction ID becomes the idempotency key. Notably, the SCEP RA transport key is deliberately separate from the platform CA signing key and never enters the isolated signer process.

Code: internal/protocols/scep, internal/crypto/scep.go. Routes /scep, /scep/pkiclient.exe.

CMP (F55) — for telecom and industrial PKI

CMP (Certificate Management Protocol, RFC 4210, over HTTP per RFC 6712) is common in 5G and industrial systems. trstctl serves the p10cr flow: it reads the DER PKIMessage, extracts the transaction ID and CSR inside internal/crypto, issues, and returns a signed pkixcmp response. As with SCEP, the CMP protection key is a transport-layer key distinct from the CA key in the signer.

Code: internal/protocols/cmp, internal/crypto/cmp.go. Route POST /cmp.

The embedded / IoT enrollment agent (F54)

The smallest devices can't run a Go agent, so trstctl ships two cooperating pieces. On the control-plane side, an enrollment authority issues single-use bootstrap tokens and signs the device's first mTLS certificate; the device generates its own key, keeps the private half forever (it never crosses the wire), and sends only a CSR. On the device side there's a POSIX C client (est_client.c) that depends only on libc and the openssl CLI — small enough for constrained hardware — and the test suite actually compiles and runs it against a real EST server. A bootstrap token is checked-and-deleted atomically, so it works exactly once.

Code: internal/agent/enroll (Authority, IssueBootstrapToken, EnrollBootstrap, EnrollRenewal), internal/agent/httpenroll.go, clients/embedded/. Status (DOCS-001): the running control plane mounts only POST /enroll/bootstrap (see internal/api/api.go). Renewal is library-complete but not yet mounted: EnrollRenewal and a POST /enroll/renewal handler exist in internal/agent/enroll, but the served API does not register that route, so a request to /enroll/renewal against the running binary returns 404 today. This matches the served route set in discovery-and-inventory.md. Mounting renewal (and the agent mTLS steady-state channel it pairs with — WIRE-004/OPS-005) onto the served listener is tracked as EXC-WIRE-04; until then, the served agent enrollment path is bootstrap-only. (The RFC issuance protocols — ACME/EST/SCEP/CMP/SPIFFE/SSH — are now served; see the table below and limitations.md.)

Intune / MDM enrollment (F56)

When a mobile-device-management platform (Microsoft Intune, JAMF) pushes a SCEP profile to a managed phone or laptop, 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 that the MDM embeds in the device's SCEP profile challengePassword. The SCEP server validates the token (constant-time MAC check, expiry) before issuing — fail-closed on any defect. It's stateless: the HMAC key is the only shared secret, so there's no database lookup on the hot path. The HMAC key is held as []byte, never a string (AN-8).

Code: internal/mdm (Challenge, Issue, Validate, Validator). Wires into the SCEP server's challenge hook.

Use it

A device using a standard EST client enrolls like this (conceptually):

# 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 over the served endpoint:

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) Library-complete, not yet mounted — 404 on the running binary; tracked as EXC-WIRE-04 (the agent steady-state channel, WIRE-004/OPS-005)
EST server (F22) Served at /.well-known/est/... (protocols.est.enabled, default on) — Bearer-token + TLS auth, orchestrator-backed, tenant-scoped
SCEP server (F23) Served at /scep (protocols.scep.enabled, default on) — CMS transport, orchestrator-backed, tenant-scoped
CMP server (F55) Served at /cmp (protocols.cmp.enabled, default on) — orchestrator-backed, tenant-scoped
MDM challenge (F56) Library-complete, tested; the challenge-password gate activates when configured on the served SCEP endpoint

The protocol servers each expose a Handler() and are mounted on the control-plane TLS listener by the composition root (internal/server, EXC-WIRE-02), each behind the signer-backed, tenant-scoped, event-sourced, idempotent, profile-gated issuance seam (the same path the API mint uses). Each is gated by protocols.<name>.enabled and binds a tenant via protocols.<name>.tenant_id (fail-closed when no tenant is set — AN-1); all activate only when an issuing CA is provisioned. Other notes: 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.

Reference

  • EST: GET /.well-known/est/cacerts, POST /.well-known/est/simpleenroll, /simplereenroll, GET /.well-known/est/csrattrs (RFC 7030).
  • SCEP: /scep?operation=GetCACaps|GetCACert|PKIOperation (RFC 8894).
  • CMP: POST /cmp (RFC 4210 / RFC 6712).
  • Embedded: POST /enroll/bootstrap (served). POST /enroll/renewal is library-complete but not yet mounted (404 on the running binary; the agent steady-state channel is EXC-WIRE-04, WIRE-004/OPS-005).
  • Events: protocol.est.est-enroll, protocol.scep.*, protocol.cmp.enroll.
  • EST authoring guide: Device enrollment (EST).

See also

Issuance & certificate authorities (the shared issuance path) · ACME & DNS (the modern alternative) · Device enrollment (EST) guide · Current limitations · glossary: EST/SCEP/CMP, CSR, mTLS

Covers: F22, F23, F55, F54, F56

Rendered live from github.com/ctlplne/trstctl — found a mistake? edit this page.