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}