From d306d7a983c4a09ab2fb37c6d35a6ed0d016e73e Mon Sep 17 00:00:00 2001 From: Malcolm Lockyer Date: Mon, 4 Aug 2025 17:04:03 +1200 Subject: [PATCH] fix(encryption): replace encryption related methods for fips mode [be-11933] (#919) Co-authored-by: andres-portainer --- api/chisel/service_test.go | 5 + api/cmd/portainer/main.go | 4 + api/crypto/aes.go | 168 +++++++- api/crypto/aes_test.go | 358 +++++++++++------- api/crypto/ecdsa.go | 2 +- api/crypto/ecdsa_test.go | 22 ++ api/crypto/nonce.go | 2 +- api/database/boltdb/json.go | 22 +- api/database/boltdb/json_test.go | 100 ++++- api/http/handler/backup/backup_test.go | 5 + .../customtemplate_git_fetch_test.go | 5 + api/http/handler/motd/motd.go | 2 +- go.mod | 1 + pkg/fips/fips.go | 39 ++ pkg/fips/fips_test.go | 15 + pkg/libcrypto/decrypt.go | 21 +- pkg/libcrypto/encrypt.go | 25 +- pkg/libcrypto/encrypt_test.go | 56 +++ pkg/libcrypto/hash.go | 18 +- 19 files changed, 701 insertions(+), 169 deletions(-) create mode 100644 api/crypto/ecdsa_test.go create mode 100644 pkg/fips/fips.go create mode 100644 pkg/fips/fips_test.go create mode 100644 pkg/libcrypto/encrypt_test.go diff --git a/api/chisel/service_test.go b/api/chisel/service_test.go index 918c7bf1e..e8fa71a5a 100644 --- a/api/chisel/service_test.go +++ b/api/chisel/service_test.go @@ -9,10 +9,15 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/datastore" + "github.com/portainer/portainer/pkg/fips" "github.com/stretchr/testify/require" ) +func init() { + fips.InitFIPS(false) +} + func TestPingAgentPanic(t *testing.T) { endpoint := &portainer.Endpoint{ ID: 1, diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index 818025bdf..08a99c603 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -49,6 +49,7 @@ import ( "github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/pkg/build" "github.com/portainer/portainer/pkg/featureflags" + "github.com/portainer/portainer/pkg/fips" "github.com/portainer/portainer/pkg/libhelm" libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types" "github.com/portainer/portainer/pkg/libstack/compose" @@ -343,6 +344,9 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { } } + // -ce can not ever be run in FIPS mode + fips.InitFIPS(false) + fileService := initFileService(*flags.Data) encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName) if encryptionKey == nil { diff --git a/api/crypto/aes.go b/api/crypto/aes.go index 922cdfd75..b63e8ffa0 100644 --- a/api/crypto/aes.go +++ b/api/crypto/aes.go @@ -6,11 +6,15 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" + "crypto/sha256" "errors" "fmt" "io" + "strings" + "github.com/portainer/portainer/pkg/fips" "golang.org/x/crypto/argon2" + "golang.org/x/crypto/pbkdf2" "golang.org/x/crypto/scrypt" ) @@ -19,20 +23,32 @@ const ( aesGcmHeader = "AES256-GCM" // The encrypted file header aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm + aesGcmFIPSHeader = "FIPS-AES256-GCM" + aesGcmFIPSBlockSize = 16 * 1024 * 1024 // 16MB block for aes gcm + // Argon2 settings - // Recommded settings lower memory hardware according to current OWASP recommendations + // Recommended settings lower memory hardware according to current OWASP recommendations // Considering some people run portainer on a NAS I think it's prudent not to assume we're on server grade hardware // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id argon2MemoryCost = 12 * 1024 argon2TimeCost = 3 argon2Threads = 1 argon2KeyLength = 32 + + pbkdf2Iterations = 600_000 // use recommended iterations from https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 a little overkill for this use + pbkdf2SaltLength = 32 ) // AesEncrypt reads from input, encrypts with AES-256 and writes to output. passphrase is used to generate an encryption key func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error { - if err := aesEncryptGCM(input, output, passphrase); err != nil { - return fmt.Errorf("error encrypting file: %w", err) + if fips.FIPSMode() { + if err := aesEncryptGCMFIPS(input, output, passphrase); err != nil { + return fmt.Errorf("error encrypting file: %w", err) + } + } else { + if err := aesEncryptGCM(input, output, passphrase); err != nil { + return fmt.Errorf("error encrypting file: %w", err) + } } return nil @@ -40,14 +56,36 @@ func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error { // AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from func AesDecrypt(input io.Reader, passphrase []byte) (io.Reader, error) { + fipsMode := fips.FIPSMode() + return aesDecrypt(input, passphrase, fipsMode) +} + +func aesDecrypt(input io.Reader, passphrase []byte, fipsMode bool) (io.Reader, error) { // Read file header to determine how it was encrypted inputReader := bufio.NewReader(input) - header, err := inputReader.Peek(len(aesGcmHeader)) + header, err := inputReader.Peek(len(aesGcmFIPSHeader)) if err != nil { return nil, fmt.Errorf("error reading encrypted backup file header: %w", err) } - if string(header) == aesGcmHeader { + if strings.HasPrefix(string(header), aesGcmFIPSHeader) { + if !fipsMode { + return nil, errors.New("fips encrypted file detected but fips mode is not enabled") + } + + reader, err := aesDecryptGCMFIPS(inputReader, passphrase) + if err != nil { + return nil, fmt.Errorf("error decrypting file: %w", err) + } + + return reader, nil + } + + if strings.HasPrefix(string(header), aesGcmHeader) { + if fipsMode { + return nil, errors.New("fips mode is enabled but non-fips encrypted file detected") + } + reader, err := aesDecryptGCM(inputReader, passphrase) if err != nil { return nil, fmt.Errorf("error decrypting file: %w", err) @@ -203,6 +241,126 @@ func aesDecryptGCM(input io.Reader, passphrase []byte) (io.Reader, error) { return &buf, nil } +// aesEncryptGCMFIPS reads from input, encrypts with AES-256 in a fips compliant +// way and writes to output. passphrase is used to generate an encryption key. +func aesEncryptGCMFIPS(input io.Reader, output io.Writer, passphrase []byte) error { + salt := make([]byte, pbkdf2SaltLength) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return err + } + + key := pbkdf2.Key(passphrase, salt, pbkdf2Iterations, 32, sha256.New) + + block, err := aes.NewCipher(key) + if err != nil { + return err + } + + // write the header + if _, err := output.Write([]byte(aesGcmFIPSHeader)); err != nil { + return err + } + + // Write nonce and salt to the output file + if _, err := output.Write(salt); err != nil { + return err + } + + // Buffer for reading plaintext blocks + buf := make([]byte, aesGcmFIPSBlockSize) + + // Encrypt plaintext in blocks + for { + // new random nonce for each block + aesgcm, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { + return fmt.Errorf("error creating gcm: %w", err) + } + + n, err := io.ReadFull(input, buf) + if n == 0 { + break // end of plaintext input + } + + if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) { + return err + } + + // Seal encrypts the plaintext + ciphertext := aesgcm.Seal(nil, nil, buf[:n], nil) + + _, err = output.Write(ciphertext) + if err != nil { + return err + } + } + + return nil +} + +// aesDecryptGCMFIPS reads from input, decrypts with AES-256 in a fips compliant +// way and returns the reader to read the decrypted content from. +func aesDecryptGCMFIPS(input io.Reader, passphrase []byte) (io.Reader, error) { + // Reader & verify header + header := make([]byte, len(aesGcmFIPSHeader)) + if _, err := io.ReadFull(input, header); err != nil { + return nil, err + } + + if string(header) != aesGcmFIPSHeader { + return nil, errors.New("invalid header") + } + + // Read salt + salt := make([]byte, pbkdf2SaltLength) + if _, err := io.ReadFull(input, salt); err != nil { + return nil, err + } + + key := pbkdf2.Key(passphrase, salt, pbkdf2Iterations, 32, sha256.New) + + // Initialize AES cipher block + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + + // Initialize a buffer to store decrypted data + buf := bytes.Buffer{} + + // Decrypt the ciphertext in blocks + for { + // Create GCM mode with the cipher block + aesgcm, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { + return nil, err + } + + // Read a block of ciphertext from the input reader + ciphertextBlock := make([]byte, aesGcmFIPSBlockSize+aesgcm.Overhead()) + n, err := io.ReadFull(input, ciphertextBlock) + if n == 0 { + break // end of ciphertext + } + + if err != nil && !(errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) { + return nil, err + } + + // Decrypt the block of ciphertext + plaintext, err := aesgcm.Open(nil, nil, ciphertextBlock[:n], nil) + if err != nil { + return nil, err + } + + if _, err := buf.Write(plaintext); err != nil { + return nil, err + } + } + + return &buf, nil +} + // aesDecryptOFB reads from input, decrypts with AES-256 and returns the reader to a read decrypted content from. // passphrase is used to generate an encryption key. // note: This function used to decrypt files that were encrypted without a header i.e. old archives diff --git a/api/crypto/aes_test.go b/api/crypto/aes_test.go index e03a9917e..de39a69fe 100644 --- a/api/crypto/aes_test.go +++ b/api/crypto/aes_test.go @@ -7,9 +7,15 @@ import ( "path/filepath" "testing" + "github.com/portainer/portainer/pkg/fips" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func init() { + fips.InitFIPS(false) +} + const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" func randBytes(n int) []byte { @@ -20,198 +26,296 @@ func randBytes(n int) []byte { return b } +type encryptFunc func(input io.Reader, output io.Writer, passphrase []byte) error +type decryptFunc func(input io.Reader, passphrase []byte) (io.Reader, error) + func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) { const passphrase = "passphrase" - tmpdir := t.TempDir() + testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc, decryptShouldSucceed bool) { + tmpdir := t.TempDir() - var ( - originFilePath = filepath.Join(tmpdir, "origin") - encryptedFilePath = filepath.Join(tmpdir, "encrypted") - decryptedFilePath = filepath.Join(tmpdir, "decrypted") - ) + var ( + originFilePath = filepath.Join(tmpdir, "origin") + encryptedFilePath = filepath.Join(tmpdir, "encrypted") + decryptedFilePath = filepath.Join(tmpdir, "decrypted") + ) - content := randBytes(1024*1024*100 + 523) - os.WriteFile(originFilePath, content, 0600) + content := randBytes(1024*1024*100 + 523) + os.WriteFile(originFilePath, content, 0600) - originFile, _ := os.Open(originFilePath) - defer originFile.Close() + originFile, _ := os.Open(originFilePath) + defer originFile.Close() - encryptedFileWriter, _ := os.Create(encryptedFilePath) + encryptedFileWriter, _ := os.Create(encryptedFilePath) - err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase)) - assert.Nil(t, err, "Failed to encrypt a file") - encryptedFileWriter.Close() + err := encrypt(originFile, encryptedFileWriter, []byte(passphrase)) + require.Nil(t, err, "Failed to encrypt a file") + encryptedFileWriter.Close() - encryptedContent, err := os.ReadFile(encryptedFilePath) - assert.Nil(t, err, "Couldn't read encrypted file") - assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + encryptedContent, err := os.ReadFile(encryptedFilePath) + require.Nil(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") - encryptedFileReader, _ := os.Open(encryptedFilePath) - defer encryptedFileReader.Close() + encryptedFileReader, _ := os.Open(encryptedFilePath) + defer encryptedFileReader.Close() - decryptedFileWriter, _ := os.Create(decryptedFilePath) - defer decryptedFileWriter.Close() + decryptedFileWriter, _ := os.Create(decryptedFilePath) + defer decryptedFileWriter.Close() - decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase)) - assert.Nil(t, err, "Failed to decrypt file") + decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase)) + if !decryptShouldSucceed { + require.Error(t, err, "Failed to decrypt file as indicated by decryptShouldSucceed") + } else { + require.NoError(t, err, "Failed to decrypt file indicated by decryptShouldSucceed") - io.Copy(decryptedFileWriter, decryptedReader) + io.Copy(decryptedFileWriter, decryptedReader) - decryptedContent, _ := os.ReadFile(decryptedFilePath) - assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + decryptedContent, _ := os.ReadFile(decryptedFilePath) + assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + } + } + + t.Run("fips", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS, true) + }) + + t.Run("non_fips", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCM, true) + }) + + t.Run("system_fips_mode_public_entry_points", func(t *testing.T) { + // use the init mode, public entry points + testFunc(t, AesEncrypt, AesDecrypt, true) + }) + + t.Run("fips_encrypted_file_header_fails_in_non_fips_mode", func(t *testing.T) { + // use aesDecrypt which checks the header, confirm that it fails + decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) { + return aesDecrypt(input, passphrase, false) + } + + testFunc(t, aesEncryptGCMFIPS, decrypt, false) + }) + + t.Run("non_fips_encrypted_file_header_fails_in_fips_mode", func(t *testing.T) { + // use aesDecrypt which checks the header, confirm that it fails + decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) { + return aesDecrypt(input, passphrase, true) + } + + testFunc(t, aesEncryptGCM, decrypt, false) + }) + + t.Run("fips_encrypted_file_fails_in_non_fips_mode", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCM, false) + }) + + t.Run("non_fips_encrypted_file_with_fips_mode_should_fail", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCMFIPS, false) + }) + + t.Run("fips_with_base_aesDecrypt", func(t *testing.T) { + // maximize coverage, use the base aesDecrypt function with valid fips mode + decrypt := func(input io.Reader, passphrase []byte) (io.Reader, error) { + return aesDecrypt(input, passphrase, true) + } + + testFunc(t, aesEncryptGCMFIPS, decrypt, true) + }) } func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) { const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+" - tmpdir := t.TempDir() - var ( - originFilePath = filepath.Join(tmpdir, "origin2") - encryptedFilePath = filepath.Join(tmpdir, "encrypted2") - decryptedFilePath = filepath.Join(tmpdir, "decrypted2") - ) + testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) { + tmpdir := t.TempDir() - content := randBytes(500) - os.WriteFile(originFilePath, content, 0600) + var ( + originFilePath = filepath.Join(tmpdir, "origin2") + encryptedFilePath = filepath.Join(tmpdir, "encrypted2") + decryptedFilePath = filepath.Join(tmpdir, "decrypted2") + ) - originFile, _ := os.Open(originFilePath) - defer originFile.Close() + content := randBytes(500) + os.WriteFile(originFilePath, content, 0600) - encryptedFileWriter, _ := os.Create(encryptedFilePath) + originFile, _ := os.Open(originFilePath) + defer originFile.Close() - err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase)) - assert.Nil(t, err, "Failed to encrypt a file") - encryptedFileWriter.Close() + encryptedFileWriter, _ := os.Create(encryptedFilePath) - encryptedContent, err := os.ReadFile(encryptedFilePath) - assert.Nil(t, err, "Couldn't read encrypted file") - assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + err := encrypt(originFile, encryptedFileWriter, []byte(passphrase)) + assert.Nil(t, err, "Failed to encrypt a file") + encryptedFileWriter.Close() - encryptedFileReader, _ := os.Open(encryptedFilePath) - defer encryptedFileReader.Close() + encryptedContent, err := os.ReadFile(encryptedFilePath) + assert.Nil(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") - decryptedFileWriter, _ := os.Create(decryptedFilePath) - defer decryptedFileWriter.Close() + encryptedFileReader, _ := os.Open(encryptedFilePath) + defer encryptedFileReader.Close() - decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase)) - assert.Nil(t, err, "Failed to decrypt file") + decryptedFileWriter, _ := os.Create(decryptedFilePath) + defer decryptedFileWriter.Close() - io.Copy(decryptedFileWriter, decryptedReader) + decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase)) + assert.Nil(t, err, "Failed to decrypt file") - decryptedContent, _ := os.ReadFile(decryptedFilePath) - assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + io.Copy(decryptedFileWriter, decryptedReader) + + decryptedContent, _ := os.ReadFile(decryptedFilePath) + assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + } + + t.Run("fips", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS) + }) + + t.Run("non_fips", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCM) + }) } func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) { - tmpdir := t.TempDir() + testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) { + tmpdir := t.TempDir() - var ( - originFilePath = filepath.Join(tmpdir, "origin2") - encryptedFilePath = filepath.Join(tmpdir, "encrypted2") - decryptedFilePath = filepath.Join(tmpdir, "decrypted2") - ) + var ( + originFilePath = filepath.Join(tmpdir, "origin2") + encryptedFilePath = filepath.Join(tmpdir, "encrypted2") + decryptedFilePath = filepath.Join(tmpdir, "decrypted2") + ) - content := randBytes(500) - os.WriteFile(originFilePath, content, 0600) + content := randBytes(500) + os.WriteFile(originFilePath, content, 0600) - originFile, _ := os.Open(originFilePath) - defer originFile.Close() + originFile, _ := os.Open(originFilePath) + defer originFile.Close() - encryptedFileWriter, _ := os.Create(encryptedFilePath) + encryptedFileWriter, _ := os.Create(encryptedFilePath) - err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase")) - assert.Nil(t, err, "Failed to encrypt a file") - encryptedFileWriter.Close() + err := encrypt(originFile, encryptedFileWriter, []byte("passphrase")) + assert.Nil(t, err, "Failed to encrypt a file") + encryptedFileWriter.Close() - encryptedContent, err := os.ReadFile(encryptedFilePath) - assert.Nil(t, err, "Couldn't read encrypted file") - assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + encryptedContent, err := os.ReadFile(encryptedFilePath) + assert.Nil(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") - encryptedFileReader, _ := os.Open(encryptedFilePath) - defer encryptedFileReader.Close() + encryptedFileReader, _ := os.Open(encryptedFilePath) + defer encryptedFileReader.Close() - decryptedFileWriter, _ := os.Create(decryptedFilePath) - defer decryptedFileWriter.Close() + decryptedFileWriter, _ := os.Create(decryptedFilePath) + defer decryptedFileWriter.Close() - decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("passphrase")) - assert.Nil(t, err, "Failed to decrypt file") + decryptedReader, err := decrypt(encryptedFileReader, []byte("passphrase")) + assert.Nil(t, err, "Failed to decrypt file") - io.Copy(decryptedFileWriter, decryptedReader) + io.Copy(decryptedFileWriter, decryptedReader) - decryptedContent, _ := os.ReadFile(decryptedFilePath) - assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + decryptedContent, _ := os.ReadFile(decryptedFilePath) + assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + } + + t.Run("fips", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS) + }) + + t.Run("non_fips", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCM) + }) } func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) { - tmpdir := t.TempDir() + testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) { + tmpdir := t.TempDir() - var ( - originFilePath = filepath.Join(tmpdir, "origin") - encryptedFilePath = filepath.Join(tmpdir, "encrypted") - decryptedFilePath = filepath.Join(tmpdir, "decrypted") - ) + var ( + originFilePath = filepath.Join(tmpdir, "origin") + encryptedFilePath = filepath.Join(tmpdir, "encrypted") + decryptedFilePath = filepath.Join(tmpdir, "decrypted") + ) - content := randBytes(1024 * 50) - os.WriteFile(originFilePath, content, 0600) + content := randBytes(1024 * 50) + os.WriteFile(originFilePath, content, 0600) - originFile, _ := os.Open(originFilePath) - defer originFile.Close() + originFile, _ := os.Open(originFilePath) + defer originFile.Close() - encryptedFileWriter, _ := os.Create(encryptedFilePath) - defer encryptedFileWriter.Close() + encryptedFileWriter, _ := os.Create(encryptedFilePath) + defer encryptedFileWriter.Close() - err := AesEncrypt(originFile, encryptedFileWriter, []byte("")) - assert.Nil(t, err, "Failed to encrypt a file") - encryptedContent, err := os.ReadFile(encryptedFilePath) - assert.Nil(t, err, "Couldn't read encrypted file") - assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + err := encrypt(originFile, encryptedFileWriter, []byte("")) + assert.Nil(t, err, "Failed to encrypt a file") + encryptedContent, err := os.ReadFile(encryptedFilePath) + assert.Nil(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") - encryptedFileReader, _ := os.Open(encryptedFilePath) - defer encryptedFileReader.Close() + encryptedFileReader, _ := os.Open(encryptedFilePath) + defer encryptedFileReader.Close() - decryptedFileWriter, _ := os.Create(decryptedFilePath) - defer decryptedFileWriter.Close() + decryptedFileWriter, _ := os.Create(decryptedFilePath) + defer decryptedFileWriter.Close() - decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("")) - assert.Nil(t, err, "Failed to decrypt file") + decryptedReader, err := decrypt(encryptedFileReader, []byte("")) + assert.Nil(t, err, "Failed to decrypt file") - io.Copy(decryptedFileWriter, decryptedReader) + io.Copy(decryptedFileWriter, decryptedReader) - decryptedContent, _ := os.ReadFile(decryptedFilePath) - assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + decryptedContent, _ := os.ReadFile(decryptedFilePath) + assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") + } + + t.Run("fips", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS) + }) + + t.Run("non_fips", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCM) + }) } func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) { - tmpdir := t.TempDir() + testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) { + tmpdir := t.TempDir() - var ( - originFilePath = filepath.Join(tmpdir, "origin") - encryptedFilePath = filepath.Join(tmpdir, "encrypted") - decryptedFilePath = filepath.Join(tmpdir, "decrypted") - ) + var ( + originFilePath = filepath.Join(tmpdir, "origin") + encryptedFilePath = filepath.Join(tmpdir, "encrypted") + decryptedFilePath = filepath.Join(tmpdir, "decrypted") + ) - content := randBytes(1034) - os.WriteFile(originFilePath, content, 0600) + content := randBytes(1034) + os.WriteFile(originFilePath, content, 0600) - originFile, _ := os.Open(originFilePath) - defer originFile.Close() + originFile, _ := os.Open(originFilePath) + defer originFile.Close() - encryptedFileWriter, _ := os.Create(encryptedFilePath) - defer encryptedFileWriter.Close() + encryptedFileWriter, _ := os.Create(encryptedFilePath) + defer encryptedFileWriter.Close() - err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase")) - assert.Nil(t, err, "Failed to encrypt a file") - encryptedContent, err := os.ReadFile(encryptedFilePath) - assert.Nil(t, err, "Couldn't read encrypted file") - assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") + err := encrypt(originFile, encryptedFileWriter, []byte("passphrase")) + assert.Nil(t, err, "Failed to encrypt a file") + encryptedContent, err := os.ReadFile(encryptedFilePath) + assert.Nil(t, err, "Couldn't read encrypted file") + assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") - encryptedFileReader, _ := os.Open(encryptedFilePath) - defer encryptedFileReader.Close() + encryptedFileReader, _ := os.Open(encryptedFilePath) + defer encryptedFileReader.Close() - decryptedFileWriter, _ := os.Create(decryptedFilePath) - defer decryptedFileWriter.Close() + decryptedFileWriter, _ := os.Create(decryptedFilePath) + defer decryptedFileWriter.Close() - _, err = AesDecrypt(encryptedFileReader, []byte("garbage")) - assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase") + _, err = decrypt(encryptedFileReader, []byte("garbage")) + assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase") + } + + t.Run("fips", func(t *testing.T) { + testFunc(t, aesEncryptGCMFIPS, aesDecryptGCMFIPS) + }) + + t.Run("non_fips", func(t *testing.T) { + testFunc(t, aesEncryptGCM, aesDecryptGCM) + }) } diff --git a/api/crypto/ecdsa.go b/api/crypto/ecdsa.go index 1279c06e1..e7eabdd1d 100644 --- a/api/crypto/ecdsa.go +++ b/api/crypto/ecdsa.go @@ -112,7 +112,7 @@ func (service *ECDSAService) CreateSignature(message string) (string, error) { message = service.secret } - hash := libcrypto.HashFromBytes([]byte(message)) + hash := libcrypto.InsecureHashFromBytes([]byte(message)) r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash) if err != nil { diff --git a/api/crypto/ecdsa_test.go b/api/crypto/ecdsa_test.go new file mode 100644 index 000000000..62141268a --- /dev/null +++ b/api/crypto/ecdsa_test.go @@ -0,0 +1,22 @@ +package crypto + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCreateSignature(t *testing.T) { + var s = NewECDSAService("secret") + + privKey, pubKey, err := s.GenerateKeyPair() + require.NoError(t, err) + require.Greater(t, len(privKey), 0) + require.Greater(t, len(pubKey), 0) + + m := "test message" + r, err := s.CreateSignature(m) + require.NoError(t, err) + require.NotEqual(t, r, m) + require.Greater(t, len(r), 0) +} diff --git a/api/crypto/nonce.go b/api/crypto/nonce.go index 571a9ba71..af1cc898b 100644 --- a/api/crypto/nonce.go +++ b/api/crypto/nonce.go @@ -15,7 +15,7 @@ func NewNonce(size int) *Nonce { } // NewRandomNonce generates a new initial nonce with the lower byte set to a random value -// This ensures there are plenty of nonce values availble before rolling over +// This ensures there are plenty of nonce values available before rolling over // Based on ideas from the Secure Programming Cookbook for C and C++ by John Viega, Matt Messier // https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html func NewRandomNonce(size int) (*Nonce, error) { diff --git a/api/database/boltdb/json.go b/api/database/boltdb/json.go index b9ce97213..363e0ad7d 100644 --- a/api/database/boltdb/json.go +++ b/api/database/boltdb/json.go @@ -4,8 +4,6 @@ import ( "bytes" "crypto/aes" "crypto/cipher" - "crypto/rand" - "io" "github.com/pkg/errors" "github.com/segmentio/encoding/json" @@ -65,18 +63,18 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error { // https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) { - block, _ := aes.NewCipher(passphrase) - gcm, err := cipher.NewGCM(block) + block, err := aes.NewCipher(passphrase) if err != nil { return encrypted, err } - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + // NewGCMWithRandomNonce in go 1.24 handles setting up the nonce and adding it to the encrypted output + gcm, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { return encrypted, err } - return gcm.Seal(nonce, nonce, plaintext, nil), nil + return gcm.Seal(nil, nil, plaintext, nil), nil } func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err error) { @@ -89,19 +87,17 @@ func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err err return encrypted, errors.Wrap(err, "Error creating cypher block") } - gcm, err := cipher.NewGCM(block) + // NewGCMWithRandomNonce in go 1.24 handles reading the nonce from the encrypted input for us + gcm, err := cipher.NewGCMWithRandomNonce(block) if err != nil { return encrypted, errors.Wrap(err, "Error creating GCM") } - nonceSize := gcm.NonceSize() - if len(encrypted) < nonceSize { + if len(encrypted) < gcm.NonceSize() { return encrypted, errEncryptedStringTooShort } - nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:] - - plaintextByte, err = gcm.Open(nil, nonce, ciphertextByteClean, nil) + plaintextByte, err = gcm.Open(nil, nil, encrypted, nil) if err != nil { return encrypted, errors.Wrap(err, "Error decrypting text") } diff --git a/api/database/boltdb/json_test.go b/api/database/boltdb/json_test.go index 577aa2cfd..32813f907 100644 --- a/api/database/boltdb/json_test.go +++ b/api/database/boltdb/json_test.go @@ -1,12 +1,19 @@ package boltdb import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" "crypto/sha256" + "encoding/base64" "fmt" + "io" "testing" "github.com/gofrs/uuid" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) const ( @@ -160,7 +167,7 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) { } key := secretToEncryptionKey(passphrase) - conn := DbConnection{EncryptionKey: key} + conn := DbConnection{EncryptionKey: key, isEncrypted: true} for _, test := range tests { t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) { @@ -175,3 +182,94 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) { }) } } + +func Test_NonceSources(t *testing.T) { + // ensure that the new go 1.24 NewGCMWithRandomNonce works correctly with + // the old way of creating and including the nonce + + encryptOldFn := func(plaintext []byte, passphrase []byte) (encrypted []byte, err error) { + block, _ := aes.NewCipher(passphrase) + gcm, err := cipher.NewGCM(block) + if err != nil { + return encrypted, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return encrypted, err + } + + return gcm.Seal(nonce, nonce, plaintext, nil), nil + } + + decryptOldFn := func(encrypted []byte, passphrase []byte) (plaintext []byte, err error) { + block, err := aes.NewCipher(passphrase) + if err != nil { + return encrypted, errors.Wrap(err, "Error creating cypher block") + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return encrypted, errors.Wrap(err, "Error creating GCM") + } + + nonceSize := gcm.NonceSize() + if len(encrypted) < nonceSize { + return encrypted, errEncryptedStringTooShort + } + + nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:] + + plaintext, err = gcm.Open(nil, nonce, ciphertextByteClean, nil) + if err != nil { + return encrypted, errors.Wrap(err, "Error decrypting text") + } + + return plaintext, err + } + + encryptNewFn := encrypt + decryptNewFn := decrypt + + passphrase := make([]byte, 32) + _, err := io.ReadFull(rand.Reader, passphrase) + require.NoError(t, err) + + junk := make([]byte, 1024) + _, err = io.ReadFull(rand.Reader, junk) + require.NoError(t, err) + + junkEnc := make([]byte, base64.StdEncoding.EncodedLen(len(junk))) + base64.StdEncoding.Encode(junkEnc, junk) + + cases := [][]byte{ + []byte("test"), + []byte("35"), + []byte("9ca4a1dd-a439-4593-b386-a7dfdc2e9fc6"), + []byte(jsonobject), + passphrase, + junk, + junkEnc, + } + + for _, plain := range cases { + var enc, dec []byte + var err error + + enc, err = encryptOldFn(plain, passphrase) + require.NoError(t, err) + + dec, err = decryptNewFn(enc, passphrase) + require.NoError(t, err) + + require.Equal(t, plain, dec) + + enc, err = encryptNewFn(plain, passphrase) + require.NoError(t, err) + + dec, err = decryptOldFn(enc, passphrase) + require.NoError(t, err) + + require.Equal(t, plain, dec) + } +} diff --git a/api/http/handler/backup/backup_test.go b/api/http/handler/backup/backup_test.go index 51fdf3e95..2755e1fd8 100644 --- a/api/http/handler/backup/backup_test.go +++ b/api/http/handler/backup/backup_test.go @@ -18,10 +18,15 @@ import ( "github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/http/offlinegate" "github.com/portainer/portainer/api/internal/testhelpers" + "github.com/portainer/portainer/pkg/fips" "github.com/stretchr/testify/assert" ) +func init() { + fips.InitFIPS(false) +} + func listFiles(dir string) []string { items := make([]string, 0) filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { diff --git a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go index b63db356d..6cb614449 100644 --- a/api/http/handler/customtemplates/customtemplate_git_fetch_test.go +++ b/api/http/handler/customtemplates/customtemplate_git_fetch_test.go @@ -20,12 +20,17 @@ import ( "github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/jwt" + "github.com/portainer/portainer/pkg/fips" httperror "github.com/portainer/portainer/pkg/libhttp/error" "github.com/segmentio/encoding/json" "github.com/stretchr/testify/assert" ) +func init() { + fips.InitFIPS(false) +} + var testFileContent = "abcdefg" type TestGitService struct { diff --git a/api/http/handler/motd/motd.go b/api/http/handler/motd/motd.go index 4117de830..2865db245 100644 --- a/api/http/handler/motd/motd.go +++ b/api/http/handler/motd/motd.go @@ -60,7 +60,7 @@ func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) { message := strings.Join(data.Message, "\n") - hash := libcrypto.HashFromBytes([]byte(message)) + hash := libcrypto.InsecureHashFromBytes([]byte(message)) resp := motdResponse{ Title: data.Title, Message: message, diff --git a/go.mod b/go.mod index 2ef7032f2..2966d54f1 100644 --- a/go.mod +++ b/go.mod @@ -236,6 +236,7 @@ require ( github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/portainer/agent v0.0.0-20250713222305-88cd348ec200 // indirect github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.62.0 // indirect diff --git a/pkg/fips/fips.go b/pkg/fips/fips.go new file mode 100644 index 000000000..a10ea1352 --- /dev/null +++ b/pkg/fips/fips.go @@ -0,0 +1,39 @@ +package fips + +import ( + "crypto/fips140" + "sync" + + "github.com/rs/zerolog/log" +) + +var fipsMode, isInitialised bool + +var once sync.Once + +func InitFIPS(enabled bool) { + once.Do(func() { + isInitialised = true + fipsMode = enabled + + if enabled && !fips140.Enabled() { + log.Fatal().Msg("If FIPS mode is enabled then the fips140 GODEBUG environment variable must be set") + } + }) +} + +func FIPSMode() bool { + if !isInitialised { + log.Fatal().Msg("Could not determine if FIPS mode is enabled because InitFIPS was never called") + } + + return fipsMode +} + +func CanTLSSkipVerify() bool { + if !isInitialised { + log.Fatal().Msg("Could not determine if FIPS mode is enabled because InitFIPS was never called") + } + + return !fipsMode +} diff --git a/pkg/fips/fips_test.go b/pkg/fips/fips_test.go new file mode 100644 index 000000000..c19a5210f --- /dev/null +++ b/pkg/fips/fips_test.go @@ -0,0 +1,15 @@ +package fips + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInitFIPS(t *testing.T) { + InitFIPS(false) + + require.False(t, FIPSMode()) + + require.True(t, CanTLSSkipVerify()) +} diff --git a/pkg/libcrypto/decrypt.go b/pkg/libcrypto/decrypt.go index ef0eb9d41..f57c0939d 100644 --- a/pkg/libcrypto/decrypt.go +++ b/pkg/libcrypto/decrypt.go @@ -4,6 +4,8 @@ import ( "crypto/aes" "crypto/cipher" "errors" + + "github.com/portainer/portainer/pkg/fips" ) // Decrypt decrypts data using 256-bit AES-GCM. This both hides the content of @@ -11,14 +13,25 @@ import ( // form nonce|ciphertext|tag where '|' indicates concatenation. // Creates a 32bit hash of the key before decrypting the data. func Decrypt(data []byte, key []byte) ([]byte, error) { - hashKey := Hash32Bit(key) + return decrypt(data, key, fips.FIPSMode()) +} + +func decrypt(data []byte, key []byte, fips bool) ([]byte, error) { + var hashKey []byte + if fips { + // sha256 hash 32 bytes + hashKey = HashFromBytes(key) + } else { + // 16 byte hash, hex encoded is 32 bytes + hashKey = InsecureHash32Bytes(key) + } block, err := aes.NewCipher(hashKey) if err != nil { return nil, err } - gcm, err := cipher.NewGCM(block) + gcm, err := cipher.NewGCMWithRandomNonce(block) if err != nil { return nil, err } @@ -28,8 +41,8 @@ func Decrypt(data []byte, key []byte) ([]byte, error) { } return gcm.Open(nil, - data[:gcm.NonceSize()], - data[gcm.NonceSize():], + nil, + data, nil, ) } diff --git a/pkg/libcrypto/encrypt.go b/pkg/libcrypto/encrypt.go index c397155cb..a0ca2e981 100644 --- a/pkg/libcrypto/encrypt.go +++ b/pkg/libcrypto/encrypt.go @@ -3,8 +3,8 @@ package libcrypto import ( "crypto/aes" "crypto/cipher" - "crypto/rand" - "io" + + "github.com/portainer/portainer/pkg/fips" ) // Encrypt encrypts data using 256-bit AES-GCM. This both hides the content of @@ -12,23 +12,26 @@ import ( // form nonce|ciphertext|tag where '|' indicates concatenation. // Creates a 32bit hash of the key before encrypting the data. func Encrypt(data, key []byte) ([]byte, error) { - hashKey := Hash32Bit(key) + return encrypt(data, key, fips.FIPSMode()) +} + +func encrypt(data, key []byte, fips bool) ([]byte, error) { + var hashKey []byte + if fips { + hashKey = HashFromBytes(key) + } else { + hashKey = InsecureHash32Bytes(key) + } block, err := aes.NewCipher(hashKey) if err != nil { return nil, err } - gcm, err := cipher.NewGCM(block) + gcm, err := cipher.NewGCMWithRandomNonce(block) if err != nil { return nil, err } - nonce := make([]byte, gcm.NonceSize()) - _, err = io.ReadFull(rand.Reader, nonce) - if err != nil { - return nil, err - } - - return gcm.Seal(nonce, nonce, data, nil), nil + return gcm.Seal(nil, nil, data, nil), nil } diff --git a/pkg/libcrypto/encrypt_test.go b/pkg/libcrypto/encrypt_test.go new file mode 100644 index 000000000..06d6d044b --- /dev/null +++ b/pkg/libcrypto/encrypt_test.go @@ -0,0 +1,56 @@ +package libcrypto + +import ( + "crypto/rand" + "io" + "testing" + + "github.com/portainer/portainer/pkg/fips" + "github.com/stretchr/testify/require" +) + +func init() { + fips.InitFIPS(false) +} + +func TestEncryptDecrypt(t *testing.T) { + dataFn := func() []byte { + data := make([]byte, 1024) + _, err := io.ReadFull(rand.Reader, data) + require.NoError(t, err) + + return data + } + + fn := func(t *testing.T, fips bool) { + data := dataFn() + + encrypted, err := encrypt(data, []byte("test"), fips) + require.NoError(t, err) + + decrypted, err := decrypt(encrypted, []byte("test"), fips) + require.NoError(t, err) + + require.Equal(t, data, decrypted) + } + + t.Run("fips", func(t *testing.T) { + fn(t, true) + }) + + t.Run("non-fips", func(t *testing.T) { + fn(t, false) + }) + + t.Run("system_fips_mode", func(t *testing.T) { + data := dataFn() + + encrypted, err := Encrypt(data, []byte("test")) + require.NoError(t, err) + + decrypted, err := Decrypt(encrypted, []byte("test")) + require.NoError(t, err) + + require.Equal(t, data, decrypted) + }) +} diff --git a/pkg/libcrypto/hash.go b/pkg/libcrypto/hash.go index 6047e2dc7..143f1489e 100644 --- a/pkg/libcrypto/hash.go +++ b/pkg/libcrypto/hash.go @@ -2,18 +2,26 @@ package libcrypto import ( "crypto/md5" + "crypto/sha256" "encoding/hex" ) -// HashFromBytes returns the hash of the specified data -func HashFromBytes(data []byte) []byte { +// InsecureHashFromBytes returns the 16 byte md5 hash of the specified data +func InsecureHashFromBytes(data []byte) []byte { digest := md5.New() digest.Write(data) return digest.Sum(nil) } -// Hash32Bit returns a hexadecimal encoded hash -func Hash32Bit(data []byte) []byte { - hash := HashFromBytes(data) +// InsecureHash32Bytes returns a hexadecimal encoded hash to make a 16 byte md5 hash into 32 bytes +func InsecureHash32Bytes(data []byte) []byte { + hash := InsecureHashFromBytes(data) return []byte(hex.EncodeToString(hash)) } + +// HashFromBytes returns the 32 byte sha256 hash of the specified data +func HashFromBytes(data []byte) []byte { + hash := sha256.New() + hash.Write(data) + return hash.Sum(nil) +}