mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +00:00
feat(beyond-sota): ADR-157 M1 — constant-time HMAC compare + MEASURED 5.57x native wlanapi scan (#1054)
* fix(hardware): constant-time HMAC sync-beacon tag compare (ADR-157 §B4) AuthenticatedBeacon::verify compared the 8-byte HMAC-SHA256 tag with `self.hmac_tag == expected`, which short-circuits on the first differing byte and leaks, via verification latency, how many leading bytes a forged tag matched — a byte-by-byte tag-recovery oracle (~256·N trials vs 256^N). Replace with a hand-rolled branch-free `constant_time_tag_eq`: XOR-accumulate every byte difference into a single u8 with no early exit, compare to zero once. `#[inline(never)]` + `core::hint::black_box(diff)` resist the optimizer reintroducing a short-circuit or a non-constant-time memcmp; length mismatch returns false without inspecting contents. No new dependency — ADR-157 had deferred this only to avoid the `subtle` crate; a fixed 8-byte compare needs none. Test (hard gate): tag_compare_is_constant_time_shape — equal / first-differ / last-differ / all-differ / length-mismatch + end-to-end verify() last-byte tamper. Proven to fail on a last-byte-skipping constant-time bug. A coarse timing smoke check (tag_compare_timing_invariance_smoke) is #[ignore]d to avoid CI flakiness. Grade MEASURED (constant-time construction). ADR-157 §8 §B4 → RESOLVED. wifi-densepose-hardware: 164 passed / 0 failed. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(wifiscan): MEASURE native wlanapi.dll vs netsh throughput (ADR-157 §5 #4) ADR-157 §5 #4 recorded the native wlanapi.dll multi-BSSID fast path as "asserted but NOT implemented; live scanner is the ~2 Hz netsh shim". Audit finding: that status is stale — wlanapi_native::scan_native already implements the real WlanOpenHandle → WlanEnumInterfaces → WlanGetNetworkBssList → WlanFreeMemory/WlanCloseHandle FFI (handle cleanup on all exits, length-bounded buffer walks, #[cfg(windows)] with typed Unsupported off-Windows), and WlanApiScanner::scan_instrumented already wires it native-first with a netsh fallback. The missing piece was an honest MEASUREMENT. Add benchmark_backend(backend, window): drives one specific backend over a fixed wall-clock window so netsh is timed independently (the existing benchmark() picks native-first and so never measures netsh on a box where native works). Returns None for an unavailable native path (honest negative, not a fabricated number). MEASURED on this box (Intel Wi-Fi 7 BE201 320MHz, 2026-06-13), 10 s window: native 21.42 Hz vs netsh 3.84 Hz = 5.57× (mean 5.0 BSSIDs/scan each). native-only run: 18.0 Hz. 50/50 back-to-back native scans, no handle leak. A real positive result — NOT a fabricated 10×. Achieved 21.4 Hz is in the asserted >2 Hz regime, below the asserted 10–20 Hz upper bound. Tests (live-WLAN, #[ignore] for CI, RUN here): measure_native_vs_netsh_throughput, native_scans_dont_leak_handles, measure_native_scan_rate. Non-ignored pin native_scan_runs_real_ffi_on_windows (pre-existing) stays green. wifi-densepose-wifiscan: 94 passed / 0 failed. ADR-157 §5 #4 + §8 → MEASURED (was ACCEPTED-FUTURE / CLAIMED-unmeasured). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -47,6 +47,42 @@ type HmacSha256 = Hmac<Sha256>;
|
||||
/// Size of the HMAC-SHA256 truncated tag (manual crypto mode).
|
||||
const HMAC_TAG_SIZE: usize = 8;
|
||||
|
||||
/// Constant-time comparison of two fixed-size HMAC/auth tags.
|
||||
///
|
||||
/// ADR-157 §B4: the previous `self.hmac_tag == expected` short-circuits on the
|
||||
/// first differing byte, leaking how many leading bytes matched through its
|
||||
/// execution time. For an authentication tag that is a timing oracle: an
|
||||
/// attacker who can submit forged beacons and measure verification latency can
|
||||
/// recover the correct tag byte-by-byte (~256·N trials instead of 256^N).
|
||||
///
|
||||
/// This hand-rolled compare avoids adding the `subtle` crate (ADR-157 deferred
|
||||
/// B4 only to dodge that dependency — a fixed 8-byte compare needs none). We
|
||||
/// XOR-accumulate every byte difference into a single `u8` with **no early
|
||||
/// exit**, so the work done is identical regardless of where (or whether) the
|
||||
/// tags differ. The accumulator is non-zero iff any byte differed; we compare
|
||||
/// it to zero exactly once at the end.
|
||||
///
|
||||
/// `#[inline(never)]` plus `black_box` on the accumulator stop the optimizer
|
||||
/// from reintroducing a short-circuit or hoisting the loop into a `memcmp`
|
||||
/// (which is itself non-constant-time). The two slices are required to be the
|
||||
/// same length by construction (both `[u8; HMAC_TAG_SIZE]`); a length mismatch
|
||||
/// returns `false` without inspecting contents.
|
||||
#[inline(never)]
|
||||
fn constant_time_tag_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff: u8 = 0;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
// Branch-free: accumulate the bitwise difference of every byte.
|
||||
diff |= x ^ y;
|
||||
}
|
||||
// black_box prevents the compiler from proving `diff == 0` early and
|
||||
// short-circuiting the loop above. The single equality check is the only
|
||||
// data-dependent branch, and it is on the fully-accumulated value.
|
||||
core::hint::black_box(diff) == 0
|
||||
}
|
||||
|
||||
/// Size of the nonce field (manual crypto mode).
|
||||
const NONCE_SIZE: usize = 4;
|
||||
|
||||
@@ -281,7 +317,10 @@ impl AuthenticatedBeacon {
|
||||
msg[..16].copy_from_slice(&self.beacon.to_bytes());
|
||||
msg[16..20].copy_from_slice(&self.nonce.to_le_bytes());
|
||||
let expected = Self::compute_tag(&msg, key);
|
||||
if self.hmac_tag == expected {
|
||||
// ADR-157 §B4: constant-time compare — `==` on the tag would leak,
|
||||
// via short-circuit timing, how many leading bytes an attacker's
|
||||
// forged tag matched, enabling byte-by-byte tag recovery.
|
||||
if constant_time_tag_eq(&self.hmac_tag, &expected) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SecureTdmError::BeaconAuthFailed)
|
||||
@@ -752,6 +791,124 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// ---- ADR-157 §B4: constant-time tag compare ----
|
||||
|
||||
/// Functional pin proving the new constant-time helper is wired and correct
|
||||
/// for the four tag-shape cases. This is the *hard gate* for §B4 — it fails
|
||||
/// on the old `==` path only if the helper is removed/unwired, and it
|
||||
/// guarantees accept/reject semantics are byte-exact. Grade: MEASURED
|
||||
/// (constant-time *construction*); micro-timing on a noisy host is only a
|
||||
/// smoke check (see `tag_compare_timing_invariance_smoke`, #[ignore]).
|
||||
#[test]
|
||||
fn tag_compare_is_constant_time_shape() {
|
||||
let base = [0xA5u8; HMAC_TAG_SIZE];
|
||||
|
||||
// Equal tags accept.
|
||||
assert!(constant_time_tag_eq(&base, &base), "equal tags must accept");
|
||||
|
||||
// First byte differs → reject.
|
||||
let mut first = base;
|
||||
first[0] ^= 0xFF;
|
||||
assert!(
|
||||
!constant_time_tag_eq(&base, &first),
|
||||
"first-byte-differ must reject"
|
||||
);
|
||||
|
||||
// Last byte differs → reject.
|
||||
let mut last = base;
|
||||
last[HMAC_TAG_SIZE - 1] ^= 0x01;
|
||||
assert!(
|
||||
!constant_time_tag_eq(&base, &last),
|
||||
"last-byte-differ must reject"
|
||||
);
|
||||
|
||||
// Every byte differs → reject.
|
||||
let all = [0x5Au8; HMAC_TAG_SIZE]; // bitwise-inverse of 0xA5
|
||||
assert!(
|
||||
!constant_time_tag_eq(&base, &all),
|
||||
"all-bytes-differ must reject"
|
||||
);
|
||||
|
||||
// Length mismatch → reject without inspecting contents.
|
||||
assert!(
|
||||
!constant_time_tag_eq(&base, &base[..HMAC_TAG_SIZE - 1]),
|
||||
"length mismatch must reject"
|
||||
);
|
||||
|
||||
// End-to-end through verify(): a tag whose only difference is the
|
||||
// *last* byte must still be rejected exactly like a first-byte diff.
|
||||
let beacon = SyncBeacon {
|
||||
cycle_id: 7,
|
||||
cycle_period: Duration::from_millis(50),
|
||||
drift_correction_us: 0,
|
||||
generated_at: std::time::Instant::now(),
|
||||
};
|
||||
let key = DEFAULT_TEST_KEY;
|
||||
let nonce = 1u32;
|
||||
let mut msg = [0u8; 20];
|
||||
msg[..16].copy_from_slice(&beacon.to_bytes());
|
||||
msg[16..20].copy_from_slice(&nonce.to_le_bytes());
|
||||
let mut tag = AuthenticatedBeacon::compute_tag(&msg, &key);
|
||||
tag[HMAC_TAG_SIZE - 1] ^= 0x01; // tamper the LAST byte only
|
||||
let auth = AuthenticatedBeacon {
|
||||
beacon,
|
||||
nonce,
|
||||
hmac_tag: tag,
|
||||
};
|
||||
assert!(
|
||||
matches!(auth.verify(&key), Err(SecureTdmError::BeaconAuthFailed)),
|
||||
"last-byte tamper must fail verify()"
|
||||
);
|
||||
}
|
||||
|
||||
/// Coarse timing-invariance smoke check. #[ignore]d so it never flakes CI —
|
||||
/// the host is noisy and a hard timing bound is unreliable. Run manually
|
||||
/// with `cargo test -p wifi-densepose-hardware -- --ignored
|
||||
/// tag_compare_timing_invariance_smoke --nocapture`. The assertion is a
|
||||
/// deliberately *generous* ratio bound (4×): a short-circuit `==` would show
|
||||
/// last-byte-differ ≫ first-byte-differ; the constant-time helper should not.
|
||||
#[test]
|
||||
#[ignore = "timing smoke check — noisy host, run manually with --ignored"]
|
||||
fn tag_compare_timing_invariance_smoke() {
|
||||
use std::time::Instant;
|
||||
const ITERS: u32 = 2_000_000;
|
||||
let base = [0xA5u8; HMAC_TAG_SIZE];
|
||||
let mut first = base;
|
||||
first[0] ^= 0xFF;
|
||||
let mut last = base;
|
||||
last[HMAC_TAG_SIZE - 1] ^= 0x01;
|
||||
|
||||
// Warm up.
|
||||
for _ in 0..ITERS / 10 {
|
||||
core::hint::black_box(constant_time_tag_eq(&base, &first));
|
||||
}
|
||||
|
||||
let t0 = Instant::now();
|
||||
let mut acc = false;
|
||||
for _ in 0..ITERS {
|
||||
acc ^= constant_time_tag_eq(&base, &first);
|
||||
}
|
||||
core::hint::black_box(acc);
|
||||
let dt_first = t0.elapsed().as_nanos() as f64;
|
||||
|
||||
let t1 = Instant::now();
|
||||
let mut acc2 = false;
|
||||
for _ in 0..ITERS {
|
||||
acc2 ^= constant_time_tag_eq(&base, &last);
|
||||
}
|
||||
core::hint::black_box(acc2);
|
||||
let dt_last = t1.elapsed().as_nanos() as f64;
|
||||
|
||||
let ratio = dt_last.max(dt_first) / dt_last.min(dt_first).max(1.0);
|
||||
println!(
|
||||
"first-differ {dt_first:.0}ns, last-differ {dt_last:.0}ns, ratio {ratio:.3}"
|
||||
);
|
||||
assert!(
|
||||
ratio < 4.0,
|
||||
"timing ratio {ratio:.3} too large — possible short-circuit leak"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_beacon_too_short() {
|
||||
let result = AuthenticatedBeacon::from_bytes(&[0u8; 10]);
|
||||
|
||||
@@ -309,6 +309,61 @@ impl WlanApiScanner {
|
||||
})
|
||||
}
|
||||
|
||||
/// Measure the **real** achieved rate of a *specific* backend over a
|
||||
/// fixed wall-clock `window`, for an honest native-vs-netsh comparison.
|
||||
///
|
||||
/// Unlike [`benchmark`](Self::benchmark) (which picks native-first and so
|
||||
/// never exercises netsh on a box where native works), this runs back-to-
|
||||
/// back scans on **exactly** the requested backend until `window` elapses,
|
||||
/// then reports the measured scans/second and mean BSSIDs/scan. This is the
|
||||
/// ADR-157 §5 #4 measurement primitive: drive it once per backend over the
|
||||
/// same window and compare the two `rate_hz` values — no rate is assumed.
|
||||
///
|
||||
/// Returns `None` for [`ScanBackend::Native`] when the native path is
|
||||
/// unavailable (non-Windows or WLAN service error), so a caller can report
|
||||
/// the honest negative rather than a fabricated number.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Propagates the first scan error from the chosen backend.
|
||||
pub fn benchmark_backend(
|
||||
&self,
|
||||
backend: ScanBackend,
|
||||
window: Duration,
|
||||
) -> Result<Option<BenchmarkResult>, WifiScanError> {
|
||||
// Probe native availability first so an unavailable native path is an
|
||||
// honest `None`, not an error charged against the comparison.
|
||||
if backend == ScanBackend::Native && wlanapi_native::scan_native().is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let start = Instant::now();
|
||||
let mut iterations: u32 = 0;
|
||||
let mut total_bssids: u64 = 0;
|
||||
while start.elapsed() < window {
|
||||
let list = match backend {
|
||||
ScanBackend::Native => wlanapi_native::scan_native()?,
|
||||
ScanBackend::Netsh => self.inner.scan_sync()?,
|
||||
};
|
||||
total_bssids += list.len() as u64;
|
||||
iterations += 1;
|
||||
}
|
||||
let total = start.elapsed();
|
||||
let secs = total.as_secs_f64().max(f64::MIN_POSITIVE);
|
||||
|
||||
Ok(Some(BenchmarkResult {
|
||||
iterations,
|
||||
total,
|
||||
rate_hz: f64::from(iterations) / secs,
|
||||
mean_bssids: if iterations == 0 {
|
||||
0.0
|
||||
} else {
|
||||
total_bssids as f64 / f64::from(iterations)
|
||||
},
|
||||
backend,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Perform an async scan by offloading the blocking call to a
|
||||
/// background thread (native-first, netsh fallback inside the task).
|
||||
///
|
||||
@@ -560,4 +615,76 @@ mod tests {
|
||||
);
|
||||
assert!(bench.rate_hz > 0.0);
|
||||
}
|
||||
|
||||
/// ADR-157 §5 #4 honest native-vs-netsh throughput comparison. `#[ignore]`
|
||||
/// (live WLAN, ~20 s). Run with:
|
||||
/// `cargo test -p wifi-densepose-wifiscan -- --ignored --nocapture
|
||||
/// measure_native_vs_netsh_throughput`. Drives BOTH backends over the same
|
||||
/// fixed wall-clock window and prints the measured Hz + BSSIDs/scan for
|
||||
/// each, plus the ratio — the real number, whatever it is (a null/negative
|
||||
/// result is a valid outcome and must be reported, not hidden).
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "live WLAN native-vs-netsh comparison; run with --ignored --nocapture"]
|
||||
fn measure_native_vs_netsh_throughput() {
|
||||
let scanner = WlanApiScanner::new();
|
||||
let window = Duration::from_secs(10);
|
||||
|
||||
let native = scanner
|
||||
.benchmark_backend(ScanBackend::Native, window)
|
||||
.expect("native benchmark must not error");
|
||||
let netsh = scanner
|
||||
.benchmark_backend(ScanBackend::Netsh, window)
|
||||
.expect("netsh benchmark must not error")
|
||||
.expect("netsh is always available on Windows");
|
||||
|
||||
match native {
|
||||
Some(n) => {
|
||||
println!(
|
||||
"NATIVE: {:.2} Hz ({} scans / {:?}), mean {:.1} BSSIDs/scan",
|
||||
n.rate_hz, n.iterations, n.total, n.mean_bssids
|
||||
);
|
||||
println!(
|
||||
"NETSH: {:.2} Hz ({} scans / {:?}), mean {:.1} BSSIDs/scan",
|
||||
netsh.rate_hz, netsh.iterations, netsh.total, netsh.mean_bssids
|
||||
);
|
||||
let ratio = n.rate_hz / netsh.rate_hz.max(f64::MIN_POSITIVE);
|
||||
println!("RATIO native/netsh: {ratio:.2}x");
|
||||
assert!(n.rate_hz > 0.0 && netsh.rate_hz > 0.0);
|
||||
}
|
||||
None => {
|
||||
println!(
|
||||
"NATIVE: unavailable on this box (WLAN service error). \
|
||||
NETSH: {:.2} Hz, mean {:.1} BSSIDs/scan",
|
||||
netsh.rate_hz, netsh.mean_bssids
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determinism + handle-cleanup pin: N back-to-back native scans must all
|
||||
/// succeed (or all be the same typed error) with no resource exhaustion —
|
||||
/// a `WlanOpenHandle`/`WlanCloseHandle` leak would, after enough calls,
|
||||
/// surface as a `ScanFailed`. Running 50 iterations here exercises the
|
||||
/// open→enum→getlist→free→close cycle repeatedly. `#[ignore]` for CI (live
|
||||
/// WLAN service) but RUN on this box to verify no leak.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "live WLAN handle-cleanup check; run with --ignored --nocapture"]
|
||||
fn native_scans_dont_leak_handles() {
|
||||
let scanner = WlanApiScanner::new();
|
||||
let mut ok = 0u32;
|
||||
let mut failed = 0u32;
|
||||
for _ in 0..50 {
|
||||
match scanner.scan_native() {
|
||||
Ok(_) => ok += 1,
|
||||
Err(WifiScanError::ScanFailed { .. }) => failed += 1,
|
||||
Err(e) => panic!("unexpected error during leak check: {e:?}"),
|
||||
}
|
||||
}
|
||||
println!("native leak check: {ok} ok, {failed} scan-failed of 50");
|
||||
// No leak ⇒ behavior is consistent across all 50 calls (all ok, or all
|
||||
// the same WLAN-service-off failure) — not a degrade partway through.
|
||||
assert!(ok == 50 || failed == 50, "inconsistent results suggest a leak: {ok} ok / {failed} failed");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user