Files
silo-server/internal/audiobooks/config.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

99 lines
3.2 KiB
Go

package audiobooks
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"sync"
"time"
"github.com/Silo-Server/silo-server/internal/audiobooks/abs"
"github.com/Silo-Server/silo-server/internal/catalog"
)
const (
absJWTSecretKey = "audiobooks.abs.jwt_secret"
absDefaultAccessTTL = 24 * time.Hour
absDefaultRefreshTTL = 30 * 24 * time.Hour
)
// ABSConfigProvider implements abs.ConfigProvider using silo's server_settings
// table. The ABS JWT secret is generated once on first read and persisted for
// the lifetime of the deployment.
type ABSConfigProvider struct {
// Settings is the encrypting settings decorator in production, so the ABS
// JWT secret (a SensitiveSettingKey) rests as ciphertext and is transparently
// decrypted here. Typed as the interface so both the raw repo and the
// decorator satisfy it.
Settings catalog.SettingsStore
// secretCache holds the generated secret so we only hit the DB once per
// process lifetime. Protected by mu.
mu sync.Mutex
secretCache []byte
}
var _ abs.ConfigProvider = (*ABSConfigProvider)(nil)
// JWTSecret returns the HMAC-SHA256 signing key for ABS JWTs. On the very
// first call it generates a random 32-byte key, persists it in server_settings
// under "audiobooks.abs.jwt_secret" (as hex), and caches it for the process
// lifetime. Subsequent calls return the cached value without touching the DB.
func (c *ABSConfigProvider) JWTSecret(ctx context.Context) ([]byte, error) {
c.mu.Lock()
defer c.mu.Unlock()
if len(c.secretCache) > 0 {
return c.secretCache, nil
}
existing, err := c.Settings.Get(ctx, absJWTSecretKey)
if err != nil {
return nil, fmt.Errorf("abs_config: read jwt secret: %w", err)
}
if existing != "" {
decoded, err := hex.DecodeString(existing)
if err != nil {
return nil, fmt.Errorf("abs_config: decode jwt secret: %w", err)
}
c.secretCache = decoded
return c.secretCache, nil
}
// Generate a new secret and persist it.
secret := make([]byte, 32)
if _, err := rand.Read(secret); err != nil {
return nil, fmt.Errorf("abs_config: generate jwt secret: %w", err)
}
if err := c.Settings.Set(ctx, absJWTSecretKey, hex.EncodeToString(secret)); err != nil {
// Non-fatal: use the in-memory key for this boot. Next start may
// differ, invalidating existing tokens — acceptable at early deploy.
_ = err
}
c.secretCache = secret
return c.secretCache, nil
}
// AccessTTL returns the default access token lifetime.
// Returns 0 to signal "use built-in default" when no override is set.
func (c *ABSConfigProvider) AccessTTL(_ context.Context) (time.Duration, error) {
return absDefaultAccessTTL, nil
}
// RefreshTTL returns the default refresh token lifetime.
func (c *ABSConfigProvider) RefreshTTL(_ context.Context) (time.Duration, error) {
return absDefaultRefreshTTL, nil
}
// StandaloneLoginEnabled reports whether body-creds login is permitted.
// We default to true; an operator can set "audiobooks.abs.login_disabled"
// to "true" in server_settings to gate it off.
func (c *ABSConfigProvider) StandaloneLoginEnabled(ctx context.Context) (bool, error) {
disabled, err := c.Settings.Get(ctx, "audiobooks.abs.login_disabled")
if err != nil {
return true, nil // fail open
}
return disabled != "true", nil
}