"""Verify a SipsPOS webhook delivery. THIS is the file to copy. It knows nothing about parcels, or about what you do with an event. Its whole job is to answer one question honestly: *did SipsPOS send this exact body, and recently?* Everything else in this example is an application built on top of that answer. Standard library only. Drop it beside your own code and call `verify()` from whatever framework you run. from sipspos_webhook import verify, Rejected body = request.get_data() # THE RAW BYTES. See below. try: verify(SECRET, request.headers["X-SipsPOS-Signature"], body) except Rejected as e: return "", 401 if e.reason != "malformed" else 400 event = json.loads(body) # only now is it safe to parse THE FIVE THINGS PEOPLE GET WRONG, and what this file does about each: 1. COMPARING WITH `==`. String comparison returns early on the first differing byte, so how long it takes leaks how much of a guess was right. Given enough attempts that is a forgery. `hmac.compare_digest` takes the same time either way. 2. VERIFYING THE PARSED BODY. The signature covers the bytes that arrived. If you `json.loads` and then re-serialise to check, you are checking a DIFFERENT string — key order, spacing and unicode escaping are all free to change — and you will either reject everything or, worse, write a "normalise first" step that an attacker can aim at. This function takes `bytes` and never decodes them. 3. IGNORING `t`. The timestamp is inside the signed string precisely so a captured delivery stops being usable. Verify the HMAC and skip the clock and every delivery you ever received is replayable against you forever. 4. ASSUMING EXACTLY-ONCE. It is at-least-once — see `parcels.py`. Verification is not where that is solved, but it is where people assume it away. 5. DOING THE WORK BEFORE ANSWERING. Also not this file's job, also assumed away here first. See `parcels.py`. """ import hashlib import hmac import time #: How old a delivery may be, in seconds. Stripe's default, and this is #: Stripe's scheme. #: #: A FIVE-MINUTE WINDOW DOES NOT DROP RETRIES, and this is the part worth #: reading twice. SipsPOS retries on a 1m / 5m / 30m / 2h / 12h ladder, so the #: obvious conclusion is that a delivery could arrive twelve hours late and a #: 300-second tolerance would throw it away. It will not: **every attempt is #: signed afresh with the current time**, so the twelve-hour retry carries a #: `t` from twelve hours later, not from when the event happened. #: #: This matters because the alternative — widening the window to a day so #: retries "work" — is exactly what makes the timestamp worthless. If you find #: yourself needing a large tolerance, the problem is a clock, not the ladder. DEFAULT_TOLERANCE = 300 class Rejected(Exception): """A delivery that must not be acted on. `reason` is one of: `malformed` the header is not the shape this scheme defines. A bug or a probe, not a forgery attempt worth alarming about. `bad_signature` the HMAC does not match. Somebody sent you a body SipsPOS did not sign. `stale` the HMAC matches but `t` is outside the tolerance — a genuine SipsPOS delivery being replayed at you, or a clock that has drifted far enough to look like one. Kept apart because they mean different things operationally: a burst of `bad_signature` is worth an alert, a burst of `stale` is usually NTP. """ def __init__(self, reason, message): super().__init__(message) self.reason = reason def _parse(header): """`t=,v1=` -> `(t, v1)`. Order-independent, extra parts ignored — a `v2=` may be added one day and must not break a v1 consumer.""" if not header: raise Rejected("malformed", "no X-SipsPOS-Signature header") parts = {} for chunk in header.split(","): key, _, value = chunk.partition("=") if value: parts.setdefault(key.strip(), value.strip()) try: timestamp = int(parts["t"]) except (KeyError, ValueError): raise Rejected("malformed", f"no usable t= in {header!r}") if "v1" not in parts: raise Rejected("malformed", f"no v1= in {header!r}") return timestamp, parts["v1"] def expected(secret, timestamp, body): """The v1 digest for this body at this timestamp. Exposed because a receiver that cannot verify is worth debugging, and the first question is always "what did you expect instead". Do NOT use it to compare by hand — see `verify`. """ if isinstance(body, str): # be forgiving at the door, body = body.encode() # strict everywhere after it signed = str(timestamp).encode() + b"." + body return hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() def verify(secret, signature_header, body, *, now=None, tolerance=DEFAULT_TOLERANCE): """Return the signed timestamp, or raise `Rejected`. `body` MUST be the raw request bytes, exactly as they arrived. Not a dict, not a re-serialised string — see the module docstring. `now` is a parameter rather than a call to the clock so that the staleness behaviour can be tested without waiting five minutes for it. The order matters: shape, then signature, then age. Checking the clock first would tell an attacker whether a timestamp was acceptable without them having to sign anything. """ timestamp, sent = _parse(signature_header) if not hmac.compare_digest(expected(secret, timestamp, body), sent): raise Rejected("bad_signature", "signature does not match the body — this was not " "signed by the secret you configured") now = int(time.time() if now is None else now) if abs(now - timestamp) > tolerance: raise Rejected( "stale", f"delivery is {now - timestamp}s old (tolerance {tolerance}s). " f"Every SipsPOS retry is re-signed with the current time, so a " f"late retry is NOT why this happens — check your clock.") return timestamp