An implementation of RFC 7030: automated certificate issuance and renewal for
devices. Controllers live in estControllers, CSR
signing in crypts/estSign.go, authentication in
middleware/estBasicAuth.go and middleware/estCertAuth.go.
The EST server runs on app.est_port (43001 by default) and always uses TLS, as
required by RFC 7030 §3.3. The TLS configuration:
tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven
tlsConfig.ClientCAs = clientCAPool // crypts.BuildESTClientCAPoolVerifyClientCertIfGiven means a client certificate is optional, but if presented, its
chain is validated against the pool of our CAs. This mode lets one listener serve both
simpleenroll (Basic Auth, no certificate yet) and simplereenroll (mTLS).
The reason for a dedicated port: the client certificate is requested during the TLS handshake, when the server does not yet know the URL. Enabling mTLS "for one route only" is impossible, and enabling it on the UI port is undesirable - browsers would start showing a certificate selection dialog.
| Method and path | Authentication | RFC |
|---|---|---|
GET /.well-known/est/cacerts |
none | 7030 §4.1 |
GET /.well-known/est/csrattrs |
none | 7030 §4.5 |
POST /.well-known/est/simpleenroll |
HTTP Basic | 7030 §4.2 |
POST /.well-known/est/simplereenroll |
mTLS | 7030 §4.2.2 |
Returns the CA chain as a "degenerate" PKCS#7 (certificates only, no signature):
pkcs7.NewSignedData([]byte{}) plus AddCertificate for each active CA. The body is
base64, with the application/pkcs7-mime; smime-type=certs-only and
Content-Transfer-Encoding: base64 headers.
Tells the client which attributes the server expects in a CSR. Two formats are
supported, switched by the estCSRAttrs.rfc9908 flag:
| Value | Structure |
|---|---|
false - RFC 7030 §4.5.2 |
SEQUENCE { OID commonName, Attribute { id-ExtensionReq, SET { OID subjectAltName } } } |
true - RFC 9908 |
id-aa-extensionReqTemplate is used instead of id-ExtensionReq, with a nested Extensions SEQUENCE |
The flag exists because clients of different generations understand different variants.
In practice the server only uses Subject.CommonName and SAN from the CSR - the
remaining extensions (KeyUsage, ExtKeyUsage, BasicConstraints) are set by the server and
ignored from the CSR.
ESTBasicAuthverifies the login and password against theest_userstable (bcrypt), and also that the account is active and themax_usescounter is not exhausted.- The request body is decoded: base64 first, then PEM, then raw DER.
crypts.SignCSRparses the PKCS#10, verifies the CSR signature and signs a certificate with the selected CA (est_users.signing_ca_id).- The certificate is stored in
est_certs- this is what makes a later reenroll possible. max_usesis decremented; when it reaches zero the user moves to thedisabledstate.- The response is a PKCS#7 carrying one certificate
(
pkcs7.DegenerateCertificate), base64 encoded.
The validity period comes from est.cert_ttl_enrollment. There is no standard way to
request a specific lifetime in a PKCS#10, so this is a server-side decision.
ESTCertAuthtakes the client certificate from the TLS state (c.RequestCtx().TLSConnectionState().PeerCertificates[0]) - the chain has already been validated at the TLS level.- The serial number is looked up in
est_certs. This rules out certificates issued through the UI outside EST, even if they were signed by the same CA. - The certificate is checked for not being revoked or expired.
- The Subject and SAN of the new CSR are compared against the presented certificate
(RFC 7030 §4.2.2). A mismatch returns
403. - The new CSR is signed with the same
signing_ca_idandttlas the presented certificate. - The new record is stored and the old certificate is revoked with the reason
superseded.
An EST user is not needed at this step: per the RFC a valid certificate is enough. The
est_user_id field is carried over into the new record only as a historical trace.
checkIdentityUnchanged compares:
| Field | Comparison method |
|---|---|
| Subject | byte-for-byte over DER (RawSubject) |
| DNS names, email, IP, URI | as sets - the order of values does not matter |
Without this check the owner of any valid EST certificate could issue one for an arbitrary name: authentication only proves possession of a key, while the CSR contents were previously unconstrained.
The ChangeSubjectName attribute (RFC 6402), which a client could use to explicitly
request a name change, is not supported - any divergence is rejected.
The est_users table holds the accounts used for the initial issuance:
| Field | Meaning |
|---|---|
password_hash |
bcrypt |
max_uses |
how many more certificates may be issued |
ttl, user_expire_time |
lifetime of the account, not of the certificate |
user_status |
0 active, 1 expired, 3 disabled (max_uses exhausted) |
signing_ca_id |
which CA signs certificates issued to this user |
Two independent blocking mechanisms:
max_usesreaches zero ->user_status = 3(set inSimpleEnroll)user_expire_timepasses ->user_status = 1(set by theCheckValidCertschecker)
In both cases ESTBasicAuth returns 401.
A side effect: the CA for a device is chosen by choosing the account - the client only
supplies a login and password, while the signing CA is determined by the est_users
record.
Three menu sections:
| Section | Route | Purpose |
|---|---|---|
| Add EST users | /est_users |
Creating and deleting accounts |
| Add EST certs | /add_est_certs |
Manual issuance without the protocol |
| Revoke EST certs | /revoke_est_certs |
Revocation, rollback, deletion |
Manual issuance through the UI writes into the same est_certs table, so such a
certificate can later be renewed through simplereenroll.
./tests/est/test_est.sh <username> <password>The script walks through the full cycle: cacerts -> CSR generation -> simpleenroll
-> chain validation -> simplereenroll over mTLS -> status reconciliation in the
database -> the max_uses blocking check.
Manually:
# CA chain
curl -sk https://tlss.lv.local:43001/.well-known/est/cacerts \
| base64 -d | openssl pkcs7 -inform DER -print_certs -noout -text
# CSR attributes
curl -sk https://tlss.lv.local:43001/.well-known/est/csrattrs \
| base64 -d | openssl asn1parse -inform DER
# Issuance
openssl req -new -newkey rsa:2048 -nodes -keyout client.key -out client.csr -subj "/CN=device-01"
openssl req -in client.csr -outform DER | base64 | \
curl -sk -u "user:pass" -X POST -H "Content-Type: application/pkcs10" \
--data-binary @- https://tlss.lv.local:43001/.well-known/est/simpleenroll \
| base64 -d | openssl pkcs7 -inform DER -print_certs -out client.crt
# Renewal over mTLS
openssl req -new -newkey rsa:2048 -nodes -keyout client2.key -out client2.csr -subj "/CN=device-01"
openssl req -in client2.csr -outform DER | base64 | \
curl -sk --cert client.crt --key client.key -X POST -H "Content-Type: application/pkcs10" \
--data-binary @- https://tlss.lv.local:43001/.well-known/est/simplereenroll