feat(boltdb): optimize encrypted connections BE-12995 (#2769)

This commit is contained in:
andres-portainer
2026-06-02 14:58:05 -03:00
committed by GitHub
parent 1fa756372e
commit 99547044bc
8 changed files with 221 additions and 52 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ type Connection interface {
IsEncryptedStore() bool
NeedsEncryptionMigration() (bool, error)
SetEncrypted(encrypted bool)
SetEncrypted(encrypted bool) error
BackupMetadata() (map[string]any, error)
RestoreMetadata(s map[string]any) error
+28 -2
View File
@@ -1,6 +1,8 @@
package boltdb
import (
"crypto/aes"
"crypto/cipher"
"encoding/binary"
"errors"
"fmt"
@@ -40,6 +42,8 @@ type DbConnection struct {
isEncrypted bool
Compact bool
gcm cipher.AEAD
*bolt.DB
}
@@ -75,8 +79,28 @@ func (connection *DbConnection) GetDatabaseFileSize() (int64, error) {
return file.Size(), nil
}
func (connection *DbConnection) SetEncrypted(flag bool) {
func (connection *DbConnection) SetEncrypted(flag bool) error {
connection.isEncrypted = flag
if !flag || connection.EncryptionKey == nil {
connection.gcm = nil
return nil
}
block, err := aes.NewCipher(connection.EncryptionKey)
if err != nil {
return fmt.Errorf("creating AES cipher for database encryption: %w", err)
}
gcm, err := cipher.NewGCMWithRandomNonce(block)
if err != nil {
return fmt.Errorf("creating GCM cipher for database encryption: %w", err)
}
connection.gcm = gcm
return nil
}
// Return true if the database is encrypted
@@ -100,7 +124,9 @@ func (connection *DbConnection) NeedsEncryptionMigration() (bool, error) {
// If we have a loaded encryption key, always set encrypted
if connection.EncryptionKey != nil {
connection.SetEncrypted(true)
if err := connection.SetEncrypted(true); err != nil {
return false, err
}
}
// Check for portainer.db
+52 -1
View File
@@ -131,7 +131,7 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
}
if tc.key {
connection.EncryptionKey = []byte("secret")
connection.EncryptionKey = secretToEncryptionKey("secret")
}
result, err := connection.NeedsEncryptionMigration()
@@ -142,6 +142,57 @@ func Test_NeedsEncryptionMigration(t *testing.T) {
}
}
func TestSetEncrypted_InvalidKeyReturnsError(t *testing.T) {
t.Parallel()
conn := DbConnection{EncryptionKey: []byte("bad")}
err := conn.SetEncrypted(true)
require.Error(t, err)
require.Nil(t, conn.gcm)
}
func TestSetEncrypted_NilKeyDoesNotSetGCM(t *testing.T) {
t.Parallel()
conn := DbConnection{}
err := conn.SetEncrypted(true)
require.NoError(t, err)
require.Nil(t, conn.gcm)
}
func TestSetEncrypted_EnableThenDisableStopsEncryption(t *testing.T) {
t.Parallel()
key := secretToEncryptionKey(passphrase)
conn := DbConnection{EncryptionKey: key}
err := conn.SetEncrypted(true)
require.NoError(t, err)
require.NotNil(t, conn.gcm)
err = conn.SetEncrypted(false)
require.NoError(t, err)
require.Nil(t, conn.gcm)
// MarshalObject must return plaintext after encryption is disabled
data, err := conn.MarshalObject("hello")
require.NoError(t, err)
require.Equal(t, "hello", string(data))
}
func TestNeedsEncryptionMigration_InvalidKeyError(t *testing.T) {
t.Parallel()
conn := DbConnection{
Path: t.TempDir(),
EncryptionKey: []byte("bad"),
}
result, err := conn.NeedsEncryptionMigration()
require.Error(t, err)
require.False(t, result)
}
func TestDBCompaction(t *testing.T) {
t.Parallel()
db := &DbConnection{Path: t.TempDir()}
+10 -36
View File
@@ -2,7 +2,6 @@ package boltdb
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"github.com/pkg/errors"
@@ -28,18 +27,18 @@ func (connection *DbConnection) MarshalObject(object any) ([]byte, error) {
}
}
if connection.getEncryptionKey() == nil {
if connection.gcm == nil {
return buf.Bytes(), nil
}
return encrypt(buf.Bytes(), connection.getEncryptionKey())
return encrypt(buf.Bytes(), connection.gcm), nil
}
// UnmarshalObject decodes an object from binary data
func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
var err error
if connection.getEncryptionKey() != nil {
data, err = decrypt(data, connection.getEncryptionKey())
if connection.gcm != nil {
data, err = decrypt(data, connection.gcm)
if err != nil {
return errors.Wrap(err, "Failed decrypting object")
}
@@ -59,48 +58,23 @@ func (connection *DbConnection) UnmarshalObject(data []byte, object any) error {
return err
}
// mmm, don't have a KMS .... aes GCM seems the most likely from
// https://gist.github.com/atoponce/07d8d4c833873be2f68c34f9afc5a78a#symmetric-encryption
func encrypt(plaintext []byte, passphrase []byte) (encrypted []byte, err error) {
block, err := aes.NewCipher(passphrase)
if err != nil {
return encrypted, err
}
// 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(nil, nil, plaintext, nil), nil
func encrypt(plaintext []byte, gcm cipher.AEAD) []byte {
return gcm.Seal(nil, nil, plaintext, nil)
}
func decrypt(encrypted []byte, passphrase []byte) (plaintextByte []byte, err error) {
func decrypt(encrypted []byte, gcm cipher.AEAD) ([]byte, error) {
if string(encrypted) == "false" {
return []byte("false"), nil
}
block, err := aes.NewCipher(passphrase)
if err != nil {
return encrypted, errors.Wrap(err, "Error creating cypher 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")
}
if len(encrypted) < gcm.NonceSize() {
if len(encrypted) < gcm.Overhead() {
return encrypted, errEncryptedStringTooShort
}
plaintextByte, err = gcm.Open(nil, nil, encrypted, nil)
plaintextByte, err := gcm.Open(nil, nil, encrypted, nil)
if err != nil {
return encrypted, errors.Wrap(err, "Error decrypting text")
}
return plaintextByte, err
return plaintextByte, nil
}
+119 -7
View File
@@ -170,7 +170,10 @@ func Test_ObjectMarshallingEncrypted(t *testing.T) {
}
key := secretToEncryptionKey(passphrase)
conn := DbConnection{EncryptionKey: key, isEncrypted: true}
conn := DbConnection{EncryptionKey: key}
err := conn.SetEncrypted(true)
require.NoError(t, err)
for _, test := range tests {
t.Run(fmt.Sprintf("%s -> %s", test.object, test.expected), func(t *testing.T) {
@@ -232,13 +235,16 @@ func Test_NonceSources(t *testing.T) {
return plaintext, err
}
encryptNewFn := encrypt
decryptNewFn := decrypt
passphrase := make([]byte, 32)
_, err := io.ReadFull(rand.Reader, passphrase)
require.NoError(t, err)
block, err := aes.NewCipher(passphrase)
require.NoError(t, err)
gcm, err := cipher.NewGCMWithRandomNonce(block)
require.NoError(t, err)
junk := make([]byte, 1024)
_, err = io.ReadFull(rand.Reader, junk)
require.NoError(t, err)
@@ -263,13 +269,12 @@ func Test_NonceSources(t *testing.T) {
enc, err = encryptOldFn(plain, passphrase)
require.NoError(t, err)
dec, err = decryptNewFn(enc, passphrase)
dec, err = decrypt(enc, gcm)
require.NoError(t, err)
require.Equal(t, plain, dec)
enc, err = encryptNewFn(plain, passphrase)
require.NoError(t, err)
enc = encrypt(plain, gcm)
dec, err = decryptOldFn(enc, passphrase)
require.NoError(t, err)
@@ -277,3 +282,110 @@ func Test_NonceSources(t *testing.T) {
require.Equal(t, plain, dec)
}
}
func TestDecrypt_FalseStringBypassesDecryption(t *testing.T) {
t.Parallel()
key := secretToEncryptionKey(passphrase)
block, err := aes.NewCipher(key)
require.NoError(t, err)
gcm, err := cipher.NewGCMWithRandomNonce(block)
require.NoError(t, err)
result, err := decrypt([]byte("false"), gcm)
require.NoError(t, err)
require.Equal(t, []byte("false"), result)
}
func TestDecrypt_ShortDataReturnsError(t *testing.T) {
t.Parallel()
key := secretToEncryptionKey(passphrase)
block, err := aes.NewCipher(key)
require.NoError(t, err)
gcm, err := cipher.NewGCMWithRandomNonce(block)
require.NoError(t, err)
short := []byte("short")
result, err := decrypt(short, gcm)
require.ErrorIs(t, err, errEncryptedStringTooShort)
require.Equal(t, short, result)
}
func TestDecrypt_CorruptDataReturnsError(t *testing.T) {
t.Parallel()
key := secretToEncryptionKey(passphrase)
block, err := aes.NewCipher(key)
require.NoError(t, err)
gcm, err := cipher.NewGCMWithRandomNonce(block)
require.NoError(t, err)
// 30 bytes passes the length check but fails authentication
corrupted := make([]byte, 30)
_, err = io.ReadFull(rand.Reader, corrupted)
require.NoError(t, err)
result, err := decrypt(corrupted, gcm)
require.Error(t, err)
require.Equal(t, corrupted, result)
}
// BenchmarkEncryptCachedCipher measures the new approach: cipher created once and reused.
func BenchmarkEncryptCachedCipher(b *testing.B) {
key := secretToEncryptionKey(passphrase)
conn := DbConnection{EncryptionKey: key}
err := conn.SetEncrypted(true)
require.NoError(b, err)
data := []byte(jsonobject)
b.ResetTimer()
for b.Loop() {
_ = encrypt(data, conn.gcm)
}
}
// BenchmarkEncryptPerCallCipher measures the old approach: cipher created on every call.
func BenchmarkEncryptPerCallCipher(b *testing.B) {
key := secretToEncryptionKey(passphrase)
data := []byte(jsonobject)
b.ResetTimer()
for b.Loop() {
block, err := aes.NewCipher(key)
if err != nil {
b.Fatal(err)
}
gcm, err := cipher.NewGCMWithRandomNonce(block)
if err != nil {
b.Fatal(err)
}
_ = gcm.Seal(nil, nil, data, nil)
}
}
// BenchmarkEncryptCachedCipherParallel verifies the cached cipher is safe for concurrent use.
func BenchmarkEncryptCachedCipherParallel(b *testing.B) {
key := secretToEncryptionKey(passphrase)
conn := DbConnection{EncryptionKey: key}
err := conn.SetEncrypted(true)
require.NoError(b, err)
data := []byte(jsonobject)
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
_ = encrypt(data, conn.gcm)
}
})
}
+2 -2
View File
@@ -40,10 +40,10 @@ func (tx *DbTransaction) GetRawBytes(bucketName string, key []byte) ([]byte, err
return nil, fmt.Errorf("%w (bucket=%s, key=%s)", dserrors.ErrObjectNotFound, bucketName, keyToString(key))
}
if tx.conn.getEncryptionKey() != nil {
if tx.conn.gcm != nil {
var err error
if value, err = decrypt(value, tx.conn.getEncryptionKey()); err != nil {
if value, err = decrypt(value, tx.conn.gcm); err != nil {
return value, errors.Wrap(err, "Failed decrypting object")
}
}
+2 -1
View File
@@ -130,7 +130,8 @@ func TestBackupDBFileUsesCorrectPath(t *testing.T) {
_, store := MustNewTestStore(t, true, false)
t.Run("backs up unencrypted db when encrypted flag is false", func(t *testing.T) {
store.connection.SetEncrypted(false)
err := store.connection.SetEncrypted(false)
require.NoError(t, err)
backupFilename, err := store.backupDBFile("")
require.NoError(t, err)
+7 -2
View File
@@ -35,7 +35,9 @@ func (store *Store) Open() (newStore bool, err error) {
// NeedsEncryptionMigration() sets encrypted=true as a side effect when a key exists.
// We need to set it back to false so GetDatabaseFilePath() returns the path to the
// actual unencrypted file (portainer.db) that we want to back up.
store.connection.SetEncrypted(false)
if err := store.connection.SetEncrypted(false); err != nil {
return false, err
}
// Use backupDBFile directly since connection isn't open yet
// and we don't want to trigger the close/open cycle of Backup()
@@ -124,7 +126,10 @@ func (store *Store) Rollback(force bool) error {
}
func (store *Store) encryptDB() error {
store.connection.SetEncrypted(false)
if err := store.connection.SetEncrypted(false); err != nil {
return err
}
if err := store.connection.Open(); err != nil {
return err
}