feat(est): serve RFC 7030 so clients can renew with the certificate they hold - #188
Conversation
…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
62d4321 to
9d3d292
Compare
Closing summaryLanded EST as What is in it
Behaviour a reviewer may want to confirm against the code
Verification
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 Two CI failures were mine and are fixed here rather than worked around:
Deferred, deliberately
|
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/estserves/cacerts,/simpleenroll,/simplereenrolland/csrattrs. Likeinternal/acmeit is a wire-format adapter holding no key: the CA chain, issuance and the revocation check all arrive as closures, and issuance routes throughnode.CASigner.IssueLeafForNamessoca.Profilestill 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:
simplereenrolltakes 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.simpleenrolltakes 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 needsallow_any_identifierspelled 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.gobuilds that one fixed envelope withencoding/asn1, about ninety lines, rather than putting a third-party PKCS#7 parser next to the CA.internal/ca/doc.goallows 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:
/cacertsexists 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),/csrattrsbeyond an empty 204, and the deferred-enrolment 202 Retry-After flow. Issuance here is synchronous or it fails.Follow-up needed
pki.est, likepki.acme, is not carried in the protoMachineConfig, so it survives a staged YAML boot but not anApplyConfigfrom a manager. Both need the sameCryptOS-PKI/apichange; noted in the field's doc comment and indocs/est.md.Verification
How this was verified
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.
TestCertsOnlyPKCS7ReadableByOpenSSLwrites the message and runsopenssl 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, becauseencoding/asn1silently ignores anexplicitstruct tag on aRawValueand 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
sameNameSetcheck inhandleSimpleReenrollmakes three subtests ofTestSimpleReenrollCannotChangeNamesfail 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.IssueLeafForNamesand touches the same config and boot wiring. Nothing else is queued behind it.