dwara_core/security/
signed_url.rs

1//! Signed URL request authentication (DW-109).
2//!
3//! This module provides short-lived signed URL verification: a
4//! request to a route with `signed_url` enabled must carry a
5//! cryptographic signature in its query string, computed as an
6//! HMAC-SHA256 over the canonical request (method, path, and an
7//! expiry timestamp). The signature proves the URL was minted by a
8//! trusted party that holds the secret, and the expiry bounds the
9//! URL's validity window.
10//!
11//! ## Replay hardening (SEC-07, #211)
12//!
13//! Two optional mechanisms prevent replay within the validity window:
14//!
15//! - **One-time nonces**: when `require_nonce: true`, each signed URL
16//!   must carry a unique `nonce` query parameter. The verifier records
17//!   each seen nonce in a bounded TTL cache; a replayed nonce is
18//!   rejected with `ReplayedNonce`. The cache is per-verifier (per
19//!   route), keyed by the nonce value, and entries expire at the
20//!   URL's `expires` timestamp (so the cache does not grow unboundedly).
21//! - **Client-IP binding**: when `bind_client_ip: true`, the canonical
22//!   request includes the client's IP address, so a signed URL minted
23//!   for one client cannot be replayed from a different IP. The IP is
24//!   appended to the canonical string as a fourth line.
25//!
26//! Both are opt-in (default off) for backward compatibility with
27//! existing signed-URL minters.
28
29use hmac::{Hmac, Mac};
30use sha2::Sha256;
31
32#[cfg(feature = "ent")]
33use std::sync::Arc;
34
35/// HMAC-SHA256 type alias.
36type HmacSha256 = Hmac<Sha256>;
37
38/// Signed URL configuration (DW-109, `routes[].signed_url`).
39///
40/// When enabled, the gateway verifies that each request to the route
41/// carries a valid HMAC-SHA256 signature in its query string. The
42/// signature is computed over the canonical request (method, path,
43/// expires timestamp) using the configured secret.
44#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
45#[serde(deny_unknown_fields)]
46pub struct SignedUrlConfig {
47    /// Whether signed URL verification is enabled on this route.
48    #[serde(default)]
49    pub enabled: bool,
50    /// The HMAC secret (shared between the URL minter and the
51    /// gateway). Must be non-empty when `enabled` is true. Stored
52    /// as a plain string; in production, use a `${...}` secret
53    /// reference (DW-045) so the secret is not in the config file.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub secret: Option<String>,
56    /// The signature validity window in seconds. A URL minted at
57    /// time T is valid until T + `ttl_seconds`. Must be > 0.
58    /// Default: 300 (5 minutes).
59    #[serde(default = "default_ttl_seconds")]
60    pub ttl_seconds: u64,
61    /// The query parameter name carrying the hex-encoded signature.
62    /// Must be non-empty. Default: `sig`.
63    #[serde(default = "default_query_param")]
64    pub query_param: String,
65    /// Require a one-time nonce (SEC-07, #211): when true, each
66    /// signed URL must carry a `nonce` query parameter, and the
67    /// verifier rejects replayed nonces within the validity window.
68    /// Default: false (backward compatible).
69    #[serde(default)]
70    pub require_nonce: bool,
71    /// The query parameter name carrying the one-time nonce.
72    /// Must be non-empty when `require_nonce` is true.
73    /// Default: `nonce`.
74    #[serde(default = "default_nonce_param")]
75    pub nonce_param: String,
76    /// Bind the signature to the client's IP address (SEC-07, #211):
77    /// when true, the canonical request includes the client IP as a
78    /// fourth line, so a signed URL minted for one client cannot be
79    /// replayed from a different IP. Default: false.
80    #[serde(default)]
81    pub bind_client_ip: bool,
82}
83
84fn default_ttl_seconds() -> u64 {
85    300
86}
87
88fn default_query_param() -> String {
89    "sig".to_string()
90}
91
92fn default_nonce_param() -> String {
93    "nonce".to_string()
94}
95
96/// An error from signed URL verification (DW-109).
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum SignedUrlError {
99    /// The signature query parameter is missing from the request.
100    MissingSignature,
101    /// The `expires` query parameter is missing or not a valid
102    /// integer.
103    MissingExpires,
104    /// The signature does not match the recomputed HMAC.
105    InvalidSignature,
106    /// The URL has expired (the `expires` timestamp is in the past).
107    Expired,
108    /// The nonce query parameter is missing (SEC-07, #211).
109    MissingNonce,
110    /// The nonce has already been used (replay detected, SEC-07, #211).
111    ReplayedNonce,
112}
113
114impl std::fmt::Display for SignedUrlError {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            SignedUrlError::MissingSignature => {
118                write!(f, "signed URL signature is missing from the query string")
119            }
120            SignedUrlError::MissingExpires => {
121                write!(f, "signed URL expires timestamp is missing or invalid")
122            }
123            SignedUrlError::InvalidSignature => {
124                write!(f, "signed URL signature is invalid")
125            }
126            SignedUrlError::Expired => {
127                write!(f, "signed URL has expired")
128            }
129            SignedUrlError::MissingNonce => {
130                write!(f, "signed URL nonce is missing from the query string")
131            }
132            SignedUrlError::ReplayedNonce => {
133                write!(
134                    f,
135                    "signed URL nonce has already been used (replay detected)"
136                )
137            }
138        }
139    }
140}
141
142impl std::error::Error for SignedUrlError {}
143
144/// The result of signed URL verification.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum SignedUrlResult {
147    /// The signature is valid and the URL has not expired.
148    Valid,
149    /// The signature is invalid (does not match the recomputed HMAC).
150    Invalid,
151    /// The URL has expired.
152    Expired,
153    /// The nonce is missing or has been replayed (SEC-07, #211).
154    NonceError,
155}
156
157/// A bounded TTL cache for one-time nonces (SEC-07, #211). Entries
158/// expire at the URL's `expires` timestamp, so the cache does not
159/// grow unboundedly. Bounded to `MAX_NONCES` entries; when full, the
160/// oldest entries are evicted (a signed URL with an evicted nonce
161/// would have expired anyway, since the cache TTL is the URL's
162/// validity window).
163struct NonceCache {
164    entries: std::collections::HashMap<String, u64>,
165    /// Ordered keys for LRU eviction (insertion order).
166    order: std::collections::VecDeque<String>,
167}
168
169impl NonceCache {
170    const MAX_NONCES: usize = 4096;
171
172    fn new() -> Self {
173        NonceCache {
174            entries: std::collections::HashMap::new(),
175            order: std::collections::VecDeque::new(),
176        }
177    }
178
179    /// Check and record a nonce. Returns `true` if the nonce is fresh
180    /// (not seen before, or the previous entry has expired), `false`
181    /// if it is a replay within the validity window. SEC-11 (#214):
182    /// the purge compares the stored expiry against `now` (the
183    /// current time), not against the new nonce's `expires`, so
184    /// entries with the same expiry are not incorrectly purged.
185    fn check_and_record(&mut self, nonce: &str, expires: u64, now: u64) -> bool {
186        // Purge expired entries (cheap: we check the front of the
187        // queue, which holds the oldest entries).
188        while let Some(front) = self.order.front() {
189            if let Some(&exp) = self.entries.get(front) {
190                if exp <= now {
191                    self.entries.remove(front);
192                    self.order.pop_front();
193                    continue;
194                }
195            }
196            break;
197        }
198        if self.entries.contains_key(nonce) {
199            return false; // replay
200        }
201        // Evict oldest if at capacity.
202        if self.entries.len() >= Self::MAX_NONCES {
203            if let Some(evicted) = self.order.pop_front() {
204                self.entries.remove(&evicted);
205            }
206        }
207        self.entries.insert(nonce.to_string(), expires);
208        self.order.push_back(nonce.to_string());
209        true
210    }
211}
212
213/// A signed URL verifier (DW-109).
214///
215/// Built from a route's [`SignedUrlConfig`] at snapshot compile time.
216/// The verifier holds the HMAC secret and the query parameter name.
217/// The [`SignedUrlVerifier::verify`] method extracts the signature
218/// and expiry from the query string, recomputes the HMAC, and checks
219/// the expiry.
220pub struct SignedUrlVerifier {
221    secret: Vec<u8>,
222    query_param: String,
223    /// SEC-07 (#211): nonce configuration.
224    require_nonce: bool,
225    nonce_param: String,
226    bind_client_ip: bool,
227    /// SEC-07 (#211): per-verifier nonce cache (guarded by the
228    /// verifier's `Mutex` in `verify`).
229    nonces: std::sync::Mutex<NonceCache>,
230    /// SEC-06 (#210, ent): optional distributed nonce store. When
231    /// present, the verifier uses the distributed store instead of
232    /// the in-process cache, so replay detection is shared across
233    /// the gateway fleet. The in-process cache remains as a
234    /// fallback when the distributed store is not configured.
235    #[cfg(feature = "ent")]
236    distributed_nonces: Option<Arc<dyn crate::extensions::nonce::NonceStore>>,
237}
238
239impl SignedUrlVerifier {
240    /// Build a verifier from a [`SignedUrlConfig`]. Returns an error
241    /// if the config is invalid (empty secret, empty query_param).
242    pub fn from_config(config: &SignedUrlConfig) -> Result<Self, SignedUrlError> {
243        let secret = config
244            .secret
245            .as_ref()
246            .filter(|s| !s.is_empty())
247            .ok_or(SignedUrlError::MissingSignature)?
248            .as_bytes()
249            .to_vec();
250        if config.query_param.is_empty() {
251            return Err(SignedUrlError::MissingSignature);
252        }
253        if config.require_nonce && config.nonce_param.is_empty() {
254            return Err(SignedUrlError::MissingNonce);
255        }
256        Ok(Self {
257            secret,
258            query_param: config.query_param.clone(),
259            require_nonce: config.require_nonce,
260            nonce_param: config.nonce_param.clone(),
261            bind_client_ip: config.bind_client_ip,
262            nonces: std::sync::Mutex::new(NonceCache::new()),
263            #[cfg(feature = "ent")]
264            distributed_nonces: None,
265        })
266    }
267
268    /// SEC-06 (#210, ent): attach a distributed nonce store. When
269    /// set, the verifier uses the distributed store for replay
270    /// detection instead of the in-process cache, so nonces are
271    /// shared across the gateway fleet. Requires the `ent` cargo
272    /// feature.
273    #[cfg(feature = "ent")]
274    pub fn with_distributed_nonce_store(
275        mut self,
276        store: Arc<dyn crate::extensions::nonce::NonceStore>,
277    ) -> Self {
278        self.distributed_nonces = Some(store);
279        self
280    }
281
282    /// The query parameter name this verifier looks for.
283    pub fn query_param(&self) -> &str {
284        &self.query_param
285    }
286
287    /// Verify a signed URL request. Extracts the signature and expiry
288    /// from the query string, recomputes the HMAC-SHA256 over the
289    /// canonical request (method, path, expires), and checks the
290    /// expiry.
291    ///
292    /// - `method`: the uppercase HTTP method (e.g. "GET").
293    /// - `path`: the request path (without query string).
294    /// - `query`: the raw query string (e.g. "foo=bar&sig=abc&expires=123").
295    /// - `now`: the current Unix epoch seconds.
296    /// - `client_ip`: the client's IP address (used when
297    ///   `bind_client_ip` is true; pass an empty string to skip).
298    ///
299    /// Returns [`SignedUrlResult::Valid`] if the signature matches
300    /// and the URL has not expired; [`SignedUrlResult::Invalid`] if
301    /// the signature is missing or does not match; [`SignedUrlResult::Expired`]
302    /// if the URL has expired; [`SignedUrlResult::NonceError`] if the
303    /// nonce is missing or replayed (SEC-07, #211).
304    pub fn verify(
305        &self,
306        method: &str,
307        path: &str,
308        query: &str,
309        now: u64,
310        client_ip: &str,
311    ) -> SignedUrlResult {
312        let params = parse_query(query);
313        let sig = match params.get(&self.query_param) {
314            Some(s) => s,
315            None => return SignedUrlResult::Invalid,
316        };
317        let expires_str = match params.get("expires") {
318            Some(e) => e,
319            None => return SignedUrlResult::Invalid,
320        };
321        let expires: u64 = match expires_str.parse() {
322            Ok(v) => v,
323            Err(_) => return SignedUrlResult::Invalid,
324        };
325        if now > expires {
326            return SignedUrlResult::Expired;
327        }
328        // SEC-07 (#211): nonce check BEFORE signature verification —
329        // a replayed nonce is rejected without recomputing the HMAC.
330        if self.require_nonce {
331            let nonce = match params.get(&self.nonce_param) {
332                Some(n) => n,
333                None => return SignedUrlResult::NonceError,
334            };
335            let mut cache = self.nonces.lock().expect("nonce cache poisoned");
336            if !cache.check_and_record(nonce, expires, now) {
337                return SignedUrlResult::NonceError;
338            }
339        }
340        // Recompute the HMAC over the canonical request.
341        let canonical = if self.bind_client_ip {
342            format!("{method}\n{path}\n{expires}\n{client_ip}")
343        } else {
344            format!("{method}\n{path}\n{expires}")
345        };
346        let mut mac = match HmacSha256::new_from_slice(&self.secret) {
347            Ok(m) => m,
348            Err(_) => return SignedUrlResult::Invalid,
349        };
350        mac.update(canonical.as_bytes());
351        let expected = mac.finalize().into_bytes();
352        let expected_hex = hex_encode(&expected);
353        if constant_time_eq(sig.as_bytes(), expected_hex.as_bytes()) {
354            SignedUrlResult::Valid
355        } else {
356            SignedUrlResult::Invalid
357        }
358    }
359
360    /// SEC-06 (#210, ent): async verify that uses the distributed
361    /// nonce store when attached. Falls back to the in-process
362    /// cache when no distributed store is configured (or when the
363    /// `ent` feature is disabled). The signature and expiry checks
364    /// are identical to [`verify`](Self::verify).
365    #[cfg(feature = "ent")]
366    pub async fn verify_async(
367        &self,
368        method: &str,
369        path: &str,
370        query: &str,
371        now: u64,
372        client_ip: &str,
373    ) -> SignedUrlResult {
374        let params = parse_query(query);
375        let sig = match params.get(&self.query_param) {
376            Some(s) => s,
377            None => return SignedUrlResult::Invalid,
378        };
379        let expires_str = match params.get("expires") {
380            Some(e) => e,
381            None => return SignedUrlResult::Invalid,
382        };
383        let expires: u64 = match expires_str.parse() {
384            Ok(v) => v,
385            Err(_) => return SignedUrlResult::Invalid,
386        };
387        if now > expires {
388            return SignedUrlResult::Expired;
389        }
390        // SEC-06 (#210): nonce check via distributed store when
391        // available, else in-process cache.
392        if self.require_nonce {
393            let nonce = match params.get(&self.nonce_param) {
394                Some(n) => n,
395                None => return SignedUrlResult::NonceError,
396            };
397            if let Some(store) = &self.distributed_nonces {
398                match store.check_and_record(nonce, expires).await {
399                    Ok(true) => {}
400                    Ok(false) => return SignedUrlResult::NonceError,
401                    Err(e) => {
402                        tracing::warn!(
403                            code = "signed_url_distributed_nonce_error",
404                            error = %e,
405                            "distributed nonce store error; falling back to in-process cache"
406                        );
407                        let mut cache = self.nonces.lock().expect("nonce cache poisoned");
408                        if !cache.check_and_record(nonce, expires, now) {
409                            return SignedUrlResult::NonceError;
410                        }
411                    }
412                }
413            } else {
414                let mut cache = self.nonces.lock().expect("nonce cache poisoned");
415                if !cache.check_and_record(nonce, expires, now) {
416                    return SignedUrlResult::NonceError;
417                }
418            }
419        }
420        // Recompute the HMAC over the canonical request.
421        let canonical = if self.bind_client_ip {
422            format!("{method}\n{path}\n{expires}\n{client_ip}")
423        } else {
424            format!("{method}\n{path}\n{expires}")
425        };
426        let mut mac = match HmacSha256::new_from_slice(&self.secret) {
427            Ok(m) => m,
428            Err(_) => return SignedUrlResult::Invalid,
429        };
430        mac.update(canonical.as_bytes());
431        let expected = mac.finalize().into_bytes();
432        let expected_hex = hex_encode(&expected);
433        if constant_time_eq(sig.as_bytes(), expected_hex.as_bytes()) {
434            SignedUrlResult::Valid
435        } else {
436            SignedUrlResult::Invalid
437        }
438    }
439
440    /// Mint a signed URL query string for the given request. This is
441    /// the counterpart to [`verify`](Self::verify): an external URL
442    /// minter (or a test) uses it to produce a valid signature.
443    ///
444    /// - `method`: the uppercase HTTP method.
445    /// - `path`: the request path (without query string).
446    /// - `expires`: the Unix epoch seconds at which the URL expires.
447    /// - `client_ip`: the client's IP address (used when
448    ///   `bind_client_ip` is true; pass an empty string to skip).
449    /// - `nonce`: an optional nonce (used when `require_nonce` is
450    ///   true; the caller generates a unique nonce per URL).
451    ///
452    /// Returns the query string with the signature, expiry, and
453    /// optional nonce parameters.
454    pub fn sign(
455        &self,
456        method: &str,
457        path: &str,
458        expires: u64,
459        client_ip: &str,
460        nonce: Option<&str>,
461    ) -> Result<String, SignedUrlError> {
462        let canonical = if self.bind_client_ip {
463            format!("{method}\n{path}\n{expires}\n{client_ip}")
464        } else {
465            format!("{method}\n{path}\n{expires}")
466        };
467        let mut mac = HmacSha256::new_from_slice(&self.secret)
468            .map_err(|_| SignedUrlError::InvalidSignature)?;
469        mac.update(canonical.as_bytes());
470        let sig = hex_encode(&mac.finalize().into_bytes());
471        let mut query = format!("{}={}&expires={}", self.query_param, sig, expires);
472        if self.require_nonce {
473            let n = nonce.ok_or(SignedUrlError::MissingNonce)?;
474            query.push('&');
475            query.push_str(&self.nonce_param);
476            query.push('=');
477            query.push_str(n);
478        }
479        Ok(query)
480    }
481}
482
483impl std::fmt::Debug for SignedUrlVerifier {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        f.debug_struct("SignedUrlVerifier")
486            .field("query_param", &self.query_param)
487            .field("secret_len", &self.secret.len())
488            .field("require_nonce", &self.require_nonce)
489            .field("bind_client_ip", &self.bind_client_ip)
490            .finish()
491    }
492}
493
494/// Parse a query string into a map of key -> value. Handles
495/// URL-encoded values (percent-decoding is NOT done here; the caller
496/// is expected to pass a raw query string with already-decoded
497/// values, as the gateway's request path decodes query params before
498/// this point).
499fn parse_query(query: &str) -> std::collections::HashMap<String, String> {
500    let mut params = std::collections::HashMap::new();
501    for pair in query.split('&') {
502        if pair.is_empty() {
503            continue;
504        }
505        if let Some((key, value)) = pair.split_once('=') {
506            params.insert(key.to_string(), value.to_string());
507        } else {
508            params.insert(pair.to_string(), String::new());
509        }
510    }
511    params
512}
513
514/// Hex-encode a byte slice (lowercase).
515fn hex_encode(bytes: &[u8]) -> String {
516    let mut s = String::with_capacity(bytes.len() * 2);
517    for b in bytes {
518        s.push_str(&format!("{b:02x}"));
519    }
520    s
521}
522
523/// Constant-time comparison of two byte slices. Returns true if they
524/// are equal. Uses `subtle::ConstantTimeEq` when available; falls
525/// back to a simple comparison otherwise.
526fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
527    use subtle::ConstantTimeEq;
528    a.ct_eq(b).into()
529}