Skip to content

cmd/rekor_watch: sign webhook deliveries and document verification - #8

Draft
ret2libc wants to merge 18 commits into
signed-webhooks-3-serverfrom
signed-webhooks-4-signing
Draft

ret2libc wants to merge 18 commits into
signed-webhooks-3-serverfrom
signed-webhooks-4-signing

Conversation

@ret2libc

@ret2libc ret2libc commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Signs webhook deliveries with the current per-subscription secret using Standard Webhooks v1 and adds the public /docs/webhooks verification guide. The notifier passes the exact JSON bytes to the signer and sets webhook-id, webhook-timestamp, and webhook-signature. A missing deriver fails delivery and schedules retry without sending an unsigned request.

Updated on top of #9 and current main, integrating signing with the extracted notifier and retaining monitor-config shard discovery. The guide documents explicit secret regeneration and entry-level deduplication when retries regroup batches.

Validation: go test ./..., go test -race ./..., go build ./..., and golangci-lint v2.6.2. Coverage includes signatures over wire bytes, dispatch with the current secret version, regeneration, missing-key failure/retry, and the docs route. Corrected an undersized test master key and removed a tautological assertion that failed staticcheck.

Depends on #9 (signed-webhooks-3-server).

Fresh GitHub lint, license, and dependency-review checks passed. Full local unit and race suites cover this stacked branch; the CI workflow targets main, so its hosted unit/end-to-end run is attached to #9.

ret2libc and others added 3 commits June 23, 2026 17:07
Add the HKDF-based per-subscription webhook signing-secret deriver, plus the
Standard Webhooks library we sign with (no dispatch wiring yet):

- WebhookSecretDeriver derives a secret on demand from a master key via
  HKDF-SHA256 over (subID, version). Nothing is stored; a regenerate is just a
  version bump. The master key loads from a 0600 file (>= 32 bytes, base64) and
  fails closed.
- Secrets use the Standard Webhooks whsec_<base64(24B)> format. Signing is done
  by the official standard-webhooks Go library (pure stdlib); an interop test
  pins our usage against the spec's canonical v1 test vector so reference
  verifiers stay compatible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 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>
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>
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 force-pushed the signed-webhooks-4-signing branch from a87caa8 to c47e653 Compare June 26, 2026 15:53
Turn on Standard Webhooks v1 signing in the dispatch path and ship a
subscriber-facing verification guide.

- WebhookSender.Send signs the exact marshalled body via the standard-webhooks
  library, setting webhook-id/webhook-timestamp/webhook-signature (one
  time.Now() feeds both the signature and the header). Unsigned deliveries are
  unchanged.
- sendNotifications derives the current secret per webhook batch and signs.
  The deriver is mandatory, so there is no unsigned fallback; a derive error is
  a delivery failure, never an unsigned send. webhookEventID builds the stable
  sub_<id>-batch_<min>-<max> idempotency key.
- main.go threads the deriver through mainLoopV2 into dispatch.
- New GET /docs/webhooks page documents the envelope, headers, HMAC
  verification algorithm, and reveal-once/regenerate semantics; the dashboard
  webhook field links to it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ret2libc
ret2libc force-pushed the signed-webhooks-4-signing branch from c47e653 to 7033693 Compare June 26, 2026 15:58
The example showed monitored_value as {"subject": ...} and listed every
entry field with "..." placeholders. The actual wire format mirrors the
subscription matcher verbatim ({"certSubject", "issuers", "type"}) and
omits empty entry fields. Replace with an anonymized example matching the
real output and note which entry fields are always present vs. optional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant