1use crate::error::Error;
29use crate::wrapper;
30
31pub const DATA_HASH_LABEL: &str = "c2pa.hash.data";
33
34#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Algorithm {
51 Sha256,
52 Sha384,
53 Sha512,
54}
55
56impl Algorithm {
57 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
76pub trait Hasher {
79 fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8>;
80}
81
82pub trait Normalizer {
85 fn nfc(&self, text: &str) -> String;
86}
87
88#[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 pub fn label(&self) -> &'static str {
100 DATA_HASH_LABEL
101 }
102
103 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
126fn 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
154pub 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
163pub 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
183pub 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
195pub 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
213pub 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#[cfg(any(feature = "hard-binding", target_arch = "wasm32"))]
245mod provided {
246 use super::{Algorithm, Hasher, Normalizer};
247 use sha2::{Digest, Sha256, Sha384, Sha512};
248 use unicode_normalization::UnicodeNormalization as _;
251
252 #[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 #[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 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 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 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 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 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 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}