open-standards/wai/wai-rs/src/package.rs
Transaction Science c65b68a2f7 wai-rs: every Ed25519 check outside OER/2 goes through sig::verify_strict
Sixteen sites still decoded the key with VerifyingKey::from_bytes and checked
with the lenient verify, which accepts a key of small order (under the identity,
(R, S) = (B, 1) verifies over any message), a second spelling of a point (y = p + j
is reduced), and an R of small order. Each now calls crate::sig::verify_strict:
the video, SSAI, key-release, channel and package receipts (v1 and v2), the
provenance, ternary and the five media receipts, and the world's receipt, op
signature and container seal. A package names a bad key apart from a bad
signature under receipt-signature; an op's bad key is still BadActorKey.

claim_receipt's own copy of the rule (signer_key, verify_ed25519_strict) is
gone: claims, JWP groups and the key log call the same routine, and its key
test moved into sig.rs. sig builds under a new `ed25519` feature that is only
the dependency, which world and oer enable, so the helper exists exactly when
ed25519-dalek does and asks for nothing else.

sig::forgeries builds, for any signing payload, the three signatures a lenient
verifier accepts and the rule refuses; every moved site has a test that its
receipt refuses all three. oer.rs is untouched: OER/2's settled rule refuses
the same keys but not R of small order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 18:06:47 -04:00

734 lines
33 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 version-2 package receipt (`extensions/packaging.md` §9): one signed statement
//! over a whole package, binding its policy digest and, per component, the stage index
//! (`WSIX`, [`crate::stage_index`]) by its digest and its stage root. The counts it signs
//! (entries, distinct objects, bytes referenced and bytes stored) are recomputed from the
//! indexes by every verifier, so a package cannot state a saving its indexes do not
//! hold.
//!
//! The version-1 receipt (`wai_video_package::PackageReceipt`) keeps its bytes and still
//! verifies. Both share the identity domain `wai:video-package-receipt-id\x01`, so the
//! figure profile `wai:video-package-receipt-id` (energy-measurement §6.1) covers both;
//! their signing domains differ, so no version-2 payload can equal a version-1 payload.
//! [`AnyPackageReceipt`] reads either from its JSON.
//!
//! ```text
//! labelled_domain("wai:pack-receipt\x01", label)
//! u32 len asset_id ‖ asset_id ‖ policy_digest[32]
//! u8 parent_kind (0 none, 1 live-window, 2 re-pack) ‖ [parent_receipt_hash[32] if kind ≠ 0]
//! u32 n_components ‖ n × ( u32 len id ‖ id ‖ u8 media_class ‖ index_digest[32] ‖ stage_root[32] )
//! u64 total_entries ‖ u64 unique_objects ‖ u64 total_bytes ‖ u64 stored_bytes ‖ u64 work_units
//! u32 len packager ‖ packager
//! u64 joules_micro ‖ write_label(label)
//! grant_hash[32] ‖ u32 len capability ‖ capability ‖ u64 joule_ceiling_micro ‖ u8 + [u64] funds_ceiling
//! signer_pubkey[32] ‖ u32 len signer_id ‖ signer_id
//! receipt_hash = BLAKE3("wai:video-package-receipt-id\x01" ‖ payload ‖ sig)
//! ```
//!
//! Integers are big-endian, as in every receipt of the family.
use ed25519_dalek::{Signer, SigningKey};
use serde_json::{json, Map, Value};
use crate::energy_class::{
check_label, figure_and_class_cohere, json_fields, label_from_json, labelled_domain, write_label, JSON_CALIBRATION_REF, JSON_CLASS, JSON_FILTERING, JSON_UNCERTAINTY,
};
use crate::jcs::{jcs, MAX_INTEGER};
use crate::quantum_ops::GrantRef;
use crate::stage_index::{IndexReason, MediaClass, StageIndex};
use crate::wai_video_manifest::EnergyProvenance;
use crate::wai_video_package::PackageReceipt;
/// The version-2 signing domain.
///
/// cbindgen:ignore
pub const DOMAIN_RECEIPT_V2: &[u8] = b"wai:pack-receipt\x01";
/// The identity domain both versions share.
///
/// cbindgen:ignore
pub const DOMAIN_RECEIPT_ID: &[u8] = b"wai:video-package-receipt-id\x01";
/// The `kind` member of a package receipt's JSON.
///
/// cbindgen:ignore
pub const KIND: &str = "package";
/// Why a package receipt, or a package against it, is refused. The code is a reason of
/// packaging §12: an index's own (`dup-flag`, `tier-weakest`, …) or the receipt's
/// (`receipt-*`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PackageReason {
pub code: &'static str,
pub detail: String,
}
impl PackageReason {
fn new(code: &'static str, detail: impl Into<String>) -> PackageReason {
PackageReason { code, detail: detail.into() }
}
fn index(component: &str, r: IndexReason) -> PackageReason {
PackageReason { code: r.code, detail: format!("component {component}: {}", r.detail) }
}
}
impl std::fmt::Display for PackageReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.code, self.detail)
}
}
impl std::error::Error for PackageReason {}
fn err<T>(code: &'static str, detail: impl Into<String>) -> Result<T, PackageReason> {
Err(PackageReason::new(code, detail))
}
/// What a package was made after (§9.2).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Parent {
/// A package with no parent.
None,
/// A live window: the receipt of the window before it.
LiveWindow([u8; 32]),
/// A re-pack: the receipt of the package it re-packs.
RePack([u8; 32]),
}
impl Parent {
/// 0, 1 or 2.
pub fn code(self) -> u8 {
match self {
Parent::None => 0,
Parent::LiveWindow(_) => 1,
Parent::RePack(_) => 2,
}
}
/// `none`, `live-window` or `re-pack`.
pub fn label(self) -> &'static str {
match self {
Parent::None => "none",
Parent::LiveWindow(_) => "live-window",
Parent::RePack(_) => "re-pack",
}
}
/// The parent's receipt hash.
pub fn hash(self) -> Option<[u8; 32]> {
match self {
Parent::None => None,
Parent::LiveWindow(h) | Parent::RePack(h) => Some(h),
}
}
}
/// One component's binding: its id, media class, and its stage index's digest and
/// stage root. Its index is the file `index-<id>.wsix`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ComponentBinding {
pub id: String,
pub media_class: MediaClass,
pub index_digest: [u8; 32],
pub stage_root: [u8; 32],
}
impl ComponentBinding {
/// The index's file name, `index-<id>.wsix`.
pub fn index_file(&self) -> String {
index_file(&self.id)
}
}
/// A component's index file name, `index-<id>.wsix`.
pub fn index_file(id: &str) -> String {
format!("index-{id}.wsix")
}
/// A signed version-2 package receipt.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PackageReceiptV2 {
pub asset_id: String,
pub policy_digest: [u8; 32],
pub parent: Parent,
pub components: Vec<ComponentBinding>,
/// Entries over every component's index.
pub total_entries: u64,
/// Distinct content hashes over every component's index.
pub unique_objects: u64,
/// `Σ byte_len` over every entry.
pub total_bytes: u64,
/// `Σ byte_len` over each content hash's first entry.
pub stored_bytes: u64,
/// Portable work: the entries packed (= `total_entries`).
pub work_units: u64,
/// The packager, `builtin:<crate>@<version>`.
pub packager: String,
/// The energy figure, `0` with no class when unmetered.
pub joules_micro: u64,
pub energy_provenance: Option<EnergyProvenance>,
pub grant: GrantRef,
pub signer_pubkey: [u8; 32],
pub signer_id: String,
pub sig: [u8; 64],
}
/// The counts a package's indexes give: entries, distinct objects, bytes referenced and
/// bytes stored (one copy per content hash). A content hash at two lengths has no count:
/// `receipt-counts`.
pub fn counts(indexes: &[StageIndex]) -> Result<(u64, u64, u64, u64), PackageReason> {
let items = indexes.iter().flat_map(|ix| ix.entries.iter().map(|e| (e.content_hash, e.byte_len)));
let t = crate::content_dedup::totals_checked(items)
.map_err(|(h, a, b)| PackageReason::new("receipt-counts", format!("{} at {a} and {b} bytes", hex(&h))))?;
Ok((t.objects, t.unique, t.referenced_bytes, t.stored_bytes))
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn unhex<const N: usize>(s: &str) -> Option<[u8; N]> {
if s.len() != 2 * N || !s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
return None;
}
let mut out = [0u8; N];
for (i, o) in out.iter_mut().enumerate() {
*o = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(out)
}
fn write_grant(g: &GrantRef, o: &mut Vec<u8>) {
o.extend_from_slice(&g.grant_hash);
o.extend_from_slice(&(g.capability.len() as u32).to_be_bytes());
o.extend_from_slice(g.capability.as_bytes());
o.extend_from_slice(&g.joule_ceiling_micro.to_be_bytes());
match g.funds_ceiling {
Some(f) => {
o.push(1);
o.extend_from_slice(&f.to_be_bytes());
}
None => o.push(0),
}
}
/// A grant as JSON: `joule_ceiling_micro` `null` for no ceiling (`u64::MAX`), and
/// `funds_ceiling` `null` for none.
fn grant_json(g: &GrantRef) -> Value {
json!({
"capability": g.capability,
"funds_ceiling": g.funds_ceiling,
"grant_hash": hex(&g.grant_hash),
"joule_ceiling_micro": if g.joule_ceiling_micro == u64::MAX { Value::Null } else { Value::from(g.joule_ceiling_micro) },
})
}
fn grant_from_json(v: &Value) -> Result<GrantRef, PackageReason> {
let bad = |w: &str| PackageReason::new("receipt-json", format!("grant: {w}"));
let o = v.as_object().ok_or_else(|| bad("not an object"))?;
exact_members(o, &["capability", "funds_ceiling", "grant_hash", "joule_ceiling_micro"]).map_err(|m| bad(&m))?;
let int = |k: &str| match o.get(k) {
Some(Value::Null) => Ok(None),
Some(x) => x.as_u64().filter(|&n| n <= MAX_INTEGER).map(Some).ok_or_else(|| bad(k)),
None => Err(bad(k)),
};
Ok(GrantRef {
grant_hash: o.get("grant_hash").and_then(Value::as_str).and_then(unhex::<32>).ok_or_else(|| bad("grant_hash"))?,
capability: o.get("capability").and_then(Value::as_str).ok_or_else(|| bad("capability"))?.to_string(),
joule_ceiling_micro: int("joule_ceiling_micro")?.unwrap_or(u64::MAX),
funds_ceiling: int("funds_ceiling")?,
})
}
/// Whether a grant's integers have a JSON form: every one at most `2^53 − 1`, except a
/// joule ceiling of `u64::MAX` (none), written `null`.
fn grant_fits_json(g: &GrantRef) -> bool {
(g.joule_ceiling_micro == u64::MAX || g.joule_ceiling_micro <= MAX_INTEGER) && g.funds_ceiling.is_none_or(|f| f <= MAX_INTEGER)
}
/// The object's members are exactly `names` plus, where `label` is true, the energy
/// label's fields.
fn exact_members(o: &Map<String, Value>, names: &[&str]) -> Result<(), String> {
let label = [JSON_CLASS, JSON_UNCERTAINTY, JSON_FILTERING, JSON_CALIBRATION_REF];
for k in o.keys() {
if !names.contains(&k.as_str()) && !label.contains(&k.as_str()) {
return Err(format!("a member {k:?} this version does not define"));
}
}
for n in names {
if !o.contains_key(*n) {
return Err(format!("no member {n:?}"));
}
}
Ok(())
}
/// A receipt's JSON text read as a value: RFC 8259, with no object naming a member twice
/// (`receipt-json` otherwise).
fn read_json(text: &str) -> Result<Value, PackageReason> {
let bad = |e: serde_json::Error| PackageReason::new("receipt-json", e.to_string());
crate::container::check_unique_members(text.as_bytes()).map_err(bad)?;
serde_json::from_str(text).map_err(bad)
}
fn str_of<'a>(o: &'a Map<String, Value>, k: &str) -> Result<&'a str, PackageReason> {
o.get(k).and_then(Value::as_str).ok_or_else(|| PackageReason::new("receipt-json", format!("{k} is not a string")))
}
fn u64_of(o: &Map<String, Value>, k: &str) -> Result<u64, PackageReason> {
o.get(k).and_then(Value::as_u64).filter(|&n| n <= MAX_INTEGER).ok_or_else(|| PackageReason::new("receipt-json", format!("{k} is not an integer 0..=2^53-1")))
}
fn hex_of<const N: usize>(o: &Map<String, Value>, k: &str) -> Result<[u8; N], PackageReason> {
o.get(k).and_then(Value::as_str).and_then(unhex::<N>).ok_or_else(|| PackageReason::new("receipt-json", format!("{k} is not {} lowercase hexadecimal digits", 2 * N)))
}
impl PackageReceiptV2 {
/// The signing payload.
pub fn signing_payload(&self) -> Vec<u8> {
let label = self.energy_provenance.as_ref();
let mut o = labelled_domain(DOMAIN_RECEIPT_V2, label);
o.extend_from_slice(&(self.asset_id.len() as u32).to_be_bytes());
o.extend_from_slice(self.asset_id.as_bytes());
o.extend_from_slice(&self.policy_digest);
o.push(self.parent.code());
if let Some(h) = self.parent.hash() {
o.extend_from_slice(&h);
}
o.extend_from_slice(&(self.components.len() as u32).to_be_bytes());
for c in &self.components {
o.extend_from_slice(&(c.id.len() as u32).to_be_bytes());
o.extend_from_slice(c.id.as_bytes());
o.push(c.media_class.code() as u8);
o.extend_from_slice(&c.index_digest);
o.extend_from_slice(&c.stage_root);
}
for n in [self.total_entries, self.unique_objects, self.total_bytes, self.stored_bytes, self.work_units] {
o.extend_from_slice(&n.to_be_bytes());
}
o.extend_from_slice(&(self.packager.len() as u32).to_be_bytes());
o.extend_from_slice(self.packager.as_bytes());
o.extend_from_slice(&self.joules_micro.to_be_bytes());
write_label(label, &mut o);
write_grant(&self.grant, &mut o);
o.extend_from_slice(&self.signer_pubkey);
o.extend_from_slice(&(self.signer_id.len() as u32).to_be_bytes());
o.extend_from_slice(self.signer_id.as_bytes());
o
}
/// `BLAKE3("wai:video-package-receipt-id\x01" ‖ payload ‖ sig)`.
pub fn receipt_hash(&self) -> [u8; 32] {
let mut h = blake3::Hasher::new();
h.update(DOMAIN_RECEIPT_ID);
h.update(&self.signing_payload());
h.update(&self.sig);
*h.finalize().as_bytes()
}
/// Seal a package: each component's id and index file, in the order the receipt
/// lists them. Every index is parsed and checked (§6), its media class read from its
/// header and its component id compared with the one given, and the counts computed
/// from the indexes. Refused: an index that does not parse or check; a component
/// whose index names another id or another policy digest (`receipt-component`,
/// `receipt-policy-digest`); two components of one id (`receipt-component`); a parent
/// that does not match the indexes' `prev_index_digest` (`receipt-parent-kind`); an
/// energy figure and class that do not cohere (`receipt-energy`); and a value with no
/// JSON form, an integer past `2^53 − 1` (`receipt-json`).
#[allow(clippy::too_many_arguments)]
pub fn seal(
signer: &SigningKey,
signer_id: &str,
asset_id: &str,
policy_digest: [u8; 32],
indexes: &[(String, Vec<u8>)],
packager: &str,
energy: (u64, Option<EnergyProvenance>),
grant: GrantRef,
parent: Parent,
) -> Result<PackageReceiptV2, PackageReason> {
if indexes.is_empty() {
return err("receipt-index-count", "a package has at least one component");
}
let mut parsed = Vec::with_capacity(indexes.len());
let mut components = Vec::with_capacity(indexes.len());
for (id, bytes) in indexes {
let ix = StageIndex::parse(bytes).map_err(|r| PackageReason::index(id, r))?;
ix.check().map_err(|r| PackageReason::index(id, r))?;
if ix.component() != id || components.iter().any(|c: &ComponentBinding| &c.id == id) {
return err("receipt-component", format!("component {id}: its index names {}", ix.component()));
}
if ix.header.policy_digest != policy_digest {
return err("receipt-policy-digest", format!("component {id}: its index states another policy digest"));
}
components.push(ComponentBinding { id: id.clone(), media_class: ix.header.media_class, index_digest: crate::stage_index::digest_of(bytes), stage_root: ix.stage_root() });
parsed.push(ix);
}
check_parent(parent, &parsed)?;
let (total_entries, unique_objects, total_bytes, stored_bytes) = counts(&parsed)?;
let (joules_micro, energy_provenance) = energy;
let r = PackageReceiptV2 {
asset_id: asset_id.to_string(),
policy_digest,
parent,
components,
total_entries,
unique_objects,
total_bytes,
stored_bytes,
work_units: total_entries,
packager: packager.to_string(),
joules_micro,
energy_provenance,
grant,
signer_pubkey: signer.verifying_key().to_bytes(),
signer_id: signer_id.to_string(),
sig: [0u8; 64],
};
r.check_energy()?;
let mut r = r;
r.sig = signer.sign(&r.signing_payload()).to_bytes();
r.json_value().map_err(|why| PackageReason::new("receipt-json", why))?;
Ok(r)
}
fn check_energy(&self) -> Result<(), PackageReason> {
let label = self.energy_provenance.as_ref();
if !figure_and_class_cohere(self.joules_micro, label) || check_label(self.joules_micro, label).is_err() {
return err("receipt-energy", "the energy figure and its class do not cohere");
}
Ok(())
}
/// [`Self::verify_detailed`] as a predicate.
pub fn verify(&self, indexes: &[&[u8]]) -> bool {
self.verify_detailed(indexes).is_ok()
}
/// Verify the receipt against its components' index files, in its order:
///
/// 1. at least one component, and one index per component (`receipt-index-count`), a
/// live window's included; each parses and checks (its
/// own reason), hashes to the component's `index_digest` (`receipt-index-digest`),
/// has its `stage_root` (`receipt-stage-root`), names its id and media class
/// (`receipt-component`) and the receipt's policy digest (`receipt-policy-digest`);
/// 2. the counts, recomputed (`receipt-counts`);
/// 3. the parent kind against the indexes' `prev_index_digest`: zero for none and
/// a re-pack, non-zero for a live window (`receipt-parent-kind`);
/// 4. the energy figure and its class (`receipt-energy`);
/// 5. the signature (`receipt-signature`).
pub fn verify_detailed(&self, indexes: &[&[u8]]) -> Result<(), PackageReason> {
if self.components.is_empty() || indexes.len() != self.components.len() {
return err("receipt-index-count", format!("{} indexes for {} components; a package has at least one", indexes.len(), self.components.len()));
}
let mut parsed = Vec::with_capacity(indexes.len());
for (c, bytes) in self.components.iter().zip(indexes) {
let ix = StageIndex::parse(bytes).map_err(|r| PackageReason::index(&c.id, r))?;
ix.check().map_err(|r| PackageReason::index(&c.id, r))?;
if crate::stage_index::digest_of(bytes) != c.index_digest {
return err("receipt-index-digest", format!("component {}: the index does not hash to index_digest", c.id));
}
if ix.stage_root() != c.stage_root {
return err("receipt-stage-root", format!("component {}: the index's stage root differs", c.id));
}
if ix.component() != c.id || ix.header.media_class != c.media_class {
return err("receipt-component", format!("component {}: the index names {} ({})", c.id, ix.component(), ix.header.media_class.label()));
}
if ix.header.policy_digest != self.policy_digest {
return err("receipt-policy-digest", format!("component {}: its index states another policy digest", c.id));
}
parsed.push(ix);
}
let ids: std::collections::BTreeSet<&str> = self.components.iter().map(|c| c.id.as_str()).collect();
if ids.len() != self.components.len() {
return err("receipt-component", "two components of one id");
}
let got = counts(&parsed)?;
if got != (self.total_entries, self.unique_objects, self.total_bytes, self.stored_bytes) || self.work_units != self.total_entries {
return err("receipt-counts", format!("the indexes give {got:?}"));
}
check_parent(self.parent, &parsed)?;
self.check_energy()?;
crate::sig::verify_strict(&self.signer_pubkey, &self.signing_payload(), &self.sig).map_err(|e| {
PackageReason::new("receipt-signature", match e {
crate::sig::SigError::BadKey => "signer_pubkey is not a canonical Ed25519 key of large order",
crate::sig::SigError::BadSignature => "the signature does not verify",
})
})
}
fn json_value(&self) -> Result<Value, String> {
if !grant_fits_json(&self.grant) {
return Err("a grant ceiling past 2^53 - 1".into());
}
let comps: Vec<Value> = self
.components
.iter()
.map(|c| {
json!({
"id": c.id,
"index": c.index_file(),
"index_digest": hex(&c.index_digest),
"media_class": c.media_class.label(),
"stage_root": hex(&c.stage_root),
})
})
.collect();
let mut o = json!({
"asset_id": self.asset_id,
"components": comps,
"energy_provenance": Value::Null,
"grant": grant_json(&self.grant),
"joules_micro": self.joules_micro,
"kind": KIND,
"packager": self.packager,
"parent_kind": self.parent.label(),
"parent_receipt_hash": self.parent.hash().map(|h| hex(&h)),
"policy_digest": hex(&self.policy_digest),
"receipt_hash": hex(&self.receipt_hash()),
"sig": hex(&self.sig),
"signer_id": self.signer_id,
"signer_pubkey": hex(&self.signer_pubkey),
"stored_bytes": self.stored_bytes,
"total_bytes": self.total_bytes,
"total_entries": self.total_entries,
"unique_objects": self.unique_objects,
"version": 2,
"work_units": self.work_units,
});
if let (Some(p), Value::Object(m)) = (&self.energy_provenance, &mut o) {
for (k, v) in json_fields(p) {
m.insert(k.to_string(), v);
}
}
jcs(&o).map_err(|e| e.to_string())?;
Ok(o)
}
/// The receipt as JSON: RFC 8785 canonical (members in code-unit order, no
/// insignificant whitespace), hexadecimal lowercase, integers as numbers. An energy
/// label writes its class as `energy_provenance` and its evidence beside it; an
/// unmetered receipt writes `energy_provenance` as `null` and no evidence.
pub fn to_json(&self) -> String {
let v = self.json_value().expect("a sealed receipt has a JSON form");
jcs(&v).expect("checked at seal")
}
/// Read a receipt from its JSON (§9.1). Refused (`receipt-json`): JSON that does not
/// parse or names a member twice in one object, a member this version does not define
/// or one it requires missing (`energy_provenance` among them, `null` when unmetered),
/// a value of the wrong form (a negative number, `-0` included), a class's evidence
/// other than exactly what the class carries or `null` in its place, a parent kind and
/// hash that disagree, an `index` that is not `index-<id>.wsix`, and a `receipt_hash`
/// that is not the receipt's. The signature is not checked here:
/// [`Self::verify_detailed`] does. A reader does not require the canonical form; a
/// package's file must be it (§11.3).
pub fn from_json(text: &str) -> Result<PackageReceiptV2, PackageReason> {
let bad = |w: String| PackageReason::new("receipt-json", w);
let v = read_json(text)?;
let o = v.as_object().ok_or_else(|| bad("not an object".into()))?;
exact_members(
o,
&[
"asset_id", "components", "energy_provenance", "grant", "joules_micro", "kind", "packager", "parent_kind", "parent_receipt_hash", "policy_digest",
"receipt_hash", "sig", "signer_id", "signer_pubkey", "stored_bytes", "total_bytes", "total_entries", "unique_objects", "version", "work_units",
],
)
.map_err(bad)?;
if str_of(o, "kind")? != KIND || u64_of(o, "version")? != 2 {
return Err(bad("not a version-2 package receipt".into()));
}
let parent_hash = match o.get("parent_receipt_hash") {
Some(Value::Null) => None,
_ => Some(hex_of::<32>(o, "parent_receipt_hash")?),
};
let parent = match (str_of(o, "parent_kind")?, parent_hash) {
("none", None) => Parent::None,
("live-window", Some(h)) => Parent::LiveWindow(h),
("re-pack", Some(h)) => Parent::RePack(h),
_ => return Err(bad("parent_kind and parent_receipt_hash disagree".into())),
};
let mut components = Vec::new();
for c in o.get("components").and_then(Value::as_array).ok_or_else(|| bad("components is not an array".into()))? {
let c = c.as_object().ok_or_else(|| bad("a component is not an object".into()))?;
exact_members(c, &["id", "index", "index_digest", "media_class", "stage_root"]).map_err(bad)?;
let id = str_of(c, "id")?.to_string();
if str_of(c, "index")? != index_file(&id) {
return Err(bad(format!("component {id}: index is not {}", index_file(&id))));
}
let media_class = MediaClass::from_label(str_of(c, "media_class")?).ok_or_else(|| bad("media_class".into()))?;
components.push(ComponentBinding { id, media_class, index_digest: hex_of(c, "index_digest")?, stage_root: hex_of(c, "stage_root")? });
}
let energy_provenance = label_from_json(o).map_err(|e| bad(format!("energy label: {e:?}")))?;
// Version 2 writes its class's evidence exactly as the class carries it, never a
// null in its place.
let want: Vec<&str> = energy_provenance.as_ref().map(|p| json_fields(p).into_iter().map(|(k, _)| k).filter(|k| *k != JSON_CLASS).collect()).unwrap_or_default();
for k in [JSON_UNCERTAINTY, JSON_FILTERING, JSON_CALIBRATION_REF] {
if o.contains_key(k) != want.contains(&k) || o.get(k).is_some_and(Value::is_null) {
return Err(bad(format!("{k} is not its class's evidence")));
}
}
let r = PackageReceiptV2 {
asset_id: str_of(o, "asset_id")?.to_string(),
policy_digest: hex_of(o, "policy_digest")?,
parent,
components,
total_entries: u64_of(o, "total_entries")?,
unique_objects: u64_of(o, "unique_objects")?,
total_bytes: u64_of(o, "total_bytes")?,
stored_bytes: u64_of(o, "stored_bytes")?,
work_units: u64_of(o, "work_units")?,
packager: str_of(o, "packager")?.to_string(),
joules_micro: u64_of(o, "joules_micro")?,
energy_provenance,
grant: grant_from_json(o.get("grant").unwrap_or(&Value::Null))?,
signer_pubkey: hex_of(o, "signer_pubkey")?,
signer_id: str_of(o, "signer_id")?.to_string(),
sig: hex_of(o, "sig")?,
};
if hex_of::<32>(o, "receipt_hash")? != r.receipt_hash() {
return Err(bad("receipt_hash is not the receipt's".into()));
}
Ok(r)
}
}
/// §9.2 and verification step 3: a package with no parent, or a re-pack, has
/// `prev_index_digest` zero in every index; a live window has it non-zero in every one.
fn check_parent(parent: Parent, indexes: &[StageIndex]) -> Result<(), PackageReason> {
let live = matches!(parent, Parent::LiveWindow(_));
for ix in indexes {
if (ix.header.prev_index_digest != [0u8; 32]) != live {
return err("receipt-parent-kind", format!("parent kind {} with component {}'s prev_index_digest", parent.label(), ix.component()));
}
}
Ok(())
}
/// The version-1 receipt as JSON (§9.3), and back. v1's class is a member of the receipt
/// from the start: `energy_provenance` is `null` when unmetered, and otherwise the label
/// with its evidence.
impl PackageReceipt {
fn json_value(&self) -> Result<Value, String> {
if !grant_fits_json(&self.grant) {
return Err("a grant ceiling past 2^53 - 1".into());
}
let segs: Vec<Value> = self
.segments
.iter()
.map(|s| json!({"byte_len": s.byte_len, "content_hash": hex(&s.content_hash), "duration_ms": s.duration_ms, "index": s.index}))
.collect();
let mut o = json!({
"asset_id": self.asset_id,
"energy_provenance": Value::Null,
"grant": grant_json(&self.grant),
"joules_micro": self.joules_micro,
"kind": KIND,
"parent_receipt_hash": self.parent_receipt_hash.map(|h| hex(&h)),
"receipt_hash": hex(&self.receipt_hash()),
"segment_map_root": hex(&self.segment_map_root),
"segments": segs,
"sig": hex(&self.sig),
"signer_id": self.signer_id,
"signer_pubkey": hex(&self.signer_pubkey),
"stored_bytes": self.stored_bytes,
"total_bytes": self.total_bytes,
"total_segments": self.total_segments,
"unique_segments": self.unique_segments,
"version": 1,
"work_segment_ops": self.work_segment_ops,
});
if let (Some(p), Value::Object(m)) = (&self.energy_provenance, &mut o) {
for (k, v) in json_fields(p) {
m.insert(k.to_string(), v);
}
}
jcs(&o).map_err(|e| e.to_string())?;
Ok(o)
}
/// The receipt as JSON: RFC 8785 canonical, `"version":1`. `None` for a receipt with
/// an integer past `2^53 − 1`, which has no JSON form.
pub fn to_json(&self) -> Option<String> {
jcs(&self.json_value().ok()?).ok()
}
/// Read a version-1 receipt from its JSON (`"version"` 1, or absent). Refused as
/// [`PackageReceiptV2::from_json`] refuses, except that `energy_provenance` may be
/// absent (read as unmetered), a `null` evidence member is absent, and an on-chip
/// counter's filtering, absent, reads as unknown: version-1 receipts were issued before
/// this form, and they keep verifying. The signature is checked by `verify`.
pub fn from_json(text: &str) -> Result<PackageReceipt, PackageReason> {
use crate::wai_video_package::SegmentEntry;
let bad = |w: String| PackageReason::new("receipt-json", w);
let v = read_json(text)?;
let o = v.as_object().ok_or_else(|| bad("not an object".into()))?;
let mut names = vec![
"asset_id", "grant", "joules_micro", "kind", "parent_receipt_hash", "receipt_hash", "segment_map_root", "segments", "sig", "signer_id", "signer_pubkey",
"stored_bytes", "total_bytes", "total_segments", "unique_segments", "work_segment_ops",
];
if o.contains_key("version") {
names.push("version");
if u64_of(o, "version")? != 1 {
return Err(bad("not a version-1 package receipt".into()));
}
}
exact_members(o, &names).map_err(bad)?;
if str_of(o, "kind")? != KIND {
return Err(bad("not a package receipt".into()));
}
let mut segments = Vec::new();
for s in o.get("segments").and_then(Value::as_array).ok_or_else(|| bad("segments is not an array".into()))? {
let s = s.as_object().ok_or_else(|| bad("a segment is not an object".into()))?;
exact_members(s, &["byte_len", "content_hash", "duration_ms", "index"]).map_err(bad)?;
segments.push(SegmentEntry { index: u64_of(s, "index")?, content_hash: hex_of(s, "content_hash")?, duration_ms: u64_of(s, "duration_ms")?, byte_len: u64_of(s, "byte_len")? });
}
let parent_receipt_hash = match o.get("parent_receipt_hash") {
Some(Value::Null) => None,
_ => Some(hex_of::<32>(o, "parent_receipt_hash")?),
};
let r = PackageReceipt {
asset_id: str_of(o, "asset_id")?.to_string(),
segment_map_root: hex_of(o, "segment_map_root")?,
segments,
total_segments: u64_of(o, "total_segments")?,
unique_segments: u64_of(o, "unique_segments")?,
total_bytes: u64_of(o, "total_bytes")?,
stored_bytes: u64_of(o, "stored_bytes")?,
work_segment_ops: u64_of(o, "work_segment_ops")?,
joules_micro: u64_of(o, "joules_micro")?,
energy_provenance: label_from_json(o).map_err(|e| bad(format!("energy label: {e:?}")))?,
grant: grant_from_json(o.get("grant").unwrap_or(&Value::Null))?,
parent_receipt_hash,
signer_pubkey: hex_of(o, "signer_pubkey")?,
signer_id: str_of(o, "signer_id")?.to_string(),
sig: hex_of(o, "sig")?,
};
if hex_of::<32>(o, "receipt_hash")? != r.receipt_hash() {
return Err(bad("receipt_hash is not the receipt's".into()));
}
Ok(r)
}
}
/// A package receipt of either version.
#[derive(Clone, Debug, PartialEq)]
pub enum AnyPackageReceipt {
V1(PackageReceipt),
V2(PackageReceiptV2),
}
impl AnyPackageReceipt {
/// Read either version from its JSON, by its `"version"` member: absent or 1 is
/// version 1, 2 is version 2, any other refused (`receipt-version`).
pub fn from_json(text: &str) -> Result<AnyPackageReceipt, PackageReason> {
let v = read_json(text)?;
match v.get("version") {
None => PackageReceipt::from_json(text).map(AnyPackageReceipt::V1),
Some(n) if n.as_u64() == Some(1) => PackageReceipt::from_json(text).map(AnyPackageReceipt::V1),
Some(n) if n.as_u64() == Some(2) => PackageReceiptV2::from_json(text).map(AnyPackageReceipt::V2),
Some(n) => err("receipt-version", format!("version {n}")),
}
}
}
#[cfg(test)]
mod tests;