wai.edge.derivation: the deriving edge — derive, publish under its own receipts, chain to the source, carry provenance #379

Merged
dcharlot merged 11 commits from wai/deriving-edge into main 2026-10-21 10:18:45 -04:00
30 changed files with 22901 additions and 583 deletions

View file

@ -110,6 +110,10 @@ on:
# codes the reference reports, c2pa_live_verify.py reads that table, and the registry's
# wire identifiers test reads its sections.
- 'wai/extensions/live-provenance.md'
# tests/derive_edge.rs requires this extension's §9 to list the codes the reference
# reports and its §4 the derivations, edge_verify.py reads both tables, and the
# registry's wire identifiers test reads its sections.
- 'wai/extensions/deriving-edge.md'
- 'wai/wai-web/**'
# The audio provenance check below reads the corpora above with this tool.
- 'wai/tools/wai_audio_provenance_check.py'
@ -780,8 +784,8 @@ jobs:
# live_metering_verify.py (the live-metering corpus), energy_steering_verify.py (the
# energy-steering corpus), and live_moderation_verify.py (the live-moderation corpus), and
# e2ee_verify.py (the end-to-end encrypted rooms corpus), live_watermark_verify.py (the
# forensic-watermark corpus), live_ads_verify.py (the live-ads corpus) and
# c2pa_live_verify.py (the live-provenance corpus).
# forensic-watermark corpus), live_ads_verify.py (the live-ads corpus), c2pa_live_verify.py
# (the live-provenance corpus) and edge_verify.py (the deriving-edge corpus).
# Each exits non-zero on any failure or any difference, and the step fails with it.
# One platform: what they check is the reading of the text, not the architecture.
independent-verifiers:
@ -1151,6 +1155,30 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features live_watermark --lib live_watermark::)
(cd wai-rs && cargo test --no-default-features --features live_watermark --test live_watermark)
python3 -I live-conformance/live_watermark_verify.py live-conformance/watermark
# wai.edge.derivation (extensions/deriving-edge.md): the reference module's unit tests
# (the operations pixel by pixel, which operations the registry's composition rule makes
# exact, the check each derivation takes, a record's reading rule by rule, its round trip
# and signature, the pinned output manifest, provenance items and AI-origin labels, the
# C2PA stores embedded in a still), the still writer and reader the module
# shares with wai-pack (png_still), and tests/derive_edge.rs (vectors.json regenerates
# byte for byte from its fixed seeds and no other file is there, wai-rs gives every vector
# its stated outcome, §9 lists exactly the reference's codes and §4 its derivations, and
# the reference edge wai_edge, run twice over a clip, makes a chain of two hops that
# verifies and is refused once a byte changes); then the corpus's second implementation,
# edge_verify.py, written from the extension's text and sharing no code with wai-rs,
# which reads receipts and writes stills itself, re-executes every replicate hop,
# requires every mutated copy of the corpus to fail, and drops each of its own checks in
# turn and requires the corpus to catch the weakened verifier; standard library only,
# run isolated (-I).
- name: deriving edge · wai-rs and edge_verify.py
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features derive_edge --lib derive_edge::)
(cd wai-rs && cargo test --no-default-features --features derive_edge --lib png_still::)
(cd wai-rs && cargo test --no-default-features --features derive_edge --test derive_edge)
python3 -I edge-conformance/edge_verify.py edge-conformance
# The quantum corpus's second implementation (quantum-sim extension), integers only:
# it derives the circuit/1 phase table by its recurrence and the circuit2 table by an
# interval-certified procedure, and requires both literal tables and their digests,

View file

@ -6,6 +6,64 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### `wai.edge.derivation`: an edge that derives, under its own receipts, chained to its source
- **The extension** ([deriving-edge](extensions/deriving-edge.md)) registers the record
`wai.edge.derivation`. An edge evaluates an operation of derived-tracks (`wai.derive.*`) over
8-bit RGB samples, rebuilds an object at a lower rung for a sink that lacks its capability (a
frame of a `wai.video.int_motion` clip as a `wai.image.png` still or a `wai.image.jpeg`
picture), or explodes a `WAI2` component into its `WAI1` envelope. It alters nothing it
received: each output is a new object, published in a group of the edge's own track under its
key, and its manifest is pinned, with `derived_from` naming a single input.
- **The record** names each input by content hash, the group receipt that carries it, the frame
it was read at, the digest of its samples and the record that derived it where another edge
did; the derivation, its parameters and the encoder where one is not pinned; the output by
content hash, receipt, size and samples digest; and the energy with its acquisition class,
which must agree with the output's object receipt. It is JSON under jwp-receipts §2.4.3's
encodings, signed over its canonical form and identified by a BLAKE3 hash.
- **The check.** `replicate` when every step is pinned: the inputs' reading (packaging §5's
still reader, which here refuses a chunk that could state a colour representation; the
`WIV1` keyframe-0 decode), an operation exact under derived-tracks §2 (every registered one
but scaling, by the registry's own composition rule), and the still writer of packaging §5
or SPEC §7.1's explode. A verifier holding the inputs re-executes the hop and compares the
output byte for byte. `attest` otherwise: the inputs and every pinned step before the
attested one are still checked.
- **Verification** follows a derived object hop by hop to the publishers a sink subscribed to:
each record read and its signature checked, its output bound to the edge's receipts, its
inputs to theirs, each derived input to the record that derived it, each replicate hop
re-executed where its inputs are held, and each edge's authority, `edge/derive`, from the
origins of the sources its chain reaches (registered in jwp-receipts §7.5). A sink's policy
may refuse attested hops, require re-execution, or require authority. The facts state each
hop's check, how it was checked, its energy and authority, the sources reached, and the
energy per acquisition class, never summed across classes.
- **Provenance carried forward.** A record lists, by digest, every provenance item of every
input: the claims about it, the C2PA Manifest Stores bound to it or embedded in it (a PNG's
`caBX` chunk, C2PA 2.4 §A.3.2) and every item the records below it carry, each in a system or
a personal compartment, read in the split California Business and Professions Code
§22757.1 makes (AB 853, Statutes of 2025, Chapter 674, whose §22757.3.1 forbids large online
platforms to knowingly strip system provenance from 1 January 2027). A hop that leaves one out
is refused (`provenance_stripped`), and the sink holds every item's digest from the subject's
record alone. This revision carries personal items as it carries system ones; redaction under
a stated rule is to come.
- **An AI-origin label** (`assist`, `generate`) on every input and output, which
may rise along a chain and never fall (`synthesis_dropped`); a step that synthesizes content
labels its output.
- **Refusals** from `record_malformed` to `attested_refused`, among them a derivation not
registered, inputs that do not verify, a hop passing an edge's output off as a source object
(`chain_skips_hop`), an output that does not match its recomputation (`replicate_mismatch`),
and an edge the source's origin does not list (`edge_not_authorised`).
- **Reference and corpus.** `wai-rs` feature `derive_edge` (`src/derive_edge.rs`), and the
reference edge `wai_edge`, which derives from a bundle of objects and receipts after verifying
each input, and verifies a derived object back to its source. The still writer and reader of
packaging §5 move from `wai-pack` into `wai-rs` (`png_still`), which `wai-pack` re-exports.
`edge-conformance/`, written from fixed seeds by `wai_edge_vectors`: replicate hops of every
operation, rebuilt stills, an explode and chains of several hops, attested hops, policies, and
controls for every reason. Checked by `tests/derive_edge.rs`, which also runs the reference
edge twice and verifies the chain it makes, and by `edge-conformance/edge_verify.py`, a second
implementation that shares no code with `wai-rs`, re-executes every replicate hop, requires
every mutated copy of the corpus to fail and the corpus to catch it with each of its own
checks dropped. The registry lists the record and the document kind `wai-derivation`.
### `wai.live.provenance`: C2PA 2.4 live video provenance for room tracks and takes, tied to the JWP receipts
- **The extension** ([live-provenance](extensions/live-provenance.md)) registers the record

View file

@ -43,8 +43,8 @@ Every registered string has one kind.
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee`,
`wai.live.watermark`, `wai.live.ads` and `wai.live.provenance`; timed text,
`wai.text.timed`).
It names no payload format.
`wai.text.timed`; the deriving edge's record, `wai.edge.derivation`). It names no payload
format.
A derivation or record string is not a payload capability, so it is not
dispatchable (SPEC §4): dispatch never takes an envelope's capability that names
@ -184,6 +184,7 @@ and a role are fixed once registered.
| `wai.derive.rotation` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
| `wai.derive.scaling` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
| `wai.derive.srgb_fill` | derivation | registered | — | — | patent-pool | — | — | §5 Prior artifacts, general tensors and derivation operations |
| `wai.edge.derivation` | record | registered | — | — | — | — | — | §5 Prior artifacts, general tensors and derivation operations |
| `wai.feed.state` | payload | registered | interactive | standalone | royalty-free | standard-defined | — | §5 Interactive, temporal & computed content |
| `wai.film.linear` | payload | registered | video | standalone | royalty-free | standard-defined | — | §5 Interactive, temporal & computed content |
| `wai.film.reel` | payload | registered | video | standalone | royalty-free | standard-defined | — | §5 Interactive, temporal & computed content |
@ -359,6 +360,7 @@ and a role are fixed once registered.
| `urn:wai:event:1` | catalog | defined | live-events §8.1 |
| `wai` | catalog | defined | moq-streaming-format §3 |
| `wai-c2pa` | catalog | defined | live-provenance §10.2 |
| `wai-derivation` | document-kind | defined | deriving-edge §5.1 |
| `wai-key-status` | document-kind | defined | jwp-receipts §7.9 |
| `wai-module-grant` | document-kind | defined | jwp-receipts §7.9 |
| `wai-module-grants` | document-kind | defined | http-delivery §8.4 |

View file

@ -595,7 +595,8 @@ kind:
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`,
`wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee`,
`wai.live.watermark`, `wai.live.ads` and `wai.live.provenance`, and timed text,
`wai.text.timed`), which names no payload format.
`wai.text.timed`; and an edge's derivation record, `wai.edge.derivation`), which names no
payload format.
Only payload, companion and refinement capabilities have a media class, a
fidelity tier, a replicate class and a role. A derivation or record string is not a payload
@ -800,6 +801,30 @@ reproducibility claim over a derived sample needs. The geometric conditions a
tier lists for each operation are read from the operation's title: clause 8 of
ISO/IEC 23001-16, which defines each operation, was not read · [derived-tracks](extensions/derived-tracks.md).
An edge that derives — `wai.edge.derivation`: an edge evaluates one of these operations over
8-bit RGB samples, rebuilds an object at a lower rung for a sink that lacks its capability (a
frame of a `wai.video.int_motion` clip written as a `wai.image.png` still, or as a
`wai.image.jpeg` picture), or re-packages one without decoding it (a `WAI2` component exploded
into its `WAI1` envelope, §7.1). It never alters what it received: it publishes each output as a
new object, in a group of its own track under its own key, and signs a derivation record that
names each input by its content hash, the group receipt that carries it and the record that
derived it where another edge did; the derivation and its parameters; the output by its content
hash and receipt; and the energy the derivation took, with its acquisition class. The record's
check is `replicate` when every step is pinned (the inputs' reading, an operation exact under
derived-tracks §2, and the still writer of packaging §5 or this section's explode), so any
verifier holding the inputs re-executes it and compares the output byte for byte; it is
`attest` otherwise (a JPEG encode, a resampling), and the attested step rests on the edge's
signature. A sink follows its object hop by hop back to the publishers it subscribed to,
refusing a hop it cannot follow, a hop that passes an edge's output off as a source object, an
output that does not match its recomputation and an edge its source's origin does not list
with `edge/derive`; its policy may refuse attested hops, require every replicate hop to be
re-executed, or require every hop to be authorised. Every record carries forward, by digest, the
provenance its inputs had (the claims about them, the C2PA Manifest Stores bound to or embedded
in them, and what the records below it carry), each item in a system or a personal compartment,
and an AI-origin label that may rise along a chain and never fall; a hop that strips an item or
lowers a label is refused:
[`extensions/deriving-edge.md`](extensions/deriving-edge.md).
### Interactive, temporal & computed content (extensions)
Media that consumes input, moves over time, or is *computed* from a

View file

@ -0,0 +1,55 @@
# edge-conformance — the deriving edge
The corpus of [`extensions/deriving-edge.md`](../extensions/deriving-edge.md) §10, in one file,
`vectors.json`, written from fixed seeds by `wai_edge_vectors gen` (`wai-rs`, feature
`derive_edge`). Every key is SHA-256 of a label, every picture a function of its coordinates,
every time a constant, and Ed25519 signing is deterministic (RFC 8032).
**Provenance.** `wai_edge_vectors gen` (`wai-rs`, feature `derive_edge`) writes every file of this
corpus, and its only input is the generator's own code: keys from SHA-256 of labels, pictures as
functions of their coordinates, constant times. No content comes from outside this repository.
**Derivation equivalence.** Each vector is what a verifier holds (§7.1) and the outcome §7 gives
the record at `subject`:
- `records`: record texts, as they were sent;
- `receipts`: the JWP group and object receipts (jwp-receipts §2.4.1, §2.4.2): the publishers'
and each edge's;
- `objects`: the envelopes held, each under the content hash it was given by, as lowercase
hexadecimal; a vector that holds an object altered keeps it under the name it was given;
- `sources`: the publisher keys the verifier subscribed to;
- `heads`: for each origin it holds one for, the head revision of that origin's key log;
- `claims` and `c2pa`: the provenance it holds (§6.1): claim texts, and C2PA Manifest Stores in
hexadecimal by the content hash of the object each is bound to. The corpus's stores are byte
strings standing for one: a verifier names a store by its SHA-256 and parses none;
- `policy`: `attested`, `recompute` and `authority` (§7.1);
- the outcome: `refuse` with the `reason` and its place `at` (§7.2), or `accept` with the
`facts` of §7.4 — the subject's record hash, each hop in the order first reached with its
record hash, signer, derivation, check, how it was checked, its energy, its authority and its
AI-origin label, each source object reached, the energy per acquisition class with the number
of unmetered hops, and the provenance items the subject's record carries.
A conforming verifier gives every vector the outcome, the reason, the place and the facts it
states.
The parties: a publisher whose `signer_id` names the origin `https://source.example`, and
whose key log there lists edges `edge-a` and `edge-b` with `edge/derive`, `edge-c` with
`delivery` alone and `edge-d` revoked; a second publisher whose `signer_id` is a `did:key`, so
its objects name no origin. The publisher signs, in one group, an integer clip
(`wai.video.int_motion`, three frames of 8 × 6), three stills and a smaller one, a still whose
PNG holds an ancillary chunk, a `WAI2` envelope of a still and a JPEG picture, a still whose PNG
embeds a manifest store in a `caBX` chunk, with a soft-binding claim about it and a store beside
it in the personal compartment, and a still whose provenance says it was generated.
**Checks.**
- `wai_edge_vectors verify edge-conformance` and `tests/derive_edge.rs`: the file regenerates
byte for byte and no other file is here but this README and `edge_verify.py`; `wai-rs` gives
every vector its outcome; every reason of §9 appears; §9 lists exactly the reference's codes
and §4 exactly its derivations; and the reference edge, `wai_edge`, run twice over a clip,
makes a chain of two hops that verifies, and that is refused once a byte of it changes.
- `python3 -I edge_verify.py .` (`edge_verify.py`, standard library only): the second
implementation, written from the extension's text and sharing no code with `wai-rs`. It
re-executes every replicate hop, writing each still itself, requires every vector's outcome,
requires every mutated copy of the corpus to fail, and requires the corpus to catch the
verifier with each of its own checks dropped in turn.

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -249,7 +249,8 @@ A receipt over a derived sample SHOULD carry:
The energy of evaluating the graph is measured and attributed as for any other
work ([energy-measurement](energy-measurement.md)). The tier and the energy are
separate claims, and neither implies the other. This extension defines the
content of such a receipt, not a field layout.
content of such a receipt, not a field layout; [deriving-edge](deriving-edge.md)
§5 gives one, for the derivations an edge makes and publishes.
## 5. Reference implementation
@ -291,8 +292,12 @@ content of such a receipt, not a field layout.
input nobody listed and be reported exact;
- `derivation_license()`.
The crate evaluates no derivation operation, and has no receipt emitter. Rules 3
and 4 of §3, and the receipt content of §4, are therefore not implemented.
`codecs::derivation` evaluates no derivation operation and emits no receipt, so
rules 3 and 4 of §3, and the receipt content of §4, are not implemented there.
The deriving edge's module (`derive_edge`, feature `derive_edge`;
[deriving-edge](deriving-edge.md)) evaluates the operations that extension
registers, over 8-bit RGB stills read at one frame each, reads which of them are
exact from `derived_sample_tier`, and signs a record carrying §4's content.
`ExecutedOp` carries only what the tier depends on (the conditions met and the
inputs read), not the parameter values, span or input hashes a claim binds. The
`wai` command-line tool does not yet list these operations.

View file

@ -0,0 +1,719 @@
# WAI Extension: Deriving Edge
> Status: Draft. Registers the record `wai.edge.derivation`: an edge that derives new objects
> from objects a publisher delivered, publishes each one under receipts of its own, and signs
> a **derivation record** that chains it to its source. An edge may evaluate a `wai.derive.*`
> operation of [derived-tracks](derived-tracks.md) over decoded samples, rebuild an object at a
> lower rung for a sink that lacks the capability it was coded in (decode a
> `wai.video.int_motion` clip and write a frame of it as a `wai.image.png` still), or
> re-package an object without re-encoding it (explode a `WAI2` component into its `WAI1`
> envelope). The record names each input by its content hash and the delivery receipt that
> carries it, and by the record that derived it where another edge did; the derivation and its
> parameters; the output by its content hash and receipt; and the energy the derivation took,
> with its acquisition class. Its **check** says how a verifier holds the edge to it:
> `replicate`, when every step is pinned, so any verifier holding the inputs re-executes the
> derivation and compares the output byte for byte; or `attest`, when a step is not (a lossy
> classical encode, a resampling), so the output rests on the edge's signature. A sink verifies
> its object back to its source, hop by hop, and its policy may refuse attested hops. Each
> record carries forward, by digest, every provenance item its inputs had — the claims about
> them, the C2PA Manifest Stores bound to or embedded in them, and what their own records carry —
> in a system or a personal compartment, and an AI-origin label that no hop may lower; a hop
> that omits an item or lowers a label is refused. Relays
> still forward every envelope and receipt unaltered ([jwp-receipts](jwp-receipts.md) §4): an
> edge alters nothing it received; what it derives is a new object of its own. No new
> transport. The reference implementation is the `derive_edge` module of `wai-rs` (feature
> `derive_edge`) and its reference edge, `wai_edge`; the corpus is
> [`edge-conformance/`](../edge-conformance/), and its second implementation is
> `edge-conformance/edge_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision: the WAI texts this one cites; RFC 8032 and RFC 8785; California
> Assembly Bill 853 (Statutes of 2025, Chapter 674), as chaptered, for Business and Professions
> Code §22757.1 (n), (o) and (p) and §22757.3.1; and the C2PA Technical Specification, version
> 2.4, §A.3.2 (a manifest store in PNG) and §18.15 (actions). The operations' names are
> ISO/IEC 23001-16:2021's, as [derived-tracks](derived-tracks.md) registers them; this extension
> defines its own parameters and arithmetic for them (§4.2), and does not read the parts of that
> standard derived-tracks did not read.
## 1. Scope
In scope: what an edge may derive and what it must not do (§3); the derivations this revision
registers, how each reads its inputs, what it computes and how it writes its output, and which
check each takes (§4); the derivation record, its signature and its identity (§5); how an edge
publishes what it derives, carries its inputs' provenance forward and keeps their AI-origin
label (§6); verification of a derived object back to its source (§7); who may sign a record
(§8).
Out of scope: a transport, and how a record reaches a sink, which is the carrier's, as it is for
receipts (jwp-receipts §1); how a sink asks an edge for a derivation, and which derivations an
edge offers; how an edge measures its energy, which [energy-measurement](energy-measurement.md)
governs; presentation, which stays with the sink ([derived-tracks](derived-tracks.md) §1); and
any derivation this revision does not register.
Relationship to the core spec: `wai.edge.derivation` is a **record** ([SPEC](../SPEC.md) §5): a
signed record a WAI tool emits and verifies. It names no payload format and is not
dispatchable. A derived object is an ordinary envelope of its own capability, dispatched by
SPEC §4 like any other.
Relationship to [jwp-receipts](jwp-receipts.md): this extension changes no receipt. A relay
forwards what it receives as §4 there requires, byte for byte, and §4.3's rules stand: a relay
re-signs no receipt and rewrites no envelope. An edge that derives is a relay for what it
forwards and a **publisher** for what it derives: each output is a new object with its own
content hash, in a group the edge signs with its own key (§6). The derivation record is the
separate, parallel statement §4.3 there asks of a relay that attests, and §7.5 there registers
its authority (§8).
Relationship to [derived-tracks](derived-tracks.md): an operation of §4.2 is that extension's
operation of the same capability string, executed under conditions this profile fixes, so that
derived-tracks §3 gives the output's tier from its inputs' (§4.5). The record carries the
content derived-tracks §4 lists for a receipt over a derived sample, in a layout of its own:
the executed operation, its parameters, the frame each input was read at, the inputs by
content hash, and, where the check is `replicate`, the output.
Relationship to SPEC §7.1: an output of one input carries `derived_from`, naming that input as
its motif (§4.3). A sink presents it as what it is, a derived object; it does not reconstruct
its motif.
## 2. Terms
- **Edge**: a party that derives objects from objects it received and publishes them; named by
its Ed25519 key (RFC 8032).
- **Derivation**: one of §4's, evaluated once: a set of inputs, its parameters, one output.
- **Input**, **output**: the objects a derivation reads and the object it writes, each a whole
`WAI1` or `WAI2` envelope (SPEC §2, §7.1).
- **Record**: the derivation record of §5. Its **hash** is its identity (§5.2).
- **Source object**: an input whose delivery receipt is signed by a key the verifier takes as a
source: a publisher it subscribed to (§7.1).
- **Hop**: a record. The **chain** of a derived object is its record, and, for each of that
record's inputs that an edge derived, the chain of that input, down to source objects.
- **Samples**: an image as the canonical `RGB8` buffer of
[reconstruction-binding](reconstruction-binding.md) §2: row-major, three bytes a pixel, no row
padding. A pixel is addressed (x, y), x to the right and y down from the top-left. The
**samples digest** is SHA-256 (FIPS 180-4) over the buffer.
- **Content hash**: jwp-receipts §2.1. A group receipt's **link hash**: jwp-receipts §3 step 3.
The record is JSON under the encodings of jwp-receipts §2.4.3: lowercase hexadecimal byte
strings, unsigned integers of at most 2^53 − 1 in plain decimal, no other number, no boolean,
terms and date-times as defined there, no member named twice. JCS is the canonical form of
that section (RFC 8785).
## 3. What an edge may derive
### 3.1 Three kinds
1. **Evaluate an operation.** Read each input as samples (§4.1), evaluate one operation of §4.2
over them, and write the result in an image capability (§4.3).
2. **Rebuild at a lower rung.** Read an object coded in a capability a sink lacks, and write it
in a capability of SPEC §5's mandatory floor or recommended set: a frame of a
`wai.video.int_motion` clip, as a `wai.image.png` still or a `wai.image.jpeg` picture. A
rebuild is an evaluation of `wai.derive.identity` over one frame (§4.2): the read and the
write change the capability, and the operation changes no sample. It serves a sink at which
the object is inert (SPEC §4 step 6).
3. **Re-package.** Change the container without decoding: `wai-edge:explode` writes one
component of a `WAI2` envelope as its exploded `WAI1` envelope (SPEC §7.1, *Exploding a
component*; §4.4). The output's payload is the component's bytes.
### 3.2 What an edge does not do
An edge **MUST NOT**:
- alter an envelope or a receipt it received, or forward one under its own signature
(jwp-receipts §4.3);
- publish an output under any key but its own, or in a group of another key's track (§6);
- present an output as the object it was derived from: an output has its own content hash and
its own receipts, and the source's receipts never cover it;
- sign a record over a derivation this revision does not register, or over an object it did not
derive;
- sign `replicate` for a derivation whose check is `attest` (§4.5);
- leave out of its record a provenance item an input had, or label its output lower than an
input (§6.1, §6.2).
### 3.3 What an edge derives from
An edge derives only from objects it has verified: each input's delivery receipts verify
(jwp-receipts §3 steps 1–5), its envelope hashes to its content hash, and, for an input another
edge derived, its chain verifies by §7. It names in its record, for each input, the record that
derived it, so that a sink can follow the chain (§7.3).
## 4. The derivations
| derivation | inputs | parameters (§4.2) | output capability | check (§4.5) |
|---|---|---|---|---|
| `wai.derive.identity` | 1 | none | `wai.image.png`, `wai.image.jpeg` | `replicate` for `wai.image.png` with no encoder; else `attest` |
| `wai.derive.crop` | 1 | `x`, `y`, `w`, `h` | `wai.image.png`, `wai.image.jpeg` | as identity |
| `wai.derive.roi_selection` | 1 | `x`, `y`, `w`, `h` | `wai.image.png`, `wai.image.jpeg` | as identity |
| `wai.derive.rotation` | 1 | `quarter_turns` | `wai.image.png`, `wai.image.jpeg` | as identity |
| `wai.derive.mirror` | 1 | `flip` | `wai.image.png`, `wai.image.jpeg` | as identity |
| `wai.derive.grid_composition` | `columns` × `rows` | `columns`, `rows` | `wai.image.png`, `wai.image.jpeg` | as identity |
| `wai.derive.overlay_composition` | 1 to 16 | `width`, `height`, `fill`, `at` | `wai.image.png`, `wai.image.jpeg` | as identity |
| `wai.derive.scaling` | 1 | `width`, `height` | `wai.image.png`, `wai.image.jpeg` | `attest` |
| `wai-edge:explode` | 1 | `component` | the component's capability | `replicate` |
A derivation is named by the string in its first column. The `wai.derive.*` strings are
derived-tracks' registered operations; `wai-edge:explode` is a term of this extension and not a
capability string. The other operations derived-tracks registers are not derivations of this
revision: `wai.derive.srgb_fill` reads no input, so an output of it chains to no source, and
`wai.derive.dissolve` has a time structure of its own, which an input read at one frame does
not give it.
### 4.1 Reading an input
An evaluation reads each input at one frame, the input's `frame` (§5.1), as samples:
- `wai.image.png`: frame 0, read by the still reader of [packaging](packaging.md) §5 (*A piece's ladder, and stills*) as 8-bit
RGB; the envelope **MUST** hold no chunk but `IHDR`, `IDAT`, `IEND` and `caBX`. An ancillary
chunk can state a colour representation the samples do not carry (`cHRM`, `gAMA`, `iCCP`,
`sRGB` and others), and this profile's representation is the samples alone. A `caBX` chunk
holds a C2PA Manifest Store (C2PA 2.4 §A.3.2): it describes the content and changes none of
its samples, and the record carries it forward (§6.1).
- `wai.video.int_motion`: the `WIV1` payload with keyframe 0 (`integer-payloads.md` §3),
decoded in full by its pinned decode; frame `frame` of the clip, which **MUST** be one of its
frames. A clip with keyframe 1 decodes through a parameter set, and is not read here.
An input of either capability holds `decode-equivalence` (determinism-tiers §2): every
conforming reader reads the same samples. An input that does not read as its record states it
— another capability, a frame it does not have, a payload its reader refuses, a chunk this
section excludes — is refused (`input_unreadable`).
### 4.2 The operations
Each operation computes its output samples from its inputs' samples and its parameters by the
rule below, and no other arithmetic. *W* × *H* is an input's width and height; out(i, j) is the
output pixel at (i, j) and in(x, y) an input's, all three bytes of it. An output's width and
height are each at least 1, and its samples at most 2^26 bytes; a parameter the inputs cannot
satisfy (a region outside an input, an output past that bound) is refused (`params_invalid`)
when the inputs are evaluated (§7.2 step 5).
- `wai.derive.identity`, no parameter (`{}`): out = in.
- `wai.derive.crop` and `wai.derive.roi_selection`, `{ "x", "y", "w", "h" }`: `w` and `h` at
least 1, `x + w ≤ W`, `y + h ≤ H`; the output is `w` × `h` and out(i, j) = in(x + i, y + j).
This profile emits an ROI at its own size, so the two operations compute the same samples;
each is named by its own string.
- `wai.derive.rotation`, `{ "quarter_turns" }`, 1, 2 or 3 quarter turns clockwise. For 1 and 3
the output is *H* × *W*, for 2 *W* × *H*; out(i, j) is in(j, H − 1 − i) for 1,
in(W − 1 − i, H − 1 − j) for 2 and in(W − 1 − j, i) for 3.
- `wai.derive.mirror`, `{ "flip" }`, `left-right` or `top-bottom`: out(i, j) is in(W − 1 − i, j)
or in(i, H − 1 − j).
- `wai.derive.grid_composition`, `{ "columns", "rows" }`, each at least 1, over exactly
`columns` × `rows` inputs of one size *W* × *H*: input *k* fills the cell at column
*k* mod `columns` and row ⌊*k* / `columns`⌋ of a `columns`·*W* × `rows`·*H* output. Inputs of
different sizes are refused (`params_invalid`).
- `wai.derive.overlay_composition`, `{ "width", "height", "fill", "at" }`: a canvas of
`width` × `height`, each at least 1, every pixel `fill` (six lowercase hexadecimal digits, the
red, green and blue bytes); then each input *k*, in order, written over it at its own size
with its top-left at `at[k]` (`{ "x", "y" }`, one entry per input), opaquely: out(x + i, y + j)
= in_k(i, j) for every pixel of the input that falls on the canvas. Pixels off the canvas are
dropped.
- `wai.derive.scaling`, `{ "width", "height" }`, each at least 1: the input resampled to
`width` × `height`. This revision pins no resampling arithmetic, so the samples are the
edge's (§4.5). The reference edge takes the nearest sample,
in(⌊i · W / `width`⌋, ⌊j · H / `height`⌋); a verifier does not hold an edge to it.
Under these rules every operation but scaling is **exact** for the instance it executes, in the
sense of derived-tracks §2: its inputs and output are 8-bit RGB with no stated colour
information, and the canvas fill is a constant the record names (same-representation); it
copies samples and computes none (copies-only); each input is one frame, and the parameters
have one value (time-invariant); a region or a cell lies on the sample grid, a rotation is a
multiple of 90° over one resolution, a reflection maps the grid onto itself, and a composed
input is placed at its own size at integer offsets, opaquely. Scaling computes new sample
values, and derived-tracks registers it untiered.
### 4.3 Writing the output
The output of an evaluation is a `WAI1` envelope whose manifest is, with no other bytes and no
whitespace, members in this order:
```
{"wai":"1.0","media":"image","intent":"replicate","model_requirement":{"capability":C},
"conditioning":{"kind":K},"derived_from":{"motif":M,"motif_capability":MC,"renderer":R,
"settings":S},"target":{"frames":1,"h":HEIGHT,"w":WIDTH}}
```
(shown here on three lines). `C` is the output capability and `K` its last dot-separated
segment (`png`, `jpeg`); `HEIGHT` and `WIDTH` are the output's. `derived_from` (SPEC §7.1) is
written exactly when the derivation has one input: `M` and `MC` are that input's content hash
and capability, `R` is the derivation followed by `@1` when the record names no encoder and the
encoder's `id` otherwise, and `S` is `BLAKE3("wai:render-settings\x01" ‖ JCS(settings))` with
`settings` the object `{"derivation", "encoder", "frame", "params"}` of the record's members of
those names and the input's `frame`. An output of several inputs carries no `derived_from`: it
presents several motifs, and its record names them all. Strings are written as JSON strings,
integers in decimal; nothing in them needs an escape.
The payload:
- `wai.image.png`, the record naming no encoder: the samples as packaging §5's still writer
writes them, every byte a pure function of the samples;
- otherwise the output of the encoder the record names, run with the settings it states: a PNG
of the samples for `wai.image.png`, a baseline JPEG of them (ITU-T T.81, SPEC §5) for
`wai.image.jpeg`. This revision pins no JPEG writer, so a `wai.image.jpeg` output always names
one.
### 4.4 Re-packaging
`wai-edge:explode`, `{ "component" }`, reads one input, a `WAI2` envelope, and writes the
exploded envelope of its entry number `component` (from 0), exactly as SPEC §7.1 defines it:
the entry's `envelope_manifest` where it carries one, and otherwise the manifest that section
assembles from the `WAI2` manifest's members, over the entry's rendition bytes. Nothing is
decoded and no sample is read. The output's capability is the entry's, and the input's
`capability` in the record names it too. An entry that cannot be exploded, or a `component`
past the last entry, is refused (`input_unreadable`). A component and its exploded envelope are
the same object for every rule that names an object by content hash (SPEC §7.1), so an exploded
output is the object a sink would have taken from the `WAI2`, now carried on its own, under the
edge's receipts.
### 4.5 The check
A derivation's check is `replicate` exactly when every step of it is pinned: the reading of its
inputs (§4.1 pins every reading this revision has), its operation, which is exact (§4.2), and
its writer, which is packaging §5's still writer for a `wai.image.png` output whose record
names no encoder, or SPEC §7.1's explode. Otherwise its check is `attest`:
- **replicate**: a verifier holding every input re-executes the derivation and requires the
output envelope, byte for byte (§7.2 step 5). Over inputs at `decode-equivalence` the output's
samples hold `decode-equivalence` too (derived-tracks §3 rule 1), and so does the output: PNG
is lossless.
- **attest**: the step that is not pinned rests on the edge's signature. A verifier still checks
every input, and every step before the first that is not pinned: the inputs' samples, and,
where the operation is exact, the samples the edge gave its encoder. For any
`wai.image.png` output, attested or not, it checks that the output's samples are the ones
the record states (§7.2 step 3). It never checks the attested step itself.
A record's `check` **MUST** be its derivation's (`check_mismatch`): one derivation has one
check, and a sink reads from the record what it is relying on.
## 5. The derivation record
### 5.1 Members
A record is a JSON object with exactly these members, under §2's encodings:
| member | type | meaning |
|---|---|---|
| `kind` | string | `"wai-derivation"`. |
| `derivation` | term | A derivation of §4. |
| `params` | object | Its parameters, exactly the members §4.2 or §4.4 gives it, each an integer, a string, or for `at` an array of `{ "x", "y" }`. |
| `encoder` | object or `null` | `null` where the output is written by a writer this revision pins (§4.3, §4.4); otherwise `{ "id": term, "settings": object }`, the encoder the edge ran and the settings it ran it with, its statement. |
| `check` | string | `replicate` or `attest` (§4.5). |
| `inputs` | array | 1 to 16 inputs, in the order the derivation reads them (below). |
| `output` | object | The output (below). |
| `energy` | object or `null` | `null` when the derivation was unmetered. Otherwise `{ "joules_micro", "energy_provenance", "energy_uncertainty", "energy_counter_filtering", "energy_calibration_ref" }`, each as jwp-receipts §2.4.5 writes it: the energy the edge spent on this derivation, reading, operating and writing, with its acquisition class. |
| `provenance` | array | At most 1024 provenance items carried forward (§6.1, below), in ascending order of `input`, then `kind` as a string, then `digest`, none twice. |
| `synthesis` | string or `null` | The output's AI-origin label (§6.2): `assist`, `generate`, or `null` for none. |
| `issued_at` | date-time | When the edge signed the record. Self-asserted. |
| `signer_pubkey` | 32 B hex | The edge's key. |
| `signer_id` | string | The edge's identity string, as for a group receipt (jwp-receipts §2.4.1, §7.1). |
| `sig` | 64 B hex | §5.2. |
An input:
| member | type | meaning |
|---|---|---|
| `content_hash` | 32 B hex | The input's content hash. |
| `capability` | term | The capability the derivation read it as (§4.1), or, for `wai-edge:explode`, the capability of the entry exploded. |
| `receipt` | 32 B hex | The link hash of the group receipt that carries it. |
| `object_id` | integer | Its `object_id` in that group. |
| `frame` | integer or `null` | The frame it was read at (§4.1); `null` for `wai-edge:explode`. |
| `samples` | 32 B hex or `null` | The samples digest of what was read; `null` for `wai-edge:explode`. |
| `derivation` | 32 B hex or `null` | The hash of the record that derived it, or `null` for an object the edge received from its publisher. |
| `synthesis` | string or `null` | The input's AI-origin label (§6.2), as `synthesis` above. |
A provenance item:
| member | type | meaning |
|---|---|---|
| `input` | integer | The position, among the inputs, of the input it came with. |
| `kind` | string | `claim`: a claim about that input (jwp-receipts §2.4.3); `c2pa`: a C2PA Manifest Store bound to it. |
| `digest` | 32 B hex | A claim's link hash (jwp-receipts §2.4.3); a manifest store's SHA-256 over its bytes. |
| `compartment` | string | `system` or `personal` (§6.1). |
The output:
| member | type | meaning |
|---|---|---|
| `content_hash` | 32 B hex | The output's content hash. |
| `capability` | term | Its capability. |
| `receipt` | 32 B hex | The link hash of the edge's group receipt that carries it (§6). |
| `object_id` | integer | Its `object_id` in that group. |
| `width`, `height` | integer or `null` | Its size; `null` for `wai-edge:explode`. |
| `samples` | 32 B hex or `null` | The samples digest of the samples written: those the operation produced, which an attested encoder was given; `null` for `wai-edge:explode`. |
### 5.2 Signature and identity
```
sig = Ed25519(signer_key, "wai:derivation\x01" || JCS(record without "sig"))
record hash = BLAKE3("wai:derivation-link\x01" || JCS(record))
```
The signature is verified under jwp-receipts §3.1 step 3's strict rule. A record **SHOULD** be
sent in canonical form; relays forward it byte for byte.
### 5.3 Reading and rules
A reader reads a record and refuses at the first of these that holds:
1. It is not one JSON text of the shape §5.1 gives, with every member of the right type and
encoding, no member missing and none added, at any level, `kind` `"wai-derivation"`, and its
provenance items in their order, none twice, each naming an input the record has
(`record_malformed`).
2. Its `derivation` is not one of §4 (`derivation_unknown`).
3. Its `params` do not hold exactly the derivation's members, each in its range (§4.2, §4.4):
`w`, `h`, `width`, `height`, `columns` and `rows` at least 1; `quarter_turns` 1, 2 or 3;
`flip` one of its two values; `fill` six lowercase hexadecimal digits; `at` one entry per
input, each `{ "x", "y" }`; a size the parameters fix (a region's, a canvas's, a scaling's)
within 2^26 sample bytes. Or its `encoder` is `null` where §4.3
pins no writer (a `wai.image.jpeg` output), or not `null` for `wai-edge:explode`. Or its
output capability is not one the derivation writes (§4), or, for `wai-edge:explode`, not the
input's (`params_invalid`).
4. Its inputs are not the derivation's: another number of them (`columns` × `rows` for a grid),
an input of a capability §4.1 does not read, a `frame`, input `samples`, output `width`,
`height` or `samples` that is `null` for an evaluation or not `null` for an explode, or a
`frame` other than 0 for a `wai.image.png` input (`inputs_invalid`).
5. Its `check` is not its derivation's (§4.5) (`check_mismatch`).
6. Its `energy` breaks [energy-measurement](energy-measurement.md) §2: a figure of 0 beside a
class, the legacy `HwShunt`, a class that does not read, a hardware class without its
evidence or below the resolution floor (§2.3 there) (`energy_invalid`).
7. Its `synthesis` is lower than an input's (§6.2) (`synthesis_dropped`).
## 6. Publishing the output
An edge publishes each output on a track of its own, under its own key, as a publisher does
(jwp-receipts §2): a group receipt it signs and an object receipt for the output, whose
`content_hash` is the output's. The record's `output.receipt` and `output.object_id` name them.
- The group receipt's `signer_pubkey` is the record's (`output_receipt_mismatch`).
- The object receipt's `joules_micro` is the record's figure, 0 where `energy` is `null`. Where
the object receipt carries a class label (jwp-receipts §2.4.2), the label is the record's
class and evidence, or the unmetered marker where `energy` is `null`
(`energy_disagrees`). An edge **SHOULD** label its object receipts, and sign its groups over
the labelled payload.
- The output is a new object: its receipts bind it to the edge's key and nothing else. What
binds it to its source is the record.
An edge serving a derived object **MUST** offer its subscriber the record of each hop of the
object's chain, the delivery receipts each record names and the provenance items each carries
(§6.1), by the same carriage as the object's receipts. Without them a sink can verify the
object's delivery from the edge and nothing about where it came from. It forwards the source's
receipts unaltered (jwp-receipts §4.1).
### 6.1 Provenance carried forward
A derivation makes a new object, and nothing the source carried is in it unless the edge puts
it there: a still the pinned writer writes holds no chunk but its picture, and an explode writes
a new envelope. So a record carries its inputs' provenance forward, by reference.
The **provenance items** of an input are, of what the edge received:
- each claim (jwp-receipts §2.4.3) whose `subject_type` is `object` and whose `subject` is the
input's content hash, named by its link hash;
- each C2PA Manifest Store bound to the input, carried beside it or embedded in it (for
`wai.image.png`, the data of a `caBX` chunk, C2PA 2.4 §A.3.2), named by its SHA-256;
- for an input another edge derived, every item that edge's record carries, whatever input of
it the item came with.
A record **MUST** list every provenance item of every input, each with the input it came with
and its compartment, and an item that a record below it carries keeps that record's
compartment. Listing an item copies nothing of it: the record names it by digest, the edge
offers the item with the chain (§6), and the item's own signature, where it has one, is what
holds it to what it says. An item the record does not list, of any compartment, has been
stripped (`provenance_stripped`).
The **compartment** is the edge's reading of an item, in the split California Business and
Professions Code §22757.1 makes: **personal** where the item holds personal information, or
unique device, system or service information reasonably capable of being associated with a
particular user (§22757.1(n)), and **system** otherwise: information about the type of device,
system or service that made the content, or about its authenticity (§22757.1(p)). Information
inside a digital signature is not personal there (§22757.1(n)(2)), so a signature is system
provenance. A store embedded in an input the edge reads as system provenance unless it knows
otherwise.
This revision carries every item, personal ones included: an edge strips none, and a sink holds
the chain's items from the edge that served it. Redacting a personal item under a stated rule,
with a salted commitment that accounts for the removal so that it is never silent, is a later
revision's; until it is registered, a personal item is carried as a system one is. A C2PA
manifest an edge emits for its output names each manifest store of its inputs as an ingredient,
`parentOf` for a derivation of one input (C2PA 2.4 §18.15: `c2pa.transcoded` for a rebuild,
`c2pa.repackaged` for an explode); the reference edge emits none.
### 6.2 The AI-origin label
A record states, for each input and for its output, an **AI-origin label**: `null` where no
label is known, or, in rising order, `assist` (AI altered the content and generated none of it)
or `generate` (AI generated the content, or part of it).
A label may rise along a chain and never fall:
- an input another edge derived is labelled at least as that edge's record labels its output
(`synthesis_dropped`); a source object is labelled as its provenance states, read by the edge;
- the output is labelled at least as every input (§5.3 rule 7, `synthesis_dropped`), and at
least as the derivation's own step warrants: an edge whose step synthesizes content (a
learned upscaler behind an attested scaling, a generated frame) labels its output at least
`assist`, or `generate` where it generated content.
Every derivation this revision pins copies samples or containers and synthesizes nothing, so it
raises no label; an attested step is labelled by its edge, and a verifier checks that no label
falls, not what an attested step did. The two labels are the two highest classes of an
origin claim's order of steps, capture, convert, assist and generate, in which every derivation
this revision pins is a convert step; a later revision may carry such a claim as a provenance
item, name the lower classes, and map them onto the digital source types an export writes.
## 7. Verification
### 7.1 What a verifier holds
- **records**: record texts, the subject's among them, and the records of its chain that it
holds;
- **receipts**: group and object receipts (jwp-receipts §2.4.1, §2.4.2);
- **objects**: envelopes it holds, each by the content hash it was given under;
- **sources**: the publisher keys it subscribed to;
- **heads**: for each origin it holds one for, the head revision of that origin's key log
(jwp-receipts §7.2);
- **provenance**: the claims it holds (jwp-receipts §2.4.3), and the C2PA Manifest Stores it
holds bound to an object, by the object's content hash;
- **policy**, three members:
| member | values | default | meaning |
|---|---|---|---|
| `attested` | `accept`, `refuse` | `accept` | `refuse`: a chain with an `attest` hop is refused (`attested_refused`). |
| `recompute` | `when-held`, `required` | `when-held` | `required`: a `replicate` hop whose inputs are not all held is refused (`inputs_not_held`); `when-held`: it is accepted as `not-recomputed`. |
| `authority` | `when-given`, `required` | `when-given` | `required`: a hop that is not confirmed under §8 is refused (`edge_not_authorised`). |
A record that reads as a JSON text is held under its hash (§5.2), whether or not it then passes
§5.3, so that a hop that is held and broken is refused for what it breaks.
### 7.2 One record
The verifier verifies the subject's record by these steps, and an input's record by the same
steps when step 4 reaches it. A record verified once is not verified again. At the first step
that fails, the verification is refused with that step's reason, at that record.
1. **Reading** (§5.3): `record_malformed`, `derivation_unknown`, `params_invalid`,
`inputs_invalid`, `check_mismatch`, `energy_invalid`, `synthesis_dropped`.
2. **Signature** (§5.2): a key that fails RFC 8032 §5.1.3's decoding or is of small order is
`bad_key`; a signature that fails, `bad_signature`.
3. **Output.** The group receipt whose link hash is `output.receipt`, and the object receipt of
that group at `output.object_id`, are held (`output_receipt_not_held`); the group verifies
(jwp-receipts §3 steps 1–2), the object receipt is included at its position (§3 step 5
there), and its `content_hash` is the output's (`output_receipt_invalid`); §6's rules hold
(`output_receipt_mismatch`, `energy_disagrees`). Where the output envelope is held: it hashes
to `output.content_hash` (`output_hash_mismatch`); its capability is `output.capability`,
and, for an evaluation, its manifest is the one §4.3 writes from the record
(`output_manifest_mismatch`); and, for a `wai.image.png` output, the samples it reads as
(packaging §5's still reader) are of the record's `width` and `height` and have its `samples`
digest (`samples_mismatch`): PNG is lossless, so this holds whoever wrote it.
4. **Each input**, in order:
1. its group receipt (by `receipt`) and its object receipt (at `object_id`) are held
(`input_receipt_not_held`); the group verifies, the object receipt is included at its
position, and its `content_hash` is the input's (`input_receipt_invalid`);
2. where `derivation` is `null`, the group receipt is signed by a source key; otherwise an
edge's output is passed off as a source object, and a hop is missing (`chain_skips_hop`);
3. where `derivation` is not `null`: the input's chain is not longer than 8 hops, the
subject's record being the first (`chain_too_long`); the record of that hash is held
(`hop_not_held`) and reads (§5.3, refused at that record); its output's `content_hash`,
`capability`, `receipt` and `object_id` are the input's (`hop_mismatch`); and it is
verified by these steps, whose refusal, at that record, is this verification's;
4. where the input envelope is held: it hashes to `content_hash` (`input_hash_mismatch`); it
reads as §4.1 or §4.4 has it (`input_unreadable`); and the samples it reads at `frame`
have the digest `samples` (`samples_mismatch`);
5. its provenance (§6.1): each claim the verifier holds about it, each manifest store the
verifier holds bound to it, and, where the input envelope is held, each store embedded in
it, is an item of the record for this input, of that kind and digest; and, where an edge
derived it, every item of that edge's record is one too, with its compartment
(`provenance_stripped`). Where an edge derived it, its `synthesis` is no lower than that
edge's record's (§6.2, `synthesis_dropped`).
5. **Recomputation**, where every input is held. For a `replicate` record: evaluate the
derivation (§4.2–§4.4) over the inputs (`params_invalid` for a parameter they cannot
satisfy); the output's samples digest, size and envelope **MUST** be the record's, byte for
byte (`replicate_mismatch`). For an `attest` record: where the operation is exact, evaluate
it, and the output's `samples`, `width` and `height` **MUST** be the record's
(`samples_mismatch`); where it is not, nothing of it is recomputed. Where an input is not
held, a `replicate` record is `not-recomputed`, and refused under the policy
`recompute: required` (`inputs_not_held`).
6. **Authority** (§8): `key_log_not_held`, `edge_not_authorised`, `edge_revoked`.
7. **Policy**: an `attest` record under `attested: refuse` is `attested_refused`.
A refusal names its place: `["record", r]` for steps 1 and 2, `["record", r, "output"]` for
step 3, `["record", r, "input", i]` for step 4, `["record", r, "recompute"]` for step 5,
`["record", r, "authority", origin]` for step 6, an origin of §8 or, where the hop has none,
`null`, and `["record", r, "policy"]` for step 7, with *r* the record's position among the
records given and *i* the input's among its inputs.
### 7.3 The chain
Step 4 follows each derived input to the record that derived it, and that record's inputs to
theirs, until every input is a source object. Each hop is verified on its own, against its own
receipts and inputs. A hop that names a record the verifier does not hold is refused, not
skipped: a sink that cannot follow a chain to its source knows only that the edge delivered the
object (§6). A chain cannot loop, since a record names its inputs' records by hash and no hash
can name a record that names it; the bound of 8 hops is a bound on work.
What a verified chain establishes: the subject's object was derived, hop by hop, from the
source objects the chain reaches, by the derivations its records name with the parameters they
state, each signed by the edge that made it; each `replicate` hop whose inputs were held was
re-executed and matched byte for byte; each `attest` hop rests, at its attested step, on its
edge's signature; and each source object is one its publisher signed for delivery. It does not
establish that the publisher meant the object to be derived from; that is §8's.
### 7.4 Outcome
A verifier states `refuse` with a reason and its place, or `accept` with:
- `subject`: the subject's record hash;
- `hops`: each record verified, in the order first reached, each with its `record` hash, its
`signer` key, its `derivation`, its `check`, `checked` — `recomputed` (a `replicate` record
re-executed), `not-recomputed` (a `replicate` record with an input not held) or `attested` —
its `energy`, `null` or `{ "joules_micro", "class" }`, its `authority`: `confirmed`,
`unconfirmed` or `none` (§8), and its `synthesis` (§6.2);
- `sources`: each source object reached, in the order first reached, with its `object` content
hash and the `signer` key and `signer_id` of its group receipt;
- `energy`: for each acquisition class among the hops, in energy-measurement §2's order,
strongest first, the sum of their figures of that class; and `unmetered`, the number of hops
whose `energy` is `null`. Figures of different classes are never summed together, and an
unmetered hop is never counted as zero. The source objects' figures are their own receipts';
this total covers the derivations alone;
- `provenance`: the subject's record's items (§6.1), each its `input`, `kind`, `digest` and
`compartment`: every item of the chain, carried to the object the sink received.
## 8. Authority
A derivation is made under the authority of the origin whose content it derives from. A hop's
**origins** are the origins its chain reaches: for each source object, the origin its group
receipt's `signer_id` names where it is origin-bound (jwp-receipts §7.1), taken in ascending
order, each once. The authority is `edge/derive` (jwp-receipts §7.5), and a hop's edge needs it
from each of its hop's origins, the hops below it included: an origin lists, in its key log,
the edges it trusts with its content, as it lists its relays and stores.
For each origin of a hop, where the verifier holds a head for it: the head is a revision the
verifier holds for that origin (jwp-receipts §7.2 rules 1–3) (`key_log_not_held`); it has an
entry whose `key` is the record's `signer_pubkey` and whose `may_sign` holds `edge/derive`
(`edge_not_authorised`); and that entry's `status` is not `revoked` (`edge_revoked`).
A hop is `confirmed` when it has origins and the verifier holds a head for each that passes;
`unconfirmed` when it has origins and lacks a head for one; and `none` when it has no origin,
its sources naming no origin. Under the policy `authority: required`, a hop that is not
`confirmed` is refused (`edge_not_authorised`, at the first origin that lacks a head, or at
`null`).
`edge/derive` is an authority of its own: not `delivery`, which an edge needs from its own
origin to sign its group receipts, and not `live/relay`, since a relay forwards what it
received and an edge publishes something new. Retiring an entry does not withdraw it here;
revoking it does. A metered record's figure needs, beside it, the energy authority of its class
(`energy/<class>`, jwp-receipts §7.5), which that section's confirmation (§7.4 there) checks
under an anchored origin; this section checks `edge/derive` alone.
## 9. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `record_malformed` | §5.3 rule 1, §7.2 step 1 |
| `derivation_unknown` | §5.3 rule 2, §7.2 step 1 |
| `params_invalid` | §4.2, §5.3 rule 3, §7.2 steps 1 and 5 |
| `inputs_invalid` | §5.3 rule 4, §7.2 step 1 |
| `check_mismatch` | §4.5, §5.3 rule 5, §7.2 step 1 |
| `energy_invalid` | §5.3 rule 6, §7.2 step 1 |
| `bad_key` | §7.2 step 2 |
| `bad_signature` | §7.2 step 2 |
| `output_receipt_not_held` | §7.2 step 3 |
| `output_receipt_invalid` | §7.2 step 3 |
| `output_receipt_mismatch` | §6, §7.2 step 3 |
| `energy_disagrees` | §6, §7.2 step 3 |
| `output_hash_mismatch` | §7.2 step 3 |
| `output_manifest_mismatch` | §4.3, §7.2 step 3 |
| `input_receipt_not_held` | §7.2 step 4 |
| `input_receipt_invalid` | §7.2 step 4 |
| `chain_skips_hop` | §7.2 step 4 |
| `chain_too_long` | §7.2 step 4 |
| `hop_not_held` | §7.2 step 4, §7.3 |
| `hop_mismatch` | §7.2 step 4 |
| `input_hash_mismatch` | §7.2 step 4 |
| `input_unreadable` | §4.1, §4.4, §7.2 step 4 |
| `samples_mismatch` | §4.5, §7.2 steps 3, 4 and 5 |
| `provenance_stripped` | §6.1, §7.2 step 4 |
| `synthesis_dropped` | §5.3 rule 7, §6.2, §7.2 steps 1 and 4 |
| `replicate_mismatch` | §4.5, §7.2 step 5 |
| `inputs_not_held` | §7.1, §7.2 step 5 |
| `key_log_not_held` | §8, §7.2 step 6 |
| `edge_not_authorised` | §7.1, §8, §7.2 step 6 |
| `edge_revoked` | §8, §7.2 step 6 |
| `attested_refused` | §7.1, §7.2 step 7 |
## 10. Conformance
[`edge-conformance/`](../edge-conformance/) holds the corpus, in one file, `vectors.json`,
written from fixed seeds by `wai_edge_vectors gen`. Each vector is what a verifier holds (§7.1)
and the outcome §7.4 gives it. It has:
- **Sources**: one publisher, whose `signer_id` is origin-bound, signing an integer clip
(`wai.video.int_motion`), three PNG stills and a `WAI2` envelope of two renditions; and the key
log of its origin, listing two edges with `edge/derive`, one edge with `delivery` alone, and
one revoked.
- **Replicate hops, recomputed**: a frame of the clip rebuilt as a still; each operation of §4.2
but scaling over a still; a grid and an overlay over several stills; a component exploded from
the `WAI2`; and a chain of two hops by two edges, a crop of a rebuilt still, each hop
recomputed from its inputs.
- **Attested hops**: a frame rebuilt as a JPEG picture, whose samples are checked and whose
encode is not; a scaling written as a pinned still; and a replicate crop over an attested
scaling.
- **Provenance and labels**: a still whose PNG embeds a manifest store, with a claim about it
and a store beside it in the personal compartment, every item carried through two hops; a
still whose provenance says it was generated, its label carried through two hops; and a
scaling its edge labels `assist`. The corpus's manifest stores are byte strings standing for
one: a verifier names a store by its SHA-256 and parses none.
- **Policies**: a chain verified without its inputs' bytes; one under `authority: required`
whose hops are confirmed; metered hops of two classes, totalled per class.
- **Refusals**: one vector or more for each reason of §9, each a control of one defect: among
them a JPEG output whose record names no encoder, a grid stated over more inputs than its
cells, a parameter its inputs cannot satisfy, an attested JPEG whose stated samples are not
its frame's, and a scaling whose stated samples are not its still's.
A conforming verifier gives every vector the outcome, the reason, the place and the facts the
corpus states. `wai_edge_vectors verify` and the test `tests/derive_edge.rs` check that the
file regenerates byte for byte, that `wai-rs` gives every vector its outcome, that every reason
of §9 appears, and that §9 lists exactly the reference's codes; the same test runs the
reference edge, `wai_edge`, twice over a source to make a chain of two hops, and verifies it.
`edge-conformance/edge_verify.py` is the second implementation, written from this text: it
shares no code with `wai-rs`; it reads delivery receipts with the JWP corpus's second
implementation (`jwp-conformance/jwp_verify.py`), decodes `WIV1` with the integer payload
corpus's (`int-payload-conformance/int_payload_verify.py`), explodes `WAI2` envelopes with the
staged-delivery corpus's (`staged-conformance/staged_verify.py`), reads key-log heads with the
live-rooms corpus's (`live-conformance/live_rooms_verify.py`), verifies every signature with
WAI's strict rule (`claim-conformance/_ed25519.py`), and writes stills and evaluates every
operation itself. It requires every mutated copy of the corpus to fail, and, with each of its
own checks dropped in turn, requires the corpus to catch the weakened verifier.
## 11. Security considerations
- **What a record proves.** An edge's signature binds it to the derivation it states. For a
`replicate` hop whose inputs a verifier holds, nothing rests on that signature but who made
it: the verifier made the output itself. For an `attest` hop, the attested step is the edge's
word; an edge that encodes something else and signs it is caught only by a party that compares
the output with what it should hold. A sink that cannot accept that refuses attested hops.
- **Chains.** A hop that names its input as a source object when an edge derived it, or that
names a record the sink cannot get, would hide a derivation; both are refused. A chain is
verified hop by hop, so a chain is no stronger than its weakest hop, and the facts say which
hops were re-executed, which were not, and which are attested.
- **Not the source.** A derived object is never the object it was derived from. The source's
receipts do not cover it, and its own receipts are the edge's. jwp-receipts §3.2's ancestor
rule is unchanged: a soft-binding recovery attests the ancestor alone. A verified chain is
the accounted transformation that recovery lacks, and it is the record that accounts for it.
- **Energy.** Each hop's figure is its edge's, with its class, and covers that derivation
alone. Totals are per class, an unmetered hop is not zero, and the source's figures stay its
own (§7.4).
- **Keys.** An edge's key signs its records and its delivery receipts. An origin that stops
trusting an edge revokes its entry, and every record under it is refused for that origin's
content from then on.
- **Work.** A chain has at most 8 hops and a record at most 16 inputs; an output's samples are
bounded. A verifier re-executes only what it holds, and only what §4 pins.
- **Provenance.** A verifier catches an item stripped from a record only where it holds the
item, or the record below that carried it: an edge that drops an item it received, before any
party downstream saw it, is caught by a party that holds what the edge received. Once a record
lists an item, every hop above it must list it too, so the sink of a chain holds every item's
digest from the subject's record alone. A compartment is the edge's reading, and the first
edge to read an item fixes it for the rest of the chain.
- **Labels.** A label can only rise along a chain, so a transcode cannot launder a generated
object into an unlabelled one. A source object's label, and the label an attested step
warrants, are the edge's statements; a verifier checks that no label falls.
- **Privacy.** A record names objects, keys and provenance items by digest, never a viewer or a
session, and copies nothing of an item. Personal provenance is carried by digest in this
revision (§6.1); an edge that must not pass a personal item on can only refuse to derive until
the redaction rule is registered.

View file

@ -197,9 +197,11 @@ reference implementation seals carries its figure in one of two ways:
`wai-rs` reads and verifies JWP group and object receipts in both forms of
each, computes an object's leaf in both forms of jwp-receipts §2.2, reads a
group total from its objects, and reads a `WAI_RECEIPT` property against a
signed statement of its object (`jwp_delivery`). Neither crate seals a JWP
group receipt for delivery — `wai-rs` builds them only for its conformance
corpus — or writes a `WAI_RECEIPT` property.
signed statement of its object (`jwp_delivery`). Neither crate writes a
`WAI_RECEIPT` property, and `wai-quantum` seals no JWP group receipt; `wai-rs`
builds them for its conformance corpus and for the objects its reference
deriving edge publishes ([deriving-edge](deriving-edge.md) §6), whose object
receipts it labels, the unmetered marker included.
### 2.1 Relationship to network-layer energy identities

View file

@ -2253,6 +2253,12 @@ below bind a relay that reads the receipts it forwards.
the subscriber. The relay MUST NOT renumber `object_id`.
- Sign a `delivery-gap` claim (§2.4.11) for an object it did not forward,
under its own key, as the parallel receipt §4.3 permits.
- Derive new objects from those it forwards and publish them as a
publisher of its own ([deriving-edge](deriving-edge.md)): each a new
object with its own content hash, in a group it signs on a track of its
own, with a derivation record (deriving-edge §5) as the parallel receipt
§4.3 permits. What it received it forwards as this section requires,
unaltered.
### 4.3. MUST NOT
@ -3252,6 +3258,7 @@ A confirmation needs every one of them:
| a room-session receipt (live-metering §4) signed by a key other than the room authority | `live/meter`; and, from any signer, `energy/<class>` for each class it totals |
| a steering policy or steering decision ([energy-steering](energy-steering.md) §3.5) signed by a key other than the room authority | `live/steer`; and, from any signer of a decision, `energy/<class>` for each class among its figures |
| a watermark session record ([forensic-watermark](forensic-watermark.md) §5) | `live/watermark` |
| a derivation record ([deriving-edge](deriving-edge.md) §5) | `edge/derive`, from the origin of each source object it derives from (deriving-edge §8); and `energy/<class>` for its figure's class where it is metered |
A class registered in §2.4.4 registers its authority here with it, and a
family that needs a narrower authority than `receipt` registers one here.
@ -3358,8 +3365,15 @@ its keys is that party is the origin's decision, and its log records it:
variants for a room's viewers: sign the session records of what it embedded
([forensic-watermark](forensic-watermark.md) §5.6). It is not pinned-only, and it is not
`live/relay` or `delivery`.
- **Deriving edges.** `edge/derive` lets a key act as an edge that derives
objects from an origin's content and publishes them under its own key
([deriving-edge](deriving-edge.md) §8). The origin that gives it is the
source's, not the edge's: an origin lists the edges it trusts with its
content, and a hop is confirmed under each origin its chain reaches. It is
not pinned-only, and it is not `delivery` or `live/relay`.
- **Pre-emption** ([unidirectional-delivery](unidirectional-delivery.md)
§3), relay attestation (§4.3) other than a `delivery-gap` claim, and
§3), relay attestation (§4.3) other than a `delivery-gap` claim and a
derivation record, and
reconciliation have no class and so no authority. When one is registered for pre-emption it is pinned-only,
and the pinned revision itself must list the key with it (§7.4
step 6a): a fetched anchor is authority taken on first use, which that

View file

@ -392,6 +392,20 @@
"edition": "ISO/IEC 23001-16:2021",
"registered": "2026-09-28"
},
{
"capability": "wai.edge.derivation",
"kind": "record",
"status": "registered",
"media": null,
"role": null,
"tier": null,
"licence": null,
"replicate": null,
"sets": [],
"spec_section": "§5 Prior artifacts, general tensors and derivation operations",
"edition": null,
"registered": "2026-10-21"
},
{
"capability": "wai.feed.state",
"kind": "payload",
@ -2298,6 +2312,13 @@
"section": "§10.2",
"status": "defined"
},
{
"name": "wai-derivation",
"kind": "document-kind",
"document": "extensions/deriving-edge.md",
"section": "§5.1",
"status": "defined"
},
{
"name": "wai-key-status",
"kind": "document-kind",

View file

@ -376,6 +376,13 @@ wai:wai.derive.srgb_fill a wai:Capability ;
wai:specSection "§5 Prior artifacts, general tensors and derivation operations" ;
wai:registeredOn "2026-09-28"^^xsd:date .
wai:wai.edge.derivation a wai:Capability ;
rdfs:label "wai.edge.derivation" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Prior artifacts, general tensors and derivation operations" ;
wai:registeredOn "2026-10-21"^^xsd:date .
wai:wai.feed.state a wai:Capability ;
rdfs:label "wai.feed.state" ;
wai:kind wai:kind-payload ;

View file

@ -23,16 +23,18 @@ path = "src/bin/wai_pack.rs"
# integer video encoder and decoder (video_int); the stage index and the package receipt
# (video_package); HTTP delivery's codecs union and floor (http); staged measurement's
# counted bytes and output digest (stage_measure); the measurement claim and its figure
# view (receipt_claims); and the model meter (energy_meter). No codec library is linked.
wai = { path = "../wai-rs", default-features = false, features = ["video_int", "video_package", "http", "stage_measure", "receipt_claims", "energy_meter"] }
# view (receipt_claims); the model meter (energy_meter); and a still's writer and reader
# (png_still, src/png.rs re-exports them). No codec library is linked.
wai = { path = "../wai-rs", default-features = false, features = ["video_int", "video_package", "http", "stage_measure", "receipt_claims", "energy_meter", "png_still"] }
ed25519-dalek = "2"
# TOML 1.0 (the 0.8 line; later lines read TOML 1.1, which a policy must not use).
toml = { version = "0.8", default-features = false, features = ["parse"] }
serde_json = "1"
blake3 = "1"
sha2 = "0.10"
# Inflate, for the verifier's reader of a still (src/png.rs): an implementation apart from
# the packer's own deflate, so a still is decoded by code that did not write it.
# Inflate, for the verifier's reader of a still (wai's png_still, which src/png.rs
# re-exports): an implementation apart from the packer's own deflate, so a still is
# decoded by code that did not write it.
# Release builds check integer overflow as debug builds do, so the release tests in CI
# catch a wrapped product (a field read as u16 and doubled) rather than run past it.

View file

@ -1,570 +1,12 @@
//! A piece's still ([packaging] §5.7): one RGB8 frame as a `wai.image.png` payload, every
//! byte a pure function of the frame, so an independent implementation writes the same
//! file, and the reader a verifier decodes any such payload with.
//!
//! The writer's choices:
//!
//! - the signature, then `IHDR` (width, height, bit depth 8, colour type 2, compression 0,
//! filter 0, interlace 0), one `IDAT` holding the whole compressed stream, and `IEND`;
//! each chunk a big-endian length, its type, its data and the CRC-32 of type and data;
//! - every row filtered `Sub` (filter byte 1): each byte less the byte three before it in
//! the row, modulo 256, the first pixel's bytes less 0;
//! - a zlib stream (RFC 1950): `0x78 0x01`, one final deflate block of the fixed Huffman
//! codes (RFC 1951 §3.2.6), and the Adler-32 of the filtered rows, big-endian;
//! - the block's symbols, from position 0 of the filtered rows: where at least three bytes
//! remain and an earlier position starts with the same three bytes, the latest such
//! position `j` within 32768 bytes is the match; its length is the bytes the two runs
//! share from there, at most 258, and it is written as that length and the distance to
//! `j`; otherwise one literal. Every position a symbol covers that has three bytes left
//! becomes the latest position of its three bytes. The block ends with symbol 256, and
//! the last byte is padded with zero bits.
//!
//! The reader takes any PNG of 8-bit RGB without interlace (PNG's chunk rules): every
//! chunk's type four letters and its CRC checked; `IHDR` first, of 8-bit RGB, compression
//! and filter method 0 and no interlace; an optional `PLTE` (a suggested palette, which an
//! RGB picture may carry) before the image data, read past; the `IDAT`s consecutive;
//! ancillary chunks read past; any other critical chunk refused; `IEND` last, empty, and
//! nothing after it. The zlib stream is inflated by the module's own inflater, written
//! to the zlib library's rules ([`inflate_zlib`]: no over-subscribed code, and no
//! incomplete one but a lone one-bit literal/length or distance code), with its Adler-32
//! checked and no byte of the image data after it; its rows are exactly `1 + 3·width`
//! bytes each, and the five filters are undone.
//! byte a pure function of the frame, and the reader a verifier decodes any such payload
//! with. The writer and the reader live in the standard's crate (`wai::png_still`, feature
//! `png_still`), where the deriving edge reads and writes stills with them too; this module
//! re-exports them, so the packager, its verifier and its golden tests call them as before.
//!
//! [packaging]: ../../../extensions/packaging.md
use std::collections::BTreeMap;
const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
/// The CRC-32 of ISO 3309 (PNG's chunk check), reflected, polynomial `0xEDB88320`.
pub fn crc32(parts: &[&[u8]]) -> u32 {
let mut table = [0u32; 256];
for (n, t) in table.iter_mut().enumerate() {
let mut c = n as u32;
for _ in 0..8 {
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
}
*t = c;
}
let mut c = 0xffff_ffffu32;
for p in parts {
for &b in *p {
c = table[((c ^ b as u32) & 0xff) as usize] ^ (c >> 8);
}
}
c ^ 0xffff_ffff
}
/// Adler-32 (RFC 1950).
pub fn adler32(b: &[u8]) -> u32 {
let (mut a, mut s) = (1u32, 0u32);
for &x in b {
a = (a + x as u32) % 65521;
s = (s + a) % 65521;
}
(s << 16) | a
}
/// Bits written least significant first, as deflate packs them.
struct Bits {
out: Vec<u8>,
acc: u64,
n: u32,
}
impl Bits {
fn put(&mut self, v: u32, n: u32) {
self.acc |= (v as u64) << self.n;
self.n += n;
while self.n >= 8 {
self.out.push(self.acc as u8);
self.acc >>= 8;
self.n -= 8;
}
}
/// A Huffman code, most significant bit first.
fn code(&mut self, code: u32, len: u32) {
let mut rev = 0u32;
for i in 0..len {
rev |= ((code >> i) & 1) << (len - 1 - i);
}
self.put(rev, len);
}
fn finish(mut self) -> Vec<u8> {
if self.n > 0 {
self.out.push(self.acc as u8);
}
self.out
}
}
/// The fixed literal/length code of symbol `s` (RFC 1951 §3.2.6).
fn lit_code(b: &mut Bits, s: u32) {
match s {
0..=143 => b.code(0x30 + s, 8),
144..=255 => b.code(0x190 + s - 144, 9),
256..=279 => b.code(s - 256, 7),
_ => b.code(0xc0 + s - 280, 8),
}
}
/// RFC 1951 §3.2.5: the length codes' base lengths and extra bits.
const LEN_BASE: [u32; 29] = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
const LEN_EXTRA: [u32; 29] = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
const DIST_BASE: [u32; 30] = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];
const DIST_EXTRA: [u32; 30] = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];
fn put_match(b: &mut Bits, len: u32, dist: u32) {
let li = LEN_BASE.iter().rposition(|&x| x <= len).expect("a length of at least 3");
lit_code(b, 257 + li as u32);
b.put(len - LEN_BASE[li], LEN_EXTRA[li]);
let di = DIST_BASE.iter().rposition(|&x| x <= dist).expect("a distance of at least 1");
b.code(di as u32, 5);
b.put(dist - DIST_BASE[di], DIST_EXTRA[di]);
}
fn key(d: &[u8], i: usize) -> u32 {
(d[i] as u32) << 16 | (d[i + 1] as u32) << 8 | d[i + 2] as u32
}
/// The module's deflate: one final block of fixed codes, the matches as the module states.
pub fn deflate_fixed(d: &[u8]) -> Vec<u8> {
let mut b = Bits { out: Vec::with_capacity(d.len() / 2 + 16), acc: 0, n: 0 };
b.put(1, 1); // BFINAL
b.put(1, 2); // BTYPE 01, fixed codes
let mut last: BTreeMap<u32, usize> = BTreeMap::new();
let n = d.len();
let mut i = 0usize;
while i < n {
let found = if i + 3 <= n { last.get(&key(d, i)).copied().filter(|&j| i - j <= 32768) } else { None };
let len = match found {
Some(j) => {
let max = (n - i).min(258);
let mut l = 0;
while l < max && d[j + l] == d[i + l] {
l += 1;
}
put_match(&mut b, l as u32, (i - j) as u32);
l
}
None => {
lit_code(&mut b, d[i] as u32);
1
}
};
for p in i..i + len {
if p + 3 <= n {
last.insert(key(d, p), p);
}
}
i += len;
}
lit_code(&mut b, 256);
b.finish()
}
/// One chunk: its length, type, data and CRC.
pub fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(data);
out.extend_from_slice(&crc32(&[kind, data]).to_be_bytes());
}
/// The rows of an RGB8 frame of `w × h`, each filtered `Sub`.
fn filtered(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
let stride = 3 * w as usize;
let mut out = Vec::with_capacity((stride + 1) * h as usize);
for row in rgb.chunks_exact(stride).take(h as usize) {
out.push(1);
for i in 0..stride {
out.push(row[i].wrapping_sub(if i >= 3 { row[i - 3] } else { 0 }));
}
}
out
}
/// The still of an RGB8 frame of `w × h` (`rgb` holds `3·w·h` bytes).
pub fn encode(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
let raw = filtered(rgb, w, h);
let mut z = vec![0x78, 0x01];
z.extend_from_slice(&deflate_fixed(&raw));
z.extend_from_slice(&adler32(&raw).to_be_bytes());
let mut ihdr = Vec::with_capacity(13);
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]);
let mut out = SIGNATURE.to_vec();
chunk(&mut out, b"IHDR", &ihdr);
chunk(&mut out, b"IDAT", &z);
chunk(&mut out, b"IEND", &[]);
out
}
fn paeth(a: u8, b: u8, c: u8) -> u8 {
let p = a as i16 + b as i16 - c as i16;
let (pa, pb, pc) = ((p - a as i16).abs(), (p - b as i16).abs(), (p - c as i16).abs());
if pa <= pb && pa <= pc {
a
} else if pb <= pc {
b
} else {
c
}
}
/// The largest picture the reader decodes: the integer payloads' cap on one unit
/// (`2^26` samples).
pub const MAX_SAMPLES: u64 = 1 << 26;
/// Decode a PNG of 8-bit RGB without interlace to its width, height and RGB8 samples.
pub fn decode(b: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
if b.len() < 8 || b[..8] != SIGNATURE {
return Err("not a PNG signature".into());
}
let mut at = 8usize;
let mut ihdr: Option<(u32, u32)> = None;
let mut z = Vec::new();
let mut ended = false;
let mut palette = false;
// 0: no IDAT yet; 1: in the IDAT run; 2: past it.
let mut idat = 0u8;
while at < b.len() {
if ended {
return Err("bytes after IEND".into());
}
let len = u32::from_be_bytes(b.get(at..at + 4).ok_or("a chunk length past the end")?.try_into().expect("4")) as usize;
let kind: [u8; 4] = b.get(at + 4..at + 8).ok_or("a chunk type past the end")?.try_into().expect("4");
let data = b.get(at + 8..at + 8 + len).ok_or("a chunk past the end")?;
let crc = u32::from_be_bytes(b.get(at + 8 + len..at + 12 + len).ok_or("a chunk CRC past the end")?.try_into().expect("4"));
if !kind.iter().all(u8::is_ascii_alphabetic) {
return Err("a chunk type that is not four letters".into());
}
if crc != crc32(&[&kind, data]) {
return Err(format!("{}: its CRC", String::from_utf8_lossy(&kind)));
}
if idat == 1 && &kind != b"IDAT" {
idat = 2;
}
match &kind {
b"IHDR" => {
if ihdr.is_some() || at != 8 || data.len() != 13 {
return Err("IHDR is not one 13-byte chunk first".into());
}
let (w, h) = (u32::from_be_bytes(data[0..4].try_into().expect("4")), u32::from_be_bytes(data[4..8].try_into().expect("4")));
if data[8..] != [8, 2, 0, 0, 0] {
return Err("only 8-bit RGB, deflate, adaptive filtering, no interlace".into());
}
if w == 0 || h == 0 || (w as u64) * (h as u64) * 3 > MAX_SAMPLES {
return Err("a size outside 1 to 2^26 samples".into());
}
ihdr = Some((w, h));
}
_ if ihdr.is_none() => return Err("a chunk before IHDR".into()),
b"PLTE" => {
if palette || idat != 0 || data.is_empty() || data.len() % 3 != 0 || data.len() > 768 {
return Err("PLTE: more than one, after the image data, or not 1 to 256 entries".into());
}
palette = true;
}
b"IDAT" => {
if idat == 2 {
return Err("IDAT chunks that are not consecutive".into());
}
idat = 1;
z.extend_from_slice(data);
}
b"IEND" => {
if !data.is_empty() {
return Err("IEND with data".into());
}
ended = true;
}
_ if kind[0] & 0x20 == 0 => return Err(format!("an unknown critical chunk {}", String::from_utf8_lossy(&kind))),
_ => {}
}
at += 12 + len;
}
let (w, h) = ihdr.ok_or("no IHDR")?;
if !ended {
return Err("no IEND".into());
}
let stride = 3 * w as usize;
let want = (stride + 1) * h as usize;
// The zlib stream: a 32 KiB window at most (PNG's CINFO ≤ 7), its Adler-32 checked,
// every byte of the image data in it, and exactly the rows the picture has.
let raw = inflate_zlib(&z, want)?;
if raw.len() != want {
return Err("the rows are not the picture's size".into());
}
let mut out = vec![0u8; stride * h as usize];
for y in 0..h as usize {
let f = raw[y * (stride + 1)];
let row = &raw[y * (stride + 1) + 1..(y + 1) * (stride + 1)];
for i in 0..stride {
let a = if i >= 3 { out[y * stride + i - 3] } else { 0 };
let up = if y > 0 { out[(y - 1) * stride + i] } else { 0 };
let c = if y > 0 && i >= 3 { out[(y - 1) * stride + i - 3] } else { 0 };
let p = match f {
0 => 0,
1 => a,
2 => up,
3 => ((a as u16 + up as u16) / 2) as u8,
4 => paeth(a, up, c),
_ => return Err(format!("row {y}: filter {f}")),
};
out[y * stride + i] = row[i].wrapping_add(p);
}
}
Ok((w, h, out))
}
// ─────────────────────────────── inflate ───────────────────────────────
//
// The reader's inflater, written from RFC 1950 and RFC 1951 with the rules of the zlib
// library's inflate (the one a PNG reader is built on, and the one the Python verifier
// calls), so the two accept the same streams:
//
// - the header: its check bits, method 8, a window of at most 32 KiB, no preset
// dictionary; the Adler-32 at the end, big-endian; nothing after it;
// - a stored block's length and its one's complement agree;
// - a dynamic block has at most 286 literal/length and 30 distance codes; its
// code-length code is complete; a repeat has a length before it and stays within
// the code lengths; the end-of-block symbol has a code;
// - the literal/length and distance codes are not over-subscribed, and are complete
// unless their longest code is one bit long (a single one-bit code, or none at all):
// the only incomplete code sets zlib takes;
// - a code with no symbol (in an incomplete set, or symbols 286 and 287 and distances
// 30 and 31 of the fixed codes) is refused where it is read, and so is a distance
// past the bytes written.
struct BitReader<'a> {
b: &'a [u8],
at: usize,
bit: u32,
}
impl BitReader<'_> {
fn bit(&mut self) -> Result<u32, String> {
let byte = *self.b.get(self.at).ok_or("the zlib stream ends early")?;
let v = (byte >> self.bit) & 1;
self.bit += 1;
if self.bit == 8 {
self.bit = 0;
self.at += 1;
}
Ok(v as u32)
}
/// `n` bits, least significant first.
fn bits(&mut self, n: u32) -> Result<u32, String> {
let mut v = 0;
for i in 0..n {
v |= self.bit()? << i;
}
Ok(v)
}
fn align(&mut self) {
if self.bit != 0 {
self.bit = 0;
self.at += 1;
}
}
}
/// A canonical Huffman code (RFC 1951 §3.2.2): how many codes of each length, and the
/// symbols in code order.
struct Code {
count: [u16; 16],
symbol: Vec<u16>,
}
/// The code of `lengths`, and how far from complete it is: 0 complete, above 0
/// incomplete, below 0 over-subscribed; and its longest length.
fn code_of(lengths: &[u8]) -> (Code, i32, usize) {
let mut count = [0u16; 16];
for &l in lengths {
count[l as usize] += 1;
}
let mut left: i32 = 1;
let mut longest = 0;
for (len, &c) in count.iter().enumerate().skip(1) {
left = (left << 1) - c as i32;
if c > 0 {
longest = len;
}
if left < 0 {
break;
}
}
let mut symbol = Vec::with_capacity(lengths.len());
for len in 1..16 {
for (s, &l) in lengths.iter().enumerate() {
if l as usize == len {
symbol.push(s as u16);
}
}
}
count[0] = 0;
(Code { count, symbol }, left, longest)
}
fn decode_symbol(r: &mut BitReader, c: &Code) -> Result<u16, String> {
let (mut code, mut first, mut index) = (0i32, 0i32, 0i32);
for len in 1..16 {
code |= r.bit()? as i32;
let n = c.count[len] as i32;
if code - n < first {
return Ok(c.symbol[(index + code - first) as usize]);
}
index += n;
first = (first + n) << 1;
code <<= 1;
}
Err("the zlib stream: a code with no symbol".into())
}
/// The literal/length and distance codes of a dynamic block, under zlib's rules.
fn dynamic_codes(r: &mut BitReader) -> Result<(Code, Code), String> {
const ORDER: [usize; 19] = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];
let nlen = r.bits(5)? as usize + 257;
let ndist = r.bits(5)? as usize + 1;
let ncode = r.bits(4)? as usize + 4;
if nlen > 286 || ndist > 30 {
return Err("the zlib stream: too many length or distance symbols".into());
}
let mut cl = [0u8; 19];
for &i in ORDER.iter().take(ncode) {
cl[i] = r.bits(3)? as u8;
}
let (clc, left, _) = code_of(&cl);
if left != 0 {
return Err("the zlib stream: a code-length code that is not complete".into());
}
let mut lengths = vec![0u8; nlen + ndist];
let mut have = 0;
while have < nlen + ndist {
let s = decode_symbol(r, &clc)?;
let (value, times) = match s {
0..=15 => (s as u8, 1),
16 => {
let prev = *lengths[..have].last().ok_or("the zlib stream: a repeat with no length before it")?;
(prev, 3 + r.bits(2)? as usize)
}
17 => (0, 3 + r.bits(3)? as usize),
_ => (0, 11 + r.bits(7)? as usize),
};
if have + times > nlen + ndist {
return Err("the zlib stream: a repeat past the code lengths".into());
}
lengths[have..have + times].fill(value);
have += times;
}
if lengths[256] == 0 {
return Err("the zlib stream: no code for the end of the block".into());
}
let (lit, left, longest) = code_of(&lengths[..nlen]);
if left < 0 || (left > 0 && longest > 1) {
return Err("the zlib stream: an over-subscribed or incomplete literal/length code".into());
}
let (dist, left, longest) = code_of(&lengths[nlen..]);
if left < 0 || (left > 0 && longest > 1) {
return Err("the zlib stream: an over-subscribed or incomplete distance code".into());
}
Ok((lit, dist))
}
/// The zlib stream `z` inflated, refused as zlib's inflate refuses it, and refused
/// when it would give more than `limit` bytes or when bytes follow its Adler-32.
pub fn inflate_zlib(z: &[u8], limit: usize) -> Result<Vec<u8>, String> {
let (cmf, flg) = match z {
[c, f, ..] => (*c as u32, *f as u32),
_ => return Err("the zlib stream: no header".into()),
};
if (cmf * 256 + flg) % 31 != 0 || cmf & 15 != 8 || cmf >> 4 > 7 || flg & 0x20 != 0 {
return Err("the zlib stream: its header (check bits, method 8, a window of at most 32 KiB, no dictionary)".into());
}
let mut r = BitReader { b: z, at: 2, bit: 0 };
let mut out: Vec<u8> = Vec::new();
let too_long = || "the zlib stream gives more than the picture's rows".to_string();
loop {
let last = r.bit()?;
match r.bits(2)? {
0 => {
r.align();
let n = z.get(r.at..r.at + 4).ok_or("the zlib stream ends early")?;
let (len, nlen) = (u16::from_le_bytes([n[0], n[1]]), u16::from_le_bytes([n[2], n[3]]));
if len != !nlen {
return Err("the zlib stream: a stored block's length and its complement disagree".into());
}
let data = z.get(r.at + 4..r.at + 4 + len as usize).ok_or("the zlib stream ends early")?;
if out.len() + data.len() > limit {
return Err(too_long());
}
out.extend_from_slice(data);
r.at += 4 + len as usize;
}
t @ (1 | 2) => {
let (lit, dist) = if t == 1 {
let mut l = [8u8; 288];
l[144..256].fill(9);
l[256..280].fill(7);
(code_of(&l).0, code_of(&[5u8; 32]).0)
} else {
dynamic_codes(&mut r)?
};
loop {
let s = decode_symbol(&mut r, &lit)? as usize;
if s < 256 {
if out.len() == limit {
return Err(too_long());
}
out.push(s as u8);
continue;
}
if s == 256 {
break;
}
let li = s - 257;
if li >= 29 {
return Err("the zlib stream: a length symbol past 285".into());
}
let len = (LEN_BASE[li] + r.bits(LEN_EXTRA[li])?) as usize;
let di = decode_symbol(&mut r, &dist)? as usize;
if di >= 30 {
return Err("the zlib stream: a distance symbol past 29".into());
}
let d = (DIST_BASE[di] + r.bits(DIST_EXTRA[di])?) as usize;
if d > out.len() {
return Err("the zlib stream: a distance past the bytes written".into());
}
if out.len() + len > limit {
return Err(too_long());
}
for _ in 0..len {
out.push(out[out.len() - d]);
}
}
}
_ => return Err("the zlib stream: block type 3".into()),
}
if last == 1 {
break;
}
}
r.align();
let sum = z.get(r.at..r.at + 4).ok_or("the zlib stream ends before its Adler-32")?;
if u32::from_be_bytes(sum.try_into().expect("4")) != adler32(&out) {
return Err("the zlib stream: its Adler-32".into());
}
if r.at + 4 != z.len() {
return Err("bytes follow the zlib stream".into());
}
Ok(out)
}
pub use wai::png_still::*;
#[cfg(test)]
mod tests {

View file

@ -274,6 +274,21 @@ live_ads = ["live_room", "video_ssai"]
# IPTC and AI-disclosure mapping through timed_text. SHA-256, BLAKE3 and Ed25519 only; no new
# dependency.
live_provenance = ["live_watermark", "timed_text"]
# A still of packaging §5.7 (src/png_still.rs): one RGB8 frame as a `wai.image.png` payload,
# every byte a pure function of the frame, and the reader that decodes any 8-bit RGB PNG
# without interlace, with an inflater of its own. No dependency. The reference packager
# (wai-pack) and the deriving edge write and read stills with it.
png_still = []
# The deriving edge (extensions/deriving-edge.md): `wai.edge.derivation` — the record an edge
# signs for an object it derived from delivered objects (a wai.derive.* operation over 8-bit RGB
# samples, a frame of a wai.video.int_motion clip rebuilt as a still or a JPEG picture, a WAI2
# component exploded), naming each input by its content hash, its delivery receipt and the
# record that derived it; the derivations' arithmetic; the verifier that follows a derived
# object back to its source, re-executing each replicate hop whose inputs it holds; and the
# reference edge's derive. Its delivery receipts are jwp_delivery's (receipt_claims), its key
# logs key_log's (key_discovery), its clips int_video's (video_int); jpeg-encoder, already a
# dependency of `codecs`, writes its attested JPEG outputs. No new dependency.
derive_edge = ["key_discovery", "video_int", "png_still", "dep:jpeg-encoder"]
# Timed text and live captions (extensions/timed-text): `wai.text.timed` — the cue model,
# live caption revisions (`caption` events of wai.live.events with the WTX1 annotation),
# and their exact mappings onto WebVTT documents and segments, the IMSC1 Text Profile,
@ -738,6 +753,19 @@ name = "wai_live_ads_vectors"
path = "src/bin/wai_live_ads_vectors.rs"
required-features = ["live_ads"]
# The deriving edge's corpus (edge-conformance/, extensions/deriving-edge.md §10).
[[bin]]
name = "wai_edge_vectors"
path = "src/bin/wai_edge_vectors.rs"
required-features = ["derive_edge"]
# The reference deriving edge (extensions/deriving-edge.md): derive an object from objects of a
# bundle under the edge's key, and verify a derived object back to its source.
[[bin]]
name = "wai_edge"
path = "src/bin/wai_edge.rs"
required-features = ["derive_edge"]
[[bin]]
name = "wai_live_provenance_vectors"
path = "src/bin/wai_live_provenance_vectors.rs"
@ -1448,6 +1476,11 @@ name = "live_provenance"
path = "tests/live_provenance.rs"
required-features = ["live_provenance"]
[[test]]
name = "derive_edge"
path = "tests/derive_edge.rs"
required-features = ["derive_edge"]
[[test]]
name = "timed_text"
path = "tests/timed_text.rs"

View file

@ -0,0 +1,400 @@
//! The reference deriving edge (`extensions/deriving-edge.md`).
//!
//! wai_edge derive --bundle <in.json> --out <out.json> --key <seed file> --signer-id <id>
//! --derivation <id> [--params <json>] [--encoder <json>] --output <capability>
//! --input <content hash>[@<frame>][=<capability>] ...
//! --track <n> --group <n> --time <date-time> --source <key hex> ...
//! [--head <origin>=<file>] ... [--energy <json>] [--origin <label>]
//! [--personal <digest>] ... [--synthesis <content hash>=<label>] ...
//! [--own-synthesis <label>]
//!
//! Derive an object from objects of a bundle, publish it under the edge's key in a group
//! of its own track, sign its record, and write the bundle with the output, its receipts
//! and its record added. Each input is verified first (§3.3): a source object's receipts
//! must verify and be signed by a `--source` key, and a derived input's chain must verify
//! by §7. The group's parent is the edge's latest group on the track in the bundle. The
//! edge measures nothing: its energy is the unmetered marker unless `--energy` gives a
//! figure a meter took, as a record's `energy` member.
//!
//! Every provenance item of every input is carried forward (§6.1): the bundle's claims
//! about it and the C2PA Manifest Stores bound to it, in the system compartment unless
//! `--personal` names the item's digest; the stores embedded in it; and every item its
//! record carries. `--synthesis` states the AI-origin label a source input's provenance
//! gives (§6.2), and `--own-synthesis` the label the derivation's own step warrants.
//!
//! wai_edge verify --bundle <b.json> --subject <content hash> --source <key hex> ...
//! [--head <origin>=<file>] ... [--refuse-attested] [--require-recompute]
//! [--require-authority]
//!
//! Verify the object `--subject` back to its source (§7) and print the outcome as JSON.
//! Exit status 0 when it is accepted, 1 when it is refused.
//!
//! A bundle is a JSON object: `records`, record texts; `receipts`, group and object receipts;
//! `objects`, envelopes by content hash, in lowercase hexadecimal; and, where there are any,
//! `claims`, claim texts, and `c2pa`, C2PA Manifest Stores in hexadecimal by the content hash
//! of the object each is bound to. The key file holds the 32-byte Ed25519 seed as 64
//! hexadecimal digits.
use std::collections::BTreeMap;
use std::process::ExitCode;
use ed25519_dalek::SigningKey;
use serde_json::{json, Map, Value};
use wai::derive_edge::edge::{self, InputObject, Publish};
use wai::derive_edge::verify::{self, Held, Policy};
use wai::derive_edge::{claim_about, sha256, Compartment, Derivation, Encoder, Energy, Params, ProvKind, Record, Synthesis};
use wai::jwp_delivery::{GroupReceipt, ObjectReceipt};
use wai::jwp_hash::content_hash;
fn usage() -> ExitCode {
eprintln!("usage: wai_edge derive --bundle <in.json> --out <out.json> --key <seed file> --signer-id <id> --derivation <id> [--params <json>] [--encoder <json>] --output <capability> --input <hash>[@<frame>][=<capability>] ... --track <n> --group <n> --time <date-time> --source <key> ... [--head <origin>=<file>] ... [--energy <json>] [--origin <label>]");
eprintln!(" wai_edge verify --bundle <b.json> --subject <hash> --source <key> ... [--head <origin>=<file>] ... [--refuse-attested] [--require-recompute] [--require-authority]");
ExitCode::from(2)
}
fn die(msg: impl std::fmt::Display) -> ExitCode {
eprintln!("error: {msg}");
ExitCode::from(2)
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn unhex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) || !s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
return None;
}
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
}
fn hash32(s: &str) -> Result<[u8; 32], String> {
unhex(s).and_then(|b| b.try_into().ok()).ok_or_else(|| format!("{s}: not 64 lowercase hexadecimal digits"))
}
/// The options: each name with every value given for it, and the flags.
struct Opts(BTreeMap<String, Vec<String>>);
impl Opts {
fn parse(args: &[String]) -> Result<Opts, String> {
let mut m: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut i = 0;
while i < args.len() {
let a = args[i].strip_prefix("--").ok_or_else(|| format!("{}: not an option", args[i]))?;
if a.starts_with("refuse-") || a.starts_with("require-") {
m.entry(a.to_owned()).or_default().push(String::new());
i += 1;
continue;
}
let v = args.get(i + 1).ok_or_else(|| format!("--{a} needs a value"))?;
m.entry(a.to_owned()).or_default().push(v.clone());
i += 2;
}
Ok(Opts(m))
}
fn one(&self, k: &str) -> Result<&str, String> {
match self.0.get(k).map(Vec::as_slice) {
Some([v]) => Ok(v),
Some(_) => Err(format!("--{k} given more than once")),
None => Err(format!("--{k} is required")),
}
}
fn opt(&self, k: &str) -> Option<&str> {
self.0.get(k).and_then(|v| v.first()).map(String::as_str)
}
fn all(&self, k: &str) -> &[String] {
self.0.get(k).map_or(&[], Vec::as_slice)
}
fn flag(&self, k: &str) -> bool {
self.0.contains_key(k)
}
}
/// A bundle of records, receipts and objects.
struct Bundle {
records: Vec<String>,
receipts: Vec<Value>,
objects: BTreeMap<[u8; 32], Vec<u8>>,
claims: Vec<String>,
c2pa: BTreeMap<[u8; 32], Vec<Vec<u8>>>,
}
impl Bundle {
fn read(path: &str) -> Result<Bundle, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
let v: Value = serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?;
let records = v["records"]
.as_array()
.ok_or("no records")?
.iter()
.map(|r| r.as_str().map(String::from).ok_or("a record that is not a string"))
.collect::<Result<_, _>>()?;
let receipts = v["receipts"].as_array().ok_or("no receipts")?.clone();
let mut objects = BTreeMap::new();
for (h, b) in v["objects"].as_object().ok_or("no objects")? {
objects.insert(hash32(h)?, unhex(b.as_str().unwrap_or("")).ok_or("an object that is not hex")?);
}
let claims = match v.get("claims") {
None => Vec::new(),
Some(c) => c
.as_array()
.ok_or("claims is not an array")?
.iter()
.map(|t| t.as_str().map(String::from).ok_or("a claim that is not a string"))
.collect::<Result<_, _>>()?,
};
let mut c2pa = BTreeMap::new();
if let Some(m) = v.get("c2pa") {
for (h, stores) in m.as_object().ok_or("c2pa is not an object")? {
let stores = stores
.as_array()
.ok_or("a c2pa entry is not an array")?
.iter()
.map(|b| unhex(b.as_str().unwrap_or("")).ok_or("a store that is not hex"))
.collect::<Result<Vec<_>, _>>()?;
c2pa.insert(hash32(h)?, stores);
}
}
Ok(Bundle { records, receipts, objects, claims, c2pa })
}
fn write(&self, path: &str) -> Result<(), String> {
let v = json!({
"records": self.records,
"receipts": self.receipts,
"objects": self.objects.iter().map(|(h, b)| (hex(h), json!(hex(b)))).collect::<Map<String, Value>>(),
"claims": self.claims,
"c2pa": self.c2pa.iter().map(|(h, st)| (hex(h), json!(st.iter().map(|b| hex(b)).collect::<Vec<_>>()))).collect::<Map<String, Value>>(),
});
std::fs::write(path, serde_json::to_string_pretty(&v).expect("JSON") + "\n").map_err(|e| format!("{path}: {e}"))
}
fn groups(&self) -> Vec<GroupReceipt> {
self.receipts
.iter()
.filter(|r| r["kind"] == "group")
.filter_map(|r| GroupReceipt::from_json(r).ok())
.collect()
}
/// The object receipt naming `hash` and its group, both verifying.
fn receipts_of(&self, hash: &[u8; 32]) -> Option<(GroupReceipt, ObjectReceipt)> {
let groups = self.groups();
self.receipts
.iter()
.filter(|r| r["kind"] == "object")
.filter_map(|r| ObjectReceipt::from_json(r).ok())
.filter(|o| o.content_hash == *hash)
.find_map(|o| {
let g = groups.iter().find(|g| {
g.track_alias == o.track_alias && g.group_id == o.group_id && g.verify().is_ok() && o.verify_inclusion(g).is_ok()
})?;
Some((g.clone(), o))
})
}
/// The position of the last record whose output is `hash`.
fn record_of(&self, hash: &[u8; 32]) -> Option<usize> {
self.records.iter().rposition(|t| Record::read(t).is_ok_and(|r| r.output.content_hash == *hash))
}
fn held(&self, sources: &[[u8; 32]], heads: &BTreeMap<String, String>, policy: Policy) -> Held {
Held {
records: self.records.clone(),
receipts: self.receipts.clone(),
objects: self.objects.clone(),
sources: sources.to_vec(),
heads: heads.clone(),
policy,
claims: self.claims.clone(),
c2pa: self.c2pa.clone(),
}
}
}
/// The source keys and the key-log heads by origin.
type SourcesAndHeads = (Vec<[u8; 32]>, BTreeMap<String, String>);
fn sources_and_heads(o: &Opts) -> Result<SourcesAndHeads, String> {
let sources = o.all("source").iter().map(|s| hash32(s)).collect::<Result<Vec<_>, _>>()?;
let mut heads = BTreeMap::new();
for h in o.all("head") {
let (origin, file) = h.split_once('=').ok_or_else(|| format!("--head {h}: not <origin>=<file>"))?;
heads.insert(origin.to_owned(), std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?);
}
Ok((sources, heads))
}
fn json_arg(o: &Opts, k: &str) -> Result<Option<Value>, String> {
o.opt(k).map(|t| serde_json::from_str(t).map_err(|e| format!("--{k}: {e}"))).transpose()
}
fn derive(o: &Opts) -> Result<String, String> {
let mut bundle = Bundle::read(o.one("bundle")?)?;
let seed = std::fs::read_to_string(o.one("key")?).map_err(|e| format!("--key: {e}"))?;
let key = SigningKey::from_bytes(&hash32(seed.trim())?);
let signer_id = o.one("signer-id")?;
let d = Derivation::from_id(o.one("derivation")?).ok_or("--derivation: not a derivation of deriving-edge §4")?;
let (sources, heads) = sources_and_heads(o)?;
// The inputs, each verified before the edge derives from it (§3.3).
struct In {
hash: [u8; 32],
frame: Option<u64>,
capability: Option<String>,
group: GroupReceipt,
object: ObjectReceipt,
record: Option<Record>,
}
let personal = o.all("personal").iter().map(|h| hash32(h)).collect::<Result<Vec<_>, _>>()?;
let compartment = |d: &[u8; 32]| if personal.contains(d) { Compartment::Personal } else { Compartment::System };
let mut labels = BTreeMap::new();
for l in o.all("synthesis") {
let (h, label) = l.split_once('=').ok_or_else(|| format!("--synthesis {l}: not <content hash>=<label>"))?;
labels.insert(hash32(h)?, Synthesis::from_label(label).ok_or_else(|| format!("--synthesis {l}: not a label"))?);
}
let own = o.opt("own-synthesis").map(|l| Synthesis::from_label(l).ok_or("--own-synthesis: not a label")).transpose()?;
let mut ins = Vec::new();
for spec in o.all("input") {
let (spec, capability) = match spec.split_once('=') {
Some((s, c)) => (s, Some(c.to_owned())),
None => (spec.as_str(), None),
};
let (h, frame) = match spec.split_once('@') {
Some((h, f)) => (h, Some(f.parse::<u64>().map_err(|_| format!("--input {spec}: a frame is a number"))?)),
None => (spec, None),
};
let hash = hash32(h)?;
let env = bundle.objects.get(&hash).ok_or_else(|| format!("input {h}: not in the bundle"))?;
if content_hash(env) != hash {
return Err(format!("input {h}: its envelope does not hash to its name"));
}
let (group, object) = bundle.receipts_of(&hash).ok_or_else(|| format!("input {h}: no receipts that verify"))?;
let record = match bundle.record_of(&hash) {
None => {
if !sources.contains(&group.signer_pubkey) {
return Err(format!("input {h}: signed by {}, which is not a --source", hex(&group.signer_pubkey)));
}
None
}
Some(r) => {
verify::verify(&bundle.held(&sources, &heads, Policy::default()), r)
.map_err(|f| format!("input {h}: its chain is refused: {} at {}", f.reason, f.at.to_value()))?;
Some(Record::read(&bundle.records[r]).expect("a verified record reads"))
}
};
ins.push(In { hash, frame: frame.or(d.evaluates().then_some(0)), capability, group, object, record });
}
if ins.is_empty() {
return Err("no --input".into());
}
let caps: Vec<String> = ins
.iter()
.map(|i| {
i.capability.clone().map_or_else(
|| wai::derive_edge::ops::wai1_capability(&bundle.objects[&i.hash]).ok_or_else(|| "an input that is not a WAI1; name its capability".to_string()),
Ok,
)
})
.collect::<Result<_, _>>()?;
let params = Params::from_value(d, &json_arg(o, "params")?.unwrap_or(json!({})), ins.len()).map_err(|r| format!("--params: {r}"))?;
let encoder = json_arg(o, "encoder")?.map(|v| Encoder::from_value(&v)).transpose().map_err(|r| format!("--encoder: {r}"))?;
let energy = match json_arg(o, "energy")? {
None => None,
Some(v) => Energy::from_value(&v).map_err(|r| format!("--energy: {r}"))?,
};
let track: u64 = o.one("track")?.parse().map_err(|_| "--track is a number")?;
let group: u64 = o.one("group")?.parse().map_err(|_| "--group is a number")?;
let me = key.verifying_key().to_bytes();
let parent = bundle
.groups()
.into_iter()
.filter(|g| g.signer_pubkey == me && g.track_alias == track && g.group_id < group)
.max_by_key(|g| g.group_id)
.map(|g| g.link_hash());
// The provenance each input came with beside its envelope (§6.1).
type Items = Vec<(ProvKind, [u8; 32], Compartment)>;
let carried = |h: &[u8; 32]| -> Items {
let claims = bundle.claims.iter().filter_map(|t| claim_about(t)).filter(|(s, _)| s == h).map(|(_, l)| (ProvKind::Claim, l));
let stores = bundle.c2pa.get(h).into_iter().flatten().map(|b| (ProvKind::C2pa, sha256(b)));
claims.chain(stores).map(|(k, d)| (k, d, compartment(&d))).collect()
};
let inputs: Vec<InputObject> = ins
.iter()
.zip(&caps)
.map(|(i, c)| InputObject {
envelope: &bundle.objects[&i.hash],
capability: c,
frame: i.frame,
group: &i.group,
object: &i.object,
record: i.record.as_ref(),
provenance: carried(&i.hash),
synthesis: labels.get(&i.hash).copied(),
})
.collect();
let p = Publish {
key: &key,
signer_id,
track_alias: track,
group_id: group,
time: o.one("time")?,
parent,
origin: o.opt("origin").unwrap_or("edge"),
};
let derived = edge::derive(d, params, encoder, o.one("output")?, &inputs, energy, own, &p)?;
let out_hash = derived.record.output.content_hash;
let record_hash = derived.record.hash();
bundle.records.push(derived.record.to_json());
bundle.receipts.push(derived.group.to_json());
bundle.receipts.push(derived.object.to_json());
bundle.objects.insert(out_hash, derived.envelope);
bundle.write(o.one("out")?)?;
Ok(json!({ "output": hex(&out_hash), "record": hex(&record_hash) }).to_string())
}
fn verify_cmd(o: &Opts) -> Result<(String, bool), String> {
let bundle = Bundle::read(o.one("bundle")?)?;
let subject = hash32(o.one("subject")?)?;
let (sources, heads) = sources_and_heads(o)?;
let policy = Policy {
refuse_attested: o.flag("refuse-attested"),
require_recompute: o.flag("require-recompute"),
require_authority: o.flag("require-authority"),
};
let r = bundle.record_of(&subject).ok_or("no record in the bundle derives the subject")?;
Ok(match verify::verify(&bundle.held(&sources, &heads, policy), r) {
Ok(f) => (json!({ "outcome": "accept", "facts": f.to_value() }).to_string(), true),
Err(f) => (json!({ "outcome": "refuse", "reason": f.reason.code(), "at": f.at.to_value() }).to_string(), false),
})
}
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().skip(1).collect();
let Some(cmd) = args.first() else { return usage() };
let opts = match Opts::parse(&args[1..]) {
Ok(o) => o,
Err(e) => return die(e),
};
match cmd.as_str() {
"derive" => match derive(&opts) {
Ok(out) => {
println!("{out}");
ExitCode::SUCCESS
}
Err(e) => die(e),
},
"verify" => match verify_cmd(&opts) {
Ok((out, accepted)) => {
println!("{out}");
if accepted { ExitCode::SUCCESS } else { ExitCode::from(1) }
}
Err(e) => die(e),
},
_ => usage(),
}
}

View file

@ -0,0 +1,74 @@
//! Conformance corpus for the deriving edge (`edge-conformance/`, extensions/deriving-edge.md §10).
//!
//! wai_edge_vectors gen <corpus-dir> write vectors.json from fixed seeds
//! wai_edge_vectors verify <corpus-dir> every file regenerates byte for byte, no other
//! file but README.md and edge_verify.py is there,
//! and every vector gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `edge-conformance/edge_verify.py`, written from the extension's
//! text and sharing no code with this binary.
use std::collections::BTreeMap;
use std::path::Path;
use wai::derive_edge::corpus;
fn main() {
let a: Vec<String> = std::env::args().collect();
match (a.get(1).map(String::as_str), a.get(2)) {
(Some("gen"), Some(d)) => {
if let Err(e) = std::fs::create_dir_all(d) {
eprintln!("create {d}: {e}");
std::process::exit(1);
}
for (name, body) in corpus::generate() {
let p = Path::new(d).join(&name);
if let Err(e) = std::fs::write(&p, body) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
}
println!("wrote {d}");
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
eprintln!("usage: wai_edge_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let mut files = BTreeMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
eprintln!("read {}: {e}", dir.display());
return 1;
}
};
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name == "README.md" || name == "edge_verify.py" {
continue;
}
match std::fs::read_to_string(e.path()) {
Ok(s) => {
files.insert(name, s);
}
Err(err) => {
eprintln!("read {name}: {err}");
return 1;
}
}
}
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
let same = files == generated;
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
let fails = corpus::verify(&files);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
(!same || !fails.is_empty()) as i32
}

View file

@ -222,6 +222,7 @@ pub const CAP_LIVE_E2EE: &str = "wai.live.e2ee";
pub const CAP_LIVE_WATERMARK: &str = "wai.live.watermark";
pub const CAP_LIVE_ADS: &str = "wai.live.ads";
pub const CAP_LIVE_PROVENANCE: &str = "wai.live.provenance";
pub const CAP_EDGE_DERIVATION: &str = "wai.edge.derivation";
/// One row per string SPEC §5 registers that is a payload, companion or record:
/// `(string, SPEC section, edition, registered)`.
@ -277,6 +278,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(CAP_PRIOR_NNC, S_PRIOR, Some("ISO/IEC 15938-17:2024"), "2026-09-28"),
(CAP_PRIOR_SET, S_PRIOR, None, "2026-09-30"),
(CAP_TENSOR_NNC, S_PRIOR, None, "2026-09-28"),
(CAP_EDGE_DERIVATION, S_PRIOR, None, "2026-10-21"),
(CAP_WORLD_SCENE, S_INTERACTIVE, None, "2026-06-13"),
(CAP_WORLD_REPLAY, S_INTERACTIVE, None, "2026-06-13"),
(CAP_WORLD_LIVE, S_INTERACTIVE, None, "2026-06-13"),
@ -365,6 +367,7 @@ const RECORDS: &[&str] = &[
CAP_LIVE_WATERMARK,
CAP_LIVE_ADS,
CAP_LIVE_PROVENANCE,
CAP_EDGE_DERIVATION,
];
/// A named identifier SPEC uses that has the shape of a capability string but is
@ -522,6 +525,7 @@ const CONFIDENTIALITY: &str = "extensions/confidentiality.md";
const FORENSIC_WATERMARK: &str = "extensions/forensic-watermark.md";
const LIVE_ADS: &str = "extensions/live-ads.md";
const LIVE_PROVENANCE: &str = "extensions/live-provenance.md";
const DERIVING_EDGE: &str = "extensions/deriving-edge.md";
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
status: WireStatus) -> WireIdentifier {
@ -533,7 +537,8 @@ const fn wire(name: &'static str, kind: WireKind, document: &'static str, sectio
/// (jwp-receipts §7), of packaging, of live events, of live rooms, of capture binding, of
/// live composition, of live recording, of timed text, of live metering, of energy steering,
/// of live moderation, of confidentiality, of forensic watermark sessions, of live ads and of
/// live provenance, in document order. The export lists them sorted by name and kind.
/// live provenance and of the deriving edge, in document order. The export lists them sorted by
/// name and kind.
pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("WAI1", WireKind::Magic, "SPEC.md", "§2", WireStatus::Defined),
wire("WAI2", WireKind::Magic, "SPEC.md", "§7.1", WireStatus::Defined),
@ -621,6 +626,7 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("WAB1", WireKind::Magic, LIVE_ADS, "§4.2", WireStatus::Defined),
wire("wai_c2pa_track", WireKind::Catalog, LIVE_PROVENANCE, "§10.2", WireStatus::Defined),
wire("wai-c2pa", WireKind::Catalog, LIVE_PROVENANCE, "§10.2", WireStatus::Defined),
wire("wai-derivation", WireKind::DocumentKind, DERIVING_EDGE, "§5.1", WireStatus::Defined),
];
/// A string with the shape of a capability that the reference implementation uses

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,234 @@
//! The reference edge's derive (`extensions/deriving-edge.md` §3, §6): read the inputs,
//! evaluate the derivation, write the output, publish it in a group of the edge's own track
//! under the edge's key, and sign its record. The `wai_edge` tool runs it over a bundle of
//! objects and receipts; the corpus runs it to make every hop it holds.
//!
//! It derives from what it is given and checks only that the inputs read as the derivation
//! needs: a caller verifies its inputs first (§3.3), as `wai_edge derive` does with
//! [`super::verify`].
use super::ops::{self, Samples};
use super::{
check_of, ordered, sha256, Compartment, Derivation, Encoder, Energy, Input, Output, Params, ProvItem, ProvKind, Record,
Synthesis,
};
use crate::codecs::{CAP_IMAGE_JPEG, CAP_IMAGE_PNG};
use crate::jwp_delivery::{GroupBuilder, GroupReceipt, ObjectLabel, ObjectReceipt};
use crate::jwp_hash::content_hash;
use ed25519_dalek::SigningKey;
/// An input as the edge holds it: its envelope, its delivery receipts, the record that
/// derived it where an edge did, and the provenance it came with.
pub struct InputObject<'a> {
pub envelope: &'a [u8],
/// The capability the derivation reads it as (§5.1).
pub capability: &'a str,
/// The frame it is read at; `None` for an explode.
pub frame: Option<u64>,
pub group: &'a GroupReceipt,
pub object: &'a ObjectReceipt,
pub record: Option<&'a Record>,
/// The provenance items it came with apart from its envelope and its record (§6.1):
/// the claims about it and the C2PA Manifest Stores bound to it, with the compartment the
/// edge reads each to be in. Those embedded in its envelope, and those its record carries,
/// are added by [`derive`].
pub provenance: Vec<(ProvKind, [u8; 32], Compartment)>,
/// The AI-origin label its provenance states (§6.2); its record's, where it has one, is
/// taken when it is higher.
pub synthesis: Option<Synthesis>,
}
/// Every provenance item of input `i` (§6.1): what it came with, the C2PA Manifest Stores
/// embedded in its envelope (system provenance), and every item its record carries.
pub fn provenance_of(i: usize, input: &InputObject<'_>) -> Vec<ProvItem> {
let n = i as u64;
let mut items: Vec<ProvItem> = input
.provenance
.iter()
.map(|(kind, digest, compartment)| ProvItem { input: n, kind: *kind, digest: *digest, compartment: *compartment })
.collect();
for store in ops::embedded_c2pa(input.capability, input.envelope) {
items.push(ProvItem { input: n, kind: ProvKind::C2pa, digest: sha256(&store), compartment: Compartment::System });
}
if let Some(r) = input.record {
items.extend(r.provenance.iter().map(|p| ProvItem { input: n, ..p.clone() }));
}
items
}
/// Where and how the edge publishes the output (§6).
pub struct Publish<'a> {
pub key: &'a SigningKey,
pub signer_id: &'a str,
pub track_alias: u64,
pub group_id: u64,
/// The group's start and end, and the record's `issued_at`.
pub time: &'a str,
/// The link hash of the edge's previous group on the track.
pub parent: Option<[u8; 32]>,
/// The object receipt's `origin` label.
pub origin: &'a str,
}
/// What a derive makes: the output envelope, its group and object receipts, its record.
#[derive(Clone, Debug)]
pub struct Derived {
pub envelope: Vec<u8>,
pub group: GroupReceipt,
pub object: ObjectReceipt,
pub record: Record,
}
/// What the derivation computed, before it is published: the output envelope, and for an
/// evaluation its samples.
pub struct Written {
pub envelope: Vec<u8>,
pub samples: Option<Samples>,
}
/// The record of a derivation over `inputs`, its output fields still to be filled.
pub fn draft(
d: Derivation,
params: Params,
encoder: Option<Encoder>,
output_capability: &str,
inputs: Vec<Input>,
energy: Option<Energy>,
issued_at: &str,
) -> Record {
let check = check_of(d, inputs.len(), output_capability, encoder.as_ref());
let inputs_synthesis = inputs.iter().filter_map(|i| i.synthesis).max();
Record {
derivation: d,
params,
encoder,
check,
inputs,
output: Output {
content_hash: [0; 32],
capability: output_capability.to_owned(),
receipt: [0; 32],
object_id: 0,
width: None,
height: None,
samples: None,
},
energy,
provenance: Vec::new(),
synthesis: inputs_synthesis,
issued_at: issued_at.to_owned(),
signer_pubkey: [0; 32],
signer_id: String::new(),
sig: [0; 64],
}
}
/// Read each input as the derivation needs it, filling each input's `samples`: its samples
/// for an evaluation, its exploded component for an explode.
pub fn read_inputs(rec: &mut Record, envelopes: &[&[u8]]) -> Result<(Vec<Samples>, Option<Vec<u8>>), String> {
if !rec.derivation.evaluates() {
let Params::Explode { component } = rec.params else { return Err("an explode's parameters".into()) };
let env = envelopes.first().ok_or("no input")?;
let out = ops::explode(env, component, &rec.inputs[0].capability).ok_or("the component does not explode")?;
return Ok((Vec::new(), Some(out)));
}
let mut samples = Vec::new();
for (i, env) in envelopes.iter().enumerate() {
let input = &mut rec.inputs[i];
let s = ops::read_samples(&input.capability, env, input.frame.unwrap_or(0))
.ok_or_else(|| format!("input {i} does not read as {} at frame {:?}", input.capability, input.frame))?;
input.samples = Some(s.digest());
samples.push(s);
}
Ok((samples, None))
}
/// Write `out`, the samples an evaluation produced, as `rec` states the output (§4.3),
/// filling the record's size and samples digest.
pub fn write(rec: &mut Record, out: &Samples) -> Result<Vec<u8>, String> {
rec.output.width = Some(out.w as u64);
rec.output.height = Some(out.h as u64);
rec.output.samples = Some(out.digest());
let payload = match rec.output.capability.as_str() {
CAP_IMAGE_PNG => crate::png_still::encode(&out.rgb, out.w, out.h),
CAP_IMAGE_JPEG => {
let q = rec.encoder.as_ref().and_then(ops::jpeg_quality).ok_or("a JPEG encoder needs a quality")?;
ops::write_jpeg(out, q).ok_or("the JPEG does not encode")?
}
c => return Err(format!("no writer for {c}")),
};
let manifest = ops::output_manifest(rec, out.w as u64, out.h as u64);
Ok(ops::wai1(manifest.as_bytes(), &payload))
}
/// Evaluate `rec` over `envelopes` and write its output: the whole derivation, before it is
/// published.
pub fn compute(rec: &mut Record, envelopes: &[&[u8]]) -> Result<Written, String> {
let (samples, exploded) = read_inputs(rec, envelopes)?;
if let Some(env) = exploded {
return Ok(Written { envelope: env, samples: None });
}
let out = ops::evaluate(rec.derivation, &rec.params, &samples).ok_or("the inputs do not satisfy the parameters")?;
let envelope = write(rec, &out)?;
Ok(Written { envelope, samples: Some(out) })
}
/// Publish `envelope` as the output of `rec` (§6): one object in a group of the edge's
/// track, its receipt labelled with the record's energy, and the record signed.
pub fn publish(mut rec: Record, envelope: Vec<u8>, p: &Publish) -> Derived {
let hash = content_hash(&envelope);
let (joules, label) = match &rec.energy {
None => (0, ObjectLabel::Unmetered),
Some(e) => (e.joules_micro, ObjectLabel::Class(e.class.clone())),
};
let mut b = GroupBuilder::default();
b.add(hash, joules, p.origin, Some(label));
let (group, mut objects) = b
.finish(p.key, p.signer_id, p.track_alias, p.group_id, p.time, p.time, p.parent, true)
.expect("one labelled object composes to a class");
rec.output.content_hash = hash;
rec.output.receipt = group.link_hash();
rec.output.object_id = 0;
rec.signer_id = p.signer_id.to_owned();
let record = rec.sign(p.key);
Derived { envelope, group, object: objects.remove(0), record }
}
/// The whole derive: §4's derivation over `inputs`, published under `p` (§6), carrying every
/// provenance item of every input forward (§6.1), and labelled no lower than any input or than
/// `own`, the label the derivation's own step warrants (§6.2).
#[allow(clippy::too_many_arguments)]
pub fn derive(
d: Derivation,
params: Params,
encoder: Option<Encoder>,
output_capability: &str,
inputs: &[InputObject<'_>],
energy: Option<Energy>,
own: Option<Synthesis>,
p: &Publish<'_>,
) -> Result<Derived, String> {
let named: Vec<Input> = inputs
.iter()
.map(|i| Input {
content_hash: i.object.content_hash,
capability: i.capability.to_owned(),
receipt: i.group.link_hash(),
object_id: i.object.object_id,
frame: i.frame,
samples: None,
derivation: i.record.map(Record::hash),
synthesis: i.synthesis.max(i.record.and_then(|r| r.synthesis)),
})
.collect();
let mut rec = draft(d, params, encoder, output_capability, named, energy, p.time);
rec.synthesis = rec.synthesis.max(own);
rec.provenance = ordered(inputs.iter().enumerate().flat_map(|(i, x)| provenance_of(i, x)).collect());
let envelopes: Vec<&[u8]> = inputs.iter().map(|i| i.envelope).collect();
let written = compute(&mut rec, &envelopes)?;
let derived = publish(rec, written.envelope, p);
// What it signed reads under §5.3: a derive that would sign a record a verifier
// refuses at reading stops here.
Record::read(&derived.record.to_json()).map_err(|r| format!("the record does not read: {r}"))?;
Ok(derived)
}

View file

@ -0,0 +1,304 @@
//! What a derivation computes (`extensions/deriving-edge.md` §4): reading an input as
//! samples (§4.1), the operations (§4.2), the output's pinned manifest and payload (§4.3),
//! and the explode (§4.4). An edge and a verifier run the same functions: what the edge
//! wrote is what the verifier recomputes.
use super::{sha256, Derivation, Encoder, Flip, Params, Record, DOMAIN_RENDER_SETTINGS};
use crate::claim_receipt::{hex, jcs};
use crate::codecs::{CAP_IMAGE_PNG, CAP_VIDEO_INT_MOTION};
use crate::container::WaiMulti;
use serde_json::Value;
/// An image as the canonical `RGB8` buffer (reconstruction-binding §2).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Samples {
pub w: u32,
pub h: u32,
pub rgb: Vec<u8>,
}
impl Samples {
/// The samples digest: SHA-256 over the buffer.
pub fn digest(&self) -> [u8; 32] {
sha256(&self.rgb)
}
fn at(&self, x: u32, y: u32) -> &[u8] {
let i = 3 * (y as usize * self.w as usize + x as usize);
&self.rgb[i..i + 3]
}
}
/// A `WAI1` envelope's manifest and payload, when `env` is exactly one `WAI1` envelope:
/// the magic, the two length-prefixed parts, and nothing after the payload.
pub fn split_wai1(env: &[u8]) -> Option<(&[u8], &[u8])> {
if env.len() < 12 || &env[..4] != crate::container::MAGIC {
return None;
}
let ml = u32::from_le_bytes(env[4..8].try_into().ok()?) as usize;
let p_at = 8usize.checked_add(ml)?;
let pl = u32::from_le_bytes(env.get(p_at..p_at + 4)?.try_into().ok()?) as usize;
let end = p_at.checked_add(4)?.checked_add(pl)?;
if end != env.len() {
return None;
}
Some((&env[8..p_at], &env[p_at + 4..end]))
}
/// A `WAI1` envelope of `manifest` and `payload` (SPEC §2).
pub fn wai1(manifest: &[u8], payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(12 + manifest.len() + payload.len());
out.extend_from_slice(crate::container::MAGIC);
out.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
out.extend_from_slice(manifest);
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(payload);
out
}
/// The capability a `WAI1` envelope names, read by the reference reader.
pub fn wai1_capability(env: &[u8]) -> Option<String> {
split_wai1(env)?;
crate::container::Wai::from_bytes(env).ok().map(|w| w.capability().to_owned())
}
/// §4.1: input `env`, whose record says it is of `capability`, read at `frame` as samples.
/// `None` where it does not read so (`input_unreadable`).
pub fn read_samples(capability: &str, env: &[u8], frame: u64) -> Option<Samples> {
let (_, payload) = split_wai1(env)?;
if wai1_capability(env)? != capability {
return None;
}
match capability {
CAP_IMAGE_PNG => {
if frame != 0 {
return None;
}
let (w, h, rgb) = crate::png_still::decode(payload).ok()?;
let kinds = crate::png_still::chunk_kinds(payload)?;
if kinds.iter().any(|k| ![*b"IHDR", *b"IDAT", *b"IEND", super::C2PA_PNG_CHUNK].contains(k)) {
return None;
}
Some(Samples { w, h, rgb })
}
CAP_VIDEO_INT_MOTION => {
let v = crate::int_wire::parse_wiv1(payload, Some(0)).ok()?;
let s = crate::int_video::VideoStream {
n_frames: v.n_frames as usize,
h: v.h as usize,
w: v.w as usize,
block: v.block as usize,
q: v.q as i64,
offset: v.offset,
mv_precision: v.mv_precision,
cdf: &v.cdf,
mvs: &v.mvs,
rans: v.rans,
};
let clip = crate::int_video::decode_typed(&s).ok()?;
let rgb = clip.frames.into_iter().nth(usize::try_from(frame).ok()?)?;
Some(Samples { w: v.w, h: v.h, rgb })
}
_ => None,
}
}
/// The C2PA Manifest Stores embedded in an input (§6.1): for a `wai.image.png` envelope, the
/// data of each `caBX` chunk of its payload (C2PA 2.2 §A.3.2), in order. Read by the chunks'
/// lengths alone; a payload whose chunks do not walk embeds none here.
pub fn embedded_c2pa(capability: &str, env: &[u8]) -> Vec<Vec<u8>> {
let Some((_, p)) = split_wai1(env) else { return Vec::new() };
if capability != CAP_IMAGE_PNG || crate::png_still::chunk_kinds(p).is_none() {
return Vec::new();
}
let mut out = Vec::new();
let mut at = 8usize;
while at + 12 <= p.len() {
let len = u32::from_be_bytes(p[at..at + 4].try_into().expect("4")) as usize;
if p[at + 4..at + 8] == super::C2PA_PNG_CHUNK {
out.push(p[at + 8..at + 8 + len].to_vec());
}
at += 12 + len;
}
out
}
/// §4.4: component `component` of the `WAI2` envelope `env`, whose record says it is of
/// `capability`, exploded. `None` where it cannot be (`input_unreadable`).
pub fn explode(env: &[u8], component: u64, capability: &str) -> Option<Vec<u8>> {
let w = WaiMulti::from_bytes(env).ok()?;
let i = usize::try_from(component).ok()?;
if w.manifest.renditions.get(i)?.capability != capability {
return None;
}
WaiMulti::explode(env, i).ok()
}
/// §4.2: the operation `d` with `p` over `inputs`. `None` where the inputs cannot satisfy
/// the parameters (`params_invalid`). Scaling is the reference edge's nearest sample, which
/// no verifier holds an edge to.
pub fn evaluate(d: Derivation, p: &Params, inputs: &[Samples]) -> Option<Samples> {
let out = match (d, p) {
(Derivation::Identity, Params::None) => inputs.first()?.clone(),
(Derivation::Crop | Derivation::RoiSelection, Params::Region { x, y, w, h }) => {
let s = inputs.first()?;
if x.checked_add(*w)? > s.w as u64 || y.checked_add(*h)? > s.h as u64 {
return None;
}
let (x, y, w, h) = (*x as u32, *y as u32, *w as u32, *h as u32);
let mut rgb = Vec::with_capacity(3 * w as usize * h as usize);
for j in 0..h {
for i in 0..w {
rgb.extend_from_slice(s.at(x + i, y + j));
}
}
Samples { w, h, rgb }
}
(Derivation::Rotation, Params::Rotation { quarter_turns }) => {
let s = inputs.first()?;
let (sw, sh) = (s.w, s.h);
let (w, h) = if *quarter_turns == 2 { (sw, sh) } else { (sh, sw) };
let mut rgb = Vec::with_capacity(s.rgb.len());
for j in 0..h {
for i in 0..w {
let (x, y) = match quarter_turns {
1 => (j, sh - 1 - i),
2 => (sw - 1 - i, sh - 1 - j),
_ => (sw - 1 - j, i),
};
rgb.extend_from_slice(s.at(x, y));
}
}
Samples { w, h, rgb }
}
(Derivation::Mirror, Params::Mirror { flip }) => {
let s = inputs.first()?;
let mut rgb = Vec::with_capacity(s.rgb.len());
for j in 0..s.h {
for i in 0..s.w {
let (x, y) = match flip {
Flip::LeftRight => (s.w - 1 - i, j),
Flip::TopBottom => (i, s.h - 1 - j),
};
rgb.extend_from_slice(s.at(x, y));
}
}
Samples { w: s.w, h: s.h, rgb }
}
(Derivation::GridComposition, Params::Grid { columns, rows }) => {
let first = inputs.first()?;
let (cw, ch) = (first.w, first.h);
if inputs.iter().any(|s| s.w != cw || s.h != ch) || inputs.len() as u64 != columns * rows {
return None;
}
let (w64, h64) = (*columns * cw as u64, *rows * ch as u64);
if !super::size_ok(w64, h64) {
return None;
}
let (w, h) = (w64 as u32, h64 as u32);
let mut rgb = Vec::with_capacity(3 * w as usize * h as usize);
for j in 0..h {
for i in 0..w {
let k = (j / ch) as usize * *columns as usize + (i / cw) as usize;
rgb.extend_from_slice(inputs[k].at(i % cw, j % ch));
}
}
Samples { w, h, rgb }
}
(Derivation::OverlayComposition, Params::Overlay { width, height, fill, at }) => {
if at.len() != inputs.len() {
return None;
}
let (w, h) = (*width as u32, *height as u32);
let mut rgb: Vec<u8> = fill.iter().copied().cycle().take(3 * w as usize * h as usize).collect();
for (s, (x, y)) in inputs.iter().zip(at) {
for j in 0..s.h as u64 {
for i in 0..s.w as u64 {
let (ox, oy) = (x + i, y + j);
if ox < *width && oy < *height {
let o = 3 * (oy as usize * w as usize + ox as usize);
rgb[o..o + 3].copy_from_slice(s.at(i as u32, j as u32));
}
}
}
}
Samples { w, h, rgb }
}
(Derivation::Scaling, Params::Scaling { width, height }) => {
let s = inputs.first()?;
let (w, h) = (*width as u32, *height as u32);
let mut rgb = Vec::with_capacity(3 * w as usize * h as usize);
for j in 0..h as u64 {
for i in 0..w as u64 {
let x = (i * s.w as u64 / *width) as u32;
let y = (j * s.h as u64 / *height) as u32;
rgb.extend_from_slice(s.at(x, y));
}
}
Samples { w, h, rgb }
}
_ => return None,
};
Some(out)
}
/// `derived_from.settings` (§4.3): `BLAKE3("wai:render-settings\x01" ‖ JCS({"derivation",
/// "encoder", "frame", "params"}))`.
pub fn settings_digest(r: &Record) -> [u8; 32] {
let encoder = r.encoder.as_ref().map_or(Value::Null, |e| serde_json::json!({ "id": e.id, "settings": e.settings }));
let settings = serde_json::json!({
"derivation": r.derivation.id(),
"encoder": encoder,
"frame": r.inputs.first().and_then(|i| i.frame),
"params": r.params.to_value(),
});
let mut h = blake3::Hasher::new();
h.update(DOMAIN_RENDER_SETTINGS);
h.update(jcs(&settings).expect("settings are within the claim subset").as_bytes());
*h.finalize().as_bytes()
}
/// `derived_from.renderer` (§4.3): the derivation and `@1` for a pinned writer, the
/// encoder's `id` otherwise.
pub fn renderer(r: &Record) -> String {
match &r.encoder {
None => format!("{}@1", r.derivation.id()),
Some(e) => e.id.clone(),
}
}
/// §4.3: the manifest of an evaluation's output of `width` × `height`, exactly.
pub fn output_manifest(r: &Record, width: u64, height: u64) -> String {
let cap = &r.output.capability;
let kind = cap.rsplit('.').next().unwrap_or(cap);
let mut s = format!(
r#"{{"wai":"1.0","media":"image","intent":"replicate","model_requirement":{{"capability":"{cap}"}},"conditioning":{{"kind":"{kind}"}}"#
);
if let [only] = r.inputs.as_slice() {
s.push_str(&format!(
r#","derived_from":{{"motif":"{}","motif_capability":"{}","renderer":"{}","settings":"{}"}}"#,
hex(&only.content_hash),
only.capability,
renderer(r),
hex(&settings_digest(r)),
));
}
s.push_str(&format!(
r#","target":{{"frames":1,"h":{height},"w":{width}}}}}"#
));
s
}
/// The quality the reference edge's JPEG encoder reads from its settings.
pub fn jpeg_quality(e: &Encoder) -> Option<u8> {
e.settings.get("quality")?.as_u64().filter(|q| (1..=100).contains(q)).map(|q| q as u8)
}
/// The reference edge's JPEG writer: a baseline JPEG of `s` at `quality`. Not pinned by the
/// extension (§4.3): a record naming it is `attest`.
pub fn write_jpeg(s: &Samples, quality: u8) -> Option<Vec<u8>> {
let (w, h) = (u16::try_from(s.w).ok()?, u16::try_from(s.h).ok()?);
let mut out = Vec::new();
jpeg_encoder::Encoder::new(&mut out, quality).encode(&s.rgb, w, h, jpeg_encoder::ColorType::Rgb).ok()?;
Some(out)
}

View file

@ -0,0 +1,207 @@
use super::ops::{self, Samples};
use super::*;
fn pic(w: u32, h: u32) -> Samples {
// Each pixel's bytes are (x, y, 7·x + y), so a position can be read back from a pixel.
let rgb = (0..h).flat_map(|y| (0..w).flat_map(move |x| [x as u8, y as u8, (7 * x + y) as u8])).collect();
Samples { w, h, rgb }
}
fn px(s: &Samples, x: u32, y: u32) -> (u8, u8) {
let i = 3 * (y * s.w + x) as usize;
(s.rgb[i], s.rgb[i + 1])
}
/// §9's codes are distinct, and `ALL` holds each reason once.
#[test]
fn reasons_are_distinct() {
let codes: std::collections::BTreeSet<&str> = Reason::ALL.iter().map(|r| r.code()).collect();
assert_eq!(codes.len(), Reason::ALL.len());
}
/// Every operation of §4.2 but scaling is exact by derived-tracks §3, read from the
/// registry's own composition rule; the explode is pinned by SPEC §7.1.
#[test]
fn exactness_is_the_registrys() {
for d in Derivation::ALL {
assert_eq!(d.exact(4), d != Derivation::Scaling, "{}", d.id());
}
}
/// §4.5: replicate exactly for an exact operation written as a pinned still, or an explode.
#[test]
fn the_check_follows_the_steps() {
let enc = Encoder { id: "x@1".into(), settings: serde_json::json!({}) };
assert_eq!(check_of(Derivation::Crop, 1, CAP_IMAGE_PNG, None), Check::Replicate);
assert_eq!(check_of(Derivation::Crop, 1, CAP_IMAGE_PNG, Some(&enc)), Check::Attest);
assert_eq!(check_of(Derivation::Crop, 1, CAP_IMAGE_JPEG, Some(&enc)), Check::Attest);
assert_eq!(check_of(Derivation::Scaling, 1, CAP_IMAGE_PNG, None), Check::Attest);
assert_eq!(check_of(Derivation::Explode, 1, CAP_IMAGE_JPEG, None), Check::Replicate);
assert_eq!(check_of(Derivation::GridComposition, 6, CAP_IMAGE_PNG, None), Check::Replicate);
}
/// §4.2's rules, pixel by pixel.
#[test]
fn the_operations_move_samples_as_stated() {
let s = pic(4, 3);
let one = |d, p: Params| ops::evaluate(d, &p, std::slice::from_ref(&s)).expect("evaluates");
let r1 = one(Derivation::Rotation, Params::Rotation { quarter_turns: 1 });
assert_eq!((r1.w, r1.h), (3, 4));
// A quarter turn clockwise puts the bottom-left pixel at the top-left.
assert_eq!(px(&r1, 0, 0), (0, 2));
assert_eq!(px(&r1, 2, 0), (0, 0));
let r2 = one(Derivation::Rotation, Params::Rotation { quarter_turns: 2 });
assert_eq!(px(&r2, 0, 0), (3, 2));
let r3 = one(Derivation::Rotation, Params::Rotation { quarter_turns: 3 });
assert_eq!(px(&r3, 0, 0), (3, 0));
// One turn and three come back to the picture.
let back = ops::evaluate(Derivation::Rotation, &Params::Rotation { quarter_turns: 3 }, &[r1]).unwrap();
assert_eq!(back, s);
let m = one(Derivation::Mirror, Params::Mirror { flip: Flip::LeftRight });
assert_eq!(px(&m, 0, 1), (3, 1));
let m = one(Derivation::Mirror, Params::Mirror { flip: Flip::TopBottom });
assert_eq!(px(&m, 1, 0), (1, 2));
let c = one(Derivation::Crop, Params::Region { x: 1, y: 1, w: 2, h: 2 });
assert_eq!((c.w, c.h, px(&c, 0, 0), px(&c, 1, 1)), (2, 2, (1, 1), (2, 2)));
assert_eq!(one(Derivation::RoiSelection, Params::Region { x: 1, y: 1, w: 2, h: 2 }), c);
assert!(ops::evaluate(Derivation::Crop, &Params::Region { x: 3, y: 0, w: 2, h: 1 }, std::slice::from_ref(&s)).is_none());
assert_eq!(one(Derivation::Identity, Params::None), s);
}
#[test]
fn compositions_place_their_inputs() {
let (a, b) = (pic(2, 2), Samples { w: 2, h: 2, rgb: vec![9; 12] });
let g = ops::evaluate(Derivation::GridComposition, &Params::Grid { columns: 2, rows: 1 }, &[a.clone(), b.clone()]).unwrap();
assert_eq!((g.w, g.h), (4, 2));
assert_eq!(px(&g, 1, 1), (1, 1));
assert_eq!(px(&g, 2, 0), (9, 9));
// Inputs of different sizes do not make a grid.
assert!(ops::evaluate(Derivation::GridComposition, &Params::Grid { columns: 2, rows: 1 }, &[a.clone(), pic(3, 2)]).is_none());
let o = ops::evaluate(
Derivation::OverlayComposition,
&Params::Overlay { width: 3, height: 3, fill: [7, 7, 7], at: vec![(0, 0), (2, 2)] },
&[a, b],
)
.unwrap();
assert_eq!(px(&o, 2, 0), (7, 7), "the fill where nothing is placed");
assert_eq!(px(&o, 1, 1), (1, 1));
assert_eq!(px(&o, 2, 2), (9, 9), "the second input over the canvas, its other pixels dropped");
}
fn record() -> Record {
let input = Input {
content_hash: [1; 32],
capability: CAP_VIDEO_INT_MOTION.into(),
receipt: [2; 32],
object_id: 3,
frame: Some(1),
samples: Some([4; 32]),
derivation: None,
synthesis: None,
};
let mut r = edge::draft(Derivation::Crop, Params::Region { x: 0, y: 0, w: 2, h: 2 }, None, CAP_IMAGE_PNG, vec![input], None, "2026-10-20T12:00:00Z");
r.output = Output {
content_hash: [5; 32],
capability: CAP_IMAGE_PNG.into(),
receipt: [6; 32],
object_id: 0,
width: Some(2),
height: Some(2),
samples: Some([7; 32]),
};
r.signer_id = "did:web:edge.example#k".into();
r.sign(&ed25519_dalek::SigningKey::from_bytes(&[9; 32]))
}
#[test]
fn a_record_round_trips_and_its_signature_holds() {
let r = record();
let text = r.to_json();
assert_eq!(Record::read(&text).unwrap(), r);
assert!(r.verify_signature().is_ok());
assert_eq!(held_hash(&text), Some(r.hash()));
let mut bad = r.clone();
bad.output.object_id = 1;
assert_eq!(bad.verify_signature(), Err(Reason::BadSignature));
}
/// §5.3's rules refuse in their order.
#[test]
fn reading_refuses_by_rule() {
let r = record();
let edit = |f: &dyn Fn(&mut serde_json::Value)| {
let mut v = r.to_value();
f(&mut v);
Record::read(&serde_json::to_string(&v).unwrap())
};
assert_eq!(Record::read(&r.to_json().replacen("{", "{\"kind\":\"wai-derivation\",", 1)), Err(Reason::RecordMalformed), "a member twice");
assert_eq!(edit(&|v| v["params"]["x"] = serde_json::json!(true)), Err(Reason::RecordMalformed), "a boolean");
assert_eq!(edit(&|v| v["issued_at"] = serde_json::json!("yesterday")), Err(Reason::RecordMalformed));
assert_eq!(edit(&|v| v["derivation"] = serde_json::json!("wai.derive.srgb_fill")), Err(Reason::DerivationUnknown));
assert_eq!(edit(&|v| v["params"]["w"] = serde_json::json!(0)), Err(Reason::ParamsInvalid));
assert_eq!(edit(&|v| v["output"]["capability"] = serde_json::json!(CAP_IMAGE_JPEG)), Err(Reason::ParamsInvalid), "a JPEG with no encoder");
assert_eq!(edit(&|v| v["inputs"][0]["frame"] = serde_json::Value::Null), Err(Reason::InputsInvalid));
assert_eq!(edit(&|v| v["check"] = serde_json::json!("attest")), Err(Reason::CheckMismatch));
let energy = serde_json::json!({"joules_micro": 0, "energy_provenance": "ModelBased", "energy_uncertainty": null,
"energy_counter_filtering": null, "energy_calibration_ref": null});
assert_eq!(edit(&|v| v["energy"] = energy.clone()), Err(Reason::EnergyInvalid), "a class beside a zero figure");
let shunt = serde_json::json!({"joules_micro": 9, "energy_provenance": "HwShunt", "energy_uncertainty": null,
"energy_counter_filtering": null, "energy_calibration_ref": null});
assert_eq!(edit(&|v| v["energy"] = shunt.clone()), Err(Reason::EnergyInvalid), "the legacy label");
}
/// §4.3's manifest, exactly.
#[test]
fn the_output_manifest_is_pinned() {
let r = record();
let m = ops::output_manifest(&r, 2, 2);
let settings = hex(&ops::settings_digest(&r));
assert_eq!(
m,
format!(
"{{\"wai\":\"1.0\",\"media\":\"image\",\"intent\":\"replicate\",\"model_requirement\":{{\"capability\":\"wai.image.png\"}},\"conditioning\":{{\"kind\":\"png\"}},\"derived_from\":{{\"motif\":\"{}\",\"motif_capability\":\"wai.video.int_motion\",\"renderer\":\"wai.derive.crop@1\",\"settings\":\"{settings}\"}},\"target\":{{\"frames\":1,\"h\":2,\"w\":2}}}}",
hex(&[1; 32])
)
);
// It reads as a WAI1 manifest.
let env = ops::wai1(m.as_bytes(), b"x");
assert_eq!(ops::wai1_capability(&env).as_deref(), Some(CAP_IMAGE_PNG));
}
/// §5.3 rule 1 holds the provenance items to their order, and rule 7 the label to its inputs'.
#[test]
fn provenance_and_label_are_read_by_rule() {
let mut r = record();
let item = |d: u8, c| ProvItem { input: 0, kind: ProvKind::C2pa, digest: [d; 32], compartment: c };
r.provenance = vec![item(1, Compartment::System), item(2, Compartment::Personal)];
let k = ed25519_dalek::SigningKey::from_bytes(&[9; 32]);
let ok = r.clone().sign(&k);
assert_eq!(Record::read(&ok.to_json()).unwrap(), ok);
let mut out_of_order = r.clone();
out_of_order.provenance.reverse();
assert_eq!(Record::read(&out_of_order.sign(&k).to_json()), Err(Reason::RecordMalformed));
let mut past = r.clone();
past.provenance[1].input = 1;
assert_eq!(Record::read(&past.sign(&k).to_json()), Err(Reason::RecordMalformed), "an item of an input the record does not have");
let mut lowered = r.clone();
lowered.inputs[0].synthesis = Some(Synthesis::Generate);
lowered.synthesis = Some(Synthesis::Assist);
assert_eq!(Record::read(&lowered.clone().sign(&k).to_json()), Err(Reason::SynthesisDropped));
lowered.synthesis = Some(Synthesis::Generate);
assert!(Record::read(&lowered.sign(&k).to_json()).is_ok(), "a label may rise");
}
#[test]
fn embedded_stores_and_claims_are_found() {
let s = Samples { w: 2, h: 1, rgb: vec![1, 2, 3, 4, 5, 6] };
let mut png = crate::png_still::encode(&s.rgb, 2, 1);
let rest = png.split_off(33);
crate::png_still::chunk(&mut png, &C2PA_PNG_CHUNK, b"store");
png.extend_from_slice(&rest);
let env = ops::wai1(b"{\"wai\":\"1.0\",\"media\":\"image\",\"intent\":\"replicate\",\"model_requirement\":{\"capability\":\"wai.image.png\"},\"conditioning\":{\"kind\":\"png\"}}", &png);
assert_eq!(ops::embedded_c2pa(CAP_IMAGE_PNG, &env), vec![b"store".to_vec()]);
assert_eq!(ops::read_samples(CAP_IMAGE_PNG, &env, 0), Some(s), "a store does not stop the reading");
let claim = r#"{"kind":"claim","subject_type":"object","subject":"0101010101010101010101010101010101010101010101010101010101010101","class":"x"}"#;
assert_eq!(claim_about(claim).map(|c| c.0), Some([1; 32]));
assert!(claim_about(&claim.replace("\"object\"", "\"receipt\"")).is_none());
}

View file

@ -0,0 +1,577 @@
//! Verification of a derived object back to its source (`extensions/deriving-edge.md` §7,
//! §8): each hop of its chain read, its signature checked, its output bound to the edge's
//! delivery receipts, its inputs to theirs and, hop by hop, to the records that derived
//! them; each `replicate` hop whose inputs are held re-executed and compared byte for byte;
//! the edge's authority from its sources' origins; and the sink's policy. The facts of an
//! accepted chain are §7.4's.
use super::ops::{self, Samples};
use super::{
claim_about, held_hash, sha256, Check, Derivation, Params, ProvItem, ProvKind, Reason, Record, Synthesis, AUTHORITY,
MAX_HOPS,
};
use crate::claim_receipt::hex;
use crate::jwp_delivery::{GroupReceipt, ObjectLabel, ObjectReceipt};
use crate::jwp_hash::content_hash;
use crate::key_log::{EntryStatus, Revision};
use crate::signer_id::{classify, SignerIdForm};
use serde_json::{json, Value};
use std::collections::{BTreeMap, BTreeSet};
/// The sink's policy (§7.1).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Policy {
/// `attested: refuse`.
pub refuse_attested: bool,
/// `recompute: required`.
pub require_recompute: bool,
/// `authority: required`.
pub require_authority: bool,
}
impl Policy {
/// Its JSON form, every member written.
pub fn to_value(&self) -> Value {
json!({
"attested": if self.refuse_attested { "refuse" } else { "accept" },
"recompute": if self.require_recompute { "required" } else { "when-held" },
"authority": if self.require_authority { "required" } else { "when-given" },
})
}
/// Read its JSON form: an absent member takes its default.
pub fn from_value(v: &Value) -> Option<Policy> {
let m = v.as_object()?;
let pick = |k: &str, default: &str, other: &str| -> Option<bool> {
match m.get(k).map(|v| v.as_str()) {
None => Some(false),
Some(Some(s)) if s == default => Some(false),
Some(Some(s)) if s == other => Some(true),
_ => None,
}
};
Some(Policy {
refuse_attested: pick("attested", "accept", "refuse")?,
require_recompute: pick("recompute", "when-held", "required")?,
require_authority: pick("authority", "when-given", "required")?,
})
}
}
/// What a verifier holds (§7.1).
#[derive(Clone, Debug, Default)]
pub struct Held {
pub records: Vec<String>,
pub receipts: Vec<Value>,
pub objects: BTreeMap<[u8; 32], Vec<u8>>,
pub sources: Vec<[u8; 32]>,
pub heads: BTreeMap<String, String>,
pub policy: Policy,
/// Claim texts it holds (jwp-receipts §2.4.3): those about an input are its provenance.
pub claims: Vec<String>,
/// C2PA Manifest Stores it holds bound to an object, by the object's content hash.
pub c2pa: BTreeMap<[u8; 32], Vec<Vec<u8>>>,
}
/// Where a refusal happened (§7.2): a record by its position among the records given.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Place {
Record(usize),
Output(usize),
Input(usize, usize),
Recompute(usize),
Authority(usize, Option<String>),
Policy(usize),
}
impl Place {
pub fn to_value(&self) -> Value {
match self {
Place::Record(r) => json!(["record", r]),
Place::Output(r) => json!(["record", r, "output"]),
Place::Input(r, i) => json!(["record", r, "input", i]),
Place::Recompute(r) => json!(["record", r, "recompute"]),
Place::Authority(r, o) => json!(["record", r, "authority", o]),
Place::Policy(r) => json!(["record", r, "policy"]),
}
}
}
/// A refusal: why, and where.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Refusal {
pub reason: Reason,
pub at: Place,
}
fn refuse<T>(reason: Reason, at: Place) -> Result<T, Refusal> {
Err(Refusal { reason, at })
}
/// How a hop was held to its record (§7.4).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Checked {
Recomputed,
NotRecomputed,
Attested,
}
impl Checked {
pub fn label(self) -> &'static str {
match self {
Checked::Recomputed => "recomputed",
Checked::NotRecomputed => "not-recomputed",
Checked::Attested => "attested",
}
}
}
/// A hop's standing under §8.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Authority {
Confirmed,
Unconfirmed,
None,
}
impl Authority {
pub fn label(self) -> &'static str {
match self {
Authority::Confirmed => "confirmed",
Authority::Unconfirmed => "unconfirmed",
Authority::None => "none",
}
}
}
/// One hop of an accepted chain (§7.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Hop {
pub record: [u8; 32],
pub signer: [u8; 32],
pub derivation: Derivation,
pub check: Check,
pub checked: Checked,
/// The figure and its class's label.
pub energy: Option<(u64, String)>,
pub authority: Authority,
pub synthesis: Option<Synthesis>,
}
/// A source object an accepted chain reaches (§7.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Source {
pub object: [u8; 32],
pub signer: [u8; 32],
pub signer_id: String,
}
/// What an accepted chain states (§7.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Facts {
pub subject: [u8; 32],
pub hops: Vec<Hop>,
pub sources: Vec<Source>,
/// Per class, strongest first: the class's label and the sum of its figures.
pub energy: Vec<(String, u64)>,
pub unmetered: u64,
/// The provenance items the subject's record carries (§6.1).
pub provenance: Vec<ProvItem>,
}
impl Facts {
pub fn to_value(&self) -> Value {
json!({
"subject": hex(&self.subject),
"hops": self.hops.iter().map(|h| json!({
"record": hex(&h.record),
"signer": hex(&h.signer),
"derivation": h.derivation.id(),
"check": h.check.label(),
"checked": h.checked.label(),
"energy": h.energy.as_ref().map_or(Value::Null, |(j, c)| json!({ "joules_micro": j, "class": c })),
"authority": h.authority.label(),
"synthesis": h.synthesis.map_or(Value::Null, |x| Value::from(x.label())),
})).collect::<Vec<_>>(),
"sources": self.sources.iter().map(|s| json!({
"object": hex(&s.object),
"signer": hex(&s.signer),
"signer_id": s.signer_id,
})).collect::<Vec<_>>(),
"energy": self.energy.iter().map(|(c, j)| json!({ "class": c, "joules_micro": j })).collect::<Vec<_>>(),
"unmetered": self.unmetered,
"provenance": self.provenance.iter().map(|p| json!({
"input": p.input,
"kind": p.kind.label(),
"digest": hex(&p.digest),
"compartment": p.compartment.label(),
})).collect::<Vec<_>>(),
})
}
}
/// The class labels in energy-measurement §2's order, strongest first.
const CLASS_ORDER: [&str; 5] = ["CalibratedInstrument", "OnChipCounter", "HwShunt", "ModelBased", "Estimator"];
/// Verify the derived object whose record is `records[subject]` (§7).
pub fn verify(held: &Held, subject: usize) -> Result<Facts, Refusal> {
let mut v = Verifier::new(held);
v.hop(subject, 1)?;
let provenance = Record::read(&held.records[subject]).map(|r| r.provenance).unwrap_or_default();
let subject_hash = v.hops.first().map(|h| h.record).unwrap_or_default();
let mut totals: BTreeMap<&str, u64> = BTreeMap::new();
let mut unmetered = 0;
for h in &v.hops {
match &h.energy {
Some((j, c)) => {
let c = CLASS_ORDER.iter().find(|x| **x == c.as_str()).copied().unwrap_or("Estimator");
*totals.entry(c).or_default() += j;
}
None => unmetered += 1,
}
}
let energy = CLASS_ORDER.iter().filter_map(|c| totals.get(c).map(|j| ((*c).to_owned(), *j))).collect();
Ok(Facts { subject: subject_hash, hops: v.hops, sources: v.sources, energy, unmetered, provenance })
}
struct Verifier<'a> {
held: &'a Held,
by_hash: BTreeMap<[u8; 32], usize>,
groups: Vec<([u8; 32], GroupReceipt)>,
objects: Vec<ObjectReceipt>,
/// Each record verified, with its origins.
done: BTreeMap<usize, BTreeSet<String>>,
hops: Vec<Hop>,
sources: Vec<Source>,
}
/// An input's or output's receipts as §7.2 steps 3 and 4.1 find them.
enum Found<'a> {
NotHeld,
Invalid,
Ok(&'a GroupReceipt, &'a ObjectReceipt),
}
impl<'a> Verifier<'a> {
fn new(held: &'a Held) -> Verifier<'a> {
let mut by_hash = BTreeMap::new();
for (i, t) in held.records.iter().enumerate() {
if let Some(h) = held_hash(t) {
by_hash.entry(h).or_insert(i);
}
}
let mut groups = Vec::new();
let mut objects = Vec::new();
for r in &held.receipts {
match r.get("kind").and_then(Value::as_str) {
Some("group") => {
if let Ok(g) = GroupReceipt::from_json(r) {
groups.push((g.link_hash(), g));
}
}
Some("object") => {
if let Ok(o) = ObjectReceipt::from_json(r) {
objects.push(o);
}
}
_ => {}
}
}
Verifier { held, by_hash, groups, objects, done: BTreeMap::new(), hops: Vec::new(), sources: Vec::new() }
}
/// The group receipt of link hash `link` and its object receipt at `object_id`, which
/// must verify and name `content`.
fn receipts(&self, link: &[u8; 32], object_id: u64, content: &[u8; 32]) -> Found<'_> {
let Some((_, g)) = self.groups.iter().find(|(l, _)| l == link) else { return Found::NotHeld };
let cands: Vec<&ObjectReceipt> = self
.objects
.iter()
.filter(|o| o.track_alias == g.track_alias && o.group_id == g.group_id && o.object_id == object_id)
.collect();
if cands.is_empty() {
return Found::NotHeld;
}
if g.verify().is_err() {
return Found::Invalid;
}
match cands.into_iter().find(|o| o.content_hash == *content && o.verify_inclusion(g).is_ok()) {
Some(o) => Found::Ok(g, o),
None => Found::Invalid,
}
}
/// §7.2 for `records[r]`, reached at `depth` hops from the subject. Its origins (§8).
fn hop(&mut self, r: usize, depth: usize) -> Result<BTreeSet<String>, Refusal> {
if let Some(o) = self.done.get(&r) {
return Ok(o.clone());
}
let held = self.held;
let text = held.records.get(r).ok_or(Refusal { reason: Reason::HopNotHeld, at: Place::Record(r) })?;
// Step 1, step 2.
let rec = Record::read(text).map_err(|reason| Refusal { reason, at: Place::Record(r) })?;
rec.verify_signature().map_err(|reason| Refusal { reason, at: Place::Record(r) })?;
let me = self.hops.len();
self.hops.push(Hop {
record: rec.hash(),
signer: rec.signer_pubkey,
derivation: rec.derivation,
check: rec.check,
checked: Checked::Attested,
energy: rec.energy.as_ref().map(|e| (e.joules_micro, e.class.label().to_owned())),
authority: Authority::None,
synthesis: rec.synthesis,
});
self.output(r, &rec)?;
// Step 4.
let mut origins = BTreeSet::new();
let mut read: Vec<Option<Read>> = Vec::new();
for (i, input) in rec.inputs.iter().enumerate() {
let at = || Place::Input(r, i);
let g = match self.receipts(&input.receipt, input.object_id, &input.content_hash) {
Found::NotHeld => return refuse(Reason::InputReceiptNotHeld, at()),
Found::Invalid => return refuse(Reason::InputReceiptInvalid, at()),
Found::Ok(g, _) => g.clone(),
};
let mut below: Option<Record> = None;
match input.derivation {
None => {
if !held.sources.contains(&g.signer_pubkey) {
return refuse(Reason::ChainSkipsHop, at());
}
if let SignerIdForm::OriginBound { origin, .. } = classify(&g.signer_id) {
origins.insert(origin);
}
if !self.sources.iter().any(|s| s.object == input.content_hash) {
self.sources.push(Source {
object: input.content_hash,
signer: g.signer_pubkey,
signer_id: g.signer_id.clone(),
});
}
}
Some(h) => {
if depth + 1 > MAX_HOPS {
return refuse(Reason::ChainTooLong, at());
}
let Some(&j) = self.by_hash.get(&h) else { return refuse(Reason::HopNotHeld, at()) };
let below_rec =
Record::read(&held.records[j]).map_err(|reason| Refusal { reason, at: Place::Record(j) })?;
let o = &below_rec.output;
if o.content_hash != input.content_hash
|| o.capability != input.capability
|| o.receipt != input.receipt
|| o.object_id != input.object_id
{
return refuse(Reason::HopMismatch, at());
}
origins.extend(self.hop(j, depth + 1)?);
below = Some(below_rec);
}
}
let env = held.objects.get(&input.content_hash);
read.push(match env {
None => None,
Some(env) => Some(read_input(&rec, i, env).map_err(|reason| Refusal { reason, at: at() })?),
});
// Step 4.5: the provenance it had, carried forward, and its label not lowered.
let listed: BTreeSet<(ProvKind, [u8; 32])> =
rec.provenance.iter().filter(|p| p.input == i as u64).map(|p| (p.kind, p.digest)).collect();
let mut had: Vec<(ProvKind, [u8; 32])> = held
.claims
.iter()
.filter_map(|t| claim_about(t))
.filter(|(subject, _)| *subject == input.content_hash)
.map(|(_, link)| (ProvKind::Claim, link))
.collect();
let stores = held.c2pa.get(&input.content_hash).into_iter().flatten().cloned();
let embedded = env.map(|e| ops::embedded_c2pa(&input.capability, e)).unwrap_or_default();
had.extend(stores.chain(embedded).map(|b| (ProvKind::C2pa, sha256(&b))));
if had.iter().any(|x| !listed.contains(x)) {
return refuse(Reason::ProvenanceStripped, at());
}
if let Some(b) = &below {
let carried = b.provenance.iter().all(|p| {
rec.provenance
.iter()
.any(|q| q.input == i as u64 && q.kind == p.kind && q.digest == p.digest && q.compartment == p.compartment)
});
if !carried {
return refuse(Reason::ProvenanceStripped, at());
}
if input.synthesis < b.synthesis {
return refuse(Reason::SynthesisDropped, at());
}
}
}
// Step 5.
let checked = self.recompute(r, &rec, &read)?;
// Step 6.
let authority = self.authority(r, &rec, &origins)?;
// Step 7.
if rec.check == Check::Attest && held.policy.refuse_attested {
return refuse(Reason::AttestedRefused, Place::Policy(r));
}
self.hops[me].checked = checked;
self.hops[me].authority = authority;
self.done.insert(r, origins.clone());
Ok(origins)
}
/// Step 3.
fn output(&self, r: usize, rec: &Record) -> Result<(), Refusal> {
let at = || Place::Output(r);
let out = &rec.output;
let (g, o) = match self.receipts(&out.receipt, out.object_id, &out.content_hash) {
Found::NotHeld => return refuse(Reason::OutputReceiptNotHeld, at()),
Found::Invalid => return refuse(Reason::OutputReceiptInvalid, at()),
Found::Ok(g, o) => (g, o),
};
if g.signer_pubkey != rec.signer_pubkey {
return refuse(Reason::OutputReceiptMismatch, at());
}
let figure = rec.energy.as_ref().map_or(0, |e| e.joules_micro);
let label_agrees = match (&o.label, &rec.energy) {
(None, _) => true,
(Some(ObjectLabel::Unmetered), None) => true,
(Some(ObjectLabel::Class(p)), Some(e)) => *p == e.class,
_ => false,
};
if o.joules_micro != figure || !label_agrees {
return refuse(Reason::EnergyDisagrees, at());
}
let Some(env) = self.held.objects.get(&out.content_hash) else { return Ok(()) };
if content_hash(env) != out.content_hash {
return refuse(Reason::OutputHashMismatch, at());
}
if rec.derivation.evaluates() {
let (w, h) = (out.width.unwrap_or(0), out.height.unwrap_or(0));
let Some((manifest, payload)) = ops::split_wai1(env) else {
return refuse(Reason::OutputManifestMismatch, at());
};
if manifest != ops::output_manifest(rec, w, h).as_bytes() {
return refuse(Reason::OutputManifestMismatch, at());
}
if out.capability == crate::codecs::CAP_IMAGE_PNG {
let ok = crate::png_still::decode(payload).is_ok_and(|(pw, ph, rgb)| {
pw as u64 == w && ph as u64 == h && Some(super::sha256(&rgb)) == out.samples
});
if !ok {
return refuse(Reason::SamplesMismatch, at());
}
}
} else if ops::wai1_capability(env).as_deref() != Some(out.capability.as_str()) {
return refuse(Reason::OutputManifestMismatch, at());
}
Ok(())
}
/// Step 5.
fn recompute(&self, r: usize, rec: &Record, read: &[Option<Read>]) -> Result<Checked, Refusal> {
let at = || Place::Recompute(r);
let all: Option<Vec<&Read>> = read.iter().map(Option::as_ref).collect();
let Some(all) = all else {
return match rec.check {
Check::Attest => Ok(Checked::Attested),
Check::Replicate if self.held.policy.require_recompute => refuse(Reason::InputsNotHeld, at()),
Check::Replicate => Ok(Checked::NotRecomputed),
};
};
if !rec.derivation.evaluates() {
let Read::Exploded(env) = all[0] else { unreachable!("an explode reads an envelope") };
if content_hash(env) != rec.output.content_hash {
return refuse(Reason::ReplicateMismatch, at());
}
return Ok(Checked::Recomputed);
}
let samples: Vec<Samples> = all
.iter()
.map(|x| match x {
Read::Samples(s) => s.clone(),
Read::Exploded(_) => unreachable!("an evaluation reads samples"),
})
.collect();
let exact = rec.derivation.exact(samples.len());
if !exact {
// An attested operation: nothing of it is pinned to recompute.
return Ok(Checked::Attested);
}
let out = ops::evaluate(rec.derivation, &rec.params, &samples)
.ok_or(Refusal { reason: Reason::ParamsInvalid, at: at() })?;
let samples_agree = Some(out.digest()) == rec.output.samples
&& Some(out.w as u64) == rec.output.width
&& Some(out.h as u64) == rec.output.height;
match rec.check {
Check::Attest => {
if !samples_agree {
return refuse(Reason::SamplesMismatch, at());
}
Ok(Checked::Attested)
}
Check::Replicate => {
let manifest = ops::output_manifest(rec, out.w as u64, out.h as u64);
let env = ops::wai1(manifest.as_bytes(), &crate::png_still::encode(&out.rgb, out.w, out.h));
if !samples_agree || content_hash(&env) != rec.output.content_hash {
return refuse(Reason::ReplicateMismatch, at());
}
Ok(Checked::Recomputed)
}
}
}
/// Step 6 (§8).
fn authority(&self, r: usize, rec: &Record, origins: &BTreeSet<String>) -> Result<Authority, Refusal> {
let mut confirmed = true;
for origin in origins {
let at = || Place::Authority(r, Some(origin.clone()));
let Some(text) = self.held.heads.get(origin) else {
if self.held.policy.require_authority {
return refuse(Reason::EdgeNotAuthorised, at());
}
confirmed = false;
continue;
};
let rev = Revision::from_json(text).map_err(|_| Refusal { reason: Reason::KeyLogNotHeld, at: at() })?;
rev.check_held(origin).map_err(|_| Refusal { reason: Reason::KeyLogNotHeld, at: at() })?;
let entry = rev
.keys
.iter()
.find(|e| e.key == rec.signer_pubkey && e.may_sign.iter().any(|a| a == AUTHORITY))
.ok_or(Refusal { reason: Reason::EdgeNotAuthorised, at: at() })?;
if entry.status == EntryStatus::Revoked {
return refuse(Reason::EdgeRevoked, at());
}
}
if origins.is_empty() {
if self.held.policy.require_authority {
return refuse(Reason::EdgeNotAuthorised, Place::Authority(r, None));
}
return Ok(Authority::None);
}
Ok(if confirmed { Authority::Confirmed } else { Authority::Unconfirmed })
}
}
/// What step 4.4 read of a held input: its samples, or for an explode the exploded
/// envelope.
enum Read {
Samples(Samples),
Exploded(Vec<u8>),
}
/// Step 4.4 for input `i` of `rec`, held as `env`.
fn read_input(rec: &Record, i: usize, env: &[u8]) -> Result<Read, Reason> {
let input = &rec.inputs[i];
if content_hash(env) != input.content_hash {
return Err(Reason::InputHashMismatch);
}
if !rec.derivation.evaluates() {
let Params::Explode { component } = rec.params else { unreachable!("an explode's parameters") };
return ops::explode(env, component, &input.capability).map(Read::Exploded).ok_or(Reason::InputUnreadable);
}
let s = ops::read_samples(&input.capability, env, input.frame.unwrap_or(0)).ok_or(Reason::InputUnreadable)?;
if Some(s.digest()) != input.samples {
return Err(Reason::SamplesMismatch);
}
Ok(Read::Samples(s))
}

View file

@ -40,8 +40,9 @@
//! rule, the one claims are held to, so every conforming verifier accepts the
//! same group signatures.
//!
//! This crate seals group receipts only to generate its conformance corpus;
//! [`GroupBuilder`] is not a publishing path.
//! This crate seals group receipts to generate its conformance corpus, and for
//! the objects the reference deriving edge publishes (`derive_edge::edge`, one
//! labelled object per group); [`GroupBuilder`] is the one place it does.
use crate::claim_receipt::{ClaimBody, ClaimReceipt};
use crate::jwp_hash::EstablishedHash;
@ -2026,7 +2027,7 @@ pub fn property_evidence(class: &EnergyProvenance) -> Vec<u8> {
// ───────────────────────────── building a group ─────────────────────────────
/// Builds a group and its objects — for this crate's conformance corpus and
/// tests. It is not a publishing path.
/// tests, and for the reference deriving edge's outputs (`derive_edge::edge`).
#[doc(hidden)]
#[derive(Default)]
pub struct GroupBuilder {

View file

@ -486,6 +486,20 @@ pub mod live_ads;
/// cbindgen:ignore
#[cfg(feature = "live_provenance")]
pub mod live_provenance;
// A still of packaging §5.7: an RGB8 frame as a `wai.image.png` payload every byte of which
// is a pure function of the frame, and the reader of an 8-bit RGB PNG with an inflater of its
// own. Rust-only: its constants must not become macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "png_still")]
pub mod png_still;
// The deriving edge (extensions/deriving-edge.md): `wai.edge.derivation` — the record an edge
// signs for an object it derived from delivered objects and published under receipts of its
// own; the derivations' arithmetic; and the verifier that follows a derived object hop by hop
// back to its source, re-executing each replicate hop whose inputs it holds. Rust-only, like
// the live modules: its constants must not become macros in include/wai.h.
/// cbindgen:ignore
#[cfg(feature = "derive_edge")]
pub mod derive_edge;
// Timed text and live captions (extensions/timed-text.md): `wai.text.timed` — the cue model
// both WebVTT and the IMSC1 Text Profile express, live caption revisions as `caption`
// events of `wai.live.events` with the WTX1 annotation, and the exact mappings onto WebVTT

641
wai/wai-rs/src/png_still.rs Normal file
View file

@ -0,0 +1,641 @@
//! A still of [packaging] §5.7: one RGB8 frame as a `wai.image.png` payload, every byte a
//! pure function of the frame, so an independent implementation writes the same file, and
//! the reader a verifier decodes any such payload with. The reference packager
//! (`wai-pack`) writes a piece's still with it, and the deriving edge ([deriving-edge]
//! §4.1, §4.3) reads its `wai.image.png` inputs and writes its pinned outputs with it.
//! No dependency: the deflate, the inflater and both checksums are the module's own.
//!
//! The writer's choices:
//!
//! - the signature, then `IHDR` (width, height, bit depth 8, colour type 2, compression 0,
//! filter 0, interlace 0), one `IDAT` holding the whole compressed stream, and `IEND`;
//! each chunk a big-endian length, its type, its data and the CRC-32 of type and data;
//! - every row filtered `Sub` (filter byte 1): each byte less the byte three before it in
//! the row, modulo 256, the first pixel's bytes less 0;
//! - a zlib stream (RFC 1950): `0x78 0x01`, one final deflate block of the fixed Huffman
//! codes (RFC 1951 §3.2.6), and the Adler-32 of the filtered rows, big-endian;
//! - the block's symbols, from position 0 of the filtered rows: where at least three bytes
//! remain and an earlier position starts with the same three bytes, the latest such
//! position `j` within 32768 bytes is the match; its length is the bytes the two runs
//! share from there, at most 258, and it is written as that length and the distance to
//! `j`; otherwise one literal. Every position a symbol covers that has three bytes left
//! becomes the latest position of its three bytes. The block ends with symbol 256, and
//! the last byte is padded with zero bits.
//!
//! The reader takes any PNG of 8-bit RGB without interlace (PNG's chunk rules): every
//! chunk's type four letters and its CRC checked; `IHDR` first, of 8-bit RGB, compression
//! and filter method 0 and no interlace; an optional `PLTE` (a suggested palette, which an
//! RGB picture may carry) before the image data, read past; the `IDAT`s consecutive;
//! ancillary chunks read past; any other critical chunk refused; `IEND` last, empty, and
//! nothing after it. The zlib stream is inflated by the module's own inflater, written
//! to the zlib library's rules ([`inflate_zlib`]: no over-subscribed code, and no
//! incomplete one but a lone one-bit literal/length or distance code), with its Adler-32
//! checked and no byte of the image data after it; its rows are exactly `1 + 3·width`
//! bytes each, and the five filters are undone.
//!
//! [packaging]: ../../extensions/packaging.md
//! [deriving-edge]: ../../extensions/deriving-edge.md
use std::collections::BTreeMap;
const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a];
/// The CRC-32 of ISO 3309 (PNG's chunk check), reflected, polynomial `0xEDB88320`.
pub fn crc32(parts: &[&[u8]]) -> u32 {
let mut table = [0u32; 256];
for (n, t) in table.iter_mut().enumerate() {
let mut c = n as u32;
for _ in 0..8 {
c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 };
}
*t = c;
}
let mut c = 0xffff_ffffu32;
for p in parts {
for &b in *p {
c = table[((c ^ b as u32) & 0xff) as usize] ^ (c >> 8);
}
}
c ^ 0xffff_ffff
}
/// Adler-32 (RFC 1950).
pub fn adler32(b: &[u8]) -> u32 {
let (mut a, mut s) = (1u32, 0u32);
for &x in b {
a = (a + x as u32) % 65521;
s = (s + a) % 65521;
}
(s << 16) | a
}
/// Bits written least significant first, as deflate packs them.
struct Bits {
out: Vec<u8>,
acc: u64,
n: u32,
}
impl Bits {
fn put(&mut self, v: u32, n: u32) {
self.acc |= (v as u64) << self.n;
self.n += n;
while self.n >= 8 {
self.out.push(self.acc as u8);
self.acc >>= 8;
self.n -= 8;
}
}
/// A Huffman code, most significant bit first.
fn code(&mut self, code: u32, len: u32) {
let mut rev = 0u32;
for i in 0..len {
rev |= ((code >> i) & 1) << (len - 1 - i);
}
self.put(rev, len);
}
fn finish(mut self) -> Vec<u8> {
if self.n > 0 {
self.out.push(self.acc as u8);
}
self.out
}
}
/// The fixed literal/length code of symbol `s` (RFC 1951 §3.2.6).
fn lit_code(b: &mut Bits, s: u32) {
match s {
0..=143 => b.code(0x30 + s, 8),
144..=255 => b.code(0x190 + s - 144, 9),
256..=279 => b.code(s - 256, 7),
_ => b.code(0xc0 + s - 280, 8),
}
}
/// RFC 1951 §3.2.5: the length codes' base lengths and extra bits.
const LEN_BASE: [u32; 29] = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
const LEN_EXTRA: [u32; 29] = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
const DIST_BASE: [u32; 30] = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];
const DIST_EXTRA: [u32; 30] = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];
fn put_match(b: &mut Bits, len: u32, dist: u32) {
let li = LEN_BASE.iter().rposition(|&x| x <= len).expect("a length of at least 3");
lit_code(b, 257 + li as u32);
b.put(len - LEN_BASE[li], LEN_EXTRA[li]);
let di = DIST_BASE.iter().rposition(|&x| x <= dist).expect("a distance of at least 1");
b.code(di as u32, 5);
b.put(dist - DIST_BASE[di], DIST_EXTRA[di]);
}
fn key(d: &[u8], i: usize) -> u32 {
(d[i] as u32) << 16 | (d[i + 1] as u32) << 8 | d[i + 2] as u32
}
/// The module's deflate: one final block of fixed codes, the matches as the module states.
pub fn deflate_fixed(d: &[u8]) -> Vec<u8> {
let mut b = Bits { out: Vec::with_capacity(d.len() / 2 + 16), acc: 0, n: 0 };
b.put(1, 1); // BFINAL
b.put(1, 2); // BTYPE 01, fixed codes
let mut last: BTreeMap<u32, usize> = BTreeMap::new();
let n = d.len();
let mut i = 0usize;
while i < n {
let found = if i + 3 <= n { last.get(&key(d, i)).copied().filter(|&j| i - j <= 32768) } else { None };
let len = match found {
Some(j) => {
let max = (n - i).min(258);
let mut l = 0;
while l < max && d[j + l] == d[i + l] {
l += 1;
}
put_match(&mut b, l as u32, (i - j) as u32);
l
}
None => {
lit_code(&mut b, d[i] as u32);
1
}
};
for p in i..i + len {
if p + 3 <= n {
last.insert(key(d, p), p);
}
}
i += len;
}
lit_code(&mut b, 256);
b.finish()
}
/// One chunk: its length, type, data and CRC.
pub fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
out.extend_from_slice(kind);
out.extend_from_slice(data);
out.extend_from_slice(&crc32(&[kind, data]).to_be_bytes());
}
/// The rows of an RGB8 frame of `w × h`, each filtered `Sub`.
fn filtered(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
let stride = 3 * w as usize;
let mut out = Vec::with_capacity((stride + 1) * h as usize);
for row in rgb.chunks_exact(stride).take(h as usize) {
out.push(1);
for i in 0..stride {
out.push(row[i].wrapping_sub(if i >= 3 { row[i - 3] } else { 0 }));
}
}
out
}
/// The still of an RGB8 frame of `w × h` (`rgb` holds `3·w·h` bytes).
pub fn encode(rgb: &[u8], w: u32, h: u32) -> Vec<u8> {
let raw = filtered(rgb, w, h);
let mut z = vec![0x78, 0x01];
z.extend_from_slice(&deflate_fixed(&raw));
z.extend_from_slice(&adler32(&raw).to_be_bytes());
let mut ihdr = Vec::with_capacity(13);
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]);
let mut out = SIGNATURE.to_vec();
chunk(&mut out, b"IHDR", &ihdr);
chunk(&mut out, b"IDAT", &z);
chunk(&mut out, b"IEND", &[]);
out
}
fn paeth(a: u8, b: u8, c: u8) -> u8 {
let p = a as i16 + b as i16 - c as i16;
let (pa, pb, pc) = ((p - a as i16).abs(), (p - b as i16).abs(), (p - c as i16).abs());
if pa <= pb && pa <= pc {
a
} else if pb <= pc {
b
} else {
c
}
}
/// The largest picture the reader decodes: the integer payloads' cap on one unit
/// (`2^26` samples).
pub const MAX_SAMPLES: u64 = 1 << 26;
/// Decode a PNG of 8-bit RGB without interlace to its width, height and RGB8 samples.
pub fn decode(b: &[u8]) -> Result<(u32, u32, Vec<u8>), String> {
if b.len() < 8 || b[..8] != SIGNATURE {
return Err("not a PNG signature".into());
}
let mut at = 8usize;
let mut ihdr: Option<(u32, u32)> = None;
let mut z = Vec::new();
let mut ended = false;
let mut palette = false;
// 0: no IDAT yet; 1: in the IDAT run; 2: past it.
let mut idat = 0u8;
while at < b.len() {
if ended {
return Err("bytes after IEND".into());
}
let len = u32::from_be_bytes(b.get(at..at + 4).ok_or("a chunk length past the end")?.try_into().expect("4")) as usize;
let kind: [u8; 4] = b.get(at + 4..at + 8).ok_or("a chunk type past the end")?.try_into().expect("4");
let data = b.get(at + 8..at + 8 + len).ok_or("a chunk past the end")?;
let crc = u32::from_be_bytes(b.get(at + 8 + len..at + 12 + len).ok_or("a chunk CRC past the end")?.try_into().expect("4"));
if !kind.iter().all(u8::is_ascii_alphabetic) {
return Err("a chunk type that is not four letters".into());
}
if crc != crc32(&[&kind, data]) {
return Err(format!("{}: its CRC", String::from_utf8_lossy(&kind)));
}
if idat == 1 && &kind != b"IDAT" {
idat = 2;
}
match &kind {
b"IHDR" => {
if ihdr.is_some() || at != 8 || data.len() != 13 {
return Err("IHDR is not one 13-byte chunk first".into());
}
let (w, h) = (u32::from_be_bytes(data[0..4].try_into().expect("4")), u32::from_be_bytes(data[4..8].try_into().expect("4")));
if data[8..] != [8, 2, 0, 0, 0] {
return Err("only 8-bit RGB, deflate, adaptive filtering, no interlace".into());
}
if w == 0 || h == 0 || (w as u64) * (h as u64) * 3 > MAX_SAMPLES {
return Err("a size outside 1 to 2^26 samples".into());
}
ihdr = Some((w, h));
}
_ if ihdr.is_none() => return Err("a chunk before IHDR".into()),
b"PLTE" => {
if palette || idat != 0 || data.is_empty() || data.len() % 3 != 0 || data.len() > 768 {
return Err("PLTE: more than one, after the image data, or not 1 to 256 entries".into());
}
palette = true;
}
b"IDAT" => {
if idat == 2 {
return Err("IDAT chunks that are not consecutive".into());
}
idat = 1;
z.extend_from_slice(data);
}
b"IEND" => {
if !data.is_empty() {
return Err("IEND with data".into());
}
ended = true;
}
_ if kind[0] & 0x20 == 0 => return Err(format!("an unknown critical chunk {}", String::from_utf8_lossy(&kind))),
_ => {}
}
at += 12 + len;
}
let (w, h) = ihdr.ok_or("no IHDR")?;
if !ended {
return Err("no IEND".into());
}
let stride = 3 * w as usize;
let want = (stride + 1) * h as usize;
// The zlib stream: a 32 KiB window at most (PNG's CINFO ≤ 7), its Adler-32 checked,
// every byte of the image data in it, and exactly the rows the picture has.
let raw = inflate_zlib(&z, want)?;
if raw.len() != want {
return Err("the rows are not the picture's size".into());
}
let mut out = vec![0u8; stride * h as usize];
for y in 0..h as usize {
let f = raw[y * (stride + 1)];
let row = &raw[y * (stride + 1) + 1..(y + 1) * (stride + 1)];
for i in 0..stride {
let a = if i >= 3 { out[y * stride + i - 3] } else { 0 };
let up = if y > 0 { out[(y - 1) * stride + i] } else { 0 };
let c = if y > 0 && i >= 3 { out[(y - 1) * stride + i - 3] } else { 0 };
let p = match f {
0 => 0,
1 => a,
2 => up,
3 => ((a as u16 + up as u16) / 2) as u8,
4 => paeth(a, up, c),
_ => return Err(format!("row {y}: filter {f}")),
};
out[y * stride + i] = row[i].wrapping_add(p);
}
}
Ok((w, h, out))
}
// ─────────────────────────────── inflate ───────────────────────────────
//
// The reader's inflater, written from RFC 1950 and RFC 1951 with the rules of the zlib
// library's inflate (the one a PNG reader is built on, and the one the Python verifier
// calls), so the two accept the same streams:
//
// - the header: its check bits, method 8, a window of at most 32 KiB, no preset
// dictionary; the Adler-32 at the end, big-endian; nothing after it;
// - a stored block's length and its one's complement agree;
// - a dynamic block has at most 286 literal/length and 30 distance codes; its
// code-length code is complete; a repeat has a length before it and stays within
// the code lengths; the end-of-block symbol has a code;
// - the literal/length and distance codes are not over-subscribed, and are complete
// unless their longest code is one bit long (a single one-bit code, or none at all):
// the only incomplete code sets zlib takes;
// - a code with no symbol (in an incomplete set, or symbols 286 and 287 and distances
// 30 and 31 of the fixed codes) is refused where it is read, and so is a distance
// past the bytes written.
struct BitReader<'a> {
b: &'a [u8],
at: usize,
bit: u32,
}
impl BitReader<'_> {
fn bit(&mut self) -> Result<u32, String> {
let byte = *self.b.get(self.at).ok_or("the zlib stream ends early")?;
let v = (byte >> self.bit) & 1;
self.bit += 1;
if self.bit == 8 {
self.bit = 0;
self.at += 1;
}
Ok(v as u32)
}
/// `n` bits, least significant first.
fn bits(&mut self, n: u32) -> Result<u32, String> {
let mut v = 0;
for i in 0..n {
v |= self.bit()? << i;
}
Ok(v)
}
fn align(&mut self) {
if self.bit != 0 {
self.bit = 0;
self.at += 1;
}
}
}
/// A canonical Huffman code (RFC 1951 §3.2.2): how many codes of each length, and the
/// symbols in code order.
struct Code {
count: [u16; 16],
symbol: Vec<u16>,
}
/// The code of `lengths`, and how far from complete it is: 0 complete, above 0
/// incomplete, below 0 over-subscribed; and its longest length.
fn code_of(lengths: &[u8]) -> (Code, i32, usize) {
let mut count = [0u16; 16];
for &l in lengths {
count[l as usize] += 1;
}
let mut left: i32 = 1;
let mut longest = 0;
for (len, &c) in count.iter().enumerate().skip(1) {
left = (left << 1) - c as i32;
if c > 0 {
longest = len;
}
if left < 0 {
break;
}
}
let mut symbol = Vec::with_capacity(lengths.len());
for len in 1..16 {
for (s, &l) in lengths.iter().enumerate() {
if l as usize == len {
symbol.push(s as u16);
}
}
}
count[0] = 0;
(Code { count, symbol }, left, longest)
}
fn decode_symbol(r: &mut BitReader, c: &Code) -> Result<u16, String> {
let (mut code, mut first, mut index) = (0i32, 0i32, 0i32);
for len in 1..16 {
code |= r.bit()? as i32;
let n = c.count[len] as i32;
if code - n < first {
return Ok(c.symbol[(index + code - first) as usize]);
}
index += n;
first = (first + n) << 1;
code <<= 1;
}
Err("the zlib stream: a code with no symbol".into())
}
/// The literal/length and distance codes of a dynamic block, under zlib's rules.
fn dynamic_codes(r: &mut BitReader) -> Result<(Code, Code), String> {
const ORDER: [usize; 19] = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];
let nlen = r.bits(5)? as usize + 257;
let ndist = r.bits(5)? as usize + 1;
let ncode = r.bits(4)? as usize + 4;
if nlen > 286 || ndist > 30 {
return Err("the zlib stream: too many length or distance symbols".into());
}
let mut cl = [0u8; 19];
for &i in ORDER.iter().take(ncode) {
cl[i] = r.bits(3)? as u8;
}
let (clc, left, _) = code_of(&cl);
if left != 0 {
return Err("the zlib stream: a code-length code that is not complete".into());
}
let mut lengths = vec![0u8; nlen + ndist];
let mut have = 0;
while have < nlen + ndist {
let s = decode_symbol(r, &clc)?;
let (value, times) = match s {
0..=15 => (s as u8, 1),
16 => {
let prev = *lengths[..have].last().ok_or("the zlib stream: a repeat with no length before it")?;
(prev, 3 + r.bits(2)? as usize)
}
17 => (0, 3 + r.bits(3)? as usize),
_ => (0, 11 + r.bits(7)? as usize),
};
if have + times > nlen + ndist {
return Err("the zlib stream: a repeat past the code lengths".into());
}
lengths[have..have + times].fill(value);
have += times;
}
if lengths[256] == 0 {
return Err("the zlib stream: no code for the end of the block".into());
}
let (lit, left, longest) = code_of(&lengths[..nlen]);
if left < 0 || (left > 0 && longest > 1) {
return Err("the zlib stream: an over-subscribed or incomplete literal/length code".into());
}
let (dist, left, longest) = code_of(&lengths[nlen..]);
if left < 0 || (left > 0 && longest > 1) {
return Err("the zlib stream: an over-subscribed or incomplete distance code".into());
}
Ok((lit, dist))
}
/// The zlib stream `z` inflated, refused as zlib's inflate refuses it, and refused
/// when it would give more than `limit` bytes or when bytes follow its Adler-32.
pub fn inflate_zlib(z: &[u8], limit: usize) -> Result<Vec<u8>, String> {
let (cmf, flg) = match z {
[c, f, ..] => (*c as u32, *f as u32),
_ => return Err("the zlib stream: no header".into()),
};
if (cmf * 256 + flg) % 31 != 0 || cmf & 15 != 8 || cmf >> 4 > 7 || flg & 0x20 != 0 {
return Err("the zlib stream: its header (check bits, method 8, a window of at most 32 KiB, no dictionary)".into());
}
let mut r = BitReader { b: z, at: 2, bit: 0 };
let mut out: Vec<u8> = Vec::new();
let too_long = || "the zlib stream gives more than the picture's rows".to_string();
loop {
let last = r.bit()?;
match r.bits(2)? {
0 => {
r.align();
let n = z.get(r.at..r.at + 4).ok_or("the zlib stream ends early")?;
let (len, nlen) = (u16::from_le_bytes([n[0], n[1]]), u16::from_le_bytes([n[2], n[3]]));
if len != !nlen {
return Err("the zlib stream: a stored block's length and its complement disagree".into());
}
let data = z.get(r.at + 4..r.at + 4 + len as usize).ok_or("the zlib stream ends early")?;
if out.len() + data.len() > limit {
return Err(too_long());
}
out.extend_from_slice(data);
r.at += 4 + len as usize;
}
t @ (1 | 2) => {
let (lit, dist) = if t == 1 {
let mut l = [8u8; 288];
l[144..256].fill(9);
l[256..280].fill(7);
(code_of(&l).0, code_of(&[5u8; 32]).0)
} else {
dynamic_codes(&mut r)?
};
loop {
let s = decode_symbol(&mut r, &lit)? as usize;
if s < 256 {
if out.len() == limit {
return Err(too_long());
}
out.push(s as u8);
continue;
}
if s == 256 {
break;
}
let li = s - 257;
if li >= 29 {
return Err("the zlib stream: a length symbol past 285".into());
}
let len = (LEN_BASE[li] + r.bits(LEN_EXTRA[li])?) as usize;
let di = decode_symbol(&mut r, &dist)? as usize;
if di >= 30 {
return Err("the zlib stream: a distance symbol past 29".into());
}
let d = (DIST_BASE[di] + r.bits(DIST_EXTRA[di])?) as usize;
if d > out.len() {
return Err("the zlib stream: a distance past the bytes written".into());
}
if out.len() + len > limit {
return Err(too_long());
}
for _ in 0..len {
out.push(out[out.len() - d]);
}
}
}
_ => return Err("the zlib stream: block type 3".into()),
}
if last == 1 {
break;
}
}
r.align();
let sum = z.get(r.at..r.at + 4).ok_or("the zlib stream ends before its Adler-32")?;
if u32::from_be_bytes(sum.try_into().expect("4")) != adler32(&out) {
return Err("the zlib stream: its Adler-32".into());
}
if r.at + 4 != z.len() {
return Err("bytes follow the zlib stream".into());
}
Ok(out)
}
/// The types of the chunks of a PNG, in order, read by their lengths alone; `None` where a
/// chunk runs past the end or the signature is not PNG's. It checks nothing [`decode`]
/// checks: a caller that needs a payload of only some chunks decodes it first.
pub fn chunk_kinds(b: &[u8]) -> Option<Vec<[u8; 4]>> {
if b.len() < 8 || b[..8] != SIGNATURE {
return None;
}
let mut at = 8usize;
let mut kinds = Vec::new();
while at < b.len() {
let len = u32::from_be_bytes(b.get(at..at + 4)?.try_into().ok()?) as usize;
kinds.push(b.get(at + 4..at + 8)?.try_into().ok()?);
at = at.checked_add(12)?.checked_add(len)?;
if at > b.len() {
return None;
}
}
Some(kinds)
}
#[cfg(test)]
mod tests {
use super::*;
fn frame(w: u32, h: u32, f: impl Fn(u32, u32, u32) -> u8) -> Vec<u8> {
(0..h).flat_map(|y| (0..w).flat_map(move |x| (0..3).map(move |c| (x, y, c)))).map(|(x, y, c)| f(x, y, c)).collect()
}
/// Known answers computed independently (RFC 1950's and ISO 3309's examples).
#[test]
fn checksums() {
assert_eq!(crc32(&[b"IEND"]), 0xae42_6082);
assert_eq!(crc32(&[b"123456789"]), 0xcbf4_3926);
assert_eq!(adler32(b"Wikipedia"), 0x11e6_0398);
}
/// The writer's output reads back to its frame, for pictures that compress well and
/// pictures that do not, and holds the three chunks the writer writes.
#[test]
fn a_still_round_trips() {
for (w, h) in [(1, 1), (3, 2), (64, 36), (33, 17)] {
for f in [
frame(w, h, |x, y, c| ((x * 7 + y * 3 + c * 50) % 256) as u8),
frame(w, h, |_, _, _| 77),
frame(w, h, |x, y, c| ((x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(40_503) ^ c) >> 3) as u8),
] {
let png = encode(&f, w, h);
assert_eq!(decode(&png).unwrap(), (w, h, f.clone()), "{w}x{h}");
assert_eq!(chunk_kinds(&png).unwrap(), vec![*b"IHDR", *b"IDAT", *b"IEND"]);
}
}
}
#[test]
fn the_reader_refuses_what_it_does_not_read() {
let f = frame(4, 3, |x, y, c| (x + y + c) as u8);
let good = encode(&f, 4, 3);
let mut bad = good.clone();
bad[30] ^= 1;
assert!(decode(&bad).is_err(), "a CRC");
let mut bad = good.clone();
bad.push(0);
assert!(decode(&bad).is_err(), "bytes after IEND");
assert!(chunk_kinds(&bad).is_none(), "a chunk past the end");
assert!(decode(&good[..good.len() - 12]).is_err(), "no IEND");
assert!(decode(b"not a png").is_err());
assert!(chunk_kinds(b"not a png").is_none());
}
}

View file

@ -0,0 +1,204 @@
//! `wai.edge.derivation` (extensions/deriving-edge.md): the committed corpus
//! `edge-conformance/vectors.json` regenerates byte for byte from its fixed seeds, every
//! vector in it gets the outcome it states, §9 lists exactly the reasons the reference
//! reports and §4 exactly its derivations; and the reference edge, `wai_edge`, run twice over
//! a publisher's clip, makes a chain of two hops that verifies back to the clip, and that is
//! refused once a byte of it changes. The generator itself asserts that the corpus gives every
//! reason.
#![cfg(feature = "derive_edge")]
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use serde_json::{json, Value};
use wai::derive_edge::{corpus, Derivation, Reason};
use wai::jwp_delivery::GroupBuilder;
use wai::jwp_hash::content_hash;
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn committed() -> BTreeMap<String, String> {
let dir = wai_dir().join("edge-conformance");
std::fs::read_dir(&dir)
.expect("the committed corpus")
.flatten()
.filter(|e| e.path().is_file())
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
.filter(|(n, _)| n != "README.md" && n != "edge_verify.py")
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
.collect()
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
let files = committed();
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_edge_vectors gen`");
}
#[test]
fn every_vector_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
fn section(text: &str, heading: &str) -> String {
let at = text.find(heading).unwrap_or_else(|| panic!("no {heading}"));
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
text[at..end].to_string()
}
fn first_cells(section: &str) -> Vec<String> {
section.lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect()
}
/// §9 lists exactly the codes `Reason::ALL` has, each once, in order; §4's table lists
/// exactly the derivations, in order.
#[test]
fn the_extension_lists_the_reasons_and_the_derivations() {
let text = std::fs::read_to_string(wai_dir().join("extensions/deriving-edge.md")).expect("the extension");
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(first_cells(&section(&text, "\n## 9. Reasons")), want, "§9's table and Reason::ALL differ");
let s4 = section(&text, "\n## 4. The derivations");
let table: String = s4.lines().take_while(|l| !l.starts_with("### ")).collect::<Vec<_>>().join("\n");
let want: Vec<String> = Derivation::ALL.iter().map(|d| d.id().to_string()).collect();
assert_eq!(first_cells(&table), want, "§4's table and Derivation::ALL differ");
}
// ───────────────────────────── the reference edge ─────────────────────────────
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn sha256(b: &[u8]) -> [u8; 32] {
use sha2::Digest;
sha2::Sha256::digest(b).into()
}
fn seed(name: &str) -> [u8; 32] {
sha256(format!("wai-edge-test:{name}").as_bytes())
}
fn wai1(manifest: &str, payload: &[u8]) -> Vec<u8> {
let mut out = b"WAI1".to_vec();
out.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
out.extend_from_slice(manifest.as_bytes());
out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
out.extend_from_slice(payload);
out
}
/// The C2PA Manifest Store the test binds to the clip: a byte string standing for one, which
/// the edge names by its SHA-256 and does not parse.
const STORE: &[u8] = b"C2PA Manifest Store stand-in bound to the clip";
/// A publisher's bundle: one `wai.video.int_motion` clip of two frames, in a group it signed,
/// with a C2PA Manifest Store bound to it.
fn source_bundle(dir: &Path) -> (PathBuf, String, [u8; 32]) {
let frames: Vec<Vec<u8>> = (0..2u32).map(|f| (0..6 * 4 * 3u32).map(|i| ((i * 11 + f * 50) % 256) as u8).collect()).collect();
let clip = wai::int_video_enc::encode(&frames, 4, 6, &wai::int_video_enc::EncParams { block: 2, search: 1, mv_precision: 1, q: 1 }).unwrap();
let manifest = r#"{"wai":"1.0","media":"video","intent":"replicate","model_requirement":{"capability":"wai.video.int_motion"},"conditioning":{"kind":"int_motion"},"target":{"frames":2,"h":4,"w":6}}"#;
let env = wai1(manifest, &clip.wiv1);
let key = ed25519_dalek::SigningKey::from_bytes(&seed("publisher"));
let mut b = GroupBuilder::default();
b.add(content_hash(&env), 0, "origin", None);
let (g, objs) = b.finish(&key, "did:web:source.example#pub", 1, 0, "2026-10-20T00:00:00Z", "2026-10-20T00:00:00Z", None, false).unwrap();
let bundle = json!({
"records": [],
"receipts": [g.to_json(), objs[0].to_json()],
"objects": { hex(&content_hash(&env)): hex(&env) },
"claims": [],
"c2pa": { hex(&content_hash(&env)): [hex(STORE)] },
});
let path = dir.join("source.json");
std::fs::write(&path, serde_json::to_string(&bundle).unwrap()).unwrap();
(path, hex(&key.verifying_key().to_bytes()), content_hash(&env))
}
fn edge(args: &[&str]) -> (bool, Value) {
let out = Command::new(env!("CARGO_BIN_EXE_wai_edge")).args(args).output().expect("wai_edge runs");
let text = String::from_utf8_lossy(&out.stdout);
let v = serde_json::from_str(text.trim()).unwrap_or_else(|_| panic!("wai_edge {args:?}: {text} {}", String::from_utf8_lossy(&out.stderr)));
(out.status.success(), v)
}
#[test]
fn the_reference_edge_makes_a_chain_that_verifies() {
let dir = std::env::temp_dir().join(format!("wai-edge-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let (source, publisher, clip) = source_bundle(&dir);
let s = |p: &Path| p.to_string_lossy().into_owned();
for (name, n) in [("a", "edge-a"), ("b", "edge-b")] {
std::fs::write(dir.join(format!("{name}.key")), hex(&seed(n))).unwrap();
}
// Hop 1: edge A rebuilds frame 1 as a pinned still.
let hop1 = dir.join("hop1.json");
let (ok, made) = edge(&[
"derive", "--bundle", &s(&source), "--out", &s(&hop1), "--key", &s(&dir.join("a.key")),
"--signer-id", "did:web:edge-a.example#derive", "--derivation", "wai.derive.identity",
"--output", "wai.image.png", "--input", &format!("{}@1", hex(&clip)),
"--track", "10", "--group", "0", "--time", "2026-10-20T12:00:00Z", "--source", &publisher,
"--personal", &hex(&sha256(STORE)), "--synthesis", &format!("{}=generate", hex(&clip)),
]);
assert!(ok, "{made}");
let still = made["output"].as_str().unwrap().to_owned();
// Hop 2: edge B crops it, with a metered figure.
let hop2 = dir.join("hop2.json");
let (ok, made) = edge(&[
"derive", "--bundle", &s(&hop1), "--out", &s(&hop2), "--key", &s(&dir.join("b.key")),
"--signer-id", "did:web:edge-b.example#derive", "--derivation", "wai.derive.crop",
"--params", r#"{"x":1,"y":1,"w":4,"h":2}"#, "--output", "wai.image.png", "--input", &still,
"--energy", r#"{"joules_micro":2500,"energy_provenance":"ModelBased","energy_uncertainty":null,"energy_counter_filtering":null,"energy_calibration_ref":null}"#,
"--track", "20", "--group", "0", "--time", "2026-10-20T12:00:01Z", "--source", &publisher,
]);
assert!(ok, "{made}");
let crop = made["output"].as_str().unwrap().to_owned();
let (ok, v) = edge(&["verify", "--bundle", &s(&hop2), "--subject", &crop, "--source", &publisher, "--require-recompute"]);
assert!(ok, "{v}");
let hops = v["facts"]["hops"].as_array().unwrap();
assert_eq!(hops.len(), 2);
assert!(hops.iter().all(|h| h["checked"] == "recomputed" && h["check"] == "replicate"), "{v}");
assert_eq!(v["facts"]["sources"][0]["object"], hex(&clip));
assert_eq!(v["facts"]["energy"], json!([{"class": "ModelBased", "joules_micro": 2500}]));
assert_eq!(v["facts"]["unmetered"], 1);
// The store bound to the clip, carried through both hops in the compartment hop 1 stated,
// and the clip's label with it.
assert_eq!(
v["facts"]["provenance"],
json!([{"input": 0, "kind": "c2pa", "digest": hex(&sha256(STORE)), "compartment": "personal"}])
);
assert!(hops.iter().all(|h| h["synthesis"] == "generate"), "{v}");
// A sink that does not take the publisher as a source finds a hop missing.
let (ok, v) = edge(&["verify", "--bundle", &s(&hop2), "--subject", &crop, "--source", &hex(&[7; 32])]);
assert!(!ok);
assert_eq!(v["reason"], "chain_skips_hop");
// A byte of the still changed: the bundle no longer verifies.
let mut b: Value = serde_json::from_str(&std::fs::read_to_string(&hop2).unwrap()).unwrap();
let env = b["objects"][&still].as_str().unwrap().to_owned();
let flipped = format!("{}{}", &env[..env.len() - 1], if env.ends_with('0') { "1" } else { "0" });
b["objects"][&still] = json!(flipped);
let tampered = dir.join("tampered.json");
std::fs::write(&tampered, serde_json::to_string(&b).unwrap()).unwrap();
let (ok, v) = edge(&["verify", "--bundle", &s(&tampered), "--subject", &crop, "--source", &publisher]);
assert!(!ok);
assert_eq!(v["reason"], "output_hash_mismatch", "{v}");
// The edge does not derive from an input whose chain does not verify.
let out = Command::new(env!("CARGO_BIN_EXE_wai_edge"))
.args([
"derive", "--bundle", &s(&tampered), "--out", &s(&dir.join("never.json")), "--key", &s(&dir.join("a.key")),
"--signer-id", "did:web:edge-a.example#derive", "--derivation", "wai.derive.mirror",
"--params", r#"{"flip":"left-right"}"#, "--output", "wai.image.png", "--input", &still,
"--track", "10", "--group", "1", "--time", "2026-10-20T12:00:02Z", "--source", &publisher,
])
.output()
.unwrap();
assert!(!out.status.success());
let _ = std::fs::remove_dir_all(&dir);
}

View file

@ -223,6 +223,7 @@ fn every_decoded_output_states_a_sha256() {
("pin-conformance", "parameter-set pins: every digest there is a SHA-256 of a parameter set"),
("ops-conformance", "integer operators: every operator, stage and graph vector, the lower_* decodes of the sets under sets/ among them, states its output integers in full or its refusal code, never a digest of them; certificates/ states each lowered set's SHA-256 (a parameter-set identity) and holds the lowered graphs as bytes, compared byte for byte; the output digests a capability decoded through its lowering reproduces (integer-operators §14.4) are stated by that capability's own corpus"),
("signer-conformance", "key logs, grants and statuses: signed documents, no decoded output"),
("edge-conformance", "derivation records of a deriving edge: each replicate-tier output is recomputed and compared byte for byte by its verifier, and every output is named by its content hash in the record"),
("live-conformance", "live events, rooms, takes, compositions, recordings, captions, metering, steering, moderation, watermark sessions and ads: signed records, commitments, packages, clips, cues and their renderings, per-hop records, ledgers and session receipts with their units and joules recomputed, steering policies and decisions with each pathway's evidence and the steering manifests recomputed byte for byte, review policies, decisions, notices and the items' timelines with each statement-of-reasons export written byte for byte, watermark tokens, session records, recovered sequences and disclosures, ad policies, breaks with their interstitial tags, events and asset lists written byte for byte and their impression receipts, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"),
("http-conformance", "objects and head proofs for transport: no case decodes a payload, and the component a fetch case picks is stated by its SHA-256"),
("cache-conformance", "cache rules: a driver, its controls and their reports, with no objects of its own"),