* 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>
53 lines
2.0 KiB
Go
53 lines
2.0 KiB
Go
package autoscan
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// ResolvedConnection is concrete credentials handed to the plugin.
|
|
type ResolvedConnection struct {
|
|
BaseURL string
|
|
APIKey string
|
|
}
|
|
|
|
// RequestIntegrationLookup resolves a soft-linked Requests integration to its
|
|
// base URL and (already-decrypted) api key.
|
|
type RequestIntegrationLookup interface {
|
|
Get(ctx context.Context, integrationID string) (baseURL, apiKey string, err error)
|
|
}
|
|
|
|
type ConnectionResolver struct {
|
|
requests RequestIntegrationLookup
|
|
}
|
|
|
|
func NewConnectionResolver(r RequestIntegrationLookup) *ConnectionResolver {
|
|
return &ConnectionResolver{requests: r}
|
|
}
|
|
|
|
// Resolve turns a stored Connection into concrete credentials. When the
|
|
// connection is linked to a Requests integration the live base URL + key are
|
|
// read from there (the Requests repo decrypts it); otherwise the connection's
|
|
// own fields are used (the autoscan repo already decrypted api_key_ref on read).
|
|
// Either way the returned key is plaintext — there is no longer a separate
|
|
// secret-resolution step.
|
|
func (cr *ConnectionResolver) Resolve(ctx context.Context, c Connection) (ResolvedConnection, error) {
|
|
baseURL, apiKey := c.BaseURL, c.APIKeyRef
|
|
// A pointer-to-empty-string request_integration_id is NOT a live link (it can
|
|
// arise from a both-NULL orphan or a stripped link). Treat empty/whitespace as
|
|
// "no link" so we don't call requests.Get("") and use the connection's own
|
|
// fields instead.
|
|
if c.RequestIntegrationID != nil && strings.TrimSpace(*c.RequestIntegrationID) != "" {
|
|
if cr.requests == nil {
|
|
return ResolvedConnection{}, fmt.Errorf("autoscan: linked requests integration %q: no lookup configured", *c.RequestIntegrationID)
|
|
}
|
|
u, key, err := cr.requests.Get(ctx, *c.RequestIntegrationID)
|
|
if err != nil {
|
|
return ResolvedConnection{}, fmt.Errorf("autoscan: linked requests integration %q: %w", *c.RequestIntegrationID, err)
|
|
}
|
|
baseURL, apiKey = u, key
|
|
}
|
|
return ResolvedConnection{BaseURL: baseURL, APIKey: strings.TrimSpace(apiKey)}, nil
|
|
}
|