use crate::base64;
use crate::document::{self, Manifest};
use crate::error::Error;
pub const DATA_HASH_LABEL: &str = "c2pa.hash.data";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Exclusion {
pub start: usize,
pub length: usize,
}
impl Exclusion {
fn end(&self) -> Option<usize> {
self.start.checked_add(self.length)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Algorithm {
Sha256,
Sha384,
Sha512,
}
impl Algorithm {
pub fn id(self) -> &'static str {
match self {
Algorithm::Sha256 => "sha256",
Algorithm::Sha384 => "sha384",
Algorithm::Sha512 => "sha512",
}
}
pub fn from_id(id: &str) -> Result<Self, Error> {
match id {
"sha256" => Ok(Algorithm::Sha256),
"sha384" => Ok(Algorithm::Sha384),
"sha512" => Ok(Algorithm::Sha512),
other => Err(Error::UnsupportedAlgorithm(other.to_string())),
}
}
}
pub trait Hasher {
fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8>;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct Sha2;
impl Hasher for Sha2 {
fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8> {
match alg {
Algorithm::Sha256 => crate::sha2::sha256(data),
Algorithm::Sha384 => crate::sha2::sha384(data),
Algorithm::Sha512 => crate::sha2::sha512(data),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DataHash {
pub exclusions: Vec<Exclusion>,
pub alg: String,
pub hash: Vec<u8>,
pub name: Option<String>,
}
impl DataHash {
pub fn label(&self) -> &'static str {
DATA_HASH_LABEL
}
pub fn to_json(&self) -> String {
let ranges: Vec<String> = self
.exclusions
.iter()
.map(|e| format!("{{\"start\":{},\"length\":{}}}", e.start, e.length))
.collect();
let mut json = format!(
"{{\"exclusions\":[{}],\"alg\":\"{}\",\"hash\":\"{}\"",
ranges.join(","),
self.alg,
base64::encode(&self.hash)
);
if let Some(name) = &self.name {
json.push_str(&format!(",\"name\":\"{name}\""));
}
json.push('}');
json
}
}
pub fn manifest_exclusions(html: &[u8]) -> Result<Vec<Exclusion>, Error> {
match document::extract(html)? {
Manifest::Embedded { start, length, .. } => Ok(vec![Exclusion { start, length }]),
Manifest::Referenced { .. } => Ok(Vec::new()),
}
}
pub fn apply_exclusions(html: &[u8], exclusions: &[Exclusion]) -> Result<Vec<u8>, Error> {
let mut cursor = 0usize;
let mut out = Vec::with_capacity(html.len());
for ex in exclusions {
let end = ex.end().ok_or(Error::MalformedExclusion)?;
if ex.start < cursor || end > html.len() {
return Err(Error::MalformedExclusion);
}
out.extend_from_slice(&html[cursor..ex.start]);
cursor = end;
}
out.extend_from_slice(&html[cursor..]);
Ok(out)
}
pub fn compute_data_hash(
html: &[u8],
alg: Algorithm,
hasher: &impl Hasher,
) -> Result<DataHash, Error> {
let exclusions = manifest_exclusions(html)?;
let covered = apply_exclusions(html, &exclusions)?;
Ok(DataHash {
exclusions,
alg: alg.id().to_string(),
hash: hasher.digest(alg, &covered),
name: None,
})
}
pub fn inline_hash_before_embed(html: &[u8], alg: Algorithm, hasher: &impl Hasher) -> Vec<u8> {
hasher.digest(alg, html)
}
pub fn verify_data_hash(
html: &[u8],
data_hash: &DataHash,
hasher: &impl Hasher,
) -> Result<(), Error> {
let alg = Algorithm::from_id(&data_hash.alg)?;
let located = manifest_exclusions(html)?;
let ranges_agree = match located.first() {
Some(l) => data_hash.exclusions.contains(l),
None => data_hash.exclusions.is_empty(),
};
if !ranges_agree {
return Err(Error::MalformedExclusion);
}
let covered = apply_exclusions(html, &data_hash.exclusions)?;
if hasher.digest(alg, &covered) == data_hash.hash {
Ok(())
} else {
Err(Error::HashMismatch)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::tests::DOC;
const STORE: &[u8] = b"manifest-store-bytes";
const HREF: &str = "https://a.example/m.c2pa";
struct SumHasher;
impl Hasher for SumHasher {
fn digest(&self, alg: Algorithm, data: &[u8]) -> Vec<u8> {
let n: u64 = data.iter().map(|&b| b as u64).sum();
let mut v = alg.id().as_bytes().to_vec();
v.extend_from_slice(&n.to_be_bytes());
v.extend_from_slice(&(data.len() as u64).to_be_bytes());
v
}
}
#[test]
fn an_inline_exclusion_covers_the_whole_script_element() {
let html = document::embed(DOC, STORE).unwrap();
let ex = manifest_exclusions(&html).unwrap();
assert_eq!(ex.len(), 1);
let element = &html[ex[0].start..ex[0].start + ex[0].length];
assert!(element.starts_with(b"<script"));
assert!(element.ends_with(b"</script>"));
}
#[test]
fn an_external_manifest_has_no_exclusion() {
let html = document::embed_reference(DOC, HREF).unwrap();
assert_eq!(manifest_exclusions(&html).unwrap(), Vec::new());
}
#[test]
fn the_covered_bytes_of_an_inline_embed_are_the_original_document() {
let html = document::embed(DOC, STORE).unwrap();
let ex = manifest_exclusions(&html).unwrap();
assert_eq!(apply_exclusions(&html, &ex).unwrap(), DOC);
}
#[test]
fn the_covered_bytes_of_an_external_embed_include_the_link() {
let html = document::embed_reference(DOC, HREF).unwrap();
let covered = apply_exclusions(&html, &[]).unwrap();
assert_eq!(covered, html);
assert!(covered.windows(HREF.len()).any(|w| w == HREF.as_bytes()));
}
#[test]
fn compute_then_verify_round_trips_inline() {
let html = document::embed(DOC, STORE).unwrap();
let dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
assert_eq!(dh.alg, "sha256");
assert_eq!(dh.label(), "c2pa.hash.data");
assert!(verify_data_hash(&html, &dh, &SumHasher).is_ok());
}
#[test]
fn compute_then_verify_round_trips_external() {
let html = document::embed_reference(DOC, HREF).unwrap();
let dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
assert!(dh.exclusions.is_empty());
assert!(verify_data_hash(&html, &dh, &SumHasher).is_ok());
}
#[test]
fn the_manifest_content_does_not_affect_an_inline_hash() {
let a = document::embed(DOC, b"aaaaaaaa").unwrap();
let b = document::embed(DOC, b"bbbbbbbb").unwrap();
let ha = compute_data_hash(&a, Algorithm::Sha256, &SumHasher).unwrap();
let hb = compute_data_hash(&b, Algorithm::Sha256, &SumHasher).unwrap();
assert_eq!(ha.hash, hb.hash);
assert_eq!(ha.exclusions, hb.exclusions);
}
#[test]
fn the_hash_can_be_computed_before_the_manifest_exists() {
let before = inline_hash_before_embed(DOC, Algorithm::Sha256, &SumHasher);
let html = document::embed(DOC, STORE).unwrap();
let after = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
assert_eq!(
before, after.hash,
"hash-then-embed must agree with embed-then-hash"
);
let dh = DataHash {
exclusions: after.exclusions.clone(),
alg: Algorithm::Sha256.id().to_string(),
hash: before,
name: None,
};
assert!(verify_data_hash(&html, &dh, &SumHasher).is_ok());
}
#[test]
fn editing_the_document_breaks_an_inline_binding() {
let html = document::embed(DOC, STORE).unwrap();
let dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
let tampered = document::embed(
&String::from_utf8(DOC.to_vec())
.unwrap()
.replace("Content here.", "Content harel")
.into_bytes(),
STORE,
)
.unwrap();
assert_eq!(
verify_data_hash(&tampered, &dh, &SumHasher),
Err(Error::HashMismatch)
);
}
#[test]
fn editing_the_document_breaks_an_external_binding() {
let html = document::embed_reference(DOC, HREF).unwrap();
let dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
let tampered = document::embed_reference(
&String::from_utf8(DOC.to_vec())
.unwrap()
.replace("Content here.", "Content harel")
.into_bytes(),
HREF,
)
.unwrap();
assert_eq!(
verify_data_hash(&tampered, &dh, &SumHasher),
Err(Error::HashMismatch)
);
}
#[test]
fn repointing_an_external_reference_breaks_its_binding() {
let html = document::embed_reference(DOC, HREF).unwrap();
let dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
let repointed = document::embed_reference(DOC, "https://b.example/m.c2pa").unwrap();
assert_eq!(
verify_data_hash(&repointed, &dh, &SumHasher),
Err(Error::HashMismatch)
);
}
#[test]
fn an_exclusion_that_is_not_the_element_is_rejected() {
let html = document::embed(DOC, STORE).unwrap();
let mut dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
dh.exclusions = vec![Exclusion {
start: 0,
length: 4,
}];
assert_eq!(
verify_data_hash(&html, &dh, &SumHasher),
Err(Error::MalformedExclusion)
);
}
#[test]
fn an_inline_binding_with_no_exclusion_is_rejected() {
let html = document::embed(DOC, STORE).unwrap();
let mut dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
dh.exclusions.clear();
assert_eq!(
verify_data_hash(&html, &dh, &SumHasher),
Err(Error::MalformedExclusion)
);
}
#[test]
fn an_external_binding_that_excludes_its_link_is_rejected() {
let html = document::embed_reference(DOC, HREF).unwrap();
let mut dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
let range = document::extract(&html).unwrap().range();
dh.exclusions = vec![Exclusion {
start: range.start,
length: range.len(),
}];
assert_eq!(
verify_data_hash(&html, &dh, &SumHasher),
Err(Error::MalformedExclusion)
);
}
#[test]
fn malformed_ranges_are_rejected() {
let html = document::embed(DOC, STORE).unwrap();
let bad = [
Exclusion {
start: 10,
length: 5,
},
Exclusion {
start: 5,
length: 5,
},
];
assert_eq!(
apply_exclusions(&html, &bad),
Err(Error::MalformedExclusion)
);
assert_eq!(
apply_exclusions(
&html,
&[Exclusion {
start: 0,
length: html.len() + 1
}]
),
Err(Error::MalformedExclusion)
);
assert_eq!(
apply_exclusions(
&html,
&[Exclusion {
start: usize::MAX,
length: 1
}]
),
Err(Error::MalformedExclusion)
);
}
#[test]
fn unsupported_algorithm_is_reported() {
let html = document::embed(DOC, STORE).unwrap();
let mut dh = compute_data_hash(&html, Algorithm::Sha256, &SumHasher).unwrap();
dh.alg = "sha1".into();
assert_eq!(
verify_data_hash(&html, &dh, &SumHasher),
Err(Error::UnsupportedAlgorithm("sha1".into()))
);
}
#[test]
fn binding_a_document_with_no_manifest_reports_not_found() {
assert_eq!(
compute_data_hash(DOC, Algorithm::Sha256, &SumHasher),
Err(Error::NotFound)
);
}
#[test]
fn algorithm_ids_round_trip() {
for alg in [Algorithm::Sha256, Algorithm::Sha384, Algorithm::Sha512] {
assert_eq!(Algorithm::from_id(alg.id()), Ok(alg));
}
assert_eq!(
Algorithm::from_id("md5"),
Err(Error::UnsupportedAlgorithm("md5".into()))
);
}
#[test]
fn json_shape_matches_the_data_hash_map() {
let dh = DataHash {
exclusions: vec![Exclusion {
start: 73,
length: 114,
}],
alg: "sha256".into(),
hash: vec![0xDE, 0xAD, 0xBE, 0xEF],
name: None,
};
assert_eq!(
dh.to_json(),
r#"{"exclusions":[{"start":73,"length":114}],"alg":"sha256","hash":"3q2+7w=="}"#
);
}
#[test]
fn json_omits_exclusions_for_an_external_manifest() {
let dh = DataHash {
exclusions: Vec::new(),
alg: "sha512".into(),
hash: vec![0x01],
name: Some("html".into()),
};
assert_eq!(
dh.to_json(),
r#"{"exclusions":[],"alg":"sha512","hash":"AQ==","name":"html"}"#
);
}
#[test]
fn the_built_in_hasher_dispatches_to_the_right_algorithm() {
assert_eq!(
Sha2.digest(Algorithm::Sha256, b"")[..4],
[0xE3, 0xB0, 0xC4, 0x42]
);
assert_eq!(
Sha2.digest(Algorithm::Sha384, b"")[..4],
[0x38, 0xB0, 0x60, 0xA7]
);
assert_eq!(
Sha2.digest(Algorithm::Sha512, b"")[..4],
[0xCF, 0x83, 0xE1, 0x35]
);
assert_eq!(Sha2.digest(Algorithm::Sha256, b"").len(), 32);
assert_eq!(Sha2.digest(Algorithm::Sha384, b"").len(), 48);
assert_eq!(Sha2.digest(Algorithm::Sha512, b"").len(), 64);
}
#[test]
fn the_built_in_hasher_round_trips_a_real_binding() {
for alg in [Algorithm::Sha256, Algorithm::Sha384, Algorithm::Sha512] {
let html = document::embed(DOC, STORE).unwrap();
let dh = compute_data_hash(&html, alg, &Sha2).unwrap();
assert!(verify_data_hash(&html, &dh, &Sha2).is_ok(), "{alg:?}");
let referenced = document::embed_reference(DOC, HREF).unwrap();
let dh = compute_data_hash(&referenced, alg, &Sha2).unwrap();
assert!(verify_data_hash(&referenced, &dh, &Sha2).is_ok(), "{alg:?}");
}
}
#[test]
fn the_built_in_hasher_detects_tampering() {
let html = document::embed(DOC, STORE).unwrap();
let dh = compute_data_hash(&html, Algorithm::Sha256, &Sha2).unwrap();
let tampered = document::embed(
&String::from_utf8(DOC.to_vec())
.unwrap()
.replace("Content here.", "Content harel")
.into_bytes(),
STORE,
)
.unwrap();
assert_eq!(
verify_data_hash(&tampered, &dh, &Sha2),
Err(Error::HashMismatch)
);
}
}