Files
silo-server/internal/api/trakt_collection_token_resolver.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

156 lines
5.4 KiB
Go

package api
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/secret"
"github.com/Silo-Server/silo-server/internal/watchsync"
watchtrakt "github.com/Silo-Server/silo-server/internal/watchsync/providers/trakt"
)
type traktCollectionTokenResolver struct {
pool *pgxpool.Pool
settings catalog.SettingsStore // encrypting decorator: decrypts trakt client_secret
cipher *secret.Cipher // decrypts watch_provider_connections tokens read via raw SQL
provider *watchtrakt.Provider
}
func (r *traktCollectionTokenResolver) ResolveTraktAccessToken(ctx context.Context, profileID string) (string, error) {
if r == nil || r.pool == nil || r.settings == nil || r.provider == nil {
return "", errors.New("trakt token resolver is not configured")
}
profileID = strings.TrimSpace(profileID)
if profileID == "" {
return "", errors.New("profile id is required")
}
conn, err := r.loadConnection(ctx, profileID)
if err != nil {
return "", err
}
if strings.TrimSpace(conn.AccessToken) == "" {
return "", errors.New("trakt connection is missing an access token")
}
if conn.TokenExpiresAt == nil || conn.TokenExpiresAt.After(time.Now().UTC().Add(time.Minute)) {
return conn.AccessToken, nil
}
if strings.TrimSpace(conn.RefreshToken) == "" {
return "", errors.New("trakt connection is expired and missing a refresh token")
}
cfg, err := r.serverConfig(ctx)
if err != nil {
return "", err
}
tokens, err := r.provider.RefreshToken(ctx, cfg, conn)
if err != nil {
return "", fmt.Errorf("refresh trakt token: %w", err)
}
if strings.TrimSpace(tokens.AccessToken) == "" {
return "", errors.New("trakt refresh returned an empty access token")
}
conn.AccessToken = tokens.AccessToken
if strings.TrimSpace(tokens.RefreshToken) != "" {
conn.RefreshToken = tokens.RefreshToken
}
if tokens.TokenExpiresAt != nil {
conn.TokenExpiresAt = tokens.TokenExpiresAt
}
if err := r.updateTokens(ctx, conn); err != nil {
return "", err
}
return conn.AccessToken, nil
}
func (r *traktCollectionTokenResolver) serverConfig(ctx context.Context) (watchsync.ServerConfig, error) {
clientID, err := r.settings.Get(ctx, "watchsync.trakt.client_id")
if err != nil {
return watchsync.ServerConfig{}, err
}
clientSecret, err := r.settings.Get(ctx, "watchsync.trakt.client_secret")
if err != nil {
return watchsync.ServerConfig{}, err
}
cfg := watchsync.ServerConfig{ClientID: clientID, ClientSecret: clientSecret}
if !cfg.Configured() {
return watchsync.ServerConfig{}, errors.New("trakt credentials are not configured")
}
return cfg, nil
}
func (r *traktCollectionTokenResolver) loadConnection(ctx context.Context, profileID string) (watchsync.Connection, error) {
row := r.pool.QueryRow(ctx, `
SELECT
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at
FROM watch_provider_connections
WHERE provider = 'trakt'
AND profile_id = $1
AND access_token <> ''
ORDER BY updated_at DESC
LIMIT 1
`, profileID)
var conn watchsync.Connection
err := row.Scan(
&conn.ID,
&conn.Provider,
&conn.UserID,
&conn.ProfileID,
&conn.ProviderAccountID,
&conn.ProviderUsername,
&conn.AccessToken,
&conn.RefreshToken,
&conn.TokenExpiresAt,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return watchsync.Connection{}, errors.New("trakt connection not found for profile")
}
return watchsync.Connection{}, fmt.Errorf("load trakt connection: %w", err)
}
// This resolver reads watch_provider_connections directly (bypassing the
// watchsync repo), so it must apply the same at-rest decryption the repo
// does, bound to the same connection identity (watchsync.TokenAAD).
if conn.AccessToken, err = r.cipher.DecryptIfEncrypted(conn.AccessToken, watchsync.TokenAAD("access_token", conn.Provider, conn.UserID, conn.ProfileID)); err != nil {
return watchsync.Connection{}, fmt.Errorf("decrypt trakt access token: %w", err)
}
if conn.RefreshToken, err = r.cipher.DecryptIfEncrypted(conn.RefreshToken, watchsync.TokenAAD("refresh_token", conn.Provider, conn.UserID, conn.ProfileID)); err != nil {
return watchsync.Connection{}, fmt.Errorf("decrypt trakt refresh token: %w", err)
}
return conn, nil
}
func (r *traktCollectionTokenResolver) updateTokens(ctx context.Context, conn watchsync.Connection) error {
// Encrypt the refreshed tokens inline, bound to the connection identity, so
// the raw-SQL write matches what the watchsync repo (and this resolver's read
// path) store.
accessToken, err := r.cipher.Encrypt(conn.AccessToken, watchsync.TokenAAD("access_token", conn.Provider, conn.UserID, conn.ProfileID))
if err != nil {
return fmt.Errorf("encrypt trakt access token: %w", err)
}
refreshToken, err := r.cipher.Encrypt(conn.RefreshToken, watchsync.TokenAAD("refresh_token", conn.Provider, conn.UserID, conn.ProfileID))
if err != nil {
return fmt.Errorf("encrypt trakt refresh token: %w", err)
}
_, err = r.pool.Exec(ctx, `
UPDATE watch_provider_connections
SET access_token = $2,
refresh_token = $3,
token_expires_at = $4,
updated_at = now()
WHERE id = $1::uuid
`, conn.ID, accessToken, refreshToken, conn.TokenExpiresAt)
if err != nil {
return fmt.Errorf("update refreshed trakt connection tokens: %w", err)
}
return nil
}