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:
rUv
2026-06-13 16:32:34 -04:00
committed by GitHub
parent 9b07dff298
commit cf2a85db66
4 changed files with 294 additions and 6 deletions
@@ -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");
}
}