package ipcrypt import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/hex" "fmt" "net" "time" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/validation" ) // 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", validation.MaskIP(ipAddress))) return "", fmt.Errorf("invalid IP address: %s", validation.MaskIP(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 }