first commit
This commit is contained in:
155
framework/encrypt/encrypt.go
Normal file
155
framework/encrypt/encrypt.go
Normal file
@@ -0,0 +1,155 @@
|
||||
// Package encrypt provides reversible AES-256-GCM encryption and decryption utilities
|
||||
// for securing sensitive data like API keys and credentials.
|
||||
package encrypt
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/maximhq/bifrost/core/schemas"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var encryptionKey []byte
|
||||
var logger schemas.Logger
|
||||
|
||||
var ErrEncryptionKeyNotInitialized = errors.New("encryption key is not initialized")
|
||||
|
||||
// Init initializes the encryption key using Argon2id KDF to derive a secure 32-byte key
|
||||
// from the provided passphrase. This ensures strong entropy regardless of passphrase length.
|
||||
// The function accepts any passphrase but warns if it's too short (< 16 bytes).
|
||||
func Init(key string, _logger schemas.Logger) {
|
||||
logger = _logger
|
||||
if key == "" {
|
||||
encryptionKey = nil
|
||||
logger.Warn("encryption key is not set, encryption will be disabled. To set encryption key: use the encryption_key field in the configuration file or set the BIFROST_ENCRYPTION_KEY environment variable. Note that - once encryption key is set, it cannot be changed later unless you clean up the database.")
|
||||
return
|
||||
}
|
||||
|
||||
// Warn if passphrase is too short
|
||||
if len(key) < 16 {
|
||||
logger.Warn("encryption passphrase is shorter than 16 bytes, consider using a longer passphrase for better security")
|
||||
}
|
||||
|
||||
// Derive a secure 32-byte key using Argon2id KDF
|
||||
// We use a fixed salt since this is a system-wide encryption key (not per-user passwords)
|
||||
// Argon2id parameters: time=1, memory=64MB, threads=4, keyLen=32
|
||||
// This provides strong security while maintaining reasonable performance for initialization
|
||||
salt := []byte("bifrost-encryption-v1-salt-2024")
|
||||
encryptionKey = argon2.IDKey([]byte(key), salt, 1, 64*1024, 4, 32)
|
||||
}
|
||||
|
||||
// CompareHash compares a hash and a password
|
||||
func CompareHash(hash string, password string) (bool, error) {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
|
||||
if err != nil {
|
||||
if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("failed to compare hash: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Hash hashes a password using bcrypt
|
||||
func Hash(password string) (string, error) {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
return string(hashedPassword), nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts a plaintext string using AES-256-GCM and returns a base64-encoded ciphertext
|
||||
func Encrypt(plaintext string) (string, error) {
|
||||
if encryptionKey == nil {
|
||||
return plaintext, nil
|
||||
}
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return plaintext, fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return plaintext, fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
// Create a nonce (number used once)
|
||||
nonce := make([]byte, aesGCM.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return plaintext, fmt.Errorf("failed to read nonce: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt the data
|
||||
ciphertext := aesGCM.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
|
||||
// Encode to base64 for storage
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// IsEnabled returns true if the encryption key has been initialized
|
||||
func IsEnabled() bool {
|
||||
return encryptionKey != nil
|
||||
}
|
||||
|
||||
// HashSHA256 returns a deterministic hex-encoded SHA-256 hash of the input.
|
||||
// Used for hash-based lookups on encrypted columns (e.g., virtual key value, session token).
|
||||
func HashSHA256(value string) string {
|
||||
h := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// Decrypt decrypts a base64-encoded ciphertext using AES-256-GCM and returns the plaintext
|
||||
func Decrypt(ciphertext string) (string, error) {
|
||||
if encryptionKey == nil {
|
||||
return ciphertext, ErrEncryptionKeyNotInitialized
|
||||
}
|
||||
if ciphertext == "" {
|
||||
return ciphertext, nil
|
||||
}
|
||||
|
||||
// Decode from base64
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode base64: %w", err)
|
||||
}
|
||||
|
||||
block, err := aes.NewCipher(encryptionKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||
}
|
||||
|
||||
aesGCM, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create GCM: %w", err)
|
||||
}
|
||||
|
||||
// Extract nonce
|
||||
nonceSize := aesGCM.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
|
||||
nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:]
|
||||
|
||||
// Decrypt the data
|
||||
plaintext, err := aesGCM.Open(nil, nonce, ciphertextBytes, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt: %w", err)
|
||||
}
|
||||
|
||||
return string(plaintext), nil
|
||||
}
|
||||
245
framework/encrypt/encrypt_test.go
Normal file
245
framework/encrypt/encrypt_test.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package encrypt
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
bifrost "github.com/maximhq/bifrost/core"
|
||||
"github.com/maximhq/bifrost/core/schemas"
|
||||
)
|
||||
|
||||
func TestEncryptDecrypt(t *testing.T) {
|
||||
// Set a test encryption key
|
||||
testKey := "test-encryption-key-for-testing-32bytes"
|
||||
Init(testKey, bifrost.NewDefaultLogger(schemas.LogLevelInfo))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
plaintext string
|
||||
}{
|
||||
{
|
||||
name: "Simple text",
|
||||
plaintext: "hello world",
|
||||
},
|
||||
{
|
||||
name: "AWS Access Key",
|
||||
plaintext: "AKIAIOSFODNN7EXAMPLE",
|
||||
},
|
||||
{
|
||||
name: "AWS Secret Key",
|
||||
plaintext: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
plaintext: "",
|
||||
},
|
||||
{
|
||||
name: "Special characters",
|
||||
plaintext: "!@#$%^&*()_+-=[]{}|;':\",./<>?`~",
|
||||
},
|
||||
{
|
||||
name: "Long text",
|
||||
plaintext: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Encrypt
|
||||
encrypted, err := Encrypt(tc.plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// For empty strings, encryption should return empty
|
||||
if tc.plaintext == "" {
|
||||
if encrypted != "" {
|
||||
t.Errorf("Expected empty string for empty input, got: %s", encrypted)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypted text should be different from plaintext
|
||||
if encrypted == tc.plaintext {
|
||||
t.Errorf("Encrypted text should be different from plaintext")
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt: %v", err)
|
||||
}
|
||||
|
||||
// Decrypted text should match original plaintext
|
||||
if decrypted != tc.plaintext {
|
||||
t.Errorf("Decrypted text does not match original.\nExpected: %s\nGot: %s", tc.plaintext, decrypted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDeterminism(t *testing.T) {
|
||||
// Set a test encryption key
|
||||
testKey := "test-encryption-key-for-testing-32bytes"
|
||||
Init(testKey, bifrost.NewDefaultLogger(schemas.LogLevelInfo))
|
||||
|
||||
plaintext := "test-plaintext"
|
||||
|
||||
// Encrypt the same text twice
|
||||
encrypted1, err := Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt: %v", err)
|
||||
}
|
||||
encrypted2, err := Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// They should be different (due to random nonce)
|
||||
if encrypted1 == encrypted2 {
|
||||
t.Errorf("Two encryptions of the same plaintext should produce different ciphertexts (due to random nonce)")
|
||||
}
|
||||
|
||||
// But both should decrypt to the same plaintext
|
||||
decrypted1, err := Decrypt(encrypted1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt first: %v", err)
|
||||
}
|
||||
decrypted2, err := Decrypt(encrypted2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt second: %v", err)
|
||||
}
|
||||
|
||||
if decrypted1 != plaintext || decrypted2 != plaintext {
|
||||
t.Errorf("Both decryptions should match original plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptInvalidData(t *testing.T) {
|
||||
// Set a test encryption key
|
||||
testKey := "test-encryption-key-for-testing-32bytes"
|
||||
Init(testKey, bifrost.NewDefaultLogger(schemas.LogLevelInfo))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
ciphertext string
|
||||
}{
|
||||
{
|
||||
name: "Invalid base64",
|
||||
ciphertext: "not-valid-base64!@#$",
|
||||
},
|
||||
{
|
||||
name: "Valid base64 but invalid ciphertext",
|
||||
ciphertext: "YWJjZGVmZ2hpamtsbW5vcA==",
|
||||
},
|
||||
{
|
||||
name: "Too short ciphertext",
|
||||
ciphertext: "YWJj",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := Decrypt(tc.ciphertext)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error when decrypting invalid data, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKDFWithVariousKeyLengths(t *testing.T) {
|
||||
// Test that keys of various lengths work correctly with KDF
|
||||
testCases := []struct {
|
||||
name string
|
||||
key string
|
||||
}{
|
||||
{
|
||||
name: "Short key (8 bytes)",
|
||||
key: "shortkey",
|
||||
},
|
||||
{
|
||||
name: "Medium key (16 bytes)",
|
||||
key: "medium-key-16byt",
|
||||
},
|
||||
{
|
||||
name: "Long key (32 bytes)",
|
||||
key: "this-is-a-32-byte-long-key!!",
|
||||
},
|
||||
{
|
||||
name: "Very long key (64 bytes)",
|
||||
key: "this-is-a-very-long-key-that-is-definitely-more-than-64-bytes",
|
||||
},
|
||||
}
|
||||
|
||||
plaintext := "test-data-for-encryption"
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Initialize with this key
|
||||
Init(tc.key, bifrost.NewDefaultLogger(schemas.LogLevelInfo))
|
||||
|
||||
// Encrypt
|
||||
encrypted, err := Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Should produce valid ciphertext
|
||||
if encrypted == plaintext {
|
||||
t.Errorf("Encrypted text should be different from plaintext")
|
||||
}
|
||||
|
||||
// Decrypt should work
|
||||
decrypted, err := Decrypt(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt with %s: %v", tc.name, err)
|
||||
}
|
||||
|
||||
if decrypted != plaintext {
|
||||
t.Errorf("Decrypted text does not match original.\nExpected: %s\nGot: %s", plaintext, decrypted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKDFDeterministic(t *testing.T) {
|
||||
// Test that the same passphrase always produces the same derived key
|
||||
passphrase := "test-passphrase"
|
||||
plaintext := "test-data"
|
||||
|
||||
// Initialize with passphrase and encrypt
|
||||
Init(passphrase, bifrost.NewDefaultLogger(schemas.LogLevelInfo))
|
||||
encrypted1, err := Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Re-initialize with same passphrase (simulating restart)
|
||||
Init(passphrase, bifrost.NewDefaultLogger(schemas.LogLevelInfo))
|
||||
|
||||
// Should be able to decrypt the previously encrypted data
|
||||
decrypted, err := Decrypt(encrypted1)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt after re-initialization: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != plaintext {
|
||||
t.Errorf("Decrypted text does not match original after re-initialization.\nExpected: %s\nGot: %s", plaintext, decrypted)
|
||||
}
|
||||
|
||||
// Encrypt again with same passphrase
|
||||
encrypted2, err := Encrypt(plaintext)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt: %v", err)
|
||||
}
|
||||
|
||||
// Should be able to decrypt both (even though they're different due to nonce)
|
||||
decrypted2, err := Decrypt(encrypted2)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to decrypt second encryption: %v", err)
|
||||
}
|
||||
|
||||
if decrypted2 != plaintext {
|
||||
t.Errorf("Second decryption does not match original.\nExpected: %s\nGot: %s", plaintext, decrypted2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user