* 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>
256 lines
7.3 KiB
Go
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)
|
|
}
|
|
}
|