Skip to content

Signed webhooks: Standard Webhooks signatures, key rotation, key screens, receiver docs (#5632 Phase C C2) #5998

Closed

Description

@erikdarlingdata

Claude posting for Erik Darling.
Part of #5632 (Phase C, wave 1).

What it does

Signed webhooks, security-sensitive. The generic webhook gets Standard Webhooks headers (webhook-id, webhook-timestamp, webhook-signature, HMAC-SHA256 over id.timestamp.body). Darling generates the key and shows it once. Rotation keeps two keys live for 7 days and sends both signatures. The viewer and the web page get key screens. The receiver docs carry a starter sample check that enforces the 5-minute timestamp window and dedupes on the id.

Security: the signing key is sealed at rest the same way the existing bearer token is. The bearer token lives in generic_headers, sealed with PasswordSeal under a binding that names the slot, the row, the channel proxy and the channel URL (Darling/PerformanceMonitor.Darling.Storage/PasswordBinding.cs:43-45, WebhookSlots; opened at Darling/PerformanceMonitor.Darling.Service/DarlingWebhookSecrets.cs:65, and per route at :80). The signing key takes a new slot in that same path: sealed with the password key ring on save, opened by DarlingWebhookSecrets.Open, and a key that will not open turns the generic channel off for that destination with a health text and never sends unsigned (the rule at DarlingWebhookSecrets.cs:51 for headers). The key never appears in logs, the journal, health text, MCP output or the diagnostics bundle (DiagnosticsBundleWebhookOpenTests.cs is the pattern). The viewer shows it once at generation and never again; a saved value reads back as "saved", the way ViewerWebhookSealing.IsSaved does.

Decisions this slice carries (agreed by Erik on 2026-10-16)

  • D1 (A). "Standard Webhooks: webhook-id, webhook-timestamp, webhook-signature (HMAC-SHA256 over id.timestamp.body). Receivers have libraries in most languages." The id header doubles as D6's event id.
  • D2 (A). "One sealed key per generic channel and per route with its own URL, in the existing sealed-value path. One more sealed field. Darling generates it and shows it once."
  • D3 (A). "Two keys live for a 7-day overlap, both signatures sent. Receivers keep working while they update. Replay: a 5-minute timestamp window and the id as dedupe key, enforced by the receiver (docs and the starter sample)."
  • D4 (A). "Generic webhook only, on once a key exists. Teams, Slack and PagerDuty have their own auth."

Files to touch

Pointers re-checked on dev at c12233ce3.

  • PerformanceMonitor.Notifications/WebhookAlertService.cs: TrySendGenericAlertAsync at 2152; the operator headers are applied only to the generic URL's own origin at 2172-2180 (HeadersApplyTo defined at 2236); BuildGenericPayload at 2335; the shared send PostWebhookAsync at 3390 and 3402 and ApplyHeaders at 3462. The signature must be computed over the exact body bytes that go on the wire. Route sends reach TrySendGenericAlertAsync through the call at 2865. Teams and Slack share PostWebhookAsync and must carry no signing headers (headers is null for them).
  • Darling/PerformanceMonitor.Darling.Storage/PasswordBinding.cs:43-45 (WebhookSlots), Darling/PerformanceMonitor.Darling.Service/DarlingWebhookSecrets.cs:47 (class), :65 (settings row), :80 (route rows), Darling/PerformanceMonitor.Darling.Viewer/ViewerWebhookSealing.cs:23 (save-side sealing, route sealing near line 148).
  • NotificationRoute at PerformanceMonitor.Notifications/NotificationRoute.cs:53 (a route carries GenericUrl but no headers).
  • The viewer route dialog Darling/PerformanceMonitor.Darling.Viewer/NotificationRouteEditDialog.xaml(.cs), the notification settings in the viewer, and the web endpoints in Darling/PerformanceMonitor.Darling.Service/DarlingWebEndpoints.cs.
  • Docs: add the receiver doc and the sample check where the existing webhook docs live.
  • Columns come from Phase C store: the one migration, grants and retention (#5632 Phase C C1) #5997; do not add a migration.

Depends on

#5997 (the key and previous-key columns).

Behaviour to pin

  • A receiver recomputing the HMAC accepts a send.
  • One changed byte in the body fails the check.
  • Both keys verify during the rotation overlap, and only the new key after 7 days.
  • The sample check rejects a 6-minute-old timestamp.
  • Teams and Slack sends carry no signing headers.
  • A route URL is signed with the route's key, not the settings row's.
  • A signing key that will not open turns the channel off and does not send unsigned.
  • The key never appears in health text or logs.

Tests

Behaviour pins only, one test per behaviour listed above. No census, roster or source-text test (the #5833 ruling in .github/REVIEW-TRAPS.md).

Notes

  • Add the new sealed slot to PasswordBindingCensusTests only as far as the existing pins need it to compile; add no new census test.
  • Nothing under deprecated/.
  • At most four parts. If the work needs more, stop and say so in the PR body rather than growing the slice.
  • Report the changelog entry in the PR body (the coordinator splices it after the wave; do not edit CHANGELOG.md).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions