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