221 lines
7 KiB
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
|
|
}
|