From 1fba99c68dc40b48464c426c2c69870033e5d4e8 Mon Sep 17 00:00:00 2001 From: Jimmy Choca Date: Fri, 11 Sep 2026 15:49:02 -0400 Subject: [PATCH 1/2] add signature verification for webhooks --- CLAUDE.md | 3 + README.md | 61 ++++++++++++ sdk_helo_email/__init__.py | 22 +++++ sdk_helo_email/_exceptions.py | 29 ++++++ sdk_helo_email/_webhooks.py | 155 +++++++++++++++++++++++++++++++ tests/test_webhook_signatures.py | 125 +++++++++++++++++++++++++ 6 files changed, 395 insertions(+) create mode 100644 sdk_helo_email/_webhooks.py create mode 100644 tests/test_webhook_signatures.py diff --git a/CLAUDE.md b/CLAUDE.md index a0ba7d6..8e720c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,9 @@ make build # build a wheel and sdist - `types/shared.py` — the `HeloModel` pydantic base plus every enum. `types/.py` holds the response models a single tag owns; models two or more tags reach live in `shared`. - `types/params.py` — TypedDicts for the object-shaped values request bodies accept. +- `_webhooks.py` — HMAC-SHA256 verification of the `X-Helo-Webhook-Signature` header + (`verify_webhook_signature`, `is_valid_webhook_signature`, `generate_webhook_signature`); the + `WebhookSignature*Error` classes in `_exceptions.py` say why a delivery was rejected. Request bodies are flattened into keyword arguments, so callers write `client.sending.transactional(from_=..., to=[...])` rather than assembling a dict. `_utils.py` diff --git a/README.md b/README.md index 95e6b18..87a5f19 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,67 @@ except helo.APIError as exc: Network failures raise `APIConnectionError` (or `APITimeoutError`) once retries are exhausted. Both subclass `HeloError`, the base of every exception this library raises. +## Webhook signature verification + +Webhook deliveries are signed with the endpoint's signing key. Verify every delivery before +acting on it, against the **raw** request body — parsing and re-serializing the JSON changes +the bytes and the signature will not match. + +```python +import json +import os + +from flask import Flask, abort, request + +import sdk_helo_email as helo + +app = Flask(__name__) + + +@app.post("/webhooks/helo") +def receive_webhook() -> tuple[str, int]: + try: + helo.verify_webhook_signature( + request.headers.get("X-Helo-Webhook-Signature"), + request.get_data(), # raw body, exactly as received + os.environ["HELO_WEBHOOK_SIGNING_KEY"], + ) + except helo.WebhookSignatureError: + abort(400) + + event = json.loads(request.get_data()) + # ... handle the event, then acknowledge quickly + return "", 204 +``` + +`verify_webhook_signature` returns `None` when the signature is valid and raises otherwise. +Each rejection has its own class, so a stale delivery can be treated differently from a +genuinely bad one: + +| Exception | Meaning | +| --- | --- | +| `WebhookSignatureMalformedHeaderError` | The header was not in the expected format | +| `WebhookSignatureUnsupportedVersionError` | The delivery used a signing scheme this SDK version cannot verify — upgrade the package | +| `WebhookSignatureTimestampSkewError` | Correctly signed, but too old to accept — possible replay, or clock drift | +| `WebhookSignatureMismatchError` | Wrong signing key, or the body was modified in transit | + +All four inherit from `WebhookSignatureError` (itself a `HeloError`), so catch that +one class to handle any rejection. If you only want a boolean, use +`is_valid_webhook_signature` instead: + +```python +if helo.is_valid_webhook_signature(signature_header, raw_body, signing_key): + ... +``` + +The body may be passed as `str` or `bytes`. The signature header may carry several versions at +once (`t=...,v1=...,v2=...`) while a new signing scheme is being rolled out. This SDK verifies +against the newest version it supports (`SUPPORTED_WEBHOOK_SIGNATURE_VERSIONS`) and ignores +elements it does not recognize, so a rollout will not break this integration. + +To compute a signature yourself — signing a fixture in tests, for example — use +`generate_webhook_signature(payload, signing_key, timestamp)`. + ## Development ```bash diff --git a/sdk_helo_email/__init__.py b/sdk_helo_email/__init__.py index 91c3b85..eb42746 100644 --- a/sdk_helo_email/__init__.py +++ b/sdk_helo_email/__init__.py @@ -12,8 +12,20 @@ PermissionDeniedError, RateLimitError, UnprocessableEntityError, + WebhookSignatureError, + WebhookSignatureMalformedHeaderError, + WebhookSignatureMismatchError, + WebhookSignatureTimestampSkewError, + WebhookSignatureUnsupportedVersionError, ) from ._version import __version__ +from ._webhooks import ( + MAX_WEBHOOK_TIMESTAMP_SKEW_SECONDS, + SUPPORTED_WEBHOOK_SIGNATURE_VERSIONS, + generate_webhook_signature, + is_valid_webhook_signature, + verify_webhook_signature, +) from .types import ( ActivityEvent, ActivityMailAddress, @@ -153,6 +165,16 @@ "PermissionDeniedError", "RateLimitError", "UnprocessableEntityError", + "WebhookSignatureError", + "WebhookSignatureMalformedHeaderError", + "WebhookSignatureMismatchError", + "WebhookSignatureTimestampSkewError", + "WebhookSignatureUnsupportedVersionError", + "MAX_WEBHOOK_TIMESTAMP_SKEW_SECONDS", + "SUPPORTED_WEBHOOK_SIGNATURE_VERSIONS", + "generate_webhook_signature", + "is_valid_webhook_signature", + "verify_webhook_signature", "ActivityEvent", "ActivityMailAddress", "Attachment", diff --git a/sdk_helo_email/_exceptions.py b/sdk_helo_email/_exceptions.py index 858a62e..c29d14b 100644 --- a/sdk_helo_email/_exceptions.py +++ b/sdk_helo_email/_exceptions.py @@ -76,3 +76,32 @@ def __init__(self, message: str, *, retry_after: float | None = None, **kwargs: class InternalServerError(APIError): pass + + +class WebhookSignatureError(HeloError): + """Base class for every reason a webhook signature is rejected. + + Catch this one class when you do not care why a delivery was rejected. + """ + + +class WebhookSignatureMalformedHeaderError(WebhookSignatureError): + """The header was not in the documented ``t={timestamp},v{version}={signature}`` form.""" + + +class WebhookSignatureUnsupportedVersionError(WebhookSignatureError): + """The header carried only signing schemes this SDK does not know how to verify. + + Upgrading the SDK is the fix; see ``SUPPORTED_WEBHOOK_SIGNATURE_VERSIONS``. + """ + + +class WebhookSignatureTimestampSkewError(WebhookSignatureError): + """The signature was correctly formed but its timestamp is too far from the current time. + + It may be a replay. + """ + + +class WebhookSignatureMismatchError(WebhookSignatureError): + """The signature did not match the body: it was tampered with, or the signing key is wrong.""" diff --git a/sdk_helo_email/_webhooks.py b/sdk_helo_email/_webhooks.py new file mode 100644 index 0000000..d30e093 --- /dev/null +++ b/sdk_helo_email/_webhooks.py @@ -0,0 +1,155 @@ +"""Helpers for verifying the signature on an incoming webhook request.""" + +from __future__ import annotations + +import hashlib +import hmac +import re +import time + +from ._exceptions import ( + WebhookSignatureError, + WebhookSignatureMalformedHeaderError, + WebhookSignatureMismatchError, + WebhookSignatureTimestampSkewError, + WebhookSignatureUnsupportedVersionError, +) + +# Signing schemes this SDK can verify. The signature header may carry several +# versions at once (``t=...,v1=...,v2=...``) so that a new scheme can be rolled +# out while receivers upgrade; verification uses the newest version present that +# appears in this tuple, and ignores the rest. +SUPPORTED_WEBHOOK_SIGNATURE_VERSIONS: tuple[int, ...] = (1,) + +# How far a signature's timestamp may be from the current time, in seconds, +# before it is rejected. +MAX_WEBHOOK_TIMESTAMP_SKEW_SECONDS = 300 # 5 minutes + +_TIMESTAMP_VALUE_REGEX = re.compile(r"\A\d+\Z") +_SIGNATURE_KEY_REGEX = re.compile(r"\Av(\d+)\Z") +_HEX_SIGNATURE_REGEX = re.compile(r"\A[a-f0-9]+\Z") + + +def verify_webhook_signature( + signature_header: str | None, request_body: str | bytes, signing_key: str +) -> None: + """Verify a webhook signature header against the raw request body. + + Returns normally when the signature is valid and otherwise raises a + :class:`WebhookSignatureError` subclass describing why it was rejected. + + :param signature_header: value of the signature header sent with the webhook + :param request_body: raw (unparsed) request body + :param signing_key: signing key for the webhook endpoint + """ + timestamp, signatures = _parse_header(signature_header) + + version = _newest_supported_version(signatures) + if version is None: + present = ", ".join(f"v{v}" for v in sorted(signatures)) + raise WebhookSignatureUnsupportedVersionError( + f"Unsupported webhook signature version: header carries only {present}" + ) + + skew = abs(int(time.time()) - int(timestamp)) + if skew > MAX_WEBHOOK_TIMESTAMP_SKEW_SECONDS: + raise WebhookSignatureTimestampSkewError( + f"Webhook signature timestamp outside tolerance: off by {skew}s, " + f"tolerance is {MAX_WEBHOOK_TIMESTAMP_SKEW_SECONDS}s" + ) + + computed = _signature_for_version(version, request_body, signing_key, timestamp) + if not any(hmac.compare_digest(computed, signature) for signature in signatures[version]): + raise WebhookSignatureMismatchError("Webhook signature mismatch") + + +def is_valid_webhook_signature( + signature_header: str | None, request_body: str | bytes, signing_key: str +) -> bool: + """Verify a webhook signature header, returning ``False`` instead of raising. + + See :func:`verify_webhook_signature`. + """ + try: + verify_webhook_signature(signature_header, request_body, signing_key) + except WebhookSignatureError: + return False + return True + + +def generate_webhook_signature(payload: str | bytes, signing_key: str, timestamp: str | int) -> str: + """Compute the hex-encoded HMAC-SHA256 signature for a webhook payload (v1 scheme). + + :param payload: raw (unparsed) request body + :param signing_key: signing key for the webhook endpoint + :param timestamp: unix timestamp in seconds, as sent in the signature header + """ + message = f"{timestamp}.".encode() + _to_bytes(payload) + return hmac.new(signing_key.encode(), message, hashlib.sha256).hexdigest() + + +def _to_bytes(value: str | bytes) -> bytes: + return value.encode() if isinstance(value, str) else value + + +def _signature_for_version( + version: int, payload: str | bytes, signing_key: str, timestamp: str +) -> str: + """Compute the signature for one signing scheme. + + This is the single place a new scheme needs to be added. + """ + if version == 1: + return generate_webhook_signature(payload, signing_key, timestamp) + raise WebhookSignatureUnsupportedVersionError( + f"Unsupported webhook signature version: v{version}" + ) + + +def _parse_header(signature_header: str | None) -> tuple[str, dict[int, list[str]]]: + """Split the header into its timestamp and its signatures keyed by version. + + Elements that are not recognized are ignored, so that a sender adding new + elements does not break verification here. + """ + timestamp: str | None = None + signatures: dict[int, list[str]] = {} + + for element in (signature_header or "").split(","): + key, separator, value = element.strip().partition("=") + if not separator: + continue + + if key == "t": + if not _TIMESTAMP_VALUE_REGEX.match(value): + raise WebhookSignatureMalformedHeaderError("Malformed webhook signature header") + timestamp = value + continue + + match = _SIGNATURE_KEY_REGEX.match(key) + if not match: + continue + version = int(match.group(1)) + + # Only versions this SDK verifies have a signature format it can insist + # on; anything else is recorded but left unchecked. + if version in SUPPORTED_WEBHOOK_SIGNATURE_VERSIONS and not _HEX_SIGNATURE_REGEX.match( + value + ): + raise WebhookSignatureMalformedHeaderError("Malformed webhook signature header") + + signatures.setdefault(version, []).append(value) + + if timestamp is None or not signatures: + raise WebhookSignatureMalformedHeaderError("Malformed webhook signature header") + + return timestamp, signatures + + +def _newest_supported_version(signatures: dict[int, list[str]]) -> int | None: + """Pick the highest version present that this SDK can verify. + + Once a sender emits a newer scheme, the older one stops being honored here. + """ + supported = [v for v in signatures if v in SUPPORTED_WEBHOOK_SIGNATURE_VERSIONS] + return max(supported) if supported else None diff --git a/tests/test_webhook_signatures.py b/tests/test_webhook_signatures.py new file mode 100644 index 0000000..e29b0d1 --- /dev/null +++ b/tests/test_webhook_signatures.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import hashlib +import hmac +import time + +import pytest + +import sdk_helo_email as helo + +SIGNING_KEY = "whsec_test" +BODY = '{"event":"message.delivered"}' + + +def now() -> str: + return str(int(time.time())) + + +def valid_header(timestamp: str | None = None) -> str: + timestamp = timestamp or now() + signature = helo.generate_webhook_signature(BODY, SIGNING_KEY, timestamp) + return f"t={timestamp},v1={signature}" + + +def test_accepts_a_valid_signature() -> None: + header = valid_header() + + helo.verify_webhook_signature(header, BODY, SIGNING_KEY) + + assert helo.is_valid_webhook_signature(header, BODY, SIGNING_KEY) + assert helo.is_valid_webhook_signature(header, BODY.encode(), SIGNING_KEY) + + +# A sender rolling out a new signing scheme emits every version at once. This SDK +# must keep verifying the versions it knows and ignore the rest, otherwise the +# rollout breaks every receiver that has not upgraded yet. +@pytest.mark.parametrize( + "header_format", + [ + "t={t},v1={sig},v2=8badf00d", + "t={t},v2=8badf00d,v1={sig}", + "t={t},v1={sig},alg=sha512", + "t={t}, v1={sig}", + "v1={sig},t={t}", + "t={t},v1=8badf00d,v1={sig}", + ], +) +def test_ignores_unknown_versions_and_elements(header_format: str) -> None: + timestamp = now() + signature = helo.generate_webhook_signature(BODY, SIGNING_KEY, timestamp) + header = header_format.format(t=timestamp, sig=signature) + + assert helo.is_valid_webhook_signature(header, BODY, SIGNING_KEY), header + + +def test_rejects_a_signature_from_a_different_key() -> None: + header = valid_header() + + with pytest.raises(helo.WebhookSignatureMismatchError): + helo.verify_webhook_signature(header, BODY, "wrong-key") + assert not helo.is_valid_webhook_signature(header, BODY, "wrong-key") + + +def test_rejects_a_tampered_body() -> None: + with pytest.raises(helo.WebhookSignatureMismatchError): + helo.verify_webhook_signature(valid_header(), '{"event":"message.bounced"}', SIGNING_KEY) + + +def test_rejects_a_stale_timestamp() -> None: + header = valid_header(str(int(time.time()) - 600)) + + with pytest.raises(helo.WebhookSignatureTimestampSkewError, match="tolerance"): + helo.verify_webhook_signature(header, BODY, SIGNING_KEY) + + +def test_rejects_a_header_carrying_only_unknown_versions() -> None: + timestamp = now() + signature = helo.generate_webhook_signature(BODY, SIGNING_KEY, timestamp) + + with pytest.raises(helo.WebhookSignatureUnsupportedVersionError, match="v2"): + helo.verify_webhook_signature(f"t={timestamp},v2={signature}", BODY, SIGNING_KEY) + + +@pytest.mark.parametrize( + "header_format", + [ + "garbage", + "", + None, + "t={t},v1=ABCDEF", + "t={t}", + "v1={sig}", + "t=yesterday,v1={sig}", + ], +) +def test_rejects_malformed_headers(header_format: str | None) -> None: + timestamp = now() + signature = helo.generate_webhook_signature(BODY, SIGNING_KEY, timestamp) + header = None if header_format is None else header_format.format(t=timestamp, sig=signature) + + with pytest.raises(helo.WebhookSignatureMalformedHeaderError): + helo.verify_webhook_signature(header, BODY, SIGNING_KEY) + + +def test_treats_a_short_hex_signature_as_a_mismatch_not_malformed() -> None: + with pytest.raises(helo.WebhookSignatureMismatchError): + helo.verify_webhook_signature(f"t={now()},v1=abc", BODY, SIGNING_KEY) + + +def test_every_rejection_shares_one_base_class() -> None: + assert not helo.is_valid_webhook_signature("garbage", BODY, SIGNING_KEY) + + with pytest.raises(helo.WebhookSignatureError): + helo.verify_webhook_signature("garbage", BODY, SIGNING_KEY) + + assert issubclass(helo.WebhookSignatureError, helo.HeloError) + + +def test_generate_matches_the_documented_scheme() -> None: + expected = hmac.new( + SIGNING_KEY.encode(), f"1700000000.{BODY}".encode(), hashlib.sha256 + ).hexdigest() + + assert helo.generate_webhook_signature(BODY, SIGNING_KEY, "1700000000") == expected + assert helo.generate_webhook_signature(BODY.encode(), SIGNING_KEY, 1700000000) == expected From 4974b33517bb0f3e9c5ef1962e319a9d0bb0efba Mon Sep 17 00:00:00 2001 From: Jimmy Choca Date: Wed, 16 Sep 2026 14:24:23 -0400 Subject: [PATCH 2/2] bump version --- sdk_helo_email/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk_helo_email/_version.py b/sdk_helo_email/_version.py index 77eaf59..bd25841 100644 --- a/sdk_helo_email/_version.py +++ b/sdk_helo_email/_version.py @@ -1 +1 @@ -__version__ = "1.0.0b9" +__version__ = "1.0.0b10"