Skip to content

feat(est): serve RFC 7030 so clients can renew with the certificate they hold - #188

Merged
Bugs5382 merged 1 commit into
mainfrom
feat/186-est-server
Sep 10, 2026
Merged

Bugs5382 merged 1 commit into
mainfrom
feat/186-est-server

Conversation

@Bugs5382

Copy link
Copy Markdown
Contributor

What and why

ACME (#184) covers anything that can answer a challenge. SCEP (#185) covers legacy gear that can do nothing else. Neither offers renewal authenticated by the certificate the client already holds, which is what a provisioned fleet actually needs: enrol once, then renew for years with no shared secret left sitting on the device.

internal/est serves /cacerts, /simpleenroll, /simplereenroll and /csrattrs. Like internal/acme it is a wire-format adapter holding no key: the CA chain, issuance and the revocation check all arrive as closures, and issuance routes through node.CASigner.IssueLeafForNames so ca.Profile still decides every extension and every certificate lands in the revocation issued set.

Closes #186

The security shape, which is the part worth reviewing

The two entry points authenticate very differently, and the asymmetry is the design:

simplereenroll takes a TLS client certificate that must chain to this CA, must not be expired, and must not be revoked. The CSR may then request only the names already on that certificate. That last rule is what stops a renewal becoming an escalation — holding one certificate never becomes a way to mint another for a different name. RFC 7030 section 4.2.2 puts it as a SHOULD on the client; enforcing it server-side is the whole point. The revocation check lives in the handler rather than the TLS stack, because a handshake that only verified the signature would let a revoked certificate renew itself.

simpleenroll takes an operator-provisioned HTTP Basic credential. Nothing about that proves the caller controls the name it asks for, so the identifier allowlist is required whenever credentials are configured, and running without one needs allow_any_identifier spelled out in the config where a reviewer can find it. Configuring no credentials at all is a supported deployment: enrol via ACME, renew via EST.

The config stores the SHA-256 of each password rather than the password, so a running configuration never holds a live credential. That is only sound because validation demands a generated high-entropy value — a digest of a chosen word would fall to a dictionary — and the docs give the generation command.

Two things EST needed that ACME did not

A CMS writer. EST carries certificates in a degenerate certs-only SignedData, which the standard library cannot produce. pkcs7.go builds that one fixed envelope with encoding/asn1, about ninety lines, rather than putting a third-party PKCS#7 parser next to the CA. internal/ca/doc.go allows a wire-format library for an adapter; this was small enough not to need one.

Its own TLS termination. The client certificate has to reach the handler, so unlike the ACME listener this one cannot sit behind a terminator. The node mints its own server certificate from its own CA and renews it in place, following the delegated-OCSP-responder precedent, so a client that trusts the CA also trusts the listener with no extra anchor. The listener requests rather than requires a client certificate: /cacerts exists precisely for a client that holds nothing yet. TLS 1.2 is the floor rather than 1.3, because RFC 7030 predates 1.3 and the embedded clients EST serves commonly top out at 1.2.

Not implemented

Server-side key generation (/serverkeygen), /csrattrs beyond an empty 204, and the deferred-enrolment 202 Retry-After flow. Issuance here is synchronous or it fails.

Follow-up needed

pki.est, like pki.acme, is not carried in the proto MachineConfig, so it survives a staged YAML boot but not an ApplyConfig from a manager. Both need the same CryptOS-PKI/api change; noted in the field's doc comment and in docs/est.md.

Verification

  • Lint clean
  • Tests pass
  • Build succeeds
  • Documentation updated

How this was verified

gofmt -l .                                                      # clean
go vet ./...                                                    # clean
golangci-lint run ./...                                         # 0 issues (v2.12.2, matched to CI)
go test ./...                                                   # all packages pass
go test -race ./internal/est/ ./internal/init/ ./internal/config/  # clean
go test ./internal/est/ -cover                                  # 84.6% of statements

The EST suite runs against a real TLS listener with real client certificates issued by a real test CA, so the client-certificate path is exercised end to end rather than faked at the handler boundary.

The PKCS#7 writer is checked against OpenSSL, not against itself. TestCertsOnlyPKCS7ReadableByOpenSSL writes the message and runs openssl pkcs7 -inform DER -print_certs, asserting both subjects come back; it skips where openssl is absent, and the structural round-trip test always runs. That check earned its place immediately: the first version of the writer produced a message neither OpenSSL nor our own parser could read, because encoding/asn1 silently ignores an explicit struct tag on a RawValue and the [0] wrapper was never emitted. A self-consistency test would have passed if I had written the parser with the same bug.

I also mutation-checked the escalation guard: disabling the sameNameSet check in handleSimpleReenroll makes three subtests of TestSimpleReenrollCannotChangeNames fail and the CA issue three certificates it should have refused. That assertion has teeth.

Merge order

This branches off #187 (ACME), already merged, because it reuses node.IssueLeafForNames and touches the same config and boot wiring. Nothing else is queued behind it.

@github-actions github-actions Bot added the enhancement New feature (feat). Minor version bump. label Sep 10, 2026
…hey hold

ACME covers anything that can answer a challenge and SCEP covers legacy gear,
but neither offers renewal authenticated by the certificate already in hand.
That is what a provisioned fleet needs: enrol once, then renew for years with
no shared secret left on the device.

internal/est serves /cacerts, /simpleenroll, /simplereenroll and /csrattrs.
Like internal/acme it is a wire-format adapter holding no key, taking the CA
chain, issuance and the revocation check as closures, and routing issuance
through node.CASigner so ca.Profile still decides every extension.

The two entry points authenticate very differently, and the asymmetry is
deliberate:

- simplereenroll takes a TLS client certificate that must chain to this CA,
  must not be expired, and must not be revoked. The CSR may then request only
  the names already on that certificate, so a renewal cannot become an
  escalation. The revocation check is in the handler rather than the TLS
  stack, which cannot consult the revoked set.

- simpleenroll takes an operator-provisioned HTTP Basic credential. Nothing
  proves the caller controls the name, so the identifier allowlist is required
  whenever credentials are configured; running without one needs
  allow_any_identifier spelled out. Configuring no credentials at all is a
  valid deployment: enrol via ACME, renew via EST.

The config stores the SHA-256 of each password rather than the password, so a
running configuration never holds a live credential. That is only sound
because validation demands a generated high-entropy value.

Two things this needed that ACME did not. EST carries certificates in a
degenerate certs-only CMS SignedData, which the standard library cannot write;
pkcs7.go builds that one fixed envelope with encoding/asn1 rather than taking
a PKCS#7 dependency next to the CA. And the listener terminates TLS itself,
because the client certificate has to reach the handler, so the node mints its
own server certificate from its CA and renews it in place. The listener
requests rather than requires a client certificate: /cacerts exists for a
client that holds nothing yet.

Not implemented: server-side key generation, csrattrs beyond an empty 204, and
the deferred-enrolment 202 Retry-After flow.

Closes #186
@Bugs5382

Copy link
Copy Markdown
Contributor Author

Closing summary

Landed EST as internal/est, wired into the node and documented.

What is in it

  • pkcs7.go, http.go, handlers.go. /cacerts, /simpleenroll, /simplereenroll and /csrattrs, with an optional RFC 7030 path label so one host can front several CAs.
  • A certs-only CMS writer built on encoding/asn1, because the standard library has none and EST needs exactly one fixed shape of it.
  • pki.est config with fail-closed validation, and an internal/init listener that terminates TLS with a server certificate the node mints from its own CA and renews in place.
  • docs/est.md: configuration, credential provisioning, runnable curl and openssl for all three operations, and the limits.

Behaviour a reviewer may want to confirm against the code

  • simplereenroll requires a client certificate that chains to this CA, is unexpired, and is not revoked, and then permits only the names that certificate already carries. Names it holds are honoured even when they fall outside the simpleenroll allowlist: the certificate, not the policy, is the authority on a renewal.
  • simpleenroll requires an allowlist whenever credentials are configured. allow_any_identifier is the only way out and has to be written down.
  • Configuring no credentials leaves simpleenroll answering 403 rather than falling open.
  • Wildcards, non-DNS SANs, non-FQDN names and a common name outside the allowlist are all refused.
  • The listener requests rather than requires a client certificate, so /cacerts stays reachable to a client holding nothing.
  • Config stores only the SHA-256 of each simpleenroll password.

Verification

gofmt, go vet, golangci-lint (v2.12.2, matched to CI), go test ./... and go test -race all clean; 84.6% statement coverage on the new package. The EST suite runs against a real TLS listener with real client certificates from a real test CA.

The PKCS#7 writer is checked against OpenSSL rather than against itself, and that check paid for itself immediately: the first version emitted a message neither OpenSSL nor our own parser could read, because encoding/asn1 ignores an explicit struct tag on a RawValue and the [0] wrapper was silently dropped. A self-consistency test would have passed if the parser had shared the bug.

Two CI failures were mine and are fixed here rather than worked around:

  • The OpenSSL assertion pinned CN=x, but CI's OpenSSL prints CN = x. It now matches on the common names alone, so it tests the message rather than the formatting.
  • Gitleaks flagged a high-entropy test password literal. The fixture now generates its password at run time, which is also closer to how a real deployment has to do it, and the two other test files use an obviously non-secret string. Because gitleaks scans history, the commit was amended rather than fixed on top.

Deferred, deliberately

  • Server-side key generation, /csrattrs beyond an empty 204, and the deferred-enrolment 202 Retry-After flow.
  • pki.est, like pki.acme, is not carried in the proto MachineConfig. Both need the same CryptOS-PKI/api change.

@Bugs5382
Bugs5382 merged commit e61fc2f into main Sep 10, 2026
13 checks passed
@Bugs5382
Bugs5382 deleted the feat/186-est-server branch September 10, 2026 18:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature (feat). Minor version bump.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(est): serve EST for clients that require certificate-based re-enrolment

1 participant