From 9c5d7016489cb194db1a8bf27edc2145ac71ab32 Mon Sep 17 00:00:00 2001 From: Wade Simmons Date: Fri, 21 Aug 2026 19:33:40 -0400 Subject: [PATCH] Support native Golang "fips140" mode (#1696) Add support for the "fips140" mode of Go: - https://go.dev/doc/security/fips140 - https://csrc.nist.gov/projects/cryptographic-module-validation-program/certificate/5247 You can build with `make fips140`, see the README changes for more info. Some differences from the boringcrypto builds: - We switch to using `go:linkname crypto/tls.aeadAESGCMTLS13`, which gives us the fips implementation for both `boringcrypto` and `fips140` modes. This means we also no longer need `-checklinkname=0` - Go native `fips140` doesn't need CGO_ENABLED=1 - We decide if we should use the fips140 GCM at runtime, if `fips140.Enabled()` is true. If you use the `make release-fips140`, we build with build tag `fips140enforce` which ensures the binary is running with fips140 enabled and that only P256 / AES-GCM is being used. If you don't want this enforce mode, you can build without the build tag. --- .github/workflows/smoke.yml | 40 ++---- .github/workflows/test.yml | 11 +- Makefile | 101 +++++++++++--- README.md | 14 +- cmd/nebula-cert/ca.go | 14 +- cmd/nebula-cert/fips140enforce.go | 5 + cmd/nebula-cert/keygen.go | 7 +- cmd/nebula-cert/sign.go | 5 + cmd/nebula-service/fips140enforce.go | 5 + cmd/nebula/fips140enforce.go | 5 + interface.go | 4 + noiseutil/boring.go | 69 +--------- noiseutil/boring_test.go | 32 ----- noiseutil/cipher_state.go | 5 +- noiseutil/cipher_state_test.go | 15 +- noiseutil/fips140.go | 197 +++++++++++++++++++++++++++ noiseutil/fips140_test.go | 48 +++++++ noiseutil/fips140enforce.go | 13 ++ noiseutil/notboring.go | 19 ++- noiseutil/notboring_test.go | 14 -- pki.go | 25 +++- pki_test.go | 94 +++++++++++++ stats.go | 10 +- 23 files changed, 566 insertions(+), 186 deletions(-) create mode 100644 cmd/nebula-cert/fips140enforce.go create mode 100644 cmd/nebula-service/fips140enforce.go create mode 100644 cmd/nebula/fips140enforce.go create mode 100644 noiseutil/fips140.go create mode 100644 noiseutil/fips140_test.go create mode 100644 noiseutil/fips140enforce.go delete mode 100644 noiseutil/notboring_test.go create mode 100644 pki_test.go diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index aa4a41e8..99d9bfe2 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -25,39 +25,19 @@ jobs: go-version: '1.26' check-latest: true - - name: build - run: make bin-docker CGO_ENABLED=1 BUILD_ARGS=-race + - name: Smoke Docker + run: make smoke-docker - - name: setup docker image - working-directory: ./.github/workflows/smoke - run: ./build.sh + - name: Smoke Docker IPv6 overlay + run: make smoke-docker-ipv6 - - name: run smoke - working-directory: ./.github/workflows/smoke - run: ./smoke.sh + - name: Smoke Relay Docker + run: make smoke-relay-docker - - name: setup docker image ipv6 - working-directory: ./.github/workflows/smoke - run: SMOKE_OVERLAY_IPV6=1 ./build.sh + - name: Smoke Docker boringcrypto + run: make boringcrypto smoke-docker - - name: run smoke ipv6 - working-directory: ./.github/workflows/smoke - run: SMOKE_OVERLAY_IPV6=1 ./smoke.sh - - - name: setup relay docker image - working-directory: ./.github/workflows/smoke - run: ./build-relay.sh - - - name: run smoke relay - working-directory: ./.github/workflows/smoke - run: ./smoke-relay.sh - - - name: setup docker image for P256 - working-directory: ./.github/workflows/smoke - run: NAME="smoke-p256" CURVE=P256 ./build.sh - - - name: run smoke-p256 - working-directory: ./.github/workflows/smoke - run: NAME="smoke-p256" ./smoke.sh + - name: Smoke Docker fips140 + run: make fips140-all GOALS=smoke-docker timeout-minutes: 10 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 443279d1..6bde71e8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,9 +58,14 @@ jobs: e2e-cmd: make e2evv - name: linux-boringcrypto os: ubuntu-latest - build-cmd: make bin-boringcrypto - test-cmd: make test-boringcrypto - e2e-cmd: make e2e GOEXPERIMENT=boringcrypto CGO_ENABLED=1 TEST_ENV="TEST_LOGS=1" TEST_FLAGS="-v -ldflags -checklinkname=0" + build-cmd: make boringcrypto + test-cmd: make boringcrypto test + e2e-cmd: make boringcrypto e2evv + - name: linux-fips140 + os: ubuntu-latest + build-cmd: make fips140-all + test-cmd: make fips140-all GOALS=test + e2e-cmd: make fips140-all GOALS=e2evv - name: linux-pkcs11 os: ubuntu-latest build-cmd: make bin-pkcs11 diff --git a/Makefile b/Makefile index d84bc6fa..722d9883 100644 --- a/Makefile +++ b/Makefile @@ -72,6 +72,17 @@ ALL_CROSS_LINUX_ARM = linux-arm-5 linux-arm-6 linux-arm-7 linux-arm64 ALL_CROSS_LINUX_MIPS = linux-mips linux-mipsle linux-mips64 linux-mips64le linux-mips-softfloat ALL_CROSS_LINUX_OTHER = linux-386 linux-ppc64le linux-riscv64 linux-loong64 +# Based on section 2.2 of the Go Cryptographic Module CVMP Security Policy #5247 +ALL_FIPS140 = linux-amd64-fips140 \ + linux-arm64-fips140 \ + windows-amd64-fips140 \ + windows-arm64-fips140 \ + darwin-arm64-fips140 \ + freebsd-amd64-fips140 \ + linux-arm-7-fips140 \ + linux-mips64-fips140 \ + linux-ppc64le-fips140 + e2e: $(TEST_ENV) go test -tags=e2e_testing -count=1 $(TEST_FLAGS) ./e2e @@ -137,6 +148,8 @@ release-netbsd: $(ALL_NETBSD:%=build/nebula-%.tar.gz) release-boringcrypto: build/nebula-linux-$(shell go env GOARCH)-boringcrypto.tar.gz +release-fips140: $(ALL_FIPS140:%=build/nebula-%.tar.gz) + BUILD_ARGS += -trimpath bin-windows: build/windows-amd64/nebula.exe build/windows-amd64/nebula-cert.exe @@ -157,6 +170,9 @@ bin-freebsd-arm64: build/freebsd-arm64/nebula build/freebsd-arm64/nebula-cert bin-boringcrypto: build/linux-$(shell go env GOARCH)-boringcrypto/nebula build/linux-$(shell go env GOARCH)-boringcrypto/nebula-cert mv $? . +bin-fips140: build/linux-$(shell go env GOARCH)-fips140/nebula build/linux-$(shell go env GOARCH)-fips140/nebula-cert + mv $? . + bin-pkcs11: BUILD_ARGS += -tags pkcs11 bin-pkcs11: CGO_ENABLED = 1 bin-pkcs11: bin @@ -166,12 +182,12 @@ debug: BUILD_ARGS += -tags debug debug: bin bin: - go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula${NEBULA_CMD_SUFFIX} ${NEBULA_CMD_PATH} - go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula-cert${NEBULA_CMD_SUFFIX} ./cmd/nebula-cert + $(GOENV) go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula${NEBULA_CMD_SUFFIX} ${NEBULA_CMD_PATH} + $(GOENV) go build $(BUILD_ARGS) -ldflags "$(LDFLAGS)" -o ./nebula-cert${NEBULA_CMD_SUFFIX} ./cmd/nebula-cert install: - go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ${NEBULA_CMD_PATH} - go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ./cmd/nebula-cert + $(GOENV) go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ${NEBULA_CMD_PATH} + $(GOENV) go install $(BUILD_ARGS) -ldflags "$(LDFLAGS)" ./cmd/nebula-cert build/linux-arm-%: GOENV += GOARM=$(word 3, $(subst -, ,$*)) build/linux-mips-%: GOENV += GOMIPS=$(word 3, $(subst -, ,$*)) @@ -182,8 +198,11 @@ build/linux-mips-softfloat/%: LDFLAGS += -s -w # boringcrypto build/linux-amd64-boringcrypto/%: GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1 build/linux-arm64-boringcrypto/%: GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1 -build/linux-amd64-boringcrypto/%: LDFLAGS += -checklinkname=0 -build/linux-arm64-boringcrypto/%: LDFLAGS += -checklinkname=0 + +# fips140 +FIPSVERSION = v1.0.0 +$(foreach _rule, $(ALL_FIPS140), build/$(_rule)/%): GOENV += GOFIPS140=$(FIPSVERSION) +$(foreach _rule, $(ALL_FIPS140), build/$(_rule)/%): BUILD_ARGS += -tags fips140enforce build/%/nebula: .FORCE GOOS=$(firstword $(subst -, , $*)) \ @@ -214,10 +233,7 @@ vet: go vet $(VET_FLAGS) -v ./... test: - go test -v ./... - -test-boringcrypto: - GOEXPERIMENT=boringcrypto CGO_ENABLED=1 go test -ldflags "-checklinkname=0" -v ./... + $(TEST_ENV) go test $(TEST_FLAGS) -v ./... test-pkcs11: CGO_ENABLED=1 go test -v -tags pkcs11 ./... @@ -260,29 +276,72 @@ ifeq ($(words $(MAKECMDGOALS)),1) @$(MAKE) service ${.DEFAULT_GOAL} --no-print-directory endif +# Useful to chain together, like: +# - make fips140 e2evv +# - make fips140 smoke-docker +# Use `release-fips140` to build release binaries +fips140: + @echo > $(NULL_FILE) +ifeq ($(strip $(GOFIPS140)),) + $(eval GOFIPS140 = $(FIPSVERSION)) +endif + $(eval GOENV += GOFIPS140=$(GOFIPS140)) + $(eval BUILD_ARGS += -tags fips140enforce) + $(eval TEST_ENV += $(GOENV)) + $(eval CURVE = P256) +ifeq ($(words $(MAKECMDGOALS)),1) + @$(MAKE) fips140 GOFIPS140=$(GOFIPS140) ${.DEFAULT_GOAL} --no-print-directory +endif + +# To test the future pending module, use like `make fips140-latest test` +ALL_GOFIPS140 = v1.0.0 v1.26.0 latest +define FIPS140_rule +fips140-$(1): GOFIPS140 = $(1) +fips140-$(1): fips140 +endef +$(foreach _rule, $(ALL_GOFIPS140), $(eval $(call FIPS140_rule,$(_rule)))) + +# Iterate and run the goals for all fips versions, like `make fips140-all GOALS=test` +fips140-all: + @$(foreach _v,$(ALL_GOFIPS140),$(MAKE) fips140-$(_v) $(GOALS) &&) true + +# Useful to chain together, like: +# - make boringcrypto e2evv +# - make boringcrypto smoke-docker +# Use `release-boringcrypto` or `bin-boringcrypto` to build release binaries +boringcrypto: + @echo > $(NULL_FILE) + $(eval GOENV += GOEXPERIMENT=boringcrypto CGO_ENABLED=1) + $(eval TEST_ENV += $(GOENV)) + $(eval CURVE = P256) +ifeq ($(words $(MAKECMDGOALS)),1) + @$(MAKE) boringcrypto ${.DEFAULT_GOAL} --no-print-directory +endif + bin-docker: bin build/linux-amd64/nebula build/linux-amd64/nebula-cert +smoke-docker: BUILD_ARGS += -race +smoke-docker: GOENV += CGO_ENABLED=1 smoke-docker: bin-docker - cd .github/workflows/smoke/ && ./build.sh - cd .github/workflows/smoke/ && ./smoke.sh - cd .github/workflows/smoke/ && NAME="smoke-p256" CURVE="P256" ./build.sh - cd .github/workflows/smoke/ && NAME="smoke-p256" ./smoke.sh + # This is so we can limit `fips140` smoke test to just P256 curve. + if [ "$(CURVE)" != "P256" ]; then cd .github/workflows/smoke/ && $(GOENV) ./build.sh; fi + if [ "$(CURVE)" != "P256" ]; then cd .github/workflows/smoke/ && $(GOENV) ./smoke.sh; fi + cd .github/workflows/smoke/ && $(GOENV) NAME="smoke-p256" CURVE="P256" ./build.sh + cd .github/workflows/smoke/ && $(GOENV) NAME="smoke-p256" ./smoke.sh +smoke-relay-docker: BUILD_ARGS += -race +smoke-relay-docker: GOENV += CGO_ENABLED=1 smoke-relay-docker: bin-docker - cd .github/workflows/smoke/ && ./build-relay.sh - cd .github/workflows/smoke/ && ./smoke-relay.sh + cd .github/workflows/smoke/ && $(GOENV) ./build-relay.sh + cd .github/workflows/smoke/ && $(GOENV) ./smoke-relay.sh smoke-docker-ipv6: export SMOKE_OVERLAY_IPV6 = 1 smoke-docker-ipv6: smoke-docker -smoke-docker-race: BUILD_ARGS = -race -smoke-docker-race: CGO_ENABLED = 1 -smoke-docker-race: smoke-docker - smoke-vagrant/%: bin-docker build/%/nebula cd .github/workflows/smoke/ && ./build.sh $* cd .github/workflows/smoke/ && ./smoke-vagrant.sh $* .FORCE: -.PHONY: all all-linux all-freebsd all-openbsd all-netbsd all-darwin all-windows all-cross-linux all-cross-linux-arm all-cross-linux-mips all-cross-linux-other all-cross-darwin all-cross-windows bench bench-cpu bench-cpu-long bin debug build-test-mobile e2e e2ev e2evv e2evvv e2evvvv proto release service smoke-docker smoke-docker-race test test-cov-html smoke-vagrant/% +.PHONY: all all-linux all-freebsd all-openbsd all-netbsd all-darwin all-windows all-cross-linux all-cross-linux-arm all-cross-linux-mips all-cross-linux-other all-cross-darwin all-cross-windows bench bench-cpu bench-cpu-long bin bin-windows bin-windows-arm64 bin-darwin bin-freebsd bin-freebsd-arm64 bin-boringcrypto bin-fips140 bin-pkcs11 bin-docker boringcrypto build-test-mobile debug docker e2e e2ev e2evv e2evvv e2evvvv e2e-bench fips140 fips140-all $(ALL_GOFIPS140:%=fips140-%) install proto release release-linux release-freebsd release-openbsd release-netbsd release-boringcrypto release-fips140 service smoke-docker smoke-relay-docker smoke-docker-ipv6 test test-pkcs11 test-cov-html vet smoke-vagrant/% .DEFAULT_GOAL := bin diff --git a/README.md b/README.md index 7cbcb412..158338e1 100644 --- a/README.md +++ b/README.md @@ -145,17 +145,27 @@ To build nebula for a specific platform (ex, Windows): See the [Makefile](Makefile) for more details on build targets -## Curve P256 and BoringCrypto +## Curve P256 and FIPS 140-3 mode The default curve used for cryptographic handshakes and signatures is Curve25519. This is the recommended setting for most users. If your deployment has certain compliance requirements, you have the option of creating your CA using `nebula-cert ca -curve P256` to use NIST Curve P256. The CA will then sign certificates using ECDSA P256, and any hosts using these certificates will use P256 for ECDH handshakes. -In addition, Nebula can be built using the [BoringCrypto GOEXPERIMENT](https://github.com/golang/go/blob/go1.20/src/crypto/internal/boring/README.md) by running either of the following make targets: +Nebula can be built to support the [FIPS 140-3](https://go.dev/doc/security/fips140) mode of Go by running either of the following make targets. (This sets GOFIPS140=v1.0.0, which must be done at compile time so that the correct AES-GCM can be used for FIPS 140-3 enforcement mode). + +```sh +make fips140 +make fips140 test +make release-fips140 +``` + +Nebula can also be built using the [BoringCrypto GOEXPERIMENT](https://github.com/golang/go/blob/go1.20/src/crypto/internal/boring/README.md) by running either of the following make targets. ```sh make bin-boringcrypto make release-boringcrypto ``` +NOTE: boringcrypto support is deprecated and will be removed in the next release. Users should migrate to the native FIPS 140-3 mode described above. + This is not the recommended default deployment, but may be useful based on your compliance requirements. ## Credits diff --git a/cmd/nebula-cert/ca.go b/cmd/nebula-cert/ca.go index d73c7c42..0ec82d71 100644 --- a/cmd/nebula-cert/ca.go +++ b/cmd/nebula-cert/ca.go @@ -3,7 +3,9 @@ package main import ( "crypto/ecdsa" "crypto/elliptic" + "crypto/fips140" "crypto/rand" + "errors" "flag" "fmt" "io" @@ -44,6 +46,13 @@ type caFlags struct { subnets *string } +func defaultCurve() string { + if fips140.Enforced() { + return "P256" + } + return "25519" +} + func newCaFlags() *caFlags { // prevent running out of memory on 32-bit systems by defaulting to // RFC9106's recommendation for memory-constrained environments @@ -74,7 +83,7 @@ func newCaFlags() *caFlags { cf.argonParallelism = cf.set.Uint("argon-parallelism", 4, "Optional: Argon2 parallelism parameter used for encrypted private key passphrase") cf.argonIterations = cf.set.Uint("argon-iterations", defaultArgonIterations, "Optional: Argon2 iterations parameter used for encrypted private key passphrase") cf.encryption = cf.set.Bool("encrypt", false, "Optional: prompt for passphrase and write out-key in an encrypted format") - cf.curve = cf.set.String("curve", "25519", "EdDSA/ECDSA Curve (25519, P256)") + cf.curve = cf.set.String("curve", defaultCurve(), "EdDSA/ECDSA Curve (25519, P256)") cf.p11url = p11Flag(cf.set) cf.ips = cf.set.String("ips", "", "Deprecated, see -networks") @@ -259,6 +268,9 @@ func ca(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) error } else { switch *cf.curve { case "25519", "X25519", "Curve25519", "CURVE25519": + if fips140.Enforced() { + return errors.New("use of Curve25519 is not allowed in FIPS 140-only mode") + } curve = cert.Curve_CURVE25519 pub, rawPriv, err = ed25519.GenerateKey(rand.Reader) if err != nil { diff --git a/cmd/nebula-cert/fips140enforce.go b/cmd/nebula-cert/fips140enforce.go new file mode 100644 index 00000000..3dad1648 --- /dev/null +++ b/cmd/nebula-cert/fips140enforce.go @@ -0,0 +1,5 @@ +//go:build fips140enforce + +//go:debug fips140=only + +package main diff --git a/cmd/nebula-cert/keygen.go b/cmd/nebula-cert/keygen.go index dea6c4af..598ce477 100644 --- a/cmd/nebula-cert/keygen.go +++ b/cmd/nebula-cert/keygen.go @@ -1,6 +1,8 @@ package main import ( + "crypto/fips140" + "errors" "flag" "fmt" "io" @@ -24,7 +26,7 @@ func newKeygenFlags() *keygenFlags { cf.set.Usage = func() {} cf.outPubPath = cf.set.String("out-pub", "", "Required: path to write the public key to") cf.outKeyPath = cf.set.String("out-key", "", "Required: path to write the private key to") - cf.curve = cf.set.String("curve", "25519", "ECDH Curve (25519, P256)") + cf.curve = cf.set.String("curve", defaultCurve(), "ECDH Curve (25519, P256)") cf.p11url = p11Flag(cf.set) return &cf } @@ -61,6 +63,9 @@ func keygen(args []string, out io.Writer, errOut io.Writer) error { } else { switch *cf.curve { case "25519", "X25519", "Curve25519", "CURVE25519": + if fips140.Enforced() { + return errors.New("use of Curve25519 is not allowed in FIPS 140-only mode") + } pub, rawPriv = x25519Keypair() curve = cert.Curve_CURVE25519 case "P256": diff --git a/cmd/nebula-cert/sign.go b/cmd/nebula-cert/sign.go index 9b57c4fe..27ea051d 100644 --- a/cmd/nebula-cert/sign.go +++ b/cmd/nebula-cert/sign.go @@ -2,6 +2,7 @@ package main import ( "crypto/ecdh" + "crypto/fips140" "crypto/rand" "errors" "flag" @@ -268,6 +269,10 @@ func signCert(args []string, out io.Writer, errOut io.Writer, pr PasswordReader) }(p11Client) } + if fips140.Enforced() && curve == cert.Curve_CURVE25519 { + return errors.New("use of Curve25519 is not allowed in FIPS 140-only mode") + } + if *sf.inPubPath != "" { var pubCurve cert.Curve rawPub, err := readInput("in-pub", *sf.inPubPath, &claims) diff --git a/cmd/nebula-service/fips140enforce.go b/cmd/nebula-service/fips140enforce.go new file mode 100644 index 00000000..3dad1648 --- /dev/null +++ b/cmd/nebula-service/fips140enforce.go @@ -0,0 +1,5 @@ +//go:build fips140enforce + +//go:debug fips140=only + +package main diff --git a/cmd/nebula/fips140enforce.go b/cmd/nebula/fips140enforce.go new file mode 100644 index 00000000..3dad1648 --- /dev/null +++ b/cmd/nebula/fips140enforce.go @@ -0,0 +1,5 @@ +//go:build fips140enforce + +//go:debug fips140=only + +package main diff --git a/interface.go b/interface.go index 972b60fa..abec0474 100644 --- a/interface.go +++ b/interface.go @@ -2,6 +2,7 @@ package nebula import ( "context" + "crypto/fips140" "errors" "fmt" "log/slog" @@ -270,6 +271,9 @@ func (f *Interface) activate() error { "build", f.version, "udpAddr", addr, "boringcrypto", boringEnabled(), + "fips140Version", fips140.Version(), + "fips140Enabled", fips140.Enabled(), + "fips140Enforced", fips140.Enforced(), ) if f.routines > 1 && !f.outside.SupportsMultipleReaders() { diff --git a/noiseutil/boring.go b/noiseutil/boring.go index 2129af71..c77fb573 100644 --- a/noiseutil/boring.go +++ b/noiseutil/boring.go @@ -4,77 +4,16 @@ package noiseutil import ( - "crypto/aes" - "crypto/cipher" - "encoding/binary" - - // unsafe needed for go:linkname - _ "unsafe" + "crypto/boring" "github.com/flynn/noise" ) +var CipherAESGCM noise.CipherFunc = CipherAESGCMFIPS140 + // EncryptLockNeeded indicates if calls to Encrypt need a lock // This is true for boringcrypto because the Seal function verifies that the // nonce is strictly increasing. const EncryptLockNeeded = true -// NewGCMTLS is no longer exposed in go1.19+, so we need to link it in -// See: https://github.com/golang/go/issues/56326 -// -// NewGCMTLS is the internal method used with boringcrypto that provides a -// validated mode of AES-GCM which enforces the nonce is strictly -// monotonically increasing. This is the TLS 1.2 specification for nonce -// generation (which also matches the method used by the Noise Protocol) -// -// - https://github.com/golang/go/blob/go1.19/src/crypto/tls/cipher_suites.go#L520-L522 -// - https://github.com/golang/go/blob/go1.19/src/crypto/internal/boring/aes.go#L235-L237 -// - https://github.com/golang/go/blob/go1.19/src/crypto/internal/boring/aes.go#L250 -// - https://github.com/google/boringssl/blob/ae223d6138807a13006342edfeef32e813246b39/include/openssl/aead.h#L379-L381 -// - https://github.com/google/boringssl/blob/ae223d6138807a13006342edfeef32e813246b39/crypto/fipsmodule/cipher/e_aes.c#L1082-L1093 -// -//go:linkname newGCMTLS crypto/internal/boring.NewGCMTLS -func newGCMTLS(c cipher.Block) (cipher.AEAD, error) - -type cipherFn struct { - fn func([32]byte) noise.Cipher - name string -} - -func (c cipherFn) Cipher(k [32]byte) noise.Cipher { return c.fn(k) } -func (c cipherFn) CipherName() string { return c.name } - -// CipherAESGCM is the AES256-GCM AEAD cipher (using NewGCMTLS when GoBoring is present) -var CipherAESGCM noise.CipherFunc = cipherFn{cipherAESGCMBoring, "AESGCM"} - -func cipherAESGCMBoring(k [32]byte) noise.Cipher { - c, err := aes.NewCipher(k[:]) - if err != nil { - panic(err) - } - gcm, err := newGCMTLS(c) - if err != nil { - panic(err) - } - return aeadCipher{ - gcm, - func(n uint64) []byte { - var nonce [12]byte - binary.BigEndian.PutUint64(nonce[4:], n) - return nonce[:] - }, - } -} - -type aeadCipher struct { - cipher.AEAD - nonce func(uint64) []byte -} - -func (c aeadCipher) Encrypt(out []byte, n uint64, ad, plaintext []byte) []byte { - return c.Seal(out, c.nonce(n), plaintext, ad) -} - -func (c aeadCipher) Decrypt(out []byte, n uint64, ad, ciphertext []byte) ([]byte, error) { - return c.Open(out, c.nonce(n), ciphertext, ad) -} +var boringEnabled = boring.Enabled() diff --git a/noiseutil/boring_test.go b/noiseutil/boring_test.go index 8c884392..c15d431a 100644 --- a/noiseutil/boring_test.go +++ b/noiseutil/boring_test.go @@ -4,8 +4,6 @@ package noiseutil import ( - "crypto/boring" - "encoding/hex" "testing" "github.com/stretchr/testify/assert" @@ -14,33 +12,3 @@ import ( func TestEncryptLockNeeded(t *testing.T) { assert.True(t, EncryptLockNeeded) } - -// Ensure NewGCMTLS validates the nonce is non-repeating -func TestNewGCMTLS(t *testing.T) { - assert.True(t, boring.Enabled()) - - // Test Case 16 from GCM Spec: - // - (now dead link): http://csrc.nist.gov/groups/ST/toolkit/BCM/documents/proposedmodes/gcm/gcm-spec.pdf - // - as listed in boringssl tests: https://github.com/google/boringssl/blob/fips-20220613/crypto/cipher_extra/test/cipher_tests.txt#L412-L418 - key, _ := hex.DecodeString("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308") - iv, _ := hex.DecodeString("cafebabefacedbaddecaf888") - plaintext, _ := hex.DecodeString("d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39") - aad, _ := hex.DecodeString("feedfacedeadbeeffeedfacedeadbeefabaddad2") - expected, _ := hex.DecodeString("522dc1f099567d07f47f37a32a84427d643a8cdcbfe5c0c97598a2bd2555d1aa8cb08e48590dbb3da7b08b1056828838c5f61e6393ba7a0abcc9f662") - expectedTag, _ := hex.DecodeString("76fc6ece0f4e1768cddf8853bb2d551b") - - expected = append(expected, expectedTag...) - - var keyArray [32]byte - copy(keyArray[:], key) - c := CipherAESGCM.Cipher(keyArray) - aead := c.(aeadCipher).AEAD - - dst := aead.Seal([]byte{}, iv, plaintext, aad) - assert.Equal(t, expected, dst) - - // We expect this to fail since we are re-encrypting with a repeat IV - assert.PanicsWithError(t, "boringcrypto: EVP_AEAD_CTX_seal failed", func() { - dst = aead.Seal([]byte{}, iv, plaintext, aad) - }) -} diff --git a/noiseutil/cipher_state.go b/noiseutil/cipher_state.go index 789c8ed5..5f10d004 100644 --- a/noiseutil/cipher_state.go +++ b/noiseutil/cipher_state.go @@ -40,8 +40,11 @@ type CipherState interface { // NewCipherState wraps the post-handshake noise.CipherState in the per-cipher type that matches cipherFunc. // cipherFunc must be the same cipher used to build the noise CipherSuite that produced s. func NewCipherState(s *noise.CipherState, cipherFunc noise.CipherFunc) CipherState { + if cs, ok := s.Cipher().(CipherState); ok { + return cs + } switch cipherFunc.CipherName() { - case CipherAESGCM.CipherName(): + case noise.CipherAESGCM.CipherName(): return NewCipherStateAESGCM(s) case noise.CipherChaChaPoly.CipherName(): return NewCipherStateChaChaPoly(s) diff --git a/noiseutil/cipher_state_test.go b/noiseutil/cipher_state_test.go index cb7b2703..1dd361f5 100644 --- a/noiseutil/cipher_state_test.go +++ b/noiseutil/cipher_state_test.go @@ -1,6 +1,7 @@ package noiseutil import ( + "crypto/fips140" "math" "testing" @@ -11,24 +12,30 @@ import ( func TestCipherStateAESGCMRoundtrip(t *testing.T) { enc, dec := buildCipherStates(t, CipherAESGCM) - roundtrip(t, NewCipherStateAESGCM(enc), NewCipherStateAESGCM(dec)) + roundtrip(t, NewCipherState(enc, CipherAESGCM), NewCipherState(dec, CipherAESGCM)) } func TestCipherStateChaChaPolyRoundtrip(t *testing.T) { enc, dec := buildCipherStates(t, noise.CipherChaChaPoly) - roundtrip(t, NewCipherStateChaChaPoly(enc), NewCipherStateChaChaPoly(dec)) + roundtrip(t, NewCipherState(enc, noise.CipherChaChaPoly), NewCipherState(dec, noise.CipherChaChaPoly)) } func TestNewCipherStateDispatch(t *testing.T) { encA, _ := buildCipherStates(t, CipherAESGCM) encC, _ := buildCipherStates(t, noise.CipherChaChaPoly) - assert.IsType(t, &CipherStateAESGCM{}, NewCipherState(encA, CipherAESGCM)) + if !boringEnabled && !fips140.Enabled() { + assert.IsType(t, &CipherStateAESGCM{}, NewCipherState(encA, CipherAESGCM)) + } else { + // fips140 + assert.IsType(t, encA.Cipher(), NewCipherState(encA, CipherAESGCM)) + } + assert.IsType(t, &CipherStateChaChaPoly{}, NewCipherState(encC, noise.CipherChaChaPoly)) } func TestNewCipherStateUnsupportedPanics(t *testing.T) { - enc, _ := buildCipherStates(t, CipherAESGCM) + enc, _ := buildCipherStates(t, noise.CipherChaChaPoly) assert.Panics(t, func() { NewCipherState(enc, fakeCipher{}) }) diff --git a/noiseutil/fips140.go b/noiseutil/fips140.go new file mode 100644 index 00000000..6666f548 --- /dev/null +++ b/noiseutil/fips140.go @@ -0,0 +1,197 @@ +package noiseutil + +import ( + "bytes" + "crypto/cipher" + "crypto/fips140" + "encoding/binary" + "errors" + "fmt" + "reflect" + "runtime" + "unsafe" + + // unsafe needed for go:linkname + _ "crypto/tls" + _ "unsafe" + + "github.com/flynn/noise" +) + +// TODO: Use NewGCMWithCounterNonce or NewGCMForQUIC once available: +// - https://github.com/golang/go/issues/73110 +// - https://github.com/golang/go/issues/79219 +// Using tls.aeadAESGCMTLS13 gives us the TLS 1.3 GCM, which also verifies +// that the nonce is strictly increasing. This works for both boringcrypto +// and fips140. +// +//go:linkname aeadAESGCMTLS13 crypto/tls.aeadAESGCMTLS13 +func aeadAESGCMTLS13(key, noncePrefix []byte) cipher.AEAD + +type cipherFn struct { + fn func([32]byte) noise.Cipher + name string +} + +func (c cipherFn) Cipher(k [32]byte) noise.Cipher { return c.fn(k) } +func (c cipherFn) CipherName() string { return c.name } + +// CipherAESGCMFIPS140 is the AES256-GCM AEAD cipher (using tls.aeadAESGCMTLS13, for both boringcrypto and fips140) +var CipherAESGCMFIPS140 noise.CipherFunc = cipherFn{cipherAESGCMFIPS140, "AESGCM"} + +// tls.aeadAESGCMTLS13 uses a 4 byte static prefix and an 8 byte XOR mask +var emptyNonce = []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} + +func cipherAESGCMFIPS140(k [32]byte) noise.Cipher { + gcm := aeadAESGCMTLS13(k[:], emptyNonce) + gcm = extractFIPSAEAD(gcm) + return &aeadGCMFIPS140Cipher{ + AEAD: gcm, + } +} + +type aeadGCMFIPS140Cipher struct { + cipher.AEAD + ready bool +} + +// Extract the internal FIPS GCM implementation from the tls wrapper. The TLS +// wrapper is not thread safe around Open, so instead of locking around it we +// can grab the internal implementation that is thread safe. This is the FIPS +// module implementation: `crypto/internal/fips140/aes/gcm.GCMWithXORCounterNonce` +// +// - https://github.com/golang/go/blob/go1.26.4/src/crypto/internal/fips140/aes/gcm/gcm_nonces.go#L212-L287 +// +// The wrapper is struct `crypto/tls.xorNonceAEAD` , with field `aead`: +// +// - https://github.com/golang/go/blob/go1.26.4/src/crypto/tls/cipher_suites.go#L482-L487 +// +// This can be cleaned up once these FIPS implementations are exposed directly: +// +// - https://github.com/golang/go/issues/73110 +func extractFIPSAEAD(xorNonceAEAD cipher.AEAD) cipher.AEAD { + r := reflect.ValueOf(xorNonceAEAD) + v := r.Elem().FieldByName("aead") + if !v.IsValid() { + // The internal crypto/tls.xorNonceAEAD struct no longer has an `aead` + // field. This can only happen on a Go version this code was not built + // against; the package init() self-test guards against ever reaching + // this at runtime, so this is a defensive fail-fast. + panic(fmt.Sprintf("noiseutil: could not extract FIPS AEAD from %T on %s: no `aead` field (incompatible Go version)", xorNonceAEAD, runtime.Version())) + } + v2 := reflect.NewAt(v.Type(), unsafe.Pointer(v.UnsafeAddr())).Elem() + aead, ok := v2.Interface().(cipher.AEAD) + if !ok { + panic(fmt.Sprintf("noiseutil: extracted FIPS `aead` field is %s, not a cipher.AEAD, on %s (incompatible Go version)", v2.Type(), runtime.Version())) + } + return aead +} + +func (c *aeadGCMFIPS140Cipher) init(nonce []byte) { + // GCMWithXORCounterNonce expects that the first call to Seal + // is with a counter of `0`, this is how it extracts the nonce mask. + // We can clean this up in the future when NewGCMWithCounterNonce or + // NewGCMForQUIC are available: + if !bytes.Equal(emptyNonce, nonce) { + c.AEAD.Seal([]byte{}, emptyNonce, []byte{}, []byte{}) + } + c.ready = true +} + +func (c *aeadGCMFIPS140Cipher) Seal(dst, nonce, plaintext, additionalData []byte) []byte { + if !c.ready { + c.init(nonce) + } + return c.AEAD.Seal(dst, nonce, plaintext, additionalData) +} + +func (c *aeadGCMFIPS140Cipher) Encrypt(out []byte, n uint64, ad, plaintext []byte) []byte { + return c.Seal(out, aeadGCMFIPS140CipherNonce(n), plaintext, ad) +} + +func (c *aeadGCMFIPS140Cipher) Decrypt(out []byte, n uint64, ad, ciphertext []byte) ([]byte, error) { + return c.Open(out, aeadGCMFIPS140CipherNonce(n), ciphertext, ad) +} + +func (c *aeadGCMFIPS140Cipher) EncryptDanger(out, ad, plaintext []byte, n uint64, nb []byte) ([]byte, error) { + if c == nil { + return nil, errors.New("no cipher state available to encrypt") + } + if n >= RejectAfterMessages { + return nil, ErrMessageCounterExhausted + } + binary.BigEndian.PutUint64(nb[4:], n) + out = c.Seal(out, nb, plaintext, ad) + return out, nil +} + +func (c *aeadGCMFIPS140Cipher) DecryptDanger(out, ad, ciphertext []byte, n uint64, nb []byte) ([]byte, error) { + if c == nil { + return []byte{}, nil + } + binary.BigEndian.PutUint64(nb[4:], n) + return c.Open(out, nb, ciphertext, ad) +} + +func (c *aeadGCMFIPS140Cipher) Overhead() int { + if c == nil { + return 0 + } + return c.AEAD.Overhead() +} + +func aeadGCMFIPS140CipherNonce(n uint64) []byte { + // GCMWithXORCounterNonce uses a 4 byte static prefix and an 8 byte nonce + var nonce [12]byte + binary.BigEndian.PutUint64(nonce[4:], n) + return nonce[:] +} + +func init() { + if boringEnabled || fips140.Enabled() { + initSelfTestAESGCMFIPS140() + } +} + +// validates the go:linkname + reflection extraction and the nonce-reuse +// protection at startup. cipherAESGCMFIPS140 relies on unexported +// crypto/tls and crypto/internal/fips140 internals; if a future Go version changes +// those, this fails fast with a clear message instead of panicking per-handshake +// (or, worse, silently losing the strictly-increasing nonce check that is the whole +// point of using this cipher). +func initSelfTestAESGCMFIPS140() { + var key [32]byte + c := cipherAESGCMFIPS140(key) + + // Verify the extracted AEAD produces a working encrypt/decrypt roundtrip. + plaintext := []byte("nebula fips140 self-test") + ad := []byte("ad") + ct := c.Encrypt(nil, 1, ad, plaintext) + pt, err := c.Decrypt(nil, 1, ad, ct) + if err != nil { + panic(fmt.Sprintf("noiseutil: FIPS AES-GCM self-test roundtrip failed on %s: %v", runtime.Version(), err)) + } + if !bytes.Equal(pt, plaintext) { + panic(fmt.Sprintf("noiseutil: FIPS AES-GCM self-test roundtrip returned wrong plaintext on %s", runtime.Version())) + } + + // Verify the nonce-reuse protection still fires: re-encrypting with the same + // counter must panic. This is the defensive check that FIPS-140 requires, so + // if the extraction ever silently yields an AEAD without it, refuse to start. + if !reusePanics(c) { + panic(fmt.Sprintf("noiseutil: FIPS AES-GCM self-test did not reject a reused nonce on %s; nonce-reuse protection is missing (incompatible Go version)", runtime.Version())) + } +} + +// reusePanics reports whether re-encrypting with an already-used counter panics, +// as GCMWithXORCounterNonce is expected to. +func reusePanics(c noise.Cipher) (panicked bool) { + c.Encrypt(nil, 2, nil, nil) + defer func() { + if recover() != nil { + panicked = true + } + }() + c.Encrypt(nil, 2, nil, nil) + return false +} diff --git a/noiseutil/fips140_test.go b/noiseutil/fips140_test.go new file mode 100644 index 00000000..b342b40d --- /dev/null +++ b/noiseutil/fips140_test.go @@ -0,0 +1,48 @@ +package noiseutil + +import ( + "crypto/cipher" + "crypto/fips140" + "encoding/hex" + "testing" + + "github.com/stretchr/testify/assert" +) + +// Ensure NewAESGCM validates the nonce is non-repeating +func TestNewAESGCM(t *testing.T) { + if !boringEnabled && !fips140.Enabled() { + t.Skip("TestNewAESGCM is only for fips140/boringcrypto") + } + + key, _ := hex.DecodeString("feffe9928665731c6d6a8f9467308308feffe9928665731c6d6a8f9467308308") + iv, _ := hex.DecodeString("00000000facedbaddecaf888") + plaintext, _ := hex.DecodeString("d9313225f88406e5a55909c5aff5269a86a7a9531534f7da2e4c303d8a318a721c3c0c95956809532fcf0e2449a6b525b16aedf5aa0de657ba637b39") + aad, _ := hex.DecodeString("feedfacedeadbeeffeedfacedeadbeefabaddad2") + expected, _ := hex.DecodeString("6a65c2edd45bd63c7e29f40e3d2ed8ba2b99f4c83135383d5676652f255059ceb24863ff10afb1089db701245da87fb88d3acd5f9dd0770cac220c3c04145caf25e190aeb775e7080401c628") + + var keyArray [32]byte + copy(keyArray[:], key) + c := CipherAESGCM.Cipher(keyArray) + aead := c.(cipher.AEAD) + + dst := aead.Seal([]byte{}, iv, plaintext, aad) + t.Logf("%x", dst) + assert.Equal(t, expected, dst) + + // We expect this to fail since we are re-encrypting with a repeat IV + switch { + case boringEnabled: + assert.PanicsWithError(t, "boringcrypto: EVP_AEAD_CTX_seal failed", func() { + dst = aead.Seal([]byte{}, iv, plaintext, aad) + }) + case fips140.Version() == "v1.0.0": + assert.PanicsWithValue(t, "crypto/cipher: counter decreased", func() { + dst = aead.Seal([]byte{}, iv, plaintext, aad) + }) + default: + assert.PanicsWithValue(t, "crypto/cipher: counter decreased or remained the same", func() { + dst = aead.Seal([]byte{}, iv, plaintext, aad) + }) + } +} diff --git a/noiseutil/fips140enforce.go b/noiseutil/fips140enforce.go new file mode 100644 index 00000000..5a6bd56e --- /dev/null +++ b/noiseutil/fips140enforce.go @@ -0,0 +1,13 @@ +//go:build fips140enforce + +package noiseutil + +import ( + "crypto/fips140" +) + +func init() { + if !fips140.Enforced() { + panic("Nebula compiled with fips140 expects FIPS140 to be enforced. Do not set GODEBUG=fips140, or if you do it must be set as GODEBUG=fips140=only") + } +} diff --git a/noiseutil/notboring.go b/noiseutil/notboring.go index be746f40..e8419d74 100644 --- a/noiseutil/notboring.go +++ b/noiseutil/notboring.go @@ -1,14 +1,25 @@ //go:build !boringcrypto -// +build !boringcrypto package noiseutil import ( + "crypto/fips140" + "github.com/flynn/noise" ) // EncryptLockNeeded indicates if calls to Encrypt need a lock -const EncryptLockNeeded = false +var EncryptLockNeeded = fips140.Enabled() -// CipherAESGCM is the standard noise.CipherAESGCM when boringcrypto is not enabled -var CipherAESGCM noise.CipherFunc = noise.CipherAESGCM +var CipherAESGCM noise.CipherFunc = initAESGCM() + +func initAESGCM() noise.CipherFunc { + if fips140.Enabled() { + return CipherAESGCMFIPS140 + } else { + return noise.CipherAESGCM + } + +} + +var boringEnabled = false diff --git a/noiseutil/notboring_test.go b/noiseutil/notboring_test.go deleted file mode 100644 index b865391e..00000000 --- a/noiseutil/notboring_test.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !boringcrypto -// +build !boringcrypto - -package noiseutil - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestEncryptLockNeeded(t *testing.T) { - assert.False(t, EncryptLockNeeded) -} diff --git a/pki.go b/pki.go index 1bef5106..17efe7c1 100644 --- a/pki.go +++ b/pki.go @@ -1,6 +1,7 @@ package nebula import ( + "crypto/fips140" "encoding/binary" "encoding/json" "errors" @@ -237,10 +238,17 @@ func (cs *CertState) getCertificate(v cert.Version) cert.Certificate { return nil } -func newCipherSuite(curve cert.Curve, pkcs11backed bool, cipher string) (noise.CipherSuite, error) { +// newCipherSuite builds the noise.CipherSuite for the given curve and cipher. +// When fips140Enforced is true (FIPS 140-only mode), non-approved algorithms +// (Curve25519 and ChaChaPoly) are rejected with an error. Callers pass +// fips140.Enforced() for fips140Enforced. +func newCipherSuite(curve cert.Curve, pkcs11backed bool, cipher string, fips140Enforced bool) (noise.CipherSuite, error) { var dhFunc noise.DHFunc switch curve { case cert.Curve_CURVE25519: + if fips140Enforced { + return nil, errors.New("pki: use of Curve25519 is not allowed in FIPS 140-only mode") + } dhFunc = noise.DH25519 case cert.Curve_P256: if pkcs11backed { @@ -253,6 +261,9 @@ func newCipherSuite(curve cert.Curve, pkcs11backed bool, cipher string) (noise.C } if cipher == "chachapoly" { + if fips140Enforced { + return nil, errors.New("pki: use of ChaChaPoly is not allowed in FIPS 140-only mode") + } return noise.NewCipherSuite(dhFunc, noise.CipherChaChaPoly, noise.HashSHA256), nil } return noise.NewCipherSuite(dhFunc, noiseutil.CipherAESGCM, noise.HashSHA256), nil @@ -326,6 +337,10 @@ func newCertStateFromConfig(c *config.C, cipher string) (*CertState, error) { return nil, err } + if fips140.Enforced() && crt.Curve() != cert.Curve_P256 { + return nil, fmt.Errorf("pki: use of %s is not allowed in FIPS 140-only mode", crt.Curve()) + } + switch crt.Version() { case cert.Version1: if v1 != nil { @@ -405,7 +420,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p //NOTE: We do not currently have a method to verify a public private key pair when the private key is in an hsm } else { if err := v1.VerifyPrivateKey(privateKeyCurve, privateKey); err != nil { - return nil, fmt.Errorf("private key is not a pair with public key in nebula cert") + return nil, fmt.Errorf("private key is not a pair with public key in nebula cert: %w", err) } } @@ -413,7 +428,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p if err != nil { return nil, fmt.Errorf("error marshalling v1 certificate for handshake: %w", err) } - ncs, err := newCipherSuite(v1.Curve(), pkcs11backed, cipher) + ncs, err := newCipherSuite(v1.Curve(), pkcs11backed, cipher, fips140.Enforced()) if err != nil { return nil, err } @@ -430,7 +445,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p //NOTE: We do not currently have a method to verify a public private key pair when the private key is in an hsm } else { if err := v2.VerifyPrivateKey(privateKeyCurve, privateKey); err != nil { - return nil, fmt.Errorf("private key is not a pair with public key in nebula cert") + return nil, fmt.Errorf("private key is not a pair with public key in nebula cert: %w", err) } } @@ -438,7 +453,7 @@ func newCertState(dv cert.Version, v1, v2 cert.Certificate, pkcs11backed bool, p if err != nil { return nil, fmt.Errorf("error marshalling v2 certificate for handshake: %w", err) } - ncs, err := newCipherSuite(v2.Curve(), pkcs11backed, cipher) + ncs, err := newCipherSuite(v2.Curve(), pkcs11backed, cipher, fips140.Enforced()) if err != nil { return nil, err } diff --git a/pki_test.go b/pki_test.go new file mode 100644 index 00000000..7bd1a24e --- /dev/null +++ b/pki_test.go @@ -0,0 +1,94 @@ +package nebula + +import ( + "strings" + "testing" + + "github.com/slackhq/nebula/cert" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCipherSuite(t *testing.T) { + tests := []struct { + name string + curve cert.Curve + cipher string + fips140Enforced bool + wantErr string + // wantName is the full expected CipherSuite name (__), + // only checked when wantErr is empty. Asserting the whole name makes both + // the curve and cipher selection load-bearing. + wantName string + }{ + { + name: "curve25519 aesgcm, not enforced", + curve: cert.Curve_CURVE25519, + cipher: "aesgcm", + wantName: "25519_AESGCM_SHA256", + }, + { + name: "curve25519 chachapoly, not enforced", + curve: cert.Curve_CURVE25519, + cipher: "chachapoly", + wantName: "25519_ChaChaPoly_SHA256", + }, + { + name: "p256 aesgcm, not enforced", + curve: cert.Curve_P256, + cipher: "aesgcm", + wantName: "P256_AESGCM_SHA256", + }, + { + name: "p256 aesgcm, enforced is allowed", + curve: cert.Curve_P256, + cipher: "aesgcm", + fips140Enforced: true, + wantName: "P256_AESGCM_SHA256", + }, + { + name: "curve25519 rejected when enforced", + curve: cert.Curve_CURVE25519, + cipher: "aesgcm", + fips140Enforced: true, + wantErr: "pki: use of Curve25519 is not allowed in FIPS 140-only mode", + }, + { + name: "chachapoly rejected when enforced", + curve: cert.Curve_P256, + cipher: "chachapoly", + fips140Enforced: true, + wantErr: "pki: use of ChaChaPoly is not allowed in FIPS 140-only mode", + }, + { + // Curve is checked before cipher, so a Curve25519+ChaChaPoly + // request reports the Curve25519 rejection. + name: "curve25519 chachapoly rejected on curve when enforced", + curve: cert.Curve_CURVE25519, + cipher: "chachapoly", + fips140Enforced: true, + wantErr: "pki: use of Curve25519 is not allowed in FIPS 140-only mode", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cs, err := newCipherSuite(tt.curve, false, tt.cipher, tt.fips140Enforced) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + assert.Nil(t, cs) + return + } + require.NoError(t, err) + require.NotNil(t, cs) + assert.Equal(t, tt.wantName, string(cs.Name())) + }) + } +} + +func TestNewCipherSuiteUnsupportedCurve(t *testing.T) { + cs, err := newCipherSuite(cert.Curve(99), false, "aesgcm", false) + require.Error(t, err) + assert.True(t, strings.HasPrefix(err.Error(), "unsupported curve:"), "got: %v", err) + assert.Nil(t, cs) +} diff --git a/stats.go b/stats.go index fd49c7fd..a82bbc2b 100644 --- a/stats.go +++ b/stats.go @@ -2,6 +2,7 @@ package nebula import ( "context" + "crypto/fips140" "errors" "fmt" "log/slog" @@ -287,9 +288,12 @@ func (s *statsServer) buildRuntime(cfg statsConfig) ([]func(), *http.Server) { Name: "info", Help: "Version information for the Nebula binary", ConstLabels: prometheus.Labels{ - "version": s.buildVersion, - "goversion": runtime.Version(), - "boringcrypto": strconv.FormatBool(boringEnabled()), + "version": s.buildVersion, + "goversion": runtime.Version(), + "boringcrypto": strconv.FormatBool(boringEnabled()), + "fips140Version": fips140.Version(), + "fips140Enabled": strconv.FormatBool(fips140.Enabled()), + "fips140Enforced": strconv.FormatBool(fips140.Enforced()), }, }) pr.MustRegister(g)