Skip to content

Run mutual TLS with a private CA

Use a Private CA when a service of yours has to recognise the machines that connect to it. The service runs the CA: it serves its own TLS from the CA's root, signs a client certificate for each machine from a CSR the machine sends, and hands each machine the root so the machine can check it is talking to the right service.

A Private CA is never installed in a trust store, and it is the only kind that signs CSRs. Why that matters is in the trust model.

Create the CA

Give it a data directory of its own. NewPrivate refuses one that an Authority already uses.

ca, err := localca.NewPrivate(localca.Config{
    DataDir: "/var/lib/collector/ca",
    AppName: "collector",
})

The root is minted on first use and kept in privateCA.pem and privateCA-key.pem. Guard the key: it signs every machine's certificate.

Serve TLS that requires a client certificate

root, err := ca.Root(ctx)
leaf, err := ca.Leaf(ctx, "collector.internal", "10.0.0.5")

tlsCfg := &tls.Config{
    Certificates: []tls.Certificate{*leaf},
    ClientCAs:    root.Pool(),
    ClientAuth:   tls.RequireAndVerifyClientCert,
    MinVersion:   tls.VersionTLS13,
}

Leaf is not cached. Call it at start, and again before its LeafTTL runs out.

Sign a machine's CSR

Decide first, outside localca, that the machine may enrol, for example with a one-time code. Then sign its CSR, naming the machine you expect:

cert, err := ca.SignClient(ctx, csrDER, localca.ClientRequest{Name: "relay-1"})
if errors.Is(err, localca.ErrRequestRefused) {
    // The CSR or the name was wrong. Nothing was signed.
}

The CSR must be DER, signed by the machine's key, with a subject of exactly CN=relay-1 and nothing else: no SANs, no requested extensions, and no attributes such as a challenge password. The key may be ECDSA P-256 or P-384, Ed25519, or RSA of 2048 bits or more. SignClient refuses anything else with ErrRequestRefused, and the error says which rule was broken.

The certificate lasts Config.ClientTTL (30 days by default). To choose per machine, set ClientRequest.TTL, which may be from one hour up to Config.MaxClientTTL (90 days by default).

Hand the machine its certificate and the root

Send the machine cert.PEM and root.PEM. It builds its client config from them:

pool := x509.NewCertPool()
pool.AppendCertsFromPEM(rootPEM)

clientCfg := &tls.Config{
    Certificates: []tls.Certificate{machineCert}, // cert.PEM with the machine's own key
    RootCAs:      pool,
    ServerName:   "collector.internal",
    MinVersion:   tls.VersionTLS13,
}

If the machine got the root over a channel it cannot fully trust, give it root.Fingerprint ahead of time, in the enrolment code for example, and have it check sha256.Sum256 of the root's DER against that before using it.

Pin what you issued

localca keeps no record of the certificates it signs, and there is no revocation. Record cert.Fingerprint against the machine when you sign, and in the server accept a client only if its certificate's SHA-256 is the one on record:

tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
    if !recorded(sha256.Sum256(rawCerts[0])) {
        return errors.New("certificate not on record")
    }

    return nil
}

Renew

A machine renews by sending a new CSR, for the same key or a new one, over its current connection. Sign it the same way and replace the recorded fingerprint, so the old certificate stops being accepted at once.

To renew the root itself, stop the service and delete privateCA.pem and privateCA-key.pem. The next call mints a new root, and every machine must enrol again, because each one trusts the old root. The WARN logged as the root nears expiry says the same.