Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
223
cloud/maplefile-backend/pkg/security/ipcrypt/encryptor.go
Normal file
223
cloud/maplefile-backend/pkg/security/ipcrypt/encryptor.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
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
|
||||
}
|
||||
13
cloud/maplefile-backend/pkg/security/ipcrypt/provider.go
Normal file
13
cloud/maplefile-backend/pkg/security/ipcrypt/provider.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package ipcrypt
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplepress-backend/config"
|
||||
)
|
||||
|
||||
// ProvideIPEncryptor provides an IP encryptor instance
|
||||
// CWE-359: GDPR compliance for IP address storage
|
||||
func ProvideIPEncryptor(cfg *config.Config, logger *zap.Logger) (*IPEncryptor, error) {
|
||||
return NewIPEncryptor(cfg.Security.IPEncryptionKey, logger)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue