Skip to main content

openentropy_core/sources/
sensor.rs

1//! SensorNoiseSource — MEMS sensor noise via ioreg.
2//!
3//! Queries the I/O Registry for motion sensor data (accelerometer, etc.),
4//! parses numeric values from the output, and extracts changing values
5//! as entropy. Even at rest, MEMS sensors exhibit Brownian motion of the
6//! proof mass and thermo-mechanical noise.
7
8use std::collections::HashMap;
9use std::process::Command;
10use std::thread;
11use std::time::Duration;
12
13use crate::source::{EntropySource, SourceCategory, SourceInfo};
14
15/// Delay between ioreg snapshots to observe sensor value changes.
16const SNAPSHOT_DELAY: Duration = Duration::from_millis(50);
17
18static SENSOR_NOISE_INFO: SourceInfo = SourceInfo {
19    name: "sensor_noise",
20    description: "MEMS accelerometer/gyro noise via ioreg",
21    physics: "Reads accelerometer, gyroscope, and magnetometer via CoreMotion. Even at rest, \
22              MEMS sensors exhibit: Brownian motion of the proof mass, thermo-mechanical noise, \
23              electronic 1/f noise, and quantization noise. The MacBook's accelerometer detects \
24              micro-vibrations from fans, disk, and building structure.",
25    category: SourceCategory::Hardware,
26    platform_requirements: &["macos"],
27    entropy_rate_estimate: 100.0,
28    composite: false,
29};
30
31/// Entropy source that harvests noise from MEMS motion sensors.
32pub struct SensorNoiseSource;
33
34/// Parse numeric values from ioreg output. Returns a map of key -> value
35/// for lines that contain numeric data.
36fn parse_ioreg_numerics(output: &str) -> HashMap<String, i64> {
37    let mut map = HashMap::new();
38
39    for line in output.lines() {
40        let trimmed = line.trim();
41
42        // Look for lines like: "key" = <number>
43        // ioreg format:  | |   "PropertyName" = value
44        // We need to handle the leading pipe/space tree-structure prefix.
45        if let Some(eq_idx) = trimmed.find(" = ") {
46            let raw_key = trimmed[..eq_idx].trim();
47            let val_part = trimmed[eq_idx + 3..].trim();
48
49            // Strip the ioreg tree prefix: remove leading '|', ' ', and '"' characters.
50            let key_part = raw_key
51                .trim_start_matches(['|', ' '])
52                .trim_matches('"')
53                .trim();
54
55            if key_part.is_empty() {
56                continue;
57            }
58
59            // Try to parse as integer
60            if let Ok(v) = val_part.parse::<i64>() {
61                map.insert(key_part.to_string(), v);
62            }
63        }
64    }
65
66    map
67}
68
69/// Run `ioreg -l -w0` and return the raw output.
70fn snapshot_ioreg() -> Option<String> {
71    let output = Command::new("ioreg").args(["-l", "-w0"]).output().ok()?;
72
73    if !output.status.success() {
74        return None;
75    }
76
77    Some(String::from_utf8_lossy(&output.stdout).to_string())
78}
79
80/// Check if ioreg shows any sensor-related data.
81fn has_sensor_data() -> bool {
82    let output = Command::new("ioreg").args(["-l", "-w0"]).output();
83
84    match output {
85        Ok(out) if out.status.success() => {
86            let stdout = String::from_utf8_lossy(&out.stdout);
87            // Look for motion sensor or accelerometer related entries.
88            stdout.contains("SMCMotionSensor")
89                || stdout.contains("Accelerometer")
90                || stdout.contains("accelerometer")
91                || stdout.contains("MotionSensor")
92                || stdout.contains("gyro")
93                || stdout.contains("Gyro")
94                // Also accept general sensor data — even without a dedicated
95                // motion sensor, ioreg has many changing numeric values from
96                // various hardware sensors (thermal, fan speed, etc.)
97                || stdout.contains("Temperature")
98                || stdout.contains("FanSpeed")
99        }
100        _ => false,
101    }
102}
103
104impl EntropySource for SensorNoiseSource {
105    fn info(&self) -> &SourceInfo {
106        &SENSOR_NOISE_INFO
107    }
108
109    fn is_available(&self) -> bool {
110        cfg!(target_os = "macos") && has_sensor_data()
111    }
112
113    fn collect(&self, n_samples: usize) -> Vec<u8> {
114        // Take two ioreg snapshots separated by a short delay and extract
115        // numeric values that changed between them.
116        let raw1 = match snapshot_ioreg() {
117            Some(s) => s,
118            None => return Vec::new(),
119        };
120        let snap1 = parse_ioreg_numerics(&raw1);
121
122        thread::sleep(SNAPSHOT_DELAY);
123
124        let raw2 = match snapshot_ioreg() {
125            Some(s) => s,
126            None => return Vec::new(),
127        };
128        let snap2 = parse_ioreg_numerics(&raw2);
129
130        // Find values that changed and compute deltas.
131        let mut deltas: Vec<i64> = Vec::new();
132        for (key, v2) in &snap2 {
133            if let Some(v1) = snap1.get(key) {
134                let delta = v2.wrapping_sub(*v1);
135                if delta != 0 {
136                    deltas.push(delta);
137                }
138            }
139        }
140
141        if deltas.is_empty() {
142            return Vec::new();
143        }
144
145        // Extract entropy from the deltas: XOR consecutive pairs and take LSBs.
146        let mut output = Vec::with_capacity(n_samples);
147
148        // First pass: XOR consecutive deltas for mixing.
149        let mixed: Vec<i64> = if deltas.len() >= 2 {
150            deltas.windows(2).map(|w| w[0] ^ w[1]).collect()
151        } else {
152            deltas.clone()
153        };
154
155        // Extract bytes from the mixed deltas.
156        for d in &mixed {
157            // Take the low byte.
158            output.push(*d as u8);
159            if output.len() >= n_samples {
160                break;
161            }
162            // Also take the second-lowest byte for more output.
163            output.push((*d >> 8) as u8);
164            if output.len() >= n_samples {
165                break;
166            }
167        }
168
169        // If we still don't have enough, cycle through with XOR folding.
170        if output.len() < n_samples && !output.is_empty() {
171            let base = output.clone();
172            let mut idx = 0;
173            while output.len() < n_samples {
174                let a = base[idx % base.len()];
175                let b = base[(idx + 1) % base.len()];
176                output.push(a ^ b ^ (idx as u8));
177                idx += 1;
178            }
179        }
180
181        output.truncate(n_samples);
182        output
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn sensor_noise_info() {
192        let src = SensorNoiseSource;
193        assert_eq!(src.name(), "sensor_noise");
194        assert_eq!(src.info().category, SourceCategory::Hardware);
195        assert_eq!(src.info().entropy_rate_estimate, 100.0);
196    }
197
198    #[test]
199    fn parse_ioreg_numerics_works() {
200        let sample = r#"
201        | |   "Temperature" = 45
202        | |   "FanSpeed" = 1200
203        | |   "Name" = "some string"
204        "#;
205        let map = parse_ioreg_numerics(sample);
206        assert_eq!(map.get("Temperature"), Some(&45));
207        assert_eq!(map.get("FanSpeed"), Some(&1200));
208        assert!(!map.contains_key("Name"));
209    }
210}