Skip to main content

rustlavel_auth/
signed_url.rs

1//! Signed URLs: links that prove they came from this application.
2//!
3//! A password-reset mail, an invitation, a one-off download — each is a URL
4//! that must work for whoever holds it and for nobody else, without a session
5//! and without a row in a database. Appending an HMAC-SHA256 signature over the
6//! path and query gives exactly that: the link is self-contained, unforgeable
7//! without the application key, and can carry its own expiry.
8
9use 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
20/// The query parameter carrying the signature.
21pub const SIGNATURE_PARAM: &str = "signature";
22/// The query parameter carrying the expiry, as a unix timestamp in seconds.
23pub const EXPIRES_PARAM: &str = "expires";
24
25/// Why a signed URL was not accepted.
26///
27/// Separate variants because the two failures deserve different words to the
28/// visitor: an expired reset link should offer a new one, an invalid one should
29/// not pretend anything is recoverable.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum SignatureError {
32    /// No `signature` parameter at all.
33    Missing,
34    /// The signature did not match — the URL was edited, or signed elsewhere.
35    Invalid,
36    /// The signature was good, but the link is past its `expires` timestamp.
37    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
58/// Answers 403 — the request was understood and refused, and no amount of
59/// authenticating will change that.
60impl 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/// Signs and verifies URLs with a key derived from `APP_KEY`.
67#[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    /// Sign a URL, optionally giving it an expiry.
82    ///
83    /// `expires_at` is a unix timestamp in seconds; `None` signs a link that
84    /// never expires, which is right for an unsubscribe URL and wrong for a
85    /// password reset. Any `signature` or `expires` already on the URL is
86    /// dropped first, so re-signing a link is idempotent rather than
87    /// accumulating parameters.
88    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    /// Sign a URL that expires `ttl` from now — the common case.
106    pub fn sign_temporary(&self, url: &str, ttl: Duration) -> String {
107        self.sign(url, Some(unix_now() + ttl.as_secs()))
108    }
109
110    /// Check a URL, usually `request.target()`.
111    ///
112    /// The signature is verified *before* the expiry is read. `expires` is an
113    /// attacker-supplied number until the MAC says otherwise, so trusting it
114    /// first would let anybody extend their own link.
115    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                // Signed by us, yet unparseable: treat it as a forgery, never
139                // as "no expiry".
140                Err(_) => Err(SignatureError::Invalid),
141            },
142        }
143    }
144
145    /// Whether a URL verifies, for callers that only want a yes or no.
146    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
163/// Split a URL into everything before the path, the path, and the raw query.
164///
165/// Both `https://app.test/reset?x=1` and `/reset?x=1` are accepted, and both
166/// sign identically: the host is deliberately outside the signature so a link
167/// mailed with one hostname still verifies when the request arrives behind a
168/// proxy, on a staging domain, or as a bare path in a test.
169fn 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
185/// The exact bytes that get signed.
186///
187/// Parameters are sorted by name, so a client that reorders the query string —
188/// or a mail client that rewrites the link — does not break the signature,
189/// while duplicate names keep their relative order and stay distinguishable.
190fn 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
204/// Render a path and its parameters back into a URL, in the given order.
205fn 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        // Not `Expired` — the signature no longer matches, which is the point.
305        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        // A zero ttl expires at the current second, which still counts as live.
314        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        // Sign a URL that legitimately carries a non-numeric `expires`, which
368        // can only happen if the signature covers it.
369        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}