Skip to content

fix(certificate): refuse to share a certname, and require an explicit one - #578

Merged
slauger merged 2 commits into
developfrom
fix/certname-uniqueness
Sep 3, 2026
Merged

slauger merged 2 commits into
developfrom
fix/certname-uniqueness

Conversation

@slauger

@slauger slauger commented Sep 3, 2026 •

Copy link
Copy Markdown
Owner

You were right on both counts: this should not be possible in the first place, and puppet was 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 submitCSR and fetchSignedCert address the CA by certname. With two Certificates racing, the second gets already 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. handleCertificateCleanup revokes 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.

  1. Webhook rejects a duplicate at creation, naming the holder.
  2. Controller refuses to sign and reports CertnameConflict with phase Error. Permanently: retrying cannot free a name. Modelled on the Pool hostname conflict in fix(controller): make status, metrics and hostname conflicts honest #564.
  3. Key verification compares the returned certificate's public key against the private key it was requested for, so a foreign certificate is never written to a Secret.

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

certname is now required with MinLength=1 and has no default.

Beyond the collision, the default was 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, a service address -- belong in dnsAltNames, which the chart already derives from the Pool Services a server joins since #561.

Nothing in the repository relied on the default:

  • certificates.yaml writes the value unconditionally; database.yaml already fails the render without one
  • all sixteen CI value files set it explicitly, and so does the sample
  • the same fail guard now covers server entries, the one path that could still fall through

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. Worth a line in the #540 release notes.

About the changed tests

TestSignCertificate_FullFlow now 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 lint and 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.

@slauger slauger changed the title fix(certificate): refuse to share a certname with another Certificate fix(certificate): refuse to share a certname, and require an explicit one Sep 3, 2026
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
slauger force-pushed the fix/certname-uniqueness branch from 27cb500 to db21eb3 Compare September 3, 2026 17:08
@slauger
slauger merged commit 883781d into develop Sep 3, 2026
50 of 51 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant