Skip to main content

openentropy_core/sources/network/
wifi_rssi.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: 0.5,
47    composite: false,
48    is_fast: false,
49};
50
51impl WiFiRSSISource {
52    pub fn new() -> Self {
53        Self
54    }
55}
56
57impl Default for WiFiRSSISource {
58    fn default() -> Self {
59        Self::new()
60    }
61}
62
63/// A single RSSI/noise measurement.
64#[derive(Debug, Clone, Copy)]
65struct WifiMeasurement {
66    rssi: i32,
67    noise: i32,
68    /// Nanoseconds taken to perform the measurement.
69    timing_nanos: u128,
70}
71
72/// Run a command with a timeout. Returns (stdout_option, elapsed_ns).
73/// Always returns elapsed time even if the command fails or times out.
74fn run_command_timed(cmd: &str, args: &[&str], timeout: Duration) -> (Option<String>, u64) {
75    let t0 = Instant::now();
76
77    let child = Command::new(cmd)
78        .args(args)
79        .stdout(std::process::Stdio::piped())
80        .stderr(std::process::Stdio::null())
81        .spawn();
82
83    let mut child = match child {
84        Ok(c) => c,
85        Err(_) => return (None, t0.elapsed().as_nanos() as u64),
86    };
87
88    let deadline = Instant::now() + timeout;
89    loop {
90        match child.try_wait() {
91            Ok(Some(status)) => {
92                let elapsed = t0.elapsed().as_nanos() as u64;
93                if !status.success() {
94                    return (None, elapsed);
95                }
96                // Child already reaped by try_wait — read stdout directly
97                // (wait_with_output() would call waitpid again, getting empty output).
98                use std::io::Read;
99                let mut stdout_str = String::new();
100                if let Some(mut out) = child.stdout.take() {
101                    let _ = out.read_to_string(&mut stdout_str);
102                }
103                let result = if stdout_str.is_empty() {
104                    None
105                } else {
106                    Some(stdout_str)
107                };
108                return (result, elapsed);
109            }
110            Ok(None) => {
111                if Instant::now() >= deadline {
112                    let _ = child.kill();
113                    let _ = child.wait();
114                    return (None, t0.elapsed().as_nanos() as u64);
115                }
116                std::thread::sleep(Duration::from_millis(50));
117            }
118            Err(_) => return (None, t0.elapsed().as_nanos() as u64),
119        }
120    }
121}
122
123/// Discover the Wi-Fi hardware device name (e.g. "en0") by parsing
124/// `networksetup -listallhardwareports`.
125fn discover_wifi_device() -> Option<String> {
126    let (output, _) = run_command_timed(
127        "/usr/sbin/networksetup",
128        &["-listallhardwareports"],
129        WIFI_COMMAND_TIMEOUT,
130    );
131
132    let text = output?;
133    let mut found_wifi = false;
134
135    for line in text.lines() {
136        if line.contains("Wi-Fi") || line.contains("AirPort") {
137            found_wifi = true;
138            continue;
139        }
140        if found_wifi && line.starts_with("Device:") {
141            let device = line.trim_start_matches("Device:").trim();
142            if !device.is_empty() {
143                return Some(device.to_string());
144            }
145        }
146        // Reset if we hit the next hardware port block without finding a device
147        if found_wifi && line.starts_with("Hardware Port:") {
148            found_wifi = false;
149        }
150    }
151    None
152}
153
154/// Try to read RSSI/noise via `ipconfig getsummary <device>`.
155/// Returns ((rssi, noise), elapsed_ns) on success, or just elapsed_ns on failure.
156fn read_via_ipconfig(device: &str) -> (Option<(i32, i32)>, u64) {
157    let (output, elapsed) = run_command_timed(
158        "/usr/sbin/ipconfig",
159        &["getsummary", device],
160        WIFI_COMMAND_TIMEOUT,
161    );
162
163    let text = match output {
164        Some(t) => t,
165        None => return (None, elapsed),
166    };
167
168    let rssi = match parse_field_value(&text, "RSSI") {
169        Some(v) => v,
170        None => return (None, elapsed),
171    };
172    let noise = parse_field_value(&text, "Noise").unwrap_or(rssi - 30);
173    (Some((rssi, noise)), elapsed)
174}
175
176/// Try to read RSSI/noise via the `airport -I` command.
177/// Returns ((rssi, noise), elapsed_ns) on success, or just elapsed_ns on failure.
178fn read_via_airport() -> (Option<(i32, i32)>, u64) {
179    let (output, elapsed) = run_command_timed(
180        "/System/Library/PrivateFrameworks/Apple80211.framework/Versions/Current/Resources/airport",
181        &["-I"],
182        WIFI_COMMAND_TIMEOUT,
183    );
184
185    let text = match output {
186        Some(t) => t,
187        None => return (None, elapsed),
188    };
189
190    let rssi = match parse_field_value(&text, "agrCtlRSSI") {
191        Some(v) => v,
192        None => return (None, elapsed),
193    };
194    let noise = parse_field_value(&text, "agrCtlNoise").unwrap_or(rssi - 30);
195    (Some((rssi, noise)), elapsed)
196}
197
198/// Parse a line of the form `  key: value` or `key : value` and return the
199/// integer value.  Handles negative numbers.
200fn parse_field_value(text: &str, field: &str) -> Option<i32> {
201    for line in text.lines() {
202        let trimmed = line.trim();
203        // Match "FIELD : VALUE" or "FIELD: VALUE"
204        if let Some(rest) = trimmed.strip_prefix(field) {
205            let rest = rest.trim_start();
206            if let Some(val_str) = rest.strip_prefix(':') {
207                let val_str = val_str.trim();
208                if let Ok(v) = val_str.parse::<i32>() {
209                    return Some(v);
210                }
211            }
212        }
213    }
214    None
215}
216
217/// Take a single RSSI/noise measurement using the best available method.
218/// Always returns timing even if RSSI reading fails (for timing entropy).
219fn measure_once(device: &Option<String>) -> WifiMeasurement {
220    let start = Instant::now();
221
222    let result = if let Some(dev) = device {
223        let (ipconfig_result, elapsed1) = read_via_ipconfig(dev);
224        if let Some(vals) = ipconfig_result {
225            Some((vals, elapsed1))
226        } else {
227            let (airport_result, elapsed2) = read_via_airport();
228            airport_result.map(|vals| (vals, elapsed1 + elapsed2))
229        }
230    } else {
231        let (airport_result, elapsed) = read_via_airport();
232        airport_result.map(|vals| (vals, elapsed))
233    };
234
235    let timing_nanos = start.elapsed().as_nanos();
236    match result {
237        Some(((rssi, noise), _)) => WifiMeasurement {
238            rssi,
239            noise,
240            timing_nanos,
241        },
242        None => WifiMeasurement {
243            rssi: 0,
244            noise: 0,
245            timing_nanos,
246        },
247    }
248}
249
250impl EntropySource for WiFiRSSISource {
251    fn info(&self) -> &SourceInfo {
252        &WIFI_RSSI_INFO
253    }
254
255    fn is_available(&self) -> bool {
256        if !cfg!(target_os = "macos") {
257            return false;
258        }
259        let device = discover_wifi_device();
260        let m = measure_once(&device);
261        // Available if we got a real RSSI (not the zero fallback)
262        m.rssi != 0 || m.noise != 0
263    }
264
265    fn collect(&self, n_samples: usize) -> Vec<u8> {
266        let mut raw = Vec::with_capacity(n_samples * 4);
267        let device = discover_wifi_device();
268
269        let mut measurements = Vec::with_capacity(SAMPLES_PER_COLLECT);
270
271        // Collect bursts of measurements. Always extract timing entropy
272        // even if RSSI reading fails (timeout timing is still entropic).
273        // Cap at 4 bursts since each measurement uses commands with timeouts.
274        let max_bursts = 4;
275        for _ in 0..max_bursts {
276            measurements.clear();
277
278            for _ in 0..SAMPLES_PER_COLLECT {
279                let m = measure_once(&device);
280                measurements.push(m);
281                thread::sleep(MEASUREMENT_DELAY);
282            }
283
284            for i in 0..measurements.len() {
285                let m = &measurements[i];
286
287                // Always extract timing entropy (works even on timeout)
288                let t_bytes = m.timing_nanos.to_le_bytes();
289                raw.push(t_bytes[0]);
290                raw.push(t_bytes[1]);
291                raw.push(t_bytes[2]);
292                raw.push(t_bytes[3]);
293
294                // Extract RSSI/noise if we got real values
295                if m.rssi != 0 || m.noise != 0 {
296                    raw.push(m.rssi as u8);
297                    raw.push(m.noise as u8);
298                }
299
300                // Deltas from previous measurement
301                if i > 0 {
302                    let prev = &measurements[i - 1];
303                    raw.push((m.rssi.wrapping_sub(prev.rssi)) as u8);
304                    let timing_delta = m.timing_nanos.abs_diff(prev.timing_nanos);
305                    raw.push(timing_delta.to_le_bytes()[0]);
306                    raw.push((m.rssi ^ m.noise) as u8);
307                }
308            }
309
310            if raw.len() >= n_samples * 2 {
311                break;
312            }
313        }
314
315        raw.truncate(n_samples);
316        raw
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    #[test]
325    fn parse_rssi_from_airport_output() {
326        let sample = "\
327             agrCtlRSSI: -62\n\
328             agrCtlNoise: -90\n\
329             state: running\n";
330        assert_eq!(parse_field_value(sample, "agrCtlRSSI"), Some(-62));
331        assert_eq!(parse_field_value(sample, "agrCtlNoise"), Some(-90));
332    }
333
334    #[test]
335    fn parse_rssi_from_ipconfig_output() {
336        let sample = "\
337             SSID : MyNetwork\n\
338             RSSI : -55\n\
339             Noise : -88\n";
340        assert_eq!(parse_field_value(sample, "RSSI"), Some(-55));
341        assert_eq!(parse_field_value(sample, "Noise"), Some(-88));
342    }
343
344    #[test]
345    fn parse_field_missing() {
346        assert_eq!(parse_field_value("nothing here", "RSSI"), None);
347    }
348
349    #[test]
350    fn source_info() {
351        let src = WiFiRSSISource::new();
352        assert_eq!(src.info().name, "wifi_rssi");
353        assert_eq!(src.info().category, SourceCategory::Network);
354        assert!((src.info().entropy_rate_estimate - 0.5).abs() < f64::EPSILON);
355        assert_eq!(src.info().platform, Platform::MacOS);
356        assert_eq!(src.info().requirements, &[Requirement::Wifi]);
357        assert!(!src.info().composite);
358    }
359
360    #[test]
361    #[cfg(target_os = "macos")]
362    #[ignore] // Requires WiFi hardware
363    fn wifi_rssi_collects_bytes() {
364        let src = WiFiRSSISource::new();
365        if src.is_available() {
366            let data = src.collect(32);
367            assert!(!data.is_empty());
368            assert!(data.len() <= 32);
369        }
370    }
371}