use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64;
use hmac::{Hmac, Mac};
use sha2::Sha256;
use crate::error::{Error, Result};
type HmacSha256 = Hmac<Sha256>;
pub const HMAC_HEADER: &str = "x-shopify-hmac-sha256";
#[derive(Clone)]
pub struct SecretSet {
current: String,
previous: Option<String>,
}
impl std::fmt::Debug for SecretSet {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SecretSet")
.field("current", &"<redacted>")
.field("previous", &self.previous.as_ref().map(|_| "<redacted>"))
.finish()
}
}
impl SecretSet {
pub fn new(current: impl Into<String>, previous: Option<String>) -> Result<Self> {
let current = current.into();
if current.trim().is_empty() {
return Err(Error::Config("the client secret is blank".to_owned()));
}
let previous = previous.filter(|secret| !secret.trim().is_empty());
Ok(Self { current, previous })
}
fn iter(&self) -> impl Iterator<Item = &str> {
std::iter::once(self.current.as_str()).chain(self.previous.as_deref())
}
#[must_use]
pub(crate) fn current(&self) -> &str {
&self.current
}
}
#[must_use]
pub fn sign_webhook(secret: &str, body: &[u8]) -> String {
mac(secret, body).map_or_else(String::new, |mac| {
BASE64.encode(mac.finalize().into_bytes())
})
}
pub fn verify_webhook(secrets: &SecretSet, header: Option<&str>, body: &[u8]) -> Result<()> {
let header = header.ok_or_else(|| Error::Signature(format!("missing {HMAC_HEADER} header")))?;
let expected = BASE64
.decode(header.trim())
.map_err(|_| Error::Signature(format!("{HMAC_HEADER} is not base64")))?;
verify_digest(secrets, body, &expected)
}
#[must_use]
pub fn sign_query(secret: &str, params: &[(String, String)]) -> String {
mac(secret, query_message(params).as_bytes())
.map_or_else(String::new, |mac| hex::encode(mac.finalize().into_bytes()))
}
pub fn verify_query(secrets: &SecretSet, params: &[(String, String)]) -> Result<()> {
let hmac = params
.iter()
.find(|(key, _)| key == "hmac")
.map(|(_, value)| value.as_str())
.ok_or_else(|| Error::Signature("missing hmac parameter".to_owned()))?;
let expected =
hex::decode(hmac).map_err(|_| Error::Signature("hmac parameter is not hex".to_owned()))?;
verify_digest(secrets, query_message(params).as_bytes(), &expected)
}
#[must_use]
pub fn query_message(params: &[(String, String)]) -> String {
let mut pairs: Vec<&(String, String)> =
params.iter().filter(|(key, _)| key != "hmac").collect();
pairs.sort();
pairs
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join("&")
}
fn mac(secret: &str, message: &[u8]) -> Option<HmacSha256> {
let mut mac = <HmacSha256 as Mac>::new_from_slice(secret.as_bytes()).ok()?;
mac.update(message);
Some(mac)
}
fn verify_digest(secrets: &SecretSet, message: &[u8], expected: &[u8]) -> Result<()> {
let mut matched = false;
for secret in secrets.iter() {
matched |= mac(secret, message).is_some_and(|mac| mac.verify_slice(expected).is_ok());
}
if matched {
Ok(())
} else {
Err(Error::Signature("hmac does not match".to_owned()))
}
}
#[cfg(test)]
mod tests;