-
Notifications
You must be signed in to change notification settings - Fork 0
feat: webhook verifier and handler with idempotency guard #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
456146e
301b50b
44f92a3
d23f650
ac76eae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| # ADR-003 — HMAC-SHA256 signature verification for webhook reception | ||
|
|
||
| **Status**: Accepted | ||
| **Date**: 2026-06-09 | ||
| **Deciders**: fullstack-pilot team | ||
|
|
||
| --- | ||
|
|
||
| ## Context | ||
|
|
||
| ADR-001 establishes that the webhook channel is the primary change-propagation path and that the engine must verify each webhook's signature before inserting it into the queue (invariant #4 in CLAUDE.md). The verification mechanism was not specified by ADR-001. | ||
|
|
||
| Three schemes were considered: | ||
|
|
||
| 1. **Symmetric HMAC-SHA256** — sender and receiver share a secret; the sender attaches `HMAC(secret, body)` as a header. | ||
| 2. **Asymmetric signature (RSA/ECDSA)** — sender signs the payload with a private key; receiver verifies with the public key. | ||
| 3. **Bearer token** — sender includes a static shared token in a header; receiver checks equality. | ||
|
|
||
| --- | ||
|
|
||
| ## Decision | ||
|
|
||
| We use **HMAC-SHA256** with a shared secret. | ||
|
|
||
| The signature is transmitted in a request header (e.g. `X-Hub-Signature-256`) as either a bare hex digest or a `sha256=<hex>` prefixed string; both forms are accepted. | ||
|
|
||
| Comparison is performed with `hmac.compare_digest` (constant-time) to prevent timing-oracle attacks. | ||
|
|
||
| Verification is isolated in `src/sync_engine/webhook/verifier.py` as a single pure function: | ||
|
|
||
| ```python | ||
| def verify_hmac_sha256(payload_bytes: bytes, signature_header: str, secret: str) -> None: ... | ||
| ``` | ||
|
|
||
| `WebhookHandler` calls this function as the first step in `handle()`. Any failure raises `WebhookSignatureError` before any queue write occurs (invariant #4). | ||
|
|
||
| --- | ||
|
|
||
| ## Why HMAC-SHA256 over asymmetric signatures | ||
|
|
||
| - HMAC-SHA256 is the de-facto standard in the webhook ecosystem (GitHub, Stripe, Shopify all use it). Third-party sources that emit webhooks to this engine will already be configured for it. | ||
| - Asymmetric signatures add key distribution and rotation complexity for no benefit at this scale: the receiver is a single service, not a distributed public verifier. | ||
| - The security model is equivalent when the shared secret is stored as an environment variable and never committed. | ||
|
|
||
| ## Why HMAC-SHA256 over bearer token | ||
|
|
||
| - A bearer token is a static equality check; its security degrades if the comparison is not constant-time (timing oracle) or if the token leaks in logs. | ||
| - HMAC binds the signature to the payload body, so a replayed token with a different payload is rejected. A bearer token provides no such protection. | ||
|
|
||
| --- | ||
|
|
||
| ## Constant-time comparison requirement | ||
|
|
||
| Naive string comparison (`==`) leaks timing information proportional to the length of the shared prefix, enabling offline secret reconstruction. `hmac.compare_digest` compares in fixed time regardless of where the strings diverge. This is mandatory for all signature comparisons in the engine — any future verification code must follow the same constraint. | ||
|
|
||
| --- | ||
|
|
||
| ## Consequences | ||
|
|
||
| **Positive:** | ||
| - Standard scheme: every major webhook source can be configured to sign with HMAC-SHA256 out of the box. | ||
| - Timing-safe by construction: `hmac.compare_digest` is the only permitted comparison. | ||
| - Isolated: `verifier.py` has no side effects and is independently testable without mocking. | ||
| - Tests compute real HMAC signatures with a fixture secret — no mocking needed, the full verification path is exercised. | ||
|
|
||
| **Negative / accepted costs:** | ||
| - Secret rotation requires coordinating the sender and receiver simultaneously; there is no multi-key grace window in the current implementation. | ||
| - The `sha256=` prefix stripping is a convention, not a standard — future sources may use a different prefix, requiring an extension to `verifier.py`. | ||
|
|
||
| --- | ||
|
|
||
| ## Rejected alternatives | ||
|
|
||
| | Alternative | Reason for rejection | | ||
| |---|---| | ||
| | Asymmetric signature (RSA/ECDSA) | Disproportionate key management overhead; incompatible with most third-party webhook senders | | ||
| | Bearer token | Does not bind signature to payload body; provides no replay protection | | ||
| | No verification | Violates invariant #4 in CLAUDE.md — rejected unconditionally | |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import logging | ||
|
|
||
| from sync_engine.exceptions import DuplicateEventError | ||
| from sync_engine.store.base import SyncStore | ||
| from sync_engine.webhook.verifier import verify_hmac_sha256 | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| class WebhookHandler: | ||
| def __init__(self, store: SyncStore, secret: str) -> None: | ||
| self._store = store | ||
| self._secret = secret | ||
|
|
||
| def handle( | ||
| self, | ||
| event_id: str, | ||
| payload: dict, | ||
| raw_bytes: bytes, | ||
| signature_header: str, | ||
| ) -> None: | ||
| """Verify, deduplicate, and enqueue an incoming webhook event. | ||
|
|
||
| Raises: | ||
| WebhookSignatureError: signature missing or invalid (logged at WARNING). | ||
| DuplicateEventError: event_id already processed (logged at DEBUG). | ||
| """ | ||
| # verify_hmac_sha256 already logs at WARNING and raises WebhookSignatureError | ||
| verify_hmac_sha256(raw_bytes, signature_header, self._secret) | ||
|
|
||
| if self._store.is_event_processed(event_id): | ||
|
koydas marked this conversation as resolved.
|
||
| logger.debug("Duplicate event received, skipping: %s", event_id) | ||
| raise DuplicateEventError(f"Event already processed: {event_id}") | ||
|
|
||
| already_queued = any( | ||
| queued_id == event_id | ||
| for queued_id, _ in self._store.dequeue_unacknowledged() | ||
| ) | ||
|
Comment on lines
+35
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two deliveries with the same Useful? React with 👍 / 👎. |
||
| if already_queued: | ||
| logger.debug("Duplicate event already queued, skipping: %s", event_id) | ||
| raise DuplicateEventError(f"Event already queued: {event_id}") | ||
|
|
||
| self._store.enqueue_webhook(event_id, payload) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| import hashlib | ||
| import hmac | ||
| import logging | ||
|
|
||
| from sync_engine.exceptions import WebhookSignatureError | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def verify_hmac_sha256( | ||
| payload_bytes: bytes, signature_header: str, secret: str | ||
| ) -> None: | ||
| """Verify the HMAC-SHA256 signature of a webhook payload. | ||
|
|
||
| Raises WebhookSignatureError if the header is missing or the signature does | ||
| not match. Uses hmac.compare_digest to prevent timing attacks. | ||
| """ | ||
| if not signature_header: | ||
| logger.warning("Webhook received with missing signature header") | ||
| raise WebhookSignatureError("Missing signature header") | ||
|
|
||
| expected = hmac.new(secret.encode(), payload_bytes, hashlib.sha256).hexdigest() | ||
|
|
||
| # Accept bare hex digest or "sha256=<hex>" prefix | ||
| received = signature_header.removeprefix("sha256=") | ||
|
|
||
| if not hmac.compare_digest(expected, received): | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a malformed signature header contains a non-ASCII character, Useful? React with 👍 / 👎. |
||
| logger.warning("Webhook signature mismatch") | ||
| raise WebhookSignatureError("Signature mismatch") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import hashlib | ||
| import hmac | ||
|
|
||
| import pytest | ||
|
|
||
| from sync_engine.exceptions import DuplicateEventError, WebhookSignatureError | ||
| from sync_engine.store.memory import InMemoryStore | ||
| from sync_engine.webhook.handler import WebhookHandler | ||
|
|
||
| SECRET = "handler-secret" | ||
| EVENT_ID = "evt-001" | ||
| PAYLOAD = {"action": "opened"} | ||
| RAW = b'{"action": "opened"}' | ||
|
|
||
|
|
||
| def _sig(payload: bytes = RAW, secret: str = SECRET) -> str: | ||
| return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def store() -> InMemoryStore: | ||
| return InMemoryStore() | ||
|
|
||
|
|
||
| @pytest.fixture() | ||
| def handler(store: InMemoryStore) -> WebhookHandler: | ||
| return WebhookHandler(store, SECRET) | ||
|
|
||
|
|
||
| def test_valid_event_is_enqueued(store: InMemoryStore, handler: WebhookHandler) -> None: | ||
| handler.handle(EVENT_ID, PAYLOAD, RAW, _sig()) | ||
|
|
||
| unacked = store.dequeue_unacknowledged() | ||
| assert len(unacked) == 1 | ||
| assert unacked[0] == (EVENT_ID, PAYLOAD) | ||
|
|
||
|
|
||
| def test_duplicate_event_raises_and_not_reenqueued( | ||
| store: InMemoryStore, handler: WebhookHandler | ||
| ) -> None: | ||
| store.mark_event_processed(EVENT_ID) | ||
|
|
||
| with pytest.raises(DuplicateEventError): | ||
| handler.handle(EVENT_ID, PAYLOAD, RAW, _sig()) | ||
|
|
||
| assert store.dequeue_unacknowledged() == [] | ||
|
|
||
|
|
||
| def test_duplicate_event_already_queued_raises_and_does_not_overwrite( | ||
| store: InMemoryStore, handler: WebhookHandler | ||
| ) -> None: | ||
| handler.handle(EVENT_ID, PAYLOAD, RAW, _sig()) | ||
|
|
||
| with pytest.raises(DuplicateEventError): | ||
| handler.handle(EVENT_ID, PAYLOAD, RAW, _sig()) | ||
|
|
||
| assert store.dequeue_unacknowledged() == [(EVENT_ID, PAYLOAD)] | ||
|
|
||
|
|
||
| def test_bad_signature_raises_and_not_enqueued( | ||
| store: InMemoryStore, handler: WebhookHandler | ||
| ) -> None: | ||
| with pytest.raises(WebhookSignatureError): | ||
| handler.handle(EVENT_ID, PAYLOAD, RAW, "bad-sig") | ||
|
|
||
| assert store.dequeue_unacknowledged() == [] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import hashlib | ||
| import hmac | ||
|
|
||
| import pytest | ||
|
|
||
| from sync_engine.exceptions import WebhookSignatureError | ||
| from sync_engine.webhook.verifier import verify_hmac_sha256 | ||
|
|
||
| SECRET = "test-secret" | ||
| PAYLOAD = b'{"event": "push"}' | ||
|
|
||
|
|
||
| def _make_sig(payload: bytes, secret: str) -> str: | ||
| return hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() | ||
|
|
||
|
|
||
| def test_valid_signature_raises_nothing() -> None: | ||
| sig = _make_sig(PAYLOAD, SECRET) | ||
| verify_hmac_sha256(PAYLOAD, sig, SECRET) | ||
|
|
||
|
|
||
| def test_valid_signature_with_prefix_raises_nothing() -> None: | ||
| sig = "sha256=" + _make_sig(PAYLOAD, SECRET) | ||
| verify_hmac_sha256(PAYLOAD, sig, SECRET) | ||
|
|
||
|
|
||
| def test_tampered_payload_raises_signature_error() -> None: | ||
| sig = _make_sig(PAYLOAD, SECRET) | ||
| tampered = b'{"event": "delete"}' | ||
| with pytest.raises(WebhookSignatureError): | ||
| verify_hmac_sha256(tampered, sig, SECRET) | ||
|
|
||
|
|
||
| def test_missing_header_raises_signature_error() -> None: | ||
| with pytest.raises(WebhookSignatureError): | ||
| verify_hmac_sha256(PAYLOAD, "", SECRET) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
event_idorpayloadcomes from a header, a separately parsed object, or any other value not derived fromraw_bytes, the signature authenticates onlyraw_byteswhile the independent values are passed to the queue. A captured valid body/signature can therefore be replayed with a newevent_idto bypass deduplication, or paired with a different payload, so the handler should parse and derive all queued identity/content from the verified bytes or otherwise bind them cryptographically.Useful? React with 👍 / 👎.