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
Missing@name:String -> Err
BadSecretErr
BadIdErr
BadTimestampErr
TooOldErr
TooNewErr
NoMatchErr
CryptoErr@code:U32 -> @msg:String -> Err
type SendErr source · line 430 · raw
Data
SendSign@err:Err -> SendErr
SendNet@err:0x5f997a9351e7133e43ba9611e734f8a6/hairpin.Err -> SendErr
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.