[root@localhost /lab]# silliEST

RFC 7030, the silliEST edition. Brought to you by Brandon @ cyb3r.sh

This server runs a two-tier PKI (root + intermediate CA) and speaks just enough of EST (RFC 7030) to complete this workshop.

Our goals are:

Instead of using a production EST client, we're going to use common CLI tools like: curl, openssl, and base64 in order to showcase how simple the protocol is and to aid in our understanding of the protocol.

This landing page is served from silliest.lab.cyb3r.sh with a publicly-trusted certificate, so your browser doesn't complain. The EST endpoints live at silliest-ca.lab.cyb3r.sh and use a certificate signed by an ephemeral root CA.

Your enrollment challenge

Use this string as the HTTP Basic Auth password when calling /simpleenroll (RFC 7030 sec 3.2.3). The username can be anything.

9138439cafb7fe25d90f54831e914291

For simplicity, this server only supports basic auth, production EST servers often support digest as well.

Step 1 - Bootstrap trust via /cacerts (sec 4.1)

The first request is the bootstrap. You don't trust the server's TLS cert yet, so use curl -k to disable validation for this one call. The response is a base64-encoded "certs-only" PKCS#7 carrying the CA certificates.

curl -k https://silliest-ca.lab.cyb3r.sh/.well-known/est/cacerts -o cacerts.b64

It's worth calling out that "certs-only" PKCS#7 is literally base64 encoded DER. A binary format that anyone who has worked with certificates should be familiar with.

RFC 7030 sec 4.1.3 requires /cacerts to return the root and every intermediate a client needs to build a chain back to the trust anchor. Since our CA is two tiers, we should have received two certificates. Let's extract them.

# list the certs inside the cacerts response
base64 -d cacerts.b64 | openssl pkcs7 -inform DER -print_certs -noout

# Convert to a PEM bundle, then split into separate root + intermediate files
base64 -d cacerts.b64 | openssl pkcs7 -inform DER -print_certs -out ca.pem
awk '/-----BEGIN/{n++; f="ca-"n".pem"} f{print > f}' ca.pem
mv ca-1.pem root.pem
mv ca-2.pem intermediate.pem

From here on, use --cacert root.pem instead of -k.

Why only the root? By convention, TLS speakers present their leaf cert plus the intermediate(s) they're signed by during the handshake. In other words, the client only needs the root in its trust store to build and validate the chain because the server includes the intermediate and it's leaf certificate. We hold onto intermediate.pem separately because we'll need it ourselves in Step 5 when presenting our leaf certificate during an mTLS handshake.

In production, EST clients either have the root provisioned out-of-band or will disable validation on the first check, then compare the root against a known valid fingerprint (that was provisioned out of band). The intermediate is then validated implicitly by its signature chain back to that root.

# Does the root fingerprint match what we expect?
openssl x509 -in root.pem -noout -fingerprint
# Can we validate the intermediate against the root?
openssl verify -CAfile root.pem intermediate.pem

Expected Root CA SHA-256 Fingerprint: 7e:41:47:d1:5e:22:bb:6c:7f:66:c3:1e:5c:6a:27:d1:fe:74:e2:51:97:04:7f:ce:79:59:25:4a:c5:b1:92:42

Finally, take a look at the intermediate and root certificates key usage and issuer.

openssl x509 -in root.pem -noout -subject -issuer -ext keyUsage
openssl x509 -in intermediate.pem -noout -subject -issuer -ext keyUsage

Two things to note, 1) The RootCA's issuer is itself. Trust has to start somewhere. 2) Both the intermediate and root can be used for certificate signing, but the root would generally only be used to sign the intermediate. All leaf certificates should be signed by the intermediate. Revoking an intermediate certificate is easier than revoking the root.

Step 2 - Generate a keypair and a CSR (RFC 2986)

# generate a key pair
openssl genrsa -out client.key 2048
# create a new CSR
openssl req -new -key client.key -out client.csr -subj "/CN=workshop-attendee-cTaYr"
In a real PKI environment, the Policy Authority (PA) (in practice often a Registration Authority (RA) component) would specify requirements for attributes contained in the certificate. For instance the CN might need to be in a specific format or match a hostname in inventory. In turn there could be a requirement for a matching DNS Subject Alternative Name (SAN) or an IP SAN matching the expected value for the host.
# optionally, view the CSR you just created
openssl req -in client.csr -noout -text

Step 3 - Enroll via /simpleenroll (sec 4.2)

As with the CACerts, we enroll by simply sending a PKCS#10 CSR. Once again, this is literally just base64 encoded DER.

# Base64 encode the DER
openssl req -in client.csr -outform DER | base64 > client.csr.b64

POST the encoded CSR and authenticate using basic auth with the challenge above as the password.

# Send an HTTPS request with appropriate headers and our CSR
curl --cacert root.pem \
  -u "workshop:9138439cafb7fe25d90f54831e914291" \
  -H "Content-Type: application/pkcs10" \
  -H "Content-Transfer-Encoding: base64" \
  --data-binary @client.csr.b64 \
  https://silliest-ca.lab.cyb3r.sh/.well-known/est/simpleenroll \
  -o client.p7.b64

The response is a base64 PKCS#7 containing your shiny new certificate. Per RFC 7030 sec 4.2.3 only the end-entity cert is returned. The client is expected to build the chain from /cacerts.

base64 -d client.p7.b64 | openssl pkcs7 -inform DER -print_certs -out client.pem

Note that unlike our intermediate and root, the leaf certificate is not valid for certificate signing. It is valid for digital signature, which is necessary for TLS operations.

openssl x509 -in client.pem -noout -subject -issuer -dates -ext keyUsage
Heads up: issued certs are short-lived (TTL: 1h0m0s). Once expired, the TLS handshake on /mtls-test and /simplereenroll will fail. Re-enroll via /simpleenroll (Step 3) or renew before expiry via Step 6.

Before using the cert, let's check that we can build the chain back to the root. -CAfile supplies the trust anchor; -untrusted supplies the intermediate(s) needed to bridge the gap.

openssl verify -CAfile root.pem -untrusted intermediate.pem client.pem

A failure here means either the intermediate doesn't match, the cert is expired, or the root you trusted isn't the one that signed our intermediate.

Step 4 - Use the cert at /mtls-test (mTLS - Mutual TLS)

The /mtls-test page requires you to present your chain in the handshake, not just the leaf. This is what TLS servers do when they present their own cert.

cat client.pem intermediate.pem > client-chain.pem

curl --cacert root.pem --cert client-chain.pem --key client.key \
  https://silliest-ca.lab.cyb3r.sh/mtls-test

Send only the leaf (--cert client.pem) and the request will fail.

Step 5 - Reenroll via /simplereenroll (sec 4.2.2)

Reenrollment authenticates with the existing certificate via mTLS. No challenge required. The format is otherwise identical to /simpleenroll.

First, the simple case. Renew the cert without rekeying. Sign a new CSR with the existing key. Then send a request to simplereenroll in the same format as our earlier enrollment request.

openssl req -new -key client.key -out reenroll.csr -subj "/CN=workshop-attendee-cTaYr"
openssl req -in reenroll.csr -outform DER | base64 > reenroll.csr.b64

curl --cacert root.pem \
  --cert client.pem --key client.key \
  -H "Content-Type: application/pkcs10" \
  -H "Content-Transfer-Encoding: base64" \
  --data-binary @reenroll.csr.b64 \
  https://silliest-ca.lab.cyb3r.sh/.well-known/est/simplereenroll \
  -o reenrolled.p7.b64

base64 -d reenrolled.p7.b64 | openssl pkcs7 -inform DER -print_certs -out reenrolled.pem
openssl x509 -in reenrolled.pem -noout -subject -issuer -dates

Or rekey at the same time by generating a new keypair and binding it to the new CSR. The mTLS handshake will still use the old cert and key (the only one we have right now), but the CSR carries the new public key.

# new keypair, new CSR signed with the new key
openssl genrsa -out client-new.key 2048
openssl req -new -key client-new.key -out reenroll-rekey.csr -subj "/CN=workshop-attendee-cTaYr"
openssl req -in reenroll-rekey.csr -outform DER | base64 > reenroll-rekey.csr.b64

curl --cacert root.pem \
  --cert client.pem --key client.key \
  -H "Content-Type: application/pkcs10" \
  -H "Content-Transfer-Encoding: base64" \
  --data-binary @reenroll-rekey.csr.b64 \
  https://silliest-ca.lab.cyb3r.sh/.well-known/est/simplereenroll \
  -o reenrolled-rekey.p7.b64

base64 -d reenrolled-rekey.p7.b64 | openssl pkcs7 -inform DER -print_certs -out reenrolled-rekey.pem
openssl x509 -in reenrolled-rekey.pem -noout -subject -issuer -dates

# confirm the rekeyed cert is bound to client-new.key (matching md5s)
openssl x509 -in reenrolled-rekey.pem -noout -pubkey | openssl md5
openssl pkey  -in client-new.key      -pubout | openssl md5

From this point on you'd swap to client-new.key + reenrolled-rekey.pem for any subsequent mTLS work (and discard the old key).

Peek under the hood with openssl s_client

To watch the raw TLS handshake and inspect the cert chain the server presents:

openssl s_client -connect silliest-ca.lab.cyb3r.sh:443 -showcerts < /dev/null

To watch an mTLS handshake with your client cert:

openssl s_client -connect silliest-ca.lab.cyb3r.sh:443 \
  -CAfile root.pem -cert client-chain.pem -key client.key < /dev/null

References