Skip to main content

doido_storage/
signing.rs

1//! HMAC-SHA256 signed ids and signed URLs — the analogue of Rails' message
2//! verifier used by ActiveStorage `signed_id`s and disk-service URLs.
3//!
4//! A token is `base64url(payload_json)--base64url(hmac)`, where `payload_json`
5//! carries the signed data, a `purpose` string, and an optional expiry (unix
6//! seconds). Verification recomputes the MAC in constant time and checks expiry.
7
8use crate::error::StorageError;
9use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
10use base64::Engine;
11use doido_core::Result;
12use hmac::digest::KeyInit;
13use hmac::{Hmac, Mac};
14use serde::{Deserialize, Serialize};
15use sha2::Sha256;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17
18type HmacSha256 = Hmac<Sha256>;
19
20/// Whether a served file should render inline or download as an attachment.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum Disposition {
24    /// Render in the browser when possible.
25    #[default]
26    Inline,
27    /// Force a download.
28    Attachment,
29}
30
31impl Disposition {
32    /// The `Content-Disposition` header value for `filename`.
33    pub fn header(&self, filename: &str) -> String {
34        let kind = match self {
35            Disposition::Inline => "inline",
36            Disposition::Attachment => "attachment",
37        };
38        format!("{kind}; filename=\"{}\"", filename.replace('"', ""))
39    }
40}
41
42/// The signed payload carried inside a token.
43#[derive(Debug, Serialize, Deserialize)]
44struct Payload {
45    /// The signed data (e.g. a blob key).
46    data: String,
47    /// A namespace so a token signed for one purpose can't be replayed for another.
48    purpose: String,
49    /// Optional expiry, unix seconds.
50    #[serde(skip_serializing_if = "Option::is_none")]
51    exp: Option<u64>,
52}
53
54fn now_secs() -> u64 {
55    SystemTime::now()
56        .duration_since(UNIX_EPOCH)
57        .map(|d| d.as_secs())
58        .unwrap_or(0)
59}
60
61/// Signs and verifies tokens with a secret key.
62#[derive(Clone)]
63pub struct Signer {
64    secret: Vec<u8>,
65}
66
67impl Signer {
68    /// Build a signer from raw secret bytes.
69    pub fn new(secret: impl Into<Vec<u8>>) -> Self {
70        Self {
71            secret: secret.into(),
72        }
73    }
74
75    /// Read the secret from `DOIDO_SECRET_KEY_BASE`, falling back to an insecure
76    /// development default (with a warning) when unset.
77    pub fn from_env() -> Self {
78        match std::env::var("DOIDO_SECRET_KEY_BASE") {
79            Ok(s) if !s.is_empty() => Self::new(s.into_bytes()),
80            _ => {
81                doido_core::tracing::warn!(
82                    "DOIDO_SECRET_KEY_BASE unset; using an insecure development signing key"
83                );
84                Self::new(b"doido-storage-development-secret".to_vec())
85            }
86        }
87    }
88
89    fn mac(&self, message: &[u8]) -> Vec<u8> {
90        let mut mac =
91            HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts keys of any length");
92        mac.update(message);
93        mac.finalize().into_bytes().to_vec()
94    }
95
96    /// Sign `data` for `purpose`, optionally expiring after `expires_in`.
97    pub fn sign(&self, data: &str, purpose: &str, expires_in: Option<Duration>) -> String {
98        let payload = Payload {
99            data: data.to_string(),
100            purpose: purpose.to_string(),
101            exp: expires_in.map(|d| now_secs() + d.as_secs()),
102        };
103        let json = serde_json::to_vec(&payload).expect("payload serializes");
104        let message = B64.encode(&json);
105        let sig = B64.encode(self.mac(message.as_bytes()));
106        format!("{message}--{sig}")
107    }
108
109    /// Verify a token for `purpose`, returning the signed `data` or an error if
110    /// the signature is invalid, the purpose mismatches, or it has expired.
111    pub fn verify(&self, token: &str, purpose: &str) -> Result<String> {
112        let (message, sig) = token
113            .split_once("--")
114            .ok_or_else(|| StorageError::InvalidSignature("malformed token".into()))?;
115
116        let expected = self.mac(message.as_bytes());
117        let provided = B64
118            .decode(sig)
119            .map_err(|_| StorageError::InvalidSignature("bad signature encoding".into()))?;
120        // Constant-time comparison via HMAC's verify_slice.
121        let mut mac =
122            HmacSha256::new_from_slice(&self.secret).expect("HMAC accepts keys of any length");
123        mac.update(message.as_bytes());
124        mac.verify_slice(&provided)
125            .map_err(|_| StorageError::InvalidSignature("signature mismatch".into()))?;
126        let _ = expected; // recomputed inside verify_slice; kept for clarity
127
128        let json = B64
129            .decode(message)
130            .map_err(|_| StorageError::InvalidSignature("bad payload encoding".into()))?;
131        let payload: Payload = serde_json::from_slice(&json)
132            .map_err(|_| StorageError::InvalidSignature("bad payload".into()))?;
133
134        if payload.purpose != purpose {
135            return Err(StorageError::InvalidSignature("purpose mismatch".into()).into());
136        }
137        if let Some(exp) = payload.exp {
138            if now_secs() > exp {
139                return Err(StorageError::InvalidSignature("token expired".into()).into());
140            }
141        }
142        Ok(payload.data)
143    }
144}