monorepo/cloud/maplepress-backend/pkg/security/ipcrypt/encryptor.go

221 lines
7 KiB
Go

package ipcrypt
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"net"
"time"
"go.uber.org/zap"
)
// IPEncryptor provides secure IP address encryption for GDPR compliance
// Uses AES-GCM (Galois/Counter Mode) for authenticated encryption
// Encrypts IP addresses before storage and provides expiration checking
type IPEncryptor struct {
gcm cipher.AEAD
logger *zap.Logger
}
// NewIPEncryptor creates a new IP encryptor with the given encryption key
// keyHex should be a 32-character hex string (16 bytes for AES-128)
// or 64-character hex string (32 bytes for AES-256)
// Example: "0123456789abcdef0123456789abcdef" (AES-128)
// Recommended: Use AES-256 with 64-character hex key
func NewIPEncryptor(keyHex string, logger *zap.Logger) (*IPEncryptor, error) {
// Decode hex key to bytes
keyBytes, err := hex.DecodeString(keyHex)
if err != nil {
return nil, fmt.Errorf("invalid hex key: %w", err)
}
// AES requires exactly 16, 24, or 32 bytes
if len(keyBytes) != 16 && len(keyBytes) != 24 && len(keyBytes) != 32 {
return nil, fmt.Errorf("key must be 16, 24, or 32 bytes (32, 48, or 64 hex characters), got %d bytes", len(keyBytes))
}
// Create AES cipher block
block, err := aes.NewCipher(keyBytes)
if err != nil {
return nil, fmt.Errorf("failed to create cipher: %w", err)
}
// Create GCM (Galois/Counter Mode) for authenticated encryption
// GCM provides both confidentiality and integrity
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
logger.Info("IP encryptor initialized with AES-GCM",
zap.Int("key_length_bytes", len(keyBytes)),
zap.Int("nonce_size", gcm.NonceSize()),
zap.Int("overhead", gcm.Overhead()))
return &IPEncryptor{
gcm: gcm,
logger: logger.Named("ip-encryptor"),
}, nil
}
// Encrypt encrypts an IP address for secure storage using AES-GCM
// Returns base64-encoded encrypted IP address with embedded nonce
// Format: base64(nonce + ciphertext + auth_tag)
// Supports both IPv4 and IPv6 addresses
//
// Security Properties:
// - Semantic security: same IP address produces different ciphertext each time
// - Authentication: tampering with ciphertext is detected
// - Unique nonce per encryption prevents pattern analysis
func (e *IPEncryptor) Encrypt(ipAddress string) (string, error) {
if ipAddress == "" {
return "", nil // Empty string remains empty
}
// Parse IP address to validate format
ip := net.ParseIP(ipAddress)
if ip == nil {
e.logger.Warn("invalid IP address format",
zap.String("ip", ipAddress))
return "", fmt.Errorf("invalid IP address: %s", ipAddress)
}
// Convert to 16-byte representation (IPv4 gets converted to IPv6 format)
ipBytes := ip.To16()
if ipBytes == nil {
return "", fmt.Errorf("failed to convert IP to 16-byte format")
}
// Generate a random nonce (number used once)
// GCM requires a unique nonce for each encryption operation
nonce := make([]byte, e.gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
e.logger.Error("failed to generate nonce", zap.Error(err))
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
// Encrypt the IP bytes using AES-GCM
// GCM appends the authentication tag to the ciphertext
// nil additional data means no associated data
ciphertext := e.gcm.Seal(nil, nonce, ipBytes, nil)
// Prepend nonce to ciphertext for storage
// Format: nonce || ciphertext+tag
encryptedData := append(nonce, ciphertext...)
// Encode to base64 for database storage (text-safe)
encryptedBase64 := base64.StdEncoding.EncodeToString(encryptedData)
e.logger.Debug("IP address encrypted with AES-GCM",
zap.Int("plaintext_length", len(ipBytes)),
zap.Int("nonce_length", len(nonce)),
zap.Int("ciphertext_length", len(ciphertext)),
zap.Int("total_encrypted_length", len(encryptedData)),
zap.Int("base64_length", len(encryptedBase64)))
return encryptedBase64, nil
}
// Decrypt decrypts an encrypted IP address
// Takes base64-encoded encrypted IP and returns original IP address string
// Verifies authentication tag to detect tampering
func (e *IPEncryptor) Decrypt(encryptedBase64 string) (string, error) {
if encryptedBase64 == "" {
return "", nil // Empty string remains empty
}
// Decode base64 to bytes
encryptedData, err := base64.StdEncoding.DecodeString(encryptedBase64)
if err != nil {
e.logger.Warn("invalid base64-encoded encrypted IP",
zap.String("base64", encryptedBase64),
zap.Error(err))
return "", fmt.Errorf("invalid base64 encoding: %w", err)
}
// Extract nonce from the beginning
nonceSize := e.gcm.NonceSize()
if len(encryptedData) < nonceSize {
return "", fmt.Errorf("encrypted data too short: expected at least %d bytes, got %d", nonceSize, len(encryptedData))
}
nonce := encryptedData[:nonceSize]
ciphertext := encryptedData[nonceSize:]
// Decrypt and verify authentication tag using AES-GCM
ipBytes, err := e.gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
e.logger.Warn("failed to decrypt IP address (authentication failed or corrupted data)",
zap.Error(err))
return "", fmt.Errorf("decryption failed: %w", err)
}
// Convert bytes to IP address
ip := net.IP(ipBytes)
if ip == nil {
return "", fmt.Errorf("failed to parse decrypted IP bytes")
}
// Convert to string
ipString := ip.String()
e.logger.Debug("IP address decrypted with AES-GCM",
zap.Int("encrypted_length", len(encryptedData)),
zap.Int("decrypted_length", len(ipBytes)))
return ipString, nil
}
// IsExpired checks if an IP address timestamp has expired (> 90 days old)
// GDPR compliance: IP addresses must be deleted after 90 days
func (e *IPEncryptor) IsExpired(timestamp time.Time) bool {
if timestamp.IsZero() {
return false // No timestamp means not expired (will be cleaned up later)
}
// Calculate age in days
age := time.Since(timestamp)
ageInDays := int(age.Hours() / 24)
expired := ageInDays > 90
if expired {
e.logger.Debug("IP timestamp expired",
zap.Time("timestamp", timestamp),
zap.Int("age_days", ageInDays))
}
return expired
}
// ShouldCleanup checks if an IP address should be cleaned up based on timestamp
// Returns true if timestamp is older than 90 days OR if timestamp is zero (unset)
func (e *IPEncryptor) ShouldCleanup(timestamp time.Time) bool {
// Always cleanup if timestamp is not set (backwards compatibility)
if timestamp.IsZero() {
return false // Don't cleanup unset timestamps immediately
}
return e.IsExpired(timestamp)
}
// ValidateKey validates that a key is properly formatted for IP encryption
// Returns true if key is valid 32-character hex string (AES-128) or 64-character (AES-256)
func ValidateKey(keyHex string) error {
// Check length (must be 16, 24, or 32 bytes = 32, 48, or 64 hex chars)
if len(keyHex) != 32 && len(keyHex) != 48 && len(keyHex) != 64 {
return fmt.Errorf("key must be 32, 48, or 64 hex characters, got %d characters", len(keyHex))
}
// Check if valid hex
_, err := hex.DecodeString(keyHex)
if err != nil {
return fmt.Errorf("key must be valid hex string: %w", err)
}
return nil
}