1use crate::base64;
48use crate::document::{self, Manifest};
49use crate::error::Error;
50
51pub const DATA_HASH_LABEL: &str = "c2pa.hash.data";
53
54#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum Algorithm {
71 Sha256,
72 Sha384,
73 Sha512,
74}
75
76impl Algorithm {
77 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
96pub trait Hasher {
100 fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8>;
101}
102
103#[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#[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 pub fn label(&self) -> &'static str {
133 DATA_HASH_LABEL
134 }
135
136 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
159pub 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
170pub 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
187pub 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
204pub fn inline_hash_before_embed(html: &[u8], alg: Algorithm, hasher: &impl Hasher) -> Vec<u8> {
215 hasher.digest(alg, html)
216}
217
218pub 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 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 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 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 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 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 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 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 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 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 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 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 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}