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]>
734 lines
33 KiB
Rust
734 lines
33 KiB
Rust
//! 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;
|