1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 05:45:22 +02:00

fix(encryption): replace encryption related methods for fips mode [be-11933] (#919)

Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
This commit is contained in:
Malcolm Lockyer 2025-08-04 17:04:03 +12:00 committed by GitHub
parent 163aa57e5c
commit d306d7a983
19 changed files with 701 additions and 169 deletions

View file

@ -9,10 +9,15 @@ import (
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/datastore"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func init() {
fips.InitFIPS(false)
}
func TestPingAgentPanic(t *testing.T) { func TestPingAgentPanic(t *testing.T) {
endpoint := &portainer.Endpoint{ endpoint := &portainer.Endpoint{
ID: 1, ID: 1,

View file

@ -49,6 +49,7 @@ import (
"github.com/portainer/portainer/api/stacks/deployments" "github.com/portainer/portainer/api/stacks/deployments"
"github.com/portainer/portainer/pkg/build" "github.com/portainer/portainer/pkg/build"
"github.com/portainer/portainer/pkg/featureflags" "github.com/portainer/portainer/pkg/featureflags"
"github.com/portainer/portainer/pkg/fips"
"github.com/portainer/portainer/pkg/libhelm" "github.com/portainer/portainer/pkg/libhelm"
libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types" libhelmtypes "github.com/portainer/portainer/pkg/libhelm/types"
"github.com/portainer/portainer/pkg/libstack/compose" "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) fileService := initFileService(*flags.Data)
encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName) encryptionKey := loadEncryptionSecretKey(*flags.SecretKeyName)
if encryptionKey == nil { if encryptionKey == nil {

View file

@ -6,11 +6,15 @@ import (
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"strings"
"github.com/portainer/portainer/pkg/fips"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"golang.org/x/crypto/pbkdf2"
"golang.org/x/crypto/scrypt" "golang.org/x/crypto/scrypt"
) )
@ -19,35 +23,69 @@ const (
aesGcmHeader = "AES256-GCM" // The encrypted file header aesGcmHeader = "AES256-GCM" // The encrypted file header
aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm aesGcmBlockSize = 1024 * 1024 // 1MB block for aes gcm
aesGcmFIPSHeader = "FIPS-AES256-GCM"
aesGcmFIPSBlockSize = 16 * 1024 * 1024 // 16MB block for aes gcm
// Argon2 settings // 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 // 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 // https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#argon2id
argon2MemoryCost = 12 * 1024 argon2MemoryCost = 12 * 1024
argon2TimeCost = 3 argon2TimeCost = 3
argon2Threads = 1 argon2Threads = 1
argon2KeyLength = 32 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 // 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 { func AesEncrypt(input io.Reader, output io.Writer, passphrase []byte) error {
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 { if err := aesEncryptGCM(input, output, passphrase); err != nil {
return fmt.Errorf("error encrypting file: %w", err) return fmt.Errorf("error encrypting file: %w", err)
} }
}
return nil return nil
} }
// AesDecrypt reads from input, decrypts with AES-256 and returns the reader to read the decrypted content from // 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) { 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 // Read file header to determine how it was encrypted
inputReader := bufio.NewReader(input) inputReader := bufio.NewReader(input)
header, err := inputReader.Peek(len(aesGcmHeader)) header, err := inputReader.Peek(len(aesGcmFIPSHeader))
if err != nil { if err != nil {
return nil, fmt.Errorf("error reading encrypted backup file header: %w", err) 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) reader, err := aesDecryptGCM(inputReader, passphrase)
if err != nil { if err != nil {
return nil, fmt.Errorf("error decrypting file: %w", err) 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 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. // 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. // 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 // note: This function used to decrypt files that were encrypted without a header i.e. old archives

View file

@ -7,9 +7,15 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func init() {
fips.InitFIPS(false)
}
const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
func randBytes(n int) []byte { func randBytes(n int) []byte {
@ -20,9 +26,13 @@ func randBytes(n int) []byte {
return b 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) { func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
const passphrase = "passphrase" const passphrase = "passphrase"
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc, decryptShouldSucceed bool) {
tmpdir := t.TempDir() tmpdir := t.TempDir()
var ( var (
@ -39,12 +49,12 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
encryptedFileWriter, _ := os.Create(encryptedFilePath) encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase)) err := encrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file") require.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close() encryptedFileWriter.Close()
encryptedContent, err := os.ReadFile(encryptedFilePath) encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file") require.Nil(t, err, "Couldn't read encrypted file")
assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted") assert.NotEqual(t, encryptedContent, content, "Content wasn't encrypted")
encryptedFileReader, _ := os.Open(encryptedFilePath) encryptedFileReader, _ := os.Open(encryptedFilePath)
@ -53,17 +63,72 @@ func Test_encryptAndDecrypt_withTheSamePassword(t *testing.T) {
decryptedFileWriter, _ := os.Create(decryptedFilePath) decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close() defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase)) decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file") 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) decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") 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) { func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+" const passphrase = "A strong passphrase with special characters: !@#$%^&*()_+"
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
tmpdir := t.TempDir() tmpdir := t.TempDir()
var ( var (
@ -80,7 +145,7 @@ func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
encryptedFileWriter, _ := os.Create(encryptedFilePath) encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte(passphrase)) err := encrypt(originFile, encryptedFileWriter, []byte(passphrase))
assert.Nil(t, err, "Failed to encrypt a file") assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close() encryptedFileWriter.Close()
@ -94,16 +159,26 @@ func Test_encryptAndDecrypt_withStrongPassphrase(t *testing.T) {
decryptedFileWriter, _ := os.Create(decryptedFilePath) decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close() defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte(passphrase)) decryptedReader, err := decrypt(encryptedFileReader, []byte(passphrase))
assert.Nil(t, err, "Failed to decrypt file") assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader) io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath) decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") 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) { func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
tmpdir := t.TempDir() tmpdir := t.TempDir()
var ( var (
@ -120,7 +195,7 @@ func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
encryptedFileWriter, _ := os.Create(encryptedFilePath) encryptedFileWriter, _ := os.Create(encryptedFilePath)
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase")) err := encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
assert.Nil(t, err, "Failed to encrypt a file") assert.Nil(t, err, "Failed to encrypt a file")
encryptedFileWriter.Close() encryptedFileWriter.Close()
@ -134,16 +209,26 @@ func Test_encryptAndDecrypt_withTheSamePasswordSmallFile(t *testing.T) {
decryptedFileWriter, _ := os.Create(decryptedFilePath) decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close() defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("passphrase")) decryptedReader, err := decrypt(encryptedFileReader, []byte("passphrase"))
assert.Nil(t, err, "Failed to decrypt file") assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader) io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath) decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") 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) { func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
tmpdir := t.TempDir() tmpdir := t.TempDir()
var ( var (
@ -161,7 +246,7 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
encryptedFileWriter, _ := os.Create(encryptedFilePath) encryptedFileWriter, _ := os.Create(encryptedFilePath)
defer encryptedFileWriter.Close() defer encryptedFileWriter.Close()
err := AesEncrypt(originFile, encryptedFileWriter, []byte("")) err := encrypt(originFile, encryptedFileWriter, []byte(""))
assert.Nil(t, err, "Failed to encrypt a file") assert.Nil(t, err, "Failed to encrypt a file")
encryptedContent, err := os.ReadFile(encryptedFilePath) encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file") assert.Nil(t, err, "Couldn't read encrypted file")
@ -173,16 +258,26 @@ func Test_encryptAndDecrypt_withEmptyPassword(t *testing.T) {
decryptedFileWriter, _ := os.Create(decryptedFilePath) decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close() defer decryptedFileWriter.Close()
decryptedReader, err := AesDecrypt(encryptedFileReader, []byte("")) decryptedReader, err := decrypt(encryptedFileReader, []byte(""))
assert.Nil(t, err, "Failed to decrypt file") assert.Nil(t, err, "Failed to decrypt file")
io.Copy(decryptedFileWriter, decryptedReader) io.Copy(decryptedFileWriter, decryptedReader)
decryptedContent, _ := os.ReadFile(decryptedFilePath) decryptedContent, _ := os.ReadFile(decryptedFilePath)
assert.Equal(t, content, decryptedContent, "Original and decrypted content should match") 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) { func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T) {
testFunc := func(t *testing.T, encrypt encryptFunc, decrypt decryptFunc) {
tmpdir := t.TempDir() tmpdir := t.TempDir()
var ( var (
@ -200,7 +295,7 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
encryptedFileWriter, _ := os.Create(encryptedFilePath) encryptedFileWriter, _ := os.Create(encryptedFilePath)
defer encryptedFileWriter.Close() defer encryptedFileWriter.Close()
err := AesEncrypt(originFile, encryptedFileWriter, []byte("passphrase")) err := encrypt(originFile, encryptedFileWriter, []byte("passphrase"))
assert.Nil(t, err, "Failed to encrypt a file") assert.Nil(t, err, "Failed to encrypt a file")
encryptedContent, err := os.ReadFile(encryptedFilePath) encryptedContent, err := os.ReadFile(encryptedFilePath)
assert.Nil(t, err, "Couldn't read encrypted file") assert.Nil(t, err, "Couldn't read encrypted file")
@ -212,6 +307,15 @@ func Test_decryptWithDifferentPassphrase_shouldProduceWrongResult(t *testing.T)
decryptedFileWriter, _ := os.Create(decryptedFilePath) decryptedFileWriter, _ := os.Create(decryptedFilePath)
defer decryptedFileWriter.Close() defer decryptedFileWriter.Close()
_, err = AesDecrypt(encryptedFileReader, []byte("garbage")) _, err = decrypt(encryptedFileReader, []byte("garbage"))
assert.NotNil(t, err, "Should not allow decrypt with wrong passphrase") 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)
})
} }

View file

@ -112,7 +112,7 @@ func (service *ECDSAService) CreateSignature(message string) (string, error) {
message = service.secret message = service.secret
} }
hash := libcrypto.HashFromBytes([]byte(message)) hash := libcrypto.InsecureHashFromBytes([]byte(message))
r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash) r, s, err := ecdsa.Sign(rand.Reader, service.privateKey, hash)
if err != nil { if err != nil {

22
api/crypto/ecdsa_test.go Normal file
View file

@ -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)
}

View file

@ -15,7 +15,7 @@ func NewNonce(size int) *Nonce {
} }
// NewRandomNonce generates a new initial nonce with the lower byte set to a random value // 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 // 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 // https://www.oreilly.com/library/view/secure-programming-cookbook/0596003943/ch04s09.html
func NewRandomNonce(size int) (*Nonce, error) { func NewRandomNonce(size int) (*Nonce, error) {

View file

@ -4,8 +4,6 @@ import (
"bytes" "bytes"
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/rand"
"io"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/segmentio/encoding/json" "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 // https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption
func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) { func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
block, _ := aes.NewCipher(passphrase) block, err := aes.NewCipher(passphrase)
gcm, err := cipher.NewGCM(block)
if err != nil { if err != nil {
return encrypted, err return encrypted, err
} }
nonce := make([]byte, gcm.NonceSize()) // NewGCMWithRandomNonce in go 1.24 handles setting up the nonce and adding it to the encrypted output
if _, err := io.ReadFull(rand.Reader, nonce); err != nil { gcm, err := cipher.NewGCMWithRandomNonce(block)
if err != nil {
return encrypted, err 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) { 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") 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 { if err != nil {
return encrypted, errors.Wrap(err, "Error creating GCM") return encrypted, errors.Wrap(err, "Error creating GCM")
} }
nonceSize := gcm.NonceSize() if len(encrypted) < gcm.NonceSize() {
if len(encrypted) < nonceSize {
return encrypted, errEncryptedStringTooShort return encrypted, errEncryptedStringTooShort
} }
nonce, ciphertextByteClean := encrypted[:nonceSize], encrypted[nonceSize:] plaintextByte, err = gcm.Open(nil, nil, encrypted, nil)
plaintextByte, err = gcm.Open(nil, nonce, ciphertextByteClean, nil)
if err != nil { if err != nil {
return encrypted, errors.Wrap(err, "Error decrypting text") return encrypted, errors.Wrap(err, "Error decrypting text")
} }

View file

@ -1,12 +1,19 @@
package boltdb package boltdb
import ( import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"fmt" "fmt"
"io"
"testing" "testing"
"github.com/gofrs/uuid" "github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
const ( const (
@ -160,7 +167,7 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
} }
key := secretToEncryptionKey(passphrase) key := secretToEncryptionKey(passphrase)
conn := DbConnection{EncryptionKey: key} conn := DbConnection{EncryptionKey: key, isEncrypted: true}
for _, test := range tests { for _, test := range tests {
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) { 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)
}
}

View file

@ -18,10 +18,15 @@ import (
"github.com/portainer/portainer/api/crypto" "github.com/portainer/portainer/api/crypto"
"github.com/portainer/portainer/api/http/offlinegate" "github.com/portainer/portainer/api/http/offlinegate"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/pkg/fips"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func init() {
fips.InitFIPS(false)
}
func listFiles(dir string) []string { func listFiles(dir string) []string {
items := make([]string, 0) items := make([]string, 0)
filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {

View file

@ -20,12 +20,17 @@ import (
"github.com/portainer/portainer/api/internal/authorization" "github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/testhelpers" "github.com/portainer/portainer/api/internal/testhelpers"
"github.com/portainer/portainer/api/jwt" "github.com/portainer/portainer/api/jwt"
"github.com/portainer/portainer/pkg/fips"
httperror "github.com/portainer/portainer/pkg/libhttp/error" httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/segmentio/encoding/json" "github.com/segmentio/encoding/json"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func init() {
fips.InitFIPS(false)
}
var testFileContent = "abcdefg" var testFileContent = "abcdefg"
type TestGitService struct { type TestGitService struct {

View file

@ -60,7 +60,7 @@ func (handler *Handler) motd(w http.ResponseWriter, r *http.Request) {
message := strings.Join(data.Message, "\n") message := strings.Join(data.Message, "\n")
hash := libcrypto.HashFromBytes([]byte(message)) hash := libcrypto.InsecureHashFromBytes([]byte(message))
resp := motdResponse{ resp := motdResponse{
Title: data.Title, Title: data.Title,
Message: message, Message: message,

1
go.mod
View file

@ -236,6 +236,7 @@ require (
github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // 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/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_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/common v0.62.0 // indirect

39
pkg/fips/fips.go Normal file
View file

@ -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
}

15
pkg/fips/fips_test.go Normal file
View file

@ -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())
}

View file

@ -4,6 +4,8 @@ import (
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"errors" "errors"
"github.com/portainer/portainer/pkg/fips"
) )
// Decrypt decrypts data using 256-bit AES-GCM. This both hides the content of // 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. // form nonce|ciphertext|tag where '|' indicates concatenation.
// Creates a 32bit hash of the key before decrypting the data. // Creates a 32bit hash of the key before decrypting the data.
func Decrypt(data []byte, key []byte) ([]byte, error) { 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) block, err := aes.NewCipher(hashKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
gcm, err := cipher.NewGCM(block) gcm, err := cipher.NewGCMWithRandomNonce(block)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -28,8 +41,8 @@ func Decrypt(data []byte, key []byte) ([]byte, error) {
} }
return gcm.Open(nil, return gcm.Open(nil,
data[:gcm.NonceSize()], nil,
data[gcm.NonceSize():], data,
nil, nil,
) )
} }

View file

@ -3,8 +3,8 @@ package libcrypto
import ( import (
"crypto/aes" "crypto/aes"
"crypto/cipher" "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 // 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. // form nonce|ciphertext|tag where '|' indicates concatenation.
// Creates a 32bit hash of the key before encrypting the data. // Creates a 32bit hash of the key before encrypting the data.
func Encrypt(data, key []byte) ([]byte, error) { 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) block, err := aes.NewCipher(hashKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
gcm, err := cipher.NewGCM(block) gcm, err := cipher.NewGCMWithRandomNonce(block)
if err != nil { if err != nil {
return nil, err return nil, err
} }
nonce := make([]byte, gcm.NonceSize()) return gcm.Seal(nil, nil, data, nil), nil
_, err = io.ReadFull(rand.Reader, nonce)
if err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, data, nil), nil
} }

View file

@ -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)
})
}

View file

@ -2,18 +2,26 @@ package libcrypto
import ( import (
"crypto/md5" "crypto/md5"
"crypto/sha256"
"encoding/hex" "encoding/hex"
) )
// HashFromBytes returns the hash of the specified data // InsecureHashFromBytes returns the 16 byte md5 hash of the specified data
func HashFromBytes(data []byte) []byte { func InsecureHashFromBytes(data []byte) []byte {
digest := md5.New() digest := md5.New()
digest.Write(data) digest.Write(data)
return digest.Sum(nil) return digest.Sum(nil)
} }
// Hash32Bit returns a hexadecimal encoded hash // InsecureHash32Bytes returns a hexadecimal encoded hash to make a 16 byte md5 hash into 32 bytes
func Hash32Bit(data []byte) []byte { func InsecureHash32Bytes(data []byte) []byte {
hash := HashFromBytes(data) hash := InsecureHashFromBytes(data)
return []byte(hex.EncodeToString(hash)) 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)
}