Conversation
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
force-pushed
the
signed-webhooks-4-signing
branch
from
June 23, 2026 15:10
826ec56 to
a87caa8
Compare
This was referenced Jun 23, 2026
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
force-pushed
the
signed-webhooks-4-signing
branch
from
June 26, 2026 15:53
a87caa8 to
c47e653
Compare
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
force-pushed
the
signed-webhooks-4-signing
branch
from
June 26, 2026 15:58
c47e653 to
7033693
Compare
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>
ret2libc
added this pull request to stack #39
September 14, 2026 14:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Signs webhook deliveries with the current per-subscription secret using Standard Webhooks v1 and adds the public
/docs/webhooksverification guide. The notifier passes the exact JSON bytes to the signer and setswebhook-id,webhook-timestamp, andwebhook-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.