Head of Product & Engineering 4d8e1eb352
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: AES-256-GCM encryption for per-location Cast API keys + MongoDB auth
- Add internal/crypto package: AES-256-GCM encrypt/decrypt with migration
  passthrough for existing plain-text records (no "enc:" prefix = plain text)
- Store.NewStoreWithCipher injects cipher; SaveToken/UpdateLocationConfig
  encrypt cast_api_key before write; GetToken/ListTokens decrypt on read
- Add CREDENTIALS_ENCRYPTION_KEY env var (64-hex / 32-byte); warns if unset
- Add MongoDB authentication: MONGO_ROOT_USERNAME / MONGO_ROOT_PASSWORD via
  docker-compose MONGO_INITDB_ROOT_USERNAME/PASSWORD; MONGO_URI now requires
  credentials in .env.example
- Update .env.example with generation instructions for all secrets

Co-Authored-By: SideKx <sidekx.ai@sds.dev>
2026-04-06 15:27:38 +02:00

84 lines
2.5 KiB
Go

package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
)
const encPrefix = "enc:"
// Cipher encrypts and decrypts short string values using AES-256-GCM.
// The encrypted format is: "enc:" + hex(nonce + ciphertext + tag).
//
// Values that do not start with "enc:" are treated as plain text —
// this allows safe migration of existing unencrypted records.
type Cipher struct {
gcm cipher.AEAD
}
// NewCipher creates a Cipher from a 64-hex-character (32-byte) key.
func NewCipher(keyHex string) (*Cipher, error) {
key, err := hex.DecodeString(keyHex)
if err != nil {
return nil, fmt.Errorf("crypto: invalid hex key: %w", err)
}
if len(key) != 32 {
return nil, fmt.Errorf("crypto: key must be 32 bytes (64 hex chars), got %d bytes", len(key))
}
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("crypto: failed to create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("crypto: failed to create GCM: %w", err)
}
return &Cipher{gcm: gcm}, nil
}
// Encrypt encrypts plaintext and returns "enc:<hex(nonce+ciphertext+tag)>".
// Empty input is returned as-is (no encryption needed for empty/unset fields).
func (c *Cipher) Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
nonce := make([]byte, c.gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("crypto: failed to generate nonce: %w", err)
}
ciphertext := c.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return encPrefix + hex.EncodeToString(ciphertext), nil
}
// Decrypt decrypts a value produced by Encrypt.
// If the value does not start with "enc:", it is returned unchanged (plain text migration path).
func (c *Cipher) Decrypt(value string) (string, error) {
if value == "" {
return "", nil
}
if !strings.HasPrefix(value, encPrefix) {
// Plain text — not yet encrypted; return as-is.
return value, nil
}
data, err := hex.DecodeString(strings.TrimPrefix(value, encPrefix))
if err != nil {
return "", fmt.Errorf("crypto: malformed ciphertext hex: %w", err)
}
nonceSize := c.gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("crypto: ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := c.gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("crypto: decryption failed: %w", err)
}
return string(plaintext), nil
}