* 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>
99 lines
3.2 KiB
Go
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
|
|
}
|