1use 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
15pub fn record_version(record: &wire::SignedRecord) -> ContentHash {
17 ContentHash::compute_typed("heddle-signed-check-receipt-v1", &record.encode_to_vec())
18}
19
20pub 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
54pub 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}
121pub 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 },
280 signer,
281 )
282 }
283 #[test]
284 fn projection_preserves_typed_attribution_references_and_outcomes() {
285 let (mut value, signer) = fixture();
286 value.artifacts = vec![Uuid::from_u128(8)];
287 value.supersedes = vec![Uuid::from_u128(9)];
288 value.completed_at_ms = 1234;
289 for (outcome, expected) in [
290 (
291 CheckOutcome::Passed,
292 wire::check_evidence_summary::Outcome::Passed,
293 ),
294 (
295 CheckOutcome::Failed,
296 wire::check_evidence_summary::Outcome::Failed,
297 ),
298 (
299 CheckOutcome::Error,
300 wire::check_evidence_summary::Outcome::Error,
301 ),
302 (
303 CheckOutcome::Skipped,
304 wire::check_evidence_summary::Outcome::Skipped,
305 ),
306 ] {
307 value.outcome = outcome;
308 let record = sign_evidence(&value, &signer).expect("signed evidence");
309 let projected = project(&record).expect("verified projection");
310 assert_eq!(projected.version, record_version(&record).as_bytes());
311 let summary = projected.summary.expect("typed summary");
312 assert_eq!(summary.outcome, expected as i32);
313 assert_eq!(summary.detail, "42 passed");
314 assert_eq!(
315 summary.author.expect("original author").id,
316 value.author.actor.principal_id.to_string()
317 );
318 assert_eq!(summary.agent_id, "test-runner");
319 assert_eq!(summary.artifacts[0].id, value.artifacts[0].to_string());
320 assert_eq!(
321 summary.artifacts[0].spool.as_ref().expect("scope").id,
322 value.spool.to_string()
323 );
324 assert_eq!(summary.supersedes[0].id, value.supersedes[0].to_string());
325 let completed = summary.completed_at.expect("completion");
326 assert_eq!((completed.seconds, completed.nanos), (1, 234_000_000));
327 let mut forged = record;
328 forged.signatures[0].signature[0] ^= 1;
329 assert!(
330 project(&forged).is_err(),
331 "unverified bytes cannot become display evidence"
332 );
333 }
334 }
335 #[test]
336 fn evidence_retains_exact_original_signed_fields() {
337 let (value, signer) = fixture();
338 let record = sign_evidence(&value, &signer).expect("sign");
339 assert_eq!(verify_evidence(&record).expect("verify"), value);
340 let mut forged = value.clone();
341 forged.outcome = CheckOutcome::Failed;
342 let mut altered = record.clone();
343 altered.canonical_record = forged.encode().expect("encode");
344 assert!(
345 verify_evidence(&altered).is_err(),
346 "outcome must be covered by original signature"
347 );
348 altered = record.clone();
349 altered.signatures.push(altered.signatures[0].clone());
350 assert!(
351 verify_evidence(&altered).is_err(),
352 "multiple signature attribution is ambiguous"
353 );
354 altered = record;
355 altered.format = ACKNOWLEDGEMENT_FORMAT.into();
356 assert!(
357 verify_evidence(&altered).is_err(),
358 "signature domain must remain exact"
359 );
360 }
361 #[test]
362 fn acknowledgement_binds_evidence_revision_policy_and_actor() {
363 let (value, signer) = fixture();
364 let acknowledgement = CheckAcknowledgement {
365 version: 1,
366 spool: value.spool,
367 evidence: value.id,
368 evidence_digest: value.id().expect("digest"),
369 revision: value.revision,
370 policy_version: ContentHash::from_bytes([5; 32]),
371 author: value.author,
372 client_operation_id: Uuid::from_u128(6),
373 occurred_at_ms: 1001,
374 };
375 let mut record = sign_acknowledgement(&acknowledgement, &signer).expect("sign");
376 let projected = project_acknowledgement(&record).expect("typed progress");
377 assert_eq!(
378 projected.evidence.expect("evidence").id,
379 acknowledgement.evidence.to_string()
380 );
381 assert_eq!(
382 projected.policy_version,
383 acknowledgement.policy_version.as_bytes()
384 );
385 assert_eq!(
386 projected.author.expect("original author").id,
387 acknowledgement.author.actor.principal_id.to_string()
388 );
389 assert_eq!(projected.agent_id, "test-runner");
390 assert_eq!(projected.acknowledgement.as_ref(), Some(&record));
391 assert_eq!(
392 verify_acknowledgement(&record).expect("verify"),
393 acknowledgement
394 );
395 let mut other = acknowledgement;
396 other.policy_version = ContentHash::from_bytes([7; 32]);
397 record.canonical_record = other.encode().expect("canonical");
398 assert!(
399 verify_acknowledgement(&record).is_err(),
400 "policy changes require new acknowledgement"
401 );
402 }
403 #[test]
404 fn authority_digest_and_artifact_set_are_canonical_and_bounded() {
405 let (mut value, signer) = fixture();
406 value.author.authority_envelope.push(0);
407 assert!(sign_evidence(&value, &signer).is_err());
408 value.author.authority_digest =
409 ContentHash::compute_typed(AUTHORITY_FORMAT, &value.author.authority_envelope);
410 value.artifacts = vec![Uuid::from_u128(8), Uuid::from_u128(8)];
411 assert!(sign_evidence(&value, &signer).is_err());
412 value.artifacts.clear();
413 value.supersedes = vec![value.id];
414 assert!(sign_evidence(&value, &signer).is_err());
415 value.supersedes.clear();
416 value.detail = "x".repeat(32769);
417 assert!(sign_evidence(&value, &signer).is_err());
418 }
419}