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)
|
- 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"
|
||||||
|
|
|
||||||
|
|
@ -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
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)
|
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
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")]
|
#[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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue