Some checks failed
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m18s
standards workspaces build + test / aac-rs (pull_request) Successful in 47s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 47s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 29s
standards workspaces build + test / map-rs (pull_request) Successful in 28s
standards workspaces build + test / mesh-rs (pull_request) Successful in 26s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 3s
standards workspaces build + test / openpay (pull_request) Successful in 1m18s
wai cross-architecture byte-equality / linux-aarch64 (pull_request) Successful in 16s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m48s
WAI conformance / cargo-class corpora · linux-arm64 (pull_request) Successful in 49s
WAI conformance / cross-arch byte-equality · linux-arm64 (pull_request) Successful in 15s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m17s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m10s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m6s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m28s
wai cross-architecture byte-equality / linux-x86_64 (pull_request) Successful in 15s
WAI conformance / cargo-class corpora · linux (pull_request) Successful in 1m9s
WAI conformance / cross-arch byte-equality · linux (pull_request) Successful in 14s
WAI conformance / C2PA manifest emission · signed, read back, validated (pull_request) Successful in 40s
wai formal verification (integer core) / kani (pull_request) Successful in 3m51s
WAI neural decoders end-to-end / synthetic fixtures → Rust decode → reference (±4) (pull_request) Failing after 1m11s
WAI neural sink gates / model-free refusals of the neural sink (pull_request) Successful in 26s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m55s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m26s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m43s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m46s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m36s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Failing after 2m29s
wai cross-architecture byte-equality / macos-arm64 (reference) (pull_request) Successful in 13s
WAI conformance / cargo-class corpora · macos (pull_request) Successful in 56s
WAI conformance / cross-arch byte-equality · macos (pull_request) Successful in 13s
wai cross-architecture byte-equality / windows-x86_64 (pull_request) Has been cancelled
WAI conformance / cargo-class corpora · windows (pull_request) Has been cancelled
WAI conformance / cross-arch byte-equality · windows (pull_request) Has been cancelled
SPEC §7 required an integer decode for `intent = replicate` in one paragraph and accepted entropy-consistency in another. Resolved in one direction: - Over a neural capability, `replicate` promises `decode-equivalence` — the whole decode to the final medium reproduces on every conforming sink. `entropy-consistency` (the decoded symbols reproduce; later stages may not) no longer backs it. The tier is a property of the capability as registered, not of one implementation. - Over a standard codec, `replicate` promises that standard's decoder conformance, stated per registry class. `StandardTolerance` covers codecs whose standard holds decoders to a criterion around a reference; `StandardUnclassified` covers codecs not yet classified. - `wai.image.jpegai` moves to the new `NeuralEntropyExact` class: its entropy decode is integer and exact, its later stages are float. It no longer backs `replicate`. This is recorded as a conservative choice pending a reading of ISO/IEC 6048-4, not as a finding about that standard. - The JPEG AI decoders now refuse codestreams outside the configuration they implement (other β, model_id, colour transform or LSBS settings) instead of decoding them incorrectly, and detect flush-to-zero and round-toward-zero float environments. - `wai wrap` writes `replicate` only over capabilities, and fallbacks, whose class a sink accepts unpinned. - extensions/determinism-tiers.md gains an evidence table stating which platforms and CI jobs have exercised each guarantee, including what has not run. - CI: a neural-gates workflow runs the model-free gates, including decode_envelope_strict, in an Ubuntu 24.04 image where the `neural` build links. Co-Authored-By: Claude Opus 5.5 <[email protected]>
204 lines
8.5 KiB
Rust
204 lines
8.5 KiB
Rust
//! **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<u8> = (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<u8> = (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\""));
|
|
}
|
|
}
|