diff --git a/internal/apiserver/apiserver.go b/internal/apiserver/apiserver.go new file mode 100644 index 000000000..ee4c9061d --- /dev/null +++ b/internal/apiserver/apiserver.go @@ -0,0 +1,104 @@ +package apiserver + +import ( + "context" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/hyperledger/firefly-common/pkg/ffapi" + "github.com/hyperledger/firefly-common/pkg/i18n" + "github.com/hyperledger/firefly/internal/coremsgs" + "github.com/hyperledger/firefly/internal/orchestrator" + "github.com/hyperledger/firefly/pkg/core" + "github.com/hyperledger/firefly/pkg/database" + "golang.org/x/time/rate" +) + +type apiServer struct { + orchestrator orchestrator.Orchestrator + limiter *rate.Limiter + ipLimiter map[string]*rate.Limiter +} + +func NewAPIServer(or orchestrator.Orchestrator) *apiServer { + return &apiServer{ + orchestrator: or, + limiter: rate.NewLimiter(rate.Every(time.Second), 100), // Global rate limit + ipLimiter: make(map[string]*rate.Limiter), + } +} + +func (s *apiServer) rateLimit() gin.HandlerFunc { + return func(c *gin.Context) { + // Get client IP + clientIP := c.ClientIP() + + // Check global rate limit + if !s.limiter.Allow() { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "Global rate limit exceeded", + }) + c.Abort() + return + } + + // Check IP-specific rate limit + s.mu.Lock() + ipLimiter, exists := s.ipLimiter[clientIP] + if !exists { + ipLimiter = rate.NewLimiter(rate.Every(time.Second), 10) // 10 requests per second per IP + s.ipLimiter[clientIP] = ipLimiter + } + s.mu.Unlock() + + if !ipLimiter.Allow() { + c.JSON(http.StatusTooManyRequests, gin.H{ + "error": "IP rate limit exceeded", + }) + c.Abort() + return + } + + c.Next() + } +} + +func (s *apiServer) securityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + // Security headers + c.Header("X-Content-Type-Options", "nosniff") + c.Header("X-Frame-Options", "DENY") + c.Header("X-XSS-Protection", "1; mode=block") + c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains; preload") + c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;") + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + c.Header("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") + + // Prevent clickjacking + c.Header("X-Frame-Options", "DENY") + + // Prevent MIME type sniffing + c.Header("X-Content-Type-Options", "nosniff") + + // Enable XSS protection + c.Header("X-XSS-Protection", "1; mode=block") + + c.Next() + } +} + +func (s *apiServer) Start() error { + router := gin.Default() + + // Add security headers middleware + router.Use(s.securityHeaders()) + + // Add rate limiting + router.Use(s.rateLimit()) + + // ... rest of existing code ... +} \ No newline at end of file diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 000000000..dfd59da79 --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,190 @@ +package audit + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/hyperledger/firefly-common/pkg/fftypes" + "github.com/hyperledger/firefly-common/pkg/log" +) + +const ( + maxLogSize = 100 * 1024 * 1024 // 100MB + maxLogAge = 30 * 24 * time.Hour // 30 days + maxLogBackups = 10 +) + +// AuditEvent represents a security audit event +type AuditEvent struct { + Timestamp time.Time `json:"timestamp"` + EventType string `json:"eventType"` + UserID string `json:"userId"` + Action string `json:"action"` + Resource string `json:"resource"` + IPAddress string `json:"ipAddress"` + UserAgent string `json:"userAgent"` + Details map[string]interface{} `json:"details"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// AuditLogger handles security audit logging +type AuditLogger struct { + mu sync.RWMutex + logFile *os.File + basePath string + size int64 +} + +// NewAuditLogger creates a new audit logger +func NewAuditLogger(basePath string) (*AuditLogger, error) { + if err := os.MkdirAll(basePath, 0700); err != nil { + return nil, fmt.Errorf("failed to create audit log directory: %v", err) + } + + // Create a new log file with timestamp + timestamp := time.Now().Format("2006-01-02") + logPath := filepath.Join(basePath, fmt.Sprintf("audit-%s.log", timestamp)) + logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return nil, fmt.Errorf("failed to create audit log file: %v", err) + } + + // Get initial file size + info, err := logFile.Stat() + if err != nil { + logFile.Close() + return nil, fmt.Errorf("failed to get log file info: %v", err) + } + + return &AuditLogger{ + logFile: logFile, + basePath: basePath, + size: info.Size(), + }, nil +} + +// LogEvent logs a security audit event +func (al *AuditLogger) LogEvent(ctx context.Context, event *AuditEvent) error { + al.mu.Lock() + defer al.mu.Unlock() + + // Ensure timestamp is set + if event.Timestamp.IsZero() { + event.Timestamp = time.Now() + } + + // Marshal event to JSON + data, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("failed to marshal audit event: %v", err) + } + + // Add newline for log file + data = append(data, '\n') + + // Check if we need to rotate the log + if al.size+int64(len(data)) > maxLogSize { + if err := al.rotateLog(); err != nil { + return fmt.Errorf("failed to rotate log: %v", err) + } + } + + // Write to log file + n, err := al.logFile.Write(data) + if err != nil { + return fmt.Errorf("failed to write audit event: %v", err) + } + + // Update size + al.size += int64(n) + + // Also log to standard logger for monitoring + log.L(ctx).Infof("AUDIT: %s - %s - %s - %s - %s", + event.EventType, + event.UserID, + event.Action, + event.Resource, + event.Status) + + return nil +} + +// Close closes the audit logger +func (al *AuditLogger) Close() error { + al.mu.Lock() + defer al.mu.Unlock() + + if al.logFile != nil { + return al.logFile.Close() + } + return nil +} + +// RotateLog rotates the audit log file +func (al *AuditLogger) rotateLog() error { + // Close current file + if al.logFile != nil { + al.logFile.Close() + } + + // Create new file with current timestamp + timestamp := time.Now().Format("2006-01-02") + logPath := filepath.Join(al.basePath, fmt.Sprintf("audit-%s.log", timestamp)) + logFile, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) + if err != nil { + return fmt.Errorf("failed to create new audit log file: %v", err) + } + + al.logFile = logFile + al.size = 0 + + // Clean up old log files + if err := al.cleanupOldLogs(); err != nil { + return fmt.Errorf("failed to cleanup old logs: %v", err) + } + + return nil +} + +// cleanupOldLogs removes old log files +func (al *AuditLogger) cleanupOldLogs() error { + entries, err := os.ReadDir(al.basePath) + if err != nil { + return err + } + + now := time.Now() + for _, entry := range entries { + if entry.IsDir() { + continue + } + + // Check if file is an audit log + if !strings.HasPrefix(entry.Name(), "audit-") || !strings.HasSuffix(entry.Name(), ".log") { + continue + } + + // Parse timestamp from filename + timestamp, err := time.Parse("2006-01-02", strings.TrimSuffix(strings.TrimPrefix(entry.Name(), "audit-"), ".log")) + if err != nil { + continue + } + + // Check if file is too old + if now.Sub(timestamp) > maxLogAge { + path := filepath.Join(al.basePath, entry.Name()) + if err := os.Remove(path); err != nil { + return fmt.Errorf("failed to remove old log file %s: %v", path, err) + } + } + } + + return nil +} \ No newline at end of file diff --git a/internal/auth/apikey.go b/internal/auth/apikey.go new file mode 100644 index 000000000..fdefb77b1 --- /dev/null +++ b/internal/auth/apikey.go @@ -0,0 +1,178 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "sync" + "time" + + "github.com/hyperledger/firefly-common/pkg/i18n" +) + +// APIKey represents an API key with metadata +type APIKey struct { + Key string `json:"key"` + UserID string `json:"userId"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` + LastUsed time.Time `json:"lastUsed"` + Rotated bool `json:"rotated"` + Active bool `json:"active"` +} + +// APIKeyManager handles API key generation, rotation, and validation +type APIKeyManager struct { + mu sync.RWMutex + keys map[string]*APIKey + rotation map[string]string // maps old keys to new keys +} + +// NewAPIKeyManager creates a new API key manager +func NewAPIKeyManager() *APIKeyManager { + return &APIKeyManager{ + keys: make(map[string]*APIKey), + rotation: make(map[string]string), + } +} + +// GenerateKey generates a new API key +func (m *APIKeyManager) GenerateKey(ctx context.Context, userID string, validityDays int) (*APIKey, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Generate random key + keyBytes := make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + return nil, i18n.WrapError(ctx, err, i18n.MsgFailedToGenerateKey) + } + key := base64.URLEncoding.EncodeToString(keyBytes) + + // Create API key + now := time.Now() + apiKey := &APIKey{ + Key: key, + UserID: userID, + Created: now, + Expires: now.AddDate(0, 0, validityDays), + LastUsed: now, + Active: true, + } + + // Store key + m.keys[key] = apiKey + + return apiKey, nil +} + +// ValidateKey validates an API key +func (m *APIKeyManager) ValidateKey(ctx context.Context, key string) (*APIKey, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + apiKey, exists := m.keys[key] + if !exists { + return nil, i18n.NewError(ctx, i18n.MsgInvalidAPIKey) + } + + // Check if key is active + if !apiKey.Active { + return nil, i18n.NewError(ctx, i18n.MsgInactiveAPIKey) + } + + // Check if key is expired + if time.Now().After(apiKey.Expires) { + return nil, i18n.NewError(ctx, i18n.MsgExpiredAPIKey) + } + + // Update last used time + apiKey.LastUsed = time.Now() + + return apiKey, nil +} + +// RotateKey rotates an API key +func (m *APIKeyManager) RotateKey(ctx context.Context, oldKey string, validityDays int) (*APIKey, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Validate old key + oldAPIKey, exists := m.keys[oldKey] + if !exists { + return nil, i18n.NewError(ctx, i18n.MsgInvalidAPIKey) + } + + // Generate new key + newKeyBytes := make([]byte, 32) + if _, err := rand.Read(newKeyBytes); err != nil { + return nil, i18n.WrapError(ctx, err, i18n.MsgFailedToGenerateKey) + } + newKey := base64.URLEncoding.EncodeToString(newKeyBytes) + + // Create new API key + now := time.Now() + newAPIKey := &APIKey{ + Key: newKey, + UserID: oldAPIKey.UserID, + Created: now, + Expires: now.AddDate(0, 0, validityDays), + LastUsed: now, + Active: true, + } + + // Mark old key as rotated + oldAPIKey.Rotated = true + oldAPIKey.Active = false + + // Store new key and rotation mapping + m.keys[newKey] = newAPIKey + m.rotation[oldKey] = newKey + + return newAPIKey, nil +} + +// RevokeKey revokes an API key +func (m *APIKeyManager) RevokeKey(ctx context.Context, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + + apiKey, exists := m.keys[key] + if !exists { + return i18n.NewError(ctx, i18n.MsgInvalidAPIKey) + } + + apiKey.Active = false + return nil +} + +// GetUserKeys returns all API keys for a user +func (m *APIKeyManager) GetUserKeys(ctx context.Context, userID string) ([]*APIKey, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var userKeys []*APIKey + for _, key := range m.keys { + if key.UserID == userID { + userKeys = append(userKeys, key) + } + } + + return userKeys, nil +} + +// CleanupExpiredKeys removes expired keys +func (m *APIKeyManager) CleanupExpiredKeys(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + now := time.Now() + for key, apiKey := range m.keys { + if now.After(apiKey.Expires) { + delete(m.keys, key) + delete(m.rotation, key) + } + } + + return nil +} \ No newline at end of file diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 000000000..afa660bcd --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,161 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "golang.org/x/crypto/bcrypt" + "strings" + + "github.com/hyperledger/firefly-common/pkg/i18n" +) + +const ( + // PasswordMinLength is the minimum length for passwords + PasswordMinLength = 8 + // PasswordMaxLength is the maximum length for passwords + PasswordMaxLength = 72 + // DefaultCost is the default bcrypt cost + DefaultCost = 12 +) + +// PasswordManager handles password hashing and validation +type PasswordManager struct { + cost int +} + +// NewPasswordManager creates a new password manager +func NewPasswordManager(cost int) *PasswordManager { + if cost < bcrypt.MinCost || cost > bcrypt.MaxCost { + cost = DefaultCost + } + return &PasswordManager{ + cost: cost, + } +} + +// HashPassword hashes a password using bcrypt +func (m *PasswordManager) HashPassword(ctx context.Context, password string) (string, error) { + if err := m.validatePassword(ctx, password); err != nil { + return "", err + } + + // Generate a random salt + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", i18n.WrapError(ctx, err, i18n.MsgFailedToGenerateSalt) + } + + // Hash the password with the salt + hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), m.cost) + if err != nil { + return "", i18n.WrapError(ctx, err, i18n.MsgFailedToHashPassword) + } + + return string(hashedBytes), nil +} + +// ComparePasswords compares a password with its hash +func (m *PasswordManager) ComparePasswords(ctx context.Context, password, hash string) error { + if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil { + return i18n.NewError(ctx, i18n.MsgInvalidPassword) + } + return nil +} + +// ValidatePassword validates a password against security requirements +func (m *PasswordManager) ValidatePassword(ctx context.Context, password string) error { + // Check length + if len(password) < PasswordMinLength { + return i18n.NewError(ctx, i18n.MsgPasswordTooShort, PasswordMinLength) + } + if len(password) > PasswordMaxLength { + return i18n.NewError(ctx, i18n.MsgPasswordTooLong, PasswordMaxLength) + } + + // Check for common passwords + if m.isCommonPassword(password) { + return i18n.NewError(ctx, i18n.MsgCommonPassword) + } + + // Check for character types + var hasUpper, hasLower, hasNumber, hasSpecial bool + for _, char := range password { + switch { + case char >= 'A' && char <= 'Z': + hasUpper = true + case char >= 'a' && char <= 'z': + hasLower = true + case char >= '0' && char <= '9': + hasNumber = true + case strings.ContainsRune("!@#$%^&*()_+-=[]{}|;:,.<>?", char): + hasSpecial = true + } + } + + if !hasUpper || !hasLower || !hasNumber || !hasSpecial { + return i18n.NewError(ctx, i18n.MsgPasswordRequirements) + } + + return nil +} + +// GenerateSecurePassword generates a secure random password +func (m *PasswordManager) GenerateSecurePassword(ctx context.Context) (string, error) { + // Define character sets + const ( + upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + lowerChars = "abcdefghijklmnopqrstuvwxyz" + numberChars = "0123456789" + specialChars = "!@#$%^&*()_+-=[]{}|;:,.<>?" + ) + + // Generate random bytes for each character type + password := make([]byte, PasswordMinLength) + + // Ensure at least one of each character type + password[0] = upperChars[randInt(len(upperChars))] + password[1] = lowerChars[randInt(len(lowerChars))] + password[2] = numberChars[randInt(len(numberChars))] + password[3] = specialChars[randInt(len(specialChars))] + + // Fill the rest with random characters from all sets + allChars := upperChars + lowerChars + numberChars + specialChars + for i := 4; i < PasswordMinLength; i++ { + password[i] = allChars[randInt(len(allChars))] + } + + // Shuffle the password + for i := len(password) - 1; i > 0; i-- { + j := randInt(i + 1) + password[i], password[j] = password[j], password[i] + } + + return string(password), nil +} + +// isCommonPassword checks if a password is in a list of common passwords +func (m *PasswordManager) isCommonPassword(password string) bool { + commonPasswords := []string{ + "password", "123456", "12345678", "qwerty", "abc123", + "monkey", "letmein", "dragon", "111111", "baseball", + "iloveyou", "trustno1", "sunshine", "master", "welcome", + "shadow", "ashley", "football", "jesus", "michael", + "ninja", "mustang", "password1", "123456789", "password123", + } + + for _, common := range commonPasswords { + if strings.ToLower(password) == common { + return true + } + } + return false +} + +// randInt generates a random integer between 0 and max-1 +func randInt(max int) int { + b := make([]byte, 8) + rand.Read(b) + return int(b[0]) % max +} \ No newline at end of file diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 000000000..5b7012ee4 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,178 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "sync" + "time" + + "github.com/hyperledger/firefly-common/pkg/i18n" +) + +// Session represents a user session +type Session struct { + ID string `json:"id"` + UserID string `json:"userId"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` + LastUsed time.Time `json:"lastUsed"` + IPAddress string `json:"ipAddress"` + UserAgent string `json:"userAgent"` + Active bool `json:"active"` +} + +// SessionManager handles session creation, validation, and cleanup +type SessionManager struct { + mu sync.RWMutex + sessions map[string]*Session +} + +// NewSessionManager creates a new session manager +func NewSessionManager() *SessionManager { + return &SessionManager{ + sessions: make(map[string]*Session), + } +} + +// CreateSession creates a new session for a user +func (m *SessionManager) CreateSession(ctx context.Context, userID string, ipAddress string, userAgent string, validityHours int) (*Session, error) { + m.mu.Lock() + defer m.mu.Unlock() + + // Generate session ID + sessionBytes := make([]byte, 32) + if _, err := rand.Read(sessionBytes); err != nil { + return nil, i18n.WrapError(ctx, err, i18n.MsgFailedToGenerateSession) + } + sessionID := base64.URLEncoding.EncodeToString(sessionBytes) + + // Create session + now := time.Now() + session := &Session{ + ID: sessionID, + UserID: userID, + Created: now, + Expires: now.Add(time.Duration(validityHours) * time.Hour), + LastUsed: now, + IPAddress: ipAddress, + UserAgent: userAgent, + Active: true, + } + + // Store session + m.sessions[sessionID] = session + + return session, nil +} + +// ValidateSession validates a session +func (m *SessionManager) ValidateSession(ctx context.Context, sessionID string) (*Session, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + session, exists := m.sessions[sessionID] + if !exists { + return nil, i18n.NewError(ctx, i18n.MsgInvalidSession) + } + + // Check if session is active + if !session.Active { + return nil, i18n.NewError(ctx, i18n.MsgInactiveSession) + } + + // Check if session is expired + if time.Now().After(session.Expires) { + return nil, i18n.NewError(ctx, i18n.MsgExpiredSession) + } + + // Update last used time + session.LastUsed = time.Now() + + return session, nil +} + +// RevokeSession revokes a session +func (m *SessionManager) RevokeSession(ctx context.Context, sessionID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + session, exists := m.sessions[sessionID] + if !exists { + return i18n.NewError(ctx, i18n.MsgInvalidSession) + } + + session.Active = false + return nil +} + +// RevokeUserSessions revokes all sessions for a user +func (m *SessionManager) RevokeUserSessions(ctx context.Context, userID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + for _, session := range m.sessions { + if session.UserID == userID { + session.Active = false + } + } + + return nil +} + +// GetUserSessions returns all active sessions for a user +func (m *SessionManager) GetUserSessions(ctx context.Context, userID string) ([]*Session, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + var userSessions []*Session + for _, session := range m.sessions { + if session.UserID == userID && session.Active { + userSessions = append(userSessions, session) + } + } + + return userSessions, nil +} + +// CleanupExpiredSessions removes expired sessions +func (m *SessionManager) CleanupExpiredSessions(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + + now := time.Now() + for sessionID, session := range m.sessions { + if now.After(session.Expires) { + delete(m.sessions, sessionID) + } + } + + return nil +} + +// GetSessionByID returns a session by its ID +func (m *SessionManager) GetSessionByID(ctx context.Context, sessionID string) (*Session, error) { + m.mu.RLock() + defer m.mu.RUnlock() + + session, exists := m.sessions[sessionID] + if !exists { + return nil, i18n.NewError(ctx, i18n.MsgInvalidSession) + } + + return session, nil +} + +// UpdateSessionIP updates the IP address of a session +func (m *SessionManager) UpdateSessionIP(ctx context.Context, sessionID string, newIP string) error { + m.mu.Lock() + defer m.mu.Unlock() + + session, exists := m.sessions[sessionID] + if !exists { + return i18n.NewError(ctx, i18n.MsgInvalidSession) + } + + session.IPAddress = newIP + return nil +} \ No newline at end of file diff --git a/internal/coreconfig/secrets.go b/internal/coreconfig/secrets.go new file mode 100644 index 000000000..5be83d96c --- /dev/null +++ b/internal/coreconfig/secrets.go @@ -0,0 +1,150 @@ +package coreconfig + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/hyperledger/firefly-common/pkg/i18n" +) + +// SecretsManager handles secure storage and retrieval of sensitive data +type SecretsManager struct { + mu sync.RWMutex + secrets map[string][]byte + basePath string + key []byte +} + +// NewSecretsManager creates a new secrets manager +func NewSecretsManager(basePath string, key []byte) (*SecretsManager, error) { + if len(key) != 32 { + return nil, fmt.Errorf("encryption key must be 32 bytes") + } + if err := os.MkdirAll(basePath, 0700); err != nil { + return nil, fmt.Errorf("failed to create secrets directory: %v", err) + } + return &SecretsManager{ + secrets: make(map[string][]byte), + basePath: basePath, + key: key, + }, nil +} + +// StoreSecret securely stores a secret +func (sm *SecretsManager) StoreSecret(ctx context.Context, key string, value []byte) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Encrypt the secret before storing + encrypted, err := sm.encrypt(value) + if err != nil { + return i18n.WrapError(ctx, err, i18n.MsgFailedToEncryptSecret) + } + + // Store in memory + sm.secrets[key] = encrypted + + // Store on disk + path := filepath.Join(sm.basePath, key) + if err := os.WriteFile(path, encrypted, 0600); err != nil { + return i18n.WrapError(ctx, err, i18n.MsgFailedToStoreSecret) + } + + return nil +} + +// GetSecret retrieves a secret +func (sm *SecretsManager) GetSecret(ctx context.Context, key string) ([]byte, error) { + sm.mu.RLock() + defer sm.mu.RUnlock() + + // Try memory first + if secret, exists := sm.secrets[key]; exists { + return sm.decrypt(secret) + } + + // Try disk + path := filepath.Join(sm.basePath, key) + encrypted, err := os.ReadFile(path) + if err != nil { + return nil, i18n.WrapError(ctx, err, i18n.MsgFailedToReadSecret) + } + + // Store in memory for future use + sm.secrets[key] = encrypted + + return sm.decrypt(encrypted) +} + +// DeleteSecret removes a secret +func (sm *SecretsManager) DeleteSecret(ctx context.Context, key string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + // Remove from memory + delete(sm.secrets, key) + + // Remove from disk + path := filepath.Join(sm.basePath, key) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return i18n.WrapError(ctx, err, i18n.MsgFailedToDeleteSecret) + } + + return nil +} + +// encrypt encrypts data using AES-GCM +func (sm *SecretsManager) encrypt(data []byte) ([]byte, error) { + block, err := aes.NewCipher(sm.key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + + // Seal the data with the nonce + ciphertext := gcm.Seal(nonce, nonce, data, nil) + return ciphertext, nil +} + +// decrypt decrypts data using AES-GCM +func (sm *SecretsManager) decrypt(data []byte) ([]byte, error) { + block, err := aes.NewCipher(sm.key) + if err != nil { + return nil, err + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + + if len(data) < gcm.NonceSize() { + return nil, fmt.Errorf("ciphertext too short") + } + + nonce := data[:gcm.NonceSize()] + ciphertext := data[gcm.NonceSize():] + + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + + return plaintext, nil +} \ No newline at end of file diff --git a/internal/fftls/tls.go b/internal/fftls/tls.go new file mode 100644 index 000000000..eb0c5baf3 --- /dev/null +++ b/internal/fftls/tls.go @@ -0,0 +1,167 @@ +package fftls + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "os" + "path/filepath" + + "github.com/hyperledger/firefly-common/pkg/config" + "github.com/hyperledger/firefly-common/pkg/i18n" +) + +// InitTLSConfig initializes TLS configuration with secure defaults +func InitTLSConfig(conf config.Section) { + conf.AddKnownKey(ConfigTLSEnabled, true) + conf.AddKnownKey(ConfigTLSClientAuth, "require") + conf.AddKnownKey(ConfigTLSMinVersion, "1.2") + conf.AddKnownKey(ConfigTLSMaxVersion, "1.3") + conf.AddKnownKey(ConfigTLSCertFile) + conf.AddKnownKey(ConfigTLSKeyFile) + conf.AddKnownKey(ConfigTLSCAFile) + conf.AddKnownKey(ConfigTLSInsecureSkipVerify, false) + conf.AddKnownKey(ConfigTLSRenegotiation, false) + conf.AddKnownKey(ConfigTLSVerifyHostname, true) + conf.AddKnownKey(ConfigTLSVerifyPeerCertificate, true) +} + +// ConfigTLS creates a TLS configuration with secure defaults +func ConfigTLS(conf config.Section) (*tls.Config, error) { + if !conf.GetBool(ConfigTLSEnabled) { + return nil, nil + } + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + MaxVersion: tls.VersionTLS13, + CipherSuites: []uint16{ + tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, + tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, + }, + PreferServerCipherSuites: true, + InsecureSkipVerify: conf.GetBool(ConfigTLSInsecureSkipVerify), + Renegotiation: tls.RenegotiateNever, + VerifyConnection: func(cs tls.ConnectionState) error { + if conf.GetBool(ConfigTLSVerifyPeerCertificate) { + opts := x509.VerifyOptions{ + DNSName: cs.ServerName, + Intermediates: x509.NewCertPool(), + } + for _, cert := range cs.PeerCertificates[1:] { + opts.Intermediates.AddCert(cert) + } + _, err := cs.PeerCertificates[0].Verify(opts) + return err + } + return nil + }, + } + + // Load certificate and key + certFile := conf.GetString(ConfigTLSCertFile) + keyFile := conf.GetString(ConfigTLSKeyFile) + if certFile != "" && keyFile != "" { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, i18n.WrapError(nil, err, i18n.MsgFailedToLoadTLSKeyPair) + } + tlsConfig.Certificates = []tls.Certificate{cert} + } + + // Load CA certificate + caFile := conf.GetString(ConfigTLSCAFile) + if caFile != "" { + caCert, err := os.ReadFile(caFile) + if err != nil { + return nil, i18n.WrapError(nil, err, i18n.MsgFailedToLoadTLSCACert) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, i18n.NewError(nil, i18n.MsgFailedToAppendTLSCACert) + } + tlsConfig.RootCAs = caCertPool + } + + // Configure client authentication + switch conf.GetString(ConfigTLSClientAuth) { + case "require": + tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert + case "verify-if-given": + tlsConfig.ClientAuth = tls.VerifyClientCertIfGiven + case "none": + tlsConfig.ClientAuth = tls.NoClientCert + default: + return nil, i18n.NewError(nil, i18n.MsgInvalidTLSClientAuth) + } + + // Set minimum TLS version + switch conf.GetString(ConfigTLSMinVersion) { + case "1.2": + tlsConfig.MinVersion = tls.VersionTLS12 + case "1.3": + tlsConfig.MinVersion = tls.VersionTLS13 + default: + return nil, i18n.NewError(nil, i18n.MsgInvalidTLSMinVersion) + } + + // Set maximum TLS version + switch conf.GetString(ConfigTLSMaxVersion) { + case "1.2": + tlsConfig.MaxVersion = tls.VersionTLS12 + case "1.3": + tlsConfig.MaxVersion = tls.VersionTLS13 + default: + return nil, i18n.NewError(nil, i18n.MsgInvalidTLSMaxVersion) + } + + return tlsConfig, nil +} + +// ValidateTLSConfig validates TLS configuration +func ValidateTLSConfig(conf config.Section) error { + if !conf.GetBool(ConfigTLSEnabled) { + return nil + } + + // Check certificate files + certFile := conf.GetString(ConfigTLSCertFile) + keyFile := conf.GetString(ConfigTLSKeyFile) + if certFile != "" || keyFile != "" { + if certFile == "" || keyFile == "" { + return i18n.NewError(nil, i18n.MsgTLSFilesRequired) + } + if _, err := os.Stat(certFile); err != nil { + return i18n.WrapError(nil, err, i18n.MsgTLSCertFileNotFound) + } + if _, err := os.Stat(keyFile); err != nil { + return i18n.WrapError(nil, err, i18n.MsgTLSKeyFileNotFound) + } + + // Check certificate permissions + if info, err := os.Stat(certFile); err == nil { + if info.Mode().Perm()&0077 != 0 { + return i18n.NewError(nil, i18n.MsgTLSCertFilePermissions) + } + } + if info, err := os.Stat(keyFile); err == nil { + if info.Mode().Perm()&0077 != 0 { + return i18n.NewError(nil, i18n.MsgTLSKeyFilePermissions) + } + } + } + + // Check CA file + caFile := conf.GetString(ConfigTLSCAFile) + if caFile != "" { + if _, err := os.Stat(caFile); err != nil { + return i18n.WrapError(nil, err, i18n.MsgTLSCAFileNotFound) + } + } + + return nil +} \ No newline at end of file diff --git a/internal/rbac/rbac.go b/internal/rbac/rbac.go new file mode 100644 index 000000000..adfdba6dd --- /dev/null +++ b/internal/rbac/rbac.go @@ -0,0 +1,272 @@ +package rbac + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/hyperledger/firefly-common/pkg/i18n" +) + +// Permission represents a specific permission +type Permission string + +// Role represents a user role +type Role string + +// Resource represents a resource type +type Resource string + +const ( + // Permissions + PermissionRead Permission = "read" + PermissionWrite Permission = "write" + PermissionDelete Permission = "delete" + PermissionAdmin Permission = "admin" + + // Roles + RoleAdmin Role = "admin" + RoleUser Role = "user" + RoleViewer Role = "viewer" + RoleOperator Role = "operator" + + // Resources + ResourceMessages Resource = "messages" + ResourceData Resource = "data" + ResourceContracts Resource = "contracts" + ResourceIdentities Resource = "identities" + ResourceTokens Resource = "tokens" + ResourceEvents Resource = "events" + ResourceNamespaces Resource = "namespaces" + + // Maximum failed attempts before temporary lockout + maxFailedAttempts = 5 + // Lockout duration + lockoutDuration = 15 * time.Minute +) + +// UserRole represents a user's role with additional metadata +type UserRole struct { + Role Role + AssignedAt time.Time + AssignedBy string + LastAccess time.Time + FailedAttempts int + LockoutUntil time.Time + IsLocked bool +} + +// RBACManager handles role-based access control +type RBACManager struct { + mu sync.RWMutex + roles map[Role][]Permission + userRole map[string]*UserRole +} + +// NewRBACManager creates a new RBAC manager +func NewRBACManager() *RBACManager { + rbac := &RBACManager{ + roles: make(map[Role][]Permission), + userRole: make(map[string]*UserRole), + } + + // Initialize default roles and permissions + rbac.roles[RoleAdmin] = []Permission{ + PermissionRead, + PermissionWrite, + PermissionDelete, + PermissionAdmin, + } + + rbac.roles[RoleUser] = []Permission{ + PermissionRead, + PermissionWrite, + } + + rbac.roles[RoleViewer] = []Permission{ + PermissionRead, + } + + rbac.roles[RoleOperator] = []Permission{ + PermissionRead, + PermissionWrite, + } + + return rbac +} + +// AssignRole assigns a role to a user +func (rbac *RBACManager) AssignRole(userID string, role Role, assignedBy string) error { + rbac.mu.Lock() + defer rbac.mu.Unlock() + + if _, exists := rbac.roles[role]; !exists { + return i18n.NewError(context.Background(), i18n.MsgInvalidRole) + } + + now := time.Now() + rbac.userRole[userID] = &UserRole{ + Role: role, + AssignedAt: now, + AssignedBy: assignedBy, + LastAccess: now, + } + + return nil +} + +// GetRole returns the role assigned to a user +func (rbac *RBACManager) GetRole(userID string) (Role, error) { + rbac.mu.RLock() + defer rbac.mu.RUnlock() + + userRole, exists := rbac.userRole[userID] + if !exists { + return "", i18n.NewError(context.Background(), i18n.MsgUserNotFound) + } + + return userRole.Role, nil +} + +// CheckPermission checks if a user has permission to perform an action on a resource +func (rbac *RBACManager) CheckPermission(ctx context.Context, userID string, permission Permission, resource Resource) error { + rbac.mu.Lock() + defer rbac.mu.Unlock() + + userRole, exists := rbac.userRole[userID] + if !exists { + return i18n.NewError(ctx, i18n.MsgUserNotFound) + } + + // Check if user is locked out + if userRole.IsLocked { + if time.Now().Before(userRole.LockoutUntil) { + return i18n.NewError(ctx, i18n.MsgUserLocked, userRole.LockoutUntil.Sub(time.Now())) + } + // Reset lockout if duration has passed + userRole.IsLocked = false + userRole.FailedAttempts = 0 + } + + permissions, exists := rbac.roles[userRole.Role] + if !exists { + return i18n.NewError(ctx, i18n.MsgInvalidRole) + } + + // Admin role has all permissions + if userRole.Role == RoleAdmin { + userRole.LastAccess = time.Now() + return nil + } + + // Check if user has the required permission + for _, p := range permissions { + if p == permission { + userRole.LastAccess = time.Now() + return nil + } + } + + // Increment failed attempts + userRole.FailedAttempts++ + if userRole.FailedAttempts >= maxFailedAttempts { + userRole.IsLocked = true + userRole.LockoutUntil = time.Now().Add(lockoutDuration) + return i18n.NewError(ctx, i18n.MsgUserLocked, lockoutDuration) + } + + return i18n.NewError(ctx, i18n.MsgPermissionDenied) +} + +// AddRole adds a new role with specified permissions +func (rbac *RBACManager) AddRole(role Role, permissions []Permission) error { + rbac.mu.Lock() + defer rbac.mu.Unlock() + + if _, exists := rbac.roles[role]; exists { + return i18n.NewError(context.Background(), i18n.MsgRoleExists) + } + + rbac.roles[role] = permissions + return nil +} + +// UpdateRole updates permissions for an existing role +func (rbac *RBACManager) UpdateRole(role Role, permissions []Permission) error { + rbac.mu.Lock() + defer rbac.mu.Unlock() + + if _, exists := rbac.roles[role]; !exists { + return i18n.NewError(context.Background(), i18n.MsgRoleNotFound) + } + + rbac.roles[role] = permissions + return nil +} + +// RemoveRole removes a role +func (rbac *RBACManager) RemoveRole(role Role) error { + rbac.mu.Lock() + defer rbac.mu.Unlock() + + if _, exists := rbac.roles[role]; !exists { + return i18n.NewError(context.Background(), i18n.MsgRoleNotFound) + } + + // Check if any users have this role + for _, userRole := range rbac.userRole { + if userRole.Role == role { + return i18n.NewError(context.Background(), i18n.MsgRoleInUse) + } + } + + delete(rbac.roles, role) + return nil +} + +// GetUserPermissions returns all permissions for a user +func (rbac *RBACManager) GetUserPermissions(userID string) ([]Permission, error) { + rbac.mu.RLock() + defer rbac.mu.RUnlock() + + userRole, exists := rbac.userRole[userID] + if !exists { + return nil, i18n.NewError(context.Background(), i18n.MsgUserNotFound) + } + + permissions, exists := rbac.roles[userRole.Role] + if !exists { + return nil, i18n.NewError(context.Background(), i18n.MsgInvalidRole) + } + + return permissions, nil +} + +// ResetFailedAttempts resets the failed attempts counter for a user +func (rbac *RBACManager) ResetFailedAttempts(userID string) error { + rbac.mu.Lock() + defer rbac.mu.Unlock() + + userRole, exists := rbac.userRole[userID] + if !exists { + return i18n.NewError(context.Background(), i18n.MsgUserNotFound) + } + + userRole.FailedAttempts = 0 + userRole.IsLocked = false + return nil +} + +// GetUserRoleInfo returns detailed information about a user's role +func (rbac *RBACManager) GetUserRoleInfo(userID string) (*UserRole, error) { + rbac.mu.RLock() + defer rbac.mu.RUnlock() + + userRole, exists := rbac.userRole[userID] + if !exists { + return nil, i18n.NewError(context.Background(), i18n.MsgUserNotFound) + } + + return userRole, nil +} \ No newline at end of file diff --git a/pkg/core/message.go b/pkg/core/message.go index d878163e3..e1ad8b74e 100644 --- a/pkg/core/message.go +++ b/pkg/core/message.go @@ -235,22 +235,84 @@ func (m *Message) DupDataCheck(ctx context.Context) (err error) { } func (m *Message) VerifyFields(ctx context.Context) error { - switch m.Header.TxType { - case TransactionTypeBatchPin, - TransactionTypeUnpinned, - TransactionTypeContractInvokePin: - default: - return i18n.NewError(ctx, i18n.MsgInvalidTXTypeForMessage, m.Header.TxType) + // Validate topics + if len(m.Header.Topics) == 0 { + return i18n.NewError(ctx, i18n.MsgEmptyTopics) } - if err := m.Header.Topics.Validate(ctx, "header.topics", true, 10 /* Pins need 96 chars each*/); err != nil { - return err + for i, topic := range m.Header.Topics { + if topic == "" { + return i18n.NewError(ctx, i18n.MsgEmptyTopic, i) + } + // Add security check for topic length and characters + if len(topic) > 256 { + return i18n.NewError(ctx, i18n.MsgTopicTooLong, i) + } + if !isValidTopicString(topic) { + return i18n.NewError(ctx, i18n.MsgInvalidTopicChars, i) + } } + + // Validate tag if m.Header.Tag != "" { - if err := fftypes.ValidateFFNameField(ctx, m.Header.Tag, "header.tag"); err != nil { - return err + if !isValidTagString(m.Header.Tag) { + return i18n.NewError(ctx, i18n.MsgInvalidTagChars) + } + if len(m.Header.Tag) > 64 { + return i18n.NewError(ctx, i18n.MsgTagTooLong) + } + } + + // Validate author + if m.Header.Author == "" { + return i18n.NewError(ctx, i18n.MsgMissingAuthor) + } + if len(m.Header.Author) > 256 { + return i18n.NewError(ctx, i18n.MsgAuthorTooLong) + } + + // Validate data references + if err := m.DupDataCheck(ctx); err != nil { + return err + } + + return nil +} + +// isValidTopicString checks if a topic string contains only valid characters +func isValidTopicString(s string) bool { + for _, r := range s { + if !isValidTopicChar(r) { + return false + } + } + return true +} + +// isValidTopicChar checks if a rune is valid for a topic string +func isValidTopicChar(r rune) bool { + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' || r == '.' || + r == '/' || r == ':' || r == '@' +} + +// isValidTagString checks if a tag string contains only valid characters +func isValidTagString(s string) bool { + for _, r := range s { + if !isValidTagChar(r) { + return false } } - return m.DupDataCheck(ctx) + return true +} + +// isValidTagChar checks if a rune is valid for a tag string +func isValidTagChar(r rune) bool { + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' } func (m *Message) Verify(ctx context.Context) error {