use std::fmt::Write as _;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hmac::{Hmac, KeyInit, Mac};
use kasapay_core::{
Currency, Delivery, Error, ErrorKind, Event, EventId, EventKind, Money, PaymentId, ProviderId,
Raw, Secret, Webhook, async_trait,
};
use sha2::Sha256;
use subtle::ConstantTimeEq as _;
use crate::convert::PROVIDER;
const SIGNATURE_HEADER: &str = "Stripe-Signature";
pub const DEFAULT_TOLERANCE: Duration = Duration::from_secs(300);
#[derive(Debug, Clone)]
pub struct Webhooks {
secret: Secret,
tolerance: Duration,
}
impl Webhooks {
#[must_use]
pub fn new(signing_secret: Secret) -> Self {
Self {
secret: signing_secret,
tolerance: DEFAULT_TOLERANCE,
}
}
#[must_use]
pub fn tolerance(mut self, tolerance: Duration) -> Self {
self.tolerance = tolerance;
self
}
fn signature(&self, timestamp: &str, body: &[u8]) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(self.secret.expose().as_bytes())
.unwrap_or_else(|_| unreachable!("HMAC accepts a key of any length"));
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(body);
mac.finalize()
.into_bytes()
.iter()
.fold(String::with_capacity(64), |mut hex, byte| {
let _ = write!(hex, "{byte:02x}");
hex
})
}
}
struct SignatureHeader<'a> {
timestamp: &'a str,
signatures: Vec<&'a str>,
}
impl<'a> SignatureHeader<'a> {
fn parse(header: &'a str) -> Option<Self> {
let mut timestamp = None;
let mut signatures = Vec::new();
for part in header.split(',') {
match part.trim().split_once('=') {
Some(("t", value)) => timestamp = Some(value),
Some(("v1", value)) => signatures.push(value),
_ => {}
}
}
let timestamp = timestamp?;
(!signatures.is_empty()).then_some(Self {
timestamp,
signatures,
})
}
}
#[async_trait]
impl Webhook for Webhooks {
fn provider(&self) -> ProviderId {
PROVIDER
}
async fn verify(&self, delivery: &Delivery<'_>) -> Result<Event, Error> {
let header = delivery
.signed_header(SIGNATURE_HEADER)
.map_err(|e| Error::new(ErrorKind::Untrusted, PROVIDER, e.to_string()).with_source(e))?
.ok_or_else(|| {
Error::new(
ErrorKind::Untrusted,
PROVIDER,
"the delivery carried no Stripe-Signature header",
)
})?;
let parsed = SignatureHeader::parse(header).ok_or_else(|| {
Error::new(
ErrorKind::Untrusted,
PROVIDER,
"the Stripe-Signature header carried no timestamp and v1 signature",
)
})?;
let expected = self.signature(parsed.timestamp, delivery.body());
let matched = parsed
.signatures
.iter()
.any(|sent| expected.as_bytes().ct_eq(sent.as_bytes()).unwrap_u8() == 1);
if !matched {
return Err(Error::new(
ErrorKind::Untrusted,
PROVIDER,
"the delivery is not signed the way Stripe signs one",
));
}
self.check_age(parsed.timestamp)?;
let body = delivery.body_str().ok_or_else(|| {
Error::new(
ErrorKind::Malformed,
PROVIDER,
"a signed delivery whose body is not UTF-8",
)
})?;
read_event(body)
}
}
impl Webhooks {
fn check_age(&self, timestamp: &str) -> Result<(), Error> {
let sent = timestamp.parse::<u64>().map_err(|e| {
Error::new(
ErrorKind::Untrusted,
PROVIDER,
"the Stripe-Signature timestamp is not a number of seconds",
)
.with_source(e)
})?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| {
Error::new(
ErrorKind::Untrusted,
PROVIDER,
"this machine's clock is before 1970, so no delivery can be dated",
)
.with_source(e)
})?
.as_secs();
let apart = now.abs_diff(sent);
if apart > self.tolerance.as_secs() {
return Err(Error::new(
ErrorKind::Untrusted,
PROVIDER,
format!(
"the delivery is signed {apart} seconds from now, outside the {} \
the tolerance allows",
self.tolerance.as_secs()
),
));
}
Ok(())
}
}
fn read_event(body: &str) -> Result<Event, Error> {
let value: serde_json::Value = serde_json::from_str(body).map_err(|e| {
Error::new(
ErrorKind::Malformed,
PROVIDER,
"a signed delivery whose body is not the JSON Stripe documents",
)
.with_source(e)
})?;
let id = value
.get("id")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
Error::new(
ErrorKind::Malformed,
PROVIDER,
"a delivery carried no event id, and there is nothing to deduplicate it by",
)
})?;
let event_type = value
.get("type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
Error::new(
ErrorKind::Malformed,
PROVIDER,
"a delivery carried no event type",
)
})?;
let object = value.pointer("/data/object");
let payment = match event_type.split_once('.') {
Some(("payment_intent", _)) => object.and_then(|o| o.get("id")),
_ => object.and_then(|o| o.get("payment_intent")),
}
.and_then(serde_json::Value::as_str)
.map(PaymentId::issued);
Ok(Event {
id: EventId::issued(id),
kind: event_kind(event_type),
payment,
amount: object.and_then(|object| event_amount(event_type, object)),
provider: PROVIDER,
raw: Raw::from_text(body),
})
}
fn event_kind(event_type: &str) -> EventKind {
match event_type {
"payment_intent.succeeded" => EventKind::Captured,
"payment_intent.amount_capturable_updated" => EventKind::Authorized,
"payment_intent.canceled" => EventKind::Canceled,
"payment_intent.payment_failed" => EventKind::Failed,
"charge.refunded" => EventKind::Refunded,
other => EventKind::Other(other.into()),
}
}
fn event_amount(event_type: &str, object: &serde_json::Value) -> Option<Money> {
let field = if event_type == "charge.refunded" {
"amount_refunded"
} else {
"amount"
};
let minor_units = object.get(field)?.as_i64()?;
let currency = object.get("currency")?.as_str()?.parse::<Currency>().ok()?;
Some(Money::from_minor_units(minor_units, currency))
}
#[cfg(test)]
mod tests {
use super::{SignatureHeader, event_kind};
use kasapay_core::EventKind;
#[test]
fn a_header_being_rotated_carries_two_signatures_and_both_are_read() {
let parsed = SignatureHeader::parse("t=1492774577,v1=aaa,v0=zzz,v1=bbb")
.expect("a timestamp and two signatures");
assert_eq!(parsed.timestamp, "1492774577");
assert_eq!(parsed.signatures, ["aaa", "bbb"]);
}
#[test]
fn a_header_with_no_v1_signature_is_no_header_at_all() {
assert!(SignatureHeader::parse("t=1492774577,v0=zzz").is_none());
assert!(SignatureHeader::parse("v1=aaa").is_none());
assert!(SignatureHeader::parse("").is_none());
}
#[test]
fn an_unknown_event_type_keeps_stripes_own_name_for_it() {
assert_eq!(event_kind("payment_intent.succeeded"), EventKind::Captured);
assert_eq!(event_kind("charge.refunded"), EventKind::Refunded);
assert_eq!(
event_kind("issuing_card.created"),
EventKind::Other("issuing_card.created".into())
);
assert_eq!(
event_kind("charge.succeeded"),
EventKind::Other("charge.succeeded".into())
);
}
}