5.6 KiB
ADR-177: nvsim Degenerate-Input Hardening (NV-Diamond Simulator)
| Field | Value |
|---|---|
| Status | Accepted — 2 real MEDIUM bugs fixed + pinned; determinism preserved |
| Date | 2026-06-15 |
| Deciders | ruv |
| Codename | NVSIM-FAILCLOSED |
| Reviews | ADR-089 (nvsim NV-diamond magnetometer pipeline simulator) |
| Milestone | #9 (ungated-crate security sweep) — crate 2 of 4 |
Context
nvsim (ADR-089) is a standalone, WASM-ready deterministic NV-diamond
magnetometer pipeline simulator — a forward-only leaf:
scene → source → propagation → NV ensemble → digitiser → MagFrame + SHA-256 witness. It has no network surface, so the real attack surface is degenerate
physical-parameter input crossing the external boundary — specifically the
WASM config_json / scene_json entry points.
Two properties matter for this crate that don't for others: it is billed
deterministic (a published cross-machine witness must reproduce bit-exactly),
and under panic=abort WASM any panic aborts the whole module. So a
config-induced panic is a denial-of-service, and a silent numeric corruption
defeats the simulator's entire purpose.
Decision
Fix the two reachable degenerate-input bugs at their funnel points, each pinned by a fails-on-old test, without perturbing the deterministic happy path (the guards fire only on non-finite / degenerate input; the published witness is unchanged).
Findings fixed (both MEASURED-reproduced)
| # | Severity | Location | Issue | Fix |
|---|---|---|---|---|
| NVSIM-DT-01 | MEDIUM (DoS) | pipeline.rs:58,95 |
dt = config.dt_s.unwrap_or(1.0 / f_s_hz); an external f_s_hz == 0.0 → dt = +Inf → (dt*1e6) as u64 saturates to u64::MAX → (sample as u64) * dt_us panics attempt to multiply with overflow at sample ≥ 2 (debug/WASM-abort; garbage t_us in release). MEASURED: panic at pipeline.rs:95:30. |
Sanitise dt (non-finite/non-positive → 1 µs fallback), cap the u64 cast at u64::MAX, saturating_mul the timestamp — no config can overflow it. |
| NVSIM-NAN-01 | MEDIUM (silent corruption) | funnel digitiser.rs::adc_quantise (root: near-field clamp bypass in source.rs) |
A non-finite scene param (NaN/Inf dipole position, Inf moment, NaN loop radius) bypasses the near-field clamp (NaN < R_MIN_M == false → the 1/r³ path runs → NaN field), and at the ADC NaN as i32 == 0 (Rust saturating cast) emits a frame b_pt=[0,0,0] with ADC_SATURATED CLEAR — indistinguishable from a legitimate zero-field reading. MEASURED: b=[NaN,NaN,NaN] sat=false → b_pt=[0,0,0] flags=0b0000. |
adc_quantise: any non-finite input → code 0 with the saturation flag raised; the pipeline's existing adc_sat OR-reduction propagates ADC_SATURATED onto the frame, making the corruption visible downstream. |
This is the same NaN-fail-open / NaN-poisoning family seen across calibration/vitals/geo and ruview-swarm — non-finite input defeating a guard — but bounded here to a single frame (no cross-timestep accumulator).
Dimensions confirmed clean (with evidence)
- Determinism integrity — clean. One RNG only:
ChaCha20Rng::seed_from_u64(seed), fully caller-seeded (grep: oneseed_from_u64, zerothread_rng/getrandom/SystemTime/Instant/HashMap);Cargo.tomlpinsrand/rand_chachadefault-features=false(no OS entropy). Box–Muller drawsgen_range(f64::EPSILON..=1.0)(avoidsln(0)=-Infby construction). Frame bytes fixed LE; source summation order fixed byVecorder. The published cross-machine witnesscc8de9b0…93b4(proof_witness_publishes_a_known_value) passes UNCHANGED after both fixes — the happy path is byte-identical; guards touch only degenerate inputs. Attested caveat (not a finding): libmcos/ln/sqrtcould differ x86↔wasm; the witness is documented as x86_64-captured. - Panic-free deserialisation — clean.
MagFrame::from_bytesvalidates len/magic/version, then per-fieldbuf[a..b].try_into().expect(...)are over fixed sub-ranges of an already-length-checked 60-byte buffer (provably infallible). Nounsafe, nopanic!/unreachable!in production; every otherunwrap/expectis#[cfg(test)]. - Div-by-zero / numerical landmines — clean.
dipole_field/current_loop_fieldclampr_norm < R_MIN_Mbefore1/r³,1/r²(finite inputs);shot_noise_floorguardsdenom <= 0;vec3_normaliseguardsn < 1e-20. The only hole was the NaN bypass of the clamp — closed at the ADC funnel (NVSIM-NAN-01).
Validation
cargo test -p nvsim --no-default-features→ 50 → 53 passed, 0 failed (+3 pins:degenerate_zero_sample_rate_does_not_panic,non_finite_scene_input_flags_frame_instead_of_silently_zeroing,adc_quantise_flags_non_finite_as_saturated).cargo test --workspace --no-default-features→ exit 0, 0 failed.python archive/v1/data/proof/verify.py→ VERDICT: PASS, hashf8e76f21…46f7aunchanged (nvsim off the signal proof path).- nvsim's own cross-machine witness
cc8de9b0…93b4reproduces unchanged.
Consequences
Positive
- A config-induced WASM-abort DoS and a silent NaN→fake-zero-field corruption are closed at their funnel points, each regression-pinned, with the deterministic witness proven intact.
Negative / Neutral
- None. Guards affect only degenerate inputs; happy-path output is byte-identical.
Links
- ADR-089 —
nvsimNV-diamond magnetometer simulator - ADR-176 —
ruview-swarm(sibling NaN-fail-open review) - ADR-172 — core/cli (where the NaN-bug-class root was settled NO)