hmac_predicate/
lib.rs

1use bytes::Bytes;
2use hex::FromHexError;
3use hmac::{digest::InvalidLength, Hmac, Mac};
4use http::{HeaderValue, Request};
5use sha2::Sha256;
6use thiserror::Error;
7use tower::filter::Predicate;
8
9#[derive(Debug, Clone)]
10pub struct HmacQueryParamValidator {
11	pub key: String,
12}
13
14/// The failure modes of HMAC verification, mostly for troubleshooting
15#[derive(Error, Debug)]
16pub enum HmacQueryParamError {
17	#[error("Request did not have a query string")]
18	NoQueryString,
19	#[error("Request did not have an HMAC query param: {query_string}")]
20	QueryStringButNoHmac { query_string: String },
21	#[error("Hash key not appropriately set: {error:#?}")]
22	HashKeyNotSet { error: InvalidLength },
23	#[error("Failed decoding HMAC as hex string: {error:#?}")]
24	HexDecodingError { error: FromHexError },
25	#[error("Computed hash did not match provided hash")]
26	HashVerificationFailed,
27}
28
29#[derive(Debug, Clone)]
30pub struct HmacHeaderValidator {
31	pub key: String,
32	pub header_name: String,
33}
34
35/// The failure modes of HMAC verification, mostly for troubleshooting
36#[derive(Error, Debug)]
37pub enum HmacHeaderError {
38	#[error("Request did not contain a '{header_name}' header.")]
39	NoHeader { header_name: String },
40	#[error("Request contained {num} headers with name '{header_name}'")]
41	MultipleHeaders { num: usize, header_name: String },
42	#[error("Hash key not appropriately set: {error:#?}")]
43	HashKeyNotSet { error: InvalidLength },
44	// #[error("Failed decoding HMAC as hex string: {error:#?}")]
45	// HexDecodingError { error: FromHexError },
46	// #[error("Computed hash did not match provided hash")]
47	// HashVerificationFailed,
48}
49
50impl<T> Predicate<Request<T>> for HmacQueryParamValidator {
51	type Request = http::Request<T>;
52	fn check(&mut self, request: Request<T>) -> Result<Self::Request, tower::BoxError> {
53		// 1. Get the entire query string in one shot
54		let query = request.uri().query().ok_or(HmacQueryParamError::NoQueryString)?;
55
56		// 2. Grab the hmac query parameter (both key and value), separate from the rest of the query params
57		let (hmac, params): (Vec<_>, Vec<_>) =
58			form_urlencoded::parse(query.as_bytes()).partition(|(key, _)| key == "hmac");
59		let hmac = &hmac
60			.first()
61			.ok_or(HmacQueryParamError::QueryStringButNoHmac {
62				query_string: query.to_string(),
63			})?
64			.1;
65
66		// 3. Rebuild the query string without the hmac, as it's excluded from the hash
67		let query_string_without_hmac = form_urlencoded::Serializer::new(String::new())
68			.extend_pairs(params)
69			.finish();
70
71		// 4. Create a HMAC SHA256 hash function using key as the hash key
72		let mut hasher = Hmac::<Sha256>::new_from_slice(self.key.as_bytes())
73			.map_err(|e| HmacQueryParamError::HashKeyNotSet { error: e })?;
74
75		// 5. Hash the remnants of the query string (with hmac removed)
76		hasher.update(&query_string_without_hmac.into_bytes());
77
78		// 6. The value in the query string is the result of the hash function represented as hexadecimal, represented
79		// as a string. I say that slighly weirdly because you can't just compare the value to the computed hash, the
80		// string needs to be decoded as hexadecimal (e.g. the characters '02' are the numerical value 2). At that
81		// point the resultant "number" can be compared with the output of the hash function
82		let hmac_bytes =
83			hex::decode(hmac.as_bytes()).map_err(|e| HmacQueryParamError::HexDecodingError { error: e })?;
84
85		// 7. Compare the Shopify-provided value to the freshly computed value
86		hasher
87			.verify(hmac_bytes.as_slice().into())
88			.map_err(|_| HmacQueryParamError::HashVerificationFailed)?;
89
90		Ok(request)
91	}
92}
93
94impl<B: Into<Bytes>> Predicate<Request<B>> for HmacHeaderValidator
95where
96	for<'a> &'a [u8]: From<&'a B>,
97{
98	type Request = http::Request<B>;
99	fn check(&mut self, request: Request<B>) -> Result<Self::Request, tower::BoxError> {
100		// 1. Get any headers that match the provided name
101		let header_values = request
102			.headers()
103			.get_all(&self.header_name)
104			.into_iter()
105			.collect::<Vec<&HeaderValue>>();
106
107		// Return if there's not exactly 1
108		if header_values.is_empty() {
109			return Err(Box::new(HmacHeaderError::NoHeader {
110				header_name: self.header_name.clone(),
111			}));
112		} else if header_values.len() > 1 {
113			return Err(Box::new(HmacHeaderError::MultipleHeaders {
114				num: header_values.len(),
115				header_name: self.header_name.clone(),
116			}));
117		}
118
119		// 2. Get the value of the header that matches the provided name
120		let hmac = header_values.first().ok_or(HmacHeaderError::NoHeader {
121			header_name: self.header_name.clone(),
122		})?;
123
124		// 3. Create a HMAC SHA256 hash function using key as the hash key
125		let mut hasher = Hmac::<Sha256>::new_from_slice(self.key.as_bytes())
126			.map_err(|e| HmacHeaderError::HashKeyNotSet { error: e })?;
127
128		// 4. Hash the request body
129		hasher.update(request.body().into());
130
131		// 5. The value in the query string is the result of the hash function represented as hexadecimal, represented
132		// as a string. I say that slighly weirdly because you can't just compare the value to the computed hash, the
133		// string needs to be decoded as hexadecimal (e.g. the characters '02' are the numerical value 2). At that
134		// point the resultant "number" can be compared with the output of the hash function
135		let hmac_bytes =
136			hex::decode(hmac.as_bytes()).map_err(|e| HmacQueryParamError::HexDecodingError { error: e })?;
137
138		// 6. Compare the Shopify-provided value to the freshly computed value
139		hasher
140			.verify(hmac_bytes.as_slice().into())
141			.map_err(|_| HmacQueryParamError::HashVerificationFailed)?;
142
143		Ok(request)
144	}
145}