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]>
1110 lines
40 KiB
Rust
1110 lines
40 KiB
Rust
//! 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, ¶ms, 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()
|
||
}
|