~/bend-docscommunity

webhooks.bend relies on unsafe/foreign

raw source on the hub · import bend-kit-webhooks@0.3.0.0/webhooks.bend as Webhooks

Webhook signatures: Standard Webhooks sign and verify, Stripe and GitHub verify, over the raw body. Source: https://github.com/paymog/bend-kit/tree/main/webhooks

9 imports
import Base
import bend-kit-crypto@0.2.0.0/crypto.bend as Crypto
import bend-kit-time@0.1.2.1/time.bend as Time
import bend-kit-int@0.2.0.0/int.bend as Int
import 0xcfc8be7b076f41f95c8e118383892d55/encoding.bend as Enc
import 0x49814d83de8f70993a43e1002be29ecd/bytes.bend as Bytes
import bend-kit-hairpin@0.3.0.0/hairpin.bend as Hairpin
import bend-kit-hairpin@0.3.0.0/retry.bend as Retry
import bend-kit-http@0.32.0.0/http.bend as Http

Types

type Err source · line 18 · raw

Data

type SendErr source · line 430 · raw

Data

Definitions

def field.head source · line 30 · raw

@xs:List<&2, String> -> Maybe<&2, String>

def field.of source · line 37 · raw

@r:Pair(Map<&2, List<&2, String>>, List<&2, String>) -> Maybe<&2, String>

def field source · line 41 · raw

@+h:Map<&2, List<&2, String>> -> @k:String -> Maybe<&2, String>

def need source · line 44 · raw

@+name:String -> @m:Maybe<&2, String> -> Result<&1, &1, Err, String>

def strip source · line 52 · raw

@+p:String -> @+s:String -> Maybe<&2, String>

s after the prefix p, or None.

def push source · line 55 · raw

@m:Maybe<&2, String> -> @rest:List<&2, String> -> List<&2, String>

def put source · line 62 · raw

@m:Map<&2, List<&2, String>> -> @k:String -> @v:String -> Map<&2, List<&2, String>>

def digits source · line 65 · raw

@s:String -> Bool

def ts.some source · line 72 · raw

@ok:Bool -> @m:Maybe<&2, 0x4eed9d7ac6ece61523d747a9d804e4f0/int.I64> -> Result<&1, &1, Err, 0x4eed9d7ac6ece61523d747a9d804e4f0/int.I64>

def ts.read source · line 84 · raw

@+s:String -> Result<&1, &1, Err, 0x4eed9d7ac6ece61523d747a9d804e4f0/int.I64>

Unix seconds: 1 to 18 ASCII digits, no sign, so the parse never overflows.

def window.pick source · line 88 · raw

@old:Bool -> @ahead:Bool -> Result<&1, &1, Err, Unit>

def window source · line 96 · raw

@+tol:U32 -> @now:0xe00bbf72c3ff9dc6d7b077524522b3a8/time.Instant -> @t:0x4eed9d7ac6ece61523d747a9d804e4f0/int.I64 -> Result<&1, &1, Err, Unit>

t within tol seconds of now, either way.

def id.check source · line 104 · raw

@+id:String -> Result<&1, &1, Err, Unit>

A webhook id is not empty and has no '.', so id.timestamp.body reads one way.

def b64.same source · line 109 · raw

@+s:String -> @r:Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes) -> Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes>

def b64.canon source · line 113 · raw

@+s:String -> @m:Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes> -> Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes>

def b64 source · line 121 · raw

@+s:String -> Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes>

Padded standard base64, only in its canonical text: Bytes.from_base64 alone accepts nonzero pad bits.

def pad source · line 125 · raw

@+s:String -> String

'=' up to a multiple of four chars.

def nonempty.of source · line 129 · raw

@b:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes>

def nonempty source · line 133 · raw

@m:Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes> -> Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes>

def key.std source · line 141 · raw

@+s:String -> Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes>

A Standard Webhooks secret: whsec_ then base64 of the key, padded or not. The prefix is optional.

def key source · line 145 · raw

@+std:Bool -> @+s:String -> Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes>

std: a Standard Webhooks secret. Otherwise the secret's UTF-8 is the key, as Stripe and GitHub use it.

def decode source · line 153 · raw

@+hex:Bool -> @+s:String -> Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes>

A signature's octets: hex (Stripe, GitHub) or canonical base64 (Standard Webhooks).

def mac.of source · line 160 · raw

@r:Result<&1, &1, Pair(U32, String), Pair(U32, Array<U32>)> -> Result<&1, &1, Err, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes>

def mac source · line 167 · raw

@k:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> @data:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Result<&1, &1, Err, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes>)

def ct.go source · line 174 · raw

@m:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> @s:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Bool)

def ct source · line 180 · raw

@s:Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes> -> @m:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Bool)

A signature that does not decode matches nothing.

def any.sig source · line 188 · raw

@sigs:List<&2, String> -> @+hex:Bool -> @r:Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes) -> IO(Bool)

Does any signature equal the MAC? r holds the MAC and a copy; every signature is compared.

def key.cmp source · line 199 · raw

@r:Result<&1, &1, Err, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes> -> @+hex:Bool -> @+sigs:List<&2, String> -> IO(Result<&1, &1, Err, Bool>)

def key.check source · line 208 · raw

@k:Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes> -> @+hex:Bool -> @+sigs:List<&2, String> -> @msg:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Result<&1, &1, Err, Bool>)

def both.done source · line 217 · raw

@+x:Bool -> @b:Result<&1, &1, Err, Bool> -> Result<&1, &1, Err, Bool>

def both source · line 225 · raw

@a:Result<&1, &1, Err, Bool> -> @b:Result<&1, &1, Err, Bool> -> Result<&1, &1, Err, Bool>

A bad secret fails the check, even when another secret matches.

def any.key source · line 233 · raw

@keys:List<&2, String> -> @+std:Bool -> @+hex:Bool -> @+sigs:List<&2, String> -> @r:Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes) -> IO(Result<&1, &1, Err, Bool>)

Does any signature sign the message under any secret? r holds the message and a copy.

def found source · line 245 · raw

@-A:Data -> @r:Result<&1, &1, Err, Bool> -> @v:A -> Result<&1, &1, Err, A>

def back source · line 253 · raw

@-A:Data -> @r:Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes) -> @e:Err -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, A>))

The body back beside a failure.

def run.mac source · line 257 · raw

@-A:Data -> @v:A -> @pre:String -> @+sigs:List<&2, String> -> @+std:Bool -> @+hex:Bool -> @secrets:List<&2, String> -> @r:Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes) -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, A>))

def run source · line 266 · raw

@-A:Data -> @c:Result<&1, &1, Err, Pair(A, Pair(String, List<&2, String>))> -> @+std:Bool -> @+hex:Bool -> @secrets:List<&2, String> -> @r:Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes) -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, A>))

c: the header checks' answer, the signed prefix before the body, and the candidate signatures.

def std.sigs source · line 277 · raw

@xs:List<&2, String> -> List<&2, String>

The v1 entries of a space-separated webhook-signature; other versions are skipped.

def std.check source · line 284 · raw

@+tol:U32 -> @now:0xe00bbf72c3ff9dc6d7b077524522b3a8/time.Instant -> @+h:Map<&2, List<&2, String>> -> Result<&1, &1, Err, Pair(String, Pair(String, List<&2, String>))>

def verify.with source · line 296 · raw

@+tol:U32 -> @secrets:List<&2, String> -> @now:0xe00bbf72c3ff9dc6d7b077524522b3a8/time.Instant -> @+headers:Map<&2, List<&2, String>> -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, String>))

Done with the webhook-id when a v1 signature in webhook-signature signs id.timestamp.body under a secret, and webhook-timestamp is within tol seconds of now.

def verify source · line 301 · raw

@secrets:List<&2, String> -> @now:0xe00bbf72c3ff9dc6d7b077524522b3a8/time.Instant -> @+headers:Map<&2, List<&2, String>> -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, String>))

verify.with a 300 s tolerance.

def sign.check source · line 305 · raw

@+id:String -> @+ts:0x4eed9d7ac6ece61523d747a9d804e4f0/int.I64 -> Result<&1, &1, Err, Pair(String, String)>

def sign.headers source · line 311 · raw

@+id:String -> @+ts:String -> @m:Result<&1, &1, Err, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes> -> Result<&1, &1, Err, Map<&2, List<&2, String>>>

def sign.mac source · line 318 · raw

@k:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> @+id:String -> @+ts:String -> @r:Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes) -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, Map<&2, List<&2, String>>>))

def sign.key source · line 325 · raw

@k:Maybe<&1, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes> -> @+id:String -> @+ts:String -> @r:Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes) -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, Map<&2, List<&2, String>>>))

def sign.go source · line 333 · raw

@c:Result<&1, &1, Err, Pair(String, String)> -> @+secret:String -> @r:Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, 0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes) -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, Map<&2, List<&2, String>>>))

def sign source · line 343 · raw

@+id:String -> @+ts:0x4eed9d7ac6ece61523d747a9d804e4f0/int.I64 -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> @+secret:String -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, Map<&2, List<&2, String>>>))

The webhook-id, webhook-timestamp, and webhook-signature ("v1,<base64 MAC>") headers for body. id: not empty, no '.'. ts: unix seconds, not negative. secret: whsec_<base64>; send the prefix.

def stripe.t source · line 349 · raw

@s:String -> @r:Pair(List<&2, String>, List<&2, String>) -> Pair(List<&2, String>, List<&2, String>)

def stripe.v.push source · line 353 · raw

@s:String -> @r:Pair(List<&2, String>, List<&2, String>) -> Pair(List<&2, String>, List<&2, String>)

def stripe.v source · line 357 · raw

@m:Maybe<&2, String> -> @r:Pair(List<&2, String>, List<&2, String>) -> Pair(List<&2, String>, List<&2, String>)

def stripe.item source · line 364 · raw

@t:Maybe<&2, String> -> @v:Maybe<&2, String> -> @r:Pair(List<&2, String>, List<&2, String>) -> Pair(List<&2, String>, List<&2, String>)

def stripe.items source · line 373 · raw

@xs:List<&2, String> -> Pair(List<&2, String>, List<&2, String>)

The t= and v1= values of comma-separated items; other schemes (v0) are skipped.

def stripe.single source · line 380 · raw

@ts:List<&2, String> -> @vs:List<&2, String> -> Result<&1, &1, Err, Pair(String, List<&2, String>)>

def stripe.one source · line 390 · raw

@r:Pair(List<&2, String>, List<&2, String>) -> Result<&1, &1, Err, Pair(String, List<&2, String>)>

Exactly one t=: a second one would leave the signed timestamp ambiguous.

def stripe.when source · line 394 · raw

@+tol:U32 -> @now:0xe00bbf72c3ff9dc6d7b077524522b3a8/time.Instant -> @p:Pair(String, List<&2, String>) -> Result<&1, &1, Err, Pair(Unit, Pair(String, List<&2, String>))>

def stripe.check source · line 401 · raw

@+tol:U32 -> @now:0xe00bbf72c3ff9dc6d7b077524522b3a8/time.Instant -> @+h:Map<&2, List<&2, String>> -> Result<&1, &1, Err, Pair(Unit, Pair(String, List<&2, String>))>

def stripe.verify.with source · line 409 · raw

@+tol:U32 -> @secrets:List<&2, String> -> @now:0xe00bbf72c3ff9dc6d7b077524522b3a8/time.Instant -> @+headers:Map<&2, List<&2, String>> -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, Unit>))

Done when a v1 hex signature in stripe-signature signs t.body under a secret (the whsec_ text itself, as UTF-8), and t is within tol seconds of now.

def stripe.verify source · line 414 · raw

@secrets:List<&2, String> -> @now:0xe00bbf72c3ff9dc6d7b077524522b3a8/time.Instant -> @+headers:Map<&2, List<&2, String>> -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, Unit>))

stripe.verify.with a 300 s tolerance, Stripe's default.

def github.check source · line 420 · raw

@+h:Map<&2, List<&2, String>> -> Result<&1, &1, Err, Pair(Unit, Pair(String, List<&2, String>))>

def github.verify source · line 426 · raw

@secrets:List<&2, String> -> @+headers:Map<&2, List<&2, String>> -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, Unit>))

Done when x-hub-signature-256 is sha256=<hex> of the body under a secret's UTF-8. GitHub signs no timestamp.

def send.result source · line 434 · raw

@x:Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, Pair(0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget, Result<&1, &1, 0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Err, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>)) -> Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, Pair(0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget, Result<&1, &1, SendErr, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>))

def send.judge source · line 444 · raw

@r:Result<&1, &1, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Err, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res> -> Pair(Result<&1, &1, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Err, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>, Maybe<&2, String>)

A timeout, a refused connect, 408, 429, or any 5xx may clear.

def send.signed source · line 455 · raw

@c:0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client -> @b:0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget -> @+url:String -> @r:Pair(0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes, Result<&1, &1, Err, Map<&2, List<&2, String>>>) -> IO(Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, Pair(0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget, Result<&1, &1, SendErr, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>)))

Receivers deduplicate on the event ID, so the POST is safe to repeat.

def send.at source · line 469 · raw

@c:0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client -> @b:0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget -> @url:String -> @id:String -> @secret:String -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> @now:0xe00bbf72c3ff9dc6d7b077524522b3a8/time.Instant -> IO(Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, Pair(0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget, Result<&1, &1, SendErr, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>)))

def send.in source · line 480 · raw

@c:0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client -> @b:0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget -> @url:String -> @id:String -> @secret:String -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, Pair(0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget, Result<&1, &1, SendErr, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>)))

One event and one signature per delivery, inside a caller's retry layer. Every attempt takes from b and reuses this event ID, timestamp, headers, and raw body; the client's retry setting caps this delivery. The budget left comes back: pass it to the next delivery of the same operation.

def send.drop source · line 486 · raw

@x:Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, Pair(0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget, Result<&1, &1, SendErr, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>)) -> Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, Result<&1, &1, SendErr, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>)

def send.budget source · line 490 · raw

@url:String -> @id:String -> @secret:String -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> @y:Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, 0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget) -> IO(Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, Pair(0x5f997a9351e7133e43ba9611e734f8a6/retry.Budget, Result<&1, &1, SendErr, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>)))

def send.with source · line 496 · raw

@+n:U32 -> @c:0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client -> @url:String -> @id:String -> @secret:String -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, Result<&1, &1, SendErr, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>))

n extra attempts, on a budget of n + 1 and the client's deadline, with Hairpin's backoff. The client comes back with retry n.

def send source · line 504 · raw

@c:0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client -> @url:String -> @id:String -> @secret:String -> @body:0x49814d83de8f70993a43e1002be29ecd/bytes.Bytes -> IO(Pair(0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Client, Result<&1, &1, SendErr, 0x6a946b742ab6f76f0ba5b90b14da52ad/http.Res>))

Three extra attempts by default.