diff --git a/db/main.go b/db/main.go index e092fa93..81cf9c98 100644 --- a/db/main.go +++ b/db/main.go @@ -26,7 +26,31 @@ import ( "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() { + verifySettings() + app := pocketbase.New() client := initializeMeiliSearch() @@ -496,7 +520,11 @@ func encryptIntegrationSecrets(app core.App, r *core.Record) error { } 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) if err != nil { return err diff --git a/db/tests/secrets_test.go b/db/tests/secrets_test.go new file mode 100644 index 00000000..ced20373 --- /dev/null +++ b/db/tests/secrets_test.go @@ -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") + } + }) +} diff --git a/db/util/secrets.go b/db/util/secrets.go new file mode 100644 index 00000000..f45c8b51 --- /dev/null +++ b/db/util/secrets.go @@ -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 +}