Skip to content

What this package does not do

A short list of things people reasonably expect from something called tls and will not find here. Each one is deliberate: the package is TLS plumbing, not a TLS platform.

If you are looking for a feature and it is on this page, the answer is no — build it in your own code, or reach for a different module.

It does not issue, renew or rotate certificates

There is no certificate authority, no ACME client, no CSR generation, no key generation. Pair holds two file paths and the package reads them. How those files arrive — cert-manager, Let's Encrypt, an internal CA, a secret mounted by your orchestrator — is entirely yours, as is protecting the key on disk.

For local development certificates, gitlab.com/phpboyscout/go/localca is the sibling module that mints them.

It does not reload a certificate that changed on disk

Pair.Certificate reads the files at the moment you call it, and ServerConfig calls it once. A renewed certificate written over the same path is not picked up: the running listener keeps serving the material it loaded at startup until the process restarts.

Nothing stops you doing it yourself. crypto/tls.Config.GetCertificate is called per handshake, so a config that wires it to a cached, periodically-reloaded certificate rotates without a restart — build that config yourself and merge the rest of the pair in with ApplyTo.

ClientConfig cannot present a client certificate

ClientConfig sets RootCAs and nothing else. It has no parameter for a client certificate, so it cannot on its own be one half of a mutual-TLS connection.

The pieces are all here, just not joined up — load the certificate with Pair.Certificate and assign it:

cfg, err := tls.ClientConfig("/etc/certs/ca.pem")
cert, err := clientPair.Certificate()
cfg.Certificates = []cryptotls.Certificate{cert}

Worked through end to end in Require a client certificate.

Private-CA trust replaces the system roots

CertPool starts from an empty x509.NewCertPool(), never x509.SystemCertPool(). So ClientConfig("/etc/certs/ca.pem") produces a client that trusts that one CA and nothing else — every public certificate on the internet now fails verification for that client.

For a client that only ever talks to your own services, that is the right, tight answer. For one that also calls a public API, it is a bug that surfaces as x509: certificate signed by unknown authority against a perfectly valid public certificate.

There is no option to combine the two. Build the pool yourself when you need both:

pool, err := x509.SystemCertPool()
pem, err := os.ReadFile("/etc/certs/ca.pem")
pool.AppendCertsFromPEM(pem)

cfg := tls.DefaultConfig()
cfg.RootCAs = pool

x509.SystemCertPool returns a copy of the system pool, so adding to it affects nothing else in the process. It also snapshots: roots added to the machine after the call are not picked up.

ApplyTo does not harden the config you pass it

ApplyTo adds a certificate, ALPN protocols and possibly a client-CA policy. It does not touch MinVersion, CipherSuites or CurvePreferences. Merge a pair into &cryptotls.Config{MinVersion: cryptotls.VersionTLS10} and you get a TLS 1.0 listener with a certificate in it.

The hardening lives in DefaultConfig, and ServerConfig is exactly DefaultConfig() + ApplyTo. If you are building a config by hand and want the posture too, start from DefaultConfig() and modify that.

ApplyTo is also not idempotent — calling it twice with the same pair appends the certificate twice.

Enabled is advisory, and nothing enforces it

Pair.Enabled is read by exactly one function, Pair.Valid. ServerConfig, ApplyTo and Certificate all build and load happily with enabled: false.

If a false in config is meant to mean "serve plaintext", you have to branch on it before you build the config. A service that calls ServerConfig unconditionally will serve TLS regardless of what the flag says.

One certificate per config, and no SNI selection

ServerConfig loads a single certificate. There is no GetCertificate hook, no per-hostname certificate map, and no helper for a listener that fronts several domains with different certificates. ApplyTo appends to cfg.Certificates, so a multi-certificate config is buildable by hand — crypto/tls picks by SNI — but the package gives you no support for assembling one.

No revocation checking

No OCSP, no OCSP stapling, no CRL fetching, no VerifyPeerCertificate hook wired up to any of it. A client certificate that chains to a CA in client_cas is accepted even if that certificate was revoked this morning. This matches the crypto/tls default, which also does no revocation checking; the package does not improve on it.

Short-lived certificates are the usual answer. Real revocation is your own VerifyPeerCertificate on a config you build.

The TLS posture is fixed, not configurable

There is no key, flag or option for the minimum version, the cipher suites or the curves. Pair cannot express them and DefaultConfig takes no arguments. Questions of the form "how do I require TLS 1.3" or "how do I add a cipher suite" have the same answer: take the returned config and set the field.

cfg, err := pair.ServerConfig("h2")
cfg.MinVersion = cryptotls.VersionTLS13 // TLS 1.3 only

Nothing prevents that, and nothing in the package undoes it afterwards. What the package refuses to give you is a configuration key that lowers the posture — see Why there is no insecure switch.

The one place the fixed posture is weaker than Go's own default is key exchange: setting CurvePreferences opts out of the post-quantum X25519MLKEM768 hybrid. See Key exchange is weaker than Go's own default.

The package does not load configuration

No file parsing, no environment variables, no flags, no PHPBOYSCOUT_-style prefix. Pair carries mapstructure, yaml and json tags so your loader can fill it in, and that is the whole integration story. ResolvePair merges already-materialised values and takes an explicit mask rather than asking a config library which keys were set.

That is what "framework-free" means here, and why it is worth the inconvenience.

It is not a transport

No listener, no dialer, no http.Server, no gRPC credentials, no connection pooling, no timeouts. The package hands you a *crypto/tls.Config and stops. Wiring it into http.Server.TLSConfig, http.Transport.TLSClientConfig or credentials.NewTLS is a line of your code, deliberately.