Skip to main content

cpop_protocol/
baseline.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use serde::{Deserialize, Serialize};
4
5/// Progressive confidence tier based on session count:
6/// - PopulationReference (0-4): Human vs machine only
7/// - Emerging (5-9): Meaningful author consistency
8/// - Established (10-19): Author identity distinguishable
9/// - Mature (20+): Full authorship attribution
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[repr(u64)]
12pub enum ConfidenceTier {
13    PopulationReference = 1,
14    Emerging = 2,
15    Established = 3,
16    Mature = 4,
17}
18
19impl ConfidenceTier {
20    pub fn from_session_count(count: u64) -> Self {
21        match count {
22            0..=4 => Self::PopulationReference,
23            5..=9 => Self::Emerging,
24            10..=19 => Self::Established,
25            _ => Self::Mature,
26        }
27    }
28}
29
30/// Welford's algorithm for streaming metrics.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct StreamingStats {
33    #[serde(rename = "1")]
34    pub count: u64,
35    #[serde(rename = "2")]
36    pub mean: f64,
37    #[serde(rename = "3")]
38    pub m2: f64,
39    #[serde(rename = "4")]
40    pub min: f64,
41    #[serde(rename = "5")]
42    pub max: f64,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct SessionBehavioralSummary {
47    /// 9-bin IKI histogram (edges: 0, 50, 100, 150, 200, 300, 500, 1000, 2000ms)
48    #[serde(rename = "1")]
49    pub iki_histogram: [f64; 9],
50    #[serde(rename = "2")]
51    pub iki_cv: f64,
52    /// Long-range dependency exponent
53    #[serde(rename = "3")]
54    pub hurst: f64,
55    #[serde(rename = "4")]
56    pub pause_frequency: f64,
57    #[serde(rename = "5")]
58    pub duration_secs: u64,
59    #[serde(rename = "6")]
60    pub keystroke_count: u64,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct BaselineDigest {
65    #[serde(rename = "1")]
66    pub version: u32,
67    #[serde(rename = "2")]
68    pub session_count: u64,
69    #[serde(rename = "3")]
70    pub total_keystrokes: u64,
71    #[serde(rename = "4")]
72    pub iki_stats: StreamingStats,
73    #[serde(rename = "5")]
74    pub cv_stats: StreamingStats,
75    #[serde(rename = "6")]
76    pub hurst_stats: StreamingStats,
77    #[serde(rename = "7")]
78    pub aggregate_iki_histogram: [f64; 9],
79    #[serde(rename = "8")]
80    pub pause_stats: StreamingStats,
81    /// MMR root over previous session evidence hashes
82    #[serde(rename = "9", with = "serde_bytes")]
83    pub session_merkle_root: Vec<u8>,
84    #[serde(rename = "10")]
85    pub confidence_tier: ConfidenceTier,
86    #[serde(rename = "11")]
87    pub computed_at: u64,
88    /// SHA-256(Ed25519 public key)
89    #[serde(rename = "12", with = "serde_bytes")]
90    pub identity_fingerprint: Vec<u8>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct BaselineVerification {
95    /// None during enrollment phase.
96    #[serde(rename = "1", default, skip_serializing_if = "Option::is_none")]
97    pub digest: Option<BaselineDigest>,
98    #[serde(rename = "2")]
99    pub session_summary: SessionBehavioralSummary,
100    /// COSE_Sign1 over the CBOR-encoded digest.
101    #[serde(
102        rename = "3",
103        default,
104        skip_serializing_if = "Option::is_none",
105        with = "serde_bytes_opt"
106    )]
107    pub digest_signature: Option<Vec<u8>>,
108}
109
110impl Default for SessionBehavioralSummary {
111    fn default() -> Self {
112        Self {
113            iki_histogram: [0.0; 9],
114            iki_cv: 0.0,
115            hurst: 0.5,
116            pause_frequency: 0.0,
117            duration_secs: 0,
118            keystroke_count: 0,
119        }
120    }
121}
122
123mod serde_bytes_opt {
124    use serde::{Deserialize, Deserializer, Serializer};
125
126    pub fn serialize<S>(val: &Option<Vec<u8>>, serializer: S) -> Result<S::Ok, S::Error>
127    where
128        S: Serializer,
129    {
130        match val {
131            Some(v) => serde_bytes::serialize(v, serializer),
132            None => serializer.serialize_none(),
133        }
134    }
135
136    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Vec<u8>>, D::Error>
137    where
138        D: Deserializer<'de>,
139    {
140        Option::<serde_bytes::ByteBuf>::deserialize(deserializer)
141            .map(|opt| opt.map(|buf| buf.into_vec()))
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_confidence_tier_from_session_count() {
151        assert_eq!(
152            ConfidenceTier::from_session_count(0),
153            ConfidenceTier::PopulationReference
154        );
155        assert_eq!(
156            ConfidenceTier::from_session_count(4),
157            ConfidenceTier::PopulationReference
158        );
159        assert_eq!(
160            ConfidenceTier::from_session_count(5),
161            ConfidenceTier::Emerging
162        );
163        assert_eq!(
164            ConfidenceTier::from_session_count(10),
165            ConfidenceTier::Established
166        );
167        assert_eq!(
168            ConfidenceTier::from_session_count(20),
169            ConfidenceTier::Mature
170        );
171        assert_eq!(
172            ConfidenceTier::from_session_count(100),
173            ConfidenceTier::Mature
174        );
175    }
176
177    #[test]
178    fn test_baseline_verification_cbor_roundtrip_enrollment() {
179        let summary = SessionBehavioralSummary {
180            iki_histogram: [0.1, 0.2, 0.15, 0.1, 0.1, 0.15, 0.1, 0.05, 0.05],
181            iki_cv: 0.45,
182            hurst: 0.72,
183            pause_frequency: 3.5,
184            duration_secs: 1800,
185            keystroke_count: 5000,
186        };
187
188        let bv = BaselineVerification {
189            digest: None,
190            session_summary: summary,
191            digest_signature: None,
192        };
193
194        let mut buf = Vec::new();
195        ciborium::into_writer(&bv, &mut buf).expect("CBOR encode");
196        let decoded: BaselineVerification = ciborium::from_reader(&buf[..]).expect("CBOR decode");
197
198        assert!(decoded.digest.is_none());
199        assert!((decoded.session_summary.iki_cv - 0.45).abs() < 1e-10);
200        assert_eq!(decoded.session_summary.keystroke_count, 5000);
201        assert!(
202            buf.len() < 200,
203            "Enrollment wire overhead: {} bytes",
204            buf.len()
205        );
206    }
207
208    #[test]
209    fn test_baseline_verification_cbor_roundtrip_with_digest() {
210        let digest = BaselineDigest {
211            version: 1,
212            session_count: 10,
213            total_keystrokes: 50000,
214            iki_stats: StreamingStats {
215                count: 10,
216                mean: 150.0,
217                m2: 500.0,
218                min: 80.0,
219                max: 300.0,
220            },
221            cv_stats: StreamingStats {
222                count: 10,
223                mean: 0.45,
224                m2: 0.02,
225                min: 0.3,
226                max: 0.6,
227            },
228            hurst_stats: StreamingStats {
229                count: 10,
230                mean: 0.72,
231                m2: 0.01,
232                min: 0.65,
233                max: 0.8,
234            },
235            aggregate_iki_histogram: [0.1, 0.2, 0.15, 0.1, 0.1, 0.15, 0.1, 0.05, 0.05],
236            pause_stats: StreamingStats {
237                count: 10,
238                mean: 3.5,
239                m2: 2.0,
240                min: 1.0,
241                max: 7.0,
242            },
243            session_merkle_root: vec![0xAA; 32],
244            confidence_tier: ConfidenceTier::Established,
245            computed_at: 1708790400,
246            identity_fingerprint: vec![0xBB; 32],
247        };
248
249        let bv = BaselineVerification {
250            digest: Some(digest),
251            session_summary: SessionBehavioralSummary::default(),
252            digest_signature: Some(vec![0xCC; 64]),
253        };
254
255        let mut buf = Vec::new();
256        ciborium::into_writer(&bv, &mut buf).expect("CBOR encode");
257        let decoded: BaselineVerification = ciborium::from_reader(&buf[..]).expect("CBOR decode");
258
259        let d = decoded.digest.as_ref().unwrap();
260        assert_eq!(d.session_count, 10);
261        assert_eq!(d.confidence_tier, ConfidenceTier::Established);
262        assert_eq!(d.identity_fingerprint, vec![0xBB; 32]);
263        assert_eq!(decoded.digest_signature.as_ref().unwrap().len(), 64);
264        assert!(buf.len() < 600, "Full wire overhead: {} bytes", buf.len());
265    }
266}