use std::fmt;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use secrecy::{ExposeSecret, SecretSlice};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use super::base64url;
use super::key::{AppKey, URL_SIGNER_LABEL};
use crate::config::AppConfig;
const SIGNATURE_PARAM: &str = "signature";
const EXPIRES_PARAM: &str = "expires";
const SIGNATURE_VERSION: &str = "v1";
const MAC_DOMAIN: &[u8] = b"arcature/signed-url/v1";
const MAC_BYTES: usize = 32;
const UNRESERVED: &AsciiSet = &NON_ALPHANUMERIC
.remove(b'-')
.remove(b'_')
.remove(b'.')
.remove(b'~');
pub trait Clock: Send + Sync + 'static {
fn now_unix(&self) -> u64;
}
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct SystemClock;
impl SystemClock {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Clock for SystemClock {
fn now_unix(&self) -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |since| since.as_secs())
}
}
#[non_exhaustive]
pub struct UrlSigner {
key: SecretSlice<u8>,
base: String,
clock: Arc<dyn Clock>,
}
impl UrlSigner {
#[must_use]
pub fn new(key: &AppKey, config: &AppConfig) -> Self {
Self {
key: key.subkey(URL_SIGNER_LABEL),
base: config.base_url().to_string(),
clock: Arc::new(SystemClock),
}
}
#[must_use]
pub fn with_clock(mut self, clock: Arc<dyn Clock>) -> Self {
self.clock = clock;
self
}
pub fn sign(&self, path: &str, params: &[(&str, &str)]) -> Result<String, SignedUrlError> {
self.build(path, params, None)
}
pub fn sign_temporary(
&self,
path: &str,
params: &[(&str, &str)],
valid_for: Duration,
) -> Result<String, SignedUrlError> {
let expires_at = self.clock.now_unix().saturating_add(valid_for.as_secs());
self.build(path, params, Some(expires_at))
}
pub fn verify(&self, url: &str) -> Result<(), SignedUrlError> {
let Presented {
path,
params,
signature,
} = self.parse(url)?;
let expected = self.signature_of(&path, ¶ms);
if !bool::from(signature.as_slice().ct_eq(&expected)) {
return Err(SignedUrlError::Mismatch);
}
if let Some((_, value)) = params.iter().find(|(name, _)| name == EXPIRES_PARAM) {
let expires_at: u64 = value.parse().map_err(|_| SignedUrlError::Malformed)?;
if self.clock.now_unix() > expires_at {
return Err(SignedUrlError::Expired);
}
}
Ok(())
}
fn build(
&self,
path: &str,
params: &[(&str, &str)],
expires_at: Option<u64>,
) -> Result<String, SignedUrlError> {
if path.contains('?') || path.contains('#') {
return Err(SignedUrlError::QueryInPath);
}
let path = canonical_path(path);
let mut all = Vec::with_capacity(params.len() + 1);
for (name, value) in params {
if *name == SIGNATURE_PARAM || *name == EXPIRES_PARAM {
return Err(SignedUrlError::ReservedParameter);
}
all.push(((*name).to_string(), (*value).to_string()));
}
if let Some(expires_at) = expires_at {
all.push((EXPIRES_PARAM.to_string(), expires_at.to_string()));
}
let signature = self.signature_of(&path, &all);
sort_canonically(&mut all);
let mut url = format!("{}{path}?", self.base);
for (name, value) in &all {
url.push_str(&utf8_percent_encode(name, UNRESERVED).to_string());
url.push('=');
url.push_str(&utf8_percent_encode(value, UNRESERVED).to_string());
url.push('&');
}
url.push_str(SIGNATURE_PARAM);
url.push('=');
url.push_str(SIGNATURE_VERSION);
url.push('.');
url.push_str(&base64url::encode(&signature));
Ok(url)
}
fn parse(&self, url: &str) -> Result<Presented, SignedUrlError> {
let rest = match url.strip_prefix(self.base.as_str()) {
Some(rest) if rest.is_empty() || rest.starts_with('/') || rest.starts_with('?') => rest,
Some(_) => return Err(SignedUrlError::ForeignOrigin),
None if url.starts_with('/') => url,
None => return Err(SignedUrlError::ForeignOrigin),
};
let rest = rest.split('#').next().unwrap_or("");
let (raw_path, raw_query) = rest.split_once('?').unwrap_or((rest, ""));
let path = canonical_path(raw_path);
let mut params = Vec::new();
let mut presented = None;
for pair in raw_query.split('&').filter(|pair| !pair.is_empty()) {
let (raw_name, raw_value) = pair.split_once('=').unwrap_or((pair, ""));
let name = decode_component(raw_name)?;
let value = decode_component(raw_value)?;
if name == SIGNATURE_PARAM {
if presented.is_some() {
return Err(SignedUrlError::Malformed);
}
presented = Some(value);
continue;
}
params.push((name, value));
}
let presented = presented.ok_or(SignedUrlError::MissingSignature)?;
let body = presented
.strip_prefix(SIGNATURE_VERSION)
.and_then(|rest| rest.strip_prefix('.'))
.ok_or(SignedUrlError::UnknownSignatureVersion)?;
let signature = base64url::decode(body).ok_or(SignedUrlError::Malformed)?;
Ok(Presented {
path,
params,
signature,
})
}
fn signature_of(&self, path: &str, params: &[(String, String)]) -> [u8; MAC_BYTES] {
let mut ordered: Vec<&(String, String)> = params.iter().collect();
ordered.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(self.key.expose_secret())
.expect("HMAC-SHA256 accepts a key of any length");
mac.update(MAC_DOMAIN);
feed(&mut mac, path.as_bytes());
mac.update(&(ordered.len() as u64).to_be_bytes());
for (name, value) in ordered {
feed(&mut mac, name.as_bytes());
feed(&mut mac, value.as_bytes());
}
let digest = mac.finalize().into_bytes();
let mut signature = [0u8; MAC_BYTES];
signature.copy_from_slice(&digest);
signature
}
}
impl fmt::Debug for UrlSigner {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("UrlSigner")
.field("base", &self.base)
.field("key", &"<redacted>")
.finish_non_exhaustive()
}
}
struct Presented {
path: String,
params: Vec<(String, String)>,
signature: Vec<u8>,
}
fn feed(mac: &mut Hmac<Sha256>, bytes: &[u8]) {
mac.update(&(bytes.len() as u64).to_be_bytes());
mac.update(bytes);
}
fn sort_canonically(params: &mut [(String, String)]) {
params.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1)));
}
fn canonical_path(path: &str) -> String {
format!("/{}", path.trim_start_matches('/'))
}
fn decode_component(raw: &str) -> Result<String, SignedUrlError> {
percent_decode_str(raw)
.decode_utf8()
.map(std::borrow::Cow::into_owned)
.map_err(|_| SignedUrlError::Malformed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SignedUrlError {
ReservedParameter,
QueryInPath,
ForeignOrigin,
MissingSignature,
UnknownSignatureVersion,
Malformed,
Mismatch,
Expired,
}
impl fmt::Display for SignedUrlError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::ReservedParameter => {
"`signature` and `expires` are reserved for the signer and cannot be set by a \
caller"
}
Self::QueryInPath => {
"the path carries a query string or a fragment; pass query parameters separately \
so they can be signed"
}
Self::ForeignOrigin => "the URL is not under the application's configured APP_URL",
Self::MissingSignature => "the URL carries no signature",
Self::UnknownSignatureVersion => "the signature does not carry a known version tag",
Self::Malformed => "the URL is not well-formed",
Self::Mismatch => {
"the signature does not match the URL; it was altered or was signed under a \
different key"
}
Self::Expired => "the signature is valid but the URL has expired",
})
}
}
impl std::error::Error for SignedUrlError {}
#[cfg(test)]
mod tests {
use super::{Clock, SignedUrlError, UrlSigner};
use crate::config::AppConfig;
use crate::crypt::AppKey;
use std::sync::Arc;
use std::time::Duration;
struct Frozen(u64);
impl Clock for Frozen {
fn now_unix(&self) -> u64 {
self.0
}
}
fn config() -> AppConfig {
AppConfig::new().url("https://example.com")
}
fn signer_at(second: u64) -> UrlSigner {
let key = AppKey::from_bytes(&[0x4a; 64]).expect("64 bytes");
UrlSigner::new(&key, &config()).with_clock(Arc::new(Frozen(second)))
}
#[test]
fn a_signed_url_verifies() {
let signer = signer_at(1_000);
let url = signer
.sign("/reports/7", &[("format", "csv")])
.expect("sign");
assert_eq!(signer.verify(&url), Ok(()));
}
#[test]
fn a_signed_url_is_rooted_at_the_configured_base() {
let signer = signer_at(1_000);
let url = signer.sign("/reports/7", &[]).expect("sign");
assert!(url.starts_with("https://example.com/reports/7?"), "{url}");
}
#[test]
fn a_path_with_or_without_a_leading_slash_signs_the_same_thing() {
let signer = signer_at(1_000);
assert_eq!(
signer.sign("reports/7", &[]).expect("sign"),
signer.sign("/reports/7", &[]).expect("sign")
);
}
#[test]
fn the_relative_form_verifies_too() {
let signer = signer_at(1_000);
let url = signer.sign("/reports/7", &[("a", "1")]).expect("sign");
let relative = url
.strip_prefix("https://example.com")
.expect("absolute form");
assert_eq!(signer.verify(relative), Ok(()));
}
#[test]
fn a_fragment_is_ignored() {
let signer = signer_at(1_000);
let url = signer.sign("/reports/7", &[]).expect("sign");
assert_eq!(signer.verify(&format!("{url}#page-2")), Ok(()));
}
#[test]
fn a_value_that_needs_escaping_round_trips() {
let signer = signer_at(1_000);
let url = signer
.sign("/search", &[("q", "a&b=c d%e+f")])
.expect("sign");
assert!(!url.contains("a&b=c"), "the value must not split the query");
assert_eq!(signer.verify(&url), Ok(()));
}
#[test]
fn a_temporary_url_carries_its_deadline() {
let url = signer_at(1_000)
.sign_temporary("/x", &[], Duration::from_secs(60))
.expect("sign");
assert!(url.contains("expires=1060"), "{url}");
}
#[test]
fn a_temporary_url_is_valid_up_to_and_including_its_deadline() {
let url = signer_at(1_000)
.sign_temporary("/x", &[], Duration::from_secs(60))
.expect("sign");
assert_eq!(signer_at(1_059).verify(&url), Ok(()));
assert_eq!(signer_at(1_060).verify(&url), Ok(()));
assert_eq!(signer_at(1_061).verify(&url), Err(SignedUrlError::Expired));
}
#[test]
fn a_url_with_no_expiry_never_expires() {
let url = signer_at(1_000).sign("/x", &[]).expect("sign");
assert_eq!(signer_at(u64::MAX).verify(&url), Ok(()));
}
#[test]
fn the_reserved_parameters_cannot_be_set_by_a_caller() {
let signer = signer_at(1_000);
assert_eq!(
signer.sign("/x", &[("signature", "forged")]),
Err(SignedUrlError::ReservedParameter)
);
assert_eq!(
signer.sign("/x", &[("expires", "99999999999")]),
Err(SignedUrlError::ReservedParameter)
);
}
#[test]
fn a_query_in_the_path_is_refused_rather_than_left_unsigned() {
let signer = signer_at(1_000);
assert_eq!(signer.sign("/x?a=1", &[]), Err(SignedUrlError::QueryInPath));
assert_eq!(
signer.sign("/x#frag", &[]),
Err(SignedUrlError::QueryInPath)
);
}
#[test]
fn an_unsigned_url_is_refused() {
assert_eq!(
signer_at(1_000).verify("/x?a=1"),
Err(SignedUrlError::MissingSignature)
);
}
#[test]
fn debug_never_shows_the_key() {
let rendered = format!("{:?}", signer_at(1_000));
assert!(rendered.contains("https://example.com"), "{rendered}");
assert!(rendered.contains("<redacted>"), "{rendered}");
}
}