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) - name: Integer rANS + int_synth + JPEG-AI mANS goldens (one machine → every machine)
working-directory: wai/byte-exact-conformance working-directory: wai/byte-exact-conformance
run: cargo test --release 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 > `c2pa.environmental-sustainability` assertion — supplying the acquisition
> class, the coverage, and the outcome binding the standard assertion has no > class, the coverage, and the outcome binding the standard assertion has no
> field for. Reference impl: the `energy_binding` module of `wai-rs` > 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 > [Reproducible-Reconstruction Binding](reconstruction-binding.md) and the
> acquisition protocol in [Energy Measurement](energy-measurement.md). > acquisition protocol in [Energy Measurement](energy-measurement.md).
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. > 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 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 it covers, and which reconstruction it describes — and can check the last of
those itself. 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) 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 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) ---- # ---- interactive worlds (optional, behind the `world` feature) ----
# extensions/interactive-worlds: WWLD container, signed CausalOps, # extensions/interactive-worlds: WWLD container, signed CausalOps,
# deterministic fixed-point simulation, replay-equivalence conformance. # deterministic fixed-point simulation, replay-equivalence conformance.
@ -141,6 +148,10 @@ score = ["world"]
# lineage receipt for opaque captured content WAI does not reconstruct. # lineage receipt for opaque captured content WAI does not reconstruct.
# Reuses the JWP Merkle + Ed25519; no codec/ML deps. # Reuses the JWP Merkle + Ed25519; no codec/ML deps.
provenance = ["world", "dep:sha2"] 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 # wai.world.model — generative-world transport: a signed, replay-equivalent action
# trace (reuses the world CausalOp log) + per-frame provenance. The neural sibling # 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 # 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")] #[cfg(feature = "provenance")]
pub mod energy_binding; 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 // wai.world.model — generative-world transport: a signed, replay-equivalent action
// trace (the world CausalOp log) + per-frame provenance. The wire carries the // 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 // action trace + a declared model_id, not the pixels; the sink renders. The neural