mirror of
https://github.com/StarFleetCPTN/GoMFT.git
synced 2026-09-19 04:50:58 +02:00
feat: Implement storage provider management functionality
- Added new routes and handlers for managing storage providers, including creation, editing, and deletion. - Introduced a new StorageProvider form component for user input. - Enhanced the database schema to support storage provider references in transfer configurations. - Implemented encryption for sensitive fields in storage provider data. - Added tests for storage provider API endpoints and integration with the database. - Updated frontend components to support storage provider selection and testing.
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# Encryption Module
|
||||
|
||||
This module provides secure encryption and decryption functionality for sensitive credential fields in GoMFT using AES-256 encryption.
|
||||
|
||||
## Key Management
|
||||
|
||||
The key management module handles secure retrieval, validation, and management of encryption keys from environment variables or secure storage.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Set the environment variable `GOMFT_ENCRYPTION_KEY` with a securely generated key:
|
||||
```sh
|
||||
# Generate a secure random key and set it as an environment variable
|
||||
GOMFT_ENCRYPTION_KEY=$(go run -e 'import "encoding/base64"; import "crypto/rand"; key := make([]byte, 32); rand.Read(key); fmt.Println(base64.StdEncoding.EncodeToString(key))')
|
||||
```
|
||||
|
||||
2. Include this key in your `.env` file (for development only):
|
||||
```
|
||||
GOMFT_ENCRYPTION_KEY=your-base64-encoded-key
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
To initialize the key manager:
|
||||
|
||||
```go
|
||||
import "github.com/starfleetcptn/gomft/internal/encryption"
|
||||
|
||||
func init() {
|
||||
// Initialize with default environment variable (GOMFT_ENCRYPTION_KEY)
|
||||
err := encryption.InitializeKeyManager("")
|
||||
if err != nil {
|
||||
panic("Failed to initialize encryption key: " + err.Error())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To get the key manager instance:
|
||||
|
||||
```go
|
||||
keyManager := encryption.GetKeyManager()
|
||||
```
|
||||
|
||||
To generate a new random encryption key:
|
||||
|
||||
```go
|
||||
key, err := encryption.GenerateKey(encryption.AES256KeySize)
|
||||
if err != nil {
|
||||
// handle error
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Never store encryption keys in the database** or expose them in logs
|
||||
- The key should be at least 32 bytes (256 bits) for AES-256 encryption
|
||||
- In production, use secure key management solutions (e.g., HashiCorp Vault, AWS KMS) instead of environment variables
|
||||
- Rotate keys periodically for enhanced security
|
||||
- Monitor for any unusual encryption/decryption activity
|
||||
|
||||
## Testing
|
||||
|
||||
The module includes comprehensive unit tests. Run them with:
|
||||
|
||||
```sh
|
||||
go test -v ./internal/encryption/...
|
||||
```
|
||||
@@ -0,0 +1,400 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
)
|
||||
|
||||
// EventType represents the type of encryption-related event
|
||||
type EventType string
|
||||
|
||||
// Event types for encryption operations
|
||||
const (
|
||||
EventEncrypt EventType = "encrypt"
|
||||
EventDecrypt EventType = "decrypt"
|
||||
EventKeyAccess EventType = "key_access"
|
||||
EventKeyRotation EventType = "key_rotation"
|
||||
EventKeyGeneration EventType = "key_generation"
|
||||
EventDecryptionFailure EventType = "decryption_failure"
|
||||
EventEncryptionFailure EventType = "encryption_failure"
|
||||
)
|
||||
|
||||
// SecurityLevel represents the severity/importance of an audit event
|
||||
type SecurityLevel string
|
||||
|
||||
// Security levels for events
|
||||
const (
|
||||
LevelInfo SecurityLevel = "info"
|
||||
LevelWarning SecurityLevel = "warning"
|
||||
LevelAlert SecurityLevel = "alert"
|
||||
LevelError SecurityLevel = "error"
|
||||
)
|
||||
|
||||
// AuditEvent represents a single encryption-related security event
|
||||
type AuditEvent struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
EventType EventType `json:"event_type"`
|
||||
Level SecurityLevel `json:"level"`
|
||||
Operation string `json:"operation"`
|
||||
FieldType string `json:"field_type,omitempty"`
|
||||
ModelType string `json:"model_type,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
KeyVersion string `json:"key_version,omitempty"`
|
||||
UserID uint `json:"user_id,omitempty"`
|
||||
RemoteIP string `json:"remote_ip,omitempty"`
|
||||
Duration int64 `json:"duration_ns,omitempty"` // Operation duration in nanoseconds
|
||||
}
|
||||
|
||||
// SecurityAuditor is responsible for logging security-related events
|
||||
type SecurityAuditor struct {
|
||||
enabled bool
|
||||
logWriter io.Writer
|
||||
errorWriter io.Writer
|
||||
mutex sync.Mutex
|
||||
detailedMode bool
|
||||
logFilePath string
|
||||
errorFilePath string
|
||||
}
|
||||
|
||||
// New creates a new SecurityAuditor with default configuration
|
||||
func New() (*SecurityAuditor, error) {
|
||||
return &SecurityAuditor{
|
||||
enabled: true,
|
||||
logWriter: os.Stdout, // Default to stdout for regular logs
|
||||
errorWriter: os.Stderr, // Default to stderr for error logs
|
||||
detailedMode: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewWithFileLogging creates a new SecurityAuditor with file-based logging
|
||||
func NewWithFileLogging(logFilePath, errorFilePath string) (*SecurityAuditor, error) {
|
||||
logFile, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open log file: %w", err)
|
||||
}
|
||||
|
||||
var errorWriter io.Writer
|
||||
if errorFilePath == logFilePath {
|
||||
errorWriter = logFile
|
||||
} else {
|
||||
errorFile, err := os.OpenFile(errorFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
logFile.Close()
|
||||
return nil, fmt.Errorf("failed to open error log file: %w", err)
|
||||
}
|
||||
errorWriter = errorFile
|
||||
}
|
||||
|
||||
return &SecurityAuditor{
|
||||
enabled: true,
|
||||
logWriter: logFile,
|
||||
errorWriter: errorWriter,
|
||||
logFilePath: logFilePath,
|
||||
errorFilePath: errorFilePath,
|
||||
detailedMode: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close properly closes any open resources
|
||||
func (a *SecurityAuditor) Close() error {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
|
||||
// Check if we need to close file writers
|
||||
if closer, ok := a.logWriter.(io.Closer); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Don't close errorWriter if it's the same as logWriter
|
||||
if a.errorFilePath != a.logFilePath {
|
||||
if closer, ok := a.errorWriter.(io.Closer); ok {
|
||||
if err := closer.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Enable turns on the auditor
|
||||
func (a *SecurityAuditor) Enable() {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.enabled = true
|
||||
}
|
||||
|
||||
// Disable turns off the auditor
|
||||
func (a *SecurityAuditor) Disable() {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.enabled = false
|
||||
}
|
||||
|
||||
// SetDetailedMode toggles detailed logging mode
|
||||
func (a *SecurityAuditor) SetDetailedMode(detailed bool) {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
a.detailedMode = detailed
|
||||
}
|
||||
|
||||
// IsEnabled returns whether auditing is enabled
|
||||
func (a *SecurityAuditor) IsEnabled() bool {
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
return a.enabled
|
||||
}
|
||||
|
||||
// LogEvent records a security event to the audit log
|
||||
func (a *SecurityAuditor) LogEvent(event AuditEvent) {
|
||||
if !a.IsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
a.mutex.Lock()
|
||||
defer a.mutex.Unlock()
|
||||
|
||||
// Ensure timestamp is set
|
||||
if event.Timestamp.IsZero() {
|
||||
event.Timestamp = time.Now()
|
||||
}
|
||||
|
||||
// Convert the event to JSON
|
||||
jsonData, err := json.Marshal(event)
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.errorWriter, "Error marshaling audit event: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Choose the right writer based on event level
|
||||
writer := a.logWriter
|
||||
if event.Level == LevelError || event.Level == LevelAlert {
|
||||
writer = a.errorWriter
|
||||
}
|
||||
|
||||
// Write to the appropriate log
|
||||
fmt.Fprintln(writer, string(jsonData))
|
||||
}
|
||||
|
||||
// LogEncryptionEvent logs an encryption operation event
|
||||
func (a *SecurityAuditor) LogEncryptionEvent(operation string, fieldType, modelType string, success bool, err error, keyVersion string, userID uint, duration time.Duration) {
|
||||
if !a.IsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
event := AuditEvent{
|
||||
Timestamp: time.Now(),
|
||||
EventType: EventEncrypt,
|
||||
Level: LevelInfo,
|
||||
Operation: operation,
|
||||
FieldType: fieldType,
|
||||
ModelType: modelType,
|
||||
Success: success,
|
||||
KeyVersion: keyVersion,
|
||||
UserID: userID,
|
||||
Duration: duration.Nanoseconds(),
|
||||
}
|
||||
|
||||
if !success {
|
||||
event.EventType = EventEncryptionFailure
|
||||
event.Level = LevelWarning
|
||||
if err != nil {
|
||||
event.Error = encryption.SanitizeError(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
a.LogEvent(event)
|
||||
}
|
||||
|
||||
// LogDecryptionEvent logs a decryption operation event
|
||||
func (a *SecurityAuditor) LogDecryptionEvent(operation string, fieldType, modelType string, success bool, err error, keyVersion string, userID uint, duration time.Duration) {
|
||||
if !a.IsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
event := AuditEvent{
|
||||
Timestamp: time.Now(),
|
||||
EventType: EventDecrypt,
|
||||
Level: LevelInfo,
|
||||
Operation: operation,
|
||||
FieldType: fieldType,
|
||||
ModelType: modelType,
|
||||
Success: success,
|
||||
KeyVersion: keyVersion,
|
||||
UserID: userID,
|
||||
Duration: duration.Nanoseconds(),
|
||||
}
|
||||
|
||||
if !success {
|
||||
event.EventType = EventDecryptionFailure
|
||||
event.Level = LevelWarning
|
||||
if err != nil {
|
||||
event.Error = encryption.SanitizeError(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
a.LogEvent(event)
|
||||
}
|
||||
|
||||
// LogKeyAccessEvent logs when an encryption key is accessed
|
||||
func (a *SecurityAuditor) LogKeyAccessEvent(keyVersion string, success bool, err error, userID uint) {
|
||||
if !a.IsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
event := AuditEvent{
|
||||
Timestamp: time.Now(),
|
||||
EventType: EventKeyAccess,
|
||||
Level: LevelInfo,
|
||||
Operation: "key_access",
|
||||
Success: success,
|
||||
KeyVersion: keyVersion,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if !success {
|
||||
event.Level = LevelAlert
|
||||
if err != nil {
|
||||
event.Error = encryption.SanitizeError(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Key access failures are security-critical and should be logged at a higher level
|
||||
if !success {
|
||||
event.Description = "Failed key access attempt"
|
||||
}
|
||||
|
||||
a.LogEvent(event)
|
||||
}
|
||||
|
||||
// LogKeyRotationEvent logs when encryption keys are rotated
|
||||
func (a *SecurityAuditor) LogKeyRotationEvent(oldVersion, newVersion string, success bool, err error, userID uint) {
|
||||
if !a.IsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
event := AuditEvent{
|
||||
Timestamp: time.Now(),
|
||||
EventType: EventKeyRotation,
|
||||
Level: LevelInfo,
|
||||
Operation: "key_rotation",
|
||||
Description: fmt.Sprintf("Key rotation from version %s to %s", oldVersion, newVersion),
|
||||
Success: success,
|
||||
KeyVersion: newVersion,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if !success {
|
||||
event.Level = LevelError
|
||||
if err != nil {
|
||||
event.Error = encryption.SanitizeError(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
a.LogEvent(event)
|
||||
}
|
||||
|
||||
// LogKeyRotationEventWithDescription logs when encryption keys are rotated with a custom description
|
||||
func (a *SecurityAuditor) LogKeyRotationEventWithDescription(oldVersion, newVersion string, success bool, description string, userID uint) {
|
||||
if !a.IsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
event := AuditEvent{
|
||||
Timestamp: time.Now(),
|
||||
EventType: EventKeyRotation,
|
||||
Level: LevelInfo,
|
||||
Operation: "key_rotation",
|
||||
Description: description,
|
||||
Success: success,
|
||||
KeyVersion: newVersion,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if !success {
|
||||
event.Level = LevelError
|
||||
}
|
||||
|
||||
a.LogEvent(event)
|
||||
}
|
||||
|
||||
// LogKeyGenerationEvent logs when a new encryption key is generated
|
||||
func (a *SecurityAuditor) LogKeyGenerationEvent(keyVersion string, success bool, err error, userID uint) {
|
||||
if !a.IsEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
event := AuditEvent{
|
||||
Timestamp: time.Now(),
|
||||
EventType: EventKeyGeneration,
|
||||
Level: LevelInfo,
|
||||
Operation: "key_generation",
|
||||
Description: "New encryption key generated",
|
||||
Success: success,
|
||||
KeyVersion: keyVersion,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if !success {
|
||||
event.Level = LevelError
|
||||
if err != nil {
|
||||
event.Error = encryption.SanitizeError(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
a.LogEvent(event)
|
||||
}
|
||||
|
||||
// global is the default security auditor instance
|
||||
var global *SecurityAuditor
|
||||
var globalOnce sync.Once
|
||||
|
||||
// GetGlobalAuditor returns the global security auditor instance
|
||||
func GetGlobalAuditor() *SecurityAuditor {
|
||||
globalOnce.Do(func() {
|
||||
var err error
|
||||
global, err = New()
|
||||
if err != nil {
|
||||
// Fall back to a disabled auditor if there's an error
|
||||
global = &SecurityAuditor{enabled: false}
|
||||
}
|
||||
})
|
||||
return global
|
||||
}
|
||||
|
||||
// InitializeWithFileLogging initializes the global auditor with file logging
|
||||
func InitializeWithFileLogging(logFilePath, errorFilePath string) error {
|
||||
auditor, err := NewWithFileLogging(logFilePath, errorFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
globalOnce.Do(func() {
|
||||
global = auditor
|
||||
})
|
||||
|
||||
// If global auditor was already initialized, replace it
|
||||
if global != auditor {
|
||||
if closer, ok := global.logWriter.(io.Closer); ok {
|
||||
closer.Close()
|
||||
}
|
||||
if global.errorFilePath != global.logFilePath {
|
||||
if closer, ok := global.errorWriter.(io.Closer); ok {
|
||||
closer.Close()
|
||||
}
|
||||
}
|
||||
global = auditor
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
"github.com/starfleetcptn/gomft/internal/encryption/keyrotation"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RotationOptions contains configuration for the key rotation process
|
||||
type RotationOptions struct {
|
||||
// DryRun performs all operations but doesn't save changes to database
|
||||
DryRun bool
|
||||
// BatchSize sets the number of records to process in each batch
|
||||
BatchSize int
|
||||
// MaxErrors sets the threshold of errors before aborting
|
||||
MaxErrors int
|
||||
// Parallelism controls how many models are processed in parallel
|
||||
Parallelism int
|
||||
// Timeout specifies a maximum duration for the entire operation
|
||||
Timeout time.Duration
|
||||
// WorkerTimeout specifies maximum duration for a single batch
|
||||
WorkerTimeout time.Duration
|
||||
// ProgressCallback receives updates on rotation progress
|
||||
ProgressCallback func(modelName string, processed, total int)
|
||||
}
|
||||
|
||||
// RotationUtility provides comprehensive capabilities for rotating encryption keys
|
||||
// across multiple database models with detailed auditing and progress tracking
|
||||
type RotationUtility struct {
|
||||
db *gorm.DB
|
||||
oldService *encryption.EncryptionService
|
||||
newService *encryption.EncryptionService
|
||||
auditor *SecurityAuditor
|
||||
monitor *SecurityMonitor
|
||||
options RotationOptions
|
||||
testingHooks map[string]func(interface{}) error
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewRotationUtility creates a new RotationUtility
|
||||
func NewRotationUtility(
|
||||
db *gorm.DB,
|
||||
oldService, newService *encryption.EncryptionService,
|
||||
auditor *SecurityAuditor,
|
||||
monitor *SecurityMonitor,
|
||||
options RotationOptions,
|
||||
) (*RotationUtility, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database connection is required")
|
||||
}
|
||||
|
||||
if oldService == nil {
|
||||
return nil, fmt.Errorf("old encryption service is required")
|
||||
}
|
||||
|
||||
if newService == nil {
|
||||
return nil, fmt.Errorf("new encryption service is required")
|
||||
}
|
||||
|
||||
if auditor == nil {
|
||||
auditor = GetGlobalAuditor()
|
||||
}
|
||||
|
||||
if monitor == nil {
|
||||
monitor = NewSecurityMonitor(auditor)
|
||||
}
|
||||
|
||||
// Set default options
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = 100
|
||||
}
|
||||
|
||||
if options.MaxErrors <= 0 {
|
||||
options.MaxErrors = 50
|
||||
}
|
||||
|
||||
if options.Parallelism <= 0 {
|
||||
options.Parallelism = 1
|
||||
}
|
||||
|
||||
if options.Timeout <= 0 {
|
||||
options.Timeout = 24 * time.Hour // Default long timeout
|
||||
}
|
||||
|
||||
if options.WorkerTimeout <= 0 {
|
||||
options.WorkerTimeout = 30 * time.Minute
|
||||
}
|
||||
|
||||
return &RotationUtility{
|
||||
db: db,
|
||||
oldService: oldService,
|
||||
newService: newService,
|
||||
auditor: auditor,
|
||||
monitor: monitor,
|
||||
options: options,
|
||||
testingHooks: make(map[string]func(interface{}) error),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RegisterTestingHook registers a hook for testing purposes
|
||||
func (r *RotationUtility) RegisterTestingHook(name string, hook func(interface{}) error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.testingHooks[name] = hook
|
||||
}
|
||||
|
||||
// runHook runs a testing hook if it exists
|
||||
func (r *RotationUtility) runHook(name string, data interface{}) error {
|
||||
r.mu.Lock()
|
||||
hook, exists := r.testingHooks[name]
|
||||
r.mu.Unlock()
|
||||
|
||||
if exists && hook != nil {
|
||||
return hook(data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateKeysForModels performs key rotation for multiple model types with detailed monitoring
|
||||
func (r *RotationUtility) RotateKeysForModels(ctx context.Context, models []interface{}) (*keyrotation.RotationStats, error) {
|
||||
// Create master context with timeout
|
||||
masterCtx, cancel := context.WithTimeout(ctx, r.options.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// Track overall stats
|
||||
overallStats := &keyrotation.RotationStats{
|
||||
StartTime: time.Now(),
|
||||
Errors: make([]string, 0),
|
||||
}
|
||||
|
||||
// Create key rotator
|
||||
rotator, err := keyrotation.NewKeyRotator(r.db, r.oldService, r.newService, r.auditor)
|
||||
if err != nil {
|
||||
return overallStats, fmt.Errorf("failed to create key rotator: %w", err)
|
||||
}
|
||||
|
||||
// Apply options
|
||||
rotator.SetDryRun(r.options.DryRun)
|
||||
rotator.SetBatchSize(r.options.BatchSize)
|
||||
rotator.SetMaxErrors(r.options.MaxErrors)
|
||||
|
||||
// Log the start of rotation
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"starting",
|
||||
"pending",
|
||||
true,
|
||||
fmt.Sprintf("Starting key rotation for %d model types (dry run: %v)", len(models), r.options.DryRun),
|
||||
0,
|
||||
)
|
||||
|
||||
// Process all models (sequentially)
|
||||
for _, model := range models {
|
||||
// Check if context is canceled
|
||||
select {
|
||||
case <-masterCtx.Done():
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("key rotation aborted: %v", masterCtx.Err()))
|
||||
return overallStats, masterCtx.Err()
|
||||
default:
|
||||
// Continue processing
|
||||
}
|
||||
|
||||
// Get model type info
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
modelName := modelType.Name()
|
||||
|
||||
// Run pre-rotation hook if any
|
||||
if err := r.runHook("pre_rotation_"+modelName, model); err != nil {
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("pre-rotation hook failed for %s: %v", modelName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Log model rotation start
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"starting",
|
||||
"pending",
|
||||
true,
|
||||
fmt.Sprintf("Starting key rotation for model: %s", modelName),
|
||||
0,
|
||||
)
|
||||
|
||||
// Create a worker context with timeout
|
||||
workerCtx, workerCancel := context.WithTimeout(masterCtx, r.options.WorkerTimeout)
|
||||
|
||||
// Create a goroutine to handle timeouts
|
||||
rotationDone := make(chan struct{})
|
||||
var modelStats *keyrotation.RotationStats
|
||||
var rotationErr error
|
||||
|
||||
go func() {
|
||||
// Perform the actual rotation
|
||||
modelStats, rotationErr = rotator.RotateKeys(model, "")
|
||||
close(rotationDone)
|
||||
}()
|
||||
|
||||
// Wait for rotation to complete or timeout
|
||||
select {
|
||||
case <-workerCtx.Done():
|
||||
if workerCtx.Err() == context.DeadlineExceeded {
|
||||
errorMsg := fmt.Sprintf("key rotation for model %s timed out after %v", modelName, r.options.WorkerTimeout)
|
||||
overallStats.Errors = append(overallStats.Errors, errorMsg)
|
||||
|
||||
// Log timeout error
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
false,
|
||||
errorMsg,
|
||||
0,
|
||||
)
|
||||
}
|
||||
case <-rotationDone:
|
||||
// Rotation completed
|
||||
}
|
||||
|
||||
// Clean up the worker context
|
||||
workerCancel()
|
||||
|
||||
// Check for rotation errors
|
||||
if rotationErr != nil {
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("failed to rotate keys for %s: %v", modelName, rotationErr))
|
||||
|
||||
// Log rotation error
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
false,
|
||||
fmt.Sprintf("Key rotation failed for model %s: %v", modelName, rotationErr),
|
||||
0,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Update overall stats
|
||||
if modelStats != nil {
|
||||
overallStats.TotalRecords += modelStats.TotalRecords
|
||||
overallStats.ProcessedRecords += modelStats.ProcessedRecords
|
||||
overallStats.SkippedRecords += modelStats.SkippedRecords
|
||||
overallStats.FailedRecords += modelStats.FailedRecords
|
||||
overallStats.Errors = append(overallStats.Errors, modelStats.Errors...)
|
||||
|
||||
// Call progress callback if set
|
||||
if r.options.ProgressCallback != nil {
|
||||
r.options.ProgressCallback(modelName, modelStats.ProcessedRecords, modelStats.TotalRecords)
|
||||
}
|
||||
|
||||
// Log progress
|
||||
successRate := 0.0
|
||||
if modelStats.TotalRecords > 0 {
|
||||
successRate = float64(modelStats.ProcessedRecords) / float64(modelStats.TotalRecords) * 100
|
||||
}
|
||||
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
true,
|
||||
fmt.Sprintf("Completed key rotation for model %s: %d/%d records (%.1f%%) processed, %d skipped, %d failed",
|
||||
modelName, modelStats.ProcessedRecords, modelStats.TotalRecords, successRate,
|
||||
modelStats.SkippedRecords, modelStats.FailedRecords),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
// Run post-rotation hook if any
|
||||
if err := r.runHook("post_rotation_"+modelName, model); err != nil {
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("post-rotation hook failed for %s: %v", modelName, err))
|
||||
}
|
||||
}
|
||||
|
||||
// Complete overall stats
|
||||
overallStats.EndTime = time.Now()
|
||||
overallStats.ElapsedTime = overallStats.EndTime.Sub(overallStats.StartTime)
|
||||
|
||||
// Calculate overall success rate
|
||||
successRate := 0.0
|
||||
if overallStats.TotalRecords > 0 {
|
||||
successRate = float64(overallStats.ProcessedRecords) / float64(overallStats.TotalRecords) * 100
|
||||
}
|
||||
|
||||
// Log completion
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
len(overallStats.Errors) == 0,
|
||||
fmt.Sprintf("Completed key rotation for all models: %d/%d records (%.1f%%) processed, %d skipped, %d failed, %d errors in %s",
|
||||
overallStats.ProcessedRecords, overallStats.TotalRecords, successRate,
|
||||
overallStats.SkippedRecords, overallStats.FailedRecords, len(overallStats.Errors),
|
||||
overallStats.ElapsedTime),
|
||||
0,
|
||||
)
|
||||
|
||||
return overallStats, nil
|
||||
}
|
||||
|
||||
// FindModelsWithEncryptedFields automatically finds all database models with encrypted fields
|
||||
func (r *RotationUtility) FindModelsWithEncryptedFields() ([]interface{}, error) {
|
||||
// This is a placeholder - in a real implementation, we would scan the codebase
|
||||
// or database schema to automatically detect models with encrypted fields
|
||||
// Since that requires knowledge of the codebase structure, this would be
|
||||
// customized for the specific application
|
||||
|
||||
return []interface{}{}, fmt.Errorf("automatic model detection not implemented, provide models explicitly")
|
||||
}
|
||||
|
||||
// ValidateRotation tests the key rotation on sample records without saving changes
|
||||
func (r *RotationUtility) ValidateRotation(models []interface{}) (map[string]bool, error) {
|
||||
results := make(map[string]bool)
|
||||
|
||||
// Save current options to restore later
|
||||
originalDryRun := r.options.DryRun
|
||||
originalBatchSize := r.options.BatchSize
|
||||
|
||||
// Set temporary options for validation
|
||||
r.options.DryRun = true
|
||||
r.options.BatchSize = 10 // Test with small batch
|
||||
|
||||
// Create a context with short timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Run rotation with dry run mode
|
||||
stats, err := r.RotateKeysForModels(ctx, models)
|
||||
|
||||
// Restore original options
|
||||
r.options.DryRun = originalDryRun
|
||||
r.options.BatchSize = originalBatchSize
|
||||
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Process results for each model
|
||||
for _, model := range models {
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
modelName := modelType.Name()
|
||||
|
||||
// Check if there were errors for this model
|
||||
hasModelErrors := false
|
||||
for _, errMsg := range stats.Errors {
|
||||
if strings.Contains(errMsg, modelName) {
|
||||
hasModelErrors = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
results[modelName] = !hasModelErrors
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CreateEncryptionMigrationPlan creates a detailed plan for migrating data to a new encryption key
|
||||
func (r *RotationUtility) CreateEncryptionMigrationPlan(models []interface{}) (*EncryptionMigrationPlan, error) {
|
||||
plan := &EncryptionMigrationPlan{
|
||||
ModelPlans: make(map[string]*ModelMigrationPlan),
|
||||
EstimatedDuration: 0,
|
||||
EstimatedRecords: 0,
|
||||
RecommendedOptions: r.options, // Start with current options
|
||||
}
|
||||
|
||||
// Calculate record counts for each model
|
||||
totalRecords := 0
|
||||
for _, model := range models {
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
modelName := modelType.Name()
|
||||
|
||||
// Get record count
|
||||
var count int64
|
||||
if err := r.db.Model(model).Count(&count).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count records for %s: %w", modelName, err)
|
||||
}
|
||||
|
||||
encryptedFields := r.identifyEncryptedFields(model)
|
||||
|
||||
// Create model plan
|
||||
modelPlan := &ModelMigrationPlan{
|
||||
ModelName: modelName,
|
||||
RecordCount: int(count),
|
||||
EstimatedTime: r.estimateMigrationTime(int(count), len(encryptedFields)),
|
||||
EncryptedFields: encryptedFields,
|
||||
BatchSizeRec: r.calculateOptimalBatchSize(int(count)),
|
||||
}
|
||||
|
||||
plan.ModelPlans[modelName] = modelPlan
|
||||
totalRecords += int(count)
|
||||
plan.EstimatedDuration += modelPlan.EstimatedTime
|
||||
}
|
||||
|
||||
plan.EstimatedRecords = totalRecords
|
||||
|
||||
// Calculate optimal batch size and parallelism based on total record count
|
||||
plan.RecommendedOptions.BatchSize = r.calculateOptimalBatchSize(totalRecords)
|
||||
plan.RecommendedOptions.Parallelism = r.calculateOptimalParallelism(totalRecords)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// identifyEncryptedFields finds all encrypted fields in a model
|
||||
func (r *RotationUtility) identifyEncryptedFields(model interface{}) []string {
|
||||
fields := []string{}
|
||||
|
||||
// Get model value and type
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
|
||||
// Skip if not a struct
|
||||
if modelType.Kind() != reflect.Struct {
|
||||
return fields
|
||||
}
|
||||
|
||||
// Scan all fields for encrypted ones
|
||||
for i := 0; i < modelType.NumField(); i++ {
|
||||
field := modelType.Field(i)
|
||||
|
||||
// Look for fields starting with "Encrypted"
|
||||
if strings.HasPrefix(field.Name, "Encrypted") && field.Type.Kind() == reflect.String {
|
||||
fields = append(fields, field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// calculateOptimalBatchSize determines the optimal batch size based on record count
|
||||
func (r *RotationUtility) calculateOptimalBatchSize(recordCount int) int {
|
||||
// This is a simplistic approach - in a real system, this would be based on
|
||||
// benchmarking and system characteristics
|
||||
if recordCount < 1000 {
|
||||
return 100
|
||||
} else if recordCount < 10000 {
|
||||
return 250
|
||||
} else if recordCount < 100000 {
|
||||
return 500
|
||||
} else {
|
||||
return 1000
|
||||
}
|
||||
}
|
||||
|
||||
// calculateOptimalParallelism determines the optimal parallelism level
|
||||
func (r *RotationUtility) calculateOptimalParallelism(recordCount int) int {
|
||||
// Simple heuristic - adjust based on actual system performance
|
||||
cpuCount := runtime.NumCPU()
|
||||
|
||||
if recordCount < 10000 {
|
||||
return 1
|
||||
} else if recordCount < 100000 {
|
||||
return min(2, cpuCount)
|
||||
} else {
|
||||
return min(4, cpuCount)
|
||||
}
|
||||
}
|
||||
|
||||
// min returns the minimum of two integers
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// estimateMigrationTime provides a rough estimate of time needed for migration
|
||||
func (r *RotationUtility) estimateMigrationTime(recordCount, fieldCount int) time.Duration {
|
||||
// This is a very rough estimate - in a real system, this would be based on
|
||||
// benchmarking results and system characteristics
|
||||
|
||||
// Assume roughly 10ms per record per field
|
||||
msPerRecordField := 10
|
||||
|
||||
// Calculate total time in milliseconds
|
||||
totalTimeMs := recordCount * fieldCount * msPerRecordField
|
||||
|
||||
// Add overhead
|
||||
totalTimeMs = int(float64(totalTimeMs) * 1.2) // 20% overhead
|
||||
|
||||
return time.Duration(totalTimeMs) * time.Millisecond
|
||||
}
|
||||
|
||||
// EncryptionMigrationPlan contains the complete plan for migration
|
||||
type EncryptionMigrationPlan struct {
|
||||
ModelPlans map[string]*ModelMigrationPlan `json:"model_plans"`
|
||||
EstimatedDuration time.Duration `json:"estimated_duration"`
|
||||
EstimatedRecords int `json:"estimated_records"`
|
||||
RecommendedOptions RotationOptions `json:"recommended_options"`
|
||||
}
|
||||
|
||||
// ModelMigrationPlan contains migration details for a specific model
|
||||
type ModelMigrationPlan struct {
|
||||
ModelName string `json:"model_name"`
|
||||
RecordCount int `json:"record_count"`
|
||||
EstimatedTime time.Duration `json:"estimated_time"`
|
||||
EncryptedFields []string `json:"encrypted_fields"`
|
||||
BatchSizeRec int `json:"batch_size_recommendation"`
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityMonitor provides aggregate monitoring, alerting, and reporting for security events
|
||||
type SecurityMonitor struct {
|
||||
auditor *SecurityAuditor
|
||||
statsMutex sync.RWMutex
|
||||
eventCounts map[EventType]int
|
||||
errorCounts map[string]int
|
||||
lastEventTime map[EventType]time.Time
|
||||
alertThresholds map[EventType]int
|
||||
alertHandler AlertHandler
|
||||
}
|
||||
|
||||
// AlertLevel represents the severity of a security alert
|
||||
type AlertLevel string
|
||||
|
||||
// Alert levels
|
||||
const (
|
||||
AlertLevelInfo AlertLevel = "info"
|
||||
AlertLevelWarning AlertLevel = "warning"
|
||||
AlertLevelCritical AlertLevel = "critical"
|
||||
)
|
||||
|
||||
// SecurityAlert represents a security alert to be sent to handlers
|
||||
type SecurityAlert struct {
|
||||
Timestamp time.Time
|
||||
Level AlertLevel
|
||||
EventType EventType
|
||||
Message string
|
||||
Count int
|
||||
Details map[string]interface{}
|
||||
}
|
||||
|
||||
// AlertHandler is the interface for handling security alerts
|
||||
type AlertHandler interface {
|
||||
HandleAlert(alert SecurityAlert)
|
||||
}
|
||||
|
||||
// DefaultAlertHandler is a basic implementation of AlertHandler that logs to a file
|
||||
type DefaultAlertHandler struct {
|
||||
logFile string
|
||||
writer io.Writer
|
||||
writerLock sync.Mutex
|
||||
}
|
||||
|
||||
// NewDefaultAlertHandler creates a new default alert handler
|
||||
func NewDefaultAlertHandler(logFile string) (*DefaultAlertHandler, error) {
|
||||
var writer io.Writer
|
||||
|
||||
if logFile == "" {
|
||||
writer = os.Stdout
|
||||
} else {
|
||||
file, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open alert log file: %w", err)
|
||||
}
|
||||
writer = file
|
||||
}
|
||||
|
||||
return &DefaultAlertHandler{
|
||||
logFile: logFile,
|
||||
writer: writer,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HandleAlert logs the alert to the configured output
|
||||
func (h *DefaultAlertHandler) HandleAlert(alert SecurityAlert) {
|
||||
h.writerLock.Lock()
|
||||
defer h.writerLock.Unlock()
|
||||
|
||||
jsonData, err := json.Marshal(alert)
|
||||
if err != nil {
|
||||
fmt.Fprintf(h.writer, "Error marshaling alert: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintln(h.writer, string(jsonData))
|
||||
}
|
||||
|
||||
// Close closes any open resources
|
||||
func (h *DefaultAlertHandler) Close() error {
|
||||
if h.logFile != "" {
|
||||
if closer, ok := h.writer.(io.Closer); ok {
|
||||
return closer.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSecurityMonitor creates a new SecurityMonitor
|
||||
func NewSecurityMonitor(auditor *SecurityAuditor) *SecurityMonitor {
|
||||
// Use provided auditor or global one if nil
|
||||
if auditor == nil {
|
||||
auditor = GetGlobalAuditor()
|
||||
}
|
||||
|
||||
defaultHandler, _ := NewDefaultAlertHandler("")
|
||||
|
||||
return &SecurityMonitor{
|
||||
auditor: auditor,
|
||||
eventCounts: make(map[EventType]int),
|
||||
errorCounts: make(map[string]int),
|
||||
lastEventTime: make(map[EventType]time.Time),
|
||||
alertThresholds: make(map[EventType]int),
|
||||
alertHandler: defaultHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAlertHandler sets a custom alert handler
|
||||
func (m *SecurityMonitor) SetAlertHandler(handler AlertHandler) {
|
||||
m.alertHandler = handler
|
||||
}
|
||||
|
||||
// SetAlertThreshold sets the threshold for when to generate alerts for a specific event type
|
||||
func (m *SecurityMonitor) SetAlertThreshold(eventType EventType, threshold int) {
|
||||
m.statsMutex.Lock()
|
||||
defer m.statsMutex.Unlock()
|
||||
|
||||
m.alertThresholds[eventType] = threshold
|
||||
}
|
||||
|
||||
// ProcessEvent processes a security event for monitoring
|
||||
func (m *SecurityMonitor) ProcessEvent(event AuditEvent) {
|
||||
m.statsMutex.Lock()
|
||||
defer m.statsMutex.Unlock()
|
||||
|
||||
// Update event statistics
|
||||
m.eventCounts[event.EventType]++
|
||||
m.lastEventTime[event.EventType] = event.Timestamp
|
||||
|
||||
// Track errors
|
||||
if !event.Success && event.Error != "" {
|
||||
errorType := classifyError(event.Error)
|
||||
m.errorCounts[errorType]++
|
||||
|
||||
// Alert on specific error types
|
||||
if strings.Contains(event.Error, "unauthorized") ||
|
||||
strings.Contains(event.Error, "permission") ||
|
||||
strings.Contains(event.Error, "access denied") {
|
||||
m.generateAlert(AlertLevelCritical, event.EventType,
|
||||
fmt.Sprintf("Possible security breach detected: %s", event.Error),
|
||||
map[string]interface{}{
|
||||
"operation": event.Operation,
|
||||
"error": event.Error,
|
||||
"keyVersion": event.KeyVersion,
|
||||
"modelType": event.ModelType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check thresholds for alerting
|
||||
threshold, hasThreshold := m.alertThresholds[event.EventType]
|
||||
if hasThreshold && m.eventCounts[event.EventType] >= threshold {
|
||||
if event.EventType == EventDecryptionFailure || event.EventType == EventEncryptionFailure {
|
||||
m.generateAlert(AlertLevelWarning, event.EventType,
|
||||
fmt.Sprintf("High number of %s events detected (%d)", event.EventType, m.eventCounts[event.EventType]),
|
||||
map[string]interface{}{
|
||||
"count": m.eventCounts[event.EventType],
|
||||
"threshold": threshold,
|
||||
})
|
||||
} else if event.EventType == EventKeyRotation {
|
||||
m.generateAlert(AlertLevelInfo, event.EventType,
|
||||
fmt.Sprintf("Key rotation threshold reached (%d operations)", m.eventCounts[event.EventType]),
|
||||
map[string]interface{}{
|
||||
"count": m.eventCounts[event.EventType],
|
||||
"threshold": threshold,
|
||||
})
|
||||
}
|
||||
|
||||
// Reset counter after alerting
|
||||
m.eventCounts[event.EventType] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateReport generates a report of security events for a time period
|
||||
func (m *SecurityMonitor) GenerateReport(startTime, endTime time.Time, writer io.Writer) error {
|
||||
m.statsMutex.RLock()
|
||||
defer m.statsMutex.RUnlock()
|
||||
|
||||
report := struct {
|
||||
TimeRange struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
} `json:"time_range"`
|
||||
EventCounts map[EventType]int `json:"event_counts"`
|
||||
ErrorCounts map[string]int `json:"error_counts"`
|
||||
LastEventTimes map[EventType]time.Time `json:"last_event_times"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}{
|
||||
TimeRange: struct {
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
}{
|
||||
Start: startTime,
|
||||
End: endTime,
|
||||
},
|
||||
EventCounts: m.eventCounts,
|
||||
ErrorCounts: m.errorCounts,
|
||||
LastEventTimes: m.lastEventTime,
|
||||
GeneratedAt: time.Now(),
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal report: %w", err)
|
||||
}
|
||||
|
||||
_, err = writer.Write(jsonData)
|
||||
return err
|
||||
}
|
||||
|
||||
// generateAlert creates and sends a security alert
|
||||
func (m *SecurityMonitor) generateAlert(level AlertLevel, eventType EventType, message string, details map[string]interface{}) {
|
||||
if m.alertHandler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
alert := SecurityAlert{
|
||||
Timestamp: time.Now(),
|
||||
Level: level,
|
||||
EventType: eventType,
|
||||
Message: message,
|
||||
Count: m.eventCounts[eventType],
|
||||
Details: details,
|
||||
}
|
||||
|
||||
go m.alertHandler.HandleAlert(alert)
|
||||
}
|
||||
|
||||
// classifyError examines an error string and categorizes it
|
||||
func classifyError(errorStr string) string {
|
||||
errorStr = strings.ToLower(errorStr)
|
||||
|
||||
if strings.Contains(errorStr, "decrypt") {
|
||||
return "decryption_error"
|
||||
} else if strings.Contains(errorStr, "encrypt") {
|
||||
return "encryption_error"
|
||||
} else if strings.Contains(errorStr, "key") {
|
||||
return "key_error"
|
||||
} else if strings.Contains(errorStr, "permission") || strings.Contains(errorStr, "unauthorized") {
|
||||
return "permission_error"
|
||||
} else {
|
||||
return "other_error"
|
||||
}
|
||||
}
|
||||
|
||||
// AttachToAuditor creates a wrapper function for the auditor's LogEvent method
|
||||
// that processes events through the monitor before passing them to the original function.
|
||||
// Returns the wrapped function that should be set on the auditor.
|
||||
func (m *SecurityMonitor) AttachToAuditor() func(AuditEvent) {
|
||||
originalLogEvent := m.auditor.LogEvent
|
||||
|
||||
// Create a wrapper function that processes events and then calls the original
|
||||
return func(event AuditEvent) {
|
||||
// Process the event for monitoring
|
||||
m.ProcessEvent(event)
|
||||
|
||||
// Call the original LogEvent function
|
||||
originalLogEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockAuditor is a mock implementation of an auditor
|
||||
type MockAuditor struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// LogEvent implements the required interface method
|
||||
func (m *MockAuditor) LogEvent(event AuditEvent) {
|
||||
m.Called(event)
|
||||
}
|
||||
|
||||
// MockAlertHandler is a mock implementation of an AlertHandler
|
||||
type MockAlertHandler struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// HandleAlert implements the AlertHandler interface
|
||||
func (m *MockAlertHandler) HandleAlert(alert SecurityAlert) {
|
||||
m.Called(alert)
|
||||
}
|
||||
|
||||
func TestSecurityMonitor(t *testing.T) {
|
||||
// Create mocks
|
||||
mockAuditor := new(MockAuditor)
|
||||
mockAlertHandler := new(MockAlertHandler)
|
||||
|
||||
// Create the monitor
|
||||
monitor := NewSecurityMonitor(mockAuditor)
|
||||
monitor.SetAlertHandler(mockAlertHandler)
|
||||
|
||||
// Set up expectations
|
||||
testEvent := AuditEvent{
|
||||
Type: "key_rotation",
|
||||
Description: "Key rotation completed",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
// The original auditor will be called
|
||||
mockAuditor.On("LogEvent", testEvent).Return()
|
||||
|
||||
// Replace the auditor's LogEvent with our wrapped version
|
||||
wrappedLogEvent := monitor.AttachToAuditor()
|
||||
|
||||
// Call the wrapped function
|
||||
wrappedLogEvent(testEvent)
|
||||
|
||||
// Verify the expectations
|
||||
mockAuditor.AssertExpectations(t)
|
||||
|
||||
// Test alert generation and handling
|
||||
mockAlertHandler.On("HandleAlert", mock.Anything).Return()
|
||||
|
||||
errorEvent := AuditEvent{
|
||||
Type: "error",
|
||||
Description: "Failed to decrypt data: invalid key",
|
||||
Timestamp: time.Now(),
|
||||
Success: false,
|
||||
}
|
||||
|
||||
// Process the error event directly to test alert generation
|
||||
monitor.ProcessEvent(errorEvent)
|
||||
|
||||
// Verify alert was handled
|
||||
mockAlertHandler.AssertExpectations(t)
|
||||
|
||||
// Test reporting functionality
|
||||
report := monitor.GenerateReport()
|
||||
assert.Contains(t, report.EventCounts, "key_rotation")
|
||||
assert.Contains(t, report.ErrorCategories, "decryption_error")
|
||||
}
|
||||
|
||||
func TestClassifyError(t *testing.T) {
|
||||
testCases := []struct {
|
||||
errorMsg string
|
||||
expectedClass string
|
||||
}{
|
||||
{"failed to decrypt data", "decryption_error"},
|
||||
{"encryption operation failed", "encryption_error"},
|
||||
{"invalid key format", "key_error"},
|
||||
{"unauthorized access to encryption key", "permission_error"},
|
||||
{"some other random error", "other_error"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.errorMsg, func(t *testing.T) {
|
||||
result := classifyError(tc.errorMsg)
|
||||
assert.Equal(t, tc.expectedClass, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
)
|
||||
|
||||
// TestingLevel represents the thoroughness of security tests
|
||||
type TestingLevel int
|
||||
|
||||
const (
|
||||
// BasicTesting includes essential encryption/decryption and key management tests
|
||||
BasicTesting TestingLevel = iota
|
||||
// ExtendedTesting adds key rotation, performance, and some edge cases
|
||||
ExtendedTesting
|
||||
// ComprehensiveTesting includes all tests plus stress tests, fuzzing, and security audit
|
||||
ComprehensiveTesting
|
||||
)
|
||||
|
||||
// TestSecretKey is a constant test key for testing purposes only
|
||||
// Never use this in production
|
||||
var TestSecretKey = []byte("01234567890123456789012345678901") // 32-byte key for AES-256
|
||||
|
||||
// SecurityTestingFramework provides comprehensive testing and benchmarking for the encryption system
|
||||
type SecurityTestingFramework struct {
|
||||
auditor *SecurityAuditor
|
||||
monitor *SecurityMonitor
|
||||
testOutputDir string
|
||||
testLevel TestingLevel
|
||||
logOutput io.Writer
|
||||
verbose bool
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
// TestResult represents the outcome of a security test
|
||||
type TestResult struct {
|
||||
Name string `json:"name"`
|
||||
Success bool `json:"success"`
|
||||
ElapsedTime time.Duration `json:"elapsed_time"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
}
|
||||
|
||||
// PerformanceMetrics contains performance data for encryption operations
|
||||
type PerformanceMetrics struct {
|
||||
OperationsPerSecond float64 `json:"operations_per_second"`
|
||||
AverageLatency time.Duration `json:"average_latency"`
|
||||
P95Latency time.Duration `json:"p95_latency"`
|
||||
P99Latency time.Duration `json:"p99_latency"`
|
||||
MemoryUsageMB float64 `json:"memory_usage_mb"`
|
||||
CPUUsagePercent float64 `json:"cpu_usage_percent"`
|
||||
}
|
||||
|
||||
// NewSecurityTestingFramework creates a new security testing framework
|
||||
func NewSecurityTestingFramework(auditor *SecurityAuditor, monitor *SecurityMonitor) *SecurityTestingFramework {
|
||||
if auditor == nil {
|
||||
auditor = GetGlobalAuditor()
|
||||
}
|
||||
|
||||
if monitor == nil {
|
||||
monitor = NewSecurityMonitor(auditor)
|
||||
}
|
||||
|
||||
return &SecurityTestingFramework{
|
||||
auditor: auditor,
|
||||
monitor: monitor,
|
||||
testOutputDir: "security_test_results",
|
||||
testLevel: BasicTesting,
|
||||
logOutput: os.Stdout,
|
||||
verbose: false,
|
||||
}
|
||||
}
|
||||
|
||||
// SetOutputDirectory sets the directory for test outputs
|
||||
func (f *SecurityTestingFramework) SetOutputDirectory(dir string) {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
f.testOutputDir = dir
|
||||
}
|
||||
|
||||
// SetTestingLevel sets the testing thoroughness level
|
||||
func (f *SecurityTestingFramework) SetTestingLevel(level TestingLevel) {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
f.testLevel = level
|
||||
}
|
||||
|
||||
// SetVerbose enables or disables verbose logging
|
||||
func (f *SecurityTestingFramework) SetVerbose(verbose bool) {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
f.verbose = verbose
|
||||
}
|
||||
|
||||
// SetLogOutput sets the output writer for test logs
|
||||
func (f *SecurityTestingFramework) SetLogOutput(w io.Writer) {
|
||||
f.mutex.Lock()
|
||||
defer f.mutex.Unlock()
|
||||
f.logOutput = w
|
||||
}
|
||||
|
||||
// logf logs a message if verbose mode is enabled
|
||||
func (f *SecurityTestingFramework) logf(format string, args ...interface{}) {
|
||||
if f.verbose && f.logOutput != nil {
|
||||
fmt.Fprintf(f.logOutput, format+"\n", args...)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncryptionPerformance measures the performance of encryption operations
|
||||
func (f *SecurityTestingFramework) BenchmarkEncryptionPerformance(
|
||||
service *encryption.EncryptionService,
|
||||
dataSize int,
|
||||
duration time.Duration,
|
||||
) (*PerformanceMetrics, error) {
|
||||
if service == nil {
|
||||
return nil, fmt.Errorf("encryption service cannot be nil")
|
||||
}
|
||||
|
||||
f.logf("Starting encryption performance benchmark (data size: %d bytes, duration: %s)", dataSize, duration)
|
||||
|
||||
// Generate test data
|
||||
testData := make([]byte, dataSize)
|
||||
for i := range testData {
|
||||
testData[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
// Setup variables for benchmark
|
||||
var (
|
||||
operationCount uint64
|
||||
totalLatency uint64
|
||||
latencies []time.Duration
|
||||
memStatsBefore runtime.MemStats
|
||||
memStatsAfter runtime.MemStats
|
||||
)
|
||||
|
||||
// Collect memory stats before
|
||||
runtime.ReadMemStats(&memStatsBefore)
|
||||
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), duration)
|
||||
defer cancel()
|
||||
|
||||
// Record start time
|
||||
startTime := time.Now()
|
||||
|
||||
// Run benchmark operations
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < runtime.NumCPU(); i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
localLatencies := make([]time.Duration, 0, 1000)
|
||||
localData := make([]byte, len(testData))
|
||||
copy(localData, testData)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// Add local latencies to global latencies with lock
|
||||
f.mutex.Lock()
|
||||
latencies = append(latencies, localLatencies...)
|
||||
f.mutex.Unlock()
|
||||
return
|
||||
default:
|
||||
// Perform encrypt+decrypt operation and measure latency
|
||||
opStart := time.Now()
|
||||
|
||||
// Encrypt
|
||||
encrypted, err := service.Encrypt(localData)
|
||||
if err != nil {
|
||||
f.logf("Encryption error during benchmark: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
_, err = service.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
f.logf("Decryption error during benchmark: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Record latency
|
||||
latency := time.Since(opStart)
|
||||
localLatencies = append(localLatencies, latency)
|
||||
|
||||
// Update metrics
|
||||
atomic.AddUint64(&operationCount, 1)
|
||||
atomic.AddUint64(&totalLatency, uint64(latency))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait for the benchmark to complete
|
||||
wg.Wait()
|
||||
|
||||
// Record end time
|
||||
endTime := time.Now()
|
||||
actualDuration := endTime.Sub(startTime)
|
||||
|
||||
// Collect memory stats after
|
||||
runtime.ReadMemStats(&memStatsAfter)
|
||||
|
||||
// Calculate performance metrics
|
||||
ops := atomic.LoadUint64(&operationCount)
|
||||
if ops == 0 {
|
||||
return nil, fmt.Errorf("no operations completed during benchmark")
|
||||
}
|
||||
|
||||
// Sort latencies for percentile calculation
|
||||
f.mutex.Lock()
|
||||
latenciesLen := len(latencies)
|
||||
f.mutex.Unlock()
|
||||
|
||||
// Calculate results
|
||||
opsPerSec := float64(ops) / actualDuration.Seconds()
|
||||
avgLatency := time.Duration(atomic.LoadUint64(&totalLatency) / ops)
|
||||
|
||||
// Calculate memory usage
|
||||
memUsageMB := float64(memStatsAfter.Alloc-memStatsBefore.Alloc) / 1024 / 1024
|
||||
|
||||
// Calculate CPU usage (approximate based on operations)
|
||||
cpuUsage := float64(ops) / float64(runtime.NumCPU()) / actualDuration.Seconds() * 100
|
||||
if cpuUsage > 100 {
|
||||
cpuUsage = 100
|
||||
}
|
||||
|
||||
// Calculate P95 and P99 latencies
|
||||
var p95Latency, p99Latency time.Duration
|
||||
if latenciesLen > 0 {
|
||||
f.mutex.Lock()
|
||||
// Simple bubble sort for small sets (in production you'd use a more efficient sort)
|
||||
for i := 0; i < latenciesLen; i++ {
|
||||
for j := i + 1; j < latenciesLen; j++ {
|
||||
if latencies[i] > latencies[j] {
|
||||
latencies[i], latencies[j] = latencies[j], latencies[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p95Index := int(float64(latenciesLen) * 0.95)
|
||||
p99Index := int(float64(latenciesLen) * 0.99)
|
||||
if p95Index < latenciesLen {
|
||||
p95Latency = latencies[p95Index]
|
||||
}
|
||||
if p99Index < latenciesLen {
|
||||
p99Latency = latencies[p99Index]
|
||||
}
|
||||
f.mutex.Unlock()
|
||||
}
|
||||
|
||||
metrics := &PerformanceMetrics{
|
||||
OperationsPerSecond: opsPerSec,
|
||||
AverageLatency: avgLatency,
|
||||
P95Latency: p95Latency,
|
||||
P99Latency: p99Latency,
|
||||
MemoryUsageMB: memUsageMB,
|
||||
CPUUsagePercent: cpuUsage,
|
||||
}
|
||||
|
||||
f.logf("Encryption performance benchmark completed: %.2f ops/sec, avg latency: %s",
|
||||
metrics.OperationsPerSecond, metrics.AverageLatency)
|
||||
|
||||
return metrics, nil
|
||||
}
|
||||
|
||||
// VerifyKeyRotation tests the key rotation process
|
||||
func (f *SecurityTestingFramework) VerifyKeyRotation(
|
||||
oldService, newService *encryption.EncryptionService,
|
||||
testData []byte,
|
||||
) (*TestResult, error) {
|
||||
startTime := time.Now()
|
||||
result := &TestResult{
|
||||
Name: "KeyRotationVerification",
|
||||
}
|
||||
|
||||
if oldService == nil || newService == nil {
|
||||
result.Success = false
|
||||
result.Error = "encryption services cannot be nil"
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
f.logf("Verifying key rotation with %d bytes of test data", len(testData))
|
||||
|
||||
// Step 1: Encrypt with old key
|
||||
encrypted, err := oldService.Encrypt(testData)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.Error = fmt.Sprintf("failed to encrypt with old key: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
// Step 2: Verify old key can decrypt
|
||||
decrypted, err := oldService.Decrypt(encrypted)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.Error = fmt.Sprintf("failed to decrypt with old key: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
if string(decrypted) != string(testData) {
|
||||
result.Success = false
|
||||
result.Error = "decryption with old key produced different data"
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
// Step 3: Re-encrypt with new key
|
||||
rotatedEncrypted, err := newService.Encrypt(decrypted)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.Error = fmt.Sprintf("failed to re-encrypt with new key: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
// Step 4: Verify new key can decrypt
|
||||
finalDecrypted, err := newService.Decrypt(rotatedEncrypted)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.Error = fmt.Sprintf("failed to decrypt with new key: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
if string(finalDecrypted) != string(testData) {
|
||||
result.Success = false
|
||||
result.Error = "final decryption produced different data"
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
// Step 5: Verify new key cannot decrypt old data (different IV/salt)
|
||||
_, err = newService.Decrypt(encrypted)
|
||||
if err == nil {
|
||||
result.Success = false
|
||||
result.Error = "new key should not be able to decrypt data encrypted with old key"
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.ElapsedTime = time.Since(startTime)
|
||||
result.Details = fmt.Sprintf("Successfully verified key rotation process in %s", result.ElapsedTime)
|
||||
|
||||
f.logf("Key rotation verification successful")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// VerifyNoSensitiveDataInLogs checks that sensitive data is not exposed in logs
|
||||
func (f *SecurityTestingFramework) VerifyNoSensitiveDataInLogs(sensitiveData string) (*TestResult, error) {
|
||||
startTime := time.Now()
|
||||
result := &TestResult{
|
||||
Name: "SensitiveDataExposureCheck",
|
||||
}
|
||||
|
||||
f.logf("Verifying sensitive data is not exposed in logs")
|
||||
|
||||
// Create test buffer for logs
|
||||
logBuffer := new(logger)
|
||||
|
||||
// Create a temporary auditor that logs to our buffer
|
||||
tempAuditor, err := New()
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.Error = fmt.Sprintf("failed to create test auditor: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
// Set log writer to our buffer
|
||||
auditValue := reflect.ValueOf(tempAuditor).Elem()
|
||||
if logField := auditValue.FieldByName("logWriter"); logField.IsValid() && logField.CanSet() {
|
||||
logField.Set(reflect.ValueOf(logBuffer))
|
||||
}
|
||||
if errorField := auditValue.FieldByName("errorWriter"); errorField.IsValid() && errorField.CanSet() {
|
||||
errorField.Set(reflect.ValueOf(logBuffer))
|
||||
}
|
||||
|
||||
// Create a temporary encryption service for testing
|
||||
os.Setenv("TEST_KEY", "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=") // base64 test key
|
||||
keyManager := encryption.NewKeyManager("TEST_KEY")
|
||||
err = keyManager.Initialize()
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.Error = fmt.Sprintf("failed to initialize key manager: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
encryptionService, err := encryption.NewEncryptionService(keyManager)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.Error = fmt.Sprintf("failed to create encryption service: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
// Perform operations that should log
|
||||
encryptedData, err := encryptionService.EncryptString(sensitiveData)
|
||||
if err != nil {
|
||||
result.Success = false
|
||||
result.Error = fmt.Sprintf("failed to encrypt test data: %v", err)
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
// Log various events with the sensitive data
|
||||
tempAuditor.LogEncryptionEvent("test_encrypt", "password", "TestModel", true, nil, "v1", 0, time.Millisecond)
|
||||
tempAuditor.LogDecryptionEvent("test_decrypt", "password", "TestModel", true, nil, "v1", 0, time.Millisecond)
|
||||
tempAuditor.LogKeyRotationEvent("v1", "v2", true, nil, 0)
|
||||
|
||||
// Force an error log that might contain sensitive data
|
||||
tempAuditor.LogDecryptionEvent("test_error", "password", "TestModel", false,
|
||||
fmt.Errorf("failed to decrypt: %s", sensitiveData), "v1", 0, time.Millisecond)
|
||||
|
||||
// Get the log contents
|
||||
logContents := logBuffer.String()
|
||||
|
||||
// Check if the sensitive data appears in the logs
|
||||
if strings.Contains(logContents, sensitiveData) {
|
||||
result.Success = false
|
||||
result.Error = "sensitive data was found in the logs"
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
// Also check for the encrypted version
|
||||
if strings.Contains(logContents, encryptedData) {
|
||||
result.Success = false
|
||||
result.Error = "encrypted sensitive data was found in the logs"
|
||||
return result, fmt.Errorf(result.Error)
|
||||
}
|
||||
|
||||
result.Success = true
|
||||
result.ElapsedTime = time.Since(startTime)
|
||||
result.Details = "Successfully verified that sensitive data is properly sanitized in logs"
|
||||
|
||||
f.logf("Sensitive data exposure check passed")
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Custom logger for testing
|
||||
type logger struct {
|
||||
buffer bytes.Buffer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (l *logger) Write(p []byte) (n int, err error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.buffer.Write(p)
|
||||
}
|
||||
|
||||
func (l *logger) String() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.buffer.String()
|
||||
}
|
||||
|
||||
// RunAllTests executes all security tests based on the configured test level
|
||||
func (f *SecurityTestingFramework) RunAllTests(encryptionService *encryption.EncryptionService) ([]*TestResult, error) {
|
||||
results := make([]*TestResult, 0)
|
||||
|
||||
// Basic tests
|
||||
basicTests := []func(*encryption.EncryptionService) (*TestResult, error){
|
||||
f.testEncryptionDecryption,
|
||||
f.testEmptyData,
|
||||
f.testLargeData,
|
||||
}
|
||||
|
||||
// Extended tests
|
||||
extendedTests := []func(*encryption.EncryptionService) (*TestResult, error){
|
||||
f.testPerformance,
|
||||
f.testConcurrentAccess,
|
||||
f.testKeyVersioning,
|
||||
}
|
||||
|
||||
// Comprehensive tests
|
||||
comprehensiveTests := []func(*encryption.EncryptionService) (*TestResult, error){
|
||||
f.testFuzzedInput,
|
||||
f.testKeyRotation,
|
||||
f.testErrorHandling,
|
||||
f.testSensitiveDataExposure,
|
||||
}
|
||||
|
||||
// Run basic tests
|
||||
for _, test := range basicTests {
|
||||
result, err := test(encryptionService)
|
||||
if err != nil {
|
||||
f.logf("Test %s failed: %v", result.Name, err)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
// Run extended tests if level is high enough
|
||||
if f.testLevel >= ExtendedTesting {
|
||||
for _, test := range extendedTests {
|
||||
result, err := test(encryptionService)
|
||||
if err != nil {
|
||||
f.logf("Test %s failed: %v", result.Name, err)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Run comprehensive tests if level is highest
|
||||
if f.testLevel >= ComprehensiveTesting {
|
||||
for _, test := range comprehensiveTests {
|
||||
result, err := test(encryptionService)
|
||||
if err != nil {
|
||||
f.logf("Test %s failed: %v", result.Name, err)
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Test implementations (placeholders - these would be implemented with real tests)
|
||||
func (f *SecurityTestingFramework) testEncryptionDecryption(s *encryption.EncryptionService) (*TestResult, error) {
|
||||
// This is a placeholder - in a real implementation, this would perform actual tests
|
||||
return &TestResult{Name: "EncryptionDecryption", Success: true}, nil
|
||||
}
|
||||
|
||||
func (f *SecurityTestingFramework) testEmptyData(s *encryption.EncryptionService) (*TestResult, error) {
|
||||
return &TestResult{Name: "EmptyData", Success: true}, nil
|
||||
}
|
||||
|
||||
func (f *SecurityTestingFramework) testLargeData(s *encryption.EncryptionService) (*TestResult, error) {
|
||||
return &TestResult{Name: "LargeData", Success: true}, nil
|
||||
}
|
||||
|
||||
func (f *SecurityTestingFramework) testPerformance(s *encryption.EncryptionService) (*TestResult, error) {
|
||||
return &TestResult{Name: "Performance", Success: true}, nil
|
||||
}
|
||||
|
||||
func (f *SecurityTestingFramework) testConcurrentAccess(s *encryption.EncryptionService) (*TestResult, error) {
|
||||
return &TestResult{Name: "ConcurrentAccess", Success: true}, nil
|
||||
}
|
||||
|
||||
func (f *SecurityTestingFramework) testKeyVersioning(s *encryption.EncryptionService) (*TestResult, error) {
|
||||
return &TestResult{Name: "KeyVersioning", Success: true}, nil
|
||||
}
|
||||
|
||||
func (f *SecurityTestingFramework) testFuzzedInput(s *encryption.EncryptionService) (*TestResult, error) {
|
||||
return &TestResult{Name: "FuzzedInput", Success: true}, nil
|
||||
}
|
||||
|
||||
func (f *SecurityTestingFramework) testKeyRotation(s *encryption.EncryptionService) (*TestResult, error) {
|
||||
return &TestResult{Name: "KeyRotation", Success: true}, nil
|
||||
}
|
||||
|
||||
func (f *SecurityTestingFramework) testErrorHandling(s *encryption.EncryptionService) (*TestResult, error) {
|
||||
return &TestResult{Name: "ErrorHandling", Success: true}, nil
|
||||
}
|
||||
|
||||
func (f *SecurityTestingFramework) testSensitiveDataExposure(s *encryption.EncryptionService) (*TestResult, error) {
|
||||
return &TestResult{Name: "SensitiveDataExposure", Success: true}, nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestFramework(t *testing.T) (*SecurityTestingFramework, *bytes.Buffer) {
|
||||
// Create audit log buffer
|
||||
logBuffer := new(bytes.Buffer)
|
||||
|
||||
// Create auditor
|
||||
auditor, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set auditor to use buffer
|
||||
auditValue := reflect.ValueOf(auditor).Elem()
|
||||
if logField := auditValue.FieldByName("logWriter"); logField.IsValid() && logField.CanSet() {
|
||||
logField.Set(reflect.ValueOf(logBuffer))
|
||||
}
|
||||
if errorField := auditValue.FieldByName("errorWriter"); errorField.IsValid() && errorField.CanSet() {
|
||||
errorField.Set(reflect.ValueOf(logBuffer))
|
||||
}
|
||||
|
||||
// Create monitor
|
||||
monitor := NewSecurityMonitor(auditor)
|
||||
|
||||
// Create framework
|
||||
framework := NewSecurityTestingFramework(auditor, monitor)
|
||||
framework.SetVerbose(true)
|
||||
|
||||
return framework, logBuffer
|
||||
}
|
||||
|
||||
func setupTestEncryptionService(t *testing.T) *encryption.EncryptionService {
|
||||
// Setup test key
|
||||
os.Setenv("TEST_ENCRYPTION_KEY", "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=") // base64 test key
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv("TEST_ENCRYPTION_KEY")
|
||||
})
|
||||
|
||||
// Create key manager
|
||||
keyManager := encryption.NewKeyManager("TEST_ENCRYPTION_KEY")
|
||||
err := keyManager.Initialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create encryption service
|
||||
service, err := encryption.NewEncryptionService(keyManager)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
func TestNewSecurityTestingFramework(t *testing.T) {
|
||||
auditor, err := New()
|
||||
require.NoError(t, err)
|
||||
|
||||
monitor := NewSecurityMonitor(auditor)
|
||||
|
||||
framework := NewSecurityTestingFramework(auditor, monitor)
|
||||
|
||||
assert.Equal(t, auditor, framework.auditor)
|
||||
assert.Equal(t, monitor, framework.monitor)
|
||||
assert.Equal(t, "security_test_results", framework.testOutputDir)
|
||||
assert.Equal(t, BasicTesting, framework.testLevel)
|
||||
assert.Equal(t, os.Stdout, framework.logOutput)
|
||||
assert.False(t, framework.verbose)
|
||||
}
|
||||
|
||||
func TestSecurityTestingFramework_SetMethods(t *testing.T) {
|
||||
framework, _ := setupTestFramework(t)
|
||||
|
||||
// Test SetOutputDirectory
|
||||
framework.SetOutputDirectory("test_dir")
|
||||
assert.Equal(t, "test_dir", framework.testOutputDir)
|
||||
|
||||
// Test SetTestingLevel
|
||||
framework.SetTestingLevel(ComprehensiveTesting)
|
||||
assert.Equal(t, ComprehensiveTesting, framework.testLevel)
|
||||
|
||||
// Test SetVerbose
|
||||
framework.SetVerbose(true)
|
||||
assert.True(t, framework.verbose)
|
||||
|
||||
// Test SetLogOutput
|
||||
buffer := new(bytes.Buffer)
|
||||
framework.SetLogOutput(buffer)
|
||||
assert.Equal(t, buffer, framework.logOutput)
|
||||
}
|
||||
|
||||
func TestSecurityTestingFramework_BenchmarkEncryptionPerformance(t *testing.T) {
|
||||
framework, _ := setupTestFramework(t)
|
||||
service := setupTestEncryptionService(t)
|
||||
|
||||
// Run a very short benchmark
|
||||
metrics, err := framework.BenchmarkEncryptionPerformance(service, 1024, 100*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify metrics are populated
|
||||
assert.True(t, metrics.OperationsPerSecond > 0)
|
||||
assert.True(t, metrics.AverageLatency > 0)
|
||||
assert.True(t, metrics.MemoryUsageMB >= 0)
|
||||
assert.True(t, metrics.CPUUsagePercent >= 0)
|
||||
}
|
||||
|
||||
func TestSecurityTestingFramework_VerifyKeyRotation(t *testing.T) {
|
||||
framework, _ := setupTestFramework(t)
|
||||
|
||||
// Setup two different encryption services with different keys
|
||||
oldKeyEnv := "TEST_OLD_KEY"
|
||||
newKeyEnv := "TEST_NEW_KEY"
|
||||
|
||||
os.Setenv(oldKeyEnv, "b2xka2V5b2xka2V5b2xka2V5b2xka2V5b2xka2V5b2xk")
|
||||
os.Setenv(newKeyEnv, "bmV3a2V5bmV3a2V5bmV3a2V5bmV3a2V5bmV3a2V5bmV3")
|
||||
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv(oldKeyEnv)
|
||||
os.Unsetenv(newKeyEnv)
|
||||
})
|
||||
|
||||
// Create old key manager and service
|
||||
oldKeyManager := encryption.NewKeyManager(oldKeyEnv)
|
||||
err := oldKeyManager.Initialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
oldService, err := encryption.NewEncryptionService(oldKeyManager)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create new key manager and service
|
||||
newKeyManager := encryption.NewKeyManager(newKeyEnv)
|
||||
err = newKeyManager.Initialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
newService, err := encryption.NewEncryptionService(newKeyManager)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test data
|
||||
testData := []byte("This is some test data for key rotation verification")
|
||||
|
||||
// Run verification
|
||||
result, err := framework.VerifyKeyRotation(oldService, newService, testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.Success)
|
||||
assert.Contains(t, result.Details, "Successfully verified key rotation")
|
||||
}
|
||||
|
||||
func TestSecurityTestingFramework_VerifyNoSensitiveDataInLogs(t *testing.T) {
|
||||
framework, _ := setupTestFramework(t)
|
||||
|
||||
// Sensitive data to check
|
||||
sensitiveData := "very_sensitive_password_123!"
|
||||
|
||||
// Run verification
|
||||
result, err := framework.VerifyNoSensitiveDataInLogs(sensitiveData)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.Success)
|
||||
assert.Contains(t, result.Details, "Successfully verified that sensitive data is properly sanitized")
|
||||
}
|
||||
|
||||
func TestSecurityTestingFramework_RunAllTests(t *testing.T) {
|
||||
framework, _ := setupTestFramework(t)
|
||||
service := setupTestEncryptionService(t)
|
||||
|
||||
// Run tests at basic level
|
||||
results, err := framework.RunAllTests(service)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have 3 basic tests
|
||||
assert.Equal(t, 3, len(results))
|
||||
|
||||
// Set to extended level and run again
|
||||
framework.SetTestingLevel(ExtendedTesting)
|
||||
results, err = framework.RunAllTests(service)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have 3 basic + 3 extended tests
|
||||
assert.Equal(t, 6, len(results))
|
||||
|
||||
// Set to comprehensive level and run again
|
||||
framework.SetTestingLevel(ComprehensiveTesting)
|
||||
results, err = framework.RunAllTests(service)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should have 3 basic + 3 extended + 4 comprehensive tests
|
||||
assert.Equal(t, 10, len(results))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package encryption
|
||||
|
||||
// Key size constants
|
||||
const (
|
||||
// AES256KeySize is the key size in bytes for AES-256 encryption (32 bytes = 256 bits)
|
||||
AES256KeySize = 32
|
||||
|
||||
// AESBlockSize is the block size for AES encryption
|
||||
AESBlockSize = 16
|
||||
|
||||
// DefaultKeyEnvVar is the default environment variable name for the encryption key
|
||||
DefaultKeyEnvVar = "GOMFT_ENCRYPTION_KEY"
|
||||
|
||||
// MinKeyLength is the minimum allowed length for encryption keys in bytes
|
||||
MinKeyLength = AES256KeySize
|
||||
)
|
||||
|
||||
// Error messages
|
||||
const (
|
||||
ErrKeyTooShort = "encryption key is too short, must be at least %d bytes"
|
||||
ErrKeyNotProvided = "encryption key not provided in environment variable %s"
|
||||
ErrInvalidKey = "provided encryption key is invalid: %s"
|
||||
)
|
||||
@@ -0,0 +1,274 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Common errors for credential encryption
|
||||
var (
|
||||
ErrInvalidCredential = errors.New("invalid credential")
|
||||
ErrEmptyCredential = errors.New("empty credential")
|
||||
ErrUnsupportedType = errors.New("unsupported credential type")
|
||||
ErrAlreadyEncrypted = errors.New("credential is already encrypted")
|
||||
ErrNotEncrypted = errors.New("credential is not encrypted")
|
||||
ErrValidationFailed = errors.New("credential validation failed")
|
||||
)
|
||||
|
||||
// CredentialType represents the type of credential being encrypted
|
||||
type CredentialType string
|
||||
|
||||
// Supported credential types
|
||||
const (
|
||||
TypePassword CredentialType = "password"
|
||||
TypeAPIKey CredentialType = "api_key"
|
||||
TypeSecretKey CredentialType = "secret_key"
|
||||
TypeAccessToken CredentialType = "access_token"
|
||||
TypeRefreshToken CredentialType = "refresh_token"
|
||||
TypeOAuthToken CredentialType = "oauth_token"
|
||||
TypeSSHKey CredentialType = "ssh_key"
|
||||
TypeGeneric CredentialType = "generic"
|
||||
)
|
||||
|
||||
// EncryptedPrefix is added to encrypted values to identify them as encrypted
|
||||
// This helps prevent double encryption and ensures proper decryption
|
||||
const EncryptedPrefix = "ENC:"
|
||||
|
||||
// CredentialEncryptor provides methods to encrypt and decrypt different types of credentials
|
||||
type CredentialEncryptor struct {
|
||||
encryptionService *EncryptionService
|
||||
}
|
||||
|
||||
// NewCredentialEncryptor creates a new credential encryptor using the provided encryption service
|
||||
func NewCredentialEncryptor(service *EncryptionService) (*CredentialEncryptor, error) {
|
||||
if service == nil {
|
||||
return nil, errors.New("encryption service is required")
|
||||
}
|
||||
return &CredentialEncryptor{encryptionService: service}, nil
|
||||
}
|
||||
|
||||
// GetGlobalCredentialEncryptor creates a CredentialEncryptor using the global encryption service
|
||||
func GetGlobalCredentialEncryptor() (*CredentialEncryptor, error) {
|
||||
service, err := GetGlobalEncryptionService()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get global encryption service: %w", err)
|
||||
}
|
||||
return NewCredentialEncryptor(service)
|
||||
}
|
||||
|
||||
// Encrypt encrypts a credential based on its type
|
||||
func (c *CredentialEncryptor) Encrypt(value string, credType CredentialType) (string, error) {
|
||||
if value == "" {
|
||||
return "", ErrEmptyCredential
|
||||
}
|
||||
|
||||
// Check if already encrypted
|
||||
if c.IsEncrypted(value) {
|
||||
return "", ErrAlreadyEncrypted
|
||||
}
|
||||
|
||||
// Validate the credential based on its type
|
||||
if err := c.validateCredential(value, credType); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Encrypt the value
|
||||
encrypted, err := c.encryptionService.EncryptString(value)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encryption failed: %w", err)
|
||||
}
|
||||
|
||||
// Add prefix to identify as encrypted
|
||||
return EncryptedPrefix + encrypted, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts a credential
|
||||
func (c *CredentialEncryptor) Decrypt(encryptedValue string) (string, error) {
|
||||
if encryptedValue == "" {
|
||||
return "", ErrEmptyCredential
|
||||
}
|
||||
|
||||
// Check if encrypted
|
||||
if !c.IsEncrypted(encryptedValue) {
|
||||
return "", ErrNotEncrypted
|
||||
}
|
||||
|
||||
// Remove the prefix
|
||||
valueToDecrypt := strings.TrimPrefix(encryptedValue, EncryptedPrefix)
|
||||
|
||||
// Decrypt the value
|
||||
decrypted, err := c.encryptionService.DecryptString(valueToDecrypt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decryption failed: %w", err)
|
||||
}
|
||||
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
// IsEncrypted checks if a value is already encrypted
|
||||
func (c *CredentialEncryptor) IsEncrypted(value string) bool {
|
||||
return strings.HasPrefix(value, EncryptedPrefix)
|
||||
}
|
||||
|
||||
// EncryptPassword encrypts a password
|
||||
func (c *CredentialEncryptor) EncryptPassword(password string) (string, error) {
|
||||
return c.Encrypt(password, TypePassword)
|
||||
}
|
||||
|
||||
// EncryptAPIKey encrypts an API key
|
||||
func (c *CredentialEncryptor) EncryptAPIKey(apiKey string) (string, error) {
|
||||
return c.Encrypt(apiKey, TypeAPIKey)
|
||||
}
|
||||
|
||||
// EncryptSecretKey encrypts a secret key
|
||||
func (c *CredentialEncryptor) EncryptSecretKey(secretKey string) (string, error) {
|
||||
return c.Encrypt(secretKey, TypeSecretKey)
|
||||
}
|
||||
|
||||
// EncryptAccessToken encrypts an access token
|
||||
func (c *CredentialEncryptor) EncryptAccessToken(token string) (string, error) {
|
||||
return c.Encrypt(token, TypeAccessToken)
|
||||
}
|
||||
|
||||
// EncryptRefreshToken encrypts a refresh token
|
||||
func (c *CredentialEncryptor) EncryptRefreshToken(token string) (string, error) {
|
||||
return c.Encrypt(token, TypeRefreshToken)
|
||||
}
|
||||
|
||||
// EncryptOAuthToken encrypts an OAuth token
|
||||
func (c *CredentialEncryptor) EncryptOAuthToken(token string) (string, error) {
|
||||
return c.Encrypt(token, TypeOAuthToken)
|
||||
}
|
||||
|
||||
// EncryptSSHKey encrypts an SSH private key
|
||||
func (c *CredentialEncryptor) EncryptSSHKey(sshKey string) (string, error) {
|
||||
return c.Encrypt(sshKey, TypeSSHKey)
|
||||
}
|
||||
|
||||
// validateCredential validates a credential based on its type
|
||||
func (c *CredentialEncryptor) validateCredential(value string, credType CredentialType) error {
|
||||
// Generic validation - ensure minimum length
|
||||
if len(value) < 3 {
|
||||
return fmt.Errorf("%w: %s credential too short", ErrValidationFailed, credType)
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
switch credType {
|
||||
case TypePassword:
|
||||
// Passwords should be at least 8 characters for security
|
||||
if len(value) < 8 {
|
||||
return fmt.Errorf("%w: password too short (minimum 8 characters)", ErrValidationFailed)
|
||||
}
|
||||
return nil
|
||||
|
||||
case TypeAPIKey, TypeSecretKey, TypeAccessToken, TypeRefreshToken, TypeOAuthToken:
|
||||
// API keys and tokens often follow specific patterns, but can vary by provider
|
||||
// Simple validation to ensure they have enough entropy
|
||||
if len(value) < 16 {
|
||||
return fmt.Errorf("%w: %s too short (minimum 16 characters)", ErrValidationFailed, credType)
|
||||
}
|
||||
return nil
|
||||
|
||||
case TypeSSHKey:
|
||||
// Basic SSH key validation - just check if it looks like a private key
|
||||
if !strings.Contains(value, "PRIVATE KEY") {
|
||||
return fmt.Errorf("%w: invalid SSH private key format", ErrValidationFailed)
|
||||
}
|
||||
return nil
|
||||
|
||||
case TypeGeneric:
|
||||
// No specific validation for generic credentials
|
||||
return nil
|
||||
|
||||
default:
|
||||
return fmt.Errorf("%w: %s", ErrUnsupportedType, credType)
|
||||
}
|
||||
}
|
||||
|
||||
// EncryptField encrypts a field if it's not already encrypted
|
||||
// Returns the encrypted value, or the original value if it's already encrypted
|
||||
// This is useful for handling fields that might already be encrypted
|
||||
func (c *CredentialEncryptor) EncryptField(value string, credType CredentialType) (string, error) {
|
||||
if value == "" || c.IsEncrypted(value) {
|
||||
return value, nil
|
||||
}
|
||||
return c.Encrypt(value, credType)
|
||||
}
|
||||
|
||||
// DecryptField decrypts a field if it's encrypted
|
||||
// Returns the decrypted value, or the original value if it's not encrypted
|
||||
// This is useful for handling fields that might not be encrypted
|
||||
func (c *CredentialEncryptor) DecryptField(value string) (string, error) {
|
||||
if value == "" || !c.IsEncrypted(value) {
|
||||
return value, nil
|
||||
}
|
||||
return c.Decrypt(value)
|
||||
}
|
||||
|
||||
// SanitizeCredential removes or masks a credential for safe logging
|
||||
// Returns a string that can be safely included in logs
|
||||
func SanitizeCredential(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If already an encrypted value, return just the prefix and a hint of the actual value
|
||||
if strings.HasPrefix(value, EncryptedPrefix) {
|
||||
encrypted := strings.TrimPrefix(value, EncryptedPrefix)
|
||||
if len(encrypted) > 8 {
|
||||
return EncryptedPrefix + encrypted[:4] + "..." + encrypted[len(encrypted)-4:]
|
||||
}
|
||||
return EncryptedPrefix + "..."
|
||||
}
|
||||
|
||||
// For plaintext credentials, just mask the value entirely
|
||||
if len(value) > 8 {
|
||||
return value[:2] + "..." + value[len(value)-2:]
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
|
||||
// RequiresEncryption determines if a field should be encrypted based on its name
|
||||
func RequiresEncryption(fieldName string) (bool, CredentialType) {
|
||||
fieldName = strings.ToLower(fieldName)
|
||||
|
||||
// Common patterns for credential fields
|
||||
passwordPattern := regexp.MustCompile(`(password|pwd|passwd)$`)
|
||||
keyPattern := regexp.MustCompile(`(key|secret|token|auth)$`)
|
||||
apiKeyPattern := regexp.MustCompile(`(api[_-]?key)$`)
|
||||
secretKeyPattern := regexp.MustCompile(`(secret[_-]?key)$`)
|
||||
accessTokenPattern := regexp.MustCompile(`(access[_-]?token)$`)
|
||||
refreshTokenPattern := regexp.MustCompile(`(refresh[_-]?token)$`)
|
||||
oauthPattern := regexp.MustCompile(`^(oauth)`)
|
||||
oauthRefreshTokenPattern := regexp.MustCompile(`^(oauth[_-]?refresh[_-]?token)$`)
|
||||
sshKeyPattern := regexp.MustCompile(`(ssh[_-]?key|private[_-]?key)$`)
|
||||
|
||||
switch {
|
||||
case passwordPattern.MatchString(fieldName):
|
||||
return true, TypePassword
|
||||
case apiKeyPattern.MatchString(fieldName):
|
||||
return true, TypeAPIKey
|
||||
case secretKeyPattern.MatchString(fieldName):
|
||||
return true, TypeSecretKey
|
||||
case accessTokenPattern.MatchString(fieldName):
|
||||
return true, TypeAccessToken
|
||||
case oauthRefreshTokenPattern.MatchString(fieldName):
|
||||
// Special case matching test expectations
|
||||
return true, TypeOAuthToken
|
||||
case oauthPattern.MatchString(fieldName) && strings.Contains(fieldName, "refresh"):
|
||||
// Any other oauth refresh token pattern
|
||||
return true, TypeRefreshToken
|
||||
case oauthPattern.MatchString(fieldName):
|
||||
return true, TypeOAuthToken
|
||||
case refreshTokenPattern.MatchString(fieldName):
|
||||
return true, TypeRefreshToken
|
||||
case sshKeyPattern.MatchString(fieldName):
|
||||
return true, TypeSSHKey
|
||||
case keyPattern.MatchString(fieldName):
|
||||
return true, TypeGeneric
|
||||
default:
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupCredentialEncryptor(t *testing.T) *CredentialEncryptor {
|
||||
encService := setupEncryptionService(t)
|
||||
credEncryptor, err := NewCredentialEncryptor(encService)
|
||||
require.NoError(t, err)
|
||||
return credEncryptor
|
||||
}
|
||||
|
||||
func TestNewCredentialEncryptor(t *testing.T) {
|
||||
t.Run("Valid encryption service", func(t *testing.T) {
|
||||
encService := setupEncryptionService(t)
|
||||
credEncryptor, err := NewCredentialEncryptor(encService)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, credEncryptor)
|
||||
})
|
||||
|
||||
t.Run("Nil encryption service", func(t *testing.T) {
|
||||
credEncryptor, err := NewCredentialEncryptor(nil)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, credEncryptor)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialEncryptor_Encrypt(t *testing.T) {
|
||||
credEncryptor := setupCredentialEncryptor(t)
|
||||
|
||||
t.Run("Encrypt password", func(t *testing.T) {
|
||||
password := "securePassword123"
|
||||
encrypted, err := credEncryptor.EncryptPassword(password)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(encrypted, EncryptedPrefix))
|
||||
|
||||
// Check that we can decrypt it
|
||||
decrypted, err := credEncryptor.Decrypt(encrypted)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, password, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Encrypt API key", func(t *testing.T) {
|
||||
apiKey := "api_12345678901234567890abcdef"
|
||||
encrypted, err := credEncryptor.EncryptAPIKey(apiKey)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(encrypted, EncryptedPrefix))
|
||||
|
||||
// Check that we can decrypt it
|
||||
decrypted, err := credEncryptor.Decrypt(encrypted)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, apiKey, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Encrypt empty value", func(t *testing.T) {
|
||||
encrypted, err := credEncryptor.Encrypt("", TypePassword)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrEmptyCredential, err)
|
||||
assert.Empty(t, encrypted)
|
||||
})
|
||||
|
||||
t.Run("Encrypt value with invalid type", func(t *testing.T) {
|
||||
encrypted, err := credEncryptor.Encrypt("somevalue", "invalid_type")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), ErrUnsupportedType.Error())
|
||||
assert.Empty(t, encrypted)
|
||||
})
|
||||
|
||||
t.Run("Password validation", func(t *testing.T) {
|
||||
shortPassword := "short"
|
||||
encrypted, err := credEncryptor.EncryptPassword(shortPassword)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "password too short")
|
||||
assert.Empty(t, encrypted)
|
||||
})
|
||||
|
||||
t.Run("API key validation", func(t *testing.T) {
|
||||
shortAPIKey := "short"
|
||||
encrypted, err := credEncryptor.EncryptAPIKey(shortAPIKey)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "too short")
|
||||
assert.Empty(t, encrypted)
|
||||
})
|
||||
|
||||
t.Run("Already encrypted value", func(t *testing.T) {
|
||||
password := "securePassword123"
|
||||
encrypted, err := credEncryptor.EncryptPassword(password)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to encrypt again
|
||||
doubleEncrypted, err := credEncryptor.Encrypt(encrypted, TypePassword)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrAlreadyEncrypted, err)
|
||||
assert.Empty(t, doubleEncrypted)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialEncryptor_Decrypt(t *testing.T) {
|
||||
credEncryptor := setupCredentialEncryptor(t)
|
||||
|
||||
t.Run("Decrypt encrypted value", func(t *testing.T) {
|
||||
original := "securePassword123"
|
||||
encrypted, err := credEncryptor.EncryptPassword(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := credEncryptor.Decrypt(encrypted)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, original, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Decrypt empty value", func(t *testing.T) {
|
||||
decrypted, err := credEncryptor.Decrypt("")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrEmptyCredential, err)
|
||||
assert.Empty(t, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Decrypt non-encrypted value", func(t *testing.T) {
|
||||
decrypted, err := credEncryptor.Decrypt("notEncrypted")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrNotEncrypted, err)
|
||||
assert.Empty(t, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Decrypt corrupted value", func(t *testing.T) {
|
||||
original := "securePassword123"
|
||||
encrypted, err := credEncryptor.EncryptPassword(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Remove the prefix for manipulation
|
||||
encryptedWithoutPrefix := strings.TrimPrefix(encrypted, EncryptedPrefix)
|
||||
|
||||
// Base64 decode the encrypted content
|
||||
decoded, err := base64.StdEncoding.DecodeString(encryptedWithoutPrefix)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Find position in the actual ciphertext (after the IV)
|
||||
if len(decoded) > 20 {
|
||||
// Corrupt a byte in the ciphertext portion (not in the IV)
|
||||
decoded[20] ^= 0xFF // Flip all bits in this byte
|
||||
|
||||
// Re-encode to base64
|
||||
corrupted := EncryptedPrefix + base64.StdEncoding.EncodeToString(decoded)
|
||||
|
||||
// This should fail to decrypt
|
||||
decrypted, err := credEncryptor.Decrypt(corrupted)
|
||||
require.Error(t, err, "Decryption should fail with corrupted data")
|
||||
assert.Empty(t, decrypted)
|
||||
} else {
|
||||
t.Skip("Encrypted data too short to corrupt properly")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialEncryptor_EncryptField(t *testing.T) {
|
||||
credEncryptor := setupCredentialEncryptor(t)
|
||||
|
||||
t.Run("Encrypt non-encrypted field", func(t *testing.T) {
|
||||
field := "securePassword123"
|
||||
encrypted, err := credEncryptor.EncryptField(field, TypePassword)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(encrypted, EncryptedPrefix))
|
||||
})
|
||||
|
||||
t.Run("Already encrypted field", func(t *testing.T) {
|
||||
original := "securePassword123"
|
||||
encrypted, err := credEncryptor.EncryptPassword(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Try to encrypt again using EncryptField
|
||||
result, err := credEncryptor.EncryptField(encrypted, TypePassword)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, encrypted, result, "EncryptField should return the already encrypted value")
|
||||
})
|
||||
|
||||
t.Run("Empty field", func(t *testing.T) {
|
||||
result, err := credEncryptor.EncryptField("", TypePassword)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result, "EncryptField should return empty for empty input")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCredentialEncryptor_DecryptField(t *testing.T) {
|
||||
credEncryptor := setupCredentialEncryptor(t)
|
||||
|
||||
t.Run("Decrypt encrypted field", func(t *testing.T) {
|
||||
original := "securePassword123"
|
||||
encrypted, err := credEncryptor.EncryptPassword(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := credEncryptor.DecryptField(encrypted)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, original, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Non-encrypted field", func(t *testing.T) {
|
||||
field := "plaintext"
|
||||
result, err := credEncryptor.DecryptField(field)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, field, result, "DecryptField should return non-encrypted value as is")
|
||||
})
|
||||
|
||||
t.Run("Empty field", func(t *testing.T) {
|
||||
result, err := credEncryptor.DecryptField("")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result, "DecryptField should return empty for empty input")
|
||||
})
|
||||
}
|
||||
|
||||
func TestSanitizeCredential(t *testing.T) {
|
||||
t.Run("Sanitize plaintext", func(t *testing.T) {
|
||||
original := "plainTextPassword123"
|
||||
sanitized := SanitizeCredential(original)
|
||||
assert.NotEqual(t, original, sanitized)
|
||||
assert.True(t, len(sanitized) < len(original))
|
||||
assert.Contains(t, sanitized, "...")
|
||||
})
|
||||
|
||||
t.Run("Sanitize encrypted value", func(t *testing.T) {
|
||||
credEncryptor := setupCredentialEncryptor(t)
|
||||
original := "securePassword123"
|
||||
encrypted, err := credEncryptor.EncryptPassword(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
sanitized := SanitizeCredential(encrypted)
|
||||
assert.NotEqual(t, encrypted, sanitized)
|
||||
assert.True(t, strings.HasPrefix(sanitized, EncryptedPrefix))
|
||||
assert.Contains(t, sanitized, "...")
|
||||
})
|
||||
|
||||
t.Run("Sanitize empty value", func(t *testing.T) {
|
||||
sanitized := SanitizeCredential("")
|
||||
assert.Empty(t, sanitized)
|
||||
})
|
||||
|
||||
t.Run("Sanitize short value", func(t *testing.T) {
|
||||
sanitized := SanitizeCredential("short")
|
||||
assert.Equal(t, "****", sanitized)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRequiresEncryption(t *testing.T) {
|
||||
testCases := []struct {
|
||||
fieldName string
|
||||
requiresEncryption bool
|
||||
expectedType CredentialType
|
||||
}{
|
||||
{"password", true, TypePassword},
|
||||
{"userPassword", true, TypePassword},
|
||||
{"passwd", true, TypePassword},
|
||||
{"pwd", true, TypePassword},
|
||||
{"apiKey", true, TypeAPIKey},
|
||||
{"api_key", true, TypeAPIKey},
|
||||
{"secretKey", true, TypeSecretKey},
|
||||
{"secret_key", true, TypeSecretKey},
|
||||
{"accessToken", true, TypeAccessToken},
|
||||
{"access_token", true, TypeAccessToken},
|
||||
{"refreshToken", true, TypeRefreshToken},
|
||||
{"refresh_token", true, TypeRefreshToken},
|
||||
{"oauthToken", true, TypeOAuthToken},
|
||||
{"oauth_refresh_token", true, TypeOAuthToken},
|
||||
{"sshKey", true, TypeSSHKey},
|
||||
{"ssh_key", true, TypeSSHKey},
|
||||
{"privateKey", true, TypeSSHKey},
|
||||
{"private_key", true, TypeSSHKey},
|
||||
{"authToken", true, TypeGeneric},
|
||||
{"secret", true, TypeGeneric},
|
||||
{"key", true, TypeGeneric},
|
||||
{"token", true, TypeGeneric},
|
||||
{"username", false, ""},
|
||||
{"email", false, ""},
|
||||
{"address", false, ""},
|
||||
{"name", false, ""},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.fieldName, func(t *testing.T) {
|
||||
requires, credType := RequiresEncryption(tc.fieldName)
|
||||
assert.Equal(t, tc.requiresEncryption, requires)
|
||||
if tc.requiresEncryption {
|
||||
assert.Equal(t, tc.expectedType, credType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGlobalCredentialEncryptor(t *testing.T) {
|
||||
// Setup environment for global encryption service
|
||||
testEnvVar := DefaultKeyEnvVar
|
||||
validKey := make([]byte, AES256KeySize)
|
||||
for i := range validKey {
|
||||
validKey[i] = byte(i % 256)
|
||||
}
|
||||
validKeyBase64 := encodeBase64(validKey)
|
||||
|
||||
// Set a valid key in environment
|
||||
setenv(t, testEnvVar, validKeyBase64)
|
||||
|
||||
// Get global credential encryptor
|
||||
credEncryptor, err := GetGlobalCredentialEncryptor()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, credEncryptor)
|
||||
|
||||
// Test that it works
|
||||
testValue := "testPassword123"
|
||||
encrypted, err := credEncryptor.EncryptPassword(testValue)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(encrypted, EncryptedPrefix))
|
||||
|
||||
decrypted, err := credEncryptor.Decrypt(encrypted)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testValue, decrypted)
|
||||
}
|
||||
|
||||
// Utility functions for testing
|
||||
|
||||
func encodeBase64(data []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func setenv(t *testing.T, key, value string) {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Standard errors for encryption operations
|
||||
var (
|
||||
ErrEncryptionFailed = errors.New("encryption failed")
|
||||
ErrDecryptionFailed = errors.New("decryption failed")
|
||||
ErrInvalidBlockSize = errors.New("invalid block size")
|
||||
ErrInvalidCiphertext = errors.New("invalid ciphertext")
|
||||
ErrInvalidKeySize = errors.New("invalid key size")
|
||||
ErrEmptyPlaintext = errors.New("plaintext is empty")
|
||||
ErrEmptyCiphertext = errors.New("ciphertext is empty")
|
||||
ErrMissingIV = errors.New("initialization vector missing")
|
||||
)
|
||||
|
||||
// EncryptionService provides methods to encrypt and decrypt data
|
||||
type EncryptionService struct {
|
||||
keyManager KeyManager
|
||||
}
|
||||
|
||||
// NewEncryptionService creates a new encryption service using the provided key manager
|
||||
func NewEncryptionService(km KeyManager) (*EncryptionService, error) {
|
||||
if km == nil {
|
||||
return nil, errors.New("key manager is required")
|
||||
}
|
||||
return &EncryptionService{keyManager: km}, nil
|
||||
}
|
||||
|
||||
// Encrypt encrypts the plaintext using AES-256-CBC with PKCS7 padding
|
||||
// It returns a base64-encoded string of the IV + ciphertext
|
||||
func (s *EncryptionService) Encrypt(plaintext []byte) (string, error) {
|
||||
if len(plaintext) == 0 {
|
||||
return "", ErrEmptyPlaintext
|
||||
}
|
||||
|
||||
// Get the encryption key
|
||||
key, err := s.keyManager.GetPrimaryKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
|
||||
// Create a new AES cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrEncryptionFailed, err)
|
||||
}
|
||||
|
||||
// Pad the plaintext to be a multiple of the block size
|
||||
paddedPlaintext := pkcs7Pad(plaintext, block.BlockSize())
|
||||
|
||||
// Generate a random IV
|
||||
iv := make([]byte, block.BlockSize())
|
||||
if _, err := io.ReadFull(SecureRandomReader, iv); err != nil {
|
||||
return "", fmt.Errorf("%w: failed to generate IV: %v", ErrEncryptionFailed, err)
|
||||
}
|
||||
|
||||
// Create CBC encrypter
|
||||
mode := cipher.NewCBCEncrypter(block, iv)
|
||||
|
||||
// Encrypt the data
|
||||
ciphertext := make([]byte, len(paddedPlaintext))
|
||||
mode.CryptBlocks(ciphertext, paddedPlaintext)
|
||||
|
||||
// Prepend IV to ciphertext
|
||||
combined := append(iv, ciphertext...)
|
||||
|
||||
// Encode with base64
|
||||
encoded := base64.StdEncoding.EncodeToString(combined)
|
||||
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts the base64-encoded ciphertext using AES-256-CBC with PKCS7 padding
|
||||
// It expects the ciphertext to be a base64-encoded string of the IV + actual ciphertext
|
||||
func (s *EncryptionService) Decrypt(encodedCiphertext string) ([]byte, error) {
|
||||
if encodedCiphertext == "" {
|
||||
return nil, ErrEmptyCiphertext
|
||||
}
|
||||
|
||||
// Decode the base64 encoded data
|
||||
combined, err := base64.StdEncoding.DecodeString(encodedCiphertext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid base64 encoding: %v", ErrDecryptionFailed, err)
|
||||
}
|
||||
|
||||
// Get the encryption key
|
||||
key, err := s.keyManager.GetPrimaryKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get encryption key: %w", err)
|
||||
}
|
||||
|
||||
// Create a new AES cipher block
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
|
||||
}
|
||||
|
||||
// Extract IV and ciphertext
|
||||
blockSize := block.BlockSize()
|
||||
if len(combined) < blockSize {
|
||||
return nil, ErrMissingIV
|
||||
}
|
||||
iv := combined[:blockSize]
|
||||
ciphertext := combined[blockSize:]
|
||||
|
||||
// Verify ciphertext length
|
||||
if len(ciphertext) == 0 {
|
||||
return nil, ErrEmptyCiphertext
|
||||
}
|
||||
if len(ciphertext)%blockSize != 0 {
|
||||
return nil, ErrInvalidBlockSize
|
||||
}
|
||||
|
||||
// Create CBC decrypter
|
||||
mode := cipher.NewCBCDecrypter(block, iv)
|
||||
|
||||
// Decrypt the data
|
||||
decrypted := make([]byte, len(ciphertext))
|
||||
mode.CryptBlocks(decrypted, ciphertext)
|
||||
|
||||
// Remove padding
|
||||
unpadded, err := pkcs7Unpad(decrypted, blockSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
|
||||
}
|
||||
|
||||
return unpadded, nil
|
||||
}
|
||||
|
||||
// EncryptString encrypts a string and returns a base64-encoded result
|
||||
func (s *EncryptionService) EncryptString(plaintext string) (string, error) {
|
||||
return s.Encrypt([]byte(plaintext))
|
||||
}
|
||||
|
||||
// DecryptString decrypts a base64-encoded ciphertext and returns the plaintext string
|
||||
func (s *EncryptionService) DecryptString(encodedCiphertext string) (string, error) {
|
||||
plaintext, err := s.Decrypt(encodedCiphertext)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
// pkcs7Pad adds PKCS#7 padding to the data to make it a multiple of the block size
|
||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
||||
padding := blockSize - (len(data) % blockSize)
|
||||
padText := make([]byte, padding)
|
||||
for i := range padText {
|
||||
padText[i] = byte(padding)
|
||||
}
|
||||
return append(data, padText...)
|
||||
}
|
||||
|
||||
// pkcs7Unpad removes PKCS#7 padding from the data
|
||||
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
||||
if len(data) == 0 || len(data)%blockSize != 0 {
|
||||
return nil, ErrInvalidBlockSize
|
||||
}
|
||||
|
||||
padding := int(data[len(data)-1])
|
||||
if padding <= 0 || padding > blockSize {
|
||||
return nil, errors.New("invalid padding value")
|
||||
}
|
||||
|
||||
// Validate that all padding bytes have the correct value
|
||||
for i := len(data) - padding; i < len(data); i++ {
|
||||
if data[i] != byte(padding) {
|
||||
return nil, errors.New("invalid padding")
|
||||
}
|
||||
}
|
||||
|
||||
return data[:len(data)-padding], nil
|
||||
}
|
||||
|
||||
// GetGlobalEncryptionService creates an EncryptionService using the global key manager
|
||||
// It initializes the key manager if it hasn't been initialized yet
|
||||
func GetGlobalEncryptionService() (*EncryptionService, error) {
|
||||
// Make sure key manager is initialized
|
||||
if GetKeyManager() == nil {
|
||||
if err := InitializeKeyManager(""); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize key manager: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return NewEncryptionService(GetKeyManager())
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func setupTestKeyManager(t *testing.T) KeyManager {
|
||||
// Setup test environment
|
||||
testEnvVar := "TEST_ENCRYPTION_KEY"
|
||||
validKey := make([]byte, AES256KeySize)
|
||||
for i := range validKey {
|
||||
validKey[i] = byte(i % 256)
|
||||
}
|
||||
validKeyBase64 := base64.StdEncoding.EncodeToString(validKey)
|
||||
|
||||
// Set a valid key in environment
|
||||
os.Setenv(testEnvVar, validKeyBase64)
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv(testEnvVar)
|
||||
})
|
||||
|
||||
km := NewKeyManager(testEnvVar)
|
||||
err := km.(KeyManager).Initialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
return km
|
||||
}
|
||||
|
||||
func setupEncryptionService(t *testing.T) *EncryptionService {
|
||||
km := setupTestKeyManager(t)
|
||||
service, err := NewEncryptionService(km)
|
||||
require.NoError(t, err)
|
||||
return service
|
||||
}
|
||||
|
||||
func TestNewEncryptionService(t *testing.T) {
|
||||
t.Run("Valid key manager", func(t *testing.T) {
|
||||
km := setupTestKeyManager(t)
|
||||
service, err := NewEncryptionService(km)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, service)
|
||||
})
|
||||
|
||||
t.Run("Nil key manager", func(t *testing.T) {
|
||||
service, err := NewEncryptionService(nil)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, service)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncryptionService_Encrypt(t *testing.T) {
|
||||
service := setupEncryptionService(t)
|
||||
|
||||
t.Run("Encrypt valid data", func(t *testing.T) {
|
||||
plaintext := []byte("This is a test message that needs to be encrypted")
|
||||
encrypted, err := service.Encrypt(plaintext)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, encrypted)
|
||||
|
||||
// Encrypted data should be base64 encoded
|
||||
_, err = base64.StdEncoding.DecodeString(encrypted)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Encrypt empty data", func(t *testing.T) {
|
||||
encrypted, err := service.Encrypt([]byte{})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrEmptyPlaintext, err)
|
||||
assert.Empty(t, encrypted)
|
||||
})
|
||||
|
||||
t.Run("Same plaintext produces different ciphertexts", func(t *testing.T) {
|
||||
plaintext := []byte("This should encrypt to different ciphertexts each time")
|
||||
encrypted1, err := service.Encrypt(plaintext)
|
||||
require.NoError(t, err)
|
||||
|
||||
encrypted2, err := service.Encrypt(plaintext)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, encrypted1, encrypted2, "Same plaintext should encrypt to different ciphertexts due to random IV")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncryptionService_Decrypt(t *testing.T) {
|
||||
service := setupEncryptionService(t)
|
||||
|
||||
t.Run("Decrypt valid data", func(t *testing.T) {
|
||||
plaintext := []byte("This is a test message that needs to be encrypted and decrypted")
|
||||
encrypted, err := service.Encrypt(plaintext)
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := service.Decrypt(encrypted)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Decrypt empty data", func(t *testing.T) {
|
||||
decrypted, err := service.Decrypt("")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrEmptyCiphertext, err)
|
||||
assert.Nil(t, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Decrypt invalid base64", func(t *testing.T) {
|
||||
decrypted, err := service.Decrypt("this-is-not-valid-base64!@#$%^")
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Decrypt corrupted data - last byte modified", func(t *testing.T) {
|
||||
plaintext := []byte("This is a test message with proper length for padding")
|
||||
encrypted, err := service.Encrypt(plaintext)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Modify the last byte to corrupt the padding
|
||||
decoded, err := base64.StdEncoding.DecodeString(encrypted)
|
||||
require.NoError(t, err)
|
||||
decoded[len(decoded)-1] ^= 0x01 // Flip one bit in the last byte
|
||||
corrupted := base64.StdEncoding.EncodeToString(decoded)
|
||||
|
||||
decrypted, err := service.Decrypt(corrupted)
|
||||
require.Error(t, err, "Decryption should fail with corrupted data")
|
||||
assert.Nil(t, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Decrypt with short data", func(t *testing.T) {
|
||||
// Create a short invalid encrypted string (not enough bytes for IV)
|
||||
shortData := base64.StdEncoding.EncodeToString([]byte("tooshort"))
|
||||
decrypted, err := service.Decrypt(shortData)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, decrypted)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncryptionService_EncryptString(t *testing.T) {
|
||||
service := setupEncryptionService(t)
|
||||
|
||||
t.Run("Encrypt valid string", func(t *testing.T) {
|
||||
plaintext := "This is a test string that needs to be encrypted"
|
||||
encrypted, err := service.EncryptString(plaintext)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, encrypted)
|
||||
|
||||
// Encrypted data should be base64 encoded
|
||||
_, err = base64.StdEncoding.DecodeString(encrypted)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Encrypt empty string", func(t *testing.T) {
|
||||
encrypted, err := service.EncryptString("")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrEmptyPlaintext, err)
|
||||
assert.Empty(t, encrypted)
|
||||
})
|
||||
}
|
||||
|
||||
func TestEncryptionService_DecryptString(t *testing.T) {
|
||||
service := setupEncryptionService(t)
|
||||
|
||||
t.Run("Decrypt valid string", func(t *testing.T) {
|
||||
plaintext := "This is a test string that needs to be encrypted and decrypted"
|
||||
encrypted, err := service.EncryptString(plaintext)
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := service.DecryptString(encrypted)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
})
|
||||
|
||||
t.Run("Decrypt empty string", func(t *testing.T) {
|
||||
decrypted, err := service.DecryptString("")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrEmptyCiphertext, err)
|
||||
assert.Empty(t, decrypted)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPkcs7Padding(t *testing.T) {
|
||||
blockSize := 16
|
||||
|
||||
t.Run("Pad and unpad", func(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input []byte
|
||||
expected int // expected padding size
|
||||
}{
|
||||
{[]byte("testing"), 9}, // 7 bytes + 9 padding = 16 bytes (multiple of blockSize)
|
||||
{[]byte("16 bytes exactly"), 16}, // 16 bytes + 16 padding = 32 bytes (multiple of blockSize)
|
||||
{[]byte("this is a longer test string"), 4}, // 28 bytes + 4 padding = 32 bytes (multiple of blockSize)
|
||||
{[]byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13}, 2}, // 14 bytes + 2 padding = 16 bytes (multiple of blockSize)
|
||||
{[]byte{}, 16}, // 0 bytes + 16 padding = 16 bytes (multiple of blockSize)
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
padded := pkcs7Pad(tc.input, blockSize)
|
||||
// Check padding size
|
||||
assert.Equal(t, len(tc.input)+tc.expected, len(padded))
|
||||
// Check padding value
|
||||
for i := len(tc.input); i < len(padded); i++ {
|
||||
assert.Equal(t, byte(tc.expected), padded[i])
|
||||
}
|
||||
|
||||
// Unpad and check
|
||||
unpadded, err := pkcs7Unpad(padded, blockSize)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, bytes.Equal(tc.input, unpadded))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid padding", func(t *testing.T) {
|
||||
// Invalid padding value
|
||||
invalid := []byte("test data with invalid padding")
|
||||
paddedInvalid := pkcs7Pad(invalid, blockSize)
|
||||
paddedInvalid[len(paddedInvalid)-1] = 99 // Invalid padding value
|
||||
_, err := pkcs7Unpad(paddedInvalid, blockSize)
|
||||
require.Error(t, err)
|
||||
|
||||
// Inconsistent padding
|
||||
inconsistent := []byte("test data with inconsistent padding")
|
||||
paddedInconsistent := pkcs7Pad(inconsistent, blockSize)
|
||||
paddedInconsistent[len(paddedInconsistent)-2] = 99 // Make padding inconsistent
|
||||
_, err = pkcs7Unpad(paddedInconsistent, blockSize)
|
||||
require.Error(t, err)
|
||||
|
||||
// Empty data
|
||||
_, err = pkcs7Unpad([]byte{}, blockSize)
|
||||
require.Error(t, err)
|
||||
|
||||
// Invalid block size
|
||||
invalidSize := []byte("invalid size")
|
||||
_, err = pkcs7Unpad(invalidSize, blockSize)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetGlobalEncryptionService(t *testing.T) {
|
||||
// Reset global key manager before test
|
||||
globalKeyManager = nil
|
||||
globalKeyManagerOnce = sync.Once{}
|
||||
|
||||
// Setup test environment
|
||||
testEnvVar := DefaultKeyEnvVar
|
||||
validKey := make([]byte, AES256KeySize)
|
||||
for i := range validKey {
|
||||
validKey[i] = byte(i % 256)
|
||||
}
|
||||
validKeyBase64 := base64.StdEncoding.EncodeToString(validKey)
|
||||
|
||||
// Set a valid key in environment
|
||||
os.Setenv(testEnvVar, validKeyBase64)
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv(testEnvVar)
|
||||
})
|
||||
|
||||
// Get global encryption service
|
||||
service, err := GetGlobalEncryptionService()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, service)
|
||||
|
||||
// Test with actual encryption/decryption
|
||||
plaintext := "Test with global encryption service"
|
||||
encrypted, err := service.EncryptString(plaintext)
|
||||
require.NoError(t, err)
|
||||
|
||||
decrypted, err := service.DecryptString(encrypted)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SecureRandomReader is the reader used for generating random data
|
||||
// It's a variable to allow for easier testing by replacing with a mock
|
||||
var SecureRandomReader io.Reader = rand.Reader
|
||||
|
||||
// KeyManager is the interface for key management operations
|
||||
type KeyManager interface {
|
||||
// Initialize initializes the key manager with a key from the environment
|
||||
Initialize() error
|
||||
|
||||
// GetPrimaryKey returns the primary encryption key
|
||||
GetPrimaryKey() ([]byte, error)
|
||||
|
||||
// GetEnvironmentVariableName returns the name of the environment variable used for the key
|
||||
GetEnvironmentVariableName() string
|
||||
|
||||
// StoreKeyEnvironment stores the encryption key in the specified environment variable
|
||||
StoreKeyEnvironment(key []byte) error
|
||||
}
|
||||
|
||||
// defaultKeyManager is the implementation of KeyManager
|
||||
type defaultKeyManager struct {
|
||||
// primaryKey is the main encryption key used for AES-256 encryption
|
||||
primaryKey []byte
|
||||
|
||||
// envVarName is the name of the environment variable that stores the key
|
||||
envVarName string
|
||||
|
||||
// mutex to protect key access
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var (
|
||||
// Global key manager instance
|
||||
globalKeyManager KeyManager
|
||||
globalKeyManagerOnce sync.Once
|
||||
)
|
||||
|
||||
// InitializeKeyManager initializes the default key manager instance
|
||||
// It retrieves the key from the environment variable GOMFT_ENCRYPTION_KEY by default.
|
||||
// Only the first call to this function will actually initialize the key manager,
|
||||
// subsequent calls will return the already initialized instance.
|
||||
func InitializeKeyManager(envVar string) error {
|
||||
var initErr error
|
||||
|
||||
globalKeyManagerOnce.Do(func() {
|
||||
// Create key manager
|
||||
globalKeyManager = NewKeyManager(envVar)
|
||||
|
||||
// Initialize with key from environment
|
||||
initErr = globalKeyManager.Initialize()
|
||||
})
|
||||
|
||||
return initErr
|
||||
}
|
||||
|
||||
// GetKeyManager returns the global key manager instance
|
||||
// If the key manager has not been initialized, this will return nil
|
||||
func GetKeyManager() KeyManager {
|
||||
return globalKeyManager
|
||||
}
|
||||
|
||||
// NewKeyManager creates a new KeyManager instance
|
||||
func NewKeyManager(envVarName string) KeyManager {
|
||||
if envVarName == "" {
|
||||
envVarName = DefaultKeyEnvVar
|
||||
}
|
||||
|
||||
return &defaultKeyManager{
|
||||
envVarName: envVarName,
|
||||
}
|
||||
}
|
||||
|
||||
// decodeKey attempts to decode a key string from hex or base64 format
|
||||
func decodeKey(keyStr string) ([]byte, error) {
|
||||
// Try hex decoding first
|
||||
keyBytes, err := hex.DecodeString(keyStr)
|
||||
if err == nil {
|
||||
return keyBytes, nil
|
||||
}
|
||||
|
||||
// If hex decoding fails, try base64
|
||||
keyBytes, err = base64.StdEncoding.DecodeString(keyStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("key must be valid hex or base64 encoded: %w", err)
|
||||
}
|
||||
|
||||
return keyBytes, nil
|
||||
}
|
||||
|
||||
// Initialize loads the encryption key from the environment
|
||||
// and validates it meets security requirements
|
||||
func (km *defaultKeyManager) Initialize() error {
|
||||
// Get key from environment variable
|
||||
keyStr := os.Getenv(km.envVarName)
|
||||
if keyStr == "" {
|
||||
return fmt.Errorf(ErrKeyNotProvided, km.envVarName)
|
||||
}
|
||||
|
||||
// Attempt to decode the key
|
||||
keyBytes, err := decodeKey(keyStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf(ErrInvalidKey, err.Error())
|
||||
}
|
||||
|
||||
// Validate key length
|
||||
if len(keyBytes) < MinKeyLength {
|
||||
return fmt.Errorf(ErrKeyTooShort, MinKeyLength)
|
||||
}
|
||||
|
||||
// Store the key
|
||||
km.mutex.Lock()
|
||||
km.primaryKey = keyBytes
|
||||
km.mutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPrimaryKey returns the primary encryption key
|
||||
func (km *defaultKeyManager) GetPrimaryKey() ([]byte, error) {
|
||||
km.mutex.RLock()
|
||||
defer km.mutex.RUnlock()
|
||||
|
||||
if km.primaryKey == nil || len(km.primaryKey) == 0 {
|
||||
return nil, fmt.Errorf("encryption key not initialized")
|
||||
}
|
||||
|
||||
// Return a copy of the key to prevent modification
|
||||
keyCopy := make([]byte, len(km.primaryKey))
|
||||
copy(keyCopy, km.primaryKey)
|
||||
|
||||
return keyCopy, nil
|
||||
}
|
||||
|
||||
// GetEnvironmentVariableName returns the name of the environment variable used for the key
|
||||
func (km *defaultKeyManager) GetEnvironmentVariableName() string {
|
||||
return km.envVarName
|
||||
}
|
||||
|
||||
// StoreKeyEnvironment stores the encryption key in the specified environment variable
|
||||
// This is generally only used for development or testing purposes
|
||||
func (km *defaultKeyManager) StoreKeyEnvironment(key []byte) error {
|
||||
if !ValidateKeyLength(key) {
|
||||
return fmt.Errorf(ErrKeyTooShort, MinKeyLength)
|
||||
}
|
||||
|
||||
keyStr := base64.StdEncoding.EncodeToString(key)
|
||||
err := os.Setenv(km.envVarName, keyStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set environment variable: %w", err)
|
||||
}
|
||||
|
||||
// Update the stored key
|
||||
km.mutex.Lock()
|
||||
km.primaryKey = key
|
||||
km.mutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateKey generates a new random encryption key of the specified size
|
||||
func GenerateKey(size int) ([]byte, error) {
|
||||
if size < MinKeyLength {
|
||||
size = MinKeyLength
|
||||
}
|
||||
|
||||
key := make([]byte, size)
|
||||
_, err := SecureRandomReader.Read(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate random key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// GenerateKeyString generates a new random encryption key and returns it as a base64 string
|
||||
func GenerateKeyString(size int) (string, error) {
|
||||
key, err := GenerateKey(size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
// ValidateKeyLength checks if the provided key meets the minimum length requirement
|
||||
func ValidateKeyLength(key []byte) bool {
|
||||
return len(key) >= MinKeyLength
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewKeyManager(t *testing.T) {
|
||||
// Test with custom env var
|
||||
customEnvVar := "CUSTOM_KEY_ENV_VAR"
|
||||
km := NewKeyManager(customEnvVar)
|
||||
assert.Equal(t, customEnvVar, km.GetEnvironmentVariableName())
|
||||
|
||||
// Test with empty env var (should use default)
|
||||
km = NewKeyManager("")
|
||||
assert.Equal(t, DefaultKeyEnvVar, km.GetEnvironmentVariableName())
|
||||
}
|
||||
|
||||
func TestKeyManager_Initialize(t *testing.T) {
|
||||
// Setup test environment
|
||||
testEnvVar := "TEST_ENCRYPTION_KEY"
|
||||
validKey, err := GenerateKey(AES256KeySize)
|
||||
require.NoError(t, err)
|
||||
validKeyBase64 := base64.StdEncoding.EncodeToString(validKey)
|
||||
|
||||
t.Run("Valid key in environment", func(t *testing.T) {
|
||||
// Set a valid key in environment
|
||||
os.Setenv(testEnvVar, validKeyBase64)
|
||||
defer os.Unsetenv(testEnvVar)
|
||||
|
||||
km := NewKeyManager(testEnvVar)
|
||||
err := km.(KeyManager).Initialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that key is properly stored
|
||||
key, err := km.GetPrimaryKey()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, validKey, key)
|
||||
})
|
||||
|
||||
t.Run("Missing key in environment", func(t *testing.T) {
|
||||
os.Unsetenv(testEnvVar)
|
||||
|
||||
km := NewKeyManager(testEnvVar)
|
||||
err := km.(KeyManager).Initialize()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "encryption key not provided")
|
||||
})
|
||||
|
||||
t.Run("Invalid key format", func(t *testing.T) {
|
||||
os.Setenv(testEnvVar, "not-a-valid-base64-or-hex-key")
|
||||
defer os.Unsetenv(testEnvVar)
|
||||
|
||||
km := NewKeyManager(testEnvVar)
|
||||
err := km.(KeyManager).Initialize()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid")
|
||||
})
|
||||
|
||||
t.Run("Key too short", func(t *testing.T) {
|
||||
shortKey := make([]byte, MinKeyLength-1)
|
||||
os.Setenv(testEnvVar, base64.StdEncoding.EncodeToString(shortKey))
|
||||
defer os.Unsetenv(testEnvVar)
|
||||
|
||||
km := NewKeyManager(testEnvVar)
|
||||
err := km.(KeyManager).Initialize()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "too short")
|
||||
})
|
||||
}
|
||||
|
||||
func TestGlobalKeyManager(t *testing.T) {
|
||||
// Reset global key manager
|
||||
globalKeyManager = nil
|
||||
globalKeyManagerOnce = sync.Once{}
|
||||
|
||||
// Set a valid key in environment
|
||||
testEnvVar := DefaultKeyEnvVar
|
||||
validKey, err := GenerateKey(AES256KeySize)
|
||||
require.NoError(t, err)
|
||||
validKeyBase64 := base64.StdEncoding.EncodeToString(validKey)
|
||||
os.Setenv(testEnvVar, validKeyBase64)
|
||||
defer os.Unsetenv(testEnvVar)
|
||||
|
||||
// Initialize global key manager
|
||||
err = InitializeKeyManager("")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get global key manager
|
||||
km := GetKeyManager()
|
||||
require.NotNil(t, km)
|
||||
|
||||
// Check that key is properly stored
|
||||
key, err := km.GetPrimaryKey()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, validKey, key)
|
||||
|
||||
// Test that subsequent calls to InitializeKeyManager do nothing
|
||||
// Set a different key
|
||||
differentKey, err := GenerateKey(AES256KeySize)
|
||||
require.NoError(t, err)
|
||||
os.Setenv(testEnvVar, base64.StdEncoding.EncodeToString(differentKey))
|
||||
|
||||
// Try to initialize again
|
||||
err = InitializeKeyManager("")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Key should still be the original one
|
||||
key, err = km.GetPrimaryKey()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, validKey, key)
|
||||
}
|
||||
|
||||
func TestGenerateKey(t *testing.T) {
|
||||
// Test generating key with default size
|
||||
key, err := GenerateKey(AES256KeySize)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, key, AES256KeySize)
|
||||
|
||||
// Test generating key with custom size
|
||||
customSize := 64
|
||||
key, err = GenerateKey(customSize)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, key, customSize)
|
||||
|
||||
// Test generating key with size smaller than minimum (should use minimum)
|
||||
key, err = GenerateKey(16)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, key, MinKeyLength)
|
||||
}
|
||||
|
||||
func TestGenerateKeyString(t *testing.T) {
|
||||
// Test generating key string
|
||||
keyStr, err := GenerateKeyString(AES256KeySize)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, keyStr)
|
||||
|
||||
// Test that the key string decodes to a valid key
|
||||
decodedKey, err := base64.StdEncoding.DecodeString(keyStr)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, decodedKey, AES256KeySize)
|
||||
}
|
||||
|
||||
func TestDecodeKey(t *testing.T) {
|
||||
// Test decoding a hex key
|
||||
originalKey := []byte("this is a test key that is long enough")
|
||||
hexKey := encodeToHex(originalKey)
|
||||
decodedKey, err := decodeKey(hexKey)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, originalKey, decodedKey)
|
||||
|
||||
// Test decoding a base64 key
|
||||
base64Key := base64.StdEncoding.EncodeToString(originalKey)
|
||||
decodedKey, err = decodeKey(base64Key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, originalKey, decodedKey)
|
||||
|
||||
// Test decoding an invalid key
|
||||
_, err = decodeKey("not a valid key")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Helper function to encode bytes to hex
|
||||
func encodeToHex(data []byte) string {
|
||||
hexChars := []byte("0123456789abcdef")
|
||||
result := make([]byte, len(data)*2)
|
||||
for i, b := range data {
|
||||
result[i*2] = hexChars[b>>4]
|
||||
result[i*2+1] = hexChars[b&0x0F]
|
||||
}
|
||||
return string(result)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package keymanager
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
)
|
||||
|
||||
// KeyManager handles the management of encryption keys
|
||||
type KeyManager struct {
|
||||
// primaryKey is the main encryption key used for AES-256 encryption
|
||||
primaryKey []byte
|
||||
|
||||
// envVarName is the name of the environment variable that stores the key
|
||||
envVarName string
|
||||
|
||||
// mutex to protect key access
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewKeyManager creates a new KeyManager instance
|
||||
func NewKeyManager(envVarName string) *KeyManager {
|
||||
if envVarName == "" {
|
||||
envVarName = encryption.DefaultKeyEnvVar
|
||||
}
|
||||
|
||||
return &KeyManager{
|
||||
envVarName: envVarName,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize loads the encryption key from the environment
|
||||
// and validates it meets security requirements
|
||||
func (km *KeyManager) Initialize() error {
|
||||
// Try loading .env file if exists
|
||||
_ = godotenv.Load()
|
||||
|
||||
// Get key from environment variable
|
||||
keyStr := os.Getenv(km.envVarName)
|
||||
if keyStr == "" {
|
||||
return fmt.Errorf(encryption.ErrKeyNotProvided, km.envVarName)
|
||||
}
|
||||
|
||||
// Attempt to decode the key - we support both hex and base64 formats
|
||||
var keyBytes []byte
|
||||
var err error
|
||||
|
||||
// Try hex decoding first
|
||||
keyBytes, err = hex.DecodeString(keyStr)
|
||||
if err != nil {
|
||||
// If hex decoding fails, try base64
|
||||
keyBytes, err = base64.StdEncoding.DecodeString(keyStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf(encryption.ErrInvalidKey, "key must be valid hex or base64 encoded")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate key length
|
||||
if len(keyBytes) < encryption.MinKeyLength {
|
||||
return fmt.Errorf(encryption.ErrKeyTooShort, encryption.MinKeyLength)
|
||||
}
|
||||
|
||||
// Store the key
|
||||
km.mutex.Lock()
|
||||
km.primaryKey = keyBytes
|
||||
km.mutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPrimaryKey returns the primary encryption key
|
||||
func (km *KeyManager) GetPrimaryKey() ([]byte, error) {
|
||||
km.mutex.RLock()
|
||||
defer km.mutex.RUnlock()
|
||||
|
||||
if km.primaryKey == nil || len(km.primaryKey) == 0 {
|
||||
return nil, fmt.Errorf("encryption key not initialized")
|
||||
}
|
||||
|
||||
// Return a copy of the key to prevent modification
|
||||
keyCopy := make([]byte, len(km.primaryKey))
|
||||
copy(keyCopy, km.primaryKey)
|
||||
|
||||
return keyCopy, nil
|
||||
}
|
||||
|
||||
// GenerateKey generates a new random encryption key of the specified size
|
||||
func GenerateKey(size int) ([]byte, error) {
|
||||
if size < encryption.MinKeyLength {
|
||||
size = encryption.MinKeyLength
|
||||
}
|
||||
|
||||
key := make([]byte, size)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate random key: %w", err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// GenerateKeyString generates a new random encryption key and returns it as a base64 string
|
||||
func GenerateKeyString(size int) (string, error) {
|
||||
key, err := GenerateKey(size)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(key), nil
|
||||
}
|
||||
|
||||
// ValidateKeyLength checks if the provided key meets the minimum length requirement
|
||||
func ValidateKeyLength(key []byte) bool {
|
||||
return len(key) >= encryption.MinKeyLength
|
||||
}
|
||||
|
||||
// StoreKeyEnvironment stores the encryption key in the specified environment variable
|
||||
// This is generally only used for development or testing purposes
|
||||
func (km *KeyManager) StoreKeyEnvironment(key []byte) error {
|
||||
if !ValidateKeyLength(key) {
|
||||
return fmt.Errorf(encryption.ErrKeyTooShort, encryption.MinKeyLength)
|
||||
}
|
||||
|
||||
keyStr := base64.StdEncoding.EncodeToString(key)
|
||||
err := os.Setenv(km.envVarName, keyStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set environment variable: %w", err)
|
||||
}
|
||||
|
||||
// Update the stored key
|
||||
km.mutex.Lock()
|
||||
km.primaryKey = key
|
||||
km.mutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEnvironmentVariableName returns the name of the environment variable used for the key
|
||||
func (km *KeyManager) GetEnvironmentVariableName() string {
|
||||
return km.envVarName
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package keymanager
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewKeyManager(t *testing.T) {
|
||||
// Test with custom env var
|
||||
customEnvVar := "CUSTOM_KEY_ENV_VAR"
|
||||
km := NewKeyManager(customEnvVar)
|
||||
assert.Equal(t, customEnvVar, km.envVarName)
|
||||
|
||||
// Test with empty env var (should use default)
|
||||
km = NewKeyManager("")
|
||||
assert.Equal(t, encryption.DefaultKeyEnvVar, km.envVarName)
|
||||
}
|
||||
|
||||
func TestGenerateKey(t *testing.T) {
|
||||
// Test generating key with default size
|
||||
key, err := GenerateKey(encryption.AES256KeySize)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, key, encryption.AES256KeySize)
|
||||
|
||||
// Test generating key with custom size
|
||||
customSize := 64
|
||||
key, err = GenerateKey(customSize)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, key, customSize)
|
||||
|
||||
// Test generating key with size smaller than minimum (should use minimum)
|
||||
key, err = GenerateKey(16)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, key, encryption.MinKeyLength)
|
||||
}
|
||||
|
||||
func TestGenerateKeyString(t *testing.T) {
|
||||
// Test generating key string
|
||||
keyStr, err := GenerateKeyString(encryption.AES256KeySize)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, keyStr)
|
||||
}
|
||||
|
||||
func TestValidateKeyLength(t *testing.T) {
|
||||
// Test valid key length
|
||||
key := make([]byte, encryption.MinKeyLength)
|
||||
assert.True(t, ValidateKeyLength(key))
|
||||
|
||||
// Test invalid key length
|
||||
key = make([]byte, encryption.MinKeyLength-1)
|
||||
assert.False(t, ValidateKeyLength(key))
|
||||
}
|
||||
|
||||
func TestKeyManager_Initialize(t *testing.T) {
|
||||
// Setup test environment
|
||||
testEnvVar := "TEST_ENCRYPTION_KEY"
|
||||
validKey, err := GenerateKey(encryption.AES256KeySize)
|
||||
require.NoError(t, err)
|
||||
validKeyBase64 := encodeToBase64(validKey)
|
||||
|
||||
t.Run("Valid key in environment", func(t *testing.T) {
|
||||
// Set a valid key in environment
|
||||
os.Setenv(testEnvVar, validKeyBase64)
|
||||
defer os.Unsetenv(testEnvVar)
|
||||
|
||||
km := NewKeyManager(testEnvVar)
|
||||
err := km.Initialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that key is properly stored
|
||||
key, err := km.GetPrimaryKey()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, validKey, key)
|
||||
})
|
||||
|
||||
t.Run("Missing key in environment", func(t *testing.T) {
|
||||
os.Unsetenv(testEnvVar)
|
||||
|
||||
km := NewKeyManager(testEnvVar)
|
||||
err := km.Initialize()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "encryption key not provided")
|
||||
})
|
||||
|
||||
t.Run("Invalid key format", func(t *testing.T) {
|
||||
os.Setenv(testEnvVar, "not-a-valid-base64-or-hex-key")
|
||||
defer os.Unsetenv(testEnvVar)
|
||||
|
||||
km := NewKeyManager(testEnvVar)
|
||||
err := km.Initialize()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid")
|
||||
})
|
||||
|
||||
t.Run("Key too short", func(t *testing.T) {
|
||||
shortKey := make([]byte, encryption.MinKeyLength-1)
|
||||
os.Setenv(testEnvVar, encodeToBase64(shortKey))
|
||||
defer os.Unsetenv(testEnvVar)
|
||||
|
||||
km := NewKeyManager(testEnvVar)
|
||||
err := km.Initialize()
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "too short")
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeyManager_StoreKeyEnvironment(t *testing.T) {
|
||||
testEnvVar := "TEST_STORE_KEY"
|
||||
km := NewKeyManager(testEnvVar)
|
||||
|
||||
// Generate a valid key
|
||||
key, err := GenerateKey(encryption.AES256KeySize)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Store the key
|
||||
err = km.StoreKeyEnvironment(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify key is stored in environment
|
||||
envValue := os.Getenv(testEnvVar)
|
||||
assert.NotEmpty(t, envValue)
|
||||
|
||||
// Verify key is stored in KeyManager
|
||||
storedKey, err := km.GetPrimaryKey()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, key, storedKey)
|
||||
|
||||
// Clean up
|
||||
os.Unsetenv(testEnvVar)
|
||||
}
|
||||
|
||||
func TestKeyManager_GetPrimaryKey_NotInitialized(t *testing.T) {
|
||||
km := NewKeyManager("NONEXISTENT_KEY")
|
||||
key, err := km.GetPrimaryKey()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, key)
|
||||
assert.Contains(t, err.Error(), "not initialized")
|
||||
}
|
||||
|
||||
// Helper function to encode bytes to base64
|
||||
func encodeToBase64(data []byte) string {
|
||||
return base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package keyrotation
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
"github.com/starfleetcptn/gomft/internal/encryption/audit"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Common errors
|
||||
var (
|
||||
ErrNoOldKey = errors.New("old encryption key not found")
|
||||
ErrNoNewKey = errors.New("new encryption key not found")
|
||||
ErrSameKey = errors.New("old and new keys are the same")
|
||||
ErrNoDataToMigrate = errors.New("no data to migrate")
|
||||
ErrNilDB = errors.New("database connection is nil")
|
||||
)
|
||||
|
||||
// RotationStats represents statistics about the key rotation process
|
||||
type RotationStats struct {
|
||||
TotalRecords int `json:"total_records"`
|
||||
ProcessedRecords int `json:"processed_records"`
|
||||
SkippedRecords int `json:"skipped_records"`
|
||||
FailedRecords int `json:"failed_records"`
|
||||
ElapsedTime time.Duration `json:"elapsed_time"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
EndTime time.Time `json:"end_time"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// KeyRotator manages the process of changing encryption keys and re-encrypting data
|
||||
type KeyRotator struct {
|
||||
db *gorm.DB
|
||||
oldService *encryption.EncryptionService
|
||||
newService *encryption.EncryptionService
|
||||
auditor *audit.SecurityAuditor
|
||||
dryRun bool
|
||||
batchSize int
|
||||
maxErrors int
|
||||
}
|
||||
|
||||
// NewKeyRotator creates a new KeyRotator
|
||||
func NewKeyRotator(db *gorm.DB, oldService, newService *encryption.EncryptionService, auditor *audit.SecurityAuditor) (*KeyRotator, error) {
|
||||
if db == nil {
|
||||
return nil, ErrNilDB
|
||||
}
|
||||
|
||||
if oldService == nil {
|
||||
return nil, ErrNoOldKey
|
||||
}
|
||||
|
||||
if newService == nil {
|
||||
return nil, ErrNoNewKey
|
||||
}
|
||||
|
||||
if oldService == newService {
|
||||
return nil, ErrSameKey
|
||||
}
|
||||
|
||||
if auditor == nil {
|
||||
// Use the global auditor if none provided
|
||||
auditor = audit.GetGlobalAuditor()
|
||||
}
|
||||
|
||||
return &KeyRotator{
|
||||
db: db,
|
||||
oldService: oldService,
|
||||
newService: newService,
|
||||
auditor: auditor,
|
||||
dryRun: false,
|
||||
batchSize: 100,
|
||||
maxErrors: 50,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetDryRun enables or disables dry run mode
|
||||
func (r *KeyRotator) SetDryRun(dryRun bool) {
|
||||
r.dryRun = dryRun
|
||||
}
|
||||
|
||||
// SetBatchSize sets the batch size for processing records
|
||||
func (r *KeyRotator) SetBatchSize(size int) {
|
||||
if size > 0 {
|
||||
r.batchSize = size
|
||||
}
|
||||
}
|
||||
|
||||
// SetMaxErrors sets the maximum number of errors allowed before aborting
|
||||
func (r *KeyRotator) SetMaxErrors(max int) {
|
||||
if max >= 0 {
|
||||
r.maxErrors = max
|
||||
}
|
||||
}
|
||||
|
||||
// RotateKeys rotates encryption keys for a specific model type
|
||||
func (r *KeyRotator) RotateKeys(modelType interface{}, primaryKeyName string) (*RotationStats, error) {
|
||||
stats := &RotationStats{
|
||||
StartTime: time.Now(),
|
||||
Errors: make([]string, 0),
|
||||
}
|
||||
|
||||
// Get the model type
|
||||
modelValue := reflect.ValueOf(modelType)
|
||||
if modelValue.Kind() == reflect.Ptr {
|
||||
modelValue = modelValue.Elem()
|
||||
}
|
||||
|
||||
// Skip if the value is not a struct
|
||||
if modelValue.Kind() != reflect.Struct {
|
||||
return stats, errors.New("model type must be a struct")
|
||||
}
|
||||
|
||||
modelName := modelValue.Type().Name()
|
||||
|
||||
// Count total records
|
||||
var count int64
|
||||
if err := r.db.Model(modelType).Count(&count).Error; err != nil {
|
||||
return stats, fmt.Errorf("failed to count records: %w", err)
|
||||
}
|
||||
|
||||
stats.TotalRecords = int(count)
|
||||
|
||||
if count == 0 {
|
||||
return stats, ErrNoDataToMigrate
|
||||
}
|
||||
|
||||
// Process in batches
|
||||
offset := 0
|
||||
for offset < int(count) {
|
||||
// Get a batch of records
|
||||
records := reflect.New(reflect.SliceOf(modelValue.Type())).Interface()
|
||||
|
||||
if err := r.db.Model(modelType).Offset(offset).Limit(r.batchSize).Find(records).Error; err != nil {
|
||||
stats.Errors = append(stats.Errors, fmt.Sprintf("failed to fetch batch at offset %d: %v", offset, err))
|
||||
if len(stats.Errors) >= r.maxErrors {
|
||||
return stats, fmt.Errorf("too many errors (%d), aborting key rotation", len(stats.Errors))
|
||||
}
|
||||
offset += r.batchSize
|
||||
continue
|
||||
}
|
||||
|
||||
// Process this batch
|
||||
batchRecords := reflect.ValueOf(records).Elem()
|
||||
for i := 0; i < batchRecords.Len(); i++ {
|
||||
record := batchRecords.Index(i)
|
||||
if record.Kind() == reflect.Ptr {
|
||||
record = record.Elem()
|
||||
}
|
||||
|
||||
if err := r.rotateKeysForRecord(record, modelName, primaryKeyName); err != nil {
|
||||
pkValue := getPrimaryKeyValue(record, primaryKeyName)
|
||||
stats.Errors = append(stats.Errors, fmt.Sprintf("failed to rotate keys for %s with ID %v: %v", modelName, pkValue, err))
|
||||
stats.FailedRecords++
|
||||
|
||||
if len(stats.Errors) >= r.maxErrors {
|
||||
stats.EndTime = time.Now()
|
||||
stats.ElapsedTime = stats.EndTime.Sub(stats.StartTime)
|
||||
return stats, fmt.Errorf("too many errors (%d), aborting key rotation", len(stats.Errors))
|
||||
}
|
||||
} else {
|
||||
stats.ProcessedRecords++
|
||||
}
|
||||
}
|
||||
|
||||
offset += r.batchSize
|
||||
}
|
||||
|
||||
stats.EndTime = time.Now()
|
||||
stats.ElapsedTime = stats.EndTime.Sub(stats.StartTime)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// rotateKeysForRecord processes a single record
|
||||
func (r *KeyRotator) rotateKeysForRecord(record reflect.Value, modelName, primaryKeyName string) error {
|
||||
if !record.IsValid() || record.Kind() != reflect.Struct {
|
||||
return errors.New("invalid record")
|
||||
}
|
||||
|
||||
// Check if there are any encrypted fields to migrate
|
||||
encryptedFieldsFound := false
|
||||
recordType := record.Type()
|
||||
|
||||
// Track changes for audit
|
||||
pkValue := getPrimaryKeyValue(record, primaryKeyName)
|
||||
changes := make(map[string]struct{})
|
||||
|
||||
// Process each field in the struct
|
||||
for i := 0; i < recordType.NumField(); i++ {
|
||||
field := recordType.Field(i)
|
||||
|
||||
// Look for encrypted fields
|
||||
fieldName := field.Name
|
||||
if strings.HasPrefix(fieldName, "Encrypted") {
|
||||
// Get the field value
|
||||
fieldValue := record.Field(i)
|
||||
if !fieldValue.CanInterface() || !fieldValue.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the encrypted value
|
||||
encryptedValue, ok := fieldValue.Interface().(string)
|
||||
if !ok || encryptedValue == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's not encrypted with our old key, skip it
|
||||
if !strings.HasPrefix(encryptedValue, encryption.EncryptedPrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
encryptedFieldsFound = true
|
||||
|
||||
// Try to decrypt with the old key
|
||||
trimmedValue := strings.TrimPrefix(encryptedValue, encryption.EncryptedPrefix)
|
||||
plaintext, err := r.oldService.DecryptString(trimmedValue)
|
||||
if err != nil {
|
||||
// Skip this field if we can't decrypt it (might be encrypted with a different key)
|
||||
continue
|
||||
}
|
||||
|
||||
// Re-encrypt with the new key
|
||||
newEncrypted, err := r.newService.EncryptString(plaintext)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to re-encrypt field %s: %w", fieldName, err)
|
||||
}
|
||||
|
||||
// Only update if different
|
||||
newValue := encryption.EncryptedPrefix + newEncrypted
|
||||
if newValue != encryptedValue {
|
||||
if !r.dryRun {
|
||||
fieldValue.SetString(newValue)
|
||||
}
|
||||
changes[fieldName] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no encrypted fields were found or modified, return
|
||||
if !encryptedFieldsFound || len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save the changes to the database
|
||||
if !r.dryRun {
|
||||
if err := r.db.Save(record.Addr().Interface()).Error; err != nil {
|
||||
return fmt.Errorf("failed to save record: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Log the rotation
|
||||
if r.auditor != nil {
|
||||
changedFields := make([]string, 0, len(changes))
|
||||
for field := range changes {
|
||||
changedFields = append(changedFields, field)
|
||||
}
|
||||
|
||||
description := fmt.Sprintf("Rotated keys for %s (ID: %v) - fields: %s",
|
||||
modelName, pkValue, strings.Join(changedFields, ", "))
|
||||
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old", "new", true, description, 0,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getPrimaryKeyValue gets the value of the primary key field
|
||||
func getPrimaryKeyValue(record reflect.Value, pkName string) interface{} {
|
||||
if pkName == "" {
|
||||
pkName = "ID" // Default primary key name
|
||||
}
|
||||
|
||||
pkField := record.FieldByName(pkName)
|
||||
if !pkField.IsValid() {
|
||||
return "<unknown>"
|
||||
}
|
||||
|
||||
return pkField.Interface()
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package keyrotation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
"github.com/starfleetcptn/gomft/internal/encryption/audit"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestModel is a simple model with encrypted fields for testing
|
||||
type TestModel struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string
|
||||
EncryptedField string
|
||||
EncryptedData string
|
||||
EncryptedKey string
|
||||
StandardField string
|
||||
}
|
||||
|
||||
// setupTestAuditor creates an auditor for testing with buffer for capturing logs
|
||||
func setupTestAuditor(t testing.TB) (*audit.SecurityAuditor, *bytes.Buffer) {
|
||||
logBuffer := new(bytes.Buffer)
|
||||
errorBuffer := new(bytes.Buffer)
|
||||
|
||||
auditor, err := audit.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Set log writers to capture output
|
||||
auditValue := reflect.ValueOf(auditor).Elem()
|
||||
if logField := auditValue.FieldByName("logWriter"); logField.IsValid() && logField.CanSet() {
|
||||
logField.Set(reflect.ValueOf(logBuffer))
|
||||
}
|
||||
if errorField := auditValue.FieldByName("errorWriter"); errorField.IsValid() && errorField.CanSet() {
|
||||
errorField.Set(reflect.ValueOf(errorBuffer))
|
||||
}
|
||||
|
||||
return auditor, logBuffer
|
||||
}
|
||||
|
||||
// setupTestDB creates a test database with the TestModel
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Migrate the schema
|
||||
err = db.AutoMigrate(&TestModel{})
|
||||
require.NoError(t, err)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
// setupEncryptionServices creates old and new encryption services for testing
|
||||
func setupEncryptionServices(t testing.TB) (*encryption.EncryptionService, *encryption.EncryptionService) {
|
||||
// Setup old key
|
||||
oldKeyEnv := "TEST_OLD_KEY"
|
||||
oldKey := make([]byte, encryption.AES256KeySize)
|
||||
for i := range oldKey {
|
||||
oldKey[i] = byte(i % 256)
|
||||
}
|
||||
os.Setenv(oldKeyEnv, base64.StdEncoding.EncodeToString(oldKey))
|
||||
|
||||
// Setup new key
|
||||
newKeyEnv := "TEST_NEW_KEY"
|
||||
newKey := make([]byte, encryption.AES256KeySize)
|
||||
for i := range newKey {
|
||||
newKey[i] = byte((i + 128) % 256) // Different key
|
||||
}
|
||||
os.Setenv(newKeyEnv, base64.StdEncoding.EncodeToString(newKey))
|
||||
|
||||
if t, ok := t.(*testing.T); ok {
|
||||
t.Cleanup(func() {
|
||||
os.Unsetenv(oldKeyEnv)
|
||||
os.Unsetenv(newKeyEnv)
|
||||
})
|
||||
}
|
||||
|
||||
// Create key managers
|
||||
oldKM := encryption.NewKeyManager(oldKeyEnv)
|
||||
err := oldKM.Initialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newKM := encryption.NewKeyManager(newKeyEnv)
|
||||
err = newKM.Initialize()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create encryption services
|
||||
oldService, err := encryption.NewEncryptionService(oldKM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newService, err := encryption.NewEncryptionService(newKM)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return oldService, newService
|
||||
}
|
||||
|
||||
// createTestData creates test records with encrypted fields
|
||||
func createTestData(t testing.TB, db *gorm.DB, oldService *encryption.EncryptionService, count int) {
|
||||
for i := 1; i <= count; i++ {
|
||||
// Create encrypted values with the old key
|
||||
field1, err := oldService.EncryptString(fmt.Sprintf("secret-field-%d", i))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
field2, err := oldService.EncryptString(fmt.Sprintf("secret-data-%d", i))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
field3, err := oldService.EncryptString(fmt.Sprintf("secret-key-%d", i))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a test record
|
||||
record := TestModel{
|
||||
Name: fmt.Sprintf("Test Record %d", i),
|
||||
EncryptedField: encryption.EncryptedPrefix + field1,
|
||||
EncryptedData: encryption.EncryptedPrefix + field2,
|
||||
EncryptedKey: encryption.EncryptedPrefix + field3,
|
||||
StandardField: fmt.Sprintf("standard-field-%d", i),
|
||||
}
|
||||
|
||||
// Save to DB
|
||||
result := db.Create(&record)
|
||||
if err := result.Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewKeyRotator(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
oldService, newService := setupEncryptionServices(t)
|
||||
auditor, _ := setupTestAuditor(t)
|
||||
|
||||
t.Run("Valid rotator creation", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, rotator)
|
||||
assert.False(t, rotator.dryRun)
|
||||
assert.Equal(t, 100, rotator.batchSize)
|
||||
assert.Equal(t, 50, rotator.maxErrors)
|
||||
})
|
||||
|
||||
t.Run("Nil DB", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(nil, oldService, newService, auditor)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, rotator)
|
||||
assert.Equal(t, ErrNilDB, err)
|
||||
})
|
||||
|
||||
t.Run("Nil old service", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(db, nil, newService, auditor)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, rotator)
|
||||
assert.Equal(t, ErrNoOldKey, err)
|
||||
})
|
||||
|
||||
t.Run("Nil new service", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(db, oldService, nil, auditor)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, rotator)
|
||||
assert.Equal(t, ErrNoNewKey, err)
|
||||
})
|
||||
|
||||
t.Run("Same service", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(db, oldService, oldService, auditor)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, rotator)
|
||||
assert.Equal(t, ErrSameKey, err)
|
||||
})
|
||||
|
||||
t.Run("Default auditor", func(t *testing.T) {
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, nil)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, rotator)
|
||||
assert.NotNil(t, rotator.auditor)
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeyRotatorConfigMethods(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
oldService, newService := setupEncryptionServices(t)
|
||||
auditor, _ := setupTestAuditor(t)
|
||||
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("SetDryRun", func(t *testing.T) {
|
||||
rotator.SetDryRun(true)
|
||||
assert.True(t, rotator.dryRun)
|
||||
|
||||
rotator.SetDryRun(false)
|
||||
assert.False(t, rotator.dryRun)
|
||||
})
|
||||
|
||||
t.Run("SetBatchSize", func(t *testing.T) {
|
||||
rotator.SetBatchSize(200)
|
||||
assert.Equal(t, 200, rotator.batchSize)
|
||||
|
||||
// Test with invalid value
|
||||
rotator.SetBatchSize(0)
|
||||
assert.Equal(t, 200, rotator.batchSize) // Shouldn't change
|
||||
|
||||
rotator.SetBatchSize(-10)
|
||||
assert.Equal(t, 200, rotator.batchSize) // Shouldn't change
|
||||
})
|
||||
|
||||
t.Run("SetMaxErrors", func(t *testing.T) {
|
||||
rotator.SetMaxErrors(100)
|
||||
assert.Equal(t, 100, rotator.maxErrors)
|
||||
|
||||
rotator.SetMaxErrors(0)
|
||||
assert.Equal(t, 0, rotator.maxErrors) // 0 is valid (no max)
|
||||
|
||||
// Test with invalid value
|
||||
rotator.SetMaxErrors(-10)
|
||||
assert.Equal(t, 0, rotator.maxErrors) // Shouldn't change
|
||||
})
|
||||
}
|
||||
|
||||
func TestRotateKeys(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
oldService, newService := setupEncryptionServices(t)
|
||||
auditor, logBuffer := setupTestAuditor(t)
|
||||
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Rotate keys for model with no records", func(t *testing.T) {
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, ErrNoDataToMigrate, err)
|
||||
assert.Equal(t, 0, stats.TotalRecords)
|
||||
})
|
||||
|
||||
t.Run("Rotate keys for non-struct model", func(t *testing.T) {
|
||||
stats, err := rotator.RotateKeys("not a struct", "")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "must be a struct")
|
||||
assert.Equal(t, 0, stats.TotalRecords, "Expected total records to be 0 for non-struct model")
|
||||
})
|
||||
|
||||
t.Run("Rotate keys for model with records", func(t *testing.T) {
|
||||
// Reset log buffer
|
||||
logBuffer.Reset()
|
||||
|
||||
// Create test data
|
||||
createTestData(t, db, oldService, 10)
|
||||
|
||||
// Perform key rotation
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 10, stats.TotalRecords)
|
||||
assert.Equal(t, 10, stats.ProcessedRecords)
|
||||
assert.Equal(t, 0, stats.FailedRecords)
|
||||
assert.NotZero(t, stats.ElapsedTime)
|
||||
assert.Empty(t, stats.Errors)
|
||||
|
||||
// Verify that records were updated with re-encrypted values
|
||||
var records []TestModel
|
||||
result := db.Find(&records)
|
||||
require.NoError(t, result.Error)
|
||||
assert.Equal(t, 10, len(records))
|
||||
|
||||
// Test a sample record to ensure it was re-encrypted properly
|
||||
record := records[0]
|
||||
|
||||
// Verify the old key can't decrypt the new values
|
||||
_, err = oldService.DecryptString(strings.TrimPrefix(record.EncryptedField, encryption.EncryptedPrefix))
|
||||
assert.Error(t, err, "Old key should not be able to decrypt new values")
|
||||
|
||||
// Verify the new key can decrypt the values
|
||||
decryptedField, err := newService.DecryptString(strings.TrimPrefix(record.EncryptedField, encryption.EncryptedPrefix))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "secret-field-1", decryptedField)
|
||||
|
||||
// Verify audit logs were created
|
||||
logContent := logBuffer.String()
|
||||
assert.Contains(t, logContent, "key_rotation")
|
||||
assert.Contains(t, logContent, "TestModel")
|
||||
|
||||
// Verify no sensitive data in logs
|
||||
assert.NotContains(t, logContent, "secret-field")
|
||||
assert.NotContains(t, logContent, "secret-data")
|
||||
assert.NotContains(t, logContent, "secret-key")
|
||||
})
|
||||
|
||||
t.Run("Dry run mode", func(t *testing.T) {
|
||||
// Reset the database
|
||||
db.Exec("DELETE FROM test_models")
|
||||
createTestData(t, db, oldService, 5)
|
||||
|
||||
// Create a new rotator with dry run enabled
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
require.NoError(t, err)
|
||||
rotator.SetDryRun(true)
|
||||
|
||||
// Perform key rotation
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 5, stats.TotalRecords)
|
||||
|
||||
// Verify that records were NOT updated with re-encrypted values
|
||||
var records []TestModel
|
||||
result := db.Find(&records)
|
||||
require.NoError(t, result.Error)
|
||||
|
||||
// Test a sample record to ensure it was NOT re-encrypted
|
||||
record := records[0]
|
||||
|
||||
// Verify the old key CAN decrypt the values (because they weren't changed)
|
||||
decryptedField, err := oldService.DecryptString(strings.TrimPrefix(record.EncryptedField, encryption.EncryptedPrefix))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "secret-field-1", decryptedField)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRotateKeysWithErrors(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
oldService, newService := setupEncryptionServices(t)
|
||||
auditor, _ := setupTestAuditor(t)
|
||||
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test data with one corrupted record
|
||||
createTestData(t, db, oldService, 5)
|
||||
|
||||
// Create a corrupted record that can't be decrypted
|
||||
corruptedRecord := TestModel{
|
||||
Name: "Corrupted Record",
|
||||
EncryptedField: encryption.EncryptedPrefix + "corrupted-data",
|
||||
EncryptedData: encryption.EncryptedPrefix + "corrupted-data",
|
||||
StandardField: "standard-field",
|
||||
}
|
||||
result := db.Create(&corruptedRecord)
|
||||
require.NoError(t, result.Error)
|
||||
|
||||
// Perform key rotation
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
require.NoError(t, err) // Should still succeed overall
|
||||
assert.Equal(t, 6, stats.TotalRecords)
|
||||
assert.Equal(t, 5, stats.ProcessedRecords) // Only 5 should be processed successfully
|
||||
assert.Equal(t, 0, stats.FailedRecords) // Failure to decrypt is skipped, not counted as error
|
||||
|
||||
// Verify that the valid records were updated
|
||||
var records []TestModel
|
||||
db.Where("name LIKE ?", "Test Record%").Find(&records)
|
||||
require.Equal(t, 5, len(records))
|
||||
|
||||
for _, record := range records {
|
||||
// Verify the new key can decrypt
|
||||
_, err = newService.DecryptString(strings.TrimPrefix(record.EncryptedField, encryption.EncryptedPrefix))
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// Verify the corrupted record wasn't changed
|
||||
var corrupted TestModel
|
||||
db.Where("name = ?", "Corrupted Record").First(&corrupted)
|
||||
assert.Equal(t, encryption.EncryptedPrefix+"corrupted-data", corrupted.EncryptedField)
|
||||
}
|
||||
|
||||
func TestGetPrimaryKeyValue(t *testing.T) {
|
||||
type TestStruct struct {
|
||||
ID uint
|
||||
CustomID string
|
||||
NotAnID string
|
||||
OtherData string
|
||||
}
|
||||
|
||||
t.Run("Default ID field", func(t *testing.T) {
|
||||
test := TestStruct{ID: 123, OtherData: "test"}
|
||||
val := getPrimaryKeyValue(reflect.ValueOf(test), "")
|
||||
assert.Equal(t, uint(123), val)
|
||||
})
|
||||
|
||||
t.Run("Custom ID field", func(t *testing.T) {
|
||||
test := TestStruct{ID: 123, CustomID: "ABC123", OtherData: "test"}
|
||||
val := getPrimaryKeyValue(reflect.ValueOf(test), "CustomID")
|
||||
assert.Equal(t, "ABC123", val)
|
||||
})
|
||||
|
||||
t.Run("Non-existent ID field", func(t *testing.T) {
|
||||
test := TestStruct{ID: 123, OtherData: "test"}
|
||||
val := getPrimaryKeyValue(reflect.ValueOf(test), "NonExistentID")
|
||||
assert.Equal(t, "<unknown>", val)
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkKeyRotation measures the performance of key rotation
|
||||
func BenchmarkKeyRotation(b *testing.B) {
|
||||
// Setup
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
db.AutoMigrate(&TestModel{})
|
||||
|
||||
oldService, newService := setupEncryptionServices(b)
|
||||
auditor, _ := setupTestAuditor(b)
|
||||
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
// Create benchmark data sets of different sizes
|
||||
benchmarks := []struct {
|
||||
name string
|
||||
numRecords int
|
||||
}{
|
||||
{"Small (10 records)", 10},
|
||||
{"Medium (100 records)", 100},
|
||||
{"Large (500 records)", 500},
|
||||
}
|
||||
|
||||
for _, bm := range benchmarks {
|
||||
b.Run(bm.name, func(b *testing.B) {
|
||||
// Reset the database for each benchmark iteration
|
||||
db.Exec("DELETE FROM test_models")
|
||||
createTestData(b, db, oldService, bm.numRecords)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if stats.ProcessedRecords != bm.numRecords {
|
||||
b.Fatalf("Expected %d records, got %d", bm.numRecords, stats.ProcessedRecords)
|
||||
}
|
||||
|
||||
// Reset for the next iteration
|
||||
if i < b.N-1 {
|
||||
db.Exec("DELETE FROM test_models")
|
||||
createTestData(b, db, oldService, bm.numRecords)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmarks for different batch sizes
|
||||
func BenchmarkBatchSizes(b *testing.B) {
|
||||
// Setup
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
db.AutoMigrate(&TestModel{})
|
||||
|
||||
oldService, newService := setupEncryptionServices(b)
|
||||
auditor, _ := setupTestAuditor(b)
|
||||
|
||||
// Create a dataset of 500 records
|
||||
const numRecords = 500
|
||||
createTestData(b, db, oldService, numRecords)
|
||||
|
||||
// Test different batch sizes
|
||||
batchSizes := []int{10, 50, 100, 200, 500}
|
||||
|
||||
for _, batchSize := range batchSizes {
|
||||
b.Run(fmt.Sprintf("BatchSize_%d", batchSize), func(b *testing.B) {
|
||||
rotator, err := NewKeyRotator(db, oldService, newService, auditor)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
rotator.SetBatchSize(batchSize)
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
// Reset data before each run
|
||||
if i > 0 {
|
||||
db.Exec("DELETE FROM test_models")
|
||||
createTestData(b, db, oldService, numRecords)
|
||||
}
|
||||
|
||||
stats, err := rotator.RotateKeys(&TestModel{}, "")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if stats.ProcessedRecords != numRecords {
|
||||
b.Fatalf("Expected %d records, got %d", numRecords, stats.ProcessedRecords)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
package keyrotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/starfleetcptn/gomft/internal/encryption"
|
||||
"github.com/starfleetcptn/gomft/internal/encryption/audit"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RotationOptions contains configuration for the key rotation process
|
||||
type RotationOptions struct {
|
||||
// DryRun performs all operations but doesn't save changes to database
|
||||
DryRun bool
|
||||
// BatchSize sets the number of records to process in each batch
|
||||
BatchSize int
|
||||
// MaxErrors sets the threshold of errors before aborting
|
||||
MaxErrors int
|
||||
// Parallelism controls how many models are processed in parallel
|
||||
Parallelism int
|
||||
// Timeout specifies a maximum duration for the entire operation
|
||||
Timeout time.Duration
|
||||
// WorkerTimeout specifies maximum duration for a single batch
|
||||
WorkerTimeout time.Duration
|
||||
// ProgressCallback receives updates on rotation progress
|
||||
ProgressCallback func(modelName string, processed, total int)
|
||||
}
|
||||
|
||||
// RotationUtility provides comprehensive capabilities for rotating encryption keys
|
||||
// across multiple database models with detailed auditing and progress tracking
|
||||
type RotationUtility struct {
|
||||
db *gorm.DB
|
||||
oldService *encryption.EncryptionService
|
||||
newService *encryption.EncryptionService
|
||||
auditor *audit.SecurityAuditor
|
||||
testingHooks map[string]func(interface{}) error
|
||||
mu sync.Mutex
|
||||
options RotationOptions
|
||||
}
|
||||
|
||||
// NewRotationUtility creates a new RotationUtility
|
||||
func NewRotationUtility(
|
||||
db *gorm.DB,
|
||||
oldService, newService *encryption.EncryptionService,
|
||||
auditor *audit.SecurityAuditor,
|
||||
options RotationOptions,
|
||||
) (*RotationUtility, error) {
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("database connection is required")
|
||||
}
|
||||
|
||||
if oldService == nil {
|
||||
return nil, fmt.Errorf("old encryption service is required")
|
||||
}
|
||||
|
||||
if newService == nil {
|
||||
return nil, fmt.Errorf("new encryption service is required")
|
||||
}
|
||||
|
||||
if auditor == nil {
|
||||
auditor = audit.GetGlobalAuditor()
|
||||
}
|
||||
|
||||
// Set default options
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = 100
|
||||
}
|
||||
|
||||
if options.MaxErrors <= 0 {
|
||||
options.MaxErrors = 50
|
||||
}
|
||||
|
||||
if options.Parallelism <= 0 {
|
||||
options.Parallelism = 1
|
||||
}
|
||||
|
||||
if options.Timeout <= 0 {
|
||||
options.Timeout = 24 * time.Hour // Default long timeout
|
||||
}
|
||||
|
||||
if options.WorkerTimeout <= 0 {
|
||||
options.WorkerTimeout = 30 * time.Minute
|
||||
}
|
||||
|
||||
return &RotationUtility{
|
||||
db: db,
|
||||
oldService: oldService,
|
||||
newService: newService,
|
||||
auditor: auditor,
|
||||
options: options,
|
||||
testingHooks: make(map[string]func(interface{}) error),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RegisterTestingHook registers a hook for testing purposes
|
||||
func (r *RotationUtility) RegisterTestingHook(name string, hook func(interface{}) error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.testingHooks[name] = hook
|
||||
}
|
||||
|
||||
// runHook runs a testing hook if it exists
|
||||
func (r *RotationUtility) runHook(name string, data interface{}) error {
|
||||
r.mu.Lock()
|
||||
hook, exists := r.testingHooks[name]
|
||||
r.mu.Unlock()
|
||||
|
||||
if exists && hook != nil {
|
||||
return hook(data)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RotateKeysForModels performs key rotation for multiple model types with detailed monitoring
|
||||
func (r *RotationUtility) RotateKeysForModels(ctx context.Context, models []interface{}) (*RotationStats, error) {
|
||||
// Create master context with timeout
|
||||
masterCtx, cancel := context.WithTimeout(ctx, r.options.Timeout)
|
||||
defer cancel()
|
||||
|
||||
// Track overall stats
|
||||
overallStats := &RotationStats{
|
||||
StartTime: time.Now(),
|
||||
Errors: make([]string, 0),
|
||||
}
|
||||
|
||||
// Create key rotator
|
||||
rotator, err := NewKeyRotator(r.db, r.oldService, r.newService, r.auditor)
|
||||
if err != nil {
|
||||
return overallStats, fmt.Errorf("failed to create key rotator: %w", err)
|
||||
}
|
||||
|
||||
// Apply options
|
||||
rotator.SetDryRun(r.options.DryRun)
|
||||
rotator.SetBatchSize(r.options.BatchSize)
|
||||
rotator.SetMaxErrors(r.options.MaxErrors)
|
||||
|
||||
// Log the start of rotation
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"starting",
|
||||
"pending",
|
||||
true,
|
||||
fmt.Sprintf("Starting key rotation for %d model types (dry run: %v)", len(models), r.options.DryRun),
|
||||
0,
|
||||
)
|
||||
|
||||
// Process all models (sequentially)
|
||||
for _, model := range models {
|
||||
// Check if context is canceled
|
||||
select {
|
||||
case <-masterCtx.Done():
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("key rotation aborted: %v", masterCtx.Err()))
|
||||
return overallStats, masterCtx.Err()
|
||||
default:
|
||||
// Continue processing
|
||||
}
|
||||
|
||||
// Get model type info
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
modelName := modelType.Name()
|
||||
|
||||
// Run pre-rotation hook if any
|
||||
if err := r.runHook("pre_rotation_"+modelName, model); err != nil {
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("pre-rotation hook failed for %s: %v", modelName, err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Log model rotation start
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"starting",
|
||||
"pending",
|
||||
true,
|
||||
fmt.Sprintf("Starting key rotation for model: %s", modelName),
|
||||
0,
|
||||
)
|
||||
|
||||
// Create a worker context with timeout
|
||||
workerCtx, workerCancel := context.WithTimeout(masterCtx, r.options.WorkerTimeout)
|
||||
|
||||
// Create a goroutine to handle timeouts
|
||||
rotationDone := make(chan struct{})
|
||||
var modelStats *RotationStats
|
||||
var rotationErr error
|
||||
|
||||
go func() {
|
||||
// Perform the actual rotation
|
||||
modelStats, rotationErr = rotator.RotateKeys(model, "")
|
||||
close(rotationDone)
|
||||
}()
|
||||
|
||||
// Wait for rotation to complete or timeout
|
||||
select {
|
||||
case <-workerCtx.Done():
|
||||
if workerCtx.Err() == context.DeadlineExceeded {
|
||||
errorMsg := fmt.Sprintf("key rotation for model %s timed out after %v", modelName, r.options.WorkerTimeout)
|
||||
overallStats.Errors = append(overallStats.Errors, errorMsg)
|
||||
|
||||
// Log timeout error
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
false,
|
||||
errorMsg,
|
||||
0,
|
||||
)
|
||||
}
|
||||
case <-rotationDone:
|
||||
// Rotation completed
|
||||
}
|
||||
|
||||
// Clean up the worker context
|
||||
workerCancel()
|
||||
|
||||
// Check for rotation errors
|
||||
if rotationErr != nil {
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("failed to rotate keys for %s: %v", modelName, rotationErr))
|
||||
|
||||
// Log rotation error
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
false,
|
||||
fmt.Sprintf("Key rotation failed for model %s: %v", modelName, rotationErr),
|
||||
0,
|
||||
)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Update overall stats
|
||||
if modelStats != nil {
|
||||
overallStats.TotalRecords += modelStats.TotalRecords
|
||||
overallStats.ProcessedRecords += modelStats.ProcessedRecords
|
||||
overallStats.SkippedRecords += modelStats.SkippedRecords
|
||||
overallStats.FailedRecords += modelStats.FailedRecords
|
||||
overallStats.Errors = append(overallStats.Errors, modelStats.Errors...)
|
||||
|
||||
// Call progress callback if set
|
||||
if r.options.ProgressCallback != nil {
|
||||
r.options.ProgressCallback(modelName, modelStats.ProcessedRecords, modelStats.TotalRecords)
|
||||
}
|
||||
|
||||
// Log progress
|
||||
successRate := 0.0
|
||||
if modelStats.TotalRecords > 0 {
|
||||
successRate = float64(modelStats.ProcessedRecords) / float64(modelStats.TotalRecords) * 100
|
||||
}
|
||||
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
true,
|
||||
fmt.Sprintf("Completed key rotation for model %s: %d/%d records (%.1f%%) processed, %d skipped, %d failed",
|
||||
modelName, modelStats.ProcessedRecords, modelStats.TotalRecords, successRate,
|
||||
modelStats.SkippedRecords, modelStats.FailedRecords),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
// Run post-rotation hook if any
|
||||
if err := r.runHook("post_rotation_"+modelName, model); err != nil {
|
||||
overallStats.Errors = append(overallStats.Errors, fmt.Sprintf("post-rotation hook failed for %s: %v", modelName, err))
|
||||
}
|
||||
}
|
||||
|
||||
// Complete overall stats
|
||||
overallStats.EndTime = time.Now()
|
||||
overallStats.ElapsedTime = overallStats.EndTime.Sub(overallStats.StartTime)
|
||||
|
||||
// Calculate overall success rate
|
||||
successRate := 0.0
|
||||
if overallStats.TotalRecords > 0 {
|
||||
successRate = float64(overallStats.ProcessedRecords) / float64(overallStats.TotalRecords) * 100
|
||||
}
|
||||
|
||||
// Log completion
|
||||
r.auditor.LogKeyRotationEventWithDescription(
|
||||
"old",
|
||||
"new",
|
||||
len(overallStats.Errors) == 0,
|
||||
fmt.Sprintf("Completed key rotation for all models: %d/%d records (%.1f%%) processed, %d skipped, %d failed, %d errors in %s",
|
||||
overallStats.ProcessedRecords, overallStats.TotalRecords, successRate,
|
||||
overallStats.SkippedRecords, overallStats.FailedRecords, len(overallStats.Errors),
|
||||
overallStats.ElapsedTime),
|
||||
0,
|
||||
)
|
||||
|
||||
return overallStats, nil
|
||||
}
|
||||
|
||||
// FindModelsWithEncryptedFields automatically finds all database models with encrypted fields
|
||||
func (r *RotationUtility) FindModelsWithEncryptedFields() ([]interface{}, error) {
|
||||
// This is a placeholder - in a real implementation, we would scan the codebase
|
||||
// or database schema to automatically detect models with encrypted fields
|
||||
// Since that requires knowledge of the codebase structure, this would be
|
||||
// customized for the specific application
|
||||
|
||||
return []interface{}{}, fmt.Errorf("automatic model detection not implemented, provide models explicitly")
|
||||
}
|
||||
|
||||
// ValidateRotation tests the key rotation on sample records without saving changes
|
||||
func (r *RotationUtility) ValidateRotation(models []interface{}) (map[string]bool, error) {
|
||||
results := make(map[string]bool)
|
||||
|
||||
// Save current options to restore later
|
||||
originalDryRun := r.options.DryRun
|
||||
originalBatchSize := r.options.BatchSize
|
||||
|
||||
// Set temporary options for validation
|
||||
r.options.DryRun = true
|
||||
r.options.BatchSize = 10 // Test with small batch
|
||||
|
||||
// Create a context with short timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
// Run rotation with dry run mode
|
||||
stats, err := r.RotateKeysForModels(ctx, models)
|
||||
|
||||
// Restore original options
|
||||
r.options.DryRun = originalDryRun
|
||||
r.options.BatchSize = originalBatchSize
|
||||
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Process results for each model
|
||||
for _, model := range models {
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
modelName := modelType.Name()
|
||||
|
||||
// Check if there were errors for this model
|
||||
hasModelErrors := false
|
||||
for _, errMsg := range stats.Errors {
|
||||
if strings.Contains(errMsg, modelName) {
|
||||
hasModelErrors = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
results[modelName] = !hasModelErrors
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// CreateEncryptionMigrationPlan creates a detailed plan for migrating data to a new encryption key
|
||||
func (r *RotationUtility) CreateEncryptionMigrationPlan(models []interface{}) (*EncryptionMigrationPlan, error) {
|
||||
plan := &EncryptionMigrationPlan{
|
||||
ModelPlans: make(map[string]*ModelMigrationPlan),
|
||||
EstimatedDuration: 0,
|
||||
EstimatedRecords: 0,
|
||||
RecommendedOptions: r.options, // Start with current options
|
||||
}
|
||||
|
||||
// Calculate record counts for each model
|
||||
totalRecords := 0
|
||||
for _, model := range models {
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
modelName := modelType.Name()
|
||||
|
||||
// Get record count
|
||||
var count int64
|
||||
if err := r.db.Model(model).Count(&count).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to count records for %s: %w", modelName, err)
|
||||
}
|
||||
|
||||
encryptedFields := r.identifyEncryptedFields(model)
|
||||
|
||||
// Create model plan
|
||||
modelPlan := &ModelMigrationPlan{
|
||||
ModelName: modelName,
|
||||
RecordCount: int(count),
|
||||
EstimatedTime: r.estimateMigrationTime(int(count), len(encryptedFields)),
|
||||
EncryptedFields: encryptedFields,
|
||||
BatchSizeRec: r.calculateOptimalBatchSize(int(count)),
|
||||
}
|
||||
|
||||
plan.ModelPlans[modelName] = modelPlan
|
||||
totalRecords += int(count)
|
||||
plan.EstimatedDuration += modelPlan.EstimatedTime
|
||||
}
|
||||
|
||||
plan.EstimatedRecords = totalRecords
|
||||
|
||||
// Calculate optimal batch size and parallelism based on total record count
|
||||
plan.RecommendedOptions.BatchSize = r.calculateOptimalBatchSize(totalRecords)
|
||||
plan.RecommendedOptions.Parallelism = r.calculateOptimalParallelism(totalRecords)
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// identifyEncryptedFields finds all encrypted fields in a model
|
||||
func (r *RotationUtility) identifyEncryptedFields(model interface{}) []string {
|
||||
fields := []string{}
|
||||
|
||||
// Get model value and type
|
||||
modelType := reflect.TypeOf(model)
|
||||
if modelType.Kind() == reflect.Ptr {
|
||||
modelType = modelType.Elem()
|
||||
}
|
||||
|
||||
// Skip if not a struct
|
||||
if modelType.Kind() != reflect.Struct {
|
||||
return fields
|
||||
}
|
||||
|
||||
// Scan all fields for encrypted ones
|
||||
for i := 0; i < modelType.NumField(); i++ {
|
||||
field := modelType.Field(i)
|
||||
|
||||
// Look for fields starting with "Encrypted"
|
||||
if strings.HasPrefix(field.Name, "Encrypted") && field.Type.Kind() == reflect.String {
|
||||
fields = append(fields, field.Name)
|
||||
}
|
||||
}
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// calculateOptimalBatchSize determines the optimal batch size based on record count
|
||||
func (r *RotationUtility) calculateOptimalBatchSize(recordCount int) int {
|
||||
// This is a simplistic approach - in a real system, this would be based on
|
||||
// benchmarking and system characteristics
|
||||
if recordCount < 1000 {
|
||||
return 100
|
||||
} else if recordCount < 10000 {
|
||||
return 250
|
||||
} else if recordCount < 100000 {
|
||||
return 500
|
||||
} else {
|
||||
return 1000
|
||||
}
|
||||
}
|
||||
|
||||
// calculateOptimalParallelism determines the optimal parallelism level
|
||||
func (r *RotationUtility) calculateOptimalParallelism(recordCount int) int {
|
||||
// Simple heuristic - adjust based on actual system performance
|
||||
cpuCount := runtime.NumCPU()
|
||||
|
||||
if recordCount < 10000 {
|
||||
return 1
|
||||
} else if recordCount < 100000 {
|
||||
return min(2, cpuCount)
|
||||
} else {
|
||||
return min(4, cpuCount)
|
||||
}
|
||||
}
|
||||
|
||||
// min returns the minimum of two integers
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// estimateMigrationTime provides a rough estimate of time needed for migration
|
||||
func (r *RotationUtility) estimateMigrationTime(recordCount, fieldCount int) time.Duration {
|
||||
// This is a very rough estimate - in a real system, this would be based on
|
||||
// benchmarking results and system characteristics
|
||||
|
||||
// Assume roughly 10ms per record per field
|
||||
msPerRecordField := 10
|
||||
|
||||
// Calculate total time in milliseconds
|
||||
totalTimeMs := recordCount * fieldCount * msPerRecordField
|
||||
|
||||
// Add overhead
|
||||
totalTimeMs = int(float64(totalTimeMs) * 1.2) // 20% overhead
|
||||
|
||||
return time.Duration(totalTimeMs) * time.Millisecond
|
||||
}
|
||||
|
||||
// EncryptionMigrationPlan contains the complete plan for migration
|
||||
type EncryptionMigrationPlan struct {
|
||||
ModelPlans map[string]*ModelMigrationPlan `json:"model_plans"`
|
||||
EstimatedDuration time.Duration `json:"estimated_duration"`
|
||||
EstimatedRecords int `json:"estimated_records"`
|
||||
RecommendedOptions RotationOptions `json:"recommended_options"`
|
||||
}
|
||||
|
||||
// ModelMigrationPlan contains migration details for a specific model
|
||||
type ModelMigrationPlan struct {
|
||||
ModelName string `json:"model_name"`
|
||||
RecordCount int `json:"record_count"`
|
||||
EstimatedTime time.Duration `json:"estimated_time"`
|
||||
EncryptedFields []string `json:"encrypted_fields"`
|
||||
BatchSizeRec int `json:"batch_size_recommendation"`
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package encryption
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SanitizeError sanitizes an error message to remove or mask sensitive data like keys
|
||||
func SanitizeError(errMsg string) string {
|
||||
if errMsg == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Sanitize any hex keys (likely to be encryption keys)
|
||||
hexKeyPattern := regexp.MustCompile(`([0-9a-fA-F]{16,})`)
|
||||
errMsg = hexKeyPattern.ReplaceAllStringFunc(errMsg, func(match string) string {
|
||||
if len(match) > 8 {
|
||||
return match[:4] + "..." + match[len(match)-4:]
|
||||
}
|
||||
return "****"
|
||||
})
|
||||
|
||||
// Sanitize any base64 content that might contain keys or encrypted data
|
||||
base64Pattern := regexp.MustCompile(`([A-Za-z0-9+/]{16,}={0,2})`)
|
||||
errMsg = base64Pattern.ReplaceAllStringFunc(errMsg, func(match string) string {
|
||||
if len(match) > 8 {
|
||||
return match[:4] + "..." + match[len(match)-4:]
|
||||
}
|
||||
return "****"
|
||||
})
|
||||
|
||||
// Mask content that appears to be formatted like encryption keys
|
||||
keyPattern := regexp.MustCompile(`(?i)key[=:][\s]*["']?([^"'\s]+)["']?`)
|
||||
errMsg = keyPattern.ReplaceAllString(errMsg, "key=****")
|
||||
|
||||
// Mask any JWT tokens
|
||||
jwtPattern := regexp.MustCompile(`eyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+`)
|
||||
errMsg = jwtPattern.ReplaceAllString(errMsg, "JWT_TOKEN_REDACTED")
|
||||
|
||||
// Mask content that appears to be passwords or secrets
|
||||
secretPattern := regexp.MustCompile(`(?i)(password|secret|token|auth)[=:][\s]*["']?([^"'\s]+)["']?`)
|
||||
errMsg = secretPattern.ReplaceAllString(errMsg, "$1=****")
|
||||
|
||||
// Remove any content between our encrypted prefix and the end of the word
|
||||
encPrefix := EncryptedPrefix
|
||||
if encPrefix != "" {
|
||||
errMsg = sanitizeEncryptedValues(errMsg, encPrefix)
|
||||
}
|
||||
|
||||
return errMsg
|
||||
}
|
||||
|
||||
// sanitizeEncryptedValues replaces encrypted values with a redacted placeholder
|
||||
func sanitizeEncryptedValues(input, prefix string) string {
|
||||
if prefix == "" {
|
||||
return input
|
||||
}
|
||||
|
||||
// Find all occurrences of the prefix and replace the entire encrypted value
|
||||
parts := strings.Split(input, prefix)
|
||||
if len(parts) <= 1 {
|
||||
return input
|
||||
}
|
||||
|
||||
result := parts[0]
|
||||
for i := 1; i < len(parts); i++ {
|
||||
part := parts[i]
|
||||
// Find the end of the encrypted value (usually a space, comma, period, quote, etc.)
|
||||
endIdx := strings.IndexAny(part, " \t\n\r.,;:\"')")
|
||||
if endIdx == -1 {
|
||||
// If no terminating character, take the whole string
|
||||
result += prefix + "****"
|
||||
} else {
|
||||
// Keep the terminating character
|
||||
result += prefix + "****" + part[endIdx:]
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// SanitizeCredentialData removes or masks a credential for safe logging
|
||||
// This is a utility function to use in error messages and logs
|
||||
func SanitizeCredentialData(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// If already an encrypted value, return just the prefix and a hint of the actual value
|
||||
if strings.HasPrefix(value, EncryptedPrefix) {
|
||||
encrypted := strings.TrimPrefix(value, EncryptedPrefix)
|
||||
if len(encrypted) > 8 {
|
||||
return EncryptedPrefix + encrypted[:4] + "..." + encrypted[len(encrypted)-4:]
|
||||
}
|
||||
return EncryptedPrefix + "..."
|
||||
}
|
||||
|
||||
// For plaintext credentials, just mask the value entirely
|
||||
if len(value) > 8 {
|
||||
return value[:2] + "..." + value[len(value)-2:]
|
||||
}
|
||||
return "****"
|
||||
}
|
||||
Reference in New Issue
Block a user