fips
Package fips reports whether the running binary performs cryptographic
operations through a FIPS 140-3 validated module, and which module that is.
Detection is gated on the fips build tag. Without it the package compiles to
constants — ActiveBackend() is BackendNone and Enabled() is false — so
ordinary builds carry no FIPS-related code and no extra dependencies. The
package imports only the standard library.
Quick start
// Log the FIPS posture once during start-up.
fips.LogStatus(a.Logger())
// Branch on it where behaviour has to differ.
if fips.Enabled() {
// ...
}
Backends
Built with -tags fips, two backends are recognised:
| Backend |
Probe |
Active when |
BackendBoringCrypto |
crypto/boring |
Built with the golang-fips toolchain (GOEXPERIMENT=boringcrypto, CGO_ENABLED=1, amd64 Linux), a loadable system OpenSSL, and the backend switched on by either a FIPS kernel or GOLANG_FIPS=1 — see below |
BackendNativeGo |
crypto/fips140 |
Built with GOFIPS140 set to a module version (e.g. v1.0.0), or run with GODEBUG=fips140=on — see Go FIPS 140-3 compliance |
BackendNone |
— |
Neither of the above |
crypto/boring only has buildable files when the boringcrypto build tag is
set, so that probe is isolated behind it (boring.go / notboring.go). This
keeps -tags fips usable on a toolchain that ships the native Go
Cryptographic Module instead of the golang-fips fork; importing crypto/boring
unconditionally fails to compile there.
BackendBoringCrypto takes precedence: when the golang-fips toolchain routes
the crypto packages through OpenSSL, OpenSSL is the validated module doing the
work regardless of what crypto/fips140 reports.
Enabled() is derived from ActiveBackend() rather than probing a second
time, so the two cannot disagree.
Logging
LogStatus records the posture at info level under the fips_backend field.
It distinguishes four states, including "compiled with FIPS support but no
backend activated" — a misconfigured FIPS deployment, which is very different
from a build that never had FIPS support:
{"level":"INFO","msg":"FIPS mode is enabled, using the native Go Cryptographic Module","fips_backend":"native-go"}
A nil logger falls back to slog.Default(), so LogStatus is safe to call
before a logger is wired up.
If you log the backend yourself, use the same fips_backend key so the field
stays queryable across services:
logger.Info("crypto backend", "fips_backend", fips.ActiveBackend())
SSH algorithms
FIPS-compliant SSH algorithm sets live in the fips/sshalgo subpackage,
which depends on golang.org/x/crypto. It is separate so that services which
only need to report their posture do not take on that dependency.
import (
"gitlab.com/gitlab-org/labkit/v2/fips/sshalgo"
"golang.org/x/crypto/ssh"
)
config := &ssh.ServerConfig{}
algorithms := sshalgo.DefaultAlgorithms()
config.Ciphers = algorithms.Ciphers
config.MACs = algorithms.MACs
config.KeyExchanges = algorithms.KeyExchanges
config.PublicKeyAuthAlgorithms = algorithms.PublicKeyAuths
signer, err := sshalgo.HostKeySigner(hostKey)
if err != nil {
return err
}
config.AddHostKey(signer)
ssh.NewServerConn calls SetDefaults() itself, so there is no need to call it
here — and doing so before assigning these fields would have no effect anyway.
Assigning PublicKeyAuthAlgorithms and wrapping each host key are required,
not optional. ssh.ServerConfig has no field that can express either policy on
its own:
SetDefaults() never populates PublicKeyAuthAlgorithms. Leave it empty and
NewServerConn fills it from a default set containing ssh-rsa and
ssh-dss.
- A server ignores
Config.HostKeyAlgorithms entirely, deriving what it
advertises from the keys it was given. An RSA key yields rsa-sha2-256,
rsa-sha2-512 and ssh-rsa.
Skip either and a FIPS server offers SHA-1 signatures.
Without the tag both functions return golang.org/x/crypto/ssh's own sets
unchanged and HostKeySigner returns its argument, so callers can wire all of
this in unconditionally and let the build tag decide the policy.
Policy
Algorithms fall into three tiers, because there are three different reasons to
withhold one. COMPLIANCE.md maps every algorithm to
the governing NIST document, and records which primitives run inside a validated
boundary on each backend.
| Tier |
Offered |
Contents |
| 1 |
Never |
ChaCha20-Poly1305; X25519 and its alias; security-key public key variants |
| 2 |
Native module only |
ML-KEM768/X25519 |
| 3 |
By default, removed by WithoutDeprecated() |
HMAC-SHA1, HMAC-SHA1-96, DH-group14-SHA1, and finite-field DH |
// Hardened: no SHA-1, and every key exchange inside a validated boundary.
algorithms := sshalgo.DefaultAlgorithms(sshalgo.WithoutDeprecated())
Tier 3 is the compatibility tier. Everything in it is still FIPS approved —
HMAC-SHA-1 via SP 800-140C Rev. 2, 96-bit truncation via SP 800-107 Rev. 1
§5.3.4, SHA-1 in the SSH KDF via SP 800-135 Rev. 1 §5.2, and the DH groups as
safe primes from SP 800-56A Rev. 3 Appendix D. SP 800-131A deprecates the SHA-1
constructions only until 2030-12-31. Withholding them is a hardening choice, so
the default keeps older clients working; pass the option when the deployment can
require modern clients.
Why the ML-KEM hybrid depends on the backend
Upstream Go approves the ML-KEM768/X25519 hybrid under fips140=on, and
x/crypto/ssh registers it ahead of its own FIPS branch so it survives. LabKit
agrees, on the native module: ML-KEM-768 supplies an approved shared secret, so
the X25519 contribution is permissible additional keying material under
SP 800-227 §4.6.2.
It is withheld on BoringCrypto for an operational reason, not a compliance one.
The golang-fips toolchain patches crypto/ecdh to refuse X25519 whenever
OpenSSL is active (golang-fips/go#316), so the hybrid cannot complete a
handshake there. x/crypto/ssh implements no Curve25519-free hybrid, so
BoringCrypto deployments have no post-quantum SSH key exchange available.
Standalone X25519 stays in Tier 1 on every backend: the FIPS 140-3
Implementation Guidance excludes curves in SP 800-186 but not SP 800-56Arev3
from key agreement, naming ECDH X25519 — see !216.
What the native module adds
Under GOFIPS140 the result is narrowed further to what x/crypto/ssh can
actually negotiate. Non-approved algorithms are never registered in its internal
maps, and SetDefaults() — called unconditionally by NewServerConn and
NewClientConn — drops anything absent from them, so a caller cannot negotiate
one even by configuring it explicitly. Deriving that narrowing rather than
hand-maintaining a second list keeps LabKit in step with the linked module,
whose contents depend on GOFIPS140 and may change across Go versions.
Finite-field Diffie-Hellman is the visible case — the native module does not
implement it, so WithoutDeprecated() is a no-op there:
native module : [mlkem768x25519-sha256 ecdh-p256 ecdh-p384 ecdh-p521]
boringcrypto : [ecdh-p256 ecdh-p384 ecdh-p521 dh-group14-sha256 dh-group14-sha1]
HostKeys is always left empty: those algorithms are derived from the host keys
presented at handshake time, which is what HostKeySigner constrains.
Both functions also drop empty algorithm names, in every build configuration.
This works around a bug in golang.org/x/crypto/ssh (present through v0.54.0)
where defaultCiphers aliases the supportedCiphers backing array, so the
in-place slices.DeleteFunc it runs under fips140.Enabled() leaves a zeroed
tail behind — ssh.SupportedAlgorithms().Ciphers ends in "" on any
GOFIPS140 build. See filter.go.
Testing
./scripts/test.sh runs both modules with $BUILD_TAGS, so the test-fips
(golang-fips image, GOEXPERIMENT=boringcrypto) and test-fips-native
(upstream image, GOFIPS140=v1.0.0) CI jobs both exercise this package. Two
jobs are needed because cmd/go rejects GOFIPS140 combined with
GOEXPERIMENT=boringcrypto.
test-fips also sets GOLANG_FIPS=1. Without it boring.Enabled() reports
false, because a CI container has no /proc/sys/crypto/fips_enabled, and the job
would compile the BoringCrypto path without ever activating it.
A FIPS kernel is not the only switch, and not the usual one. GOLANG_FIPS=1
switches the backend on by itself. GitLab's CNG FIPS images set it from the
gitlab-base entrypoint, keyed on /etc/system-fips, so the backend is active in
those images with no FIPS kernel involved. A consumer that does not run those
images has to set GOLANG_FIPS=1 or run on a FIPS kernel; otherwise the binary
carries the BoringCrypto path without using it, and LogStatus reports that
state.
The SSH tests negotiate a real handshake and assert on
ssh.NegotiatedAlgorithms, because the algorithm sets alone cannot show what a
server offers — see COMPLIANCE.md for the four
configurations that matter and how to reproduce them locally in containers.