* 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>
114 lines
3.5 KiB
Go
114 lines
3.5 KiB
Go
package autoscan
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
// fakeRequestLookup implements RequestIntegrationLookup from a static map. The
|
|
// returned apiKey is already plaintext (the Requests repo decrypts on read).
|
|
type fakeRequestLookup struct {
|
|
entries map[string]struct{ baseURL, apiKey string }
|
|
err error
|
|
}
|
|
|
|
func (f fakeRequestLookup) Get(_ context.Context, id string) (string, string, error) {
|
|
if f.err != nil {
|
|
return "", "", f.err
|
|
}
|
|
e, ok := f.entries[id]
|
|
if !ok {
|
|
return "", "", errors.New("integration not found: " + id)
|
|
}
|
|
return e.baseURL, e.apiKey, nil
|
|
}
|
|
|
|
func TestResolveConnectionOwnCredentials(t *testing.T) {
|
|
// The autoscan repo already decrypted api_key_ref, so the connection carries
|
|
// the literal key; Resolve returns it verbatim (trimmed).
|
|
r := NewConnectionResolver(fakeRequestLookup{})
|
|
|
|
got, err := r.Resolve(context.Background(), Connection{
|
|
BaseURL: "http://own:8989",
|
|
APIKeyRef: "OWNKEY",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve own: %v", err)
|
|
}
|
|
if got.BaseURL != "http://own:8989" || got.APIKey != "OWNKEY" {
|
|
t.Fatalf("own resolve = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveConnectionLinked(t *testing.T) {
|
|
lookup := fakeRequestLookup{entries: map[string]struct{ baseURL, apiKey string }{
|
|
"req-1": {baseURL: "http://req:7878", apiKey: "REQKEY"},
|
|
}}
|
|
r := NewConnectionResolver(lookup)
|
|
|
|
id := "req-1"
|
|
got, err := r.Resolve(context.Background(), Connection{RequestIntegrationID: &id})
|
|
if err != nil {
|
|
t.Fatalf("Resolve linked: %v", err)
|
|
}
|
|
if got.BaseURL != "http://req:7878" || got.APIKey != "REQKEY" {
|
|
t.Fatalf("linked resolve = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestResolveConnectionLinkedMissingErrors(t *testing.T) {
|
|
r := NewConnectionResolver(fakeRequestLookup{})
|
|
id := "gone"
|
|
if _, err := r.Resolve(context.Background(), Connection{RequestIntegrationID: &id}); err == nil {
|
|
t.Fatal("expected error when linked Requests integration is missing")
|
|
}
|
|
}
|
|
|
|
func TestResolveConnectionLinkedLookupErrors(t *testing.T) {
|
|
r := NewConnectionResolver(fakeRequestLookup{err: errors.New("boom")})
|
|
id := "req-1"
|
|
if _, err := r.Resolve(context.Background(), Connection{RequestIntegrationID: &id}); err == nil {
|
|
t.Fatal("expected error when the lookup itself fails")
|
|
}
|
|
}
|
|
|
|
func TestResolveConnectionEmptyIntegrationIDIsNotALink(t *testing.T) {
|
|
// A pointer-to-empty/whitespace request_integration_id must NOT be treated as
|
|
// a live link: it must fall back to the connection's own fields, never call
|
|
// requests.Get(""). The lookup here errors on any call, so reaching it fails.
|
|
r := NewConnectionResolver(
|
|
fakeRequestLookup{err: errors.New("lookup must not be called")},
|
|
)
|
|
for _, empty := range []string{"", " "} {
|
|
id := empty
|
|
got, err := r.Resolve(context.Background(), Connection{
|
|
BaseURL: "http://own:8989",
|
|
APIKeyRef: "OWNKEY",
|
|
RequestIntegrationID: &id,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve with empty integration id %q: %v", empty, err)
|
|
}
|
|
if got.BaseURL != "http://own:8989" || got.APIKey != "OWNKEY" {
|
|
t.Fatalf("empty integration id %q should use own creds, got %+v", empty, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveConnectionTrimsKey(t *testing.T) {
|
|
// Resolve trims surrounding whitespace from the (decrypted) key.
|
|
r := NewConnectionResolver(fakeRequestLookup{})
|
|
|
|
got, err := r.Resolve(context.Background(), Connection{
|
|
BaseURL: "http://own:8989",
|
|
APIKeyRef: " OWNKEY ",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Resolve: %v", err)
|
|
}
|
|
if got.APIKey != "OWNKEY" {
|
|
t.Fatalf("expected trimmed key, got %q", got.APIKey)
|
|
}
|
|
}
|