Skip to main content

rainier_crypt/
url.rs

1//! Signed URLs — [`UrlSigner`].
2//!
3//! ```ignore
4//! let link = signer.sign("/unsubscribe?user=42")?;
5//! // /unsubscribe?user=42&signature=9f3c…
6//!
7//! let link = signer.sign_until("/verify-email?user=42", expires_at)?;
8//! // /verify-email?user=42&expires=1790000000&signature=9f3c…
9//! ```
10//!
11//! A link that proves the application produced it, so following it needs no
12//! session and no database row. That is the whole trick: an unsubscribe link
13//! in an email, an email-verification link, a one-time download — none of them
14//! need a token table, a lookup, or a sweep job, because the URL *is* the
15//! token and it verifies itself.
16//!
17//! # What is signed
18//!
19//! The **path and the query**, with parameters sorted and `signature` removed.
20//! Sorting is what makes the signature independent of the order a client
21//! happens to send them in; removing `signature` is obvious in hindsight and
22//! is the first thing everybody gets wrong.
23//!
24//! The **host is not signed**. The key is the boundary here, not the hostname:
25//! two deployments holding the same `APP_KEY` are the same application by
26//! definition, and signing the host would break every link the moment a
27//! request arrives through a proxy that rewrote it. If you need a link to work
28//! on exactly one hostname, put the hostname in the query — then it is signed
29//! like everything else, and your handler can check it.
30//!
31//! # What a signature is not
32//!
33//! **It is not single-use.** Anyone holding the link can follow it as many
34//! times as they like until it expires, and a link in an email lives in that
35//! mailbox forever. For anything that must happen once — accepting an
36//! invitation, changing an address — the signature proves *this application
37//! issued it* and something stateful still has to prove *it has not been used*.
38//!
39//! **It is not secret.** The query is in plain sight, in the address bar, in
40//! the referrer header, and in whatever logs the URL. Sign an id; do not sign
41//! anything you would mind reading over somebody's shoulder.
42
43use base64::engine::general_purpose::URL_SAFE_NO_PAD;
44use base64::Engine as _;
45use rainier_support::{Error, Result};
46
47use crate::key::KeyRing;
48use crate::signer::HmacSigner;
49
50/// The query parameter holding the signature.
51const SIGNATURE: &str = "signature";
52
53/// The query parameter holding the expiry, in seconds since the epoch.
54const EXPIRES: &str = "expires";
55
56/// Signs and verifies URLs.
57pub struct UrlSigner {
58    signer: HmacSigner,
59}
60
61impl UrlSigner {
62    /// A signer over `keys`.
63    ///
64    /// The same ring the rest of the application encrypts with, so rotating a
65    /// key retires the links it signed — which is the correct behaviour and
66    /// worth knowing before rotating one: every outstanding verification email
67    /// stops working.
68    pub fn new(keys: KeyRing) -> Self {
69        Self { signer: HmacSigner::new(keys) }
70    }
71
72    /// Sign `url`, which may be a path or an absolute URL.
73    ///
74    /// Returns it with `signature` appended.
75    pub fn sign(&self, url: &str) -> Result<String> {
76        self.attach(url, None)
77    }
78
79    /// Sign `url` so that it stops verifying after `expires_at`.
80    ///
81    /// `expires_at` is seconds since the epoch. It goes into the query — and
82    /// is therefore signed, so it cannot be moved without invalidating the
83    /// link.
84    pub fn sign_until(&self, url: &str, expires_at: i64) -> Result<String> {
85        self.attach(url, Some(expires_at))
86    }
87
88    /// Whether `url` carries a signature this application produced, and has
89    /// not expired.
90    ///
91    /// # Errors
92    ///
93    /// Distinguishes the two cases a caller wants to tell apart: a link that
94    /// was never valid, and one that was and is not any more. Somebody with an
95    /// expired verification email should be offered a new one; somebody with a
96    /// forged link should not.
97    pub fn verify(&self, url: &str) -> Result<()> {
98        let (path, query) = split(url);
99
100        let Some(provided) = value_of(query, SIGNATURE) else {
101            return Err(Error::unauthorized("This link is not signed."));
102        };
103
104        // Constant time, and against the key the tag itself names — so a link
105        // signed before a rotation still verifies while the old key is on the
106        // ring.
107        if !self.verify_tag(&canonical(path, query), &provided) {
108            return Err(Error::unauthorized("This link's signature is not valid."));
109        }
110
111        // Checked **after** the signature, so a forged link with a past expiry
112        // is reported as forged rather than as expired.
113        if let Some(expires_at) = value_of(query, EXPIRES) {
114            let expires_at: i64 = expires_at
115                .parse()
116                .map_err(|_| Error::unauthorized("This link's expiry is not valid."))?;
117
118            if now() > expires_at {
119                return Err(Error::unauthorized("This link has expired."));
120            }
121        }
122
123        Ok(())
124    }
125
126    /// Whether `url` verifies, as a boolean.
127    pub fn is_valid(&self, url: &str) -> bool {
128        self.verify(url).is_ok()
129    }
130
131    /// The signature for a URL, without attaching it.
132    ///
133    /// For a caller building the link some other way — a redirect, a template
134    /// — that still wants the same tag.
135    pub fn signature_for(&self, url: &str) -> Result<String> {
136        let (path, query) = split(url);
137        self.tag(&canonical(path, query))
138    }
139
140    fn attach(&self, url: &str, expires_at: Option<i64>) -> Result<String> {
141        let (path, query) = split(url);
142
143        // Any signature already on it is replaced rather than added to, so
144        // signing twice is idempotent instead of producing a link with two.
145        let mut pairs: Vec<(String, String)> = parse(query)
146            .into_iter()
147            .filter(|(name, _)| name != SIGNATURE && name != EXPIRES)
148            .collect();
149
150        if let Some(expires_at) = expires_at {
151            pairs.push((EXPIRES.to_string(), expires_at.to_string()));
152        }
153
154        let query = encode(&pairs);
155        let signature = self.tag(&canonical(path, &query))?;
156
157        let separator = if query.is_empty() { "" } else { "&" };
158        Ok(format!("{path}?{query}{separator}{SIGNATURE}={signature}"))
159    }
160
161    /// The tag for an already-canonical string, in a form a query can hold.
162    ///
163    /// Base64url over `<kid>.<tag>`, because the key id may contain characters
164    /// a query would have to escape and a signature that needs escaping is a
165    /// signature somebody will mangle.
166    fn tag(&self, canonical: &str) -> Result<String> {
167        Ok(URL_SAFE_NO_PAD.encode(self.signer.detached_tag(canonical)?.as_bytes()))
168    }
169
170    /// The inverse of [`tag`](Self::tag).
171    fn verify_tag(&self, canonical: &str, presented: &str) -> bool {
172        let Ok(decoded) = URL_SAFE_NO_PAD.decode(presented) else {
173            return false;
174        };
175        let Ok(decoded) = String::from_utf8(decoded) else {
176            return false;
177        };
178
179        self.signer.verify_detached(canonical, &decoded)
180    }
181}
182
183impl std::fmt::Debug for UrlSigner {
184    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
185        f.write_str("UrlSigner")
186    }
187}
188
189/// Split a URL into everything before the query and the query itself.
190fn split(url: &str) -> (&str, &str) {
191    match url.split_once('?') {
192        Some((path, query)) => (path, query),
193        None => (url, ""),
194    }
195}
196
197/// The path of a URL, with any scheme and authority removed.
198///
199/// `https://app.example.com/verify` becomes `/verify`, so signing an absolute
200/// link and verifying the request's path agree about what was signed — which
201/// is what makes an emailed link work when it arrives at a server that only
202/// ever sees the path.
203fn path_of(url: &str) -> &str {
204    let Some(after_scheme) = url.split_once("://").map(|(_, rest)| rest) else {
205        return url;
206    };
207
208    match after_scheme.find('/') {
209        Some(start) => &after_scheme[start..],
210        // `https://example.com` with no path at all.
211        None => "/",
212    }
213}
214
215/// The string that actually gets signed: the path, then the query sorted by
216/// name with `signature` removed.
217fn canonical(path: &str, query: &str) -> String {
218    let path = path_of(path);
219
220    let mut pairs: Vec<(String, String)> =
221        parse(query).into_iter().filter(|(name, _)| name != SIGNATURE).collect();
222
223    // By name, then by value, so repeated parameters have one canonical order
224    // rather than whichever the client sent.
225    pairs.sort();
226
227    format!("{path}?{}", encode(&pairs))
228}
229
230/// `a=1&b=2` into pairs, percent-decoded.
231fn parse(query: &str) -> Vec<(String, String)> {
232    query
233        .split('&')
234        .filter(|pair| !pair.is_empty())
235        .map(|pair| match pair.split_once('=') {
236            Some((name, value)) => (decode(name), decode(value)),
237            None => (decode(pair), String::new()),
238        })
239        .collect()
240}
241
242/// Pairs back into `a=1&b=2`, percent-encoded.
243fn encode(pairs: &[(String, String)]) -> String {
244    pairs
245        .iter()
246        .map(|(name, value)| format!("{}={}", percent_encode(name), percent_encode(value)))
247        .collect::<Vec<_>>()
248        .join("&")
249}
250
251/// Percent-encode everything outside the unreserved set.
252///
253/// Deliberately strict: encoding more than strictly necessary is always safe,
254/// and the alternative is arguing about which delimiters a particular parser
255/// treats as significant.
256fn percent_encode(value: &str) -> String {
257    let mut out = String::with_capacity(value.len());
258
259    for byte in value.bytes() {
260        match byte {
261            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
262                out.push(byte as char);
263            }
264            _ => out.push_str(&format!("%{byte:02X}")),
265        }
266    }
267    out
268}
269
270/// Reverse [`percent_encode`], leaving anything malformed as it was.
271fn decode(value: &str) -> String {
272    let bytes = value.as_bytes();
273    let mut out = Vec::with_capacity(bytes.len());
274    let mut i = 0;
275
276    while i < bytes.len() {
277        match bytes[i] {
278            b'+' => {
279                out.push(b' ');
280                i += 1;
281            }
282            b'%' if i + 2 < bytes.len() => {
283                match u8::from_str_radix(&value[i + 1..i + 3], 16) {
284                    Ok(byte) => {
285                        out.push(byte);
286                        i += 3;
287                    }
288                    // Not a valid escape, so it is a literal `%`.
289                    Err(_) => {
290                        out.push(b'%');
291                        i += 1;
292                    }
293                }
294            }
295            byte => {
296                out.push(byte);
297                i += 1;
298            }
299        }
300    }
301
302    String::from_utf8_lossy(&out).into_owned()
303}
304
305/// The value of `name` in a query, if it is there.
306fn value_of(query: &str, name: &str) -> Option<String> {
307    parse(query).into_iter().find(|(found, _)| found == name).map(|(_, value)| value)
308}
309
310/// Seconds since the epoch.
311fn now() -> i64 {
312    std::time::SystemTime::now()
313        .duration_since(std::time::UNIX_EPOCH)
314        .map(|since| since.as_secs() as i64)
315        .unwrap_or(0)
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::key::Key;
322
323    fn signer() -> UrlSigner {
324        UrlSigner::new(KeyRing::new(Key::generate()))
325    }
326
327    fn in_an_hour() -> i64 {
328        now() + 3600
329    }
330
331    #[test]
332    fn a_signed_url_verifies() {
333        let signer = signer();
334        let signed = signer.sign("/unsubscribe?user=42").unwrap();
335
336        assert!(signed.contains("signature="));
337        assert!(signer.is_valid(&signed));
338    }
339
340    #[test]
341    fn changing_anything_at_all_invalidates_it() {
342        let signer = signer();
343        let signed = signer.sign("/unsubscribe?user=42").unwrap();
344
345        // The one that matters: a different id on somebody else's link.
346        assert!(!signer.is_valid(&signed.replace("user=42", "user=43")));
347        assert!(!signer.is_valid(&signed.replace("/unsubscribe", "/delete-account")));
348        assert!(!signer.is_valid(&format!("{signed}&admin=1")));
349    }
350
351    #[test]
352    fn a_link_from_another_key_does_not_verify() {
353        let signed = signer().sign("/unsubscribe?user=42").unwrap();
354
355        assert!(!signer().is_valid(&signed), "a different application signed this");
356    }
357
358    #[test]
359    fn an_unsigned_url_is_refused_rather_than_accepted() {
360        let error = signer().verify("/unsubscribe?user=42").unwrap_err();
361
362        assert!(error.message().contains("not signed"), "{}", error.message());
363    }
364
365    #[test]
366    fn the_order_of_the_query_does_not_matter() {
367        // A client, a proxy or a mail client may reorder them, and the link
368        // has to keep working.
369        let signer = signer();
370        let signed = signer.sign("/report?to=ada&from=grace").unwrap();
371
372        let signature = value_of(split(&signed).1, SIGNATURE).unwrap();
373        let reordered = format!("/report?from=grace&to=ada&signature={signature}");
374
375        assert!(signer.is_valid(&reordered));
376    }
377
378    #[test]
379    fn a_url_with_no_query_signs_and_verifies() {
380        let signer = signer();
381        let signed = signer.sign("/newsletter/unsubscribe").unwrap();
382
383        assert!(signer.is_valid(&signed));
384    }
385
386    #[test]
387    fn signing_twice_replaces_the_signature_rather_than_adding_one() {
388        let signer = signer();
389        let once = signer.sign("/unsubscribe?user=42").unwrap();
390        let twice = signer.sign(&once).unwrap();
391
392        assert_eq!(once, twice);
393        assert_eq!(twice.matches("signature=").count(), 1);
394        assert!(signer.is_valid(&twice));
395    }
396
397    #[test]
398    fn an_expiring_link_verifies_until_it_does_not() {
399        let signer = signer();
400
401        let live = signer.sign_until("/verify?user=42", in_an_hour()).unwrap();
402        assert!(signer.is_valid(&live));
403
404        let dead = signer.sign_until("/verify?user=42", now() - 1).unwrap();
405        let error = signer.verify(&dead).unwrap_err();
406        assert!(error.message().contains("expired"), "{}", error.message());
407    }
408
409    #[test]
410    fn the_expiry_cannot_be_moved() {
411        // It is in the query, so it is signed. Extending it breaks the tag.
412        let signer = signer();
413        let signed = signer.sign_until("/verify?user=42", now() - 1).unwrap();
414
415        let extended = signed.replace(&format!("expires={}", now() - 1), "expires=99999999999");
416
417        let error = signer.verify(&extended).unwrap_err();
418        assert!(error.message().contains("not valid"), "{}", error.message());
419    }
420
421    #[test]
422    fn a_forged_link_with_a_past_expiry_reads_as_forged_rather_than_expired() {
423        // Reporting "expired" would confirm the rest of the URL was right,
424        // which is a hint worth not giving.
425        let signer = signer();
426        let forged = format!("/verify?user=42&expires={}&signature=deadbeef", now() - 1);
427
428        let error = signer.verify(&forged).unwrap_err();
429        assert!(error.message().contains("not valid"), "{}", error.message());
430    }
431
432    #[test]
433    fn a_signature_of_the_wrong_length_is_refused_without_panicking() {
434        let signer = signer();
435
436        for forged in ["", "x", "not-base64-at-all-!!!", &"a".repeat(500)] {
437            assert!(!signer.is_valid(&format!("/verify?user=42&signature={forged}")));
438        }
439    }
440
441    #[test]
442    fn a_value_with_delimiters_in_it_survives_the_round_trip() {
443        // An email address, which is the value these links carry most often,
444        // and which contains characters a naive splitter mangles.
445        let signer = signer();
446        let signed = signer.sign("/verify?email=ada%2Blovelace%40example.com&note=a%26b").unwrap();
447
448        assert!(signer.is_valid(&signed));
449        assert!(!signer.is_valid(&signed.replace("ada%2B", "eve%2B")));
450    }
451
452    #[test]
453    fn the_canonical_form_drops_the_signature_and_sorts() {
454        assert_eq!(canonical("/x", "b=2&a=1&signature=zzz"), "/x?a=1&b=2");
455        assert_eq!(canonical("/x", ""), "/x?");
456    }
457
458    #[test]
459    fn percent_coding_round_trips() {
460        for value in ["ada@example.com", "a b", "a&b=c", "100%", "é", ""] {
461            assert_eq!(decode(&percent_encode(value)), value, "{value:?}");
462        }
463    }
464
465    #[test]
466    fn a_link_signed_before_a_rotation_still_verifies() {
467        // Retiring a key invalidates its links, which is correct — but the
468        // *previous* key staying on the ring is how an application rotates
469        // without invalidating every verification email in flight.
470        let old = Key::generate();
471        let signed = UrlSigner::new(KeyRing::new(old.clone())).sign("/verify?user=42").unwrap();
472
473        let rotated = UrlSigner::new(KeyRing::new(Key::generate()).with_previous(old));
474        assert!(rotated.is_valid(&signed));
475
476        // And once it is off the ring, it does not.
477        let without = UrlSigner::new(KeyRing::new(Key::generate()));
478        assert!(!without.is_valid(&signed));
479    }
480
481    #[test]
482    fn an_absolute_link_and_its_path_share_a_signature() {
483        // The property that makes an emailed link work: it is signed with the
484        // host on it, and verified by a server that only sees the path.
485        let signer = signer();
486
487        let absolute = signer.sign("https://app.example.com/verify?user=42").unwrap();
488        let path = absolute.trim_start_matches("https://app.example.com");
489
490        assert!(signer.is_valid(path));
491        assert!(signer.is_valid(&absolute));
492    }
493
494    #[test]
495    fn the_host_is_deliberately_not_covered() {
496        // Stated in the docs, so asserted here: the same link verifies on any
497        // hostname the application answers to. The key is the boundary.
498        let signer = signer();
499        let signed = signer.sign("https://app.example.com/verify?user=42").unwrap();
500
501        assert!(signer.is_valid(&signed.replace("app.example.com", "staging.example.com")));
502    }
503
504    #[test]
505    fn a_url_with_a_host_and_no_path_still_signs() {
506        let signer = signer();
507        let signed = signer.sign("https://app.example.com").unwrap();
508
509        assert!(signer.is_valid(&signed));
510    }
511
512    #[test]
513    fn the_authority_is_stripped_from_the_canonical_form() {
514        assert_eq!(path_of("https://app.example.com/verify"), "/verify");
515        assert_eq!(path_of("http://localhost:8000/a/b"), "/a/b");
516        assert_eq!(path_of("https://app.example.com"), "/");
517        assert_eq!(path_of("/verify"), "/verify");
518        assert_eq!(path_of(""), "");
519    }
520}