wai: actually write the C2PA manifest the energy binding describes #79
6 changed files with 2946 additions and 46 deletions
60
.github/workflows/wai-conformance.yml
vendored
60
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
2120
wai/wai-rs/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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
688
wai/wai-rs/src/c2pa_emit.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue