Local-first runtime cleanup and recovery hardening
This commit is contained in:
@@ -7,19 +7,104 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appstate"
|
||||
)
|
||||
|
||||
// getEncryptionKey derives a 32-byte key from environment variable or machine ID
|
||||
func getEncryptionKey() []byte {
|
||||
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 == "" {
|
||||
// Fallback to a machine-based key (hostname + fixed salt)
|
||||
hostname, _ := os.Hostname()
|
||||
key = hostname + "quoteforge-salt-2024"
|
||||
if key != "" {
|
||||
hash := sha256.Sum256([]byte(key))
|
||||
return hash[:], nil
|
||||
}
|
||||
// Hash to get exactly 32 bytes for AES-256
|
||||
|
||||
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[:]
|
||||
}
|
||||
@@ -30,7 +115,10 @@ func Encrypt(plaintext string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key := getEncryptionKey()
|
||||
key, err := getEncryptionKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -56,12 +144,50 @@ func Decrypt(ciphertext string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
key := getEncryptionKey()
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user