Trust a private CA¶
A service behind an internal certificate authority, or a test server with a self-signed
certificate, is not in the host's trust store. WithCertPool supplies the roots to verify
against while keeping the hardened TLS settings — cipher suites, TLS 1.2 floor and curve
preferences — intact.
The important detail before any of the recipes: a pool replaces the system trust store, it does not add to it.
Trust a private CA alongside the public roots¶
This is almost always what you want. Seed the pool from the system store, then add your CA:
import (
"crypto/x509"
"errors"
"os"
"gitlab.com/phpboyscout/go/httpclient"
)
pool, err := x509.SystemCertPool()
if err != nil {
return err
}
pem, err := os.ReadFile("/etc/pki/internal-ca.pem")
if err != nil {
return err
}
if !pool.AppendCertsFromPEM(pem) {
return errors.New("no certificates found in /etc/pki/internal-ca.pem")
}
client := httpclient.NewClient(httpclient.WithCertPool(pool))
SystemCertPool returns a copy, so adding to it affects only this client. On Unix systems
other than macOS, SSL_CERT_FILE and SSL_CERT_DIR still override where those system
roots are read from.
Trust only a private CA¶
For a client that talks to nothing but internal services, an exclusive pool is a tighter
posture — a public CA mis-issuing a certificate for your internal hostname will not be
accepted. gtls.CertPool from go/tls reads the PEM files
and fails if one contains no certificates:
import gtls "gitlab.com/phpboyscout/go/tls"
pool, err := gtls.CertPool("/etc/pki/internal-ca.pem")
if err != nil {
return err
}
client := httpclient.NewClient(httpclient.WithCertPool(pool))
Use this client only for the hosts that CA issues for. Every other HTTPS destination will fail, which is the next section.
Why public HTTPS calls fail after you add a pool¶
A client that worked, given a pool, starts failing with:
Get "https://api.github.com/": tls: failed to verify certificate:
x509: certificate signed by unknown authority
RootCAs is the complete set of roots the client will verify against. Setting it to a pool
containing one internal CA means exactly one CA is trusted, and every publicly-trusted
certificate is now unknown. Nothing warns about this; the internal calls keep working while
the external ones stop.
Two ways out: seed the pool from x509.SystemCertPool() as above, or keep two clients —
one hardened client with the private pool for internal calls and a plain
httpclient.NewClient() for public ones. Two clients is often clearer, and each keeps its
own connection pool to the hosts it actually talks to.
Note also that gtls.CertPool() called with no file arguments returns an empty pool.
A client given that pool trusts nothing at all and fails every HTTPS request.
Trust a self-signed certificate in a test¶
httptest's TLS server exposes its certificate, so a test can trust it explicitly rather
than disabling verification:
srv := httptest.NewTLSServer(handler)
defer srv.Close()
pool := x509.NewCertPool()
pool.AddCert(srv.Certificate())
client := httpclient.NewClient(httpclient.WithCertPool(pool))
Reach for this rather than WithTLSConfig(&tls.Config{InsecureSkipVerify: true}). Skipping
verification turns off hostname and chain checking for every request the client makes, and
tends to escape from the test that introduced it.
Keep the pool when you also change the TLS config¶
WithCertPool writes RootCAs into whichever TLS config is current, so a later
WithTLSConfig replaces it and the pool is silently lost:
// WRONG: the pool is discarded by the later option
httpclient.NewClient(
httpclient.WithCertPool(pool),
httpclient.WithTLSConfig(myConfig),
)
Put WithCertPool last, or set RootCAs on the config you pass. Be aware that applying
WithCertPool after WithTLSConfig assigns RootCAs on the very *tls.Config value you
supplied, so a config shared with another client picks up the pool as well; pass a fresh
config per client.