Skip to main content

reinhardt_core/security/
csrf.rs

1//! CSRF (Cross-Site Request Forgery) protection
2
3use hmac::{Hmac, Mac};
4use rand::Rng;
5use sha2::Sha256;
6
7/// CSRF token length (64 characters)
8pub const CSRF_TOKEN_LENGTH: usize = 64;
9
10/// CSRF secret length (32 characters)
11pub const CSRF_SECRET_LENGTH: usize = 32;
12
13/// Allowed characters for CSRF tokens (alphanumeric)
14pub const CSRF_ALLOWED_CHARS: &str =
15	"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
16
17/// CSRF session key
18pub const CSRF_SESSION_KEY: &str = "_csrf_token";
19
20/// Rejection reason: Origin header does not match any trusted origins.
21pub const REASON_BAD_ORIGIN: &str = "Origin checking failed - does not match any trusted origins.";
22/// Rejection reason: Referer header does not match any trusted origins.
23pub const REASON_BAD_REFERER: &str =
24	"Referer checking failed - does not match any trusted origins.";
25/// Rejection reason: CSRF token is missing from the request.
26pub const REASON_CSRF_TOKEN_MISSING: &str = "CSRF token missing.";
27/// Rejection reason: CSRF token has an incorrect length.
28pub const REASON_INCORRECT_LENGTH: &str = "CSRF token has incorrect length.";
29/// Rejection reason: Referer uses HTTP while the host uses HTTPS.
30pub const REASON_INSECURE_REFERER: &str =
31	"Referer checking failed - Referer is insecure while host is secure.";
32/// Rejection reason: CSRF token contains invalid characters.
33pub const REASON_INVALID_CHARACTERS: &str = "CSRF token has invalid characters.";
34/// Rejection reason: Referer header is malformed.
35pub const REASON_MALFORMED_REFERER: &str = "Referer checking failed - Referer is malformed.";
36/// Rejection reason: CSRF cookie is not set.
37pub const REASON_NO_CSRF_COOKIE: &str = "CSRF cookie not set.";
38/// Rejection reason: Referer header is missing.
39pub const REASON_NO_REFERER: &str = "Referer checking failed - no Referer.";
40
41/// CSRF token validation error
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct RejectRequest {
44	/// Human-readable reason for CSRF rejection.
45	pub reason: String,
46}
47
48/// Invalid token format error
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct InvalidTokenFormat {
51	/// Description of the format error.
52	pub reason: String,
53}
54
55/// CSRF metadata
56#[derive(Debug, Clone)]
57pub struct CsrfMeta {
58	/// The CSRF token value.
59	pub token: String,
60}
61
62/// SameSite cookie attribute
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum SameSite {
65	/// Strict mode - cookie only sent in first-party context
66	Strict,
67	/// Lax mode - cookie sent with top-level navigation
68	#[default]
69	Lax,
70	/// None mode - cookie sent in all contexts (requires Secure)
71	None,
72}
73
74/// CSRF configuration
75///
76/// All tokens are generated using HMAC-SHA256 for cryptographic security.
77#[derive(Debug, Clone)]
78pub struct CsrfConfig {
79	/// Name of the CSRF cookie (default: "csrftoken").
80	pub cookie_name: String,
81	/// Name of the HTTP header for CSRF token (default: "X-CSRFToken").
82	pub header_name: String,
83	/// CSRF cookie should NOT be HttpOnly (JavaScript needs access)
84	pub cookie_httponly: bool,
85	/// Cookie should be Secure in production (HTTPS only)
86	pub cookie_secure: bool,
87	/// SameSite attribute for CSRF protection
88	pub cookie_samesite: SameSite,
89	/// Cookie domain (None = current domain only)
90	pub cookie_domain: Option<String>,
91	/// Cookie path (default: "/")
92	pub cookie_path: String,
93	/// Cookie max age in seconds (None = session cookie)
94	pub cookie_max_age: Option<i64>,
95	/// Enable token rotation (security enhancement)
96	pub enable_token_rotation: bool,
97	/// Token rotation interval in seconds (None = rotate on every request)
98	pub token_rotation_interval: Option<u64>,
99}
100
101impl Default for CsrfConfig {
102	fn default() -> Self {
103		Self {
104			cookie_name: "csrftoken".to_string(),
105			header_name: "X-CSRFToken".to_string(),
106			cookie_httponly: false, // CSRF token needs JavaScript access
107			cookie_secure: false,   // Development default (set to true in production)
108			cookie_samesite: SameSite::Lax,
109			cookie_domain: None,
110			cookie_path: "/".to_string(),
111			cookie_max_age: None,          // Session cookie
112			enable_token_rotation: false,  // Development default
113			token_rotation_interval: None, // Rotate on every request when enabled
114		}
115	}
116}
117
118impl CsrfConfig {
119	/// Production-ready configuration with security hardening
120	///
121	/// # Examples
122	///
123	/// ```
124	/// use reinhardt_core::security::csrf::CsrfConfig;
125	///
126	/// let config = CsrfConfig::production();
127	/// assert!(config.cookie_secure);
128	/// assert_eq!(config.cookie_path, "/");
129	/// assert!(config.enable_token_rotation);
130	/// ```
131	pub fn production() -> Self {
132		Self {
133			cookie_name: "csrftoken".to_string(),
134			header_name: "X-CSRFToken".to_string(),
135			cookie_httponly: false, // CSRF token needs JavaScript access
136			cookie_secure: true,    // HTTPS only in production
137			cookie_samesite: SameSite::Strict,
138			cookie_domain: None,
139			cookie_path: "/".to_string(),
140			cookie_max_age: Some(31449600),      // 1 year
141			enable_token_rotation: true,         // Enable rotation in production
142			token_rotation_interval: Some(3600), // Rotate every hour
143		}
144	}
145
146	/// Enable token rotation
147	///
148	/// # Examples
149	///
150	/// ```
151	/// use reinhardt_core::security::csrf::CsrfConfig;
152	///
153	/// let config = CsrfConfig::default().with_token_rotation(Some(1800));
154	/// assert!(config.enable_token_rotation);
155	/// assert_eq!(config.token_rotation_interval, Some(1800));
156	/// ```
157	pub fn with_token_rotation(mut self, interval: Option<u64>) -> Self {
158		self.enable_token_rotation = true;
159		self.token_rotation_interval = interval;
160		self
161	}
162}
163
164/// CSRF middleware
165pub struct CsrfMiddleware {
166	// Allow dead_code: config stored for future middleware request/response processing; not yet consumed by HTTP pipeline
167	#[allow(dead_code)]
168	config: CsrfConfig,
169}
170
171impl CsrfMiddleware {
172	/// Creates a new `CsrfMiddleware` with default configuration.
173	pub fn new() -> Self {
174		Self {
175			config: CsrfConfig::default(),
176		}
177	}
178
179	/// Creates a new `CsrfMiddleware` with the given configuration.
180	pub fn with_config(config: CsrfConfig) -> Self {
181		Self { config }
182	}
183}
184
185impl Default for CsrfMiddleware {
186	fn default() -> Self {
187		Self::new()
188	}
189}
190
191/// CSRF token
192#[derive(Debug, Clone)]
193pub struct CsrfToken(pub String);
194
195impl CsrfToken {
196	/// Creates a new `CsrfToken` from a token string.
197	pub fn new(token: String) -> Self {
198		Self(token)
199	}
200
201	/// Returns the token value as a string slice.
202	pub fn as_str(&self) -> &str {
203		&self.0
204	}
205}
206
207/// HMAC-SHA256 type alias
208type HmacSha256 = Hmac<Sha256>;
209
210/// Generate HMAC-SHA256 based CSRF token
211///
212/// Creates a cryptographically secure token using HMAC-SHA256.
213/// This is more secure than the legacy masking approach.
214///
215/// # Arguments
216///
217/// * `secret` - Secret key for HMAC (should be at least 32 bytes)
218/// * `message` - Message to authenticate (typically timestamp or session ID)
219///
220/// # Returns
221///
222/// Hex-encoded HMAC token (64 characters)
223///
224/// # Examples
225///
226/// ```
227/// use reinhardt_core::security::csrf::generate_token_hmac;
228///
229/// let secret = b"my-secret-key-at-least-32-bytes-long";
230/// let message = "session-id-12345";
231/// let token = generate_token_hmac(secret, message);
232/// assert_eq!(token.len(), 64); // HMAC-SHA256 produces 32 bytes = 64 hex chars
233/// ```
234pub fn generate_token_hmac(secret: &[u8], message: &str) -> String {
235	let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
236	mac.update(message.as_bytes());
237	let result = mac.finalize();
238	hex::encode(result.into_bytes())
239}
240
241/// Verify HMAC-SHA256 based CSRF token
242///
243/// Verifies that the token was generated with the given secret and message.
244/// Uses constant-time comparison to prevent timing attacks.
245///
246/// # Arguments
247///
248/// * `token` - Hex-encoded HMAC token to verify
249/// * `secret` - Secret key used for HMAC generation
250/// * `message` - Original message that was authenticated
251///
252/// # Returns
253///
254/// `true` if the token is valid, `false` otherwise
255///
256/// # Examples
257///
258/// ```
259/// use reinhardt_core::security::csrf::{generate_token_hmac, verify_token_hmac};
260///
261/// let secret = b"my-secret-key-at-least-32-bytes-long";
262/// let message = "session-id-12345";
263/// let token = generate_token_hmac(secret, message);
264///
265/// assert!(verify_token_hmac(&token, secret, message));
266/// assert!(!verify_token_hmac(&token, secret, "different-message"));
267/// assert!(!verify_token_hmac("invalid-token", secret, message));
268/// ```
269pub fn verify_token_hmac(token: &str, secret: &[u8], message: &str) -> bool {
270	// Decode hex token
271	let Ok(token_bytes) = hex::decode(token) else {
272		return false;
273	};
274
275	// Generate expected HMAC
276	let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size");
277	mac.update(message.as_bytes());
278
279	// Constant-time comparison to prevent timing attacks
280	mac.verify_slice(&token_bytes).is_ok()
281}
282
283/// Get CSRF secret as bytes (32 bytes)
284///
285/// Generates a cryptographically secure random secret suitable for HMAC.
286///
287/// # Examples
288///
289/// ```
290/// use reinhardt_core::security::csrf::get_secret_bytes;
291///
292/// let secret = get_secret_bytes();
293/// assert_eq!(secret.len(), 32);
294/// ```
295pub fn get_secret_bytes() -> Vec<u8> {
296	let mut rng = rand::rng();
297	let mut secret = vec![0u8; 32];
298	rng.fill(&mut secret[..]);
299	secret
300}
301
302/// Get CSRF token using HMAC-SHA256
303///
304/// Generates a CSRF token using the HMAC-SHA256 approach.
305/// This is the recommended method for new implementations.
306///
307/// # Arguments
308///
309/// * `secret_bytes` - 32-byte secret key
310/// * `session_id` - Session identifier or timestamp
311///
312/// # Returns
313///
314/// Hex-encoded HMAC token (64 characters)
315///
316/// # Examples
317///
318/// ```
319/// use reinhardt_core::security::csrf::{get_secret_bytes, get_token_hmac};
320///
321/// let secret = get_secret_bytes();
322/// let session_id = "user-session-12345";
323/// let token = get_token_hmac(&secret, session_id);
324/// assert_eq!(token.len(), 64);
325/// ```
326pub fn get_token_hmac(secret_bytes: &[u8], session_id: &str) -> String {
327	generate_token_hmac(secret_bytes, session_id)
328}
329
330/// Check HMAC-based CSRF token validity
331///
332/// Verifies a CSRF token generated with HMAC-SHA256.
333///
334/// # Arguments
335///
336/// * `request_token` - Token from the request
337/// * `secret_bytes` - Secret key used for generation
338/// * `session_id` - Session identifier or timestamp
339///
340/// # Returns
341///
342/// `Ok(())` if valid, `Err(RejectRequest)` if invalid
343///
344/// # Examples
345///
346/// ```
347/// use reinhardt_core::security::csrf::{get_secret_bytes, get_token_hmac, check_token_hmac};
348///
349/// let secret = get_secret_bytes();
350/// let session_id = "user-session-12345";
351/// let token = get_token_hmac(&secret, session_id);
352///
353/// assert!(check_token_hmac(&token, &secret, session_id).is_ok());
354/// assert!(check_token_hmac("invalid", &secret, session_id).is_err());
355/// ```
356pub fn check_token_hmac(
357	request_token: &str,
358	secret_bytes: &[u8],
359	session_id: &str,
360) -> Result<(), RejectRequest> {
361	if !verify_token_hmac(request_token, secret_bytes, session_id) {
362		return Err(RejectRequest {
363			reason: "CSRF token mismatch (HMAC verification failed)".to_string(),
364		});
365	}
366	Ok(())
367}
368
369/// Check origin header
370pub fn check_origin(origin: &str, allowed_origins: &[String]) -> Result<(), RejectRequest> {
371	if !allowed_origins.iter().any(|o| o == origin) {
372		return Err(RejectRequest {
373			reason: REASON_BAD_ORIGIN.to_string(),
374		});
375	}
376	Ok(())
377}
378
379/// Check referer header
380pub fn check_referer(
381	referer: Option<&str>,
382	allowed_origins: &[String],
383	is_secure: bool,
384) -> Result<(), RejectRequest> {
385	let referer = referer.ok_or_else(|| RejectRequest {
386		reason: REASON_NO_REFERER.to_string(),
387	})?;
388
389	if referer.is_empty() {
390		return Err(RejectRequest {
391			reason: REASON_MALFORMED_REFERER.to_string(),
392		});
393	}
394
395	if is_secure && referer.starts_with("http://") {
396		return Err(RejectRequest {
397			reason: REASON_INSECURE_REFERER.to_string(),
398		});
399	}
400
401	if !allowed_origins.iter().any(|o| referer.starts_with(o)) {
402		return Err(RejectRequest {
403			reason: REASON_BAD_REFERER.to_string(),
404		});
405	}
406
407	Ok(())
408}
409
410/// Check if two domains are the same
411pub fn is_same_domain(domain1: &str, domain2: &str) -> bool {
412	domain1 == domain2
413}
414
415/// Generate token timestamp
416///
417/// # Examples
418///
419/// ```
420/// use reinhardt_core::security::csrf::get_token_timestamp;
421///
422/// let timestamp = get_token_timestamp();
423/// assert!(timestamp > 0);
424/// ```
425pub fn get_token_timestamp() -> u64 {
426	std::time::SystemTime::now()
427		.duration_since(std::time::UNIX_EPOCH)
428		.unwrap_or_default()
429		.as_secs()
430}
431
432/// Check if token rotation is due
433///
434/// # Examples
435///
436/// ```
437/// use reinhardt_core::security::csrf::{should_rotate_token, get_token_timestamp};
438///
439/// let current_time = get_token_timestamp();
440/// let token_time = current_time - 3700; // 1 hour and 1 minute ago
441/// assert!(should_rotate_token(token_time, current_time, Some(3600)));
442/// assert!(!should_rotate_token(token_time, current_time, None)); // No rotation without interval
443/// ```
444pub fn should_rotate_token(
445	token_timestamp: u64,
446	current_timestamp: u64,
447	rotation_interval: Option<u64>,
448) -> bool {
449	match rotation_interval {
450		Some(interval) => current_timestamp.saturating_sub(token_timestamp) >= interval,
451		None => false, // Never rotate when interval is not specified
452	}
453}
454
455/// Generate token with timestamp
456///
457/// # Examples
458///
459/// ```
460/// use reinhardt_core::security::csrf::{get_secret_bytes, generate_token_with_timestamp};
461///
462/// let secret = get_secret_bytes();
463/// let session_id = "user-session-12345";
464/// let token_data = generate_token_with_timestamp(&secret, session_id);
465/// assert!(token_data.contains(':'));
466/// ```
467pub fn generate_token_with_timestamp(secret_bytes: &[u8], session_id: &str) -> String {
468	let timestamp = get_token_timestamp();
469	let message = format!("{}:{}", session_id, timestamp);
470	let token = generate_token_hmac(secret_bytes, &message);
471	format!("{}:{}", token, timestamp)
472}
473
474/// Verify token with timestamp
475///
476/// # Examples
477///
478/// ```
479/// use reinhardt_core::security::csrf::{get_secret_bytes, generate_token_with_timestamp, verify_token_with_timestamp};
480///
481/// let secret = get_secret_bytes();
482/// let session_id = "user-session-12345";
483/// let token_data = generate_token_with_timestamp(&secret, session_id);
484///
485/// assert!(verify_token_with_timestamp(&token_data, &secret, session_id).is_ok());
486/// ```
487pub fn verify_token_with_timestamp(
488	token_data: &str,
489	secret_bytes: &[u8],
490	session_id: &str,
491) -> Result<u64, RejectRequest> {
492	if token_data.is_empty() {
493		return Err(RejectRequest {
494			reason: "Invalid token format (empty token)".to_string(),
495		});
496	}
497
498	// Use rsplitn to split from the right, ensuring the timestamp is always
499	// the last segment even if the token portion somehow contains ':'
500	let mut parts = token_data.rsplitn(2, ':');
501	let timestamp_str = parts.next().ok_or_else(|| RejectRequest {
502		reason: "Invalid token format (missing timestamp)".to_string(),
503	})?;
504	let token = parts.next().ok_or_else(|| RejectRequest {
505		reason: "Invalid token format (missing delimiter)".to_string(),
506	})?;
507
508	if token.is_empty() {
509		return Err(RejectRequest {
510			reason: "Invalid token format (empty token value)".to_string(),
511		});
512	}
513
514	if timestamp_str.is_empty() {
515		return Err(RejectRequest {
516			reason: "Invalid token format (empty timestamp)".to_string(),
517		});
518	}
519
520	// Validate that the token is a valid hex string of the expected length
521	if token.len() != CSRF_TOKEN_LENGTH {
522		return Err(RejectRequest {
523			reason: format!(
524				"Invalid token format (expected {} hex characters, got {})",
525				CSRF_TOKEN_LENGTH,
526				token.len()
527			),
528		});
529	}
530
531	if !token.chars().all(|c| c.is_ascii_hexdigit()) {
532		return Err(RejectRequest {
533			reason: "Invalid token format (token contains non-hex characters)".to_string(),
534		});
535	}
536
537	let timestamp: u64 = timestamp_str.parse().map_err(|_| RejectRequest {
538		reason: "Invalid token format (timestamp is not a valid number)".to_string(),
539	})?;
540
541	let message = format!("{}:{}", session_id, timestamp);
542	if !verify_token_hmac(token, secret_bytes, &message) {
543		return Err(RejectRequest {
544			reason: "CSRF token mismatch (HMAC verification failed)".to_string(),
545		});
546	}
547
548	Ok(timestamp)
549}
550
551#[cfg(test)]
552mod tests {
553	use super::*;
554	use rstest::rstest;
555
556	fn test_secret() -> Vec<u8> {
557		b"test-secret-key-at-least-32-bytes".to_vec()
558	}
559
560	#[rstest]
561	fn test_verify_token_with_timestamp_valid_token() {
562		// Arrange
563		let secret = test_secret();
564		let session_id = "user-session-12345";
565		let token_data = generate_token_with_timestamp(&secret, session_id);
566
567		// Act
568		let result = verify_token_with_timestamp(&token_data, &secret, session_id);
569
570		// Assert
571		assert!(result.is_ok(), "Expected valid token to pass verification");
572		assert!(result.unwrap() > 0, "Expected positive timestamp");
573	}
574
575	#[rstest]
576	fn test_verify_token_with_timestamp_rejects_empty_input() {
577		// Arrange
578		let secret = test_secret();
579
580		// Act
581		let result = verify_token_with_timestamp("", &secret, "session");
582
583		// Assert
584		assert!(result.is_err());
585		assert_eq!(
586			result.unwrap_err().reason,
587			"Invalid token format (empty token)"
588		);
589	}
590
591	#[rstest]
592	#[case("no-delimiter-at-all")]
593	#[case("abcdef")]
594	fn test_verify_token_with_timestamp_rejects_missing_delimiter(#[case] input: &str) {
595		// Arrange
596		let secret = test_secret();
597
598		// Act
599		let result = verify_token_with_timestamp(input, &secret, "session");
600
601		// Assert
602		assert!(result.is_err());
603		assert_eq!(
604			result.unwrap_err().reason,
605			"Invalid token format (missing delimiter)"
606		);
607	}
608
609	#[rstest]
610	fn test_verify_token_with_timestamp_rejects_empty_token_value() {
611		// Arrange
612		let secret = test_secret();
613
614		// Act
615		let result = verify_token_with_timestamp(":12345", &secret, "session");
616
617		// Assert
618		assert!(result.is_err());
619		assert_eq!(
620			result.unwrap_err().reason,
621			"Invalid token format (empty token value)"
622		);
623	}
624
625	#[rstest]
626	fn test_verify_token_with_timestamp_rejects_empty_timestamp() {
627		// Arrange
628		let secret = test_secret();
629
630		// Act
631		let result = verify_token_with_timestamp(
632			"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2:",
633			&secret,
634			"session",
635		);
636
637		// Assert
638		assert!(result.is_err());
639		assert_eq!(
640			result.unwrap_err().reason,
641			"Invalid token format (empty timestamp)"
642		);
643	}
644
645	#[rstest]
646	#[case("short:12345")]
647	#[case("ab:12345")]
648	fn test_verify_token_with_timestamp_rejects_wrong_token_length(#[case] input: &str) {
649		// Arrange
650		let secret = test_secret();
651
652		// Act
653		let result = verify_token_with_timestamp(input, &secret, "session");
654
655		// Assert
656		assert!(result.is_err());
657		assert!(
658			result
659				.unwrap_err()
660				.reason
661				.contains("expected 64 hex characters"),
662			"Expected token length error"
663		);
664	}
665
666	#[rstest]
667	fn test_verify_token_with_timestamp_rejects_non_hex_token() {
668		// Arrange
669		let secret = test_secret();
670		// 64 characters but contains non-hex 'g' and 'z'
671		let bad_token = "g1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6z1b2";
672		let input = format!("{}:12345", bad_token);
673
674		// Act
675		let result = verify_token_with_timestamp(&input, &secret, "session");
676
677		// Assert
678		assert!(result.is_err());
679		assert_eq!(
680			result.unwrap_err().reason,
681			"Invalid token format (token contains non-hex characters)"
682		);
683	}
684
685	#[rstest]
686	#[case("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2:not_a_number")]
687	#[case("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2:-1")]
688	#[case("a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2:12.34")]
689	fn test_verify_token_with_timestamp_rejects_invalid_timestamp(#[case] input: &str) {
690		// Arrange
691		let secret = test_secret();
692
693		// Act
694		let result = verify_token_with_timestamp(input, &secret, "session");
695
696		// Assert
697		assert!(result.is_err());
698		assert_eq!(
699			result.unwrap_err().reason,
700			"Invalid token format (timestamp is not a valid number)"
701		);
702	}
703
704	#[rstest]
705	fn test_verify_token_with_timestamp_rejects_tampered_token() {
706		// Arrange
707		let secret = test_secret();
708		let session_id = "user-session-12345";
709		let token_data = generate_token_with_timestamp(&secret, session_id);
710
711		// Act - verify with a different session ID
712		let result = verify_token_with_timestamp(&token_data, &secret, "different-session");
713
714		// Assert
715		assert!(result.is_err());
716		assert_eq!(
717			result.unwrap_err().reason,
718			"CSRF token mismatch (HMAC verification failed)"
719		);
720	}
721
722	#[rstest]
723	fn test_verify_token_with_timestamp_rejects_wrong_secret() {
724		// Arrange
725		let secret = test_secret();
726		let wrong_secret = b"wrong-secret-key-at-least-32-byte".to_vec();
727		let session_id = "user-session-12345";
728		let token_data = generate_token_with_timestamp(&secret, session_id);
729
730		// Act
731		let result = verify_token_with_timestamp(&token_data, &wrong_secret, session_id);
732
733		// Assert
734		assert!(result.is_err());
735		assert_eq!(
736			result.unwrap_err().reason,
737			"CSRF token mismatch (HMAC verification failed)"
738		);
739	}
740
741	#[rstest]
742	fn test_verify_token_with_timestamp_handles_extra_colons_in_crafted_input() {
743		// Arrange
744		let secret = test_secret();
745		// Attacker crafts a token with extra colons - rsplitn ensures only the
746		// last segment is treated as timestamp
747		let input = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2:extra:12345";
748
749		// Act
750		let result = verify_token_with_timestamp(input, &secret, "session");
751
752		// Assert - rsplitn splits "...a1b2:extra" as token and "12345" as timestamp.
753		// The token portion "...a1b2:extra" has wrong length, so it is rejected.
754		assert!(result.is_err());
755	}
756
757	#[rstest]
758	fn test_should_rotate_token_normal_case() {
759		// Arrange
760		let token_timestamp = 1000u64;
761		let current_timestamp = 4700u64; // 3700 seconds later
762		let interval = 3600u64; // 1 hour
763
764		// Act
765		let result = should_rotate_token(token_timestamp, current_timestamp, Some(interval));
766
767		// Assert
768		assert_eq!(
769			result, true,
770			"Token older than interval should trigger rotation"
771		);
772	}
773
774	#[rstest]
775	fn test_should_rotate_token_future_timestamp_no_panic() {
776		// Arrange
777		let token_timestamp = 5000u64; // Future: token_timestamp > current_timestamp
778		let current_timestamp = 1000u64;
779		let interval = 3600u64;
780
781		// Act
782		let result = should_rotate_token(token_timestamp, current_timestamp, Some(interval));
783
784		// Assert
785		assert_eq!(
786			result, false,
787			"Future-dated token should not trigger rotation"
788		);
789	}
790
791	#[rstest]
792	fn test_should_rotate_token_equal_timestamps() {
793		// Arrange
794		let timestamp = 1000u64;
795		let interval = 3600u64;
796
797		// Act
798		let result = should_rotate_token(timestamp, timestamp, Some(interval));
799
800		// Assert
801		assert_eq!(
802			result, false,
803			"Equal timestamps (0 elapsed) should not trigger rotation"
804		);
805	}
806}