Skip to content

Why the package is shaped this way

The threat model covers the cryptographic choices. This page covers the structural ones: why the package refuses to load config, why the merge takes a mask, why bad input errors instead of falling back, and why there is no way to turn any of it off.

Why framework-free is worth the inconvenience

The package works only from typed Pair values. It cannot read a file, an environment variable or a flag, and it has no opinion about where your settings came from. That is more work at every call site than a tls.FromConfig(cfg) would be.

The reason is the dependency graph. This code sits at the bottom of every service — HTTP, gRPC, gateways, clients — and anything it imports is imported by all of them. A config framework drags in a file-format library, a flag library and their transitive tails; an observability hook drags in OpenTelemetry. Each is fine on its own and ruinous as a mandatory tax on a package whose whole job is to return a struct.

So the only non-standard-library import is github.com/cockroachdb/errors, and the rule is enforced rather than remembered. depfootprint_test.go runs go list -deps ./... and fails the build if the graph contains go-tool-base, viper, pflag, cobra, charmbracelet, OpenTelemetry, or the AWS, Google Cloud and Azure SDKs.

The payoff is that adopting this package is a decision about TLS and nothing else. A project already committed to a different config stack, or to no framework at all, is not asked to change anything.

Why the merge takes an explicit override mask

ResolvePair(shared, transport, overrides) needs a boolean per field, which looks like ceremony next to "just merge the non-zero fields".

Non-zero merging cannot express enabled: false. Once a config file has been unmarshalled into a struct, a section that says enabled: false and a section that omits enabled are the same value — and they mean opposite things. Silently preferring the shared true would ignore an explicit instruction to turn TLS off for one listener, which is exactly the kind of surprise a TLS layer must not produce.

The alternative is to ask a config library which keys were present, which means importing one and coupling to its notion of "set". The mask pushes that question back to the caller, who already knows the answer: whichever framework you use can tell you whether a key was present, whether a flag was changed, or whether an environment variable existed, and you express it as five booleans.

ResolvePair stays pure as a result — no I/O, no error return, trivially testable, and it works the same whether the values came from YAML, flags or a literal in a test.

Why an unknown mode is an error rather than a fallback

client_auth: "require_verify" — an underscore instead of a hyphen — fails the build with a message listing the three valid values, rather than being coerced or ignored.

Every fallback available is worse than the error. Falling back to no client-certificate auth turns a typo into a silently unauthenticated listener, which is the single worst outcome for this field. Falling back to require-verify turns a typo into a listener that rejects every client, which is at least loud but arrives at the first connection instead of at startup. Ignoring the field entirely means config that says one thing and does another.

The same reasoning explains why require-verify and verify-if-given refuse to build without client_cas: a verifying mode with nothing to verify against is not a weaker policy, it is an incoherent one.

ApplyTo validates the mode even when the caller's own client-certificate policy takes precedence and the pair's would have been discarded. Validating only the values that end up being used would mean a typo goes unreported on exactly the configs where someone was being careful.

Why the caller's policy wins in ApplyTo

ApplyTo merges a pair into a config the caller built. Where both have an opinion about client certificates, the caller's survives: the pair's ClientCAs/ClientAuth are applied only onto a config whose ClientAuth is NoClientCert and whose ClientCAs is nil.

The function exists for callers doing something the Pair shape cannot express — a custom VerifyPeerCertificate, a certificate chosen per handshake, an application-specific ClientCAs pool assembled at runtime. Overwriting those with values from a config file would defeat the reason for reaching for ApplyTo at all, and it would do so silently.

The cost is a merge whose result depends on what was already in the config, which is harder to reason about than "the pair wins". Where you want the pair to be authoritative, do not use ApplyTo — use ServerConfig, which starts from a fresh DefaultConfig and has nothing to defer to.

Why there is no insecure switch

There is no InsecureSkipVerify field on Pair, no min_version key, no allow_weak_ciphers escape hatch. Not because those situations do not arise — a staging environment with a self-signed certificate and no CA distribution is a real place to be — but because of where the switch would live.

A configuration key is reachable by anyone who can edit config: an environment variable in a deployment manifest, a value copied from a local .yaml into production, a default someone flipped while debugging. It is also invisible in code review, because the code that reads it looks identical either way.

Building a deliberately weak crypto/tls.Config at one call site is not invisible. It appears in the diff, it names the endpoint it applies to, and it does not travel. The package can be used alongside such a config — nothing here is exclusive — and the insecure path stays explicit and local, which is the only property that matters.

For self-signed certificates specifically there is a better answer than skipping verification: pass the CA to ClientConfig and keep verification on.

Why the package name collides with the standard library

gitlab.com/phpboyscout/go/tls declares package tls, so importing it alongside crypto/tls forces one of them to be aliased. The convention in this repository is to alias the standard library — cryptotls "crypto/tls" — because the local package is the one being called more often in the file.

Naming it phpboyscouttls or tlsutil would avoid the collision at the cost of reading badly at every call site forever. One alias per file, in the few files that need both, is the cheaper trade.