Files
silo-server/internal/secret/cipher_test.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

256 lines
7.3 KiB
Go

package secret
import (
"crypto/rand"
"encoding/base64"
"errors"
"strings"
"testing"
)
// testKey returns a deterministic-length master key suitable for New.
func testKey(t *testing.T) []byte {
t.Helper()
key := make([]byte, 48)
if _, err := rand.Read(key); err != nil {
t.Fatalf("read key: %v", err)
}
return key
}
func newTestCipher(t *testing.T) *Cipher {
t.Helper()
c, err := New(testKey(t))
if err != nil {
t.Fatalf("New: %v", err)
}
return c
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
c := newTestCipher(t)
const (
plaintext = "sk-radarr-0123456789abcdef"
aad = "request_integrations:api_key_ref:abc123"
)
ct, err := c.Encrypt(plaintext, aad)
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
if !strings.HasPrefix(ct, "enc:v1:") {
t.Fatalf("ciphertext missing enc:v1: prefix: %q", ct)
}
if !IsEncrypted(ct) {
t.Fatalf("IsEncrypted(%q) = false, want true", ct)
}
got, err := c.Decrypt(ct, aad)
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if got != plaintext {
t.Fatalf("round-trip = %q, want %q", got, plaintext)
}
}
func TestEncryptEmptyReturnsEmpty(t *testing.T) {
c := newTestCipher(t)
ct, err := c.Encrypt("", "any:aad")
if err != nil {
t.Fatalf("Encrypt empty: %v", err)
}
if ct != "" {
t.Fatalf("Encrypt(\"\") = %q, want empty (no envelope for an empty secret)", ct)
}
}
func TestEncryptNonDeterministic(t *testing.T) {
c := newTestCipher(t)
const plaintext, aad = "same-secret", "t:c:1"
a, err := c.Encrypt(plaintext, aad)
if err != nil {
t.Fatalf("Encrypt a: %v", err)
}
b, err := c.Encrypt(plaintext, aad)
if err != nil {
t.Fatalf("Encrypt b: %v", err)
}
if a == b {
t.Fatalf("two encryptions of the same plaintext+aad were identical; random nonce not applied")
}
// Both must still decrypt back to the same plaintext.
for _, ct := range []string{a, b} {
got, err := c.Decrypt(ct, aad)
if err != nil || got != plaintext {
t.Fatalf("Decrypt(%q) = (%q, %v), want (%q, nil)", ct, got, err, plaintext)
}
}
}
func TestDecryptTamperRejected(t *testing.T) {
c := newTestCipher(t)
const aad = "t:c:1"
ct, err := c.Encrypt("secret", aad)
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
// Decode the body, flip a byte, re-encode, reattach the envelope.
body := strings.TrimPrefix(ct, "enc:v1:")
raw, err := base64.RawURLEncoding.DecodeString(body)
if err != nil {
t.Fatalf("decode: %v", err)
}
raw[len(raw)-1] ^= 0xFF
tampered := "enc:v1:" + base64.RawURLEncoding.EncodeToString(raw)
if _, err := c.Decrypt(tampered, aad); err == nil {
t.Fatalf("Decrypt of tampered ciphertext succeeded, want error")
}
}
func TestDecryptAADMismatchRejected(t *testing.T) {
c := newTestCipher(t)
ct, err := c.Encrypt("secret", "t:c:1")
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
if _, err := c.Decrypt(ct, "t:c:2"); err == nil {
t.Fatalf("Decrypt with mismatched AAD succeeded, want error (row binding broken)")
}
}
func TestDecryptWrongKeyRejected(t *testing.T) {
a := newTestCipher(t)
b := newTestCipher(t)
const aad = "t:c:1"
ct, err := a.Encrypt("secret", aad)
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
if _, err := b.Decrypt(ct, aad); err == nil {
t.Fatalf("Decrypt with the wrong key succeeded, want error")
}
}
func TestNewRejectsShortKey(t *testing.T) {
if _, err := New(make([]byte, MinMasterKeyLen-1)); err == nil {
t.Fatalf("New with a %d-byte key succeeded, want error", MinMasterKeyLen-1)
}
if _, err := New(nil); err == nil {
t.Fatalf("New(nil) succeeded, want error")
}
if _, err := New(make([]byte, MinMasterKeyLen)); err != nil {
t.Fatalf("New with a %d-byte key failed: %v", MinMasterKeyLen, err)
}
}
func TestIsEncrypted(t *testing.T) {
cases := map[string]bool{
"enc:v1:x": true,
"sa_abc": false,
"": false,
"ENC:V1:x": false,
"enc:v1": false,
"plain text": false,
}
for in, want := range cases {
if got := IsEncrypted(in); got != want {
t.Errorf("IsEncrypted(%q) = %v, want %v", in, got, want)
}
}
}
func TestDecryptUnknownVersion(t *testing.T) {
c := newTestCipher(t)
_, err := c.Decrypt("enc:v99:"+base64.RawURLEncoding.EncodeToString([]byte("whatever")), "t:c:1")
if !errors.Is(err, ErrUnknownVersion) {
t.Fatalf("Decrypt of enc:v99: returned %v, want ErrUnknownVersion", err)
}
}
func TestDecryptNonEnvelopeErrors(t *testing.T) {
c := newTestCipher(t)
// A non-prefixed legacy value is the caller's pass-through responsibility, but
// Decrypt itself must reject it rather than echo it back.
if _, err := c.Decrypt("plaintext-not-an-envelope", "t:c:1"); err == nil {
t.Fatalf("Decrypt of a non-envelope value succeeded, want error")
}
}
func TestDecryptShortCiphertext(t *testing.T) {
c := newTestCipher(t)
// Valid base64url but fewer bytes than the GCM nonce.
short := "enc:v1:" + base64.RawURLEncoding.EncodeToString([]byte("ab"))
if _, err := c.Decrypt(short, "t:c:1"); err == nil {
t.Fatalf("Decrypt of a too-short ciphertext succeeded, want error")
}
}
func TestDecryptIfEncrypted(t *testing.T) {
c := newTestCipher(t)
const aad = "server_settings:tmdb.api_key"
// Empty passes through.
if got, err := c.DecryptIfEncrypted("", aad); err != nil || got != "" {
t.Fatalf("DecryptIfEncrypted(\"\") = (%q, %v), want (\"\", nil)", got, err)
}
// Legacy plaintext passes through unchanged.
if got, err := c.DecryptIfEncrypted("legacy-plaintext-key", aad); err != nil || got != "legacy-plaintext-key" {
t.Fatalf("DecryptIfEncrypted(plaintext) = (%q, %v), want pass-through", got, err)
}
// A real enc:v1: value is decrypted.
ct, err := c.Encrypt("real-secret", aad)
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
if got, err := c.DecryptIfEncrypted(ct, aad); err != nil || got != "real-secret" {
t.Fatalf("DecryptIfEncrypted(ct) = (%q, %v), want (\"real-secret\", nil)", got, err)
}
// A corrupt enc:v1: value errors — never falls back to the ciphertext.
if _, err := c.DecryptIfEncrypted(ct, "server_settings:wrong.key"); err == nil {
t.Fatalf("DecryptIfEncrypted with wrong AAD succeeded, want error")
}
}
func TestAADHelpers(t *testing.T) {
if got := SettingsAAD("auth.jwt_secret"); got != "server_settings:auth.jwt_secret" {
t.Errorf("SettingsAAD = %q", got)
}
if got := RowAAD("request_integrations", "api_key_ref", "abc"); got != "request_integrations:api_key_ref:abc" {
t.Errorf("RowAAD = %q", got)
}
}
func TestEncryptIfPlaintextIdempotent(t *testing.T) {
c := newTestCipher(t)
const aad = "t:c:1"
// First call encrypts.
ct, changed, err := c.EncryptIfPlaintext("secret", aad)
if err != nil {
t.Fatalf("EncryptIfPlaintext: %v", err)
}
if !changed {
t.Fatalf("first EncryptIfPlaintext reported changed=false, want true")
}
if !IsEncrypted(ct) {
t.Fatalf("EncryptIfPlaintext did not produce an envelope: %q", ct)
}
// Second call is a no-op on the already-encrypted value.
ct2, changed2, err := c.EncryptIfPlaintext(ct, aad)
if err != nil {
t.Fatalf("EncryptIfPlaintext (2nd): %v", err)
}
if changed2 {
t.Fatalf("second EncryptIfPlaintext reported changed=true, want false (double-encrypt)")
}
if ct2 != ct {
t.Fatalf("second EncryptIfPlaintext mutated the value: %q -> %q", ct, ct2)
}
// Empty is a no-op too.
got, changed3, err := c.EncryptIfPlaintext("", aad)
if err != nil || changed3 || got != "" {
t.Fatalf("EncryptIfPlaintext(\"\") = (%q, %v, %v), want (\"\", false, nil)", got, changed3, err)
}
}