openentropy_core/sources/
wifi.rs1use std::process::Command;
9use std::thread;
10use std::time::{Duration, Instant};
11
12use crate::source::{EntropySource, Platform, Requirement, SourceCategory, SourceInfo};
13
14const MEASUREMENT_DELAY: Duration = Duration::from_millis(10);
15const SAMPLES_PER_COLLECT: usize = 8;
16
17const WIFI_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
19
20pub struct WiFiRSSISource;
34
35static WIFI_RSSI_INFO: SourceInfo = SourceInfo {
36 name: "wifi_rssi",
37 description: "WiFi signal strength (RSSI) and noise floor fluctuations",
38 physics: "Reads WiFi signal strength (RSSI) and noise floor via CoreWLAN \
39 framework. RSSI fluctuates due to: multipath fading (reflections \
40 off walls/objects), constructive/destructive interference at \
41 2.4/5/6 GHz, Rayleigh fading from moving objects, atmospheric \
42 absorption, and thermal noise in the radio receiver's LNA.",
43 category: SourceCategory::Network,
44 platform: Platform::MacOS,
45 requirements: &[Requirement::Wifi],
46 entropy_rate_estimate: 30.0,
47 composite: false,
48};
49
50impl WiFiRSSISource {
51 pub fn new() -> Self {
52 Self
53 }
54}
55
56impl Default for WiFiRSSISource {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62#[derive(Debug, Clone, Copy)]
64struct WifiMeasurement {
65 rssi: i32,
66 noise: i32,
67 timing_nanos: u128,
69}
70
71fn run_command_timed(cmd: &str, args: &[&str], timeout: Duration) -> (Option<String>, u64) {
74 let t0 = Instant::now();
75
76 let child = Command::new(cmd)
77 .args(args)
78 .stdout(std::process::Stdio::piped())
79 .stderr(std::process::Stdio::null())
80 .spawn();
81
82 let mut child = match child {
83 Ok(c) => c,
84 Err(_) => return (None, t0.elapsed().as_nanos() as u64),
85 };
86
87 let deadline = Instant::now() + timeout;
88 loop {
89 match child.try_wait() {
90 Ok(Some(status)) => {
91 let elapsed = t0.elapsed().as_nanos() as u64;
92 if !status.success() {
93 return (None, elapsed);
94 }
95 let stdout = child
96 .wait_with_output()
97 .ok()
98 .map(|o| String::from_utf8_lossy(&o.stdout).to_string());
99 return (stdout, elapsed);
100 }
101 Ok(None) => {
102 if Instant::now() >= deadline {
103 let _ = child.kill();
104 let _ = child.wait();
105 return (None, t0.elapsed().as_nanos() as u64);
106 }
107 std::thread::sleep(Duration::from_millis(50));
108 }
109 Err(_) => return (None, t0.elapsed().as_nanos() as u64),
110 }
111 }
112}
113
114fn discover_wifi_device() -> Option<String> {
117 let (output, _) = run_command_timed(
118 "/usr/sbin/networksetup",
119 &["-listallhardwareports"],
120 WIFI_COMMAND_TIMEOUT,
121 );
122
123 let text = output?;
124 let mut found_wifi = false;
125
126 for line in text.lines() {
127 if line.contains("Wi-Fi") || line.contains("AirPort") {
128 found_wifi = true;
129 continue;
130 }
131 if found_wifi && line.starts_with("Device:") {
132 let device = line.trim_start_matches("Device:").trim();
133 if !device.is_empty() {
134 return Some(device.to_string());
135 }
136 }
137 if found_wifi && line.starts_with("Hardware Port:") {
139 found_wifi = false;
140 }
141 }
142 None
143}
144
145fn read_via_ipconfig(device: &str) -> (Option<(i32, i32)>, u64) {
148 let (output, elapsed) = run_command_timed(
149 "/usr/sbin/ipconfig",
150 &["getsummary", device],
151 WIFI_COMMAND_TIMEOUT,
152 );
153
154 let text = match output {
155 Some(t) => t,
156 None => return (None, elapsed),
157 };
158
159 let rssi = match parse_field_value(&text, "RSSI") {
160 Some(v) => v,
161 None => return (None, elapsed),
162 };
163 let noise = parse_field_value(&text, "Noise").unwrap_or(rssi - 30);
164 (Some((rssi, noise)), elapsed)
165}
166
167fn read_via_airport() -> (Option<(i32, i32)>, u64) {
170 let (output, elapsed) = run_command_timed(
171 "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport",
172 &["-I"],
173 WIFI_COMMAND_TIMEOUT,
174 );
175
176 let text = match output {
177 Some(t) => t,
178 None => return (None, elapsed),
179 };
180
181 let rssi = match parse_field_value(&text, "agrCtlRSSI") {
182 Some(v) => v,
183 None => return (None, elapsed),
184 };
185 let noise = parse_field_value(&text, "agrCtlNoise").unwrap_or(rssi - 30);
186 (Some((rssi, noise)), elapsed)
187}
188
189fn parse_field_value(text: &str, field: &str) -> Option<i32> {
192 for line in text.lines() {
193 let trimmed = line.trim();
194 if let Some(rest) = trimmed.strip_prefix(field) {
196 let rest = rest.trim_start();
197 if let Some(val_str) = rest.strip_prefix(':') {
198 let val_str = val_str.trim();
199 if let Ok(v) = val_str.parse::<i32>() {
200 return Some(v);
201 }
202 }
203 }
204 }
205 None
206}
207
208fn measure_once(device: &Option<String>) -> WifiMeasurement {
211 let start = Instant::now();
212
213 let result = if let Some(dev) = device {
214 let (ipconfig_result, elapsed1) = read_via_ipconfig(dev);
215 if let Some(vals) = ipconfig_result {
216 Some((vals, elapsed1))
217 } else {
218 let (airport_result, elapsed2) = read_via_airport();
219 airport_result.map(|vals| (vals, elapsed1 + elapsed2))
220 }
221 } else {
222 let (airport_result, elapsed) = read_via_airport();
223 airport_result.map(|vals| (vals, elapsed))
224 };
225
226 let timing_nanos = start.elapsed().as_nanos();
227 match result {
228 Some(((rssi, noise), _)) => WifiMeasurement {
229 rssi,
230 noise,
231 timing_nanos,
232 },
233 None => WifiMeasurement {
234 rssi: 0,
235 noise: 0,
236 timing_nanos,
237 },
238 }
239}
240
241impl EntropySource for WiFiRSSISource {
242 fn info(&self) -> &SourceInfo {
243 &WIFI_RSSI_INFO
244 }
245
246 fn is_available(&self) -> bool {
247 let device = discover_wifi_device();
248 let m = measure_once(&device);
249 m.rssi != 0 || m.noise != 0
251 }
252
253 fn collect(&self, n_samples: usize) -> Vec<u8> {
254 let mut raw = Vec::with_capacity(n_samples * 4);
255 let device = discover_wifi_device();
256
257 let mut measurements = Vec::with_capacity(SAMPLES_PER_COLLECT);
258
259 let max_bursts = 4;
263 for _ in 0..max_bursts {
264 measurements.clear();
265
266 for _ in 0..SAMPLES_PER_COLLECT {
267 let m = measure_once(&device);
268 measurements.push(m);
269 thread::sleep(MEASUREMENT_DELAY);
270 }
271
272 for i in 0..measurements.len() {
273 let m = &measurements[i];
274
275 let t_bytes = m.timing_nanos.to_le_bytes();
277 raw.push(t_bytes[0]);
278 raw.push(t_bytes[1]);
279 raw.push(t_bytes[2]);
280 raw.push(t_bytes[3]);
281
282 if m.rssi != 0 || m.noise != 0 {
284 raw.push(m.rssi as u8);
285 raw.push(m.noise as u8);
286 }
287
288 if i > 0 {
290 let prev = &measurements[i - 1];
291 raw.push((m.rssi.wrapping_sub(prev.rssi)) as u8);
292 let timing_delta = m.timing_nanos.abs_diff(prev.timing_nanos);
293 raw.push(timing_delta.to_le_bytes()[0]);
294 raw.push((m.rssi ^ m.noise) as u8);
295 }
296 }
297
298 if raw.len() >= n_samples * 2 {
299 break;
300 }
301 }
302
303 raw.truncate(n_samples);
304 raw
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311
312 #[test]
313 fn parse_rssi_from_airport_output() {
314 let sample = "\
315 agrCtlRSSI: -62\n\
316 agrCtlNoise: -90\n\
317 state: running\n";
318 assert_eq!(parse_field_value(sample, "agrCtlRSSI"), Some(-62));
319 assert_eq!(parse_field_value(sample, "agrCtlNoise"), Some(-90));
320 }
321
322 #[test]
323 fn parse_rssi_from_ipconfig_output() {
324 let sample = "\
325 SSID : MyNetwork\n\
326 RSSI : -55\n\
327 Noise : -88\n";
328 assert_eq!(parse_field_value(sample, "RSSI"), Some(-55));
329 assert_eq!(parse_field_value(sample, "Noise"), Some(-88));
330 }
331
332 #[test]
333 fn parse_field_missing() {
334 assert_eq!(parse_field_value("nothing here", "RSSI"), None);
335 }
336
337 #[test]
338 fn source_info() {
339 let src = WiFiRSSISource::new();
340 assert_eq!(src.info().name, "wifi_rssi");
341 assert_eq!(src.info().category, SourceCategory::Network);
342 assert!((src.info().entropy_rate_estimate - 30.0).abs() < f64::EPSILON);
343 assert_eq!(src.info().platform, Platform::MacOS);
344 assert_eq!(src.info().requirements, &[Requirement::Wifi]);
345 assert!(!src.info().composite);
346 }
347
348 #[test]
349 #[cfg(target_os = "macos")]
350 #[ignore] fn wifi_rssi_collects_bytes() {
352 let src = WiFiRSSISource::new();
353 if src.is_available() {
354 let data = src.collect(32);
355 assert!(!data.is_empty());
356 assert!(data.len() <= 32);
357 }
358 }
359}