feat: encrypt sensitive values in app.yaml with AES-256-GCM
Passwords and secrets from deploy fields (type: password/secret) are now encrypted at rest in app.yaml using a per-node 32-byte key. Values stored as ENC:base64(nonce+ciphertext), decrypted transparently for docker-compose and web UI. Key included in infra backup bundle for disaster recovery. Existing plaintext values migrated automatically on startup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
// Package crypto provides AES-256-GCM encryption for sensitive app config values.
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"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.
|
||||
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) {
|
||||
if dec, err := Decrypt(key, v); err == nil {
|
||||
result[k] = dec
|
||||
continue
|
||||
}
|
||||
}
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user