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.