1use crate::key::AppKey;
10use crate::{base64, constant_time_eq, unix_now};
11use hmac::{Hmac, Mac};
12use rustlavel_core::{Config, Result};
13use rustlavel_http::response::{IntoResponse, Response};
14use rustlavel_http::url;
15use sha2::Sha256;
16use std::time::Duration;
17
18type HmacSha256 = Hmac<Sha256>;
19
20pub const SIGNATURE_PARAM: &str = "signature";
22pub const EXPIRES_PARAM: &str = "expires";
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum SignatureError {
32 Missing,
34 Invalid,
36 Expired,
38}
39
40impl SignatureError {
41 pub fn message(self) -> &'static str {
42 match self {
43 SignatureError::Missing => "This link is missing its signature.",
44 SignatureError::Invalid => "This link is not valid.",
45 SignatureError::Expired => "This link has expired.",
46 }
47 }
48}
49
50impl std::fmt::Display for SignatureError {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.write_str(self.message())
53 }
54}
55
56impl std::error::Error for SignatureError {}
57
58impl IntoResponse for SignatureError {
61 fn into_response(self) -> Response {
62 Response::new(rustlavel_http::Status::FORBIDDEN).with_html(format!("<h1>{}</h1>", self))
63 }
64}
65
66#[derive(Clone)]
68pub struct UrlSigner {
69 key: [u8; 32],
70}
71
72impl UrlSigner {
73 pub fn new(key: &AppKey) -> Self {
74 UrlSigner { key: key.derive("signed-url") }
75 }
76
77 pub fn from_config(config: &Config) -> Result<Self> {
78 Ok(UrlSigner::new(&AppKey::from_config(config)?))
79 }
80
81 pub fn sign(&self, url: &str, expires_at: Option<u64>) -> String {
89 let (origin, path, query) = split_url(url);
90
91 let mut pairs: Vec<(String, String)> = url::parse_query(query)
92 .into_iter()
93 .filter(|(key, _)| key != SIGNATURE_PARAM && key != EXPIRES_PARAM)
94 .collect();
95 if let Some(expires) = expires_at {
96 pairs.push((EXPIRES_PARAM.to_string(), expires.to_string()));
97 }
98
99 let signature = self.signature(&canonical(path, &pairs));
100 pairs.push((SIGNATURE_PARAM.to_string(), signature));
101
102 format!("{origin}{}", render(path, &pairs))
103 }
104
105 pub fn sign_temporary(&self, url: &str, ttl: Duration) -> String {
107 self.sign(url, Some(unix_now() + ttl.as_secs()))
108 }
109
110 pub fn verify(&self, url: &str) -> std::result::Result<(), SignatureError> {
116 let (_, path, query) = split_url(url);
117 let pairs = url::parse_query(query);
118
119 let signature = pairs
120 .iter()
121 .find(|(key, _)| key == SIGNATURE_PARAM)
122 .map(|(_, value)| value.clone())
123 .ok_or(SignatureError::Missing)?;
124
125 let signed: Vec<(String, String)> =
126 pairs.into_iter().filter(|(key, _)| key != SIGNATURE_PARAM).collect();
127
128 let expected = self.signature(&canonical(path, &signed));
129 if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
130 return Err(SignatureError::Invalid);
131 }
132
133 match signed.iter().find(|(key, _)| key == EXPIRES_PARAM) {
134 None => Ok(()),
135 Some((_, value)) => match value.parse::<u64>() {
136 Ok(expires) if unix_now() <= expires => Ok(()),
137 Ok(_) => Err(SignatureError::Expired),
138 Err(_) => Err(SignatureError::Invalid),
141 },
142 }
143 }
144
145 pub fn is_valid(&self, url: &str) -> bool {
147 self.verify(url).is_ok()
148 }
149
150 fn signature(&self, canonical: &str) -> String {
151 let mut mac = HmacSha256::new_from_slice(&self.key).expect("HMAC accepts any key length");
152 mac.update(canonical.as_bytes());
153 base64::encode_url(&mac.finalize().into_bytes())
154 }
155}
156
157impl std::fmt::Debug for UrlSigner {
158 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159 f.write_str("UrlSigner { key: <redacted> }")
160 }
161}
162
163fn split_url(url: &str) -> (&str, &str, &str) {
170 let (location, query) = match url.split_once('?') {
171 Some((location, query)) => (location, query),
172 None => (url, ""),
173 };
174
175 let path_start = match location.find("://") {
176 Some(scheme_end) => location[scheme_end + 3..]
177 .find('/')
178 .map_or(location.len(), |offset| scheme_end + 3 + offset),
179 None => 0,
180 };
181
182 (&location[..path_start], &location[path_start..], query)
183}
184
185fn canonical(path: &str, pairs: &[(String, String)]) -> String {
191 let mut sorted: Vec<&(String, String)> = pairs.iter().collect();
192 sorted.sort_by(|a, b| a.0.cmp(&b.0));
193
194 let path = if path.is_empty() { "/" } else { path };
195 let query = sorted
196 .iter()
197 .map(|(key, value)| format!("{}={}", url::encode(key), url::encode(value)))
198 .collect::<Vec<_>>()
199 .join("&");
200
201 if query.is_empty() { path.to_string() } else { format!("{path}?{query}") }
202}
203
204fn render(path: &str, pairs: &[(String, String)]) -> String {
206 let path = if path.is_empty() { "/" } else { path };
207 if pairs.is_empty() {
208 return path.to_string();
209 }
210
211 let query = pairs
212 .iter()
213 .map(|(key, value)| format!("{}={}", url::encode(key), url::encode(value)))
214 .collect::<Vec<_>>()
215 .join("&");
216 format!("{path}?{query}")
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn signer() -> UrlSigner {
224 UrlSigner::new(&AppKey::from_bytes([9u8; 32]))
225 }
226
227 #[test]
228 fn a_signed_url_verifies() {
229 let signer = signer();
230 let signed = signer.sign("/downloads/42", None);
231
232 assert!(signed.starts_with("/downloads/42?signature="));
233 assert_eq!(signer.verify(&signed), Ok(()));
234 }
235
236 #[test]
237 fn signing_preserves_the_existing_query_string() {
238 let signer = signer();
239 let signed = signer.sign("/invoices?year=2026&format=pdf", None);
240
241 assert!(signed.contains("year=2026"));
242 assert!(signed.contains("format=pdf"));
243 assert!(signer.is_valid(&signed));
244 }
245
246 #[test]
247 fn an_unsigned_url_is_missing_its_signature() {
248 assert_eq!(signer().verify("/downloads/42"), Err(SignatureError::Missing));
249 assert_eq!(signer().verify("/downloads/42?other=1"), Err(SignatureError::Missing));
250 }
251
252 #[test]
253 fn a_modified_path_or_parameter_fails() {
254 let signer = signer();
255 let signed = signer.sign("/downloads/42?user=7", None);
256
257 assert_eq!(signer.verify(&signed.replace("/42", "/43")), Err(SignatureError::Invalid));
258 assert_eq!(signer.verify(&signed.replace("user=7", "user=8")), Err(SignatureError::Invalid));
259 assert_eq!(
260 signer.verify(&format!("{signed}&admin=1")),
261 Err(SignatureError::Invalid),
262 "an appended parameter must invalidate the signature"
263 );
264 }
265
266 #[test]
267 fn a_forged_signature_fails() {
268 let signer = signer();
269 let forged = format!("/downloads/42?signature={}", base64::encode_url(&[0u8; 32]));
270
271 assert_eq!(signer.verify(&forged), Err(SignatureError::Invalid));
272 assert_eq!(signer.verify("/downloads/42?signature="), Err(SignatureError::Invalid));
273 }
274
275 #[test]
276 fn a_url_signed_with_another_key_fails() {
277 let signed = signer().sign("/downloads/42", None);
278 let other = UrlSigner::new(&AppKey::from_bytes([1u8; 32]));
279
280 assert_eq!(other.verify(&signed), Err(SignatureError::Invalid));
281 }
282
283 #[test]
284 fn an_expiry_in_the_future_is_accepted_and_one_in_the_past_is_not() {
285 let signer = signer();
286
287 let live = signer.sign("/reset/token", Some(unix_now() + 3_600));
288 assert!(live.contains("expires="));
289 assert_eq!(signer.verify(&live), Ok(()));
290
291 let stale = signer.sign("/reset/token", Some(unix_now() - 1));
292 assert_eq!(signer.verify(&stale), Err(SignatureError::Expired));
293 }
294
295 #[test]
296 fn extending_an_expiry_by_hand_is_rejected() {
297 let signer = signer();
298 let expired_at = unix_now() - 1;
299 let stale = signer.sign("/reset/token", Some(expired_at));
300
301 let extended =
302 stale.replace(&format!("expires={expired_at}"), &format!("expires={}", unix_now() + 999));
303
304 assert_eq!(signer.verify(&extended), Err(SignatureError::Invalid));
306 }
307
308 #[test]
309 fn sign_temporary_expires_after_its_ttl() {
310 let signer = signer();
311
312 assert!(signer.is_valid(&signer.sign_temporary("/x", Duration::from_secs(60))));
313 assert!(signer.is_valid(&signer.sign_temporary("/x", Duration::ZERO)));
315 }
316
317 #[test]
318 fn re_signing_replaces_rather_than_appends() {
319 let signer = signer();
320 let once = signer.sign("/x", Some(unix_now() + 60));
321 let twice = signer.sign(&once, Some(unix_now() + 120));
322
323 assert_eq!(twice.matches("signature=").count(), 1);
324 assert_eq!(twice.matches("expires=").count(), 1);
325 assert!(signer.is_valid(&twice));
326 }
327
328 #[test]
329 fn absolute_and_relative_forms_of_the_same_url_verify_alike() {
330 let signer = signer();
331 let signed = signer.sign("https://app.test/reset/token", Some(unix_now() + 60));
332
333 assert!(signed.starts_with("https://app.test/reset/token?"));
334
335 let (_, path, query) = split_url(&signed);
336 assert_eq!(signer.verify(&format!("{path}?{query}")), Ok(()));
337 }
338
339 #[test]
340 fn reordering_the_query_string_does_not_break_the_signature() {
341 let signer = signer();
342 let signed = signer.sign("/x?b=2&a=1", None);
343 let signature = signed.split("signature=").nth(1).unwrap();
344
345 assert_eq!(signer.verify(&format!("/x?a=1&b=2&signature={signature}")), Ok(()));
346 }
347
348 #[test]
349 fn parameters_with_reserved_characters_round_trip() {
350 let signer = signer();
351 let signed = signer.sign("/search?q=rust%20%26%20laravel", None);
352
353 assert!(signer.is_valid(&signed), "signed url was {signed}");
354 }
355
356 #[test]
357 fn splits_urls_with_and_without_an_origin() {
358 assert_eq!(split_url("/a/b?x=1"), ("", "/a/b", "x=1"));
359 assert_eq!(split_url("https://h.test/a?x=1"), ("https://h.test", "/a", "x=1"));
360 assert_eq!(split_url("https://h.test"), ("https://h.test", "", ""));
361 assert_eq!(split_url("/a"), ("", "/a", ""));
362 }
363
364 #[test]
365 fn an_expiry_that_is_not_a_number_is_treated_as_a_forgery() {
366 let signer = signer();
367 let pairs = vec![(EXPIRES_PARAM.to_string(), "soon".to_string())];
370 let signature = signer.signature(&canonical("/x", &pairs));
371
372 assert_eq!(
373 signer.verify(&format!("/x?expires=soon&signature={signature}")),
374 Err(SignatureError::Invalid)
375 );
376 }
377
378 #[test]
379 fn an_invalid_signature_renders_as_a_403() {
380 let response = SignatureError::Expired.into_response();
381
382 assert_eq!(response.status.code(), 403);
383 assert!(response.body_string().contains("expired"));
384 }
385}