Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ make build # build a wheel and sdist
- `types/shared.py` — the `HeloModel` pydantic base plus every enum. `types/<tag>.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`
Expand Down
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions sdk_helo_email/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions sdk_helo_email/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
2 changes: 1 addition & 1 deletion sdk_helo_email/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.0b9"
__version__ = "1.0.0b10"
155 changes: 155 additions & 0 deletions sdk_helo_email/_webhooks.py
Original file line number Diff line number Diff line change
@@ -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
Loading