use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SignedUrlError {
#[error("missing `signature` query parameter")]
MissingSignature,
#[error("signature is malformed")]
MalformedSignature,
#[error("signature does not match")]
InvalidSignature,
#[error("URL has expired")]
Expired,
}
const SIGNATURE_PARAM: &str = "signature";
const EXPIRES_PARAM: &str = "expires";
#[must_use]
pub fn sign(url: &str, secret: &[u8], ttl: Option<Duration>) -> String {
let expires = ttl.map(|d| {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |t| t.as_secs() + d.as_secs())
});
sign_at(url, secret, expires)
}
#[must_use]
pub fn sign_at(url: &str, secret: &[u8], expires_at: Option<u64>) -> String {
let (base, mut params) = parse_url(url);
params.retain(|(k, _)| k != SIGNATURE_PARAM && k != EXPIRES_PARAM);
if let Some(exp) = expires_at {
params.push((EXPIRES_PARAM.to_owned(), exp.to_string()));
}
let canonical = canonicalize(&base, ¶ms);
let signature = compute_signature(&canonical, secret);
params.push((SIGNATURE_PARAM.to_owned(), signature));
rebuild_url(&base, ¶ms)
}
pub fn verify(url: &str, secret: &[u8]) -> Result<(), SignedUrlError> {
verify_at(url, secret, current_unix_secs())
}
pub fn verify_at(url: &str, secret: &[u8], now_secs: u64) -> Result<(), SignedUrlError> {
let (base, mut params) = parse_url(url);
let sig_idx = params
.iter()
.position(|(k, _)| k == SIGNATURE_PARAM)
.ok_or(SignedUrlError::MissingSignature)?;
let (_, provided_sig) = params.remove(sig_idx);
if let Some((_, exp_str)) = params.iter().find(|(k, _)| k == EXPIRES_PARAM) {
let exp: u64 = exp_str
.parse()
.map_err(|_| SignedUrlError::MalformedSignature)?;
if now_secs > exp {
return Err(SignedUrlError::Expired);
}
}
let canonical = canonicalize(&base, ¶ms);
let expected = compute_signature(&canonical, secret);
if expected.as_bytes().ct_eq(provided_sig.as_bytes()).unwrap_u8() != 1 {
return Err(SignedUrlError::InvalidSignature);
}
Ok(())
}
fn current_unix_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |t| t.as_secs())
}
fn parse_url(url: &str) -> (String, Vec<(String, String)>) {
if let Some((base, query)) = url.split_once('?') {
let params: Vec<(String, String)> = query
.split('&')
.filter(|s| !s.is_empty())
.map(|pair| match pair.split_once('=') {
Some((k, v)) => (decode_component(k), decode_component(v)),
None => (decode_component(pair), String::new()),
})
.collect();
(base.to_owned(), params)
} else {
(url.to_owned(), Vec::new())
}
}
fn canonicalize(base: &str, params: &[(String, String)]) -> String {
let mut sorted: Vec<&(String, String)> = params.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
let qs: Vec<String> = sorted
.iter()
.map(|(k, v)| format!("{}={}", encode_component(k), encode_component(v)))
.collect();
if qs.is_empty() {
base.to_owned()
} else {
format!("{base}?{}", qs.join("&"))
}
}
fn rebuild_url(base: &str, params: &[(String, String)]) -> String {
if params.is_empty() {
return base.to_owned();
}
let qs: Vec<String> = params
.iter()
.map(|(k, v)| format!("{}={}", encode_component(k), encode_component(v)))
.collect();
format!("{base}?{}", qs.join("&"))
}
fn compute_signature(canonical: &str, secret: &[u8]) -> String {
use base64::Engine;
let mut mac = <Hmac<Sha256>>::new_from_slice(secret).expect("HMAC accepts any key");
mac.update(canonical.as_bytes());
let bytes = mac.finalize().into_bytes();
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn encode_component(s: &str) -> String {
s.bytes()
.map(|b| {
if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
(b as char).to_string()
} else {
format!("%{b:02X}")
}
})
.collect()
}
fn decode_component(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("00");
if let Ok(b) = u8::from_str_radix(hex, 16) {
out.push(b);
i += 3;
continue;
}
}
out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
i += 1;
}
String::from_utf8(out).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &[u8] = b"my-test-secret";
#[test]
fn sign_and_verify_no_expiry() {
let signed = sign("https://example.com/path", SECRET, None);
assert!(signed.contains("signature="));
assert!(verify(&signed, SECRET).is_ok());
}
#[test]
fn sign_preserves_existing_query_params() {
let signed = sign("https://example.com/path?a=1&b=2", SECRET, None);
assert!(signed.contains("a=1"));
assert!(signed.contains("b=2"));
assert!(verify(&signed, SECRET).is_ok());
}
#[test]
fn verify_fails_on_wrong_secret() {
let signed = sign("https://example.com/path", SECRET, None);
let r = verify(&signed, b"different-secret");
assert_eq!(r, Err(SignedUrlError::InvalidSignature));
}
#[test]
fn verify_fails_on_tampered_path() {
let signed = sign("https://example.com/admin", SECRET, None);
let tampered = signed.replace("/admin", "/superadmin");
let r = verify(&tampered, SECRET);
assert_eq!(r, Err(SignedUrlError::InvalidSignature));
}
#[test]
fn verify_fails_on_tampered_query_param() {
let signed = sign("https://example.com/?user_id=42", SECRET, None);
let tampered = signed.replace("user_id=42", "user_id=99");
let r = verify(&tampered, SECRET);
assert_eq!(r, Err(SignedUrlError::InvalidSignature));
}
#[test]
fn verify_fails_when_no_signature() {
let r = verify("https://example.com/path", SECRET);
assert_eq!(r, Err(SignedUrlError::MissingSignature));
}
#[test]
fn expired_url_rejected() {
let past = 100;
let signed = sign_at("https://example.com/path", SECRET, Some(past));
let r = verify_at(&signed, SECRET, 1000);
assert_eq!(r, Err(SignedUrlError::Expired));
}
#[test]
fn unexpired_url_accepted() {
let future = 10_000;
let signed = sign_at("https://example.com/path", SECRET, Some(future));
let r = verify_at(&signed, SECRET, 1000);
assert!(r.is_ok());
}
#[test]
fn query_param_order_doesnt_change_signature() {
let url_a = "https://example.com/path?a=1&b=2";
let url_b = "https://example.com/path?b=2&a=1";
let sig_a = sign(url_a, SECRET, None);
let provided_a = sig_a.split("signature=").nth(1).unwrap();
let sig_b = sign(url_b, SECRET, None);
let provided_b = sig_b.split("signature=").nth(1).unwrap();
assert_eq!(provided_a, provided_b, "sorted canonical form must match");
}
#[test]
fn re_signing_overrides_existing_signature() {
let signed = sign("https://example.com/path", SECRET, None);
let re_signed = sign(&signed, SECRET, None);
let r = verify(&re_signed, SECRET);
assert!(r.is_ok());
assert_eq!(re_signed.matches("signature=").count(), 1);
}
#[test]
fn url_encoded_params_round_trip() {
let original = "https://example.com/?email=alice%40example.com&q=hello%20world";
let signed = sign(original, SECRET, None);
assert!(verify(&signed, SECRET).is_ok());
}
#[test]
fn malformed_expires_is_rejected() {
let url = "https://example.com/?expires=not-a-number&signature=anything";
let r = verify(url, SECRET);
assert_eq!(r, Err(SignedUrlError::MalformedSignature));
}
}