///|
/// Helpers for receiving and verifying Notion webhook deliveries.
///
/// Port of `src/webhooks.ts`. Event signatures use HMAC-SHA256 with the
/// subscription's verification token. The `X-Notion-Signature` header contains
/// `sha256=`. Pass the raw body as received; parsing and re-serializing
/// JSON can change its bytes and invalidate the signature.
///|
/// Arguments for `verify_webhook_signature`.
pub(all) struct VerifyWebhookSignatureArgs {
/// The raw HTTP request body, before any parsing.
body : String
/// The value of the `X-Notion-Signature` request header. Verification fails
/// (returns false) if this is missing or malformed.
signature : String?
/// The verification token configured for this webhook subscription.
verification_token : String
}
///|
/// The prefix of the signature header value.
let signature_prefix : String = "sha256="
///|
/// Length of a SHA-256 digest in lower-case hex.
let sha256_hex_length : Int = 64
///|
/// Verify that a webhook delivery came from Notion and has not been tampered
/// with.
///
/// Returns `true` if the signature matches HMAC-SHA256 of the body using the
/// verification token. Returns `false` for a missing or malformed signature.
pub fn verify_webhook_signature(args : VerifyWebhookSignatureArgs) -> Bool {
let { body, signature, verification_token, } = args
let provided_hex = match signature {
None => return false
Some(value) => {
if !value.has_prefix(signature_prefix) {
return false
}
value[signature_prefix.length():].to_owned().to_lower()
}
}
if provided_hex.length() != sha256_hex_length {
return false
}
if !is_lower_hex(provided_hex) {
return false
}
let computed_hex = bytes_to_hex(
hmac_sha256(@utf8.encode(verification_token), @utf8.encode(body)),
)
timing_safe_equal_hex(provided_hex, computed_hex)
}
///|
/// Compute the value Notion would send in `X-Notion-Signature` for a given body
/// and verification token. Use this to test webhook handlers without a live
/// subscription.
pub fn sign_webhook_payload(
body : String,
verification_token : String,
) -> String {
let hex = bytes_to_hex(
hmac_sha256(@utf8.encode(verification_token), @utf8.encode(body)),
)
signature_prefix + hex
}
///|
/// Whether a string consists only of lower-case hex digits.
fn is_lower_hex(value : String) -> Bool {
for ch in value {
match ch {
'0'..='9' | 'a'..='f' => ()
_ => return false
}
}
true
}
///|
/// Constant-time string comparison. The inputs are already known to be the same
/// length, so this purely avoids early-exit on the first differing character.
fn timing_safe_equal_hex(a : String, b : String) -> Bool {
if a.length() != b.length() {
return false
}
let mut diff = 0
for i = 0; i < a.length(); i = i + 1 {
diff = diff | (a.get(i).unwrap() ^ b.get(i).unwrap()).to_int()
}
diff == 0
}