dsfb_rf/dna.rs
1//! Hardware DNA authentication via Allan variance fingerprinting.
2//!
3//! ## Theoretical Basis
4//!
5//! Every hardware oscillator (OCXO, TCXO, VCXO, MEMS) produces a unique
6//! combination of frequency-stability noise coefficients that can be estimated
7//! from short-term measurements of the RF residual's carrier phase. The
8//! Allan deviation σ_y(τ) evaluated at a set of averaging times
9//! {τ₁, τ₂, τ₄, τ₈, τ₁₆, τ₃₂, τ₆₄, τ₁₂₈} forms a 8-dimensional fingerprint
10//! vector that is unique to each physical oscillator at the manufacturing-
11//! process level (thermal-mechanical history, crystal cut variations, ageing
12//! state).
13//!
14//! **Authentication Protocol:**
15//! 1. During commissioning, compute the hardware DNA fingerprint and register it.
16//! 2. At each calibration epoch, compute a fresh fingerprint from the live residual.
17//! 3. Compute the cosine similarity between the fresh and registered fingerprints.
18//! 4. Authenticate if similarity > AUTHENTICATION_THRESHOLD (default 0.95).
19//!
20//! **Security Note:** This is a *physical layer* authentication signal.
21//! It detects hardware substitution (swap attack) and clock-injection spoofing
22//! by comparing the intrinsic noise signature of the oscillator, not a
23//! transmitted authentication code. It does **not** provide cryptographic
24//! guarantees and is not a replacement for link-layer authentication.
25//!
26//! ## Design
27//!
28//! - `no_std`, `no_alloc`, zero `unsafe`
29//! - Rolling circular buffer of N residual norms
30//! - Allan deviation computed at 8 averaging times via overlapping samples
31//! - Cosine similarity matching with configurable threshold
32//!
33//! ## References
34//!
35//! Allan, D.W. (1966) "Statistics of atomic frequency standards,"
36//! *Proc. IEEE* 54(2):221–230. doi:10.1109/PROC.1966.4634.
37//!
38//! IEEE Std 1139-2008, "Standard Definitions of Physical Quantities for
39//! Fundamental Frequency and Time Metrology—Random Jitter and Phase Noise."
40//!
41//! Danev, B., Zanetti, D. and Capkun, S. (2010) "On physical-layer identification
42//! of wireless devices," *IEEE TNET* 20(3):1157–1270.
43//! doi:10.1109/TNET.2012.2191619.
44
45use crate::math::sqrt_f32;
46
47// ── Constants ──────────────────────────────────────────────────────────────
48
49/// Cosine similarity threshold for authentic match (> 0.95).
50pub const AUTHENTICATION_THRESHOLD: f32 = 0.95;
51
52/// Allan deviation averaging times in samples (powers of 2: 1, 2, 4, …, 128).
53pub const ALLAN_TAUS: [u32; 8] = [1, 2, 4, 8, 16, 32, 64, 128];
54
55// ── Hardware DNA Fingerprint ───────────────────────────────────────────────
56
57/// Authentication verdict from DNA comparison.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum DnaVerdict {
60 /// Similarity ≥ AUTHENTICATION_THRESHOLD: fingerprint matches registration.
61 Authentic,
62 /// Similarity ∈ [0.85, threshold): close but not confident. Flag for review.
63 Suspicious,
64 /// Similarity < 0.85: hardware substitution or spoofed clock likely.
65 Spoofed,
66}
67
68/// Result of comparing an incoming oscillator fingerprint to a registered DNA.
69#[derive(Debug, Clone, Copy)]
70pub struct DnaMatchResult {
71 /// Cosine similarity between incoming and registered fingerprints ∈ [−1, 1].
72 pub similarity: f32,
73 /// True if similarity ≥ AUTHENTICATION_THRESHOLD.
74 pub is_authentic: bool,
75 /// Verdict classification.
76 pub verdict: DnaVerdict,
77}
78
79/// Registered hardware DNA fingerprint.
80///
81/// The 8 Allan deviation values at tau = 1, 2, 4, 8, 16, 32, 64, 128.
82#[derive(Debug, Clone, Copy)]
83pub struct HardwareDna {
84 /// Allan deviation fingerprint: σ_y(τ) at the 8 standard averaging times.
85 pub signature: [f32; 8],
86 /// Human-readable hardware label.
87 pub label: &'static str,
88}
89
90impl HardwareDna {
91 /// Create a DNA record from a computed Allan deviation fingerprint.
92 pub const fn new(signature: [f32; 8], label: &'static str) -> Self {
93 Self { signature, label }
94 }
95}
96
97// ── Allan Variance Estimator ───────────────────────────────────────────────
98
99/// Rolling Allan variance estimator.
100///
101/// Maintains a circular buffer of N phase/norm samples and computes σ_y(τ)
102/// for each of the ALLAN_TAUS averaging intervals using the overlapping
103/// Allan variance formula:
104///
105/// ```text
106/// σ²_y(τ) = 1 / (2τ²(N-2τ)) · Σ_{k=1}^{N-2τ} [x(k+2τ) - 2x(k+τ) + x(k)]²
107/// ```
108///
109/// ## Type Parameters
110/// - `N`: Buffer capacity (≥ 256 recommended for τ=128 support; 512 is ideal).
111pub struct AllanVarianceEstimator<const N: usize> {
112 buf: [f32; N],
113 head: usize,
114 count: usize,
115}
116
117impl<const N: usize> AllanVarianceEstimator<N> {
118 /// Create a new estimator with empty buffer.
119 pub const fn new() -> Self {
120 Self { buf: [0.0; N], head: 0, count: 0 }
121 }
122
123 /// Absorb one sample (residual norm or phase increment).
124 pub fn push(&mut self, sample: f32) {
125 self.buf[self.head] = sample;
126 self.head = (self.head + 1) % N;
127 if self.count < N { self.count += 1; }
128 }
129
130 /// Number of valid samples.
131 pub fn len(&self) -> usize { self.count }
132
133 /// Whether enough samples are available for fingerprint computation.
134 /// Requires count ≥ 2 * max_tau + 1 = 257 for τ_max = 128.
135 pub fn is_ready(&self) -> bool { self.count >= 2 * ALLAN_TAUS[7] as usize + 1 }
136
137 /// Access sample at absolute position `i` in the ring buffer.
138 fn sample(&self, i: usize) -> f32 {
139 // Most-recent sample is at index (head-1+N)%N
140 // sample(0) = most recent, sample(count-1) = oldest
141 let idx = (self.head + N - 1 - i) % N;
142 self.buf[idx]
143 }
144
145 /// Compute Allan deviation σ_y(τ) for averaging time τ (in samples).
146 ///
147 /// Uses overlapping samples for N_eff = count − 2τ averages.
148 /// Returns 0.0 if count < 2τ + 1.
149 pub fn allan_deviation(&self, tau: u32) -> f32 {
150 let t = tau as usize;
151 if self.count < 2 * t + 1 { return 0.0; }
152 let n = self.count.min(N);
153 let max_k = n.saturating_sub(2 * t);
154 if max_k == 0 { return 0.0; }
155 let tau_sq = (t * t) as f32;
156 let mut sum = 0.0_f32;
157 for k in 0..max_k {
158 // x(k), x(k+τ), x(k+2τ) — oldest-first ordering
159 // In ring buf with sample(0)=most recent: oldest is sample(count-1)
160 // k=0 is the oldest triple
161 let offset = n.saturating_sub(1).saturating_sub(k);
162 let x0 = if offset >= 2 * t { self.sample(offset) } else { 0.0 };
163 let x1 = if offset >= t { self.sample(offset - t) } else { 0.0 };
164 let x2 = self.sample(offset);
165 // Second difference: Δ = x(k+2τ) - 2x(k+τ) + x(k)
166 let diff = x2 - 2.0 * x1 + x0;
167 sum += diff * diff;
168 }
169 let avar = sum / (2.0 * tau_sq * max_k as f32);
170 sqrt_f32(avar.max(0.0))
171 }
172
173 /// Compute the 8-element Allan deviation fingerprint.
174 ///
175 /// Returns `None` if `is_ready()` is false.
176 pub fn fingerprint(&self) -> Option<[f32; 8]> {
177 if !self.is_ready() { return None; }
178 let mut sig = [0.0_f32; 8];
179 for (i, &tau) in ALLAN_TAUS.iter().enumerate() {
180 sig[i] = self.allan_deviation(tau);
181 }
182 Some(sig)
183 }
184
185 /// Reset the buffer.
186 pub fn reset(&mut self) {
187 self.buf = [0.0; N];
188 self.head = 0;
189 self.count = 0;
190 }
191}
192
193impl<const N: usize> Default for AllanVarianceEstimator<N> {
194 fn default() -> Self { Self::new() }
195}
196
197// ── Authentication ─────────────────────────────────────────────────────────
198
199/// Authenticate an incoming fingerprint against a registered hardware DNA.
200///
201/// Uses cosine similarity as the distance metric. Cosine similarity is
202/// invariant to overall scale amplitude changes (gain variations), making
203/// it robust to received-power differences while sensitive to the *shape*
204/// of the σ_y(τ) curve which is intrinsic to the oscillator hardware.
205pub fn verify_dna(incoming: &[f32; 8], registered: &HardwareDna) -> DnaMatchResult {
206 let sim = cosine_similarity(incoming, ®istered.signature);
207 let is_authentic = sim >= AUTHENTICATION_THRESHOLD;
208 let verdict = if sim >= AUTHENTICATION_THRESHOLD {
209 DnaVerdict::Authentic
210 } else if sim >= 0.85 {
211 DnaVerdict::Suspicious
212 } else {
213 DnaVerdict::Spoofed
214 };
215 DnaMatchResult { similarity: sim, is_authentic, verdict }
216}
217
218/// Cosine similarity between two 8-element vectors.
219/// Returns 0.0 if either vector is zero-magnitude.
220pub fn cosine_similarity(a: &[f32; 8], b: &[f32; 8]) -> f32 {
221 let dot: f32 = a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum();
222 let mag_a: f32 = sqrt_f32(a.iter().map(|&x| x * x).sum::<f32>());
223 let mag_b: f32 = sqrt_f32(b.iter().map(|&x| x * x).sum::<f32>());
224 if mag_a < 1e-20 || mag_b < 1e-20 { return 0.0; }
225 (dot / (mag_a * mag_b)).max(-1.0).min(1.0)
226}
227
228// ── Tests ──────────────────────────────────────────────────────────────────
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn cosine_identical_vectors_is_one() {
236 let a = [0.1_f32, 0.2, 0.3, 0.1, 0.05, 0.02, 0.01, 0.005];
237 let sim = cosine_similarity(&a, &a);
238 assert!((sim - 1.0).abs() < 1e-4, "identical vectors: cosine={}", sim);
239 }
240
241 #[test]
242 fn cosine_orthogonal_vectors_is_zero() {
243 let a = [1.0_f32, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
244 let b = [0.0_f32, 1.0, 0.0, 1.0, 0.0, 1.0, 0.0, 1.0];
245 let sim = cosine_similarity(&a, &b);
246 assert!(sim.abs() < 1e-4, "orthogonal: cosine={}", sim);
247 }
248
249 #[test]
250 fn authentic_match_passes() {
251 let sig = [0.1_f32, 0.08, 0.06, 0.05, 0.04, 0.03, 0.02, 0.01];
252 let dna = HardwareDna::new(sig, "OCXO_test");
253 // Identical fingerprint: similarity = 1.0 → Authentic
254 let result = verify_dna(&sig, &dna);
255 assert_eq!(result.verdict, DnaVerdict::Authentic);
256 assert!(result.is_authentic);
257 }
258
259 #[test]
260 fn spoofed_fingerprint_detected() {
261 let registered = [0.1_f32, 0.08, 0.06, 0.05, 0.04, 0.03, 0.02, 0.01];
262 let dna = HardwareDna::new(registered, "reference");
263 // Very different shape: opposite slope
264 let incoming = [0.01_f32, 0.02, 0.03, 0.04, 0.05, 0.06, 0.08, 0.10];
265 let result = verify_dna(&incoming, &dna);
266 assert_ne!(result.verdict, DnaVerdict::Authentic,
267 "opposite-slope fingerprint should not authenticate: sim={:.3}", result.similarity);
268 }
269
270 #[test]
271 fn allan_estimator_ready_after_sufficient_samples() {
272 let mut est = AllanVarianceEstimator::<512>::new();
273 assert!(!est.is_ready());
274 for i in 0..257 {
275 est.push(0.01 + i as f32 * 0.0001);
276 }
277 assert!(est.is_ready(), "estimator must be ready after 257 samples");
278 }
279
280 #[test]
281 fn allan_estimator_returns_none_when_not_ready() {
282 let mut est = AllanVarianceEstimator::<512>::new();
283 for _ in 0..10 { est.push(0.01); }
284 assert!(est.fingerprint().is_none());
285 }
286
287 #[test]
288 fn fingerprint_returns_some_when_ready() {
289 let mut est = AllanVarianceEstimator::<512>::new();
290 for i in 0..512 { est.push(0.01 + (i as f32 * 0.0001).sin() * 0.001); }
291 let fp = est.fingerprint();
292 assert!(fp.is_some(), "must return Some after 512 samples");
293 let sig = fp.unwrap();
294 // All values must be non-negative
295 for (i, &v) in sig.iter().enumerate() {
296 assert!(v >= 0.0, "sigma[{}] = {} must be non-negative", i, v);
297 }
298 }
299
300 #[test]
301 fn verify_dna_with_small_perturbation_authentic() {
302 // Build an exact fingerprint via cosine similarity check
303 let base = [0.10_f32, 0.07, 0.05, 0.04, 0.03, 0.02, 0.015, 0.01];
304 let dna = HardwareDna::new(base, "reference");
305 // Tiny perturbation: < 1% change per element
306 let perturbed: [f32; 8] = core::array::from_fn(|i| base[i] * (1.0 + 0.002 * (i as f32)));
307 let result = verify_dna(&perturbed, &dna);
308 assert_eq!(result.verdict, DnaVerdict::Authentic,
309 "tiny perturbation should still authenticate: sim={:.4}", result.similarity);
310 }
311}