use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hmac::{Hmac, KeyInit, Mac};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::error::{Error, codes};
pub const SIGNATURE_HEADER: &str = "X-Invoist-Signature";
pub const EVENT_HEADER: &str = "X-Invoist-Event";
pub const DELIVERY_HEADER: &str = "X-Invoist-Delivery";
pub const DEFAULT_TOLERANCE: Duration = Duration::from_secs(300);
pub mod events {
pub const DOCUMENT_CREATED: &str = "document.created";
pub const EXTRACTION_COMPLETED: &str = "extraction.completed";
pub const EXTRACTION_FAILED: &str = "extraction.failed";
pub const REVIEW_ITEM_CREATED: &str = "review_item.created";
pub const REVIEW_ITEM_RESOLVED: &str = "review_item.resolved";
pub const DOCUMENT_REQUEST_FULFILLED: &str = "document_request.fulfilled";
pub const ALL: [&str; 6] = [
DOCUMENT_CREATED,
EXTRACTION_COMPLETED,
EXTRACTION_FAILED,
REVIEW_ITEM_CREATED,
REVIEW_ITEM_RESOLVED,
DOCUMENT_REQUEST_FULFILLED,
];
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Event {
pub id: String,
#[serde(rename = "type")]
pub event_type: String,
pub created_at: String,
#[serde(default)]
pub data: serde_json::Value,
}
impl Event {
pub fn data_as<T: DeserializeOwned>(&self) -> Result<T, Error> {
T::deserialize(&self.data).map_err(|e| {
Error::new(
400,
codes::INVALID_PAYLOAD,
format!("The event's data has an unexpected shape: {e}"),
)
.with_source(e)
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VerifyOptions {
pub tolerance: Duration,
pub now: Option<SystemTime>,
}
impl Default for VerifyOptions {
fn default() -> Self {
VerifyOptions {
tolerance: DEFAULT_TOLERANCE,
now: None,
}
}
}
impl VerifyOptions {
pub fn tolerance(mut self, tolerance: Duration) -> Self {
self.tolerance = tolerance;
self
}
pub fn now_unix(mut self, seconds: u64) -> Self {
self.now = Some(UNIX_EPOCH + Duration::from_secs(seconds));
self
}
}
pub fn verify(
payload: impl AsRef<[u8]>,
signature_header: &str,
secret: &str,
) -> Result<Event, Error> {
verify_with(payload, signature_header, secret, VerifyOptions::default())
}
pub fn verify_with(
payload: impl AsRef<[u8]>,
signature_header: &str,
secret: &str,
options: VerifyOptions,
) -> Result<Event, Error> {
let payload = payload.as_ref();
let header = parse_header(signature_header)?;
let expected = signature(secret, header.timestamp, payload);
let matched = header
.signatures
.iter()
.fold(subtle::Choice::from(0), |acc, sig| {
acc | sig.as_bytes().ct_eq(expected.as_bytes())
});
if !bool::from(matched) {
return Err(invalid("The signature does not match the payload"));
}
if !options.tolerance.is_zero() {
let now = options
.now
.unwrap_or_else(SystemTime::now)
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
if now.abs_diff(header.seconds) > options.tolerance.as_secs() {
return Err(Error::new(
400,
codes::SIGNATURE_TOO_OLD,
"The signature's timestamp is outside the tolerance window",
));
}
}
serde_json::from_slice(payload).map_err(|e| {
Error::new(
400,
codes::INVALID_PAYLOAD,
format!("The webhook body is not an event: {e}"),
)
.with_source(e)
})
}
struct Header<'a> {
timestamp: &'a str,
seconds: u64,
signatures: Vec<&'a str>,
}
fn parse_header(header: &str) -> Result<Header<'_>, Error> {
let mut timestamp = None;
let mut signatures = Vec::new();
for part in header.split(',') {
let Some((key, value)) = part.trim().split_once('=') else {
continue;
};
match key.trim() {
"t" if timestamp.is_some() => {
return Err(invalid("The signature header has more than one timestamp"));
}
"t" => timestamp = Some(value.trim()),
"v1" => signatures.push(value.trim()),
_ => {}
}
}
let timestamp = timestamp.ok_or_else(|| invalid("The signature header has no timestamp"))?;
if timestamp.is_empty() || !timestamp.bytes().all(|b| b.is_ascii_digit()) {
return Err(invalid("The signature timestamp is not a number"));
}
let seconds = timestamp
.parse()
.map_err(|_| invalid("The signature timestamp is not a number"))?;
if signatures.is_empty() {
return Err(invalid("The signature header has no v1 signature"));
}
Ok(Header {
timestamp,
seconds,
signatures,
})
}
fn invalid(message: &str) -> Error {
Error::new(400, codes::INVALID_SIGNATURE, message)
}
fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(DIGITS[(b >> 4) as usize] as char);
out.push(DIGITS[(b & 0x0f) as usize] as char);
}
out
}
pub fn sign(payload: impl AsRef<[u8]>, secret: &str, timestamp: u64) -> String {
let timestamp = timestamp.to_string();
let v1 = signature(secret, ×tamp, payload.as_ref());
format!("t={timestamp},v1={v1}")
}
fn signature(secret: &str, timestamp: &str, payload: &[u8]) -> String {
let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(secret.as_bytes())
.expect("HMAC accepts keys of any length");
mac.update(timestamp.as_bytes());
mac.update(b".");
mac.update(payload);
hex(&mac.finalize().into_bytes())
}