8b8c04a487
Concurrency fixes: - Deep-copy stacks in GetStack/GetStacks to prevent shared state mutation (C04) - Add per-state mutex to watchdog pathProbeState (C05) - Guard MetricsCollector.Start() with sync.Once against double-start (C06) - Hold diskJobMu across entire raw mount operation (C07) - Add mutex to SetEncryptionKey (C08), MigrateEncryption write lock (H03) - Use sync.Once for sync.Stop() channel close (H08) - Set syncing=true before releasing lock in TriggerSync (H09) - Deep-copy lastDBDump/lastBackup in GetFullStatus (H11) - Add WaitGroup for stderr goroutine in MigrateDrive (H19) - Add mutex to SetBackupRunningCheck (M18) Security fixes: - Validate Bearer token against Hub API key in CSRF middleware (H16) - Validate backup paths start with expected prefix in RemoveStack (M12) - Guard uuid[:8] slice with length check (H20) - Parse fstab fields exactly for mount target matching (H21) Bug fixes: - Use decrypted env vars for compose deploy (C01) - Log decrypt failures in DecryptMap instead of swallowing (C02) - Move Deployed=false inside lock in runComposeDeploy (C03) - Fix activeDrives() to skip disconnected drives (H02) - Fix Snapshot() stderr extraction from exec.ExitError (H01) - Check unlockCmd.Run() error in restic (H01) - Buffer template rendering via bytes.Buffer (H07) - Thread context.Context through cloudflare client (H10) - Fix leaf-name collision detection in cross-drive backup (H15) - Add nil check for crossDriveRunner (H17) - Use strings.TrimSpace instead of slice on command output (H18) - Make SaveAppConfig atomic with write-to-tmp+rename (H04) - Pass encKey on deploy failure SaveAppConfig (H05) - Fix IPv6 address format in TCP health probe Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
123 lines
3.1 KiB
Go
123 lines
3.1 KiB
Go
// Package crypto provides AES-256-GCM encryption for sensitive app config values.
|
|
package crypto
|
|
|
|
import (
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
keySize = 32 // AES-256
|
|
prefix = "ENC:"
|
|
)
|
|
|
|
// LoadOrCreateKey reads a 32-byte encryption key from path, or generates one if it doesn't exist.
|
|
func LoadOrCreateKey(path string) ([]byte, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err == nil {
|
|
if len(data) != keySize {
|
|
return nil, fmt.Errorf("encryption key file %s has invalid size %d (expected %d)", path, len(data), keySize)
|
|
}
|
|
return data, nil
|
|
}
|
|
if !os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("reading encryption key %s: %w", path, err)
|
|
}
|
|
|
|
// Generate new key
|
|
key := make([]byte, keySize)
|
|
if _, err := rand.Read(key); err != nil {
|
|
return nil, fmt.Errorf("generating encryption key: %w", err)
|
|
}
|
|
if err := os.WriteFile(path, key, 0600); err != nil {
|
|
return nil, fmt.Errorf("writing encryption key %s: %w", path, err)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
// Encrypt encrypts plaintext with AES-256-GCM and returns "ENC:<base64(nonce+ciphertext)>".
|
|
func Encrypt(key []byte, plaintext string) (string, error) {
|
|
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 := rand.Read(nonce); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil) // nonce prepended
|
|
return prefix + base64.StdEncoding.EncodeToString(ciphertext), nil
|
|
}
|
|
|
|
// Decrypt decrypts a value produced by Encrypt. Returns error if value is not encrypted or decryption fails.
|
|
func Decrypt(key []byte, value string) (string, error) {
|
|
if !IsEncrypted(value) {
|
|
return "", fmt.Errorf("value is not encrypted")
|
|
}
|
|
|
|
data, err := base64.StdEncoding.DecodeString(value[len(prefix):])
|
|
if err != nil {
|
|
return "", fmt.Errorf("base64 decode: %w", err)
|
|
}
|
|
|
|
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 "", fmt.Errorf("ciphertext too short")
|
|
}
|
|
|
|
plaintext, err := gcm.Open(nil, data[:nonceSize], data[nonceSize:], nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("decrypt: %w", err)
|
|
}
|
|
return string(plaintext), nil
|
|
}
|
|
|
|
// IsEncrypted returns true if the value has the encryption prefix.
|
|
func IsEncrypted(value string) bool {
|
|
return strings.HasPrefix(value, prefix)
|
|
}
|
|
|
|
// DecryptMap decrypts all encrypted values in a map, returning a new map with plaintext values.
|
|
// Logs a warning for any value that fails to decrypt (key rotation, data corruption).
|
|
func DecryptMap(key []byte, env map[string]string) map[string]string {
|
|
if key == nil || env == nil {
|
|
return env
|
|
}
|
|
result := make(map[string]string, len(env))
|
|
for k, v := range env {
|
|
if IsEncrypted(v) {
|
|
dec, err := Decrypt(key, v)
|
|
if err != nil {
|
|
log.Printf("[WARN] Failed to decrypt env var %q: %v — passing through encrypted value", k, err)
|
|
result[k] = v
|
|
continue
|
|
}
|
|
result[k] = dec
|
|
continue
|
|
}
|
|
result[k] = v
|
|
}
|
|
return result
|
|
}
|