open-standards/wai/wai-rs/src/derive_edge.rs
Transaction Science 033265f8e6 deriving edge: AI-origin labels take the origin claim's terms (assist, generate)
The label a record carries is the top of the order an origin claim
gives its steps (capture < convert < assist < generate), so one
vocabulary serves both; every derivation this revision pins is a convert
step and raises no label.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 04:58:07 -04:00

1110 lines
40 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The deriving edge — `extensions/deriving-edge.md`, the record `wai.edge.derivation`.
//!
//! An edge derives an object from objects a publisher delivered, publishes it on a track of
//! its own under its own delivery receipts, and signs a **derivation record** that chains it
//! to its source: 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** says how a verifier holds the edge to it:
//! `replicate`, when every step is pinned, so a verifier holding the inputs re-executes the
//! derivation and compares the output byte for byte; or `attest`, when one is not.
//!
//! # What is here
//!
//! - [`Record`]: the record, read under §5.3's rules in their order ([`Record::read`]),
//! written in JCS form, signed and identified (§5.2).
//! - [`Derivation`], [`Params`]: the derivations of §4 and their parameters, and the check
//! each takes ([`check_of`]). Which operations are exact is read from
//! [`crate::codecs::derivation::derived_sample_tier`], the registry's own composition rule,
//! not restated here.
//! - [`ops`]: 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).
//! - [`verify`]: verification of a derived object back to its source (§7, §8), with the
//! facts §7.4 states.
//! - [`edge`]: the reference edge's derive: an output, its group and object receipts under
//! the edge's key, and its record.
//! - [`corpus`]: the conformance corpus of §10.
//!
//! The record is JSON under jwp-receipts §2.4.3's encodings, read with the claims' strict
//! reader and canonicalised with their JCS, so a record has one canonical form whatever its
//! carriage.
use crate::claim_receipt::{hex, jcs, parse_strict, rfc3339, term_ok, unhex};
use crate::codecs::derivation::{
derivation_op, derived_sample_tier, ExecutedOp, VisualInput, CAP_DERIVE_CROP, CAP_DERIVE_GRID_COMPOSITION,
CAP_DERIVE_IDENTITY, CAP_DERIVE_MIRROR, CAP_DERIVE_OVERLAY_COMPOSITION, CAP_DERIVE_ROI_SELECTION,
CAP_DERIVE_ROTATION, CAP_DERIVE_SCALING,
};
use crate::codecs::{DeterminismTier, CAP_IMAGE_JPEG, CAP_IMAGE_PNG, CAP_VIDEO_INT_MOTION};
use crate::energy_class::{check_label, json_fields, CounterFiltering, EnergyProvenance, Uncertainty};
use crate::jcs::MAX_INTEGER;
use ed25519_dalek::{Signer, SigningKey};
use serde_json::{Map, Value};
pub mod corpus;
pub mod edge;
pub mod ops;
pub mod verify;
#[cfg(test)]
mod tests;
/// The record's `kind` (§5.1).
pub const KIND: &str = "wai-derivation";
/// The domain the record's signature covers, before its canonical form (§5.2).
pub const DOMAIN_SIGN: &[u8] = b"wai:derivation\x01";
/// The domain of the record's hash (§5.2).
pub const DOMAIN_LINK: &[u8] = b"wai:derivation-link\x01";
/// The domain of `derived_from.settings` (SPEC §7.1).
pub const DOMAIN_RENDER_SETTINGS: &[u8] = b"wai:render-settings\x01";
/// The authority an edge needs from its sources' origins (§8, jwp-receipts §7.5).
pub const AUTHORITY: &str = "edge/derive";
/// The term of the re-packaging derivation (§4.4).
pub const EXPLODE: &str = "wai-edge:explode";
/// The most inputs a record names (§5.1).
pub const MAX_INPUTS: usize = 16;
/// The most hops of a chain, the subject's record being the first (§7.2 step 4).
pub const MAX_HOPS: usize = 8;
/// The most bytes an output's samples may take (§4.2).
pub const MAX_SAMPLE_BYTES: u64 = 1 << 26;
/// The most provenance items a record carries (§5.1).
pub const MAX_PROVENANCE: usize = 1024;
/// The PNG chunk a C2PA Manifest Store is embedded in (C2PA 2.2 §A.3.2).
pub const C2PA_PNG_CHUNK: [u8; 4] = *b"caBX";
// ───────────────────────────── reasons ─────────────────────────────
/// Why a record or a chain was refused (§9). The order is §9's table's.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Reason {
RecordMalformed,
DerivationUnknown,
ParamsInvalid,
InputsInvalid,
CheckMismatch,
EnergyInvalid,
BadKey,
BadSignature,
OutputReceiptNotHeld,
OutputReceiptInvalid,
OutputReceiptMismatch,
EnergyDisagrees,
OutputHashMismatch,
OutputManifestMismatch,
InputReceiptNotHeld,
InputReceiptInvalid,
ChainSkipsHop,
ChainTooLong,
HopNotHeld,
HopMismatch,
InputHashMismatch,
InputUnreadable,
SamplesMismatch,
ProvenanceStripped,
SynthesisDropped,
ReplicateMismatch,
InputsNotHeld,
KeyLogNotHeld,
EdgeNotAuthorised,
EdgeRevoked,
AttestedRefused,
}
impl Reason {
/// Every reason, in §9's order.
pub const ALL: [Reason; 31] = [
Reason::RecordMalformed,
Reason::DerivationUnknown,
Reason::ParamsInvalid,
Reason::InputsInvalid,
Reason::CheckMismatch,
Reason::EnergyInvalid,
Reason::BadKey,
Reason::BadSignature,
Reason::OutputReceiptNotHeld,
Reason::OutputReceiptInvalid,
Reason::OutputReceiptMismatch,
Reason::EnergyDisagrees,
Reason::OutputHashMismatch,
Reason::OutputManifestMismatch,
Reason::InputReceiptNotHeld,
Reason::InputReceiptInvalid,
Reason::ChainSkipsHop,
Reason::ChainTooLong,
Reason::HopNotHeld,
Reason::HopMismatch,
Reason::InputHashMismatch,
Reason::InputUnreadable,
Reason::SamplesMismatch,
Reason::ProvenanceStripped,
Reason::SynthesisDropped,
Reason::ReplicateMismatch,
Reason::InputsNotHeld,
Reason::KeyLogNotHeld,
Reason::EdgeNotAuthorised,
Reason::EdgeRevoked,
Reason::AttestedRefused,
];
/// The conformance code.
pub fn code(self) -> &'static str {
match self {
Reason::RecordMalformed => "record_malformed",
Reason::DerivationUnknown => "derivation_unknown",
Reason::ParamsInvalid => "params_invalid",
Reason::InputsInvalid => "inputs_invalid",
Reason::CheckMismatch => "check_mismatch",
Reason::EnergyInvalid => "energy_invalid",
Reason::BadKey => "bad_key",
Reason::BadSignature => "bad_signature",
Reason::OutputReceiptNotHeld => "output_receipt_not_held",
Reason::OutputReceiptInvalid => "output_receipt_invalid",
Reason::OutputReceiptMismatch => "output_receipt_mismatch",
Reason::EnergyDisagrees => "energy_disagrees",
Reason::OutputHashMismatch => "output_hash_mismatch",
Reason::OutputManifestMismatch => "output_manifest_mismatch",
Reason::InputReceiptNotHeld => "input_receipt_not_held",
Reason::InputReceiptInvalid => "input_receipt_invalid",
Reason::ChainSkipsHop => "chain_skips_hop",
Reason::ChainTooLong => "chain_too_long",
Reason::HopNotHeld => "hop_not_held",
Reason::HopMismatch => "hop_mismatch",
Reason::InputHashMismatch => "input_hash_mismatch",
Reason::InputUnreadable => "input_unreadable",
Reason::SamplesMismatch => "samples_mismatch",
Reason::ProvenanceStripped => "provenance_stripped",
Reason::SynthesisDropped => "synthesis_dropped",
Reason::ReplicateMismatch => "replicate_mismatch",
Reason::InputsNotHeld => "inputs_not_held",
Reason::KeyLogNotHeld => "key_log_not_held",
Reason::EdgeNotAuthorised => "edge_not_authorised",
Reason::EdgeRevoked => "edge_revoked",
Reason::AttestedRefused => "attested_refused",
}
}
}
impl core::fmt::Display for Reason {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(self.code())
}
}
// ───────────────────────────── derivations ─────────────────────────────
/// A derivation of §4.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Derivation {
Identity,
Crop,
RoiSelection,
Rotation,
Mirror,
GridComposition,
OverlayComposition,
Scaling,
Explode,
}
impl Derivation {
/// Every derivation, in §4's table's order.
pub const ALL: [Derivation; 9] = [
Derivation::Identity,
Derivation::Crop,
Derivation::RoiSelection,
Derivation::Rotation,
Derivation::Mirror,
Derivation::GridComposition,
Derivation::OverlayComposition,
Derivation::Scaling,
Derivation::Explode,
];
/// The string that names it.
pub fn id(self) -> &'static str {
match self {
Derivation::Identity => CAP_DERIVE_IDENTITY,
Derivation::Crop => CAP_DERIVE_CROP,
Derivation::RoiSelection => CAP_DERIVE_ROI_SELECTION,
Derivation::Rotation => CAP_DERIVE_ROTATION,
Derivation::Mirror => CAP_DERIVE_MIRROR,
Derivation::GridComposition => CAP_DERIVE_GRID_COMPOSITION,
Derivation::OverlayComposition => CAP_DERIVE_OVERLAY_COMPOSITION,
Derivation::Scaling => CAP_DERIVE_SCALING,
Derivation::Explode => EXPLODE,
}
}
pub fn from_id(s: &str) -> Option<Derivation> {
Derivation::ALL.into_iter().find(|d| d.id() == s)
}
/// Whether it evaluates an operation over samples (§3.1 kinds 1 and 2), rather than
/// re-package (kind 3).
pub fn evaluates(self) -> bool {
self != Derivation::Explode
}
/// Whether the operation is exact for every instance this profile executes (§4.2), by
/// derived-tracks §3's composition rule over inputs at `decode-equivalence`: the
/// profile's instances meet every condition derived-tracks §2 lists for the operation
/// (§4.2's closing paragraph), so they are listed as met, and the registry decides.
/// Scaling is untiered there, and so not exact here. The explode evaluates no
/// operation and is pinned by SPEC §7.1.
pub fn exact(self, n_inputs: usize) -> bool {
if !self.evaluates() {
return true;
}
let Some(op) = derivation_op(self.id()) else { return false };
let Some(conditions) = op.exact_conditions() else { return false };
let n = n_inputs.max(1);
let reads: Vec<VisualInput> = (0..n).map(VisualInput::Listed).collect();
let executed = [ExecutedOp { capability: self.id(), conditions_met: &conditions, reads: &reads }];
let inputs = vec![Some(DeterminismTier::DecodeEquivalence); n];
derived_sample_tier(&inputs, &executed) == Ok(Some(DeterminismTier::DecodeEquivalence))
}
}
/// How a verifier holds an edge to a record (§4.5).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Check {
Replicate,
Attest,
}
impl Check {
pub fn label(self) -> &'static str {
match self {
Check::Replicate => "replicate",
Check::Attest => "attest",
}
}
}
/// A mirror's reflection (§4.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Flip {
LeftRight,
TopBottom,
}
impl Flip {
pub fn label(self) -> &'static str {
match self {
Flip::LeftRight => "left-right",
Flip::TopBottom => "top-bottom",
}
}
}
/// A derivation's parameters (§4.2, §4.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Params {
/// `wai.derive.identity`: `{}`.
None,
/// `wai.derive.crop` and `wai.derive.roi_selection`.
Region { x: u64, y: u64, w: u64, h: u64 },
Rotation { quarter_turns: u64 },
Mirror { flip: Flip },
Grid { columns: u64, rows: u64 },
Overlay { width: u64, height: u64, fill: [u8; 3], at: Vec<(u64, u64)> },
Scaling { width: u64, height: u64 },
Explode { component: u64 },
}
impl Params {
/// Its JSON object.
pub fn to_value(&self) -> Value {
let mut m = Map::new();
let mut put = |k: &str, v: Value| {
m.insert(k.into(), v);
};
match self {
Params::None => {}
Params::Region { x, y, w, h } => {
put("x", (*x).into());
put("y", (*y).into());
put("w", (*w).into());
put("h", (*h).into());
}
Params::Rotation { quarter_turns } => put("quarter_turns", (*quarter_turns).into()),
Params::Mirror { flip } => put("flip", flip.label().into()),
Params::Grid { columns, rows } => {
put("columns", (*columns).into());
put("rows", (*rows).into());
}
Params::Overlay { width, height, fill, at } => {
put("width", (*width).into());
put("height", (*height).into());
put("fill", hex(fill).into());
put(
"at",
Value::Array(at.iter().map(|(x, y)| serde_json::json!({ "x": x, "y": y })).collect()),
);
}
Params::Scaling { width, height } => {
put("width", (*width).into());
put("height", (*height).into());
}
Params::Explode { component } => put("component", (*component).into()),
}
Value::Object(m)
}
}
/// An encoder the record names (§5.1): the edge's statement of what it ran.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Encoder {
pub id: String,
/// A JSON object within §2.4.3's encodings.
pub settings: Value,
}
/// An AI-origin label (§6.2): how far AI made the content, in the order a label may only
/// rise. No label (`None`) is lowest. The terms are the two highest step classes of the
/// origin claim's order (capture, convert, assist, generate), which a later revision reads.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Synthesis {
/// AI altered the content without generating any of it.
Assist,
/// AI generated the content, or part of it.
Generate,
}
impl Synthesis {
pub fn label(self) -> &'static str {
match self {
Synthesis::Assist => "assist",
Synthesis::Generate => "generate",
}
}
pub fn from_label(s: &str) -> Option<Synthesis> {
[Synthesis::Assist, Synthesis::Generate].into_iter().find(|x| x.label() == s)
}
}
/// What a provenance item is (§6.1).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProvKind {
/// A claim about the input (jwp-receipts §2.4.3), named by its link hash.
Claim,
/// A C2PA Manifest Store bound to the input, named by its SHA-256.
C2pa,
}
impl ProvKind {
pub fn label(self) -> &'static str {
match self {
ProvKind::Claim => "claim",
ProvKind::C2pa => "c2pa",
}
}
pub fn from_label(s: &str) -> Option<ProvKind> {
[ProvKind::Claim, ProvKind::C2pa].into_iter().find(|x| x.label() == s)
}
}
/// The compartment of a provenance item (§6.1).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Compartment {
System,
Personal,
}
impl Compartment {
pub fn label(self) -> &'static str {
match self {
Compartment::System => "system",
Compartment::Personal => "personal",
}
}
pub fn from_label(s: &str) -> Option<Compartment> {
[Compartment::System, Compartment::Personal].into_iter().find(|x| x.label() == s)
}
}
/// One provenance item a record carries forward (§6.1).
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ProvItem {
/// The input it came with.
pub input: u64,
pub kind: ProvKind,
pub digest: [u8; 32],
pub compartment: Compartment,
}
impl ProvItem {
/// The order a record lists items in: by input, then kind, then digest.
pub fn key(&self) -> (u64, &'static str, [u8; 32]) {
(self.input, self.kind.label(), self.digest)
}
fn to_value(&self) -> Value {
serde_json::json!({
"input": self.input,
"kind": self.kind.label(),
"digest": hex(&self.digest),
"compartment": self.compartment.label(),
})
}
fn read(v: &Value) -> Result<ProvItem, Reason> {
let m = exact(v, PROVENANCE_MEMBERS)?;
Ok(ProvItem {
input: int(&m["input"])?,
kind: m["kind"].as_str().and_then(ProvKind::from_label).ok_or(Reason::RecordMalformed)?,
digest: hash32(&m["digest"])?,
compartment: m["compartment"].as_str().and_then(Compartment::from_label).ok_or(Reason::RecordMalformed)?,
})
}
}
/// Sort `items` in a record's order, each once.
pub fn ordered(mut items: Vec<ProvItem>) -> Vec<ProvItem> {
items.sort_by_key(ProvItem::key);
items.dedup_by_key(|i| i.key());
items
}
fn synthesis_value(s: Option<Synthesis>) -> Value {
s.map_or(Value::Null, |s| s.label().into())
}
fn read_synthesis(v: &Value) -> Result<Option<Synthesis>, Reason> {
match v {
Value::Null => Ok(None),
v => v.as_str().and_then(Synthesis::from_label).map(Some).ok_or(Reason::RecordMalformed),
}
}
/// An input of a record (§5.1).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Input {
pub content_hash: [u8; 32],
pub capability: String,
pub receipt: [u8; 32],
pub object_id: u64,
pub frame: Option<u64>,
pub samples: Option<[u8; 32]>,
pub derivation: Option<[u8; 32]>,
/// The input's AI-origin label (§6.2).
pub synthesis: Option<Synthesis>,
}
/// The output of a record (§5.1).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Output {
pub content_hash: [u8; 32],
pub capability: String,
pub receipt: [u8; 32],
pub object_id: u64,
pub width: Option<u64>,
pub height: Option<u64>,
pub samples: Option<[u8; 32]>,
}
/// A metered derivation's figure and its class (§5.1).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Energy {
pub joules_micro: u64,
pub class: EnergyProvenance,
}
/// A derivation record (§5).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Record {
pub derivation: Derivation,
pub params: Params,
pub encoder: Option<Encoder>,
pub check: Check,
pub inputs: Vec<Input>,
pub output: Output,
pub energy: Option<Energy>,
/// The provenance items carried forward (§6.1), in their order.
pub provenance: Vec<ProvItem>,
/// The output's AI-origin label (§6.2).
pub synthesis: Option<Synthesis>,
pub issued_at: String,
pub signer_pubkey: [u8; 32],
pub signer_id: String,
pub sig: [u8; 64],
}
/// The check §4.5 gives a derivation over `n_inputs` inputs written in `output_capability`
/// by `encoder`.
pub fn check_of(d: Derivation, n_inputs: usize, output_capability: &str, encoder: Option<&Encoder>) -> Check {
if !d.evaluates() {
return Check::Replicate;
}
if d.exact(n_inputs) && output_capability == CAP_IMAGE_PNG && encoder.is_none() {
Check::Replicate
} else {
Check::Attest
}
}
const RECORD_MEMBERS: &[&str] = &[
"kind",
"derivation",
"params",
"encoder",
"check",
"inputs",
"output",
"energy",
"provenance",
"synthesis",
"issued_at",
"signer_pubkey",
"signer_id",
"sig",
];
const INPUT_MEMBERS: &[&str] =
&["content_hash", "capability", "receipt", "object_id", "frame", "samples", "derivation", "synthesis"];
const PROVENANCE_MEMBERS: &[&str] = &["input", "kind", "digest", "compartment"];
const OUTPUT_MEMBERS: &[&str] = &["content_hash", "capability", "receipt", "object_id", "width", "height", "samples"];
const ENERGY_MEMBERS: &[&str] =
&["joules_micro", "energy_provenance", "energy_uncertainty", "energy_counter_filtering", "energy_calibration_ref"];
const ENCODER_MEMBERS: &[&str] = &["id", "settings"];
const UNCERTAINTY_MEMBERS: &[&str] = &["relative_ppm", "window_us"];
fn malformed<T>() -> Result<T, Reason> {
Err(Reason::RecordMalformed)
}
/// `v` as an object with exactly `members`.
fn exact<'a>(v: &'a Value, members: &[&str]) -> Result<&'a Map<String, Value>, Reason> {
let m = v.as_object().ok_or(Reason::RecordMalformed)?;
if m.len() != members.len() || !members.iter().all(|k| m.contains_key(*k)) {
return malformed();
}
Ok(m)
}
fn int(v: &Value) -> Result<u64, Reason> {
v.as_u64().filter(|&n| n <= MAX_INTEGER).ok_or(Reason::RecordMalformed)
}
fn opt_int(v: &Value) -> Result<Option<u64>, Reason> {
if v.is_null() { Ok(None) } else { int(v).map(Some) }
}
fn hash32(v: &Value) -> Result<[u8; 32], Reason> {
v.as_str().and_then(unhex).and_then(|b| b.try_into().ok()).ok_or(Reason::RecordMalformed)
}
fn opt_hash32(v: &Value) -> Result<Option<[u8; 32]>, Reason> {
if v.is_null() { Ok(None) } else { hash32(v).map(Some) }
}
fn term(v: &Value) -> Result<String, Reason> {
let s = v.as_str().ok_or(Reason::RecordMalformed)?;
term_ok(s).map_err(|_| Reason::RecordMalformed)?;
Ok(s.to_owned())
}
fn string(v: &Value) -> Result<String, Reason> {
v.as_str().map(str::to_owned).ok_or(Reason::RecordMalformed)
}
/// Whether `v` holds only what §2.4.3's encodings allow: strings, integers of at most
/// 2^53 − 1, `null`, arrays and objects.
fn in_subset(v: &Value) -> bool {
match v {
Value::Null | Value::String(_) => true,
Value::Bool(_) => false,
Value::Number(n) => n.as_u64().is_some_and(|n| n <= MAX_INTEGER),
Value::Array(a) => a.iter().all(in_subset),
Value::Object(m) => m.values().all(in_subset),
}
}
/// The energy object's shape (§5.3 rule 1): its members, their types.
struct EnergyShape {
joules_micro: u64,
provenance: String,
uncertainty: Option<(u64, u64)>,
filtering: Option<String>,
calibration_ref: Option<String>,
}
fn energy_shape(v: &Value) -> Result<Option<EnergyShape>, Reason> {
if v.is_null() {
return Ok(None);
}
let m = exact(v, ENERGY_MEMBERS)?;
let uncertainty = match &m["energy_uncertainty"] {
Value::Null => None,
u => {
let u = exact(u, UNCERTAINTY_MEMBERS)?;
Some((int(&u["relative_ppm"])?, int(&u["window_us"])?))
}
};
let opt_str = |k: &str| -> Result<Option<String>, Reason> {
match &m[k] {
Value::Null => Ok(None),
v => string(v).map(Some),
}
};
Ok(Some(EnergyShape {
joules_micro: int(&m["joules_micro"])?,
provenance: string(&m["energy_provenance"])?,
uncertainty,
filtering: opt_str("energy_counter_filtering")?,
calibration_ref: opt_str("energy_calibration_ref")?,
}))
}
/// §5.3 rule 6: the class the energy object states, under energy-measurement §2.
fn energy_class(e: &EnergyShape) -> Result<Energy, Reason> {
let bad = |_| Reason::EnergyInvalid;
let uncertainty = match e.uncertainty {
None => None,
Some((ppm, window)) => {
let ppm = u32::try_from(ppm).map_err(|_| Reason::EnergyInvalid)?;
Some(Uncertainty::new(ppm, window).map_err(bad)?)
}
};
let filtering = match &e.filtering {
None => None,
Some(f) => Some(CounterFiltering::from_label(f).ok_or(Reason::EnergyInvalid)?),
};
// A claim's filtering state is written whenever its class carries one, `Unknown`
// included (energy-measurement §2.7): absent beside an on-chip counter is not read as
// `Unknown`.
if e.provenance == "OnChipCounter" && filtering.is_none() {
return Err(Reason::EnergyInvalid);
}
let class = EnergyProvenance::from_parts(&e.provenance, uncertainty, filtering, e.calibration_ref.as_deref())
.map_err(bad)?;
check_label(e.joules_micro, Some(&class)).map_err(bad)?;
Ok(Energy { joules_micro: e.joules_micro, class })
}
/// The energy object of a record.
pub fn energy_value(e: Option<&Energy>) -> Value {
let Some(e) = e else { return Value::Null };
let mut m = Map::new();
m.insert("joules_micro".into(), e.joules_micro.into());
for k in &ENERGY_MEMBERS[1..] {
m.insert((*k).into(), Value::Null);
}
for (k, v) in json_fields(&e.class) {
m.insert(k.into(), v);
}
Value::Object(m)
}
fn opt_hex(h: &Option<[u8; 32]>) -> Value {
h.as_ref().map_or(Value::Null, |h| hex(h).into())
}
fn opt_u64(n: &Option<u64>) -> Value {
n.map_or(Value::Null, Value::from)
}
impl Input {
fn to_value(&self) -> Value {
serde_json::json!({
"content_hash": hex(&self.content_hash),
"capability": self.capability,
"receipt": hex(&self.receipt),
"object_id": self.object_id,
"frame": opt_u64(&self.frame),
"samples": opt_hex(&self.samples),
"derivation": opt_hex(&self.derivation),
"synthesis": synthesis_value(self.synthesis),
})
}
fn read(v: &Value) -> Result<Input, Reason> {
let m = exact(v, INPUT_MEMBERS)?;
Ok(Input {
content_hash: hash32(&m["content_hash"])?,
capability: term(&m["capability"])?,
receipt: hash32(&m["receipt"])?,
object_id: int(&m["object_id"])?,
frame: opt_int(&m["frame"])?,
samples: opt_hash32(&m["samples"])?,
derivation: opt_hash32(&m["derivation"])?,
synthesis: read_synthesis(&m["synthesis"])?,
})
}
}
impl Output {
fn to_value(&self) -> Value {
serde_json::json!({
"content_hash": hex(&self.content_hash),
"capability": self.capability,
"receipt": hex(&self.receipt),
"object_id": self.object_id,
"width": opt_u64(&self.width),
"height": opt_u64(&self.height),
"samples": opt_hex(&self.samples),
})
}
fn read(v: &Value) -> Result<Output, Reason> {
let m = exact(v, OUTPUT_MEMBERS)?;
Ok(Output {
content_hash: hash32(&m["content_hash"])?,
capability: term(&m["capability"])?,
receipt: hash32(&m["receipt"])?,
object_id: int(&m["object_id"])?,
width: opt_int(&m["width"])?,
height: opt_int(&m["height"])?,
samples: opt_hash32(&m["samples"])?,
})
}
}
/// Whether `w × h` samples of three bytes fit [`MAX_SAMPLE_BYTES`].
pub fn size_ok(w: u64, h: u64) -> bool {
w >= 1 && h >= 1 && (w as u128) * (h as u128) * 3 <= MAX_SAMPLE_BYTES as u128
}
/// §5.3 rule 3's reading of `params` for `d`, given the number of inputs.
fn read_params(d: Derivation, v: &Value, n_inputs: usize) -> Result<Params, Reason> {
let bad = Reason::ParamsInvalid;
let m = v.as_object().ok_or(Reason::RecordMalformed)?;
let only = |members: &[&str]| -> Result<(), Reason> {
if m.len() == members.len() && members.iter().all(|k| m.contains_key(*k)) { Ok(()) } else { Err(bad) }
};
let n = |k: &str| -> Result<u64, Reason> { m[k].as_u64().filter(|&n| n <= MAX_INTEGER).ok_or(bad) };
let at_least_1 = |x: u64| if x >= 1 { Ok(x) } else { Err(bad) };
Ok(match d {
Derivation::Identity => {
only(&[])?;
Params::None
}
Derivation::Crop | Derivation::RoiSelection => {
only(&["x", "y", "w", "h"])?;
let (x, y, w, h) = (n("x")?, n("y")?, n("w")?, n("h")?);
if !size_ok(w, h) {
return Err(bad);
}
Params::Region { x, y, w, h }
}
Derivation::Rotation => {
only(&["quarter_turns"])?;
let q = n("quarter_turns")?;
if !(1..=3).contains(&q) {
return Err(bad);
}
Params::Rotation { quarter_turns: q }
}
Derivation::Mirror => {
only(&["flip"])?;
let flip = match m["flip"].as_str() {
Some("left-right") => Flip::LeftRight,
Some("top-bottom") => Flip::TopBottom,
_ => return Err(bad),
};
Params::Mirror { flip }
}
Derivation::GridComposition => {
only(&["columns", "rows"])?;
Params::Grid { columns: at_least_1(n("columns")?)?, rows: at_least_1(n("rows")?)? }
}
Derivation::OverlayComposition => {
only(&["width", "height", "fill", "at"])?;
let (width, height) = (n("width")?, n("height")?);
if !size_ok(width, height) {
return Err(bad);
}
let fill: [u8; 3] = m["fill"].as_str().and_then(unhex).and_then(|b| b.try_into().ok()).ok_or(bad)?;
let at = m["at"].as_array().ok_or(bad)?;
if at.len() != n_inputs {
return Err(bad);
}
let at = at
.iter()
.map(|p| {
let p = p.as_object().filter(|p| p.len() == 2).ok_or(bad)?;
let c = |k: &str| p.get(k).and_then(Value::as_u64).filter(|&n| n <= MAX_INTEGER).ok_or(bad);
Ok((c("x")?, c("y")?))
})
.collect::<Result<Vec<_>, Reason>>()?;
Params::Overlay { width, height, fill, at }
}
Derivation::Scaling => {
only(&["width", "height"])?;
let (width, height) = (n("width")?, n("height")?);
if !size_ok(width, height) {
return Err(bad);
}
Params::Scaling { width, height }
}
Derivation::Explode => {
only(&["component"])?;
Params::Explode { component: n("component")? }
}
})
}
impl Params {
/// Read `v` as `d`'s parameters for `n_inputs` inputs, by §5.3 rule 3.
pub fn from_value(d: Derivation, v: &Value, n_inputs: usize) -> Result<Params, Reason> {
if !in_subset(v) {
return Err(Reason::RecordMalformed);
}
read_params(d, v, n_inputs)
}
}
impl Encoder {
/// Read `{ "id", "settings" }` by §5.3 rule 1.
pub fn from_value(v: &Value) -> Result<Encoder, Reason> {
let e = exact(v, ENCODER_MEMBERS)?;
let settings = e["settings"].clone();
if !settings.is_object() || !in_subset(&settings) {
return malformed();
}
Ok(Encoder { id: term(&e["id"])?, settings })
}
}
impl Energy {
/// Read a record's `energy` member, `null` or its object, by §5.3 rules 1 and 6.
pub fn from_value(v: &Value) -> Result<Option<Energy>, Reason> {
energy_shape(v)?.as_ref().map(energy_class).transpose()
}
}
/// The inputs a derivation reads, as §5.3 rule 4 counts them.
fn inputs_fit(d: Derivation, p: &Params, n: usize) -> bool {
match (d, p) {
(Derivation::GridComposition, Params::Grid { columns, rows }) => {
(*columns as u128) * (*rows as u128) == n as u128
}
(Derivation::OverlayComposition, _) => (1..=MAX_INPUTS).contains(&n),
_ => n == 1,
}
}
impl Record {
/// The record as a JSON object, `sig` included.
pub fn to_value(&self) -> Value {
let mut m = self.unsigned_map();
m.insert("sig".into(), hex(&self.sig).into());
Value::Object(m)
}
fn unsigned_map(&self) -> Map<String, Value> {
let mut m = Map::new();
m.insert("kind".into(), KIND.into());
m.insert("derivation".into(), self.derivation.id().into());
m.insert("params".into(), self.params.to_value());
m.insert(
"encoder".into(),
self.encoder.as_ref().map_or(Value::Null, |e| serde_json::json!({ "id": e.id, "settings": e.settings })),
);
m.insert("check".into(), self.check.label().into());
m.insert("inputs".into(), Value::Array(self.inputs.iter().map(Input::to_value).collect()));
m.insert("output".into(), self.output.to_value());
m.insert("energy".into(), energy_value(self.energy.as_ref()));
m.insert("provenance".into(), Value::Array(self.provenance.iter().map(ProvItem::to_value).collect()));
m.insert("synthesis".into(), synthesis_value(self.synthesis));
m.insert("issued_at".into(), self.issued_at.clone().into());
m.insert("signer_pubkey".into(), hex(&self.signer_pubkey).into());
m.insert("signer_id".into(), self.signer_id.clone().into());
m
}
/// The canonical form (JCS), `sig` included: the bytes a record is sent as.
pub fn to_json(&self) -> String {
jcs(&self.to_value()).expect("a record is within the claim subset")
}
/// What the signature covers: the domain, then JCS of the record without `sig` (§5.2).
pub fn signing_input(&self) -> Vec<u8> {
let mut b = DOMAIN_SIGN.to_vec();
b.extend_from_slice(
jcs(&Value::Object(self.unsigned_map())).expect("a record is within the claim subset").as_bytes(),
);
b
}
/// The record's hash (§5.2).
pub fn hash(&self) -> [u8; 32] {
link_of_canonical(&self.to_json())
}
/// Sign with `key`, which becomes `signer_pubkey`.
pub fn sign(mut self, key: &SigningKey) -> Record {
self.signer_pubkey = key.verifying_key().to_bytes();
self.sig = key.sign(&self.signing_input()).to_bytes();
self
}
/// Sign with `key` over whatever the record states, keeping `signer_pubkey` as it is:
/// for the corpus's controls, whose records break a rule under a valid signature.
#[doc(hidden)]
pub fn resign(mut self, key: &SigningKey) -> Record {
self.sig = key.sign(&self.signing_input()).to_bytes();
self
}
/// §5.2's signature, under jwp-receipts §3.1 step 3's strict rule.
pub fn verify_signature(&self) -> Result<(), Reason> {
crate::sig::verify_strict(&self.signer_pubkey, &self.signing_input(), &self.sig).map_err(|e| match e {
crate::sig::SigError::BadKey => Reason::BadKey,
crate::sig::SigError::BadSignature => Reason::BadSignature,
})
}
/// §5.3: read a record text, refusing at the first rule it breaks, in the rules' order.
pub fn read(text: &str) -> Result<Record, Reason> {
// Rule 1: the shape.
let v = parse_strict(text).map_err(|_| Reason::RecordMalformed)?;
let m = exact(&v, RECORD_MEMBERS)?;
if m["kind"].as_str() != Some(KIND) {
return malformed();
}
let derivation_term = term(&m["derivation"])?;
if !m["params"].is_object() || !in_subset(&m["params"]) {
return malformed();
}
let encoder = match &m["encoder"] {
Value::Null => None,
e => Some(Encoder::from_value(e)?),
};
let check = match m["check"].as_str() {
Some("replicate") => Check::Replicate,
Some("attest") => Check::Attest,
_ => return malformed(),
};
let inputs = m["inputs"].as_array().ok_or(Reason::RecordMalformed)?;
if inputs.is_empty() || inputs.len() > MAX_INPUTS {
return malformed();
}
let inputs = inputs.iter().map(Input::read).collect::<Result<Vec<_>, _>>()?;
let output = Output::read(&m["output"])?;
let energy = energy_shape(&m["energy"])?;
let provenance = m["provenance"].as_array().ok_or(Reason::RecordMalformed)?;
if provenance.len() > MAX_PROVENANCE {
return malformed();
}
let provenance = provenance.iter().map(ProvItem::read).collect::<Result<Vec<_>, _>>()?;
if provenance.iter().any(|p| p.input >= inputs.len() as u64)
|| provenance.windows(2).any(|w| w[0].key() >= w[1].key())
{
return malformed();
}
let synthesis = read_synthesis(&m["synthesis"])?;
let issued_at = string(&m["issued_at"])?;
if !rfc3339(&issued_at) {
return malformed();
}
let signer_pubkey = hash32(&m["signer_pubkey"])?;
let signer_id = string(&m["signer_id"])?;
let sig: [u8; 64] =
m["sig"].as_str().and_then(unhex).and_then(|b| b.try_into().ok()).ok_or(Reason::RecordMalformed)?;
// Rule 2: the derivation.
let derivation = Derivation::from_id(&derivation_term).ok_or(Reason::DerivationUnknown)?;
// Rule 3: the parameters, the encoder and the output capability.
let params = read_params(derivation, &m["params"], inputs.len())?;
if derivation.evaluates() {
if ![CAP_IMAGE_PNG, CAP_IMAGE_JPEG].contains(&output.capability.as_str()) {
return Err(Reason::ParamsInvalid);
}
if output.capability == CAP_IMAGE_JPEG && encoder.is_none() {
return Err(Reason::ParamsInvalid);
}
} else if encoder.is_some() || output.capability != inputs[0].capability {
return Err(Reason::ParamsInvalid);
}
// Rule 4: the inputs.
if !inputs_fit(derivation, &params, inputs.len()) {
return Err(Reason::InputsInvalid);
}
let evaluates = derivation.evaluates();
for i in &inputs {
let shaped = if evaluates {
i.frame.is_some()
&& i.samples.is_some()
&& [CAP_IMAGE_PNG, CAP_VIDEO_INT_MOTION].contains(&i.capability.as_str())
&& (i.capability != CAP_IMAGE_PNG || i.frame == Some(0))
} else {
i.frame.is_none() && i.samples.is_none()
};
if !shaped {
return Err(Reason::InputsInvalid);
}
}
let out_shaped = if evaluates {
output.width.is_some() && output.height.is_some() && output.samples.is_some()
} else {
output.width.is_none() && output.height.is_none() && output.samples.is_none()
};
if !out_shaped {
return Err(Reason::InputsInvalid);
}
// Rule 5: the check.
if check != check_of(derivation, inputs.len(), &output.capability, encoder.as_ref()) {
return Err(Reason::CheckMismatch);
}
// Rule 6: the energy.
let energy = energy.as_ref().map(energy_class).transpose()?;
// Rule 7: the AI-origin label is no lower than any input's.
if inputs.iter().any(|i| i.synthesis > synthesis) {
return Err(Reason::SynthesisDropped);
}
Ok(Record {
derivation,
params,
encoder,
check,
inputs,
output,
energy,
provenance,
synthesis,
issued_at,
signer_pubkey,
signer_id,
sig,
})
}
}
/// The hash of a record text that reads as JSON within the claim subset, whether or not it
/// then passes §5.3: what a verifier holds it under (§7.1).
pub fn held_hash(text: &str) -> Option<[u8; 32]> {
let v = parse_strict(text).ok()?;
Some(link_of_canonical(&jcs(&v).ok()?))
}
fn link_of_canonical(canonical: &str) -> [u8; 32] {
let mut h = blake3::Hasher::new();
h.update(DOMAIN_LINK);
h.update(canonical.as_bytes());
*h.finalize().as_bytes()
}
/// A claim text read as a statement about an object (§6.1): a JSON object, within the claim
/// subset, of `kind` `claim` and `subject_type` `object`, and its `subject` and link hash
/// (jwp-receipts §2.4.3). Nothing else of it is checked: what an edge carries forward is
/// what it received.
pub fn claim_about(text: &str) -> Option<([u8; 32], [u8; 32])> {
let v = parse_strict(text).ok()?;
let m = v.as_object()?;
if m.get("kind")?.as_str()? != "claim" || m.get("subject_type")?.as_str()? != "object" {
return None;
}
let subject = hash32(m.get("subject")?).ok()?;
let mut h = blake3::Hasher::new();
h.update(crate::claim_receipt::DOMAIN_CLAIM_LINK);
h.update(jcs(&v).ok()?.as_bytes());
Some((subject, *h.finalize().as_bytes()))
}
/// SHA-256 of `b`.
pub fn sha256(b: &[u8]) -> [u8; 32] {
use sha2::Digest;
sha2::Sha256::digest(b).into()
}