Skip to main content

origin_crypto_sdk/
entropy.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Entropy analysis and quality testing for cryptographic seeds.
4//!
5//! Ported from the cryp Python prototype's `entropy_analyzer.py`. Provides
6//! statistical tests to validate that seed material has sufficient randomness.
7//!
8//! # Metrics
9//!
10//! - **Shannon entropy** — classic information-theoretic entropy
11//! - **Min-entropy** — most conservative entropy estimate
12//! - **Collision entropy** — Renyi entropy of order 2
13//! - **Chi-squared test** — uniformity of byte distribution
14//! - **Serial correlation** — correlation between adjacent bytes
15//! - **Longest run** — longest run of identical bits
16//!
17//! # Quick start
18//!
19//! ```ignore
20//! use origin_crypto_sdk::entropy;
21//!
22//! let data = [0x42u8; 256];
23//! let metrics = entropy::analyze(&data);
24//!
25//! // Shannon entropy should be close to 8.0 bits/byte for random data
26//! println!("Shannon: {:.2} bits/byte", metrics.shannon_entropy);
27//! ```
28
29// ---------------------------------------------------------------------------
30// Metrics container
31// ---------------------------------------------------------------------------
32
33/// Comprehensive entropy metrics for a byte sequence.
34#[derive(Debug, Clone)]
35pub struct EntropyMetrics {
36    /// Shannon entropy in bits per byte (0.0–8.0, 8.0 = perfectly random).
37    pub shannon_entropy: f64,
38    /// Min-entropy in bits per byte (most conservative estimate).
39    pub min_entropy: f64,
40    /// Collision entropy (Renyi order-2) in bits per byte.
41    pub collision_entropy: f64,
42    /// Chi-squared test statistic.
43    pub chi_squared: f64,
44    /// Chi-squared p-value (1.0 = perfectly uniform).
45    pub chi_squared_p: f64,
46    /// Serial correlation coefficient (-1.0 to 1.0, 0.0 = uncorrelated).
47    pub serial_correlation: f64,
48    /// Longest run of identical bits.
49    pub longest_run: usize,
50    /// Fraction of bits that are 1 (should be ~0.5).
51    pub bit_bias: f64,
52    /// Number of unique bytes observed.
53    pub unique_bytes: usize,
54}
55
56// ---------------------------------------------------------------------------
57// Analysis
58// ---------------------------------------------------------------------------
59
60/// Analyze entropy characteristics of a byte sequence.
61///
62/// Minimum recommended input size: 256 bytes for meaningful statistics.
63pub fn analyze(data: &[u8]) -> EntropyMetrics {
64    if data.is_empty() {
65        return EntropyMetrics {
66            shannon_entropy: 0.0,
67            min_entropy: 0.0,
68            collision_entropy: 0.0,
69            chi_squared: 0.0,
70            chi_squared_p: 1.0,
71            serial_correlation: 0.0,
72            longest_run: 0,
73            bit_bias: 0.0,
74            unique_bytes: 0,
75        };
76    }
77
78    let n = data.len();
79
80    // Byte frequency distribution
81    let mut freq = [0usize; 256];
82    for &b in data {
83        freq[b as usize] += 1;
84    }
85
86    let unique_bytes = freq.iter().filter(|&&c| c > 0).count();
87
88    // Shannon entropy: H = -sum(p_i * log2(p_i))
89    let mut shannon = 0.0_f64;
90    let mut max_freq = 0usize;
91    let mut collision_sum = 0.0_f64;
92    for &count in &freq {
93        if count > 0 {
94            let p = count as f64 / n as f64;
95            shannon -= p * p.log2();
96            collision_sum += p * p;
97        }
98        if count > max_freq {
99            max_freq = count;
100        }
101    }
102
103    // Min-entropy: H_min = -log2(max(p_i))
104    let min_entropy = -((max_freq as f64) / (n as f64)).log2();
105
106    // Collision entropy: H_2 = -log2(sum(p_i^2))
107    let collision_entropy = -collision_sum.log2();
108
109    // Chi-squared test
110    let expected = n as f64 / 256.0;
111    let chi_squared = freq
112        .iter()
113        .map(|&count| {
114            let diff = count as f64 - expected;
115            diff * diff / expected
116        })
117        .sum::<f64>();
118    let chi_squared_p = chi2_p_value(chi_squared, 255);
119
120    // Serial correlation
121    let serial_correlation = compute_serial_correlation(data);
122
123    // Longest bit run
124    let longest_run = compute_longest_bit_run(data);
125
126    // Bit bias (fraction of 1 bits)
127    let total_bits = n * 8;
128    let ones: usize = data.iter().map(|b| b.count_ones() as usize).sum();
129    let bit_bias = ones as f64 / total_bits as f64;
130
131    EntropyMetrics {
132        shannon_entropy: shannon,
133        min_entropy,
134        collision_entropy,
135        chi_squared,
136        chi_squared_p,
137        serial_correlation,
138        longest_run,
139        bit_bias,
140        unique_bytes,
141    }
142}
143
144// ---------------------------------------------------------------------------
145// Quality checking
146// ---------------------------------------------------------------------------
147
148/// Quality requirements for a given seed bit length.
149#[derive(Debug)]
150pub struct QualityRequirements {
151    /// Minimum Shannon entropy (bits of entropy per bit of input).
152    pub min_shannon: f64,
153    /// Minimum min-entropy (worst-case entropy).
154    pub min_min_entropy: f64,
155    /// Minimum collision entropy.
156    pub min_collision: f64,
157    /// Minimum chi-squared p-value (uniformity test).
158    pub min_chi_squared_p: f64,
159    /// Maximum absolute serial correlation between consecutive bits.
160    pub max_serial_correlation: f64,
161    /// Maximum allowed deviation from uniform bit distribution (0.0 to 1.0).
162    pub max_bit_bias_deviation: f64,
163    /// Maximum length of the longest run of identical bits.
164    pub max_longest_run: usize,
165}
166
167impl QualityRequirements {
168    /// Requirements for the given seed bit length.
169    pub fn for_bits(bits: u32) -> Self {
170        match bits {
171            128 => QualityRequirements {
172                min_shannon: 3.8,
173                min_min_entropy: 3.5,
174                min_collision: 3.8,
175                min_chi_squared_p: 0.0001,
176                max_serial_correlation: 0.7,
177                max_bit_bias_deviation: 0.2,
178                max_longest_run: 25,
179            },
180            192 => QualityRequirements {
181                min_shannon: 4.2,
182                min_min_entropy: 3.75,
183                min_collision: 4.2,
184                min_chi_squared_p: 0.0001,
185                max_serial_correlation: 0.6,
186                max_bit_bias_deviation: 0.15,
187                max_longest_run: 20,
188            },
189            // 256 and default
190            _ => QualityRequirements {
191                min_shannon: 4.8,
192                min_min_entropy: 4.0,
193                min_collision: 4.8,
194                min_chi_squared_p: 0.0001,
195                max_serial_correlation: 0.5,
196                max_bit_bias_deviation: 0.12,
197                max_longest_run: 20,
198            },
199        }
200    }
201}
202
203/// Check if entropy metrics meet quality requirements for a seed of `bits` bits.
204///
205/// Returns `(passed, warnings)` where `passed` is true if all checks pass.
206pub fn check_quality(metrics: &EntropyMetrics, bits: u32) -> (bool, Vec<String>) {
207    let req = QualityRequirements::for_bits(bits);
208    let mut warnings = Vec::new();
209
210    if metrics.shannon_entropy < req.min_shannon {
211        warnings.push(format!(
212            "Shannon entropy ({:.2}) below minimum ({:.2})",
213            metrics.shannon_entropy, req.min_shannon
214        ));
215    }
216    if metrics.min_entropy < req.min_min_entropy {
217        warnings.push(format!(
218            "Min entropy ({:.2}) below minimum ({:.2})",
219            metrics.min_entropy, req.min_min_entropy
220        ));
221    }
222    if metrics.collision_entropy < req.min_collision {
223        warnings.push(format!(
224            "Collision entropy ({:.2}) below minimum ({:.2})",
225            metrics.collision_entropy, req.min_collision
226        ));
227    }
228    if metrics.chi_squared_p < req.min_chi_squared_p {
229        warnings.push(format!(
230            "Chi-squared p-value ({:.6}) below minimum ({:.6})",
231            metrics.chi_squared_p, req.min_chi_squared_p
232        ));
233    }
234    if metrics.serial_correlation.abs() > req.max_serial_correlation {
235        warnings.push(format!(
236            "Serial correlation ({:.4}) exceeds maximum ({:.1})",
237            metrics.serial_correlation, req.max_serial_correlation
238        ));
239    }
240    if (metrics.bit_bias - 0.5).abs() > req.max_bit_bias_deviation {
241        warnings.push(format!(
242            "Bit bias ({:.4}) deviates more than {:.2} from 0.5",
243            metrics.bit_bias, req.max_bit_bias_deviation
244        ));
245    }
246    if metrics.longest_run > req.max_longest_run {
247        warnings.push(format!(
248            "Longest run ({}) exceeds maximum ({})",
249            metrics.longest_run, req.max_longest_run
250        ));
251    }
252
253    (warnings.is_empty(), warnings)
254}
255
256// ---------------------------------------------------------------------------
257// Internal helpers
258// ---------------------------------------------------------------------------
259
260/// Approximate chi-squared p-value using the Wilson-Hilferty transformation.
261fn chi2_p_value(chi2: f64, df: usize) -> f64 {
262    if df == 0 {
263        return 1.0;
264    }
265    // Wilson-Hilferty approximation: (chi2/df)^(1/3) ~ N(1-2/(9df), 2/(9df))
266    let df_f = df as f64;
267    let x = (chi2 / df_f).powf(1.0 / 3.0);
268    let mean = 1.0 - 2.0 / (9.0 * df_f);
269    let std_dev = (2.0 / (9.0 * df_f)).sqrt();
270
271    if std_dev == 0.0 {
272        return 1.0;
273    }
274
275    let z = (x - mean) / std_dev;
276
277    // Standard normal CDF approximation (Abramowitz & Stegun)
278    normal_cdf(z)
279}
280
281/// Standard normal CDF (Abramowitz & Stegun approximation).
282fn normal_cdf(z: f64) -> f64 {
283    let t = 1.0 / (1.0 + 0.2316419 * z.abs());
284    let d = 0.3989422804014327; // 1/sqrt(2*pi)
285    let prob = d
286        * (-z * z / 2.0).exp()
287        * t
288        * (0.319381530
289            + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))));
290
291    if z > 0.0 {
292        1.0 - prob
293    } else {
294        prob
295    }
296}
297
298/// Serial correlation between adjacent bytes.
299fn compute_serial_correlation(data: &[u8]) -> f64 {
300    if data.len() <= 1 {
301        return 0.0;
302    }
303
304    let n = data.len() as f64;
305    let mean: f64 = data.iter().map(|&b| b as f64).sum::<f64>() / n;
306
307    let mut numerator = 0.0_f64;
308    let mut denominator = 0.0_f64;
309
310    let deviations: Vec<f64> = data.iter().map(|&b| b as f64 - mean).collect();
311
312    for i in 0..deviations.len() {
313        denominator += deviations[i] * deviations[i];
314        if i > 0 {
315            numerator += deviations[i - 1] * deviations[i];
316        }
317    }
318
319    if denominator == 0.0 {
320        return 0.0;
321    }
322
323    let corr = numerator / denominator;
324    if corr.is_nan() {
325        0.0
326    } else {
327        corr
328    }
329}
330
331/// Longest run of identical bits.
332fn compute_longest_bit_run(data: &[u8]) -> usize {
333    if data.is_empty() {
334        return 0;
335    }
336
337    let mut longest = 1usize;
338    let mut current = 1usize;
339    let mut prev_bit: Option<bool> = None;
340
341    for &byte in data {
342        for shift in (0..8).rev() {
343            let bit = (byte >> shift) & 1 == 1;
344            if let Some(pb) = prev_bit {
345                if bit == pb {
346                    current += 1;
347                    if current > longest {
348                        longest = current;
349                    }
350                } else {
351                    current = 1;
352                }
353            }
354            prev_bit = Some(bit);
355        }
356    }
357
358    longest
359}
360
361// ---------------------------------------------------------------------------
362// Tests
363// ---------------------------------------------------------------------------
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    #[test]
370    fn perfect_random_has_high_entropy() {
371        // Generate pseudo-random data for testing
372        let mut data = vec![0u8; 1024];
373        for i in 0..data.len() {
374            data[i] = ((i as u64 * 1103515245 + 12345) >> 16) as u8;
375        }
376        let m = analyze(&data);
377        assert!(
378            m.shannon_entropy > 5.0,
379            "Shannon too low: {}",
380            m.shannon_entropy
381        );
382    }
383
384    #[test]
385    fn constant_data_has_zero_entropy() {
386        let data = [0u8; 256];
387        let m = analyze(&data);
388        assert!(
389            m.shannon_entropy < 0.1,
390            "Shannon should be ~0: {}",
391            m.shannon_entropy
392        );
393        assert!(m.min_entropy < 0.1);
394        assert_eq!(m.unique_bytes, 1);
395    }
396
397    #[test]
398    fn alternating_bytes() {
399        let data: Vec<u8> = (0..256)
400            .map(|i| if i % 2 == 0 { 0xAA } else { 0x55 })
401            .collect();
402        let m = analyze(&data);
403        assert!(
404            m.shannon_entropy < 2.0,
405            "Alternating should have low entropy"
406        );
407        assert_eq!(m.unique_bytes, 2);
408    }
409
410    #[test]
411    fn quality_check_passes_for_good_data() {
412        let mut data = vec![0u8; 512];
413        for i in 0..data.len() {
414            data[i] = ((i as u64)
415                .wrapping_mul(6364136223846793005)
416                .wrapping_add(1442695040888963407)
417                >> 32) as u8;
418        }
419        let m = analyze(&data);
420        let (pass, warnings) = check_quality(&m, 256);
421        if !pass {
422            eprintln!("Warnings: {warnings:?}");
423        }
424        // This is a PRNG, not true random, so we just check it doesn't crash
425    }
426
427    #[test]
428    fn quality_check_fails_for_constant() {
429        let data = [0u8; 256];
430        let m = analyze(&data);
431        let (pass, warnings) = check_quality(&m, 256);
432        assert!(!pass, "Constant data should fail quality check");
433        assert!(!warnings.is_empty());
434    }
435
436    #[test]
437    fn serial_correlation_zero_for_random() {
438        let mut data = vec![0u8; 1024];
439        for i in 0..data.len() {
440            data[i] = ((i as u64).wrapping_mul(6364136223846793005) >> 32) as u8;
441        }
442        let m = analyze(&data);
443        assert!(
444            m.serial_correlation.abs() < 0.3,
445            "Serial correlation too high: {}",
446            m.serial_correlation
447        );
448    }
449
450    #[test]
451    fn empty_input_no_panic() {
452        let m = analyze(&[]);
453        assert_eq!(m.shannon_entropy, 0.0);
454    }
455
456    #[test]
457    fn bit_bias_near_half() {
458        let mut data = vec![0u8; 1024];
459        for i in 0..data.len() {
460            data[i] = ((i as u64)
461                .wrapping_mul(6364136223846793005)
462                .wrapping_add(1442695040888963407)
463                >> 32) as u8;
464        }
465        let m = analyze(&data);
466        assert!(
467            (m.bit_bias - 0.5).abs() < 0.1,
468            "Bit bias too far from 0.5: {}",
469            m.bit_bias
470        );
471    }
472}