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