Skip to content

caddypki: Add support for multiple intermediates in signing chain#7057

Merged
mholt merged 6 commits intocaddyserver:masterfrom
hslatman:multiple-intermediates
Dec 3, 2025
Merged

caddypki: Add support for multiple intermediates in signing chain#7057
mholt merged 6 commits intocaddyserver:masterfrom
hslatman:multiple-intermediates

Conversation

@hslatman
Copy link
Contributor

@hslatman hslatman commented Jun 9, 2025

This PR adds support for loading multiple intermediate CA certificates from a PEM file. This is useful in case the internal issuer is configured to issue from a signing chain that includes more than a single intermediate.

A summary of changes:

  • The intermediate file can contain multiple certificates. It is expected that the actual signer is the first certificate, the intermediate that signed the signer is next, etc. Currently no further verification of the chain is performed, but it could be made more strict in the future.
  • The root file is still expected to have a single certificate. If there are more, they are currently dropped without an error. This could be changed to be more strict.
  • When loading a key pair from disk, the certificate and the signer are now compared to verify they have the same public key. This is a new check, but it shouldn't break existing deployments; just catching this case earlier.

@mholt
Copy link
Member

mholt commented Aug 21, 2025

Super sorry for my late reply to this one... and this is totally on me but if you have a chance do you want to just tidy up the merge conflict in go.mod real quick?

The change LGTM, and I trust your competency on this one 😄 -- though I haven't tested it myself. I don't see any obvious issues!

Once the merge conflict is resolved we can merge. 👍

@DonovanDMC
Copy link

I'm currently running into the exact issue of being unable to properly trust only my CA where the site certificate is 4 certificates deep (ca -> int -> int -> site), this would be great to get merged so I can serve the full chain

@hslatman hslatman force-pushed the multiple-intermediates branch from 5c0f3fc to 302e749 Compare November 4, 2025 23:15
@hslatman hslatman force-pushed the multiple-intermediates branch 2 times, most recently from 6946961 to d557431 Compare November 5, 2025 00:50
@francislavoie
Copy link
Member

Thanks @hslatman ! What's left to do here keeping it as draft?

@hslatman hslatman marked this pull request as ready for review November 5, 2025 23:27
@hslatman
Copy link
Contributor Author

hslatman commented Nov 5, 2025

@francislavoie just a click of the button 🙂

There's room for some more improvements around CA chain validation and additional tests, but imo those can go in later.

Copy link
Member

@mholt mholt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for iterating, and sorry for my slow feedback loop!

This is looking great overall; just a few nits I noticed...

Comment on lines +187 to +190
func (ca CA) IntermediateCertificate() *x509.Certificate {
ca.mu.RLock()
defer ca.mu.RUnlock()
return ca.inter
return ca.interChain[0]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should remove this tbh?

Comment on lines +47 to +48
func pemDecodeCertificateChain(pemDER []byte) ([]*x509.Certificate, error) {
chain, err := pemutil.ParseCertificateBundle(pemDER)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather either copy in the code of pemutil.ParseCertificateBundle() or remove this wrapper func entirely

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd still prefer this, but it's not a showstopper for now. Will await your reply in case you are OK with inlining the logic here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind, missed an above comment.

storage certmagic.Storage
root *x509.Certificate
interChain []*x509.Certificate
interKey any // TODO: should we just store these as crypto.Signer?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're here, curious what your thoughts are on using crypto.Signer -- do you typically use that interface, or any? I feel like I saw only any back in the day for private keys, but maybe crypto.Signer is more specific?

Copy link
Contributor Author

@hslatman hslatman Nov 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case it doesn't make a big difference in practice because the private keys are kept in memory after 1) having been generated (and persisted as PEM), or 2) having been read from PEM. The any is just pointing to the underlying *rsa.PrivateKey, *ecdsa.PrivateKey or ed25519.PrivateKey. There's currently no support for an external system to perform the signing (e.g. an HSM or KMS), like we do in step-ca with our kms or cas packages (and I think that's fine, given what Caddy is), which require a more flexible "backend", but strict interface (it would work with any too, basically).

crypto.Signer is more explicit about the intent, and it's already used in many places, so I'll update those usages of any for which it makes sense to be crypto.Signer; there's not many places to do it. In fact, there's currently two locations where an "unsafe" type cast is done to get it to pass as a crypto.Signer to the signing authority, which can be removed after using crypto.Signer everywhere.

That said, any is applicable to public keys, as crypto.PublicKey is an alias for any for backwards compatibility reasons: https://pkg.go.dev/crypto#PublicKey.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh... you know, that would exclude ecdh.PrivateKey -- that is probably fine, since this is intended for DH key exchange... so yeah, if that's okay, and you don't mind while you're at it, let's switch to crypto.Signer :)

Thanks!

In caddyserver#7272 a check was changed to ensure that generated intermediate
certificates would always use a lifetime that falls within the
lifetime of the root. However, when a root and intermediate(s)
are supplied, the configuration value was being used instead of
the actual lifetimes of the certificates. The check was moved to
only be performed when an intermediate is generated; not when
loaded from disk.
@hslatman hslatman force-pushed the multiple-intermediates branch from d557431 to 364e503 Compare December 3, 2025 11:01
@hslatman hslatman requested a review from mholt December 3, 2025 11:09
Copy link
Member

@mholt mholt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks very much! We'll give this a shot.

@mholt mholt merged commit 7ebe72b into caddyserver:master Dec 3, 2025
36 checks passed
@francislavoie francislavoie added this to the v2.11.0 milestone Dec 3, 2025
@francislavoie francislavoie added the feature ⚙️ New feature or request label Dec 3, 2025
@github-actions github-actions bot mentioned this pull request Jan 6, 2026
4 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature ⚙️ New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants