package config import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "fmt" "os" "path/filepath" ) const ( // integrityKeyFile is the filename for the integrity key integrityKeyFile = ".config_key" // integrityKeyLength is the length of the HMAC key in bytes integrityKeyLength = 32 // hmacField is the JSON field name for the HMAC signature hmacField = "_integrity" ) // ConfigWithIntegrity wraps a Config with an integrity signature type ConfigWithIntegrity struct { Config Integrity string `json:"_integrity,omitempty"` } // IntegrityService provides HMAC-based integrity verification for config files type IntegrityService struct { keyPath string key []byte } // NewIntegrityService creates a new integrity service for the given app func NewIntegrityService(appName string) (*IntegrityService, error) { configDir, err := os.UserConfigDir() if err != nil { return nil, fmt.Errorf("failed to get config directory: %w", err) } appConfigDir := filepath.Join(configDir, appName) keyPath := filepath.Join(appConfigDir, integrityKeyFile) svc := &IntegrityService{ keyPath: keyPath, } // Load or generate key if err := svc.loadOrGenerateKey(); err != nil { return nil, fmt.Errorf("failed to initialize integrity key: %w", err) } return svc, nil } // loadOrGenerateKey loads the HMAC key from file or generates a new one func (s *IntegrityService) loadOrGenerateKey() error { // Try to load existing key data, err := os.ReadFile(s.keyPath) if err == nil { // Key exists, decode it s.key, err = base64.StdEncoding.DecodeString(string(data)) if err != nil || len(s.key) != integrityKeyLength { // Invalid key, regenerate return s.generateNewKey() } return nil } if !os.IsNotExist(err) { return fmt.Errorf("failed to read integrity key: %w", err) } // Key doesn't exist, generate new one return s.generateNewKey() } // generateNewKey generates a new HMAC key and saves it func (s *IntegrityService) generateNewKey() error { s.key = make([]byte, integrityKeyLength) if _, err := rand.Read(s.key); err != nil { return fmt.Errorf("failed to generate key: %w", err) } // Ensure directory exists with restrictive permissions if err := os.MkdirAll(filepath.Dir(s.keyPath), 0700); err != nil { return fmt.Errorf("failed to create key directory: %w", err) } // Save key with restrictive permissions (owner read only) encoded := base64.StdEncoding.EncodeToString(s.key) if err := os.WriteFile(s.keyPath, []byte(encoded), 0400); err != nil { return fmt.Errorf("failed to save integrity key: %w", err) } return nil } // ComputeHMAC computes the HMAC signature for config data func (s *IntegrityService) ComputeHMAC(config *Config) (string, error) { // Serialize config without the integrity field data, err := json.Marshal(config) if err != nil { return "", fmt.Errorf("failed to serialize config: %w", err) } // Compute HMAC-SHA256 h := hmac.New(sha256.New, s.key) h.Write(data) signature := h.Sum(nil) return base64.StdEncoding.EncodeToString(signature), nil } // VerifyHMAC verifies the HMAC signature of config data func (s *IntegrityService) VerifyHMAC(config *Config, providedHMAC string) error { // Compute expected HMAC expectedHMAC, err := s.ComputeHMAC(config) if err != nil { return fmt.Errorf("failed to compute HMAC: %w", err) } // Decode provided HMAC providedBytes, err := base64.StdEncoding.DecodeString(providedHMAC) if err != nil { return errors.New("invalid HMAC format") } expectedBytes, err := base64.StdEncoding.DecodeString(expectedHMAC) if err != nil { return errors.New("internal error computing HMAC") } // Constant-time comparison to prevent timing attacks if !hmac.Equal(providedBytes, expectedBytes) { return errors.New("config integrity check failed: file may have been tampered with") } return nil } // SignConfig adds an HMAC signature to the config func (s *IntegrityService) SignConfig(config *Config) (*ConfigWithIntegrity, error) { signature, err := s.ComputeHMAC(config) if err != nil { return nil, err } return &ConfigWithIntegrity{ Config: *config, Integrity: signature, }, nil } // VerifyAndExtractConfig verifies the integrity and returns the config func (s *IntegrityService) VerifyAndExtractConfig(configWithInt *ConfigWithIntegrity) (*Config, error) { if configWithInt.Integrity == "" { // No integrity field - config was created before integrity checking was added // Allow it but log a warning (caller should handle this) return &configWithInt.Config, nil } // Verify the HMAC if err := s.VerifyHMAC(&configWithInt.Config, configWithInt.Integrity); err != nil { return nil, err } return &configWithInt.Config, nil } // integrityAwareRepository wraps a repository with integrity checking type integrityAwareRepository struct { inner repository integritySvc *IntegrityService configPath string warnOnMissingMAC bool // If true, allows configs without MAC (for migration) } // NewIntegrityAwareRepository creates a repository wrapper with integrity checking func NewIntegrityAwareRepository(inner repository, appName string, configPath string) (repository, error) { integritySvc, err := NewIntegrityService(appName) if err != nil { return nil, err } return &integrityAwareRepository{ inner: inner, integritySvc: integritySvc, configPath: configPath, warnOnMissingMAC: true, // Allow migration from old configs }, nil } // LoadConfig loads and verifies the config func (r *integrityAwareRepository) LoadConfig(ctx context.Context) (*Config, error) { // Check if config file exists if _, err := os.Stat(r.configPath); os.IsNotExist(err) { // Load from inner (will create defaults) config, err := r.inner.LoadConfig(ctx) if err != nil { return nil, err } // Save with integrity return config, r.SaveConfig(ctx, config) } // Read raw file to check for integrity field data, err := os.ReadFile(r.configPath) if err != nil { return nil, fmt.Errorf("failed to read config: %w", err) } // Try to parse with integrity field var configWithInt ConfigWithIntegrity if err := json.Unmarshal(data, &configWithInt); err != nil { return nil, fmt.Errorf("failed to parse config: %w", err) } // Verify integrity config, err := r.integritySvc.VerifyAndExtractConfig(&configWithInt) if err != nil { return nil, err } // If config had no integrity field, save it with one (migration) if configWithInt.Integrity == "" && r.warnOnMissingMAC { // Re-save with integrity _ = r.SaveConfig(ctx, config) // Ignore error, not critical } return config, nil } // SaveConfig saves the config with integrity signature func (r *integrityAwareRepository) SaveConfig(ctx context.Context, config *Config) error { // Sign the config signedConfig, err := r.integritySvc.SignConfig(config) if err != nil { return fmt.Errorf("failed to sign config: %w", err) } // Serialize with integrity field data, err := json.MarshalIndent(signedConfig, "", " ") if err != nil { return fmt.Errorf("failed to serialize config: %w", err) } // Write with restrictive permissions return os.WriteFile(r.configPath, data, 0600) }