This application downloads all intermediate CA certificates for a given SSL server certificate. It can help you fix the incomplete certificate chain issue, also reported as Extra download by Qualys SSL Server Test.
See Releases for prebuilt binaries or build it yourself.
NOTE: In case of any troubles with Go you can try the deprecated shell script in shell branch.
NAME:
cert-chain-resolver - SSL certificate chain resolver
USAGE:
cert-chain-resolver [global options] [INPUT_FILE]
VERSION:
1.1.0
GLOBAL OPTIONS:
--output OUTPUT_FILE, -o OUTPUT_FILE output to OUTPUT_FILE (default: stdout)
--intermediate-only, -i output intermediate certificates only
--der, -d output DER format
--include-system, -s include root CA in output, for inspection: a server should not send it
--include-cross-signed, -x include cross-signed CA certificate in output, for clients with an older trust store
--help, -h show help
--version, -v print the version
--include-system and --include-cross-signed read the trust store published by the Common CA Database, the record shared by Mozilla, Google, Apple and Microsoft. The reports are downloaded on first use and cached for 7 days under your user cache directory, in cert-chain-resolver/ccadb. If a later download fails while a cached copy exists, the cached copy is used and a notice is written to standard error.
Being listed in these reports is not by itself a statement that a CA is trusted for TLS. They also carry roots the root program keeps for S/MIME only, and roots it distrusts for TLS after a given date. Several widely used anchors are in that group, AAA Certificate Services and GlobalSign Root CA among them. Dropping them would turn --include-system into a silent no-op for the CAs that need it most, so --include-system appends whichever root actually issued the top of your chain and warns on standard error when the root program no longer trusts it for TLS:
$ cert-chain-resolver -s -o domain.bundle.pem domain.pem
Warning: the root program no longer trusts AAA Certificate Services for TLS.
Earlier versions read --include-system from the operating system trust store. That only ever worked on Linux: on macOS and Windows Go delegates to the platform verifier, which applies a TLS server policy and rejects a CA certificate, and x509.SystemCertPool returns an empty pool there.
The chain is written only once every certificate in it is shown to have been signed by the next one, and no certificate appears twice. A chain that fails this is a chain this program assembled wrong, so it is an error: nothing is written and the exit status is non-zero.
With --include-system or --include-cross-signed, where the trust store is loaded anyway, the chain is also checked to reach a root of that store, and which root that is is reported. Reaching none is a warning rather than an error, because a private CA is a good reason for it.
Certificates read from the CCADB reports are checked against the SHA-256 fingerprint the report declares for them, and skipped when the two disagree. Both values come from the same file, so this catches a report that was damaged or built wrong, not one that was rewritten wholesale.
The certificate you pass in is never validated: resolving the chain of an expired certificate is a normal thing to want, and its validity is yours to judge, not this program's.
$ cert-chain-resolver -o domain.bundle.pem domain.pem
1: *.xxx.com (certificate)
2: COMODO RSA Domain Validation Secure Server CA (intermediate)
3: COMODO RSA Certification Authority (intermediate)
Certificate chain complete.
Total 3 certificate(s) found.
--include-cross-signed and --include-system together show the intermediates, the cross-signed CA and the root in one run. A cross-signed CA carries the subject of the root, so the two are told apart by the role in the listing rather than by name:
$ cert-chain-resolver -x -s -o domain.bundle.pem domain.pem
Cross-signed CA certificate included, issued by GlobalSign Root CA.
1: www.xxx.com (certificate)
2: GlobalSign GCC R3 EV TLS CA 2025 (intermediate)
3: GlobalSign (cross-signed CA)
4: GlobalSign Root CA (root CA)
Certificate chain complete.
Total 4 certificate(s) found.
Note which root that is. --include-system appends the issuer of the last certificate of the chain, so after --include-cross-signed it appends the older root the cross-signed CA leads to, not the current self-signed one. Use --include-system on its own to see the current root.
What you deploy is --include-cross-signed on its own. The root is a trust anchor: a client that trusts it already has it, and one that doesn't won't start trusting it because you sent it, so all it does is add bytes to every handshake. --include-system is there to show you what the chain anchors on, not to build the bundle the server sends.
Dependencies:
- Go >= 1.26
go mod download
go build
go test ./...
tests/run.sh
All operating systems contain a set of default trusted root certificates. But CAs usually don't use their root certificate to sign customer certificates. Instead of they use so called intermediate certificates, because they can be rotated more frequently.
A certificate can contain a special Authority Information Access extension (RFC-3280) with URL to issuer's certificate. Most browsers can use the AIA extension to download missing intermediate certificate to complete the certificate chain. This is the exact meaning of the Extra download message. But some clients (mobile browsers, OpenSSL) don't support this extension, so they report such certificate as untrusted.
A server should always send a complete chain, which means concatenated all certificates from the certificate to the trusted root certificate (exclusive, in this order), to prevent such issues. Note, the trusted root certificate should not be there, as it is already included in the system’s root certificate store.
You should be able to fetch intermediate certificates from the issuer and concat them together by yourself, this script helps you automatize it by looping over certificate's AIA extension field.
AIA URLs are plain HTTP, so every certificate fetched is checked to have actually signed the one that pointed at it. Without that check anyone on the network path could substitute a certificate of their choosing into the bundle.
When a CA introduces a new root, clients whose trust store predates it can't build a path to it. To bridge that gap, CAs publish a cross-signed variant of the new root: same subject and same public key, but signed by an older root that the outdated clients already trust. Serving it as the last certificate of the chain keeps those clients working, while current clients stop at the new root they already have and ignore it.
The AIA extension never points at the cross-signed variant. GlobalSign, for example, publishes GlobalSign Root CA - R3 at the AIA URL of its intermediates in its self-signed form, while www.globalsign.com serves the variant cross-signed by the older GlobalSign Root CA (R1). --include-cross-signed therefore looks the variant up in the CCADB trust store. It takes the issuer of the last certificate of the chain and accepts a certificate with that subject that
- is not self-signed,
- has a key that verifies the signature on the chain,
- is still valid,
- has a path length constraint wide enough for the certificates already in the chain — a third of the cross-signed CA certificates published in CCADB carry
pathlen:0, which cannot sit above a chain that has an intermediate, - is not already in the chain.
When a CA publishes more than one, the one anchored on the oldest root wins. The longer a root has been in circulation, the more outdated trust stores contain it, which is the whole point. GlobalSign Root E46 shows why: it is cross-signed by three roots, from 1998, 2009 and 2012, and the 2012 one is both the longest lived and an ECC root that the oldest clients can't use at all. Picking by remaining validity would take exactly the wrong one. Candidates whose issuer is not in the trust store come last, and any remaining tie goes to the longest lived.
The issuer of the certificate it picked is reported on standard error, since which root a given client holds is not something the tool can know:
$ cert-chain-resolver -x -o domain.bundle.pem domain.pem
Cross-signed CA certificate included, issued by GlobalSign Root CA.
The MIT License (MIT). See LICENCE file for more information. TL;DR
If you use my code in some interesting project, I'd be happy to know about it.
