Conversation
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-2-store
branch
from
June 23, 2026 15:10
ca2370e to
604aad8
Compare
ret2libc
force-pushed
the
signed-webhooks-3-server
branch
from
June 23, 2026 15:10
d8d1d4b to
1ba8cce
Compare
ret2libc
force-pushed
the
signed-webhooks-2-store
branch
from
June 24, 2026 09:50
604aad8 to
b5cc1ba
Compare
ret2libc
changed the base branch from
signed-webhooks-2-store
to
signed-webhooks-1-crypto
June 24, 2026 10:03
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
force-pushed
the
signed-webhooks-3-server
branch
2 times, most recently
from
June 26, 2026 14:41
78d4832 to
f8dda30
Compare
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
force-pushed
the
signed-webhooks-3-server
branch
from
June 26, 2026 15:35
f8dda30 to
0465ee4
Compare
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>
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.
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
000009, default1, no backfill) that on-demand secret derivation keys on. The secret itself is never stored.SubscriptiongainsWebhookSecretVersion(json:"-", internal bookkeeping).SaveSubscriptionreflects the persisted version back onto the struct viaINSERT ... 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 (bothErrNotFoundfor missing/not-owner).scanSubscriptionRows, and theListPendingMatchesjoin carry the new column.Server + UI
ServerConfig/Servergain aSecretDeriver;main.goloads it fromREKOR_WATCH_WEBHOOK_SECRET_KEY_FILEand fails closed (the key is mandatory so deliveries are never sent unsigned).POST /api/subscriptions/{id}/regenerate-secretbumps the version (hard cutover) and returns the new secret reveal-once, rejecting email subs (400) and non-owners (404). Ownership/type are checked viastore.GetSubscription.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:
🤖 Generated with Claude Code