monorepo/native/desktop/maplefile/internal/config/integrity.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)
}