Skip to main content

c2pa_text_binding/
crosscheck.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Content-keyed cross-check and confidence classification.
4//!
5//! A watermark hit routes to a *candidate* manifest but never establishes a
6//! binding on its own — a routing tag can be transferred onto other content.
7//! A candidate is only [`Confidence::Bound`] when a *durable* fingerprint
8//! recomputed from the current text matches the manifest's stored fingerprint,
9//! so a transferred tag is rejected. The [`crosscheck_tag`] anti-transfer
10//! pointer binds a manifest to a `(repo_id, content_hash)` pair.
11//!
12//! # Empirical grounding of the tiers
13//!
14//! The tiers are not asserted; they follow the measured false-match rate (FMR)
15//! of each algorithm at its registered threshold. On the n=200 all-pairs sweep
16//! in `examples/threshold_sweep.rs` (39,800 unrelated document pairs, PAN'26):
17//!
18//! | algorithm | threshold | measured FMR | separation margin | tier role |
19//! | --- | --- | --- | --- | --- |
20//! | 41 simhash | Hamming ≤ 32 | 0 / 39,800 | +12 bits | durable → BOUND-eligible |
21//! | 44 minhash | Jaccard ≥ 0.70 | 0 / 39,800 | n/a | durable → BOUND-eligible |
22//! | 43 structural | Hamming ≤ 24 | 392 / 39,800 (1.0%) | **−16 bits** | corroborating only |
23//!
24//! The structural fingerprint's threshold sits *above* the nearest unrelated
25//! distance (min 8 vs. threshold 24), so a structural match alone is ~1% likely
26//! between unrelated documents. It therefore never lifts a candidate past
27//! [`Confidence::Likely`]. BOUND requires a durable (41/44) match — measured
28//! zero false matches — combined with the keyed crosscheck, which bounds
29//! transfer cryptographically rather than statistically. Re-run the sweep to
30//! reproduce these numbers on any corpus.
31
32use crate::minhash::{self, MinHash};
33use crate::simhash::{self, Fingerprint, Hash256};
34use crate::soft_binding::{SoftBinding, ALG_FINGERPRINT, ALG_MINHASH, ALG_STRUCTURE};
35use crate::structure;
36use hmac::{Hmac, Mac};
37use sha2::Sha256;
38
39type HmacSha256 = Hmac<Sha256>;
40
41/// Anti-transfer pointer: `HMAC-SHA256(key, repo_id ‖ content_hash)`.
42///
43/// Stored in the manifest and recomputed on verify; a mismatch means the
44/// content or the routing target was swapped.
45pub fn crosscheck_tag(key: &[u8], repo_id: &[u8], content_hash: &[u8]) -> [u8; 32] {
46    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
47    mac.update(repo_id);
48    mac.update(content_hash);
49    mac.finalize().into_bytes().into()
50}
51
52/// The provenance confidence tier surfaced to a verifier.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum Confidence {
55    /// A fingerprint recomputed from the current text matches the manifest and
56    /// the anti-transfer cross-check passes.
57    Bound,
58    /// A watermark or fingerprint hit, but the binding is not fully confirmed.
59    Likely,
60    /// Weak or no evidence; a human should review.
61    Review,
62}
63
64/// Evidence gathered while resolving a candidate manifest.
65///
66/// The two fingerprint fields are split by measured false-match rate: only a
67/// *durable* match (41 simhash / 44 minhash, measured FMR ~0) can establish
68/// BOUND; a *structural* match (43, measured FMR ~1%) is corroborating and caps
69/// at LIKELY. See the module-level table.
70#[derive(Clone, Copy, Debug, Default)]
71pub struct Evidence {
72    /// The v2 watermark payload verified against the current content.
73    pub watermark_verified: bool,
74    /// A durable fingerprint (surface SimHash 41 or lexical MinHash 44) matched
75    /// the manifest. Measured zero false matches at threshold — BOUND-eligible.
76    pub durable_fingerprint_match: bool,
77    /// The structural fingerprint (43) matched. ~1% false-match rate between
78    /// unrelated documents, so this corroborates but never establishes BOUND.
79    pub structural_match: bool,
80    /// The anti-transfer cross-check tag matched.
81    pub crosscheck_ok: bool,
82}
83
84/// Classify the evidence into a confidence tier.
85///
86/// [`Confidence::Bound`] requires a *durable* fingerprint match (41/44) plus the
87/// cross-check — never a watermark hit, a structural match, or a bare cross-check
88/// alone — so neither a tag transferred onto unrelated content nor a
89/// coincidental structural collision can reach BOUND.
90pub fn classify(ev: &Evidence) -> Confidence {
91    if ev.durable_fingerprint_match && ev.crosscheck_ok {
92        Confidence::Bound
93    } else if ev.watermark_verified || ev.durable_fingerprint_match || ev.structural_match {
94        Confidence::Likely
95    } else {
96        Confidence::Review
97    }
98}
99
100/// Recompute the fingerprint named by a candidate `c2pa.soft-binding` assertion
101/// from the current `text` and test it against the stored value(s) at that
102/// algorithm's registered threshold.
103///
104/// This is the content half of the verify path: it re-derives the binding and
105/// compares, so a candidate routed by a (transferable) watermark is only
106/// believed when the current text actually reproduces the stored fingerprint.
107/// Watermark verification and the [`crosscheck_tag`] check are supplied by the
108/// caller (from [`crate::stego::extract`] and a recomputed tag) and folded in by
109/// [`verify`].
110///
111/// Returns [`Evidence`] with the fingerprint fields set; watermark and
112/// cross-check fields are left `false` for the caller to fill.
113pub fn fingerprint_evidence(text: &str, candidate: &SoftBinding) -> Evidence {
114    let mut ev = Evidence::default();
115    match candidate.alg.as_str() {
116        ALG_FINGERPRINT => {
117            let current = Fingerprint::compute(text).whole;
118            ev.durable_fingerprint_match = candidate
119                .blocks
120                .iter()
121                .filter_map(|b| Hash256::from_hex(&b.value))
122                .any(|stored| current.hamming(&stored) <= simhash::MATCH_THRESHOLD);
123        }
124        ALG_MINHASH => {
125            if let Some(stored) = candidate
126                .blocks
127                .first()
128                .and_then(|b| parse_minhash(&b.value))
129            {
130                let current = MinHash::compute(text);
131                ev.durable_fingerprint_match = current.jaccard(&stored) >= minhash::MATCH_JACCARD
132                    || current.shares_band(&stored);
133            }
134        }
135        ALG_STRUCTURE => {
136            if let Some(stored) = candidate
137                .blocks
138                .first()
139                .and_then(|b| Hash256::from_hex(&b.value))
140            {
141                ev.structural_match = structure::matches(&structure::compute(text), &stored);
142            }
143        }
144        // A watermark-type binding carries no recomputable fingerprint here; its
145        // content proof is the extracted tag, folded in via `verify`.
146        _ => {}
147    }
148    ev
149}
150
151/// Full verify: gather fingerprint evidence from `text` and a candidate
152/// assertion, fold in the externally-computed watermark and cross-check results,
153/// and classify into a confidence tier.
154pub fn verify(
155    text: &str,
156    candidate: &SoftBinding,
157    watermark_verified: bool,
158    crosscheck_ok: bool,
159) -> Confidence {
160    let mut ev = fingerprint_evidence(text, candidate);
161    ev.watermark_verified = watermark_verified;
162    ev.crosscheck_ok = crosscheck_ok;
163    classify(&ev)
164}
165
166/// Parse a stored MinHash signature: 128 big-endian `u64` words, hex-encoded.
167fn parse_minhash(value_hex: &str) -> Option<MinHash> {
168    let bytes = hex::decode(value_hex).ok()?;
169    if bytes.len() != minhash::NUM_PERM * 8 {
170        return None;
171    }
172    let mut sig = [0u64; minhash::NUM_PERM];
173    for (word, chunk) in sig.iter_mut().zip(bytes.chunks_exact(8)) {
174        *word = u64::from_be_bytes(chunk.try_into().expect("chunk is 8 bytes"));
175    }
176    Some(MinHash::from_signature(sig))
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn crosscheck_is_deterministic_and_binds_inputs() {
185        let a = crosscheck_tag(b"key", b"repo-1", b"hashA");
186        assert_eq!(a, crosscheck_tag(b"key", b"repo-1", b"hashA"));
187        assert_ne!(a, crosscheck_tag(b"key", b"repo-2", b"hashA"));
188        assert_ne!(a, crosscheck_tag(b"key", b"repo-1", b"hashB"));
189    }
190
191    #[test]
192    fn bound_requires_durable_fingerprint_and_crosscheck() {
193        let ev = Evidence {
194            watermark_verified: true,
195            durable_fingerprint_match: true,
196            structural_match: true,
197            crosscheck_ok: true,
198        };
199        assert_eq!(classify(&ev), Confidence::Bound);
200    }
201
202    #[test]
203    fn watermark_alone_is_only_likely() {
204        let ev = Evidence {
205            watermark_verified: true,
206            crosscheck_ok: false,
207            ..Default::default()
208        };
209        assert_eq!(
210            classify(&ev),
211            Confidence::Likely,
212            "a watermark hit alone must never reach BOUND"
213        );
214    }
215
216    #[test]
217    fn durable_fingerprint_without_crosscheck_is_likely() {
218        let ev = Evidence {
219            durable_fingerprint_match: true,
220            crosscheck_ok: false,
221            ..Default::default()
222        };
223        assert_eq!(classify(&ev), Confidence::Likely);
224    }
225
226    #[test]
227    fn structural_match_never_reaches_bound() {
228        // 43 has a measured ~1% false-match rate, so even with the crosscheck it
229        // must cap at LIKELY — a coincidental structural collision plus a keyed
230        // tag on transferred content must not read as BOUND.
231        let ev = Evidence {
232            structural_match: true,
233            crosscheck_ok: true,
234            ..Default::default()
235        };
236        assert_eq!(classify(&ev), Confidence::Likely);
237    }
238
239    #[test]
240    fn no_evidence_is_review() {
241        assert_eq!(classify(&Evidence::default()), Confidence::Review);
242    }
243
244    #[test]
245    fn verify_recomputes_durable_fingerprint_from_text() {
246        use crate::soft_binding;
247
248        let text = "The principles of provenance require that a document's origin can be \
249            recovered even after it has been copied, reformatted, or lightly edited, so a \
250            manifest can be found again when the embedded one is stripped away.";
251        let sb = soft_binding::from_fingerprint(&Fingerprint::compute(text));
252
253        // Same text + crosscheck -> BOUND.
254        assert_eq!(verify(text, &sb, false, true), Confidence::Bound);
255        // Same text, no crosscheck -> LIKELY (fingerprint matched, tag unproven).
256        assert_eq!(verify(text, &sb, false, false), Confidence::Likely);
257        // Unrelated text -> the durable fingerprint does not match -> REVIEW,
258        // even though the (transferable) crosscheck bit is set.
259        let unrelated = "Chocolate chip cookies need flour, butter, sugar, eggs, and vanilla \
260            before the oven ever reaches three hundred and fifty degrees for the bake.";
261        assert_eq!(verify(unrelated, &sb, false, true), Confidence::Review);
262    }
263
264    #[test]
265    fn verify_structural_candidate_caps_at_likely() {
266        use crate::soft_binding;
267
268        let text = "Provenance must survive editing. A soft binding derives a durable value \
269            from the words themselves. When the embedded manifest is stripped, a resolver \
270            recomputes the value and finds the manifest again.";
271        let sb = soft_binding::from_structure(&structure::compute(text));
272        // Even the exact same text through a structural candidate + crosscheck
273        // stays LIKELY, because 43 is corroborating only.
274        assert_eq!(verify(text, &sb, false, true), Confidence::Likely);
275    }
276}