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.
This commit is contained in:
Wade Simmons
2026-08-21 19:33:40 -04:00
committed by GitHub
parent edc3c5e018
commit 9c5d701648
23 changed files with 566 additions and 186 deletions
+4 -65
View File
@@ -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()
-32
View File
@@ -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)
})
}
+4 -1
View File
@@ -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)
+11 -4
View File
@@ -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{})
})
+197
View File
@@ -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
}
+48
View File
@@ -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)
})
}
}
+13
View File
@@ -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")
}
}
+15 -4
View File
@@ -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
-14
View File
@@ -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)
}