Skip to main content

c2pa_vtt/
binding.rs

1//! Hard binding for WebVTT: the `c2pa.hash.data` data hash.
2//!
3//! # What "hard binding" means for WebVTT
4//!
5//! WebVTT is a structured text container, so C2PA binds it with a byte-exact
6//! [`c2pa.hash.data`] data hash carrying a **single exclusion range** that
7//! covers the manifest `NOTE` block. The hash is computed over the *raw bytes*
8//! of the file with that range removed — the `WEBVTT` signature, every cue,
9//! `STYLE`/`REGION` block, and author comment is covered; only the manifest
10//! block itself is excluded.
11//!
12//! Unlike the unstructured-text (Unicode Variation Selector) method, structured
13//! text hashing applies **no Unicode normalization**: the file is byte-stable
14//! on disk and the ASCII delimiters make the excluded range unambiguous.
15//! Applying NFC would create false mismatches for legitimate NFD content in cue
16//! text. Files must use LF or CRLF line terminators; bare CR is not supported.
17//!
18//! [`c2pa.hash.data`]: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html
19
20use crate::error::Error;
21use crate::extract::extract_manifest;
22
23/// A `c2pa.hash.data` exclusion range (`start`/`length`, in bytes), matching the
24/// `EXCLUSION_RANGE-map` CDDL rule.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub struct Exclusion {
27    /// Starting byte of the excluded range.
28    pub start: usize,
29    /// Number of bytes to exclude.
30    pub length: usize,
31}
32
33/// A C2PA cryptographic hash algorithm usable for the data hash.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum HashAlg {
36    Sha256,
37    Sha384,
38    Sha512,
39}
40
41impl HashAlg {
42    /// The C2PA hash algorithm identifier (the value of the `alg` field).
43    pub fn c2pa_id(self) -> &'static str {
44        match self {
45            HashAlg::Sha256 => "sha256",
46            HashAlg::Sha384 => "sha384",
47            HashAlg::Sha512 => "sha512",
48        }
49    }
50}
51
52/// The single exclusion range covering the manifest `NOTE` block, which the
53/// generator must place in the `c2pa.hash.data` assertion's `exclusions` field.
54///
55/// Available with no dependencies so callers using their own hasher can compute
56/// the binding themselves.
57pub fn data_hash_exclusion(text: &str) -> Result<Exclusion, Error> {
58    let found = extract_manifest(text)?;
59    Ok(Exclusion {
60        start: found.offset,
61        length: found.length,
62    })
63}
64
65/// Compute the `c2pa.hash.data` value over the file with the manifest block
66/// excluded. The result is the byte string that goes in the assertion's `hash`
67/// field.
68#[cfg(feature = "hash")]
69pub fn compute_data_hash(text: &str, alg: HashAlg) -> Result<Vec<u8>, Error> {
70    let ex = data_hash_exclusion(text)?;
71    hash_excluding(text.as_bytes(), ex, alg)
72}
73
74/// Verify a `c2pa.hash.data` value against a WebVTT file. Returns `true` when
75/// the recomputed hash matches `expected`.
76#[cfg(feature = "hash")]
77pub fn verify_data_hash(text: &str, alg: HashAlg, expected: &[u8]) -> Result<bool, Error> {
78    let got = compute_data_hash(text, alg)?;
79    Ok(constant_time_eq(&got, expected))
80}
81
82#[cfg(feature = "hash")]
83fn hash_excluding(bytes: &[u8], ex: Exclusion, alg: HashAlg) -> Result<Vec<u8>, Error> {
84    use c2pa_structured_text::hardbinding::{apply_exclusions, Exclusion as StExclusion};
85    use sha2::{Digest, Sha256, Sha384, Sha512};
86
87    let covered = apply_exclusions(
88        bytes,
89        &[StExclusion {
90            start: ex.start,
91            length: ex.length,
92        }],
93    )
94    .map_err(|_| Error::ExclusionOutOfRange)?;
95
96    Ok(match alg {
97        HashAlg::Sha256 => Sha256::digest(&covered).to_vec(),
98        HashAlg::Sha384 => Sha384::digest(&covered).to_vec(),
99        HashAlg::Sha512 => Sha512::digest(&covered).to_vec(),
100    })
101}
102
103#[cfg(feature = "hash")]
104fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
105    if a.len() != b.len() {
106        return false;
107    }
108    let mut diff = 0u8;
109    for (x, y) in a.iter().zip(b) {
110        diff |= x ^ y;
111    }
112    diff == 0
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use crate::embed::{embed_manifest, ManifestRef};
119
120    const PLAIN: &str = "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nHello world\n";
121
122    #[test]
123    fn exclusion_covers_note_line() {
124        let signed = embed_manifest(PLAIN, ManifestRef::Url("urn:x")).unwrap();
125        let ex = data_hash_exclusion(&signed).unwrap();
126        let excluded = &signed[ex.start..ex.start + ex.length];
127        assert!(excluded.starts_with("NOTE -----BEGIN C2PA MANIFEST-----"));
128        assert!(excluded.trim_end().ends_with("-----END C2PA MANIFEST-----"));
129    }
130
131    #[test]
132    fn alg_identifiers() {
133        assert_eq!(HashAlg::Sha256.c2pa_id(), "sha256");
134        assert_eq!(HashAlg::Sha384.c2pa_id(), "sha384");
135        assert_eq!(HashAlg::Sha512.c2pa_id(), "sha512");
136    }
137
138    #[cfg(feature = "hash")]
139    #[test]
140    fn hash_is_independent_of_reference() {
141        // Excluding the NOTE block means two references over identical content
142        // must produce the same data hash.
143        let a = embed_manifest(PLAIN, ManifestRef::Url("urn:a")).unwrap();
144        let b = embed_manifest(
145            PLAIN,
146            ManifestRef::Url("urn:completely-different-and-longer"),
147        )
148        .unwrap();
149        let ha = compute_data_hash(&a, HashAlg::Sha256).unwrap();
150        let hb = compute_data_hash(&b, HashAlg::Sha256).unwrap();
151        assert_eq!(ha, hb);
152    }
153
154    #[cfg(feature = "hash")]
155    #[test]
156    fn verify_round_trip_and_tamper() {
157        let signed = embed_manifest(PLAIN, ManifestRef::Url("urn:x")).unwrap();
158        let hash = compute_data_hash(&signed, HashAlg::Sha256).unwrap();
159        assert!(verify_data_hash(&signed, HashAlg::Sha256, &hash).unwrap());
160
161        let tampered = signed.replace("Hello world", "Goodbye world");
162        assert!(!verify_data_hash(&tampered, HashAlg::Sha256, &hash).unwrap());
163    }
164
165    #[cfg(feature = "hash")]
166    #[test]
167    fn hash_covers_exactly_the_non_excluded_bytes() {
168        // With only the manifest block after the header, the hashed bytes are
169        // exactly "WEBVTT\n\n".
170        let signed =
171            "WEBVTT\n\nNOTE -----BEGIN C2PA MANIFEST----- urn:x -----END C2PA MANIFEST-----\n";
172        let h = compute_data_hash(signed, HashAlg::Sha256).unwrap();
173        use sha2::{Digest, Sha256};
174        let mut d = Sha256::new();
175        d.update(b"WEBVTT\n\n");
176        assert_eq!(h, d.finalize().to_vec());
177    }
178}