feat(v2/fips): add FIPS 140-3 posture detection and SSH algorithm sets

Ports the fips package to v2, carrying the native FIPS 140-3 module fix from !582 (merged) rather than reproducing the bug it fixed. No v2 FIPS package existed before this.

Layout

Two packages, so services that only need to report their posture don't take on a golang.org/x/crypto dependency:

Package Contents
v2/fips Detection only. Stdlib imports only.
v2/fips/ssh FIPS-compliant SSH algorithm sets.

API changes from v1

  • ActiveBackend() returns BackendNone / BackendBoringCrypto / BackendNativeGo, so callers can log, label a metric, or branch on which module is active — not just whether one is.
  • Enabled() is derived from ActiveBackend() instead of probing a second time, so the two can't disagree. v1 needs a test to pin that contract; here it's structural.
  • LogStatus(*slog.Logger) replaces v1's Check(), which logged through the labkit global logger that v2 doesn't have. It distinguishes "compiled with FIPS support but no backend active" from "no FIPS support compiled in", and falls back to slog.Default so it's safe to call before a logger is wired up.

As in v1, the crypto/boring probe is isolated behind the boringcrypto build tag: crypto/boring has no buildable files on a toolchain shipping the native module, so importing it unconditionally breaks every -tags fips build there.

SSH policy depends on the backend

This is the substantive difference from v1, and the reason the port isn't a copy.

Native module (GOFIPS140, crypto/fips140.Enabled() true) — x/crypto/ssh enforces its own approved set. Non-approved ciphers, MACs and key exchanges are never registered in its internal algorithm maps, and ssh.Config.SetDefaults — called unconditionally by NewServerConn and NewClientConn — drops anything absent from them. A caller cannot negotiate a non-approved algorithm even by configuring it explicitly:

forced.Ciphers      = [chacha20-poly1305, aes128-ctr]        → [aes128-ctr]
forced.MACs         = [hmac-sha1, hmac-sha1-96, hmac-sha2-256] → [hmac-sha2-256]
forced.KeyExchanges = [curve25519, dh-group14-sha1, ecdh-p256] → [ecdh-p256]

So this package defers to x/crypto rather than layering a second hand-maintained policy on top.

BoringCrypto (golang-fips) — crypto/fips140.Enabled() is false, because the OpenSSL backend is invisible to crypto/fips140. This is the same fact !582 (merged) rests on. x/crypto/ssh applies no policy whatsoever, so this package's exclusion lists are the only thing preventing a FIPS build from negotiating ChaCha20-Poly1305, HMAC-SHA1 or X25519.

Why deferring is a correctness requirement

x/crypto approves the ML-KEM768/X25519 hybrid key exchange in FIPS mode and registers it ahead of its FIPS branch specifically so it survives. Applying v1's list would filter it out as "contains X25519":

x/crypto approved : [mlkem768x25519-sha256  ecdh-p256  ecdh-p384  ecdh-p521]
v1 policy applied : [                       ecdh-p256  ecdh-p384  ecdh-p521]

That drops post-quantum key exchange from exactly the deployments we're migrating to. The approved set also depends on the module selected by GOFIPS140 and, per ssh/doc.go, "may change across Go versions" — a parallel list cannot track that.

One subtlety: ssh.SupportedAlgorithms() is not itself reduced in FIPS mode. It's a catalogue of what the package implements, not the active policy, and still lists hmac-sha1, curve25519 and the DH groups. The effective set is therefore derived by round-tripping candidates through SetDefaults, which filters by the same map membership the handshake uses.

Resulting key exchange sets, as they should differ:

native module : [mlkem768x25519-sha256  ecdh-p256  ecdh-p384  ecdh-p521]
boringcrypto  : [ecdh-p256  ecdh-p384  ecdh-p521  dh-group14-sha256]

Two tests pin the reasoning rather than the values: TestDefersToNativeModulePolicy asserts LabKit matches the module's set exactly, and TestRejectsForcedNonApprovedAlgorithms pins the property the deferral depends on — if x/crypto ever stopped dropping explicitly-configured non-approved algorithms, deferring would silently stop enforcing anything.

Both packages also drop empty algorithm names in every configuration, working around the x/crypto backing-array bug fixed for v1 in !594 (merged).

The same backend-split policy is applied to v1 in !598 (merged) (stacked on !594 (merged)), so FIPS deployments on the current release get it without waiting for v2 adoption.

CI

No change needed. scripts/test.sh runs both modules with $BUILD_TAGS, so test-fips (GOEXPERIMENT=boringcrypto) and test-fips-native (GOFIPS140=v1.0.0) each exercise one backend path.

Verified locally: all six combinations of {none, GOEXPERIMENT=boringcrypto, GOFIPS140=v1.0.0} × {no tag, -tags fips} pass with -race, plus the full CI matrix (7 compile configs, 3 test configs, both FIPS jobs), gofmt, golangci-lint (0 issues both modules), go-mod-tidy (clean).

Caveat: local boringcrypto runs are darwin/arm64 on upstream Go, so boring.Enabled() is false throughout — the build path and boringBuilt test arm are exercised, a live OpenSSL backend is not. CI on the golang-fips image is authoritative there.

golang.org/x/crypto becomes a direct dependency of the v2 module, used only by v2/fips/ssh.

Related: #129 (closed) · Epic: &22761

🤖 Generated with Claude Code

Edited by Jason Plum

Merge request reports

Loading
Loading