Transport servers: honour a supplied server TLS config so mTLS is reachable on the managed path¶
- Authors
- Matt Cockayne, Claude Fable 5 (AI drafting assistant)
- Date
- 2026-07-23
- Status
- DRAFT — pending review
- Related
- architectural review (HIGH finding, transport section), TLS module extraction (where gtls.Pair landed), transport server stack extraction
1. Problem¶
transporthttp.WithServerTLSConfig (go/transport/http/server.go:154-158) is
applied in newServer (lines 186-189, falling back to gtls.DefaultConfig())
— and then silently discarded on every managed path:
- TLS enabled:
start(server.go:256-263) unconditionally replacessrv.TLSConfigwith a freshtlsPair.ServerConfig()— a baregtls.DefaultConfig()plus the pair's certificate (go/tls/tls.go:50-64). The caller's config is clobbered. - TLS disabled:
startusessrv.Serve(line 287), so the custom config is never consulted at all.
The gRPC server is the same shape with no option at all: wrapTLS
(go/transport/grpc/server.go:274-281) always builds pair.ServerConfig("h2");
there is no server-TLS-config hook on the managed path.
Neither gtls.Pair (Enabled/Cert/Key, go/tls/config.go) nor any
ServerOption can express ClientCAs/ClientAuth. Server mTLS is therefore
unreachable via the managed path.
Failure scenario. A caller configures client-cert auth:
transporthttp.Register(..., tlsPair,
transporthttp.WithServerTLSConfig(&tls.Config{
ClientCAs: pool,
ClientAuth: tls.RequireAndVerifyClientCert,
}),
transporthttp.WithAuthMiddleware(transporthttp.WithMTLSVerifier(verifier)),
)
The server starts and serves without client-cert verification.
r.TLS.VerifiedChains is always empty, so AuthMiddleware's mTLS branch
(go/transport/http/auth.go:203-204) can never match: every request 401s — or,
worse, is admitted by another configured verifier while the operator believes
mTLS is active. WithGRPCMTLSVerifier (go/transport/grpc/auth.go:48-50) is
equally dead on the managed gRPC path. The failure is silent: nothing logs or
errors when the supplied config is discarded.
2. Proposed change¶
Two alternatives were considered; both are presented, with (b) recommended and (a) folded in as its mechanical core.
(a) Merge instead of replace. In start/wrapTLS, when the caller supplied
a server TLS config, load the pair's certificate into that config
(cfg.Certificates = append(...) via a new gtls.Pair helper, e.g.
ApplyTo(cfg *tls.Config, nextProtos ...string) error) instead of building a
fresh one; only fall back to pair.ServerConfig() when no config was supplied.
The gRPC server gains the missing WithServerTLSConfig-equivalent
ServerOption. This makes the existing option truthful for programmatic
callers, but mTLS remains unreachable from config — GTB tools drive these
servers from gtls.Pair resolved out of the config tree.
(b) Extend gtls.Pair with client-CA fields (recommended), plus (a). Add to
Pair:
ClientCAs []string `mapstructure:"client_cas" ...` // PEM CA files; empty = no client-cert auth
ClientAuth string `mapstructure:"client_auth" ...` // "", "request", "require-verify"
ServerConfig builds the pool via the existing gtls.CertPool and sets
ClientCAs/ClientAuth accordingly (unknown ClientAuth values are a hard
error — fail closed). Because both managed transports already funnel through
Pair.ServerConfig, this single change makes mTLS reachable on HTTP and
gRPC, config-driven end to end, with no per-transport plumbing. (a) is still
implemented so a hand-built config with custom verification
(VerifyPeerCertificate, session tickets, etc.) is honoured rather than
clobbered; when both a custom config and Pair client-CA fields are set,
error on construction rather than guessing precedence.
At minimum — if (b) is rejected — the WithServerTLSConfig doc comment must
state that the managed path overrides it, and Register must error on the
conflicting combination (tlsPair.Enabled && sc.tlsConfig != nil) instead of
silently discarding a security-relevant setting.
3. Scope & release plan¶
go/tls— extendPairwithClientCAs/ClientAuth+ApplyTohelper;PairOverrides/MergePairgain the new fields. Additivefeat(tls)minor.go/transport— honour supplied configs intransporthttpstartandtransportgrpcwrapTLS; add the gRPC server-TLS-config option; bumpgo/tls.fix(http,grpc)release after go/tls.go/transit— not involved (client-side middleware only).- GTB —
pkg/tlsconfig adapter exposes the new keys (*.tls.client_cas,*.tls.client_auth) through the existing section-adapter pattern;pkg/http/pkg/grpc/pkg/gatewaypick the rest up via dependency bumps. Docs:docs/components/TLS/server pages document the mTLS wiring end to end (Pair fields → verifier middleware).
4. Acceptance criteria¶
- mTLS end-to-end handshake test (HTTP): managed
transporthttp.Registerserver withPair{ClientCAs, ClientAuth: require-verify}; a client presenting a valid cert from that CA completes the handshake, the request reaches the handler with non-emptyr.TLS.VerifiedChains, andWithMTLSVerifierauthenticates it; a client with no cert (and one with a cert from a different CA) fails the handshake. This test is impossible against the current code. - mTLS end-to-end handshake test (gRPC): same shape through the managed
gRPC path;
WithGRPCMTLSVerifiersees verified chains. - Clobber regression test:
WithServerTLSConfigcarrying a distinctive marker (e.g.VerifyPeerCertificatehook or a customMinVersion) is observably in effect on the managed TLS path — the hook fires / the handshake reflects the setting. - Conflict handling: custom server config and Pair client-CA fields set →
construction error, not silent precedence; invalid
ClientAuthstring → error. Pairzero-value behaviour unchanged (no client-cert auth, existing handshake tests green); ALPN"h2"still advertised on the gRPC listener path with a merged config.- GTB config adapter round-trips the new keys; doctor/E2E smoke unaffected;
coverage ≥90% on touched
go/tlscode.
5. Open questions¶
ClientAuthvocabulary: is two values (request,require-verify) enough, or expose all fourtls.ClientAuthTypenon-zero modes? Fewer, fail-closed options are easier to hold;VerifyClientCertIfGivenhas real use for mixed-auth listeners.- Should
WithServerTLSConfig+tlsPair.Enabledmerge (recommended, viaApplyTo) or error, forcing callers to choose one mechanism? Merging is more useful but creates a precedence story to document. - Does the gateway need its own client-CA surface for its outbound dial to the
gRPC server (it already has
WithDialOptions), or is documenting the existing escape hatch sufficient?