1use std::fmt;
105use std::time::{Duration, SystemTime, UNIX_EPOCH};
106
107use serde::{Deserialize, Serialize};
108use thiserror::Error;
109
110#[derive(Debug, Error)]
114pub enum ReplayDefenseError {
115 #[error("invalid replay defense field: {0}")]
117 InvalidField(String),
118 #[error("failed to (de)serialise replay defense field: {0}")]
120 Serialization(String),
121}
122
123impl From<serde_json::Error> for ReplayDefenseError {
124 fn from(err: serde_json::Error) -> Self {
125 Self::Serialization(err.to_string())
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ReplayDefensePolicy {
159 pub rotation_interval: Duration,
163 #[serde(with = "duration_ms")]
169 pub nonce_validity_window: Duration,
170 pub force_reset_on_drift: bool,
175}
176
177impl Default for ReplayDefensePolicy {
178 fn default() -> Self {
179 Self {
180 rotation_interval: Duration::from_mins(30),
181 nonce_validity_window: Duration::from_mins(5),
182 force_reset_on_drift: true,
183 }
184 }
185}
186
187impl ReplayDefensePolicy {
188 #[must_use]
191 pub const fn with_rotation_interval(rotation_interval: Duration) -> Self {
192 Self {
193 rotation_interval,
194 nonce_validity_window: Duration::from_mins(5),
195 force_reset_on_drift: true,
196 }
197 }
198
199 #[must_use]
202 pub const fn with_nonce_validity_window(nonce_validity_window: Duration) -> Self {
203 Self {
204 rotation_interval: Duration::from_mins(30),
205 nonce_validity_window,
206 force_reset_on_drift: true,
207 }
208 }
209
210 #[must_use]
212 pub const fn with_rotation(mut self, rotation_interval: Duration) -> Self {
213 self.rotation_interval = rotation_interval;
214 self
215 }
216
217 #[must_use]
219 pub const fn with_nonce_window(mut self, nonce_validity_window: Duration) -> Self {
220 self.nonce_validity_window = nonce_validity_window;
221 self
222 }
223
224 #[must_use]
226 pub const fn with_force_reset_on_drift(mut self, force: bool) -> Self {
227 self.force_reset_on_drift = force;
228 self
229 }
230
231 pub fn validate(&self) -> Result<(), ReplayDefenseError> {
239 if self.rotation_interval.is_zero() {
240 return Err(ReplayDefenseError::InvalidField(
241 "rotation_interval must be > 0".to_string(),
242 ));
243 }
244 if self.nonce_validity_window.is_zero() {
245 return Err(ReplayDefenseError::InvalidField(
246 "nonce_validity_window must be > 0".to_string(),
247 ));
248 }
249 Ok(())
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281pub struct ReplayDefenseState {
282 pub domain: String,
284 pub signature: Option<String>,
286 pub nonce: Option<String>,
288 pub captured_at_epoch_ms: u64,
290}
291
292impl ReplayDefenseState {
293 #[must_use]
295 pub fn new(
296 domain: &str,
297 signature: Option<&str>,
298 nonce: Option<&str>,
299 captured_at_epoch_ms: u64,
300 ) -> Self {
301 Self {
302 domain: domain.trim().to_ascii_lowercase(),
303 signature: signature.map(str::to_string).filter(|s| !s.is_empty()),
304 nonce: nonce.map(str::to_string).filter(|s| !s.is_empty()),
305 captured_at_epoch_ms,
306 }
307 }
308
309 #[must_use]
312 pub fn with_fingerprint(
313 domain: &str,
314 signature: &str,
315 nonce: Option<&str>,
316 captured_at_epoch_ms: u64,
317 ) -> Self {
318 Self::new(
319 domain,
320 if signature.is_empty() {
321 None
322 } else {
323 Some(signature)
324 },
325 nonce,
326 captured_at_epoch_ms,
327 )
328 }
329
330 #[must_use]
332 pub fn capture_now(domain: &str, signature: Option<&str>, nonce: Option<&str>) -> Self {
333 Self::new(domain, signature, nonce, unix_epoch_ms())
334 }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
360pub struct ReplayDefenseCheckInput {
361 pub observed_domain: String,
363 pub observed_signature: Option<String>,
365 pub observed_nonce: Option<String>,
367 pub observed_at_epoch_ms: u64,
369}
370
371impl ReplayDefenseCheckInput {
372 #[must_use]
375 pub fn new(
376 observed_domain: &str,
377 observed_signature: Option<&str>,
378 observed_nonce: Option<&str>,
379 observed_at_epoch_ms: u64,
380 ) -> Self {
381 Self {
382 observed_domain: observed_domain.trim().to_ascii_lowercase(),
383 observed_signature: observed_signature
384 .filter(|s| !s.is_empty())
385 .map(str::to_string),
386 observed_nonce: observed_nonce.filter(|s| !s.is_empty()).map(str::to_string),
387 observed_at_epoch_ms,
388 }
389 }
390
391 #[must_use]
393 pub fn capture_now(
394 observed_domain: &str,
395 observed_signature: Option<&str>,
396 observed_nonce: Option<&str>,
397 ) -> Self {
398 Self::new(
399 observed_domain,
400 observed_signature,
401 observed_nonce,
402 unix_epoch_ms(),
403 )
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416pub struct ReplayDefenseReason {
417 pub contract_domain: String,
419 pub observed_domain: String,
421 pub contract_signature: Option<String>,
423 pub observed_signature: Option<String>,
425 pub contract_nonce: Option<String>,
427 pub observed_nonce: Option<String>,
429 pub captured_at_epoch_ms: u64,
431 pub observed_at_epoch_ms: u64,
433 pub elapsed_ms: u64,
435 pub rotation_interval_ms: u64,
437 pub nonce_validity_window_ms: u64,
439 pub force_reset_on_drift: bool,
441 pub kind: ReplayDefenseInvalidationKind,
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
447#[serde(rename_all = "snake_case")]
448pub enum ReplayDefenseInvalidationKind {
449 RotationDue,
451 NonceExpired,
453 NonceRotated,
455 DomainMismatch,
457 SignatureDrift,
459}
460
461impl ReplayDefenseInvalidationKind {
462 #[must_use]
464 pub const fn as_str(self) -> &'static str {
465 match self {
466 Self::RotationDue => "rotation_due",
467 Self::NonceExpired => "nonce_expired",
468 Self::NonceRotated => "nonce_rotated",
469 Self::DomainMismatch => "domain_mismatch",
470 Self::SignatureDrift => "signature_drift",
471 }
472 }
473}
474
475impl fmt::Display for ReplayDefenseInvalidationKind {
476 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
477 f.write_str(self.as_str())
478 }
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488#[serde(tag = "outcome", rename_all = "snake_case")]
489pub enum ReplayDefenseDecision {
490 Valid,
492 RotationDue {
494 reason: Box<ReplayDefenseReason>,
496 },
497 NonceExpired {
499 reason: Box<ReplayDefenseReason>,
501 },
502 NonceRotated {
504 reason: Box<ReplayDefenseReason>,
506 },
507 DomainMismatch {
509 reason: Box<ReplayDefenseReason>,
511 },
512 SignatureDrift {
514 reason: Box<ReplayDefenseReason>,
516 },
517}
518
519impl ReplayDefenseDecision {
520 #[must_use]
522 pub const fn is_valid(&self) -> bool {
523 matches!(self, Self::Valid)
524 }
525
526 #[must_use]
528 pub const fn is_invalid(&self) -> bool {
529 !self.is_valid()
530 }
531
532 #[must_use]
534 pub fn reason(&self) -> Option<&ReplayDefenseReason> {
535 match self {
536 Self::Valid => None,
537 Self::RotationDue { reason }
538 | Self::NonceExpired { reason }
539 | Self::NonceRotated { reason }
540 | Self::DomainMismatch { reason }
541 | Self::SignatureDrift { reason } => Some(reason),
542 }
543 }
544
545 #[must_use]
547 #[allow(clippy::missing_const_for_fn)]
548 pub fn label(&self) -> &'static str {
549 match self {
550 Self::Valid => "valid",
551 Self::RotationDue { .. } => "rotation_due",
552 Self::NonceExpired { .. } => "nonce_expired",
553 Self::NonceRotated { .. } => "nonce_rotated",
554 Self::DomainMismatch { .. } => "domain_mismatch",
555 Self::SignatureDrift { .. } => "signature_drift",
556 }
557 }
558
559 #[must_use]
566 pub const fn requires_forced_refresh(&self, policy: &ReplayDefensePolicy) -> bool {
567 if policy.force_reset_on_drift && matches!(self, Self::SignatureDrift { .. }) {
568 return true;
569 }
570 matches!(
572 self,
573 Self::RotationDue { .. } | Self::NonceExpired { .. } | Self::NonceRotated { .. }
574 )
575 }
576}
577
578impl fmt::Display for ReplayDefenseDecision {
579 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
580 match self {
581 Self::Valid => f.write_str("valid"),
582 Self::RotationDue { reason }
583 | Self::NonceExpired { reason }
584 | Self::NonceRotated { reason }
585 | Self::DomainMismatch { reason }
586 | Self::SignatureDrift { reason } => {
587 write!(f, "{} ({})", self.label(), reason.kind)
588 }
589 }
590 }
591}
592
593#[must_use]
612#[allow(clippy::too_many_lines)]
613pub fn check(
614 policy: &ReplayDefensePolicy,
615 state: &ReplayDefenseState,
616 input: &ReplayDefenseCheckInput,
617) -> ReplayDefenseDecision {
618 let elapsed_ms = input
619 .observed_at_epoch_ms
620 .saturating_sub(state.captured_at_epoch_ms);
621 let rotation_interval_ms = duration_to_ms_u64(policy.rotation_interval);
622 let nonce_validity_window_ms = duration_to_ms_u64(policy.nonce_validity_window);
623 let nonce_age_ms = match (state.nonce.as_deref(), input.observed_nonce.as_deref()) {
624 (Some(_), Some(_)) => elapsed_ms,
627 _ => 0,
630 };
631
632 if state.domain != input.observed_domain {
634 return ReplayDefenseDecision::DomainMismatch {
635 reason: Box::new(ReplayDefenseReason {
636 contract_domain: state.domain.clone(),
637 observed_domain: input.observed_domain.clone(),
638 contract_signature: state.signature.clone(),
639 observed_signature: input.observed_signature.clone(),
640 contract_nonce: state.nonce.clone(),
641 observed_nonce: input.observed_nonce.clone(),
642 captured_at_epoch_ms: state.captured_at_epoch_ms,
643 observed_at_epoch_ms: input.observed_at_epoch_ms,
644 elapsed_ms,
645 rotation_interval_ms,
646 nonce_validity_window_ms,
647 force_reset_on_drift: policy.force_reset_on_drift,
648 kind: ReplayDefenseInvalidationKind::DomainMismatch,
649 }),
650 };
651 }
652
653 if let (Some(expected), Some(observed)) = (&state.signature, &input.observed_signature)
655 && expected != observed
656 {
657 return ReplayDefenseDecision::SignatureDrift {
658 reason: Box::new(ReplayDefenseReason {
659 contract_domain: state.domain.clone(),
660 observed_domain: input.observed_domain.clone(),
661 contract_signature: Some(expected.clone()),
662 observed_signature: Some(observed.clone()),
663 contract_nonce: state.nonce.clone(),
664 observed_nonce: input.observed_nonce.clone(),
665 captured_at_epoch_ms: state.captured_at_epoch_ms,
666 observed_at_epoch_ms: input.observed_at_epoch_ms,
667 elapsed_ms,
668 rotation_interval_ms,
669 nonce_validity_window_ms,
670 force_reset_on_drift: policy.force_reset_on_drift,
671 kind: ReplayDefenseInvalidationKind::SignatureDrift,
672 }),
673 };
674 }
675
676 if let (Some(contract_nonce), Some(observed_nonce)) = (&state.nonce, &input.observed_nonce)
678 && contract_nonce != observed_nonce
679 {
680 return ReplayDefenseDecision::NonceRotated {
681 reason: Box::new(ReplayDefenseReason {
682 contract_domain: state.domain.clone(),
683 observed_domain: input.observed_domain.clone(),
684 contract_signature: state.signature.clone(),
685 observed_signature: input.observed_signature.clone(),
686 contract_nonce: Some(contract_nonce.clone()),
687 observed_nonce: Some(observed_nonce.clone()),
688 captured_at_epoch_ms: state.captured_at_epoch_ms,
689 observed_at_epoch_ms: input.observed_at_epoch_ms,
690 elapsed_ms,
691 rotation_interval_ms,
692 nonce_validity_window_ms,
693 force_reset_on_drift: policy.force_reset_on_drift,
694 kind: ReplayDefenseInvalidationKind::NonceRotated,
695 }),
696 };
697 }
698
699 if state.nonce.is_some() && nonce_age_ms > nonce_validity_window_ms {
701 return ReplayDefenseDecision::NonceExpired {
702 reason: Box::new(ReplayDefenseReason {
703 contract_domain: state.domain.clone(),
704 observed_domain: input.observed_domain.clone(),
705 contract_signature: state.signature.clone(),
706 observed_signature: input.observed_signature.clone(),
707 contract_nonce: state.nonce.clone(),
708 observed_nonce: input.observed_nonce.clone(),
709 captured_at_epoch_ms: state.captured_at_epoch_ms,
710 observed_at_epoch_ms: input.observed_at_epoch_ms,
711 elapsed_ms,
712 rotation_interval_ms,
713 nonce_validity_window_ms,
714 force_reset_on_drift: policy.force_reset_on_drift,
715 kind: ReplayDefenseInvalidationKind::NonceExpired,
716 }),
717 };
718 }
719
720 if elapsed_ms > rotation_interval_ms {
722 return ReplayDefenseDecision::RotationDue {
723 reason: Box::new(ReplayDefenseReason {
724 contract_domain: state.domain.clone(),
725 observed_domain: input.observed_domain.clone(),
726 contract_signature: state.signature.clone(),
727 observed_signature: input.observed_signature.clone(),
728 contract_nonce: state.nonce.clone(),
729 observed_nonce: input.observed_nonce.clone(),
730 captured_at_epoch_ms: state.captured_at_epoch_ms,
731 observed_at_epoch_ms: input.observed_at_epoch_ms,
732 elapsed_ms,
733 rotation_interval_ms,
734 nonce_validity_window_ms,
735 force_reset_on_drift: policy.force_reset_on_drift,
736 kind: ReplayDefenseInvalidationKind::RotationDue,
737 }),
738 };
739 }
740
741 ReplayDefenseDecision::Valid
742}
743
744#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
749pub struct ReplayDefenseReport {
750 pub decision: ReplayDefenseDecision,
752 pub state_evaluated: bool,
754 pub forced_refresh: bool,
756}
757
758impl ReplayDefenseReport {
759 #[must_use]
761 #[allow(clippy::missing_const_for_fn)]
762 pub fn skipped() -> Self {
763 Self {
764 decision: ReplayDefenseDecision::Valid,
765 state_evaluated: false,
766 forced_refresh: false,
767 }
768 }
769
770 #[must_use]
772 pub fn evaluate(
773 policy: &ReplayDefensePolicy,
774 state: &ReplayDefenseState,
775 input: &ReplayDefenseCheckInput,
776 ) -> Self {
777 let decision = check(policy, state, input);
778 let forced_refresh = decision.requires_forced_refresh(policy);
779 Self {
780 decision,
781 state_evaluated: true,
782 forced_refresh,
783 }
784 }
785
786 pub fn log(&self) {
788 match &self.decision {
789 ReplayDefenseDecision::Valid => {
790 if self.state_evaluated {
791 tracing::debug!(
792 target: "stygian::replay_defense",
793 decision = self.decision.label(),
794 forced_refresh = self.forced_refresh,
795 "replay defense state is valid",
796 );
797 }
798 }
799 ReplayDefenseDecision::RotationDue { reason }
800 | ReplayDefenseDecision::NonceExpired { reason }
801 | ReplayDefenseDecision::NonceRotated { reason }
802 | ReplayDefenseDecision::DomainMismatch { reason }
803 | ReplayDefenseDecision::SignatureDrift { reason } => {
804 tracing::warn!(
805 target: "stygian::replay_defense",
806 decision = self.decision.label(),
807 invalidation_reason = reason.kind.as_str(),
808 contract_domain = %reason.contract_domain,
809 observed_domain = %reason.observed_domain,
810 contract_signature = reason.contract_signature.as_deref().unwrap_or(""),
811 observed_signature = reason.observed_signature.as_deref().unwrap_or(""),
812 contract_nonce = reason.contract_nonce.as_deref().unwrap_or(""),
813 observed_nonce = reason.observed_nonce.as_deref().unwrap_or(""),
814 captured_at_epoch_ms = reason.captured_at_epoch_ms,
815 observed_at_epoch_ms = reason.observed_at_epoch_ms,
816 elapsed_ms = reason.elapsed_ms,
817 rotation_interval_ms = reason.rotation_interval_ms,
818 nonce_validity_window_ms = reason.nonce_validity_window_ms,
819 forced_refresh = self.forced_refresh,
820 "replay defense state invalidated",
821 );
822 }
823 }
824 }
825}
826
827#[must_use]
833pub fn unix_epoch_ms() -> u64 {
834 SystemTime::now()
835 .duration_since(UNIX_EPOCH)
836 .map_or(Duration::ZERO, |d| d)
837 .as_millis()
838 .try_into()
839 .unwrap_or(u64::MAX)
840}
841
842#[must_use]
843#[allow(
844 clippy::cast_possible_truncation,
845 clippy::cast_lossless,
846 clippy::cast_sign_loss
847)]
848const fn duration_to_ms_u64(d: Duration) -> u64 {
849 let v = d.as_millis();
850 if v > u64::MAX as u128 {
851 u64::MAX
852 } else {
853 v as u64
854 }
855}
856
857mod duration_ms {
859 use serde::{Deserialize, Deserializer, Serializer};
860 use std::time::Duration;
861
862 #[allow(clippy::cast_possible_truncation)]
863 pub fn serialize<S: Serializer>(value: &Duration, ser: S) -> Result<S::Ok, S::Error> {
864 let ms = value.as_millis();
865 let n = if ms > u128::from(u64::MAX) {
866 u64::MAX
867 } else {
868 ms as u64
869 };
870 ser.serialize_u64(n)
871 }
872
873 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Duration, D::Error> {
874 let ms = u64::deserialize(de)?;
875 Ok(Duration::from_millis(ms))
876 }
877}
878
879#[cfg(test)]
882#[allow(
883 clippy::unwrap_used,
884 clippy::expect_used,
885 clippy::panic,
886 clippy::indexing_slicing
887)]
888mod tests {
889 use super::*;
890
891 const CAPTURED_AT: u64 = 1_700_000_000_000;
892
893 #[allow(
896 clippy::cast_possible_truncation,
897 clippy::cast_lossless,
898 clippy::cast_sign_loss
899 )]
900 fn duration_ms(d: Duration) -> u64 {
901 let v = d.as_millis();
902 if v > u64::MAX as u128 {
903 u64::MAX
904 } else {
905 v as u64
906 }
907 }
908
909 fn policy() -> ReplayDefensePolicy {
910 ReplayDefensePolicy {
911 rotation_interval: Duration::from_secs(1),
912 nonce_validity_window: Duration::from_secs(1),
913 force_reset_on_drift: true,
914 }
915 }
916
917 #[test]
918 fn default_policy_is_deterministic() {
919 let a = ReplayDefensePolicy::default();
920 let b = ReplayDefensePolicy::default();
921 assert_eq!(a.rotation_interval, b.rotation_interval);
922 assert_eq!(a.nonce_validity_window, b.nonce_validity_window);
923 assert_eq!(a.force_reset_on_drift, b.force_reset_on_drift);
924 assert!(a.force_reset_on_drift);
925 }
926
927 #[test]
928 fn default_policy_is_serializable() -> std::result::Result<(), Box<dyn std::error::Error>> {
929 let p = ReplayDefensePolicy::default();
930 let json = serde_json::to_string(&p)?;
931 let back: ReplayDefensePolicy = serde_json::from_str(&json)?;
932 assert_eq!(p.rotation_interval, back.rotation_interval);
933 assert_eq!(p.nonce_validity_window, back.nonce_validity_window);
934 assert_eq!(p.force_reset_on_drift, back.force_reset_on_drift);
935 Ok(())
936 }
937
938 #[test]
939 fn rotation_interval_triggers_rotation_due() {
940 let policy = ReplayDefensePolicy {
941 rotation_interval: Duration::from_mins(1),
942 ..policy()
943 };
944 let state = ReplayDefenseState::new("example.com", None, None, CAPTURED_AT);
945 let input = ReplayDefenseCheckInput::new(
947 "example.com",
948 None,
949 None,
950 CAPTURED_AT + duration_ms(Duration::from_mins(2)),
951 );
952 let decision = check(&policy, &state, &input);
953 assert!(matches!(
954 decision,
955 ReplayDefenseDecision::RotationDue { ref reason } if reason.kind == ReplayDefenseInvalidationKind::RotationDue
956 ));
957 assert!(decision.requires_forced_refresh(&policy));
958 }
959
960 #[test]
961 fn rotation_holds_within_window() {
962 let policy = ReplayDefensePolicy {
963 rotation_interval: Duration::from_mins(1),
964 ..policy()
965 };
966 let state = ReplayDefenseState::new("example.com", None, None, CAPTURED_AT);
967 let input = ReplayDefenseCheckInput::new(
968 "example.com",
969 None,
970 None,
971 CAPTURED_AT + duration_ms(Duration::from_secs(30)),
972 );
973 assert!(check(&policy, &state, &input).is_valid());
974 }
975
976 #[test]
977 fn nonce_window_expires_nonce() {
978 let policy = ReplayDefensePolicy {
979 nonce_validity_window: Duration::from_secs(1),
980 ..policy()
981 };
982 let state = ReplayDefenseState::new("example.com", None, Some("nonce-001"), CAPTURED_AT);
983 let input = ReplayDefenseCheckInput::new(
985 "example.com",
986 None,
987 Some("nonce-001"),
988 CAPTURED_AT + duration_ms(Duration::from_secs(5)),
989 );
990 let decision = check(&policy, &state, &input);
991 match &decision {
992 ReplayDefenseDecision::NonceExpired { reason } => {
993 assert_eq!(reason.kind, ReplayDefenseInvalidationKind::NonceExpired);
994 assert_eq!(reason.contract_nonce.as_deref(), Some("nonce-001"));
995 }
996 other => panic!("expected NonceExpired, got {other:?}"),
997 }
998 assert!(decision.requires_forced_refresh(&policy));
999 }
1000
1001 #[test]
1002 fn nonce_rotation_emits_nonce_rotated() {
1003 let policy = policy();
1004 let state = ReplayDefenseState::new("example.com", None, Some("nonce-001"), CAPTURED_AT);
1005 let input = ReplayDefenseCheckInput::new(
1006 "example.com",
1007 None,
1008 Some("nonce-002"),
1009 CAPTURED_AT + duration_ms(Duration::from_secs(1)),
1010 );
1011 let decision = check(&policy, &state, &input);
1012 match decision {
1013 ReplayDefenseDecision::NonceRotated { reason } => {
1014 assert_eq!(reason.kind, ReplayDefenseInvalidationKind::NonceRotated);
1015 assert_eq!(reason.contract_nonce.as_deref(), Some("nonce-001"));
1016 assert_eq!(reason.observed_nonce.as_deref(), Some("nonce-002"));
1017 }
1018 other => panic!("expected NonceRotated, got {other:?}"),
1019 }
1020 }
1021
1022 #[test]
1023 fn signature_drift_with_force_reset_requires_refresh() {
1024 let policy = ReplayDefensePolicy {
1025 force_reset_on_drift: true,
1026 ..policy()
1027 };
1028 let state =
1029 ReplayDefenseState::with_fingerprint("example.com", "sha256:abc", None, CAPTURED_AT);
1030 let input = ReplayDefenseCheckInput::new(
1031 "example.com",
1032 Some("sha256:xyz"),
1033 None,
1034 CAPTURED_AT + 1_000,
1035 );
1036 let decision = check(&policy, &state, &input);
1037 match &decision {
1038 ReplayDefenseDecision::SignatureDrift { reason } => {
1039 assert_eq!(reason.kind, ReplayDefenseInvalidationKind::SignatureDrift);
1040 assert_eq!(reason.contract_signature.as_deref(), Some("sha256:abc"));
1041 assert_eq!(reason.observed_signature.as_deref(), Some("sha256:xyz"));
1042 assert!(reason.force_reset_on_drift);
1043 }
1044 other => panic!("expected SignatureDrift, got {other:?}"),
1045 }
1046 assert!(decision.requires_forced_refresh(&policy));
1047 }
1048
1049 #[test]
1050 fn signature_drift_without_force_reset_does_not_require_refresh() {
1051 let policy = ReplayDefensePolicy {
1052 force_reset_on_drift: false,
1053 ..policy()
1054 };
1055 let state =
1056 ReplayDefenseState::with_fingerprint("example.com", "sha256:abc", None, CAPTURED_AT);
1057 let input = ReplayDefenseCheckInput::new(
1058 "example.com",
1059 Some("sha256:xyz"),
1060 None,
1061 CAPTURED_AT + 1_000,
1062 );
1063 let decision = check(&policy, &state, &input);
1064 assert!(matches!(
1065 decision,
1066 ReplayDefenseDecision::SignatureDrift { .. }
1067 ));
1068 assert!(!decision.requires_forced_refresh(&policy));
1071 }
1072
1073 #[test]
1074 fn domain_mismatch_takes_precedence_over_other_checks() {
1075 let policy = policy();
1076 let state = ReplayDefenseState::with_fingerprint(
1077 "example.com",
1078 "sha256:abc",
1079 Some("nonce-001"),
1080 CAPTURED_AT,
1081 );
1082 let input = ReplayDefenseCheckInput::new(
1083 "other.example",
1084 Some("sha256:abc"),
1085 Some("nonce-001"),
1086 CAPTURED_AT + 1_000,
1087 );
1088 let decision = check(&policy, &state, &input);
1089 match decision {
1090 ReplayDefenseDecision::DomainMismatch { reason } => {
1091 assert_eq!(reason.kind, ReplayDefenseInvalidationKind::DomainMismatch);
1092 assert_eq!(reason.contract_domain, "example.com");
1093 assert_eq!(reason.observed_domain, "other.example");
1094 }
1095 other => panic!("expected DomainMismatch, got {other:?}"),
1096 }
1097 }
1098
1099 #[test]
1100 fn determinism_same_inputs_same_decision() {
1101 let policy = policy();
1102 let state = ReplayDefenseState::with_fingerprint(
1103 "example.com",
1104 "sha256:abc",
1105 Some("nonce-001"),
1106 CAPTURED_AT,
1107 );
1108 let input = ReplayDefenseCheckInput::new(
1109 "example.com",
1110 Some("sha256:abc"),
1111 Some("nonce-001"),
1112 CAPTURED_AT + 30_000,
1113 );
1114 assert_eq!(
1115 check(&policy, &state, &input),
1116 check(&policy, &state, &input)
1117 );
1118 }
1119
1120 #[test]
1121 fn empty_signature_and_nonce_stays_valid() {
1122 let policy = policy();
1123 let state = ReplayDefenseState::new("example.com", None, None, CAPTURED_AT);
1124 let input = ReplayDefenseCheckInput::new("example.com", None, None, CAPTURED_AT + 1_000);
1125 assert!(check(&policy, &state, &input).is_valid());
1126 }
1127
1128 #[test]
1129 fn decision_labels_are_stable() {
1130 assert_eq!(ReplayDefenseDecision::Valid.label(), "valid");
1131 assert_eq!(
1132 ReplayDefenseInvalidationKind::RotationDue.as_str(),
1133 "rotation_due"
1134 );
1135 assert_eq!(
1136 ReplayDefenseInvalidationKind::NonceExpired.as_str(),
1137 "nonce_expired"
1138 );
1139 assert_eq!(
1140 ReplayDefenseInvalidationKind::NonceRotated.as_str(),
1141 "nonce_rotated"
1142 );
1143 assert_eq!(
1144 ReplayDefenseInvalidationKind::DomainMismatch.as_str(),
1145 "domain_mismatch"
1146 );
1147 assert_eq!(
1148 ReplayDefenseInvalidationKind::SignatureDrift.as_str(),
1149 "signature_drift"
1150 );
1151 }
1152
1153 #[test]
1154 fn skipped_report_is_valid_and_does_not_force_refresh() {
1155 let report = ReplayDefenseReport::skipped();
1156 assert!(report.decision.is_valid());
1157 assert!(!report.state_evaluated);
1158 assert!(!report.forced_refresh);
1159 }
1160
1161 #[test]
1162 fn evaluate_report_attaches_forced_refresh_flag() {
1163 let policy = policy();
1164 let state = ReplayDefenseState::new("example.com", None, None, CAPTURED_AT);
1165 let input = ReplayDefenseCheckInput::new(
1166 "example.com",
1167 None,
1168 None,
1169 CAPTURED_AT + duration_ms(Duration::from_secs(2)),
1170 );
1171 let report = ReplayDefenseReport::evaluate(&policy, &state, &input);
1172 assert!(report.state_evaluated);
1173 assert!(report.decision.is_invalid());
1174 assert!(report.forced_refresh);
1175 }
1176
1177 #[test]
1178 fn validate_rejects_zero_intervals() {
1179 let zero_rotation = ReplayDefensePolicy {
1180 rotation_interval: Duration::ZERO,
1181 ..ReplayDefensePolicy::default()
1182 };
1183 assert!(zero_rotation.validate().is_err());
1184 let zero_nonce = ReplayDefensePolicy {
1185 nonce_validity_window: Duration::ZERO,
1186 ..ReplayDefensePolicy::default()
1187 };
1188 assert!(zero_nonce.validate().is_err());
1189 assert!(ReplayDefensePolicy::default().validate().is_ok());
1190 }
1191
1192 #[test]
1193 fn state_trims_and_lowercases_domain() {
1194 let s = ReplayDefenseState::new(" EXAMPLE.com ", Some("sha256:a"), None, 0);
1195 assert_eq!(s.domain, "example.com");
1196 assert_eq!(s.signature.as_deref(), Some("sha256:a"));
1197 }
1198
1199 #[test]
1200 fn state_drops_empty_signature_and_nonce() {
1201 let s = ReplayDefenseState::new("example.com", Some(""), Some(""), 0);
1202 assert!(s.signature.is_none());
1203 assert!(s.nonce.is_none());
1204 }
1205
1206 #[test]
1207 fn input_trims_and_lowercases_domain() {
1208 let i = ReplayDefenseCheckInput::new(" Example.COM ", Some("sha256:a"), Some("n1"), 0);
1209 assert_eq!(i.observed_domain, "example.com");
1210 assert_eq!(i.observed_signature.as_deref(), Some("sha256:a"));
1211 assert_eq!(i.observed_nonce.as_deref(), Some("n1"));
1212 }
1213
1214 #[test]
1215 fn json_roundtrip_preserves_policy() -> std::result::Result<(), Box<dyn std::error::Error>> {
1216 let p = ReplayDefensePolicy::default();
1217 let json = serde_json::to_string(&p)?;
1218 let back: ReplayDefensePolicy = serde_json::from_str(&json)?;
1219 assert_eq!(p.rotation_interval, back.rotation_interval);
1220 assert_eq!(p.nonce_validity_window, back.nonce_validity_window);
1221 assert_eq!(p.force_reset_on_drift, back.force_reset_on_drift);
1222 Ok(())
1223 }
1224
1225 #[test]
1226 fn json_roundtrip_preserves_decision() -> std::result::Result<(), Box<dyn std::error::Error>> {
1227 let policy = policy();
1228 let state = ReplayDefenseState::new("example.com", None, None, CAPTURED_AT);
1229 let input = ReplayDefenseCheckInput::new(
1230 "example.com",
1231 None,
1232 None,
1233 CAPTURED_AT + duration_ms(Duration::from_secs(5)),
1234 );
1235 let decision = check(&policy, &state, &input);
1236 let json = serde_json::to_string(&decision)?;
1237 let back: ReplayDefenseDecision = serde_json::from_str(&json)?;
1238 assert_eq!(decision, back);
1239 Ok(())
1240 }
1241}