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}