Skip to main content

renox_core/
signed.rs

1//! Signed URLs: links that can't be altered and may expire, e.g. for email
2//! verification. `state.signed_url(...)` builds one; the `ValidSignature`
3//! extractor rejects tampered or expired ones with 403, `Option<ValidSignature>`
4//! lets a handler decide, and [`verify`] checks any URL.
5
6use std::fmt::Display;
7use std::time::Duration;
8
9use axum::extract::FromRequestParts;
10use axum::http::request::Parts;
11use hmac::{Hmac, KeyInit, Mac};
12use sha2::Sha256;
13
14use crate::crypto::constant_time_eq;
15use crate::{AppState, Error, Result};
16
17fn now() -> u64 {
18    crate::clock::unix_secs().max(0) as u64
19}
20
21pub(crate) fn signature(state: &AppState, payload: &str) -> String {
22    hmac_hex(state.key.signing(), payload)
23}
24
25/// HMAC-SHA256 of `payload` under `key`, as lowercase hex.
26pub(crate) fn hmac_hex(key: &[u8], payload: &str) -> String {
27    let mut mac =
28        <Hmac<Sha256> as KeyInit>::new_from_slice(key).expect("HMAC accepts keys of any length");
29    mac.update(payload.as_bytes());
30    mac.finalize()
31        .into_bytes()
32        .iter()
33        .map(|b| format!("{b:02x}"))
34        .collect()
35}
36
37impl AppState {
38    /// An absolute URL to a named route, valid for `ttl` and signed with `APP_KEY`.
39    pub fn signed_url(&self, name: &str, params: &[&dyn Display], ttl: Duration) -> Result<String> {
40        let path = self.url(name, params)?;
41        self.sign_path(&path, ttl)
42    }
43
44    /// Like `signed_url`, for an already-encoded path such as `/_renox/files/a.pdf`.
45    pub fn sign_path(&self, path: &str, ttl: Duration) -> Result<String> {
46        let unsigned = format!("{path}?expires={}", now().saturating_add(ttl.as_secs()));
47        let signature = signature(self, &unsigned);
48        Ok(format!(
49            "{}{unsigned}&signature={signature}",
50            self.config.url.trim_end_matches('/')
51        ))
52    }
53
54    /// An absolute URL to a named route.
55    pub fn absolute_url(&self, name: &str, params: &[&dyn Display]) -> Result<String> {
56        Ok(format!(
57            "{}{}",
58            self.config.url.trim_end_matches('/'),
59            self.url(name, params)?
60        ))
61    }
62}
63
64/// Proof that the request's URL was signed by `signed_url` and hasn't expired.
65/// A route that takes it answers 403 to any other URL.
66///
67/// As `Option<ValidSignature>` it never refuses, so one route can accept a
68/// signed link (a guest coming back from a mail or a payment page) or a
69/// logged-in user:
70///
71/// ```
72/// # use renox::prelude::*;
73/// use renox::signed::ValidSignature;
74///
75/// async fn receipt(
76///     signature: Option<ValidSignature>,
77///     user: Option<AuthUser>,
78///     Path(order): Path<i64>,
79/// ) -> Result<String> {
80///     if signature.is_none() && user.is_none() {
81///         return Err(Error::Forbidden);
82///     }
83///     Ok(format!("Order {order}"))
84/// }
85/// # let _ = Routes::new().get("/orders/{order}/receipt", receipt);
86/// ```
87///
88/// (A logged-in user still needs a check that the order is theirs.)
89pub struct ValidSignature;
90
91impl<S: Send + Sync> FromRequestParts<S> for ValidSignature {
92    type Rejection = Error;
93
94    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self> {
95        if signed_request(parts)? {
96            Ok(Self)
97        } else {
98            Err(Error::Forbidden)
99        }
100    }
101}
102
103impl<S: Send + Sync> axum::extract::OptionalFromRequestParts<S> for ValidSignature {
104    type Rejection = Error;
105
106    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Option<Self>> {
107        Ok(signed_request(parts)?.then_some(Self))
108    }
109}
110
111/// Whether the request's whole URL is signed. Inside a `Routes::group`, axum
112/// strips the group's prefix from `parts.uri`, so the original URI is used.
113fn signed_request(parts: &Parts) -> Result<bool> {
114    let state = parts
115        .extensions
116        .get::<AppState>()
117        .ok_or_else(|| anyhow::anyhow!("the auth middleware is not installed"))?;
118    let uri = parts
119        .extensions
120        .get::<axum::extract::OriginalUri>()
121        .map_or(&parts.uri, |original| &original.0);
122    Ok(verify(state, uri))
123}
124
125/// Whether `uri` (a path with its query, as the request has it) carries a
126/// valid, unexpired signature from [`AppState::signed_url`] or
127/// [`AppState::sign_path`], for a check inside a handler or middleware. The
128/// [`ValidSignature`] extractor does the same for the request's own URL.
129///
130/// ```
131/// # use renox::prelude::*;
132/// # use std::time::Duration;
133/// # fn demo(state: &AppState) -> Result {
134/// let url = state.sign_path("/invoices/7", Duration::from_secs(600))?;
135/// let path = url.trim_start_matches(&state.config.url);
136/// assert!(renox::signed::verify(state, &path.parse()?));
137/// assert!(!renox::signed::verify(state, &"/invoices/8?expires=1&signature=00".parse()?));
138/// # Ok(()) }
139/// ```
140pub fn verify(state: &AppState, uri: &axum::http::Uri) -> bool {
141    let query = uri.query().unwrap_or_default();
142    let Some((unsigned_query, given)) = query.rsplit_once("&signature=") else {
143        return false;
144    };
145    let Some(expires) = unsigned_query
146        .strip_prefix("expires=")
147        .and_then(|v| v.parse::<u64>().ok())
148    else {
149        return false;
150    };
151    let unsigned = format!("{}?{unsigned_query}", uri.path());
152    expires >= now() && constant_time_eq(&signature(state, &unsigned), given)
153}