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
This commit is contained in:
xarmian
2026-07-11 00:10:27 -04:00
committed by GitHub
parent 3bb50ab27f
commit bfa32dde5a
7 changed files with 558 additions and 7 deletions
+7
View File
@@ -147,6 +147,13 @@ func serveCmd() *cobra.Command {
slog.Info("Encrypted plaintext TOTP secrets", "count", n)
}
// Backfill: encrypt any plaintext webhook HMAC secrets (BUG-2057).
if n, err := s.EncryptWebhookSecretsAtRest(); err != nil {
return fmt.Errorf("encrypt webhook secrets at rest: %w", err)
} else if n > 0 {
slog.Info("Encrypted plaintext webhook secrets", "count", n)
}
// Backfill: populate item_wiki_links from existing item bodies
// (PLAN-1593 / TASK-1594). Idempotent — items already indexed
// at write time get a cheap EXISTS-skip; only newly-introduced
+12 -4
View File
@@ -5,10 +5,18 @@ import "time"
// Webhook represents a registered webhook endpoint that receives
// POST notifications when events occur in a workspace.
type Webhook struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
URL string `json:"url"`
Secret string `json:"secret,omitempty"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
URL string `json:"url"`
// Secret is the HMAC signing secret in PLAINTEXT. It is encrypted at rest
// (see internal/store/webhooks.go) and decrypted on read for internal use
// (the dispatcher signs payloads with it). API responses return the raw
// secret ONLY on creation; list/get responses mask it — see
// internal/server/handlers_webhooks.go::maskWebhookSecret.
Secret string `json:"secret,omitempty"`
// HasSecret reports whether an HMAC secret is configured, without leaking
// the value. Populated on reads so masked responses still signal presence.
HasSecret bool `json:"has_secret"`
Events string `json:"events"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
+30 -1
View File
@@ -3,6 +3,7 @@ package server
import (
"database/sql"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
@@ -11,6 +12,15 @@ import (
"github.com/PerpetualSoftware/pad/internal/webhooks"
)
// maskWebhookSecret blanks the plaintext HMAC secret before a webhook is
// returned in a list/get response, so the raw signing secret is never echoed
// after creation (BUG-2057). The has_secret flag still signals whether one is
// configured. Returns a copy — the caller's slice/model is left untouched.
func maskWebhookSecret(hook models.Webhook) models.Webhook {
hook.Secret = ""
return hook
}
// dispatchWebhook fires a webhook event if a dispatcher is configured.
func (s *Server) dispatchWebhook(workspaceID, event string, data interface{}) {
if s.webhooks == nil {
@@ -45,6 +55,16 @@ func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// "enc:" is the reserved marker the store uses to tag encrypted secrets at
// rest (see internal/store/encryption.go::encryptedPrefix). Reject a raw
// secret that starts with it so a user-supplied plaintext can never be
// mistaken for ciphertext on read (which would break signing/dispatch,
// including on keyless self-host instances).
if strings.HasPrefix(input.Secret, "enc:") {
writeError(w, http.StatusBadRequest, "bad_request", "secret must not start with the reserved prefix \"enc:\"")
return
}
// Validate URL to prevent SSRF attacks
if err := webhooks.ValidateWebhookURL(input.URL); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid webhook URL: "+err.Error())
@@ -57,6 +77,9 @@ func (s *Server) handleCreateWebhook(w http.ResponseWriter, r *http.Request) {
return
}
// The creation response is the ONLY place the raw secret is returned, so
// the caller can record it for HMAC verification. It is masked everywhere
// else (BUG-2057).
writeJSON(w, http.StatusCreated, hook)
}
@@ -80,7 +103,13 @@ func (s *Server) handleListWebhooks(w http.ResponseWriter, r *http.Request) {
hooks = []models.Webhook{}
}
writeJSON(w, http.StatusOK, hooks)
// Never echo the raw signing secret in a list response (BUG-2057).
masked := make([]models.Webhook, len(hooks))
for i, hook := range hooks {
masked[i] = maskWebhookSecret(hook)
}
writeJSON(w, http.StatusOK, masked)
}
// handleDeleteWebhook removes a webhook by ID.
@@ -0,0 +1,101 @@
package server
import (
"crypto/rand"
"encoding/json"
"net/http"
"strings"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TestWebhookSecret_MaskedExceptOnCreate is the BUG-2057 API-surface regression:
// the raw HMAC secret is returned ONLY in the creation response and is masked
// (absent) from list responses, which instead expose only has_secret.
func TestWebhookSecret_MaskedExceptOnCreate(t *testing.T) {
srv := testServer(t)
// Configure an encryption key so the secret is genuinely encrypted at rest.
key := make([]byte, 32)
rand.Read(key)
srv.store.SetEncryptionKey(key)
token := bootstrapFirstUser(t, srv, "owner@test.com", "Owner")
owner, err := srv.store.GetUserByEmail("owner@test.com")
if err != nil || owner == nil {
t.Fatalf("GetUserByEmail: %v", err)
}
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Hooks", OwnerID: owner.ID})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
const secret = "top-secret-hmac-key"
base := "/api/v1/workspaces/" + ws.Slug + "/webhooks"
// CREATE — the raw secret MUST be echoed back exactly once.
rr := doRequestWithCookie(srv, "POST", base, map[string]any{
"url": "https://example.com/hook",
"secret": secret,
}, token)
if rr.Code != http.StatusCreated {
t.Fatalf("create: status=%d body=%s", rr.Code, rr.Body.String())
}
var created models.Webhook
if err := json.Unmarshal(rr.Body.Bytes(), &created); err != nil {
t.Fatalf("decode create: %v", err)
}
if created.Secret != secret {
t.Errorf("create response should return raw secret %q, got %q", secret, created.Secret)
}
if !created.HasSecret {
t.Error("create response should report has_secret=true")
}
// LIST — the raw secret must NOT appear anywhere in the response body.
rr = doRequestWithCookie(srv, "GET", base, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("list: status=%d body=%s", rr.Code, rr.Body.String())
}
if body := rr.Body.String(); strings.Contains(body, secret) {
t.Fatalf("raw secret leaked in list response: %s", body)
}
var listed []models.Webhook
if err := json.Unmarshal(rr.Body.Bytes(), &listed); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(listed) != 1 {
t.Fatalf("expected 1 webhook, got %d", len(listed))
}
if listed[0].Secret != "" {
t.Errorf("list response should mask secret, got %q", listed[0].Secret)
}
if !listed[0].HasSecret {
t.Error("list response should still report has_secret=true")
}
}
// TestWebhookSecret_RejectsReservedPrefix pins the review follow-up: a create
// request whose secret starts with the reserved "enc:" marker is rejected, so a
// user plaintext can never masquerade as ciphertext at rest.
func TestWebhookSecret_RejectsReservedPrefix(t *testing.T) {
srv := testServer(t)
token := bootstrapFirstUser(t, srv, "owner@test.com", "Owner")
owner, err := srv.store.GetUserByEmail("owner@test.com")
if err != nil || owner == nil {
t.Fatalf("GetUserByEmail: %v", err)
}
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "Hooks", OwnerID: owner.ID})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/webhooks", map[string]any{
"url": "https://example.com/hook",
"secret": "enc:sneaky",
}, token)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for reserved-prefix secret, got status=%d body=%s", rr.Code, rr.Body.String())
}
}
+110
View File
@@ -142,3 +142,113 @@ func (s *Store) BackfillEncryptTOTPSecrets() (int, error) {
return len(toEncrypt), nil
}
// webhookSecretsEncryptedFlag marks that the one-time legacy migration of
// pre-encryption webhook secrets has run. Stored in platform_settings.
const webhookSecretsEncryptedFlag = "webhook_secrets_encrypted_v1"
// EncryptWebhookSecretsAtRest encrypts plaintext webhook HMAC secrets (BUG-2057).
// Called on startup when an encryption key is configured. It handles two
// populations without ever corrupting genuine ciphertext:
//
// - First run (flag unset): webhook-secret encryption is new in this release,
// so every existing secret in the DB is plaintext — even one that
// coincidentally starts with the reserved "enc:" marker. Encrypt them ALL,
// then persist the flag. Because this runs exactly once, ciphertext written
// by later encrypted creates is never re-wrapped under a rotated key.
// - Steady state (flag set): only unprefixed plaintext is encrypted (a
// webhook created while the instance was keyless). "enc:" values are always
// genuine ciphertext now — handleCreateWebhook rejects "enc:"-prefixed
// secrets — so they are left alone and a bad key fails loud via decrypt().
//
// Idempotent and safe to run on every boot.
func (s *Store) EncryptWebhookSecretsAtRest() (int, error) {
if !s.HasEncryptionKey() {
// Keyless: nothing to encrypt, and the flag stays unset so the full
// legacy migration still runs if a key is configured later.
return 0, nil
}
migrated, err := s.GetPlatformSetting(webhookSecretsEncryptedFlag)
if err != nil {
return 0, fmt.Errorf("read webhook-secret migration flag: %w", err)
}
firstRun := migrated != "1"
query := `SELECT id, secret FROM webhooks WHERE secret != '' AND secret NOT LIKE 'enc:%'`
if firstRun {
// Every pre-migration secret is plaintext — include "enc:"-prefixed ones.
query = `SELECT id, secret FROM webhooks WHERE secret != ''`
}
rows, err := s.db.Query(s.q(query))
if err != nil {
return 0, fmt.Errorf("query webhook secrets: %w", err)
}
defer rows.Close()
type row struct {
id, secret string
}
var toEncrypt []row
for rows.Next() {
var r row
if err := rows.Scan(&r.id, &r.secret); err != nil {
return 0, fmt.Errorf("scan row: %w", err)
}
// First-run only: an "enc:" value that already decrypts under the
// current key is genuine ciphertext — skip it so it isn't double-
// encrypted. A decrypt failure means legacy plaintext that merely looks
// prefixed (no ciphertext under a different key can exist before the
// migration has ever run), so fall through and encrypt it.
if firstRun && strings.HasPrefix(r.secret, encryptedPrefix) {
if _, derr := s.decrypt(r.secret); derr == nil {
continue
}
}
toEncrypt = append(toEncrypt, r)
}
if err := rows.Err(); err != nil {
return 0, err
}
rows.Close() // release the read before opening the write transaction (SQLite)
if len(toEncrypt) == 0 && !firstRun {
return 0, nil
}
// Apply every row update AND the completion flag in one transaction. A crash
// mid-migration must not leave some rows encrypted while the flag is unset —
// otherwise a later key change would mis-read those rows as legacy plaintext
// and double-encrypt them into nested ciphertext (BUG-2057 review follow-up).
tx, err := s.db.Begin()
if err != nil {
return 0, fmt.Errorf("begin webhook-secret migration: %w", err)
}
defer tx.Rollback()
for _, r := range toEncrypt {
encrypted, err := s.encrypt(r.secret)
if err != nil {
return 0, fmt.Errorf("encrypt secret for webhook %s: %w", r.id, err)
}
if _, err := tx.Exec(s.q(`UPDATE webhooks SET secret = ? WHERE id = ?`), encrypted, r.id); err != nil {
return 0, fmt.Errorf("update secret for webhook %s: %w", r.id, err)
}
}
if firstRun {
if _, err := tx.Exec(s.q(`
INSERT INTO platform_settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
`), webhookSecretsEncryptedFlag, "1", now()); err != nil {
return 0, fmt.Errorf("persist webhook-secret migration flag: %w", err)
}
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("commit webhook-secret migration: %w", err)
}
return len(toEncrypt), nil
}
+21 -2
View File
@@ -17,10 +17,18 @@ func (s *Store) CreateWebhook(workspaceID string, input models.WebhookCreate) (*
evts = `["*"]`
}
_, err := s.db.Exec(s.q(`
// Encrypt the HMAC secret at rest. With no encryption key configured
// (common on self-host) encrypt() returns the plaintext unchanged, so
// this stays a no-op fallback rather than a hard requirement.
encSecret, err := s.encrypt(input.Secret)
if err != nil {
return nil, fmt.Errorf("encrypt webhook secret: %w", err)
}
_, 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, workspaceID, input.URL, input.Secret, evts, s.dialect.BoolToInt(true), ts, ts)
`), id, workspaceID, input.URL, encSecret, evts, s.dialect.BoolToInt(true), ts, ts)
if err != nil {
return nil, fmt.Errorf("insert webhook: %w", err)
}
@@ -50,6 +58,13 @@ func (s *Store) GetWebhook(id string) (*models.Webhook, error) {
return nil, fmt.Errorf("get webhook: %w", err)
}
// Decrypt the secret for internal use (the dispatcher signs with the
// plaintext). Pre-encryption rows lack the "enc:" prefix and pass through
// unchanged, so existing plaintext secrets keep working.
if wh.Secret, err = s.decrypt(wh.Secret); err != nil {
return nil, fmt.Errorf("decrypt webhook secret: %w", err)
}
wh.HasSecret = wh.Secret != ""
wh.Active = active
wh.CreatedAt = parseTime(createdAt)
wh.UpdatedAt = parseTime(updatedAt)
@@ -83,6 +98,10 @@ func (s *Store) ListWebhooks(workspaceID string) ([]models.Webhook, error) {
); err != nil {
return nil, fmt.Errorf("scan webhook: %w", err)
}
if wh.Secret, err = s.decrypt(wh.Secret); err != nil {
return nil, fmt.Errorf("decrypt webhook secret: %w", err)
}
wh.HasSecret = wh.Secret != ""
wh.Active = active
wh.CreatedAt = parseTime(createdAt)
wh.UpdatedAt = parseTime(updatedAt)
+277
View File
@@ -0,0 +1,277 @@
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")
}
}