package platformsettings import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "io" "strings" "github.com/descrybe/descrybe-v2/apps/api/internal/config" ) const encPrefix = "enc:v1:" // DeriveKey builds a 32-byte AES key from APP_ENCRYPTION_KEY material. // In production, empty explicit key returns nil (fail closed). func DeriveKey(explicitKey, fallbackMaterial string) []byte { explicitKey = strings.TrimSpace(explicitKey) if explicitKey != "" { if b, err := decodeKeyMaterial(explicitKey); err == nil { return b } sum := sha256.Sum256([]byte(explicitKey)) return sum[:] } if config.IsProductionEnv() { return nil } sum := sha256.Sum256([]byte("descrybe-platform-settings-v1|" + fallbackMaterial)) return sum[:] } func decodeKeyMaterial(s string) ([]byte, error) { if b, err := base64.StdEncoding.DecodeString(s); err == nil && len(b) == 32 { return b, nil } if b, err := base64.RawStdEncoding.DecodeString(s); err == nil && len(b) == 32 { return b, nil } if b, err := hex.DecodeString(s); err == nil && len(b) == 32 { return b, nil } return nil, errors.New("invalid key material") } func EncryptSecret(key []byte, plaintext string) (string, error) { if plaintext == "" { return "", nil } if len(key) != 32 { return "", errors.New("encryption key must be 32 bytes") } 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 } sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil) return encPrefix + base64.RawStdEncoding.EncodeToString(sealed), nil } func DecryptSecret(key []byte, stored string) (string, error) { if stored == "" { return "", nil } if !strings.HasPrefix(stored, encPrefix) { if config.IsProductionEnv() { return "", errors.New("plaintext secrets are not allowed when APP_ENV=production") } return stored, nil } if len(key) != 32 { return "", errors.New("encryption key must be 32 bytes") } raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, encPrefix)) 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 } if len(raw) < gcm.NonceSize() { return "", errors.New("ciphertext too short") } nonce, ciphertext := raw[:gcm.NonceSize()], raw[gcm.NonceSize():] plain, err := gcm.Open(nil, nonce, ciphertext, nil) if err != nil { return "", err } return string(plain), nil } func maskSecret(plain string) string { plain = strings.TrimSpace(plain) if plain == "" { return "" } if len(plain) <= 4 { return "••••" } return "••••" + plain[len(plain)-4:] } func last4(s string) string { s = strings.TrimSpace(s) if len(s) <= 4 { return s } return s[len(s)-4:] } func maskLast4(last4v string) string { last4v = strings.TrimSpace(last4v) if last4v == "" { return "" } return "••••" + last4v }