fix(fips): apply SSH algorithm policy per FIPS backend
Stacked on !594 (merged) — targets fix-fips-ssh-empty-cipher-name, retarget to master once !594 (merged) merges. Review !594 (merged) first; the diff here is only the second commit.
This addresses both items listed as out of scope in !594 (merged).
Two defects, one root cause
The package applied a single hand-maintained algorithm policy to two backends that behave very differently.
1. DefaultAlgorithms() filtered only KeyExchanges. A FIPS build offered ChaCha20-Poly1305, HMAC-SHA1 and HMAC-SHA1-96 — all enabled by ssh.Config.SetDefaults, none FIPS approved. SupportedAlgorithms() filtered them correctly, so the two functions disagreed. HMAC-SHA1-96 and DH-group14-SHA1 weren't in the exclusion lists at all, so wiring the filters up isn't sufficient on its own.
2. ML-KEM768/X25519 was stripped under the native module — which Go approves in FIPS mode. x/crypto registers it ahead of its fips140 branch in kex.go specifically so it survives. FIPS deployments were losing post-quantum key exchange:
x/crypto approved : [mlkem768x25519-sha256 ecdh-p256 ecdh-p384 ecdh-p521]
this package : [ ecdh-p256 ecdh-p384 ecdh-p521]Fix: policy per backend
Only one backend needs a policy from us at all.
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 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 we defer rather than layering a second policy on top. The approved set depends on the module selected by GOFIPS140 and, per ssh/doc.go, "may change across Go versions" — a parallel list cannot track that.
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 applies no policy at all, so the nonFIPS* lists are the only enforcement. Every field of DefaultAlgorithms() is now filtered, and HMAC-SHA1-96 and DH-group14-SHA1 are added to the lists.
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. negotiableAlgorithms therefore derives the effective set by round-tripping candidates through SetDefaults, which filters by the same map membership the handshake uses.
Result
Key exchange sets now differ by backend, as they should:
native module : [mlkem768x25519-sha256 ecdh-p256 ecdh-p384 ecdh-p521]
boringcrypto : [ecdh-p256 ecdh-p384 ecdh-p521 dh-group14-sha256]Ciphers and MACs are identical on both and no longer leak ChaCha20 or SHA-1.
Tests
Assertions holding for both backends stay shared; the two points of legitimate divergence are asserted per backend. Two tests pin the reasoning rather than the values:
TestDefersToNativeModulePolicy— this package matches the module's approved set exactly.TestRejectsForcedNonApprovedAlgorithms— pins the property the deferral depends on. Ifx/cryptoever stopped dropping explicitly-configured non-approved algorithms, deferring would silently stop enforcing anything.
Both CI paths are already covered: test-fips (GOEXPERIMENT=boringcrypto) exercises the list-based branch, test-fips-native (GOFIPS140) the deferring branch.
Verified locally: all six of {none, GOEXPERIMENT=boringcrypto, GOFIPS140=v1.0.0} × {no tag, -tags fips} build and pass with -race, plus scripts/test.sh (both modules), gofmt, golangci-lint (0 issues).
Local
boringcryptoruns are darwin/arm64 on upstream Go, soboring.Enabled()is false throughout — CI on the golang-fips image is authoritative for that arm.
Relationship to v2
The same policy model is in the v2 port, !595 (merged), which was written against this analysis. This brings v1 to parity so FIPS deployments on the current release don't have to wait for v2 adoption.
Related: #129 (closed) · Epic: &22761