Skip to main content

c2pa_unstructured_text/
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 unstructured text (A.8).
6//!
7//! # Order of operations
8//!
9//! The exclusion ranges are byte offsets into the text **as stored**, before any
10//! normalization. A validator removes the excluded bytes first, normalizes what
11//! remains to NFC, encodes as UTF-8, and hashes that. Normalizing first would
12//! shift every offset whenever the stored text is not already NFC.
13//!
14//! This is the opposite of [`c2pa_structured_text`]'s A.9 binding, which applies
15//! no normalization at all: structured text files are byte-stable on disk, while
16//! A.8 text is clipboard-portable and may arrive in any normalization form.
17//!
18//! # Dependency-free by default
19//!
20//! Hashing and NFC are injected through [`Hasher`] and [`Normalizer`], so the
21//! binding algorithm itself pulls nothing in. A host that already provides both
22//! (a Cloudflare Worker, a browser) implements the two traits against its
23//! runtime. The `hard-binding` feature ships ready-made implementations for
24//! callers who would rather not.
25//!
26//! [`c2pa_structured_text`]: https://crates.io/crates/c2pa-structured-text
27
28use crate::error::Error;
29use crate::wrapper;
30
31/// The assertion label for the hard binding.
32pub const DATA_HASH_LABEL: &str = "c2pa.hash.data";
33
34/// A byte range excluded from the data hash, matching the `EXCLUSION_RANGE-map`
35/// CDDL (`start`, `length`). Offsets are into the text as stored.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct Exclusion {
38    pub start: usize,
39    pub length: usize,
40}
41
42impl Exclusion {
43    fn end(&self) -> Option<usize> {
44        self.start.checked_add(self.length)
45    }
46}
47
48/// A C2PA-allowed hash algorithm for the data hash.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Algorithm {
51    Sha256,
52    Sha384,
53    Sha512,
54}
55
56impl Algorithm {
57    /// The C2PA algorithm identifier used in the `alg` field.
58    pub fn id(self) -> &'static str {
59        match self {
60            Algorithm::Sha256 => "sha256",
61            Algorithm::Sha384 => "sha384",
62            Algorithm::Sha512 => "sha512",
63        }
64    }
65
66    pub fn from_id(id: &str) -> Result<Self, Error> {
67        match id {
68            "sha256" => Ok(Algorithm::Sha256),
69            "sha384" => Ok(Algorithm::Sha384),
70            "sha512" => Ok(Algorithm::Sha512),
71            other => Err(Error::UnsupportedAlgorithm(other.to_string())),
72        }
73    }
74}
75
76/// A digest implementation. Supplied by the caller so the core has no crypto
77/// dependency; the `hard-binding` feature provides [`RustCrypto`].
78pub trait Hasher {
79    fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8>;
80}
81
82/// A Unicode NFC normalizer. Supplied by the caller so the core carries no
83/// Unicode tables; the `hard-binding` feature provides [`UnicodeNfc`].
84pub trait Normalizer {
85    fn nfc(&self, text: &str) -> String;
86}
87
88/// A computed `c2pa.hash.data` assertion.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct DataHash {
91    pub exclusions: Vec<Exclusion>,
92    pub alg: String,
93    pub hash: Vec<u8>,
94    pub name: Option<String>,
95}
96
97impl DataHash {
98    /// The assertion label, `c2pa.hash.data`.
99    pub fn label(&self) -> &'static str {
100        DATA_HASH_LABEL
101    }
102
103    /// Serialise to the JSON shape consumed when building a manifest, with the
104    /// hash as standard Base64. Hand-built to keep the crate dependency-free;
105    /// the field set matches the `data-hash-map` CDDL.
106    pub fn to_json(&self) -> String {
107        let ranges: Vec<String> = self
108            .exclusions
109            .iter()
110            .map(|e| format!("{{\"start\":{},\"length\":{}}}", e.start, e.length))
111            .collect();
112        let mut json = format!(
113            "{{\"exclusions\":[{}],\"alg\":\"{}\",\"hash\":\"{}\"",
114            ranges.join(","),
115            self.alg,
116            base64(&self.hash)
117        );
118        if let Some(name) = &self.name {
119            json.push_str(&format!(",\"name\":\"{name}\""));
120        }
121        json.push('}');
122        json
123    }
124}
125
126/// Standard Base64 (RFC 4648 §4, with padding). Encode only; the crate never
127/// needs to decode one.
128fn base64(bytes: &[u8]) -> String {
129    const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
130    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
131    for chunk in bytes.chunks(3) {
132        let b = [
133            chunk[0],
134            *chunk.get(1).unwrap_or(&0),
135            *chunk.get(2).unwrap_or(&0),
136        ];
137        let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | b[2] as u32;
138        out.push(T[(n >> 18) as usize & 63] as char);
139        out.push(T[(n >> 12) as usize & 63] as char);
140        out.push(if chunk.len() > 1 {
141            T[(n >> 6) as usize & 63] as char
142        } else {
143            '='
144        });
145        out.push(if chunk.len() > 2 {
146            T[n as usize & 63] as char
147        } else {
148            '='
149        });
150    }
151    out
152}
153
154/// The single exclusion range covering the located wrapper, marker included.
155pub fn manifest_exclusion(text: &str) -> Result<Exclusion, Error> {
156    let w = wrapper::extract(text)?;
157    Ok(Exclusion {
158        start: w.start,
159        length: w.length,
160    })
161}
162
163/// Remove `exclusions` from `text`, validating that they are ordered,
164/// non-overlapping, within bounds, and on character boundaries.
165pub fn apply_exclusions(text: &str, exclusions: &[Exclusion]) -> Result<String, Error> {
166    let mut cursor = 0usize;
167    let mut out = String::with_capacity(text.len());
168    for ex in exclusions {
169        let end = ex.end().ok_or(Error::MalformedExclusion)?;
170        if ex.start < cursor || end > text.len() {
171            return Err(Error::MalformedExclusion);
172        }
173        if !text.is_char_boundary(ex.start) || !text.is_char_boundary(end) {
174            return Err(Error::MalformedExclusion);
175        }
176        out.push_str(&text[cursor..ex.start]);
177        cursor = end;
178    }
179    out.push_str(&text[cursor..]);
180    Ok(out)
181}
182
183/// The exact bytes the data hash covers: `text` with `exclusions` removed, then
184/// normalized to NFC and encoded as UTF-8. This is the seam shared by
185/// computation and verification.
186pub fn hashed_bytes(
187    text: &str,
188    exclusions: &[Exclusion],
189    normalizer: &impl Normalizer,
190) -> Result<Vec<u8>, Error> {
191    let stripped = apply_exclusions(text, exclusions)?;
192    Ok(normalizer.nfc(&stripped).into_bytes())
193}
194
195/// Compute the hard binding for `text`: locate the wrapper, exclude it, then
196/// hash the NFC-normalized remainder.
197pub fn compute_data_hash(
198    text: &str,
199    alg: Algorithm,
200    hasher: &impl Hasher,
201    normalizer: &impl Normalizer,
202) -> Result<DataHash, Error> {
203    let exclusion = manifest_exclusion(text)?;
204    let covered = hashed_bytes(text, &[exclusion], normalizer)?;
205    Ok(DataHash {
206        exclusions: vec![exclusion],
207        alg: alg.id().to_string(),
208        hash: hasher.digest(alg, &covered),
209        name: None,
210    })
211}
212
213/// Verify a `c2pa.hash.data` binding against `text`, following the validator
214/// procedure: apply the assertion's own exclusion ranges, normalize, recompute,
215/// compare.
216///
217/// The ranges must match the located wrapper. An assertion that excludes some
218/// other span would otherwise hash a document the wrapper does not describe.
219pub fn verify_data_hash(
220    text: &str,
221    data_hash: &DataHash,
222    hasher: &impl Hasher,
223    normalizer: &impl Normalizer,
224) -> Result<(), Error> {
225    if data_hash.exclusions.is_empty() {
226        return Err(Error::MalformedExclusion);
227    }
228    let alg = Algorithm::from_id(&data_hash.alg)?;
229    let located = manifest_exclusion(text)?;
230    if !data_hash.exclusions.contains(&located) {
231        return Err(Error::MalformedExclusion);
232    }
233    let covered = hashed_bytes(text, &data_hash.exclusions, normalizer)?;
234    if hasher.digest(alg, &covered) == data_hash.hash {
235        Ok(())
236    } else {
237        Err(Error::HashMismatch)
238    }
239}
240
241/// Ready-made implementations, behind the `hard-binding` feature — and always
242/// present on `wasm32`, where the npm distribution needs them: a JavaScript
243/// caller cannot implement the [`Hasher`] and [`Normalizer`] traits.
244#[cfg(any(feature = "hard-binding", target_arch = "wasm32"))]
245mod provided {
246    use super::{Algorithm, Hasher, Normalizer};
247    use sha2::{Digest, Sha256, Sha384, Sha512};
248    // Imported for its methods only; binding the name would collide with the
249    // `UnicodeNfc` type below.
250    use unicode_normalization::UnicodeNormalization as _;
251
252    /// [`Hasher`] backed by RustCrypto.
253    #[derive(Debug, Default, Clone, Copy)]
254    pub struct RustCrypto;
255
256    impl Hasher for RustCrypto {
257        fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8> {
258            match alg {
259                Algorithm::Sha256 => Sha256::digest(data).to_vec(),
260                Algorithm::Sha384 => Sha384::digest(data).to_vec(),
261                Algorithm::Sha512 => Sha512::digest(data).to_vec(),
262            }
263        }
264    }
265
266    /// [`Normalizer`] backed by `unicode-normalization`.
267    #[derive(Debug, Default, Clone, Copy)]
268    pub struct UnicodeNfc;
269
270    impl Normalizer for UnicodeNfc {
271        fn nfc(&self, text: &str) -> String {
272            text.nfc().collect()
273        }
274    }
275}
276
277#[cfg(any(feature = "hard-binding", target_arch = "wasm32"))]
278pub use provided::{RustCrypto, UnicodeNfc};
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::wrapper;
284
285    const HOST: &str = "This sentence carries an invisible C2PA text manifest wrapper at its end.";
286    const PAYLOAD: &[u8] = b"c2pa-manifest-01";
287
288    /// A deterministic stand-in so the core is testable without the feature.
289    struct SumHasher;
290    impl Hasher for SumHasher {
291        fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8> {
292            let n: u64 = data.iter().map(|&b| b as u64).sum();
293            let mut v = alg.id().as_bytes().to_vec();
294            v.extend_from_slice(&n.to_be_bytes());
295            v
296        }
297    }
298    /// Identity normalizer: correct for the ASCII fixtures used here.
299    struct AsciiNormalizer;
300    impl Normalizer for AsciiNormalizer {
301        fn nfc(&self, text: &str) -> String {
302            text.to_string()
303        }
304    }
305
306    #[test]
307    fn exclusion_covers_the_marker_and_the_whole_run() {
308        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
309        let ex = manifest_exclusion(&asset).unwrap();
310        assert_eq!(ex.start, HOST.len());
311        assert_eq!(ex.start + ex.length, asset.len());
312        assert!(asset[ex.start..].starts_with(wrapper::MARKER));
313    }
314
315    #[test]
316    fn padding_is_inside_the_exclusion() {
317        let padded = wrapper::encode_padded(PAYLOAD).unwrap();
318        let asset = format!("{HOST}{padded}");
319        let ex = manifest_exclusion(&asset).unwrap();
320        assert_eq!(ex.length, padded.len());
321        // Covered bytes are the visible text either way, padded or not.
322        let covered = hashed_bytes(&asset, &[ex], &AsciiNormalizer).unwrap();
323        assert_eq!(covered, HOST.as_bytes());
324    }
325
326    #[test]
327    fn covered_bytes_are_the_visible_text() {
328        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
329        let ex = manifest_exclusion(&asset).unwrap();
330        assert_eq!(
331            hashed_bytes(&asset, &[ex], &AsciiNormalizer).unwrap(),
332            HOST.as_bytes()
333        );
334    }
335
336    #[test]
337    fn compute_then_verify_round_trips() {
338        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
339        let dh =
340            compute_data_hash(&asset, Algorithm::Sha256, &SumHasher, &AsciiNormalizer).unwrap();
341        assert_eq!(dh.alg, "sha256");
342        assert_eq!(dh.label(), "c2pa.hash.data");
343        assert!(verify_data_hash(&asset, &dh, &SumHasher, &AsciiNormalizer).is_ok());
344    }
345
346    #[test]
347    fn editing_the_visible_text_breaks_the_binding() {
348        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
349        let dh =
350            compute_data_hash(&asset, Algorithm::Sha256, &SumHasher, &AsciiNormalizer).unwrap();
351        let tampered = wrapper::embed(&HOST.replace("invisible", "visible!"), PAYLOAD).unwrap();
352        assert_eq!(
353            verify_data_hash(&tampered, &dh, &SumHasher, &AsciiNormalizer),
354            Err(Error::MalformedExclusion)
355        );
356        // Same length, so the exclusion still matches and the hash is what fails.
357        let same_len = wrapper::embed(&HOST.replace("invisible", "invisibIe"), PAYLOAD).unwrap();
358        assert_eq!(
359            verify_data_hash(&same_len, &dh, &SumHasher, &AsciiNormalizer),
360            Err(Error::HashMismatch)
361        );
362    }
363
364    #[test]
365    fn an_exclusion_that_is_not_the_wrapper_is_rejected() {
366        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
367        let mut dh =
368            compute_data_hash(&asset, Algorithm::Sha256, &SumHasher, &AsciiNormalizer).unwrap();
369        dh.exclusions = vec![Exclusion {
370            start: 0,
371            length: 4,
372        }];
373        assert_eq!(
374            verify_data_hash(&asset, &dh, &SumHasher, &AsciiNormalizer),
375            Err(Error::MalformedExclusion)
376        );
377    }
378
379    #[test]
380    fn malformed_ranges_are_rejected() {
381        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
382        // Out of order / overlapping.
383        let bad = [
384            Exclusion {
385                start: 10,
386                length: 5,
387            },
388            Exclusion {
389                start: 5,
390                length: 5,
391            },
392        ];
393        assert_eq!(
394            apply_exclusions(&asset, &bad),
395            Err(Error::MalformedExclusion)
396        );
397        // Past the end.
398        assert_eq!(
399            apply_exclusions(
400                &asset,
401                &[Exclusion {
402                    start: 0,
403                    length: asset.len() + 1
404                }]
405            ),
406            Err(Error::MalformedExclusion)
407        );
408    }
409
410    #[test]
411    fn unsupported_algorithm_is_reported() {
412        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
413        let mut dh =
414            compute_data_hash(&asset, Algorithm::Sha256, &SumHasher, &AsciiNormalizer).unwrap();
415        dh.alg = "sha1".into();
416        assert_eq!(
417            verify_data_hash(&asset, &dh, &SumHasher, &AsciiNormalizer),
418            Err(Error::UnsupportedAlgorithm("sha1".into()))
419        );
420    }
421
422    #[test]
423    fn json_shape_matches_the_data_hash_map() {
424        let dh = DataHash {
425            exclusions: vec![Exclusion {
426                start: 73,
427                length: 114,
428            }],
429            alg: "sha256".into(),
430            hash: vec![0xDE, 0xAD, 0xBE, 0xEF],
431            name: None,
432        };
433        assert_eq!(
434            dh.to_json(),
435            r#"{"exclusions":[{"start":73,"length":114}],"alg":"sha256","hash":"3q2+7w=="}"#
436        );
437    }
438
439    #[test]
440    fn base64_matches_rfc4648_vectors() {
441        assert_eq!(base64(b""), "");
442        assert_eq!(base64(b"f"), "Zg==");
443        assert_eq!(base64(b"fo"), "Zm8=");
444        assert_eq!(base64(b"foo"), "Zm9v");
445        assert_eq!(base64(b"foob"), "Zm9vYg==");
446        assert_eq!(base64(b"fooba"), "Zm9vYmE=");
447        assert_eq!(base64(b"foobar"), "Zm9vYmFy");
448    }
449}