1use 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
50const SIGNATURE: &str = "signature";
52
53const EXPIRES: &str = "expires";
55
56pub struct UrlSigner {
58 signer: HmacSigner,
59}
60
61impl UrlSigner {
62 pub fn new(keys: KeyRing) -> Self {
69 Self { signer: HmacSigner::new(keys) }
70 }
71
72 pub fn sign(&self, url: &str) -> Result<String> {
76 self.attach(url, None)
77 }
78
79 pub fn sign_until(&self, url: &str, expires_at: i64) -> Result<String> {
85 self.attach(url, Some(expires_at))
86 }
87
88 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 if !self.verify_tag(&canonical(path, query), &provided) {
108 return Err(Error::unauthorized("This link's signature is not valid."));
109 }
110
111 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 pub fn is_valid(&self, url: &str) -> bool {
128 self.verify(url).is_ok()
129 }
130
131 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 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 fn tag(&self, canonical: &str) -> Result<String> {
167 Ok(URL_SAFE_NO_PAD.encode(self.signer.detached_tag(canonical)?.as_bytes()))
168 }
169
170 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
189fn split(url: &str) -> (&str, &str) {
191 match url.split_once('?') {
192 Some((path, query)) => (path, query),
193 None => (url, ""),
194 }
195}
196
197fn 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 None => "/",
212 }
213}
214
215fn 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 pairs.sort();
226
227 format!("{path}?{}", encode(&pairs))
228}
229
230fn 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
242fn 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
251fn 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
270fn 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 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
305fn value_of(query: &str, name: &str) -> Option<String> {
307 parse(query).into_iter().find(|(found, _)| found == name).map(|(_, value)| value)
308}
309
310fn 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 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 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 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 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 let signer = signer();
446 let signed = signer.sign("/verify?email=ada%2Blovelace%40example.com¬e=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 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 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 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 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}