1use crate::crypto::EvidenceSigner;
10use crate::error::{Error, Result};
11use crate::rfc::EvidencePacket;
12#[cfg(test)]
13use coset::CborSerializable;
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ProcessAssertion {
19 pub label: String,
20 pub version: u32,
21 pub evidence_id: String,
22 pub evidence_hash: String,
23 pub jitter_seals: Vec<JitterSeal>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct JitterSeal {
28 pub sequence: u64,
29 pub timestamp: u64,
30 pub seal_hash: String,
31}
32
33impl ProcessAssertion {
34 pub fn from_evidence(packet: &EvidencePacket, original_bytes: &[u8]) -> Self {
35 let hash = Sha256::digest(original_bytes);
36
37 let jitter_seals = packet
38 .checkpoints
39 .iter()
40 .map(|cp| JitterSeal {
41 sequence: cp.sequence,
42 timestamp: cp.timestamp,
43 seal_hash: hex::encode(&cp.checkpoint_hash.digest),
44 })
45 .collect();
46
47 Self {
48 label: ASSERTION_LABEL_CPOP.to_string(),
49 version: packet.version,
50 evidence_id: hex::encode(&packet.packet_id),
51 evidence_hash: hex::encode(hash),
52 jitter_seals,
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ActionsAssertion {
60 pub actions: Vec<Action>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Action {
66 pub action: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub when: Option<String>,
69 #[serde(rename = "softwareAgent", skip_serializing_if = "Option::is_none")]
70 pub software_agent: Option<SoftwareAgent>,
71 #[serde(skip_serializing_if = "Option::is_none")]
72 pub parameters: Option<ActionParameters>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(untagged)]
78pub enum SoftwareAgent {
79 Simple(String),
80 Info(ClaimGeneratorInfo),
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ActionParameters {
85 #[serde(skip_serializing_if = "Option::is_none")]
86 pub description: Option<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct HashDataAssertion {
92 pub name: String,
93 #[serde(with = "serde_bytes")]
94 pub hash: Vec<u8>,
95 #[serde(rename = "alg")]
97 pub algorithm: String,
98 #[serde(default)]
99 pub exclusions: Vec<ExclusionRange>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ExclusionRange {
105 pub start: u64,
106 pub length: u64,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct C2paClaim {
112 pub claim_generator: String,
114
115 pub claim_generator_info: Vec<ClaimGeneratorInfo>,
117
118 #[serde(rename = "instanceID")]
120 pub instance_id: String,
121
122 pub signature: String,
124
125 pub created_assertions: Vec<HashedUri>,
127
128 #[serde(rename = "dc:title", skip_serializing_if = "Option::is_none")]
129 pub title: Option<String>,
130
131 #[serde(rename = "dc:format", skip_serializing_if = "Option::is_none")]
132 pub format: Option<String>,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct ClaimGeneratorInfo {
138 pub name: String,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub version: Option<String>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct HashedUri {
149 pub url: String,
150 #[serde(with = "serde_bytes")]
151 pub hash: Vec<u8>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 pub alg: Option<String>,
154}
155
156#[derive(Debug, Clone)]
160pub struct C2paManifest {
161 pub claim: C2paClaim,
162 pub manifest_label: String,
164 pub assertion_boxes: Vec<Vec<u8>>,
165 pub signature: Vec<u8>,
166}
167
168const C2PA_MANIFEST_STORE_UUID: [u8; 16] = [
170 0x63, 0x32, 0x70, 0x61, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
172];
173
174const C2PA_MANIFEST_UUID: [u8; 16] = [
175 0x63, 0x32, 0x6D, 0x61, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
177];
178
179const C2PA_CLAIM_UUID: [u8; 16] = [
180 0x63, 0x32, 0x63, 0x6C, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
182];
183
184const C2PA_ASSERTION_STORE_UUID: [u8; 16] = [
185 0x63, 0x32, 0x61, 0x73, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
187];
188
189const C2PA_SIGNATURE_UUID: [u8; 16] = [
190 0x63, 0x32, 0x63, 0x73, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
192];
193
194const JUMBF_CBOR_UUID: [u8; 16] = [
196 0x63, 0x62, 0x6F, 0x72, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
198];
199
200const JUMBF_JSON_UUID: [u8; 16] = [
202 0x6A, 0x73, 0x6F, 0x6E, 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
204];
205
206pub const ASSERTION_LABEL_CPOP: &str = "org.cpop.evidence";
207pub const ASSERTION_LABEL_ACTIONS: &str = "c2pa.actions";
208pub const ASSERTION_LABEL_HASH_DATA: &str = "c2pa.hash.data";
209
210const CLAIM_GENERATOR: &str = concat!(
211 "CPOP/",
212 env!("CARGO_PKG_VERSION"),
213 " cpop_protocol/",
214 env!("CARGO_PKG_VERSION")
215);
216
217const COSE_HEADER_LABEL_COSE_KEY: i64 = -1;
219
220struct JumbfWriter {
222 buf: Vec<u8>,
223}
224
225impl JumbfWriter {
226 fn new() -> Self {
227 Self {
228 buf: Vec::with_capacity(4096),
229 }
230 }
231
232 fn write_description(&mut self, uuid: &[u8; 16], label: Option<&str>, toggles: u8) {
233 let label_bytes = label.map(|l| l.as_bytes());
234 let label_len = label_bytes.map_or(0, |b| b.len() + 1); let box_len = 8 + 16 + 1 + label_len;
236 self.write_box_header(box_len as u32, b"jumd");
237 self.buf.extend_from_slice(uuid);
238 self.buf.push(toggles);
239 if let Some(bytes) = label_bytes {
240 self.buf.extend_from_slice(bytes);
241 self.buf.push(0);
242 }
243 }
244
245 fn write_content_cbor(&mut self, data: &[u8]) {
246 let box_len = (8 + data.len()) as u32;
247 self.write_box_header(box_len, b"cbor");
248 self.buf.extend_from_slice(data);
249 }
250
251 fn write_content_json(&mut self, data: &[u8]) {
252 let box_len = (8 + data.len()) as u32;
253 self.write_box_header(box_len, b"json");
254 self.buf.extend_from_slice(data);
255 }
256
257 fn write_raw(&mut self, data: &[u8]) {
258 self.buf.extend_from_slice(data);
259 }
260
261 fn begin_superbox(&mut self) -> usize {
263 let offset = self.buf.len();
264 self.write_box_header(0, b"jumb");
265 offset
266 }
267
268 fn end_superbox(&mut self, offset: usize) {
269 let total_len = (self.buf.len() - offset) as u32;
270 self.buf[offset..offset + 4].copy_from_slice(&total_len.to_be_bytes());
271 }
272
273 fn write_box_header(&mut self, size: u32, box_type: &[u8; 4]) {
274 self.buf.extend_from_slice(&size.to_be_bytes());
275 self.buf.extend_from_slice(box_type);
276 }
277
278 fn finish(self) -> Vec<u8> {
279 self.buf
280 }
281}
282
283pub struct C2paManifestBuilder {
284 document_hash: [u8; 32],
285 document_filename: Option<String>,
286 evidence_bytes: Vec<u8>,
287 evidence_packet: EvidencePacket,
288 title: Option<String>,
289 manifest_label: String,
290}
291
292impl C2paManifestBuilder {
293 pub fn new(
294 evidence_packet: EvidencePacket,
295 evidence_bytes: Vec<u8>,
296 document_hash: [u8; 32],
297 ) -> Self {
298 let manifest_label = format!("urn:cpop:{}", hex::encode(&evidence_packet.packet_id));
299 Self {
300 document_hash,
301 document_filename: None,
302 evidence_bytes,
303 evidence_packet,
304 title: None,
305 manifest_label,
306 }
307 }
308
309 pub fn document_filename(mut self, name: impl Into<String>) -> Self {
310 self.document_filename = Some(name.into());
311 self
312 }
313
314 pub fn title(mut self, title: impl Into<String>) -> Self {
316 self.title = Some(title.into());
317 self
318 }
319
320 pub fn build_jumbf(self, signer: &dyn EvidenceSigner) -> Result<Vec<u8>> {
321 let manifest = self.build_manifest(signer)?;
322 encode_jumbf(&manifest)
323 }
324
325 pub fn build_manifest(self, signer: &dyn EvidenceSigner) -> Result<C2paManifest> {
326 let cpop_assertion =
327 ProcessAssertion::from_evidence(&self.evidence_packet, &self.evidence_bytes);
328
329 let now = chrono::Utc::now().to_rfc3339();
330
331 let actions_assertion = ActionsAssertion {
332 actions: vec![Action {
333 action: "c2pa.created".to_string(),
334 when: Some(now),
335 software_agent: Some(SoftwareAgent::Info(ClaimGeneratorInfo {
336 name: "CPOP".to_string(),
337 version: Some(env!("CARGO_PKG_VERSION").to_string()),
338 })),
339 parameters: Some(ActionParameters {
340 description: Some(
341 "Document authored with CPOP Proof-of-Process witnessing".to_string(),
342 ),
343 }),
344 }],
345 };
346
347 let hash_data_assertion = HashDataAssertion {
348 name: self
349 .document_filename
350 .clone()
351 .unwrap_or_else(|| "document".to_string()),
352 hash: self.document_hash.to_vec(),
353 algorithm: "sha256".to_string(),
354 exclusions: vec![],
355 };
356
357 let hash_data_box =
359 build_assertion_jumbf_cbor(ASSERTION_LABEL_HASH_DATA, &hash_data_assertion)?;
360 let actions_box = build_assertion_jumbf_cbor(ASSERTION_LABEL_ACTIONS, &actions_assertion)?;
361 let cpop_box = build_assertion_jumbf_json(ASSERTION_LABEL_CPOP, &cpop_assertion)?;
362
363 let hash_data_hash = Sha256::digest(&hash_data_box[8..]);
365 let actions_hash = Sha256::digest(&actions_box[8..]);
366 let cpop_hash = Sha256::digest(&cpop_box[8..]);
367
368 let manifest_label = &self.manifest_label;
369
370 let sig_url = format!("self#jumbf=/c2pa/{manifest_label}/c2pa.signature");
371
372 let created_assertions = vec![
373 HashedUri {
374 url: format!(
375 "self#jumbf=/c2pa/{manifest_label}/c2pa.assertions/{ASSERTION_LABEL_HASH_DATA}"
376 ),
377 hash: hash_data_hash.to_vec(),
378 alg: Some("sha256".to_string()),
379 },
380 HashedUri {
381 url: format!(
382 "self#jumbf=/c2pa/{manifest_label}/c2pa.assertions/{ASSERTION_LABEL_ACTIONS}"
383 ),
384 hash: actions_hash.to_vec(),
385 alg: Some("sha256".to_string()),
386 },
387 HashedUri {
388 url: format!(
389 "self#jumbf=/c2pa/{manifest_label}/c2pa.assertions/{ASSERTION_LABEL_CPOP}"
390 ),
391 hash: cpop_hash.to_vec(),
392 alg: Some("sha256".to_string()),
393 },
394 ];
395
396 let claim = C2paClaim {
397 claim_generator: CLAIM_GENERATOR.to_string(),
398 claim_generator_info: vec![
399 ClaimGeneratorInfo {
400 name: "CPOP".to_string(),
401 version: Some(env!("CARGO_PKG_VERSION").to_string()),
402 },
403 ClaimGeneratorInfo {
404 name: "cpop_protocol".to_string(),
405 version: Some(env!("CARGO_PKG_VERSION").to_string()),
406 },
407 ],
408 instance_id: format!("xmp:iid:{}", hex::encode(&self.evidence_packet.packet_id)),
409 signature: sig_url,
410 created_assertions,
411 title: self.title,
412 format: None,
413 };
414
415 let claim_cbor = ciborium_to_vec(&claim)?;
417 let signature = sign_c2pa_claim(&claim_cbor, signer)?;
418
419 Ok(C2paManifest {
420 claim,
421 manifest_label: self.manifest_label.clone(),
422 assertion_boxes: vec![hash_data_box, actions_box, cpop_box],
423 signature,
424 })
425 }
426}
427
428fn sign_c2pa_claim(claim_cbor: &[u8], signer: &dyn EvidenceSigner) -> Result<Vec<u8>> {
430 let mut unprotected = coset::Header::default();
431 unprotected.rest.push((
432 coset::Label::Int(COSE_HEADER_LABEL_COSE_KEY),
433 ciborium::Value::Bytes(signer.public_key()),
434 ));
435 crate::crypto::cose_sign1(claim_cbor, signer, unprotected)
436}
437
438fn build_assertion_jumbf_json<T: Serialize>(label: &str, value: &T) -> Result<Vec<u8>> {
439 let content = serde_json::to_vec(value).map_err(|e| Error::Serialization(e.to_string()))?;
440 build_assertion_jumbf(label, &JUMBF_JSON_UUID, &content, false)
441}
442
443fn build_assertion_jumbf_cbor<T: Serialize>(label: &str, value: &T) -> Result<Vec<u8>> {
444 let content = ciborium_to_vec(value)?;
445 build_assertion_jumbf(label, &JUMBF_CBOR_UUID, &content, true)
446}
447
448fn build_assertion_jumbf(
449 label: &str,
450 uuid: &[u8; 16],
451 content: &[u8],
452 is_cbor: bool,
453) -> Result<Vec<u8>> {
454 let mut w = JumbfWriter::new();
455 let off = w.begin_superbox();
456 w.write_description(uuid, Some(label), 0x03);
457 if is_cbor {
458 w.write_content_cbor(content);
459 } else {
460 w.write_content_json(content);
461 }
462 w.end_superbox(off);
463 Ok(w.finish())
464}
465
466fn ciborium_to_vec<T: Serialize>(value: &T) -> Result<Vec<u8>> {
467 let mut buf = Vec::new();
468 ciborium::into_writer(value, &mut buf)
469 .map_err(|e| Error::Serialization(format!("CBOR encode: {e}")))?;
470 Ok(buf)
471}
472
473pub fn encode_jumbf(manifest: &C2paManifest) -> Result<Vec<u8>> {
474 let mut w = JumbfWriter::new();
475
476 let store_off = w.begin_superbox();
477 w.write_description(&C2PA_MANIFEST_STORE_UUID, Some("c2pa"), 0x03);
478
479 let manifest_off = w.begin_superbox();
480 w.write_description(&C2PA_MANIFEST_UUID, Some(&manifest.manifest_label), 0x03);
481
482 let claim_off = w.begin_superbox();
484 w.write_description(&C2PA_CLAIM_UUID, Some("c2pa.claim.v2"), 0x03);
485 let claim_cbor = ciborium_to_vec(&manifest.claim)?;
486 w.write_content_cbor(&claim_cbor);
487 w.end_superbox(claim_off);
488
489 let astore_off = w.begin_superbox();
490 w.write_description(&C2PA_ASSERTION_STORE_UUID, Some("c2pa.assertions"), 0x03);
491 for assertion_box in &manifest.assertion_boxes {
492 w.write_raw(assertion_box);
493 }
494 w.end_superbox(astore_off);
495
496 let sig_off = w.begin_superbox();
497 w.write_description(&C2PA_SIGNATURE_UUID, Some("c2pa.signature"), 0x03);
498 w.write_content_cbor(&manifest.signature);
499 w.end_superbox(sig_off);
500
501 w.end_superbox(manifest_off);
502 w.end_superbox(store_off);
503
504 Ok(w.finish())
505}
506
507#[derive(Debug)]
508pub struct ValidationResult {
509 pub errors: Vec<String>,
510 pub warnings: Vec<String>,
511}
512
513impl ValidationResult {
514 pub fn is_valid(&self) -> bool {
515 self.errors.is_empty()
516 }
517}
518
519pub fn validate_manifest(manifest: &C2paManifest) -> ValidationResult {
521 let mut errors = Vec::new();
522 let mut warnings = Vec::new();
523
524 let hard_binding_count = manifest
525 .claim
526 .created_assertions
527 .iter()
528 .filter(|a| a.url.contains(ASSERTION_LABEL_HASH_DATA))
529 .count();
530 if hard_binding_count != 1 {
531 errors.push(format!(
532 "Standard manifest requires exactly 1 hard binding, found {hard_binding_count}"
533 ));
534 }
535
536 let actions_count = manifest
537 .claim
538 .created_assertions
539 .iter()
540 .filter(|a| a.url.contains(ASSERTION_LABEL_ACTIONS))
541 .count();
542 if actions_count != 1 {
543 errors.push(format!(
544 "Standard manifest requires exactly 1 actions assertion, found {actions_count}"
545 ));
546 }
547
548 for (i, assertion) in manifest.claim.created_assertions.iter().enumerate() {
549 if !assertion.url.contains(&manifest.manifest_label) {
550 errors.push(format!(
551 "created_assertions[{i}].url does not contain manifest label '{}'",
552 manifest.manifest_label
553 ));
554 }
555 }
556
557 if !manifest.claim.signature.contains(&manifest.manifest_label) {
558 errors.push(format!(
559 "signature URI does not contain manifest label '{}'",
560 manifest.manifest_label
561 ));
562 }
563
564 if manifest.claim.claim_generator_info.is_empty() {
565 errors.push("claim_generator_info must have at least one entry".to_string());
566 } else if manifest.claim.claim_generator_info[0].name.is_empty() {
567 errors.push("claim_generator_info[0].name must not be empty".to_string());
568 }
569
570 if manifest.claim.instance_id.is_empty() {
571 errors.push("instanceID must not be empty".to_string());
572 }
573
574 if manifest.claim.signature.is_empty() {
575 errors.push("signature URI must not be empty".to_string());
576 }
577
578 for (i, assertion) in manifest.claim.created_assertions.iter().enumerate() {
579 if assertion.hash.len() != 32 {
580 errors.push(format!(
581 "created_assertions[{i}] hash length {} != 32",
582 assertion.hash.len()
583 ));
584 }
585 if assertion.url.is_empty() {
586 errors.push(format!("created_assertions[{i}] has empty URL"));
587 }
588 }
589
590 if manifest.assertion_boxes.len() != manifest.claim.created_assertions.len() {
591 errors.push(format!(
592 "assertion_boxes count ({}) != created_assertions count ({})",
593 manifest.assertion_boxes.len(),
594 manifest.claim.created_assertions.len()
595 ));
596 }
597
598 for (i, (assertion_ref, box_bytes)) in manifest
599 .claim
600 .created_assertions
601 .iter()
602 .zip(manifest.assertion_boxes.iter())
603 .enumerate()
604 {
605 if box_bytes.len() < 8 {
606 errors.push(format!("assertion_boxes[{i}] too short"));
607 continue;
608 }
609 let computed_hash = Sha256::digest(&box_bytes[8..]);
610 if assertion_ref.hash != computed_hash.as_slice() {
611 errors.push(format!(
612 "created_assertions[{i}] hash mismatch: claim has {}, box hashes to {}",
613 hex::encode(&assertion_ref.hash),
614 hex::encode(computed_hash)
615 ));
616 }
617 }
618
619 if manifest.signature.is_empty() {
620 errors.push("COSE_Sign1 signature is empty".to_string());
621 }
622
623 if manifest.manifest_label.is_empty() {
624 warnings.push("manifest_label is empty".to_string());
625 }
626
627 ValidationResult { errors, warnings }
628}
629
630pub fn verify_jumbf_structure(data: &[u8]) -> Result<JumbfInfo> {
631 if data.len() < 8 {
632 return Err(Error::Validation("JUMBF data too short".to_string()));
633 }
634
635 let box_len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
636 if box_len > data.len() {
637 return Err(Error::Validation(
638 "JUMBF box length exceeds data".to_string(),
639 ));
640 }
641
642 let box_type = &data[4..8];
643 if box_type != b"jumb" {
644 return Err(Error::Validation(format!(
645 "Expected JUMBF superbox, got {:?}",
646 String::from_utf8_lossy(box_type)
647 )));
648 }
649
650 let mut offset = 8;
651 let mut found_jumd = false;
652 let mut child_count = 0u32;
653
654 while offset + 8 <= box_len {
655 let child_len = u32::from_be_bytes([
656 data[offset],
657 data[offset + 1],
658 data[offset + 2],
659 data[offset + 3],
660 ]) as usize;
661 if child_len < 8 || offset + child_len > box_len {
662 return Err(Error::Validation(format!(
663 "Invalid child box length {child_len} at offset {offset}"
664 )));
665 }
666 let child_type = &data[offset + 4..offset + 8];
667 if child_type == b"jumd" {
668 found_jumd = true;
669 }
670 child_count += 1;
671 offset += child_len;
672 }
673
674 if !found_jumd {
675 return Err(Error::Validation(
676 "Manifest store missing description box".to_string(),
677 ));
678 }
679
680 Ok(JumbfInfo {
681 total_size: box_len,
682 child_boxes: child_count,
683 })
684}
685
686#[derive(Debug)]
687pub struct JumbfInfo {
688 pub total_size: usize,
689 pub child_boxes: u32,
690}
691
692#[cfg(test)]
693mod tests {
694 use super::*;
695 use crate::rfc::{Checkpoint, DocumentRef, HashAlgorithm, HashValue};
696 use ed25519_dalek::SigningKey;
697
698 fn test_evidence_packet() -> EvidencePacket {
699 EvidencePacket {
700 version: 1,
701 profile_uri: "urn:ietf:params:pop:profile:1.0".to_string(),
702 packet_id: vec![0xAA; 16],
703 created: 1710000000000,
704 document: DocumentRef {
705 content_hash: HashValue {
706 algorithm: HashAlgorithm::Sha256,
707 digest: vec![0xAB; 32],
708 },
709 filename: Some("test.txt".to_string()),
710 byte_length: 1024,
711 char_count: 512,
712 },
713 checkpoints: vec![
714 make_checkpoint(0, 1710000001000),
715 make_checkpoint(1, 1710000002000),
716 make_checkpoint(2, 1710000003000),
717 ],
718 attestation_tier: None,
719 baseline_verification: None,
720 }
721 }
722
723 fn make_checkpoint(seq: u64, ts: u64) -> Checkpoint {
724 Checkpoint {
725 sequence: seq,
726 checkpoint_id: vec![0u8; 16],
727 timestamp: ts,
728 content_hash: HashValue {
729 algorithm: HashAlgorithm::Sha256,
730 digest: vec![seq as u8; 32],
731 },
732 char_count: 100 + seq * 50,
733 prev_hash: HashValue {
734 algorithm: HashAlgorithm::Sha256,
735 digest: vec![0u8; 32],
736 },
737 checkpoint_hash: HashValue {
738 algorithm: HashAlgorithm::Sha256,
739 digest: vec![seq as u8 + 0x10; 32],
740 },
741 jitter_hash: None,
742 }
743 }
744
745 fn test_signing_key() -> SigningKey {
746 SigningKey::from_bytes(&[1u8; 32])
747 }
748
749 fn build_test_manifest() -> C2paManifest {
750 let packet = test_evidence_packet();
751 let evidence_bytes = b"fake evidence cbor".to_vec();
752 let doc_hash = [0xABu8; 32];
753 let key = test_signing_key();
754
755 C2paManifestBuilder::new(packet, evidence_bytes, doc_hash)
756 .document_filename("test.txt")
757 .title("Test Document")
758 .build_manifest(&key)
759 .unwrap()
760 }
761
762 #[test]
763 fn cpop_assertion_from_evidence() {
764 let packet = test_evidence_packet();
765 let evidence_bytes = b"fake evidence cbor";
766 let assertion = ProcessAssertion::from_evidence(&packet, evidence_bytes);
767
768 assert_eq!(assertion.label, ASSERTION_LABEL_CPOP);
769 assert_eq!(assertion.version, 1);
770 assert_eq!(assertion.jitter_seals.len(), 3);
771 assert!(!assertion.evidence_hash.is_empty());
772 }
773
774 #[test]
775 fn claim_v2_required_fields() {
776 let manifest = build_test_manifest();
777
778 assert!(
779 !manifest.claim.instance_id.is_empty(),
780 "instanceID required"
781 );
782 assert!(
783 manifest.claim.instance_id.starts_with("xmp:iid:"),
784 "instanceID should use XMP format"
785 );
786 assert!(
787 !manifest.claim.signature.is_empty(),
788 "signature URI required"
789 );
790 assert!(
791 manifest.claim.signature.contains("c2pa.signature"),
792 "signature should reference signature box"
793 );
794 assert!(
795 !manifest.claim.claim_generator_info.is_empty(),
796 "claim_generator_info required"
797 );
798 assert!(
799 !manifest.claim.claim_generator_info[0].name.is_empty(),
800 "first entry must have name"
801 );
802 assert_eq!(manifest.claim.created_assertions.len(), 3);
803 }
804
805 #[test]
806 fn manifest_label_consistent_in_urls() {
807 let manifest = build_test_manifest();
808
809 for assertion in &manifest.claim.created_assertions {
810 assert!(
811 assertion.url.contains(&manifest.manifest_label),
812 "Assertion URL '{}' must contain manifest label '{}'",
813 assertion.url,
814 manifest.manifest_label
815 );
816 }
817
818 assert!(
819 manifest.claim.signature.contains(&manifest.manifest_label),
820 "Signature URL must contain manifest label"
821 );
822 }
823
824 #[test]
825 fn assertion_hashes_match_stored_boxes() {
826 let manifest = build_test_manifest();
827
828 assert_eq!(
829 manifest.assertion_boxes.len(),
830 manifest.claim.created_assertions.len()
831 );
832
833 for (assertion_ref, box_bytes) in manifest
834 .claim
835 .created_assertions
836 .iter()
837 .zip(manifest.assertion_boxes.iter())
838 {
839 let computed = Sha256::digest(&box_bytes[8..]);
840 assert_eq!(
841 assertion_ref.hash,
842 computed.to_vec(),
843 "Stored box hash must match claim reference"
844 );
845 }
846 }
847
848 #[test]
849 fn hashed_uri_uses_binary_hash() {
850 let manifest = build_test_manifest();
851 for assertion_ref in &manifest.claim.created_assertions {
852 assert_eq!(assertion_ref.hash.len(), 32, "SHA-256 = 32 raw bytes");
853 }
854 }
855
856 #[test]
857 fn signature_contains_public_key() {
858 let manifest = build_test_manifest();
859
860 let sign1 = coset::CoseSign1::from_slice(&manifest.signature).expect("valid COSE_Sign1");
861 let pk_entry = sign1
862 .unprotected
863 .rest
864 .iter()
865 .find(|(label, _)| *label == coset::Label::Int(COSE_HEADER_LABEL_COSE_KEY));
866 assert!(
867 pk_entry.is_some(),
868 "Public key must be in unprotected header"
869 );
870
871 if let Some((_, ciborium::Value::Bytes(pk_bytes))) = pk_entry {
872 let key = test_signing_key();
873 assert_eq!(
874 pk_bytes,
875 &key.verifying_key().to_bytes().to_vec(),
876 "Embedded key must match signer"
877 );
878 } else {
879 panic!("Public key header value must be bytes");
880 }
881 }
882
883 #[test]
884 fn standard_manifest_validation_passes() {
885 let manifest = build_test_manifest();
886 let result = validate_manifest(&manifest);
887 assert!(
888 result.is_valid(),
889 "Valid manifest should pass: {:?}",
890 result.errors
891 );
892 }
893
894 #[test]
895 fn validation_catches_label_mismatch() {
896 let mut manifest = build_test_manifest();
897 manifest.manifest_label = "urn:wrong:label".to_string();
898 let result = validate_manifest(&manifest);
899 assert!(!result.is_valid());
900 assert!(
901 result.errors.iter().any(|e| e.contains("manifest label")),
902 "Should catch label mismatch: {:?}",
903 result.errors
904 );
905 }
906
907 #[test]
908 fn validation_catches_hash_mismatch() {
909 let mut manifest = build_test_manifest();
910 if let Some(first_box) = manifest.assertion_boxes.first_mut() {
911 if first_box.len() > 10 {
912 first_box[10] ^= 0xFF;
913 }
914 }
915 let result = validate_manifest(&manifest);
916 assert!(!result.is_valid());
917 assert!(
918 result.errors.iter().any(|e| e.contains("hash mismatch")),
919 "Should catch hash mismatch: {:?}",
920 result.errors
921 );
922 }
923
924 #[test]
925 fn validation_catches_missing_hard_binding() {
926 let mut manifest = build_test_manifest();
927 manifest
928 .claim
929 .created_assertions
930 .retain(|a| !a.url.contains(ASSERTION_LABEL_HASH_DATA));
931 manifest.assertion_boxes.remove(0); let result = validate_manifest(&manifest);
933 assert!(!result.is_valid());
934 assert!(result.errors.iter().any(|e| e.contains("hard binding")));
935 }
936
937 #[test]
938 fn validation_catches_missing_actions() {
939 let mut manifest = build_test_manifest();
940 manifest
941 .claim
942 .created_assertions
943 .retain(|a| !a.url.contains(ASSERTION_LABEL_ACTIONS));
944 manifest.assertion_boxes.remove(1); let result = validate_manifest(&manifest);
946 assert!(!result.is_valid());
947 assert!(result.errors.iter().any(|e| e.contains("actions")));
948 }
949
950 #[test]
951 fn encode_jumbf_roundtrip() {
952 let manifest = build_test_manifest();
953 let jumbf = encode_jumbf(&manifest).unwrap();
954
955 assert!(jumbf.len() > 100);
956 let info = verify_jumbf_structure(&jumbf).unwrap();
957 assert!(info.child_boxes >= 2);
958 assert_eq!(&jumbf[4..8], b"jumb");
959
960 let box_len = u32::from_be_bytes([jumbf[0], jumbf[1], jumbf[2], jumbf[3]]) as usize;
961 assert_eq!(box_len, jumbf.len());
962 }
963
964 #[test]
965 fn jumbf_contains_manifest_label() {
966 let manifest = build_test_manifest();
967 let jumbf = encode_jumbf(&manifest).unwrap();
968 let jumbf_str = String::from_utf8_lossy(&jumbf);
969
970 assert!(
971 jumbf_str.contains(&manifest.manifest_label),
972 "JUMBF must contain the manifest label as the box label"
973 );
974 assert!(
975 jumbf_str.contains("c2pa.claim.v2"),
976 "JUMBF must contain c2pa.claim.v2 label"
977 );
978 }
979
980 #[test]
981 fn jumbf_contains_cbor_content() {
982 let manifest = build_test_manifest();
983 let jumbf = encode_jumbf(&manifest).unwrap();
984 let has_cbor_box = jumbf.windows(4).any(|w| w == b"cbor");
985 assert!(has_cbor_box, "JUMBF should contain cbor content boxes");
986 }
987
988 #[test]
989 fn jumbf_structure_validation_errors() {
990 assert!(verify_jumbf_structure(&[]).is_err());
991 assert!(verify_jumbf_structure(&[0; 4]).is_err());
992
993 let mut bad = vec![0, 0, 0, 16];
994 bad.extend_from_slice(b"xxxx");
995 bad.extend_from_slice(&[0; 8]);
996 assert!(verify_jumbf_structure(&bad).is_err());
997 }
998
999 #[test]
1000 fn unique_packet_id_produces_unique_manifest() {
1001 let mut p1 = test_evidence_packet();
1002 let mut p2 = test_evidence_packet();
1003 p1.packet_id = vec![0x01; 16];
1004 p2.packet_id = vec![0x02; 16];
1005 let key = test_signing_key();
1006
1007 let m1 = C2paManifestBuilder::new(p1, b"ev1".to_vec(), [0xAA; 32])
1008 .build_manifest(&key)
1009 .unwrap();
1010 let m2 = C2paManifestBuilder::new(p2, b"ev2".to_vec(), [0xBB; 32])
1011 .build_manifest(&key)
1012 .unwrap();
1013
1014 assert_ne!(m1.manifest_label, m2.manifest_label);
1015 assert_ne!(m1.claim.instance_id, m2.claim.instance_id);
1016 }
1017
1018 #[test]
1019 fn full_pipeline_build_validate_encode() {
1020 let packet = test_evidence_packet();
1021 let evidence_bytes = b"fake evidence cbor".to_vec();
1022 let doc_hash = [0xABu8; 32];
1023 let key = test_signing_key();
1024
1025 let builder = C2paManifestBuilder::new(packet, evidence_bytes, doc_hash)
1026 .document_filename("test.txt")
1027 .title("Test Document");
1028
1029 let manifest = builder.build_manifest(&key).unwrap();
1030
1031 let validation = validate_manifest(&manifest);
1032 assert!(validation.is_valid(), "Errors: {:?}", validation.errors);
1033
1034 let jumbf = encode_jumbf(&manifest).unwrap();
1035 assert!(jumbf.len() > 200);
1036
1037 let info = verify_jumbf_structure(&jumbf).unwrap();
1038 assert_eq!(info.total_size, jumbf.len());
1039 }
1040}