Fix for double encryption of secrets (#216)

* Update db init code to verify that all the required environment
variables are set.

* Add a workaround for integration secret encryption code - make sure we
don't try to encrypt the secret twice.

This is needed due to the bug in the code which would encrypt secrets
even if they are already encrypted.

* fixes spelling & folder structure

---------

Co-authored-by: Christian Beutel <>
This commit is contained in:
Tomaz Muraus
2025-03-28 19:54:43 +01:00
committed by GitHub
parent 04bd60612e
commit 123470b81b
3 changed files with 189 additions and 1 deletions

View File

@@ -26,7 +26,31 @@ import (
"pocketbase/util" "pocketbase/util"
) )
const defaultMeiliMasterKey = "vODkljPcfFANYNepCHyDyGjzAMPcdHnrb6X5KyXQPWo"
// verifySettings checks if the required environment variables are set.
// If they are not set, it logs an error and exits the program.
func verifySettings() {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 || len(encryptionKey) < 32 {
log.Fatal("POCKETBASE_ENCRYPTION_KEY not set or is shorter than 32 bytes")
}
meiliMasterKey := os.Getenv("MEILI_MASTER_KEY")
if len(meiliMasterKey) == 0 || len(meiliMasterKey) < 32 {
log.Fatal("MEILI_MASTER_KEY not set or is shorter than 32 bytes")
}
if meiliMasterKey == defaultMeiliMasterKey {
log.Println("MEILI_MASTER_KEY is still set to the default value. Please change it to a secure value.")
}
}
func main() { func main() {
verifySettings()
app := pocketbase.New() app := pocketbase.New()
client := initializeMeiliSearch() client := initializeMeiliSearch()
@@ -496,7 +520,11 @@ func encryptIntegrationSecrets(app core.App, r *core.Record) error {
} }
for _, secretKey := range secretKeys { for _, secretKey := range secretKeys {
if secret, ok := integration[secretKey].(string); ok && len(secret) > 0 { // If the secret is already encrypted, we don't re-encrypt it.
// TODO: This is a bit of a hack, we should handle this in a more robust way (e.g.
// storing flag on the record or prefixing encrypted strings with enc: or smilar).
// Doing that would also potentially allow us to support key rotation in the future.
if secret, ok := integration[secretKey].(string); ok && len(secret) > 0 && !util.CanDecryptSecret(secret) {
encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey) encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey)
if err != nil { if err != nil {
return err return err

107
db/tests/secrets_test.go Normal file
View File

@@ -0,0 +1,107 @@
package util
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"os"
"pocketbase/util"
"testing"
"github.com/pocketbase/pocketbase/tools/security"
)
func TestLooksLikeEncrypted(t *testing.T) {
// Sub-test for non-base64 input.
t.Run("NonBase64", func(t *testing.T) {
if util.LooksLikeEncrypted("not a base64 string!") {
t.Errorf("Expected false for non-base64 input")
}
})
// Sub-test for an empty string.
t.Run("EmptyString", func(t *testing.T) {
if util.LooksLikeEncrypted("") {
t.Errorf("Expected false for empty string")
}
})
// Create a dummy byte slice to determine the nonce size using a dummy 32-byte key.
dummyKey := make([]byte, 32)
block, err := aes.NewCipher(dummyKey)
if err != nil {
t.Fatalf("Failed to create dummy cipher: %v", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("Failed to create dummy GCM: %v", err)
}
nonceSize := gcm.NonceSize()
minSize := nonceSize + 16
// Sub-test for data length less than nonce+16 bytes.
t.Run("ShortData", func(t *testing.T) {
shortData := make([]byte, minSize-1)
encodedShort := base64.StdEncoding.EncodeToString(shortData)
if util.LooksLikeEncrypted(encodedShort) {
t.Errorf("Expected false for data length < nonce+16, got true")
}
})
// Sub-test for data length exactly equal to nonce+16 bytes.
t.Run("ExactData", func(t *testing.T) {
exactData := make([]byte, minSize)
encodedExact := base64.StdEncoding.EncodeToString(exactData)
if !util.LooksLikeEncrypted(encodedExact) {
t.Errorf("Expected true for data length == nonce+16")
}
})
// Sub-test for data length greater than nonce+16 bytes.
t.Run("LongData", func(t *testing.T) {
longData := make([]byte, minSize+10)
encodedLong := base64.StdEncoding.EncodeToString(longData)
if !util.LooksLikeEncrypted(encodedLong) {
t.Errorf("Expected true for data length > nonce+16")
}
})
}
func TestCanDecryptSecret(t *testing.T) {
// Sub-test: When POCKETBASE_ENCRYPTION_KEY is not set.
t.Run("NoEncryptionKey", func(t *testing.T) {
os.Unsetenv("POCKETBASE_ENCRYPTION_KEY")
if util.CanDecryptSecret("anyciphertext") {
t.Errorf("Expected false when POCKETBASE_ENCRYPTION_KEY is not set")
}
})
// Set a valid 32-byte key (for AES-256).
encryptionKey := "0123456789abcdef0123456789abcdef" // exactly 32 bytes
os.Setenv("POCKETBASE_ENCRYPTION_KEY", encryptionKey)
// Cleanup environment variable after the test.
t.Cleanup(func() {
os.Unsetenv("POCKETBASE_ENCRYPTION_KEY")
})
// Sub-test: Valid ciphertext should return true.
t.Run("ValidCiphertext", func(t *testing.T) {
plaintext := "my secret message"
// Cast plaintext to []byte as required by security.Encrypt.
ciphertext, err := security.Encrypt([]byte(plaintext), encryptionKey)
if err != nil {
t.Fatalf("Failed to encrypt secret: %v", err)
}
if !util.CanDecryptSecret(ciphertext) {
t.Errorf("Expected true for valid ciphertext with correct key")
}
})
// Sub-test: Invalid ciphertext should return false.
t.Run("InvalidCiphertext", func(t *testing.T) {
invalidCiphertext := "invalid_ciphertext"
if util.CanDecryptSecret(invalidCiphertext) {
t.Errorf("Expected false for invalid ciphertext")
}
})
}

53
db/util/secrets.go Normal file
View File

@@ -0,0 +1,53 @@
package util
import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"os"
"github.com/pocketbase/pocketbase/tools/security"
)
// LooksLikeEncrypted checks if the given string looks like base64 encoded and AES encrypted data.
// This is format used by pocketbase encryption.
func LooksLikeEncrypted(s string) bool {
ciphertext, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return false
}
// Use a dummy 32-byte key since we only want the nonce size
dummyKey := make([]byte, 32)
block, err := aes.NewCipher(dummyKey)
if err != nil {
return false
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return false
}
nonceSize := gcm.NonceSize()
// At minimum: nonce + GCM auth tag (16 bytes)
return len(ciphertext) >= nonceSize+16
}
// Return true if the provided value can be decrypted using the secret key
func CanDecryptSecret(ciphertext string) bool {
encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY")
if len(encryptionKey) == 0 {
return false
}
decryptedSecret, err := security.Decrypt(ciphertext, encryptionKey)
if len(decryptedSecret) > 0 && err == nil {
return true
}
return false
}