//! **Reproducible-reconstruction binding** — a cryptographic *hard* binding over the //! deterministically decoded pixels of a neural-coded asset. //! //! Provenance systems (C2PA, JPEG Trust) bind a manifest to an asset with a **hard //! binding**: a hash over the asset bytes. For neural-coded media that hash covers the //! *compressed* codestream — and it is the only hard option available, because a neural //! decode is normally not reproducible: two conformant decoders can produce different //! pixels, so a hash of the *decoded image* is not portable. JPEG AI makes this explicit //! — its normative conformance point is the output of the entropy decoder, and its //! multi-branch design lets one codestream reconstruct through up to three synthesis //! transforms. So provenance over the *rendered* result has had to fall back to fuzzy //! **soft** bindings (perceptual hashes, watermarks). //! //! WAI's integer-exact decoders (decode-equivalence) reconstruct byte-identically on //! every conforming sink, and its exact JPEG AI decoder pins one reconstruction that //! reproduces across machines under the conditions in `extensions/determinism-tiers.md` //! §5 (a property of that decoder; `wai.image.jpegai` itself is registered at //! entropy-consistency). Either restores the precondition a hard binding needs: a //! stable hash of the decoded pixels. This module produces that binding — a SHA-256 //! (the C2PA default hash) over the canonical decoded RGB, plus the descriptor that //! makes the decode reproducible (which synthesis branch, profile/level, pixel layout, //! colour space). A verifier with a WAI decoder re-decodes per the descriptor, re-hashes, //! and compares. //! //! It is the reconstruct-side analogue of [`crate::provenance`]: that binds the opaque //! asset bytes (content-hash-equivalence); this binds the reconstructed pixels. The //! digest is expressed as a custom C2PA / JPEG-Trust assertion — see //! `extensions/reconstruction-binding.md` for the on-the-wire mapping. WAI is //! self-referential about it: it states the capability (a reproducible-reconstruction //! binding), not a comparison. use sha2::{Digest, Sha256}; /// What must be pinned for the decode to be reproducible. Different values here decode /// the same codestream to different pixels, so the descriptor is part of the binding — /// a verifier decodes under exactly these parameters before hashing. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ReconstructionDescriptor { /// The synthesis branch the reconstruction is pinned to (e.g. the JPEG-AI /// `decoderID`, 0/1/2). Multi-branch codecs decode one codestream several ways; /// the binding fixes one. pub decoder_id: u8, /// Codec profile identifier (capability-specific, e.g. a JPEG-AI profile id). pub profile: String, /// Codec level identifier. pub level: String, pub width: u32, pub height: u32, /// Canonical pixel layout of the hashed buffer. This module hashes `RGB8`: /// row-major, 3 bytes/pixel, no row padding, no alpha. pub format: String, /// Colour space / transfer of the decoded pixels (e.g. `"bt709"`). pub color_space: String, } impl ReconstructionDescriptor { /// The byte length the canonical `RGB8` buffer must have. pub fn canonical_len(&self) -> usize { self.width as usize * self.height as usize * 3 } } /// A reproducible-reconstruction binding: the descriptor plus a hash over the canonical /// decoded pixels, ready to be carried as a C2PA / JPEG-Trust assertion or in a WAI /// receipt. #[derive(Clone, Debug)] pub struct ReconstructionBinding { /// Hash algorithm id, in the C2PA registry's spelling. Always `"sha256"` here — the /// C2PA default every validator is required to support. pub alg: String, /// SHA-256 over the canonical `RGB8` buffer. pub hash: [u8; 32], pub descriptor: ReconstructionDescriptor, } impl ReconstructionBinding { /// The custom C2PA / JPEG-Trust assertion label (reverse-domain, as the C2PA spec /// requires for third-party assertions). The full CBOR mapping is specified in /// `extensions/reconstruction-binding.md`. pub const C2PA_LABEL: &'static str = "science.transaction.wai.hash.reconstruction"; /// Bind a decoded image: hash the canonical `RGB8` buffer under `descriptor`. /// `rgb` MUST be exactly `descriptor.canonical_len()` bytes (row-major RGB8). pub fn bind(rgb: &[u8], descriptor: ReconstructionDescriptor) -> Self { assert_eq!( rgb.len(), descriptor.canonical_len(), "rgb buffer must be canonical RGB8 (width*height*3, no padding)" ); let hash: [u8; 32] = Sha256::digest(rgb).into(); ReconstructionBinding { alg: "sha256".to_string(), hash, descriptor } } /// Re-verify: recompute the hash over a freshly decoded `rgb` and compare. The /// caller MUST have decoded under this binding's `descriptor`. pub fn verify(&self, rgb: &[u8]) -> bool { self.alg == "sha256" && rgb.len() == self.descriptor.canonical_len() && <[u8; 32]>::from(Sha256::digest(rgb)) == self.hash } /// Lowercase-hex of the digest. pub fn hash_hex(&self) -> String { let mut s = String::with_capacity(64); for b in self.hash { use std::fmt::Write; let _ = write!(s, "{b:02x}"); } s } /// The binding as JSON, the shape a WAI receipt carries. The C2PA CBOR assertion /// (label [`Self::C2PA_LABEL`]) is the same fields under the mapping in /// `extensions/reconstruction-binding.md`; the digest is identical, so a validator /// that re-decodes with a WAI decoder gets a matching hash either way. pub fn to_json(&self) -> String { let d = &self.descriptor; format!( "{{\"alg\":\"{}\",\"hash\":\"{}\",\"reconstruction\":{{\"decoderId\":{},\ \"profile\":{},\"level\":{},\"width\":{},\"height\":{},\"format\":{},\ \"colorSpace\":{}}}}}", self.alg, self.hash_hex(), d.decoder_id, json_str(&d.profile), json_str(&d.level), d.width, d.height, json_str(&d.format), json_str(&d.color_space), ) } } /// Minimal JSON string escaping for the small, controlled descriptor fields. fn json_str(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), c => out.push(c), } } out.push('"'); out } #[cfg(test)] mod tests { use super::*; fn desc(w: u32, h: u32) -> ReconstructionDescriptor { ReconstructionDescriptor { decoder_id: 0, profile: "main".into(), level: "L1".into(), width: w, height: h, format: "RGB8".into(), color_space: "bt709".into(), } } #[test] fn bind_then_verify_roundtrips() { let rgb: Vec = (0..4 * 4 * 3).map(|i| (i * 7 % 251) as u8).collect(); let b = ReconstructionBinding::bind(&rgb, desc(4, 4)); assert!(b.verify(&rgb), "the same pixels must verify"); assert_eq!(b.alg, "sha256"); assert_eq!(b.hash_hex().len(), 64); } #[test] fn one_flipped_pixel_fails_verification() { let rgb: Vec = (0..4 * 4 * 3).map(|i| (i * 7 % 251) as u8).collect(); let b = ReconstructionBinding::bind(&rgb, desc(4, 4)); let mut tampered = rgb.clone(); tampered[10] ^= 1; assert!(!b.verify(&tampered), "a single flipped byte must break the hard binding"); } #[test] fn hash_is_the_c2pa_default_over_canonical_bytes() { // The digest is a plain SHA-256 over the RGB8 buffer — reproducible by any // SHA-256, which is exactly what makes it portable into a C2PA validator. let rgb = vec![0u8; 2 * 2 * 3]; let b = ReconstructionBinding::bind(&rgb, desc(2, 2)); let expect: [u8; 32] = Sha256::digest(&rgb).into(); assert_eq!(b.hash, expect); } #[test] fn json_carries_descriptor_and_hash() { let rgb = vec![1u8; 2 * 2 * 3]; let b = ReconstructionBinding::bind(&rgb, desc(2, 2)); let j = b.to_json(); assert!(j.contains("\"alg\":\"sha256\"")); assert!(j.contains("\"decoderId\":0")); assert!(j.contains(&format!("\"hash\":\"{}\"", b.hash_hex()))); assert!(j.contains("\"colorSpace\":\"bt709\"")); } }