Skip to content

feat(webhooks): per-subscription signing secrets derived from a master key - #9

Open
ret2libc wants to merge 29 commits into
mainfrom
signed-webhooks-3-server
Open

ret2libc wants to merge 29 commits into
mainfrom
signed-webhooks-3-server

Conversation

@ret2libc

@ret2libc ret2libc commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Adds reveal-once signing secrets to webhook subscriptions, derived from the required master-key file and the subscription ID/version. Secrets are shown on creation and explicit regeneration; editing a subscription or its URL preserves the secret. Regeneration increments the stored version, and replacing the master key invalidates all derived secrets.

Includes the version migration, authenticated regeneration endpoint, dashboard controls, startup validation, and Docker Compose key-file mounting and setup instructions. Updated against current main, preserving monitor-config shard discovery and the notifier refactor. Signed dispatch is added separately in #8.

Validation: go test ./..., go test -race ./..., and golangci-lint v2.6.2. Fixed the session-cookie duration naming lint finding. The existing CodeQL configuration is unchanged: its custom analysis fails because GitHub default setup is enabled and rejects duplicate advanced-configuration uploads.

Fresh GitHub CI passed unit tests, race tests, end-to-end tests, lint, license checks, dependency review, and default CodeQL scanning. The duplicate custom CodeQL upload remains failing with the default/advanced setup conflict; configuration was left unchanged as requested.

ret2libc and others added 15 commits June 23, 2026 15:24
Build rekor-watch on top of rekor-monitor: a long-running daemon that
polls the Rekor transparency log, matches new entries against per-user
subscriptions, and delivers notifications, fronted by a web dashboard.

Core watcher (cmd/rekor_watch):
- Monitor loop that polls Rekor, stores checkpoints, and persists matches.
- Track Rekor shard rollovers and close boundary gaps so entries are not
  silently dropped when the active shard changes.
- Persist failed entries for later inspection.

Persistence (pkg/store, pkg/store/sqlite):
- SQLite-backed store for users, subscriptions, matches, and checkpoints,
  with golang-migrate migrations (each up paired with a down) covering
  cascade deletes, webhook-failure tracking, notification type, indexes,
  failed entries, and subscription names.
- Foreign keys and pragmas configured via the DSN.

Subscriptions and matching:
- Users and subscriptions with full CRUD REST APIs.
- Required, per-user subscription names surfaced on matches/notifications.
- Fulcio OID names for creating/editing subscriptions and tightened OID
  validation; per-user subscription cap and per-subscription match cap.

Notifications (cmd/rekor_watch/notifications, pkg/email):
- Webhook and email backends; dispatch by notification type.
- Batched webhook delivery and email digests per subscription per cycle.
- Webhook payloads wrapped in a {type, timestamp, data} envelope.
- Exponential-backoff retries, auto-disable, and persistent retry
  scheduling for failing webhooks.
- SMTP configuration with XOAUTH2 support; SMTP password kept out of help.

Web UI and HTTP server (cmd/rekor_watch/web):
- Dashboard with subscription management, channel selector, and match views.
- Magic-link auth with login UI and hardened token handling.
- Per-user rate limiting for login/auth, reusable rate-limit middleware,
  and SSRF prevention (safe dialer) around webhook destinations.

Tooling and ops:
- Dockerfile.watch and env-file-driven Docker Compose profile.
- Init and DB-seeding scripts, .env.example, and updated README/.gitignore.

Signed-off-by: Riccardo Schirone <riccardo.schirone@trailofbits.com>
mainLoopV2 had grown to ten parameters and threaded the same
(store, searchOpts, maxMatches) bundle through a set of free functions
on every cycle. Restructure the v2 main loop into collaborator types so
the loop body becomes pure composition.

- Promote the monitoring iteration (runMonitoringIterationV2,
  catchUpShard, decideSearchRange and the subscription helpers) to a
  monitor type that holds its stable dependencies once and exposes a
  runOnce method satisfying IterationFunc. The monitoring domain moves
  to a new monitor.go, leaving main.go to wiring. catchUpShard becomes a
  method so the store, shard set, search options and per-subscription
  match cap are reached through the receiver.
- Promote sendNotifications to a notifier type built once with its
  stable dependencies (store, webhook sender, email sender, rate
  limiter). The webhook sender is constructed a single time in
  newNotifier rather than rebuilt every cycle, and runOnce takes now as
  a parameter so tests drive retry/backoff windows deterministically.
- Drop the intermediate mainLoopV2Config bag and build the monitor and
  notifier directly at the composition root, handing their runOnce
  methods to monitorLoop.

No behavior change.

Signed-off-by: Riccardo Schirone <riccardo.schirone@trailofbits.com>
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>
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 requested a review from facutuesca June 29, 2026 13:36
Comment thread README.md
Following the docker-compose instructions failed to start because the
mandatory webhook signing-secret master key file was never made available
inside the container. Bind-mount it read-only at a fixed in-container path
(host path from REKOR_WATCH_WEBHOOK_SECRET_KEY_FILE, default
./webhook_secret.key), pin the container env to that path, and document the
host-vs-container distinction in the README and .env.example. The quick start
now generates the key before `up`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ret2libc and others added 2 commits June 29, 2026 17:40
The deploy image runs as distroless nonroot (UID 65532), whose UID differs
from the host user that creates webhook_secret.key. A read-only bind-mount
preserves host ownership and mode, so a chmod 600 file owned by the host user
is unreadable inside the container and the service fails to start on
os.ReadFile (EACCES) — the same symptom as a missing file. Use chmod 644 in the
Docker quick start and explain the requirement (and the production alternative
of matching ownership) in the Signing secret section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the full explanation in the Signing secret section; the quick start now
just points there. Also gitignore webhook_secret.key so the generated local
master key is never committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ret2libc
ret2libc force-pushed the signed-webhooks-3-server branch from df17653 to e397647 Compare June 29, 2026 15:50
ret2libc and others added 2 commits June 29, 2026 17:56
644 isn't required for security — the nonroot container user (65532) just needs
read access. Spell out the chmod 640 + chgrp 65532 alternative that keeps the
key out of world-read while preserving host-user ownership.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drop the nonroot-UID explanation and the 640/chgrp alternative; use chmod 644
consistently across the quick start and the Signing secret section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ret2libc
ret2libc requested a review from facutuesca June 29, 2026 16:02
ret2libc and others added 5 commits June 30, 2026 15:29
Updating a subscription (including changing its webhook URL) no longer bumps
webhook_secret_version; the signing secret rotates only on an explicit
Regenerate secret.

Decoupling the secret from updates removes the reason updateSubscription
needed a read-modify-write: it no longer has to compute "did the URL change"
to reveal a rotated secret. That comparison was the source of a TOCTOU race —
it was evaluated at SELECT time while the version bump happened at UPDATE time,
so a concurrent writer in between could rotate the secret without revealing it
(or reveal a stale one). updateSubscription is now a single, race-free UPDATE
with no optimistic-concurrency guard, and ErrConcurrentModification is gone.

The update handler no longer reveals a secret, and the docs/UI note that
editing a subscription does not rotate its secret.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GetSubscription had no production callers (there is no single-subscription GET
endpoint) — only tests used it. Drop it from the SubscriptionStore interface and
the Store/Tx implementations, and replace the test assertions with a small
direct-read helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A missing host ./webhook_secret.key made Docker silently create a directory at
the bind-mount source, which the app then rejected at runtime with "failed to
read webhook master key file: ... is a directory" — confusing and only visible
in container logs.

Switch to the long-form bind mount with bind.create_host_path:false so a
missing key file fails fast at `docker compose up` with a clear "bind source
path does not exist", and no stray directory is created. Behaviour with the
file present is unchanged (read-only, SELinux relabel preserved).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dump the full service list (URL + MajorAPIVersion) alongside the count so a
URL that matches no service — and thus falls back to the unimplemented v1 — is
diagnosable from the logs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ret2libc and others added 4 commits September 11, 2026 14:28
Add protojson-serialized dev.sigstore.monitor.v1.MonitorConfig files that
stand in for the TUF targets not yet published by root-signing. Only rekorLogs
is populated. Entries are derived from each environment's trusted_root.json,
with every logOrigin confirmed against the log's live checkpoint.

Monitors need this because the trusted root and signing config can no longer
be joined on URL. In staging the v2 signing URL global.rekor.sigstage.dev is
write-only (501 on reads), while reads and the checkpoint origin live at
log2026-1.us-east4.rekor.sigstage.dev.

Signed-off-by: Riccardo Schirone <riccardo.schirone@trailofbits.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Use --monitor-config / REKOR_WATCH_MONITOR_CONFIG to discover current and
retired Rekor v2 shards by read URL, checkpoint origin, and API version.
Resolve shard validity against the trusted root and refresh targets on each
iteration, retaining existing clients if refresh fails.

This restores staging monitoring when the signing config advertises a
write-only endpoint. Remove the redundant URL selector and ship the monitor
configs in the watch image, defaulting to staging.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Riccardo Schirone <riccardo.schirone@trailofbits.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.

2 participants