openentropy_core/sources/thermal/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 a genuine
26//! physical noise source independent of software state
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//! confirmation that CoreAudio queries cross an independent PLL clock domain.
41
42use crate::source::{EntropySource, Platform, Requirement, SourceCategory, SourceInfo};
43#[cfg(target_os = "macos")]
44use crate::sources::helpers::read_cntvct;
45#[cfg(target_os = "macos")]
46use crate::sources::helpers::xor_fold_u64;
47
48static COUNTER_BEAT_INFO: SourceInfo = SourceInfo {
49 name: "counter_beat",
50 description: "Two-oscillator beat frequency: CPU counter (CNTVCT_EL0) vs audio PLL crystal",
51 physics: "Reads the ARM generic timer counter (CNTVCT_EL0, driven by a 24 MHz crystal) \
52 immediately before and after a CoreAudio property query that forces \
53 synchronization with the audio PLL clock domain. The query duration in raw \
54 counter ticks is modulated by the instantaneous phase relationship between \
55 the CPU crystal and the independent audio PLL crystal. XORing the counter \
56 value with this PLL-modulated duration produces a two-oscillator beat that \
57 encodes the phase difference between two independent oscillators. \
58 Entropy arises from independent \
59 Johnson-Nyquist thermal noise in each crystal's sustaining amplifier. \
60 The raw physical signal is preserved for statistical analysis.",
61 category: SourceCategory::Thermal,
62 platform: Platform::MacOS,
63 requirements: &[Requirement::AppleSilicon, Requirement::AudioUnit],
64 entropy_rate_estimate: 3.0,
65 composite: false,
66 is_fast: false,
67};
68
69/// Two-oscillator beat frequency entropy source.
70///
71/// Captures the instantaneous phase difference between the CPU's ARM counter
72/// and the audio PLL clock — two physically independent crystal oscillators
73/// with uncorrelated thermal noise.
74pub struct CounterBeatSource;
75
76impl EntropySource for CounterBeatSource {
77 fn info(&self) -> &SourceInfo {
78 &COUNTER_BEAT_INFO
79 }
80
81 fn is_available(&self) -> bool {
82 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
83 {
84 super::coreaudio_ffi::get_default_output_device() != 0
85 }
86 #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
87 {
88 false
89 }
90 }
91
92 fn collect(&self, n_samples: usize) -> Vec<u8> {
93 #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))]
94 {
95 let _ = n_samples;
96 Vec::new()
97 }
98
99 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
100 {
101 use super::coreaudio_ffi;
102
103 let device = coreaudio_ffi::get_default_output_device();
104 if device == 0 {
105 return Vec::new();
106 }
107
108 // Cycle through different audio property queries to exercise
109 // different code paths crossing the PLL clock domain boundary.
110 let selectors = [
111 (
112 coreaudio_ffi::AUDIO_DEVICE_PROPERTY_ACTUAL_SAMPLE_RATE,
113 coreaudio_ffi::AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL,
114 ),
115 (
116 coreaudio_ffi::AUDIO_DEVICE_PROPERTY_LATENCY,
117 coreaudio_ffi::AUDIO_DEVICE_PROPERTY_SCOPE_OUTPUT,
118 ),
119 (
120 coreaudio_ffi::AUDIO_DEVICE_PROPERTY_NOMINAL_SAMPLE_RATE,
121 coreaudio_ffi::AUDIO_OBJECT_PROPERTY_SCOPE_GLOBAL,
122 ),
123 ];
124
125 // Over-collect: delta + XOR + fold reduces count.
126 let raw_count = n_samples * 4 + 64;
127 let mut beats: Vec<u64> = Vec::with_capacity(raw_count);
128
129 for i in 0..raw_count {
130 let (sel, scope) = selectors[i % selectors.len()];
131
132 // Read CNTVCT_EL0 immediately before the audio PLL crossing.
133 let counter_before = read_cntvct();
134
135 // Force a clock domain crossing into the audio PLL.
136 coreaudio_ffi::query_device_property_timed(device, sel, scope);
137
138 // Read CNTVCT_EL0 immediately after.
139 let counter_after = read_cntvct();
140
141 // The beat: XOR the raw counter value (CPU oscillator phase)
142 // with the PLL-modulated duration (audio oscillator phase).
143 // The duration encodes how long the CPU had to wait for the
144 // audio PLL to respond — modulated by the instantaneous phase
145 // relationship between the two independent crystals.
146 let pll_duration = counter_after.wrapping_sub(counter_before);
147 let beat = counter_before ^ pll_duration;
148 beats.push(beat);
149 }
150
151 if beats.len() < 4 {
152 return Vec::new();
153 }
154
155 // Extract entropy: consecutive beat differences capture the phase
156 // drift rate, then XOR adjacent deltas and fold to bytes.
157 let deltas: Vec<u64> = beats.windows(2).map(|w| w[1].wrapping_sub(w[0])).collect();
158
159 let xored: Vec<u64> = deltas.windows(2).map(|w| w[0] ^ w[1]).collect();
160
161 let mut output: Vec<u8> = xored.iter().map(|&x| xor_fold_u64(x)).collect();
162 output.truncate(n_samples);
163 output
164 }
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn info() {
174 let src = CounterBeatSource;
175 assert_eq!(src.name(), "counter_beat");
176 assert_eq!(src.info().category, SourceCategory::Thermal);
177 assert!(!src.info().composite);
178 }
179
180 #[test]
181 fn physics_mentions_two_oscillators() {
182 let src = CounterBeatSource;
183 assert!(src.info().physics.contains("CNTVCT_EL0"));
184 assert!(src.info().physics.contains("two-oscillator"));
185 assert!(src.info().physics.contains("phase difference"));
186 }
187
188 #[test]
189 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
190 fn cntvct_is_nonzero() {
191 let v = read_cntvct();
192 assert!(v > 0);
193 }
194
195 #[test]
196 #[cfg(all(target_os = "macos", target_arch = "aarch64"))]
197 #[ignore] // Requires audio hardware
198 fn collects_bytes() {
199 let src = CounterBeatSource;
200 if src.is_available() {
201 let data = src.collect(64);
202 assert!(!data.is_empty());
203 assert!(data.len() <= 64);
204 }
205 }
206}