A reusable verifier for Standard Webhooks (https://www.standardwebhooks.com), the signing scheme Increase uses. A delivery carries three headers:
webhook-id: the message id (Increase sends the Event id)webhook-timestamp: Unix seconds when the delivery was sentwebhook-signature: space-separated signatures, eachv1,<base64 HMAC-SHA256>; several are sent while a secret rotates
The signed content is {id}.{timestamp}.{raw body}. verify accepts
the delivery when any v1 signature matches (each compared in
constant time) and the timestamp is within the tolerance (five minutes
by default) in either direction. It fails closed: a missing header, a
non-numeric timestamp, an empty key or an unknown signature version is
a rejection.
Keys: Standard Webhooks secrets look like whsec_<base64>; turn one
into the HMAC key with decode-secret. Increase signs with the raw
bytes of the event subscription's shared secret instead (see
::increase::webhooks).
Values
BASE64_ALPHABET
BASE64_ALPHABET: Str "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
DEFAULT_TOLERANCE_SECONDS
DEFAULT_TOLERANCE_SECONDS: Int 300
SECRET_PREFIX
SECRET_PREFIX: Str "whsec_"
Functions
decode-secret
fn (secret: Str): Bytes
The HMAC key for a Standard Webhooks secret: drop an optional
whsec_ prefix and base64-decode the rest (padding optional). Errs
on an empty or non-base64 secret.
Example
key ::increase::standard-webhooks/decode-secret("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw")
endpoint-verification-response
fn (key: Str | Bytes, challenge: Str): Str
The webhook-challenge-response header value for a Standard Webhooks endpoint-verification challenge: v1, + base64 HMAC-SHA256 of the challenge under HMAC-SHA256(key, "wev-v1"). Increase doesn't send challenges; this is here for other Standard Webhooks senders.
header
fn (headers: Map, name: Str): Str
A header value by name, ignoring case, trimmed; "" when absent. Exposed for testing.
key-is-empty
fn (key: Str | Bytes): Bool
pad-base64
fn (text: Str): Str
Canonical, padded base64 for a secret that may be shared unpadded:
strip any =, clear the unused low bits of the last character (the
reference libraries decode leniently and ignore them; Hot's decoder
is strict), and pad to a multiple of four. Exposed for testing.
sign
fn (key: Str | Bytes, msg-id: Str, timestamp: Str, body: Str | Bytes): Str
The v1,<base64> signature for a message: HMAC-SHA256 of
signed-content under key (the decoded secret, as Bytes or Str).
Use it to produce test deliveries.
Example
key ::increase::standard-webhooks/decode-secret("whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw")
::increase::standard-webhooks/sign(key, "msg_p5jXN8AQM9LWM0D4loKWxJek", "1614265330", "{\\"test\\": 2432232314}")
// => "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="
signatures
fn (value: Str): Vec<Str>
The space-separated entries of a webhook-signature header. Exposed for testing.
signed-content
fn (msg-id: Str, timestamp: Str, body: Str | Bytes): Str | Bytes
The bytes a Standard Webhooks signature covers: {msg-id}.{timestamp}. followed by the body exactly as received. A Bytes body stays bytes, so a body that isn't valid UTF-8 still verifies. Exposed for testing.
timestamp-within
fn (timestamp: Int, now: Int, tolerance-seconds: Int): Bool
verify
fn (headers: Map, body: Str | Bytes, key: Str | Bytes): Bool
fn (headers: Map, body: Str | Bytes, key: Str | Bytes, tolerance-seconds: Int): Bool
Verify a delivery's headers and raw body against key (the
decoded secret) at the current time, with a five-minute tolerance by
default. Pass the body exactly as received (::hot::http/raw-body),
never a re-serialized request.body.
Example
key ::increase::standard-webhooks/decode-secret(secret)
ok ::increase::standard-webhooks/verify(request.headers, ::hot::http/raw-body(request), key)
verify-at
fn (headers: Map, body: Str | Bytes, key: Str | Bytes, tolerance-seconds: Int, now: Int): Bool
Verify a delivery against key as of now (Unix seconds): true when
webhook-id, webhook-timestamp and webhook-signature are present,
the timestamp is an integer within tolerance-seconds of now (a
tolerance of 0 or less rejects everything), and one of the signatures
equals sign(key, id, timestamp, body). Every candidate is compared
in constant time. Exposed for testing with fixed vectors; use
verify in handlers.
verify-request
fn (request: Map, key: Str | Bytes): Bool
fn (request: Map, key: Str | Bytes, tolerance-seconds: Int): Bool
verify for an inbound webhook request: its headers and ::hot::http/raw-body.