Head of Product & Engineering 4d8e1eb352
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: AES-256-GCM encryption for per-location Cast API keys + MongoDB auth
- Add internal/crypto package: AES-256-GCM encrypt/decrypt with migration
  passthrough for existing plain-text records (no "enc:" prefix = plain text)
- Store.NewStoreWithCipher injects cipher; SaveToken/UpdateLocationConfig
  encrypt cast_api_key before write; GetToken/ListTokens decrypt on read
- Add CREDENTIALS_ENCRYPTION_KEY env var (64-hex / 32-byte); warns if unset
- Add MongoDB authentication: MONGO_ROOT_USERNAME / MONGO_ROOT_PASSWORD via
  docker-compose MONGO_INITDB_ROOT_USERNAME/PASSWORD; MONGO_URI now requires
  credentials in .env.example
- Update .env.example with generation instructions for all secrets

Co-Authored-By: SideKx <sidekx.ai@sds.dev>
2026-04-06 15:27:38 +02:00

95 lines
2.3 KiB
Go

package crypto
import (
"strings"
"testing"
)
const testKeyHex = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
func TestEncryptDecryptRoundtrip(t *testing.T) {
c, err := NewCipher(testKeyHex)
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
plaintext := "cast_abc123def456"
encrypted, err := c.Encrypt(plaintext)
if err != nil {
t.Fatalf("Encrypt: %v", err)
}
if !strings.HasPrefix(encrypted, encPrefix) {
t.Errorf("encrypted value missing %q prefix: %q", encPrefix, encrypted)
}
if encrypted == plaintext {
t.Errorf("encrypted value equals plaintext — no encryption occurred")
}
decrypted, err := c.Decrypt(encrypted)
if err != nil {
t.Fatalf("Decrypt: %v", err)
}
if decrypted != plaintext {
t.Errorf("roundtrip mismatch: got %q, want %q", decrypted, plaintext)
}
}
func TestEncryptProducesUniqueNonce(t *testing.T) {
c, err := NewCipher(testKeyHex)
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
a, _ := c.Encrypt("same-value")
b, _ := c.Encrypt("same-value")
if a == b {
t.Error("two encryptions of the same plaintext produced identical ciphertext (nonce reuse)")
}
}
func TestDecryptPlainText(t *testing.T) {
c, err := NewCipher(testKeyHex)
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
// Plain text (no "enc:" prefix) should pass through unchanged for migration.
plain := "legacy_unencrypted_key"
result, err := c.Decrypt(plain)
if err != nil {
t.Fatalf("Decrypt plain text: %v", err)
}
if result != plain {
t.Errorf("plain text passthrough: got %q, want %q", result, plain)
}
}
func TestEncryptEmptyString(t *testing.T) {
c, err := NewCipher(testKeyHex)
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
enc, err := c.Encrypt("")
if err != nil {
t.Fatalf("Encrypt empty: %v", err)
}
if enc != "" {
t.Errorf("encrypt empty: expected empty result, got %q", enc)
}
}
func TestNewCipherInvalidKey(t *testing.T) {
cases := []struct{ name, key string }{
{"not hex", "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"},
{"too short", "0102030405060708"},
{"too long", testKeyHex + "ff"},
{"empty", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewCipher(tc.key)
if err == nil {
t.Errorf("expected error for key %q, got nil", tc.key)
}
})
}
}