Skip to main content

cpop_jitter/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Proof-of-process primitive using timing jitter for human authorship verification.
4//!
5//! Two engines: [`PureJitter`] (HMAC-based, economic security) and
6//! [`PhysJitter`] (hardware entropy, physics security). [`HybridEngine`] selects
7//! the best available source with automatic fallback.
8//!
9//! ```rust
10//! use cpop_jitter::{HybridEngine, PureJitter, PhysJitter, Evidence};
11//!
12//! let engine = HybridEngine::new(PhysJitter::default(), PureJitter::default());
13//! let secret = [0u8; 32];
14//! let (jitter, evidence) = engine.sample(&secret, b"keystroke data").unwrap();
15//! println!("Jitter: {}us, Physics: {}", jitter, evidence.is_phys());
16//! ```
17//!
18//! Supports `no_std` via [`PureJitter`] with explicit timestamps. The `std` feature
19//! enables [`HybridEngine`], [`PhysJitter`], and [`Session`].
20
21#![cfg_attr(not(feature = "std"), no_std)]
22#![cfg_attr(not(feature = "hardware"), forbid(unsafe_code))]
23
24#[cfg(not(feature = "std"))]
25extern crate alloc;
26
27#[cfg(feature = "std")]
28use zeroize::Zeroizing;
29
30pub mod evidence;
31pub mod model;
32#[cfg(feature = "std")]
33pub mod phys;
34pub mod pure;
35pub mod traits;
36
37pub use evidence::{Evidence, EvidenceChain, MAX_EVIDENCE_RECORDS};
38pub use model::{Anomaly, AnomalyKind, HumanModel, SequenceStats, ValidationResult};
39#[cfg(feature = "std")]
40pub use phys::PhysJitter;
41pub use pure::PureJitter;
42#[cfg(feature = "std")]
43pub use traits::EntropySource;
44pub use traits::JitterEngine;
45
46pub fn derive_session_secret(master_key: &[u8], context: &[u8]) -> [u8; 32] {
47    use hkdf::Hkdf;
48    use sha2::Sha256;
49
50    let hk = Hkdf::<Sha256>::new(None, master_key);
51    let mut output = [0u8; 32];
52    hk.expand(context, &mut output)
53        .expect("32 bytes is a valid output length for HKDF-SHA256");
54    output
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
58pub struct PhysHash {
59    pub hash: [u8; 32],
60    pub entropy_bits: u8,
61}
62
63impl From<[u8; 32]> for PhysHash {
64    fn from(hash: [u8; 32]) -> Self {
65        Self {
66            hash,
67            entropy_bits: 0,
68        }
69    }
70}
71
72/// Microseconds.
73pub type Jitter = u32;
74
75#[derive(Debug)]
76#[cfg_attr(feature = "std", derive(thiserror::Error))]
77pub enum Error {
78    #[cfg_attr(
79        feature = "std",
80        error("Insufficient entropy: required {required} bits, found {found}")
81    )]
82    InsufficientEntropy { required: u8, found: u8 },
83
84    #[cfg_attr(feature = "std", error("Hardware entropy not available: {reason}"))]
85    HardwareUnavailable {
86        #[cfg(feature = "std")]
87        reason: String,
88        #[cfg(not(feature = "std"))]
89        reason: &'static str,
90    },
91
92    #[cfg_attr(feature = "std", error("Invalid input: {0}"))]
93    InvalidInput(
94        #[cfg(feature = "std")] String,
95        #[cfg(not(feature = "std"))] &'static str,
96    ),
97}
98
99#[cfg(not(feature = "std"))]
100impl core::fmt::Display for Error {
101    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
102        match self {
103            Error::InsufficientEntropy { required, found } => {
104                write!(
105                    f,
106                    "Insufficient entropy: required {} bits, found {}",
107                    required, found
108                )
109            }
110            Error::HardwareUnavailable { reason } => {
111                write!(f, "Hardware entropy not available: {}", reason)
112            }
113            Error::InvalidInput(msg) => write!(f, "Invalid input: {}", msg),
114        }
115    }
116}
117
118#[cfg(feature = "std")]
119#[derive(Debug, Clone)]
120pub struct HybridEngine {
121    phys: PhysJitter,
122    fallback: PureJitter,
123    min_phys_entropy: u8,
124}
125
126#[cfg(feature = "std")]
127impl Default for HybridEngine {
128    fn default() -> Self {
129        Self::new(PhysJitter::default(), PureJitter::default())
130    }
131}
132
133#[cfg(feature = "std")]
134impl HybridEngine {
135    pub fn new(phys: PhysJitter, fallback: PureJitter) -> Self {
136        Self {
137            phys,
138            fallback,
139            min_phys_entropy: 8,
140        }
141    }
142
143    pub fn with_min_entropy(mut self, bits: u8) -> Self {
144        self.min_phys_entropy = bits;
145        self
146    }
147
148    pub fn sample(&self, secret: &[u8; 32], inputs: &[u8]) -> Result<(Jitter, Evidence), Error> {
149        match self.phys.sample(inputs) {
150            Ok(entropy)
151                if entropy.entropy_bits >= self.min_phys_entropy && self.phys.validate(entropy) =>
152            {
153                let jitter = self.phys.compute_jitter(secret, inputs, entropy);
154                Ok((jitter, Evidence::phys(entropy, jitter)))
155            }
156            _ => {
157                let jitter = self
158                    .fallback
159                    .compute_jitter(secret, inputs, [0u8; 32].into());
160                Ok((jitter, Evidence::pure(jitter)))
161            }
162        }
163    }
164
165    pub fn phys_available(&self) -> bool {
166        self.phys.sample(b"probe").is_ok()
167    }
168}
169
170#[cfg(feature = "std")]
171#[derive(Debug)]
172pub struct Session {
173    secret: Zeroizing<[u8; 32]>,
174    engine: HybridEngine,
175    evidence: EvidenceChain,
176    model: HumanModel,
177}
178
179#[cfg(feature = "std")]
180impl Session {
181    pub fn new(secret: [u8; 32]) -> Self {
182        Self {
183            secret: Zeroizing::new(secret),
184            engine: HybridEngine::default(),
185            evidence: EvidenceChain::with_secret(secret),
186            model: HumanModel::default(),
187        }
188    }
189
190    pub fn with_engine(secret: [u8; 32], engine: HybridEngine) -> Self {
191        Self {
192            secret: Zeroizing::new(secret),
193            engine,
194            evidence: EvidenceChain::with_secret(secret),
195            model: HumanModel::default(),
196        }
197    }
198
199    #[cfg(feature = "rand")]
200    pub fn random() -> Self {
201        use rand::RngCore;
202        let mut secret = [0u8; 32];
203        rand::thread_rng().fill_bytes(&mut secret);
204        Self::new(secret)
205    }
206
207    pub fn sample(&mut self, inputs: &[u8]) -> Result<Jitter, Error> {
208        let (jitter, evidence) = self.engine.sample(&self.secret, inputs)?;
209        self.evidence.append(evidence);
210        Ok(jitter)
211    }
212
213    pub fn evidence(&self) -> &EvidenceChain {
214        &self.evidence
215    }
216
217    pub fn validate(&self) -> ValidationResult {
218        let jitters: Vec<Jitter> = self.evidence.records.iter().map(|e| e.jitter()).collect();
219        self.model.validate(&jitters)
220    }
221
222    pub fn phys_ratio(&self) -> f64 {
223        self.evidence.phys_ratio()
224    }
225
226    pub fn export_json(&self) -> Result<String, serde_json::Error> {
227        serde_json::to_string_pretty(&self.evidence)
228    }
229}
230
231#[cfg(all(test, feature = "std"))]
232mod tests {
233    use super::*;
234
235    #[test]
236    fn test_hybrid_engine_default() {
237        let engine = HybridEngine::default();
238        let secret = [42u8; 32];
239        let inputs = b"test input";
240
241        let result = engine.sample(&secret, inputs);
242        assert!(result.is_ok());
243
244        let (jitter, evidence) = result.unwrap();
245        assert!(jitter >= 500);
246        assert!(jitter < 3000);
247        assert!(evidence.jitter() == jitter);
248    }
249
250    #[test]
251    fn test_session_workflow() {
252        let secret = [1u8; 32];
253        let mut session = Session::new(secret);
254
255        for i in 0..30 {
256            let input = format!("keystroke {}", i);
257            let jitter = session.sample(input.as_bytes()).unwrap();
258            assert!(jitter >= 500);
259        }
260
261        assert_eq!(session.evidence().records.len(), 30);
262        let validation = session.validate();
263        println!("Validation: {:?}", validation);
264    }
265
266    #[test]
267    fn test_evidence_serialization() {
268        let secret = [2u8; 32];
269        let mut session = Session::new(secret);
270
271        for i in 0..10 {
272            session.sample(format!("key{}", i).as_bytes()).unwrap();
273        }
274
275        let json = session.export_json().unwrap();
276        assert!(json.contains("\"version\""));
277        assert!(json.contains("\"records\""));
278    }
279
280    #[test]
281    fn test_pure_jitter_determinism() {
282        let engine = PureJitter::default();
283        let secret = [99u8; 32];
284        let inputs = b"deterministic test";
285        let entropy: PhysHash = [0u8; 32].into();
286
287        let j1 = engine.compute_jitter(&secret, inputs, entropy);
288        let j2 = engine.compute_jitter(&secret, inputs, entropy);
289
290        assert_eq!(j1, j2, "Pure jitter should be deterministic");
291    }
292
293    #[test]
294    fn test_empty_inputs() {
295        let engine = HybridEngine::default();
296        let secret = [42u8; 32];
297
298        let result = engine.sample(&secret, b"");
299        assert!(result.is_ok());
300    }
301
302    #[test]
303    fn test_large_inputs() {
304        let engine = HybridEngine::default();
305        let secret = [42u8; 32];
306        let large_input = vec![0u8; 10000];
307        let result = engine.sample(&secret, &large_input);
308        assert!(result.is_ok());
309    }
310
311    #[test]
312    fn test_min_phys_entropy_enforced() {
313        let engine = HybridEngine::default().with_min_entropy(255);
314        let secret = [42u8; 32];
315
316        let (_, evidence) = engine.sample(&secret, b"test").unwrap();
317        assert!(
318            !evidence.is_phys(),
319            "Should have fallen back to pure jitter"
320        );
321    }
322}
323
324#[cfg(test)]
325mod no_std_compatible_tests {
326    use super::*;
327
328    #[test]
329    fn test_phys_hash_from_array() {
330        let hash: PhysHash = [42u8; 32].into();
331        assert_eq!(hash.entropy_bits, 0);
332        assert_eq!(hash.hash, [42u8; 32]);
333    }
334
335    #[test]
336    fn test_derive_session_secret() {
337        let master = [1u8; 32];
338        let secret1 = derive_session_secret(&master, b"context1");
339        let secret2 = derive_session_secret(&master, b"context2");
340        assert_ne!(secret1, secret2);
341    }
342
343    #[test]
344    fn test_evidence_with_timestamp() {
345        let evidence = Evidence::pure_with_timestamp(1500, 12345);
346        assert_eq!(evidence.jitter(), 1500);
347        assert_eq!(evidence.timestamp_us(), 12345);
348        assert!(!evidence.is_phys());
349
350        let phys_hash: PhysHash = [1u8; 32].into();
351        let phys_evidence = Evidence::phys_with_timestamp(phys_hash, 2000, 67890);
352        assert_eq!(phys_evidence.jitter(), 2000);
353        assert_eq!(phys_evidence.timestamp_us(), 67890);
354        assert!(phys_evidence.is_phys());
355    }
356}