Skip to main content

openentropy_core/sources/
wifi.rs

1//! WiFi RSSI entropy source.
2//!
3//! Reads WiFi signal strength (RSSI) and noise floor values on macOS.
4//! Fluctuations in RSSI arise from multipath fading, constructive/destructive
5//! interference, Rayleigh fading, atmospheric absorption, and thermal noise in
6//! the radio receiver.
7
8use 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
17/// Timeout for external WiFi commands.
18const WIFI_COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
19
20/// Entropy source that harvests WiFi RSSI and noise floor fluctuations.
21///
22/// On macOS, it attempts multiple methods to read the current RSSI:
23///
24/// 1. `networksetup -listallhardwareports` to discover the Wi-Fi device name,
25///    then `ipconfig getsummary <device>` to read RSSI/noise.
26/// 2. Fallback: the `airport -I` command from Apple's private framework.
27///
28/// The raw entropy is a combination of RSSI LSBs, successive RSSI deltas,
29/// noise floor LSBs, and measurement timing jitter.
30///
31/// No tunable parameters — automatically discovers the Wi-Fi device and
32/// selects the best available measurement method.
33pub 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/// A single RSSI/noise measurement.
63#[derive(Debug, Clone, Copy)]
64struct WifiMeasurement {
65    rssi: i32,
66    noise: i32,
67    /// Nanoseconds taken to perform the measurement.
68    timing_nanos: u128,
69}
70
71/// Run a command with a timeout. Returns (stdout_option, elapsed_ns).
72/// Always returns elapsed time even if the command fails or times out.
73fn 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
114/// Discover the Wi-Fi hardware device name (e.g. "en0") by parsing
115/// `networksetup -listallhardwareports`.
116fn 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        // Reset if we hit the next hardware port block without finding a device
138        if found_wifi && line.starts_with("Hardware Port:") {
139            found_wifi = false;
140        }
141    }
142    None
143}
144
145/// Try to read RSSI/noise via `ipconfig getsummary <device>`.
146/// Returns ((rssi, noise), elapsed_ns) on success, or just elapsed_ns on failure.
147fn 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
167/// Try to read RSSI/noise via the `airport -I` command.
168/// Returns ((rssi, noise), elapsed_ns) on success, or just elapsed_ns on failure.
169fn 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
189/// Parse a line of the form `  key: value` or `key : value` and return the
190/// integer value.  Handles negative numbers.
191fn parse_field_value(text: &str, field: &str) -> Option<i32> {
192    for line in text.lines() {
193        let trimmed = line.trim();
194        // Match "FIELD : VALUE" or "FIELD: VALUE"
195        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
208/// Take a single RSSI/noise measurement using the best available method.
209/// Always returns timing even if RSSI reading fails (for timing entropy).
210fn 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        // Available if we got a real RSSI (not the zero fallback)
250        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        // Collect bursts of measurements. Always extract timing entropy
260        // even if RSSI reading fails (timeout timing is still entropic).
261        // Cap at 4 bursts since each measurement uses commands with timeouts.
262        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                // Always extract timing entropy (works even on timeout)
276                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                // Extract RSSI/noise if we got real values
283                if m.rssi != 0 || m.noise != 0 {
284                    raw.push(m.rssi as u8);
285                    raw.push(m.noise as u8);
286                }
287
288                // Deltas from previous measurement
289                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] // Requires WiFi hardware
351    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}