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.
242#[cfg(feature = "hard-binding")]
243mod provided {
244    use super::{Algorithm, Hasher, Normalizer};
245    use sha2::{Digest, Sha256, Sha384, Sha512};
246    // Imported for its methods only; binding the name would collide with the
247    // `UnicodeNfc` type below.
248    use unicode_normalization::UnicodeNormalization as _;
249
250    /// [`Hasher`] backed by RustCrypto.
251    #[derive(Debug, Default, Clone, Copy)]
252    pub struct RustCrypto;
253
254    impl Hasher for RustCrypto {
255        fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8> {
256            match alg {
257                Algorithm::Sha256 => Sha256::digest(data).to_vec(),
258                Algorithm::Sha384 => Sha384::digest(data).to_vec(),
259                Algorithm::Sha512 => Sha512::digest(data).to_vec(),
260            }
261        }
262    }
263
264    /// [`Normalizer`] backed by `unicode-normalization`.
265    #[derive(Debug, Default, Clone, Copy)]
266    pub struct UnicodeNfc;
267
268    impl Normalizer for UnicodeNfc {
269        fn nfc(&self, text: &str) -> String {
270            text.nfc().collect()
271        }
272    }
273}
274
275#[cfg(feature = "hard-binding")]
276pub use provided::{RustCrypto, UnicodeNfc};
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281    use crate::wrapper;
282
283    const HOST: &str = "This sentence carries an invisible C2PA text manifest wrapper at its end.";
284    const PAYLOAD: &[u8] = b"c2pa-manifest-01";
285
286    /// A deterministic stand-in so the core is testable without the feature.
287    struct SumHasher;
288    impl Hasher for SumHasher {
289        fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8> {
290            let n: u64 = data.iter().map(|&b| b as u64).sum();
291            let mut v = alg.id().as_bytes().to_vec();
292            v.extend_from_slice(&n.to_be_bytes());
293            v
294        }
295    }
296    /// Identity normalizer: correct for the ASCII fixtures used here.
297    struct AsciiNormalizer;
298    impl Normalizer for AsciiNormalizer {
299        fn nfc(&self, text: &str) -> String {
300            text.to_string()
301        }
302    }
303
304    #[test]
305    fn exclusion_covers_the_marker_and_the_whole_run() {
306        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
307        let ex = manifest_exclusion(&asset).unwrap();
308        assert_eq!(ex.start, HOST.len());
309        assert_eq!(ex.start + ex.length, asset.len());
310        assert!(asset[ex.start..].starts_with(wrapper::MARKER));
311    }
312
313    #[test]
314    fn padding_is_inside_the_exclusion() {
315        let padded = wrapper::encode_padded(PAYLOAD).unwrap();
316        let asset = format!("{HOST}{padded}");
317        let ex = manifest_exclusion(&asset).unwrap();
318        assert_eq!(ex.length, padded.len());
319        // Covered bytes are the visible text either way, padded or not.
320        let covered = hashed_bytes(&asset, &[ex], &AsciiNormalizer).unwrap();
321        assert_eq!(covered, HOST.as_bytes());
322    }
323
324    #[test]
325    fn covered_bytes_are_the_visible_text() {
326        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
327        let ex = manifest_exclusion(&asset).unwrap();
328        assert_eq!(
329            hashed_bytes(&asset, &[ex], &AsciiNormalizer).unwrap(),
330            HOST.as_bytes()
331        );
332    }
333
334    #[test]
335    fn compute_then_verify_round_trips() {
336        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
337        let dh =
338            compute_data_hash(&asset, Algorithm::Sha256, &SumHasher, &AsciiNormalizer).unwrap();
339        assert_eq!(dh.alg, "sha256");
340        assert_eq!(dh.label(), "c2pa.hash.data");
341        assert!(verify_data_hash(&asset, &dh, &SumHasher, &AsciiNormalizer).is_ok());
342    }
343
344    #[test]
345    fn editing_the_visible_text_breaks_the_binding() {
346        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
347        let dh =
348            compute_data_hash(&asset, Algorithm::Sha256, &SumHasher, &AsciiNormalizer).unwrap();
349        let tampered = wrapper::embed(&HOST.replace("invisible", "visible!"), PAYLOAD).unwrap();
350        assert_eq!(
351            verify_data_hash(&tampered, &dh, &SumHasher, &AsciiNormalizer),
352            Err(Error::MalformedExclusion)
353        );
354        // Same length, so the exclusion still matches and the hash is what fails.
355        let same_len = wrapper::embed(&HOST.replace("invisible", "invisibIe"), PAYLOAD).unwrap();
356        assert_eq!(
357            verify_data_hash(&same_len, &dh, &SumHasher, &AsciiNormalizer),
358            Err(Error::HashMismatch)
359        );
360    }
361
362    #[test]
363    fn an_exclusion_that_is_not_the_wrapper_is_rejected() {
364        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
365        let mut dh =
366            compute_data_hash(&asset, Algorithm::Sha256, &SumHasher, &AsciiNormalizer).unwrap();
367        dh.exclusions = vec![Exclusion {
368            start: 0,
369            length: 4,
370        }];
371        assert_eq!(
372            verify_data_hash(&asset, &dh, &SumHasher, &AsciiNormalizer),
373            Err(Error::MalformedExclusion)
374        );
375    }
376
377    #[test]
378    fn malformed_ranges_are_rejected() {
379        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
380        // Out of order / overlapping.
381        let bad = [
382            Exclusion {
383                start: 10,
384                length: 5,
385            },
386            Exclusion {
387                start: 5,
388                length: 5,
389            },
390        ];
391        assert_eq!(
392            apply_exclusions(&asset, &bad),
393            Err(Error::MalformedExclusion)
394        );
395        // Past the end.
396        assert_eq!(
397            apply_exclusions(
398                &asset,
399                &[Exclusion {
400                    start: 0,
401                    length: asset.len() + 1
402                }]
403            ),
404            Err(Error::MalformedExclusion)
405        );
406    }
407
408    #[test]
409    fn unsupported_algorithm_is_reported() {
410        let asset = wrapper::embed(HOST, PAYLOAD).unwrap();
411        let mut dh =
412            compute_data_hash(&asset, Algorithm::Sha256, &SumHasher, &AsciiNormalizer).unwrap();
413        dh.alg = "sha1".into();
414        assert_eq!(
415            verify_data_hash(&asset, &dh, &SumHasher, &AsciiNormalizer),
416            Err(Error::UnsupportedAlgorithm("sha1".into()))
417        );
418    }
419
420    #[test]
421    fn json_shape_matches_the_data_hash_map() {
422        let dh = DataHash {
423            exclusions: vec![Exclusion {
424                start: 73,
425                length: 114,
426            }],
427            alg: "sha256".into(),
428            hash: vec![0xDE, 0xAD, 0xBE, 0xEF],
429            name: None,
430        };
431        assert_eq!(
432            dh.to_json(),
433            r#"{"exclusions":[{"start":73,"length":114}],"alg":"sha256","hash":"3q2+7w=="}"#
434        );
435    }
436
437    #[test]
438    fn base64_matches_rfc4648_vectors() {
439        assert_eq!(base64(b""), "");
440        assert_eq!(base64(b"f"), "Zg==");
441        assert_eq!(base64(b"fo"), "Zm8=");
442        assert_eq!(base64(b"foo"), "Zm9v");
443        assert_eq!(base64(b"foob"), "Zm9vYg==");
444        assert_eq!(base64(b"fooba"), "Zm9vYmE=");
445        assert_eq!(base64(b"foobar"), "Zm9vYmFy");
446    }
447}