* 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>
156 lines
5.4 KiB
Go
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
|
|
}
|