diff --git a/internal/platform/encryption/encryption.go b/internal/platform/encryption/encryption.go new file mode 100644 index 00000000..1a4a626a --- /dev/null +++ b/internal/platform/encryption/encryption.go @@ -0,0 +1,164 @@ +// Package encryption provides mandatory AES-256-GCM encryption and stable +// HMAC fingerprints for stored credentials. +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "gpt-load/internal/platform/utils" + + "github.com/sirupsen/logrus" +) + +// KeyFileName is the persistent master-key filename within DATA_DIR. +const KeyFileName = "encryption.key" + +// Service defines credential encryption and fingerprinting operations. +type Service interface { + Encrypt(plaintext string) (string, error) + Decrypt(ciphertext string) (string, error) + Hash(plaintext string) string +} + +// NewService creates a mandatory AES-GCM service from non-empty key material. +func NewService(keyMaterial string) (Service, error) { + if keyMaterial == "" { + return nil, fmt.Errorf("encryption key material is required") + } + + aesKey := utils.DeriveAESKey(keyMaterial) + utils.ValidatePasswordStrength(keyMaterial, "ENCRYPTION_KEY") + + block, err := aes.NewCipher(aesKey) + if err != nil { + return nil, fmt.Errorf("create AES cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create GCM: %w", err) + } + + return &aesService{key: aesKey, gcm: gcm}, nil +} + +// NewServiceWithKeyFile resolves explicit key material or a persistent keyfile +// before constructing the encryption service. +func NewServiceWithKeyFile(explicitKey, dataDir string) (Service, error) { + keyMaterial, err := LoadOrCreateKeyMaterial(explicitKey, dataDir) + if err != nil { + return nil, err + } + return NewService(keyMaterial) +} + +// LoadOrCreateKeyMaterial prefers explicit key material. When it is absent, a +// 32-byte random key is loaded from or created at DATA_DIR/encryption.key. +func LoadOrCreateKeyMaterial(explicitKey, dataDir string) (string, error) { + if explicitKey != "" { + return explicitKey, nil + } + if dataDir == "" { + return "", fmt.Errorf("DATA_DIR is required when ENCRYPTION_KEY is empty") + } + if err := os.MkdirAll(dataDir, 0o700); err != nil { + return "", fmt.Errorf("create data directory: %w", err) + } + + path := filepath.Join(dataDir, KeyFileName) + if material, err := readKeyFile(path); err == nil { + return material, nil + } else if !os.IsNotExist(err) { + return "", err + } + + randomKey := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, randomKey); err != nil { + return "", fmt.Errorf("generate encryption key: %w", err) + } + material := hex.EncodeToString(randomKey) + + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + if os.IsExist(err) { + return readKeyFile(path) + } + return "", fmt.Errorf("create encryption keyfile: %w", err) + } + if _, err := file.WriteString(material + "\n"); err != nil { + _ = file.Close() + return "", fmt.Errorf("write encryption keyfile: %w", err) + } + if err := file.Close(); err != nil { + return "", fmt.Errorf("close encryption keyfile: %w", err) + } + if err := os.Chmod(path, 0o600); err != nil { + return "", fmt.Errorf("secure encryption keyfile: %w", err) + } + + logrus.WithField("path", path).Warn("Generated encryption keyfile; back it up before relying on encrypted credentials") + return material, nil +} + +func readKeyFile(path string) (string, error) { + contents, err := os.ReadFile(path) + if err != nil { + return "", err + } + material := strings.TrimSpace(string(contents)) + if material == "" { + return "", fmt.Errorf("encryption keyfile %s is empty", path) + } + if err := os.Chmod(path, 0o600); err != nil { + return "", fmt.Errorf("secure encryption keyfile: %w", err) + } + return material, nil +} + +type aesService struct { + key []byte + gcm cipher.AEAD +} + +func (s *aesService) Encrypt(plaintext string) (string, error) { + nonce := make([]byte, s.gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generate encryption nonce: %w", err) + } + ciphertext := s.gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return hex.EncodeToString(ciphertext), nil +} + +func (s *aesService) Decrypt(ciphertext string) (string, error) { + data, err := hex.DecodeString(ciphertext) + if err != nil { + return "", fmt.Errorf("decode ciphertext: %w", err) + } + if len(data) < s.gcm.NonceSize() { + return "", fmt.Errorf("ciphertext too short") + } + nonce, encrypted := data[:s.gcm.NonceSize()], data[s.gcm.NonceSize():] + plaintext, err := s.gcm.Open(nil, nonce, encrypted, nil) + if err != nil { + return "", fmt.Errorf("decrypt ciphertext: %w", err) + } + return string(plaintext), nil +} + +func (s *aesService) Hash(plaintext string) string { + if plaintext == "" { + return "" + } + mac := hmac.New(sha256.New, s.key) + _, _ = mac.Write([]byte(plaintext)) + return hex.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/platform/encryption/encryption_test.go b/internal/platform/encryption/encryption_test.go new file mode 100644 index 00000000..c98d935a --- /dev/null +++ b/internal/platform/encryption/encryption_test.go @@ -0,0 +1,101 @@ +package encryption + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestServiceEncryptDecryptAndStableHash(t *testing.T) { + service, err := NewService("a-test-master-key") + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + ciphertext, err := service.Encrypt("sk-secret-value") + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + if ciphertext == "sk-secret-value" { + t.Fatal("Encrypt() returned plaintext") + } + + plaintext, err := service.Decrypt(ciphertext) + if err != nil { + t.Fatalf("Decrypt() error = %v", err) + } + if plaintext != "sk-secret-value" { + t.Fatalf("Decrypt() = %q", plaintext) + } + + first := service.Hash("sk-secret-value") + second := service.Hash("sk-secret-value") + if first == "" || first != second { + t.Fatalf("Hash() is not stable: %q != %q", first, second) + } +} + +func TestNewServiceRejectsEmptyMasterKey(t *testing.T) { + if _, err := NewService(""); err == nil { + t.Fatal("NewService() error = nil, want error") + } +} + +func TestLoadOrCreateKeyMaterialGeneratesAndReusesKeyFile(t *testing.T) { + dataDir := t.TempDir() + + first, err := LoadOrCreateKeyMaterial("", dataDir) + if err != nil { + t.Fatalf("first LoadOrCreateKeyMaterial() error = %v", err) + } + if len(first) != 64 { + t.Fatalf("generated key length = %d, want 64 hex chars", len(first)) + } + + second, err := LoadOrCreateKeyMaterial("", dataDir) + if err != nil { + t.Fatalf("second LoadOrCreateKeyMaterial() error = %v", err) + } + if second != first { + t.Fatalf("keyfile was not reused: %q != %q", second, first) + } + + info, err := os.Stat(filepath.Join(dataDir, KeyFileName)) + if err != nil { + t.Fatalf("Stat(keyfile) error = %v", err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 { + t.Fatalf("keyfile permissions = %o, want 600", info.Mode().Perm()) + } +} + +func TestLoadOrCreateKeyMaterialPrefersExplicitKey(t *testing.T) { + dataDir := t.TempDir() + + got, err := LoadOrCreateKeyMaterial("explicit-key", dataDir) + if err != nil { + t.Fatalf("LoadOrCreateKeyMaterial() error = %v", err) + } + if got != "explicit-key" { + t.Fatalf("key material = %q", got) + } + if _, err := os.Stat(filepath.Join(dataDir, KeyFileName)); !os.IsNotExist(err) { + t.Fatalf("explicit key unexpectedly created keyfile: %v", err) + } +} + +func TestNewServiceWithKeyFileUsesGeneratedMaterial(t *testing.T) { + dataDir := t.TempDir() + service, err := NewServiceWithKeyFile("", dataDir) + if err != nil { + t.Fatalf("NewServiceWithKeyFile() error = %v", err) + } + ciphertext, err := service.Encrypt("value") + if err != nil { + t.Fatalf("Encrypt() error = %v", err) + } + if _, err := service.Decrypt(ciphertext); err != nil { + t.Fatalf("Decrypt() error = %v", err) + } +}