use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use rainier_support::{Error, Result};
use crate::key::KeyRing;
use crate::signer::HmacSigner;
const SIGNATURE: &str = "signature";
const EXPIRES: &str = "expires";
pub struct UrlSigner {
signer: HmacSigner,
}
impl UrlSigner {
pub fn new(keys: KeyRing) -> Self {
Self { signer: HmacSigner::new(keys) }
}
pub fn sign(&self, url: &str) -> Result<String> {
self.attach(url, None)
}
pub fn sign_until(&self, url: &str, expires_at: i64) -> Result<String> {
self.attach(url, Some(expires_at))
}
pub fn verify(&self, url: &str) -> Result<()> {
let (path, query) = split(url);
let Some(provided) = value_of(query, SIGNATURE) else {
return Err(Error::unauthorized("This link is not signed."));
};
if !self.verify_tag(&canonical(path, query), &provided) {
return Err(Error::unauthorized("This link's signature is not valid."));
}
if let Some(expires_at) = value_of(query, EXPIRES) {
let expires_at: i64 = expires_at
.parse()
.map_err(|_| Error::unauthorized("This link's expiry is not valid."))?;
if now() > expires_at {
return Err(Error::unauthorized("This link has expired."));
}
}
Ok(())
}
pub fn is_valid(&self, url: &str) -> bool {
self.verify(url).is_ok()
}
pub fn signature_for(&self, url: &str) -> Result<String> {
let (path, query) = split(url);
self.tag(&canonical(path, query))
}
fn attach(&self, url: &str, expires_at: Option<i64>) -> Result<String> {
let (path, query) = split(url);
let mut pairs: Vec<(String, String)> = parse(query)
.into_iter()
.filter(|(name, _)| name != SIGNATURE && name != EXPIRES)
.collect();
if let Some(expires_at) = expires_at {
pairs.push((EXPIRES.to_string(), expires_at.to_string()));
}
let query = encode(&pairs);
let signature = self.tag(&canonical(path, &query))?;
let separator = if query.is_empty() { "" } else { "&" };
Ok(format!("{path}?{query}{separator}{SIGNATURE}={signature}"))
}
fn tag(&self, canonical: &str) -> Result<String> {
Ok(URL_SAFE_NO_PAD.encode(self.signer.detached_tag(canonical)?.as_bytes()))
}
fn verify_tag(&self, canonical: &str, presented: &str) -> bool {
let Ok(decoded) = URL_SAFE_NO_PAD.decode(presented) else {
return false;
};
let Ok(decoded) = String::from_utf8(decoded) else {
return false;
};
self.signer.verify_detached(canonical, &decoded)
}
}
impl std::fmt::Debug for UrlSigner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("UrlSigner")
}
}
fn split(url: &str) -> (&str, &str) {
match url.split_once('?') {
Some((path, query)) => (path, query),
None => (url, ""),
}
}
fn path_of(url: &str) -> &str {
let Some(after_scheme) = url.split_once("://").map(|(_, rest)| rest) else {
return url;
};
match after_scheme.find('/') {
Some(start) => &after_scheme[start..],
None => "/",
}
}
fn canonical(path: &str, query: &str) -> String {
let path = path_of(path);
let mut pairs: Vec<(String, String)> =
parse(query).into_iter().filter(|(name, _)| name != SIGNATURE).collect();
pairs.sort();
format!("{path}?{}", encode(&pairs))
}
fn parse(query: &str) -> Vec<(String, String)> {
query
.split('&')
.filter(|pair| !pair.is_empty())
.map(|pair| match pair.split_once('=') {
Some((name, value)) => (decode(name), decode(value)),
None => (decode(pair), String::new()),
})
.collect()
}
fn encode(pairs: &[(String, String)]) -> String {
pairs
.iter()
.map(|(name, value)| format!("{}={}", percent_encode(name), percent_encode(value)))
.collect::<Vec<_>>()
.join("&")
}
fn percent_encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(byte as char);
}
_ => out.push_str(&format!("%{byte:02X}")),
}
}
out
}
fn decode(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
match u8::from_str_radix(&value[i + 1..i + 3], 16) {
Ok(byte) => {
out.push(byte);
i += 3;
}
Err(_) => {
out.push(b'%');
i += 1;
}
}
}
byte => {
out.push(byte);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn value_of(query: &str, name: &str) -> Option<String> {
parse(query).into_iter().find(|(found, _)| found == name).map(|(_, value)| value)
}
fn now() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|since| since.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::key::Key;
fn signer() -> UrlSigner {
UrlSigner::new(KeyRing::new(Key::generate()))
}
fn in_an_hour() -> i64 {
now() + 3600
}
#[test]
fn a_signed_url_verifies() {
let signer = signer();
let signed = signer.sign("/unsubscribe?user=42").unwrap();
assert!(signed.contains("signature="));
assert!(signer.is_valid(&signed));
}
#[test]
fn changing_anything_at_all_invalidates_it() {
let signer = signer();
let signed = signer.sign("/unsubscribe?user=42").unwrap();
assert!(!signer.is_valid(&signed.replace("user=42", "user=43")));
assert!(!signer.is_valid(&signed.replace("/unsubscribe", "/delete-account")));
assert!(!signer.is_valid(&format!("{signed}&admin=1")));
}
#[test]
fn a_link_from_another_key_does_not_verify() {
let signed = signer().sign("/unsubscribe?user=42").unwrap();
assert!(!signer().is_valid(&signed), "a different application signed this");
}
#[test]
fn an_unsigned_url_is_refused_rather_than_accepted() {
let error = signer().verify("/unsubscribe?user=42").unwrap_err();
assert!(error.message().contains("not signed"), "{}", error.message());
}
#[test]
fn the_order_of_the_query_does_not_matter() {
let signer = signer();
let signed = signer.sign("/report?to=ada&from=grace").unwrap();
let signature = value_of(split(&signed).1, SIGNATURE).unwrap();
let reordered = format!("/report?from=grace&to=ada&signature={signature}");
assert!(signer.is_valid(&reordered));
}
#[test]
fn a_url_with_no_query_signs_and_verifies() {
let signer = signer();
let signed = signer.sign("/newsletter/unsubscribe").unwrap();
assert!(signer.is_valid(&signed));
}
#[test]
fn signing_twice_replaces_the_signature_rather_than_adding_one() {
let signer = signer();
let once = signer.sign("/unsubscribe?user=42").unwrap();
let twice = signer.sign(&once).unwrap();
assert_eq!(once, twice);
assert_eq!(twice.matches("signature=").count(), 1);
assert!(signer.is_valid(&twice));
}
#[test]
fn an_expiring_link_verifies_until_it_does_not() {
let signer = signer();
let live = signer.sign_until("/verify?user=42", in_an_hour()).unwrap();
assert!(signer.is_valid(&live));
let dead = signer.sign_until("/verify?user=42", now() - 1).unwrap();
let error = signer.verify(&dead).unwrap_err();
assert!(error.message().contains("expired"), "{}", error.message());
}
#[test]
fn the_expiry_cannot_be_moved() {
let signer = signer();
let signed = signer.sign_until("/verify?user=42", now() - 1).unwrap();
let extended = signed.replace(&format!("expires={}", now() - 1), "expires=99999999999");
let error = signer.verify(&extended).unwrap_err();
assert!(error.message().contains("not valid"), "{}", error.message());
}
#[test]
fn a_forged_link_with_a_past_expiry_reads_as_forged_rather_than_expired() {
let signer = signer();
let forged = format!("/verify?user=42&expires={}&signature=deadbeef", now() - 1);
let error = signer.verify(&forged).unwrap_err();
assert!(error.message().contains("not valid"), "{}", error.message());
}
#[test]
fn a_signature_of_the_wrong_length_is_refused_without_panicking() {
let signer = signer();
for forged in ["", "x", "not-base64-at-all-!!!", &"a".repeat(500)] {
assert!(!signer.is_valid(&format!("/verify?user=42&signature={forged}")));
}
}
#[test]
fn a_value_with_delimiters_in_it_survives_the_round_trip() {
let signer = signer();
let signed = signer.sign("/verify?email=ada%2Blovelace%40example.com¬e=a%26b").unwrap();
assert!(signer.is_valid(&signed));
assert!(!signer.is_valid(&signed.replace("ada%2B", "eve%2B")));
}
#[test]
fn the_canonical_form_drops_the_signature_and_sorts() {
assert_eq!(canonical("/x", "b=2&a=1&signature=zzz"), "/x?a=1&b=2");
assert_eq!(canonical("/x", ""), "/x?");
}
#[test]
fn percent_coding_round_trips() {
for value in ["ada@example.com", "a b", "a&b=c", "100%", "é", ""] {
assert_eq!(decode(&percent_encode(value)), value, "{value:?}");
}
}
#[test]
fn a_link_signed_before_a_rotation_still_verifies() {
let old = Key::generate();
let signed = UrlSigner::new(KeyRing::new(old.clone())).sign("/verify?user=42").unwrap();
let rotated = UrlSigner::new(KeyRing::new(Key::generate()).with_previous(old));
assert!(rotated.is_valid(&signed));
let without = UrlSigner::new(KeyRing::new(Key::generate()));
assert!(!without.is_valid(&signed));
}
#[test]
fn an_absolute_link_and_its_path_share_a_signature() {
let signer = signer();
let absolute = signer.sign("https://app.example.com/verify?user=42").unwrap();
let path = absolute.trim_start_matches("https://app.example.com");
assert!(signer.is_valid(path));
assert!(signer.is_valid(&absolute));
}
#[test]
fn the_host_is_deliberately_not_covered() {
let signer = signer();
let signed = signer.sign("https://app.example.com/verify?user=42").unwrap();
assert!(signer.is_valid(&signed.replace("app.example.com", "staging.example.com")));
}
#[test]
fn a_url_with_a_host_and_no_path_still_signs() {
let signer = signer();
let signed = signer.sign("https://app.example.com").unwrap();
assert!(signer.is_valid(&signed));
}
#[test]
fn the_authority_is_stripped_from_the_canonical_form() {
assert_eq!(path_of("https://app.example.com/verify"), "/verify");
assert_eq!(path_of("http://localhost:8000/a/b"), "/a/b");
assert_eq!(path_of("https://app.example.com"), "/");
assert_eq!(path_of("/verify"), "/verify");
assert_eq!(path_of(""), "");
}
}