253 lines
7.1 KiB
Go
253 lines
7.1 KiB
Go
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)
|
|
}
|