Skip to main content

cpop_protocol/
c2pa.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! C2PA (Coalition for Content Provenance and Authenticity) manifest generation.
4//!
5//! Produces sidecar `.c2pa` manifests containing CPoP evidence assertions
6//! per C2PA 2.2 specification (2025-05-01). The manifest uses JUMBF
7//! (ISO 19566-5) box format with COSE_Sign1 signatures.
8
9use 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/// Standard C2PA actions assertion (§12.1, CBOR map).
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ActionsAssertion {
60    pub actions: Vec<Action>,
61}
62
63/// Single C2PA action entry (e.g., "c2pa.created").
64#[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/// Software agent can be a string or a structured claim-generator-info map (§12.1).
76#[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/// C2PA hash-data assertion binding manifest to the asset (§9.1, CBOR map).
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct HashDataAssertion {
92    pub name: String,
93    #[serde(with = "serde_bytes")]
94    pub hash: Vec<u8>,
95    /// Algorithm identifier per §15.4.
96    #[serde(rename = "alg")]
97    pub algorithm: String,
98    #[serde(default)]
99    pub exclusions: Vec<ExclusionRange>,
100}
101
102/// Byte range exclusion for embedded manifests (§9.1).
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct ExclusionRange {
105    pub start: u64,
106    pub length: u64,
107}
108
109/// C2PA claim v2 per §10 and §15.6. All field names match the CDDL schema.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct C2paClaim {
112    /// §10.5
113    pub claim_generator: String,
114
115    /// §10.5, required in v2.
116    pub claim_generator_info: Vec<ClaimGeneratorInfo>,
117
118    /// §10.3, required.
119    #[serde(rename = "instanceID")]
120    pub instance_id: String,
121
122    /// §10.7, required.
123    pub signature: String,
124
125    /// §10.6
126    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/// §10.5
136#[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/// Hashed URI reference per §8.4.2 and §15.10.3.
144/// The hash is binary (CBOR bstr), computed over the JUMBF superbox
145/// contents (description + content boxes, excluding the 8-byte superbox header)
146/// per §8.4.2.3.
147#[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/// Assertion JUMBF bytes are pre-built so the hashes in
157/// `claim.created_assertions` match the actual bytes written
158/// into JUMBF output (no double-serialization risk).
159#[derive(Debug, Clone)]
160pub struct C2paManifest {
161    pub claim: C2paClaim,
162    /// Must match assertion URL paths.
163    pub manifest_label: String,
164    pub assertion_boxes: Vec<Vec<u8>>,
165    pub signature: Vec<u8>,
166}
167
168/// C2PA manifest store superbox UUID (C2PA 2.2 §8.1).
169const C2PA_MANIFEST_STORE_UUID: [u8; 16] = [
170    0x63, 0x32, 0x70, 0x61, // "c2pa"
171    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, // "c2ma"
176    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, // "c2cl"
181    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, // "c2as"
186    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, // "c2cs"
191    0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
192];
193
194/// ISO 19566-5
195const JUMBF_CBOR_UUID: [u8; 16] = [
196    0x63, 0x62, 0x6F, 0x72, // "cbor"
197    0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71,
198];
199
200/// ISO 19566-5
201const JUMBF_JSON_UUID: [u8; 16] = [
202    0x6A, 0x73, 0x6F, 0x6E, // "json"
203    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
217/// IANA-registered COSE_Key label for unprotected headers.
218const COSE_HEADER_LABEL_COSE_KEY: i64 = -1;
219
220/// Minimal JUMBF box writer (ISO 19566-5).
221struct 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); // NUL terminator
235        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    /// Returns offset for back-patching length.
262    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    /// Set the dc:title metadata field in the claim.
315    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        // Built once; same bytes are hashed for the claim and embedded in JUMBF.
358        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        // §8.4.2.3: hash superbox contents, skipping 8-byte jumb header
364        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        // §13.2: COSE_Sign1 with public key in unprotected header
416        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
428/// §13.2: COSE_Sign1 with public key in unprotected header.
429fn 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    // §15.6
483    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
519/// §15.10.1.2 standard manifest validation.
520pub 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); // hash.data is first
932        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); // actions is second
945        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}