Configuring certificate enrollment: ACME, EST, SCEP
This page shows how to set up iya-sts so that a client can enroll for a
certificate over ACME, EST or SCEP: the realm’s certificate authority, the
directory entry the certificate is for, the credential the client needs, and
the commands the client then runs. Each recipe is given twice, once as steps
in the admin console (/admin) and once as calls to the Management
API (/admin-api).
The values come from this repository’s own test jobs in
tests/protocol/ and tests/, and each recipe names the job it is taken
from. The client recipes come from the jobs that drive real clients: certbot,
lego, libest’s estclient, sscep and certmonger. Change the realm, the names
and the host names to your own.
How each protocol behaves, and every setting with its default, is on
ACME, EST, SCEP and PKI. This page
covers only what to fill in.
Configuring OAuth 2.0 grants → Before you start
explains the shared mechanics: the API token, the api shell helper used
below, and the application attribute editor.
Where things are
| Page | Console menu | What you do there |
|---|---|---|
/admin/pki |
Protocols → PKI | Build the realm’s certificate authority, and revoke any certificate it issued. |
/admin/cert-issuance#cert-tab-acme |
Protocols → Cert issuance, ACME tab | Create External Account Binding (EAB) keys, register host names, see accounts and certificates, revoke, and change the acme.* settings. |
/admin/cert-issuance#cert-tab-est |
Protocols → Cert issuance, EST tab | Register host names, issue a certificate with a server-generated key, revoke, and change the est.* settings. |
/admin/cert-issuance#cert-tab-scep |
Protocols → Cert issuance, SCEP tab | Create challenge passwords, register host names, re-issue the RA certificate, revoke, and change the scep.* settings. |
/admin/cert-issuance |
Protocols → Cert issuance, Metadata tab | Every endpoint of the three servers in this realm, with EST’s label-form address. |
/admin/applications?application=<id> |
Directory → Applications | Declare an application for ACME, EST or SCEP, and override the realm’s enrollment rules for it. |
/admin/acme/monitor, /admin/est/monitor, /admin/scep/monitor |
Monitoring → Cert issuance | What each server did, and the error code of every refusal. |
/portal/certificates |
the user portal | A person makes their own EAB key or SCEP challenge, and revokes their own certificates. |
The settings on each tab are saved with their group’s button: Save ACME,
Save EST or Save SCEP. The API equivalents are
POST /admin-api/config/set, /config/set-many and /config/reset. Every
acme.*, est.* and scep.* setting takes effect at runtime and is per
trust realm.
The GET operations /admin-api/acme, /admin-api/est and
/admin-api/scep answer everything the tabs draw, including the absolute
endpoints of the realm’s server. The live list of every operation and its body
is GET /admin-api/openapi.json.
What development and product mode change
The three protocols do the same thing in both modes, with these exceptions:
- ACME and EST refuse plain HTTP in product mode. In development they also answer over HTTP and log that they did. The main port is HTTPS by default, so this matters only when you turn TLS off. SCEP is answered over plain HTTP in both modes, because RFC 8894 secures its own messages.
- EST checks the password or client secret in product mode only. In
development mode any password is accepted for a person who exists, and an
application’s secret is not checked. In product mode a person who holds,
or must hold, a second factor cannot use their own password for EST; give
them an app password
scoped to
est. - An application must be declared for the protocol in product mode. An
application declared for other protocol families, but not ACME, EST or
SCEP, is refused (
STS-ENROLL-0094). Development mode refuses nothing, and an application declared for nothing is not refused in either mode. - A device certificate needs a TPM attestation in product mode. See Devices.
What does not change: the ACME EAB key and the SCEP challenge are checked in both modes, a certificate is issued only for the entry that authenticated (or that an administrator named), and a host name is issued only when it is registered on the entry.
Before you start: the realm and its certificate authority
Each realm has an ACME Issuing CA, an EST Issuing CA and a SCEP
Issuing CA, under the realm’s Intermediate CA and the service Root. They are
made the first time one is needed, as long as the service Root exists, which
it does by default (pki.autoBuild). See
PKI → An Issuing CA per use case.
Every job below works in a throwaway realm of its own, and builds the realm’s
branch first so that the organisation in every subject is its own. Taken from
enroll_clients_kit.js, which the certbot, lego, estclient, sscep and
certmonger jobs share.
Console:
- Create the realm on Trust realms (
/admin/realms), then switch to it. - On Protocols → PKI, fill in Organisation (O=) and Country (C=), and click Build the certificate authority.
API:
api realms/create '{"id":"enroll-demo","domain":"enroll-demo.example.net",
"name":"enrollment"}'
BASE=https://localhost:8081/realm/enroll-demo # every call below is in the realm
api pki/build '{"organisation":"Enrollment Demo","country":"US"}'
Building again replaces the branch, and everything it issued then chains to nothing. Build once, before anything is enrolled. See PKI → A branch is built whole, or not at all.
The trust anchor. The main port’s certificate and every enrolled certificate chain to the service Root. A client is given it as PEM:
curl -s http://localhost:8082/pki/ca/service/root.cer |
openssl x509 -inform DER -out sts-root.pem
Port 8082 is pki.httpPort, the plain-HTTP listener that serves /pki/ and
SCEP. The main port answers the same path.
The person or application. A certificate always names a directory entry, and is written onto it. The jobs create a person with a generated password, because product mode checks it:
api users/create '{"username":"alice","invent":false,"credential":"generate",
"attributes":{"cn":"alice","givenName":"alice","sn":"alice",
"mail":"alice@example.test"}}'
The reply carries the password, once. givenName, sn and mail are there
because a product-mode realm’s attribute policy requires them.
Host names, for all three protocols
A dNSName or iPAddress is issued in a certificate only when it is
registered on the entry. One registration serves ACME, EST and SCEP. A
registered *.example.com authorizes that wildcard, and only when it is
written so. This service never dials a host to prove control of it.
Taken from sts_acme_certbot.js.
Console: on any of the three tabs, in Registered host names (on the EST tab, Certificate host names), choose person or application, type the identifier and the host name or address, and click Register. On an application’s page, its Certificate enrollment tab has the same form.
API (acme/, est/ or scep/; the three are the same register):
api acme/add-host-name '{"kind":"person","identifier":"alice",
"hostName":"www.alice.test"}'
api acme/add-host-name '{"kind":"person","identifier":"alice",
"hostName":"api.alice.test"}'
acme/remove-host-name (and the EST and SCEP ones) takes the same body.
ACME
An ACME account is bound to one directory entry, for life, by a required
External Account Binding key. No challenge is performed: an identifier is
authorized from the entry, and the authorization is created valid. See
ACME for why.
An EAB key for a person
Taken from sts_acme_certbot.js and sts_acme_enrollment.js.
Console:
- On Protocols → Cert issuance, ACME tab, go to External Account Binding keys.
- Choose person, type the identifier
alice, and leave lifetime (s) empty for the realm’sacme.eabLifetimeS(a week). - Click Create EAB key. Copy the key id and the HMAC key. The HMAC key is shown once.
API:
api acme/create-eab '{"kind":"person","identifier":"alice"}'
The reply carries kid (eab-p-…), hmacKey, alg (HS256),
expiresAt, the realm’s directory URL, and a ready-to-paste certbot line.
lifetimeS (60 to 31536000, at most acme.eabLifetimeS) shortens it; the
enrollment job makes a sixty-second one to check that an expired key is
refused.
A person can make their own on /portal/certificates, with Make an ACME
account binding key. sts_portal_certificates.js covers that door.
An EAB key binds one account and no other. To delete one before it binds, use
Delete on its row, or acme/delete-eab with {"kid":"…"}.
certbot
Taken from sts_acme_certbot.js, which drives certbot 5.8.0. The realm’s
directory is $BASE/enroll/acme/directory; the EAB reply names it.
DIR=https://localhost:8081/realm/enroll-demo/enroll/acme/directory
export REQUESTS_CA_BUNDLE=sts-root.pem
certbot register --server $DIR --agree-tos --no-eff-email \
-m alice@example.test --eab-kid "$KID" --eab-hmac-key="$HMAC"
certbot certonly --server $DIR --cert-name plain \
--standalone --http-01-port 18888 -d www.alice.test -d api.alice.test
- Write
--eab-hmac-key=<key>as one argument. A base64url key may begin with-, and certbot then reads it as an option. - No challenge is performed. certbot still wants an authenticator named;
--standalonebinds its port and receives nothing. - An order of host names only is issued
tls-server(serverAuth) when it names no profile.--required-profile tls-serverand--preferred-profile tls-server-clientname one. - The certificate’s subject is
CN=www.alice.test, UID=alice, O=<organisation>.
Renew, revoke and manage the account:
certbot renew --server $DIR --cert-name plain --force-renewal \
--no-random-sleep-on-renew
certbot revoke --server $DIR --cert-path <lineage>/cert.pem \
--reason keycompromise --no-delete-after-revoke
certbot update_account --server $DIR -m renamed-alice@example.test
certbot unregister --server $DIR
A plain certbot renew consults the renewal information (RFC 9773) and
renews nothing that is not due. The revoked serial goes on the ACME Issuing
CA’s CRL. unregister deactivates the account, and the Accounts section
lists it deactivated.
lego
Taken from sts_acme_lego.js, which drives lego v5.5.2:
export LEGO_CA_CERTIFICATES=sts-root.pem
lego accounts register --server $DIR --accept-tos -m bob@example.test \
--eab --eab.kid "$KID" --eab.hmac="$HMAC"
lego run --server $DIR -m bob@example.test --profile tls-server -c web \
-d www.bob.test -d api.bob.test --http --http.address 127.0.0.1:18889
lego accounts keyrollover --server $DIR -m bob@example.test --key-type RSA2048
lego certificates revoke --server $DIR -m bob@example.test -c web --reason 4 --keep
lego, unlike certbot, does key rollover. --not-after and --not-before are
refused, because the realm sets the lifetime. A refused registration leaves
lego holding a key with no account; register again in a fresh --path.
An application’s own certificate
Taken from sts_acme_enrollment.js. An application gets an account the same
way a person does: an administrator creates the EAB key for it.
Console:
- Create the application on Applications → New application ›, and tick ACME under Protocol families it is declared for.
- Register its host names as above, choosing application.
- On the ACME tab, create an EAB key for application
acmeapp, and hand the key id and HMAC key to whoever runs the client.
API:
api applications/create '{"identifier":"acmeapp","protocols":["oauth2","acme"],
"fields":{"oauthClientId":"acmeapp"}}'
api acme/create-eab '{"kind":"application","identifier":"acmeapp"}'
The key id of an application’s key begins eab-a-. An application’s account
is refused a person’s identifiers.
Profiles and lifetime
An order may name a profile. One that names none gets tls-server when every
identifier is a host name, and acme.defaultProfile (tls-client) otherwise.
The CA, OCSP-responder and KDC profiles are never issued. The full table is on
ACME → Profiles.
To narrow the realm to one profile (from sts_acme_enrollment.js):
api config/set '{"key":"acme.allowedProfiles","value":"tls-client"}'
api config/reset '{"key":"acme.allowedProfiles"}'
An order naming any other profile is then refused invalidProfile. The
certificate lifetime is acme.certificateLifetimeDays (90). For a per-application
override, see Rules for one application.
EST
EST has no credential of its own. A client authenticates with a person’s
password, an application’s client_id and client_secret, or a TLS client
certificate this realm issued. The path is /.well-known/est/<profile>/<operation>,
and the unlabelled path issues est.defaultProfile (tls-client). See
EST.
A person, with libest’s estclient
Taken from sts_est_libest.js. The person is alice with the password the
users/create reply gave, and dev.alice.test is registered on her entry.
First, turn TLS 1.2 back on. Every listener is TLS 1.3 only by default
(tls.disableTls12), and estclient is built on OpenSSL 1.1 and negotiates TLS
1.2. The job turns it on for the whole service and resets it afterwards. To
change only the main port, set listenerMain.disableTls12 to off instead; see
TLS and mutual TLS.
api config/set '{"key":"tls.disableTls12","value":false}'
Then bootstrap from the Root, and use the /cacerts answer as the trust
anchors from then on:
export EST_OPENSSL_CACERT=sts-root.pem
estclient -g -s localhost -p 8081 -o out
base64 -d out/cacert-0-0.pkcs7 | openssl pkcs7 -inform DER -print_certs > est-ca.pem
export EST_OPENSSL_CACERT=est-ca.pem
estclient -e -s localhost -p 8081 -o out -u alice -h "$PASSWORD" \
--common-name alice --pem-output # /simpleenroll
estclient -r -s localhost -p 8081 -o out -c out/cert-0-0.pem -k <key> \
--pem-output # /simplereenroll
estclient -q -s localhost -p 8081 -o out -x <key> -u alice -h "$PASSWORD" \
--common-name alice --pem-output # /serverkeygen
A server certificate needs the host registered and the tls-server
label, with a request made by openssl:
openssl req -new -newkey rsa:2048 -nodes -keyout web.key -out web.csr \
-subj /CN=dev.alice.test -addext subjectAltName=DNS:dev.alice.test
estclient -e -s localhost -p 8081 -o out --path-seg tls-server -y web.csr \
--pem-output -u alice -h "$PASSWORD"
The certificate’s subject is CN=dev.alice.test, UID=alice, O=…. A host not
registered on the entry is refused.
In a realm other than the default, estclient cannot build
/realm/<id>/.well-known/est, and it sends only one label. Name the realm in
the label position instead: --path-seg <realm>. The profile is then that
realm’s est.defaultProfile, so the job sets it before asking for a server
certificate:
api config/set '{"key":"est.defaultProfile","value":"tls-server"}'
estclient -e -s localhost -p 8081 -o out --path-seg enroll-demo -y web.csr \
-u alice -h "$PASSWORD"
A realm may not be named after a profile, so the segment always means one thing. See EST → Reaching a realm through the label.
estclient exits 0 whether or not it enrolled; read what it wrote to out/.
EST → With libest’s estclient lists the
other things it prints that are not the server’s.
An application, with curl
Taken from sts_est_enrollment.js, which sends these requests with its own
client. The application authenticates with HTTP Basic, its client_id and its
client_secret.
Console:
- Create the application on Applications → New application ›: tick OAuth 2.0 and EST, and generate a secret in The client secret.
- Register its host name, choosing application.
API:
APP_SECRET=$(openssl rand -base64 18)
api applications/create '{"identifier":"estapp","protocols":["oauth2","est"],
"fields":{"oauthClientId":"estapp","oauthClientSecret":"'"$APP_SECRET"'"}}'
api est/add-host-name '{"kind":"application","identifier":"estapp",
"hostName":"estapp.svc.example.test"}'
Request (the body is the base64 of the DER PKCS#10):
openssl req -new -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-keyout app.key -subj /CN=estapp \
-addext subjectAltName=DNS:estapp.svc.example.test -outform DER -out app.csr.der
base64 app.csr.der | curl -s --cacert sts-root.pem -u "estapp:$APP_SECRET" \
-H 'Content-Type: application/pkcs10' --data-binary @- \
https://localhost:8081/realm/enroll-demo/.well-known/est/tls-server/simpleenroll \
| base64 -d | openssl pkcs7 -inform DER -print_certs > app.pem
A Basic username is looked up as a person first, then as an application’s
client_id. A wrong secret is refused in product mode.
Which credentials are accepted. est.basicAuthentication and
est.certificateAuthentication (both on) turn each method off for the realm,
and the application’s estBasicAuthentication and
estCertificateAuthentication override them for it. In the console these are
in the EST tab’s Settings, and on the application’s Certificate
enrollment tab.
A key pair made by the service
/serverkeygen makes the key pair here and keeps a sealed copy on the entry.
An administrator can do the same for any entry. Taken from
tests/est_handlers.js.
Console: on the EST tab, in Issue a certificate with a server-generated key, choose the entry, the Profile and the Key algorithm, and click Issue with a server-generated key. The private key is shown once.
API:
api est/issue-server-key '{"kind":"person","identifier":"alice",
"profile":"digital-signature","keyAlg":"ec-p256"}'
The reply carries privateKeyPem, once. keyAlg is one of the
keyAlgorithms that GET /admin-api/est lists; an ML-KEM key is certified
for key-encipherment only. est.serverKeyGeneration turns this off.
SCEP
A SCEP client authenticates its first enrollment with a challenge password, made for one entry and one profile and redeemed once. A renewal is signed by the certificate being renewed and needs no challenge. Only RSA keys can be enrolled, and SHA-1, MD5, DES and 3DES are refused. See SCEP.
A challenge password
Taken from sts_scep_sscep.js.
Console:
- On Protocols → Cert issuance, SCEP tab, go to Challenge passwords.
- Choose person, type
carol, pick the profiletls-client, and leave the lifetime empty forscep.challengeLifetimeS(an hour). - Click Create a challenge. It is shown once.
API:
api scep/create-challenge '{"kind":"person","identifier":"carol","profile":"tls-client"}'
The reply carries challenge, the url on the main port, the plainUrl on
the plain-HTTP listener, and hint: the three shell lines that make a request
and enroll with sscep. The job runs the hint as it is written, and it enrolls.
A person can make their own on /portal/certificates, with Make a SCEP
challenge password. An application has no portal, so an administrator makes
its challenges with "kind":"application".
A challenge is spent by the first request that proves it, even if the request is then refused (for an unregistered host name, say). Make another.
sscep
sscep has no TLS, so it uses the plain-HTTP URL, http://<host>:8082/realm/<realm>/enroll/scep/<profile>.
Taken from sts_scep_sscep.js:
URL=http://localhost:8082/realm/enroll-demo/enroll/scep/tls-client
sscep getca -u $URL -c ca.crt # ca.crt-0 is the RA, ca.crt-1 the SCEP Issuing CA
cat > req.cnf <<EOF
[req]
prompt=no
distinguished_name=dn
attributes=attrs
[dn]
CN=carol
O=<the organisation the hint names>
C=US
[attrs]
challengePassword=$CHALLENGE
EOF
openssl req -new -newkey rsa:2048 -nodes -keyout carol.key -out carol.csr \
-config req.cnf -addext subjectAltName=URI:urn:sts:person:carol
sscep enroll -u $URL -c ca.crt-0 -e ca.crt-0 -k carol.key -r carol.csr \
-l carol.pem -S sha256 -E aes
prompt=no, or OpenSSL puts no challengePassword in the request.-c ca.crt-0, the RA, because the RA signs every reply.- The subject the certificate will carry, or sscep warns. The hint writes the right one.
Renew with the old certificate and a new key; no challenge is needed, and the old serial goes on the SCEP CRL:
sscep enroll -u $URL -c ca.crt-0 -e ca.crt-0 -k new.key -r new.csr \
-K carol.key -O carol.pem -S sha256 -E aes -l renewed.pem
sscep enroll … -R resumes the same transaction and gets the same
certificate back. sscep getcert and sscep getcrl, signed with the enrolled
certificate, answer the certificate and the SCEP Issuing CA’s CRL.
certmonger
Taken from sts_scep_certmonger.js, where erin makes her own challenge on
/portal/certificates. Use the plain-HTTP URL here too:
certmonger passes its CA file only to GetCACaps and GetCACert, so an HTTPS
enrollment fails unless the service Root is in the host’s trust store.
getcert add-scep-ca -c STS -u http://localhost:8082/realm/enroll-demo/enroll/scep/tls-client
getcert request -c STS -k "$PWD/erin.key" -f "$PWD/erin.pem" \
-N "CN=erin,O=<organisation>,C=US" -L "$CHALLENGE" -I erin -w
getcert resubmit -i erin -w # renew with the same key
getcert rekey -i erin -w # renew with a new key
A refusal shows as CA_UNREACHABLE, and certmonger retries it. The reason is
on Monitoring → Cert issuance → SCEP enrollments; stop the retries with
getcert stop-tracking -i erin.
The RA certificate
SCEP requests are encrypted to the realm’s RA certificate, an RSA leaf of the
SCEP Issuing CA. Its key size is scep.raKeyAlgorithm (rsa-2048). To
replace it (from sts_scep_enrollment.js):
Console: on the SCEP tab, RA certificate, click Re-issue the RA certificate.
API:
api scep/reissue-ra '{}'
The old RA certificate goes on the CRL as superseded. A client encrypting
to it is answered badMessageCheck until it runs GetCACert again.
For all three
Rules for one application
An application can override the realm’s enrollment rules. Taken from
tests/application_enrollment.js.
Console: on the application’s page, Configuration tab:
- In Protocol families, tick ACME, EST or SCEP, and click Save.
- In Certificate enrollment, fill in any override and click Save. An empty field takes the realm’s value.
API:
api applications/set '{"application":"estapp","attribute":"estCertificateLifetimeDays","value":"30"}'
api applications/add '{"application":"estapp","attribute":"estAllowedProfiles","value":"tls-server"}'
api applications/set '{"application":"estapp","attribute":"estBasicAuthentication","value":"FALSE"}'
The attributes are <family>AllowedProfiles, <family>DefaultProfile and
<family>CertificateLifetimeDays for acme, est and scep;
enrollMaxCertificates; and, for EST only, estBasicAuthentication,
estCertificateAuthentication and estServerKeyGeneration (TRUE or
FALSE). Applications → Certificate enrollment
describes each.
How many certificates one entry may hold across the three protocols is
pki.enrollmentMaxCertificatesPerEntry (20). Past it a request is refused
(STS-ENROLL-0040); no older certificate is dropped to make room.
Revocation, as a client sees it
Every enrolled certificate names its Issuing CA’s CRL and OCSP responder, on
the plain-HTTP listener (pki.httpPort, 8082). The <scope> is the realm id,
or default in the default realm, and <ca> is acme, est or scep:
| What | Where |
|---|---|
| The CRL | http://<host>:8082/pki/crl/<scope>/<ca>.crl (the .crl is optional) |
| The OCSP responder | http://<host>:8082/pki/ocsp/<scope>/<ca> (GET or POST) |
| The Issuing CA’s certificate | http://<host>:8082/pki/ca/<scope>/<ca>.cer |
| Every authority’s addresses | GET /pki/revocation |
A CRL is signed when it is fetched, and its nextUpdate is
pki.crlLifetimeMinutes (60) away. Behind a port mapping, set
pki.distributionPort or pki.distributionBaseUrl so that the addresses
written into a certificate are reachable; see
PKI → Revocation is published, and consulted.
A client revokes with its own protocol: ACME revokeCert (signed by the
account or by the certificate’s own key), or nothing for EST and SCEP. A
person revokes their own on /portal/certificates. EST and SCEP renewals
revoke the old certificate as superseded; an ACME order’s replaces does
not.
An administrator revokes on the protocol’s tab (Revoke on the certificate’s row, with a reason), on Protocols → PKI, or through the API. The serial is named differently in each:
api acme/revoke-certificate '{"serial":"<serial>","reason":"superseded"}' # sts_acme_enrollment.js
api est/revoke-certificate '{"serialHex":"<serial>","reason":"keyCompromise"}' # sts_est_enrollment.js
api scep/revoke-certificate '{"serial":"<serial>","reason":"keyCompromise"}' # sts_scep_enrollment.js
The serial is hexadecimal, as GET /admin-api/acme, /est or /scep lists
it. From then on the serial is on the CRL and OCSP answers revoked; the jobs
check both. An ACME client asking for renewal information about a revoked
certificate is told to renew now.
Revoking here publishes the revocation. Whether anything then refuses the
certificate is up to whoever relies on it; this service itself checks what is
presented to it under pki.revocationCheck.
When something is refused
The client sees only the protocol’s own error: an ACME problem document, an
EST HTTP status with one plain sentence, or a SCEP CertRep FAILURE with a
failInfo. The reason is on the protocol’s monitoring page and on the audit
log, as an STS-ACME-…, STS-EST-…, STS-SCEP-… or STS-ENROLL-… code.
GET /admin-api/acme/monitor (and est/monitor, scep/monitor) answers the
same counts. Look the code up on Error codes.
Related
- ACME, EST and SCEP: the behaviour and every setting.
- PKI: the certificate authority, its profiles, CRLs and OCSP responders.
- Devices: the
deviceprofile over EST and SCEP. - Configuring TLS and mutual TLS: using an enrolled
tls-clientcertificate to sign in. - Configuring SPIFFE: X509-SVIDs, the fourth thing on Cert issuance.
- Configuring OAuth 2.0 grants: the API token and the
apihelper.