use crate::detection::{analytic_pmd, detection_boundary, monte_carlo_pfa_pmd};
use crate::run::PHASE_MEAS_VAR_S2;
use crate::scenario::{ClockCfg, TimeCfg};
use crate::security::{min_detectable_offset_ns, monitor_sigma_s, SPOOF_DETECT_K, SPOOF_MONITOR_S};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::f64::consts::TAU;
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SpoofShape {
LinearRamp { rate_ns_per_s: f64 },
StepJump { magnitude_ns: f64 },
Meaconing { delay_ns: f64, oscillation_hz: f64 },
Replay { capture_offset_s: f64 },
}
impl SpoofShape {
pub fn offset_ns(&self, tau: f64) -> f64 {
match *self {
SpoofShape::LinearRamp { rate_ns_per_s } => rate_ns_per_s * tau,
SpoofShape::StepJump { magnitude_ns } => magnitude_ns,
SpoofShape::Meaconing {
delay_ns,
oscillation_hz,
} => delay_ns * (1.0 + (TAU * oscillation_hz * tau).sin()),
SpoofShape::Replay { capture_offset_s } => capture_offset_s * 1e9,
}
}
}
fn default_target_pfa() -> f64 {
0.01
}
fn default_mc_runs() -> usize {
10_000
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct AttackCfg {
pub start_s: f64,
#[serde(default)]
pub rate_ns_per_s: Option<f64>,
#[serde(default)]
pub shape: Option<SpoofShape>,
#[serde(default = "default_target_pfa")]
pub target_pfa: f64,
#[serde(default = "default_mc_runs")]
pub mc_runs: usize,
}
impl AttackCfg {
pub fn resolved_shape(&self) -> SpoofShape {
self.shape.clone().unwrap_or(SpoofShape::LinearRamp {
rate_ns_per_s: self.rate_ns_per_s.unwrap_or(0.0),
})
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct SpoofScenario {
pub threshold_ns: f64,
pub time: TimeCfg,
pub attack: AttackCfg,
pub clock_quantum: ClockCfg,
pub clock_classical: ClockCfg,
}
#[derive(Clone, Copy, Debug, Serialize)]
pub struct SpoofSample {
pub t: f64,
pub offset_ns: f64,
pub bound_ns: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct SpoofDetectionStats {
pub monitor_sigma_ns: f64,
pub target_pfa: f64,
pub boundary_ns: f64,
pub eval_offset_ns: f64,
pub analytic_pmd: f64,
pub mc_pfa: f64,
pub mc_pmd: f64,
pub mc_runs: usize,
}
#[derive(Clone, Debug, Serialize)]
pub struct SpoofClock {
pub id: String,
pub min_detectable_ns: f64,
pub detect_time_s: Option<f64>,
pub offset_at_detection_ns: Option<f64>,
pub breaches_spec_undetected: bool,
pub security_fom: f64,
pub detection: SpoofDetectionStats,
pub series: Vec<SpoofSample>,
}
#[derive(Clone, Debug, Serialize)]
pub struct SpoofResult {
pub schema_version: String,
pub engine_version: String,
pub scenario_hash: String,
pub threshold_ns: f64,
pub quantum: SpoofClock,
pub classical: SpoofClock,
}
fn mc_seed(id: &str) -> u64 {
id.bytes().fold(0xC0FF_EE15_u64, |a, b| {
a.wrapping_mul(131).wrapping_add(b as u64)
})
}
fn run_clock(scn: &SpoofScenario, cfg: &ClockCfg) -> SpoofClock {
let dt = scn.time.step_s;
let n = (scn.time.duration_s / dt).round() as usize;
let samples = if dt > 0.0 {
(SPOOF_MONITOR_S / dt).round()
} else {
1.0
};
let bound_ns = min_detectable_offset_ns(
cfg.q_wf,
cfg.q_rw,
PHASE_MEAS_VAR_S2,
SPOOF_MONITOR_S,
samples,
SPOOF_DETECT_K,
);
let sigma_s = monitor_sigma_s(
cfg.q_wf,
cfg.q_rw,
PHASE_MEAS_VAR_S2,
SPOOF_MONITOR_S,
samples,
);
let target_pfa = scn.attack.target_pfa;
let gamma_s = detection_boundary(sigma_s, target_pfa);
let eval_offset_s = scn.threshold_ns * 1e-9;
let pmd = analytic_pmd(eval_offset_s, sigma_s, gamma_s);
let (mc_pfa, mc_pmd) = monte_carlo_pfa_pmd(
eval_offset_s,
sigma_s,
gamma_s,
scn.attack.mc_runs,
mc_seed(&cfg.id),
);
let detection = SpoofDetectionStats {
monitor_sigma_ns: sigma_s * 1e9,
target_pfa,
boundary_ns: gamma_s * 1e9,
eval_offset_ns: scn.threshold_ns,
analytic_pmd: pmd,
mc_pfa,
mc_pmd,
mc_runs: scn.attack.mc_runs.max(1),
};
let shape = scn.attack.resolved_shape();
let offset_at = |t: f64| {
if t >= scn.attack.start_s {
shape.offset_ns(t - scn.attack.start_s)
} else {
0.0
}
};
let mut series = Vec::with_capacity(n + 1);
let mut detect_time_s = None;
for i in 0..=n {
let t = i as f64 * dt;
let offset_ns = offset_at(t);
if detect_time_s.is_none() && t >= scn.attack.start_s && offset_ns.abs() > bound_ns {
detect_time_s = Some(t);
}
series.push(SpoofSample {
t,
offset_ns,
bound_ns,
});
}
SpoofClock {
id: cfg.id.clone(),
min_detectable_ns: bound_ns,
detect_time_s,
offset_at_detection_ns: detect_time_s.map(offset_at),
breaches_spec_undetected: bound_ns >= scn.threshold_ns,
security_fom: 1.0 - pmd,
detection,
series,
}
}
fn hash_spoof(scn: &SpoofScenario) -> String {
let c = serde_json::to_string(scn).expect("scenario serializes");
let mut h = Sha256::new();
h.update(c.as_bytes());
hex::encode(h.finalize())
}
pub fn run_spoof(scn: &SpoofScenario) -> SpoofResult {
SpoofResult {
schema_version: "0.7".into(),
engine_version: env!("CARGO_PKG_VERSION").into(),
scenario_hash: hash_spoof(scn),
threshold_ns: scn.threshold_ns,
quantum: run_clock(scn, &scn.clock_quantum),
classical: run_clock(scn, &scn.clock_classical),
}
}
pub fn to_svg(result: &SpoofResult) -> String {
let (w, h) = (820.0_f64, 420.0_f64);
let (ml, mr, mt, mb) = (70.0_f64, 20.0_f64, 30.0_f64, 50.0_f64);
let pw = w - ml - mr;
let ph = h - mt - mb;
let series = &result.quantum.series; let t_max = series.iter().map(|s| s.t).fold(1.0_f64, f64::max);
let offset_end = series.last().map_or(0.0, |s| s.offset_ns);
let mut y_max = result.threshold_ns;
y_max = y_max
.max(offset_end)
.max(result.classical.min_detectable_ns)
.max(result.quantum.min_detectable_ns)
* 1.2;
if y_max <= 0.0 {
y_max = 1.0;
}
let xof = |t: f64| ml + (t / t_max) * pw;
let yof = |v: f64| mt + ph - (v.min(y_max) / y_max) * ph;
let ramp = series
.iter()
.map(|s| format!("{:.1},{:.1}", xof(s.t), yof(s.offset_ns)))
.collect::<Vec<_>>()
.join(" ");
let hline = |y_ns: f64| format!("{:.1}", yof(y_ns));
let axis_y = mt + ph;
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.0}\" height=\"{h:.0}\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#cdd6e0\">"
));
svg.push_str(&format!(
"<rect width=\"{w:.0}\" height=\"{h:.0}\" fill=\"#0e131b\"/>"
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"18\" font-size=\"15\" font-weight=\"bold\">Time-spoof detection: offset vs clock-aided detection bounds</text>",
ml
));
svg.push_str(&crate::chart::y_axis(
ml,
mt,
pw,
ph,
y_max,
"spoof offset (ns)",
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{mt:.0}\" x2=\"{ml:.0}\" y2=\"{axis_y:.0}\" stroke=\"#3a4757\"/>"
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{axis_y:.0}\" x2=\"{:.0}\" y2=\"{axis_y:.0}\" stroke=\"#3a4757\"/>",
ml + pw
));
let right = ml + pw;
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{0}\" x2=\"{right:.0}\" y2=\"{0}\" stroke=\"#d33\" stroke-dasharray=\"6 4\"/>",
hline(result.threshold_ns)
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.1}\" fill=\"#d33\">spec {:.0} ns</text>",
ml + 4.0,
yof(result.threshold_ns) - 4.0,
result.threshold_ns
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{0}\" x2=\"{right:.0}\" y2=\"{0}\" stroke=\"#5cb8d6\" stroke-dasharray=\"3 3\"/>",
hline(result.quantum.min_detectable_ns)
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{0}\" x2=\"{right:.0}\" y2=\"{0}\" stroke=\"#c0392b\" stroke-dasharray=\"3 3\"/>",
hline(result.classical.min_detectable_ns)
));
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"#3a4757\" stroke-width=\"2\" points=\"{ramp}\"/>"
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"{:.0}\" text-anchor=\"middle\">time (s)</text>",
ml + pw / 2.0,
h - 12.0
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"44\" fill=\"#8593a3\">spoof offset</text>",
ml + 10.0
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"60\" fill=\"#5cb8d6\">quantum detect bound</text>",
ml + 10.0
));
svg.push_str(&format!(
"<text x=\"{:.0}\" y=\"76\" fill=\"#c0392b\">classical detect bound</text>",
ml + 10.0
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
fn scenario() -> SpoofScenario {
toml::from_str(include_str!("../scenarios/spoof-attack.toml"))
.expect("spoof scenario parses")
}
#[test]
fn quantum_detects_before_harm_classical_does_not() {
let r = run_spoof(&scenario());
assert!(!r.quantum.breaches_spec_undetected);
assert!(r.quantum.detect_time_s.is_some());
assert!(r.quantum.offset_at_detection_ns.unwrap() < r.threshold_ns);
assert!(r.classical.breaches_spec_undetected);
assert!(r.classical.min_detectable_ns >= r.threshold_ns);
}
#[test]
fn detection_time_is_hand_derived() {
let r = run_spoof(&scenario());
let q = &r.quantum;
let scn = scenario();
let rate = match scn.attack.resolved_shape() {
SpoofShape::LinearRamp { rate_ns_per_s } => rate_ns_per_s,
_ => unreachable!("the legacy spoof scenario is a linear ramp"),
};
let analytic = scn.attack.start_s + q.min_detectable_ns / rate;
let dt = scn.time.step_s;
assert!((q.detect_time_s.unwrap() - analytic).abs() <= dt + 1e-9);
}
#[test]
fn is_reproducible() {
let a = run_spoof(&scenario());
let b = run_spoof(&scenario());
assert_eq!(a.quantum.detect_time_s, b.quantum.detect_time_s);
assert_eq!(a.classical.min_detectable_ns, b.classical.min_detectable_ns);
assert_eq!(a.quantum.detection.mc_pmd, b.quantum.detection.mc_pmd);
}
#[test]
fn spoof_shapes_produce_the_right_offset_trajectories() {
let ramp = SpoofShape::LinearRamp {
rate_ns_per_s: 10.0,
};
assert!((ramp.offset_ns(60.0) - 600.0).abs() < 1e-9);
let step = SpoofShape::StepJump { magnitude_ns: 50.0 };
assert_eq!(step.offset_ns(0.0), 50.0);
assert_eq!(step.offset_ns(100.0), 50.0);
let mea = SpoofShape::Meaconing {
delay_ns: 30.0,
oscillation_hz: 0.25,
};
assert!((mea.offset_ns(0.0) - 30.0).abs() < 1e-9);
assert!((mea.offset_ns(1.0) - 60.0).abs() < 1e-9); let rep = SpoofShape::Replay {
capture_offset_s: 2e-6,
};
assert!((rep.offset_ns(5.0) - 2000.0).abs() < 1e-9);
}
fn cn0_scenario(threshold_ns: f64, q_wf_csac: f64, q_wf_optical: f64) -> SpoofScenario {
let clk = |id: &str, q_wf: f64| ClockCfg {
id: id.into(),
provenance: "test".into(),
y0: 0.0,
q_wf,
q_rw: 0.0,
drift: 0.0,
flicker_floor: 0.0,
};
SpoofScenario {
threshold_ns,
time: TimeCfg {
step_s: 1.0,
duration_s: 600.0,
},
attack: AttackCfg {
start_s: 0.0,
rate_ns_per_s: Some(10.0),
shape: None,
target_pfa: 0.01,
mc_runs: 40_000,
},
clock_quantum: clk("optical", q_wf_optical),
clock_classical: clk("csac", q_wf_csac),
}
}
#[test]
fn monte_carlo_pmd_tracks_the_analytic_optimum_and_separates_the_clocks() {
let q_wf_csac = 1e-22;
let samples = 600.0_f64; let sigma_csac = monitor_sigma_s(q_wf_csac, 0.0, PHASE_MEAS_VAR_S2, 600.0, samples);
let threshold_ns = 2.0 * sigma_csac * 1e9; let scn = cn0_scenario(threshold_ns, q_wf_csac, 1e-26);
let r = run_spoof(&scn);
let csac = &r.classical.detection;
assert!(
(csac.analytic_pmd - 0.717_67).abs() < 1e-2,
"analytic P_md should be ~0.7177, got {}",
csac.analytic_pmd
);
assert!(
(csac.mc_pmd - csac.analytic_pmd).abs() / csac.analytic_pmd < 0.05,
"MC P_md {} should be within 5% of analytic {}",
csac.mc_pmd,
csac.analytic_pmd
);
assert!((csac.mc_pfa - 0.01).abs() < 0.005, "mc_pfa={}", csac.mc_pfa);
let opt = &r.quantum.detection;
assert!(
opt.analytic_pmd < 0.01,
"optical analytic P_md={}",
opt.analytic_pmd
);
assert!(r.quantum.security_fom > 0.99);
assert!(
r.quantum.security_fom > r.classical.security_fom,
"optical {} should out-score CSAC {}",
r.quantum.security_fom,
r.classical.security_fom
);
assert!((r.classical.security_fom - (1.0 - csac.analytic_pmd)).abs() < 1e-12);
}
}