* 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>
145 lines
4.8 KiB
Go
145 lines
4.8 KiB
Go
package secret
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
)
|
|
|
|
// fakeRows yields preset all-string rows for the backfill SELECT.
|
|
type fakeRows struct {
|
|
rows [][]string
|
|
i int
|
|
}
|
|
|
|
func (f *fakeRows) Next() bool { f.i++; return f.i <= len(f.rows) }
|
|
func (f *fakeRows) Scan(dest ...any) error {
|
|
row := f.rows[f.i-1]
|
|
if len(dest) != len(row) {
|
|
return fmt.Errorf("fakeRows: scan into %d dests, row has %d", len(dest), len(row))
|
|
}
|
|
for j, d := range dest {
|
|
p, ok := d.(*string)
|
|
if !ok {
|
|
return fmt.Errorf("fakeRows: dest %d is %T, want *string", j, d)
|
|
}
|
|
*p = row[j]
|
|
}
|
|
return nil
|
|
}
|
|
func (f *fakeRows) Close() {}
|
|
func (f *fakeRows) Err() error { return nil }
|
|
func (f *fakeRows) CommandTag() pgconn.CommandTag { return pgconn.CommandTag{} }
|
|
func (f *fakeRows) FieldDescriptions() []pgconn.FieldDescription { return nil }
|
|
func (f *fakeRows) Values() ([]any, error) { return nil, nil }
|
|
func (f *fakeRows) RawValues() [][]byte { return nil }
|
|
func (f *fakeRows) Conn() *pgx.Conn { return nil }
|
|
|
|
type capturedExec struct {
|
|
args []any
|
|
}
|
|
|
|
// fakeExec returns a fixed candidate set from Query and captures every Exec.
|
|
type fakeExec struct {
|
|
rows [][]string
|
|
execs []capturedExec
|
|
}
|
|
|
|
func (f *fakeExec) Query(context.Context, string, ...any) (pgx.Rows, error) {
|
|
return &fakeRows{rows: f.rows}, nil
|
|
}
|
|
func (f *fakeExec) Exec(_ context.Context, _ string, args ...any) (pgconn.CommandTag, error) {
|
|
f.execs = append(f.execs, capturedExec{args: args})
|
|
return pgconn.NewCommandTag("UPDATE 1"), nil
|
|
}
|
|
|
|
func TestBackfillColumns_EncryptsBindsAndSkipsEncrypted(t *testing.T) {
|
|
c := newTestCipher(t)
|
|
target := BackfillTarget{Table: "subtitle_provider_config", Column: "api_key", KeyExpr: "provider_name"}
|
|
|
|
// Pre-encrypted row must be skipped by the in-loop EncryptIfPlaintext guard.
|
|
preEnc, err := c.Encrypt("already", RowAAD(target.Table, target.Column, "opensubtitles"))
|
|
if err != nil {
|
|
t.Fatalf("seed encrypt: %v", err)
|
|
}
|
|
db := &fakeExec{rows: [][]string{
|
|
// {key, aadID, value}; for this target aadID == key (provider_name).
|
|
{"subdl", "subdl", "plain-subdl-key"},
|
|
{"opensubtitles", "opensubtitles", preEnc},
|
|
}}
|
|
|
|
n, err := BackfillColumns(context.Background(), db, c, []BackfillTarget{target})
|
|
if err != nil {
|
|
t.Fatalf("BackfillColumns: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("encrypted = %d, want 1 (pre-encrypted row skipped)", n)
|
|
}
|
|
if len(db.execs) != 1 {
|
|
t.Fatalf("exec count = %d, want 1", len(db.execs))
|
|
}
|
|
// args: [ciphertext, key, originalValue]
|
|
got := db.execs[0].args
|
|
ct, key, orig := got[0].(string), got[1].(string), got[2].(string)
|
|
if key != "subdl" || orig != "plain-subdl-key" {
|
|
t.Fatalf("update bound key=%q orig=%q", key, orig)
|
|
}
|
|
if !IsEncrypted(ct) {
|
|
t.Fatalf("update value %q is not enc:v1:", ct)
|
|
}
|
|
// Ciphertext must decrypt under the row-bound AAD back to the original.
|
|
plain, err := c.Decrypt(ct, RowAAD(target.Table, target.Column, key))
|
|
if err != nil || plain != "plain-subdl-key" {
|
|
t.Fatalf("decrypt = (%q, %v), want original", plain, err)
|
|
}
|
|
}
|
|
|
|
func TestBackfillReferencedColumns_ResolveThenEncrypt(t *testing.T) {
|
|
c := newTestCipher(t)
|
|
target := BackfillTarget{Table: "request_integrations", Column: "api_key_ref", KeyExpr: "id::text"}
|
|
|
|
// resolve mimics the settings decorator: a known settings key resolves to the
|
|
// real credential; anything else is a literal (returns "").
|
|
resolve := func(_ context.Context, ref string) (string, error) {
|
|
if ref == "requests.radarr.api_key" {
|
|
return "REAL-RADARR-KEY", nil
|
|
}
|
|
return "", nil
|
|
}
|
|
db := &fakeExec{rows: [][]string{
|
|
{"int-1", "requests.radarr.api_key"}, // a reference → encrypt the resolved key
|
|
{"int-2", "literal-key-xyz"}, // a literal → encrypt the value itself
|
|
}}
|
|
|
|
n, err := BackfillReferencedColumns(context.Background(), db, c, resolve, []BackfillTarget{target})
|
|
if err != nil {
|
|
t.Fatalf("BackfillReferencedColumns: %v", err)
|
|
}
|
|
if n != 2 || len(db.execs) != 2 {
|
|
t.Fatalf("encrypted=%d execs=%d, want 2/2", n, len(db.execs))
|
|
}
|
|
|
|
want := map[string]string{
|
|
"int-1": "REAL-RADARR-KEY", // resolved, not the setting name
|
|
"int-2": "literal-key-xyz",
|
|
}
|
|
for _, e := range db.execs {
|
|
ct, key, orig := e.args[0].(string), e.args[1].(string), e.args[2].(string)
|
|
plain, err := c.Decrypt(ct, RowAAD(target.Table, target.Column, key))
|
|
if err != nil {
|
|
t.Fatalf("decrypt for %s: %v", key, err)
|
|
}
|
|
if plain != want[key] {
|
|
t.Fatalf("row %s encrypted %q, want %q", key, plain, want[key])
|
|
}
|
|
// The UPDATE guard ($3) must be the ORIGINAL stored value so concurrent
|
|
// boots converge (the reference name for int-1, the literal for int-2).
|
|
if key == "int-1" && orig != "requests.radarr.api_key" {
|
|
t.Fatalf("int-1 guard = %q, want the original reference", orig)
|
|
}
|
|
}
|
|
}
|