mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user