Skip to main content

heddle_thread_api/
evidence.rs

1//! Typed immutable evidence codecs shared by hosted and device callers.
2//! Signature verification does not establish current account or Spool authority.
3use crypto::{Ed25519Signer, Signer};
4pub use heddle_object_model::object::check_evidence::{
5    CheckAcknowledgement, CheckAuthor, CheckEvidence, CheckOutcome,
6};
7use heddle_object_model::object::{
8    ContentHash,
9    check_evidence::{ACKNOWLEDGEMENT_FORMAT, EVIDENCE_FORMAT},
10};
11use prost::Message;
12
13use crate::{contract as wire, transport::Error};
14
15/// Stable version of the exact signed receipt, including its original signature.
16pub fn record_version(record: &wire::SignedRecord) -> ContentHash {
17    ContentHash::compute_typed("heddle-signed-check-receipt-v1", &record.encode_to_vec())
18}
19
20/// Derive the complete query/display surface from the verified original.
21/// Current authority and visibility remain the receiving endpoint's responsibility.
22pub fn project(record: &wire::SignedRecord) -> Result<wire::EvidenceRecord, Error> {
23    let value = verify_evidence(record)?;
24    let spool = wire::SpoolRef {
25        id: value.spool.to_string(),
26    };
27    Ok(wire::EvidenceRecord {
28        r#ref: Some(wire::RecordRef {
29            spool: Some(spool.clone()),
30            id: value.id.to_string(),
31        }),
32        thread: Some(wire::ThreadRef {
33            spool: Some(spool.clone()),
34            id: Some(wire::ThreadId {
35                value: value.thread.as_bytes().to_vec(),
36            }),
37        }),
38        version: record_version(record).as_bytes().to_vec(),
39        revision: Some(wire::RevisionRef {
40            spool: Some(spool),
41            revision: Some(wire::revision_ref::Revision::State(
42                api::heddle::api::common::StateId {
43                    value: value.revision.as_bytes().to_vec(),
44                },
45            )),
46        }),
47        check: value.check.clone(),
48        evidence: Some(record.clone()),
49        coverage: wire::Coverage::Complete as i32,
50        summary: Some(summary(&value)),
51    })
52}
53
54/// Presentation only: call `verify_evidence` before projecting untrusted bytes.
55pub fn summary(value: &CheckEvidence) -> wire::CheckEvidenceSummary {
56    let reference = |id: &uuid::Uuid| wire::RecordRef {
57        spool: Some(wire::SpoolRef {
58            id: value.spool.to_string(),
59        }),
60        id: id.to_string(),
61    };
62    wire::CheckEvidenceSummary {
63        outcome: match value.outcome {
64            CheckOutcome::Passed => wire::check_evidence_summary::Outcome::Passed,
65            CheckOutcome::Failed => wire::check_evidence_summary::Outcome::Failed,
66            CheckOutcome::Error => wire::check_evidence_summary::Outcome::Error,
67            CheckOutcome::Skipped => wire::check_evidence_summary::Outcome::Skipped,
68        } as i32,
69        detail: value.detail.clone(),
70        author: Some(wire::PrincipalRef {
71            id: value.author.actor.principal_id.to_string(),
72        }),
73        agent_id: value.author.actor.agent_id.clone().unwrap_or_default(),
74        artifacts: value.artifacts.iter().map(reference).collect(),
75        supersedes: value.supersedes.iter().map(reference).collect(),
76        completed_at: Some(prost_types::Timestamp {
77            seconds: value.completed_at_ms.div_euclid(1000),
78            nanos: (value.completed_at_ms.rem_euclid(1000) * 1_000_000) as i32,
79        }),
80    }
81}
82
83pub fn sign_evidence(
84    value: &CheckEvidence,
85    signer: &impl Signer,
86) -> Result<wire::SignedRecord, Error> {
87    sign(
88        EVIDENCE_FORMAT,
89        value
90            .encode()
91            .map_err(|_| Error::Protocol("invalid check evidence"))?,
92        &value.author,
93        signer,
94    )
95}
96pub fn verify_evidence(record: &wire::SignedRecord) -> Result<CheckEvidence, Error> {
97    let value = CheckEvidence::decode(&record.canonical_record)
98        .map_err(|_| Error::Protocol("invalid canonical check evidence"))?;
99    verify(record, EVIDENCE_FORMAT, &value.author)?;
100    Ok(value)
101}
102pub fn sign_acknowledgement(
103    value: &CheckAcknowledgement,
104    signer: &impl Signer,
105) -> Result<wire::SignedRecord, Error> {
106    sign(
107        ACKNOWLEDGEMENT_FORMAT,
108        value
109            .encode()
110            .map_err(|_| Error::Protocol("invalid check acknowledgement"))?,
111        &value.author,
112        signer,
113    )
114}
115pub fn verify_acknowledgement(record: &wire::SignedRecord) -> Result<CheckAcknowledgement, Error> {
116    let value = CheckAcknowledgement::decode(&record.canonical_record)
117        .map_err(|_| Error::Protocol("invalid canonical check acknowledgement"))?;
118    verify(record, ACKNOWLEDGEMENT_FORMAT, &value.author)?;
119    Ok(value)
120}
121/// Project original progress without treating it as a successful check outcome.
122pub fn project_acknowledgement(
123    record: &wire::SignedRecord,
124) -> Result<wire::CheckAcknowledgementRecord, Error> {
125    let value = verify_acknowledgement(record)?;
126    let spool = wire::SpoolRef {
127        id: value.spool.to_string(),
128    };
129    Ok(wire::CheckAcknowledgementRecord {
130        r#ref: Some(wire::RecordRef {
131            spool: Some(spool.clone()),
132            id: value.client_operation_id.to_string(),
133        }),
134        version: record_version(record).as_bytes().to_vec(),
135        evidence: Some(wire::RecordRef {
136            spool: Some(spool.clone()),
137            id: value.evidence.to_string(),
138        }),
139        revision: Some(wire::RevisionRef {
140            spool: Some(spool),
141            revision: Some(wire::revision_ref::Revision::State(
142                api::heddle::api::common::StateId {
143                    value: value.revision.as_bytes().to_vec(),
144                },
145            )),
146        }),
147        policy_version: value.policy_version.as_bytes().to_vec(),
148        author: Some(wire::PrincipalRef {
149            id: value.author.actor.principal_id.to_string(),
150        }),
151        agent_id: value.author.actor.agent_id.unwrap_or_default(),
152        acknowledged_at: Some(prost_types::Timestamp {
153            seconds: value.occurred_at_ms.div_euclid(1000),
154            nanos: (value.occurred_at_ms.rem_euclid(1000) * 1_000_000) as i32,
155        }),
156        acknowledgement: Some(record.clone()),
157    })
158}
159fn sign(
160    format: &str,
161    canonical: Vec<u8>,
162    author: &CheckAuthor,
163    signer: &impl Signer,
164) -> Result<wire::SignedRecord, Error> {
165    if signer.public_key() != author.publisher {
166        return Err(Error::Protocol("check publisher differs from signer"));
167    }
168    let signature = signer
169        .sign(&signing_bytes(format, &canonical))
170        .map_err(|_| Error::Protocol("check signing failed"))?;
171    let record = wire::SignedRecord {
172        format: format.into(),
173        canonical_record: canonical,
174        signatures: vec![wire::RecordSignature {
175            public_key: author.publisher.to_vec(),
176            signature,
177        }],
178    };
179    verify(&record, format, author)?;
180    Ok(record)
181}
182fn verify(record: &wire::SignedRecord, format: &str, author: &CheckAuthor) -> Result<(), Error> {
183    let [signature] = record.signatures.as_slice() else {
184        return Err(Error::Protocol(
185            "check requires exactly one original signature",
186        ));
187    };
188    if record.format != format || signature.public_key != author.publisher {
189        return Err(Error::Protocol("check format or publisher mismatch"));
190    }
191    Ed25519Signer::verify_with_public_key(
192        &signing_bytes(format, &record.canonical_record),
193        &author.publisher,
194        &signature.signature,
195    )
196    .map_err(|_| Error::Protocol("invalid check signature"))
197}
198fn signing_bytes(format: &str, canonical: &[u8]) -> Vec<u8> {
199    let mut bytes = Vec::with_capacity(format.len() + 1 + canonical.len());
200    bytes.extend_from_slice(format.as_bytes());
201    bytes.push(0);
202    bytes.extend_from_slice(canonical);
203    bytes
204}
205
206#[cfg(test)]
207mod tests {
208    use heddle_object_model::object::{
209        CollaborationActor, ContentHash, StateId, thread_replication::metadata::AUTHORITY_FORMAT,
210    };
211    use uuid::Uuid;
212
213    use super::*;
214
215    #[test]
216    fn rust_v2_interop_vector() {
217        let (mut value, _) = fixture();
218        let signer = Ed25519Signer::from_seed(&[19; 32]).expect("fixed vector seed");
219        value.author.publisher = signer.public_key().try_into().expect("Ed25519 key");
220        value.artifacts = vec![Uuid::from_u128(8)];
221        value.supersedes = vec![Uuid::from_u128(9)];
222        value.completed_at_ms = 1234;
223        let evidence = sign_evidence(&value, &signer).expect("evidence");
224        let ack = CheckAcknowledgement {
225            version: 1,
226            spool: value.spool,
227            evidence: value.id,
228            evidence_digest: value.id().expect("digest"),
229            revision: value.revision,
230            policy_version: ContentHash::from_bytes([5; 32]),
231            author: value.author.clone(),
232            client_operation_id: Uuid::from_u128(6),
233            occurred_at_ms: 2001,
234        };
235        let ack = sign_acknowledgement(&ack, &signer).expect("acknowledgement");
236        let hex = |bytes: &[u8]| {
237            bytes
238                .iter()
239                .map(|byte| format!("{byte:02x}"))
240                .collect::<String>()
241        };
242        println!("RUST_V2_EVIDENCE_HEX {}", hex(&evidence.canonical_record));
243        println!(
244            "RUST_V2_EVIDENCE_SIGNATURE {}",
245            hex(&evidence.signatures[0].signature)
246        );
247        println!("RUST_V2_ACKNOWLEDGEMENT_HEX {}", hex(&ack.canonical_record));
248        println!(
249            "RUST_V2_ACKNOWLEDGEMENT_SIGNATURE {}",
250            hex(&ack.signatures[0].signature)
251        );
252    }
253
254    fn fixture() -> (CheckEvidence, Ed25519Signer) {
255        let signer = Ed25519Signer::generate().expect("signer");
256        let envelope = b"independently verified by receiving host".to_vec();
257        (
258            CheckEvidence {
259                version: 2,
260                thread: ContentHash::from_bytes([7; 32]),
261                id: Uuid::from_u128(1),
262                spool: Uuid::from_u128(2),
263                revision: StateId::from_bytes([3; 32]),
264                check: "unit-tests".into(),
265                outcome: CheckOutcome::Passed,
266                detail: "42 passed".into(),
267                artifacts: Vec::new(),
268                supersedes: Vec::new(),
269                author: CheckAuthor {
270                    actor: CollaborationActor {
271                        principal_id: Uuid::from_u128(4),
272                        agent_id: Some("test-runner".into()),
273                    },
274                    publisher: signer.public_key().try_into().expect("Ed25519"),
275                    authority_digest: ContentHash::compute_typed(AUTHORITY_FORMAT, &envelope),
276                    authority_envelope: envelope,
277                },
278                completed_at_ms: 1000,
279                canonical_body: Default::default(),
280            },
281            signer,
282        )
283    }
284    #[test]
285    fn projection_preserves_typed_attribution_references_and_outcomes() {
286        let (mut value, signer) = fixture();
287        value.artifacts = vec![Uuid::from_u128(8)];
288        value.supersedes = vec![Uuid::from_u128(9)];
289        value.completed_at_ms = 1234;
290        for (outcome, expected) in [
291            (
292                CheckOutcome::Passed,
293                wire::check_evidence_summary::Outcome::Passed,
294            ),
295            (
296                CheckOutcome::Failed,
297                wire::check_evidence_summary::Outcome::Failed,
298            ),
299            (
300                CheckOutcome::Error,
301                wire::check_evidence_summary::Outcome::Error,
302            ),
303            (
304                CheckOutcome::Skipped,
305                wire::check_evidence_summary::Outcome::Skipped,
306            ),
307        ] {
308            value.outcome = outcome;
309            let record = sign_evidence(&value, &signer).expect("signed evidence");
310            let projected = project(&record).expect("verified projection");
311            assert_eq!(projected.version, record_version(&record).as_bytes());
312            let summary = projected.summary.expect("typed summary");
313            assert_eq!(summary.outcome, expected as i32);
314            assert_eq!(summary.detail, "42 passed");
315            assert_eq!(
316                summary.author.expect("original author").id,
317                value.author.actor.principal_id.to_string()
318            );
319            assert_eq!(summary.agent_id, "test-runner");
320            assert_eq!(summary.artifacts[0].id, value.artifacts[0].to_string());
321            assert_eq!(
322                summary.artifacts[0].spool.as_ref().expect("scope").id,
323                value.spool.to_string()
324            );
325            assert_eq!(summary.supersedes[0].id, value.supersedes[0].to_string());
326            let completed = summary.completed_at.expect("completion");
327            assert_eq!((completed.seconds, completed.nanos), (1, 234_000_000));
328            let mut forged = record;
329            forged.signatures[0].signature[0] ^= 1;
330            assert!(
331                project(&forged).is_err(),
332                "unverified bytes cannot become display evidence"
333            );
334        }
335    }
336    #[test]
337    fn evidence_retains_exact_original_signed_fields() {
338        let (value, signer) = fixture();
339        let record = sign_evidence(&value, &signer).expect("sign");
340        assert_eq!(verify_evidence(&record).expect("verify"), value);
341        let mut forged = value.clone();
342        forged.outcome = CheckOutcome::Failed;
343        let mut altered = record.clone();
344        altered.canonical_record = forged.encode().expect("encode");
345        assert!(
346            verify_evidence(&altered).is_err(),
347            "outcome must be covered by original signature"
348        );
349        altered = record.clone();
350        altered.signatures.push(altered.signatures[0].clone());
351        assert!(
352            verify_evidence(&altered).is_err(),
353            "multiple signature attribution is ambiguous"
354        );
355        altered = record;
356        altered.format = ACKNOWLEDGEMENT_FORMAT.into();
357        assert!(
358            verify_evidence(&altered).is_err(),
359            "signature domain must remain exact"
360        );
361    }
362    #[test]
363    fn acknowledgement_binds_evidence_revision_policy_and_actor() {
364        let (value, signer) = fixture();
365        let acknowledgement = CheckAcknowledgement {
366            version: 1,
367            spool: value.spool,
368            evidence: value.id,
369            evidence_digest: value.id().expect("digest"),
370            revision: value.revision,
371            policy_version: ContentHash::from_bytes([5; 32]),
372            author: value.author,
373            client_operation_id: Uuid::from_u128(6),
374            occurred_at_ms: 1001,
375        };
376        let mut record = sign_acknowledgement(&acknowledgement, &signer).expect("sign");
377        let projected = project_acknowledgement(&record).expect("typed progress");
378        assert_eq!(
379            projected.evidence.expect("evidence").id,
380            acknowledgement.evidence.to_string()
381        );
382        assert_eq!(
383            projected.policy_version,
384            acknowledgement.policy_version.as_bytes()
385        );
386        assert_eq!(
387            projected.author.expect("original author").id,
388            acknowledgement.author.actor.principal_id.to_string()
389        );
390        assert_eq!(projected.agent_id, "test-runner");
391        assert_eq!(projected.acknowledgement.as_ref(), Some(&record));
392        assert_eq!(
393            verify_acknowledgement(&record).expect("verify"),
394            acknowledgement
395        );
396        let mut other = acknowledgement;
397        other.policy_version = ContentHash::from_bytes([7; 32]);
398        record.canonical_record = other.encode().expect("canonical");
399        assert!(
400            verify_acknowledgement(&record).is_err(),
401            "policy changes require new acknowledgement"
402        );
403    }
404    #[test]
405    fn authority_digest_and_artifact_set_are_canonical_and_bounded() {
406        let (mut value, signer) = fixture();
407        value.author.authority_envelope.push(0);
408        assert!(sign_evidence(&value, &signer).is_err());
409        value.author.authority_digest =
410            ContentHash::compute_typed(AUTHORITY_FORMAT, &value.author.authority_envelope);
411        value.artifacts = vec![Uuid::from_u128(8), Uuid::from_u128(8)];
412        assert!(sign_evidence(&value, &signer).is_err());
413        value.artifacts.clear();
414        value.supersedes = vec![value.id];
415        assert!(sign_evidence(&value, &signer).is_err());
416        value.supersedes.clear();
417        value.detail = "x".repeat(32769);
418        assert!(sign_evidence(&value, &signer).is_err());
419    }
420}