wai: actually write the C2PA manifest the energy binding describes #79

Merged
dcharlot merged 2 commits from wai/c2pa-emitter into main 2026-09-07 00:10:39 -04:00
6 changed files with 2946 additions and 46 deletions

View file

@ -161,3 +161,63 @@ jobs:
- name: Integer rANS + int_synth + JPEG-AI mANS goldens (one machine → every machine)
working-directory: wai/byte-exact-conformance
run: cargo test --release
# The C2PA emitter is gated behind an off-by-default feature, so the four-platform
# job above — which builds a fixed feature list — never compiles it. A feature no
# workflow enables has no CI coverage at all: the suite passes, and the tests in it
# simply do not run.
#
# One platform, deliberately. Everything the matrix above exists to prove is about
# byte-identical reconstruction across architectures; a signed manifest makes no
# such claim and would not become more true for being emitted on four machines. What
# this job proves is that the manifest is still well-formed, still carries the three
# assertions, and still validates — which is architecture-independent.
c2pa-emitter:
name: C2PA manifest emission · signed, read back, validated
runs-on: linux
steps:
- uses: actions/checkout@v4
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal
rustup default 1.98.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# `rust_native_crypto` rather than the SDK's default openssl backend, so this
# job needs no system libraries — the same property the matrix above depends on.
# Asserted, not assumed: an openssl-linked build would still pass the tests here
# while quietly making the crate undistributable to a bare runner.
- name: The emitter pulls in no system crypto
working-directory: wai/wai-rs
shell: bash
run: |
for dep in openssl openssl-sys; do
if cargo tree --no-default-features --features c2pa_emit -i "$dep" >/dev/null 2>&1; then
echo "::error::$dep reached the c2pa_emit dependency graph"
exit 1
fi
done
echo "no system crypto in the graph"
- name: Emit, sign, read back, validate
working-directory: wai/wai-rs
run: cargo test --lib --no-default-features --features c2pa_emit c2pa_emit
# The feature must stay optional. If `provenance` ever pulls c2pa in, every
# consumer that wanted only the binding types inherits a large dependency tree
# they did not ask for, and the "off by default" claim in the extension is false.
- name: The feature stays optional
working-directory: wai/wai-rs
shell: bash
run: |
if cargo tree --no-default-features --features provenance -i c2pa >/dev/null 2>&1; then
echo "::error::c2pa is reachable from the provenance feature; it must stay opt-in"
exit 1
fi
cargo build --no-default-features --features provenance
echo "provenance builds without c2pa"

View file

@ -5,7 +5,8 @@
> `c2pa.environmental-sustainability` assertion — supplying the acquisition
> class, the coverage, and the outcome binding the standard assertion has no
> field for. Reference impl: the `energy_binding` module of `wai-rs`
> (feature `provenance`). Companion to
> (feature `provenance`), emitted into a real signed manifest by `c2pa_emit`
> (feature `c2pa_emit`). Companion to
> [Reproducible-Reconstruction Binding](reconstruction-binding.md) and the
> acquisition protocol in [Energy Measurement](energy-measurement.md).
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
@ -100,3 +101,67 @@ already better than the common case. A validator that understands this
extension additionally learns how the figure was acquired, how much of the work
it covers, and which reconstruction it describes — and can check the last of
those itself.
## 6. Emitting a manifest
The assertion shapes above are emitted into an actual signed C2PA manifest by the
`c2pa_emit` module of `wai-rs`. Four decisions in that emitter are normative for
anyone writing their own, because each is a place where the obvious
implementation produces a manifest that validators reject, or that overstates
what a decoder knows.
### 6.1 The inception action is `c2pa.opened`, not `c2pa.created`
A manifest **MUST NOT** describe a reconstruction with `c2pa.created`.
C2PA 2.x requires every `c2pa.created` action to carry a `digitalSourceType`, and
that vocabulary describes how the *content* came to exist — captured, drawn by a
human, generated by a model. A decoder does not know that. It reconstructed
pixels from a codestream, and whatever was encoded could have been any of them.
Selecting `trainedAlgorithmicMedia` because a codec has a neural stage would
assert that a compressed photograph is AI-generated content, which is false, and
the vocabulary has no member meaning "decoded; origin not visible from here".
A decode is in any case not a creation. It is a conversion of an existing asset
into another representation, which C2PA already models: the codestream is a
`parentOf` ingredient, the manifest **opens** it, and `c2pa.converted` records
what was done. No action asserts a `digitalSourceType`, so no claim about the
original's origin is made anywhere — which is precisely the position a decoder is
in.
### 6.2 The codestream is carried as the parent ingredient
An emitted manifest **MUST** carry the codestream as a `parentOf` ingredient.
§3 turns on a verifier being able to re-decode. A manifest that carries the
reconstruction digest but does not name what produces it hands the verifier a
digest and no way to reach it. The digest says *which* decode the joules were
measured over; the parent ingredient says *what to decode to get there*. Both
halves, or the check §3 describes is not actually available.
### 6.3 The bindings are `created` assertions, not `gathered`
The bindings **MUST** be marked as attributed to the signer.
C2PA distinguishes assertions the signer stands behind from material gathered
elsewhere and carried along — the same distinction between a claim and hearsay
that the acquisition ladder draws inside the figure itself. These are first-party
statements: the signer measured the joules and hashed the pixels.
This is load-bearing rather than editorial. A validator looks for the inception
action among the created assertions, so an actions assertion left gathered yields
a manifest that signs without complaint and every validator then rejects as
`assertion.action.malformed` — a failure that surfaces only on the far side of a
signature.
### 6.4 What a signature does not settle
An emitted manifest signed by an untrusted certificate reaches
`ValidationState::Valid` — well-formed, signature intact, asset unmodified — and
not `ValidationState::Trusted`, which additionally requires a certificate
chaining to a known authority. An implementation **MUST NOT** present the first
as though it were the second.
The gap between them is the whole of *who says so*, and it is orthogonal to
everything §3 makes checkable: a perfectly reproducible reconstruction signed by
nobody in particular is still a claim from nobody in particular.

2120
wai/wai-rs/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -59,6 +59,13 @@ ndarray = { version = "0.17", optional = true }
zstd-safe = { version = "7", optional = true, features = ["std"] } # for bmshj2018 latent unpack (std → WriteBuf for Vec)
sha2 = { version = "0.10", optional = true } # verify a pinned neural prior against the registered model artifact
# ---- real C2PA manifest emission (optional, behind `c2pa_emit`) ----
# Writes an actual signed manifest carrying the energy + reconstruction
# bindings, rather than only defining their shapes. Off by default: the SDK
# is a large dependency chain and most consumers only need the types.
c2pa = { version = "0.90", optional = true, default-features = false,
features = ["rust_native_crypto"] }
# ---- interactive worlds (optional, behind the `world` feature) ----
# extensions/interactive-worlds: WWLD container, signed CausalOps,
# deterministic fixed-point simulation, replay-equivalence conformance.
@ -141,6 +148,10 @@ score = ["world"]
# lineage receipt for opaque captured content WAI does not reconstruct.
# Reuses the JWP Merkle + Ed25519; no codec/ML deps.
provenance = ["world", "dep:sha2"]
# Emit a REAL C2PA manifest (not just the assertion shapes). Off by default:
# the SDK is a large dependency chain and most WAI consumers only need the
# binding types, which `provenance` already gives them.
c2pa_emit = ["provenance", "dep:c2pa"]
# wai.world.model — generative-world transport: a signed, replay-equivalent action
# trace (reuses the world CausalOp log) + per-frame provenance. The neural sibling
# of wai.world.replay; the renderer is a declared world model (sink-supplied, or the

688
wai/wai-rs/src/c2pa_emit.rs Normal file
View file

@ -0,0 +1,688 @@
//! Writes a **real** C2PA manifest carrying the energy and reconstruction bindings.
//!
//! # Why this module exists
//!
//! [`crate::energy_binding`] defines what a WAI reconstruction claims about its own
//! energy, and [`crate::reconstruction_binding`] defines the digest that claim is about.
//! Both produce assertion *bodies*. Neither produces a manifest, and a shape nothing
//! emits is a proposal, not an implementation — the exact gap this module closes.
//!
//! So this signs an asset with the C2PA SDK and puts three assertions in the manifest:
//! the standard `c2pa.environmental-sustainability` for interoperability, and the two
//! companions that make the standard one checkable.
//!
//! # The chain a verifier actually walks
//!
//! Two different bindings do two different jobs, and conflating them is the easy
//! mistake:
//!
//! 1. **C2PA's hard binding** covers the *delivered asset bytes*. Validating the
//! manifest proves those bytes are the ones that were signed.
//! 2. **The reconstruction digest** covers the *canonical decoded pixels*. It survives
//! re-encoding, so it says which decode the energy figure was measured over.
//!
//! A verifier decodes the delivered asset, hashes the pixels, and compares against the
//! sealed digest. It never needs to reproduce the asset bytes it was handed — only to
//! decode them. That is what lets the energy claim attach to a reconstruction rather
//! than to a particular file.
//!
//! # The one rule this module enforces that the SDK cannot
//!
//! `extensions/energy-binding.md` §2 says a manifest whose two digests disagree is
//! self-refuting: the energy figure would be measured over one decode while the
//! reconstruction assertion commits to another, and nothing in the manifest would say
//! which is the subject. The C2PA SDK has no opinion about this — third-party assertion
//! bodies are opaque to it, so it will happily sign the contradiction, and the result
//! validates.
//!
//! [`ReconstructionManifest::new`] therefore refuses to build one. The check is at
//! construction rather than at signing time so the contradiction cannot reach a
//! signature at all.
//!
//! # Signed is not trusted
//!
//! [`untrusted_local_signer`] returns an ephemeral self-signed chain. A manifest it signs
//! reaches [`c2pa::ValidationState::Valid`] — well-formed, signature intact, asset
//! unmodified — and never [`c2pa::ValidationState::Trusted`], which additionally requires
//! a certificate chaining to a known authority. The gap between those two states is the
//! whole of "who says so", so the constructor is named for its limit rather than for
//! what is convenient to assume. Production callers pass their own [`c2pa::Signer`].
use c2pa::assertions::{c2pa_action, Action};
use c2pa::BuilderIntent;
use c2pa::{Builder, ClaimGeneratorInfo, EphemeralSigner, Manifest, Signer};
use serde_json::Value;
use std::io::{Read, Seek, Write};
use crate::energy_binding::EnergyBinding;
use crate::reconstruction_binding::ReconstructionBinding;
/// Errors from emitting or recovering a bound manifest.
#[derive(Debug)]
pub enum EmitError {
/// The energy figure and the reconstruction assertion name different decodes. The
/// manifest would be self-refuting, so it is refused before it can be signed.
DigestsDisagree { energy: String, reconstruction: String },
/// A manifest carries the standard sustainability assertion with no companion. This
/// is the common case in the wild and is not an error in C2PA — it is reported as
/// one *here* because such a figure is self-declared, unattributed to any protocol,
/// and not about any reproducible output.
UnaccompaniedStandardAssertion,
/// The manifest carries none of the assertions this module emits.
NoBinding,
/// The SDK failed.
C2pa(c2pa::Error),
/// An assertion body was not the JSON this crate emits.
Malformed(&'static str),
}
impl core::fmt::Display for EmitError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
EmitError::DigestsDisagree { energy, reconstruction } => write!(
f,
"energy figure is measured over {energy} but the reconstruction \
assertion commits to {reconstruction}; the manifest would be \
self-refuting"
),
EmitError::UnaccompaniedStandardAssertion => write!(
f,
"manifest carries c2pa.environmental-sustainability with no companion \
binding: the figure is self-declared and describes no reproducible \
output"
),
EmitError::NoBinding => write!(f, "manifest carries no WAI binding"),
EmitError::C2pa(e) => write!(f, "c2pa: {e}"),
EmitError::Malformed(w) => write!(f, "malformed assertion body: {w}"),
}
}
}
impl std::error::Error for EmitError {}
impl From<c2pa::Error> for EmitError {
fn from(e: c2pa::Error) -> Self {
EmitError::C2pa(e)
}
}
/// A signer for local use and tests: an ephemeral self-signed certificate chain.
///
/// Named for its limit. Manifests it signs validate as
/// [`Valid`](c2pa::ValidationState::Valid) but never as
/// [`Trusted`](c2pa::ValidationState::Trusted), because nothing vouches for the
/// certificate. Use it to prove a manifest is well-formed and its assertions survive a
/// round trip; do not use it to publish a claim anyone is expected to rely on.
pub fn untrusted_local_signer() -> Result<EphemeralSigner, EmitError> {
Ok(EphemeralSigner::new("wai-energy-binding.local")?)
}
/// The three assertions of one reconstruction, checked for self-consistency and ready to
/// be sealed into a manifest.
pub struct ReconstructionManifest<'a> {
energy: &'a EnergyBinding,
reconstruction: &'a ReconstructionBinding,
}
impl<'a> ReconstructionManifest<'a> {
/// Pair an energy figure with the reconstruction it was measured over.
///
/// Refuses the pair when the two digests disagree. See the module docs: the SDK
/// cannot make this check, because the assertion bodies are opaque to it.
pub fn new(
energy: &'a EnergyBinding,
reconstruction: &'a ReconstructionBinding,
) -> Result<Self, EmitError> {
if energy.reconstruction_hash != reconstruction.hash {
return Err(EmitError::DigestsDisagree {
energy: energy.hash_hex(),
reconstruction: reconstruction.hash_hex(),
});
}
Ok(ReconstructionManifest { energy, reconstruction })
}
/// Build the manifest, with the codestream as parent ingredient and all three
/// assertions attached.
///
/// The assertion bodies are parsed from the same `to_json` functions the extension
/// documents pin, so the emitter cannot drift from the specified shape: there is one
/// definition of each assertion and this reads it rather than restating it.
///
/// The standard assertion is added in the same call as its companion, so it is not
/// possible to emit through this module the unaccompanied figure that
/// [`EmitError::UnaccompaniedStandardAssertion`] describes.
///
/// # Why the codestream is required, and why the action is not `c2pa.created`
///
/// The obvious modelling — "this decoder made an image, so `c2pa.created`" — is both
/// unavailable and wrong.
///
/// Unavailable, because C2PA 2.x requires every `c2pa.created` action to carry a
/// `digitalSourceType`, and that vocabulary describes how the *content* came to
/// exist: captured, drawn by a human, generated by a model. A decoder does not know
/// that. It reconstructed pixels from a codestream, and whatever was encoded could
/// have been any of them. Reaching for `trainedAlgorithmicMedia` because a codec has
/// a neural stage would assert that a compressed photograph is AI-generated content,
/// which is false — and there is no "I decoded something and cannot see past it"
/// member of the vocabulary to reach for instead.
///
/// Wrong, because a decode is not a creation. It is a **conversion** of an existing
/// asset — the codestream — into another representation. C2PA already models that:
/// the codestream is a `parentOf` ingredient, the manifest opens it, and the decoded
/// pixels are what came out. No claim about the original's origin is made anywhere,
/// which is exactly the epistemic position a decoder is in.
///
/// That modelling is not merely more correct, it is more useful. The reconstruction
/// digest says *which* decode the joules were measured over, and the parent
/// ingredient says *what to decode to get there*. A verifier holding this manifest
/// has both halves of the check rather than a digest and no way to reproduce it.
pub fn builder(
&self,
codestream: &[u8],
codestream_format: &str,
) -> Result<Builder, EmitError> {
let mut b = Builder::default();
b.set_claim_generator_info(ClaimGeneratorInfo::new("wai"));
// Edit intent + a parentOf ingredient makes the SDK emit the inception action —
// `c2pa.opened`, tied to that ingredient. Deliberately not written by hand: the
// action must reference the ingredient's hashed URI, which does not exist until
// the claim is built, and a hand-written one that misses is reported as
// `assertion.action.ingredientMismatch` on the far side of a signature.
b.set_intent(BuilderIntent::Edit);
b.add_ingredient_from_stream(
serde_json::json!({
"relationship": "parentOf",
"title": "WAI codestream",
"format": codestream_format,
})
.to_string(),
codestream_format,
&mut std::io::Cursor::new(codestream.to_vec()),
)?;
// What was done to the parent: its format was changed, by decoding it.
b.add_action(
Action::new(c2pa_action::CONVERTED)
.set_software_agent("wai")
.set_description("deterministic WAI reconstruction of the parent codestream"),
)?;
let parse = |s: String, w: &'static str| -> Result<Value, EmitError> {
serde_json::from_str(&s).map_err(|_| EmitError::Malformed(w))
};
b.add_assertion(
EnergyBinding::C2PA_STANDARD_LABEL,
&parse(self.energy.to_standard_assertion_json(), "standard sustainability")?,
)?;
b.add_assertion(
EnergyBinding::C2PA_LABEL,
&parse(self.energy.to_json(), "energy binding")?,
)?;
b.add_assertion(
ReconstructionBinding::C2PA_LABEL,
&parse(self.reconstruction.to_json(), "reconstruction binding")?,
)?;
// Mark every assertion as *created*: C2PA's word for "attributed to the signer",
// as against *gathered*, which is material collected from elsewhere and merely
// carried along. The distinction is the same one the whole family turns on — a
// claim someone stands behind versus hearsay in transit — and these are all
// first-party statements: this signer measured the joules and hashed the pixels.
//
// It is also load-bearing rather than cosmetic. A validator looks for the
// inception action in the created list; leaving the actions assertion gathered
// (which is what `add_action` does on its own) yields a manifest the SDK signs
// happily and every validator then rejects as `assertion.action.malformed`.
for a in &mut b.definition.assertions {
a.created = true;
}
Ok(b)
}
/// Sign `source` into `dest`, embedding the manifest. Returns the manifest bytes.
///
/// `format` is the delivered asset's MIME type or extension (`"image/png"`), and
/// `codestream_format` the parent's. This module deliberately does not encode
/// images: which container the reconstruction is delivered in is the caller's
/// decision, and the bindings are indifferent to it — the reconstruction digest is
/// over decoded pixels, not over the delivered bytes.
pub fn sign_stream<R, W>(
&self,
signer: &dyn Signer,
codestream: &[u8],
codestream_format: &str,
format: &str,
source: &mut R,
dest: &mut W,
) -> Result<Vec<u8>, EmitError>
where
R: Read + Seek + Send,
W: Write + Read + Seek + Send,
{
Ok(self.builder(codestream, codestream_format)?.sign(signer, format, source, dest)?)
}
}
/// The assertion bodies recovered from a signed manifest, as a verifier reads them.
#[derive(Debug, Clone)]
pub struct RecoveredBindings {
/// The standard `c2pa.environmental-sustainability` body.
pub standard: Value,
/// The companion energy binding.
pub energy: Value,
/// The reconstruction binding.
pub reconstruction: Value,
}
impl RecoveredBindings {
/// The digest both companions agree on, lowercase hex.
pub fn reconstruction_hash_hex(&self) -> Option<&str> {
self.energy.get("reconstructionHash")?.as_str()
}
/// The authoritative integer figure. `energy_kwh` in the standard assertion is a
/// derived interop value; a verifier reads this one.
pub fn microjoules(&self) -> Option<u64> {
self.energy.get("microjoules")?.as_u64()
}
/// Does the sealed claim describe the pixels a verifier just decoded?
///
/// This is the whole construction in one call: re-decode the asset, pass the
/// canonical RGB8 here, and everything in the claim is confirmed except the joules.
pub fn describes(&self, rgb: &[u8]) -> bool {
use sha2::{Digest, Sha256};
let mut hex = String::with_capacity(64);
for b in Sha256::digest(rgb) {
use std::fmt::Write;
let _ = write!(hex, "{b:02x}");
}
self.reconstruction_hash_hex() == Some(hex.as_str())
&& self.reconstruction.get("hash").and_then(Value::as_str) == Some(hex.as_str())
}
}
/// Recover the bindings from a validated manifest, applying the rules the SDK does not.
///
/// Reports [`EmitError::UnaccompaniedStandardAssertion`] for a manifest carrying only
/// the standard figure. That manifest is perfectly valid C2PA; it is refused here
/// because the figure in it is self-declared, names no acquisition class, and is not
/// about any output a reader can reproduce — which is the situation the companion
/// assertion exists to distinguish itself from.
pub fn recover(manifest: &Manifest) -> Result<RecoveredBindings, EmitError> {
let find = |label: &str| -> Option<Value> {
manifest.assertions().iter().find(|a| a.label() == label).and_then(|a| {
a.value().ok().cloned()
})
};
let standard = find(EnergyBinding::C2PA_STANDARD_LABEL);
let energy = find(EnergyBinding::C2PA_LABEL);
let reconstruction = find(ReconstructionBinding::C2PA_LABEL);
match (standard, energy, reconstruction) {
(Some(standard), Some(energy), Some(reconstruction)) => {
let e = energy.get("reconstructionHash").and_then(Value::as_str).unwrap_or("");
let r = reconstruction.get("hash").and_then(Value::as_str).unwrap_or("");
if e.is_empty() || r.is_empty() {
return Err(EmitError::Malformed("missing reconstruction digest"));
}
if e != r {
// The same self-refutation the emitter refuses to create. Checked again
// on the way in, because a manifest may have been produced by anything.
return Err(EmitError::DigestsDisagree {
energy: e.to_string(),
reconstruction: r.to_string(),
});
}
Ok(RecoveredBindings { standard, energy, reconstruction })
}
(Some(_), None, _) => Err(EmitError::UnaccompaniedStandardAssertion),
_ => Err(EmitError::NoBinding),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::energy_binding::{Acquisition, Coverage};
use crate::reconstruction_binding::ReconstructionDescriptor;
use c2pa::{Reader, ValidationState};
use std::io::Cursor;
// ---- a minimal, valid RGB8 PNG ------------------------------------------------
//
// The emitter deliberately does not encode images, so the tests must supply an
// asset. This writes one with stored (uncompressed) deflate blocks: enough for the
// SDK to parse the chunk structure, insert its `caBX` chunk and read it back, which
// is the structural oracle these tests need. It is test scaffolding, not a codec.
fn crc32(bytes: &[u8]) -> u32 {
let mut c = 0xffff_ffff_u32;
for &b in bytes {
c ^= u32::from(b);
for _ in 0..8 {
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
let mut crc_over = kind.to_vec();
crc_over.extend_from_slice(data);
out.extend_from_slice(kind);
out.extend_from_slice(data);
out.extend_from_slice(&crc32(&crc_over).to_be_bytes());
}
fn png(w: u32, h: u32, rgb: &[u8]) -> Vec<u8> {
assert_eq!(rgb.len() as u32, w * h * 3);
// Raw PNG data: one filter byte (0 = None) per scanline.
let mut raw = Vec::with_capacity((h * (1 + w * 3)) as usize);
for y in 0..h as usize {
raw.push(0);
let row = y * (w as usize) * 3;
raw.extend_from_slice(&rgb[row..row + (w as usize) * 3]);
}
// zlib: header, one final stored block, adler32.
let mut z = vec![0x78, 0x01];
assert!(raw.len() < 65_535, "one stored block is enough for a test image");
z.push(0x01);
z.extend_from_slice(&(raw.len() as u16).to_le_bytes());
z.extend_from_slice(&(!(raw.len() as u16)).to_le_bytes());
z.extend_from_slice(&raw);
let (mut a, mut b) = (1u32, 0u32);
for &byte in &raw {
a = (a + u32::from(byte)) % 65521;
b = (b + a) % 65521;
}
z.extend_from_slice(&((b << 16) | a).to_be_bytes());
let mut out = vec![0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&w.to_be_bytes());
ihdr.extend_from_slice(&h.to_be_bytes());
ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); // 8-bit, truecolour RGB, no interlace
chunk(&mut out, b"IHDR", &ihdr);
chunk(&mut out, b"IDAT", &z);
chunk(&mut out, b"IEND", &[]);
out
}
// ---- fixtures ------------------------------------------------------------------
const W: u32 = 4;
const H: u32 = 4;
fn pixels() -> Vec<u8> {
(0..(W * H * 3) as u8).collect()
}
fn descriptor() -> ReconstructionDescriptor {
ReconstructionDescriptor {
decoder_id: 0,
profile: "int".into(),
level: "1".into(),
width: W,
height: H,
format: "RGB8".into(),
color_space: "bt709".into(),
}
}
fn bindings(rgb: &[u8]) -> (EnergyBinding, ReconstructionBinding) {
let e = EnergyBinding::bind_pixels(
rgb,
10_565,
Acquisition::HwShunt,
Coverage::Whole,
"extensions/energy-measurement.md",
)
.unwrap();
(e, ReconstructionBinding::bind(rgb, descriptor()))
}
/// Sign the test asset and hand back the bytes a consumer would receive.
/// Stand-in for a WAI codestream: the emitter treats it as opaque parent bytes.
const CODESTREAM: &[u8] = b"WAI\x00 not a real codestream, only opaque parent bytes";
fn sign(m: &ReconstructionManifest<'_>, asset: &[u8]) -> Vec<u8> {
let signer = untrusted_local_signer().unwrap();
let mut src = Cursor::new(asset.to_vec());
let mut dst = Cursor::new(Vec::new());
m.sign_stream(
&signer,
CODESTREAM,
"application/octet-stream",
"image/png",
&mut src,
&mut dst,
)
.unwrap();
dst.into_inner()
}
// ---- tests ---------------------------------------------------------------------
/// The gap this module closes: a manifest is actually written, actually signed, and
/// an independent reader finds all three assertions in it.
#[test]
fn a_signed_manifest_carries_all_three_assertions() {
let rgb = pixels();
let (e, r) = bindings(&rgb);
let m = ReconstructionManifest::new(&e, &r).unwrap();
let signed = sign(&m, &png(W, H, &rgb));
assert_ne!(signed, png(W, H, &rgb), "signing must change the asset bytes");
let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap();
let manifest = reader.active_manifest().expect("a manifest was embedded");
let got = recover(manifest).unwrap();
// The bodies are the ones the extension documents pin, not a restatement.
assert_eq!(got.microjoules(), Some(10_565));
assert_eq!(got.energy.get("acquisition").unwrap(), "HwShunt");
assert_eq!(got.energy.get("coverage").unwrap(), "Whole");
assert_eq!(got.energy.get("workUnits").unwrap(), &Value::from(rgb.len()));
assert_eq!(
got.energy.get("method").unwrap(),
"extensions/energy-measurement.md",
"method is mandatory here, unlike the standard assertion's optional field"
);
assert!(got.standard.get("measurementMethod").is_some(), "never left empty");
}
/// The construction in one call: re-decode, hash, and everything is confirmed except
/// the joules.
#[test]
fn the_recovered_claim_describes_the_pixels_a_verifier_decodes() {
let rgb = pixels();
let (e, r) = bindings(&rgb);
let signed = sign(&ReconstructionManifest::new(&e, &r).unwrap(), &png(W, H, &rgb));
let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap();
let got = recover(reader.active_manifest().unwrap()).unwrap();
assert!(got.describes(&rgb), "the sealed claim is about these pixels");
let mut other = rgb.clone();
other[0] ^= 1;
assert!(!got.describes(&other), "and provably not about any other decode");
}
/// The rule the SDK cannot enforce, caught before a signature can cover it.
#[test]
fn a_manifest_whose_two_digests_disagree_is_refused_before_signing() {
let rgb = pixels();
let mut other = rgb.clone();
other[0] ^= 1;
// An energy figure measured over one decode, an assertion committing to another.
let e = EnergyBinding::bind_pixels(
&other,
10_565,
Acquisition::HwShunt,
Coverage::Whole,
"extensions/energy-measurement.md",
)
.unwrap();
let r = ReconstructionBinding::bind(&rgb, descriptor());
match ReconstructionManifest::new(&e, &r) {
Err(EmitError::DigestsDisagree { energy, reconstruction }) => {
assert_ne!(energy, reconstruction);
}
_ => panic!("a self-refuting pair must never reach a signer"),
}
}
/// The common case in the wild: a C2PA manifest whose energy figure is
/// self-declared and about nothing reproducible. C2PA has no objection to it — the
/// assertion is well-formed and validates — and this module refuses it anyway.
#[test]
fn an_unaccompanied_standard_figure_is_reported_as_such() {
let rgb = pixels();
let (e, r) = bindings(&rgb);
// The same manifest, minus the two companions.
let mut b = ReconstructionManifest::new(&e, &r)
.unwrap()
.builder(CODESTREAM, "application/octet-stream")
.unwrap();
b.definition
.assertions
.retain(|a| !a.label.starts_with("science.transaction.wai"));
let signer = untrusted_local_signer().unwrap();
let mut src = Cursor::new(png(W, H, &rgb));
let mut dst = Cursor::new(Vec::new());
b.sign(&signer, "image/png", &mut src, &mut dst).unwrap();
let reader = Reader::from_stream("image/png", Cursor::new(dst.into_inner())).unwrap();
let manifest = reader.active_manifest().unwrap();
// The SDK is content: nothing it checks has anything to say about the figure.
let faults: Vec<&str> = reader
.validation_results()
.and_then(|r| r.active_manifest().map(|m| m.failure().iter().map(|s| s.code()).collect()))
.unwrap_or_default();
assert!(
!faults.iter().any(|c| c.starts_with("assertion.")),
"an unaccompanied sustainability figure is valid C2PA, not a malformed one: {faults:?}"
);
// This module is not content.
assert!(matches!(
recover(manifest),
Err(EmitError::UnaccompaniedStandardAssertion)
));
}
/// Signed is not trusted. The test pins the exact verdict and the exact single
/// reason, so a real defect can never hide behind the expected one — which is not
/// hypothetical: while this module was being written, a missing inception action
/// made the manifest malformed, and reading only the overall state would have
/// attributed that to the certificate.
#[test]
fn the_local_signer_signs_but_does_not_claim_trust() {
let rgb = pixels();
let (e, r) = bindings(&rgb);
let signed = sign(&ReconstructionManifest::new(&e, &r).unwrap(), &png(W, H, &rgb));
let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap();
let state = reader.validation_state();
assert_eq!(
state,
ValidationState::Valid,
"the manifest is well-formed, the signature intact and the asset unmodified"
);
assert_ne!(
state,
ValidationState::Trusted,
"but nothing vouches for an ephemeral certificate, and the name says so"
);
let results = reader.validation_results().expect("validation ran");
let codes: Vec<&str> = results
.active_manifest()
.map(|m| m.failure().iter().map(|s| s.code()).collect())
.unwrap_or_default();
assert_eq!(
codes,
vec!["signingCredential.untrusted"],
"the credential is the ONLY thing wrong: no hash, signature or action fault \
may hide behind it"
);
let ok: Vec<&str> = results
.active_manifest()
.map(|m| m.success().iter().map(|s| s.code()).collect())
.unwrap_or_default();
assert!(ok.contains(&"claimSignature.validated"), "signature intact");
assert!(ok.contains(&"assertion.dataHash.match"), "asset unmodified since signing");
}
/// Modelled quantities are not smuggled in beside a measured one, and the integer
/// stays authoritative.
#[test]
fn no_modelled_carbon_or_water_travels_beside_the_measured_figure() {
let rgb = pixels();
let (e, r) = bindings(&rgb);
let signed = sign(&ReconstructionManifest::new(&e, &r).unwrap(), &png(W, H, &rgb));
let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap();
let got = recover(reader.active_manifest().unwrap()).unwrap();
assert!(got.standard.get("carbon_kgco2e").is_none());
assert!(got.standard.get("water_litres").is_none());
// The derived kWh is a lossy interop value; the integer is the measurement.
let kwh = got.standard.get("energy_kwh").unwrap().as_f64().unwrap();
assert_eq!(got.microjoules(), Some(10_565));
assert!(
(kwh - 10_565.0 / 3.6e12).abs() < 1e-20,
"energy_kwh is derived from the integer, not the other way round"
);
}
/// A verifier needs both halves: which decode the joules describe, and what to
/// decode to get there. The digest is the first; the parent ingredient is the second.
#[test]
fn the_manifest_names_the_codestream_it_was_decoded_from() {
let rgb = pixels();
let (e, r) = bindings(&rgb);
let signed = sign(&ReconstructionManifest::new(&e, &r).unwrap(), &png(W, H, &rgb));
let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap();
let manifest = reader.active_manifest().unwrap();
let parent = manifest
.ingredients()
.iter()
.find(|i| *i.relationship() == c2pa::Relationship::ParentOf)
.expect("the codestream is carried as the parent ingredient");
assert_eq!(parent.title(), Some("WAI codestream"));
// The inception action is `opened` on that parent, not `created` — the decoder
// makes no claim about how the encoded content originally came to exist.
let actions = manifest
.assertions()
.iter()
.find(|a| a.label().starts_with("c2pa.actions"))
.and_then(|a| a.value().ok().cloned())
.expect("an actions assertion");
let list = actions.get("actions").and_then(Value::as_array).unwrap();
assert_eq!(list[0].get("action").unwrap(), "c2pa.opened");
assert_eq!(list[1].get("action").unwrap(), "c2pa.converted");
assert!(
list.iter().all(|a| a.get("digitalSourceType").is_none()),
"no action claims a digitalSourceType: a decoder cannot see past the codestream"
);
}
}

View file

@ -204,6 +204,14 @@ pub mod reconstruction_binding;
#[cfg(feature = "provenance")]
pub mod energy_binding;
/// Writes a real, signed C2PA manifest carrying those bindings.
///
/// [`energy_binding`] and [`reconstruction_binding`] define assertion *shapes*; this
/// emits them into an actual manifest, and enforces the one rule the C2PA SDK cannot —
/// that the two digests in a manifest must name the same decode.
#[cfg(feature = "c2pa_emit")]
pub mod c2pa_emit;
// wai.world.model — generative-world transport: a signed, replay-equivalent action
// trace (the world CausalOp log) + per-frame provenance. The wire carries the
// action trace + a declared model_id, not the pixels; the sink renders. The neural