diff --git a/.fernignore b/.fernignore index 3f83c052..98b6df5f 100644 --- a/.fernignore +++ b/.fernignore @@ -9,9 +9,31 @@ # Stainless machinery (release-please, publish-pypi.yml, release-doctor.yml, bin/) was # removed with the cutover. # -# src/whop_sdk/lib holds verify_user_token, the one hand-written helper in this SDK. It -# depends only on the standard library and pyjwt, so it survives the generated client -# being replaced. The [user-tokens] extra that declares pyjwt does not survive — see the -# cutover notes in the monorepo's sdks/fern/README.md. +# src/whop_sdk/lib holds the hand-written helpers in this SDK. Each depends only on the +# standard library and one third-party package, never on the generated client, so each +# survives the generated client being replaced: +# +# - verify_user_token — the x-whop-user-token verifier, on pyjwt. +# - verify_webhook — the Standard Webhooks signature verifier, on standardwebhooks. It +# restores the verification half of `client.webhooks.unwrap`, which the Stainless SDK +# shipped through 0.0.41 and Fern cannot generate: Fern generates from OpenAPI paths +# and `unwrap` was never a path. +# +# Reaching a helper needs nothing else — src/whop_sdk/lib is a real package and +# `from whop_sdk.lib.verify_webhook import unwrap` resolves without a generated re-export, +# which is why there is no Python equivalent of the Ruby SDK's requirePaths entry. +# Declaring what a helper imports does: pyproject.toml is generated, so the declaration +# comes from `extra_dependencies` in the python config in whop-monorepo's +# sdks/fern/generators.yml, not from here. standardwebhooks is declared there. +# pyjwt is not — the [user-tokens] extra did not survive the cutover, so importing +# verify_user_token from a clean install still raises ImportError. +# +# tests/custom holds the generated placeholder test plus the helpers' tests. Fern owns +# tests/, so without this entry a regeneration would prune them and a helper would go +# untested rather than fail loudly. tests/custom/test_verify_webhook.py also asserts the +# standardwebhooks declaration survived, which is where Ruby's ci.yml guard would sit if +# this repo's ci.yml were hand-written; it is not, and forking it for a guard is a worse +# trade than putting the guard in a test. .github/workflows/publish-main.yml src/whop_sdk/lib +tests/custom diff --git a/poetry.lock b/poetry.lock index 98c8f659..ccaa22a2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1225,6 +1225,18 @@ files = [ {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] +[[package]] +name = "standardwebhooks" +version = "1.1.0" +description = "Standard Webhooks" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "standardwebhooks-1.1.0-py3-none-any.whl", hash = "sha256:9a88d48a1f198be61517fc7ad328cf58ba02b73f0fbd8941d4213e9c0b6c2a61"}, + {file = "standardwebhooks-1.1.0.tar.gz", hash = "sha256:e5cb66e21a6356ebb9375aeb57f1348583323015808d475a7c1baaa4b718068a"}, +] + [[package]] name = "tomli" version = "2.4.1" @@ -1466,4 +1478,4 @@ aiohttp = ["aiohttp", "httpx-aiohttp"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "4eaeff06fb8e558535aa3729ed6f99c2398eaaba665bcdf9c096bb08d9cb5fc6" +content-hash = "bd92c3ec03904984bad084ecc0b18fecd563e9dda8200299aff3508422e83931" diff --git a/pyproject.toml b/pyproject.toml index 99778e46..62408fed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ httpx = ">=0.21.2" httpx-aiohttp = { version = "^0.1.8", optional = true, python = ">=3.10"} pydantic = ">= 1.9.2" pydantic-core = ">=2.18.2,<3.0.0" +standardwebhooks = ">=1.0.1,<2" typing_extensions = ">= 4.0.0" [tool.poetry.group.dev.dependencies] diff --git a/requirements.txt b/requirements.txt index 443b1af4..1b747fee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ httpx>=0.21.2 pydantic>= 1.9.2 pydantic-core>=2.18.2,<3.0.0 +standardwebhooks>=1.0.1,<2 typing_extensions>= 4.0.0 diff --git a/src/whop_sdk/lib/verify_webhook.py b/src/whop_sdk/lib/verify_webhook.py new file mode 100644 index 00000000..1431ed58 --- /dev/null +++ b/src/whop_sdk/lib/verify_webhook.py @@ -0,0 +1,92 @@ +"""Verify the Standard Webhooks signature Whop sends on every webhook delivery. + +This is the verification half of the ``client.webhooks.unwrap`` the Stainless-generated +SDK shipped through 0.0.41. Fern generates from OpenAPI paths and ``unwrap`` was never a +path, so the generated client has no equivalent. It lives here, beside +``verify_user_token``, rather than on the client so that nothing generated has to be +patched: it depends only on the standard library and ``standardwebhooks``, never on +generated client code, so it survives the client being replaced. + + from whop_sdk.lib.verify_webhook import unwrap + + event = unwrap(request.body, headers=request.headers, key=WHOP_WEBHOOK_SECRET) + +What it does NOT do, and the Stainless version did: coerce the parsed body into one of +42 typed event models. Fern generates no webhook event models — ``whop_sdk.WebhookEvent`` +is the enum of event *names* a webhook subscribes to, not a payload type — so there is +nothing to coerce into. The parsed body is returned as a plain dict. +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any, Dict, Mapping, Union + +from standardwebhooks import Webhook, WebhookVerificationError + +__all__ = ["unwrap", "WebhookVerificationError"] + +MISSING_KEY_MESSAGE = "Cannot verify a webhook without a key. Pass the endpoint's signing secret as `key`." + + +def _hmac_key(key: Union[str, bytes]) -> str: + """Base64-encode the secret so ``Webhook`` derives the key Whop actually signs with. + + Whop's backend HMACs with the *literal bytes* of the secret it issued + (``WebhooksManager::SignWebhook`` passes ``webhook.webhook_secret`` straight to + ``OpenSSL::HMAC``). ``standardwebhooks.Webhook`` instead base64-decodes whatever it is + handed to derive its key, so handing it the secret raw derives the wrong key and every + genuine delivery fails to verify. Encoding here cancels that decode out, leaving + exactly the bytes the backend signed with. + + The whole secret is encoded, prefix included, because the backend never strips a prefix + either. That also disarms the library's own ``whsec_`` stripping: base64 output cannot + begin with ``whsec_``, since ``_`` is not in the base64 alphabet. + """ + return base64.b64encode(key.encode("utf-8") if isinstance(key, str) else key).decode("ascii") + + +def unwrap( + payload: Union[str, bytes], + *, + headers: Mapping[str, str], + key: Union[str, bytes, None], +) -> Dict[str, Any]: + """Verify ``payload`` against the signature headers and return the parsed body. + + Args: + payload: The raw, unmodified request body. Verifying a re-serialized body fails: + the signature covers the exact bytes sent. + headers: The request headers. Only ``webhook-id``, ``webhook-timestamp`` and + ``webhook-signature`` are read, and the lookup is case-insensitive. + key: The endpoint's signing secret, exactly as Whop shows it — a ``ws_``-prefixed + string. Pass it verbatim; do not strip the prefix and do not pre-encode it. + + Returns: + The parsed body. + + Raises: + ValueError: when ``key`` is missing or empty, or the verified body is not a + JSON object. + WebhookVerificationError: when a signature header is missing or malformed, the + timestamp is outside the tolerance window, or no signature matches. + """ + if not key: + raise ValueError(MISSING_KEY_MESSAGE) + + try: + Webhook(_hmac_key(key)).verify(payload, dict(headers), json_parse=False) + except WebhookVerificationError: + raise + except Exception as error: + # standardwebhooks lets a malformed webhook-signature header escape as a bare + # ValueError or binascii.Error rather than a WebhookVerificationError, and that + # header is attacker-controlled. Rejecting is right; the exception type is not, + # so callers can catch one thing. + raise WebhookVerificationError(f"Invalid signature headers: {error}") from error + + event = json.loads(payload) + if not isinstance(event, dict): + raise ValueError(f"Expected the webhook body to be a JSON object, got {type(event).__name__}") + return event diff --git a/tests/custom/test_verify_webhook.py b/tests/custom/test_verify_webhook.py new file mode 100644 index 00000000..384bfb67 --- /dev/null +++ b/tests/custom/test_verify_webhook.py @@ -0,0 +1,228 @@ +import base64 +import hashlib +import hmac +import json +import re +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Dict, Optional, Union + +import pytest +from standardwebhooks import WebhookVerificationError + +from whop_sdk.lib.verify_webhook import unwrap + +# The format WebhooksManager::Create issues: "ws_" + SecureRandom.hex(32). +KEY = "ws_" + "3f2a" * 16 +OTHER_KEY = "ws_" + "c17b" * 16 +PAYLOAD = '{"id":"evt_123","event":"payment.succeeded","data":{"id":"pay_123"}}' + + +def backend_signature(payload: Union[str, bytes], key: str, msg_id: str, timestamp: str) -> str: + """Reproduce backend/app/services/webhooks_manager/sign_webhook.rb. + + Deliberately not the library under test. Signing and verifying with the same library is + self-consistent and proved nothing: it agreed with itself while rejecting every genuine + Whop delivery. + + payload = "#{id}.#{timestamp}.#{body_json}" + raw_sig = OpenSSL::HMAC.digest("sha256", secret, payload) + signature = Base64.strict_encode64(raw_sig) + header = "v1,#{signature}" + """ + body = payload if isinstance(payload, bytes) else payload.encode("utf-8") + signed = f"{msg_id}.{timestamp}.".encode("utf-8") + body + return base64.b64encode(hmac.new(key.encode("utf-8"), signed, hashlib.sha256).digest()).decode("ascii") + + +def signed_headers( + payload: Union[str, bytes] = PAYLOAD, + key: str = KEY, + msg_id: str = "msg_2Xa9", + timestamp: Optional[datetime] = None, +) -> Dict[str, str]: + at = timestamp if timestamp is not None else datetime.now(tz=timezone.utc) + ts = str(int(at.timestamp())) + return { + "webhook-id": msg_id, + "webhook-timestamp": ts, + "webhook-signature": "v1," + backend_signature(payload, key, msg_id, ts), + } + + +def test_returns_the_parsed_body_for_a_valid_signature() -> None: + event = unwrap(PAYLOAD, headers=signed_headers(), key=KEY) + + assert event == {"id": "evt_123", "event": "payment.succeeded", "data": {"id": "pay_123"}} + + +def test_accepts_a_bytes_payload() -> None: + assert unwrap(PAYLOAD.encode(), headers=signed_headers(), key=KEY)["id"] == "evt_123" + + +def test_accepts_headers_whose_names_are_capitalized() -> None: + headers = {name.title(): value for name, value in signed_headers().items()} + + assert unwrap(PAYLOAD, headers=headers, key=KEY)["id"] == "evt_123" + + +def test_signs_over_the_exact_bytes_of_the_body() -> None: + payload = '{"id":"evt_123","note":"a\\u00e9b","emoji":"\U0001f600"}'.encode("utf-8") + headers = signed_headers(payload=payload) + + signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode("utf-8") + payload + expected = base64.b64encode(hmac.new(KEY.encode("utf-8"), signed, hashlib.sha256).digest()).decode("ascii") + + assert headers["webhook-signature"] == f"v1,{expected}" + assert unwrap(payload, headers=headers, key=KEY)["id"] == "evt_123" + + +def test_uses_the_secret_verbatim_without_stripping_a_prefix() -> None: + # The backend HMACs the stored secret as-is, so a secret and that same secret minus a + # prefix are two different keys. Stripping either one would silently derive the wrong key. + prefixed = "whsec_" + "9d4e" * 16 + bare = prefixed.removeprefix("whsec_") + + assert unwrap(PAYLOAD, headers=signed_headers(key=prefixed), key=prefixed)["id"] == "evt_123" + with pytest.raises(WebhookVerificationError): + unwrap(PAYLOAD, headers=signed_headers(key=prefixed), key=bare) + + +@pytest.mark.parametrize("template", ["v1,{filler} {valid}", "{valid} v1,{filler}", "v0,{filler} {valid} v2,{filler}"]) +def test_accepts_a_valid_v1_entry_in_a_multi_signature_header(template: str) -> None: + headers = signed_headers() + value = template.format(valid=headers["webhook-signature"], filler="A" * 44) + + assert unwrap(PAYLOAD, headers={**headers, "webhook-signature": value}, key=KEY)["id"] == "evt_123" + + +def test_ignores_a_v1n_entry_and_verifies_the_v1_entry_beside_it() -> None: + """The nonce-bound scheme from https://github.com/whopio/whop/pull/23394: + base64(HMAC(secret, "v1n....")), appended after the v1 + entry. That PR is closed and no deployed sender emits it — WebhooksManager::SignWebhook + on main writes a single "v1," entry — so the helper ignores v1n rather than + verifying it. This pins that ignoring it stays harmless if the scheme ever ships: the v1 + entry it travels beside is still the one that authenticates the delivery.""" + headers = signed_headers() + msg_id, ts, nonce = headers["webhook-id"], headers["webhook-timestamp"], "nonce_zRq4" + signed = f"v1n.{msg_id}.{ts}.{nonce}.{PAYLOAD}".encode("utf-8") + v1n = base64.b64encode(hmac.new(KEY.encode("utf-8"), signed, hashlib.sha256).digest()).decode("ascii") + headers = {**headers, "webhook-nonce": nonce, "webhook-signature": f"{headers['webhook-signature']} v1n,{v1n}"} + + assert unwrap(PAYLOAD, headers=headers, key=KEY)["id"] == "evt_123" + + +def test_rejects_a_header_carrying_only_a_v1n_entry() -> None: + headers = signed_headers() + msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"] + signed = f"v1n.{msg_id}.{ts}.nonce_zRq4.{PAYLOAD}".encode("utf-8") + v1n = base64.b64encode(hmac.new(KEY.encode("utf-8"), signed, hashlib.sha256).digest()).decode("ascii") + + with pytest.raises(WebhookVerificationError): + unwrap(PAYLOAD, headers={**headers, "webhook-signature": f"v1n,{v1n}"}, key=KEY) + + +def test_rejects_a_tampered_payload() -> None: + with pytest.raises(WebhookVerificationError): + unwrap(PAYLOAD.replace("pay_123", "pay_456"), headers=signed_headers(), key=KEY) + + +def test_rejects_a_payload_reserialized_with_the_same_content() -> None: + reserialized = json.dumps(json.loads(PAYLOAD), indent=2) + + assert reserialized != PAYLOAD + assert json.loads(reserialized) == json.loads(PAYLOAD) + with pytest.raises(WebhookVerificationError): + unwrap(reserialized, headers=signed_headers(), key=KEY) + + +def test_rejects_a_signature_made_with_a_different_key() -> None: + with pytest.raises(WebhookVerificationError): + unwrap(PAYLOAD, headers=signed_headers(key=OTHER_KEY), key=KEY) + + +def test_rejects_a_signature_bound_to_a_different_message_id() -> None: + headers = {**signed_headers(msg_id="msg_original"), "webhook-id": "msg_replaced"} + + with pytest.raises(WebhookVerificationError): + unwrap(PAYLOAD, headers=headers, key=KEY) + + +def test_rejects_a_signature_bound_to_a_different_timestamp() -> None: + now = datetime.now(tz=timezone.utc) + headers = {**signed_headers(timestamp=now), "webhook-timestamp": str(int((now - timedelta(minutes=1)).timestamp()))} + + with pytest.raises(WebhookVerificationError): + unwrap(PAYLOAD, headers=headers, key=KEY) + + +@pytest.mark.parametrize("offset", [timedelta(minutes=-10), timedelta(minutes=10)]) +def test_rejects_a_timestamp_outside_the_tolerance_window(offset: timedelta) -> None: + stale = datetime.now(tz=timezone.utc) + offset + + with pytest.raises(WebhookVerificationError): + unwrap(PAYLOAD, headers=signed_headers(timestamp=stale), key=KEY) + + +@pytest.mark.parametrize("dropped", ["webhook-id", "webhook-timestamp", "webhook-signature"]) +def test_rejects_each_missing_signature_header(dropped: str) -> None: + headers = {name: value for name, value in signed_headers().items() if name != dropped} + + with pytest.raises(WebhookVerificationError): + unwrap(PAYLOAD, headers=headers, key=KEY) + + +@pytest.mark.parametrize( + "signature", + ["not-a-signature", "v1,", "v1,!!!!", "v2,abc", "", "v1,a,b", "v1," + "A" * 44], +) +def test_rejects_a_malformed_signature_header(signature: str) -> None: + """standardwebhooks lets some of these escape as a bare ValueError; the helper + normalizes every rejection onto WebhookVerificationError so callers catch one thing.""" + headers = {**signed_headers(), "webhook-signature": signature} + + with pytest.raises(WebhookVerificationError): + unwrap(PAYLOAD, headers=headers, key=KEY) + + +@pytest.mark.parametrize("timestamp", ["not-a-timestamp", "", "1e999"]) +def test_rejects_an_unparsable_timestamp(timestamp: str) -> None: + headers = {**signed_headers(), "webhook-timestamp": timestamp} + + with pytest.raises(WebhookVerificationError): + unwrap(PAYLOAD, headers=headers, key=KEY) + + +@pytest.mark.parametrize("key", [None, "", b""]) +def test_raises_a_clear_error_when_the_key_is_missing(key: Union[str, bytes, None]) -> None: + with pytest.raises(ValueError, match="without a key"): + unwrap(PAYLOAD, headers=signed_headers(), key=key) + + +def test_raises_before_verifying_when_the_key_is_missing() -> None: + with pytest.raises(ValueError, match="without a key"): + unwrap(PAYLOAD, headers={}, key=None) + + +def test_rejects_a_verified_body_that_is_not_a_json_object() -> None: + payload = "[1, 2, 3]" + + with pytest.raises(ValueError, match="JSON object"): + unwrap(payload, headers=signed_headers(payload=payload), key=KEY) + + +def test_pyproject_declares_standardwebhooks() -> None: + """src/whop_sdk/lib is kept by .fernignore, but pyproject.toml is generated: the + dependency is re-declared from extra_dependencies in the python config in + whop-monorepo sdks/fern/generators.yml. Without it, importing this helper from a + clean install raises ImportError.""" + # tomllib is 3.11+ and this SDK supports 3.10, so read the section by hand. + pyproject = (Path(__file__).resolve().parents[2] / "pyproject.toml").read_text() + section = re.search(r"^\[tool\.poetry\.dependencies\]\n(.*?)(?=^\[)", pyproject, re.S | re.M) + + assert section is not None, "pyproject.toml has no [tool.poetry.dependencies] section" + assert re.search(r"^standardwebhooks\s*=", section.group(1), re.M), ( + "pyproject.toml must declare standardwebhooks. It is generated, so .fernignore " + "does not preserve the declaration: set extra_dependencies in the python config " + "in whop-monorepo sdks/fern/generators.yml." + )