Files
silo-server/internal/config/bootstrap.go
9e29e7b330 feat(security): encrypt server-owned credentials at rest (#45) (#95)
* feat(security): encrypt server-owned credentials at rest

Introduce AES-256-GCM at-rest encryption (HKDF-derived from a required
SECRET_KEY) for server-owned credentials, with row-bound AAD, a versioned
enc:v1: envelope, and an idempotent startup backfill.

- internal/secret: cipher + RowAAD/SettingsAAD + the startup backfill engine.
- SECRET_KEY required at bootstrap; cipher threaded as an explicit dependency.
- server_settings: EncryptedSettingsRepo decorator over the audited
  SensitiveSettingKeys (also drives admin redaction); the config watcher and
  watch-sync settings reads decrypt too.
- Arr keys inline-encrypted; the ambiguous SecretResolver indirection removed
  from requests/autoscan.
- Per-table columns encrypted: subtitles, watch-sync, webhook-sync (not
  webhook_secret), history-import, and the jellycompat session's bridged Silo
  access/refresh tokens.
- Startup backfill (resolve-then-encrypt for arr refs) is best-effort and
  primary-node gated.

Equality-looked-up secrets and plugin_runtime_configs.config_value are out of
scope (need hashing / cross-repo design) — see
docs/architecture/secret-encryption.md.

Refs #45

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(compose): require SECRET_KEY in docker-compose

The server now fatals without SECRET_KEY, so the integrated service (and the
commented distributed proxy/transcode examples) pass it through with a
fail-fast guard matching the existing MEDIA_ROOT pattern. Distributed worker
nodes must use the SAME key as the primary to decrypt shared data.
Generate with: openssl rand -base64 48.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(security): encrypt history import session credentials

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:25:48 -04:00

75 lines
2.1 KiB
Go

package config
import (
"fmt"
"os"
"github.com/joho/godotenv"
)
// minSecretKeyLen is the minimum acceptable SECRET_KEY length (in characters).
// It mirrors secret.MinMasterKeyLen; the package is not imported here to keep
// the bootstrap loader dependency-free, so the two constants must stay in sync.
const minSecretKeyLen = 32
// BootstrapConfig holds the minimal config needed before database connection.
type BootstrapConfig struct {
DatabaseURL string
RedisURL string // optional override; empty means use DB setting
Listen string
JFListen string
Mode string
// SecretKey is the master key (raw SECRET_KEY env value) from which the
// at-rest credential cipher derives its data key. It lives outside Postgres
// so encrypted secrets survive a full database compromise/dump.
SecretKey []byte
}
// LoadBootstrap loads bootstrap configuration from a .env file (if it exists)
// and environment variables. Only DATABASE_URL is required.
func LoadBootstrap(envFile string) (*BootstrapConfig, error) {
if envFile != "" {
_ = godotenv.Load(envFile)
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required (set in .env or environment)")
}
// SECRET_KEY is the at-rest encryption master key. It is required: the server
// must never fall back to a zero key or a key derived from another value, or
// encrypted credentials would not survive a database dump. godotenv has
// already loaded .env above, so dev sets it there.
secretKey := os.Getenv("SECRET_KEY")
if len(secretKey) < minSecretKeyLen {
return nil, fmt.Errorf("SECRET_KEY is required (>=%d chars); generate one with: openssl rand -base64 48", minSecretKeyLen)
}
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
jfPort := os.Getenv("JF_PORT")
if jfPort == "" {
jfPort = "8096"
}
mode := os.Getenv("MODE")
if mode == "" {
mode = "integrated"
}
redisURL := os.Getenv("REDIS_URL")
return &BootstrapConfig{
DatabaseURL: dbURL,
RedisURL: redisURL,
Listen: ":" + port,
JFListen: ":" + jfPort,
Mode: mode,
SecretKey: []byte(secretKey),
}, nil
}