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()returnsBackendNone/BackendBoringCrypto/BackendNativeGo, so callers can log, label a metric, or branch on which module is active — not just whether one is.Enabled()is derived fromActiveBackend()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'sCheck(), 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 toslog.Defaultso 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
boringcryptoruns are darwin/arm64 on upstream Go, soboring.Enabled()is false throughout — the build path andboringBuilttest 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