Skip to main content

cpop_protocol/
evidence.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use crate::codec::{decode_evidence, encode_evidence};
4use crate::crypto::{hash_sha256, sign_evidence_cose, verify_evidence_cose, EvidenceSigner};
5use crate::error::{Error, Result};
6use crate::rfc::{
7    AttestationTier, Checkpoint, DocumentRef, EvidencePacket, HashAlgorithm, HashValue,
8};
9use cpop_jitter::{EntropySource, PhysJitter};
10use ed25519_dalek::VerifyingKey;
11use rand::rngs::OsRng;
12use rand::RngCore;
13use std::time::{SystemTime, UNIX_EPOCH};
14
15fn hash_document_ref(doc: &DocumentRef) -> Result<HashValue> {
16    let mut buf = Vec::new();
17    ciborium::into_writer(doc, &mut buf)
18        .map_err(|e| Error::Protocol(format!("CBOR encode document-ref: {e}")))?;
19    Ok(hash_sha256(&buf))
20}
21
22fn now_millis() -> Result<u64> {
23    Ok(SystemTime::now()
24        .duration_since(UNIX_EPOCH)
25        .map_err(|e| Error::Protocol(format!("system clock error: {}", e)))?
26        .as_millis() as u64)
27}
28
29/// Incrementally build a signed CPoP evidence packet with causality-chained checkpoints.
30pub struct Builder {
31    version: u32,
32    profile_uri: String,
33    packet_id: [u8; 16],
34    created: u64,
35    document: DocumentRef,
36    checkpoints: Vec<Checkpoint>,
37    last_checkpoint_hash: HashValue,
38    signer: Box<dyn EvidenceSigner>,
39    jitter: PhysJitter,
40    attestation_tier: AttestationTier,
41    baseline_verification: Option<crate::baseline::BaselineVerification>,
42}
43
44impl Builder {
45    pub fn new(document: DocumentRef, signer: Box<dyn EvidenceSigner>) -> Result<Self> {
46        let mut packet_id = [0u8; 16];
47        OsRng.fill_bytes(&mut packet_id);
48
49        let now = now_millis()?;
50
51        let initial_hash = hash_document_ref(&document)?;
52
53        Ok(Self {
54            version: 1,
55            profile_uri: "urn:ietf:params:pop:profile:1.0".to_string(),
56            packet_id,
57            created: now,
58            document,
59            checkpoints: Vec::new(),
60            last_checkpoint_hash: initial_hash,
61            signer,
62            jitter: PhysJitter::new(1), // Lowered for demo compatibility
63            attestation_tier: AttestationTier::SoftwareOnly,
64            baseline_verification: None,
65        })
66    }
67
68    pub fn with_attestation_tier(mut self, tier: AttestationTier) -> Self {
69        self.attestation_tier = tier;
70        self
71    }
72
73    pub fn with_baseline_verification(mut self, bv: crate::baseline::BaselineVerification) -> Self {
74        self.baseline_verification = Some(bv);
75        self
76    }
77
78    /// Append a checkpoint, extending the causality chain.
79    pub fn add_checkpoint(&mut self, content: &[u8], char_count: u64) -> Result<()> {
80        let now = now_millis()?;
81
82        let sequence = self.checkpoints.len() as u64;
83        let mut checkpoint_id = [0u8; 16];
84        OsRng.fill_bytes(&mut checkpoint_id);
85
86        let content_hash = hash_sha256(content);
87
88        let entropy = self
89            .jitter
90            .sample(content)
91            .map_err(|e| Error::Crypto(format!("PhysJitter sampling failed: {}", e)))?;
92
93        // Causality Lock V2: HMAC(packet_id, prev_hash | content_hash | entropy)
94        let checkpoint_hash = crate::crypto::compute_causality_lock_v2(
95            &self.packet_id,
96            &self.last_checkpoint_hash.digest,
97            &content_hash.digest,
98            &entropy.hash,
99        )?;
100
101        let checkpoint = Checkpoint {
102            sequence,
103            checkpoint_id: checkpoint_id.to_vec(),
104            timestamp: now,
105            content_hash,
106            char_count,
107            prev_hash: self.last_checkpoint_hash.clone(),
108            checkpoint_hash: checkpoint_hash.clone(),
109            jitter_hash: Some(HashValue {
110                algorithm: HashAlgorithm::Sha256,
111                digest: entropy.hash.to_vec(),
112            }),
113        };
114
115        self.last_checkpoint_hash = checkpoint_hash;
116        self.checkpoints.push(checkpoint);
117
118        Ok(())
119    }
120
121    /// Finalize the evidence packet, CBOR-encode it, and wrap in a COSE_Sign1 envelope.
122    pub fn finalize(self) -> Result<Vec<u8>> {
123        let packet = EvidencePacket {
124            version: self.version,
125            profile_uri: self.profile_uri,
126            packet_id: self.packet_id.to_vec(),
127            created: self.created,
128            document: self.document,
129            checkpoints: self.checkpoints,
130            attestation_tier: Some(self.attestation_tier),
131            baseline_verification: self.baseline_verification,
132        };
133
134        let encoded = encode_evidence(&packet)?;
135        sign_evidence_cose(&encoded, self.signer.as_ref())
136    }
137}
138
139/// Verify COSE-signed evidence packets: signature, causality chain, and temporal consistency.
140pub struct Verifier {
141    verifying_key: VerifyingKey,
142}
143
144impl Verifier {
145    pub fn new(verifying_key: VerifyingKey) -> Self {
146        Self { verifying_key }
147    }
148
149    /// Verify signature, decode the packet, and validate causality chain integrity.
150    pub fn verify(&self, cose_data: &[u8]) -> Result<EvidencePacket> {
151        let payload = verify_evidence_cose(cose_data, &self.verifying_key)?;
152        let packet = decode_evidence(&payload)?;
153        self.validate_structure(&packet)?;
154
155        let mut last_hash = hash_document_ref(&packet.document)?;
156
157        for (i, checkpoint) in packet.checkpoints.iter().enumerate() {
158            if checkpoint.sequence != i as u64 {
159                return Err(Error::Validation(format!(
160                    "Sequence mismatch at index {}: expected {}, got {}",
161                    i, i, checkpoint.sequence
162                )));
163            }
164
165            if !checkpoint.prev_hash.ct_eq(&last_hash) {
166                return Err(Error::Validation(format!(
167                    "Causality chain broken at sequence {}: prev_hash mismatch",
168                    checkpoint.sequence
169                )));
170            }
171
172            let expected_hash = if let Some(ref jitter) = checkpoint.jitter_hash {
173                crate::crypto::compute_causality_lock_v2(
174                    &packet.packet_id,
175                    &last_hash.digest,
176                    &checkpoint.content_hash.digest,
177                    &jitter.digest,
178                )?
179            } else {
180                crate::crypto::compute_causality_lock(
181                    &packet.packet_id,
182                    &last_hash.digest,
183                    &checkpoint.content_hash.digest,
184                )?
185            };
186
187            if !checkpoint.checkpoint_hash.ct_eq(&expected_hash) {
188                return Err(Error::Validation(format!(
189                    "Causality chain broken at sequence {}: checkpoint_hash mismatch",
190                    checkpoint.sequence
191                )));
192            }
193
194            last_hash = expected_hash;
195        }
196
197        self.validate_temporal_consistency(&packet)?;
198
199        if let Some(ref bv) = packet.baseline_verification {
200            self.validate_baseline_verification(bv)?;
201        }
202
203        Ok(packet)
204    }
205
206    /// Verifies identity_fingerprint == SHA-256(signer pubkey) and that
207    /// digest_signature is present when digest is present.
208    /// Behavioral similarity scoring is done at the engine layer.
209    fn validate_baseline_verification(
210        &self,
211        bv: &crate::baseline::BaselineVerification,
212    ) -> Result<()> {
213        if let Some(ref digest) = bv.digest {
214            let pubkey_hash = hash_sha256(self.verifying_key.as_bytes());
215            if digest.identity_fingerprint != pubkey_hash.digest {
216                return Err(Error::Validation(
217                    "Baseline digest identity_fingerprint does not match signer public key"
218                        .to_string(),
219                ));
220            }
221
222            if bv.digest_signature.is_none() {
223                return Err(Error::Validation(
224                    "Baseline digest present but digest_signature is missing".to_string(),
225                ));
226            }
227        }
228        Ok(())
229    }
230
231    fn validate_structure(&self, packet: &EvidencePacket) -> Result<()> {
232        const EXPECTED_PROFILE_URI: &str = "urn:ietf:params:pop:profile:1.0";
233        if packet.profile_uri != EXPECTED_PROFILE_URI {
234            return Err(Error::Validation(format!(
235                "Invalid profile_uri: expected \"{}\", got \"{}\"",
236                EXPECTED_PROFILE_URI, packet.profile_uri
237            )));
238        }
239
240        if packet.packet_id.len() != 16 {
241            return Err(Error::Validation(format!(
242                "Invalid packet_id length: expected 16, got {}",
243                packet.packet_id.len()
244            )));
245        }
246
247        if !packet.document.content_hash.validate() {
248            return Err(Error::Validation(
249                "Document content_hash digest length does not match algorithm".to_string(),
250            ));
251        }
252
253        const MAX_FILENAME_LEN: usize = 256;
254        if let Some(ref filename) = packet.document.filename {
255            if filename.len() > MAX_FILENAME_LEN {
256                return Err(Error::Validation(format!(
257                    "Document filename too long: {} bytes exceeds limit of {}",
258                    filename.len(),
259                    MAX_FILENAME_LEN
260                )));
261            }
262        }
263
264        const MAX_CHECKPOINTS: usize = 100_000;
265        if packet.checkpoints.len() > MAX_CHECKPOINTS {
266            return Err(Error::Validation(format!(
267                "Too many checkpoints: {} exceeds limit of {}",
268                packet.checkpoints.len(),
269                MAX_CHECKPOINTS
270            )));
271        }
272
273        for checkpoint in &packet.checkpoints {
274            if checkpoint.checkpoint_id.len() != 16 {
275                return Err(Error::Validation(format!(
276                    "Invalid checkpoint_id length at sequence {}: expected 16, got {}",
277                    checkpoint.sequence,
278                    checkpoint.checkpoint_id.len()
279                )));
280            }
281            if !checkpoint.content_hash.validate() {
282                return Err(Error::Validation(format!(
283                    "Invalid content_hash at sequence {}: digest length mismatch",
284                    checkpoint.sequence
285                )));
286            }
287            if !checkpoint.prev_hash.validate() {
288                return Err(Error::Validation(format!(
289                    "Invalid prev_hash at sequence {}: digest length mismatch",
290                    checkpoint.sequence
291                )));
292            }
293            if !checkpoint.checkpoint_hash.validate() {
294                return Err(Error::Validation(format!(
295                    "Invalid checkpoint_hash at sequence {}: digest length mismatch",
296                    checkpoint.sequence
297                )));
298            }
299            if let Some(ref jitter) = checkpoint.jitter_hash {
300                if !jitter.validate() {
301                    return Err(Error::Validation(format!(
302                        "Invalid jitter_hash at sequence {}: digest length mismatch",
303                        checkpoint.sequence
304                    )));
305                }
306            }
307        }
308
309        Ok(())
310    }
311
312    fn validate_temporal_consistency(&self, packet: &EvidencePacket) -> Result<()> {
313        if packet.checkpoints.is_empty() {
314            return Ok(());
315        }
316
317        let mut last_ts = packet.created;
318        let mut intervals = Vec::with_capacity(packet.checkpoints.len().saturating_sub(1));
319
320        for checkpoint in &packet.checkpoints {
321            if checkpoint.timestamp < last_ts {
322                return Err(Error::Validation(format!(
323                    "Temporal anomaly: checkpoint {} timestamp is before previous",
324                    checkpoint.sequence
325                )));
326            }
327
328            if checkpoint.sequence > 0 {
329                intervals.push(checkpoint.timestamp - last_ts);
330            }
331            last_ts = checkpoint.timestamp;
332        }
333
334        // Adversarial Collapse: all-identical intervals indicate script/playback
335        if intervals.len() >= 3 {
336            let first = intervals[0];
337            if intervals.iter().all(|&x| x == first) {
338                return Err(Error::Validation(
339                    "Adversarial collapse detected: non-human timing uniformity".to_string(),
340                ));
341            }
342        }
343
344        Ok(())
345    }
346}