Skip to content

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.