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, 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.
30pub 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/// A single RSSI/noise measurement.
61#[derive(Debug, Clone, Copy)]
62struct WifiMeasurement {
63    rssi: i32,
64    noise: i32,
65    /// Nanoseconds taken to perform the measurement.
66    timing_nanos: u128,
67}
68
69/// Run a command with a timeout. Returns (stdout_option, elapsed_ns).
70/// Always returns elapsed time even if the command fails or times out.
71fn 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
112/// Discover the Wi-Fi hardware device name (e.g. "en0") by parsing
113/// `networksetup -listallhardwareports`.
114fn 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        // Reset if we hit the next hardware port block without finding a device
136        if found_wifi && line.starts_with("Hardware Port:") {
137            found_wifi = false;
138        }
139    }
140    None
141}
142
143/// Try to read RSSI/noise via `ipconfig getsummary <device>`.
144/// Returns ((rssi, noise), elapsed_ns) on success, or just elapsed_ns on failure.
145fn 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
165/// Try to read RSSI/noise via the `airport -I` command.
166/// Returns ((rssi, noise), elapsed_ns) on success, or just elapsed_ns on failure.
167fn 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
187/// Parse a line of the form `  key: value` or `key : value` and return the
188/// integer value.  Handles negative numbers.
189fn parse_field_value(text: &str, field: &str) -> Option<i32> {
190    for line in text.lines() {
191        let trimmed = line.trim();
192        // Match "FIELD : VALUE" or "FIELD: VALUE"
193        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
206/// Take a single RSSI/noise measurement using the best available method.
207/// Always returns timing even if RSSI reading fails (for timing entropy).
208fn 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        // Available if we got a real RSSI (not the zero fallback)
248        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        // Collect bursts of measurements. Always extract timing entropy
258        // even if RSSI reading fails (timeout timing is still entropic).
259        // Cap at 4 bursts since each measurement uses commands with timeouts.
260        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                // Always extract timing entropy (works even on timeout)
274                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                // Extract RSSI/noise if we got real values
281                if m.rssi != 0 || m.noise != 0 {
282                    raw.push(m.rssi as u8);
283                    raw.push(m.noise as u8);
284                }
285
286                // Deltas from previous measurement
287                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}