Skip to main content

c2pa_html/
hardbinding.rs

1// Copyright 2026 WritersLogic. All rights reserved.
2// Licensed under the Apache License, Version 2.0 or the MIT license,
3// at your option.
4
5//! The `c2pa.hash.data` hard binding for HTML documents.
6//!
7//! # Coverage
8//!
9//! - **Inline manifest**: one exclusion range covering the entire `script`
10//!   element, `<script` through `</script>` inclusive. The hash is over the
11//!   document with that range removed.
12//! - **External manifest**: no exclusion range at all. The hash is over the
13//!   entire document, `link` element included.
14//!
15//! The hash is defined over the bytes of the document *as stored*, with no
16//! normalization of any kind. Anything that re-serializes the HTML — a CMS, a
17//! CDN, a formatter that rewrites quote styles or collapses whitespace — shifts
18//! byte offsets and invalidates the binding. That is by design: re-serialization
19//! is a content modification. A generator in such a pipeline embeds after the
20//! final serialization step, or uses an external manifest.
21//!
22//! Contrast the text bindings, which normalize: A.9 hashes raw file bytes
23//! because structured text is byte-stable on disk, and A.8 normalizes to NFC
24//! because clipboard-portable text may arrive in any normalization form. HTML is
25//! a file, so it is bytes.
26//!
27//! # The inline hash can be computed before the manifest exists
28//!
29//! Because the exclusion covers the *entire* script element, the covered bytes
30//! are the document with the element cut out — which, for an element that was
31//! inserted rather than edited, is the original document. So a generator does
32//! not need the placeholder-reserve-then-fill dance other formats require: hash
33//! the document, sign, then embed. [`inline_hash_before_embed`] is that
34//! shortcut, and [`compute_data_hash`] on the embedded result agrees with it.
35//!
36//! An external manifest has no exclusion, so the `link` element is inside the
37//! hash and the order is reversed: insert the `link` first, then hash.
38//!
39//! # Dependency-free
40//!
41//! [`Sha2`] implements all three C2PA digest algorithms in-crate, so the binding
42//! works out of the box with nothing pulled in. Hashing still goes through the
43//! [`Hasher`] trait, so a caller with a reason to substitute — an accelerated or
44//! hardware-backed digest, or one already provided by the host runtime — passes
45//! their own instead.
46
47use crate::base64;
48use crate::document::{self, Manifest};
49use crate::error::Error;
50
51/// The assertion label for the hard binding.
52pub const DATA_HASH_LABEL: &str = "c2pa.hash.data";
53
54/// A byte range excluded from the data hash, matching the `EXCLUSION_RANGE-map`
55/// CDDL (`start`, `length`). Offsets are into the document as stored.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub struct Exclusion {
58    pub start: usize,
59    pub length: usize,
60}
61
62impl Exclusion {
63    fn end(&self) -> Option<usize> {
64        self.start.checked_add(self.length)
65    }
66}
67
68/// A C2PA-allowed hash algorithm for the data hash.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Algorithm {
71    Sha256,
72    Sha384,
73    Sha512,
74}
75
76impl Algorithm {
77    /// The C2PA algorithm identifier used in the `alg` field.
78    pub fn id(self) -> &'static str {
79        match self {
80            Algorithm::Sha256 => "sha256",
81            Algorithm::Sha384 => "sha384",
82            Algorithm::Sha512 => "sha512",
83        }
84    }
85
86    pub fn from_id(id: &str) -> Result<Self, Error> {
87        match id {
88            "sha256" => Ok(Algorithm::Sha256),
89            "sha384" => Ok(Algorithm::Sha384),
90            "sha512" => Ok(Algorithm::Sha512),
91            other => Err(Error::UnsupportedAlgorithm(other.to_string())),
92        }
93    }
94}
95
96/// A digest implementation. [`Sha2`] is the built-in one; the trait exists so a
97/// caller can substitute an accelerated or host-provided digest without the
98/// binding algorithm depending on either.
99pub trait Hasher {
100    fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8>;
101}
102
103/// The built-in [`Hasher`]: SHA-256, SHA-384, and SHA-512 per FIPS 180-4,
104/// implemented in-crate so the binding pulls in no dependency.
105///
106/// It is portable and correct but not vectorized. For a large asset, inject an
107/// accelerated implementation instead — that is what the trait is for.
108#[derive(Debug, Default, Clone, Copy)]
109pub struct Sha2;
110
111impl Hasher for Sha2 {
112    fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8> {
113        match alg {
114            Algorithm::Sha256 => crate::sha2::sha256(data),
115            Algorithm::Sha384 => crate::sha2::sha384(data),
116            Algorithm::Sha512 => crate::sha2::sha512(data),
117        }
118    }
119}
120
121/// A computed `c2pa.hash.data` assertion.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct DataHash {
124    pub exclusions: Vec<Exclusion>,
125    pub alg: String,
126    pub hash: Vec<u8>,
127    pub name: Option<String>,
128}
129
130impl DataHash {
131    /// The assertion label, `c2pa.hash.data`.
132    pub fn label(&self) -> &'static str {
133        DATA_HASH_LABEL
134    }
135
136    /// Serialise to the JSON shape consumed when building a manifest, with the
137    /// hash as standard Base64. Hand-built to keep the crate dependency-free;
138    /// the field set matches the `data-hash-map` CDDL.
139    pub fn to_json(&self) -> String {
140        let ranges: Vec<String> = self
141            .exclusions
142            .iter()
143            .map(|e| format!("{{\"start\":{},\"length\":{}}}", e.start, e.length))
144            .collect();
145        let mut json = format!(
146            "{{\"exclusions\":[{}],\"alg\":\"{}\",\"hash\":\"{}\"",
147            ranges.join(","),
148            self.alg,
149            base64::encode(&self.hash)
150        );
151        if let Some(name) = &self.name {
152            json.push_str(&format!(",\"name\":\"{name}\""));
153        }
154        json.push('}');
155        json
156    }
157}
158
159/// The exclusion ranges for the document's manifest association.
160///
161/// One range covering the whole `script` element for an inline manifest; none
162/// at all for an external one.
163pub fn manifest_exclusions(html: &[u8]) -> Result<Vec<Exclusion>, Error> {
164    match document::extract(html)? {
165        Manifest::Embedded { start, length, .. } => Ok(vec![Exclusion { start, length }]),
166        Manifest::Referenced { .. } => Ok(Vec::new()),
167    }
168}
169
170/// Remove `exclusions` from `html`, validating that they are ordered,
171/// non-overlapping, and within bounds.
172pub fn apply_exclusions(html: &[u8], exclusions: &[Exclusion]) -> Result<Vec<u8>, Error> {
173    let mut cursor = 0usize;
174    let mut out = Vec::with_capacity(html.len());
175    for ex in exclusions {
176        let end = ex.end().ok_or(Error::MalformedExclusion)?;
177        if ex.start < cursor || end > html.len() {
178            return Err(Error::MalformedExclusion);
179        }
180        out.extend_from_slice(&html[cursor..ex.start]);
181        cursor = end;
182    }
183    out.extend_from_slice(&html[cursor..]);
184    Ok(out)
185}
186
187/// Compute the hard binding for `html`: locate the manifest element, exclude it
188/// if it is inline, and hash what the exclusions leave.
189pub fn compute_data_hash(
190    html: &[u8],
191    alg: Algorithm,
192    hasher: &impl Hasher,
193) -> Result<DataHash, Error> {
194    let exclusions = manifest_exclusions(html)?;
195    let covered = apply_exclusions(html, &exclusions)?;
196    Ok(DataHash {
197        exclusions,
198        alg: alg.id().to_string(),
199        hash: hasher.digest(alg, &covered),
200        name: None,
201    })
202}
203
204/// The hash an inline binding will have once a `script` element is embedded in
205/// `html`, computed before the manifest exists.
206///
207/// The exclusion covers the whole element, so the covered bytes are the
208/// document without it — this document. Pair the result with the exclusion
209/// [`compute_data_hash`] reports after embedding; the two agree.
210///
211/// This is the ordering that makes an inline HTML manifest signable: hash,
212/// sign, embed. There is no equivalent for an external manifest, whose `link`
213/// element is inside the hash.
214pub fn inline_hash_before_embed(html: &[u8], alg: Algorithm, hasher: &impl Hasher) -> Vec<u8> {
215    hasher.digest(alg, html)
216}
217
218/// Verify a `c2pa.hash.data` binding against `html`, following the validator
219/// procedure: apply the assertion's own exclusion ranges, recompute, compare.
220///
221/// The ranges must match the located manifest element. An assertion that
222/// excludes some other span would otherwise hash a document the manifest does
223/// not describe.
224pub fn verify_data_hash(
225    html: &[u8],
226    data_hash: &DataHash,
227    hasher: &impl Hasher,
228) -> Result<(), Error> {
229    let alg = Algorithm::from_id(&data_hash.alg)?;
230    let located = manifest_exclusions(html)?;
231    // An inline manifest must be excluded; an external one must not be, since
232    // the `link` element is part of what the hash covers.
233    let ranges_agree = match located.first() {
234        Some(l) => data_hash.exclusions.contains(l),
235        None => data_hash.exclusions.is_empty(),
236    };
237    if !ranges_agree {
238        return Err(Error::MalformedExclusion);
239    }
240    let covered = apply_exclusions(html, &data_hash.exclusions)?;
241    if hasher.digest(alg, &covered) == data_hash.hash {
242        Ok(())
243    } else {
244        Err(Error::HashMismatch)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use crate::document::tests::DOC;
252
253    const STORE: &[u8] = b"manifest-store-bytes";
254    const HREF: &str = "https://a.example/m.c2pa";
255
256    /// A deterministic stand-in so the core is testable without the feature.
257    struct SumHasher;
258    impl Hasher for SumHasher {
259        fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8> {
260            let n: u64 = data.iter().map(|&b| b as u64).sum();
261            let mut v = alg.id().as_bytes().to_vec();
262            v.extend_from_slice(&n.to_be_bytes());
263            v.extend_from_slice(&(data.len() as u64).to_be_bytes());
264            v
265        }
266    }
267
268    #[test]
269    fn an_inline_exclusion_covers_the_whole_script_element() {
270        let html = document::embed(DOC, STORE).unwrap();
271        let ex = manifest_exclusions(&html).unwrap();
272        assert_eq!(ex.len(), 1);
273        let element = &html[ex[0].start..ex[0].start + ex[0].length];
274        assert!(element.starts_with(b"<script"));
275        assert!(element.ends_with(b"</script>"));
276    }
277
278    #[test]
279    fn an_external_manifest_has_no_exclusion() {
280        let html = document::embed_reference(DOC, HREF).unwrap();
281        assert_eq!(manifest_exclusions(&html).unwrap(), Vec::new());
282    }
283
284    #[test]
285    fn the_covered_bytes_of_an_inline_embed_are_the_original_document() {
286        let html = document::embed(DOC, STORE).unwrap();
287        let ex = manifest_exclusions(&html).unwrap();
288        // `embed` adds no bytes outside the element and the exclusion covers the
289        // whole element, so cutting it out leaves the document untouched.
290        assert_eq!(apply_exclusions(&html, &ex).unwrap(), DOC);
291    }
292
293    #[test]
294    fn the_covered_bytes_of_an_external_embed_include_the_link() {
295        let html = document::embed_reference(DOC, HREF).unwrap();
296        let covered = apply_exclusions(&html, &[]).unwrap();
297        assert_eq!(covered, html);
298        assert!(covered.windows(HREF.len()).any(|w| w == HREF.as_bytes()));
299    }
300
301    #[test]
302    fn compute_then_verify_round_trips_inline() {
303        let html = document::embed(DOC, STORE).unwrap();
304        let dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
305        assert_eq!(dh.alg, "sha256");
306        assert_eq!(dh.label(), "c2pa.hash.data");
307        assert!(verify_data_hash(&html, &dh, &SumHasher).is_ok());
308    }
309
310    #[test]
311    fn compute_then_verify_round_trips_external() {
312        let html = document::embed_reference(DOC, HREF).unwrap();
313        let dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
314        assert!(dh.exclusions.is_empty());
315        assert!(verify_data_hash(&html, &dh, &SumHasher).is_ok());
316    }
317
318    #[test]
319    fn the_manifest_content_does_not_affect_an_inline_hash() {
320        // The exclusion covers the whole element, so two documents differing
321        // only in the store bind identically — as long as the Base64 is the same
322        // length, which it is for a fixed store size.
323        let a = document::embed(DOC, b"aaaaaaaa").unwrap();
324        let b = document::embed(DOC, b"bbbbbbbb").unwrap();
325        let ha = compute_data_hash(&a, Algorithm::Sha256, &SumHasher).unwrap();
326        let hb = compute_data_hash(&b, Algorithm::Sha256, &SumHasher).unwrap();
327        assert_eq!(ha.hash, hb.hash);
328        assert_eq!(ha.exclusions, hb.exclusions);
329    }
330
331    #[test]
332    fn the_hash_can_be_computed_before_the_manifest_exists() {
333        let before = inline_hash_before_embed(DOC, Algorithm::Sha256, &SumHasher);
334        let html = document::embed(DOC, STORE).unwrap();
335        let after = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
336        assert_eq!(
337            before, after.hash,
338            "hash-then-embed must agree with embed-then-hash"
339        );
340        // And the assertion built that way verifies against the embedded document.
341        let dh = DataHash {
342            exclusions: after.exclusions.clone(),
343            alg: Algorithm::Sha256.id().to_string(),
344            hash: before,
345            name: None,
346        };
347        assert!(verify_data_hash(&html, &dh, &SumHasher).is_ok());
348    }
349
350    #[test]
351    fn editing_the_document_breaks_an_inline_binding() {
352        let html = document::embed(DOC, STORE).unwrap();
353        let dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
354        // Same length, so the exclusion still matches and the hash is what fails.
355        let tampered = document::embed(
356            &String::from_utf8(DOC.to_vec())
357                .unwrap()
358                .replace("Content here.", "Content harel")
359                .into_bytes(),
360            STORE,
361        )
362        .unwrap();
363        assert_eq!(
364            verify_data_hash(&tampered, &dh, &SumHasher),
365            Err(Error::HashMismatch)
366        );
367    }
368
369    #[test]
370    fn editing_the_document_breaks_an_external_binding() {
371        let html = document::embed_reference(DOC, HREF).unwrap();
372        let dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
373        let tampered = document::embed_reference(
374            &String::from_utf8(DOC.to_vec())
375                .unwrap()
376                .replace("Content here.", "Content harel")
377                .into_bytes(),
378            HREF,
379        )
380        .unwrap();
381        assert_eq!(
382            verify_data_hash(&tampered, &dh, &SumHasher),
383            Err(Error::HashMismatch)
384        );
385    }
386
387    #[test]
388    fn repointing_an_external_reference_breaks_its_binding() {
389        // The `link` is inside the hash precisely so the URI cannot be swapped.
390        let html = document::embed_reference(DOC, HREF).unwrap();
391        let dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
392        let repointed = document::embed_reference(DOC, "https://b.example/m.c2pa").unwrap();
393        assert_eq!(
394            verify_data_hash(&repointed, &dh, &SumHasher),
395            Err(Error::HashMismatch)
396        );
397    }
398
399    #[test]
400    fn an_exclusion_that_is_not_the_element_is_rejected() {
401        let html = document::embed(DOC, STORE).unwrap();
402        let mut dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
403        dh.exclusions = vec![Exclusion {
404            start: 0,
405            length: 4,
406        }];
407        assert_eq!(
408            verify_data_hash(&html, &dh, &SumHasher),
409            Err(Error::MalformedExclusion)
410        );
411    }
412
413    #[test]
414    fn an_inline_binding_with_no_exclusion_is_rejected() {
415        let html = document::embed(DOC, STORE).unwrap();
416        let mut dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
417        dh.exclusions.clear();
418        assert_eq!(
419            verify_data_hash(&html, &dh, &SumHasher),
420            Err(Error::MalformedExclusion)
421        );
422    }
423
424    #[test]
425    fn an_external_binding_that_excludes_its_link_is_rejected() {
426        // Excluding the `link` would let the URI be swapped freely.
427        let html = document::embed_reference(DOC, HREF).unwrap();
428        let mut dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
429        let range = document::extract(&html).unwrap().range();
430        dh.exclusions = vec![Exclusion {
431            start: range.start,
432            length: range.len(),
433        }];
434        assert_eq!(
435            verify_data_hash(&html, &dh, &SumHasher),
436            Err(Error::MalformedExclusion)
437        );
438    }
439
440    #[test]
441    fn malformed_ranges_are_rejected() {
442        let html = document::embed(DOC, STORE).unwrap();
443        // Out of order / overlapping.
444        let bad = [
445            Exclusion {
446                start: 10,
447                length: 5,
448            },
449            Exclusion {
450                start: 5,
451                length: 5,
452            },
453        ];
454        assert_eq!(
455            apply_exclusions(&html, &bad),
456            Err(Error::MalformedExclusion)
457        );
458        // Past the end.
459        assert_eq!(
460            apply_exclusions(
461                &html,
462                &[Exclusion {
463                    start: 0,
464                    length: html.len() + 1
465                }]
466            ),
467            Err(Error::MalformedExclusion)
468        );
469        // Overflowing.
470        assert_eq!(
471            apply_exclusions(
472                &html,
473                &[Exclusion {
474                    start: usize::MAX,
475                    length: 1
476                }]
477            ),
478            Err(Error::MalformedExclusion)
479        );
480    }
481
482    #[test]
483    fn unsupported_algorithm_is_reported() {
484        let html = document::embed(DOC, STORE).unwrap();
485        let mut dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
486        dh.alg = "sha1".into();
487        assert_eq!(
488            verify_data_hash(&html, &dh, &SumHasher),
489            Err(Error::UnsupportedAlgorithm("sha1".into()))
490        );
491    }
492
493    #[test]
494    fn binding_a_document_with_no_manifest_reports_not_found() {
495        assert_eq!(
496            compute_data_hash(DOC, Algorithm::Sha256, &SumHasher),
497            Err(Error::NotFound)
498        );
499    }
500
501    #[test]
502    fn algorithm_ids_round_trip() {
503        for alg in [Algorithm::Sha256, Algorithm::Sha384, Algorithm::Sha512] {
504            assert_eq!(Algorithm::from_id(alg.id()), Ok(alg));
505        }
506        assert_eq!(
507            Algorithm::from_id("md5"),
508            Err(Error::UnsupportedAlgorithm("md5".into()))
509        );
510    }
511
512    #[test]
513    fn json_shape_matches_the_data_hash_map() {
514        let dh = DataHash {
515            exclusions: vec![Exclusion {
516                start: 73,
517                length: 114,
518            }],
519            alg: "sha256".into(),
520            hash: vec![0xDE, 0xAD, 0xBE, 0xEF],
521            name: None,
522        };
523        assert_eq!(
524            dh.to_json(),
525            r#"{"exclusions":[{"start":73,"length":114}],"alg":"sha256","hash":"3q2+7w=="}"#
526        );
527    }
528
529    #[test]
530    fn json_omits_exclusions_for_an_external_manifest() {
531        let dh = DataHash {
532            exclusions: Vec::new(),
533            alg: "sha512".into(),
534            hash: vec![0x01],
535            name: Some("html".into()),
536        };
537        assert_eq!(
538            dh.to_json(),
539            r#"{"exclusions":[],"alg":"sha512","hash":"AQ==","name":"html"}"#
540        );
541    }
542
543    #[test]
544    fn the_built_in_hasher_dispatches_to_the_right_algorithm() {
545        // FIPS 180-4 vector for the empty string, so a mis-wired match arm shows
546        // up here rather than as a silent interop failure.
547        assert_eq!(
548            Sha2.digest(Algorithm::Sha256, b"")[..4],
549            [0xE3, 0xB0, 0xC4, 0x42]
550        );
551        assert_eq!(
552            Sha2.digest(Algorithm::Sha384, b"")[..4],
553            [0x38, 0xB0, 0x60, 0xA7]
554        );
555        assert_eq!(
556            Sha2.digest(Algorithm::Sha512, b"")[..4],
557            [0xCF, 0x83, 0xE1, 0x35]
558        );
559        assert_eq!(Sha2.digest(Algorithm::Sha256, b"").len(), 32);
560        assert_eq!(Sha2.digest(Algorithm::Sha384, b"").len(), 48);
561        assert_eq!(Sha2.digest(Algorithm::Sha512, b"").len(), 64);
562    }
563
564    #[test]
565    fn the_built_in_hasher_round_trips_a_real_binding() {
566        for alg in [Algorithm::Sha256, Algorithm::Sha384, Algorithm::Sha512] {
567            let html = document::embed(DOC, STORE).unwrap();
568            let dh = compute_data_hash(&html, alg, &Sha2).unwrap();
569            assert!(verify_data_hash(&html, &dh, &Sha2).is_ok(), "{alg:?}");
570
571            let referenced = document::embed_reference(DOC, HREF).unwrap();
572            let dh = compute_data_hash(&referenced, alg, &Sha2).unwrap();
573            assert!(verify_data_hash(&referenced, &dh, &Sha2).is_ok(), "{alg:?}");
574        }
575    }
576
577    #[test]
578    fn the_built_in_hasher_detects_tampering() {
579        let html = document::embed(DOC, STORE).unwrap();
580        let dh = compute_data_hash(&html, Algorithm::Sha256, &Sha2).unwrap();
581        let tampered = document::embed(
582            &String::from_utf8(DOC.to_vec())
583                .unwrap()
584                .replace("Content here.", "Content harel")
585                .into_bytes(),
586            STORE,
587        )
588        .unwrap();
589        assert_eq!(
590            verify_data_hash(&tampered, &dh, &Sha2),
591            Err(Error::HashMismatch)
592        );
593    }
594}