Skip to content

Commit 224878e

Browse files
fix(webhooks): generate_signature returns prefixed wire form
Receivers copy-pasting `generate_signature(...)` into their handler used to produce a bare hex digest like `"abc123..."` and had to remember to wrap it as `f"sha256={sig}"` to match the dispatcher's wire format. Forgetting the wrap silently produced malformed signatures that failed verification on the other side. `generate_signature(body, secret)` now returns the full `"sha256=<lowercase hex>"` form, ready to be assigned to the `X-Tango-Signature` header directly. `verify_signature` continues to accept both the prefixed wire form and the bare-hex form so callers that pre-strip the prefix keep working — added an explicit regression test for that. Audited and updated: - `tango/webhooks/simulate.py` — `sign()` builds the header from the prefixed form directly; `SignedRequest.signature` field stays bare hex - `tango/webhooks/cli.py` — uses `SIGNATURE_PREFIX` for the printed signature value (via the SimulationResult.signature bare-hex field) - `tests/test_webhooks_signing.py` — vectors updated to prefixed form; added explicit dual-form acceptance test - `tests/test_webhooks_receiver.py` — `_post_signed` no longer double-wraps the header - `docs/WEBHOOKS.md` and `docs/API_REFERENCE.md` — receiver examples drop the manual `f"sha256={...}"` wrap Breaking change for direct callers of `generate_signature` who relied on bare-hex output — pass through `parse_signature_header()` to recover the previous form. Folded into the v0.7.0 release. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7ede733 commit 224878e

8 files changed

Lines changed: 75 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3131
### Changed
3232
- `create_webhook_alert` accepts `endpoint=` (keyword-only). Required for accounts with multiple webhook endpoints; auto-resolves for single-endpoint accounts. Closes the multi-endpoint smoke-test gap (tango#2256).
3333
- `test_webhook_delivery` now sends the canonical `endpoint` body key instead of the deprecated `endpoint_id` alias (tango#2252). The Python kwarg name stays `endpoint_id=` for backwards compatibility; the wire payload is what changed.
34+
- **`generate_signature(body, secret)` now returns the full wire form `"sha256=<hex>"`** instead of bare hex. Callers can assign the return value directly to the `X-Tango-Signature` header without wrapping in a format string. This is a breaking change for code that relied on the bare-hex return; pass it through `parse_signature_header()` to recover the previous form. `verify_signature` accepts both prefixed and bare-hex inputs (unchanged), so receivers continue to work either way.
3435

3536
### Removed
3637
- **Subject-based webhook subscription surface** (tango#2275). Migrate to `create_webhook_alert(...)` and the alerts API.

docs/API_REFERENCE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1717,7 +1717,7 @@ The `tango.webhooks` subpackage adds testing and developer-tooling primitives on
17171717
```python
17181718
from tango.webhooks import (
17191719
verify_signature, # (body: bytes, secret: str, header: str | None) -> bool
1720-
generate_signature, # (body: bytes, secret: str) -> str (lowercase hex)
1720+
generate_signature, # (body: bytes, secret: str) -> str ("sha256=<hex>" wire form)
17211721
parse_signature_header, # (header: str | None) -> str | None (strips "sha256=")
17221722
SIGNATURE_HEADER, # "X-Tango-Signature"
17231723
SIGNATURE_PREFIX, # "sha256="

docs/WEBHOOKS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -307,9 +307,11 @@ def test_my_handler_processes_entity_update():
307307
with WebhookReceiver(secret="test_secret").run() as rx:
308308
# Trigger whatever in your code-under-test should send a webhook
309309
# (e.g. a publisher, or in this case a manual POST).
310-
body = b'{"events":[{"event_type":"entities.updated","uei":"ABC"}]}'
310+
body = b'{"events":[{"event_type":"alerts.entity.match","alert_id":"ABC"}]}'
311311
sig = generate_signature(body, "test_secret")
312-
httpx.post(rx.url, content=body, headers={"X-Tango-Signature": f"sha256={sig}"})
312+
# generate_signature returns the wire form ("sha256=<hex>") — assign
313+
# directly to the header without wrapping.
314+
httpx.post(rx.url, content=body, headers={"X-Tango-Signature": sig})
313315

314316
assert len(rx.deliveries) == 1
315317
assert rx.deliveries[0].verified
@@ -335,7 +337,7 @@ Each `Delivery` has: `received_at`, `path`, `signature_header`, `body_bytes`, `b
335337
```python
336338
from tango.webhooks import sign
337339

338-
signed = sign({"events": [{"event_type": "entities.updated"}]}, secret="s")
340+
signed = sign({"events": [{"event_type": "alerts.entity.match"}]}, secret="s")
339341
assert signed.headers["X-Tango-Signature"].startswith("sha256=")
340342

341343
# Use `signed.body` as the raw bytes and `signed.headers` directly:

tango/webhooks/cli.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,13 +217,17 @@ def simulate_cmd(
217217
return
218218

219219
result = simulate.deliver(target_url=target_url, payload=payload, secret=secret)
220+
# `result.signature` is the bare hex on the SimulationResult dataclass;
221+
# render the prefixed wire form (matches X-Tango-Signature exactly).
222+
from tango.webhooks.signing import SIGNATURE_PREFIX
223+
220224
click.echo(
221225
json.dumps(
222226
{
223227
"delivered": True,
224228
"target_url": target_url,
225229
"status_code": result.status_code,
226-
"signature": f"sha256={result.signature}",
230+
"signature": f"{SIGNATURE_PREFIX}{result.signature}",
227231
"sent_payload": payload,
228232
"receiver_response": result.response_body[:500],
229233
},

tango/webhooks/signing.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,15 @@
1919

2020

2121
def generate_signature(body: bytes, secret: str) -> str:
22-
"""Return the lowercase hex HMAC-SHA256 of ``body`` keyed by ``secret``."""
23-
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
22+
"""Return the wire-format signature for ``body`` keyed by ``secret``.
23+
24+
Output is the full ``sha256=<lowercase hex>`` form Tango emits in the
25+
``X-Tango-Signature`` header, so the return value can be assigned to
26+
the header directly without a wrapping format string. To get the bare
27+
hex digest, strip the prefix with :func:`parse_signature_header`.
28+
"""
29+
digest = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
30+
return f"{SIGNATURE_PREFIX}{digest}"
2431

2532

2633
def parse_signature_header(value: str | None) -> str | None:
@@ -39,12 +46,18 @@ def parse_signature_header(value: str | None) -> str | None:
3946
def verify_signature(body: bytes, secret: str, signature_header: str | None) -> bool:
4047
"""Return True if ``signature_header`` matches the HMAC of ``body``.
4148
42-
Uses :func:`hmac.compare_digest` for constant-time comparison.
43-
Returns False for an absent or malformed header rather than raising — let
44-
callers decide how to respond (typically a 401 / 403).
49+
Accepts both the prefixed form (``sha256=<hex>``) and the bare-hex form
50+
in ``signature_header`` — callers passing in pre-stripped headers keep
51+
working. Uses :func:`hmac.compare_digest` for constant-time comparison.
52+
Returns False for an absent or malformed header rather than raising —
53+
let callers decide how to respond (typically a 401 / 403).
4554
"""
4655
received = parse_signature_header(signature_header)
4756
if not received:
4857
return False
49-
expected = generate_signature(body, secret)
58+
# generate_signature now returns the prefixed wire form; strip it for
59+
# the bare-hex comparison.
60+
expected = parse_signature_header(generate_signature(body, secret))
61+
if not expected:
62+
return False
5063
return hmac.compare_digest(expected, received)

tango/webhooks/simulate.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@
2323
from dataclasses import dataclass
2424
from typing import Any
2525

26-
from tango.webhooks.signing import SIGNATURE_HEADER, SIGNATURE_PREFIX, generate_signature
26+
from tango.webhooks.signing import SIGNATURE_HEADER, generate_signature, parse_signature_header
2727

2828

2929
@dataclass(frozen=True)
3030
class SignedRequest:
3131
"""A Tango-shaped signed request, ready to be POSTed."""
3232

3333
body: bytes
34-
signature: str # bare lowercase hex
34+
signature: str # bare lowercase hex (header-prefix stripped)
3535
headers: dict[str, str] # includes Content-Type and X-Tango-Signature
3636

3737

@@ -52,13 +52,14 @@ def sign(payload: dict[str, Any] | list[Any] | bytes | str, secret: str) -> Sign
5252
receive, or for hand-rolling deliveries with a custom HTTP client.
5353
"""
5454
body = _to_bytes(payload)
55-
signature_hex = generate_signature(body, secret)
55+
header_value = generate_signature(body, secret)
56+
bare_hex = parse_signature_header(header_value) or ""
5657
return SignedRequest(
5758
body=body,
58-
signature=signature_hex,
59+
signature=bare_hex,
5960
headers={
6061
"Content-Type": "application/json",
61-
SIGNATURE_HEADER: f"{SIGNATURE_PREFIX}{signature_hex}",
62+
SIGNATURE_HEADER: header_value,
6263
},
6364
)
6465

tests/test_webhooks_receiver.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515

1616

1717
def _post_signed(url: str, body: bytes, secret: str) -> httpx.Response:
18-
sig = generate_signature(body, secret)
18+
# generate_signature returns the wire form ("sha256=<hex>") so it can
19+
# be assigned to the header directly with no wrapping.
1920
return httpx.post(
2021
url,
2122
content=body,
2223
headers={
2324
"Content-Type": "application/json",
24-
"X-Tango-Signature": f"sha256={sig}",
25+
"X-Tango-Signature": generate_signature(body, secret),
2526
},
2627
timeout=5.0,
2728
)

tests/test_webhooks_signing.py

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
from tango.webhooks import generate_signature, parse_signature_header, verify_signature
1616

1717
KNOWN_VECTORS: list[tuple[bytes, str, str]] = [
18-
# (body_bytes, secret, expected_lowercase_hex_hmac_sha256)
19-
(b"", "dev_secret", hmac.new(b"dev_secret", b"", hashlib.sha256).hexdigest()),
18+
# (body_bytes, secret, expected_wire_signature) — full sha256=<hex> form
19+
(b"", "dev_secret", "sha256=" + hmac.new(b"dev_secret", b"", hashlib.sha256).hexdigest()),
2020
(
21-
b'{"events":[{"event_type":"entities.updated","uei":"ABC123"}]}',
21+
b'{"events":[{"event_type":"alerts.entity.match","alert_id":"ABC"}]}',
2222
"shh",
23-
hmac.new(
23+
"sha256="
24+
+ hmac.new(
2425
b"shh",
25-
b'{"events":[{"event_type":"entities.updated","uei":"ABC123"}]}',
26+
b'{"events":[{"event_type":"alerts.entity.match","alert_id":"ABC"}]}',
2627
hashlib.sha256,
2728
).hexdigest(),
2829
),
@@ -34,29 +35,50 @@ def test_generate_signature_matches_reference_algorithm() -> None:
3435
assert generate_signature(body, secret) == expected
3536

3637

37-
def test_generate_signature_is_lowercase_hex() -> None:
38+
def test_generate_signature_returns_prefixed_wire_form() -> None:
39+
"""generate_signature returns the full ``sha256=<hex>`` header value, so
40+
callers can assign it directly to X-Tango-Signature without wrapping."""
3841
sig = generate_signature(b"payload", "secret")
39-
assert sig == sig.lower()
40-
int(sig, 16) # must parse as hex
42+
assert sig.startswith("sha256=")
43+
bare = sig[len("sha256=") :]
44+
assert bare == bare.lower()
45+
int(bare, 16) # must parse as hex
4146

4247

4348
def test_verify_signature_round_trip() -> None:
44-
body = b'{"events":[{"event_type":"awards.created"}]}'
49+
body = b'{"events":[{"event_type":"alerts.contract.match"}]}'
4550
secret = "rotating-secret"
4651
sig = generate_signature(body, secret)
47-
assert verify_signature(body, secret, f"sha256={sig}") is True
48-
assert verify_signature(body, secret, sig) is True # bare hex also accepted
52+
# Prefixed form (what generate_signature returns and what Tango sends)
53+
assert verify_signature(body, secret, sig) is True
54+
# Bare-hex form (callers passing pre-stripped headers)
55+
bare = parse_signature_header(sig)
56+
assert bare is not None
57+
assert verify_signature(body, secret, bare) is True
58+
59+
60+
def test_verify_signature_accepts_both_prefixed_and_bare_hex() -> None:
61+
"""Regression test: verify_signature must accept BOTH the wire form
62+
(sha256=<hex>) and the pre-stripped bare-hex form. Callers that strip
63+
the prefix themselves before passing in must keep working."""
64+
body = b"hello"
65+
secret = "k"
66+
sig = generate_signature(body, secret)
67+
bare = parse_signature_header(sig)
68+
assert bare is not None and bare != sig # sanity: they really differ
69+
assert verify_signature(body, secret, sig) is True
70+
assert verify_signature(body, secret, bare) is True
4971

5072

5173
def test_verify_signature_rejects_tampered_body() -> None:
5274
secret = "secret"
5375
sig = generate_signature(b"original", secret)
54-
assert verify_signature(b"tampered", secret, f"sha256={sig}") is False
76+
assert verify_signature(b"tampered", secret, sig) is False
5577

5678

5779
def test_verify_signature_rejects_wrong_secret() -> None:
5880
sig = generate_signature(b"body", "right")
59-
assert verify_signature(b"body", "wrong", f"sha256={sig}") is False
81+
assert verify_signature(b"body", "wrong", sig) is False
6082

6183

6284
def test_verify_signature_handles_missing_or_empty_header() -> None:

0 commit comments

Comments
 (0)