package localdb import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" "errors" "fmt" "io" "os" "path/filepath" "strings" "git.mchus.pro/mchus/quoteforge/internal/appstate" ) const encryptionKeyFileName = "local_encryption.key" // getEncryptionKey resolves the active encryption key. // Preference order: // 1. QUOTEFORGE_ENCRYPTION_KEY env var // 2. application-managed random key file in the user state directory func getEncryptionKey() ([]byte, error) { key := os.Getenv("QUOTEFORGE_ENCRYPTION_KEY") if key != "" { hash := sha256.Sum256([]byte(key)) return hash[:], nil } stateDir, err := resolveEncryptionStateDir() if err != nil { return nil, fmt.Errorf("resolve encryption state dir: %w", err) } return loadOrCreateEncryptionKey(filepath.Join(stateDir, encryptionKeyFileName)) } func resolveEncryptionStateDir() (string, error) { configPath, err := appstate.ResolveConfigPath("") if err != nil { return "", err } return filepath.Dir(configPath), nil } func loadOrCreateEncryptionKey(path string) ([]byte, error) { if data, err := os.ReadFile(path); err == nil { return parseEncryptionKeyFile(data) } else if !errors.Is(err, os.ErrNotExist) { return nil, fmt.Errorf("read encryption key: %w", err) } if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return nil, fmt.Errorf("create encryption key dir: %w", err) } raw := make([]byte, 32) if _, err := io.ReadFull(rand.Reader, raw); err != nil { return nil, fmt.Errorf("generate encryption key: %w", err) } encoded := base64.StdEncoding.EncodeToString(raw) if err := writeKeyFile(path, []byte(encoded+"\n")); err != nil { if errors.Is(err, os.ErrExist) { data, readErr := os.ReadFile(path) if readErr != nil { return nil, fmt.Errorf("read concurrent encryption key: %w", readErr) } return parseEncryptionKeyFile(data) } return nil, err } return raw, nil } func writeKeyFile(path string, data []byte) error { file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) if err != nil { return err } defer file.Close() if _, err := file.Write(data); err != nil { return err } return file.Sync() } func parseEncryptionKeyFile(data []byte) ([]byte, error) { trimmed := strings.TrimSpace(string(data)) decoded, err := base64.StdEncoding.DecodeString(trimmed) if err != nil { return nil, fmt.Errorf("decode encryption key file: %w", err) } if len(decoded) != 32 { return nil, fmt.Errorf("invalid encryption key length: %d", len(decoded)) } return decoded, nil } func getLegacyEncryptionKey() []byte { hostname, _ := os.Hostname() key := hostname + "quoteforge-salt-2024" hash := sha256.Sum256([]byte(key)) return hash[:] } // Encrypt encrypts plaintext using AES-256-GCM func Encrypt(plaintext string) (string, error) { if plaintext == "" { return "", nil } key, err := getEncryptionKey() if err != nil { return "", err } block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", err } ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) return base64.StdEncoding.EncodeToString(ciphertext), nil } // Decrypt decrypts ciphertext that was encrypted with Encrypt func Decrypt(ciphertext string) (string, error) { if ciphertext == "" { return "", nil } key, err := getEncryptionKey() if err != nil { return "", err } plaintext, legacy, err := decryptWithKeys(ciphertext, key, getLegacyEncryptionKey()) if err != nil { return "", err } _ = legacy return plaintext, nil } func DecryptWithMetadata(ciphertext string) (string, bool, error) { if ciphertext == "" { return "", false, nil } key, err := getEncryptionKey() if err != nil { return "", false, err } return decryptWithKeys(ciphertext, key, getLegacyEncryptionKey()) } func decryptWithKeys(ciphertext string, primaryKey, legacyKey []byte) (string, bool, error) { data, err := base64.StdEncoding.DecodeString(ciphertext) if err != nil { return "", false, err } plaintext, err := decryptWithKey(data, primaryKey) if err == nil { return plaintext, false, nil } legacyPlaintext, legacyErr := decryptWithKey(data, legacyKey) if legacyErr == nil { return legacyPlaintext, true, nil } return "", false, err } func decryptWithKey(data, key []byte) (string, error) { block, err := aes.NewCipher(key) if err != nil { return "", err } gcm, err := cipher.NewGCM(block) if err != nil { return "", err } nonceSize := gcm.NonceSize() if len(data) < nonceSize { return "", errors.New("ciphertext too short") } nonce, ciphertextBytes := data[:nonceSize], data[nonceSize:] plaintext, err := gcm.Open(nil, nonce, ciphertextBytes, nil) if err != nil { return "", err } return string(plaintext), nil }