From d08ac86106ca2203e76b3956b891e8200b46bb23 Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Wed, 11 Mar 2026 16:45:59 -0600 Subject: [PATCH] Crypto package for db encryption (#41139) --- server/crypto/aes.go | 62 +++++++++++++++++ server/crypto/aes_test.go | 142 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+) create mode 100644 server/crypto/aes.go create mode 100644 server/crypto/aes_test.go diff --git a/server/crypto/aes.go b/server/crypto/aes.go new file mode 100644 index 0000000000..c60100fa08 --- /dev/null +++ b/server/crypto/aes.go @@ -0,0 +1,62 @@ +// Package crypto provides cryptographic utilities for Fleet. +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "fmt" + "io" +) + +// EncryptAESGCM encrypts plaintext using AES-256-GCM with the given key. +// The key must be 32 bytes for AES-256. +// Returns [nonce || ciphertext]. +func EncryptAESGCM(plainText []byte, key string) ([]byte, error) { + if len(key) != 32 { + return nil, fmt.Errorf("invalid key size: got %d bytes, AES-256 requires exactly 32 bytes", len(key)) + } + + block, err := aes.NewCipher([]byte(key)) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create gcm: %w", err) + } + + nonce := make([]byte, aesGCM.NonceSize()) + if _, err = io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("generate nonce: %w", err) + } + + return aesGCM.Seal(nonce, nonce, plainText, nil), nil +} + +// DecryptAESGCM decrypts ciphertext that was encrypted with EncryptAESGCM. +// The key must be the same 32-byte key used for encryption. +func DecryptAESGCM(encrypted []byte, key string) ([]byte, error) { + if len(key) != 32 { + return nil, fmt.Errorf("invalid key size: got %d bytes, AES-256 requires exactly 32 bytes", len(key)) + } + + block, err := aes.NewCipher([]byte(key)) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create gcm: %w", err) + } + + nonceSize := aesGCM.NonceSize() + if len(encrypted) < nonceSize+1 { + return nil, fmt.Errorf("malformed ciphertext: length %d is less than minimum required %d", len(encrypted), nonceSize+1) + } + + nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:] + return aesGCM.Open(nil, nonce, ciphertext, nil) +} diff --git a/server/crypto/aes_test.go b/server/crypto/aes_test.go new file mode 100644 index 0000000000..1305c46902 --- /dev/null +++ b/server/crypto/aes_test.go @@ -0,0 +1,142 @@ +package crypto + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncryptDecryptAESGCM(t *testing.T) { + // 32-byte key for AES-256 (simple test key to avoid false positive secret scanning warnings) + key := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + tests := []struct { + name string + plaintext []byte + }{ + {"empty", []byte{}}, + {"short", []byte("hello")}, + {"medium", []byte("this is a longer test message for encryption")}, + {"with special chars", []byte("hello\x00world\ntest\ttab")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + encrypted, err := EncryptAESGCM(tt.plaintext, key) + require.NoError(t, err, "EncryptAESGCM()") + + // Encrypted should be longer than plaintext (nonce + auth tag) + assert.Greater(t, len(encrypted), len(tt.plaintext), "encrypted length should be > plaintext length") + + decrypted, err := DecryptAESGCM(encrypted, key) + require.NoError(t, err, "DecryptAESGCM()") + + assert.True(t, bytes.Equal(decrypted, tt.plaintext), "DecryptAESGCM() result mismatch") + }) + } +} + +func TestDecryptAESGCM_InvalidKey(t *testing.T) { + key := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + wrongKey := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + plaintext := []byte("secret message") + encrypted, err := EncryptAESGCM(plaintext, key) + require.NoError(t, err, "EncryptAESGCM()") + + _, err = DecryptAESGCM(encrypted, wrongKey) + assert.Error(t, err, "DecryptAESGCM() with wrong key should fail") +} + +func TestDecryptAESGCM_MalformedCiphertext(t *testing.T) { + key := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + tests := []struct { + name string + ciphertext []byte + }{ + {"empty", []byte{}}, + {"one byte", []byte{0x01}}, + {"too short for nonce", []byte("short")}, + {"exactly nonce size", make([]byte, 12)}, // GCM nonce is 12 bytes, but no ciphertext + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := DecryptAESGCM(tt.ciphertext, key) + assert.Error(t, err, "DecryptAESGCM() with %s ciphertext should fail", tt.name) + }) + } +} + +func TestEncryptAESGCM_InvalidKeyLength(t *testing.T) { + tests := []struct { + name string + keyLen int + wantErr bool + }{ + {"empty key", 0, true}, + {"too short", 5, true}, + {"AES-128 rejected", 16, true}, + {"AES-192 rejected", 24, true}, + {"AES-256 accepted", 32, false}, + {"too long", 64, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key := strings.Repeat("a", tt.keyLen) + + _, err := EncryptAESGCM([]byte("test"), key) + if tt.wantErr { + assert.Error(t, err, "EncryptAESGCM() should fail") + } else { + assert.NoError(t, err, "EncryptAESGCM() should succeed") + } + }) + } +} + +func TestDecryptAESGCM_InvalidKeyLength(t *testing.T) { + // First encrypt with valid key + validKey := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + encrypted, err := EncryptAESGCM([]byte("test"), validKey) + require.NoError(t, err, "EncryptAESGCM()") + + tests := []struct { + name string + keyLen int + }{ + {"empty key", 0}, + {"too short", 5}, + {"AES-128 rejected", 16}, + {"AES-192 rejected", 24}, + {"too long", 64}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key := strings.Repeat("b", tt.keyLen) + + _, err := DecryptAESGCM(encrypted, key) + assert.Error(t, err, "DecryptAESGCM() with %d-byte key should fail", tt.keyLen) + }) + } +} + +func TestEncryptAESGCM_UniqueNonces(t *testing.T) { + key := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + plaintext := []byte("same message") + + encrypted1, err := EncryptAESGCM(plaintext, key) + require.NoError(t, err, "first EncryptAESGCM()") + + encrypted2, err := EncryptAESGCM(plaintext, key) + require.NoError(t, err, "second EncryptAESGCM()") + + // Same plaintext should produce different ciphertext due to random nonce + assert.NotEqual(t, encrypted1, encrypted2, "encrypting same plaintext twice should produce different ciphertext") +}