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.
4
5use std::fmt::Display;
6use std::time::Duration;
7
8use axum::extract::FromRequestParts;
9use axum::http::request::Parts;
10use hmac::{Hmac, KeyInit, Mac};
11use sha2::Sha256;
12
13use crate::crypto::constant_time_eq;
14use crate::{AppState, Error, Result};
15
16fn now() -> u64 {
17    crate::clock::unix_secs().max(0) as u64
18}
19
20pub(crate) fn signature(state: &AppState, payload: &str) -> String {
21    let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(state.key.signing())
22        .expect("HMAC accepts keys of any length");
23    mac.update(payload.as_bytes());
24    mac.finalize()
25        .into_bytes()
26        .iter()
27        .map(|b| format!("{b:02x}"))
28        .collect()
29}
30
31impl AppState {
32    /// An absolute URL to a named route, valid for `ttl` and signed with `APP_KEY`.
33    pub fn signed_url(&self, name: &str, params: &[&dyn Display], ttl: Duration) -> Result<String> {
34        let path = self.url(name, params)?;
35        self.sign_path(&path, ttl)
36    }
37
38    /// Like `signed_url`, for an already-encoded path such as `/_renox/files/a.pdf`.
39    pub fn sign_path(&self, path: &str, ttl: Duration) -> Result<String> {
40        let unsigned = format!("{path}?expires={}", now().saturating_add(ttl.as_secs()));
41        let signature = signature(self, &unsigned);
42        Ok(format!(
43            "{}{unsigned}&signature={signature}",
44            self.config.url.trim_end_matches('/')
45        ))
46    }
47
48    /// An absolute URL to a named route.
49    pub fn absolute_url(&self, name: &str, params: &[&dyn Display]) -> Result<String> {
50        Ok(format!(
51            "{}{}",
52            self.config.url.trim_end_matches('/'),
53            self.url(name, params)?
54        ))
55    }
56}
57
58/// Proof that the request's URL was signed by `signed_url` and hasn't expired.
59pub struct ValidSignature;
60
61impl<S: Send + Sync> FromRequestParts<S> for ValidSignature {
62    type Rejection = Error;
63
64    async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self> {
65        let state = parts
66            .extensions
67            .get::<AppState>()
68            .ok_or_else(|| anyhow::anyhow!("the auth middleware is not installed"))?;
69        if verify(state, &parts.uri) {
70            Ok(Self)
71        } else {
72            Err(Error::Forbidden)
73        }
74    }
75}
76
77/// Whether `uri` carries a valid, unexpired signature from `sign_path`.
78pub(crate) fn verify(state: &AppState, uri: &axum::http::Uri) -> bool {
79    let query = uri.query().unwrap_or_default();
80    let Some((unsigned_query, given)) = query.rsplit_once("&signature=") else {
81        return false;
82    };
83    let Some(expires) = unsigned_query
84        .strip_prefix("expires=")
85        .and_then(|v| v.parse::<u64>().ok())
86    else {
87        return false;
88    };
89    let unsigned = format!("{}?{unsigned_query}", uri.path());
90    expires >= now() && constant_time_eq(&signature(state, &unsigned), given)
91}