Skip to main content

openentropy_core/sources/frontier/
counter_beat.rs

1//! Two-oscillator beat frequency — CPU counter vs audio PLL crystal.
2//!
3//! Measures the phase difference between two physically independent oscillators:
4//! - **CNTVCT_EL0**: ARM generic timer counter, driven by the CPU's 24 MHz crystal
5//! - **Audio PLL**: the audio subsystem's independent crystal oscillator, probed
6//!   via CoreAudio property queries that force clock domain crossings
7//!
8//! The entropy arises from independent thermal noise (Johnson-Nyquist) in each
9//! crystal oscillator's sustaining circuit, causing uncorrelated phase drift
10//! between the two clock domains. This two-oscillator beat technique is used in
11//! some hardware random number generators for anomaly detection research (note:
12//! the original PEAR lab REGs used noise diodes, not oscillator beats).
13//!
14//! ## Mechanism
15//!
16//! Each sample reads CNTVCT_EL0 immediately before and after a CoreAudio property
17//! query (actual sample rate, latency) that forces synchronization with the audio
18//! PLL clock domain. The query duration in raw counter ticks is modulated by the
19//! instantaneous phase relationship between the CPU crystal and the audio PLL.
20//! XORing the raw counter value with this PLL-modulated duration produces a beat
21//! that encodes the phase difference between the two independent oscillators.
22//!
23//! ## Why this matters for anomaly detection research
24//!
25//! - **Clean physical signal**: thermal noise in crystal oscillators is as close to
26//!   quantum-origin randomness as consumer hardware provides
27//! - **High sample rate**: thousands of phase-difference samples per second
28//! - **Well-characterized physics**: crystal oscillator phase noise (Allan variance,
29//!   flicker FM, white PM) is thoroughly documented in metrology literature
30//! - **Low min-entropy is a feature**: a source with H∞ ~1–3 bits/byte is easier
31//!   to detect statistical anomalies in than one at 7.9 — useful for anomaly
32//!   detection experiments
33//!
34//! ## Previous version
35//!
36//! An earlier `counter_beat` was removed because it XOR'd CNTVCT_EL0 with
37//! `mach_absolute_time()` — which on Apple Silicon is the *same* counter, not an
38//! independent oscillator. This version fixes that by using the audio PLL as the
39//! genuinely independent second clock domain, validated by `audio_pll_timing`'s
40
41use crate::source::{EntropySource, Platform, Requirement, SourceCategory, SourceInfo};
42#[cfg(target_os = "macos")]
43use crate::sources::helpers::read_cntvct;
44#[cfg(target_os = "macos")]
45use crate::sources::helpers::xor_fold_u64;
46
47static COUNTER_BEAT_INFO: SourceInfo = SourceInfo {
48    name: "counter_beat",
49    description: "Two-oscillator beat frequency: CPU counter (CNTVCT_EL0) vs audio PLL crystal",
50    physics: "Reads the ARM generic timer counter (CNTVCT_EL0, driven by a 24 MHz crystal) \
51              immediately before and after a CoreAudio property query that forces \
52              synchronization with the audio PLL clock domain. The query duration in raw \
53              counter ticks is modulated by the instantaneous phase relationship between \
54              the CPU crystal and the independent audio PLL crystal. XORing the counter \
55              value with this PLL-modulated duration produces a two-oscillator beat that \
56              encodes the phase difference between two independent oscillators. \
57              Entropy arises from independent \
58              Johnson-Nyquist thermal noise in each crystal's sustaining amplifier. \
59              The raw physical signal is preserved for statistical analysis.
60              raw physical signal for statistical analysis.",
61    category: SourceCategory::Thermal,
62    platform: Platform::MacOS,
63    requirements: &[Requirement::AppleSilicon, Requirement::AudioUnit],
64    entropy_rate_estimate: 2000.0,
65    composite: false,
66};
67
68/// Two-oscillator beat frequency entropy source.
69///
70/// Captures the instantaneous phase difference between the CPU's ARM counter
71/// and the audio PLL clock — two physically independent crystal oscillators
72/// with uncorrelated thermal noise.
73pub struct CounterBeatSource;
74
75/// CoreAudio FFI for audio PLL clock domain crossing.
76#[cfg(target_os = "macos")]
77mod coreaudio {
78    #[repr(C)]
79    pub struct AudioObjectPropertyAddress {
80        pub m_selector: u32,
81        pub m_scope: u32,
82        pub m_element: u32,
83    }
84
85    pub const AUDIO_OBJECT_SYSTEM_OBJECT: u32 = 1;
86    pub const AUDIO_HARDWARE_PROPERTY_DEFAULT_OUTPUT_DEVICE: u32 = 0x644F7574; // 'dOut'
87    pub const AUDIO_DEVICE_PROPERTY_ACTUAL_SAMPLE_RATE: u32 = 0x61737264; // 'asrd'
88    pub const AUDIO_DEVICE_PROPERTY_LATENCY: u32 = 0x6C746E63; // 'ltnc'
89    pub const AUDIO_DEVICE_PROPERTY_NOMINAL_SAMPLE_RATE: u32 = 0x6E737274; // 'nsrt'
90    pub const AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL: u32 = 0x676C6F62; // 'glob'
91    pub const AUDIO_OBJECT_PROPERTY_ELEMENT_MAIN: u32 = 0;
92    pub const AUDIO_DEVICE_PROPERTY_SCOPE_OUTPUT: u32 = 0x6F757470; // 'outp'
93
94    #[link(name = "CoreAudio", kind = "framework")]
95    unsafe extern "C" {
96        pub fn AudioObjectGetPropertyData(
97            object_id: u32,
98            address: *const AudioObjectPropertyAddress,
99            qualifier_data_size: u32,
100            qualifier_data: *const std::ffi::c_void,
101            data_size: *mut u32,
102            data: *mut std::ffi::c_void,
103        ) -> i32;
104    }
105
106    /// Get the default output audio device ID, or 0 if none.
107    pub fn get_default_output_device() -> u32 {
108        let addr = AudioObjectPropertyAddress {
109            m_selector: AUDIO_HARDWARE_PROPERTY_DEFAULT_OUTPUT_DEVICE,
110            m_scope: AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL,
111            m_element: AUDIO_OBJECT_PROPERTY_ELEMENT_MAIN,
112        };
113        let mut device: u32 = 0;
114        let mut size: u32 = std::mem::size_of::<u32>() as u32;
115        // SAFETY: AudioObjectGetPropertyData reads a property from the system
116        // audio object. We pass valid pointers with correct sizes.
117        let status = unsafe {
118            AudioObjectGetPropertyData(
119                AUDIO_OBJECT_SYSTEM_OBJECT,
120                &addr,
121                0,
122                std::ptr::null(),
123                &mut size,
124                &mut device as *mut u32 as *mut std::ffi::c_void,
125            )
126        };
127        if status == 0 { device } else { 0 }
128    }
129
130    /// Force a clock domain crossing by querying an audio device property.
131    ///
132    /// Returns the raw bytes read from the audio subsystem (we don't care about
133    /// the value — the point is forcing the CPU to synchronize with the audio PLL).
134    pub fn query_audio_property(device: u32, selector: u32, scope: u32) {
135        let addr = AudioObjectPropertyAddress {
136            m_selector: selector,
137            m_scope: scope,
138            m_element: AUDIO_OBJECT_PROPERTY_ELEMENT_MAIN,
139        };
140        let mut data = [0u8; 8];
141        let mut size: u32 = 8;
142        // SAFETY: AudioObjectGetPropertyData reads a property from a valid audio device.
143        // `data` is an 8-byte stack buffer sufficient for all queried properties.
144        unsafe {
145            AudioObjectGetPropertyData(
146                device,
147                &addr,
148                0,
149                std::ptr::null(),
150                &mut size,
151                data.as_mut_ptr() as *mut std::ffi::c_void,
152            );
153        }
154        // Prevent the compiler from optimizing away the query.
155        std::hint::black_box(data);
156    }
157}
158
159impl EntropySource for CounterBeatSource {
160    fn info(&self) -> &SourceInfo {
161        &COUNTER_BEAT_INFO
162    }
163
164    fn is_available(&self) -> bool {
165        #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
166        {
167            coreaudio::get_default_output_device() != 0
168        }
169        #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
170        {
171            false
172        }
173    }
174
175    fn collect(&self, n_samples: usize) -> Vec<u8> {
176        #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
177        {
178            let _ = n_samples;
179            Vec::new()
180        }
181
182        #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
183        {
184            let device = coreaudio::get_default_output_device();
185            if device == 0 {
186                return Vec::new();
187            }
188
189            // Cycle through different audio property queries to exercise
190            // different code paths crossing the PLL clock domain boundary.
191            let selectors = [
192                (
193                    coreaudio::AUDIO_DEVICE_PROPERTY_ACTUAL_SAMPLE_RATE,
194                    coreaudio::AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL,
195                ),
196                (
197                    coreaudio::AUDIO_DEVICE_PROPERTY_LATENCY,
198                    coreaudio::AUDIO_DEVICE_PROPERTY_SCOPE_OUTPUT,
199                ),
200                (
201                    coreaudio::AUDIO_DEVICE_PROPERTY_NOMINAL_SAMPLE_RATE,
202                    coreaudio::AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL,
203                ),
204            ];
205
206            // Over-collect: delta + XOR + fold reduces count.
207            let raw_count = n_samples * 4 + 64;
208            let mut beats: Vec<u64> = Vec::with_capacity(raw_count);
209
210            for i in 0..raw_count {
211                let (sel, scope) = selectors[i % selectors.len()];
212
213                // Read CNTVCT_EL0 immediately before the audio PLL crossing.
214                let counter_before = read_cntvct();
215
216                // Force a clock domain crossing into the audio PLL.
217                coreaudio::query_audio_property(device, sel, scope);
218
219                // Read CNTVCT_EL0 immediately after.
220                let counter_after = read_cntvct();
221
222                // The beat: XOR the raw counter value (CPU oscillator phase)
223                // with the PLL-modulated duration (audio oscillator phase).
224                // The duration encodes how long the CPU had to wait for the
225                // audio PLL to respond — modulated by the instantaneous phase
226                // relationship between the two independent crystals.
227                let pll_duration = counter_after.wrapping_sub(counter_before);
228                let beat = counter_before ^ pll_duration;
229                beats.push(beat);
230            }
231
232            if beats.len() < 4 {
233                return Vec::new();
234            }
235
236            // Extract entropy: consecutive beat differences capture the phase
237            // drift rate, then XOR adjacent deltas and fold to bytes.
238            let deltas: Vec<u64> = beats.windows(2).map(|w| w[1].wrapping_sub(w[0])).collect();
239
240            let xored: Vec<u64> = deltas.windows(2).map(|w| w[0] ^ w[1]).collect();
241
242            let mut output: Vec<u8> = xored.iter().map(|&x| xor_fold_u64(x)).collect();
243            output.truncate(n_samples);
244            output
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn info() {
255        let src = CounterBeatSource;
256        assert_eq!(src.name(), "counter_beat");
257        assert_eq!(src.info().category, SourceCategory::Thermal);
258        assert!(!src.info().composite);
259    }
260
261    #[test]
262    fn physics_mentions_two_oscillators() {
263        let src = CounterBeatSource;
264        assert!(src.info().physics.contains("CNTVCT_EL0"));
265        assert!(src.info().physics.contains("two-oscillator"));
266        assert!(src.info().physics.contains("phase difference"));
267    }
268
269    #[test]
270    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
271    fn cntvct_is_nonzero() {
272        let v = read_cntvct();
273        assert!(v > 0);
274    }
275
276    #[test]
277    #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
278    #[ignore] // Requires audio hardware
279    fn collects_bytes() {
280        let src = CounterBeatSource;
281        if src.is_available() {
282            let data = src.collect(64);
283            assert!(!data.is_empty());
284            assert!(data.len() <= 64);
285        }
286    }
287}