Files
pad/internal/store/webhooks_test.go
T
xarmian bfa32dde5a fix(security): encrypt webhook HMAC secrets at rest, mask in responses (BUG-2057) (#915)
Webhook signing secrets were stored plaintext in the webhooks.secret column
and echoed back in every API response. Encrypt them at rest (reusing the
existing AES-256-GCM store helpers, same pattern as TOTP secrets) and return
the raw secret ONLY in the creation response; list responses now mask it and
expose a has_secret flag instead.

- store: encrypt on CreateWebhook, decrypt on Get/ListWebhooks so the
  dispatcher still signs with the plaintext secret. Reuses the secret column
  with the "enc:" prefix — no new column/migration. Keyless self-host stays a
  no-op fallback (encrypt returns plaintext; decrypt passes legacy rows
  through unchanged).
- BackfillEncryptWebhookSecrets encrypts pre-existing plaintext rows on
  startup once a key is configured (idempotent), mirroring the TOTP backfill.
- model: add HasSecret so masked responses still signal presence.
- handlers: mask secret on list; document raw-only-on-create.
- tests: encrypt-at-rest round-trip + HMAC validity, list decrypt,
  plaintext backfill/back-compat, and the API mask-except-on-create contract.

Claude-Session: https://claude.ai/code/session_015yuBJQYfDj95cgX3DaD8SF
2026-07-11 00:10:27 -04:00

278 lines
8.9 KiB
Go

package store
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// newWebhookTestWorkspace creates a user + workspace so webhook FK constraints
// are satisfied, and returns the workspace ID.
func newWebhookTestWorkspace(t *testing.T, s *Store) string {
t.Helper()
u, err := s.CreateUser(models.UserCreate{
Email: "wh@test.com",
Name: "Webhook Tester",
Password: "password123",
})
if err != nil {
t.Fatalf("create user: %v", err)
}
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Hooks", Slug: "hooks", OwnerID: u.ID})
if err != nil {
t.Fatalf("create workspace: %v", err)
}
return ws.ID
}
// TestWebhookSecret_EncryptedAtRest is the BUG-2057 regression: the HMAC secret
// must be encrypted in the DB, round-trip to plaintext on read (so the
// dispatcher can sign), and that plaintext must produce a valid HMAC.
func TestWebhookSecret_EncryptedAtRest(t *testing.T) {
s := testStore(t)
key := make([]byte, 32)
if _, err := rand.Read(key); err != nil {
t.Fatal(err)
}
s.SetEncryptionKey(key)
wsID := newWebhookTestWorkspace(t, s)
const secret = "super-secret-signing-key"
hook, err := s.CreateWebhook(wsID, models.WebhookCreate{
URL: "https://example.com/hook",
Secret: secret,
})
if err != nil {
t.Fatalf("create webhook: %v", err)
}
// Read back through the store — secret must be decrypted plaintext.
if hook.Secret != secret {
t.Errorf("expected decrypted secret %q, got %q", secret, hook.Secret)
}
if !hook.HasSecret {
t.Error("HasSecret should be true when a secret is configured")
}
// Raw DB value must be encrypted, not plaintext.
var rawSecret string
if err := s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), hook.ID).Scan(&rawSecret); err != nil {
t.Fatalf("read raw secret: %v", err)
}
if rawSecret == secret {
t.Fatal("raw DB value should be encrypted, not plaintext")
}
if !strings.HasPrefix(rawSecret, "enc:") {
t.Errorf("raw DB value should start with 'enc:', got %q", rawSecret)
}
// The decrypted secret must sign identically to the known plaintext (proves
// the dispatcher gets a usable secret).
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte("payload"))
want := hex.EncodeToString(mac.Sum(nil))
got := hmac.New(sha256.New, []byte(hook.Secret))
got.Write([]byte("payload"))
if hex.EncodeToString(got.Sum(nil)) != want {
t.Error("decrypted secret produced a different HMAC than the original")
}
}
// TestWebhookSecret_ListRoundTrips confirms ListWebhooks also decrypts the
// secret for internal/dispatch use.
func TestWebhookSecret_ListRoundTrips(t *testing.T) {
s := testStore(t)
key := make([]byte, 32)
rand.Read(key)
s.SetEncryptionKey(key)
wsID := newWebhookTestWorkspace(t, s)
const secret = "list-secret"
if _, err := s.CreateWebhook(wsID, models.WebhookCreate{URL: "https://example.com/h", Secret: secret}); err != nil {
t.Fatalf("create webhook: %v", err)
}
hooks, err := s.ListWebhooks(wsID)
if err != nil {
t.Fatalf("list webhooks: %v", err)
}
if len(hooks) != 1 {
t.Fatalf("expected 1 webhook, got %d", len(hooks))
}
if hooks[0].Secret != secret {
t.Errorf("expected decrypted secret %q, got %q", secret, hooks[0].Secret)
}
if !hooks[0].HasSecret {
t.Error("HasSecret should be true")
}
}
// TestWebhookSecret_BackfillEncryptsPlaintext covers the back-compat path: a
// pre-encryption plaintext row (inserted before a key was configured) still
// signs correctly, and BackfillEncryptWebhookSecrets encrypts it in place.
func TestWebhookSecret_BackfillEncryptsPlaintext(t *testing.T) {
s := testStore(t)
wsID := newWebhookTestWorkspace(t, s)
// Insert with NO encryption key configured — stored plaintext (legacy row).
const secret = "legacy-plaintext-secret"
hook, err := s.CreateWebhook(wsID, models.WebhookCreate{URL: "https://example.com/legacy", Secret: secret})
if err != nil {
t.Fatalf("create webhook: %v", err)
}
var rawSecret string
s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), hook.ID).Scan(&rawSecret)
if rawSecret != secret {
t.Fatalf("expected plaintext storage without key, got %q", rawSecret)
}
// Now configure a key and run the backfill.
key := make([]byte, 32)
rand.Read(key)
s.SetEncryptionKey(key)
n, err := s.EncryptWebhookSecretsAtRest()
if err != nil {
t.Fatalf("backfill: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 row encrypted, got %d", n)
}
// Raw value now encrypted...
s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), hook.ID).Scan(&rawSecret)
if !strings.HasPrefix(rawSecret, "enc:") {
t.Errorf("expected encrypted value after backfill, got %q", rawSecret)
}
// ...but still decrypts to the original plaintext.
fetched, err := s.GetWebhook(hook.ID)
if err != nil {
t.Fatalf("get webhook: %v", err)
}
if fetched.Secret != secret {
t.Errorf("expected %q after backfill decrypt, got %q", secret, fetched.Secret)
}
// Backfill is idempotent — a second run touches nothing.
if n, err := s.EncryptWebhookSecretsAtRest(); err != nil || n != 0 {
t.Errorf("expected idempotent backfill (0 rows), got n=%d err=%v", n, err)
}
}
// insertRawWebhook writes a webhook row directly, bypassing CreateWebhook's
// encryption, to simulate a legacy pre-encryption row.
func insertRawWebhook(t *testing.T, s *Store, wsID, secret string) string {
t.Helper()
id := newID()
ts := now()
if _, err := s.db.Exec(s.q(`
INSERT INTO webhooks (id, workspace_id, url, secret, events, active, created_at, updated_at, failure_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
`), id, wsID, "https://example.com/x", secret, `["*"]`, s.dialect.BoolToInt(true), ts, ts); err != nil {
t.Fatalf("insert legacy webhook: %v", err)
}
return id
}
// TestWebhookSecret_MigratesEncPrefixedLegacyPlaintext is the review follow-up:
// a legacy plaintext secret that literally starts with the reserved "enc:"
// marker must be encrypted by the one-time migration (first run encrypts every
// pre-encryption value) and round-trip on read.
func TestWebhookSecret_MigratesEncPrefixedLegacyPlaintext(t *testing.T) {
s := testStore(t)
wsID := newWebhookTestWorkspace(t, s)
const secret = "enc:not-actually-encrypted"
id := insertRawWebhook(t, s, wsID, secret)
key := make([]byte, 32)
rand.Read(key)
s.SetEncryptionKey(key)
// First run: flag unset → every existing secret is treated as plaintext,
// including the "enc:"-prefixed one.
n, err := s.EncryptWebhookSecretsAtRest()
if err != nil {
t.Fatalf("migrate: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 row encrypted, got %d", n)
}
var raw string
s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), id).Scan(&raw)
if raw == secret {
t.Fatal("legacy enc:-prefixed plaintext should have been encrypted")
}
fetched, err := s.GetWebhook(id)
if err != nil {
t.Fatalf("get webhook after migrate: %v", err)
}
if fetched.Secret != secret {
t.Errorf("expected %q after migrate, got %q", secret, fetched.Secret)
}
// Second run is steady-state (flag set): the now-genuine ciphertext is left
// alone.
if n, err := s.EncryptWebhookSecretsAtRest(); err != nil || n != 0 {
t.Errorf("expected idempotent run (0 rows), got n=%d err=%v", n, err)
}
}
// TestWebhookSecret_DoesNotRewrapCiphertextOnKeyChange guards the other horn:
// once migrated, genuine ciphertext must NOT be re-encrypted under a rotated /
// wrong key (which would corrupt the secret). Steady-state skips "enc:" rows and
// a wrong key surfaces as a loud decrypt error instead.
func TestWebhookSecret_DoesNotRewrapCiphertextOnKeyChange(t *testing.T) {
s := testStore(t)
wsID := newWebhookTestWorkspace(t, s)
key1 := make([]byte, 32)
rand.Read(key1)
s.SetEncryptionKey(key1)
const secret = "genuine-secret"
hook, err := s.CreateWebhook(wsID, models.WebhookCreate{URL: "https://example.com/g", Secret: secret})
if err != nil {
t.Fatalf("create webhook: %v", err)
}
// Run the migration so the flag is set (steady state hereafter).
if _, err := s.EncryptWebhookSecretsAtRest(); err != nil {
t.Fatalf("migrate: %v", err)
}
var ciphertext string
s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), hook.ID).Scan(&ciphertext)
if !strings.HasPrefix(ciphertext, "enc:") {
t.Fatalf("expected ciphertext, got %q", ciphertext)
}
// Rotate to a different key. A steady-state run must NOT touch the enc: row.
key2 := make([]byte, 32)
rand.Read(key2)
s.SetEncryptionKey(key2)
n, err := s.EncryptWebhookSecretsAtRest()
if err != nil {
t.Fatalf("run after key change: %v", err)
}
if n != 0 {
t.Fatalf("expected 0 rows touched after key change, got %d", n)
}
var after string
s.db.QueryRow(s.q("SELECT secret FROM webhooks WHERE id = ?"), hook.ID).Scan(&after)
if after != ciphertext {
t.Fatal("ciphertext must not be re-wrapped under a rotated key")
}
// And the wrong key fails loud rather than returning corrupt data.
if _, err := s.GetWebhook(hook.ID); err == nil {
t.Error("expected a decrypt error under the wrong key, got nil")
}
}