Skip to main content

cpop_protocol/rfc/
jitter_binding.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! RFC-compliant jitter-binding structure.
4//!
5//! Implements the 7-key CDDL structure from draft-condrey-rats-pop-01:
6//! - entropy-commitment: Hash commitment to entropy sources
7//! - sources: Entropy source descriptors
8//! - summary: Statistical summary of jitter data
9//! - binding-mac: HMAC binding to document state
10//! - raw-intervals: Optional raw interval data (Enhanced/Maximum tiers)
11//! - active-probes: Active behavioral probes (Galton Invariant, Reflex Gate)
12//! - labyrinth-structure: Topological phase space analysis
13
14use serde::{Deserialize, Serialize};
15
16/// ```cddl
17/// jitter-binding = {
18///   1: entropy-commitment,      ; Hash commitment
19///   2: [* source-descriptor],   ; Entropy sources
20///   3: jitter-summary,          ; Statistical summary
21///   4: binding-mac,             ; HMAC binding
22///   ? 5: raw-intervals,         ; Raw data (optional)
23///   ? 6: active-probes,         ; Behavioral probes (optional)
24///   ? 7: labyrinth-structure    ; Phase space (optional)
25/// }
26/// ```
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct JitterBinding {
29    #[serde(rename = "1")]
30    pub entropy_commitment: EntropyCommitment,
31
32    #[serde(rename = "2")]
33    pub sources: Vec<SourceDescriptor>,
34
35    #[serde(rename = "3")]
36    pub summary: JitterSummary,
37
38    #[serde(rename = "4")]
39    pub binding_mac: BindingMac,
40
41    /// Enhanced/Maximum tiers only.
42    #[serde(rename = "5", skip_serializing_if = "Option::is_none")]
43    pub raw_intervals: Option<RawIntervals>,
44
45    /// Galton Invariant + Reflex Gate.
46    #[serde(rename = "6", skip_serializing_if = "Option::is_none")]
47    pub active_probes: Option<ActiveProbes>,
48
49    /// Takens' delay-coordinate embedding.
50    #[serde(rename = "7", skip_serializing_if = "Option::is_none")]
51    pub labyrinth_structure: Option<LabyrinthStructure>,
52}
53
54/// Hash commitment over concatenated entropy sources.
55///
56/// ```cddl
57/// entropy-commitment = {
58///   1: bstr .size 32,           ; SHA-256 hash of sources
59///   2: uint,                    ; Timestamp (Unix epoch ms)
60///   3: bstr .size 32            ; Previous commitment hash
61/// }
62/// ```
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct EntropyCommitment {
65    #[serde(rename = "1", with = "super::serde_helpers::hex_bytes")]
66    pub hash: [u8; 32],
67
68    #[serde(rename = "2")]
69    pub timestamp_ms: u64,
70
71    /// Chain linkage.
72    #[serde(rename = "3", with = "super::serde_helpers::hex_bytes")]
73    pub previous_hash: [u8; 32],
74}
75
76/// ```cddl
77/// source-descriptor = {
78///   1: tstr,                    ; Source type identifier
79///   2: uint,                    ; Contribution weight (0-1000)
80///   ? 3: tstr,                  ; Device fingerprint (optional)
81///   ? 4: transport-calibration  ; Transport calibration (optional)
82/// }
83/// ```
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct SourceDescriptor {
86    /// e.g., "keyboard.usb", "cpop_jitter".
87    #[serde(rename = "1")]
88    pub source_type: String,
89
90    /// 0-1000 (1000 = 100%).
91    #[serde(rename = "2")]
92    pub weight: u16,
93
94    #[serde(rename = "3", skip_serializing_if = "Option::is_none")]
95    pub device_fingerprint: Option<String>,
96
97    #[serde(rename = "4", skip_serializing_if = "Option::is_none")]
98    pub transport_calibration: Option<TransportCalibration>,
99}
100
101/// Per-transport baseline latency calibration.
102///
103/// ```cddl
104/// transport-calibration = {
105///   1: tstr,                    ; Transport type (usb, bluetooth, internal, etc.)
106///   2: uint,                    ; Baseline latency in microseconds
107///   3: uint,                    ; Latency variance in microseconds
108///   4: uint                     ; Calibration timestamp (Unix epoch ms)
109/// }
110/// ```
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct TransportCalibration {
113    #[serde(rename = "1")]
114    pub transport: String,
115
116    #[serde(rename = "2")]
117    pub baseline_latency_us: u64,
118
119    #[serde(rename = "3")]
120    pub latency_variance_us: u64,
121
122    #[serde(rename = "4")]
123    pub calibrated_at_ms: u64,
124}
125
126/// ```cddl
127/// jitter-summary = {
128///   1: uint,                    ; Sample count
129///   2: float64,                 ; Mean interval (microseconds)
130///   3: float64,                 ; Standard deviation
131///   4: float64,                 ; Coefficient of variation
132///   5: [5*float64],             ; Percentiles (10th, 25th, 50th, 75th, 90th)
133///   6: float64,                 ; Entropy bits
134///   ? 7: float64                ; Hurst exponent (optional)
135/// }
136/// ```
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct JitterSummary {
139    #[serde(rename = "1")]
140    pub sample_count: u64,
141
142    #[serde(rename = "2")]
143    pub mean_interval_us: f64,
144
145    #[serde(rename = "3")]
146    pub std_dev: f64,
147
148    /// std_dev / mean.
149    #[serde(rename = "4")]
150    pub coefficient_of_variation: f64,
151
152    #[serde(rename = "5")]
153    pub percentiles: [f64; 5],
154
155    /// Shannon entropy.
156    #[serde(rename = "6")]
157    pub entropy_bits: f64,
158
159    /// H_e ~ 0.7 for human; reject 0.5 or 1.0.
160    #[serde(rename = "7", skip_serializing_if = "Option::is_none")]
161    pub hurst_exponent: Option<f64>,
162}
163
164/// HMAC binding to document state at a point in time.
165///
166/// ```cddl
167/// binding-mac = {
168///   1: bstr .size 32,           ; HMAC-SHA256 value
169///   2: bstr .size 32,           ; Document hash at binding
170///   3: uint,                    ; Keystroke count at binding
171///   4: uint                     ; Timestamp (Unix epoch ms)
172/// }
173/// ```
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct BindingMac {
176    #[serde(rename = "1", with = "super::serde_helpers::hex_bytes")]
177    pub mac: [u8; 32],
178
179    #[serde(rename = "2", with = "super::serde_helpers::hex_bytes")]
180    pub document_hash: [u8; 32],
181
182    /// Cumulative.
183    #[serde(rename = "3")]
184    pub keystroke_count: u64,
185
186    #[serde(rename = "4")]
187    pub timestamp_ms: u64,
188}
189
190impl BindingMac {
191    /// Compute the HMAC-SHA256 binding over document state.
192    pub fn compute(
193        key: &[u8],
194        document_hash: [u8; 32],
195        keystroke_count: u64,
196        timestamp_ms: u64,
197        entropy_hash: &[u8; 32],
198    ) -> Self {
199        use hmac::{Hmac, Mac};
200        use sha2::Sha256;
201        let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts any key size");
202        mac.update(&document_hash);
203        mac.update(&keystroke_count.to_be_bytes());
204        mac.update(&timestamp_ms.to_be_bytes());
205        mac.update(entropy_hash);
206        Self {
207            mac: mac.finalize().into_bytes().into(),
208            document_hash,
209            keystroke_count,
210            timestamp_ms,
211        }
212    }
213}
214
215/// ```cddl
216/// raw-intervals = {
217///   1: [* uint],                ; Interval values (microseconds)
218///   2: uint,                    ; Compression method (0=none, 1=delta, 2=zstd)
219///   ? 3: bstr                   ; Compressed data (if method != 0)
220/// }
221/// ```
222#[derive(Debug, Clone, Serialize, Deserialize)]
223pub struct RawIntervals {
224    #[serde(rename = "1")]
225    pub intervals: Vec<u32>,
226
227    #[serde(rename = "2")]
228    pub compression_method: u8,
229
230    #[serde(rename = "3", skip_serializing_if = "Option::is_none")]
231    pub compressed_data: Option<Vec<u8>>,
232}
233
234/// ```cddl
235/// active-probes = {
236///   ? 1: galton-invariant,
237///   ? 2: reflex-gate
238/// }
239/// ```
240#[derive(Debug, Clone, Default, Serialize, Deserialize)]
241pub struct ActiveProbes {
242    #[serde(rename = "1", skip_serializing_if = "Option::is_none")]
243    pub galton_invariant: Option<GaltonInvariant>,
244
245    #[serde(rename = "2", skip_serializing_if = "Option::is_none")]
246    pub reflex_gate: Option<ReflexGate>,
247}
248
249/// Binomial absorption test — human responses show characteristic
250/// coefficients distinct from automated input.
251///
252/// ```cddl
253/// galton-invariant = {
254///   1: float64,                 ; Absorption coefficient (0.0-1.0)
255///   2: uint,                    ; Stimulus count
256///   3: float64,                 ; Expected absorption (baseline)
257///   4: float64,                 ; Z-score deviation
258///   5: bool                     ; Pass/fail (within 2σ of expected)
259/// }
260/// ```
261#[derive(Debug, Clone, Serialize, Deserialize)]
262pub struct GaltonInvariant {
263    #[serde(rename = "1")]
264    pub absorption_coefficient: f64,
265
266    #[serde(rename = "2")]
267    pub stimulus_count: u32,
268
269    #[serde(rename = "3")]
270    pub expected_absorption: f64,
271
272    #[serde(rename = "4")]
273    pub z_score: f64,
274
275    #[serde(rename = "5")]
276    pub passed: bool,
277}
278
279/// Backspace-after-typo latency follows a characteristic distribution
280/// that is difficult to simulate.
281///
282/// ```cddl
283/// reflex-gate = {
284///   1: float64,                 ; Mean reflex latency (ms)
285///   2: float64,                 ; Standard deviation (ms)
286///   3: uint,                    ; Reflex event count
287///   4: [5*float64],             ; Percentiles (10, 25, 50, 75, 90)
288///   5: bool                     ; Pass/fail (within human range)
289/// }
290/// ```
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct ReflexGate {
293    #[serde(rename = "1")]
294    pub mean_latency_ms: f64,
295
296    #[serde(rename = "2")]
297    pub std_dev_ms: f64,
298
299    #[serde(rename = "3")]
300    pub event_count: u32,
301
302    #[serde(rename = "4")]
303    pub percentiles: [f64; 5],
304
305    /// Typically 150-400ms for humans.
306    #[serde(rename = "5")]
307    pub passed: bool,
308}
309
310/// Detects characteristic attractors in human typing via Takens' embedding.
311///
312/// ```cddl
313/// labyrinth-structure = {
314///   1: uint,                    ; Embedding dimension (typically 3-5)
315///   2: uint,                    ; Time delay (samples)
316///   3: [[* float64]],           ; Attractor points (sampled)
317///   4: [* uint],                ; Betti numbers [β₀, β₁, β₂, ...]
318///   5: float64,                 ; Lyapunov exponent estimate
319///   6: float64                  ; Correlation dimension
320/// }
321/// ```
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct LabyrinthStructure {
324    #[serde(rename = "1")]
325    pub embedding_dimension: u8,
326
327    #[serde(rename = "2")]
328    pub time_delay: u16,
329
330    /// Each inner vec has length `embedding_dimension`.
331    #[serde(rename = "3")]
332    pub attractor_points: Vec<Vec<f64>>,
333
334    /// β₀=components, β₁=loops, β₂=voids.
335    #[serde(rename = "4")]
336    pub betti_numbers: Vec<u32>,
337
338    /// Positive = chaotic (human-like), non-positive = periodic.
339    /// `None` when not computed from source data.
340    #[serde(rename = "5")]
341    pub lyapunov_exponent: Option<f64>,
342
343    /// Non-integer values suggest fractal attractor.
344    #[serde(rename = "6")]
345    pub correlation_dimension: f64,
346}
347
348impl JitterBinding {
349    /// Create a jitter binding from its required components.
350    pub fn new(
351        entropy_commitment: EntropyCommitment,
352        sources: Vec<SourceDescriptor>,
353        summary: JitterSummary,
354        binding_mac: BindingMac,
355    ) -> Self {
356        Self {
357            entropy_commitment,
358            sources,
359            summary,
360            binding_mac,
361            raw_intervals: None,
362            active_probes: None,
363            labyrinth_structure: None,
364        }
365    }
366
367    /// Attach raw interval data (Enhanced/Maximum tiers).
368    pub fn with_raw_intervals(mut self, intervals: RawIntervals) -> Self {
369        self.raw_intervals = Some(intervals);
370        self
371    }
372
373    /// Attach Galton Invariant and Reflex Gate probe results.
374    pub fn with_active_probes(mut self, probes: ActiveProbes) -> Self {
375        self.active_probes = Some(probes);
376        self
377    }
378
379    /// Attach Takens' delay-coordinate embedding analysis.
380    pub fn with_labyrinth(mut self, labyrinth: LabyrinthStructure) -> Self {
381        self.labyrinth_structure = Some(labyrinth);
382        self
383    }
384
385    /// Verify the binding MAC against the provided HMAC seed.
386    pub fn verify_binding(&self, seed: &[u8]) -> bool {
387        let expected = BindingMac::compute(
388            seed,
389            self.binding_mac.document_hash,
390            self.binding_mac.keystroke_count,
391            self.binding_mac.timestamp_ms,
392            &self.entropy_commitment.hash,
393        );
394        subtle::ConstantTimeEq::ct_eq(&self.binding_mac.mac[..], &expected.mac[..]).unwrap_u8() == 1
395    }
396
397    /// Returns `true` if Hurst exponent is within human range (0.55-0.85).
398    pub fn is_hurst_valid(&self) -> bool {
399        if let Some(h) = self.summary.hurst_exponent {
400            // Reject white noise (H=0.5) and perfectly predictable (H=1.0)
401            h > 0.55 && h < 0.85
402        } else {
403            true
404        }
405    }
406
407    /// Returns `true` if all active probes passed (or none present).
408    pub fn probes_passed(&self) -> bool {
409        if let Some(probes) = &self.active_probes {
410            let galton_ok = probes
411                .galton_invariant
412                .as_ref()
413                .map(|g| g.passed)
414                .unwrap_or(true);
415            let reflex_ok = probes
416                .reflex_gate
417                .as_ref()
418                .map(|r| r.passed)
419                .unwrap_or(true);
420            galton_ok && reflex_ok
421        } else {
422            true
423        }
424    }
425
426    /// Validate all fields. Returns a list of errors (empty if valid).
427    pub fn validate(&self) -> Vec<String> {
428        let mut errors = Vec::new();
429
430        if self.entropy_commitment.hash == [0u8; 32] {
431            errors.push("entropy commitment hash is zero".into());
432        }
433        if self.entropy_commitment.timestamp_ms == 0 {
434            errors.push("entropy commitment timestamp is zero".into());
435        }
436
437        if self.sources.is_empty() {
438            errors.push("no entropy sources declared".into());
439        }
440        let total_weight: u32 = self.sources.iter().map(|s| s.weight as u32).sum();
441        if total_weight == 0 {
442            errors.push("total source weight is zero".into());
443        }
444        if total_weight > 1000 {
445            errors.push(format!("total source weight {} exceeds 1000", total_weight));
446        }
447        for source in &self.sources {
448            if source.source_type.is_empty() {
449                errors.push("empty source type".into());
450            }
451        }
452
453        if self.summary.sample_count == 0 {
454            errors.push("sample count is zero".into());
455        }
456        if self.summary.mean_interval_us <= 0.0 {
457            errors.push("mean interval is non-positive".into());
458        }
459        if self.summary.std_dev < 0.0 {
460            errors.push("standard deviation is negative".into());
461        }
462        if self.summary.coefficient_of_variation < 0.0 {
463            errors.push("coefficient of variation is negative".into());
464        }
465        if self.summary.entropy_bits < 0.0 {
466            errors.push("entropy bits is negative".into());
467        }
468
469        for i in 1..5 {
470            if self.summary.percentiles[i] < self.summary.percentiles[i - 1] {
471                errors.push(format!(
472                    "percentiles not monotonic: index {} ({}) < index {} ({})",
473                    i,
474                    self.summary.percentiles[i],
475                    i - 1,
476                    self.summary.percentiles[i - 1]
477                ));
478                break;
479            }
480        }
481
482        if let Some(h) = self.summary.hurst_exponent {
483            if !(0.0..=1.0).contains(&h) {
484                errors.push(format!("Hurst exponent {} out of range [0, 1]", h));
485            }
486        }
487
488        if self.binding_mac.mac == [0u8; 32] {
489            errors.push("binding MAC is zero".into());
490        }
491        if self.binding_mac.document_hash == [0u8; 32] {
492            errors.push("document hash is zero".into());
493        }
494        if self.binding_mac.timestamp_ms == 0 {
495            errors.push("binding MAC timestamp is zero".into());
496        }
497
498        if let Some(probes) = &self.active_probes {
499            if let Some(galton) = &probes.galton_invariant {
500                if galton.absorption_coefficient < 0.0 || galton.absorption_coefficient > 1.0 {
501                    errors.push(format!(
502                        "Galton absorption coefficient {} out of range [0, 1]",
503                        galton.absorption_coefficient
504                    ));
505                }
506                if galton.stimulus_count == 0 {
507                    errors.push("Galton stimulus count is zero".into());
508                }
509            }
510            if let Some(reflex) = &probes.reflex_gate {
511                if reflex.mean_latency_ms < 0.0 {
512                    errors.push("reflex gate mean latency is negative".into());
513                }
514                if reflex.std_dev_ms < 0.0 {
515                    errors.push("reflex gate std dev is negative".into());
516                }
517            }
518        }
519
520        if let Some(labyrinth) = &self.labyrinth_structure {
521            if labyrinth.embedding_dimension < 2 {
522                errors.push("labyrinth embedding dimension < 2".into());
523            }
524            if labyrinth.time_delay == 0 {
525                errors.push("labyrinth time delay is zero".into());
526            }
527            if labyrinth.betti_numbers.is_empty() {
528                errors.push("labyrinth betti numbers empty".into());
529            }
530            if labyrinth.correlation_dimension < 0.0 {
531                errors.push("correlation dimension is negative".into());
532            }
533        }
534
535        errors
536    }
537
538    /// Return `true` if all validation checks pass.
539    pub fn is_valid(&self) -> bool {
540        self.validate().is_empty()
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    fn create_test_binding() -> JitterBinding {
549        let commitment = EntropyCommitment {
550            hash: [1u8; 32],
551            timestamp_ms: 1700000000000,
552            previous_hash: [0u8; 32],
553        };
554
555        let sources = vec![
556            SourceDescriptor {
557                source_type: "keyboard".to_string(),
558                weight: 700,
559                device_fingerprint: Some("usb:1234:5678".to_string()),
560                transport_calibration: None,
561            },
562            SourceDescriptor {
563                source_type: "mouse".to_string(),
564                weight: 300,
565                device_fingerprint: None,
566                transport_calibration: None,
567            },
568        ];
569
570        let summary = JitterSummary {
571            sample_count: 1000,
572            mean_interval_us: 150000.0,
573            std_dev: 50000.0,
574            coefficient_of_variation: 0.33,
575            percentiles: [50000.0, 80000.0, 140000.0, 200000.0, 300000.0],
576            entropy_bits: 8.5,
577            hurst_exponent: Some(0.72),
578        };
579
580        let binding_mac = BindingMac {
581            mac: [2u8; 32],
582            document_hash: [3u8; 32],
583            keystroke_count: 5000,
584            timestamp_ms: 1700000000000,
585        };
586
587        JitterBinding::new(commitment, sources, summary, binding_mac)
588    }
589
590    #[test]
591    fn test_jitter_binding_serialization() {
592        let binding = create_test_binding();
593
594        let json = serde_json::to_string_pretty(&binding).unwrap();
595        let decoded: JitterBinding = serde_json::from_str(&json).unwrap();
596
597        assert_eq!(binding.summary.sample_count, decoded.summary.sample_count);
598        assert_eq!(binding.sources.len(), decoded.sources.len());
599    }
600
601    #[test]
602    fn test_hurst_validation() {
603        let mut binding = create_test_binding();
604
605        binding.summary.hurst_exponent = Some(0.72);
606        assert!(binding.is_hurst_valid());
607
608        binding.summary.hurst_exponent = Some(0.5);
609        assert!(!binding.is_hurst_valid());
610
611        binding.summary.hurst_exponent = Some(1.0);
612        assert!(!binding.is_hurst_valid());
613
614        binding.summary.hurst_exponent = None;
615        assert!(binding.is_hurst_valid());
616    }
617
618    #[test]
619    fn test_active_probes() {
620        let mut binding = create_test_binding();
621
622        let probes = ActiveProbes {
623            galton_invariant: Some(GaltonInvariant {
624                absorption_coefficient: 0.65,
625                stimulus_count: 100,
626                expected_absorption: 0.63,
627                z_score: 0.5,
628                passed: true,
629            }),
630            reflex_gate: Some(ReflexGate {
631                mean_latency_ms: 250.0,
632                std_dev_ms: 50.0,
633                event_count: 50,
634                percentiles: [180.0, 210.0, 245.0, 285.0, 340.0],
635                passed: true,
636            }),
637        };
638
639        binding.active_probes = Some(probes);
640        assert!(binding.probes_passed());
641
642        binding
643            .active_probes
644            .as_mut()
645            .unwrap()
646            .galton_invariant
647            .as_mut()
648            .unwrap()
649            .passed = false;
650        assert!(!binding.probes_passed());
651    }
652
653    #[test]
654    fn test_jitter_binding_validation_valid() {
655        let binding = create_test_binding();
656        let errors = binding.validate();
657        assert!(errors.is_empty(), "expected no errors, got: {:?}", errors);
658        assert!(binding.is_valid());
659    }
660
661    #[test]
662    fn test_jitter_binding_validation_zero_hash() {
663        let mut binding = create_test_binding();
664        binding.entropy_commitment.hash = [0u8; 32];
665        let errors = binding.validate();
666        assert!(errors
667            .iter()
668            .any(|e| e.contains("entropy commitment hash is zero")));
669        assert!(!binding.is_valid());
670    }
671
672    #[test]
673    fn test_jitter_binding_validation_empty_sources() {
674        let mut binding = create_test_binding();
675        binding.sources.clear();
676        let errors = binding.validate();
677        assert!(errors.iter().any(|e| e.contains("no entropy sources")));
678    }
679
680    #[test]
681    fn test_jitter_binding_validation_excessive_weight() {
682        let mut binding = create_test_binding();
683        binding.sources[0].weight = 800;
684        binding.sources[1].weight = 500;
685        let errors = binding.validate();
686        assert!(errors.iter().any(|e| e.contains("exceeds 1000")));
687    }
688
689    #[test]
690    fn test_jitter_binding_validation_invalid_hurst() {
691        let mut binding = create_test_binding();
692        binding.summary.hurst_exponent = Some(1.5);
693        let errors = binding.validate();
694        assert!(errors.iter().any(|e| e.contains("Hurst exponent")));
695    }
696
697    #[test]
698    fn test_jitter_binding_validation_non_monotonic_percentiles() {
699        let mut binding = create_test_binding();
700        binding.summary.percentiles = [100.0, 50.0, 75.0, 80.0, 90.0];
701        let errors = binding.validate();
702        assert!(errors.iter().any(|e| e.contains("not monotonic")));
703    }
704}