Skip to main content

openentropy_core/sources/
novel.rs

1//! Novel entropy sources: dispatch queue scheduling, dynamic linker timing,
2//! VM page fault timing, and Spotlight metadata query timing.
3
4use std::process::Command;
5use std::ptr;
6use std::sync::mpsc;
7use std::thread;
8use std::time::{Duration, Instant};
9
10use crate::source::{EntropySource, SourceCategory, SourceInfo};
11
12use super::helpers::extract_timing_entropy;
13
14// ---------------------------------------------------------------------------
15// DispatchQueueSource
16// ---------------------------------------------------------------------------
17
18static DISPATCH_QUEUE_INFO: SourceInfo = SourceInfo {
19    name: "dispatch_queue",
20    description: "Thread scheduling latency jitter from concurrent dispatch queue operations",
21    physics: "Submits blocks to GCD (Grand Central Dispatch) queues and measures scheduling \
22              latency. macOS dynamically migrates work between P-cores (performance) and \
23              E-cores (efficiency) based on thermal state and load. The migration decisions, \
24              queue priority inversions, and QoS tier scheduling create non-deterministic \
25              dispatch timing.",
26    category: SourceCategory::Novel,
27    platform_requirements: &[],
28    entropy_rate_estimate: 1500.0,
29    composite: false,
30};
31
32/// Entropy source that harvests scheduling latency jitter from worker thread
33/// dispatch via MPSC channels (analogous to GCD queue dispatch).
34pub struct DispatchQueueSource;
35
36impl EntropySource for DispatchQueueSource {
37    fn info(&self) -> &SourceInfo {
38        &DISPATCH_QUEUE_INFO
39    }
40
41    fn is_available(&self) -> bool {
42        true
43    }
44
45    fn collect(&self, n_samples: usize) -> Vec<u8> {
46        let raw_count = n_samples * 10 + 64;
47        let mut timings: Vec<u64> = Vec::with_capacity(raw_count);
48
49        // Create 4 worker threads with MPSC channels.
50        let num_workers = 4;
51        let mut senders: Vec<mpsc::Sender<Instant>> = Vec::with_capacity(num_workers);
52        let (result_tx, result_rx) = mpsc::channel::<u64>();
53
54        for _ in 0..num_workers {
55            let (tx, rx) = mpsc::channel::<Instant>();
56            let rtx = result_tx.clone();
57            senders.push(tx);
58
59            thread::spawn(move || {
60                while let Ok(sent_at) = rx.recv() {
61                    // Measure scheduling latency: time from send to receive.
62                    let latency_ns = sent_at.elapsed().as_nanos() as u64;
63                    if rtx.send(latency_ns).is_err() {
64                        break;
65                    }
66                }
67            });
68        }
69
70        // Submit items to workers round-robin and collect scheduling latencies.
71        for i in 0..raw_count {
72            let worker_idx = i % num_workers;
73            let sent_at = Instant::now();
74            if senders[worker_idx].send(sent_at).is_err() {
75                break;
76            }
77            match result_rx.recv() {
78                Ok(latency_ns) => timings.push(latency_ns),
79                Err(_) => break,
80            }
81        }
82
83        // Drop senders to signal workers to exit.
84        drop(senders);
85
86        extract_timing_entropy(&timings, n_samples)
87    }
88}
89
90// ---------------------------------------------------------------------------
91// DyldTimingSource
92// ---------------------------------------------------------------------------
93
94/// Libraries to cycle through on macOS.
95#[cfg(target_os = "macos")]
96const DYLD_LIBRARIES: &[&str] = &[
97    "libz.dylib",
98    "libc++.dylib",
99    "libobjc.dylib",
100    "libSystem.B.dylib",
101];
102
103/// Libraries to cycle through on Linux.
104#[cfg(target_os = "linux")]
105const DYLD_LIBRARIES: &[&str] = &["libc.so.6", "libm.so.6"];
106
107/// Fallback for other platforms.
108#[cfg(not(any(target_os = "macos", target_os = "linux")))]
109const DYLD_LIBRARIES: &[&str] = &[];
110
111static DYLD_TIMING_INFO: SourceInfo = SourceInfo {
112    name: "dyld_timing",
113    description: "Dynamic library loading (dlopen/dlsym) timing jitter",
114    physics: "Times dynamic library loading (dlopen/dlsym) which requires: searching the \
115              dyld shared cache, resolving symbol tables, rebasing pointers, and running \
116              initializers. The timing varies with: shared cache page residency (depends on \
117              what other apps have loaded), ASLR randomization, and filesystem metadata \
118              cache state.",
119    category: SourceCategory::Novel,
120    platform_requirements: &[],
121    entropy_rate_estimate: 1200.0,
122    composite: false,
123};
124
125/// Entropy source that harvests timing jitter from dynamic library loading.
126pub struct DyldTimingSource;
127
128impl EntropySource for DyldTimingSource {
129    fn info(&self) -> &SourceInfo {
130        &DYLD_TIMING_INFO
131    }
132
133    fn is_available(&self) -> bool {
134        !DYLD_LIBRARIES.is_empty()
135    }
136
137    fn collect(&self, n_samples: usize) -> Vec<u8> {
138        if DYLD_LIBRARIES.is_empty() {
139            return Vec::new();
140        }
141
142        let raw_count = n_samples * 10 + 64;
143        let mut timings: Vec<u64> = Vec::with_capacity(raw_count);
144        let lib_count = DYLD_LIBRARIES.len();
145
146        for i in 0..raw_count {
147            let lib_name = DYLD_LIBRARIES[i % lib_count];
148
149            // Measure the time to load and immediately unload a system library.
150            let t0 = Instant::now();
151
152            // SAFETY: We are loading well-known system libraries.
153            let result = unsafe { libloading::Library::new(lib_name) };
154            if let Ok(lib) = result {
155                // The library is dropped (unloaded) at end of this scope.
156                std::hint::black_box(&lib);
157                drop(lib);
158            }
159
160            let elapsed_ns = t0.elapsed().as_nanos() as u64;
161            timings.push(elapsed_ns);
162        }
163
164        extract_timing_entropy(&timings, n_samples)
165    }
166}
167
168// ---------------------------------------------------------------------------
169// VMPageTimingSource
170// ---------------------------------------------------------------------------
171
172/// Page size for mmap allocations.
173const PAGE_SIZE: usize = 4096;
174
175static VM_PAGE_TIMING_INFO: SourceInfo = SourceInfo {
176    name: "vm_page_timing",
177    description: "Mach VM page fault timing jitter from mmap/munmap cycles",
178    physics: "Times Mach VM operations (mmap/munmap cycles). Each operation requires: \
179              VM map entry allocation, page table updates, TLB shootdown across cores \
180              (IPI interrupt), and physical page management. The timing depends on: \
181              VM map fragmentation, physical memory pressure, and cross-core \
182              synchronization latency.",
183    category: SourceCategory::Novel,
184    platform_requirements: &[],
185    entropy_rate_estimate: 1300.0,
186    composite: false,
187};
188
189/// Entropy source that harvests timing jitter from VM page allocation/deallocation.
190pub struct VMPageTimingSource;
191
192impl EntropySource for VMPageTimingSource {
193    fn info(&self) -> &SourceInfo {
194        &VM_PAGE_TIMING_INFO
195    }
196
197    fn is_available(&self) -> bool {
198        cfg!(unix)
199    }
200
201    fn collect(&self, n_samples: usize) -> Vec<u8> {
202        let raw_count = n_samples * 10 + 64;
203        let mut timings: Vec<u64> = Vec::with_capacity(raw_count);
204
205        for _ in 0..raw_count {
206            let t0 = Instant::now();
207
208            // SAFETY: mmap with MAP_ANONYMOUS|MAP_PRIVATE creates a private anonymous
209            // mapping. We check for MAP_FAILED before using the returned address.
210            let addr = unsafe {
211                libc::mmap(
212                    ptr::null_mut(),
213                    PAGE_SIZE,
214                    libc::PROT_READ | libc::PROT_WRITE,
215                    libc::MAP_ANONYMOUS | libc::MAP_PRIVATE,
216                    -1,
217                    0,
218                )
219            };
220
221            if addr == libc::MAP_FAILED {
222                continue;
223            }
224
225            // SAFETY: addr is a valid mmap'd region of PAGE_SIZE bytes (checked
226            // != MAP_FAILED). Writes are within bounds of the mapping.
227            unsafe {
228                ptr::write_volatile(addr as *mut u8, 0xBE);
229                ptr::write_volatile((addr as *mut u8).add(PAGE_SIZE - 1), 0xEF);
230
231                // Read back to force a full round-trip.
232                let _v = ptr::read_volatile(addr as *const u8);
233            }
234
235            // SAFETY: addr was returned by mmap (checked != MAP_FAILED) with size PAGE_SIZE.
236            unsafe {
237                libc::munmap(addr, PAGE_SIZE);
238            }
239
240            let elapsed_ns = t0.elapsed().as_nanos() as u64;
241            timings.push(elapsed_ns);
242        }
243
244        extract_timing_entropy(&timings, n_samples)
245    }
246}
247
248// ---------------------------------------------------------------------------
249// SpotlightTimingSource
250// ---------------------------------------------------------------------------
251
252/// Files to query via mdls, cycling through them.
253const SPOTLIGHT_FILES: &[&str] = &[
254    "/usr/bin/true",
255    "/usr/bin/false",
256    "/usr/bin/env",
257    "/usr/bin/which",
258];
259
260/// Path to the mdls binary.
261const MDLS_PATH: &str = "/usr/bin/mdls";
262
263/// Timeout for mdls commands.
264const MDLS_TIMEOUT: Duration = Duration::from_secs(2);
265
266static SPOTLIGHT_TIMING_INFO: SourceInfo = SourceInfo {
267    name: "spotlight_timing",
268    description: "Spotlight metadata index query timing jitter via mdls",
269    physics: "Queries Spotlight\u{2019}s metadata index (mdls) and measures response time. \
270              The index is a complex B-tree/inverted index structure. Query timing depends \
271              on: index size, disk cache residency, concurrent indexing activity, and \
272              filesystem metadata state. When Spotlight is actively indexing new files, \
273              query latency becomes highly variable.",
274    category: SourceCategory::Novel,
275    platform_requirements: &["macos"],
276    entropy_rate_estimate: 800.0,
277    composite: false,
278};
279
280/// Entropy source that harvests timing jitter from Spotlight metadata queries.
281pub struct SpotlightTimingSource;
282
283impl EntropySource for SpotlightTimingSource {
284    fn info(&self) -> &SourceInfo {
285        &SPOTLIGHT_TIMING_INFO
286    }
287
288    fn is_available(&self) -> bool {
289        std::path::Path::new(MDLS_PATH).exists()
290    }
291
292    fn collect(&self, n_samples: usize) -> Vec<u8> {
293        // Cap the number of mdls calls since each has a 2s timeout.
294        // mdls usually completes fast (~5ms), so 200 calls is ~1s normally.
295        // If mdls hangs, 200 * 2s = 400s is too long, so also add an
296        // early-exit when we have enough raw timing data.
297        let raw_count = (n_samples * 10 + 64).min(200);
298        let mut timings: Vec<u64> = Vec::with_capacity(raw_count);
299        let file_count = SPOTLIGHT_FILES.len();
300
301        for i in 0..raw_count {
302            let file = SPOTLIGHT_FILES[i % file_count];
303
304            // Measure the time to query Spotlight metadata with a timeout.
305            // Even timeouts produce useful timing entropy.
306            let t0 = Instant::now();
307
308            let child = Command::new(MDLS_PATH)
309                .args(["-name", "kMDItemFSName", file])
310                .stdout(std::process::Stdio::null())
311                .stderr(std::process::Stdio::null())
312                .spawn();
313
314            if let Ok(mut child) = child {
315                let deadline = Instant::now() + MDLS_TIMEOUT;
316                loop {
317                    match child.try_wait() {
318                        Ok(Some(_)) => break,
319                        Ok(None) => {
320                            if Instant::now() >= deadline {
321                                let _ = child.kill();
322                                let _ = child.wait();
323                                break;
324                            }
325                            std::thread::sleep(Duration::from_millis(10));
326                        }
327                        Err(_) => break,
328                    }
329                }
330            }
331
332            // Always record timing — timeouts are just as entropic.
333            let elapsed_ns = t0.elapsed().as_nanos() as u64;
334            timings.push(elapsed_ns);
335        }
336
337        extract_timing_entropy(&timings, n_samples)
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::super::helpers::extract_lsbs_u64;
344    use super::*;
345
346    #[test]
347    fn dispatch_queue_info() {
348        let src = DispatchQueueSource;
349        assert_eq!(src.name(), "dispatch_queue");
350        assert_eq!(src.info().category, SourceCategory::Novel);
351        assert!((src.info().entropy_rate_estimate - 1500.0).abs() < f64::EPSILON);
352    }
353
354    #[test]
355    #[ignore] // Run with: cargo test -- --ignored
356    fn dispatch_queue_collects_bytes() {
357        let src = DispatchQueueSource;
358        assert!(src.is_available());
359        let data = src.collect(64);
360        assert!(!data.is_empty());
361        assert!(data.len() <= 64);
362    }
363
364    #[test]
365    fn dyld_timing_info() {
366        let src = DyldTimingSource;
367        assert_eq!(src.name(), "dyld_timing");
368        assert_eq!(src.info().category, SourceCategory::Novel);
369        assert!((src.info().entropy_rate_estimate - 1200.0).abs() < f64::EPSILON);
370    }
371
372    #[test]
373    fn vm_page_timing_info() {
374        let src = VMPageTimingSource;
375        assert_eq!(src.name(), "vm_page_timing");
376        assert_eq!(src.info().category, SourceCategory::Novel);
377        assert!((src.info().entropy_rate_estimate - 1300.0).abs() < f64::EPSILON);
378    }
379
380    #[test]
381    #[cfg(unix)]
382    #[ignore] // Run with: cargo test -- --ignored
383    fn vm_page_timing_collects_bytes() {
384        let src = VMPageTimingSource;
385        assert!(src.is_available());
386        let data = src.collect(64);
387        assert!(!data.is_empty());
388        assert!(data.len() <= 64);
389    }
390
391    #[test]
392    fn spotlight_timing_info() {
393        let src = SpotlightTimingSource;
394        assert_eq!(src.name(), "spotlight_timing");
395        assert_eq!(src.info().category, SourceCategory::Novel);
396        assert!((src.info().entropy_rate_estimate - 800.0).abs() < f64::EPSILON);
397    }
398
399    #[test]
400    #[cfg(target_os = "macos")]
401    #[ignore] // Run with: cargo test -- --ignored
402    fn spotlight_timing_collects_bytes() {
403        let src = SpotlightTimingSource;
404        if src.is_available() {
405            let data = src.collect(32);
406            assert!(!data.is_empty());
407            assert!(data.len() <= 32);
408        }
409    }
410
411    #[test]
412    fn extract_lsbs_packing() {
413        let deltas = vec![1u64, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 0, 0, 0, 0];
414        let bytes = extract_lsbs_u64(&deltas);
415        assert_eq!(bytes.len(), 2);
416        // First 8 bits: 1,0,1,0,1,0,1,0 -> 0xAA
417        assert_eq!(bytes[0], 0xAA);
418        // Next 8 bits: 1,1,1,1,0,0,0,0 -> 0xF0
419        assert_eq!(bytes[1], 0xF0);
420    }
421}