Skip to content

cmd/rekor_watch: webhook signing-secret store + reveal-once secret + regenerate endpoint - #7

Closed
ret2libc wants to merge 12 commits into
signed-webhooks-1-cryptofrom
signed-webhooks-3-server
Closed

ret2libc wants to merge 12 commits into
signed-webhooks-1-cryptofrom
signed-webhooks-3-server

Conversation

@ret2libc

@ret2libc ret2libc commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Slices 2+3 of 4 — store layer + reveal-once secret, regenerate endpoint, UI

Folds the former store-only slice (was #6) into the server slice that actually consumes it, so nothing in this PR is dead weight: the store primitives land alongside their first real caller. Signing is not yet applied to deliveries — that's slice 4 (#8).

Store layer

  • Persists a per-subscription signing-secret version counter (migration 000009, default 1, no backfill) that on-demand secret derivation keys on. The secret itself is never stored.
  • Subscription gains WebhookSecretVersion (json:"-", internal bookkeeping). SaveSubscription reflects the persisted version back onto the struct via INSERT ... RETURNING, so the secret revealed right after create matches what dispatch will sign with.
  • RegenerateWebhookSecret(id, userID) bumps the counter scoped to the owner; GetSubscription(id, userID) returns one owner-scoped subscription (both ErrNotFound for missing/not-owner).
  • All subscription SELECTs, scanSubscriptionRows, and the ListPendingMatches join carry the new column.

Server + UI

  • ServerConfig/Server gain a SecretDeriver; main.go loads it from REKOR_WATCH_WEBHOOK_SECRET_KEY_FILE and fails closed (the key is mandatory so deliveries are never sent unsigned).
  • Creating a webhook subscription returns the derived secret once in the response; email subscriptions omit it.
  • POST /api/subscriptions/{id}/regenerate-secret bumps the version (hard cutover) and returns the new secret reveal-once, rejecting email subs (400) and non-owners (404). Ownership/type are checked via store.GetSubscription.
  • Dashboard shows the secret once with a copy/dismiss control and adds a per-webhook "Regenerate secret" button.

Tests

go test ./pkg/store/... ./cmd/rekor_watch/web/ ./cmd/rekor_watch/ — migration default, version bump/persistence, owner-scoping, save-reflects-version; create-returns-secret-once (matching the version-1 derivation), email omits, regenerate bump/reject/owner-scope, reveal-once UI presence, and fail-closed key-file config.

Stack

Part of the signed-webhooks stack — based on #5:

  1. cmd/rekor_watch/notifications: add webhook secret deriver #5 crypto libs
  2. store + server reveal-once + regenerate + UI ← this PR (former pkg/store: add webhook_secret_version and regenerate #6 folded in)
  3. dispatch signing + docs (cmd/rekor_watch: sign webhook deliveries and document verification #8)

🤖 Generated with Claude Code

ret2libc and others added 2 commits June 23, 2026 17:07
Persist a per-subscription signing-secret version counter (migration 000009,
default 1, no backfill) that on-demand secret derivation keys on. The secret
itself is never stored.

- Subscription gains WebhookSecretVersion (json:"-", internal bookkeeping).
  SaveSubscription reflects the persisted version back onto the struct via
  INSERT ... RETURNING, so a caller deriving the reveal-once secret right
  after create uses the same version dispatch signs with.
- RegenerateWebhookSecret bumps the counter scoped to the owning user and
  returns the new version (ErrNotFound for missing/not-owner).
- GetSubscription(id, userID) returns one owner-scoped subscription
  (ErrNotFound otherwise), matching the other scoped store methods.
- All subscription SELECTs, scanSubscriptionRows, and the ListPendingMatches
  join carry the new column so dispatch can derive the current secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire the secret deriver into the web server and expose the per-subscription
signing secret to its owner, reveal-once:

- ServerConfig/Server gain a SecretDeriver; main.go loads it from
  REKOR_WATCH_WEBHOOK_SECRET_KEY_FILE and fails closed (the key is mandatory
  so deliveries are never sent unsigned, and the deriver is always present).
- Creating a webhook subscription returns the derived secret once in the
  response; email subscriptions omit it.
- POST /api/subscriptions/{id}/regenerate-secret bumps the version (hard
  cutover) and returns the new secret reveal-once, rejecting email subs (400)
  and non-owners (404). Ownership/type are checked via store.GetSubscription.
- Dashboard shows the secret once with a copy/dismiss control and adds a
  per-webhook "Regenerate secret" button.

Signing is not yet applied to deliveries; that follows in the next change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ret2libc
ret2libc force-pushed the signed-webhooks-2-store branch from ca2370e to 604aad8 Compare June 23, 2026 15:10
@ret2libc
ret2libc force-pushed the signed-webhooks-3-server branch from d8d1d4b to 1ba8cce Compare June 23, 2026 15:10
@ret2libc
ret2libc force-pushed the signed-webhooks-2-store branch from 604aad8 to b5cc1ba Compare June 24, 2026 09:50
@ret2libc ret2libc changed the title cmd/rekor_watch: reveal-once webhook secret + regenerate endpoint cmd/rekor_watch: webhook signing-secret store + reveal-once secret + regenerate endpoint Jun 24, 2026
@ret2libc
ret2libc changed the base branch from signed-webhooks-2-store to signed-webhooks-1-crypto June 24, 2026 10:03
ret2libc and others added 8 commits June 24, 2026 12:30
Use a shared typed secretResponse for both the create and regenerate
endpoints instead of an ad-hoc map, document why handleRegenerateSecret
makes two store calls (400-vs-404 before mutating), and trim over-verbose
comments (migration prose, struct field, SaveSubscription RETURNING).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These were string-presence greps over the embedded HTML/JS, not behavior
tests: they pass even if the JS is broken and break on harmless renames or
copy tweaks. They also don't cross-check the JS route against the route
constant or the element id against the template, so they don't guard the
one gap they gesture at. Server-side behavior is covered by
webhook_secret_handlers_test.go.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- rejectsEmailSubscription: assert exactly 400 instead of "400 or 409";
  the handler returns 400 deterministically, so the looser check could
  mask a status-code regression.
- rejectsNonOwner: drop the captured-then-discarded owner var (only the
  session string is used).
- returnsNewSecret: pin the regenerated secret to the version-2 derivation,
  mirroring the create test's version-1 check, so a wrong-version bump
  can't slip past the "differs from create" assertion.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The derived signing secret now incorporates the webhook URL in the HKDF
info (Secret(subID, version, webhookURL)), so each secret is bound to the
destination it was issued for. Changing a subscription's webhook URL bumps
the signing-secret version in the same UPDATE statement (atomic with the
URL change, so dispatch never pairs the new URL with the old version) and
the update endpoint reveals the freshly rotated secret reveal-once, exactly
like create/regenerate. Updates that don't change the URL neither bump the
version nor reveal a secret.

Tests: deriver differs across URLs; store bumps version on URL change only;
update handler reveals the version-2/new-URL secret on URL change and omits
it on a name-only change. Existing create/regenerate derivation assertions
updated for the new signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The route constant name ends in 'Secret' and is assigned a string literal,
which gosec G101 flags as a hardcoded credential. It is an HTTP route path;
suppress with the same nolint directive main.go already uses for env-var
name constants.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the webhook URL from the HKDF derivation: Secret(subID, version). The
URL is not something a receiver verifies, so binding the secret to it added
no verifiable property — only internal defense-in-depth already covered by
the atomic version bump. The version is now the single rotation counter,
bumped on an explicit regenerate and (atomically) whenever the URL changes,
so a URL change still rotates the secret and reveals it once. Behavior is
unchanged; the derivation just has one fewer (user-controlled, normalization-
sensitive) input.

Reverts only the derivation part of the earlier URL-binding change; the
version-bump-on-URL-change and reveal-on-update logic stays.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Leave cmd/rekor_watch/notifications/webhook_secret.go untouched by this PR;
the version-rotation context lives in the migration and store/interface docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
handleRegenerateSecret and handleUpdateSubscription each did a
GetSubscription pre-read before the mutating store call: regenerate to
return 400 (email sub) vs 404 (missing/not-owner), update to detect a
webhook URL change and reveal the rotated secret. Fold both decisions
into the mutating call so each handler makes a single store call, which
also closes the read-then-mutate window.

- RegenerateWebhookSecret bumps the version only for webhook rows (CASE)
  and RETURNs the type, yielding ErrNotFound for missing/not-owner and
  the new ErrNotWebhook sentinel for an owned non-webhook subscription.
- UpdateSubscription now returns secretRotated; it reads the prior URL
  and updates within one transaction so rotation detection and the
  version bump observe a consistent snapshot. The handler reveals the
  secret iff secretRotated.

Tests: add store-level ErrNotWebhook coverage and assert the
secretRotated result on URL-change/no-change updates; adapt existing
UpdateSubscription call sites to the new signature.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ret2libc
ret2libc force-pushed the signed-webhooks-3-server branch 2 times, most recently from 78d4832 to f8dda30 Compare June 26, 2026 14:41
UpdateSubscription reads the row then writes it, so a racing update or
secret regeneration in between could make it write a version bump derived
from stale data. Instead of a transaction, gate the write on the secret
version read up front: the UPDATE carries WHERE ... AND
webhook_secret_version = <read value>, so a racing bump makes it match no
row and the caller gets the new ErrConcurrentModification (mapped to 409)
rather than a stale write. Each statement is its own autocommit, so there
is no transaction and no held-snapshot race.

Add a concurrent-update regression test (final version == 1 + commits)
and trim the over-verbose comments from the earlier commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ret2libc
ret2libc force-pushed the signed-webhooks-3-server branch from f8dda30 to 0465ee4 Compare June 26, 2026 15:35
REKOR_WATCH_WEBHOOK_SECRET_KEY_FILE is required (the watcher refuses to
start without it) but was absent from the README env table and
.env.example. Add a "Signing secret" section with the key-generation
command (openssl rand -base64 32), an env-table row, and an .env.example
entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ret2libc
ret2libc marked this pull request as ready for review June 29, 2026 12:01
@ret2libc
ret2libc requested a review from facutuesca June 29, 2026 12:01
@facutuesca
facutuesca deleted the branch signed-webhooks-1-crypto June 29, 2026 13:07
@facutuesca facutuesca closed this Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants