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:
2026-02-23 19:12:24 +01:00
parent 703dee15ab
commit 44f7fd2f19
11 changed files with 297 additions and 15 deletions
+116
View File
@@ -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
}
@@ -26,6 +26,7 @@ type InfraBackup struct {
ResticPassword string `json:"restic_password,omitempty"`
CrossDrivePassword string `json:"cross_drive_password,omitempty"`
EncryptionKeyB64 string `json:"encryption_key_b64,omitempty"`
}
// InfraStack identifies a deployed app for disaster recovery.
@@ -42,6 +43,7 @@ func BuildInfraBackup(
controllerYAMLPath string,
settingsPath string,
resticPasswordFile string,
encryptionKeyFile string,
systemDataPath string,
sett *settings.Settings,
stackProvider backup.StackDataProvider,
@@ -75,6 +77,15 @@ func BuildInfraBackup(
logger.Printf("[WARN] Infra backup: could not read restic password file: %v", err)
}
// Read encryption key for app.yaml secrets (important but non-fatal)
if encryptionKeyFile != "" {
if data, err := os.ReadFile(encryptionKeyFile); err == nil {
ib.EncryptionKeyB64 = base64.StdEncoding.EncodeToString(data)
} else if !os.IsNotExist(err) {
logger.Printf("[WARN] Infra backup: could not read encryption key file: %v", err)
}
}
// Collect disk layout from fstab + blkid
ib.DiskLayout = collectDiskLayout(systemDataPath)
+10
View File
@@ -653,6 +653,16 @@ func (s *Server) restoreFromInfraBackup(ib *report.InfraBackup) {
}
}
}
// Restore encryption key for app.yaml secrets
if ib.EncryptionKeyB64 != "" {
if data, err := base64.StdEncoding.DecodeString(ib.EncryptionKeyB64); err == nil {
keyFile := filepath.Join(s.dataDir, "encryption.key")
if err := atomicWriteFile(keyFile, data, 0600); err != nil {
s.logger.Printf("[WARN] Setup: failed to restore encryption key: %v", err)
}
}
}
}
func (s *Server) writeFreshConfig(configYAML, retrievalPassword string) error {
+52 -7
View File
@@ -12,6 +12,7 @@ import (
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gopkg.in/yaml.v3"
)
@@ -255,7 +256,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
LockedFields: lockedFields,
}
if err := SaveAppConfig(stackDir, appCfg); err != nil {
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return "", fmt.Errorf("saving app config: %w", err)
}
@@ -308,7 +309,7 @@ func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string,
m.mu.Unlock()
// Revert disk state — keep app.yaml for debugging but mark as not deployed
appCfg.Deployed = false
_ = SaveAppConfig(stackDir, appCfg)
_ = SaveAppConfig(stackDir, appCfg, nil, nil)
return
}
@@ -350,6 +351,7 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
lockedSet[f] = true
}
meta := LoadMetadata(stackDir)
for key, val := range values {
if lockedSet[key] {
return fmt.Errorf("field %q is locked and cannot be changed after deployment", key)
@@ -357,7 +359,7 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
appCfg.Env[key] = val
}
if err := SaveAppConfig(stackDir, appCfg); err != nil {
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("saving updated config: %w", err)
}
@@ -444,7 +446,8 @@ func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]strin
}
// Save app.yaml
if err := SaveAppConfig(stackDir, appCfg); err != nil {
meta := LoadMetadata(stackDir)
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
return fmt.Errorf("saving app config: %w", err)
}
m.logger.Printf("[INFO] Saved updated app.yaml for %s", stackName)
@@ -520,8 +523,29 @@ func LoadAppConfig(stackDir string) *AppConfig {
return cfg
}
func SaveAppConfig(stackDir string, cfg *AppConfig) error {
data, err := yaml.Marshal(cfg)
func SaveAppConfig(stackDir string, cfg *AppConfig, encKey []byte, sensitiveVars []string) error {
// Clone env and encrypt sensitive values
saveCfg := &AppConfig{
Deployed: cfg.Deployed,
DeployedAt: cfg.DeployedAt,
Env: make(map[string]string, len(cfg.Env)),
LockedFields: cfg.LockedFields,
}
sensitiveSet := make(map[string]bool, len(sensitiveVars))
for _, v := range sensitiveVars {
sensitiveSet[v] = true
}
for k, v := range cfg.Env {
if encKey != nil && sensitiveSet[k] && !crypto.IsEncrypted(v) && v != "" {
if enc, err := crypto.Encrypt(encKey, v); err == nil {
saveCfg.Env[k] = enc
continue
}
}
saveCfg.Env[k] = v
}
data, err := yaml.Marshal(saveCfg)
if err != nil {
return fmt.Errorf("marshaling app config: %w", err)
}
@@ -534,6 +558,27 @@ func SaveAppConfig(stackDir string, cfg *AppConfig) error {
return nil
}
// LoadAppConfigDecrypted loads app.yaml and decrypts any encrypted values.
func LoadAppConfigDecrypted(stackDir string, encKey []byte) *AppConfig {
cfg := LoadAppConfig(stackDir)
if cfg == nil || encKey == nil {
return cfg
}
cfg.Env = crypto.DecryptMap(encKey, cfg.Env)
return cfg
}
// SensitiveEnvVars returns the env var names for secret/password fields from metadata.
func SensitiveEnvVars(meta *Metadata) []string {
var vars []string
for _, f := range meta.DeployFields {
if f.Type == "secret" || f.Type == "password" {
vars = append(vars, f.EnvVar)
}
}
return vars
}
// --- Secret generation ---
const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -650,7 +695,7 @@ func (m *Manager) InjectMissingFields(stackNames []string) {
}
if len(injected) > 0 {
if err := SaveAppConfig(stackDir, appCfg); err != nil {
if err := SaveAppConfig(stackDir, appCfg, m.encKey, SensitiveEnvVars(&meta)); err != nil {
m.logger.Printf("[ERROR] Stack %s: failed to save app.yaml after injection: %v", name, err)
continue
}
+53 -2
View File
@@ -14,6 +14,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
)
// ContainerState represents the current state of a container.
@@ -64,6 +65,7 @@ type Manager struct {
composeCmd string
stacks map[string]*Stack
mu sync.RWMutex
encKey []byte // AES-256 key for encrypting sensitive values in app.yaml
}
// NewManager creates a new stack manager.
@@ -90,6 +92,55 @@ func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
}, nil
}
// SetEncryptionKey sets the AES-256 key used to encrypt/decrypt sensitive values in app.yaml.
func (m *Manager) SetEncryptionKey(key []byte) {
m.encKey = key
}
// MigrateEncryption re-saves app.yaml for deployed stacks that still have
// plaintext values in sensitive fields. Called once on startup.
func (m *Manager) MigrateEncryption() {
if m.encKey == nil {
return
}
m.mu.RLock()
defer m.mu.RUnlock()
migrated := 0
for _, s := range m.stacks {
if !s.Deployed {
continue
}
stackDir := filepath.Dir(s.ComposePath)
appCfg := LoadAppConfig(stackDir)
if appCfg == nil {
continue
}
meta := LoadMetadata(stackDir)
sensitive := SensitiveEnvVars(&meta)
if len(sensitive) == 0 {
continue
}
needsMigration := false
for _, envVar := range sensitive {
if v, ok := appCfg.Env[envVar]; ok && v != "" && !crypto.IsEncrypted(v) {
needsMigration = true
break
}
}
if needsMigration {
if err := SaveAppConfig(stackDir, appCfg, m.encKey, sensitive); err != nil {
m.logger.Printf("[WARN] Encryption migration failed for %s: %v", s.Name, err)
} else {
migrated++
}
}
}
if migrated > 0 {
m.logger.Printf("[INFO] Encrypted sensitive values in %d app.yaml file(s)", migrated)
}
}
// toTitleCase capitalizes the first letter of each word.
func toTitleCase(s string) string {
words := strings.Fields(s)
@@ -535,8 +586,8 @@ func (m *Manager) stackEnv(stackDir string) []string {
// Always inject DOMAIN
env = append(env, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
// Load app.yaml if it exists — merge its env vars
appCfg := LoadAppConfig(stackDir)
// Load app.yaml if it exists — merge its env vars (decrypted for docker-compose)
appCfg := LoadAppConfigDecrypted(stackDir, m.encKey)
if appCfg != nil {
for k, v := range appCfg.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
+9 -4
View File
@@ -12,6 +12,7 @@ import (
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
@@ -300,9 +301,13 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
data["AutoFields"] = meta.AutoGeneratedFields()
// Auto-generated field values: existing values for deployed apps, pre-generated for new deploys
autoFieldValues := make(map[string]string)
var decryptedEnv map[string]string
if appCfg != nil {
decryptedEnv = crypto.DecryptMap(s.encKey, appCfg.Env)
}
if alreadyDeployed && appCfg != nil {
for _, f := range meta.AutoGeneratedFields() {
if val, ok := appCfg.Env[f.EnvVar]; ok {
if val, ok := decryptedEnv[f.EnvVar]; ok {
autoFieldValues[f.EnvVar] = val
}
}
@@ -314,9 +319,9 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
}
}
data["AutoFieldValues"] = autoFieldValues
// For deployed apps, pass stored field values so subdomain and other user fields show current values
if alreadyDeployed && appCfg != nil {
data["DeployedFieldValues"] = appCfg.Env
// For deployed apps, pass stored field values (decrypted) so fields show current values
if alreadyDeployed && decryptedEnv != nil {
data["DeployedFieldValues"] = decryptedEnv
}
// Storage paths with free space info for deploy dropdown
var deployPaths []DeployStoragePath
+6
View File
@@ -37,6 +37,7 @@ type Server struct {
updater *selfupdate.Updater
logger *log.Logger
version string
encKey []byte // AES-256 key for decrypting app.yaml values
tmpl *template.Template
sessions map[string]*session
@@ -107,6 +108,11 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
return s
}
// SetEncryptionKey sets the AES-256 key used to decrypt app.yaml values for display.
func (s *Server) SetEncryptionKey(key []byte) {
s.encKey = key
}
func (s *Server) loadTemplates() {
s.tmpl = template.Must(
template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"),
+2 -1
View File
@@ -596,7 +596,8 @@ func (s *Server) updateStackHDDPath(stackName, newPath string) error {
return fmt.Errorf("app.yaml not found for stack: %s", stackName)
}
appCfg.Env["HDD_PATH"] = newPath
return stacks.SaveAppConfig(stackDir, appCfg)
meta := stacks.LoadMetadata(stackDir)
return stacks.SaveAppConfig(stackDir, appCfg, s.encKey, stacks.SensitiveEnvVars(&meta))
}
// storageInfoForStack returns deploy storage info for a deployed stack.