Skip to main content

stygian_browser/replay_defense/
mod.rs

1//! Adaptive session replay defense mode for browser identity.
2//!
3//! ## What is "replay-style" anti-bot detection?
4//!
5//! Several anti-bot vendors record a session (TLS handshake + a
6//! sequence of navigations + identity surface values) and **replay**
7//! the same artifacts later. If the same fingerprint, nonce, or
8//! challenge response shows up in two different sessions (or after a
9//! site-side rotation), the vendor flags the second session as a
10//! replay. This is distinct from
11//! [coherence drift][crate::coherence] (one session has multiple
12//! inconsistent identity surfaces) — replay detection specifically
13//! looks at **session-lifetime artifacts**:
14//!
15//! - The session **nonce** issued by a challenge / challenge-response
16//!   endpoint.
17//! - The browser **fingerprint** captured at session start.
18//! - The **age** of the session.
19//!
20//! ## How this module helps
21//!
22//! [`ReplayDefensePolicy`] captures three orthogonal levers the
23//! runner can use to defeat replay-style detection:
24//!
25//! - [`ReplayDefensePolicy::rotation_interval`] — maximum session
26//!   age before the runner must rotate the browser.
27//! - [`ReplayDefensePolicy::nonce_validity_window`] — maximum
28//!   age of a session nonce. After this window the nonce is no
29//!   longer trustworthy and the session must be reset.
30//! - [`ReplayDefensePolicy::force_reset_on_drift`] — when `true`,
31//!   signature drift (the fingerprint captured at session start
32//!   no longer matches the freshly observed one) triggers a forced
33//!   refresh of the sticky browser context.
34//!
35//! The deterministic [`check`] function evaluates a
36//! [`ReplayDefenseState`] (the per-session record) against a
37//! [`ReplayDefenseCheckInput`] (the observed context) and returns a
38//! [`ReplayDefenseDecision`]. The decision is purely derived from
39//! the inputs — no I/O, no clock reads — so unit tests can exercise
40//! the full state space.
41//!
42//! ## Integration with `AcquisitionRunner`
43//!
44//! [`AcquisitionRequest::replay_defense`][crate::acquisition::AcquisitionRequest::replay_defense]
45//! carries the live context (policy + state) into the runner. The
46//! runner evaluates the policy before any stage executes:
47//!
48//! 1. The decision is logged via [`ReplayDefenseReport::log`].
49//! 2. If the decision is invalid **and** `force_reset_on_drift` is
50//!    `true`, the runner calls
51//!    [`BrowserPool::release_context`][crate::pool::BrowserPool::release_context]
52//!    to invalidate the sticky session, then short-circuits with a
53//!    [`StageFailureKind::Setup`][crate::acquisition::StageFailureKind::Setup]
54//!    failure tagged `replay_defense_forced_refresh`.
55//! 3. The full report is attached to the
56//!    [`AcquisitionResult::replay_defense`][crate::acquisition::AcquisitionResult::replay_defense]
57//!    field so downstream automation can attribute the rejection.
58//!
59//! ## Feature flag
60//!
61//! This module is **default-on** and is always compiled as part of
62//! the `stygian-browser` crate. No new feature gate is introduced.
63//!
64//! ## Default policy
65//!
66//! [`ReplayDefensePolicy::default`] returns a deterministic
67//! baseline that is safe to ship in production:
68//!
69//! - `rotation_interval` = `1800 s` (30 min)
70//! - `nonce_validity_window` = `300 s` (5 min)
71//! - `force_reset_on_drift` = `true`
72//!
73//! Callers can override any field via the with-builder methods or by
74//! deserialising a config from JSON / TOML.
75//!
76//! # Example
77//!
78//! ```
79//! use stygian_browser::replay_defense::{
80//!     ReplayDefenseCheckInput, ReplayDefenseDecision, ReplayDefensePolicy,
81//!     ReplayDefenseState, check,
82//! };
83//! use std::time::Duration;
84//!
85//! let policy = ReplayDefensePolicy::default();
86//! let captured = stygian_browser::freshness::unix_epoch_ms();
87//! let state = ReplayDefenseState::with_fingerprint(
88//!     "example.com",
89//!     "sha256:abc",
90//!     Some("nonce-001"),
91//!     captured,
92//! );
93//!
94//! let observed = ReplayDefenseCheckInput::new(
95//!     "example.com",
96//!     Some("sha256:abc"),
97//!     Some("nonce-001"),
98//!     captured + 1_000,
99//! );
100//! let decision = check(&policy, &state, &observed);
101//! assert!(decision.is_valid());
102//! ```
103
104use std::fmt;
105use std::time::{Duration, SystemTime, UNIX_EPOCH};
106
107use serde::{Deserialize, Serialize};
108use thiserror::Error;
109
110// ─── Error type ───────────────────────────────────────────────────────────────
111
112/// Errors produced by replay-defense policy / state construction.
113#[derive(Debug, Error)]
114pub enum ReplayDefenseError {
115    /// Field carried an invalid value (e.g. zero rotation interval).
116    #[error("invalid replay defense field: {0}")]
117    InvalidField(String),
118    /// Failed to (de)serialise a replay-defense type.
119    #[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// ─── Policy ───────────────────────────────────────────────────────────────────
130
131/// Adaptive session-replay defense policy.
132///
133/// The three levers are independent and the runner combines them
134/// to decide when a session must be rotated or reset:
135///
136/// - [`rotation_interval`](Self::rotation_interval) — max age of a
137///   session before it is forcibly rotated.
138/// - [`nonce_validity_window`](Self::nonce_validity_window) — max age
139///   of the **session nonce** the challenge / challenge-response
140///   endpoint issued.
141/// - [`force_reset_on_drift`](Self::force_reset_on_drift) — whether
142///   signature drift triggers an immediate session reset.
143///
144/// # Example
145///
146/// ```
147/// use stygian_browser::replay_defense::ReplayDefensePolicy;
148/// use std::time::Duration;
149///
150/// let policy = ReplayDefensePolicy {
151///     rotation_interval: Duration::from_mins(15),
152///     ..ReplayDefensePolicy::default()
153/// };
154/// assert_eq!(policy.rotation_interval, Duration::from_mins(15));
155/// assert!(policy.force_reset_on_drift);
156/// ```
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct ReplayDefensePolicy {
159    /// Maximum age of a session before a forced rotation. The
160    /// `check` function emits [`ReplayDefenseDecision::RotationDue`]
161    /// once `elapsed >= rotation_interval`.
162    pub rotation_interval: Duration,
163    /// Maximum age of a session **nonce**. After this window the
164    /// nonce is no longer trustworthy and the session must be
165    /// re-bound to a fresh nonce. The `check` function emits
166    /// [`ReplayDefenseDecision::NonceExpired`] once
167    /// `nonce_age >= nonce_validity_window`.
168    #[serde(with = "duration_ms")]
169    pub nonce_validity_window: Duration,
170    /// When `true`, signature drift (`observed_signature !=
171    /// captured_signature`) triggers a forced refresh of the sticky
172    /// browser context. When `false`, drift is reported but the
173    /// runner continues.
174    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    /// Build a policy with explicit rotation interval and defaults
189    /// for the other fields.
190    #[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    /// Build a policy with an explicit nonce validity window and
200    /// defaults for the other fields.
201    #[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    /// Replace the rotation interval.
211    #[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    /// Replace the nonce validity window.
218    #[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    /// Replace the `force_reset_on_drift` flag.
225    #[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    /// Validate the policy. `rotation_interval` and
232    /// `nonce_validity_window` must be strictly positive.
233    ///
234    /// # Errors
235    ///
236    /// Returns [`ReplayDefenseError::InvalidField`] when either
237    /// interval is zero.
238    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// ─── State ────────────────────────────────────────────────────────────────────
254
255/// Per-session replay-defense state captured when the session was
256/// first bound.
257///
258/// The state is the **frozen record** the runner compares against
259/// the freshly observed context on every reuse. It is fully
260/// serialisable so the existing session snapshot path
261/// ([`SessionSnapshot`][crate::session::SessionSnapshot] in
262/// Browser T23) can persist and reload it across restarts.
263///
264/// # Example
265///
266/// ```
267/// use stygian_browser::replay_defense::ReplayDefenseState;
268///
269/// let captured = stygian_browser::freshness::unix_epoch_ms();
270/// let state = ReplayDefenseState::with_fingerprint(
271///     "example.com",
272///     "sha256:abc",
273///     Some("nonce-001"),
274///     captured,
275/// );
276/// assert_eq!(state.domain, "example.com");
277/// assert_eq!(state.signature.as_deref(), Some("sha256:abc"));
278/// assert_eq!(state.nonce.as_deref(), Some("nonce-001"));
279/// ```
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281pub struct ReplayDefenseState {
282    /// Lower-cased host the state was bound to.
283    pub domain: String,
284    /// Fingerprint signature captured at session start.
285    pub signature: Option<String>,
286    /// Session nonce issued at session start (when applicable).
287    pub nonce: Option<String>,
288    /// Unix epoch milliseconds when the state was captured.
289    pub captured_at_epoch_ms: u64,
290}
291
292impl ReplayDefenseState {
293    /// Build a state record.
294    #[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    /// Build a state record that captures a fingerprint + nonce for
310    /// `domain` at the current wall-clock.
311    #[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    /// Build a state record at the current wall-clock.
331    #[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// ─── Input ────────────────────────────────────────────────────────────────────
338
339/// Observed context passed to [`check`] on every session reuse.
340///
341/// Mirrors [`crate::freshness::FreshnessCheckInput`] but adds the
342/// `observed_nonce` field, which is the nonce the application
343/// currently has in flight (or `None` when the host never issued
344/// one).
345///
346/// # Example
347///
348/// ```
349/// use stygian_browser::replay_defense::ReplayDefenseCheckInput;
350///
351/// let input = ReplayDefenseCheckInput::new(
352///     "example.com",
353///     Some("sha256:abc"),
354///     Some("nonce-001"),
355///     1_700_000_000_000,
356/// );
357/// assert_eq!(input.observed_domain, "example.com");
358/// ```
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub struct ReplayDefenseCheckInput {
361    /// Lower-cased target host observed at reuse time.
362    pub observed_domain: String,
363    /// Lower-cased observed signature, when available.
364    pub observed_signature: Option<String>,
365    /// Observed session nonce, when the host has issued one.
366    pub observed_nonce: Option<String>,
367    /// Observation timestamp (Unix epoch ms).
368    pub observed_at_epoch_ms: u64,
369}
370
371impl ReplayDefenseCheckInput {
372    /// Build a check input from raw fields. Empty strings are
373    /// treated as `None`.
374    #[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    /// Build an input capturing the current wall-clock for `host`.
392    #[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// ─── Decision ─────────────────────────────────────────────────────────────────
408
409/// Structured reason a replay-defense check invalidated a session.
410///
411/// Mirrors [`crate::freshness::InvalidationReason`] but covers the
412/// replay-defense dimensions. Every field is populated regardless
413/// of which rule fired so telemetry always carries the full
414/// context.
415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
416pub struct ReplayDefenseReason {
417    /// State's bound domain (lower-case).
418    pub contract_domain: String,
419    /// Observed domain passed to [`check`].
420    pub observed_domain: String,
421    /// State's bound signature (when set).
422    pub contract_signature: Option<String>,
423    /// Observed signature passed to [`check`] (when set).
424    pub observed_signature: Option<String>,
425    /// State's bound nonce (when set).
426    pub contract_nonce: Option<String>,
427    /// Observed nonce passed to [`check`] (when set).
428    pub observed_nonce: Option<String>,
429    /// State's captured-at timestamp.
430    pub captured_at_epoch_ms: u64,
431    /// Observed timestamp passed to [`check`].
432    pub observed_at_epoch_ms: u64,
433    /// Elapsed milliseconds between capture and observation.
434    pub elapsed_ms: u64,
435    /// Policy's rotation interval in milliseconds.
436    pub rotation_interval_ms: u64,
437    /// Policy's nonce validity window in milliseconds.
438    pub nonce_validity_window_ms: u64,
439    /// Whether `force_reset_on_drift` was set on the policy.
440    pub force_reset_on_drift: bool,
441    /// Stable machine-readable reason tag.
442    pub kind: ReplayDefenseInvalidationKind,
443}
444
445/// Machine-readable reason tag attached to [`ReplayDefenseReason`].
446#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
447#[serde(rename_all = "snake_case")]
448pub enum ReplayDefenseInvalidationKind {
449    /// Elapsed since capture exceeded the rotation interval.
450    RotationDue,
451    /// Nonce age exceeded the nonce validity window.
452    NonceExpired,
453    /// Nonce was rotated by the server (state nonce != observed nonce).
454    NonceRotated,
455    /// Observed domain did not match the state's domain.
456    DomainMismatch,
457    /// Signature hash did not match the state's bound signature.
458    SignatureDrift,
459}
460
461impl ReplayDefenseInvalidationKind {
462    /// Stable string label.
463    #[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/// Decision produced by [`check`].
482///
483/// The variants are tagged by their machine-readable
484/// `outcome` field so the same JSON shape used by
485/// [`crate::freshness::FreshnessDecision`] is preserved
486/// for downstream automation.
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
488#[serde(tag = "outcome", rename_all = "snake_case")]
489pub enum ReplayDefenseDecision {
490    /// Session is still valid for the observed context.
491    Valid,
492    /// Elapsed since capture exceeded the rotation interval.
493    RotationDue {
494        /// Structured invalidation reason.
495        reason: Box<ReplayDefenseReason>,
496    },
497    /// Session nonce age exceeded the validity window.
498    NonceExpired {
499        /// Structured invalidation reason.
500        reason: Box<ReplayDefenseReason>,
501    },
502    /// Session nonce was rotated server-side.
503    NonceRotated {
504        /// Structured invalidation reason.
505        reason: Box<ReplayDefenseReason>,
506    },
507    /// Observed domain did not match the state's bound domain.
508    DomainMismatch {
509        /// Structured invalidation reason.
510        reason: Box<ReplayDefenseReason>,
511    },
512    /// Observed signature did not match the state's bound signature.
513    SignatureDrift {
514        /// Structured invalidation reason.
515        reason: Box<ReplayDefenseReason>,
516    },
517}
518
519impl ReplayDefenseDecision {
520    /// `true` when the session is still valid.
521    #[must_use]
522    pub const fn is_valid(&self) -> bool {
523        matches!(self, Self::Valid)
524    }
525
526    /// `true` when the session is invalid (any non-Valid variant).
527    #[must_use]
528    pub const fn is_invalid(&self) -> bool {
529        !self.is_valid()
530    }
531
532    /// Invalid [`ReplayDefenseReason`] when the decision is non-Valid.
533    #[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    /// Stable machine-readable label.
546    #[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    /// `true` when the policy mandates a forced refresh for this
560    /// invalid decision. Used by the
561    /// [`AcquisitionRunner`][crate::acquisition::AcquisitionRunner]
562    /// to decide whether to call
563    /// [`BrowserPool::release_context`][crate::pool::BrowserPool::release_context]
564    /// and short-circuit the run.
565    #[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        // Rotation / nonce expiry always force a fresh session.
571        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// ─── Check ────────────────────────────────────────────────────────────────────
594
595/// Evaluate `policy` + `state` against `input` and return a
596/// deterministic [`ReplayDefenseDecision`].
597///
598/// Precedence:
599///
600/// 1. Domain mismatch is checked first (cheap, structural).
601/// 2. Signature drift is checked next so a rotated signature
602///    never silently slips through on an unexpired session.
603/// 3. Nonce rotation (state nonce != observed nonce) is checked
604///    before the nonce age so a nonce that was explicitly rotated
605///    is reported distinctly from a nonce that simply aged out.
606/// 4. Nonce age (`nonce_age >= nonce_validity_window`).
607/// 5. Rotation age (`elapsed >= rotation_interval`).
608///
609/// The decision is fully determined by `(policy, state, input)` —
610/// no I/O, no clock reads.
611#[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        // Nonce present on both sides — the only meaningful "age"
625        // is the elapsed time between capture and observation.
626        (Some(_), Some(_)) => elapsed_ms,
627        // No nonce tracked → nonce age is also elapsed time, but the
628        // age check below will only fire if a nonce is in flight.
629        _ => 0,
630    };
631
632    // 1. Domain mismatch
633    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    // 2. Signature drift
654    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    // 3. Nonce rotation
677    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    // 4. Nonce age (only meaningful when a nonce is in flight)
700    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    // 5. Rotation age
721    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// ─── Report ───────────────────────────────────────────────────────────────────
745
746/// Compact replay-defense report attached to acquisition results
747/// and emitted via `tracing`.
748#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
749pub struct ReplayDefenseReport {
750    /// Resolved decision for this run.
751    pub decision: ReplayDefenseDecision,
752    /// Whether the state was considered (vs. no state supplied).
753    pub state_evaluated: bool,
754    /// Whether the runner was instructed to force a refresh.
755    pub forced_refresh: bool,
756}
757
758impl ReplayDefenseReport {
759    /// A no-state report (`Valid`, no evaluation performed, no forced refresh).
760    #[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    /// Build a report from a policy / state / input triple.
771    #[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    /// Emit a structured `tracing` event for this report.
787    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// ─── Helpers ──────────────────────────────────────────────────────────────────
828
829/// Current Unix epoch in milliseconds, clamped to `u64`.
830///
831/// Saturates to `0` if the clock is before the epoch (theoretical).
832#[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
857// serde helper: serialise Duration as integer milliseconds
858mod 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// ─── Tests ────────────────────────────────────────────────────────────────────
880
881#[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    /// Convert a [`Duration`] to a `u64` millisecond count. Saturates
894    /// to `u64::MAX` on overflow (theoretical for ms-scale inputs).
895    #[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        // 2 minutes later — past the rotation interval
946        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        // 5 seconds later — past the nonce validity window
984        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        // Without force_reset_on_drift, drift alone does not force
1069        // a refresh — the runner continues.
1070        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}