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(feature = "hard-binding")]
243mod provided {
244 use super::{Algorithm, Hasher, Normalizer};
245 use sha2::{Digest, Sha256, Sha384, Sha512};
246 use unicode_normalization::UnicodeNormalization as _;
249
250 #[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 #[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 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 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 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 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 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 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}