fix(certificate): refuse to share a certname, and require an explicit one - #578
Merged
Merged
Conversation
A certname identifies exactly one entry on the CA, so two Certificates claiming the same one against the same CertificateAuthority are indistinguishable to it. Nothing prevented that: the webhook validated the certname format but not its uniqueness, there was no index on it, and the CRD defaults it to puppet - so two Certificates created without one collide by default rather than by mistake. The consequences were both silent. Signing and fetching both address the CA by certname, so a Certificate could adopt a certificate issued for another resource's key; the mismatch would only surface later as a failed TLS handshake. And handleCertificateCleanup revokes by certname, so deleting either Certificate revokes the entry the other one depends on. Three layers, because webhooks are disabled by default: - the admission webhook rejects a duplicate at creation, naming the holder - the controller refuses to sign and reports CertnameConflict, permanently: retrying cannot free a name, only a spec change can - a certificate returned by the CA is verified against the private key it was requested for, so a foreign certificate under the same name is never written to a Secret A terminating Certificate releases its claim, since its finalizer cleans the CA entry, and the rule is per CA rather than per namespace. The full-flow test now signs the submitted CSR instead of returning a canned certificate. It passed before only because nothing verified the pairing.
The kubebuilder default was the reason the collision was easy to hit: two Certificates created without a certname both landed on puppet, so they shared one CA entry by default rather than by mistake. It was also wrong on its own terms. A certname is an identity, and puppet is only the right identity for the main server - PuppetDB and any further certificate need their own. The names agents connect through, a load balancer or a service address, belong in dnsAltNames, which the chart already derives from the Pool Services a server joins. certname is now required with MinLength=1. Nothing in the repository relied on the default: the chart writes the value unconditionally and already fails the render when the Database has none, all sixteen CI value files set it explicitly, and so does the sample. The same guard now covers server entries, which were the one path that could still fall through to it. Also folds five copies of the puppet fallback in certificate_signing.go into certnameOf. The two remaining checks stay as they are: they return an error rather than substituting a name, which is correct for renewal and cleanup. Existing resources are unaffected - they carry certname: puppet materialised from the default, and the field is immutable anyway. New manifests that omit it are rejected, which is the point.
slauger
force-pushed
the
fix/certname-uniqueness
branch
from
September 3, 2026 17:08
27cb500 to
db21eb3
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
You were right on both counts: this should not be possible in the first place, and
puppetwas the wrong default. Two commits -- the guard, and the removal of the cause.Why it was possible
A certname identifies exactly one entry on the CA, and a real Puppet CA refuses a CSR for a name it has already issued. The operator had no such guarantee: the webhook validated the certname format but not its uniqueness, there was no index on it, and the CRD defaulted it to
puppet-- so two Certificates created without one collided by default rather than by mistake.Two silent consequences
Adoption of a foreign certificate. Both
submitCSRandfetchSignedCertaddress the CA by certname. With two Certificates racing, the second getsalready has a requested certificate, which the code treats as success, and then fetches whatever holds that name. Nothing verified that the returned certificate belonged to the key we generated, so the TLS Secret would pair someone else's certificate with our private key. That fails at a TLS handshake much later, with an error pointing nowhere near here.Revocation of someone else's certificate.
handleCertificateCleanuprevokes by certname, so deleting either Certificate revokes the entry the other one depends on.To be clear about what was not wrong: there is no clean-before-sign anywhere, so the operator never took a certname away during signing.
The guard, in three layers
Webhooks are disabled by default, so admission alone would not hold.
CertnameConflictwith phaseError. Permanently: retrying cannot free a name. Modelled on the Pool hostname conflict in fix(controller): make status, metrics and hostname conflicts honest #564.A terminating Certificate releases its claim, and uniqueness is per CertificateAuthority rather than per namespace, because each CA keeps its own entries.
Removing the cause
certnameis now required withMinLength=1and has no default.Beyond the collision, the default was wrong on its own terms: a certname is an identity, and
puppetis only the right identity for the main server. PuppetDB and any further certificate need their own. The names agents connect through -- a load balancer, a service address -- belong indnsAltNames, which the chart already derives from the Pool Services a server joins since #561.Nothing in the repository relied on the default:
certificates.yamlwrites the value unconditionally;database.yamlalready fails the render without onefailguard now covers server entries, the one path that could still fall throughExisting resources are unaffected: they carry
certname: puppetmaterialised from the default, and the field is immutable anyway. New manifests that omit it are rejected, which is the point. Worth a line in the #540 release notes.About the changed tests
TestSignCertificate_FullFlownow signs the submitted CSR instead of returning a canned certificate. It passed before because nothing verified the pairing. Two envtest cases that relied on the default were rewritten -- one of them now asserts the opposite, that an omitted certname is rejected.Verification
make test(with envtest),make manifests,helm lintand golangci-lint 2.13.2 are clean. The chart guard was verified to fire on a values file without a certname. Documented under "Certname uniqueness" in the Certificate reference.Closes the B1 blocker from the second review pass (2026-09-03), which had no issue of its own.