From 60f09fcf5d715e011f1aca438ce9e207ac6cae02 Mon Sep 17 00:00:00 2001 From: RayRose Date: Sun, 16 Aug 2026 21:46:26 -0500 Subject: [PATCH] Detect the takeover moment, not just its consequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every existing lane is residual-based, so each needs the spoofer to have already moved us. `docs/threats.md` states the consequence as the velocity-aiding lane's accepted bounds: a walk-off under ~0.5 m/s sits below the free-inertial floor, and a slow enough ramp-on gets tracked by the adaptive floor. Both bounds describe the same window — a competent spoofer captures the receiver ALIGNED and only then walks off, and while it is aligned the residual is ~0. There is nothing there to see. The takeover itself is not free, though. Capturing a receiver means out-powering the live signals and handing it a SIMULATED constellation, and that is rarely a byte-perfect continuation of the real sky: the satellite count steps and the DOP steps with it — often DOWNWARD, because synthesized geometry is cleaner than anything a real sky with real obstructions produces. So this lane watches the metadata instead of the position. EWMA baselines of sat count and HDOP track the sky we have been flying under; a step away from it (>=4 sats or >=0.8 HDOP), persisting >=2 fixes, fires `ConstellationShift`. An abrupt HDOP IMPROVEMENT counts as much as a degradation. The baseline learner freezes while a deviation is in progress — the same idiom the drift lane uses on its noise floor — so it cannot chase the step and erase the signal. Two things this is NOT, both load-bearing: It is not coverage. These are fields the attacker's own signal generator produces, so an adversary who holds them steady across the handover is invisible here. What it buys is cost: a clean takeover now needs the victim's current constellation modelled well enough to fake continuity into it, not just more power. It is corroboration, and the threat model says so. It cannot sever GPS. The FSM counts an external anomaly as a detector firing, so anything that fires CONTINUOUSLY across the spoofed dwell can reach Spoofed and cut the GPS. A metadata step is corroboration on data that is, at that instant, still good — severing there would be a self-inflicted DoS. So the detector is one-shot per step: it re-baselines onto the new level the moment it reports, and a 30 s refractory bounds what an oscillating receiver (or attacker) can contribute. It raises Suspicious; a residual lane has to confirm before anything is cut. `ConstellationSwap` sim pattern added to isolate the case: metadata steps, position and velocity stay honest. The e2e test asserts all three properties — the shift is seen exactly once, the residual lanes stay silent (proving orthogonality rather than redundancy), and sever/RTL counts are zero. 10 unit tests + 2 e2e. 192 lib tests, all 15 suites green, including the 8 false-positive characterizations. Co-Authored-By: Claude Opus 5 --- docs/threats.md | 1 + src/detect/mod.rs | 15 + src/detect/quality.rs | 466 ++++++++++++++++++++++++++ src/fusion.rs | 54 +++ src/sim/spoof.rs | 35 ++ src/types.rs | 9 + tests/scenario_constellation_shift.rs | 101 ++++++ 7 files changed, 681 insertions(+) create mode 100644 src/detect/quality.rs create mode 100644 tests/scenario_constellation_shift.rs diff --git a/docs/threats.md b/docs/threats.md index fee7c77..b201d25 100644 --- a/docs/threats.md +++ b/docs/threats.md @@ -74,6 +74,7 @@ follow-up issue. | **Velocity-mismatch teleport** — GPS reports velocity inconsistent with IMU integration | Two-of-two persistence: `|v_gps − v_imu| > 15 m/s` sustained ≥2 consecutive fixes | `src/detect/jump.rs:46-55, 41` | `vmismatch_requires_persistence` | Attacker who can keep `|Δv|` below 15 m/s wins this single test, falls through to CUSUM and hard-residual layers | | **Slow drift (naive)** — GPS walks off course at sub-jump speed, reported Doppler left honest | Per-axis two-sided CUSUM with `k=1.0 m`, `h=25 m`. Sums accumulate any per-fix residual above noise floor until threshold | `src/detect/drift.rs:79-98` | `persistent_north_drift_fires`, `tests/scenario_drift.rs` | If attacker pins `|r|` to *exactly* `k=1.0 m`, per-axis accumulators stagnate (B-01 risk); magnitude CUSUM is the backup | | **Consistent-velocity walk-off ("smart" / EKF-laundered)** — GPS position ramps off course AND the reported Doppler is faked to match, so the complementary velocity blend tracks it and the position + velocity-mismatch lanes are driven to ~0 (the RQ-170 class; also the param-mode SITL case where ArduPilot's EKF has fused a slow ramp). Found by the SITL Phase-2 characterization: evaded the detector entirely below 2 m/s | Velocity-aiding CUSUM over the FREE-INERTIAL velocity residual `mag_vel_free = \|v_gps − v_free_inertial\|`, where `v_free_inertial = v_blended − Σ(blend corrections)` is reconstructed GPS-velocity-INDEPENDENT (so it retains the masked bias ≈ the spoof rate). Adaptive floor learned only from clearly-quiet (`< base k`) non-maneuvering fixes; base `k=0.55 m/s`, `h=8`. SUSPENDED while maneuvering (gyro-gated) — a coordinated turn makes the free-inertial velocity diverge ~2 m/s of legitimate attitude/centripetal error | `src/detect/drift.rs` (`s_vel_aiding`, `vel_aiding_*`), `src/nav/mod.rs` (`aiding_vel`, `is_maneuvering`) | `velocity_aiding_fires_on_sustained_masked_bias`, `velocity_aiding_suspended_while_maneuvering`, `velocity_aiding_no_false_alarm_on_realistic_doppler_noise`, `tests/scenario_consistent_drift.rs` (caught ≥1 m/s; clean 600 s + sustained turn no false-latch) | Bounds (all 🟡, by design — each trades detection of a vanishingly-slow attack for zero false alarms on honest dynamic flight): (1) a walk-off below ~0.5 m/s sits at/under the free-inertial velocity-bias floor — undetected; (2) a walk-off confined to turns is unobservable (lane suspended while maneuvering); (3) a spoofer who ramps the bias on over many minutes can be tracked by the adaptive floor | +| **Receiver takeover / constellation capture** — the moment a spoofer out-powers the live signals and hands the receiver a SIMULATED constellation. Every other lane in this table is residual-based and therefore structurally blind here: a competent spoofer captures ALIGNED (first spoofed fix ≈ truth) and only then walks off, so at takeover the residual is ~0 and there is genuinely nothing for them to see. This is the window the velocity-aiding row's bounds (1) and (3) live in | Constellation-quality discontinuity lane: EWMA baselines of satellite count and HDOP track "the sky we have been flying under"; a step of ≥4 sats or ≥0.8 HDOP away from that baseline, persisting ≥2 fixes, fires `ConstellationShift` and registers an external anomaly with the FSM. An abrupt HDOP **improvement** counts as much as a degradation — synthesized geometry is cleaner than a real sky. The baseline learner FREEZES while a deviation is in progress (same idiom as the drift lane's noise floor) so it cannot chase the step and erase the signal | `src/detect/quality.rs`, `src/fusion.rs` (call site) | `satellite_count_step_is_reported_once_then_the_lane_goes_quiet`, `suspiciously_perfect_geometry_is_reported`, `simultaneous_steps_report_as_both`, `slow_natural_drift_is_absorbed_not_reported`, `single_fix_glitch_is_ignored`, `oscillating_metadata_cannot_sustain_the_dwell`, `warmup_suppresses_reports_while_the_baseline_is_forming`, `absent_quality_data_is_not_a_discontinuity`, `steady_constellation_never_reports`, `disabled_lane_is_silent`; end-to-end `tests/scenario_constellation_shift.rs` | 🟡 **Evadable by construction, and must never be described as coverage.** These are fields the attacker's own signal generator produces, so an adversary who holds sat count and HDOP steady across the handover is invisible to this lane. What it buys is cost: a clean takeover now requires modelling the victim's *current* constellation well enough to fake continuity into it, not merely out-powering the sky. It is **corroboration, not proof** — and it is built so it *cannot* sever GPS alone: it re-baselines onto the new level the instant it reports (one-shot per step) and is rate-limited by a 30 s refractory, so it can raise Suspicious but can never sustain a firing across the spoofed dwell. Severing requires a residual lane to confirm. Also blind to **meaconing** (rebroadcast preserves the real constellation) — covered instead by the boot anchor and the rebroadcast-delay position jump | | **Circular / spiral drift** — per-axis sums oscillate around zero so signed CUSUM misses it | Magnitude CUSUM: one-sided sum over `|r|`, with an ADAPTIVE reference — the effective `k_mag` is `max(base, learned_noise_floor × 1.5)`. The noise floor is an online running-mean (warmup) then quiescence-gated EWMA of `|r|`, so the detector adapts to the actual GPS noise level instead of a fixed constant | `src/detect/drift.rs` (`mag_noise_ewma`, warmup logic) | `circular_drift_caught_by_magnitude_cusum_only`, `magnitude_cusum_no_false_alarm_on_realistic_gps_noise`, `real_attack_still_fires_after_noise_floor_learned_on_noisy_gps` | None for the false-alarm case (B-02 fixed: verified 0 fires over 600 s of σ=2.5 m noise while a 6 m/fix drift still fires). Two residuals (both 🟢, audit U-01): (1) a constant-radius circular attack present from boot with NO clean baseline reads as the noise floor — indistinguishable from noisy GPS by construction; (2) a circular spoof sustained through the 20-fix warmup window (begins at preflight-Ready) can inflate the learned floor, desensitizing ONLY the magnitude lane — the per-axis CUSUM and jump detector are unaffected during warmup, so a linear/teleport component is still caught | | **Replay attack** — attacker re-injects an old GPS_RAW_INT with fresh timestamp, GPS lat/lon identical across fixes | Frozen-fix detector: if last 3+ fixes are within `FROZEN_FIX_RADIUS_M = 0.5 m` AND IMU reports `|v| > 1 m/s` motion, fire `FrozenGps` event and register external anomaly with FSM | `src/fusion.rs:198-205, 665-763` | `tests/scenario_frozen_gps.rs` | Attacker who alternates replay with one drift-step per cycle (B-04) keeps streak below threshold; circular-replay also defeats unless covered by magnitude CUSUM | | **Vertical-only spoof (sudden)** — altitude teleported but lat/lon unchanged | GPS-only vertical-rate sanity check: if reported altitude changes faster than `max_vertical_rate_mps` (default 30 m/s, ~2× the fastest real multirotor descent) between consecutive fixes, fire a `Jump` event with reason `VerticalRate` and register an external anomaly with the FSM. Deliberately does NOT use the unreliable vertical dead-reckoning | `src/fusion.rs:847-883`, `src/detect/mod.rs:46-58` | `altitude_teleport_fires_vertical_rate_jump`, `clean_flight_emits_no_vertical_rate_jump` | Two gaps (both 🟢): (1) **gradual** altitude drift below the 30 m/s rate bound — GPS altitude is inherently 2-3× noisier than horizontal with no reliable inertial vertical reference; (2) an altitude teleport that lands exactly on a GPS-dropout boundary (gap > freeze threshold) is not assessed — the rate check is skipped across dropouts (audit U-02) to avoid false-firing on a legitimate sustained descent during an outage. Sudden teleports during normal operation are caught (B-42) | diff --git a/src/detect/mod.rs b/src/detect/mod.rs index f2dffb3..f8dd7eb 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -8,6 +8,11 @@ pub mod drift; pub mod jump; +/// Constellation-quality discontinuity — the only lane that watches the GPS +/// METADATA rather than the residual, so it can fire at the takeover moment, +/// before any drift has accumulated. Corroboration, not coverage: a spoofer +/// controls these fields and can forge continuity. See the module docs. +pub mod quality; pub mod residual; pub mod state_machine; @@ -81,6 +86,15 @@ pub struct DetectConfig { /// see docs/threats.md. pub vel_aiding_cusum_k_mps: f32, pub vel_aiding_cusum_h: f32, + /// Constellation-quality discontinuity lane (see [`quality`]). Watches the + /// satellite count and HDOP for a STEP away from the sky we have been + /// flying under — the signature of a receiver being captured by a + /// simulated constellation. Orthogonal to every other lane: it fires at the + /// takeover, where the residual is still ~0 and nothing else can see + /// anything. Evadable by a spoofer that forges metadata continuity, so it + /// escalates to Suspicious and is deliberately built so it can never + /// sustain a firing and sever GPS by itself. + pub quality: quality::QualityConfig, } impl Default for DetectConfig { @@ -122,6 +136,7 @@ impl Default for DetectConfig { // realistic-noise + consistent-drift sims. vel_aiding_cusum_k_mps: 0.55, vel_aiding_cusum_h: 8.0, + quality: quality::QualityConfig::default(), } } } diff --git a/src/detect/quality.rs b/src/detect/quality.rs new file mode 100644 index 0000000..16054be --- /dev/null +++ b/src/detect/quality.rs @@ -0,0 +1,466 @@ +//! Constellation-quality discontinuity detector — the takeover-moment lane. +//! +//! # Why this exists +//! +//! Every other detector in this crate is RESIDUAL-based: it needs the spoofer +//! to have already moved the vehicle's apparent position away from truth by +//! enough to clear a noise floor. That is a real and deliberate design, but it +//! has one structural consequence, and `docs/threats.md` states it plainly as +//! the accepted bound of the velocity-aiding lane: a competent spoofer takes +//! over ALIGNED (its first fix matches truth) and walks off slowly enough to +//! stay under the floor. During that alignment window the residual is ~0 and +//! nothing in the residual family can fire, because there is nothing to see. +//! +//! But the takeover itself is not free. To capture a receiver, a spoofer must +//! out-power the live signals and hand the receiver a SIMULATED constellation. +//! That simulated constellation is almost never a byte-perfect continuation of +//! the real one: the satellite count steps, and the dilution-of-precision steps +//! with it — very often *downward*, because a synthesized geometry is cleaner +//! than anything a real sky with real obstructions produces. +//! +//! So this lane watches the metadata rather than the position, and it is +//! strongest exactly where the residual lanes are weakest — at t=0 of the +//! attack, before any drift has accumulated. The two families are orthogonal: +//! one sees the spoof's EFFECT, this one sees its ONSET. +//! +//! # What it deliberately does NOT claim +//! +//! **A spoofer that controls the receiver's output can forge these fields.** +//! Everything here is metadata the attacker's own signal generator produces, so +//! a careful adversary holds the satellite count and HDOP steady across the +//! handover and this lane sees nothing. That is not a reason to skip it — it +//! raises the cost of a clean takeover from "out-power the sky" to "out-power +//! the sky AND model the victim's current constellation well enough to fake +//! continuity into it" — but it IS a reason never to describe this as coverage. +//! It is corroboration, not proof, and it is listed that way in the threat +//! model. +//! +//! It is also blind to **meaconing** (rebroadcast of the genuine signals), +//! which by construction preserves the real constellation. That case is covered +//! elsewhere: at boot by the home-anchor check, and in flight by the position +//! jump the rebroadcast delay produces. +//! +//! # Why it cannot sever GPS on its own +//! +//! The FSM treats an external anomaly as "a detector fired", and a SUSTAINED +//! firing across the whole suspicious→spoofed dwell would reach Spoofed and cut +//! the GPS. A quality discontinuity is by definition a transient, so this +//! detector is built to be **one-shot per step**: it fires on the transition, +//! immediately re-baselines onto the new level, and goes quiet. A genuine +//! takeover therefore escalates to Suspicious — loud, visible, dwell running — +//! and then needs a residual lane to corroborate before anything is severed. +//! +//! [`REFRACTORY_S`] closes the remaining hole: a receiver (or an attacker) +//! oscillating the reported quality would otherwise fire on every step and +//! sustain the latch through repetition. Bounding the report rate means this +//! lane can raise the alarm but can never, by itself, talk the FSM into +//! severing GPS on an aircraft whose position data is fine. + +use crate::types::GpsFix; + +/// Fixes with usable quality data required before any baseline is trusted. +/// A cold receiver climbs from 0 satellites to its working count over the first +/// seconds of lock; treating that ramp as a discontinuity would fire on every +/// boot. +pub const WARMUP_FIXES: u32 = 12; + +/// Consecutive fixes a deviation must hold before it counts. One-sample sat +/// dropouts are ordinary (a satellite clipped by an airframe leg, a momentary +/// multipath null); a takeover persists. +pub const PERSIST_FIXES: u32 = 2; + +/// Seconds of silence enforced after a report. Bounds how much a flapping +/// receiver — or an attacker deliberately oscillating the metadata — can +/// contribute toward the spoofed dwell. +pub const REFRACTORY_S: f64 = 30.0; + +/// EWMA weight for the baselines. Deliberately slow: real constellation +/// changes (a satellite rising or setting) take minutes, so the baseline should +/// represent "the sky we have been flying under", not the last few fixes. +pub const BASELINE_ALPHA: f64 = 0.05; + +/// What kind of discontinuity was seen. Both directions matter, which is the +/// point of naming them separately in the event payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShiftKind { + /// Satellite count stepped (either way). + SatCount, + /// HDOP stepped. An abrupt IMPROVEMENT is as suspicious as a degradation: + /// synthesized geometry tends to be implausibly good. + Dop, + /// Both stepped on the same fix — the strongest form, and the ordinary + /// signature of a constellation swap. + Both, +} + +/// A reported discontinuity, carrying the evidence for the operator log. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct QualityShift { + pub kind: ShiftKind, + pub sats_baseline: Option, + pub sats_now: Option, + pub hdop_baseline: Option, + pub hdop_now: Option, +} + +/// Tunables. Defaults are sized against ordinary receiver behaviour, not +/// against any particular spoofer. +#[derive(Debug, Clone, Copy)] +pub struct QualityConfig { + /// Satellite-count deviation from baseline that counts as a step. A real + /// sky loses or gains satellites one at a time; 4 at once is a different + /// sky. + pub sats_step: f64, + /// HDOP deviation from baseline that counts as a step. + pub hdop_step: f64, + /// Set false to disable the lane entirely (operators flying receivers with + /// erratic quality reporting). + pub enabled: bool, +} + +impl Default for QualityConfig { + fn default() -> Self { + Self { + sats_step: 4.0, + hdop_step: 0.8, + enabled: true, + } + } +} + +/// Tracks the constellation we have been flying under and reports steps away +/// from it. +#[derive(Debug, Default)] +pub struct ConstellationHealth { + sats_baseline: Option, + hdop_baseline: Option, + warmed: u32, + deviating_streak: u32, + last_report_t: Option, +} + +impl ConstellationHealth { + pub fn new() -> Self { + Self::default() + } + + /// Current baselines, for event payloads and tests. + pub fn baselines(&self) -> (Option, Option) { + (self.sats_baseline, self.hdop_baseline) + } + + /// Feed one fix. Returns `Some` exactly on the fix where a discontinuity is + /// confirmed; the baseline is re-anchored onto the new level at that point, + /// so a persisting new level does NOT keep reporting. + pub fn observe( + &mut self, + fix: &GpsFix, + t_secs: f64, + cfg: &QualityConfig, + ) -> Option { + if !cfg.enabled { + return None; + } + // No quality data at all → nothing to say. Notably we do NOT treat + // "the receiver stopped reporting quality" as a discontinuity: the + // NMEA path legitimately omits these fields on some sentence mixes, + // and firing on that would punish a receiver for being terse. + let (sats, hdop) = (fix.sats, fix.hdop); + if sats.is_none() && hdop.is_none() { + return None; + } + + let sats_dev = match (sats, self.sats_baseline) { + (Some(s), Some(b)) => (s as f64 - b).abs() >= cfg.sats_step, + _ => false, + }; + let hdop_dev = match (hdop, self.hdop_baseline) { + (Some(h), Some(b)) => (h as f64 - b).abs() >= cfg.hdop_step, + _ => false, + }; + // Classify up front so "something deviated" and "what deviated" are the + // same fact. Carrying them as two values would leave an impossible + // (false, false) case to handle at the report site — and a flight loop + // is the wrong place to answer that with a panic. + let kind = match (sats_dev, hdop_dev) { + (true, true) => Some(ShiftKind::Both), + (true, false) => Some(ShiftKind::SatCount), + (false, true) => Some(ShiftKind::Dop), + (false, false) => None, + }; + let deviating = kind.is_some(); + + // Warmup counts only fixes we could actually learn from. + if self.warmed < WARMUP_FIXES { + self.warmed += 1; + self.absorb(sats, hdop); + return None; + } + + if !deviating { + // Quiet fix: this is the only place the baseline moves. Freezing + // the learner while a deviation is in progress is what stops the + // baseline from chasing the step and erasing the very signal we are + // looking for — the same guard the drift lane uses on its noise + // floor. + self.deviating_streak = 0; + self.absorb(sats, hdop); + return None; + } + + self.deviating_streak += 1; + // `deviating` is true, so `kind` is Some by construction. + let kind = kind?; + if self.deviating_streak < PERSIST_FIXES { + return None; + } + + // Confirmed step. Re-anchor onto the new level FIRST so that whatever + // happens next, this lane is quiet again — it must not be able to hold + // the FSM's dwell open by itself. + let report = QualityShift { + kind, + sats_baseline: self.sats_baseline, + sats_now: sats, + hdop_baseline: self.hdop_baseline, + hdop_now: hdop, + }; + self.sats_baseline = sats.map(|s| s as f64).or(self.sats_baseline); + self.hdop_baseline = hdop.map(|h| h as f64).or(self.hdop_baseline); + self.deviating_streak = 0; + + if let Some(last) = self.last_report_t { + if t_secs - last < REFRACTORY_S { + // Re-baselined (so we stay quiet), but deliberately silent: a + // metadata oscillation must not accumulate dwell. + return None; + } + } + self.last_report_t = Some(t_secs); + Some(report) + } + + /// Blend a quiet fix into the baselines. + fn absorb(&mut self, sats: Option, hdop: Option) { + if let Some(s) = sats { + let s = s as f64; + self.sats_baseline = Some(match self.sats_baseline { + Some(b) => b + BASELINE_ALPHA * (s - b), + None => s, + }); + } + if let Some(h) = hdop { + let h = h as f64; + self.hdop_baseline = Some(match self.hdop_baseline { + Some(b) => b + BASELINE_ALPHA * (h - b), + None => h, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::Timestamp; + + fn fix(sats: Option, hdop: Option) -> GpsFix { + GpsFix { + t: Timestamp::now_mono(), + lat_deg: 47.0, + lon_deg: 8.0, + alt_m: 500.0, + speed_mps: Some(5.0), + course_deg: Some(90.0), + hdop, + sats, + } + } + + /// Drive `n` steady fixes starting at `t0`, one per second. + fn steady(h: &mut ConstellationHealth, n: u32, t0: f64, sats: u8, hdop: f32) -> f64 { + let cfg = QualityConfig::default(); + let mut t = t0; + for _ in 0..n { + assert_eq!( + h.observe(&fix(Some(sats), Some(hdop)), t, &cfg), + None, + "steady sky must stay quiet at t={t}" + ); + t += 1.0; + } + t + } + + #[test] + fn steady_constellation_never_reports() { + let mut h = ConstellationHealth::new(); + steady(&mut h, 600, 0.0, 11, 0.9); + } + + #[test] + fn warmup_suppresses_reports_while_the_baseline_is_forming() { + // A cold receiver climbing 4 -> 12 satellites is a normal boot, not a + // takeover. Nothing may fire before the baseline is trusted. + let mut h = ConstellationHealth::new(); + let cfg = QualityConfig::default(); + for (i, s) in (4u8..=15).enumerate() { + assert_eq!( + h.observe(&fix(Some(s), Some(2.0)), i as f64, &cfg), + None, + "no report during warmup (sats={s})" + ); + } + } + + #[test] + fn satellite_count_step_is_reported_once_then_the_lane_goes_quiet() { + // THE core property. A sustained firing would let this lane alone + // drive Suspicious -> Spoofed and sever a healthy GPS. + let mut h = ConstellationHealth::new(); + let cfg = QualityConfig::default(); + let mut t = steady(&mut h, 30, 0.0, 12, 0.9); + + // Takeover: 12 -> 6 satellites, and it persists. + assert_eq!( + h.observe(&fix(Some(6), Some(0.9)), t, &cfg), + None, + "first deviating fix only arms the streak" + ); + t += 1.0; + let shift = h + .observe(&fix(Some(6), Some(0.9)), t, &cfg) + .expect("a persisting sat-count step must report"); + assert_eq!(shift.kind, ShiftKind::SatCount); + assert_eq!(shift.sats_now, Some(6)); + + // The new level persists — and the lane must NOT keep firing. + for _ in 0..200 { + t += 1.0; + assert_eq!( + h.observe(&fix(Some(6), Some(0.9)), t, &cfg), + None, + "a persisting new level must not re-report" + ); + } + } + + #[test] + fn single_fix_glitch_is_ignored() { + // One satellite briefly clipped by an airframe leg is not an attack. + let mut h = ConstellationHealth::new(); + let cfg = QualityConfig::default(); + let t = steady(&mut h, 30, 0.0, 12, 0.9); + assert_eq!(h.observe(&fix(Some(5), Some(0.9)), t, &cfg), None); + // Recovers on the very next fix → streak broken, nothing reported. + assert_eq!(h.observe(&fix(Some(12), Some(0.9)), t + 1.0, &cfg), None); + steady(&mut h, 30, t + 2.0, 12, 0.9); + } + + #[test] + fn suspiciously_perfect_geometry_is_reported() { + // An abrupt HDOP IMPROVEMENT is a spoof signature, not good news: + // synthesized constellations are cleaner than real skies. + let mut h = ConstellationHealth::new(); + let cfg = QualityConfig::default(); + let mut t = steady(&mut h, 30, 0.0, 10, 1.6); + h.observe(&fix(Some(10), Some(0.4)), t, &cfg); + t += 1.0; + let shift = h + .observe(&fix(Some(10), Some(0.4)), t, &cfg) + .expect("an abrupt DOP improvement must report"); + assert_eq!(shift.kind, ShiftKind::Dop); + } + + #[test] + fn simultaneous_steps_report_as_both() { + let mut h = ConstellationHealth::new(); + let cfg = QualityConfig::default(); + let mut t = steady(&mut h, 30, 0.0, 12, 1.8); + h.observe(&fix(Some(6), Some(0.5)), t, &cfg); + t += 1.0; + let shift = h.observe(&fix(Some(6), Some(0.5)), t, &cfg).unwrap(); + assert_eq!( + shift.kind, + ShiftKind::Both, + "a constellation swap moves both" + ); + } + + #[test] + fn oscillating_metadata_cannot_sustain_the_dwell() { + // The safety property behind REFRACTORY_S: an attacker (or a sick + // receiver) flapping the sat count must not be able to fire every few + // fixes and hold the FSM's suspicious->spoofed dwell open. + let mut h = ConstellationHealth::new(); + let cfg = QualityConfig::default(); + let mut t = steady(&mut h, 30, 0.0, 12, 0.9); + let mut reports = 0; + for cycle in 0..40 { + let sats = if cycle % 2 == 0 { 5 } else { 13 }; + for _ in 0..PERSIST_FIXES { + if h.observe(&fix(Some(sats), Some(0.9)), t, &cfg).is_some() { + reports += 1; + } + t += 1.0; + } + } + let elapsed = t - 30.0; + let ceiling = (elapsed / REFRACTORY_S).ceil() as usize + 1; + assert!( + reports <= ceiling, + "reports ({reports}) must be bounded by the refractory rate (<= {ceiling}) over {elapsed}s" + ); + } + + #[test] + fn absent_quality_data_is_not_a_discontinuity() { + // A receiver that simply stops reporting HDOP/sats is terse, not + // hostile — and the NMEA path legitimately does this on some sentence + // mixes. + let mut h = ConstellationHealth::new(); + let cfg = QualityConfig::default(); + let mut t = steady(&mut h, 30, 0.0, 12, 0.9); + for _ in 0..50 { + assert_eq!(h.observe(&fix(None, None), t, &cfg), None); + t += 1.0; + } + // And the baseline survived the gap, so the sky we return to is still + // recognised as the same one. + steady(&mut h, 10, t, 12, 0.9); + } + + #[test] + fn slow_natural_drift_is_absorbed_not_reported() { + // Satellites rise and set over minutes. The baseline must follow that + // without ever calling it a step. + let mut h = ConstellationHealth::new(); + let cfg = QualityConfig::default(); + let mut t = steady(&mut h, 30, 0.0, 14, 1.0); + for i in 0..8 { + // One satellite lost every 40 fixes: 14 -> 6 over ~5 minutes. + let sats = 14 - i; + for _ in 0..40 { + assert_eq!( + h.observe(&fix(Some(sats), Some(1.0)), t, &cfg), + None, + "gradual constellation change must be absorbed (sats={sats})" + ); + t += 1.0; + } + } + } + + #[test] + fn disabled_lane_is_silent() { + let mut h = ConstellationHealth::new(); + let cfg = QualityConfig { + enabled: false, + ..Default::default() + }; + for i in 0..100 { + let sats = if i < 50 { 12 } else { 4 }; + assert_eq!(h.observe(&fix(Some(sats), Some(0.9)), i as f64, &cfg), None); + } + } +} diff --git a/src/fusion.rs b/src/fusion.rs index 4ef66f9..4483d5f 100644 --- a/src/fusion.rs +++ b/src/fusion.rs @@ -239,6 +239,12 @@ pub struct Fusion { last_gps_alt: Option<(f64, f64)>, /// Cached config copy for the vertical-rate threshold. max_vertical_rate_mps: f32, + /// Constellation-quality lane: tracks the sky we have been flying under so + /// a STEP away from it (the takeover signature) can be reported while the + /// position residual is still ~0. See `detect::quality`. + constellation: crate::detect::quality::ConstellationHealth, + /// Cached config copy for the constellation lane. + quality_cfg: crate::detect::quality::QualityConfig, /// Pre-flight readiness — see `PreflightState` doc. preflight: PreflightState, checklist: PreflightChecklist, @@ -315,6 +321,8 @@ impl Fusion { frozen_imu_displacement_m: 0.0, last_gps_alt: None, max_vertical_rate_mps: cfg.detect.max_vertical_rate_mps, + constellation: crate::detect::quality::ConstellationHealth::new(), + quality_cfg: cfg.detect.quality, preflight: PreflightState::Initializing, checklist: PreflightChecklist { static_init_done: false, @@ -1033,6 +1041,52 @@ impl Fusion { } self.last_gps_alt = Some((fix.alt_m, t_gps_secs)); + // Constellation-quality discontinuity. Every lane above needs the + // spoofer to have already MOVED us; this one watches the metadata and + // so can fire at the takeover, while the residual is still ~0 — which + // is exactly the window a slow walk-off lives in (docs/threats.md + // §"velocity-aiding bounds"). It escalates like the other external + // anomalies, but `ConstellationHealth` re-baselines on every report and + // rate-limits itself, so it CANNOT sustain a firing across the + // suspicious→spoofed dwell and sever GPS on its own. Corroboration + // only: a spoofer controls these fields and can forge continuity. + if let Some(shift) = self + .constellation + .observe(&fix, t_gps_secs, &self.quality_cfg) + { + self.fsm.note_external_anomaly(); + let (sats_base, hdop_base) = self.constellation.baselines(); + let ev = SpoofingEvent::new( + fix.t, + SpoofKind::ConstellationShift, + self.fsm.state(), + r.mag_pos as f32, + serde_json::json!({ + "shift": format!("{:?}", shift.kind), + "sats_baseline": shift.sats_baseline, + "sats_now": shift.sats_now, + "hdop_baseline": shift.hdop_baseline, + "hdop_now": shift.hdop_now, + "sats_baseline_after_reanchor": sats_base, + "hdop_baseline_after_reanchor": hdop_base, + "reason": "reported constellation stepped away from the one in use — the \ + signature of a receiver captured by a simulated constellation. \ + An abrupt HDOP IMPROVEMENT counts: synthesized geometry is \ + cleaner than a real sky. Corroborating evidence only; a spoofer \ + can forge these fields, so this raises the cost of a clean \ + takeover rather than closing it", + }), + self.boot, + ); + let _ = events_tx.send(ev); + warn!( + shift = ?shift.kind, + sats_now = ?shift.sats_now, + hdop_now = ?shift.hdop_now, + "CONSTELLATION SHIFT — possible spoofer takeover; corroborate with residual lanes" + ); + } + // Observability for an attacker (or broken GPS) hiding behind missing // Doppler. Velocity-mismatch detection is skipped while this is true; // operator should know. diff --git a/src/sim/spoof.rs b/src/sim/spoof.rs index ab65787..f459c42 100644 --- a/src/sim/spoof.rs +++ b/src/sim/spoof.rs @@ -74,6 +74,25 @@ pub enum SpoofPattern { drift_north_mps: f32, drift_east_mps: f32, }, + /// The TAKEOVER MOMENT, isolated. At `apply_at_s` the reported satellite + /// count and HDOP step to a new constellation, while lat/lon/velocity stay + /// completely honest. + /// + /// This is deliberately not an attack that moves the vehicle — it is the + /// instant BEFORE one does. A competent spoofer captures the receiver + /// aligned and only then begins to walk the position off, so during this + /// window every residual-based lane is looking at a ~0 residual and, quite + /// correctly, sees nothing. The only thing that changed is the metadata: + /// the simulated constellation is not the real sky it replaced. + /// + /// Exists to prove the constellation lane fires here — and, just as + /// importantly, that it does NOT escalate all the way to severing GPS on + /// its own, since the position data at this point is still perfectly good. + ConstellationSwap { + apply_at_s: f32, + sats_after: u8, + hdop_after: f32, + }, } pub struct SpoofInjector { @@ -166,6 +185,21 @@ fn apply_spoof(fix: &mut GpsFix, pattern: &SpoofPattern, elapsed_s: f64) { } return; } + // Constellation swap — metadata only. Position and velocity stay honest, + // so no residual lane has anything to fire on; this isolates the + // takeover-moment signal. + if let SpoofPattern::ConstellationSwap { + apply_at_s, + sats_after, + hdop_after, + } = pattern + { + if elapsed_s >= *apply_at_s as f64 { + fix.sats = Some(*sats_after); + fix.hdop = Some(*hdop_after); + } + return; + } // Doppler strip — clears speed/course only, leaving lat/lon honest. if let SpoofPattern::DropDoppler { after_s } = pattern { if elapsed_s >= *after_s as f64 { @@ -237,6 +271,7 @@ fn apply_spoof(fix: &mut GpsFix, pattern: &SpoofPattern, elapsed_s: f64) { SpoofPattern::VelocityInconsistent { .. } | SpoofPattern::AltitudeJump { .. } | SpoofPattern::DropDoppler { .. } + | SpoofPattern::ConstellationSwap { .. } | SpoofPattern::ConsistentDrift { .. } => { unreachable!("handled above") } diff --git a/src/types.rs b/src/types.rs index f25e55c..701654e 100644 --- a/src/types.rs +++ b/src/types.rs @@ -134,6 +134,15 @@ pub enum SpoofKind { /// be throttling GPS to permanently stall escalation. Operator should /// investigate. After this fires, normal FSM accounting resumes. DwellPauseExceeded, + /// The reported constellation stepped away from the one we had been + /// flying under (satellite count and/or HDOP), which is what a receiver + /// being captured by a simulated constellation looks like — including a + /// suspiciously ABRUPT improvement, since synthesized geometry is cleaner + /// than a real sky. Fires at the takeover moment, where the position + /// residual is still ~0 and no other lane can see anything yet. + /// Corroboration, not proof: a spoofer controls these fields and can forge + /// continuity. Reported once per step, never sustained. + ConstellationShift, /// The same GPS lat/lon has been reported for many consecutive fixes /// while the IMU shows the vehicle is moving — either the GPS module /// has frozen or an attacker is replaying a captured fix. Treated as diff --git a/tests/scenario_constellation_shift.rs b/tests/scenario_constellation_shift.rs new file mode 100644 index 0000000..9ab9480 --- /dev/null +++ b/tests/scenario_constellation_shift.rs @@ -0,0 +1,101 @@ +//! Constellation-quality lane, end to end. +//! +//! Two properties, and the second matters as much as the first: +//! +//! 1. **It sees the takeover.** A spoofer capturing the receiver swaps in a +//! simulated constellation. At that instant it has not moved the vehicle at +//! all, so every residual-based lane is looking at a ~0 residual and +//! correctly stays silent. The metadata step is the only observable, and +//! this lane must catch it. +//! +//! 2. **It cannot sever GPS by itself.** The FSM counts an external anomaly as +//! "a detector fired", so anything able to fire continuously across the +//! suspicious→spoofed dwell can reach Spoofed and cut the GPS. A metadata +//! step is corroboration, not proof — the position data here is perfectly +//! good — so a run where NOTHING but the constellation changed must never +//! end in a sever. `ConstellationHealth` guarantees that by re-baselining on +//! every report and rate-limiting itself; this test is what holds that +//! guarantee honest at the system level. + +mod common; + +use common::{count_kind, run_scenario, Scenario}; +use flyingsquirrel::sim::spoof::SpoofPattern; +use flyingsquirrel::types::SpoofKind; + +#[tokio::test(start_paused = true)] +async fn constellation_swap_is_seen_but_never_severs_on_its_own() { + // 12 sats / HDOP 0.9 for the first 20 s, then a swap to 6 sats / HDOP 0.3 + // — fewer satellites AND implausibly perfect geometry, the ordinary shape + // of a synthesized constellation. lat/lon/velocity remain honest for the + // whole run. + let outcome = run_scenario(Scenario { + duration_s: 120, + speed_mps: 8.0, + pattern: SpoofPattern::ConstellationSwap { + apply_at_s: 20.0, + sats_after: 6, + hdop_after: 0.3, + }, + ..Scenario::default() + }) + .await; + + // 1. The takeover is seen. + let shifts = count_kind(&outcome.events, SpoofKind::ConstellationShift); + assert_eq!( + shifts, 1, + "expected exactly one ConstellationShift — the lane must report the step \ + once and then re-baseline onto the new sky, not re-report it forever \ + (a sustained firing is what would let it sever GPS alone); got {shifts}" + ); + + // 2. Nothing else could possibly have seen it — the position never moved. + // This is what makes the lane orthogonal rather than redundant. + let jumps = count_kind(&outcome.events, SpoofKind::Jump); + let drifts = count_kind(&outcome.events, SpoofKind::Drift); + assert_eq!( + (jumps, drifts), + (0, 0), + "position and velocity stayed honest, so the residual lanes must stay \ + silent — if they fired, this test is no longer isolating the takeover \ + signal (jumps={jumps} drifts={drifts})" + ); + + // 3. THE SAFETY BOUND: metadata alone never cuts the GPS. + assert_eq!( + (outcome.sever_count, outcome.rtb_count), + (0, 0), + "a constellation step is corroboration, not proof — the position data in \ + this run is good, and severing GPS + forcing RTL on an aircraft with a \ + healthy position solution would be a self-inflicted denial of service \ + (sever={} rtb={})", + outcome.sever_count, + outcome.rtb_count + ); +} + +#[tokio::test(start_paused = true)] +async fn steady_sky_produces_no_constellation_events() { + // The false-positive guard at system level: the sim's ordinary GPS reports + // a constant 12 sats / 0.9 HDOP, and a clean flight must never produce a + // shift report. + let outcome = run_scenario(Scenario { + duration_s: 120, + speed_mps: 8.0, + pattern: SpoofPattern::Clean, + ..Scenario::default() + }) + .await; + + assert_eq!( + count_kind(&outcome.events, SpoofKind::ConstellationShift), + 0, + "a steady constellation must never be reported as a shift" + ); + assert_eq!( + (outcome.sever_count, outcome.rtb_count), + (0, 0), + "clean flight must not act" + ); +}