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 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}