WAI delivery bindings: secure-objects AAD in receipts (MOQT 22/18/16), CMSD read, steering named to CDNs, live C2PA signing #458

Closed
dcharlot wants to merge 16 commits from wai/delivery-bindings into main
86 changed files with 7035 additions and 630 deletions

View file

@ -336,6 +336,7 @@ pub fn sign_group(signer: &Signer, receipt_alias: u64, group: u64, hashes: &[[u8
origin: signer.origin_label.clone(),
merkle_proof: b.proof_for(i).expect("an index of the group"),
energy_class: Some(vec![0]),
identity: None,
})
.collect();
let g = b.finalize_labelled(&signer.key, signer.signer_id.clone(), end.to_string(), parent).expect("the unmetered marker composes");

View file

@ -1,7 +1,7 @@
[package]
name = "joule-moq-interop"
version = "0.1.0"
description = "An interoperability client for joule-moq: the MOQT interop runner's test cases (TAP 14) at draft -22, -18 or -16 against any relay, and a scenario that carries a WAI track and its receipts through a relay and verifies every object at the subscriber."
description = "An interoperability client for joule-moq: the MOQT interop runner's test cases (TAP 14) at draft -22, -18 or -16 against any relay, a scenario that carries a WAI track and its receipts through a relay and verifies every object at the subscriber, and one that does so with every object sealed as an MoQ secure object and every receipt bound to its identity."
edition = "2024"
rust-version = "1.97"
license = "Apache-2.0"
@ -13,7 +13,7 @@ name = "joule-moq-interop"
path = "src/main.rs"
[dependencies]
joule-moq = { path = "../joule-moq" }
joule-moq = { path = "../joule-moq", features = ["secure-objects"] }
joule-moq-relay = { path = "../joule-moq-relay" }
quinn = "0.11"
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }

View file

@ -1,6 +1,6 @@
#!/bin/bash
# joule-moq-interop against joule-moq-relay on this host: every test at each draft, and
# wai-receipts across drafts. No network beyond loopback.
# wai-receipts and wai-sealed across drafts. No network beyond loopback.
# scripts/local.sh <target-dir>
set -u
T=${1:-target}
@ -15,11 +15,14 @@ for d in 22 18 16; do
echo "== draft $d"
"$T/debug/joule-moq-interop" --relay $URL --draft $d --tls-disable-verify || rc=1
"$T/debug/joule-moq-interop" --relay $URL --draft $d --tls-disable-verify --test wai-receipts || rc=1
"$T/debug/joule-moq-interop" --relay $URL --draft $d --tls-disable-verify --test wai-sealed || rc=1
done
for pair in "16 22" "22 16" "18 16" "16 18"; do
set -- $pair
echo "== wai-receipts publisher $1, subscriber $2"
"$T/debug/joule-moq-interop" --relay $URL --draft $1 --sub-draft $2 --tls-disable-verify --test wai-receipts || rc=1
echo "== wai-sealed publisher $1, subscriber $2"
"$T/debug/joule-moq-interop" --relay $URL --draft $1 --sub-draft $2 --tls-disable-verify --test wai-sealed || rc=1
done
# The check must fail when the receipts or the objects are not what was signed.
for t in payload receipt in-flight; do
@ -30,4 +33,13 @@ for t in payload receipt in-flight; do
echo "tamper $t: refused, as it must be"
fi
done
# A sealed object whose Immutable Properties are not the ones its seal and receipt bound,
# published at -22 and received at -16 through the bridge: the identity check and the AEAD
# must both refuse it.
echo "== wai-sealed, tamper identity (must fail)"
if "$T/debug/joule-moq-interop" --relay $URL --draft 22 --sub-draft 16 --tls-disable-verify --test wai-sealed --tamper identity; then
echo "FALSIFICATION FAILED: tamper identity passed"; rc=1
else
echo "tamper identity: refused, as it must be"
fi
exit $rc

View file

@ -14,6 +14,15 @@
//! receipt carried as a property against the one in the receipts track, the
//! `WAI_RECEIPT` property against both, the receipt chain's links, and the envelope
//! itself. `--tamper` breaks it on purpose, to show the check fails.
//! - `wai-sealed`, the same scenario with every media object sealed as an MoQ secure
//! object (`draft-ietf-moq-secure-objects-01` as WAI `confidentiality.md` §4 places it on
//! -22's objects: suite 0x0004, the Key ID inside Immutable Properties) and every object
//! receipt identity-bound (confidentiality §8.4): the subscriber forms each object's
//! identity from what arrived, its Group ID, Object ID, Publisher Priority and Immutable
//! Properties, checks it against the receipt, and opens the object under that same AAD.
//! It shows whether a relay carries the bytes secure objects authenticates unchanged.
//! `--tamper identity` changes one object's Immutable Properties after its seal and its
//! receipt, to show both checks fail.
//!
//! A `moqt://` relay is reached over native QUIC with the chosen draft's ALPN, and the
//! SETUP carries AUTHORITY and PATH; an `https://` relay over WebTransport, offering
@ -40,7 +49,7 @@ use std::time::{Duration, Instant, SystemTime};
use tokio::time::timeout;
/// The runner's test cases, in its catalog's order, and this client's own scenario.
const TESTS: [&str; 9] = [
const TESTS: [&str; 10] = [
"setup-only",
"announce-only",
"publish-namespace-done",
@ -50,9 +59,10 @@ const TESTS: [&str; 9] = [
"announce-subscribe",
"subscribe-before-announce",
"wai-receipts",
"wai-sealed",
];
/// The runner's tests run by default; `wai-receipts` only when named.
/// The runner's tests run by default; `wai-receipts` and `wai-sealed` only when named.
const RUNNER_TESTS: usize = 8;
#[derive(Clone, Debug)]
@ -93,6 +103,9 @@ enum Tamper {
/// The subscriber's own end flips the last byte of every third media stream it reads,
/// after the relay: an object changed in flight.
InFlight,
/// `wai-sealed`: the publisher sends one object whose Immutable Properties are not the
/// ones its seal and its receipt bound.
Identity,
}
fn env_flag(k: &str) -> bool {
@ -108,7 +121,7 @@ fn usage() -> ! {
eprintln!(
"usage: joule-moq-interop [-r|--relay URL] [-t|--test NAME] [-l|--list] [-v|--verbose] [--tls-disable-verify]
[--draft 22|18|16] [--sub-draft 22|18|16] [--groups N] [--objects N]
[--tamper none|payload|receipt|in-flight] [--json PATH] [--timeout-scale X]
[--tamper none|payload|receipt|in-flight|identity] [--json PATH] [--timeout-scale X]
[--flow namespace|publish] [--no-object-properties]
RELAY_URL, TESTCASE, TLS_DISABLE_VERIFY=1, VERBOSE=1 and MOQT_DRAFT stand for the flags."
);
@ -166,6 +179,7 @@ fn main() {
"payload" => Tamper::Payload,
"receipt" => Tamper::Receipt,
"in-flight" => Tamper::InFlight,
"identity" => Tamper::Identity,
_ => usage(),
}
}
@ -242,7 +256,8 @@ async fn run(cfg: Config, tests: Vec<&str>) -> i32 {
"rendezvous-timeout" => rendezvous_timeout(&cfg).await,
"announce-subscribe" => announce_subscribe(&cfg).await,
"subscribe-before-announce" => subscribe_before_announce(&cfg).await,
"wai-receipts" => wai_receipts(&cfg).await,
"wai-receipts" => wai_receipts(&cfg, false).await,
"wai-sealed" => wai_receipts(&cfg, true).await,
_ => Outcome::skip("not a test of this client"),
};
o.diag.insert("duration_ms".into(), json!(start.elapsed().as_millis() as u64));
@ -873,11 +888,12 @@ fn percentile(v: &mut [f64], p: f64) -> f64 {
v[i.min(v.len() - 1)]
}
async fn wai_receipts(cfg: &Config) -> Outcome {
async fn wai_receipts(cfg: &Config, sealed: bool) -> Outcome {
let id = run_id();
let space = ns(&["moq-test", &format!("wai-receipts-{id}")]);
let space = ns(&["moq-test", &format!("{}-{id}", if sealed { "wai-sealed" } else { "wai-receipts" })]);
let (catalog, media, receipts) = (TrackName::from_str("catalog"), TrackName::from_str("wai"), TrackName::from_str("wai_receipts"));
let key = SigningKey::from_bytes(&blake3::hash(format!("joule-moq-interop {id}").as_bytes()).into());
let sealing = sealed.then(|| Sealing::new(&space, &media, &id));
let publ = match connect(cfg, cfg.draft, true, None).await {
Ok(s) => Arc::new(s),
@ -941,7 +957,7 @@ async fn wai_receipts(cfg: &Config) -> Outcome {
// own: from the publisher's send to the end of its stream at the subscriber.
// The catalog's object, a receipts bundle per group, and the media objects.
let enough = (1 + cfg.groups + cfg.groups * cfg.objects_per_group) as usize;
let (sent, got) = tokio::join!(publish(cfg, &publ.client, &key), collect(&sub.client, enough, secs(cfg, 20.0)));
let (sent, got) = tokio::join!(publish(cfg, &publ.client, &key, sealing.as_ref()), collect(&sub.client, enough, secs(cfg, 20.0)));
let sent = sent?;
drop(held);
Ok::<_, String>((sent, got, c_ok.track_alias, m_ok.track_alias, r_ok.track_alias))
@ -1012,6 +1028,8 @@ async fn wai_receipts(cfg: &Config) -> Outcome {
let mut verified = 0u64;
let mut inclusion_verified = 0u64;
let mut properties_intact = 0u64;
let mut identity_verified = 0u64;
let mut opened = 0u64;
let mut seen = std::collections::BTreeSet::new();
let mut latencies = Vec::new();
for (h, objs, at) in got.iter().filter(|(h, _, _)| h.track_alias == m_alias) {
@ -1035,6 +1053,25 @@ async fn wai_receipts(cfg: &Config) -> Outcome {
Ok(()) => inclusion_verified += 1,
Err(e) => fault.push(format!("payload: {e}")),
}
// A sealed object: its identity as it arrived against its receipt's, then the
// AEAD under that same AAD; what it opens to is the envelope checked below.
let mut envelope_bytes = o.payload.clone();
if let Some(s) = &sealing {
let priority = h.publisher_priority.unwrap_or(128);
let received = joule_moq::receipt::identity_as_received(h.group_id, o.object_id, priority, &o.properties);
match r.check_identity(received.as_deref()) {
Ok(()) if r.identity.is_some() => identity_verified += 1,
Ok(()) => fault.push("its receipt binds no identity".into()),
Err(e) => fault.push(e.to_string()),
}
match s.open(h.group_id, o.object_id, priority, &o.properties, &o.payload) {
Ok(p) => {
opened += 1;
envelope_bytes = p;
}
Err(e) => fault.push(format!("does not open: {e}")),
}
}
let mut hd = ObjectHeader {
track_alias: TrackAlias(h.track_alias),
group_id: GroupId(h.group_id),
@ -1046,7 +1083,7 @@ async fn wai_receipts(cfg: &Config) -> Outcome {
mrl_total_layers: 0,
wai_envelope: false,
};
if cfg.properties {
if cfg.properties && sealing.is_none() {
match read_object_properties(&o.properties, &mut hd) {
Err(e) => fault.push(format!("properties: {e}")),
Ok(extras) => {
@ -1070,7 +1107,7 @@ async fn wai_receipts(cfg: &Config) -> Outcome {
}
}
}
match envelope_unpack(&o.payload) {
match envelope_unpack(&envelope_bytes) {
Ok((m, body)) if m.model_requirement.capability == TEXT_ZSTD => match zstd::decode_all(&body[..]) {
Ok(t) if t == text_of(h.group_id, o.object_id).into_bytes() => {}
_ => fault.push("the envelope's text".into()),
@ -1099,7 +1136,7 @@ async fn wai_receipts(cfg: &Config) -> Outcome {
o.message = Some(format!(
"{verified} of {} objects verified ({delivered} delivered, {inclusion_verified} inclusion-verified, {} with properties intact); {} of {} receipt groups verified; {} failure(s)",
sent.objects,
if cfg.properties { properties_intact.to_string() } else { "no properties sent, so none".into() },
if sealing.is_some() { format!("sealed; {identity_verified} identity-verified, {opened} opened") } else if cfg.properties { properties_intact.to_string() } else { "no properties sent, so none".into() },
bundles_verified,
cfg.groups,
failures.len()
@ -1114,7 +1151,11 @@ async fn wai_receipts(cfg: &Config) -> Outcome {
o.diag.insert("objects_delivered".into(), json!(delivered));
o.diag.insert("objects_verified".into(), json!(verified));
o.diag.insert("objects_inclusion_verified".into(), json!(inclusion_verified));
o.diag.insert("objects_properties_intact".into(), json!(if cfg.properties { json!(properties_intact) } else { json!("not sent") }));
o.diag.insert("objects_properties_intact".into(), json!(if sealing.is_some() { json!("sealed: Immutable Properties only") } else if cfg.properties { json!(properties_intact) } else { json!("not sent") }));
if sealing.is_some() {
o.diag.insert("objects_identity_verified".into(), json!(identity_verified));
o.diag.insert("objects_opened".into(), json!(opened));
}
o.diag.insert("flow".into(), json!(match cfg.flow { Flow::Namespace => "namespace", Flow::Publish => "publish" }));
if !missing.is_empty() {
o.diag.insert("objects_missing".into(), json!(missing));
@ -1146,18 +1187,77 @@ async fn wai_receipts(cfg: &Config) -> Outcome {
o
}
/// The sealing of `wai-sealed` (WAI confidentiality §4): suite 0x0004, one Key ID, one
/// track base key, and the media track's Serialized Full Track Name.
struct Sealing {
ftn: Vec<u8>,
keys: joule_moq::secure_objects::KeySalt,
/// The Serialized Immutable Properties every object carries: the Key ID alone.
sip: Vec<u8>,
}
const SEALED_KID: u64 = 7;
const SEALED_SUITE: joule_moq::secure_objects::Suite = joule_moq::secure_objects::Suite::AesGcm128Sha256;
impl Sealing {
fn new(space: &TrackNamespace, media: &TrackName, id: &str) -> Sealing {
use joule_moq::secure_objects as so;
let ftn = so::serialize_full_track_name(space, media);
let base = blake3::hash(format!("joule-moq-interop sealed {id}").as_bytes());
Sealing { keys: so::derive_key_salt(SEALED_SUITE, SEALED_KID, base.as_bytes(), &ftn), sip: so::immutable_properties(SEALED_KID, &[]), ftn }
}
fn at(&self, group: u64, object: u64, priority: u8) -> joule_moq::secure_objects::Location<'_> {
joule_moq::secure_objects::Location { ftn: &self.ftn, group, object, priority }
}
/// The sealed payload of `envelope` as object (`group`, `object`) at `priority`.
fn seal(&self, group: u64, object: u64, priority: u8, envelope: &[u8]) -> Result<Vec<u8>, String> {
use joule_moq::secure_objects as so;
let pt = so::Plaintext::with_digest(envelope.to_vec()).encode();
so::seal_object(SEALED_SUITE, &self.keys, self.at(group, object, priority), &self.sip, &pt).map_err(|e| e.code().to_string())
}
/// Open an object as it arrived (confidentiality §6, in this scenario's one key): its
/// Key ID from its Immutable Properties, the AEAD under the AAD those and its header
/// give, the plaintext and its inner digest. The original payload, or the reason.
fn open(&self, group: u64, object: u64, priority: u8, props: &Properties, payload: &[u8]) -> Result<Vec<u8>, String> {
use joule_moq::secure_objects as so;
let (kid, sip) = so::key_id_of(props).map_err(|e| e.code().to_string())?;
if kid != SEALED_KID {
return Err(so::Refusal::KeyIdUnknown.code().to_string());
}
let pt = so::open_object(SEALED_SUITE, &self.keys, self.at(group, object, priority), &sip, payload).map_err(|e| e.code().to_string())?;
let pt = so::Plaintext::decode(&pt).map_err(|e| e.code().to_string())?;
pt.check_digest().map_err(|e| e.code().to_string())?;
Ok(pt.payload)
}
}
/// Publish the catalog, then each group's receipts and its objects (moq-streaming-format
/// §4.1: the receipts no later than the group's first object), the groups chained.
async fn publish(cfg: &Config, c: &Client, key: &SigningKey) -> Result<Sent, String> {
/// §4.1: the receipts no later than the group's first object), the groups chained. With a
/// sealing, each object is sealed and its receipt binds its identity (`wai-sealed`).
async fn publish(cfg: &Config, c: &Client, key: &SigningKey, sealing: Option<&Sealing>) -> Result<Sent, String> {
let catalog_header = SubgroupHeader { track_alias: 1, group_id: 0, subgroup_id: SubgroupId::Zero, publisher_priority: Some(32), properties: false, end_of_group: true, first_object: true };
c.send_subgroup(catalog_header, &[SubgroupObject::normal(0, catalog_json("wai", "wai_receipts"))]).await?;
let mut sent = Sent { at: BTreeMap::new(), bundle_bytes: Vec::new(), objects: 0 };
let mut parent: Option<[u8; 32]> = None;
for g in 0..cfg.groups {
let envs: Vec<Vec<u8>> = (0..cfg.objects_per_group).map(|i| envelope(g, i)).collect();
let mut envs: Vec<Vec<u8>> = (0..cfg.objects_per_group).map(|i| envelope(g, i)).collect();
if let Some(s) = sealing {
envs = envs.iter().enumerate().map(|(i, e)| s.seal(g, i as u64, 64, e)).collect::<Result<_, _>>()?;
}
let mut b = ReceiptBuilder::new(TrackAlias(RECEIPT_ALIAS), GroupId(g));
for e in &envs {
b.add_labelled_object(ObjectReceipt::hash_payload(e), 0, "joule-moq-interop", "2026-10-21T00:00:00Z", vec![0]).map_err(|e| e.to_string())?;
for (i, e) in envs.iter().enumerate() {
match sealing {
Some(s) => {
let identity = joule_moq::receipt::object_identity(g, i as u64, 64, &s.sip).ok_or("an Object ID above 2^32 - 1")?;
b.add_identity_object(ObjectReceipt::hash_payload(e), 0, "joule-moq-interop", "2026-10-21T00:00:00Z", vec![0], identity).map_err(|e| e.to_string())?;
}
None => {
b.add_labelled_object(ObjectReceipt::hash_payload(e), 0, "joule-moq-interop", "2026-10-21T00:00:00Z", vec![0]).map_err(|e| e.to_string())?;
}
}
}
let objects: Vec<ObjectReceipt> = envs
.iter()
@ -1171,6 +1271,7 @@ async fn publish(cfg: &Config, c: &Client, key: &SigningKey) -> Result<Sent, Str
origin: "joule-moq-interop".into(),
merkle_proof: b.proof_for(i).expect("a proof"),
energy_class: Some(vec![0]),
identity: b.identity_for(i),
})
.collect();
let group = b.finalize_labelled(key, "joule-moq-interop", "2026-10-21T00:00:01Z", parent).map_err(|e| e.to_string())?;
@ -1201,7 +1302,17 @@ async fn publish(cfg: &Config, c: &Client, key: &SigningKey) -> Result<Sent, Str
wai_envelope: true,
};
let extras = ObjectExtras { receipt: Some(r.clone()), wai_receipt: WaiReceiptProperty::from_receipts(r, &group), delivery_timeout_ms: None };
let props = if cfg.properties { object_properties(&header, &extras).map_err(|e| e.to_string())? } else { Properties::new() };
let props = match sealing {
// A sealed object carries its Immutable Properties alone, and no receipt
// property, which the identity would then hold (confidentiality §8.4).
Some(_) if cfg.tamper == Tamper::Identity && g == 1 && i == 0 => {
let other = joule_moq::secure_objects::immutable_properties(SEALED_KID, &[KeyValuePair::varint(0x3a4c, 1)]);
joule_moq::secure_objects::object_properties(&other, &[])
}
Some(s) => joule_moq::secure_objects::object_properties(&s.sip, &[]),
None if cfg.properties => object_properties(&header, &extras).map_err(|e| e.to_string())?,
None => Properties::new(),
};
let mut payload = e.clone();
if cfg.tamper == Tamper::Payload && g == 1 && i == 0 {
let n = payload.len() - 1;
@ -1210,7 +1321,7 @@ async fn publish(cfg: &Config, c: &Client, key: &SigningKey) -> Result<Sent, Str
wire_objects.push(SubgroupObject { object_id: i as u64, properties: props, status: joule_moq::draft22::data::ObjectStatus::Normal, payload });
}
sent.at.insert(g, Instant::now());
let mh = SubgroupHeader { track_alias: 2, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: cfg.properties, end_of_group: true, first_object: true };
let mh = SubgroupHeader { track_alias: 2, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: cfg.properties || sealing.is_some(), end_of_group: true, first_object: true };
c.send_subgroup(mh, &wire_objects).await?;
sent.objects += wire_objects.len() as u64;
tokio::time::sleep(Duration::from_millis(50)).await;

View file

@ -1,53 +1,57 @@
//! The corpus of WAI `live-conformance/e2ee/` (confidentiality §14).
//!
//! joule_moq_e2ee_vectors gen <corpus-dir> write vectors.json from the fixed seeds
//! joule_moq_e2ee_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
//! the reference gives every vector its outcome
//! joule_moq_e2ee_vectors gen <corpus-dir> write vectors.json and identity.json from the
//! fixed seeds
//! joule_moq_e2ee_vectors verify <corpus-dir> both regenerate byte for byte, and the
//! reference gives every vector its outcome
use joule_moq_mls::corpus;
use joule_moq_mls::{corpus, identity};
use std::process::ExitCode;
/// One file of the corpus: its name, its bytes regenerated, and its check.
type Part = (&'static str, serde_json::Value, fn(&serde_json::Value) -> Result<(), String>);
fn main() -> ExitCode {
let args: Vec<String> = std::env::args().collect();
let (Some(mode), Some(dir)) = (args.get(1), args.get(2)) else {
eprintln!("usage: joule_moq_e2ee_vectors gen|verify <corpus-dir>");
return ExitCode::from(2);
};
let path = std::path::Path::new(dir).join(corpus::FILE);
let v = corpus::generate();
let bytes = corpus::to_bytes(&v);
let parts: [Part; 2] = [(corpus::FILE, corpus::generate(), corpus::check), (identity::FILE, identity::generate(), identity::check)];
match mode.as_str() {
"gen" => {
if let Err(e) = std::fs::write(&path, &bytes) {
eprintln!("{}: {e}", path.display());
return ExitCode::FAILURE;
}
println!("wrote {}", path.display());
ExitCode::SUCCESS
}
"verify" => {
let on_disk = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
for (file, v, _) in &parts {
let path = std::path::Path::new(dir).join(file);
if let Err(e) = std::fs::write(&path, corpus::to_bytes(v)) {
eprintln!("{}: {e}", path.display());
return ExitCode::FAILURE;
}
};
if on_disk != bytes {
eprintln!("{} does not regenerate byte for byte", path.display());
return ExitCode::FAILURE;
println!("wrote {}", path.display());
}
let parsed: serde_json::Value = serde_json::from_slice(&on_disk).expect("JSON");
match corpus::check(&parsed) {
Ok(()) => {
println!("{}: every vector regenerates and gives its stated outcome", path.display());
ExitCode::SUCCESS
ExitCode::SUCCESS
}
"verify" => {
for (file, v, check) in &parts {
let path = std::path::Path::new(dir).join(file);
let on_disk = match std::fs::read(&path) {
Ok(b) => b,
Err(e) => {
eprintln!("{}: {e}", path.display());
return ExitCode::FAILURE;
}
};
if on_disk != corpus::to_bytes(v) {
eprintln!("{} does not regenerate byte for byte", path.display());
return ExitCode::FAILURE;
}
Err(e) => {
eprintln!("{e}");
ExitCode::FAILURE
let parsed: serde_json::Value = serde_json::from_slice(&on_disk).expect("JSON");
if let Err(e) = check(&parsed) {
eprintln!("{}: {e}", path.display());
return ExitCode::FAILURE;
}
println!("{}: every vector regenerates and gives its stated outcome", path.display());
}
ExitCode::SUCCESS
}
_ => {
eprintln!("usage: joule_moq_e2ee_vectors gen|verify <corpus-dir>");

View file

@ -116,7 +116,7 @@ fn props_of(v: &Value) -> Result<Vec<KeyValuePair>, String> {
}
/// Sort every object's keys, so the file's bytes do not depend on how a JSON map orders.
fn canonical(v: Value) -> Value {
pub fn canonical(v: Value) -> Value {
match v {
Value::Object(m) => {
let sorted: BTreeMap<String, Value> = m.into_iter().map(|(k, v)| (k, canonical(v))).collect();

View file

@ -0,0 +1,344 @@
//! The identity vectors of WAI `live-conformance/e2ee/` (confidentiality §8.4):
//! `identity.json`, written from the corpus's seeds by [`generate`] and read by [`check`].
//!
//! - `groups`: a sealed video track's group whose object receipts bind each object's
//! identity, the `SECURE_OBJECT_AAD` it is sealed under; the group's receipts in the
//! binary form a receipts track carries (`WJR1`), each object's sealed payload, content
//! hash, identity and leaf; and copies with one defect each, with the outcome a verifier
//! gives them.
//! - `arrivals`: one of those objects as a hop receives it, at -22, -18 or -16, the
//! Immutable Properties as that session carries them and as -22 encodes them, and the
//! outcome of checking it against its receipt: `pass`, or `identity_mismatch` with the
//! part that differs.
//!
//! The seeds are [`crate::corpus`]'s: the publisher's key, the epoch's application secret
//! and every payload are SHA-256 of fixed labels, and AES-GCM and Ed25519 are
//! deterministic, so the file is the same bytes on every machine.
use crate::corpus::{bytes, key, room_ns, secret};
use joule_moq::carriage::ReceiptBundle;
use joule_moq::draft16::{bridge, kvp as kv16};
use joule_moq::draft22::kvp::{prop, KeyValuePair, Properties};
use joule_moq::receipt::{identity_as_received, identity_difference, ObjectReceipt, ReceiptBuilder, ReceiptVerifier};
use joule_moq::secure_objects::room::{Keyring, Outgoing, Roster};
use joule_moq::secure_objects::{immutable_properties, key_id_of, object_properties, Plaintext, Suite};
use joule_moq::{GroupId, ObjectId, TrackAlias, TrackName};
use serde_json::{json, Value};
/// The file [`generate`] writes.
pub const FILE: &str = "identity.json";
const ROOM_LABEL: &str = "identity";
const EPOCH: u64 = 4;
const RECEIPT_ALIAS: u64 = 7;
const GROUP: u64 = 5;
const ORIGIN: &str = "identity-publisher";
/// A property of these vectors' own inside Immutable Properties, beside the Key ID.
const EXTRA: u64 = 0x3a4c;
/// One object of the group.
struct Spec {
object: u64,
priority: u8,
/// The value of [`EXTRA`] inside Immutable Properties beside the Key ID, if any.
extra: Option<u64>,
joules: u64,
class: &'static [u8],
}
const OBJECTS: [Spec; 3] = [
Spec { object: 0, priority: 64, extra: None, joules: 1_200, class: &[2] },
Spec { object: 1, priority: 64, extra: Some(1), joules: 900, class: &[2] },
Spec { object: 2, priority: 96, extra: None, joules: 0, class: &[0] },
];
fn h(b: &[u8]) -> String {
hex::encode(b)
}
fn unhex(v: &Value) -> Result<Vec<u8>, String> {
hex::decode(v.as_str().ok_or("not a string")?).map_err(|e| e.to_string())
}
/// The publisher's sealed objects and their receipts.
struct Published {
publisher: [u8; 32],
objects: Vec<(u64, u8, Properties, Vec<u8>)>,
bundle: ReceiptBundle,
}
fn publish() -> Published {
let k = key("identity-publisher");
let p = k.verifying_key().to_bytes();
let roster: Roster = [(0u32, p)].into_iter().collect();
let mut ring = Keyring::new(Suite::AesGcm128Sha256, p);
ring.enter(EPOCH, secret(ROOM_LABEL, 0, EPOCH), roster);
let room: [u8; 32] = bytes("identity-room", 32).try_into().expect("32 bytes");
let ns = room_ns(&room, &p, "video");
let name = TrackName::from_str("0");
let mut b = ReceiptBuilder::new(TrackAlias(RECEIPT_ALIAS), GroupId(GROUP));
let mut objects = Vec::new();
for Spec { object: o, priority: prio, extra, joules, class } in OBJECTS {
let pt = Plaintext::with_digest(bytes(&format!("identity-object-{o}"), 240));
let extra: Vec<KeyValuePair> = extra.map(|v| KeyValuePair::varint(EXTRA, v)).into_iter().collect();
let sealed = ring
.seal(&Outgoing { ns: &ns, name: &name, group: GROUP, object: o, priority: prio, plaintext: &pt, immutable: &extra, mutable: &[] })
.expect("a member")
.expect("seals");
let identity = identity_as_received(GROUP, o, prio, &sealed.properties).expect("an identity");
b.add_identity_object(ObjectReceipt::hash_payload(&sealed.payload), joules, ORIGIN, "2026-10-22T00:00:00Z", class.to_vec(), identity)
.expect("an identity-bound object");
objects.push((o, prio, sealed.properties, sealed.payload));
}
let receipts = objects
.iter()
.enumerate()
.map(|(i, (o, _, _, payload))| ObjectReceipt {
track_alias: TrackAlias(RECEIPT_ALIAS),
group_id: GroupId(GROUP),
object_id: ObjectId(*o),
content_hash: ObjectReceipt::hash_payload(payload),
joules_micro: OBJECTS[i].joules,
origin: ORIGIN.into(),
merkle_proof: b.proof_for(i).expect("a proof"),
energy_class: b.class_for(i),
identity: b.identity_for(i),
})
.collect();
let group = b.finalize_labelled(&k, ORIGIN, "2026-10-22T00:00:01Z", None).expect("signs");
Published { publisher: p, objects, bundle: ReceiptBundle { group, objects: receipts } }
}
/// The value of an object's Immutable Properties pair.
fn immutable_of(props: &Properties) -> Vec<u8> {
props.0.iter().find(|p| p.ty == prop::IMMUTABLE_PROPERTIES).and_then(|p| p.as_bytes()).unwrap_or_default().to_vec()
}
/// A group's vector: its receipts as a receipts track carries them, what each object is,
/// and the outcome a verifier gives the group.
fn group_json(name: &str, what: &str, p: &Published, bundle_bytes: &[u8]) -> Value {
let objects: Vec<Value> = p
.objects
.iter()
.zip(&p.bundle.objects)
.map(|((o, prio, props, payload), r)| {
json!({
"object": o, "priority": prio, "immutable": h(&immutable_of(props)), "payload": h(payload),
"content_hash": h(&r.content_hash), "identity": h(r.identity.as_deref().unwrap_or_default()), "leaf": h(&r.leaf()),
})
})
.collect();
let mut v = json!({
"name": name, "defect": what, "publisher": h(&p.publisher), "receipt_alias": RECEIPT_ALIAS, "group": GROUP,
"objects": objects, "bundle": h(bundle_bytes),
});
let outcome = group_outcome(&v);
v["outcome"] = outcome;
v
}
/// What a verifier gives a group's receipts (confidentiality §8.4; jwp-receipts §2.7.4 and
/// §3): `accept`, `malformed` for bytes the binary form does not read, `identity_malformed`
/// for a receipt whose identity is not of its own Group ID and Object ID or a length no
/// identity has, `unverified` for a signature or a proof that does not verify.
fn group_outcome(v: &Value) -> Value {
let Ok(raw) = unhex(&v["bundle"]) else { return json!("malformed") };
let Ok((b, _)) = ReceiptBundle::read(&raw) else { return json!("malformed") };
let Ok(pk) = unhex(&v["publisher"]).and_then(|k| <[u8; 32]>::try_from(k).map_err(|_| "a key".to_string())) else { return json!("malformed") };
let Ok(pk) = ed25519_dalek::VerifyingKey::from_bytes(&pk) else { return json!("unverified") };
let verifier = ReceiptVerifier::with_anchors(vec![pk]);
let Ok(vg) = verifier.verify_group(&b.group) else { return json!("unverified") };
for r in &b.objects {
if r.identity.is_some() && r.check_label().is_err() {
return json!("identity_malformed");
}
if verifier.verify_object(r, &vg).is_err() {
return json!("unverified");
}
}
json!("accept")
}
fn groups(p: &Published) -> Vec<Value> {
let good = p.bundle.to_payload().expect("encodes");
let mut out = vec![group_json("sealed-video", "none", p, &good)];
// A receipt that names another object's identity: its label is refused.
let mut other = ReceiptBundle { group: p.bundle.group.clone(), objects: p.bundle.objects.clone() };
other.objects[1].identity = other.objects[2].identity.clone();
out.push(group_json("identity-of-another-object", "object 1's receipt carries object 2's identity", p, &other.to_payload().expect("encodes")));
// A receipt whose identity was dropped: its leaf is the labelled one, not the one signed.
let mut dropped = ReceiptBundle { group: p.bundle.group.clone(), objects: p.bundle.objects.clone() };
dropped.objects[0].identity = None;
out.push(group_json("identity-dropped", "object 0's receipt dropped its identity, its leaf_version 2", p, &dropped.to_payload().expect("encodes")));
// A priority changed inside the signed identity: the proof no longer reaches the root.
let mut changed = ReceiptBundle { group: p.bundle.group.clone(), objects: p.bundle.objects.clone() };
if let Some(id) = changed.objects[0].identity.as_mut() {
id[12] ^= 1;
}
out.push(group_json("identity-priority-restated", "object 0's identity states another Publisher Priority than the one signed", p, &changed.to_payload().expect("encodes")));
// An identity length below the fixed part: the bytes do not read.
let mut short = good.clone();
let r0 = &p.bundle.objects[0];
let body = r0.body_binary().expect("encodes");
let start = find(&short, &body).expect("the body is in the bundle");
let id_len = r0.identity.as_ref().map_or(0, Vec::len);
let len_at = start + body.len() - id_len - 4;
short[len_at..len_at + 4].copy_from_slice(&12u32.to_be_bytes());
out.push(group_json("identity-length-12", "object 0's identity length field states 12, below the fixed 13", p, &short));
out
}
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle)
}
/// How a session of `draft` carries `props`'s Immutable Properties: -22's and -18's bytes
/// as -22 encodes them, -16's with -16's integer.
fn on_the_wire(draft: u32, props: &Properties) -> Vec<u8> {
match draft {
16 => {
let p16 = bridge::properties_from22(props).expect("a -16 form");
p16.0.iter().find(|p| p.ty == kv16::ext::IMMUTABLE_EXTENSIONS).and_then(|p| p.as_bytes()).unwrap_or_default().to_vec()
}
_ => immutable_of(props),
}
}
/// The Immutable Properties as -22 encodes them, from a session of `draft` (§11.6): at
/// -16, the pairs re-encoded with -22's integer, as the bridge re-encodes them; otherwise
/// the bytes as carried.
fn as_22(draft: u32, wire: &[u8], converted: bool) -> Result<Vec<u8>, String> {
if draft != 16 || !converted {
return Ok(wire.to_vec());
}
let p16 = kv16::Properties(vec![KeyValuePair::bytes(kv16::ext::IMMUTABLE_EXTENSIONS, wire.to_vec())]);
Ok(immutable_of(&bridge::properties_to22(&p16).map_err(|e| e.to_string())?))
}
#[allow(clippy::too_many_arguments)]
fn arrival(bundle: &ReceiptBundle, name: &str, what: &str, receipt: usize, group: u64, object: u64, priority: u8, draft: u32, wire: Option<Vec<u8>>, converted: bool) -> Value {
let mut v = json!({
"name": name, "defect": what, "receipt": receipt, "group": group, "object": object, "priority": priority, "draft": draft,
"immutable_wire": wire.as_ref().map(|w| h(w)), "converted": converted,
});
let (outcome, immutable) = arrival_outcome(&v, bundle).expect("reads");
v["immutable"] = immutable.map_or(Value::Null, |i| json!(h(&i)));
for (k, x) in outcome.as_object().expect("an object") {
v[k] = x.clone();
}
v
}
/// The identity of an arrival as the hop holds it, and its check against its receipt.
fn arrival_outcome(v: &Value, bundle: &ReceiptBundle) -> Result<(Value, Option<Vec<u8>>), String> {
let r = bundle.objects.get(v["receipt"].as_u64().ok_or("receipt")? as usize).ok_or("no such receipt")?;
let draft = v["draft"].as_u64().ok_or("draft")? as u32;
let converted = v["converted"].as_bool().ok_or("converted")?;
let immutable = match &v["immutable_wire"] {
Value::Null => None,
w => Some(as_22(draft, &unhex(w)?, converted)?),
};
let mut props = Properties::new();
if let Some(i) = &immutable {
props.push(KeyValuePair::bytes(prop::IMMUTABLE_PROPERTIES, i.clone()));
}
let received = identity_as_received(v["group"].as_u64().ok_or("group")?, v["object"].as_u64().ok_or("object")?, v["priority"].as_u64().ok_or("priority")? as u8, &props);
let outcome = match (r.check_identity(received.as_deref()), received.as_deref().zip(r.identity.as_deref())) {
(Ok(()), _) => json!({"outcome": "pass"}),
(Err(_), Some((got, signed))) => json!({"outcome": "identity_mismatch", "part": identity_difference(signed, got)}),
(Err(_), None) => json!({"outcome": "identity_mismatch", "part": "length"}),
};
Ok((outcome, immutable))
}
fn arrivals(p: &Published) -> Vec<Value> {
let (o0, p0, props0, _) = &p.objects[0];
let (o1, p1, props1, _) = &p.objects[1];
let (o2, p2, props2, _) = &p.objects[2];
let kid = key_id_of(props0).expect("a Key ID").0;
let imm = |extra: &[KeyValuePair]| immutable_of(&object_properties(&immutable_properties(kid, extra), &[]));
vec![
arrival(&p.bundle, "as-sealed-0", "none", 0, GROUP, *o0, *p0, 22, Some(on_the_wire(22, props0)), false),
arrival(&p.bundle, "as-sealed-1", "none", 1, GROUP, *o1, *p1, 22, Some(on_the_wire(22, props1)), false),
arrival(&p.bundle, "as-sealed-2", "none", 2, GROUP, *o2, *p2, 22, Some(on_the_wire(22, props2)), false),
arrival(&p.bundle, "draft18-as-sealed", "none: -18 carries -22's integers and pairs", 1, GROUP, *o1, *p1, 18, Some(on_the_wire(18, props1)), false),
arrival(&p.bundle, "draft16-converted", "none: -16's pairs re-encoded as -22 encodes them (§11.6)", 1, GROUP, *o1, *p1, 16, Some(on_the_wire(16, props1)), true),
arrival(&p.bundle, "draft16-read-as-is", "-16's bytes taken for -22's, not re-encoded", 1, GROUP, *o1, *p1, 16, Some(on_the_wire(16, props1)), false),
arrival(&p.bundle, "priority-changed", "a hop changed the Publisher Priority", 0, GROUP, *o0, *p0 + 1, 22, Some(on_the_wire(22, props0)), false),
arrival(&p.bundle, "key-id-changed", "a hop changed the Key ID", 0, GROUP, *o0, *p0, 22, Some(immutable_of(&object_properties(&immutable_properties(kid + 1, &[]), &[]))), false),
arrival(&p.bundle, "pair-added", "a hop added a pair inside Immutable Properties", 0, GROUP, *o0, *p0, 22, Some(imm(&[KeyValuePair::varint(EXTRA + 2, 9)])), false),
arrival(&p.bundle, "pair-dropped", "a hop dropped a pair from Immutable Properties", 1, GROUP, *o1, *p1, 22, Some(imm(&[])), false),
arrival(&p.bundle, "immutable-stripped", "a hop stripped Immutable Properties", 0, GROUP, *o0, *p0, 22, None, false),
arrival(&p.bundle, "object-moved", "object 1 delivered at object 2's place", 1, GROUP, *o2, *p1, 22, Some(on_the_wire(22, props1)), false),
arrival(&p.bundle, "group-moved", "the object delivered in another group", 0, GROUP + 1, *o0, *p0, 22, Some(on_the_wire(22, props0)), false),
]
}
/// The identity vectors, from the corpus's seeds.
pub fn generate() -> Value {
let p = publish();
crate::corpus::canonical(json!({
"corpus": "WAI live-conformance/e2ee, identity-bound receipts",
"spec": "wai/extensions/confidentiality.md §8.4, §11.6",
"generator": "joule_moq_e2ee_vectors gen (joule-code/joule-code-rs/crates/joule-moq-mls)",
"seeds": "the corpus's: the publisher's key wai:e2ee/corpus/key identity-publisher, the epoch's secret wai:e2ee/corpus/secret identity, the room id and payloads wai:e2ee/corpus/bytes",
"groups": groups(&p),
"arrivals": arrivals(&p),
}))
}
/// Check every vector with the reference: each group's outcome, and each arrival's
/// Immutable Properties as -22 encodes them and its outcome; every outcome and part is
/// raised.
pub fn check(v: &Value) -> Result<(), String> {
let p = publish();
let mut seen = std::collections::BTreeSet::new();
for g in v["groups"].as_array().ok_or("groups")? {
let got = group_outcome(g);
if got != g["outcome"] {
return Err(format!("group {}: {got}", g["name"]));
}
seen.insert(got.as_str().unwrap_or_default().to_string());
}
for a in v["arrivals"].as_array().ok_or("arrivals")? {
let (got, immutable) = arrival_outcome(a, &p.bundle)?;
if got["outcome"] != a["outcome"] || got.get("part") != a.get("part") {
return Err(format!("arrival {}: {got}", a["name"]));
}
if immutable.map(|i| h(&i)) != a["immutable"].as_str().map(str::to_string) {
return Err(format!("arrival {}: the Immutable Properties as -22 encodes them", a["name"]));
}
seen.insert(format!("{}/{}", got["outcome"].as_str().unwrap_or_default(), got["part"].as_str().unwrap_or_default()));
}
for want in ["accept", "malformed", "identity_malformed", "unverified", "pass/", "identity_mismatch/group_object", "identity_mismatch/publisher_priority", "identity_mismatch/immutable_properties"] {
if !seen.contains(want) {
return Err(format!("no vector gives {want}"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// The file regenerates and every vector gives its stated outcome.
#[test]
fn the_identity_vectors_check() {
let v = generate();
check(&v).unwrap();
let bytes = crate::corpus::to_bytes(&v);
assert_eq!(crate::corpus::to_bytes(&serde_json::from_slice(&bytes).unwrap()), bytes);
}
/// The sealed objects open under the identities their receipts bind.
#[test]
fn each_identity_is_the_aad_the_object_opens_under() {
let p = publish();
for ((o, prio, props, payload), r) in p.objects.iter().zip(&p.bundle.objects) {
let (_, sip) = key_id_of(props).unwrap();
assert_eq!(r.identity.as_deref().unwrap(), joule_moq::secure_objects::aad(GROUP, *o, *prio, &sip).unwrap().as_slice());
assert_eq!(r.content_hash, ObjectReceipt::hash_payload(payload));
}
}
}

View file

@ -24,6 +24,7 @@
pub mod corpus;
pub mod group;
pub mod identity;
pub mod model;
pub mod record;
pub mod strict;

View file

@ -307,8 +307,12 @@ not verify.
of its group is fanned out, and a group that does not verify is dropped with a structured
error in the log; each media object must match the receipt of its position (its group,
its Object ID, its content hash, its Merkle proof to the signed root), and an object that
carries its receipt as a property must carry that same one. An object waits for its
group's receipts up to `hold_ms`. Receipt bytes are forwarded unaltered and nothing is
carries its receipt as a property must carry that same one. An object whose receipt binds
its identity (WAI `extensions/confidentiality.md` §8.4: its Group ID, Object ID,
Publisher Priority and Immutable Properties, the AAD a secure object is sealed under) must
have arrived with that identity, its priority the one its header states or the track's
default it inherits; otherwise it is refused, `identity_mismatch`, naming the part that
differs. An object waits for its group's receipts up to `hold_ms`. Receipt bytes are forwarded unaltered and nothing is
re-signed. A refused object ends its downstream streams rather than leaving a hole in
them.

View file

@ -14,7 +14,11 @@
//! - each media object is checked against the receipt of its own position in the bundle:
//! the receipt names this group and Object ID (`moqt_mismatch` otherwise), its content
//! hash is the payload's, and its Merkle proof reaches the signed root. An object that
//! carries its receipt as a property must carry that same receipt;
//! carries its receipt as a property must carry that same receipt. A receipt that binds
//! the object's identity (WAI `confidentiality.md` §8.4) must bind the identity the
//! object arrived with: its Group ID, Object ID, Publisher Priority and Immutable
//! Properties, the AAD secure objects seals under (`identity_mismatch` otherwise), so a
//! hop that changed what it forwards in the clear is found at the next door, with no key;
//! - an object whose bundle has not arrived waits for it, up to the rule's `hold_ms`
//! (the publisher publishes the bundle no later than the group's first object), and is
//! refused after that;
@ -220,8 +224,11 @@ impl Door {
}
/// Check one media object. `property` is the receipt the object carries as its
/// JWP_OBJECT_RECEIPT property, if any.
pub async fn object(&self, ns: &TrackNamespace, media: &TrackName, group: u64, object: u64, payload: &[u8], property: Option<&ObjectReceipt>) -> Verdict {
/// JWP_OBJECT_RECEIPT property, if any; `identity` is the object's identity as it
/// arrived ([`joule_moq::receipt::identity_as_received`]), which an identity-bound
/// receipt is held to.
#[allow(clippy::too_many_arguments)]
pub async fn object(&self, ns: &TrackNamespace, media: &TrackName, group: u64, object: u64, payload: &[u8], property: Option<&ObjectReceipt>, identity: Option<&[u8]>) -> Verdict {
let Some(rule) = self.rule(ns) else { return Verdict::Unchecked };
let key = ((ns.clone(), media.clone()), group);
let deadline = tokio::time::Instant::now() + rule.hold;
@ -249,9 +256,12 @@ impl Door {
Ok(v) => v,
Err(why) => return Verdict::Refuse(why),
};
return match verifier.verify_payload(payload, r, vg) {
if let Err(e) = verifier.verify_payload(payload, r, vg) {
return Verdict::Refuse(format!("its payload does not verify: {e}"));
}
return match r.check_identity(identity) {
Ok(()) => Verdict::Pass { joules_micro: r.joules_micro, class: r.energy_class.clone() },
Err(e) => Verdict::Refuse(format!("its payload does not verify: {e}")),
Err(e) => Verdict::Refuse(e.to_string()),
};
}
None => {}
@ -285,6 +295,7 @@ mod tests {
origin: "o".into(),
merkle_proof: b.proof_for(i).unwrap(),
energy_class: Some(vec![0]),
identity: None,
})
.collect();
let group = b.finalize_labelled(key, "p", "t", None).unwrap();
@ -304,20 +315,71 @@ mod tests {
let b = bundle(&key, 3, &[b"x", b"y"]);
door.bundle(&ns, &media, 3, &b.to_payload().unwrap()).unwrap();
assert!(matches!(door.object(&ns, &media, 3, 1, b"y", None).await, Verdict::Pass { .. }));
assert!(matches!(door.object(&ns, &media, 3, 1, b"tampered", None).await, Verdict::Refuse(_)));
assert!(matches!(door.object(&ns, &media, 3, 0, b"x", Some(&b.objects[1])).await, Verdict::Refuse(_)), "a property that is not its receipt");
assert!(matches!(door.object(&ns, &media, 4, 0, b"x", None).await, Verdict::Refuse(_)), "no bundle within the hold");
assert!(matches!(door.object(&ns, &media, 3, 1, b"y", None, None).await, Verdict::Pass { .. }));
assert!(matches!(door.object(&ns, &media, 3, 1, b"tampered", None, None).await, Verdict::Refuse(_)));
assert!(matches!(door.object(&ns, &media, 3, 0, b"x", Some(&b.objects[1]), None).await, Verdict::Refuse(_)), "a property that is not its receipt");
assert!(matches!(door.object(&ns, &media, 4, 0, b"x", None, None).await, Verdict::Refuse(_)), "no bundle within the hold");
// A bundle signed by a key the rule does not trust drops its group.
let stranger = ed25519_dalek::SigningKey::from_bytes(&[6; 32]);
let bad = bundle(&stranger, 5, &[b"z"]);
assert!(door.bundle(&ns, &media, 5, &bad.to_payload().unwrap()).is_err());
assert!(matches!(door.object(&ns, &media, 5, 0, b"z", None).await, Verdict::Refuse(_)));
assert!(matches!(door.object(&ns, &media, 5, 0, b"z", None, None).await, Verdict::Refuse(_)));
// A bundle under the wrong MOQT group is refused before its signature is read.
assert!(door.bundle(&ns, &media, 9, &bundle(&key, 8, &[b"q"]).to_payload().unwrap()).is_err());
}
/// A group whose receipts bind each object's identity (WAI confidentiality §8.4): the
/// object at priority 64 whose Immutable Properties are `imm`.
fn identity_bundle(key: &ed25519_dalek::SigningKey, group: u64, payloads: &[&[u8]], imm: &[u8]) -> ReceiptBundle {
let mut b = ReceiptBuilder::new(TrackAlias(8), GroupId(group));
let ids: Vec<Vec<u8>> = (0..payloads.len()).map(|i| joule_moq::receipt::object_identity(group, i as u64, 64, imm).unwrap()).collect();
for (p, id) in payloads.iter().zip(&ids) {
b.add_identity_object(ObjectReceipt::hash_payload(p), 0, "o", "t", vec![0], id.clone()).unwrap();
}
let objects = (0..payloads.len())
.map(|i| ObjectReceipt {
track_alias: TrackAlias(8),
group_id: GroupId(group),
object_id: ObjectId(i as u64),
content_hash: ObjectReceipt::hash_payload(payloads[i]),
joules_micro: 0,
origin: "o".into(),
merkle_proof: b.proof_for(i).unwrap(),
energy_class: Some(vec![0]),
identity: b.identity_for(i),
})
.collect();
let group = b.finalize_labelled(key, "p", "t", None).unwrap();
ReceiptBundle { group, objects }
}
#[tokio::test]
async fn an_identity_bound_receipt_holds_the_object_to_what_it_signed() {
let key = ed25519_dalek::SigningKey::from_bytes(&[5; 32]);
let rule = DoorRule { prefix: vec![], trust: vec![], trust_field: None, hold_ms: 100 };
let door = Door::new(&[rule], "_receipts").unwrap();
let ns = TrackNamespace::from_strs(["live"]);
let media = TrackName::from_str("cam");
// A Key ID property (0x3A4A, delta-coded as -22 writes it) inside Immutable Properties.
let imm = joule_moq::draft22::kvp::Properties(vec![joule_moq::draft22::kvp::KeyValuePair::varint(0x3a4a, 7)]).to_bytes().unwrap();
let b = identity_bundle(&key, 2, &[b"x", b"y"], &imm);
door.bundle(&ns, &media, 2, &b.to_payload().unwrap()).unwrap();
let id = |o: u64, prio: u8, imm: &[u8]| joule_moq::receipt::object_identity(2, o, prio, imm).unwrap();
assert!(matches!(door.object(&ns, &media, 2, 1, b"y", None, Some(&id(1, 64, &imm))).await, Verdict::Pass { .. }));
let refused = |v: Verdict| matches!(v, Verdict::Refuse(ref why) if why.contains("identity_mismatch"));
assert!(refused(door.object(&ns, &media, 2, 1, b"y", None, Some(&id(1, 65, &imm))).await), "a priority changed in flight");
let mut other = imm.clone();
*other.last_mut().unwrap() ^= 1;
assert!(refused(door.object(&ns, &media, 2, 1, b"y", None, Some(&id(1, 64, &other))).await), "a Key ID changed in flight");
assert!(refused(door.object(&ns, &media, 2, 0, b"x", None, Some(&id(0, 64, &[]))).await), "Immutable Properties stripped");
assert!(refused(door.object(&ns, &media, 2, 0, b"x", None, None).await), "no identity at all");
// A receipt that binds no identity holds the object to none.
door.bundle(&ns, &media, 3, &bundle(&key, 3, &[b"z"]).to_payload().unwrap()).unwrap();
let unbound = joule_moq::receipt::object_identity(3, 0, 1, &[]).unwrap();
assert!(matches!(door.object(&ns, &media, 3, 0, b"z", None, Some(&unbound)).await, Verdict::Pass { .. }));
}
#[tokio::test]
async fn an_object_waits_for_its_bundle() {
let key = ed25519_dalek::SigningKey::from_bytes(&[5; 32]);
@ -327,7 +389,7 @@ mod tests {
let media = TrackName::from_str("m");
let d2 = door.clone();
let (ns2, m2) = (ns.clone(), media.clone());
let waiter = tokio::spawn(async move { d2.object(&ns2, &m2, 0, 0, b"late", None).await });
let waiter = tokio::spawn(async move { d2.object(&ns2, &m2, 0, 0, b"late", None, None).await });
tokio::time::sleep(Duration::from_millis(50)).await;
door.bundle(&ns, &media, 0, &bundle(&key, 0, &[b"late"]).to_payload().unwrap()).unwrap();
let v = tokio::time::timeout(Duration::from_secs(5), waiter).await.unwrap().unwrap();
@ -345,7 +407,7 @@ mod tests {
assert!(door.checks(&ns_a) && !door.checks(&TrackNamespace::from_strs(["room", "x", "events"])));
// a's groups verify in a's namespace; b's signature there drops its group.
door.bundle(&ns_a, &media, 0, &bundle(&a, 0, &[b"x"]).to_payload().unwrap()).unwrap();
assert!(matches!(door.object(&ns_a, &media, 0, 0, b"x", None).await, Verdict::Pass { .. }));
assert!(matches!(door.object(&ns_a, &media, 0, 0, b"x", None, None).await, Verdict::Pass { .. }));
assert!(door.bundle(&ns_a, &media, 1, &bundle(&b, 1, &[b"y"]).to_payload().unwrap()).is_err());
// A namespace whose field is not a key: refused.
let odd = TrackNamespace::from_strs(["room", "not-a-key", "video"]);

View file

@ -576,6 +576,12 @@ impl Relay {
}
}
/// The Publisher Priority an object of `key` inherits when its header states none: the
/// track's DEFAULT_PUBLISHER_PRIORITY, 128 when it has none (MOQT -22 §10.4).
pub fn default_priority(&self, key: &TrackKey) -> u8 {
self.inner.state.lock().unwrap().tracks.get(key).map_or(DEFAULT_PRIORITY, |t| t.default_priority)
}
/// The receipts track of a media track, and the media track of a receipts track.
pub fn receipts_of(&self, key: &TrackKey) -> TrackKey {
(key.0.clone(), self.inner.door.receipts_of(&key.1))

View file

@ -444,7 +444,12 @@ async fn datagram_loop(relay: Relay, ctx: Arc<SessionCtx>) {
joule_moq::draft22::data::DatagramContent::Payload(p) => p.clone(),
_ => Vec::new(),
};
let v = relay.inner.door.object(&key.0, &key.1, d.group_id, d.object_id, &payload, None).await;
let priority = d.publisher_priority.unwrap_or_else(|| relay.default_priority(&key));
let identity = d.properties.as_ref().map_or_else(
|| joule_moq::receipt::object_identity(d.group_id, d.object_id, priority, &[]),
|p| joule_moq::receipt::identity_as_received(d.group_id, d.object_id, priority, p),
);
let v = relay.inner.door.object(&key.0, &key.1, d.group_id, d.object_id, &payload, None, identity.as_deref()).await;
if let Verdict::Refuse(why) = &v {
refused(&relay, &ctx, &key, d.group_id, d.object_id, why);
return;
@ -672,7 +677,11 @@ async fn upstream_subgroup(relay: Relay, ctx: Arc<SessionCtx>, h: joule_moq::dra
wai_envelope: false,
};
let property = read_object_properties(&o.properties, &mut hh).ok().and_then(|x| x.receipt);
let v = relay.inner.door.object(&key.0, &key.1, h.group_id, o.object_id, &o.payload, property.as_ref()).await;
// The identity as it arrived (WAI confidentiality §8.4): the header's
// priority, or the track's default it inherits, as fan-out uses.
let priority = h.publisher_priority.unwrap_or_else(|| relay.default_priority(&key));
let identity = joule_moq::receipt::identity_as_received(h.group_id, o.object_id, priority, &o.properties);
let v = relay.inner.door.object(&key.0, &key.1, h.group_id, o.object_id, &o.payload, property.as_ref(), identity.as_deref()).await;
if let Verdict::Refuse(why) = &v {
// The rest of a multi-object stream is not forwarded around a
// hole: its downstream streams are reset (§7.4).

View file

@ -127,6 +127,7 @@ pub fn signed_group(key: &SigningKey, receipt_alias: u64, g: u64, payload: &[u8]
origin: "test-publisher".into(),
merkle_proof: b.proof_for(0).unwrap(),
energy_class: Some(vec![0]),
identity: None,
};
let group = b.finalize_labelled(key, "test-publisher", "2026-10-17T00:00:01Z", None).unwrap();
(receipt.clone(), ReceiptBundle { group, objects: vec![receipt] })

View file

@ -102,6 +102,7 @@ fn bundle_of(key: &SigningKey, receipt_alias: u64, g: u64, payloads: &[Vec<u8>])
origin: "test-publisher".into(),
merkle_proof: b.proof_for(i).unwrap(),
energy_class: Some(vec![0]),
identity: None,
})
.collect();
let group = b.finalize_labelled(key, "test-publisher", "2026-10-19T00:00:01Z", None).unwrap();

View file

@ -3,7 +3,10 @@
//! the sealed bytes, the door holds each publisher's namespace to its own key and checks
//! every object against its receipt, and every member opens what the others sent while a
//! subscriber outside the group verifies every receipt and opens nothing. The relay
//! forwards each object's payload and properties byte for byte.
//! forwards each object's payload and properties byte for byte. Every receipt binds its
//! object's identity, the AAD the object is sealed under (confidentiality §8.4), so the
//! door also checks what the relay forwards in the clear, and a sealed object whose
//! Immutable Properties changed after its receipt was signed is refused there.
mod common;
@ -24,10 +27,13 @@ const ROOM: [u8; 32] = [0x5e; 32];
/// Publish group `g` of `t`: the receipts over the sealed payload first, then the sealed
/// object, whose Immutable Properties carry its Key ID and no receipt (confidentiality §4.3).
async fn publish_sealed(c: &Client, key: &SigningKey, t: &Tracks, g: u64, properties: joule_moq::draft22::kvp::Properties, payload: &[u8]) {
/// The receipt binds the object's identity as sealed (§8.4); `sent` is the property list
/// the object goes out with, which a test makes differ from the sealed one.
async fn publish_sealed(c: &Client, key: &SigningKey, t: &Tracks, g: u64, properties: joule_moq::draft22::kvp::Properties, sent: Option<joule_moq::draft22::kvp::Properties>, payload: &[u8]) {
let hash = ObjectReceipt::hash_payload(payload);
let mut b = ReceiptBuilder::new(TrackAlias(t.receipt_alias), GroupId(g));
b.add_labelled_object(hash, 0, "sealed-publisher", "2026-10-19T00:00:00Z", vec![0]).unwrap();
let identity = joule_moq::receipt::identity_as_received(g, 0, t.priority, &properties).expect("an identity");
b.add_identity_object(hash, 0, "sealed-publisher", "2026-10-19T00:00:00Z", vec![0], identity).unwrap();
let receipt = ObjectReceipt {
track_alias: TrackAlias(t.receipt_alias),
group_id: GroupId(g),
@ -37,13 +43,14 @@ async fn publish_sealed(c: &Client, key: &SigningKey, t: &Tracks, g: u64, proper
origin: "sealed-publisher".into(),
merkle_proof: b.proof_for(0).unwrap(),
energy_class: Some(vec![0]),
identity: b.identity_for(0),
};
let group = b.finalize_labelled(key, "sealed-publisher", "2026-10-19T00:00:01Z", None).unwrap();
let bundle = joule_moq::carriage::ReceiptBundle { group, objects: vec![receipt] };
let rh = SubgroupHeader { track_alias: t.receipts_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true };
c.send_subgroup(rh, &[SubgroupObject::normal(0, bundle.to_payload().unwrap())]).await.expect("the receipts");
let mh = SubgroupHeader { track_alias: t.media_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: true, end_of_group: true, first_object: true };
c.send_subgroup(mh, &[SubgroupObject { object_id: 0, properties, status: WireStatus::Normal, payload: payload.to_vec() }]).await.expect("the object");
c.send_subgroup(mh, &[SubgroupObject { object_id: 0, properties: sent.unwrap_or(properties), status: WireStatus::Normal, payload: payload.to_vec() }]).await.expect("the object");
}
async fn room_of_three(via: Via) {
@ -93,7 +100,7 @@ async fn room_of_three(via: Via) {
for g in 0..GROUPS {
let pt = Plaintext::with_digest(payload(&format!("member {i}"), g, 700));
let s = rings[i].seal(&Outgoing { ns: &t.ns, name: &name, group: g, object: 0, priority: t.priority, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap().unwrap();
publish_sealed(&pubs[i].0, &keys[i], t, g, s.properties.clone(), &s.payload).await;
publish_sealed(&pubs[i].0, &keys[i], t, g, s.properties.clone(), None, &s.payload).await;
sent.push((i, g, s));
}
}
@ -112,6 +119,11 @@ async fn room_of_three(via: Via) {
let (_, _, s) = sent.iter().find(|(k, gg, _)| *k == i && *gg == x.header.group_id).unwrap();
assert_eq!(o.payload, s.payload, "the sealed payload, byte for byte");
assert_eq!(o.properties, s.properties, "the properties, the Key ID among them, byte for byte");
// The identity as it arrived is the one the publisher's receipt signed.
let (_, _, _, receipts) = verify(g, &v[i], &keys[i]);
let arrived = joule_moq::receipt::identity_as_received(x.header.group_id, 0, x.header.publisher_priority.unwrap(), &o.properties);
let r = receipts.values().find(|r| r.group_id == GroupId(x.header.group_id)).expect("its receipt");
r.check_identity(arrived.as_deref()).expect("the identity it was sealed under");
let inc = Incoming { ns: &t.ns, name: &name, group: x.header.group_id, object: 0, priority: x.header.publisher_priority.unwrap(), properties: &o.properties, payload: &o.payload };
match ring.open(&inc, None) {
Ok(op) if j < 3 => assert_eq!(op.plaintext.payload, payload(&format!("member {i}"), x.header.group_id, 700)),
@ -132,6 +144,52 @@ async fn room_of_three(via: Via) {
server.shutdown().await;
}
/// A sealed object whose Immutable Properties a hop changed after its receipt was signed:
/// the door refuses it (`identity_mismatch`), with no key, before any member would find
/// that it does not open; the groups around it pass.
async fn a_changed_identity_is_refused_at_the_door(via: Via) {
let key = SigningKey::from_bytes(&[0xd4; 32]);
let principal = key.verifying_key().to_bytes();
let roster: Roster = [(0u32, principal)].into_iter().collect();
let mut ring = Keyring::new(Suite::AesGcm128Sha256, principal);
ring.enter(EPOCH, [0x78; 32], roster);
let room = hex::encode(ROOM);
let server = relay(|c| {
c.receipts.door = vec![DoorRule { prefix: vec![room.clone(), "*".into(), "*".into()], trust: vec![], trust_field: Some(1), hold_ms: 5_000 }];
})
.await;
let t = Tracks::new(ns(&[&room, &hex::encode(principal), "video"]), "0", 10, 7, 64);
let (c, _e) = connect(&server, via, ClientOptions::default()).await;
let held = publish_tracks(&c, &t, None).await;
let (s, _e2) = connect(&server, via, ClientOptions::default()).await;
let v = subscribe_both(&s, &t, Parameters::new()).await;
let name = TrackName::from_str("0");
for g in 0..3u64 {
let pt = Plaintext::with_digest(payload("solo", g, 300));
let sealed = ring.seal(&Outgoing { ns: &t.ns, name: &name, group: g, object: 0, priority: t.priority, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap().unwrap();
// Group 1 goes out with one more pair inside its Immutable Properties.
let changed = (g == 1).then(|| {
let (kid, _) = joule_moq::secure_objects::key_id_of(&sealed.properties).unwrap();
let sip = joule_moq::secure_objects::immutable_properties(kid, &[joule_moq::draft22::kvp::KeyValuePair::varint(0x3a4c, 1)]);
joule_moq::secure_objects::object_properties(&sip, &[])
});
publish_sealed(&c, &key, &t, g, sealed.properties.clone(), changed, &sealed.payload).await;
}
let got = collect(&s, STEP, |got| got.iter().filter(|x| x.header.track_alias == v.media_alias).count() >= 2 && got.iter().filter(|x| x.header.track_alias == v.receipts_alias).count() >= 3).await;
let media: Vec<u64> = got.iter().filter(|x| x.header.track_alias == v.media_alias && !x.objects.is_empty()).map(|x| x.header.group_id).collect();
assert_eq!(media, vec![0, 2], "group 1's object is refused at the door, the others pass");
assert_eq!(server.relay().stats().refused_at_door, 1, "one refusal, the changed identity");
drop(held);
s.close(0, "done");
c.close(0, "done");
server.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_changed_identity_is_refused_at_the_door_over_quic() {
tokio::time::timeout(std::time::Duration::from_secs(60), a_changed_identity_is_refused_at_the_door(Via::Quic)).await.expect("in time");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn sealed_objects_over_websocket() {
tokio::time::timeout(std::time::Duration::from_secs(120), room_of_three(Via::WebSocket)).await.expect("the room finishes in time");

View file

@ -633,6 +633,7 @@ impl Signer {
origin: self.origin.clone(),
merkle_proof: b.proof_for(0).map_err(|e| e.to_string())?,
energy_class: Some(UNMETERED.to_vec()),
identity: None,
};
let g = b.finalize_labelled(&self.key, self.origin.clone(), ended, self.parent).map_err(|e| e.to_string())?;
let link = g.link_hash();

View file

@ -70,6 +70,7 @@ and has another session shape, so `draft16` is its own throughout:
| a Track Namespace | 1 to 32 fields | 0 to 32 |
| PUBLISH_DONE codes | EXPIRED 0x5, TOO_FAR_BEHIND 0x6 | the reverse |
| stream reset UNKNOWN_OBJECT_STATUS | 0x4 | 0x6 (0x4 is GOING_AWAY) |
| the pairs inside Immutable Extensions / Properties | -16's integer | -22's | re-encoded both ways, every pair kept in order, each integer shortest, so a secure object's AAD and a receipt's identity are -22's on either side (WAI confidentiality §11.6) |
`joule-moq-relay`'s bridge renumbers this end's Request IDs into -16's sequence, grants
the peer requests ahead of it, routes each request and its responses to a stream of its
@ -161,6 +162,16 @@ legacy group its parent flag and parent), a u32 count, and each object receipt's
does not read with the §3 step jwp-receipts §2.7.4 names. `tests/wai_jwp_corpus.rs` reads
and writes every binary vector of WAI's corpus.
An object receipt may also be **identity-bound** (WAI `extensions/confidentiality.md`
§8.4, `leaf_version` 3): beside its class it carries the object's identity, the
`SECURE_OBJECT_AAD` of `draft-ietf-moq-secure-objects-01` §3.5 as -22 encodes it (Group
ID, 8 bytes; Object ID, 4; Publisher Priority, 1; the value of its Immutable Properties
pair), and its leaf binds it (`moq-jwp:merkle-leaf\x03`). `receipt::object_identity`
forms it, `identity_as_received` forms it for an object as a hop holds it,
`ReceiptBuilder::add_identity_object` signs it, and `ObjectReceipt::check_identity` holds
an object to it, naming the part that differs. Receipts of the two earlier forms keep their
bytes. WAI's `live-conformance/e2ee/identity.json` holds its vectors.
**Room grants** (WAI `extensions/live-rooms.md` §10.5, `grant_proof`): a grant is public,
so it is presented with a `WRP1` proof of its principal's key over a challenge the verifier
chose. Over MOQT the relay's SETUP carries the challenge (`GRANT_CHALLENGE`, `0x3A47`) and
@ -347,3 +358,27 @@ reading after a count of streams, not of objects. `scripts/local.sh` runs the cl
against the relay at each draft and across drafts, and checks that `--tamper` (a payload, a
receipt, or a byte of an object in flight) fails. None of these runs is in CI: they reach
the public internet or run other implementations' code.
### Sealed objects and identity-bound receipts (runs of 2026-10-22)
`joule-moq-interop`'s `wai-sealed` scenario: ten groups of three objects, each sealed as a
secure object (suite `0x0004`, the Key ID inside Immutable Properties) with an
identity-bound receipt (WAI `extensions/confidentiality.md` §8.4). Every relay ran on
loopback on the build host, the two of another implementation built from their own source
at the commit named; no deployed endpoint was reached. **Pass** is every object's receipt
verified, its identity as it arrived equal to its receipt's, and the object opened under
that AAD.
| Relay | Draft | QUIC | WebTransport | Result |
|---|---|---|---|---|
| `joule-moq-relay` at this revision | -22, -18, -16; and publisher to subscriber 22→16, 16→22, 18→16, 16→18, 22→18, 18→22 | yes | yes (-22) | pass; `--tamper identity` refused at 22→16 and 16→22, `--tamper in-flight` refused |
| A public MOQT relay network's open-source relay, its -16 line (`main` at `8bc03ed`, 2026-10-14) | -16 | yes | yes | pass; `wai-receipts` pass; `--tamper identity` and `--tamper payload` refused |
| The same relay's -18 line (`draft-18-dev` at `8779758`, 2026-10-15) | -18 | yes | yes | pass; `wai-receipts` pass; `--tamper identity` refused |
Both of the other relays forwarded every object's Group ID, Object ID, Publisher Priority
and Immutable Properties unchanged, the Key ID among them, so the AAD secure objects
authenticates crossed them; at -16 the pairs arrive in -16's integer and re-encode to the
bytes the publisher sealed (confidentiality §11.6). Each refusal named the part that
differed (`identity_mismatch: its Immutable Properties`) before the AEAD's own `bad_tag`.
The door's check of the identity is tested in `joule-moq-relay`'s `tests/sealed_objects.rs`.

View file

@ -123,6 +123,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
origin: origin.to_string(),
merkle_proof: proof,
energy_class: None,
identity: None,
};
sent.push((header, payload, receipt));
}

View file

@ -409,7 +409,7 @@ mod tests {
Some(_) => b.finalize_labelled(&key, "p", "t", None).unwrap(),
None => b.finalize(&key, "p", "t"),
};
let o = ObjectReceipt { track_alias: TrackAlias(3), group_id: GroupId(9), object_id: ObjectId(0), content_hash: hash, joules_micro: joules, origin: "o".into(), merkle_proof: proof, energy_class: class };
let o = ObjectReceipt { track_alias: TrackAlias(3), group_id: GroupId(9), object_id: ObjectId(0), content_hash: hash, joules_micro: joules, origin: "o".into(), merkle_proof: proof, energy_class: class, identity: None };
(o, group)
}

View file

@ -258,6 +258,7 @@ impl GroupHandle for MockGroupHandle {
origin: origin.clone(),
merkle_proof: proof,
energy_class: None,
identity: None,
};
let wai_receipt = WaiReceiptProperty::from_receipts(&receipt, &group_receipt);
let extras = ObjectExtras { receipt: Some(receipt), wai_receipt, delivery_timeout_ms: None };
@ -748,6 +749,7 @@ mod tests {
origin: "o".into(),
merkle_proof: Default::default(),
energy_class: None,
identity: None,
}),
}
}

View file

@ -37,6 +37,16 @@
//! receipt in a legacy form keeps its bytes. A verifier that predates them
//! refuses a labelled object and a labelled group.
//!
//! A third leaf form, the identity-bound one of WAI `extensions/confidentiality.md`
//! §8.4 (`leaf_version` 3), is the labelled leaf with the object's **identity**
//! appended: the `SECURE_OBJECT_AAD` that `draft-ietf-moq-secure-objects-01` §3.5
//! forms for the object, its Group ID (8 bytes), Object ID (4), Publisher Priority
//! (1) and Serialized Immutable Properties, in the encoding of
//! `draft-ietf-moq-transport-22` ([`object_identity`]). A relay that checks receipts
//! then checks the fields it forwards in the clear against what the publisher signed,
//! with no key ([`ObjectReceipt::check_identity`]). It is opt-in too, and a verifier
//! that predates it refuses it.
//!
//! The class bytes are WAI's `energy-measurement.md` §2.7 encoding: a tag (0
//! unmetered, 2 ModelBased, 3 Estimator, 4 OnChipCounter, 5 CalibratedInstrument;
//! 1, the legacy HwShunt, is refused here) and the class's evidence.
@ -53,6 +63,14 @@ const MERKLE_LEAF_DOMAIN: &[u8] = b"moq-jwp:merkle-leaf\x01";
/// Domain separator for labelled Merkle leaves (content hash, figure, class,
/// origin).
const MERKLE_LEAF_LABELLED_DOMAIN: &[u8] = b"moq-jwp:merkle-leaf\x02";
/// Domain separator for identity-bound Merkle leaves (the labelled leaf's fields,
/// then the object's identity; WAI confidentiality §8.4).
const MERKLE_LEAF_IDENTITY_DOMAIN: &[u8] = b"moq-jwp:merkle-leaf\x03";
/// An identity's fixed part: Group ID (8 bytes), Object ID (4), Publisher Priority (1).
pub const IDENTITY_FIXED_LEN: usize = 13;
/// The longest identity: the fixed part and the longest value a -22 Key-Value-Pair
/// holds (`draft-ietf-moq-transport-22` §8.3), the Immutable Properties'.
pub const IDENTITY_MAX_LEN: usize = IDENTITY_FIXED_LEN + crate::draft22::kvp::MAX_KVP_VALUE_LEN as usize;
/// Domain separator for object payload hashing.
pub const OBJECT_HASH_DOMAIN: &[u8] = b"moq-jwp:object\x01";
/// Domain separator for the group payload that gets signed.
@ -84,6 +102,120 @@ pub struct ObjectReceipt {
/// `joules_micro`, this class and `origin`; absent, the legacy one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub energy_class: Option<Vec<u8>>,
/// The object's identity ([`object_identity`]; WAI confidentiality §8.4). Present,
/// the leaf is the identity-bound one (`leaf_version` 3), which binds it beside
/// `joules_micro`, `energy_class` and `origin`; it requires `energy_class`. Its Group
/// ID and Object ID are this receipt's.
#[serde(default, skip_serializing_if = "Option::is_none", with = "hex_opt")]
pub identity: Option<Vec<u8>>,
}
/// An object's **identity**: the `SECURE_OBJECT_AAD` of `draft-ietf-moq-secure-objects-01`
/// §3.5, its Group ID as 8 bytes and its Object ID as 4, big-endian, its Publisher Priority,
/// then `immutable`, the value of its Immutable Properties property (MOQT -22 §10.7)
/// encoded as -22 encodes it (§8.1, §8.3), empty where it carries none. `None` for an
/// Object ID above 2^32 − 1, which no identity holds (secure objects §3.6), and for an
/// `immutable` longer than a Key-Value-Pair's value can be.
///
/// It is the AAD [`crate::secure_objects`] seals under (WAI confidentiality §4.7), so a
/// receipt that binds it binds what the AEAD authenticates, less the track, which the
/// receipts track and its alias already name (WAI moq-streaming-format §4.1).
pub fn object_identity(group: u64, object: u64, priority: u8, immutable: &[u8]) -> Option<Vec<u8>> {
let object = u32::try_from(object).ok()?;
if immutable.len() as u64 > crate::draft22::kvp::MAX_KVP_VALUE_LEN {
return None;
}
let mut a = Vec::with_capacity(IDENTITY_FIXED_LEN + immutable.len());
a.extend_from_slice(&group.to_be_bytes());
a.extend_from_slice(&object.to_be_bytes());
a.push(priority);
a.extend_from_slice(immutable);
Some(a)
}
/// The identity of an object as a hop holds it (WAI confidentiality §8.4):
/// [`object_identity`] of its Group ID, Object ID and Publisher Priority (the one its
/// header states, or the one it inherits: MOQT -22 §10.4, §11.2.1, §11.3.1) and the value
/// of its one Immutable Properties pair, empty where it carries none. `None` where it
/// carries two such pairs or one whose value is not bytes, which no publisher signs an
/// identity for, or where [`object_identity`] gives none.
///
/// An object read from a -18 session carries -22's integers and pairs, and one read
/// through [`crate::draft16::bridge`] from a -16 session has its pairs re-encoded in -22's
/// encoding: either way the bytes are the ones a -22 publisher signed when it wrote
/// each integer in its shortest form, as confidentiality §4.3 requires.
pub fn identity_as_received(group: u64, object: u64, priority: u8, props: &crate::draft22::kvp::Properties) -> Option<Vec<u8>> {
let imm: Vec<&crate::draft22::kvp::KeyValuePair> =
props.0.iter().filter(|p| p.ty == crate::draft22::kvp::prop::IMMUTABLE_PROPERTIES).collect();
let value: &[u8] = match imm.as_slice() {
[] => &[],
[p] => p.as_bytes()?,
_ => return None,
};
object_identity(group, object, priority, value)
}
/// What part of an identity differs from another's (WAI confidentiality §8.4):
/// `group_object`, `publisher_priority` or `immutable_properties`, the first that differs
/// in that order; `length` for an identity shorter than the fixed part. `None` when they
/// are the same.
pub fn identity_difference(signed: &[u8], received: &[u8]) -> Option<&'static str> {
if signed == received {
return None;
}
if signed.len() < IDENTITY_FIXED_LEN || received.len() < IDENTITY_FIXED_LEN {
return Some("length");
}
Some(if signed[..12] != received[..12] {
"group_object"
} else if signed[12] != received[12] {
"publisher_priority"
} else {
"immutable_properties"
})
}
/// The words a refusal's message gives a part of [`identity_difference`].
fn identity_part_words(part: &str) -> &'static str {
match part {
"group_object" => "its Group ID or Object ID",
"publisher_priority" => "its Publisher Priority",
"immutable_properties" => "its Immutable Properties",
_ => "its length",
}
}
/// An identity's structural checks against the receipt's coordinates.
fn check_identity_fields(id: &[u8], group: GroupId, object: ObjectId) -> Result<(), String> {
if id.len() < IDENTITY_FIXED_LEN || id.len() > IDENTITY_MAX_LEN {
return Err("an identity of a length no identity has".into());
}
let object = u32::try_from(object.0).map_err(|_| "an identity for an Object ID above 2^32 - 1".to_string())?;
if id[..8] != group.0.to_be_bytes() || id[8..12] != object.to_be_bytes() {
return Err("an identity of another Group ID or Object ID than the receipt's".into());
}
Ok(())
}
/// An identity in the JSON form: lowercase hexadecimal, or absent.
mod hex_opt {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
match v {
Some(b) => s.serialize_str(&b.iter().map(|x| format!("{x:02x}")).collect::<String>()),
None => s.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
let Some(t) = Option::<String>::deserialize(d)? else { return Ok(None) };
let bad = || serde::de::Error::custom("an identity that is not lowercase hexadecimal");
if t.len() % 2 != 0 || !t.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) {
return Err(bad());
}
(0..t.len()).step_by(2).map(|i| u8::from_str_radix(&t[i..i + 2], 16).map_err(|_| bad())).collect::<Result<Vec<u8>, _>>().map(Some)
}
}
impl ObjectReceipt {
@ -100,16 +232,28 @@ impl ObjectReceipt {
/// The object's Merkle leaf, in the form its label gives.
pub fn leaf(&self) -> [u8; 32] {
match &self.energy_class {
None => hash_leaf(&self.content_hash),
Some(c) => hash_labelled_leaf(&self.content_hash, self.joules_micro, c, &self.origin),
match (&self.energy_class, &self.identity) {
// An identity beside no class is refused by `check_label` before any leaf
// is compared; its leaf is the legacy one, which never matches its root.
(None, _) => hash_leaf(&self.content_hash),
(Some(c), None) => hash_labelled_leaf(&self.content_hash, self.joules_micro, c, &self.origin),
(Some(c), Some(id)) => hash_identity_leaf(&self.content_hash, self.joules_micro, c, &self.origin, id),
}
}
/// The label's structural checks: a class this crate can read, tag 0 (the
/// unmetered marker) exactly beside a zero figure, never the legacy
/// HwShunt, and a declared uncertainty at the resolution floor.
/// HwShunt, and a declared uncertainty at the resolution floor; and, for an
/// identity-bound leaf, a class beside it and an identity of this receipt's Group ID
/// and Object ID (WAI confidentiality §8.4).
pub fn check_label(&self) -> MoqResult<()> {
if let Some(id) = &self.identity {
if self.energy_class.is_none() {
return Err(MoqError::ReceiptVerification("object label: an identity beside no class".into()));
}
check_identity_fields(id, self.group_id, self.object_id)
.map_err(|e| MoqError::ReceiptVerification(format!("object label: {e}")))?;
}
match &self.energy_class {
None => Ok(()),
Some(c) => check_class(c, self.joules_micro)
@ -117,6 +261,20 @@ impl ObjectReceipt {
}
}
/// The object as it arrived against the identity its receipt binds (WAI
/// confidentiality §8.4): `received` is [`object_identity`] of the object's Group ID,
/// Object ID, Publisher Priority and Immutable Properties as this hop holds them. A
/// receipt with no identity binds none, and passes. Refused, `identity_mismatch`,
/// naming the part that differs.
pub fn check_identity(&self, received: Option<&[u8]>) -> MoqResult<()> {
let Some(signed) = &self.identity else { return Ok(()) };
match received {
Some(r) if r == signed.as_slice() => Ok(()),
Some(r) => Err(MoqError::ReceiptVerification(format!("identity_mismatch: {}", identity_part_words(identity_difference(signed, r).unwrap_or("length"))))),
None => Err(MoqError::ReceiptVerification("identity_mismatch: the object has no identity (an Object ID above 2^32 - 1)".into())),
}
}
/// Compute a properly-domained content hash for an object payload.
pub fn hash_payload(payload: &[u8]) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
@ -468,8 +626,10 @@ pub fn read_receipts(b: &[u8]) -> Result<(GroupReceipt, Vec<ObjectReceipt>, [u8;
impl ObjectReceipt {
/// The body of the binary form (WAI jwp-receipts §2.7.2): `object_id`, the content
/// hash, the figure, the origin, the proof, then `leaf_version` (1 the legacy leaf, 2
/// the labelled one) and, for 2, the class. Refused for a proof of more than 255
/// siblings, which has none.
/// the labelled one, 3 the identity-bound one of WAI confidentiality §8.4) and, for 2
/// and 3, the class, and for 3 the identity's length (4 bytes) and the identity.
/// Refused for a proof of more than 255 siblings, and for an identity beside no class,
/// which have none.
pub fn body_binary(&self) -> MoqResult<Vec<u8>> {
let p = &self.merkle_proof;
let n = u8::try_from(p.siblings.len()).map_err(|_| MoqError::WireEncode("a proof of more than 255 siblings has no binary form".into()))?;
@ -485,12 +645,22 @@ impl ObjectReceipt {
}
b.extend_from_slice(&p.direction_bits.to_be_bytes());
b.extend_from_slice(&p.leaf_index.to_be_bytes());
match &self.energy_class {
None => b.push(1),
Some(c) => {
match (&self.energy_class, &self.identity) {
(None, None) => b.push(1),
(Some(c), None) => {
b.push(2);
b.extend_from_slice(c);
}
(Some(c), Some(id)) => {
if id.len() > IDENTITY_MAX_LEN {
return Err(MoqError::WireEncode("an identity longer than any identity is".into()));
}
b.push(3);
b.extend_from_slice(c);
b.extend_from_slice(&(id.len() as u32).to_be_bytes());
b.extend_from_slice(id);
}
(None, Some(_)) => return Err(MoqError::WireEncode("an identity beside no class has no binary form".into())),
}
Ok(b)
}
@ -515,10 +685,18 @@ impl ObjectReceipt {
let siblings = (0..n).map(|_| c.hash("a sibling")).collect::<Result<Vec<_>, _>>()?;
let direction_bits = c.u64("direction_bits")?;
let leaf_index = c.u64("leaf_index")?;
let energy_class = match c.u8("leaf_version")? {
1 => None,
2 => Some(c.class()?),
_ => return Err(c.bad("a leaf_version other than 1 or 2")),
let (energy_class, identity) = match c.u8("leaf_version")? {
1 => (None, None),
2 => (Some(c.class()?), None),
3 => {
let class = c.class()?;
let n = c.u32("an identity's length")? as usize;
if !(IDENTITY_FIXED_LEN..=IDENTITY_MAX_LEN).contains(&n) {
return Err(c.bad("an identity's length outside 13 to 13 + 2^16 - 1"));
}
(Some(class), Some(c.take(n, "an identity")?.to_vec()))
}
_ => return Err(c.bad("a leaf_version other than 1, 2 or 3")),
};
Ok(ObjectReceipt {
track_alias,
@ -529,6 +707,7 @@ impl ObjectReceipt {
origin,
merkle_proof: MerkleProof { siblings, direction_bits, leaf_index },
energy_class,
identity,
})
}
@ -768,6 +947,21 @@ fn hash_labelled_leaf(content_hash: &[u8; 32], joules_micro: u64, class: &[u8],
hasher.finalize().into()
}
/// Hash an object's identity-bound leaf (WAI confidentiality §8.4): the labelled leaf's
/// fields under its own domain, then the identity's length (4 bytes) and the identity.
fn hash_identity_leaf(content_hash: &[u8; 32], joules_micro: u64, class: &[u8], origin: &str, identity: &[u8]) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
hasher.update(MERKLE_LEAF_IDENTITY_DOMAIN);
hasher.update(content_hash);
hasher.update(&joules_micro.to_be_bytes());
hasher.update(class);
hasher.update(&(origin.len() as u32).to_be_bytes());
hasher.update(origin.as_bytes());
hasher.update(&(identity.len() as u32).to_be_bytes());
hasher.update(identity);
hasher.finalize().into()
}
/// Hash an internal Merkle node (left, right children).
fn hash_node(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
let mut hasher = blake3::Hasher::new();
@ -787,6 +981,7 @@ pub struct ReceiptBuilder {
joules_per_object: Vec<u64>,
origins: Vec<String>,
classes: Vec<Option<Vec<u8>>>,
identities: Vec<Option<Vec<u8>>>,
started_at: Option<String>,
}
@ -799,6 +994,7 @@ impl ReceiptBuilder {
joules_per_object: Vec::new(),
origins: Vec::new(),
classes: Vec::new(),
identities: Vec::new(),
started_at: None,
}
}
@ -820,6 +1016,7 @@ impl ReceiptBuilder {
self.joules_per_object.push(joules_micro);
self.origins.push(origin.into());
self.classes.push(None);
self.identities.push(None);
id
}
@ -844,6 +1041,36 @@ impl ReceiptBuilder {
self.joules_per_object.push(joules_micro);
self.origins.push(origin);
self.classes.push(Some(class));
self.identities.push(None);
Ok(id)
}
/// Record an object with its figure's class and its identity ([`object_identity`]):
/// the object's leaf is the identity-bound one (WAI confidentiality §8.4), binding the
/// labelled leaf's fields and the identity. The identity's Group ID is the group's and
/// its Object ID the one assigned here, the object's position. Refuses a class the
/// verifier would refuse, and an identity that does not hold those.
pub fn add_identity_object(
&mut self,
content_hash: [u8; 32],
joules_micro: u64,
origin: impl Into<String>,
timestamp: impl Into<String>,
class: Vec<u8>,
identity: Vec<u8>,
) -> MoqResult<ObjectId> {
check_class(&class, joules_micro).map_err(MoqError::ReceiptVerification)?;
let id = ObjectId(self.leaves.len() as u64);
check_identity_fields(&identity, self.group_id, id).map_err(MoqError::ReceiptVerification)?;
let origin = origin.into();
if self.started_at.is_none() {
self.started_at = Some(timestamp.into());
}
self.leaves.push(hash_identity_leaf(&content_hash, joules_micro, &class, &origin, &identity));
self.joules_per_object.push(joules_micro);
self.origins.push(origin);
self.classes.push(Some(class));
self.identities.push(Some(identity));
Ok(id)
}
@ -852,6 +1079,11 @@ impl ReceiptBuilder {
self.classes.get(index).cloned().flatten()
}
/// The identity object `index` was recorded with, for its receipt.
pub fn identity_for(&self, index: usize) -> Option<Vec<u8>> {
self.identities.get(index).cloned().flatten()
}
/// Number of objects accumulated so far.
pub fn len(&self) -> usize {
self.leaves.len()
@ -1782,6 +2014,7 @@ mod tests {
origin: "joule-cloud-us-west-2".into(),
merkle_proof: proof_for_1,
energy_class: None,
identity: None,
};
verifier.verify_payload(b"frame-2", &object_receipt, &root).unwrap();
}
@ -1834,6 +2067,7 @@ mod tests {
origin: "dc1".into(),
merkle_proof: proof,
energy_class: None,
identity: None,
};
// Verify against a *different* payload — must fail.
assert!(matches!(
@ -1916,6 +2150,7 @@ mod tests {
origin: format!("dc-{n}.example"),
merkle_proof: b.proof_for(i).unwrap(),
energy_class: c.clone(),
identity: None,
}
})
.collect();
@ -2221,6 +2456,7 @@ mod tests {
origin: "o".into(),
merkle_proof: proof,
energy_class: Some(vec![0]),
identity: None,
};
assert_eq!(o.check_moqt(TrackAlias(8), GroupId(5), ObjectId(0)), Ok(()));
assert_eq!(o.check_moqt(TrackAlias(8), GroupId(5), ObjectId(1)), Err(ChainRefusal::MoqtMismatch));
@ -2423,6 +2659,7 @@ mod tests {
origin: "dc1".into(),
merkle_proof: proof,
energy_class: None,
identity: None,
};
verifier.verify_object(&obj(2, proofs[2].clone(), h(2)), &g).unwrap();
// The duplicated last leaf of the odd level, presented past the end.
@ -2496,4 +2733,114 @@ mod tests {
assert!(jt.contains("\"energy_coverage\":1"), "{jt}");
assert!(verifier.verify_group(&serde_json::from_str(&jt).unwrap()).is_err());
}
/// The identity-bound leaf (WAI confidentiality §8.4): its binary form round-trips,
/// its proof reaches the signed root, it differs from the labelled leaf of the same
/// fields, and the checks of its label and of the object as received hold.
#[test]
fn an_identity_bound_receipt_binds_the_objects_aad() {
let key = test_signing_key(0x31);
// A Key ID property (0x3A4A = 7) as -22 encodes it, delta-coded from 0.
let imm = crate::draft22::kvp::Properties(vec![crate::draft22::kvp::KeyValuePair::varint(0x3a4a, 7)]).to_bytes().unwrap();
let mut b = ReceiptBuilder::new(TrackAlias(7), GroupId(9));
let payloads: [&[u8]; 3] = [b"frame-0", b"frame-1", b"frame-2"];
for (i, p) in payloads.iter().enumerate() {
let id = object_identity(9, i as u64, 64, &imm).unwrap();
b.add_identity_object(ObjectReceipt::hash_payload(p), 100 + i as u64, "o", T0, vec![2], id).unwrap();
}
// The builder holds an identity to the group and to the position it assigns.
assert!(b.add_identity_object([0; 32], 0, "o", T0, vec![0], object_identity(8, 3, 64, &imm).unwrap()).is_err());
assert!(b.add_identity_object([0; 32], 0, "o", T0, vec![0], object_identity(9, 4, 64, &imm).unwrap()).is_err());
let receipts: Vec<ObjectReceipt> = (0..3)
.map(|i| ObjectReceipt {
track_alias: TrackAlias(7),
group_id: GroupId(9),
object_id: ObjectId(i as u64),
content_hash: ObjectReceipt::hash_payload(payloads[i]),
joules_micro: 100 + i as u64,
origin: "o".into(),
merkle_proof: b.proof_for(i).unwrap(),
energy_class: Some(vec![2]),
identity: b.identity_for(i),
})
.collect();
let group = b.finalize_labelled(&key, "p", T0, None).unwrap();
let verifier = ReceiptVerifier::with_anchors(vec![key.verifying_key()]);
let vg = verifier.verify_group(&group).unwrap();
for (r, p) in receipts.iter().zip(payloads) {
verifier.verify_payload(p, r, &vg).unwrap();
// The binary form: leaf_version 3, the class, the identity's length and bytes.
let bin = r.to_binary().unwrap();
assert_eq!(ObjectReceipt::from_binary(&bin).unwrap(), *r);
let id = r.identity.as_ref().unwrap();
let tail = [&[3u8, 2][..], &(id.len() as u32).to_be_bytes(), id].concat();
assert!(bin.ends_with(&tail), "the body ends in leaf_version 3, its class and its identity");
// The JSON form carries the identity in hexadecimal.
let j = serde_json::to_string(r).unwrap();
assert_eq!(serde_json::from_str::<ObjectReceipt>(&j).unwrap(), *r);
r.check_identity(Some(id)).unwrap();
}
let r = &receipts[1];
let as_received = |prio: u8, imm: &[u8]| object_identity(9, 1, prio, imm).unwrap();
// The labelled leaf of the same fields is another leaf, and does not verify.
let mut no_id = r.clone();
no_id.identity = None;
assert_ne!(no_id.leaf(), r.leaf());
assert!(verifier.verify_object(&no_id, &vg).is_err(), "a receipt that dropped its identity");
// Each part the identity binds, changed, is refused and named.
let why = |e: MoqError| e.to_string();
assert!(why(r.check_identity(Some(&as_received(65, &imm))).unwrap_err()).contains("identity_mismatch: its Publisher Priority"));
let mut kid = imm.clone();
*kid.last_mut().unwrap() = 8;
assert!(why(r.check_identity(Some(&as_received(64, &kid))).unwrap_err()).contains("identity_mismatch: its Immutable Properties"));
assert!(why(r.check_identity(Some(&object_identity(9, 2, 64, &imm).unwrap())).unwrap_err()).contains("its Group ID or Object ID"));
assert!(r.check_identity(None).is_err(), "an object with no identity");
assert!(no_id.check_identity(Some(&as_received(1, &[]))).is_ok(), "a receipt that binds no identity holds none");
// A signed identity that is not the receipt's own position is refused by its label,
// and a changed identity no longer reaches the root.
let mut moved = r.clone();
moved.identity = Some(object_identity(9, 2, 64, &imm).unwrap());
assert!(moved.check_label().is_err());
let mut other = r.clone();
other.identity = Some(as_received(65, &imm));
assert!(verifier.verify_object(&other, &vg).is_err());
// An identity beside no class has no binary form and its label is refused.
let mut bare = r.clone();
bare.energy_class = None;
assert!(bare.check_label().is_err() && bare.to_binary().is_err());
// An identity shorter than its fixed part does not read.
let mut short = r.to_binary().unwrap();
let cut = short.len() - id_len(r) - 4;
short.truncate(cut);
short.extend_from_slice(&12u32.to_be_bytes());
short.extend_from_slice(&[0; 12]);
assert!(ObjectReceipt::from_binary(&short).is_err());
// No identity exists for an Object ID above 2^32 - 1.
assert_eq!(object_identity(0, u64::from(u32::MAX) + 1, 0, &[]), None);
assert_eq!(object_identity(0, u64::from(u32::MAX), 0, &[]).unwrap().len(), IDENTITY_FIXED_LEN);
}
fn id_len(r: &ObjectReceipt) -> usize {
r.identity.as_ref().map_or(0, Vec::len)
}
/// The identity of an object as a hop holds it: its one Immutable Properties value,
/// none when it carries none, and no identity when it carries two.
#[test]
fn an_objects_identity_as_received() {
use crate::draft22::kvp::{prop, KeyValuePair, Properties};
let imm = vec![0x80, 0x3a, 0x4a, 0x07];
let one = Properties(vec![KeyValuePair::varint(0x02, 500), KeyValuePair::bytes(prop::IMMUTABLE_PROPERTIES, imm.clone())]);
assert_eq!(identity_as_received(5, 6, 7, &one), object_identity(5, 6, 7, &imm));
assert_eq!(identity_as_received(5, 6, 7, &Properties::new()), object_identity(5, 6, 7, &[]));
let two = Properties(vec![KeyValuePair::bytes(prop::IMMUTABLE_PROPERTIES, imm.clone()), KeyValuePair::bytes(prop::IMMUTABLE_PROPERTIES, imm)]);
assert_eq!(identity_as_received(5, 6, 7, &two), None);
}
/// The identity is the AAD secure objects seals under (confidentiality §4.7).
#[cfg(feature = "secure-objects")]
#[test]
fn the_identity_is_the_secure_objects_aad() {
let sip = crate::secure_objects::immutable_properties(7, &[]);
assert_eq!(object_identity(42, 1, 128, &sip).unwrap(), crate::secure_objects::aad(42, 1, 128, &sip).unwrap());
}
}

View file

@ -741,6 +741,7 @@ mod tests {
origin: origin.to_string(),
merkle_proof: proof,
energy_class: None,
identity: None,
};
receipts.push(receipt.clone());
objects.push((header, payload, receipt));
@ -849,6 +850,7 @@ mod tests {
origin: "o".into(),
merkle_proof: b.proof_for(0).unwrap(),
energy_class: Some(vec![0]),
identity: None,
};
let group = b.finalize_labelled(&signing_key, "p", "t", None).unwrap();
let property = WaiReceiptProperty::from_receipts(&receipt, &group).expect("an unmetered property");

View file

@ -169,6 +169,7 @@ fn staged_tracks_drop_on_timeout_keep_receipts() {
origin: "origin.example".into(),
merkle_proof: proof,
energy_class: Some(vec![0]),
identity: None,
};
let header = ObjectHeader {
track_alias: TrackAlias(o.alias),

View file

@ -223,6 +223,7 @@ fn object_of(v: &Value) -> Option<ObjectReceipt> {
leaf_index: p.get("leaf_index")?.as_u64()?,
},
energy_class,
identity: None,
})
}

View file

@ -6,6 +6,40 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### Delivery bindings: secure-objects AAD in receipts, CMSD read, steering named to CDNs, live C2PA signing
- **Receipts bound to each object's identity** ([confidentiality](extensions/confidentiality.md)
§8.4): an object's identity is the `SECURE_OBJECT_AAD` of `draft-ietf-moq-secure-objects-01`
§3.5 as `draft-ietf-moq-transport-22` encodes it (Group ID, Object ID, Publisher Priority,
Immutable Properties). An identity-bound object receipt (`leaf_version` 3) binds it beside the
labelled leaf's fields, and whoever holds the object and its receipt refuses it
`identity_mismatch` when it arrived with another, naming the part. `joule-moq`'s receipts and
`joule-moq-relay`'s door implement it; `live-conformance/e2ee/identity.json` holds the
vectors and `e2ee_verify.py` checks them. Receipts of the earlier forms keep their bytes.
- **Earlier transport revisions and the publication request** (confidentiality §11.6, §11.7):
-18 carries -22's bytes; -16 writes QUIC's integer, so a -16 endpoint or bridge re-encodes
the pairs of Immutable Extensions as -22 writes them before forming the AAD. The MoQ working
group's milestone to request publication of the transport, due 31 December 2026, and what
the next revision re-reads then. Sealed objects with identity-bound receipts crossed the
reference relay at every draft pair and another implementation's open-source relay at -16
and -18, built from source on loopback, unchanged.
- **Corrected**: `WAI_INNER_DIGEST` is BLAKE3 over the payload alone and a receipt's content
hash is jwp-receipts §2.1's; confidentiality §4.4, §8.1 and §8.2 said they were the same hash.
- **CMSD read** ([delivery-telemetry](extensions/delivery-telemetry.md) §6, paragraphs 6.6 to
6.10): CMSD-Static and CMSD-Dynamic from any server read as CTA-5006 §8.1 has a client read
them, with what was ignored stated; `st` written as `v` or `l` only; a read corpus checked
against the SVTA common-media-library. `science.transaction.wai-op` is now written and read
as a String (CTA-5006 §4 item 1: a custom key carries the String type alone); a Token for it
is ignored (`type`). The one string of `expected/cmsd.json` that held it changes.
- **Steering for CDNs** ([energy-steering](extensions/energy-steering.md) §8.5): the decision's
digest in CMSD-Static on the steering response and as a CMCD key on media requests, each hop's
signing key in its own CMSD-Dynamic entry, and a check of which policy hops a response names;
nothing CMSD or CMCD carries is evidence, and `WSP1` and `WSD1` are unchanged.
- **Live C2PA signing** ([live-provenance](extensions/live-provenance.md) §10.4, §11.7): a live
signer that writes each segment as its last chunk closes and refuses what a verifier refuses,
and a verifier of a stream as it arrives; interop with the C2PA command-line tool 0.28.2 and
the C2PA Rust library 0.91.4 recorded in the corpus README.
### `wai.media.upscale`: super-resolution as an exact derivation, residuals against it, and an account of sink-made pictures
- **The extension** ([exact-upscale](extensions/exact-upscale.md)) registers the record

View file

@ -1032,7 +1032,11 @@ classes, stale records) after every pathway whose energy is, holds a client agai
within a margin, and signs a steering decision, `WSD1`, naming the records, the client's
report, its own inputs and the output, which a verifier recomputes. The output is the steering
manifest HLS and DASH players already read, written byte for byte, with the decision's URI in
one member players ignore: [`extensions/energy-steering.md`](extensions/energy-steering.md).
one member players ignore. The decision's digest also rides CMSD-Static on the steering
response and a CMCD key on media requests, and each hop names the key that signs its records
in its own CMSD-Dynamic entry, so CDNs that log those fields tie a response to the signed
decision and hops; no CMSD or CMCD value is evidence:
[`extensions/energy-steering.md`](extensions/energy-steering.md).
And for the **moderation** of a room — `wai.live.moderation`: the room authority signs a review
policy, `WMP1`, that keeps the room descriptor's review field and refines it: which event kinds
@ -1069,7 +1073,11 @@ base key and KID (the epoch and its leaf index) follow, so a receiver selects th
that opens an object; a receiver refuses, each with a named reason, an object of an epoch it
never joined, one after its own removal, a Key ID naming no member, a sender that is not the
track's owner or was removed since, a replay, a bad tag and a plaintext that does not read
or does not match its sealed inner digest. Receipts bind the sealed bytes. A browser page is
or does not match its sealed inner digest. Receipts bind the sealed bytes, and an
identity-bound receipt binds the object's identity too, the AAD it is sealed under (its Group
ID, Object ID, Publisher Priority and Immutable Properties as MOQT -22 encodes them, a -16 hop's
pairs re-encoded so), so a relay with a receipt door finds a change to what it forwards in the
clear, with no key. A browser page is
a member through the same code built for WebAssembly, sealing each encoded frame before it
publishes it and opening each object before it decodes it; a room service may carry the
group's messages in one order, and a member reached so applies its own create or commit only
@ -1158,7 +1166,9 @@ segment and every signature is deterministic, so packagers sharing a key write t
segment and packagers that do not chain across a failover, as C2PA 2.4 has dynamically
packaged content; and a registered initialization manifest carries C2PA's repository receipt,
whose proof is an RFC 9942 receipt of inclusion in the repository's RFC 9162 log. The C2PA
boxes of each segment ride a provenance track beside the media track:
boxes of each segment ride a provenance track beside the media track. A packager signs each
segment as its last chunk closes, refusing what a verifier would refuse, and a verifier checks
a stream as it arrives, segment by segment:
[`extensions/live-provenance.md`](extensions/live-provenance.md).
And for the **origin** of any media — `wai.media.synthesis`: an origin claim says of one

View file

@ -541,7 +541,8 @@ there). A take's CMAF master is a stream of kind `take` of [live-provenance](liv
(§3.1 there), which writes and verifies the C2PA structures; this section states what a take
puts in them. The reference implementation is `live-provenance`'s: the `live_provenance`
module of `wai-rs` writes a take's initialization segment and segments under both of §19's
methods and verifies them, and its corpus holds a take (`live-conformance/c2pa-live/take-both`).
methods and verifies them, its live signer writing each segment as the device closes its chunk
(live-provenance §10.4), and its corpus holds a take (`live-conformance/c2pa-live/take-both`).
The take's commitments are the WAI side's witness, which §9 verifies; the C2PA output is what
a C2PA validator reads, and each is checked on its own.

View file

@ -24,9 +24,12 @@
> `draft-ietf-moq-secure-objects-01` (6 July 2026, "secure objects"),
> `draft-jennings-moq-mocha-mls-keying-00` (6 July 2026, "the keying draft") and
> `draft-ietf-moq-transport-22` (1 October 2026, "MOQT"), all Internet-Drafts and none past
> Working Group Last Call; RFC 9605 (SFrame), RFC 9420 (MLS) and RFC 9750 (the MLS
> architecture); with RFC 5869 (HKDF), RFC 5116, NIST SP 800-38D (GCM) and FIPS 197 (AES)
> for the primitives. A section number of one of those documents in this text is that
> Working Group Last Call; for the paths of §11.6, `draft-ietf-moq-transport-18` (12 May
> 2026, the revision secure objects cites) §1.4.1, §1.4.3 and §12.7, and
> `draft-ietf-moq-transport-16` (13 January 2026) §1.4, §1.4.2 and §11.2, with RFC 9000 §16
> for -16's integer; the MoQ working group's milestones as its page lists them on 22 October
> 2026 (§11.7); RFC 9605 (SFrame), RFC 9420 (MLS) and RFC 9750 (the MLS architecture); with
> RFC 5869 (HKDF), RFC 5116, NIST SP 800-38D (GCM) and FIPS 197 (AES) for the primitives. A section number of one of those documents in this text is that
> revision's. Where the documents disagree with one another, §11 says which reading this
> extension takes and why.
@ -47,7 +50,8 @@ In scope:
epoch's per-sender keys (§5);
- what a receiver checks, in order, and the reason for each refusal (§6);
- rooms where several principals publish at once (§7);
- receipts and metering over ciphertext, and what a relay can and cannot prove (§8);
- receipts and metering over ciphertext, what a relay can and cannot prove, and receipts
that bind each object's identity, the AAD it is sealed under (§8);
- recording and clips (§9);
- decrypt-then-dispatch and relay transparency (§10);
- a member in a browser, and a room service as the carrier of a room's MLS messages (§15).
@ -215,10 +219,10 @@ room without this extension: one WAI envelope ([moq-streaming-format](moq-stream
This extension defines one entry:
- **`WAI_INNER_DIGEST`** (type `0x3A4B`): the 32-byte BLAKE3 hash of the original payload,
the content hash [jwp-receipts](jwp-receipts.md) §2.1 computes over an object's payload.
It is the hash a receipt of the object would carry in the clear, sealed with the object, so
a member checks the plaintext against a digest the publisher committed to (§8.2) and
plaintext-level records attach to it. A publisher **SHOULD** carry it on every sealed
over the payload's bytes alone. It is not a receipt's content hash, which
[jwp-receipts](jwp-receipts.md) §2.1 computes over the domain `moq-jwp:object\x01` and
the bytes. Sealed with the object, it lets a member check the plaintext against a digest
the publisher committed to (§8.2), and plaintext-level records attach to it. A publisher **SHOULD** carry it on every sealed
object of a video or screen track and **MAY** leave it out where its 35 bytes weigh on a
small object, as a frame of speech is.
@ -535,12 +539,15 @@ changes who publishes, not who is a member, and needs no commit.
### 8.1 What receipts bind
A sealed object's receipt is the receipt of [jwp-receipts](jwp-receipts.md), unchanged,
over the object's payload as it travels: its content hash is BLAKE3 over the sealed bytes,
ciphertext and tag. The publisher signs its group receipts with its room key, and they ride
the track's receipts track (live-rooms §6.1, moq-streaming-format §4.1), in the clear. A
relay with a receipt door ([jwp-receipts](jwp-receipts.md) §4.1) checks each sealed object
against its receipt exactly as it checks a plaintext one, and holds each publisher's tracks
to its own key (the namespace names it).
over the object's payload as it travels: its content hash is the one jwp-receipts §2.1
computes, over the sealed bytes, ciphertext and tag. The publisher signs its group receipts
with its room key, and they ride the track's receipts track (live-rooms §6.1,
moq-streaming-format §4.1), in the clear. A relay with a receipt door
([jwp-receipts](jwp-receipts.md) §4.1) checks each sealed object against its receipt exactly
as it checks a plaintext one, and holds each publisher's tracks to its own key (the
namespace names it). A publisher of a sealed track **SHOULD** sign its object receipts in
the identity-bound form of §8.4, so the receipt also binds what the AEAD authenticates in
the clear.
### 8.2 What a member checks besides
@ -548,9 +555,8 @@ A member that opens an object knows the plaintext came from the sealed bytes the
signed for, under a key of the publisher's leaf: the AEAD authenticates the plaintext
under the KID's key, and the receipt binds those exact bytes to the publisher's room key.
Where the object carries `WAI_INNER_DIGEST` (§4.4), the member also checks the plaintext
against the digest the publisher sealed into it, the content hash a receipt of the plaintext
would carry; records made over plaintext (a decoded output's digest, a reconstruction
binding) attach to that hash.
against the digest the publisher sealed into it; records made over plaintext (a decoded
output's digest, a reconstruction binding) attach to that digest.
### 8.3 What a relay can prove, and what it cannot
@ -571,6 +577,90 @@ member's). A moderator who reviews media is a member and reviews what it opens;
withhold an object a moderator's decision names by its receipt leaf, and cannot review one by
its content ([live-moderation](live-moderation.md)).
### 8.4 The object's identity in its receipt
An object's **identity** is the AAD secure objects §3.5 forms for it (§4.7), its fields as
MOQT -22 encodes them:
```
identity = Group ID (8 bytes) ‖ Object ID (4 bytes) ‖ Publisher Priority (1 byte) ‖ Serialized Immutable Properties
```
big-endian, the Serialized Immutable Properties being the value of the object's Immutable
Properties pair (MOQT §10.7), empty where it carries none. An object whose Object ID is above
2^32 − 1 has no identity, as it has no nonce (§4.6). The identity is defined for any object;
on a sealed one it is exactly what the AEAD authenticates in the clear.
An **identity-bound** object receipt binds it. It is the labelled object receipt of
[jwp-receipts](jwp-receipts.md) §2.2 with one more field, the identity, and a leaf of its own:
```
leaf = BLAKE3("moq-jwp:merkle-leaf\x03" ‖ content_hash ‖ u64(joules_micro) ‖ class
‖ u32(len(origin)) ‖ origin ‖ u32(len(identity)) ‖ identity)
```
the labelled leaf's fields in its order under a domain of its own, then the identity. In the
binary form of jwp-receipts §2.7.2 its `leaf_version` is 3, followed by its class (as for 2),
the identity's length as 4 bytes, and the identity. The group receipt over such leaves is a
labelled group receipt, unchanged (jwp-receipts §2.3), and a group may hold leaves of every
form; a receipt of the two earlier forms keeps its bytes. A verifier that predates this form
does not read it (a `leaf_version` jwp-receipts §2.7.4 does not define), so a relay with a
receipt door is brought to this revision before the publishers of its namespaces sign in
this form. This form is defined here, beside the AAD it binds, rather than in jwp-receipts,
whose labelled leaf it extends.
1. An identity-bound receipt carries a class; its identity's Group ID and Object ID are the
receipt's `group_id` and `object_id`; its identity is at least 13 bytes and at most
13 + 2^16 − 1, the longest value a Key-Value-Pair holds (MOQT §8.3). A receipt that
breaks the first two is refused `identity_malformed`; a length outside that range does
not read (`malformed`).
2. The track is not in the identity. A receipt is already about one track, by its receipts
track and its `wai_receipt_alias` (moq-streaming-format §4.1), and secure objects binds a
sealed object to its track through its key (§4.5).
3. An object whose receipt is identity-bound carries no receipt property inside its Immutable
Properties (moq-streaming-format §5): its identity would then hold the receipt that binds
it. §4.3 says so for every sealed object.
4. **The check.** Whoever holds an object and its identity-bound receipt (a relay with a
receipt door, a member, a subscriber outside the group) forms the object's identity as it
arrived: its Group ID and Object ID; its Publisher Priority as its subgroup header or
datagram states it or, where the header sets DEFAULT_PRIORITY, the one it inherits (MOQT
§10.4, §11.2.1, §11.3.1); and its Immutable Properties as §11.6 gives them for the
revision its session speaks. Where that identity is not the receipt's, it refuses the
object, `identity_mismatch`, naming the first part that differs, in this order:
`group_object`, `publisher_priority`, `immutable_properties`. A relay with a door checks
it after the receipt itself verifies (jwp-receipts §4.1) and before it forwards the
object.
5. A publisher writes each integer of its Immutable Properties in its shortest form (§4.3)
and states its priority in every subgroup header and datagram (§4.7), so the identity
every hop forms is the one it signed.
| reason | raised at |
|---|---|
| `identity_malformed` | rule 1 |
| `identity_mismatch`, with `group_object`, `publisher_priority` or `immutable_properties` | rule 4 |
These reasons are this section's, beside §13's: §13 lists why a member refuses an object, a
record or a transcript, and these are why any holder of a receipt refuses an object.
**What it adds.** Without it, a hop that changes a sealed object's priority, its Key ID or
another of its Immutable Properties forwards bytes whose receipt still verifies, and only a
member finds, by a tag that fails (`bad_tag`), that something changed, and not where. With
it, the next relay with a door, and every subscriber, finds the change, names the part and
refuses the object, holding no key; a relay's per-hop record counts it among the objects
refused at its door (`dropped_refused`, live-metering §3.2). A per-hop record or receipt
that names an object names it by its receipt's leaf, which now binds its identity; the
per-hop records are live-metering's (§3), and a later revision of them reads this section for
the identity rather than restating it.
**What it does not.** It hides nothing: the identity is in the clear, as the fields it holds
are (§12, *Metadata*). It does not stop a relay dropping, delaying or reordering objects
(§12), and it says nothing of the plaintext.
The reference writes and checks it: `joule-moq`'s `receipt` module (`object_identity`,
`identity_as_received`, `ReceiptBuilder::add_identity_object`, `check_identity`) and the door
of `joule-moq-relay`. A member in a browser (§15) does not sign identity-bound receipts in
this revision. The vectors are `identity.json` (§14).
## 9. Recording and clips: refused
**A room with a confidentiality record is never recorded.** Its descriptor's recording
@ -674,6 +764,48 @@ written against. This extension writes them as MOQT -22's (§8.1), shortest, eve
the Serialized Full Track Name, the payload's length and the list's. Every value in secure
objects' Appendix B has the same bytes in both encodings, so those vectors hold unchanged.
### 11.6 Earlier transport revisions on a path
The AAD (§4.7) and the identity (§8.4) are formed from the fields as MOQT -22 encodes them,
whatever revision each session on the object's path speaks.
- **-18** (`draft-ietf-moq-transport-18`, the revision secure objects cites) has -22's
integer (§1.4.1 there) and pairs (§1.4.3), and keeps the bytes of Immutable Properties
unchanged, their integers' encodings included (§12.7). An object carried at -18 holds the
bytes its -22 publisher wrote.
- **-16** writes its integers as QUIC does (-16 §1.4; RFC 9000 §16), so the pairs inside its
Immutable Extensions (-16 §11.2, the same type `0xB`, which no relay modifies or removes)
are other bytes than -22's wherever an integer is 64 or more, as the Key ID's type
`0x3A4A` is. An endpoint, or a relay that bridges -16 to another revision, forms the
Serialized Immutable Properties of an object it receives at -16 from the pairs as -16
carries them: the same types and values, in the same order, each integer in -22's
shortest form. One that sends at -16 writes -22's pairs with -16's integer. A relay that
reads and writes -16 forwards the bytes as they came, as -16 §11.2 requires. Since a -16
integer holds at most 2^62 − 1, an object whose Key ID or another immutable value exceeds
it is not carried over a -16 session.
The vectors of `identity.json` check each case: arrivals at -18 pass as carried, at -16 pass
once re-encoded, and fail when -16's bytes are taken for -22's. The interoperation of the
reference's client with other implementations' relays at -16 and -18 is recorded in
`joule-moq`'s README.
### 11.7 The transport's publication request
The MoQ working group's milestones, as its page lists them on 22 October 2026, include
"Request publication of Publication and Subscription Protocol to IESG", due 31 December
2026, for `draft-ietf-moq-transport`; none names secure objects. This extension binds -22,
the current revision. When the working group requests publication of a revision, the next
revision of this extension reads, in that revision, the parts this text reads (the Object
fields, -22 §2.1.1; the integer, §8.1; the Key-Value-Pair, §8.3; the Track Namespace, §8.7;
Immutable Properties, §10.7; the Property registry, §16.8) and changes this text only where
they changed. Until secure objects and MOQT's registry agree on the Key ID's code point, the
Key ID stays at `0x3A4A` (§11.1). The control-plane changes -22's change log records since
-18 (its Appendix A.1 to A.4) touch none of the fields the AAD holds; of its data-plane
changes, two bear on this text: delivery timeouts became Object Properties (#1476), which
is how `0x02` came to be OBJECT_DELIVERY_TIMEOUT on objects (§11.1), and the provisional
registrations of secure objects' properties were corrected (#1807, #1818, #1848), which
list `0x0A` and `0x32` and no Key ID.
## 12. Security and privacy considerations
- **Who can open.** The members of an epoch, and no one else: not a relay, not the clock,
@ -769,16 +901,24 @@ Appendix B as those documents print them. The corpus has:
creation raises, cut at the step it refuses.
- **Negatives**: one control for each reason of §13 an object, a transcript or a record
raises.
- **Identity-bound receipts** (`identity.json`, §8.4, §11.6): a sealed video track's group
whose object receipts bind each object's identity, in the binary form a receipts track
carries, with each sealed payload, content hash, identity and leaf; copies of it with one
defect each (`identity_malformed`, `malformed`, and two whose proofs no longer reach the
signed root); and objects as a hop receives them at -22, -18 and -16, each with the
outcome of its check: passed, or `identity_mismatch` with the part that differs.
A conforming implementation gives every vector its stated outcome.
`joule_moq_e2ee_vectors verify` and the crate's tests check that the file regenerates byte for
byte, that the reference gives every vector its outcome, and that §13 lists exactly the
reference's codes. `live-conformance/e2ee_verify.py` is the second implementation, written
reference's codes; it writes and checks `identity.json` beside `vectors.json`. `live-conformance/e2ee_verify.py` is the second implementation, written
from this text and the documents it reads, standard library only: AES (FIPS 197) and GCM (SP
800-38D) written from their specifications for vectors only, HKDF and HMAC from Python's
standard library, BLAKE3 from `claim-conformance/_blake3.py`, and Ed25519 under WAI's strict
rule from `claim-conformance/_ed25519.py`. It requires every mutated copy of the corpus to
fail.
rule from `claim-conformance/_ed25519.py`. For `identity.json` it reads the receipts in the
binary form, checks the group's signature, rebuilds each identity-bound leaf and walks it to
the signed root, re-encodes -16's pairs as -22 encodes them, and checks each arrival. It
requires every mutated copy of the corpus, and of `identity.json`, to fail.
**The verifier's boundary.** It does not implement MLS. It takes each epoch's application
secret (§5.7) from the corpus as given and checks everything after it: the roster of each

View file

@ -168,9 +168,12 @@ A later extension adds its reasons here.
`ht`, `nor`, `nrr`, `ot`, `sf`, `st` and `su`, and the custom keys
`science.transaction.wai-ch` (its content hash), `-l` and `-lt` (its layer and its
stage set's stage count) and `-rcpt` (a delivery receipt's link hash), each a String.
`v` is not written for version 1.
`v` is not written for version 1. `st` is `v` or `l`, the two values CTA-5006 §7 Table 1
gives it: CMSD has no value for CMCD's `ll`, and a low-latency live object is `l`.
§6.2 A deriving node adds `science.transaction.wai-op`, its operation token, as a Token.
§6.2 A deriving node adds `science.transaction.wai-op`, its operation token, as a String
holding the token: CTA-5006 §4 item 1 gives a custom key the String type alone. (Before
this revision §6.2 wrote a Token, which §6.7 now ignores.)
§6.3 `CMSD-Dynamic` **MAY** state, for each node, `etp`, `rtt`, `mb`, `rd` and `du`, and
`science.transaction.wai-drop` (a String): the number of objects the node discarded on
@ -182,6 +185,89 @@ only when its value is not `null`, and a Boolean (`su`, `du`) only when true.
§6.5 CMSD carries no joule figure. An edge's energy travels in a claim.
§6.6 **Reading.** A party that reads CMSD, whoever wrote it, reads it as CTA-5006 (November
2022) §8.1 has a client read it. It reads `CMSD-Static` as an RFC 9651 Dictionary and
`CMSD-Dynamic` as an RFC 9651 List (CTA-5006 §4), each the value of its field lines joined
in order by a comma and a space (RFC 9110 §5.3), by the whole grammar of RFC 9651 §4.2. A
field that does not parse is ignored whole (CTA-5006 §8.1 item 1). CTA-5006 cites RFC 8941,
which RFC 9651 obsoletes; the two parse the same text but for RFC 9651's Dates and Display
Strings, so a field holding one is read here and only the key that holds it is then ignored
(§6.7), where a parser of RFC 8941 alone would ignore the whole field. A browser sink reads
the fields only where the server exposes them (CTA-5006 §8.2 item 3).
The version is read first. `v` absent is version 1 (CTA-5006 §7 Table 1, `v`), and so is
`v` the Integer one. An Integer above one is a version the reader does not understand: it
ignores the response's whole data set, `CMSD-Static` and `CMSD-Dynamic` both (CTA-5006
§8.1 item 8). Any other `v` is ignored, as that section's item 4 has it, and the data set
is read as version 1.
§6.7 **The keys read.** The reader reads each key Table 1 reserves for the header it is in,
by the type Table 1 gives it, and ignores alone a value that is not of that type (CTA-5006
§8.1 item 4). The order of members is not read (item 6), and a key given twice has its last
value (RFC 9651 §4.2.2).
| key | header | read as |
|---|---|---|
| `at`, `d`, `ht` | Static | an Integer of 0 or more, milliseconds |
| `br` | Static | an Integer of 0 or more, kilobits per second |
| `n` | Static | a String |
| `nor` | Static | a String, cut at each `\|` into paths, none empty, each kept as written, its percent-encodings included |
| `nrr` | Static | a String, cut at each `\|`: each element empty (the whole object) or a start and an end, decimal integers joined by `-`, the start not above the end |
| `ot` | Static | a Token: `m`, `a`, `v`, `av`, `i`, `c`, `tt`, `k` or `o` |
| `sf` | Static | a Token, `d`, `h`, `s` or `o`, or a non-empty Inner List of them (§6.9) |
| `st` | Static | a Token: `v` or `l` |
| `su` | Static | a Boolean |
| `v` | Static | §6.6 |
| `du` | Dynamic | a Boolean |
| `etp`, `mb` | Dynamic | an Integer of 0 or more, kilobits per second |
| `rd`, `rtt` | Dynamic | an Integer of 0 or more, milliseconds |
Table 1 gives these keys times since the epoch, durations and rates; a negative Integer is
none of them, and is ignored. A `CMSD-Dynamic` member is the String that identifies its
server (CTA-5006 §4), an empty String included (CTA-5006 §5). A member that is not a String
has its identifier ignored and its parameters read, so that no entry leaves its place in
the order, the first nearest the origin and the last nearest the client (CTA-5006 §4).
Parameters on a `CMSD-Static` member are not CTA-5006's and are not read.
A **custom key** is a key the header does not reserve that holds a hyphen (CTA-5006 §4 item
1). Its value is read when it is a String, the only type item 1 gives a custom key, and
ignored otherwise. This extension's own keys are read in the forms §6.1 to §6.3 write:
`science.transaction.wai-ch` and `-rcpt` as 64 lowercase hexadecimal digits; `-l`, `-lt`
and `-drop` as decimal digits with no leading zero, at most 10¹⁵ − 1; and `-op` as a String
holding a token. A value in another form is ignored, a Token for `-op` among them (`type`,
§6.8; CTA-5006 §4 item 1 and §8.1 item 4). Every other custom key
with a String value is read as written, for the extension that defines it
([energy-steering](energy-steering.md) §8.5 defines two). A key the header neither reserves
nor holds as a custom key, a key Table 1 reserves for the other header among them, is
unknown and ignored (CTA-5006 §8.1 item 3); `tl`, which Table 1 does not define, is one.
§6.8 **What is ignored is stated.** A reader states what it ignored and why, with the
field, the `CMSD-Dynamic` entry and the key: the whole field (`unparsed`, CTA-5006 §8.1
item 1), the data set (`version`, item 8), a value not of its key's type (`type`, item 4,
and CTA-5006 §4 item 1 for a custom key), or an unknown key (`unknown`, item 3). Nothing
ignored changes what else is read. CMSD is unsigned, and an intermediary may add to it,
change it or remove it (CTA-5006 §5); a `CMSD-Dynamic` entry may be a cached one that
belonged to an earlier response (CTA-5006 §6.1); and a malicious origin or proxy may send
false values (CTA-5006 §8.1 item 7 and §9.2). What a reader reads is therefore what the
response said, never evidence, and this extension acts on none of it.
§6.9 **Where CTA-5006 is read one way.**
- `sf`: CTA-5006 §7 Table 1 types it an Inner List of Tokens, and its Annex A writes it as
a bare Token. §6.1 writes a Token, as Annex A does, and §6.7 reads both, a Token as a
list of one.
- Annex A ends some `CMSD-Dynamic` entries with `;`, which neither RFC 8941 nor RFC 9651
parses; a reader ignores such a field whole (§6.6).
§6.10 **Corpus.** `telemetry-conformance/cmsd-read/` holds CMSD fields as other parties
write them, each its field lines and what it shows, among them cases in the shape of
CTA-5006 Annex A's examples, written with the corpus's own identifiers and figures; and
`expected/cmsd-read.json` states what §6.6 to §6.8 read of each. `wai_telemetry verify`
requires it, and that every field §6.1 to §6.3 write reads back as the description it was
written from; `cml/differential.mjs` decodes every case with the independent library and
requires the library's value for every key the reader keeps, and the corpus's README states
each kind of case where the two differ.
## 7. Session metrics
§7.1 A **log** is one session's observations in `sn` order: one `sid`, `sn` strictly

View file

@ -26,22 +26,27 @@
> `draft-pantos-content-steering-04` (§2 to §7: pathway IDs, the steering manifest, pathway
> cloning, query parameters and client behaviour), Internet-Drafts not past Working Group
> Last Call; ETSI TS 103 998 V1.1.1 (2024-01), content steering for DASH (clauses 5 to 8);
> CTA-5004-B (the CMCD keys `bl` and `mtp`, and the query and header modes); RFC 9651 §4.2;
> RFC 3986 §2; RFC 8259; RFC 8032; FIPS 180-4. A section number of one of those drafts in
> this text is that revision's.
> CTA-5004-B (the CMCD keys `bl` and `mtp`, the query and header modes and the custom-key
> rule); CTA-5006 (November 2022), Common Media Server Data (§3, §4, §5, §6.1, §7 Table 1,
> §8.1, §8.2 and §9.2), for §8.5; RFC 9651 §4.2; RFC 9110 §5.3; RFC 3986 §2; RFC 8259;
> RFC 8032; FIPS 180-4. A section number of one of those drafts in this text is that
> revision's.
## 1. Scope
In scope: the steering policy, its pathways and limits, and who signs it (§3); what a
steering server reads from a client's steering request (§4); a pathway's evidence and its
figure (§5); the decision rule (§6); the steering decision record and who signs it (§7);
the steering manifest, the HLS tag and the DASH element, written exactly, and how the
record rides beside them (§8); verification (§9).
the steering manifest, the HLS tag and the DASH element, written exactly, how the record
rides beside them, and the CMSD and CMCD fields that name the decision and the hops to CDNs
(§8); verification (§9).
Out of scope: a transport, and a steering server's network service, which this extension
describes only through its inputs and its output; how a hop measures its energy, which
energy-measurement governs; how an operator chooses its limits; and how a player applies a
steering manifest, which the three texts above fix and this extension does not change.
energy-measurement governs; how an operator chooses its limits; how a player applies a
steering manifest, which the three texts above fix and this extension does not change; and
the specifications of the SVTA's Open Caching working group, which this revision does not
bind.
Relationship to the core spec: `wai.live.steering` is a **record** ([SPEC](../SPEC.md) §5):
a signed record a WAI tool emits and verifies. It names no payload format and is not
@ -528,6 +533,94 @@ is no loop between them. A party that holds the manifest fetches the record, ver
(§9), and requires that the manifest be the bytes §8.1 writes from it. A steering server
**SHOULD** keep each decision available for as long as the records it names are.
### 8.5 For CDNs: CMSD and CMCD
A decision, and the hops that deliver under it, are named in the two fields that CDNs and
players already parse: CMSD (CTA-5006) on responses and CMCD (CTA-5004-B) on requests. A
CDN's logs and a player's telemetry then name, for each response, the signed decision and
the hops whose signed records are its evidence. Nothing in this section is evidence, and
nothing here changes a policy, a decision or a manifest: the decision rule (§6) reads no
CMSD value and no CMCD key but the report's (§4.4), since neither field is signed and any
intermediary may change them (CTA-5006 §5, §8.1 item 7 and §9.2). The bytes of `WSP1` and
`WSD1` are those of §3 and §7. CMSD is read as [delivery-telemetry](delivery-telemetry.md)
§6 reads it (paragraphs 6.6 to 6.8 there), and written as that section writes it (paragraph
6.4).
**The steering response.** A steering server **SHOULD** send, with each steering manifest,
one `CMSD-Static` field (CTA-5006 §3) holding exactly three keys: `n`, its identifier, a
String (CTA-5006 §7 Table 1); `ot=m`, the object type of a manifest; and
`science.transaction.wai-sd`, the decision's digest as 64 lowercase hexadecimal digits, a
String, the type CTA-5006 §4 item 1 gives a custom key. In bytewise order:
n="<identifier>",ot=m,science.transaction.wai-sd="<digest>"
A client that reads the field compares the key with the digest the manifest's member names
(§8.4). The two equal, the response is **bound**. The field absent, ignored whole or of a
later version, or holding no such key in that form, nothing is stated: the response is
**absent**, which is no fault. A different digest is `cmsd_decision_mismatch`. The
manifest's member is the one that names the decision, since the field is outside the body
and an intermediary may extend or change it (CTA-5006 §4, §5): a client keeps to the
manifest and records the mismatch, and a party that holds both verifies the decision (§9)
whatever the field says. An intermediary that extends the field leaves the key readable
wherever it stands.
**Media requests.** A client that applies a steering manifest carrying the member of §8.4
**SHOULD** add to each media request it then sends the CMCD custom key
`science.transaction.wai-sd`, the decision's digest, a String (the form of a custom key
that delivery-telemetry §4 gives in paragraph 4.1): in the `CMCD-Session` header shard, or
in the `CMCD` query parameter when it reports by query (CTA-5004-B's modes). It inserts the
key among the report's keys in bytewise order and changes no other, so a report
delivery-telemetry §3 writes (paragraph 3.3) keeps its bytes but for the key; a key already
there, from an earlier manifest, takes the new digest. A CDN that logs CMCD then logs, with
each request, the decision that sent it to that pathway. The key names a published record
and no person; delivery-telemetry §8 governs the rest of the report.
**Hops in CMSD-Dynamic.** A hop of a policy's pathway that answers an HTTP request
([http-delivery](http-delivery.md)) **SHOULD** add its own `CMSD-Dynamic` entry (CTA-5006
§4 item 5, §5): its identifier, a String, and among its parameters
`science.transaction.wai-hop`, the key that signs its per-hop session records
(live-metering §3.1, `hop`) as 64 lowercase hexadecimal digits, a String, beside any
parameter that delivery-telemetry §6 gives a node (paragraph 6.3), all in bytewise order.
Where the hop also writes `CMSD-Static`, its identifier is the `n` it writes there
(CTA-5006 §4). This section names a hop by that key and reads nothing else of its records,
so it holds for any later version of live-metering's per-hop records, and any per-hop
receipt live-metering adds, that is signed by the hop's key. A steering server and a hop
that serve browsers expose both fields (CTA-5006 §8.2 item 3).
**Served by.** Given a policy, the `id` of the pathway a client applied, and the
`CMSD-Dynamic` field of a response it received there, a client or an auditor finds which of
the policy's hops the response says served it. It reads the field (delivery-telemetry §6,
paragraphs 6.6 to 6.8), then each entry in order, the first nearest the origin, and stops
at the first refusal, which it reports with the entry:
1. the `id` is the `id` of a pathway of the policy: otherwise `pathway_unknown`;
2. an entry with no `science.transaction.wai-hop` is another server's, and is passed over;
3. a `science.transaction.wai-hop` that is not a String of 64 lowercase hexadecimal digits,
or a key that an entry before it also names, is `served_malformed`;
4. a key that is no hop of the policy is `served_unknown_hop`, and a hop of another of its
pathways `served_off_pathway`;
5. a hop named after an edge hop of the pathway is `served_out_of_order`: an edge hop
serves clients (§2), so it is the last hop of the pathway a response leaves.
A field that is absent, ignored whole or of a later version, or that names no hop, leaves
the response **unattributed**; otherwise it is **attributed** to the hops it names, in
order, each with its place. Attribution names hops; it measures nothing. An intermediary
may cache an entry from an earlier response (CTA-5006 §6.1), and a malicious one may name
any hop (CTA-5006 §9.2): an attributed response is what its field said, and the hops'
signed records remain the evidence (§5). A hop named on a pathway the policy does not give
it is a misrouting or a false field, not a figure.
The reasons of this section:
| reason | raised at |
|---|---|
| `cmsd_decision_mismatch` | the steering response |
| `pathway_unknown` | served by, step 1 |
| `served_malformed` | served by, step 3 |
| `served_unknown_hop` | served by, step 4 |
| `served_off_pathway` | served by, step 4 |
| `served_out_of_order` | served by, step 5 |
## 9. Verification
### 9.1 Inputs and steps
@ -615,7 +708,9 @@ The codes this extension adds, each with where it is raised:
| `decision_mismatch` | §6, §9.1 step 6 |
| `manifest_mismatch` | §8.1, §9.1 step 7 |
A verifier of this extension also raises `truncated`, `malformed` and `unknown_version` for a
§8.5 lists the reasons of the fields for CDNs, which a client or an auditor raises and §9's
verifier does not. A verifier of this extension also raises `truncated`, `malformed` and
`unknown_version` for a
policy, a decision or a record that does not read; `bad_key` and `bad_signature` for their
signatures; `wrong_room` for one of another room; live-metering's `classes_summed`,
`signer_not_authorised`, `signer_revoked`, `record_missing`, `energy_invalid`,
@ -624,8 +719,9 @@ the room, at `room`.
## 11. Conformance
[`live-conformance/steering/`](../live-conformance/steering/) holds the corpus, in one file,
`vectors.json`, written from fixed seeds by `wai_energy_steering_vectors gen`. It sits beside
[`live-conformance/steering/`](../live-conformance/steering/) holds the corpus, in
`vectors.json`, written from fixed seeds by `wai_energy_steering_vectors gen`, and the cases
of §8.5 in `cdn.json`. It sits beside
the metering corpus because its evidence is per-hop records of live-metering, read by that
corpus's second implementation. It holds a room with its key log, policies, the per-hop
records of the pathways' hops, and:
@ -657,6 +753,14 @@ and the per-hop records with `live_metering_verify.py`, parses RFC 9651 itself,
every report, figure, decision and manifest with Python's integers, gives every request and
vector the same outcome, and requires every mutated copy of the corpus to fail.
A second file, `cdn.json`, written by the same tool from `vectors.json`, holds the fields of
§8.5: the `CMSD-Static` field of steering responses for accepted vectors, with a response
bound, absent in each way, and refused; the `CMSD-Dynamic` entries of hops; CMCD reports and
header shards with the decision's key added; and responses to attribute, attributed through
other servers' entries, unattributed, and refused for each reason of §8.5. The same test and
`energy_steering_verify.py`, which reads CMSD with its own RFC 9651 parser, give every case
its outcome, and require §8.5 to list exactly those reasons.
The corpus's energy figures are synthetic, chosen to exercise the rule. It computes which
pathway the rule places first, and claims nothing about the energy any deployment spends or
saves.
@ -683,6 +787,11 @@ saves.
upstream hop's record contradicts a downstream one's.
- **Flapping.** The draw is fixed for a session, the window moves one period at a time, and
the margin *h* keeps a client on its pathway against a figure within it (§6.4 step 5).
- **The fields for CDNs are pointers.** `science.transaction.wai-sd` and
`science.transaction.wai-hop` (§8.5) name a decision and a hop; they state no figure and
are signed by no one. A party that changes them misleads a log, not the rule, which reads
neither, and a party that checks a decision or a figure fetches the signed records they
name.
- **Privacy.** A decision holds no identifier of the client beyond its session, which the
server minted and which names no person; the report is the throughput and buffer the
client sends to every steering server; and the decision does not carry the request, so a

View file

@ -20,8 +20,12 @@
> share a signing key write the same segment and packagers that do not still chain across a
> failover: the dynamically packaged content C2PA 2.4 §19.2.1 treats as live. The
> initialization manifest may carry C2PA's repository receipt (§18.27 there), whose proof is an
> RFC 9942 receipt of inclusion in a repository's RFC 9162 log. No new transport. The reference
> implementation is the `live_provenance` module of `wai-rs` (feature `live_provenance`); the
> RFC 9942 receipt of inclusion in a repository's RFC 9162 log. A packager signs each segment
> as its last chunk closes, holding between segments only public values another packager can
> continue from, and checks before it releases a segment what a verifier will check of it
> (§10.4); a verifier checks each segment as it arrives, holding what does not grow with the
> stream (§11.7). No new transport. The reference implementation is the `live_provenance`
> module of `wai-rs` (feature `live_provenance`), its live signer `live_provenance::live`; the
> corpus is [`live-conformance/c2pa-live/`](../live-conformance/c2pa-live/), and its second
> implementation is `live-conformance/c2pa_live_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
@ -38,7 +42,7 @@
> ISO/IEC 19566-5:2023 (JUMBF) and ISO/IEC 23009-9:2025 (REaP) were not read for this
> revision: the box layouts below are the ones C2PA 2.4 and [packaging](packaging.md) §7
> (paragraph 7.6) state, REaP is named only as C2PA 2.4 §13.2.1 and §19.4.1 name it, and the
> corpus's README says what an independent C2PA validator reports for the corpus. A section
> corpus's README says what independent C2PA implementations report for the corpus. A section
> number in this text is C2PA 2.4's unless it names another document.
## 1. Scope
@ -51,8 +55,9 @@ hard binding, the actions, and the repository receipt in an update manifest (§4
assertions (§5); the per-segment C2PA Manifest Box method (§6); the Verifiable Segment Info
method, its session keys and their rotation (§7); the correspondence
with the group receipts and the object receipts (§8); the digital source type and AI
disclosure (§9); carriage over HTTP and over MoQ, and dynamic and redundant packaging (§10);
verification (§11); and the reasons (§12).
disclosure (§9); carriage over HTTP and over MoQ, dynamic and redundant packaging, and signing
as the stream runs (§10); verification, of a whole stream and as it arrives (§11); and the
reasons (§12).
Out of scope: a transport; the media codecs; MPEG transport streams, which §19.1 excludes; a
track packaged as WAI envelopes (`packaging` `"wai"`, [moq-streaming-format](moq-streaming-format.md)
@ -640,6 +645,64 @@ objects, by re-encoding the source under a WAI capability whose decode is `repli
([determinism-tiers](determinism-tiers.md)), is not in this revision: it needs the source and
an encoder that is itself a pure function of it.
### 10.4 Signing as the stream runs
A packager signs a segment when the segment's last chunk has closed. The hard binding covers
every chunk of the segment (§4.5) and the C2PA Manifest Box precedes the first `mdat` (§3.2),
so neither C2PA box can be written before then: C2PA 2.4 hashes a whole segment and carries
the hash in that segment (§19.2.1, §19.2.2). Over MoQ a track's chunks travel as the publisher
produces them, and the segment's C2PA boxes follow on the provenance track when its group
closes (§10.2); over HTTP a segment's file is complete once its C2PA boxes are written. A
player that presents a segment's chunks before its C2PA boxes arrive, as a low-latency player
presents the parts of a segment, presents them unverified until the segment is complete: this
revision signs whole segments, not parts of them.
**What a packager holds.** Between segments it holds its keys and:
- the stream's identity, label and claim signer (§4.4, §5);
- the sequence number the next chunk carries (§3.3), and the next segment's group (a track)
or chunk index (a take);
- the identifier of the last manifest the stream carried: the next segment's
`previousManifestId` (§6) and, where the next segment has no manifest of its own, its
segment-info map's `manifestId` (§7.2; C2PA 2.4 §19.4.1);
- for a track, the previous group's receipt, to which the next group's receipt links (§8.4);
- the session keys the stream has delivered, each with its range and validity (§7.1).
It holds no earlier segment's bytes. Every value it holds but its keys is public, and the
manifest identifiers are derived (§4.2), so a packager that takes over a stream continues it
from these values alone: its first segment's `previousManifestId` names the manifest the
previous packager wrote last, and the continuity chain holds across the change (§10.3). Under
the Verifiable Segment Info method the session keys also sign the receipts (§8.3), so a
packager that is not the publisher continues under the per-segment method.
**Before it releases a segment** a packager checks what a verifier will check of it (§11.4
steps 1, 2, 4, 5 and 6), and where a check fails it releases nothing for that group and
reports the reason §12 gives the defect: chunks that do not read, or that are not numbered on
from the previous segment (`segment_malformed`, `sequence_mismatch`); a group that is not the
next one (`sequence_not_increasing`, or `segment_missing` for a group passed over); a group
receipt that does not verify, carries another `trackAlias` or does not link to the previous
group's (`receipt_invalid`), or does not hold exactly the segment's chunks
(`receipt_mismatch`); and, under the Verifiable Segment Info method, a receipt not signed by
the session key current for the segment (`receipt_signer_mismatch`), a segment outside every
held key's range or validity (`session_key_out_of_range`), and keys delivered for an earlier
segment, delivered under the per-segment method, or already held (`session_key_invalid`). A
refusal changes nothing the packager holds. A packager that releases nothing for a group has
ended the stream there: that group's receipt is held and its segment never comes, which a
verifier refuses at the end (§11.5).
**Keys.** The initialization manifest delivers every session key the stream begins with
(C2PA 2.4 §19.4.4.1, §19.6.2). A packager delivers a later key in a segment's manifest (§7.3;
§19.4.3), before the current key's validity ends, with a `minSequenceNumber` at that segment
or a later one, so that a verifier already holds the key a segment is signed with: §19.6.2's
overlap, which lets a key be delivered ahead of its first segment.
**The reference.** `live_provenance::live`: `LiveSigner::start` writes the initialization
segment; `LiveSigner::track_segment` writes a track's segment from its chunks, exactly as they
travelled, and its group receipt, and `LiveSigner::take_segment` a take's from its chunks and
the device's last commitment; `LiveState` is what it holds between segments, and
`LiveSigner::resume` continues a stream from it. It returns each refusal above as the
`Reason` a verifier gives.
## 11. Verification
### 11.1 Inputs
@ -791,6 +854,30 @@ number, its group (a track) or chunk index (a take), the methods it carries (`ma
its group (a track) or the SHA-256 of its take chunk (a take: chunk 0 is the initialization
segment followed by segment 0).
### 11.7 Verifying as the stream arrives
A verifier of a live stream runs §11.3 on the initialization segment, §11.4 on each segment as
it arrives and §11.5 when the stream ends, and states §11.6's summary of each segment as it
accepts it. It checks a track's segment of group *g* once it holds the receipts of *g* and of
every group before it. One that waits for them waits for a time it chooses, and then refuses
`receipt_missing`, as a relay holds an object for its receipts ([jwp-receipts](jwp-receipts.md)
§4.1). Between segments it holds:
- what §11.3 established: the stream's identity, identifier and label, its registration, and
the session keys with their ranges, to which a rotation adds (§7.3);
- the previous segment's sequence number, its last `sequence_number`, its group, and the
active manifest's identifier;
- one more than the number of objects of the groups from `firstGroup` to the last one reached:
the sequence number the next group's segment carries (§3.3);
- the last group's receipt, and the receipts of groups it has not reached;
- the first group a segment passed over, which §11.5 refuses at the end.
What it holds therefore does not grow with the stream's length. It stops at the first refusal
(§11.1) and accepts no later segment: the state a later segment is checked against is no
longer established. Given each segment of the corpus as it would arrive, with the receipts of
its group and of the groups before it, it gives every vector the outcome the whole stream gets
(§13).
## 12. Reasons
| reason | raised at | C2PA 2.4 §15 code | §19.7 code |
@ -875,14 +962,19 @@ A conforming verifier gives every vector the outcome, the reason, the place, the
codes and, for an accepted stream, what §11.6 states, as the corpus states them.
`wai_live_provenance_vectors verify` and the test `tests/live_provenance.rs` check that every
file regenerates byte for byte, that no other file is there, that `wai-rs` gives every vector
its outcome, that every reason of §12 refuses a vector, and that §12 lists exactly the
reference's codes. `live-conformance/c2pa_live_verify.py` is the second implementation,
its outcome, whole and as it arrives (§11.7), that every reason of §12 refuses a vector, and
that §12 lists exactly the reference's codes. They also sign every good stream with the live
signer of §10.4, one segment at a time from its chunks and receipts, and require the corpus's
bytes, the failover's second packager resumed from the first's state at its third segment; and
they give the live signer one defect of each kind §10.4 lists, requiring the refusal and
reason it states, the signer's state unchanged, and the stream then continuing as the
corpus's. `live-conformance/c2pa_live_verify.py` is the second implementation,
written from this text: it shares no code with `wai-rs`; it reads CBOR, COSE, JUMBF, ISO BMFF
and X.509 DER itself, verifies signatures with WAI's strict rule written from RFC 8032
(`claim-conformance/_ed25519.py`) and hashes with BLAKE3 from `claim-conformance/_blake3.py`;
gives every vector the same outcome; and mutates copies of the good vectors, one defect each,
and requires every copy to be refused. The README of the corpus records what an independent C2PA validator reports for the
corpus's files.
and requires every copy to be refused. The README of the corpus records what independent C2PA
implementations report for the corpus's files, and which of its checks they do not make.
## 14. Security and privacy considerations

View file

@ -47,7 +47,7 @@ file of that capability's conformance vector; and `upscale/` reads two priors th
floor, and `contribution/` and `influence/` generate their pictures with `wai.generate.spectral`
and the model file of that capability's conformance vector (`influence/` also with a second
model file written from it). `joule_moq_e2ee_vectors gen` (in
`joule-code/joule-code-rs/crates/joule-moq-mls`) writes `e2ee/vectors.json` from fixed seeds,
`joule-code/joule-code-rs/crates/joule-moq-mls`) writes `e2ee/vectors.json` and `e2ee/identity.json` from fixed seeds,
and `e2ee/` also holds the test vectors RFC 9605 and `draft-ietf-moq-secure-objects-01`
publish, as printed (`e2ee/README.md`). One recorded record: `metering/relay-record.wmh` and
its ledger, which the reference relay signed in its test.

View file

@ -48,36 +48,76 @@ group one of the floor's pieces, renumbered and retimed across the stream.
**Checked by.** `wai_live_provenance_vectors verify` and `tests/live_provenance.rs` in
`wai-rs` (every file regenerates byte for byte and no other is here; every vector gets its
outcome; every reason refuses one; §12 lists the reference's codes), and
outcome, whole and verified segment by segment as it would arrive, §11.7; every reason refuses
one; §12 lists the reference's codes; the live signer of §10.4 writes every good stream byte for
byte one segment at a time, and refuses one defect of each kind §10.4 lists with its reason,
keeping its state), and
`../c2pa_live_verify.py`, the second implementation, written from the extension's text, which
also requires every mutated copy of a good vector to be refused.
**An independent C2PA validator.** The corpus's files were also read with the C2PA
command-line tool, version 0.27.22, given `anchor.der` as its trust anchor, on 2026-10-21:
**Independent C2PA implementations.** The corpus's files were read on 2026-10-22, each given
`anchor.der` as its trust anchor, by the C2PA command-line tool, version 0.28.2, built against
the C2PA Rust library 0.91.2 as the tool's lock file pins it, and by the C2PA Rust library
0.91.4 itself, through a harness kept outside this repository that reads each vector's
initialization segment, each segment's C2PA Manifest Box store against that segment's bytes as
a sidecar store, each segment as a fragment of its initialization segment, and each segment
alone. On 2026-10-21 the command-line tool 0.27.22 read the initialization segments.
- every good vector's initialization segment validates: state `Trusted`, with
`assertion.bmffHash.match`, `assertion.hashedURI.match`, `claimSignature.validated`,
`claimSignature.insideValidity` and `signingCredential.trusted`. The manifest store, the
claim, its signature, the certificate chain and the BMFF hash over the `moov` are therefore
read the same way by an implementation that shares nothing with this one;
- `camera-registered/init.mp4` validates too, state `Trusted`, its active manifest the update
manifest: the tool reads the ingredient (`ingredient.manifest.validated`) and its validation
results. Before the ingredient carried them it reported `assertion.ingredient.malformed`,
which C2PA 2.4 §18.16.12.4.3 requires; `update-ingredient/init.mp4` is reported
`ingredient.manifest.mismatch`, where this corpus states `update_manifest_invalid`;
`repository-receipt-forged/init.mp4` is reported `Trusted`: the receipt's proof is the
repository's form (§18.27), which the tool does not check;
- `cert-untrusted/init.mp4` is reported `signingCredential.untrusted`, as here;
- `cert-expired/init.mp4` is reported `signingCredential.expired`, a code C2PA 2.4 does not
list; §15.8 names this failure `claimSignature.outsideValidity`, which this corpus states;
- `cert-wrong-eku/init.mp4`, whose end entity is valid for `id-kp-timeStamping` alone, is
reported `Trusted`: the tool's default list of accepted extended key usages includes
time-stamping and OCSP signing. C2PA 2.4 §14.5.1.1 makes a certificate valid for either of
those purposes valid for no other, and §14.5.1.2 requires a claim signer's to hold an EKU of
the validator's configuration, so this corpus refuses it `credential_invalid`;
- the tool reads no media segment: a file that does not begin with `ftyp` is not one it
opens, so it does not verify §19 per-segment manifests, segment-info maps or session keys.
The segments are checked by the two implementations above alone.
- **The trust configuration.** Given a trust anchor and no list of accepted extended key
usages, the library 0.91 clears its default list and accepts only e-mail protection,
time-stamping and OCSP signing, which it recognises by name. Every good initialization
segment is then reported `signingCredential.invalid` ("certificate missing required EKU"),
its end entity holding `c2pa-kp-claimSigning` and `id-kp-documentSigning`, and
`cert-wrong-eku/init.mp4`, valid for time-stamping alone, `Trusted`. So it is with the
tool's `trust --trust_anchors`, the invocation of the 0.27.22 run, and with a settings file
that holds the anchor alone. Given also the library's own default list, its
`valid_eku_oids.cfg` (the same in 0.91.2 and 0.91.4), as `trust_config`, the results below
hold. C2PA 2.4 §14.5.1.2 requires a claim signer's certificate to hold an EKU of the
validator's configuration, and §14.5.1.1 makes a certificate valid for time-stamping or OCSP
signing valid for no other purpose, so this corpus refuses `cert-wrong-eku`
`credential_invalid`.
- **The initialization segments**, by the tool and the library alike: every good vector's
validates, state `Trusted`, with `assertion.bmffHash.match`, `assertion.hashedURI.match`,
`claimSignature.validated`, `claimSignature.insideValidity` and `signingCredential.trusted`,
as with 0.27.22; `camera-registered/init.mp4` is `Trusted`, its active manifest the update
manifest; `update-ingredient/init.mp4` is reported `ingredient.manifest.mismatch` in its
ingredient's deltas, where this corpus states `update_manifest_invalid`;
`repository-receipt-forged/init.mp4` is `Trusted`, the receipt's proof being the
repository's form (§18.27), which neither reads; `cert-untrusted/init.mp4` is
`signingCredential.untrusted`, as here; `cert-expired/init.mp4` is
`signingCredential.untrusted` and `signingCredential.expired` (0.27.22 reported the second
alone), a code C2PA 2.4 does not list, where §15.8 names the failure
`claimSignature.outsideValidity`, which this corpus states; `init-mdat/init.mp4` is
`assertion.bmffHash.mismatch`, the `mdat` added falling under the hash, where this corpus
states §19.7.1's `init_invalid`; and `manifest-malformed/init.mp4` is not read ("claim version
is too new, not supported": its claim's label reads `c2pa.claim.v9`).
- **Each segment's C2PA Manifest Box**, by the library: every per-segment manifest of every
good stream, the failover's second packager's included, and the manifest that delivers
`camera-vsi`'s second session key, validates against its own segment, state `Trusted`, with
`assertion.bmffHash.match`. The library computes a segment's hash, with §4.5's exclusions
and offsets, as this corpus does, and reports `assertion.bmffHash.additionalExclusionsPresent`
as information. Of the negatives, segment 2 of `bytes-changed-manifests` and of
`segment-truncated` is `assertion.bmffHash.mismatch` (this corpus states
`hard_binding_mismatch` for the first and, reading the truncated chunk before the hash,
`segment_malformed` for the second), segment 1 of `claim-signature` is
`claimSignature.mismatch` and segment 1 of `assertion-altered` is
`assertion.hashedURI.mismatch`, each the §15 code §12 gives the reason.
- **What they do not read.** Neither implements C2PA 2.4 §19's validation: no source file of
the library 0.91.2 or 0.91.4 names `c2pa.livevideo.segment`, `urn:c2pa:verifiable-segment-info`
or `c2pa.session-keys`. The checks of §19.7.2 and §19.7.3 — sequence numbers, the stream
identifier, the continuity chain, session keys and the segment-info map in the VSI `emsg`
box — are therefore made by this corpus's two implementations alone: read one at a time,
the manifests of `sequence-mismatch`, `stream-id-mismatch`, `continuity-method`,
`manifest-id-not-derived`, `wrong-receipt`, `segment-missing-manifests` and
`segment-reordered` are each `Trusted`, and a segment under the Verifiable Segment Info
method alone carries no manifest for them to read. Their fragmented-BMFF path (the tool's
`fragment --fragments_glob`, the library's `with_fragment`) is Appendix A.5.4's Merkle
form: given an initialization segment and a segment of it, it refuses every stream here
with `assertion.bmffHash.mismatch` ("Hash value should not be present for a fragmented
BMFF asset"), since §19.2.3 binds the initialization segment by a hash over its `moov`. A
segment opened alone is not read: the tool reports an unsupported file type, the library a
BMFF structure with no leading `ftyp`.
The tool reports a claim signature as failed when the end-entity certificate's subject holds
no organization name, which C2PA does not require; the corpus's names all hold one (§4.4).
The tool 0.27.22 reported a claim signature as failed when the end-entity certificate's
subject held no organization name, which C2PA does not require; the corpus's names all hold one
(§4.4).

View file

@ -18,8 +18,9 @@ room.
digest, padding, other encrypted entries, other Immutable Properties), every `derived`
value (track base key, KID, Full Track Name, key, salt, Immutable Properties, plaintext,
AAD, nonce), the `object` as a -22 object carries it (its property list and sealed
payload, hexadecimal), and its `content_hash`, the BLAKE3 hash a receipt commits to. The
negatives are controls of one defect each. Each is opened by the receiver its `context`
payload, hexadecimal), and its `content_hash`, BLAKE3 over the sealed payload alone (a
receipt's content hash, jwp-receipts §2.1, is BLAKE3 over `moq-jwp:object\x01` and those
bytes, as `identity.json` states it). The negatives are controls of one defect each. Each is opened by the receiver its `context`
names and has its `outcome`: `opened` with the BLAKE3 hash of its original payload, or a
reason of §6;
- `rooms`: transcripts, each a list of `steps` (a lifecycle `event`, the group's `create`,
@ -28,14 +29,30 @@ room.
the outcome a replay gives it (§5.4 to §5.6, §6): `accept` or `refuse` with the reason and
the step, each epoch's roster, and each delivery's outcome;
- `codes`: the reasons of §13, in its order.
- `identity.json`, written by `joule_moq_e2ee_vectors gen` too (confidentiality §8.4,
§11.6):
- `groups`: a sealed video track's group whose object receipts are identity-bound: the
publisher's key, the receipts in the binary form a receipts track carries (`bundle`,
`WJR1`), and each object's Object ID, priority, Immutable Properties, sealed payload,
content hash (jwp-receipts §2.1's), identity and leaf; and copies of the bundle with one
defect each. Each has its `outcome`: `accept`, `identity_malformed` (a receipt whose
identity is another object's), `unverified` (a receipt that dropped its identity, and one
whose identity states another priority than the one signed: neither proof reaches the
root) or `malformed` (an identity length below 13);
- `arrivals`: those objects as a hop receives them, at `draft` 22, 18 or 16, the Immutable
Properties as that session carries them (`immutable_wire`), whether the hop re-encodes a
-16 session's pairs as -22 encodes them (`converted`, §11.6), the result (`immutable`),
and the outcome of checking the identity against the receipt it names: `pass`, or
`identity_mismatch` with the `part` that differs (`group_object`, `publisher_priority`,
`immutable_properties`). One takes -16's bytes for -22's and fails.
- `rfc9605.json`: RFC 9605 Appendix C, its header, AES-CTR with HMAC, and SFrame encryption
vectors.
- `secure-objects-01.json`: `draft-ietf-moq-secure-objects-01` Appendix B, its Full Track
Name, key derivation, nonce and AAD, and encryption vectors.
**Equivalence.** A conforming implementation gives every record, object and delivery its
outcome, every room its outcome, rosters and deliveries, and every key schedule entry and
derived value the bytes stated; it reproduces RFC 9605's vectors, and secure objects' exactly
outcome, every room its outcome, rosters and deliveries, every key schedule entry and
derived value the bytes stated, and every group and arrival of `identity.json` its outcome; it reproduces RFC 9605's vectors, and secure objects' exactly
in suites `0x0004` and `0x0005`, with the difference §11.2 states in `0x0001` and `0x0002`.
**The boundary.** Each epoch's application secret is given, standing for
@ -49,7 +66,8 @@ authored in code, from fixed seeds: every key is an Ed25519 key whose seed is SH
`wai:e2ee/corpus/secret`, a zero byte, the room's label, the generation and the epoch; every
payload SHA-256 in counter mode over `wai:e2ee/corpus/bytes` and a label; the room id SHA-256
of `wai:e2ee/corpus/room` and a label. AES-GCM and Ed25519 signing are deterministic, so the
file is the same bytes on every machine. No recorded data. `rfc9605.json` and
file is the same bytes on every machine. `identity.json` draws from the same seeds under the
labels `identity-publisher`, `identity` and `identity-room`. No recorded data. `rfc9605.json` and
`secure-objects-01.json` are the test vectors those documents print, copied with the
whitespace inside each value removed and each document's numbers as JSON numbers; nothing in
them is generated here.
@ -67,4 +85,5 @@ python3 -I live-conformance/e2ee_verify.py live-conformance/e2ee
```
`e2ee_verify.py` is the second implementation, written from the text with the standard
library alone; it writes AES and GCM itself, for these vectors only.
library alone; it writes AES and GCM itself, for these vectors only, and checks
`identity.json` from the binary form of the receipts up.

View file

@ -0,0 +1,377 @@
{
"arrivals": [
{
"converted": false,
"defect": "none",
"draft": 22,
"group": 5,
"immutable": "ba4ae4000000",
"immutable_wire": "ba4ae4000000",
"name": "as-sealed-0",
"object": 0,
"outcome": "pass",
"priority": 64,
"receipt": 0
},
{
"converted": false,
"defect": "none",
"draft": 22,
"group": 5,
"immutable": "ba4ae40000000201",
"immutable_wire": "ba4ae40000000201",
"name": "as-sealed-1",
"object": 1,
"outcome": "pass",
"priority": 64,
"receipt": 1
},
{
"converted": false,
"defect": "none",
"draft": 22,
"group": 5,
"immutable": "ba4ae4000000",
"immutable_wire": "ba4ae4000000",
"name": "as-sealed-2",
"object": 2,
"outcome": "pass",
"priority": 96,
"receipt": 2
},
{
"converted": false,
"defect": "none: -18 carries -22's integers and pairs",
"draft": 18,
"group": 5,
"immutable": "ba4ae40000000201",
"immutable_wire": "ba4ae40000000201",
"name": "draft18-as-sealed",
"object": 1,
"outcome": "pass",
"priority": 64,
"receipt": 1
},
{
"converted": true,
"defect": "none: -16's pairs re-encoded as -22 encodes them (§11.6)",
"draft": 16,
"group": 5,
"immutable": "ba4ae40000000201",
"immutable_wire": "7a4a840000000201",
"name": "draft16-converted",
"object": 1,
"outcome": "pass",
"priority": 64,
"receipt": 1
},
{
"converted": false,
"defect": "-16's bytes taken for -22's, not re-encoded",
"draft": 16,
"group": 5,
"immutable": "7a4a840000000201",
"immutable_wire": "7a4a840000000201",
"name": "draft16-read-as-is",
"object": 1,
"outcome": "identity_mismatch",
"part": "immutable_properties",
"priority": 64,
"receipt": 1
},
{
"converted": false,
"defect": "a hop changed the Publisher Priority",
"draft": 22,
"group": 5,
"immutable": "ba4ae4000000",
"immutable_wire": "ba4ae4000000",
"name": "priority-changed",
"object": 0,
"outcome": "identity_mismatch",
"part": "publisher_priority",
"priority": 65,
"receipt": 0
},
{
"converted": false,
"defect": "a hop changed the Key ID",
"draft": 22,
"group": 5,
"immutable": "ba4ae4000001",
"immutable_wire": "ba4ae4000001",
"name": "key-id-changed",
"object": 0,
"outcome": "identity_mismatch",
"part": "immutable_properties",
"priority": 64,
"receipt": 0
},
{
"converted": false,
"defect": "a hop added a pair inside Immutable Properties",
"draft": 22,
"group": 5,
"immutable": "ba4ae40000000409",
"immutable_wire": "ba4ae40000000409",
"name": "pair-added",
"object": 0,
"outcome": "identity_mismatch",
"part": "immutable_properties",
"priority": 64,
"receipt": 0
},
{
"converted": false,
"defect": "a hop dropped a pair from Immutable Properties",
"draft": 22,
"group": 5,
"immutable": "ba4ae4000000",
"immutable_wire": "ba4ae4000000",
"name": "pair-dropped",
"object": 1,
"outcome": "identity_mismatch",
"part": "immutable_properties",
"priority": 64,
"receipt": 1
},
{
"converted": false,
"defect": "a hop stripped Immutable Properties",
"draft": 22,
"group": 5,
"immutable": null,
"immutable_wire": null,
"name": "immutable-stripped",
"object": 0,
"outcome": "identity_mismatch",
"part": "immutable_properties",
"priority": 64,
"receipt": 0
},
{
"converted": false,
"defect": "object 1 delivered at object 2's place",
"draft": 22,
"group": 5,
"immutable": "ba4ae40000000201",
"immutable_wire": "ba4ae40000000201",
"name": "object-moved",
"object": 2,
"outcome": "identity_mismatch",
"part": "group_object",
"priority": 64,
"receipt": 1
},
{
"converted": false,
"defect": "the object delivered in another group",
"draft": 22,
"group": 6,
"immutable": "ba4ae4000000",
"immutable_wire": "ba4ae4000000",
"name": "group-moved",
"object": 0,
"outcome": "identity_mismatch",
"part": "group_object",
"priority": 64,
"receipt": 0
}
],
"corpus": "WAI live-conformance/e2ee, identity-bound receipts",
"generator": "joule_moq_e2ee_vectors gen (joule-code/joule-code-rs/crates/joule-moq-mls)",
"groups": [
{
"bundle": "574a52316d6f712d6a77703a67726f75702d7369676e020000000000000007000000000000000594f6adb18b18ecc92cb48996bdcddbb3a54a1d7e73dd40262e810a56c7b3ac830000000000000003000000000000083400000014323032362d31302d32325430303a30303a30305a00000014323032362d31302d32325430303a30303a30315a35de4b8f339f19a519b692b2bae2b6d206d1b6e3287c0334e86621fcc74f624f000000126964656e746974792d7075626c69736865720201000000000000000000000000000000000000000000000000000000000000000000e91ec09fa45c6be58443be092ea856f14c9428a4e474774230078397835b7d97dff8052ca83659107d7cc835362b2903a545a6f10d164f1cb2d92b326b32d803000000030000000000000000ec72d30c0799f32541e82c9ed7cbad50b26d4a88fb20032dc58b4e3269201fc700000000000004b0000000126964656e746974792d7075626c697368657202745e02d138955f65c973ab578f240a000d8098b0721f6dc7d13fb216439ba7b1843ca35ece1aec684b816172c4b31f79c7ea5ebf297c937424ffc6125d6bc8a30000000000000000000000000000000003020000001300000000000000050000000040ba4ae40000000000000000000001b2bffacaf4b5d9f112e18c4dcb859f02ae5350de4473ad0bcb5187b3e89cefd70000000000000384000000126964656e746974792d7075626c69736865720281c04336818b5d8881c7d65f3746e4215b550320397eb935f03d7a857ceb8e67843ca35ece1aec684b816172c4b31f79c7ea5ebf297c937424ffc6125d6bc8a30000000000000001000000000000000103020000001500000000000000050000000140ba4ae400000002010000000000000002e236d68d8f97796924d2bac46fd3068004d31d08adc9355ee2300f1b3475530c0000000000000000000000126964656e746974792d7075626c6973686572023af826fe593942208e71436dda6695a58d4f2a936c748bdf968ef9f8e01800c20dc666dfd793b6bbd0e5e2674a718473e3fe1639ac3a443e7b0cc7cadb4292380000000000000002000000000000000203000000001300000000000000050000000260ba4ae4000000",
"defect": "none",
"group": 5,
"name": "sealed-video",
"objects": [
{
"content_hash": "ec72d30c0799f32541e82c9ed7cbad50b26d4a88fb20032dc58b4e3269201fc7",
"identity": "00000000000000050000000040ba4ae4000000",
"immutable": "ba4ae4000000",
"leaf": "81c04336818b5d8881c7d65f3746e4215b550320397eb935f03d7a857ceb8e67",
"object": 0,
"payload": "603085374ddb9ad7195ebc2b5050e900ee29b4e7eab5ebb971f0729f229ca29ab1844dfa1bf9fd2186b1e6bcdaaf354c3709d332fdcdcf606a7e45887be459a1a45f27754373bf1af47cba621d53146d8c7a9abe80588093db0ff8a48f5932842f474bcb3983b1834f0841dd876e85f746f96fe079e218378d0c5f5fb5f0a897326b3ca506e2ff7c01049c4fa6fa123d351dbb7831ce0c023950201a796c859205988d939818ef19cef60198cd3bab6db99094cad742b1d9cc2e7abd6488579789cebde9f2f91005545f220531be1ad3ca410ca68301521e08522b8c645723cdd951cd108284a495746672dcfa8740ccdfb2acd1df1f4839b9d6e260bfdcdb1ad91fe152d8bb66ca63a52710470d903a27f0fa961cc51c243b0a8588e89bbcccdc4a8a47997698ea",
"priority": 64
},
{
"content_hash": "b2bffacaf4b5d9f112e18c4dcb859f02ae5350de4473ad0bcb5187b3e89cefd7",
"identity": "00000000000000050000000140ba4ae40000000201",
"immutable": "ba4ae40000000201",
"leaf": "745e02d138955f65c973ab578f240a000d8098b0721f6dc7d13fb216439ba7b1",
"object": 1,
"payload": "07edb9ad1ad60ad340639038c8b74f1e0e494d41e7aca07e57f950675018dfb08915c1ae47ed718ab8ebd01ed468dc648e53fcb93341078166098d7e699ab6c5bd761fd2ecd3351ae70274be1cee9676fd071e0d7783b6097f862693a8bf45691793e138e674122eefcecf6e2a1bccea957b0cc9383e4ebff358134fa06f5be2a613e7925fe206a1dad345502c5be564e0a71d730d03020d9cd24b0d84ee46d7137cead141141eab3cca440f5556fac71b2e14d7647b30fcb42ae2fea8601b0fda07b61e159aed981e203feb24222e582708c8e8de8762678a0815c8c8795817f090eb9b8d1622071827c0cb28ba3239725ce0e6e59ce85af6c1e21e33e8ad4d41ed236579c890b4d926bee9d92678023e5dd49e009c85d6187b5b319aad2c8e89f5894569d09a44",
"priority": 64
},
{
"content_hash": "e236d68d8f97796924d2bac46fd3068004d31d08adc9355ee2300f1b3475530c",
"identity": "00000000000000050000000260ba4ae4000000",
"immutable": "ba4ae4000000",
"leaf": "3af826fe593942208e71436dda6695a58d4f2a936c748bdf968ef9f8e01800c2",
"object": 2,
"payload": "0d3103a3fcb8efbdb2e4cb286349b403d9aa3d3019b75a327b48e315583fe6838cc04b0dbf45c6a779cdcfc9b3f8e2feef47a94afe6f348ce02b70e05088095b9ac44c07f435622e437f816f3c232cfd12ffe0f6bd3de95065c63b1ac08a2dc2b5453f806d152aff1bfbf4ff685b050812049857d366cb4ca8d24095589890d15a0b1f4009237f00aff330f7255cd7b2cfe86a064cb04c4770a2c1416fe42e0dfd6723085bc1384fa5e50ff718e704a7677ada51d6339910a34e342317e0039af8ea678827056d6623883c566cd45639e02cc6b4b70a25b87e20cc34a16394da8429ac346955897a0d51bb3b9dfd52576bbab3a859e921527894d43786f06399346325f569dae28f341249198229452cb981347e014bb225e43389b6b18ec9032d2d4247b95cb834",
"priority": 96
}
],
"outcome": "accept",
"publisher": "35de4b8f339f19a519b692b2bae2b6d206d1b6e3287c0334e86621fcc74f624f",
"receipt_alias": 7
},
{
"bundle": "574a52316d6f712d6a77703a67726f75702d7369676e020000000000000007000000000000000594f6adb18b18ecc92cb48996bdcddbb3a54a1d7e73dd40262e810a56c7b3ac830000000000000003000000000000083400000014323032362d31302d32325430303a30303a30305a00000014323032362d31302d32325430303a30303a30315a35de4b8f339f19a519b692b2bae2b6d206d1b6e3287c0334e86621fcc74f624f000000126964656e746974792d7075626c69736865720201000000000000000000000000000000000000000000000000000000000000000000e91ec09fa45c6be58443be092ea856f14c9428a4e474774230078397835b7d97dff8052ca83659107d7cc835362b2903a545a6f10d164f1cb2d92b326b32d803000000030000000000000000ec72d30c0799f32541e82c9ed7cbad50b26d4a88fb20032dc58b4e3269201fc700000000000004b0000000126964656e746974792d7075626c697368657202745e02d138955f65c973ab578f240a000d8098b0721f6dc7d13fb216439ba7b1843ca35ece1aec684b816172c4b31f79c7ea5ebf297c937424ffc6125d6bc8a30000000000000000000000000000000003020000001300000000000000050000000040ba4ae40000000000000000000001b2bffacaf4b5d9f112e18c4dcb859f02ae5350de4473ad0bcb5187b3e89cefd70000000000000384000000126964656e746974792d7075626c69736865720281c04336818b5d8881c7d65f3746e4215b550320397eb935f03d7a857ceb8e67843ca35ece1aec684b816172c4b31f79c7ea5ebf297c937424ffc6125d6bc8a30000000000000001000000000000000103020000001300000000000000050000000260ba4ae40000000000000000000002e236d68d8f97796924d2bac46fd3068004d31d08adc9355ee2300f1b3475530c0000000000000000000000126964656e746974792d7075626c6973686572023af826fe593942208e71436dda6695a58d4f2a936c748bdf968ef9f8e01800c20dc666dfd793b6bbd0e5e2674a718473e3fe1639ac3a443e7b0cc7cadb4292380000000000000002000000000000000203000000001300000000000000050000000260ba4ae4000000",
"defect": "object 1's receipt carries object 2's identity",
"group": 5,
"name": "identity-of-another-object",
"objects": [
{
"content_hash": "ec72d30c0799f32541e82c9ed7cbad50b26d4a88fb20032dc58b4e3269201fc7",
"identity": "00000000000000050000000040ba4ae4000000",
"immutable": "ba4ae4000000",
"leaf": "81c04336818b5d8881c7d65f3746e4215b550320397eb935f03d7a857ceb8e67",
"object": 0,
"payload": "603085374ddb9ad7195ebc2b5050e900ee29b4e7eab5ebb971f0729f229ca29ab1844dfa1bf9fd2186b1e6bcdaaf354c3709d332fdcdcf606a7e45887be459a1a45f27754373bf1af47cba621d53146d8c7a9abe80588093db0ff8a48f5932842f474bcb3983b1834f0841dd876e85f746f96fe079e218378d0c5f5fb5f0a897326b3ca506e2ff7c01049c4fa6fa123d351dbb7831ce0c023950201a796c859205988d939818ef19cef60198cd3bab6db99094cad742b1d9cc2e7abd6488579789cebde9f2f91005545f220531be1ad3ca410ca68301521e08522b8c645723cdd951cd108284a495746672dcfa8740ccdfb2acd1df1f4839b9d6e260bfdcdb1ad91fe152d8bb66ca63a52710470d903a27f0fa961cc51c243b0a8588e89bbcccdc4a8a47997698ea",
"priority": 64
},
{
"content_hash": "b2bffacaf4b5d9f112e18c4dcb859f02ae5350de4473ad0bcb5187b3e89cefd7",
"identity": "00000000000000050000000140ba4ae40000000201",
"immutable": "ba4ae40000000201",
"leaf": "745e02d138955f65c973ab578f240a000d8098b0721f6dc7d13fb216439ba7b1",
"object": 1,
"payload": "07edb9ad1ad60ad340639038c8b74f1e0e494d41e7aca07e57f950675018dfb08915c1ae47ed718ab8ebd01ed468dc648e53fcb93341078166098d7e699ab6c5bd761fd2ecd3351ae70274be1cee9676fd071e0d7783b6097f862693a8bf45691793e138e674122eefcecf6e2a1bccea957b0cc9383e4ebff358134fa06f5be2a613e7925fe206a1dad345502c5be564e0a71d730d03020d9cd24b0d84ee46d7137cead141141eab3cca440f5556fac71b2e14d7647b30fcb42ae2fea8601b0fda07b61e159aed981e203feb24222e582708c8e8de8762678a0815c8c8795817f090eb9b8d1622071827c0cb28ba3239725ce0e6e59ce85af6c1e21e33e8ad4d41ed236579c890b4d926bee9d92678023e5dd49e009c85d6187b5b319aad2c8e89f5894569d09a44",
"priority": 64
},
{
"content_hash": "e236d68d8f97796924d2bac46fd3068004d31d08adc9355ee2300f1b3475530c",
"identity": "00000000000000050000000260ba4ae4000000",
"immutable": "ba4ae4000000",
"leaf": "3af826fe593942208e71436dda6695a58d4f2a936c748bdf968ef9f8e01800c2",
"object": 2,
"payload": "0d3103a3fcb8efbdb2e4cb286349b403d9aa3d3019b75a327b48e315583fe6838cc04b0dbf45c6a779cdcfc9b3f8e2feef47a94afe6f348ce02b70e05088095b9ac44c07f435622e437f816f3c232cfd12ffe0f6bd3de95065c63b1ac08a2dc2b5453f806d152aff1bfbf4ff685b050812049857d366cb4ca8d24095589890d15a0b1f4009237f00aff330f7255cd7b2cfe86a064cb04c4770a2c1416fe42e0dfd6723085bc1384fa5e50ff718e704a7677ada51d6339910a34e342317e0039af8ea678827056d6623883c566cd45639e02cc6b4b70a25b87e20cc34a16394da8429ac346955897a0d51bb3b9dfd52576bbab3a859e921527894d43786f06399346325f569dae28f341249198229452cb981347e014bb225e43389b6b18ec9032d2d4247b95cb834",
"priority": 96
}
],
"outcome": "identity_malformed",
"publisher": "35de4b8f339f19a519b692b2bae2b6d206d1b6e3287c0334e86621fcc74f624f",
"receipt_alias": 7
},
{
"bundle": "574a52316d6f712d6a77703a67726f75702d7369676e020000000000000007000000000000000594f6adb18b18ecc92cb48996bdcddbb3a54a1d7e73dd40262e810a56c7b3ac830000000000000003000000000000083400000014323032362d31302d32325430303a30303a30305a00000014323032362d31302d32325430303a30303a30315a35de4b8f339f19a519b692b2bae2b6d206d1b6e3287c0334e86621fcc74f624f000000126964656e746974792d7075626c69736865720201000000000000000000000000000000000000000000000000000000000000000000e91ec09fa45c6be58443be092ea856f14c9428a4e474774230078397835b7d97dff8052ca83659107d7cc835362b2903a545a6f10d164f1cb2d92b326b32d803000000030000000000000000ec72d30c0799f32541e82c9ed7cbad50b26d4a88fb20032dc58b4e3269201fc700000000000004b0000000126964656e746974792d7075626c697368657202745e02d138955f65c973ab578f240a000d8098b0721f6dc7d13fb216439ba7b1843ca35ece1aec684b816172c4b31f79c7ea5ebf297c937424ffc6125d6bc8a30000000000000000000000000000000002020000000000000001b2bffacaf4b5d9f112e18c4dcb859f02ae5350de4473ad0bcb5187b3e89cefd70000000000000384000000126964656e746974792d7075626c69736865720281c04336818b5d8881c7d65f3746e4215b550320397eb935f03d7a857ceb8e67843ca35ece1aec684b816172c4b31f79c7ea5ebf297c937424ffc6125d6bc8a30000000000000001000000000000000103020000001500000000000000050000000140ba4ae400000002010000000000000002e236d68d8f97796924d2bac46fd3068004d31d08adc9355ee2300f1b3475530c0000000000000000000000126964656e746974792d7075626c6973686572023af826fe593942208e71436dda6695a58d4f2a936c748bdf968ef9f8e01800c20dc666dfd793b6bbd0e5e2674a718473e3fe1639ac3a443e7b0cc7cadb4292380000000000000002000000000000000203000000001300000000000000050000000260ba4ae4000000",
"defect": "object 0's receipt dropped its identity, its leaf_version 2",
"group": 5,
"name": "identity-dropped",
"objects": [
{
"content_hash": "ec72d30c0799f32541e82c9ed7cbad50b26d4a88fb20032dc58b4e3269201fc7",
"identity": "00000000000000050000000040ba4ae4000000",
"immutable": "ba4ae4000000",
"leaf": "81c04336818b5d8881c7d65f3746e4215b550320397eb935f03d7a857ceb8e67",
"object": 0,
"payload": "603085374ddb9ad7195ebc2b5050e900ee29b4e7eab5ebb971f0729f229ca29ab1844dfa1bf9fd2186b1e6bcdaaf354c3709d332fdcdcf606a7e45887be459a1a45f27754373bf1af47cba621d53146d8c7a9abe80588093db0ff8a48f5932842f474bcb3983b1834f0841dd876e85f746f96fe079e218378d0c5f5fb5f0a897326b3ca506e2ff7c01049c4fa6fa123d351dbb7831ce0c023950201a796c859205988d939818ef19cef60198cd3bab6db99094cad742b1d9cc2e7abd6488579789cebde9f2f91005545f220531be1ad3ca410ca68301521e08522b8c645723cdd951cd108284a495746672dcfa8740ccdfb2acd1df1f4839b9d6e260bfdcdb1ad91fe152d8bb66ca63a52710470d903a27f0fa961cc51c243b0a8588e89bbcccdc4a8a47997698ea",
"priority": 64
},
{
"content_hash": "b2bffacaf4b5d9f112e18c4dcb859f02ae5350de4473ad0bcb5187b3e89cefd7",
"identity": "00000000000000050000000140ba4ae40000000201",
"immutable": "ba4ae40000000201",
"leaf": "745e02d138955f65c973ab578f240a000d8098b0721f6dc7d13fb216439ba7b1",
"object": 1,
"payload": "07edb9ad1ad60ad340639038c8b74f1e0e494d41e7aca07e57f950675018dfb08915c1ae47ed718ab8ebd01ed468dc648e53fcb93341078166098d7e699ab6c5bd761fd2ecd3351ae70274be1cee9676fd071e0d7783b6097f862693a8bf45691793e138e674122eefcecf6e2a1bccea957b0cc9383e4ebff358134fa06f5be2a613e7925fe206a1dad345502c5be564e0a71d730d03020d9cd24b0d84ee46d7137cead141141eab3cca440f5556fac71b2e14d7647b30fcb42ae2fea8601b0fda07b61e159aed981e203feb24222e582708c8e8de8762678a0815c8c8795817f090eb9b8d1622071827c0cb28ba3239725ce0e6e59ce85af6c1e21e33e8ad4d41ed236579c890b4d926bee9d92678023e5dd49e009c85d6187b5b319aad2c8e89f5894569d09a44",
"priority": 64
},
{
"content_hash": "e236d68d8f97796924d2bac46fd3068004d31d08adc9355ee2300f1b3475530c",
"identity": "00000000000000050000000260ba4ae4000000",
"immutable": "ba4ae4000000",
"leaf": "3af826fe593942208e71436dda6695a58d4f2a936c748bdf968ef9f8e01800c2",
"object": 2,
"payload": "0d3103a3fcb8efbdb2e4cb286349b403d9aa3d3019b75a327b48e315583fe6838cc04b0dbf45c6a779cdcfc9b3f8e2feef47a94afe6f348ce02b70e05088095b9ac44c07f435622e437f816f3c232cfd12ffe0f6bd3de95065c63b1ac08a2dc2b5453f806d152aff1bfbf4ff685b050812049857d366cb4ca8d24095589890d15a0b1f4009237f00aff330f7255cd7b2cfe86a064cb04c4770a2c1416fe42e0dfd6723085bc1384fa5e50ff718e704a7677ada51d6339910a34e342317e0039af8ea678827056d6623883c566cd45639e02cc6b4b70a25b87e20cc34a16394da8429ac346955897a0d51bb3b9dfd52576bbab3a859e921527894d43786f06399346325f569dae28f341249198229452cb981347e014bb225e43389b6b18ec9032d2d4247b95cb834",
"priority": 96
}
],
"outcome": "unverified",
"publisher": "35de4b8f339f19a519b692b2bae2b6d206d1b6e3287c0334e86621fcc74f624f",
"receipt_alias": 7
},
{
"bundle": "574a52316d6f712d6a77703a67726f75702d7369676e020000000000000007000000000000000594f6adb18b18ecc92cb48996bdcddbb3a54a1d7e73dd40262e810a56c7b3ac830000000000000003000000000000083400000014323032362d31302d32325430303a30303a30305a00000014323032362d31302d32325430303a30303a30315a35de4b8f339f19a519b692b2bae2b6d206d1b6e3287c0334e86621fcc74f624f000000126964656e746974792d7075626c69736865720201000000000000000000000000000000000000000000000000000000000000000000e91ec09fa45c6be58443be092ea856f14c9428a4e474774230078397835b7d97dff8052ca83659107d7cc835362b2903a545a6f10d164f1cb2d92b326b32d803000000030000000000000000ec72d30c0799f32541e82c9ed7cbad50b26d4a88fb20032dc58b4e3269201fc700000000000004b0000000126964656e746974792d7075626c697368657202745e02d138955f65c973ab578f240a000d8098b0721f6dc7d13fb216439ba7b1843ca35ece1aec684b816172c4b31f79c7ea5ebf297c937424ffc6125d6bc8a30000000000000000000000000000000003020000001300000000000000050000000041ba4ae40000000000000000000001b2bffacaf4b5d9f112e18c4dcb859f02ae5350de4473ad0bcb5187b3e89cefd70000000000000384000000126964656e746974792d7075626c69736865720281c04336818b5d8881c7d65f3746e4215b550320397eb935f03d7a857ceb8e67843ca35ece1aec684b816172c4b31f79c7ea5ebf297c937424ffc6125d6bc8a30000000000000001000000000000000103020000001500000000000000050000000140ba4ae400000002010000000000000002e236d68d8f97796924d2bac46fd3068004d31d08adc9355ee2300f1b3475530c0000000000000000000000126964656e746974792d7075626c6973686572023af826fe593942208e71436dda6695a58d4f2a936c748bdf968ef9f8e01800c20dc666dfd793b6bbd0e5e2674a718473e3fe1639ac3a443e7b0cc7cadb4292380000000000000002000000000000000203000000001300000000000000050000000260ba4ae4000000",
"defect": "object 0's identity states another Publisher Priority than the one signed",
"group": 5,
"name": "identity-priority-restated",
"objects": [
{
"content_hash": "ec72d30c0799f32541e82c9ed7cbad50b26d4a88fb20032dc58b4e3269201fc7",
"identity": "00000000000000050000000040ba4ae4000000",
"immutable": "ba4ae4000000",
"leaf": "81c04336818b5d8881c7d65f3746e4215b550320397eb935f03d7a857ceb8e67",
"object": 0,
"payload": "603085374ddb9ad7195ebc2b5050e900ee29b4e7eab5ebb971f0729f229ca29ab1844dfa1bf9fd2186b1e6bcdaaf354c3709d332fdcdcf606a7e45887be459a1a45f27754373bf1af47cba621d53146d8c7a9abe80588093db0ff8a48f5932842f474bcb3983b1834f0841dd876e85f746f96fe079e218378d0c5f5fb5f0a897326b3ca506e2ff7c01049c4fa6fa123d351dbb7831ce0c023950201a796c859205988d939818ef19cef60198cd3bab6db99094cad742b1d9cc2e7abd6488579789cebde9f2f91005545f220531be1ad3ca410ca68301521e08522b8c645723cdd951cd108284a495746672dcfa8740ccdfb2acd1df1f4839b9d6e260bfdcdb1ad91fe152d8bb66ca63a52710470d903a27f0fa961cc51c243b0a8588e89bbcccdc4a8a47997698ea",
"priority": 64
},
{
"content_hash": "b2bffacaf4b5d9f112e18c4dcb859f02ae5350de4473ad0bcb5187b3e89cefd7",
"identity": "00000000000000050000000140ba4ae40000000201",
"immutable": "ba4ae40000000201",
"leaf": "745e02d138955f65c973ab578f240a000d8098b0721f6dc7d13fb216439ba7b1",
"object": 1,
"payload": "07edb9ad1ad60ad340639038c8b74f1e0e494d41e7aca07e57f950675018dfb08915c1ae47ed718ab8ebd01ed468dc648e53fcb93341078166098d7e699ab6c5bd761fd2ecd3351ae70274be1cee9676fd071e0d7783b6097f862693a8bf45691793e138e674122eefcecf6e2a1bccea957b0cc9383e4ebff358134fa06f5be2a613e7925fe206a1dad345502c5be564e0a71d730d03020d9cd24b0d84ee46d7137cead141141eab3cca440f5556fac71b2e14d7647b30fcb42ae2fea8601b0fda07b61e159aed981e203feb24222e582708c8e8de8762678a0815c8c8795817f090eb9b8d1622071827c0cb28ba3239725ce0e6e59ce85af6c1e21e33e8ad4d41ed236579c890b4d926bee9d92678023e5dd49e009c85d6187b5b319aad2c8e89f5894569d09a44",
"priority": 64
},
{
"content_hash": "e236d68d8f97796924d2bac46fd3068004d31d08adc9355ee2300f1b3475530c",
"identity": "00000000000000050000000260ba4ae4000000",
"immutable": "ba4ae4000000",
"leaf": "3af826fe593942208e71436dda6695a58d4f2a936c748bdf968ef9f8e01800c2",
"object": 2,
"payload": "0d3103a3fcb8efbdb2e4cb286349b403d9aa3d3019b75a327b48e315583fe6838cc04b0dbf45c6a779cdcfc9b3f8e2feef47a94afe6f348ce02b70e05088095b9ac44c07f435622e437f816f3c232cfd12ffe0f6bd3de95065c63b1ac08a2dc2b5453f806d152aff1bfbf4ff685b050812049857d366cb4ca8d24095589890d15a0b1f4009237f00aff330f7255cd7b2cfe86a064cb04c4770a2c1416fe42e0dfd6723085bc1384fa5e50ff718e704a7677ada51d6339910a34e342317e0039af8ea678827056d6623883c566cd45639e02cc6b4b70a25b87e20cc34a16394da8429ac346955897a0d51bb3b9dfd52576bbab3a859e921527894d43786f06399346325f569dae28f341249198229452cb981347e014bb225e43389b6b18ec9032d2d4247b95cb834",
"priority": 96
}
],
"outcome": "unverified",
"publisher": "35de4b8f339f19a519b692b2bae2b6d206d1b6e3287c0334e86621fcc74f624f",
"receipt_alias": 7
},
{
"bundle": "574a52316d6f712d6a77703a67726f75702d7369676e020000000000000007000000000000000594f6adb18b18ecc92cb48996bdcddbb3a54a1d7e73dd40262e810a56c7b3ac830000000000000003000000000000083400000014323032362d31302d32325430303a30303a30305a00000014323032362d31302d32325430303a30303a30315a35de4b8f339f19a519b692b2bae2b6d206d1b6e3287c0334e86621fcc74f624f000000126964656e746974792d7075626c69736865720201000000000000000000000000000000000000000000000000000000000000000000e91ec09fa45c6be58443be092ea856f14c9428a4e474774230078397835b7d97dff8052ca83659107d7cc835362b2903a545a6f10d164f1cb2d92b326b32d803000000030000000000000000ec72d30c0799f32541e82c9ed7cbad50b26d4a88fb20032dc58b4e3269201fc700000000000004b0000000126964656e746974792d7075626c697368657202745e02d138955f65c973ab578f240a000d8098b0721f6dc7d13fb216439ba7b1843ca35ece1aec684b816172c4b31f79c7ea5ebf297c937424ffc6125d6bc8a30000000000000000000000000000000003020000000c00000000000000050000000040ba4ae40000000000000000000001b2bffacaf4b5d9f112e18c4dcb859f02ae5350de4473ad0bcb5187b3e89cefd70000000000000384000000126964656e746974792d7075626c69736865720281c04336818b5d8881c7d65f3746e4215b550320397eb935f03d7a857ceb8e67843ca35ece1aec684b816172c4b31f79c7ea5ebf297c937424ffc6125d6bc8a30000000000000001000000000000000103020000001500000000000000050000000140ba4ae400000002010000000000000002e236d68d8f97796924d2bac46fd3068004d31d08adc9355ee2300f1b3475530c0000000000000000000000126964656e746974792d7075626c6973686572023af826fe593942208e71436dda6695a58d4f2a936c748bdf968ef9f8e01800c20dc666dfd793b6bbd0e5e2674a718473e3fe1639ac3a443e7b0cc7cadb4292380000000000000002000000000000000203000000001300000000000000050000000260ba4ae4000000",
"defect": "object 0's identity length field states 12, below the fixed 13",
"group": 5,
"name": "identity-length-12",
"objects": [
{
"content_hash": "ec72d30c0799f32541e82c9ed7cbad50b26d4a88fb20032dc58b4e3269201fc7",
"identity": "00000000000000050000000040ba4ae4000000",
"immutable": "ba4ae4000000",
"leaf": "81c04336818b5d8881c7d65f3746e4215b550320397eb935f03d7a857ceb8e67",
"object": 0,
"payload": "603085374ddb9ad7195ebc2b5050e900ee29b4e7eab5ebb971f0729f229ca29ab1844dfa1bf9fd2186b1e6bcdaaf354c3709d332fdcdcf606a7e45887be459a1a45f27754373bf1af47cba621d53146d8c7a9abe80588093db0ff8a48f5932842f474bcb3983b1834f0841dd876e85f746f96fe079e218378d0c5f5fb5f0a897326b3ca506e2ff7c01049c4fa6fa123d351dbb7831ce0c023950201a796c859205988d939818ef19cef60198cd3bab6db99094cad742b1d9cc2e7abd6488579789cebde9f2f91005545f220531be1ad3ca410ca68301521e08522b8c645723cdd951cd108284a495746672dcfa8740ccdfb2acd1df1f4839b9d6e260bfdcdb1ad91fe152d8bb66ca63a52710470d903a27f0fa961cc51c243b0a8588e89bbcccdc4a8a47997698ea",
"priority": 64
},
{
"content_hash": "b2bffacaf4b5d9f112e18c4dcb859f02ae5350de4473ad0bcb5187b3e89cefd7",
"identity": "00000000000000050000000140ba4ae40000000201",
"immutable": "ba4ae40000000201",
"leaf": "745e02d138955f65c973ab578f240a000d8098b0721f6dc7d13fb216439ba7b1",
"object": 1,
"payload": "07edb9ad1ad60ad340639038c8b74f1e0e494d41e7aca07e57f950675018dfb08915c1ae47ed718ab8ebd01ed468dc648e53fcb93341078166098d7e699ab6c5bd761fd2ecd3351ae70274be1cee9676fd071e0d7783b6097f862693a8bf45691793e138e674122eefcecf6e2a1bccea957b0cc9383e4ebff358134fa06f5be2a613e7925fe206a1dad345502c5be564e0a71d730d03020d9cd24b0d84ee46d7137cead141141eab3cca440f5556fac71b2e14d7647b30fcb42ae2fea8601b0fda07b61e159aed981e203feb24222e582708c8e8de8762678a0815c8c8795817f090eb9b8d1622071827c0cb28ba3239725ce0e6e59ce85af6c1e21e33e8ad4d41ed236579c890b4d926bee9d92678023e5dd49e009c85d6187b5b319aad2c8e89f5894569d09a44",
"priority": 64
},
{
"content_hash": "e236d68d8f97796924d2bac46fd3068004d31d08adc9355ee2300f1b3475530c",
"identity": "00000000000000050000000260ba4ae4000000",
"immutable": "ba4ae4000000",
"leaf": "3af826fe593942208e71436dda6695a58d4f2a936c748bdf968ef9f8e01800c2",
"object": 2,
"payload": "0d3103a3fcb8efbdb2e4cb286349b403d9aa3d3019b75a327b48e315583fe6838cc04b0dbf45c6a779cdcfc9b3f8e2feef47a94afe6f348ce02b70e05088095b9ac44c07f435622e437f816f3c232cfd12ffe0f6bd3de95065c63b1ac08a2dc2b5453f806d152aff1bfbf4ff685b050812049857d366cb4ca8d24095589890d15a0b1f4009237f00aff330f7255cd7b2cfe86a064cb04c4770a2c1416fe42e0dfd6723085bc1384fa5e50ff718e704a7677ada51d6339910a34e342317e0039af8ea678827056d6623883c566cd45639e02cc6b4b70a25b87e20cc34a16394da8429ac346955897a0d51bb3b9dfd52576bbab3a859e921527894d43786f06399346325f569dae28f341249198229452cb981347e014bb225e43389b6b18ec9032d2d4247b95cb834",
"priority": 96
}
],
"outcome": "malformed",
"publisher": "35de4b8f339f19a519b692b2bae2b6d206d1b6e3287c0334e86621fcc74f624f",
"receipt_alias": 7
}
],
"seeds": "the corpus's: the publisher's key wai:e2ee/corpus/key identity-publisher, the epoch's secret wai:e2ee/corpus/secret identity, the room id and payloads wai:e2ee/corpus/bytes",
"spec": "wai/extensions/confidentiality.md §8.4, §11.6"
}

View file

@ -25,7 +25,14 @@ What it checks:
committer, what a commit holds, each epoch's roster) and every delivery of every sealed
object opened or refused (§6);
- that the corpus raises every reason of §13, and that §13 lists exactly the codes the
corpus lists.
corpus lists;
- every vector of identity.json (§8.4, §11.6): each group's receipts read in the binary
form of jwp-receipts §2.7 with the identity-bound leaf (leaf_version 3), the group's
signature checked under the publisher's key, each object's leaf rebuilt from its sealed
payload's content hash and its identity and walked to the signed root; and each object
as a hop receives it, at -22, -18 or -16 (its pairs re-encoded as -22 encodes them where
the vector says so), its identity formed and checked against its receipt, the part that
differs named.
The boundary: it does not implement MLS. Each epoch's application secret is taken from the
corpus as given (§14), and everything after it is checked.
@ -334,6 +341,34 @@ def pairs_write(ps):
return out
def qv_read(b, i):
"""A QUIC variable-length integer (RFC 9000 §16), -16's integer."""
if i >= len(b):
raise ValueError("truncated")
n = 1 << (b[i] >> 6)
if i + n > len(b):
raise ValueError("truncated")
return int.from_bytes(bytes([b[i] & 0x3F]) + b[i + 1:i + n], "big"), i + n
def pairs16_read(b):
"""-16's pairs: the pairs of MOQT §8.3 over QUIC's integer."""
out, i, prev = [], 0, 0
while i < len(b):
d, i = qv_read(b, i)
ty = prev + d
prev = ty
if ty % 2 == 0:
v, i = qv_read(b, i)
else:
n, i = qv_read(b, i)
if n > 0xFFFF or i + n > len(b):
raise ValueError("value")
v, i = bytes(b[i:i + n]), i + n
out.append((ty, v))
return out
# ===================================================================== secure objects
KEY_ID = 0x3A4A
@ -960,6 +995,220 @@ def check(doc, quiet=False):
return fails
# ================================================ identity-bound receipts (§8.4, §11.6)
OBJECT_DOMAIN = b"moq-jwp:object\x01"
LEAF = {1: b"moq-jwp:merkle-leaf\x01", 2: b"moq-jwp:merkle-leaf\x02", 3: b"moq-jwp:merkle-leaf\x03"}
NODE = b"moq-jwp:merkle-node\x01"
GROUP_SIGN = (b"moq-jwp:group-sign\x01", b"moq-jwp:group-sign\x02")
IDENTITY_FIXED = 13
IDENTITY_MAX = 13 + 0xFFFF
class Malformed(Exception):
pass
class Cur:
"""jwp-receipts §2.7's fields: big-endian integers, u32-prefixed strings."""
def __init__(self, b):
self.b, self.i = b, 0
def take(self, n):
if n < 0 or self.i + n > len(self.b):
raise Malformed("truncated")
s = self.b[self.i:self.i + n]
self.i += n
return s
def u(self, n):
return int.from_bytes(self.take(n), "big")
def string(self):
return self.take(self.u(4))
def klass(self):
"""energy-measurement §2.7: a tag and its evidence."""
start = self.i
tag = self.u(1)
if tag == 4:
self.take(12)
if self.u(1) > 2:
raise Malformed("filtering state")
elif tag == 5:
self.take(12)
self.string()
elif tag > 5:
raise Malformed("class tag")
return self.b[start:self.i]
def read_bundle(b):
"""WJR1: the group receipt (§2.7.1), the count, each object receipt's body (§2.7.2)."""
c = Cur(b)
if c.take(4) != b"WJR1":
raise Malformed("magic")
start = c.i
domain = c.take(19)
if domain not in GROUP_SIGN:
raise Malformed("signing domain")
labelled = domain == GROUP_SIGN[1]
g = {"track_alias": c.u(8), "group_id": c.u(8), "root": c.take(32), "leaf_count": c.u(8), "total": c.u(8)}
c.string()
c.string()
g["key"] = c.take(32)
c.string()
if labelled:
c.klass()
if c.u(1) > 1:
raise Malformed("coverage")
c.take(33)
g["payload"] = b[start:c.i]
g["sig"] = c.take(64)
if not labelled:
c.take(33)
objects = []
for _ in range(c.u(4)):
o = {"object_id": c.u(8), "content_hash": c.take(32), "joules": c.u(8), "origin": c.string()}
o["siblings"] = [c.take(32) for _ in range(c.u(1))]
o["bits"], o["index"] = c.u(8), c.u(8)
o["version"] = c.u(1)
o["class"] = c.klass() if o["version"] in (2, 3) else None
o["identity"] = None
if o["version"] == 3:
n = c.u(4)
if not IDENTITY_FIXED <= n <= IDENTITY_MAX:
raise Malformed("an identity's length")
o["identity"] = c.take(n)
elif o["version"] not in (1, 2):
raise Malformed("leaf_version")
objects.append(o)
if c.i != len(b):
raise Malformed("bytes after the last field")
return g, objects
def leaf_of(o):
if o["version"] == 1:
return blake3(LEAF[1] + o["content_hash"])
body = o["content_hash"] + o["joules"].to_bytes(8, "big") + o["class"] + len(o["origin"]).to_bytes(4, "big") + o["origin"]
if o["version"] == 3:
body += len(o["identity"]).to_bytes(4, "big") + o["identity"]
return blake3(LEAF[o["version"]] + body)
def walks(o, leaf_count, root):
depth = 0 if leaf_count <= 1 else (leaf_count - 1).bit_length()
if not (o["index"] < leaf_count and len(o["siblings"]) == depth and o["bits"] == o["index"]):
return False
cur = leaf_of(o)
for level, s in enumerate(o["siblings"]):
cur = blake3(NODE + s + cur) if (o["bits"] >> level) & 1 else blake3(NODE + cur + s)
return cur == root
def identity_fields_ok(o, group_id):
i = o["identity"]
return (o["class"] is not None and IDENTITY_FIXED <= len(i) <= IDENTITY_MAX and o["object_id"] < 1 << 32
and i[:8] == group_id.to_bytes(8, "big") and i[8:12] == o["object_id"].to_bytes(4, "big"))
def group_outcome(v):
try:
g, objects = read_bundle(bytes.fromhex(v["bundle"]))
except (Malformed, ValueError):
return "malformed"
if g["key"] != bytes.fromhex(v["publisher"]) or not ED.verifies(g["key"], g["payload"], g["sig"]):
return "unverified"
for o in objects:
if o["identity"] is not None and not identity_fields_ok(o, g["group_id"]):
return "identity_malformed"
if o["object_id"] != o["index"] or not walks(o, g["leaf_count"], g["root"]):
return "unverified"
return "accept"
def identity_part(signed, got):
if signed == got:
return None
if len(signed) < IDENTITY_FIXED or len(got) < IDENTITY_FIXED:
return "length"
if signed[:12] != got[:12]:
return "group_object"
return "publisher_priority" if signed[12] != got[12] else "immutable_properties"
def check_identity(doc, quiet=False):
fails, seen = [], set()
def report(kind, name, bad, what):
if not quiet:
print(("PASS" if not bad else "FAIL") + " %s %s: %s" % (kind, name, what))
fails.extend("%s %s: %s" % (kind, name, b) for b in bad)
for v in doc["groups"]:
got = group_outcome(v)
seen.add(got)
report("identity group", v["name"], [] if got == v["outcome"] else ["%s, not %s" % (got, v["outcome"])], got)
if got != "accept":
continue
_, objects = read_bundle(bytes.fromhex(v["bundle"]))
for o, x in zip(objects, v["objects"]):
bad = []
if blake3(OBJECT_DOMAIN + bytes.fromhex(x["payload"])) != o["content_hash"] or x["content_hash"] != o["content_hash"].hex():
bad.append("the content hash is not jwp-receipts §2.1's over the sealed payload")
ident = v["group"].to_bytes(8, "big") + x["object"].to_bytes(4, "big") + bytes([x["priority"]]) + bytes.fromhex(x["immutable"])
if ident.hex() != x["identity"] or o["identity"] != ident:
bad.append("the identity is not the object's SECURE_OBJECT_AAD")
if leaf_of(o).hex() != x["leaf"]:
bad.append("the leaf")
report("identity object", "%s/%d" % (v["name"], x["object"]), bad, "bound")
good = next(g for g in doc["groups"] if g["outcome"] == "accept")
_, receipts = read_bundle(bytes.fromhex(good["bundle"]))
for v in doc["arrivals"]:
bad = []
wire = None if v["immutable_wire"] is None else bytes.fromhex(v["immutable_wire"])
imm = wire
if wire is not None and v["draft"] == 16 and v["converted"]:
imm = pairs_write(pairs16_read(wire))
if (None if imm is None else imm.hex()) != v["immutable"]:
bad.append("the Immutable Properties as -22 encodes them")
ident = v["group"].to_bytes(8, "big") + v["object"].to_bytes(4, "big") + bytes([v["priority"]]) + (imm or b"")
part = identity_part(receipts[v["receipt"]]["identity"], ident)
got = {"outcome": "pass"} if part is None else {"outcome": "identity_mismatch", "part": part}
if got.get("outcome") != v["outcome"] or got.get("part") != v.get("part"):
bad.append("%s, not %s" % (got, {k: v.get(k) for k in ("outcome", "part")}))
seen.add(got["outcome"] + "/" + (got.get("part") or ""))
report("identity arrival", v["name"], bad, got["outcome"])
for want in ("accept", "malformed", "identity_malformed", "unverified", "pass/", "identity_mismatch/group_object",
"identity_mismatch/publisher_priority", "identity_mismatch/immutable_properties"):
if want not in seen:
fails.append("no identity vector gives %s" % want)
return fails
def identity_mutations(doc):
"""Copies of identity.json, each with one defect a conforming verifier must catch."""
def mutate(label, f):
d = copy.deepcopy(doc)
f(d)
return label, d
g0 = doc["groups"][0]
# The Publisher Priority byte of object 0's identity, where the bundle carries it.
at = g0["bundle"].find(g0["objects"][0]["identity"]) // 2 + 12
return [
mutate("the priority inside a signed identity in the bundle", lambda d: d["groups"][0].update(bundle=flip(d["groups"][0]["bundle"], at))),
mutate("an arrival's priority", lambda d: d["arrivals"][0].update(priority=d["arrivals"][0]["priority"] + 1)),
mutate("a byte of an arrival's Immutable Properties", lambda d: d["arrivals"][1].update(immutable_wire=flip(d["arrivals"][1]["immutable_wire"], -1))),
mutate("a -16 arrival stated as not re-encoded", lambda d: d["arrivals"][4].update(converted=False)),
mutate("a mismatch's part restated", lambda d: d["arrivals"][6].update(part="immutable_properties")),
mutate("a refused group stated accepted", lambda d: d["groups"][1].update(outcome="accept")),
mutate("an object's stated identity", lambda d: d["groups"][0]["objects"][0].update(identity=flip(d["groups"][0]["objects"][0]["identity"], 12))),
]
def flip(h, at):
b = bytearray(bytes.fromhex(h))
b[at] ^= 0x01
@ -1038,6 +1287,21 @@ def main(argv):
else:
pcaught += 1
print("%d of %d mutated copies of the published vectors fail, as they must" % (pcaught, len(pmuts)))
ident = json.load(open(os.path.join(d, "identity.json")))
ifails = check_identity(ident)
fails += ifails
icaught = 0
imuts = identity_mutations(ident)
for label, m in imuts:
try:
ok = not check_identity(m, quiet=True)
except Exception: # a crash on a mutated copy is a failure to read it
ok = False
if ok:
fails.append("a mutated copy of identity.json verifies: %s" % label)
else:
icaught += 1
print("%d of %d mutated copies of identity.json fail, as they must" % (icaught, len(imuts)))
doc = json.load(open(os.path.join(d, "vectors.json")))
if not (doc["records"] and doc["objects"] and doc["rooms"] and doc["key_schedule"]):
fails.append("an empty corpus")

View file

@ -14,14 +14,19 @@ else is here, from the text:
- each pathway's window, coverage, status and figure (§5);
- the decision rule (§6): the limits, the order, the draw and the bands, the first pathway,
the priority and the TTL, all with Python's integers;
- the steering manifest, the HLS tag and the DASH element (§8), byte for byte.
- the steering manifest, the HLS tag and the DASH element (§8), byte for byte;
- the fields for CDNs (§8.5), from cdn.json: a steering response's CMSD-Static field and
its check against the manifest, the decision's CMCD key, a hop's CMSD-Dynamic entry, and
which hops a response's CMSD-Dynamic field names, read as delivery-telemetry §6.6 to §6.8
read CMSD, with this file's RFC 9651 parser.
For every request it requires the report the corpus states; for every vector the outcome,
reason and place, and the digest, priority, TTL, reason for the first pathway, each
pathway's evidence and limits, the manifest and the URI; for every policy its HLS tag and
DASH element; of the corpus, that every reason of §10 refuses a vector and that §10 lists
exactly those reasons. Then it mutates copies of the corpus, one defect each, and requires
every copy to fail.
exactly those reasons; and that every reason of §8.5 refuses a case of cdn.json and that
§8.5 lists exactly those. Then it mutates copies of both files, one defect each, and
requires every copy to fail.
python3 energy_steering_verify.py <live-conformance/steering>
@ -437,6 +442,28 @@ def parse_dictionary(s):
return None
def parse_list(s):
"""RFC 9651 §4.2.1: the list, or None when it does not parse."""
p = SF(s)
try:
p.sp()
out = []
while p.i < len(p.b):
out.append(p.member())
p.ows()
if p.i == len(p.b):
break
if p.peek() != 0x2c:
p.fail()
p.i += 1
p.ows()
if p.i == len(p.b):
p.fail()
return out
except (ValueError, UnicodeDecodeError):
return None
def percent_decode(s):
out, i = bytearray(), 0
while i < len(s):
@ -733,6 +760,244 @@ def dash_element(p):
# ---------------------------------------------------------------- §9 verification
# ---------------------------------------------------------------- §8.5 the fields for CDNs
DECISION_KEY = "science.transaction.wai-sd"
HOP_KEY = "science.transaction.wai-hop"
DROP_KEY = "science.transaction.wai-drop"
CDN_REASONS = ["cmsd_decision_mismatch", "pathway_unknown", "served_malformed", "served_unknown_hop",
"served_off_pathway", "served_out_of_order"]
EDGE, INTERIOR = 1, 2
def hex64(v):
return isinstance(v, str) and len(v) == 64 and all(c in "0123456789abcdef" for c in v)
def sf_quote(v):
return '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
def combined(lines):
"""delivery-telemetry §6.6: a field's lines joined in order by a comma and a space."""
return ", ".join(lines).encode("utf-8")
def cmsd_static(lines):
"""CMSD-Static as delivery-telemetry §6.6 reads it: None when absent, when it does not
parse, or when its `v` is an Integer above 1 (the data set ignored)."""
if not lines:
return None
d = parse_dictionary(combined(lines))
if d is None:
return None
v = d.get("v")
if v is not None and v[0] == "item" and v[1][0] == "integer" and v[1][1] > 1:
return None
return d
def custom_string(d, key):
"""A custom key's value when it is a String (delivery-telemetry §6.7), else None."""
m = d.get(key) if d else None
if m is None or m[0] != "item" or m[1][0] != "string":
return None
return m[1][1].decode("ascii")
def steering_field(server, digest):
"""§8.5: the CMSD-Static field of a steering response."""
return "n=%s,ot=m,%s=%s" % (sf_quote(server), DECISION_KEY, sf_quote(digest))
def manifest_decision(manifest):
"""§8.4: the digest the manifest's member ends in."""
try:
m = json.loads(manifest).get(MANIFEST_KEY)
except (ValueError, AttributeError):
return None
if not isinstance(m, str) or not hex64(m[-64:]):
return None
return m[-64:]
def check_response(manifest, lines):
stated = custom_string(cmsd_static(lines), DECISION_KEY)
if not hex64(stated):
return {"outcome": "absent"}
if stated == manifest_decision(manifest):
return {"outcome": "bound"}
return {"outcome": "refuse", "reason": "cmsd_decision_mismatch"}
def members_of(report):
"""A dictionary's members as written, cut at the commas outside its Strings, each with
its key; None when it does not parse."""
if parse_dictionary(report.encode("ascii")) is None:
return None
out, cur, quoted, esc = [], "", False, False
for ch in report:
if quoted:
cur += ch
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
quoted = False
elif ch == '"':
quoted, cur = True, cur + ch
elif ch == ",":
out.append(cur)
cur = ""
else:
cur += ch
if report:
out.append(cur)
return [(m.split("=", 1)[0], m) for m in out]
def with_decision(report, digest):
"""§8.5: the key inserted in bytewise order, every other member as it was."""
ms = [m for m in members_of(report) if m[0] != DECISION_KEY]
new = (DECISION_KEY, "%s=%s" % (DECISION_KEY, sf_quote(digest)))
at_ = next((i for i, (k, _) in enumerate(ms) if k.encode() > DECISION_KEY.encode()), len(ms))
ms.insert(at_, new)
return ",".join(m for _, m in ms)
def hop_entry(h, key):
"""§8.5: a hop's CMSD-Dynamic entry, its parameters in bytewise order."""
params = []
for k in ("etp", "mb", "rd", "rtt"):
if h.get(k) is not None:
params.append((k, str(h[k])))
if h.get("drop") is not None:
params.append((DROP_KEY, sf_quote(str(h["drop"]))))
params.append((HOP_KEY, sf_quote(key)))
params.sort(key=lambda x: x[0].encode())
return sf_quote(h["server"]) + "".join(";%s=%s" % kv for kv in params)
def hop_key(p, pathway, place):
x = p["pathways"][index_of(p, pathway.encode())]
return next(k for k, pl in x["hops"] if pl == place).hex()
def served_by(p, pathway, static_lines, lines):
"""§8.5 *Served by*, steps 1 to 5; CMSD-Static matters only for its version."""
j = index_of(p, pathway.encode())
if j is None:
return {"outcome": "refuse", "reason": "pathway_unknown", "entry": None}
if static_lines and parse_dictionary(combined(static_lines)) is not None and cmsd_static(static_lines) is None:
return {"outcome": "unattributed"} # a later version: the whole data set ignored
entries = parse_list(combined(lines)) if lines else None
if entries is None:
return {"outcome": "unattributed"}
hops, seen = [], set()
for i, m in enumerate(entries):
params = m[2]
if HOP_KEY not in params:
continue
v = params[HOP_KEY]
key = v[1].decode("ascii") if v[0] == "string" else None
if not hex64(key) or key in seen:
return {"outcome": "refuse", "reason": "served_malformed", "entry": i}
seen.add(key)
found = [(k, x, pl) for k, x in enumerate(p["pathways"]) for hk, pl in x["hops"] if hk.hex() == key]
if not found:
return {"outcome": "refuse", "reason": "served_unknown_hop", "entry": i}
on, _, pl = found[0]
if on != j:
return {"outcome": "refuse", "reason": "served_off_pathway", "entry": i}
if any(h["place"] == "edge" for h in hops):
return {"outcome": "refuse", "reason": "served_out_of_order", "entry": i}
hops.append({"entry": i, "hop": key, "place": "edge" if pl == EDGE else "interior"})
if not hops:
return {"outcome": "unattributed"}
return {"outcome": "attributed", "hops": hops}
def check_cdn(doc, cdn, quiet=False):
"""Every failure of cdn.json against §8.5, as strings."""
fails = []
try:
pols = {n: read_policy(bytes.fromhex(v["policy"])) for n, v in doc["policies"].items() if v.get("hls_tag")}
vecs = {v["name"]: v for v in doc["vectors"]}
for w in cdn["written"]["steering_static"]:
if steering_field(w["server"], vecs[w["vector"]]["digest"]) != w["cmsd_static"]:
fails.append("written steering field for %s differs" % w["vector"])
for h in cdn["written"]["hop_entries"]:
key = hop_key(pols[h["policy"]], h["pathway"], EDGE if h["place"] == "edge" else INTERIOR)
if hop_entry(h, key) != h["cmsd_dynamic"]:
fails.append("written hop entry %s differs" % h["server"])
seen = set()
for r in cdn["responses"]:
got = check_response(vecs[r["vector"]]["steering_manifest"], r["cmsd_static"])
if got.get("outcome") != r.get("outcome") or got.get("reason") != r.get("reason"):
fails.append("response %s: %s, the file states %s %s" % (r["name"], got, r.get("outcome"), r.get("reason")))
seen.add(got.get("reason"))
for c in cdn["cmcd"]:
if with_decision(c["report"], vecs[c["vector"]]["digest"]) != c["with_decision"]:
fails.append("cmcd %s: the key is not added as §8.5 adds it" % c["name"])
for x in cdn["served"]:
got = served_by(pols[x["policy"]], x["pathway"], x["cmsd_static"], x["cmsd_dynamic"])
for k in ("outcome", "reason", "entry", "hops"):
if got.get(k) != x.get(k):
fails.append("served %s: %s is %s, the file states %s" % (x["name"], k, got.get(k), x.get(k)))
seen.add(got.get("reason"))
for r in CDN_REASONS:
if r not in seen:
fails.append("nothing in cdn.json is refused %s" % r)
if cdn.get("codes") != CDN_REASONS:
fails.append("cdn.json's codes are not §8.5's")
except (KeyError, ValueError, TypeError, AttributeError, StopIteration) as e:
fails.append("cdn.json does not read: %r" % e)
if not quiet:
for f in fails:
print("FAIL " + f)
return fails
def cdn_reasons_listed():
"""§8.5's table, in order."""
path = os.path.join(HERE, "..", "extensions", "energy-steering.md")
with open(path, encoding="utf-8") as f:
text = f.read()
at_ = text.index("\n### 8.5 ")
end = text.find("\n## ", at_ + 1)
body = text[at_:end if end >= 0 else len(text)]
return [line.split("`")[1] for line in body.splitlines() if line.startswith("| `")]
def cdn_mutations(cdn):
"""Copies of cdn.json, each with one defect, by name."""
out = []
def mutate(name, f):
d = copy.deepcopy(cdn)
f(d)
out.append((name, d))
def case(d, part, n):
return next(x for x in d[part] if x["name"] == n)
mutate("a bound response stated absent", lambda d: case(d, "responses", "bound").update(outcome="absent"))
mutate("a mismatch stated bound", lambda d: case(d, "responses", "mismatch").update(outcome="bound", reason=None))
mutate("a response's field", lambda d: case(d, "responses", "bound")["cmsd_static"].__setitem__(0, case(d, "responses", "mismatch")["cmsd_static"][0]))
mutate("a written steering field", lambda d: d["written"]["steering_static"][0].update(cmsd_static=d["written"]["steering_static"][0]["cmsd_static"].replace("ot=m", "ot=o")))
mutate("a written hop entry", lambda d: d["written"]["hop_entries"][1].update(cmsd_dynamic=d["written"]["hop_entries"][1]["cmsd_dynamic"].replace("rtt=40", "rtt=41")))
mutate("a CMCD report's key out of order", lambda d: case(d, "cmcd", "session-shard").update(with_decision="sf=h," + case(d, "cmcd", "session-shard")["with_decision"].replace("sf=h,", "", 1)))
mutate("an attributed hop's place", lambda d: case(d, "served", "tree")["hops"][0].update(place="edge"))
mutate("an attributed hop's entry", lambda d: case(d, "served", "tree-through-other-servers")["hops"][1].update(entry=2))
mutate("an unattributed response attributed", lambda d: case(d, "served", "unattributed-no-hop").update(outcome="attributed"))
mutate("a refusal's reason", lambda d: case(d, "served", "off-pathway").update(reason="served_unknown_hop"))
mutate("a refusal's entry", lambda d: case(d, "served", "malformed-twice").update(entry=0))
mutate("the out-of-order entries swapped", lambda d: case(d, "served", "out-of-order")["cmsd_dynamic"].reverse())
mutate("the reason's only case", lambda d: d["served"].remove(case(d, "served", "pathway-unknown")))
mutate("a later version's static field dropped", lambda d: case(d, "served", "unattributed-version-2").update(cmsd_static=[]))
return out
class Keys:
"""What live_metering_verify.hop_authorised reads of a room: its descriptor and log."""
@ -1012,6 +1277,24 @@ def main(corpus_dir):
print("FAIL the mutation '%s' goes unnoticed" % name)
fail += 1
print("%s %d of %d mutated copies of the corpus fail" % ("PASS" if caught == len(muts) else "FAIL", caught, len(muts)))
with open(os.path.join(corpus_dir, "cdn.json"), encoding="utf-8") as f:
cdn = json.load(f)
fails = check_cdn(doc, cdn)
print("%s %d responses, %d CMCD reports and %d attributions of cdn.json verify from §8.5" % (
"PASS" if not fails else "FAIL", len(cdn.get("responses", [])), len(cdn.get("cmcd", [])), len(cdn.get("served", []))))
fail += bool(fails)
ok = cdn_reasons_listed() == CDN_REASONS
print("%s §8.5 lists exactly the reasons this verifier raises for it" % ("PASS" if ok else "FAIL"))
fail += not ok
caught = 0
muts = cdn_mutations(cdn)
for name, d in muts:
if check_cdn(doc, d, quiet=True):
caught += 1
else:
print("FAIL the mutation '%s' of cdn.json goes unnoticed" % name)
fail += 1
print("%s %d of %d mutated copies of cdn.json fail" % ("PASS" if caught == len(muts) else "FAIL", caught, len(muts)))
return 1 if fail else 0

View file

@ -43,7 +43,7 @@ before it writes it.
## Layout
One file, `vectors.json`:
`vectors.json` (and `cdn.json`, below):
- `principals`: the keys, by name (informative).
- `room`: its `id`, `descriptor` (`WRD1`, hexadecimal) and `key_log_head` (a key-log revision,
@ -80,3 +80,37 @@ cargo run --no-default-features --features energy_steering --bin wai_energy_stee
cargo test --no-default-features --features energy_steering --test energy_steering
python3 -I ../live-conformance/energy_steering_verify.py ../live-conformance/steering
```
## The fields for CDNs: `cdn.json`
`wai_energy_steering_vectors gen` also writes `cdn.json`, the cases of §8.5, from
`vectors.json`: each names the accepted vector or the policy it is built on, so the two files
are read together.
- `written`: `steering_static`, the `CMSD-Static` field a steering server sends with the
manifest of two accepted vectors (`n`, `ot=m` and the decision's digest under
`science.transaction.wai-sd`); and `hop_entries`, the `CMSD-Dynamic` entries of three hops
of the policy `three`, the relay tree's interior and edge hops and `edge-a`'s edge hop, each
naming the key that signs its per-hop records under `science.transaction.wai-hop`.
- `responses`: a steering response's `cmsd_static` field lines against its vector's manifest,
and the outcome: `bound`; `absent` (no field, no key, a field that does not parse, a later
version, a key not in §8.5's form); or `refuse` with `cmsd_decision_mismatch`.
- `cmcd`: a CMCD `report` or header shard, and the same `with_decision`, the key inserted
in bytewise order, an earlier decision's replaced.
- `served`: a response's `cmsd_static` and `cmsd_dynamic` field lines (the first read for
its version alone) on the `pathway` a client applied under `policy`, and the outcome: `attributed` with the `hops` it names (each its `entry`, `hop`
and `place`), `unattributed`, or `refuse` with the `reason` and the `entry`, for each
reason of §8.5.
- `codes`: §8.5's reasons, in its order.
**Equivalence.** A conforming implementation writes every `written` field, adds every CMCD
key, and gives every response and attribution its outcome, reason and entry.
`tests/energy_steering.rs` and `wai_energy_steering_vectors verify` require it of `wai-rs`
and require `cdn.json` to regenerate byte for byte; `energy_steering_verify.py` requires it
from the text, with its own RFC 9651 parser, and requires every mutated copy to fail.
**Provenance.** Generated here, from `vectors.json`: the digests, manifests and hop keys are
that file's; the identifiers (`steer-1`, `relay-core`, `relay-edge`, `edge-a-1`), the
throughput, round-trip and drop figures in the entries, and the CMCD reports are constants
chosen for the corpus; the one key that is no hop is SHA-256 of a fixed label. Nothing was
measured or recorded.

View file

@ -0,0 +1,444 @@
{
"corpus": "wai.live.steering",
"extension": "extensions/energy-steering.md",
"section": "8.5",
"from": "vectors.json",
"written": {
"steering_static": [
{
"vector": "three_metered",
"server": "steer-1",
"cmsd_static": "n=\"steer-1\",ot=m,science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\""
},
{
"vector": "dash",
"server": "steer-1",
"cmsd_static": "n=\"steer-1\",ot=m,science.transaction.wai-sd=\"34669873c3d1b5496483719b6f19ed1efa5d9e4bbe17f06f721bc76aa33fc723\""
}
],
"hop_entries": [
{
"policy": "three",
"pathway": "tree",
"place": "interior",
"server": "relay-core",
"etp": 800000,
"rtt": 4,
"cmsd_dynamic": "\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\""
},
{
"policy": "three",
"pathway": "tree",
"place": "edge",
"server": "relay-edge",
"etp": 25000,
"mb": 8000,
"rd": 3,
"rtt": 40,
"drop": 1,
"cmsd_dynamic": "\"relay-edge\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"1\";science.transaction.wai-hop=\"c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10\""
},
{
"policy": "three",
"pathway": "edge-a",
"place": "edge",
"server": "edge-a-1",
"etp": 30000,
"rtt": 20,
"cmsd_dynamic": "\"edge-a-1\";etp=30000;rtt=20;science.transaction.wai-hop=\"d37933408b255514043ed93b9b9a086b6305839e5775d9a6d2cbc05df36789f4\""
}
]
},
"responses": [
{
"name": "bound",
"about": "the field names the decision the manifest names",
"vector": "three_metered",
"cmsd_static": [
"n=\"steer-1\",ot=m,science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\""
],
"outcome": "bound"
},
{
"name": "bound-dash",
"about": "a DASH client's steering response, bound",
"vector": "dash",
"cmsd_static": [
"n=\"steer-1\",ot=m,science.transaction.wai-sd=\"34669873c3d1b5496483719b6f19ed1efa5d9e4bbe17f06f721bc76aa33fc723\""
],
"outcome": "bound"
},
{
"name": "bound-extended",
"about": "an intermediary has extended the field with a key of its own (CTA-5006 §4); the decision is still read",
"vector": "three_metered",
"cmsd_static": [
"n=\"steer-1\",ot=m,science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\",org.example-tier=\"gold\""
],
"outcome": "bound"
},
{
"name": "bound-two-lines",
"about": "the field in two field lines, read as one (RFC 9110 §5.3)",
"vector": "three_metered",
"cmsd_static": [
"n=\"steer-1\",ot=m",
"science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\""
],
"outcome": "bound"
},
{
"name": "absent",
"about": "no CMSD-Static field: nothing is stated, which is no fault",
"vector": "three_metered",
"cmsd_static": [],
"outcome": "absent"
},
{
"name": "absent-no-key",
"about": "a field with no decision key",
"vector": "three_metered",
"cmsd_static": [
"n=\"steer-1\",ot=m"
],
"outcome": "absent"
},
{
"name": "absent-unparsed",
"about": "a field that is not a Dictionary, ignored whole (delivery-telemetry §6.6)",
"vector": "three_metered",
"cmsd_static": [
"n=\"steer-1\",ot=m,science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\",,"
],
"outcome": "absent"
},
{
"name": "absent-version-2",
"about": "a field of a later version: the data set is ignored (CTA-5006 §8.1 item 8)",
"vector": "three_metered",
"cmsd_static": [
"v=2,n=\"steer-1\",ot=m,science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\""
],
"outcome": "absent"
},
{
"name": "absent-not-hex",
"about": "a decision key that is not 64 lowercase hexadecimal digits: not in §8.5's form, ignored",
"vector": "three_metered",
"cmsd_static": [
"n=\"steer-1\",ot=m,science.transaction.wai-sd=\"9D86F36D179971166346C39085D2407E785184ED24FDA05C28AE4F2E3B10D213\""
],
"outcome": "absent"
},
{
"name": "mismatch",
"about": "the field names another decision than the manifest's",
"vector": "three_metered",
"cmsd_static": [
"n=\"steer-1\",ot=m,science.transaction.wai-sd=\"1abe1d385890be7d48713458ee2e8b35dd9d6f91157ede507f2c606eb0af8d83\""
],
"outcome": "refuse",
"reason": "cmsd_decision_mismatch"
}
],
"cmcd": [
{
"name": "query-report",
"about": "a Request-mode report as the CMCD query carries it",
"vector": "three_metered",
"report": "bl=2100,br=1450,mtp=4800,ot=v,sf=h,sid=\"6f1c2a9e0b7d4c3a8e5f1d2c3b4a5968\",st=l,v=2",
"with_decision": "bl=2100,br=1450,mtp=4800,ot=v,science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\",sf=h,sid=\"6f1c2a9e0b7d4c3a8e5f1d2c3b4a5968\",st=l,v=2"
},
{
"name": "session-shard",
"about": "the CMCD-Session header shard",
"vector": "three_metered",
"report": "sf=h,sid=\"6f1c2a9e0b7d4c3a8e5f1d2c3b4a5968\",st=l,v=2",
"with_decision": "science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\",sf=h,sid=\"6f1c2a9e0b7d4c3a8e5f1d2c3b4a5968\",st=l,v=2"
},
{
"name": "session-shard-empty",
"about": "a client that sends no other session key: the shard holds the decision's key alone",
"vector": "three_metered",
"report": "",
"with_decision": "science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\""
},
{
"name": "replaced",
"about": "a report carrying an earlier manifest's decision: the key takes the new digest",
"vector": "three_metered",
"report": "sf=h,science.transaction.wai-sd=\"1abe1d385890be7d48713458ee2e8b35dd9d6f91157ede507f2c606eb0af8d83\",sid=\"6f1c2a9e0b7d4c3a8e5f1d2c3b4a5968\",v=2",
"with_decision": "science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\",sf=h,sid=\"6f1c2a9e0b7d4c3a8e5f1d2c3b4a5968\",v=2"
},
{
"name": "custom-keys-around",
"about": "a report with custom keys before and after the decision's",
"vector": "three_metered",
"report": "ot=v,science.transaction.wai-ch=\"abababababababababababababababababababababababababababababababab\",science.transaction.wai-tx=\"12\",sid=\"6f1c2a9e0b7d4c3a8e5f1d2c3b4a5968\",v=2",
"with_decision": "ot=v,science.transaction.wai-ch=\"abababababababababababababababababababababababababababababababab\",science.transaction.wai-sd=\"9d86f36d179971166346c39085d2407e785184ed24fda05c28ae4f2e3b10d213\",science.transaction.wai-tx=\"12\",sid=\"6f1c2a9e0b7d4c3a8e5f1d2c3b4a5968\",v=2"
}
],
"served": [
{
"name": "tree",
"about": "the relay tree's interior hop, then its edge hop, nearest the client last",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\"",
"\"relay-edge\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"1\";science.transaction.wai-hop=\"c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10\""
],
"outcome": "attributed",
"hops": [
{
"entry": 0,
"hop": "891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878",
"place": "interior"
},
{
"entry": 1,
"hop": "c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10",
"place": "edge"
}
]
},
{
"name": "tree-one-line",
"about": "the same entries in one field line",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\",\"relay-edge\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"1\";science.transaction.wai-hop=\"c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10\""
],
"outcome": "attributed",
"hops": [
{
"entry": 0,
"hop": "891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878",
"place": "interior"
},
{
"entry": 1,
"hop": "c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10",
"place": "edge"
}
]
},
{
"name": "tree-through-other-servers",
"about": "other servers' entries before, between and after the hops: passed over",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"cdn1-a7\";etp=900000;rtt=2",
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\"",
"\"shield-2\"",
"\"relay-edge\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"1\";science.transaction.wai-hop=\"c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10\"",
"\"isp-cache\";rtt=1"
],
"outcome": "attributed",
"hops": [
{
"entry": 1,
"hop": "891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878",
"place": "interior"
},
{
"entry": 3,
"hop": "c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10",
"place": "edge"
}
]
},
{
"name": "tree-edge-only",
"about": "the edge hop alone, its interior hop writing no entry",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-edge\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"1\";science.transaction.wai-hop=\"c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10\""
],
"outcome": "attributed",
"hops": [
{
"entry": 0,
"hop": "c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10",
"place": "edge"
}
]
},
{
"name": "edge-a",
"about": "a hop of the pathway the client applied, edge-a",
"policy": "three",
"pathway": "edge-a",
"cmsd_static": [],
"cmsd_dynamic": [
"\"edge-a-1\";etp=30000;rtt=20;science.transaction.wai-hop=\"d37933408b255514043ed93b9b9a086b6305839e5775d9a6d2cbc05df36789f4\""
],
"outcome": "attributed",
"hops": [
{
"entry": 0,
"hop": "d37933408b255514043ed93b9b9a086b6305839e5775d9a6d2cbc05df36789f4",
"place": "edge"
}
]
},
{
"name": "unattributed-no-hop",
"about": "a field that names no hop",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"cdn1-a7\";etp=900000;rtt=2"
],
"outcome": "unattributed"
},
{
"name": "unattributed-absent",
"about": "no CMSD-Dynamic field",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [],
"outcome": "unattributed"
},
{
"name": "unattributed-unparsed",
"about": "a field that does not parse, ignored whole",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\"",
"\"relay-edge\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"1\";science.transaction.wai-hop=\"c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10\";"
],
"outcome": "unattributed"
},
{
"name": "off-pathway",
"about": "the client applied the tree, and the field names edge-a's edge hop",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\"",
"\"edge-a-1\";etp=30000;rtt=20;science.transaction.wai-hop=\"d37933408b255514043ed93b9b9a086b6305839e5775d9a6d2cbc05df36789f4\""
],
"outcome": "refuse",
"reason": "served_off_pathway",
"entry": 1
},
{
"name": "unknown-hop",
"about": "a key that is no hop of the policy",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\"",
"\"stranger\";etp=1;science.transaction.wai-hop=\"f0463a345cb7f0577cfb6c65cde7706b8ceef55d5689fb8a47510a43673f8214\""
],
"outcome": "refuse",
"reason": "served_unknown_hop",
"entry": 1
},
{
"name": "malformed-hex",
"about": "a hop key that is not 64 lowercase hexadecimal digits",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-core\";science.transaction.wai-hop=\"891469E0DF5BB5BE2947BA5D00F8B84E55BB024EB005156BAA0BFA82DC38D878\""
],
"outcome": "refuse",
"reason": "served_malformed",
"entry": 0
},
{
"name": "malformed-not-a-string",
"about": "a hop key that is a Boolean, not a String",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-core\";science.transaction.wai-hop"
],
"outcome": "refuse",
"reason": "served_malformed",
"entry": 0
},
{
"name": "malformed-twice",
"about": "one hop named by two entries",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\"",
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\"",
"\"relay-edge\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"1\";science.transaction.wai-hop=\"c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10\""
],
"outcome": "refuse",
"reason": "served_malformed",
"entry": 1
},
{
"name": "out-of-order",
"about": "the interior hop named after the edge hop",
"policy": "three",
"pathway": "tree",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-edge\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"1\";science.transaction.wai-hop=\"c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10\"",
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\""
],
"outcome": "refuse",
"reason": "served_out_of_order",
"entry": 1
},
{
"name": "pathway-unknown",
"about": "a pathway the policy does not have",
"policy": "three",
"pathway": "nowhere",
"cmsd_static": [],
"cmsd_dynamic": [
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\"",
"\"relay-edge\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"1\";science.transaction.wai-hop=\"c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10\""
],
"outcome": "refuse",
"reason": "pathway_unknown",
"entry": null
},
{
"name": "unattributed-version-2",
"about": "a response whose CMSD-Static field is of a later version: its whole data set is ignored, the hops it names with it",
"policy": "three",
"pathway": "tree",
"cmsd_static": [
"v=2,ot=v"
],
"cmsd_dynamic": [
"\"relay-core\";etp=800000;rtt=4;science.transaction.wai-hop=\"891469e0df5bb5be2947ba5d00f8b84e55bb024eb005156baa0bfa82dc38d878\"",
"\"relay-edge\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"1\";science.transaction.wai-hop=\"c6492ee13788405fb5da423ec5a807476c46d68bd1488f455a817a6d6c70fa10\""
],
"outcome": "unattributed"
}
],
"codes": [
"cmsd_decision_mismatch",
"pathway_unknown",
"served_malformed",
"served_unknown_hop",
"served_off_pathway",
"served_out_of_order"
]
}

View file

@ -25,6 +25,12 @@ their metrics. Every value here is a string or an integer, the same on every mac
`expected/refused.json` lists them.
- `cmsd/` — CMSD-Static descriptions of a base and of a derived layer, and a
CMSD-Dynamic field from two nodes (§6); `expected/cmsd.json`, their fields.
- `cmsd-read/` — CMSD fields as other parties write them, each `about` (what it shows),
`static` and `dynamic` (the field lines, none when the field is absent);
`expected/cmsd-read.json`, what §6.6 to §6.8 read of each: the keys read, and every key
or field ignored, with why (`unparsed`, `version`, `type`, `unknown`). Among them, cases
in the shape of each kind of example CTA-5006 (November 2022) Annex A gives, written with
this corpus's own identifiers, paths and figures: the Annex's own strings are not copied.
- `logs/` — session logs (§7.1), one observation per line: a session with stalls, one
open at its end; an exit before start; a start failure. `expected/qoe.json`, their
metrics (§7.2).
@ -34,8 +40,10 @@ their metrics. Every value here is a string or an integer, the same on every mac
- `wai_telemetry verify .` (from `../wai-rs`, feature `telemetry`): reads every input
from disk, requires each observation to write back as given, recomputes every
expected file and requires it byte for byte, reads every report back, and requires
every `invalid/` observation refused. `wai_telemetry gen .` writes the corpus.
expected file and requires it byte for byte, reads every report back, requires
every `invalid/` observation refused, reads every `cmsd-read/` case, and requires each
field of `expected/cmsd.json` to read back as the description it was written from.
`wai_telemetry gen .` writes the corpus.
- `cml/differential.mjs` (`npm ci && node differential.mjs ..` in `cml/`): builds each
report from the extension's tables in JavaScript, not from `wai-rs`'s output; encodes
it with an independent open-source CMCD and CMSD library at the versions
@ -43,7 +51,37 @@ their metrics. Every value here is a string or an integer, the same on every mac
Request mode; requires the bytes of `expected/cmcd.json` and `expected/cmsd.json`;
reads the expected strings back with the library's dictionary, query and header
readers and requires the members; and validates every report, and the Event-mode
reports together, requiring no error.
reports together, requiring no error. It then decodes every `cmsd-read/` case with
the library's CMSD decoders and requires the library's value for every key
`expected/cmsd-read.json` keeps, and the library's failure exactly where a field is
ignored whole.
**Where the library and §6.6 to §6.9 differ.** The library's CMSD decoders
(`@svta/cml-cmsd` 1.1.1, over `@svta/cml-structured-field-values` 1.1.6) parse a field and
return every key as parsed; they apply none of CTA-5006 §8.1's reading rules. The
differential requires each difference to be one of these kinds, and prints how many of each;
for `type` and `unknown` it checks, from CTA-5006 §7 Table 1 and §4 item 1 written in
JavaScript and against the value the library's own Structured Field decoder gives, that the
value is not of its key's type or that the key has none, and it checks every key the reader
keeps to be of its type:
- `type`: a value that is not of its key's type (§8.1 item 4), a CMSD-Dynamic member that
is not a String, or a custom key that is not a String (§4 item 1): the reader ignores it,
the library returns it;
- `unknown`: a key the header does not reserve and that is not a custom key (§8.1 item 3),
among them `tl`, and a key of the other header: the reader ignores it, the library
returns it;
- `version`: a field whose `v` is above 1: the reader ignores the whole data set (§8.1
item 8), the library returns it;
- `token-as-string`: `decodeCmsdStatic` returns a Token as a string, so its caller cannot
tell `n=origin`, a Token the reader ignores, from `n="origin"`; the library's
`decodeSfDict` keeps the two apart, and the type checks above use it;
- `display-string`: the library's parser takes RFC 9651's Dates but not its Display
Strings, so it refuses a whole field holding one, where the reader parses the field and
ignores the one key.
They agree on every field that does not parse at all, a CMSD-Dynamic entry ending in `;`
among them, and on every value the reader keeps.
**Provenance.** Generated here: session ids, content hashes and timestamps are fixed
values chosen for the corpus. No recorded traffic.

View file

@ -7,7 +7,14 @@
// expected/cmcd.json; reads the expected strings back with the library's dictionary,
// query and header readers and requires the members; and validates every report, and
// the Event-mode reports together, requiring no error. CMSD-Static and CMSD-Dynamic
// are encoded and read back the same way. Exit status 0 only if everything agrees.
// are encoded and read back the same way. Then every CMSD field of cmsd-read/ is decoded
// by the library, and each value expected/cmsd-read.json keeps (§6.6 to §6.9) must be the
// library's; where the two differ, the difference must be one of the kinds the corpus's
// README names: the library applies none of CTA-5006 §8.1's reading rules (a key of the
// wrong type, an unknown key, a later version), its CMSD-Static decoder returns a Token as a
// string, and its parser takes RFC 9651's Dates but not its Display Strings. Each key the
// reader ignores for its type, or calls unknown, is checked here against CTA-5006 §7
// Table 1 and §4 item 1, as is each key it keeps. Exit status 0 only if everything agrees.
//
// npm ci && node differential.mjs <telemetry-conformance dir>
@ -15,7 +22,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { encodeCmcd, toCmcdHeaders, decodeCmcd, fromCmcdQuery, fromCmcdHeaders, validateCmcd, validateCmcdEvents } from '@svta/cml-cmcd';
import { encodeCmsdStatic, encodeCmsdDynamic, decodeCmsdStatic, decodeCmsdDynamic } from '@svta/cml-cmsd';
import { SfItem, SfToken } from '@svta/cml-structured-field-values';
import { SfItem, SfToken, decodeSfDict } from '@svta/cml-structured-field-values';
const dir = path.resolve(process.argv[2] ?? '..');
const read = (p) => JSON.parse(fs.readFileSync(path.join(dir, p), 'utf8'));
@ -129,7 +136,7 @@ for (const f of fs.readdirSync(path.join(dir, 'cmsd')).sort()) {
put('at', v.at); put('d', v.d); put('ht', v.ht); put('nor', v.nor); put('nrr', v.nrr); put('ot', v.ot);
put(P + 'ch', v.content_hash); put(P + 'l', v.layer === null ? null : String(v.layer));
put(P + 'lt', v.layers_total === null ? null : String(v.layers_total));
put(P + 'op', v.op === null ? null : new SfToken(v.op)); put(P + 'rcpt', v.receipt);
put(P + 'op', v.op); put(P + 'rcpt', v.receipt);
put('sf', v.sf); put('st', v.st); if (v.su) m.su = true;
const enc = encodeCmsdStatic(m);
if (enc !== cmsdExpected[name]) fail(`cmsd ${name}: the library writes ${enc}`);
@ -151,8 +158,139 @@ for (const f of fs.readdirSync(path.join(dir, 'cmsd')).sort()) {
}
}
// CMSD read by §6.6 to §6.9, against the library's decoders.
const readExpected = read('expected/cmsd-read.json');
const kinds = {};
const differ = (kind) => { kinds[kind] = (kinds[kind] ?? 0) + 1; };
const plain = (v) => {
if (v instanceof SfItem) return plain(v.value);
if (v instanceof SfToken) return v.description ?? String(v);
if (typeof v === 'symbol') return v.description;
if (Array.isArray(v)) return v.map(plain);
return v;
};
const decodeOr = (f, s) => { try { return { ok: f(s) }; } catch (e) { return { err: String(e) }; } };
// One value the reader keeps, as the library gives it.
function asLibrary(key, ours) {
if (ours === null) return undefined;
if (key === 'nor') return ours.join('|');
if (key === 'nrr') return ours.map((r) => (r === null ? '' : `${r[0]}-${r[1]}`)).join('|');
if (key === P + 'l' || key === P + 'lt' || key === P + 'drop') return String(ours);
return ours;
}
// CTA-5006 §7 Table 1 and §4 item 1, written here from the text: whether a value the library
// decoded is of its key's type, so that each key the reader ignores for its type is shown to
// be one, and each key it keeps to be of its type.
const RESERVED = {
static: ['at', 'br', 'd', 'ht', 'n', 'nor', 'nrr', 'ot', 'sf', 'st', 'su', 'v'],
dynamic: ['du', 'etp', 'mb', 'rd', 'rtt'],
};
const isInt = (v) => Number.isInteger(v) && v >= 0;
const isStr = (v) => typeof v === 'string';
function isTokenValue(v) { return v instanceof SfToken || typeof v === 'symbol'; }
const ofType = {
at: isInt, br: isInt, d: isInt, ht: isInt, etp: isInt, mb: isInt, rd: isInt, rtt: isInt,
n: isStr,
nor: (v) => isStr(v) && v.split('|').every((p) => p.length > 0),
nrr: (v) => isStr(v) && v.split('|').every((r) => r === '' || (/^[0-9]{1,20}-[0-9]{1,20}$/.test(r) && BigInt(r.split('-')[0]) <= BigInt(r.split('-')[1]))),
ot: (v) => isTokenValue(v) && ['m', 'a', 'v', 'av', 'i', 'c', 'tt', 'k', 'o'].includes(plain(v)),
st: (v) => isTokenValue(v) && ['v', 'l'].includes(plain(v)),
sf: (v) => (isTokenValue(v) && ['d', 'h', 's', 'o'].includes(plain(v)))
|| (Array.isArray(v) && v.length > 0 && v.every((x) => isTokenValue(x instanceof SfItem ? x.value : x) && ['d', 'h', 's', 'o'].includes(plain(x)))),
su: (v) => typeof v === 'boolean', du: (v) => typeof v === 'boolean',
[P + 'ch']: (v) => isStr(v) && /^[0-9a-f]{64}$/.test(v), [P + 'rcpt']: (v) => isStr(v) && /^[0-9a-f]{64}$/.test(v),
[P + 'l']: (v) => isStr(v) && /^(0|[1-9][0-9]{0,14})$/.test(v), [P + 'lt']: (v) => isStr(v) && /^(0|[1-9][0-9]{0,14})$/.test(v),
[P + 'drop']: (v) => isStr(v) && /^(0|[1-9][0-9]{0,14})$/.test(v),
[P + 'op']: (v) => isStr(v) && /^[A-Za-z*][A-Za-z0-9!#$%&'*+\-.^_`|~:/]*$/.test(v),
};
// The type a key has in a header: Table 1's for a reserved key, a String for a custom key.
function typed(field, key, v) {
if (ofType[key] && (RESERVED[field].includes(key) || key.includes('-'))) return ofType[key](v);
if (key.includes('-') && !RESERVED[field].includes(key)) return isStr(v);
return null; // unknown to this header
}
const STATIC_FIELDS = { at: 'at', br: 'br', d: 'd', ht: 'ht', n: 'n', nor: 'nor', nrr: 'nrr', ot: 'ot', st: 'st', su: 'su',
content_hash: P + 'ch', layer: P + 'l', layers_total: P + 'lt', receipt: P + 'rcpt', op: P + 'op' };
const ENTRY_FIELDS = { du: 'du', etp: 'etp', mb: 'mb', rd: 'rd', rtt: 'rtt', drop: P + 'drop' };
let readCount = 0;
for (const f of fs.readdirSync(path.join(dir, 'cmsd-read')).sort()) {
const name = f.replace(/\.json$/, '');
const c = read(`cmsd-read/${f}`);
const x = readExpected[name];
if (!x) { fail(`cmsd-read ${name}: not in expected/cmsd-read.json`); continue; }
readCount++;
const ign = (field, why, entry = null, key = null) => x.ignored.some((g) => g.field === field && g.why === why && (entry === null || g.entry === entry) && (key === null || g.key === key));
for (const field of ['static', 'dynamic']) {
if (c[field].length === 0) continue;
const lib = decodeOr(field === 'static' ? decodeCmsdStatic : decodeCmsdDynamic, c[field].join(', '));
if (lib.err !== undefined) {
if (ign(field, 'unparsed')) continue; // both ignore the whole field
// The library does not parse a Display String (RFC 9651 §3.3.8, `%"`): it refuses a field
// the reader parses and then ignores one key of.
if (c[field].join(', ').includes('%"') && x.ignored.some((g) => g.field === field && g.why === 'type')) { differ('display-string'); continue; }
fail(`cmsd-read ${name}: the library refuses its ${field} field (${lib.err}), the reader does not`);
continue;
}
if (ign(field, 'unparsed')) { fail(`cmsd-read ${name}: the library decodes the ${field} field the reader ignores whole`); continue; }
if (ign(field, 'version') || (field === 'static' && ign('static', 'version'))) { differ('version'); continue; }
if (field === 'static') {
const ours = x.static;
const libv = lib.ok;
// decodeCmsdStatic returns a Token as a string, so the types are read from the
// library's own Structured Field decoder, which keeps them.
const raw = Object.fromEntries(Object.entries(decodeSfDict(c[field].join(', '))).map(([k, it]) => [k, it.value]));
if (Object.entries(raw).some(([k, v]) => isTokenValue(v) && typeof libv[k] === 'string')) differ('token-as-string');
for (const [m, key] of Object.entries(STATIC_FIELDS)) {
const want = asLibrary(key, ours[m]);
if (want === undefined) continue;
if (JSON.stringify(plain(libv[key])) !== JSON.stringify(want)) fail(`cmsd-read ${name}: ${key} reads ${JSON.stringify(want)}, the library ${JSON.stringify(plain(libv[key]))}`);
}
if (ours.sf !== null) {
const l = plain(libv.sf);
if (JSON.stringify(Array.isArray(l) ? l : [l]) !== JSON.stringify(ours.sf)) fail(`cmsd-read ${name}: sf ${JSON.stringify(ours.sf)}, the library ${JSON.stringify(l)}`);
}
for (const [k, v] of Object.entries(ours.custom)) if (plain(libv[k]) !== v) fail(`cmsd-read ${name}: ${k}`);
// Each key the reader ignores (§8.1 items 3 and 4), the library returns as it parsed it.
for (const g of x.ignored.filter((g) => g.field === 'static' && g.key !== null)) {
if (!(g.key in libv)) { fail(`cmsd-read ${name}: the library does not return ${g.key}, which the reader ignores`); continue; }
const t = g.key === 'v' ? (Number.isInteger(raw.v) && raw.v >= 1) : typed('static', g.key, raw[g.key]);
if (g.why === 'type' && t !== false) fail(`cmsd-read ${name}: the reader ignores ${g.key} for its type, and the library's value ${JSON.stringify(plain(raw[g.key]))} is of it`);
else if (g.why === 'unknown' && t !== null) fail(`cmsd-read ${name}: the reader calls ${g.key} unknown, and Table 1 or §4 gives it a type`);
else differ(g.why);
}
for (const [k, v] of Object.entries(raw)) {
const kept = x.ignored.every((g) => g.field !== 'static' || g.key !== k);
if (kept && k !== 'v' && typed('static', k, v) === false) fail(`cmsd-read ${name}: the reader keeps ${k}, whose value ${JSON.stringify(plain(v))} is not of its type`);
}
} else {
const ours = x.dynamic;
const libv = lib.ok;
if (libv.length !== ours.length) { fail(`cmsd-read ${name}: ${ours.length} entries, the library ${libv.length}`); continue; }
ours.forEach((e, i) => {
if (e.server !== null && plain(libv[i].value) !== e.server) fail(`cmsd-read ${name}: entry ${i}'s identifier`);
if (e.server === null) { if (typeof libv[i].value === 'string') fail(`cmsd-read ${name}: entry ${i} is a String to the library`); else differ('type'); }
const params = libv[i].params ?? {};
for (const [m, key] of Object.entries(ENTRY_FIELDS)) {
const want = asLibrary(key, e[m]);
if (want === undefined) continue;
if (JSON.stringify(plain(params[key])) !== JSON.stringify(want)) fail(`cmsd-read ${name}: entry ${i}'s ${key}`);
}
for (const [k, v] of Object.entries(e.custom)) if (plain(params[k]) !== v) fail(`cmsd-read ${name}: entry ${i}'s ${k}`);
for (const g of x.ignored.filter((g) => g.field === 'dynamic' && g.entry === i && g.key !== null)) {
if (!(g.key in params)) { fail(`cmsd-read ${name}: the library does not return entry ${i}'s ${g.key}`); continue; }
const t = typed('dynamic', g.key, params[g.key]);
if (g.why === 'type' && t !== false) fail(`cmsd-read ${name}: the reader ignores entry ${i}'s ${g.key} for its type, and the library's value is of it`);
else if (g.why === 'unknown' && t !== null) fail(`cmsd-read ${name}: the reader calls entry ${i}'s ${g.key} unknown`);
else differ(g.why);
}
});
}
}
}
if (fails.length) {
for (const f of fails) console.log('FAIL', f);
process.exit(1);
}
console.log(`telemetry: every report agrees with the independent library (${Object.keys(expected).length} observations, ${events.length} Event-mode reports, ${Object.keys(cmsdExpected).length} CMSD fields)`);
console.log(`telemetry: every report agrees with the independent library (${Object.keys(expected).length} observations, ${events.length} Event-mode reports, ${Object.keys(cmsdExpected).length} CMSD fields written, ${readCount} CMSD cases read)`);
console.log(`telemetry: where the library and §6.6 to §6.9 differ, by kind: ${JSON.stringify(Object.fromEntries(Object.entries(kinds).sort()))}`);

View file

@ -0,0 +1,5 @@
{
"about": "no CMSD field: nothing is read and nothing is ignored",
"static": [],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "Annex A: a custom key with a reverse-DNS prefix and a String value, read as a custom key (§4 item 1)",
"static": [
"ot=m,sf=h,st=v,su,n=\"origin-a\",org.example-city=\"245\""
],
"dynamic": []
}

View file

@ -0,0 +1,9 @@
{
"about": "custom keys whose values are not Strings, which §4 item 1 requires: each ignored",
"static": [
"ot=v,org.example-n=5"
],
"dynamic": [
"\"edge-1\";org.example-x=?1;rtt=7"
]
}

View file

@ -0,0 +1,7 @@
{
"about": "a Display String (RFC 9651 §3.3.8) in a custom parameter: not a String, ignored",
"static": [],
"dynamic": [
"\"edge-1\";org.example-x=%\"caf%c3%a9\";rtt=9"
]
}

View file

@ -0,0 +1,7 @@
{
"about": "a key given twice: its last value, in its first place (RFC 9651 §4.2.2)",
"static": [
"ot=v,st=v,ot=a"
],
"dynamic": []
}

View file

@ -0,0 +1,9 @@
{
"about": "Annex A: an edge under duress (`du`), with a maximum suggested bitrate kept",
"static": [
"ot=m,sf=h,su,st=v,n=\"origin-a\",nor=\"/hi/p.m3u8|/lo/p.m3u8\""
],
"dynamic": [
"\"cdn1-d4\";etp=12;rtt=28;du"
]
}

View file

@ -0,0 +1,7 @@
{
"about": "a key Table 1 reserves for CMSD-Dynamic, in CMSD-Static: not this header's, ignored as unknown",
"static": [
"etp=100,ot=v"
],
"dynamic": []
}

View file

@ -0,0 +1,8 @@
{
"about": "an intermediary with nothing to add, an empty String and no parameters (CTA-5006 §5)",
"static": [],
"dynamic": [
"\"\"",
"\"edge-1\";rtt=4"
]
}

View file

@ -0,0 +1,7 @@
{
"about": "Annex A: a live segment whose field carries `tl`, which Table 1 does not define: an unknown key, ignored (item 3)",
"static": [
"ot=v,sf=h,st=l,d=2000,br=4800,tl=9000,n=\"origin-a\""
],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "a CMSD-Dynamic member that is a Token, not a String: its identifier is ignored, its parameters read",
"static": [],
"dynamic": [
"edge-1;rtt=5,\"edge-2\";rtt=6"
]
}

View file

@ -0,0 +1,7 @@
{
"about": "Annex A: a path holding a vertical line, percent-encoded so that it is not a separator; the path is kept as written",
"static": [
"nor=\"/seg/a%7Cb/a%7Cb-12.wai|/seg/a%7Cb/a%7Cb-13.wai\""
],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "Annex A: a relative path into a parent folder",
"static": [
"nor=\"../../720/s12.wai\""
],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "a CMSD-Static field that is not a Dictionary: ignored whole (item 1)",
"static": [
"ot=v,,sf=h"
],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "Annex A: an origin's on-demand DASH manifest, needed at start-up",
"static": [
"ot=m,sf=d,st=v,su,n=\"origin-a\""
],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "Annex A: next objects, and the byte range of each where one is given, the empty element the whole object",
"static": [
"ot=m,sf=d,st=v,su,n=\"origin-a\",nor=\"/hi/init.mp4|/lo/init.mp4|/hi/track.mp4|/lo/track.mp4\",nrr=\"||0-1499|1500-2999\""
],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "a Date (RFC 9651 §3.3.7), which RFC 8941 does not define: read as a Structured Field here, and `at` ignored as not an Integer (§6.6)",
"static": [
"at=@1790000000,ot=v"
],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "`sf` as Table 1 types it, an inner list of tokens, for an object served as DASH and HLS",
"static": [
"ot=v,sf=(d h),st=v"
],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "a key Table 1 reserves for CMSD-Static, as a CMSD-Dynamic parameter: ignored as unknown",
"static": [],
"dynamic": [
"\"edge-1\";ot=v;rtt=5"
]
}

View file

@ -0,0 +1,9 @@
{
"about": "the same nodes in one field line, read as the case before (CTA-5006 §4 calls the two forms equivalent; RFC 9110 §5.3)",
"static": [
"ot=v,sf=h,st=v,d=6000,br=1450,n=\"origin-a\""
],
"dynamic": [
"\"cdn2-a1\";etp=96000;rtt=8,\"cdn2-b2\";etp=76000;rtt=32,\"cdn1-c3\";etp=48000;rtt=30,\"cdn1-d4\";etp=32000;rtt=56;mb=5000"
]
}

View file

@ -0,0 +1,12 @@
{
"about": "Annex A: a segment through two CDNs, one field line per node, read in order: the first nearest the origin",
"static": [
"ot=v,sf=h,st=v,d=6000,br=1450,n=\"origin-a\""
],
"dynamic": [
"\"cdn2-a1\";etp=96000;rtt=8",
"\"cdn2-b2\";etp=76000;rtt=32",
"\"cdn1-c3\";etp=48000;rtt=30",
"\"cdn1-d4\";etp=32000;rtt=56;mb=5000"
]
}

View file

@ -0,0 +1,9 @@
{
"about": "Annex A: a CMSD-Dynamic entry ending in `;`, which RFC 8941 and RFC 9651 do not parse: the whole CMSD-Dynamic field is ignored (item 1), and CMSD-Static is still read",
"static": [
"ot=a,sf=h,st=v,d=6000,br=128,n=\"origin-a\",nor=\"s-3257.m4a\""
],
"dynamic": [
"\"cdn1-d4\";etp=12000;rtt=28;"
]
}

View file

@ -0,0 +1,7 @@
{
"about": "values not of their key's type, each ignored alone (item 4): a negative time, a Decimal rate, a String duration, a Boolean held time, a Token identifier, an object type and a stream type Table 1 does not list, an Integer start-up flag, an inner list holding an unknown format, an empty path and a reversed range",
"static": [
"at=-5,br=1.5,d=\"40\",ht=?1,n=origin,ot=x,st=ll,su=1,sf=(h x),nor=\"a||b\",nrr=\"5-2\""
],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "`v=1`: version 1, read",
"static": [
"v=1,ot=v,st=v"
],
"dynamic": []
}

View file

@ -0,0 +1,9 @@
{
"about": "`v=2`: a later version; the reader ignores the whole data set, both fields (item 8)",
"static": [
"v=2,ot=v,st=v,n=\"origin-a\""
],
"dynamic": [
"\"edge-1\";etp=100"
]
}

View file

@ -0,0 +1,7 @@
{
"about": "`v` that is not an Integer: ignored alone (item 4), and the data set read as version 1",
"static": [
"v=x,ot=v"
],
"dynamic": []
}

View file

@ -0,0 +1,9 @@
{
"about": "this extension's custom keys holding what §6 does not write: a layer with a leading zero, an upper-case hash, a drop count that is not decimal; each ignored",
"static": [
"science.transaction.wai-l=\"01\",science.transaction.wai-ch=\"AAF90792D6FF5318E5F306FBB000DFCBE73637E244AAD1A2EFF6DAE537CD6B5F\""
],
"dynamic": [
"\"edge-7\";science.transaction.wai-drop=\"two\""
]
}

View file

@ -0,0 +1,9 @@
{
"about": "this extension's custom keys (§6.1 to §6.3), the operation token as §6.2 writes it, a String",
"static": [
"ot=v,science.transaction.wai-ch=\"aaf90792d6ff5318e5f306fbb000dfcbe73637e244aad1a2eff6dae537cd6b5f\",science.transaction.wai-l=\"1\",science.transaction.wai-lt=\"4\",science.transaction.wai-op=\"crop\",science.transaction.wai-rcpt=\"7bf0f413db62aa185969b9ccf925d40f0281601268899bb7f2e97344487f4189\""
],
"dynamic": [
"\"edge-7\";science.transaction.wai-drop=\"2\""
]
}

View file

@ -0,0 +1,7 @@
{
"about": "the operation token as a String, the type CTA-5006 §4 item 1 gives a custom key",
"static": [
"science.transaction.wai-op=\"crop\""
],
"dynamic": []
}

View file

@ -0,0 +1,7 @@
{
"about": "the operation token as a Token, the form §6.2 wrote before this revision: not a custom key's type (CTA-5006 §4 item 1), so it is ignored, `type`, and the rest read",
"static": [
"ot=v,science.transaction.wai-op=crop"
],
"dynamic": []
}

View file

@ -0,0 +1,9 @@
{
"about": "optional whitespace around members, and a leading space, which RFC 9651 reads",
"static": [
" ot=v ,\tst=l"
],
"dynamic": [
" \"edge-1\";rtt=4 , \"edge-2\""
]
}

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
{
"static-base": "at=1790000000000,d=40,nor=\"L01.wai\",ot=v,science.transaction.wai-ch=\"aaf90792d6ff5318e5f306fbb000dfcbe73637e244aad1a2eff6dae537cd6b5f\",science.transaction.wai-l=\"0\",science.transaction.wai-lt=\"4\",science.transaction.wai-rcpt=\"7bf0f413db62aa185969b9ccf925d40f0281601268899bb7f2e97344487f4189\",sf=o,st=v,su",
"static-derived": "at=1790000000005,ht=20,nrr=\"0-1023\",ot=o,science.transaction.wai-ch=\"4fc1bd94bd48b2c1ded99c0053eed742f5399cec09dbd2f8694cca6a30e44875\",science.transaction.wai-l=\"1\",science.transaction.wai-lt=\"4\",science.transaction.wai-op=crop,sf=o,st=l",
"static-derived": "at=1790000000005,ht=20,nrr=\"0-1023\",ot=o,science.transaction.wai-ch=\"4fc1bd94bd48b2c1ded99c0053eed742f5399cec09dbd2f8694cca6a30e44875\",science.transaction.wai-l=\"1\",science.transaction.wai-lt=\"4\",science.transaction.wai-op=\"crop\",sf=o,st=l",
"dynamic-two-nodes": "\"edge-7\";etp=25000;mb=8000;rd=3;rtt=40;science.transaction.wai-drop=\"2\",\"origin-1\";du;rd=12"
}

View file

@ -1,28 +1,38 @@
//! Conformance corpus for energy-aware content steering (`live-conformance/steering/`,
//! extensions/energy-steering.md §11).
//!
//! wai_energy_steering_vectors gen <corpus-dir> write vectors.json, from fixed seeds
//! wai_energy_steering_vectors verify <corpus-dir> vectors.json regenerates byte for byte,
//! and every request and vector gets the
//! outcome it states
//! wai_energy_steering_vectors gen <corpus-dir> write vectors.json, from fixed seeds,
//! and cdn.json from it (§8.5)
//! wai_energy_steering_vectors verify <corpus-dir> both files regenerate byte for byte,
//! and every request, vector and case
//! gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/energy_steering_verify.py`, written from the
//! extension's text and sharing no code with this binary.
use std::path::Path;
use wai::energy_steering::corpus;
use wai::energy_steering::{cdn, corpus};
fn main() {
let a: Vec<String> = std::env::args().collect();
match (a.get(1).map(String::as_str), a.get(2)) {
(Some("gen"), Some(d)) => {
let doc = corpus::generate();
let p = Path::new(d).join("vectors.json");
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, corpus::generate())) {
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, &doc)) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
println!("wrote {}", p.display());
let q = Path::new(d).join("cdn.json");
match cdn::generate(&doc).map_err(|e| e.to_string()).and_then(|c| std::fs::write(&q, c).map_err(|e| e.to_string())) {
Ok(()) => println!("wrote {}", q.display()),
Err(e) => {
eprintln!("write {}: {e}", q.display());
std::process::exit(1);
}
}
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
@ -51,5 +61,22 @@ fn verify(dir: &Path) -> i32 {
}
println!("{} every request and vector gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
fail += (!fails.is_empty()) as i32;
let q = dir.join("cdn.json");
let cdn_doc = match std::fs::read_to_string(&q) {
Ok(s) => s,
Err(e) => {
eprintln!("read {}: {e}", q.display());
return 1;
}
};
let same = cdn::generate(&doc).ok().as_deref() == Some(cdn_doc.as_str());
println!("{} cdn.json regenerates byte for byte from vectors.json", if same { "PASS" } else { "FAIL" });
fail += (!same) as i32;
let fails = cdn::verify(&doc, &cdn_doc);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every case of cdn.json gets the outcome it states (§8.5)", if fails.is_empty() { "PASS" } else { "FAIL" });
fail += (!fails.is_empty()) as i32;
(fail != 0) as i32
}

View file

@ -5,8 +5,11 @@
//! every vector's segments and receipts,
//! from fixed seeds
//! wai_live_provenance_vectors verify <corpus-dir> every file regenerates byte for byte, no
//! other file but README.md is there, and
//! every vector gets the outcome it states
//! other file but README.md is there, every
//! vector gets the outcome it states, whole
//! and segment by segment, and the live
//! signer writes the good streams and
//! refuses what a verifier refuses
//!
//! The media are the AV1 floor of the packaging corpus (`pack-conformance/packs/video-base-01`),
//! read from the `wai` directory two levels above the corpus directory. The generator and this
@ -81,13 +84,22 @@ fn verify(dir: &Path) -> i32 {
eprintln!("read {}: {e}", dir.display());
return 1;
}
let generated: BTreeMap<String, Vec<u8>> = corpus::generate(&media(dir)).into_iter().collect();
let m = media(dir);
let generated: BTreeMap<String, Vec<u8>> = corpus::generate(&m).into_iter().collect();
let same = files == generated;
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
let fails = corpus::verify_files(&files);
for f in &fails {
println!("FAIL {f}");
let mut bad = !same;
for (what, fails) in [
("every vector gets the outcome the corpus states", corpus::verify_files(&files)),
("every vector gets it verified as it arrives, segment by segment (§11.7)", corpus::verify_files_live(&files)),
("the live signer writes every good stream byte for byte, one segment at a time (§10.4)", corpus::live_differences(&m)),
("the live signer refuses what a verifier refuses, and the stream continues (§10.4)", corpus::live_refusals(&m)),
] {
for f in &fails {
println!("FAIL {f}");
}
println!("{} {what}", if fails.is_empty() { "PASS" } else { "FAIL" });
bad |= !fails.is_empty();
}
println!("{} every vector gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
(!same || !fails.is_empty()) as i32
bad as i32
}

View file

@ -7,10 +7,12 @@
//! wai_telemetry verify <corpus>
//! wai_telemetry gen <corpus>
//!
//! `verify` reads every observation, CMSD description and session log of the corpus,
//! requires each observation to read back to its own bytes, recomputes every expected
//! file and requires it byte for byte, and requires every observation under `invalid/`
//! to be refused. `gen` writes the corpus; it never runs in CI.
//! `verify` reads every observation, CMSD description, CMSD field and session log of the
//! corpus, requires each observation to read back to its own bytes, recomputes every
//! expected file and requires it byte for byte, requires every observation under
//! `invalid/` to be refused, and requires each CMSD field this crate writes to read back
//! (§6.6) as the description it was written from. `gen` writes the corpus; it never runs
//! in CI.
use std::collections::BTreeMap;
use std::path::Path;
@ -18,7 +20,7 @@ use std::process::ExitCode;
use serde_json::{json, Value};
use wai::telemetry::cmcd::{cmcd, cmcd_headers, from_cmcd, Report};
use wai::telemetry::cmsd::{encode_dynamic, CmsdDynamic, CmsdStatic};
use wai::telemetry::cmsd::{encode_dynamic, read as read_cmsd, CmsdDynamic, CmsdStatic};
use wai::telemetry::observation::Observation;
use wai::telemetry::qoe::session_metrics;
use wai::telemetry::sf::{SfBare, SfMember};
@ -144,6 +146,46 @@ fn cmsd_cases() -> Vec<(&'static str, Value)> {
]
}
/// CMSD fields as other parties write them, read as CTA-5006 §8.1 has a client read them
/// (delivery-telemetry §6.6 to §6.9): each its name, what it shows, its `CMSD-Static`
/// field lines and its `CMSD-Dynamic` field lines (none: the field is absent). The cases
/// marked "Annex A" take the shape of an example of CTA-5006 Annex A with this corpus's own
/// identifiers, paths and figures; the Annex's own strings are not copied.
fn cmsd_read_cases() -> Vec<(&'static str, &'static str, Vec<&'static str>, Vec<&'static str>)> {
vec![
("absent", "no CMSD field: nothing is read and nothing is ignored", vec![], vec![]),
("origin-manifest", "Annex A: an origin's on-demand DASH manifest, needed at start-up", vec![r#"ot=m,sf=d,st=v,su,n="origin-a""#], vec![]),
("prefetch-aligned", "Annex A: next objects, and the byte range of each where one is given, the empty element the whole object", vec![r#"ot=m,sf=d,st=v,su,n="origin-a",nor="/hi/init.mp4|/lo/init.mp4|/hi/track.mp4|/lo/track.mp4",nrr="||0-1499|1500-2999""#], vec![]),
("nor-escaped-bar", "Annex A: a path holding a vertical line, percent-encoded so that it is not a separator; the path is kept as written", vec![r#"nor="/seg/a%7Cb/a%7Cb-12.wai|/seg/a%7Cb/a%7Cb-13.wai""#], vec![]),
("nor-parent", "Annex A: a relative path into a parent folder", vec![r#"nor="../../720/s12.wai""#], vec![]),
("live-unknown-key", "Annex A: a live segment whose field carries `tl`, which Table 1 does not define: an unknown key, ignored (item 3)", vec![r#"ot=v,sf=h,st=l,d=2000,br=4800,tl=9000,n="origin-a""#], vec![]),
("through-two-cdns", "Annex A: a segment through two CDNs, one field line per node, read in order: the first nearest the origin", vec![r#"ot=v,sf=h,st=v,d=6000,br=1450,n="origin-a""#], vec![r#""cdn2-a1";etp=96000;rtt=8"#, r#""cdn2-b2";etp=76000;rtt=32"#, r#""cdn1-c3";etp=48000;rtt=30"#, r#""cdn1-d4";etp=32000;rtt=56;mb=5000"#]),
("through-two-cdns-one-line", "the same nodes in one field line, read as the case before (CTA-5006 §4 calls the two forms equivalent; RFC 9110 §5.3)", vec![r#"ot=v,sf=h,st=v,d=6000,br=1450,n="origin-a""#], vec![r#""cdn2-a1";etp=96000;rtt=8,"cdn2-b2";etp=76000;rtt=32,"cdn1-c3";etp=48000;rtt=30,"cdn1-d4";etp=32000;rtt=56;mb=5000"#]),
("trailing-semicolon", "Annex A: a CMSD-Dynamic entry ending in `;`, which RFC 8941 and RFC 9651 do not parse: the whole CMSD-Dynamic field is ignored (item 1), and CMSD-Static is still read", vec![r#"ot=a,sf=h,st=v,d=6000,br=128,n="origin-a",nor="s-3257.m4a""#], vec![r#""cdn1-d4";etp=12000;rtt=28;"#]),
("duress", "Annex A: an edge under duress (`du`), with a maximum suggested bitrate kept", vec![r#"ot=m,sf=h,su,st=v,n="origin-a",nor="/hi/p.m3u8|/lo/p.m3u8""#], vec![r#""cdn1-d4";etp=12;rtt=28;du"#]),
("custom-key", "Annex A: a custom key with a reverse-DNS prefix and a String value, read as a custom key (§4 item 1)", vec![r#"ot=m,sf=h,st=v,su,n="origin-a",org.example-city="245""#], vec![]),
("version-1-stated", "`v=1`: version 1, read", vec![r#"v=1,ot=v,st=v"#], vec![]),
("version-2", "`v=2`: a later version; the reader ignores the whole data set, both fields (item 8)", vec![r#"v=2,ot=v,st=v,n="origin-a""#], vec![r#""edge-1";etp=100"#]),
("version-not-an-integer", "`v` that is not an Integer: ignored alone (item 4), and the data set read as version 1", vec![r#"v=x,ot=v"#], vec![]),
("types-ignored-alone", "values not of their key's type, each ignored alone (item 4): a negative time, a Decimal rate, a String duration, a Boolean held time, a Token identifier, an object type and a stream type Table 1 does not list, an Integer start-up flag, an inner list holding an unknown format, an empty path and a reversed range", vec![r#"at=-5,br=1.5,d="40",ht=?1,n=origin,ot=x,st=ll,su=1,sf=(h x),nor="a||b",nrr="5-2""#], vec![]),
("sf-inner-list", "`sf` as Table 1 types it, an inner list of tokens, for an object served as DASH and HLS", vec![r#"ot=v,sf=(d h),st=v"#], vec![]),
("not-a-structured-field", "a CMSD-Static field that is not a Dictionary: ignored whole (item 1)", vec![r#"ot=v,,sf=h"#], vec![]),
("dynamic-key-in-static", "a key Table 1 reserves for CMSD-Dynamic, in CMSD-Static: not this header's, ignored as unknown", vec![r#"etp=100,ot=v"#], vec![]),
("static-key-in-dynamic", "a key Table 1 reserves for CMSD-Static, as a CMSD-Dynamic parameter: ignored as unknown", vec![], vec![r#""edge-1";ot=v;rtt=5"#]),
("member-not-a-string", "a CMSD-Dynamic member that is a Token, not a String: its identifier is ignored, its parameters read", vec![], vec![r#"edge-1;rtt=5,"edge-2";rtt=6"#]),
("custom-not-a-string", "custom keys whose values are not Strings, which §4 item 1 requires: each ignored", vec![r#"ot=v,org.example-n=5"#], vec![r#""edge-1";org.example-x=?1;rtt=7"#]),
("wai-keys", "this extension's custom keys (§6.1 to §6.3), the operation token as §6.2 writes it, a String", vec![r#"ot=v,science.transaction.wai-ch="aaf90792d6ff5318e5f306fbb000dfcbe73637e244aad1a2eff6dae537cd6b5f",science.transaction.wai-l="1",science.transaction.wai-lt="4",science.transaction.wai-op="crop",science.transaction.wai-rcpt="7bf0f413db62aa185969b9ccf925d40f0281601268899bb7f2e97344487f4189""#], vec![r#""edge-7";science.transaction.wai-drop="2""#]),
("wai-keys-malformed", "this extension's custom keys holding what §6 does not write: a layer with a leading zero, an upper-case hash, a drop count that is not decimal; each ignored", vec![r#"science.transaction.wai-l="01",science.transaction.wai-ch="AAF90792D6FF5318E5F306FBB000DFCBE73637E244AAD1A2EFF6DAE537CD6B5F""#], vec![r#""edge-7";science.transaction.wai-drop="two""#]),
("wai-op-a-string", "the operation token as a String, the type CTA-5006 §4 item 1 gives a custom key", vec![r#"science.transaction.wai-op="crop""#], vec![]),
("wai-op-a-token", "the operation token as a Token, the form §6.2 wrote before this revision: not a custom key's type (CTA-5006 §4 item 1), so it is ignored, `type`, and the rest read", vec![r#"ot=v,science.transaction.wai-op=crop"#], vec![]),
("rfc9651-date", "a Date (RFC 9651 §3.3.7), which RFC 8941 does not define: read as a Structured Field here, and `at` ignored as not an Integer (§6.6)", vec![r#"at=@1790000000,ot=v"#], vec![]),
("display-string", "a Display String (RFC 9651 §3.3.8) in a custom parameter: not a String, ignored", vec![], vec![r#""edge-1";org.example-x=%"caf%c3%a9";rtt=9"#]),
("empty-identifier", "an intermediary with nothing to add, an empty String and no parameters (CTA-5006 §5)", vec![], vec![r#""""#, r#""edge-1";rtt=4"#]),
("duplicate-key", "a key given twice: its last value, in its first place (RFC 9651 §4.2.2)", vec![r#"ot=v,st=v,ot=a"#], vec![]),
("whitespace", "optional whitespace around members, and a leading space, which RFC 9651 reads", vec![r#" ot=v , st=l"#], vec![r#" "edge-1";rtt=4 , "edge-2""#]),
]
}
/// Session logs: one session each.
fn logs() -> Vec<(&'static str, Vec<Value>)> {
let o = |sid, sn, t, e| obs(sid, sn, Some(T0 + t), e);
@ -215,6 +257,12 @@ fn corpus() -> Result<BTreeMap<String, Vec<u8>>, String> {
cs.insert(n.to_string(), json!(s));
}
files.insert("expected/cmsd.json".into(), pretty(&Value::Object(cs)));
let mut rs = serde_json::Map::new();
for (n, about, st, dy) in cmsd_read_cases() {
files.insert(format!("cmsd-read/{n}.json"), pretty(&json!({"about": about, "static": st, "dynamic": dy})));
rs.insert(n.to_string(), read_cmsd(&st, &dy).to_json());
}
files.insert("expected/cmsd-read.json".into(), pretty(&Value::Object(rs)));
let mut q = serde_json::Map::new();
for (n, log) in logs() {
let mut text = String::new();
@ -251,7 +299,7 @@ fn verify(dir: &Path) -> Result<(), String> {
fails.push(format!("{rel}: not what the generator writes"));
}
}
for sub in ["observations", "invalid", "cmsd", "logs"] {
for sub in ["observations", "invalid", "cmsd", "cmsd-read", "logs"] {
for e in std::fs::read_dir(dir.join(sub)).map_err(|e| format!("{sub}: {e}"))?.flatten() {
let rel = format!("{sub}/{}", e.file_name().to_string_lossy());
if !want.contains_key(&rel) {
@ -284,8 +332,37 @@ fn verify(dir: &Path) -> Result<(), String> {
fails.push(format!("invalid/{n}: accepted"));
}
}
// The CMSD fields other parties write, read from the files on disk (§6.6 to §6.9).
let read_expected: Value = serde_json::from_slice(&std::fs::read(dir.join("expected/cmsd-read.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
for (n, ..) in cmsd_read_cases() {
let v: Value = serde_json::from_slice(&std::fs::read(dir.join(format!("cmsd-read/{n}.json"))).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
let lines = |k: &str| -> Vec<String> { v[k].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default() };
let (st, dy) = (lines("static"), lines("dynamic"));
let got = read_cmsd(&st.iter().map(String::as_str).collect::<Vec<_>>(), &dy.iter().map(String::as_str).collect::<Vec<_>>()).to_json();
if got != read_expected[n] {
fails.push(format!("cmsd-read/{n}: reads otherwise than expected/cmsd-read.json"));
}
}
// Each field this crate writes reads back as the description it was written from.
let written: Value = serde_json::from_slice(&std::fs::read(dir.join("expected/cmsd.json")).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
for (n, v) in cmsd_cases() {
let Some(f) = written[n].as_str() else {
fails.push(format!("cmsd/{n}: not in expected/cmsd.json"));
continue;
};
if let Err(e) = written_reads_back(n, &v, f) {
fails.push(format!("cmsd/{n}: {e}"));
}
}
if fails.is_empty() {
println!("telemetry: {} observations, {} refusals, {} CMSD descriptions and {} session logs verify", observations().len(), invalid().len(), cmsd_cases().len(), logs().len());
println!(
"telemetry: {} observations, {} refusals, {} CMSD descriptions, {} CMSD fields read and {} session logs verify",
observations().len(),
invalid().len(),
cmsd_cases().len(),
cmsd_read_cases().len(),
logs().len()
);
Ok(())
} else {
for f in &fails {
@ -295,6 +372,36 @@ fn verify(dir: &Path) -> Result<(), String> {
}
}
/// A field this crate wrote (§6.1 to §6.3) read back by §6.6: every member the description
/// states, and nothing ignored.
fn written_reads_back(n: &str, v: &Value, field: &str) -> Result<(), String> {
if n.starts_with("static") {
let r = read_cmsd(&[field], &[]);
let s = r.static_.ok_or("CMSD-Static does not read")?;
let want = json!({
"at": v["at"], "br": null, "d": v["d"], "ht": v["ht"], "n": null,
"nor": v["nor"].as_str().map(|x| x.split('|').collect::<Vec<_>>()),
"nrr": v["nrr"].as_str().map(|x| x.split('|').map(|r| r.split_once('-').map(|(a, b)| json!([a.parse::<u64>().unwrap_or(0), b.parse::<u64>().unwrap_or(0)]))).collect::<Vec<_>>()),
"ot": v["ot"], "sf": v["sf"].as_str().map(|x| vec![x]), "st": v["st"], "su": if v["su"] == json!(true) { json!(true) } else { Value::Null }, "v": 1,
"content_hash": v["content_hash"], "layer": v["layer"], "layers_total": v["layers_total"], "receipt": v["receipt"], "op": v["op"], "custom": {},
});
if s.to_json() != want || !r.ignored.is_empty() {
return Err(format!("reads back as {}", s.to_json()));
}
} else {
let r = read_cmsd(&[], &[field]);
let d = r.dynamic.ok_or("CMSD-Dynamic does not read")?;
let want: Vec<Value> = v.as_array().ok_or("a list")?.iter().map(|e| json!({
"server": e["server"], "du": if e["du"] == json!(true) { json!(true) } else { Value::Null }, "etp": e["etp"], "mb": e["mb"], "rd": e["rd"], "rtt": e["rtt"], "drop": e["drop"], "custom": {},
})).collect();
let got: Vec<Value> = d.iter().map(|e| e.to_json()).collect();
if got != want || !r.ignored.is_empty() {
return Err(format!("reads back as {}", json!(got)));
}
}
Ok(())
}
fn member_json(m: &SfMember) -> Value {
match m {
SfMember::Item(SfBare::Int(i)) => json!(i),

View file

@ -14,11 +14,12 @@
//!
//! - §3: the policy, its rules and its signature;
//! - §4: the client's report, read from a steering request over HLS or DASH and from CMCD,
//! with an RFC 9651 parser of its own ([`sf`]);
//! with the whole-grammar RFC 9651 parser delivery telemetry's CMSD reader shares ([`sf`]);
//! - §5: a pathway's window, coverage, status and figure;
//! - §6: the decision rule ([`decide`]);
//! - §7: the decision record, its rules and its signature;
//! - §8: the steering manifest, the HLS tag and the DASH element;
//! - §8: the steering manifest, the HLS tag and the DASH element, and the CMSD and CMCD
//! fields that name a decision and its hops to CDNs ([`cdn`], §8.5);
//! - §9: verification ([`verify`]).
//!
//! The corpus `live-conformance/steering/` is written by [`corpus::generate`] and checked
@ -38,6 +39,7 @@ use crate::live_events as events;
use crate::live_metering::{self as metering, HopRecord, HOP_AUTHORITIES};
use crate::live_room::{self as room, Descriptor};
pub mod cdn;
pub mod corpus;
/// The record this extension registers.
@ -797,311 +799,8 @@ pub fn read_request(protocol: u8, target: &[u8], cmcd_header: Option<&[u8]>, pol
Report::new(c, t, b)
}
/// RFC 9651 §4.2: Structured Field Values, parsed whole.
pub mod sf {
/// A bare item (RFC 9651 §3.3).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Bare {
Integer(i64),
/// A Decimal, as its value × 1000.
Decimal(i64),
String(Vec<u8>),
Token(String),
Bytes(Vec<u8>),
Boolean(bool),
Date(i64),
Display(String),
}
/// An item with its parameters.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Item {
pub bare: Bare,
pub params: Vec<(String, Bare)>,
}
/// A dictionary member: an item, or an inner list with its parameters.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Member {
Item(Item),
Inner(Vec<Item>, Vec<(String, Bare)>),
}
struct P<'a> {
b: &'a [u8],
i: usize,
}
type R<T> = Result<T, ()>;
impl P<'_> {
fn peek(&self) -> Option<u8> {
self.b.get(self.i).copied()
}
fn sp(&mut self) {
while self.peek() == Some(b' ') {
self.i += 1;
}
}
fn ows(&mut self) {
while matches!(self.peek(), Some(b' ' | b'\t')) {
self.i += 1;
}
}
fn key(&mut self) -> R<String> {
match self.peek() {
Some(c) if c.is_ascii_lowercase() || c == b'*' => {}
_ => return Err(()),
}
let s = self.i;
while let Some(c) = self.peek() {
if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'_' | b'-' | b'.' | b'*') {
self.i += 1;
} else {
break;
}
}
String::from_utf8(self.b[s..self.i].to_vec()).map_err(|_| ())
}
fn params(&mut self) -> R<Vec<(String, Bare)>> {
let mut out: Vec<(String, Bare)> = Vec::new();
while self.peek() == Some(b';') {
self.i += 1;
self.sp();
let k = self.key()?;
let v = if self.peek() == Some(b'=') {
self.i += 1;
self.bare()?
} else {
Bare::Boolean(true)
};
match out.iter_mut().find(|(x, _)| *x == k) {
Some(slot) => slot.1 = v,
None => out.push((k, v)),
}
}
Ok(out)
}
fn item(&mut self) -> R<Item> {
let bare = self.bare()?;
let params = self.params()?;
Ok(Item { bare, params })
}
fn inner(&mut self) -> R<Member> {
self.i += 1; // (
let mut items = Vec::new();
loop {
self.sp();
if self.peek() == Some(b')') {
self.i += 1;
let params = self.params()?;
return Ok(Member::Inner(items, params));
}
items.push(self.item()?);
match self.peek() {
Some(b' ' | b')') => {}
_ => return Err(()),
}
}
}
fn member(&mut self) -> R<Member> {
if self.peek() == Some(b'(') { self.inner() } else { Ok(Member::Item(self.item()?)) }
}
fn number(&mut self) -> R<Bare> {
let neg = if self.peek() == Some(b'-') {
self.i += 1;
true
} else {
false
};
if !self.peek().is_some_and(|c| c.is_ascii_digit()) {
return Err(());
}
let s = self.i;
let mut dot = None;
while let Some(c) = self.peek() {
if c.is_ascii_digit() {
self.i += 1;
} else if c == b'.' && dot.is_none() {
if self.i - s > 12 {
return Err(());
}
dot = Some(self.i);
self.i += 1;
} else {
break;
}
let len = self.i - s;
if dot.is_none() && len > 15 || dot.is_some() && len > 16 {
return Err(());
}
}
let digits = std::str::from_utf8(&self.b[s..self.i]).map_err(|_| ())?;
let sign = if neg { -1 } else { 1 };
match dot {
None => Ok(Bare::Integer(sign * digits.parse::<i64>().map_err(|_| ())?)),
Some(d) => {
let frac = self.i - d - 1;
if frac == 0 || frac > 3 {
return Err(());
}
let (int, f) = digits.split_at(d - s);
let f = &f[1..];
let scaled = int.parse::<i64>().map_err(|_| ())? * 1000 + f.parse::<i64>().map_err(|_| ())? * 10i64.pow(3 - frac as u32);
Ok(Bare::Decimal(sign * scaled))
}
}
}
fn bare(&mut self) -> R<Bare> {
match self.peek().ok_or(())? {
b'-' | b'0'..=b'9' => self.number(),
b'"' => {
self.i += 1;
let mut o = Vec::new();
loop {
let c = self.peek().ok_or(())?;
self.i += 1;
match c {
b'\\' => {
let n = self.peek().ok_or(())?;
if n != b'"' && n != b'\\' {
return Err(());
}
self.i += 1;
o.push(n);
}
b'"' => return Ok(Bare::String(o)),
0x20..=0x7e => o.push(c),
_ => return Err(()),
}
}
}
c if c.is_ascii_alphabetic() || c == b'*' => {
let s = self.i;
self.i += 1;
while let Some(c) = self.peek() {
if c.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~:/".contains(&c) {
self.i += 1;
} else {
break;
}
}
Ok(Bare::Token(String::from_utf8(self.b[s..self.i].to_vec()).map_err(|_| ())?))
}
b':' => {
self.i += 1;
let s = self.i;
loop {
let c = self.peek().ok_or(())?;
if c == b':' {
break;
}
if !(c.is_ascii_alphanumeric() || matches!(c, b'+' | b'/' | b'=')) {
return Err(());
}
self.i += 1;
}
let v = self.b[s..self.i].to_vec();
self.i += 1;
if !base64_ok(&v) {
return Err(());
}
Ok(Bare::Bytes(v))
}
b'?' => {
self.i += 1;
let v = match self.peek() {
Some(b'0') => false,
Some(b'1') => true,
_ => return Err(()),
};
self.i += 1;
Ok(Bare::Boolean(v))
}
b'@' => {
self.i += 1;
match self.number()? {
Bare::Integer(x) => Ok(Bare::Date(x)),
_ => Err(()),
}
}
b'%' => {
self.i += 1;
if self.peek() != Some(b'"') {
return Err(());
}
self.i += 1;
let mut o = Vec::new();
loop {
let c = self.peek().ok_or(())?;
self.i += 1;
match c {
b'%' => {
let h = self.b.get(self.i..self.i + 2).ok_or(())?;
if !h.iter().all(|&x| x.is_ascii_digit() || (b'a'..=b'f').contains(&x)) {
return Err(());
}
o.push(u8::from_str_radix(std::str::from_utf8(h).map_err(|_| ())?, 16).map_err(|_| ())?);
self.i += 2;
}
b'"' => return Ok(Bare::Display(String::from_utf8(o).map_err(|_| ())?)),
0x20..=0x7e => o.push(c),
_ => return Err(()),
}
}
}
_ => Err(()),
}
}
}
/// Whether a Byte Sequence's content decodes as base64 (RFC 4648 §4), its padding
/// synthesized where it is missing (RFC 9651 §4.2.7): at most two `=`, only at the end,
/// and a length the padding completes to a multiple of four.
fn base64_ok(v: &[u8]) -> bool {
let q = v.iter().rev().take_while(|&&c| c == b'=').count();
let core = &v[..v.len() - q];
q <= 2 && core.iter().all(|&c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/') && if q == 0 { core.len() % 4 != 1 } else { (core.len() + q).is_multiple_of(4) }
}
/// Parse a Dictionary field value (RFC 9651 §4.2, §4.2.2): `None` when it does not
/// parse. A key given twice keeps its first place and its last value.
pub fn parse_dictionary(s: &str) -> Option<Vec<(String, Member)>> {
dictionary(s).ok()
}
fn dictionary(s: &str) -> R<Vec<(String, Member)>> {
let mut p = P { b: s.as_bytes(), i: 0 };
p.sp();
let mut out: Vec<(String, Member)> = Vec::new();
while p.i < p.b.len() {
let k = p.key()?;
let v = if p.peek() == Some(b'=') {
p.i += 1;
p.member()?
} else {
Member::Item(Item { bare: Bare::Boolean(true), params: p.params()? })
};
match out.iter_mut().find(|(x, _)| *x == k) {
Some(slot) => slot.1 = v,
None => out.push((k, v)),
}
p.ows();
if p.i == p.b.len() {
break;
}
if p.peek() != Some(b',') {
return Err(());
}
p.i += 1;
p.ows();
if p.i == p.b.len() {
return Err(());
}
}
Ok(out)
}
}
/// RFC 9651 §4.2, parsed whole: the parser delivery telemetry's CMSD reader also uses.
pub use crate::telemetry::sf::full as sf;
// ---------------------------------------------------------------------------------------
// §5 A pathway's evidence

View file

@ -0,0 +1,555 @@
//! The fields for CDNs (extensions/energy-steering.md §8.5): a decision and the hops that
//! deliver under it, named in CMSD (CTA-5006) on responses and in CMCD (CTA-5004-B) on
//! requests, for CDNs and players that already parse those fields.
//!
//! - [`steering_static`]: the `CMSD-Static` field a steering server sends with a manifest,
//! and [`check_response`], a client's check of it against the manifest's member (§8.4);
//! - [`with_decision`]: a CMCD report or header shard with the decision's key added;
//! - [`hop_entry`]: a hop's own `CMSD-Dynamic` entry, naming the key that signs its
//! per-hop session records (live-metering §3.1);
//! - [`served_by`]: which of a policy's hops a response's `CMSD-Dynamic` field says served
//! it, on the pathway the client applied.
//!
//! Nothing here is evidence and nothing here is read by the decision rule (§6): neither field
//! is signed. CMSD is read by [`crate::telemetry::cmsd::read`] (delivery-telemetry §6.6 to
//! §6.8). The corpus `live-conformance/steering/cdn.json` is written by [`generate`] from
//! `vectors.json` and checked by [`verify`].
use std::collections::BTreeSet;
use serde_json::{json, Map, Value};
use super::{place, Policy, MANIFEST_KEY};
use crate::telemetry::cmcd::PREFIX;
use crate::telemetry::cmsd::{self, CmsdDynamic};
use crate::telemetry::sf::{parse_dict, serialize_dict, serialize_list, SfBare, SfItem, SfMember};
/// The custom key that names a decision, in CMSD-Static and in CMCD (§8.5).
pub const DECISION_KEY: &str = "science.transaction.wai-sd";
/// The custom parameter that names a hop in its CMSD-Dynamic entry (§8.5).
pub const HOP_KEY: &str = "science.transaction.wai-hop";
/// The reasons of §8.5.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Reason {
CmsdDecisionMismatch,
PathwayUnknown,
ServedMalformed,
ServedUnknownHop,
ServedOffPathway,
ServedOutOfOrder,
}
impl Reason {
/// In §8.5's order.
pub const ALL: [Reason; 6] = [
Reason::CmsdDecisionMismatch,
Reason::PathwayUnknown,
Reason::ServedMalformed,
Reason::ServedUnknownHop,
Reason::ServedOffPathway,
Reason::ServedOutOfOrder,
];
pub fn code(self) -> &'static str {
match self {
Reason::CmsdDecisionMismatch => "cmsd_decision_mismatch",
Reason::PathwayUnknown => "pathway_unknown",
Reason::ServedMalformed => "served_malformed",
Reason::ServedUnknownHop => "served_unknown_hop",
Reason::ServedOffPathway => "served_off_pathway",
Reason::ServedOutOfOrder => "served_out_of_order",
}
}
pub fn from_code(c: &str) -> Option<Reason> {
Reason::ALL.into_iter().find(|r| r.code() == c)
}
}
impl std::fmt::Display for Reason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.code())
}
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// 64 lowercase hexadecimal digits, as 32 bytes.
fn unhex32(s: &str) -> Option<[u8; 32]> {
if s.len() != 64 || !s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) {
return None;
}
let mut o = [0u8; 32];
for (i, x) in o.iter_mut().enumerate() {
*x = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
}
Some(o)
}
// ---------------------------------------------------------------------------------------
// The steering response
// ---------------------------------------------------------------------------------------
/// The `CMSD-Static` field of a steering response (§8.5): `n`, `ot=m` and the decision's
/// digest, in bytewise order.
pub fn steering_static(server: &str, decision: &[u8; 32]) -> Result<String, String> {
let m = vec![
("n".to_string(), SfMember::Item(SfBare::Str(server.to_string()))),
("ot".to_string(), SfMember::Item(SfBare::Token("m".into()))),
(DECISION_KEY.to_string(), SfMember::Item(SfBare::Str(hex(decision)))),
];
serialize_dict(&m).map_err(|e| e.to_string())
}
/// The decision a steering manifest names (§8.4): the digest its member ends in.
pub fn manifest_decision(manifest: &str) -> Option<[u8; 32]> {
let v: Value = serde_json::from_str(manifest).ok()?;
let m = v.get(MANIFEST_KEY)?.as_str()?;
unhex32(m.get(m.len().checked_sub(64)?..)?)
}
/// What a steering response's `CMSD-Static` field states of its decision (§8.5).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Binding {
/// The field names the decision the manifest names.
Bound,
/// The field states nothing of a decision.
Absent,
}
/// A client's check of a steering response (§8.5): the manifest it carried, and its
/// `CMSD-Static` field lines (none when absent).
pub fn check_response(manifest: &str, static_lines: &[&str]) -> Result<Binding, Reason> {
let read = cmsd::read(static_lines, &[]);
let stated = read.static_.as_ref().and_then(|s| s.custom.get(DECISION_KEY)).and_then(|v| unhex32(v));
match (stated, manifest_decision(manifest)) {
(None, _) => Ok(Binding::Absent),
(Some(d), Some(m)) if d == m => Ok(Binding::Bound),
_ => Err(Reason::CmsdDecisionMismatch),
}
}
// ---------------------------------------------------------------------------------------
// Media requests
// ---------------------------------------------------------------------------------------
/// A CMCD report, or a header shard, with the decision's key (§8.5): inserted in bytewise
/// order, every other member kept as it was, a key already there given the new digest.
/// The report is read as delivery-telemetry §3.3 writes one.
pub fn with_decision(report: &str, decision: &[u8; 32]) -> Result<String, String> {
let mut m = parse_dict(report).map_err(|e| e.to_string())?;
m.retain(|(k, _)| k != DECISION_KEY);
let at = m.iter().position(|(k, _)| k.as_bytes() > DECISION_KEY.as_bytes()).unwrap_or(m.len());
m.insert(at, (DECISION_KEY.to_string(), SfMember::Item(SfBare::Str(hex(decision)))));
serialize_dict(&m).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------------------
// Hops in CMSD-Dynamic
// ---------------------------------------------------------------------------------------
/// A hop's own `CMSD-Dynamic` entry (§8.5): its identifier and delivery-telemetry §6.3's
/// parameters, with the key that signs its per-hop records, in bytewise order.
pub fn hop_entry(e: &CmsdDynamic, hop: &[u8; 32]) -> Result<String, String> {
let mut params: Vec<(String, SfBare)> = Vec::new();
if e.du {
params.push(("du".into(), SfBare::Bool(true)));
}
for (k, v) in [("etp", e.etp), ("mb", e.mb), ("rd", e.rd), ("rtt", e.rtt)] {
if let Some(v) = v {
params.push((k.into(), SfBare::Int(i64::try_from(v).map_err(|_| format!("{k}: too large"))?)));
}
}
if let Some(d) = e.drop {
params.push((format!("{PREFIX}drop"), SfBare::Str(d.to_string())));
}
params.push((HOP_KEY.into(), SfBare::Str(hex(hop))));
params.sort_by(|a, b| a.0.cmp(&b.0));
serialize_list(&[SfItem { value: SfBare::Str(e.server.clone()), params }]).map_err(|e| e.to_string())
}
// ---------------------------------------------------------------------------------------
// Served by
// ---------------------------------------------------------------------------------------
/// A hop a response names, on the pathway it was served on.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServedHop {
/// The `CMSD-Dynamic` entry that names it, from 0.
pub entry: usize,
pub hop: [u8; 32],
/// [`place::EDGE`] or [`place::INTERIOR`].
pub place: u8,
}
/// Which of a policy's hops a response says served it (§8.5).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Attribution {
Attributed(Vec<ServedHop>),
Unattributed,
}
/// A refusal of §8.5's steps, with the entry it was found at.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Refusal {
pub reason: Reason,
pub entry: Option<usize>,
}
/// §8.5 *Served by*: the policy, the `id` of the pathway the client applied, and the
/// response's `CMSD-Static` and `CMSD-Dynamic` field lines (none when absent); the first
/// matters only for its version (delivery-telemetry §6, paragraph 6.6).
pub fn served_by(policy: &Policy, pathway: &str, static_lines: &[&str], dynamic_lines: &[&str]) -> Result<Attribution, Refusal> {
let refuse = |reason, entry| Err(Refusal { reason, entry });
let Some(p) = policy.pathways.iter().position(|x| x.id == pathway) else {
return refuse(Reason::PathwayUnknown, None);
};
let read = cmsd::read(static_lines, dynamic_lines);
let Some(entries) = read.dynamic else {
return Ok(Attribution::Unattributed);
};
let mut out: Vec<ServedHop> = Vec::new();
let mut seen = BTreeSet::new();
for (i, e) in entries.iter().enumerate() {
// A parameter of another type is ignored by the reader (delivery-telemetry §6.7),
// and is malformed here.
let wrong_type = read.ignored.iter().any(|g| g.field == "dynamic" && g.entry == Some(i) && g.key.as_deref() == Some(HOP_KEY));
let named = e.custom.get(HOP_KEY);
if named.is_none() && !wrong_type {
continue;
}
let Some(key) = named.and_then(|v| unhex32(v)) else {
return refuse(Reason::ServedMalformed, Some(i));
};
if !seen.insert(key) {
return refuse(Reason::ServedMalformed, Some(i));
}
let Some((on, pl)) = policy.pathways.iter().enumerate().find_map(|(j, x)| x.hops.iter().find(|(k, _)| *k == key).map(|(_, pl)| (j, *pl))) else {
return refuse(Reason::ServedUnknownHop, Some(i));
};
if on != p {
return refuse(Reason::ServedOffPathway, Some(i));
}
if out.iter().any(|h| h.place == place::EDGE) {
return refuse(Reason::ServedOutOfOrder, Some(i));
}
out.push(ServedHop { entry: i, hop: key, place: pl });
}
Ok(if out.is_empty() { Attribution::Unattributed } else { Attribution::Attributed(out) })
}
// ---------------------------------------------------------------------------------------
// The corpus: live-conformance/steering/cdn.json
// ---------------------------------------------------------------------------------------
fn pretty(v: &Value) -> String {
let mut s = serde_json::to_string_pretty(v).expect("JSON");
s.push('\n');
s
}
fn place_label(p: u8) -> &'static str {
if p == place::EDGE { "edge" } else { "interior" }
}
/// One response's outcome, as the corpus states it.
fn response_json(r: Result<Binding, Reason>) -> Value {
match r {
Ok(Binding::Bound) => json!({"outcome": "bound"}),
Ok(Binding::Absent) => json!({"outcome": "absent"}),
Err(e) => json!({"outcome": "refuse", "reason": e.code()}),
}
}
fn served_json(r: Result<Attribution, Refusal>) -> Value {
match r {
Ok(Attribution::Unattributed) => json!({"outcome": "unattributed"}),
Ok(Attribution::Attributed(hs)) => json!({
"outcome": "attributed",
"hops": hs.iter().map(|h| json!({"entry": h.entry, "hop": hex(&h.hop), "place": place_label(h.place)})).collect::<Vec<_>>(),
}),
Err(f) => json!({"outcome": "refuse", "reason": f.reason.code(), "entry": f.entry}),
}
}
struct Doc {
policies: Map<String, Value>,
vectors: Vec<Value>,
}
fn doc_of(vectors_json: &str) -> Result<Doc, String> {
let v: Value = serde_json::from_str(vectors_json).map_err(|e| format!("vectors.json: {e}"))?;
Ok(Doc {
policies: v["policies"].as_object().cloned().ok_or("vectors.json: no policies")?,
vectors: v["vectors"].as_array().cloned().ok_or("vectors.json: no vectors")?,
})
}
impl Doc {
fn policy(&self, name: &str) -> Result<Policy, String> {
let h = self.policies.get(name).and_then(|p| p["policy"].as_str()).ok_or(format!("no policy {name}"))?;
let b: Vec<u8> = (0..h.len() / 2).map(|i| u8::from_str_radix(&h[2 * i..2 * i + 2], 16)).collect::<Result<_, _>>().map_err(|e| e.to_string())?;
Policy::read(&b).map_err(|e| format!("policy {name}: {e:?}"))
}
fn vector(&self, name: &str) -> Result<&Value, String> {
self.vectors.iter().find(|v| v["name"] == name).ok_or(format!("no vector {name}"))
}
fn digest(&self, name: &str) -> Result<[u8; 32], String> {
self.vector(name)?["digest"].as_str().and_then(unhex32).ok_or(format!("vector {name}: no digest"))
}
fn manifest(&self, name: &str) -> Result<String, String> {
self.vector(name)?["steering_manifest"].as_str().map(String::from).ok_or(format!("vector {name}: no manifest"))
}
}
/// A case of `served`: its name, what it shows, the pathway, and the CMSD-Static and
/// CMSD-Dynamic field lines.
type ServedCase = (&'static str, &'static str, &'static str, Vec<String>, Vec<String>);
/// The hop of `pathway` at `place` in a policy, its key.
fn hop_of(p: &Policy, pathway: &str, pl: u8) -> [u8; 32] {
p.pathways.iter().find(|x| x.id == pathway).and_then(|x| x.hops.iter().find(|(_, q)| *q == pl)).map(|(k, _)| *k).expect("a hop of that place")
}
fn entry(server: &str, etp: Option<u64>, rtt: Option<u64>) -> CmsdDynamic {
CmsdDynamic { server: server.into(), du: false, etp, mb: None, rd: None, rtt, drop: None }
}
/// `cdn.json` from `vectors.json` (§11): each case built from the accepted vectors and the
/// policies there, and its outcome computed here.
pub fn generate(vectors_json: &str) -> Result<String, String> {
let d = doc_of(vectors_json)?;
let three = d.policy("three")?;
let (tree_edge, tree_core) = (hop_of(&three, "tree", place::EDGE), hop_of(&three, "tree", place::INTERIOR));
let a_edge = hop_of(&three, "edge-a", place::EDGE);
let stranger: [u8; 32] = {
use sha2::{Digest, Sha256};
Sha256::digest(b"wai:steering/corpus/cdn/stranger").into()
};
let srv = "steer-1";
// Written fields.
let mut written = Vec::new();
for v in ["three_metered", "dash"] {
written.push(json!({"vector": v, "server": srv, "cmsd_static": steering_static(srv, &d.digest(v)?)?}));
}
let core_e = hop_entry(&entry("relay-core", Some(800_000), Some(4)), &tree_core)?;
let edge_e = hop_entry(&CmsdDynamic { server: "relay-edge".into(), du: false, etp: Some(25_000), mb: Some(8_000), rd: Some(3), rtt: Some(40), drop: Some(1) }, &tree_edge)?;
let a_edge_e = hop_entry(&entry("edge-a-1", Some(30_000), Some(20)), &a_edge)?;
let stranger_e = hop_entry(&entry("stranger", Some(1), None), &stranger)?;
let hops = vec![
json!({"policy": "three", "pathway": "tree", "place": "interior", "server": "relay-core", "etp": 800_000, "rtt": 4, "cmsd_dynamic": core_e}),
json!({"policy": "three", "pathway": "tree", "place": "edge", "server": "relay-edge", "etp": 25_000, "mb": 8_000, "rd": 3, "rtt": 40, "drop": 1, "cmsd_dynamic": edge_e}),
json!({"policy": "three", "pathway": "edge-a", "place": "edge", "server": "edge-a-1", "etp": 30_000, "rtt": 20, "cmsd_dynamic": a_edge_e}),
];
// Steering responses.
let bound = steering_static(srv, &d.digest("three_metered")?)?;
let other = steering_static(srv, &d.digest("mixed_classes")?)?;
let responses: Vec<(&str, &str, &str, Vec<String>)> = vec![
("bound", "the field names the decision the manifest names", "three_metered", vec![bound.clone()]),
("bound-dash", "a DASH client's steering response, bound", "dash", vec![steering_static(srv, &d.digest("dash")?)?]),
("bound-extended", "an intermediary has extended the field with a key of its own (CTA-5006 §4); the decision is still read", "three_metered", vec![format!("{bound},org.example-tier=\"gold\"")]),
("bound-two-lines", "the field in two field lines, read as one (RFC 9110 §5.3)", "three_metered", vec!["n=\"steer-1\",ot=m".into(), bound[bound.find("science").expect("key")..].to_string()]),
("absent", "no CMSD-Static field: nothing is stated, which is no fault", "three_metered", vec![]),
("absent-no-key", "a field with no decision key", "three_metered", vec!["n=\"steer-1\",ot=m".into()]),
("absent-unparsed", "a field that is not a Dictionary, ignored whole (delivery-telemetry §6.6)", "three_metered", vec![format!("{bound},,")]),
("absent-version-2", "a field of a later version: the data set is ignored (CTA-5006 §8.1 item 8)", "three_metered", vec![format!("v=2,{bound}")]),
("absent-not-hex", "a decision key that is not 64 lowercase hexadecimal digits: not in §8.5's form, ignored", "three_metered", vec![format!("n=\"steer-1\",ot=m,{DECISION_KEY}=\"{}\"", hex(&d.digest("three_metered")?).to_uppercase())]),
("mismatch", "the field names another decision than the manifest's", "three_metered", vec![other]),
];
let mut rs = Vec::new();
for (name, about, v, lines) in &responses {
let m = d.manifest(v)?;
let l: Vec<&str> = lines.iter().map(String::as_str).collect();
let mut o = json!({"name": name, "about": about, "vector": v, "cmsd_static": lines});
o.as_object_mut().expect("object").extend(response_json(check_response(&m, &l)).as_object().expect("object").clone());
rs.push(o);
}
// CMCD.
let dg = d.digest("three_metered")?;
let sid = "\"6f1c2a9e0b7d4c3a8e5f1d2c3b4a5968\"";
let cmcd_cases: Vec<(&str, &str, String)> = vec![
("query-report", "a Request-mode report as the CMCD query carries it", format!("bl=2100,br=1450,mtp=4800,ot=v,sf=h,sid={sid},st=l,v=2")),
("session-shard", "the CMCD-Session header shard", format!("sf=h,sid={sid},st=l,v=2")),
("session-shard-empty", "a client that sends no other session key: the shard holds the decision's key alone", String::new()),
("replaced", "a report carrying an earlier manifest's decision: the key takes the new digest", format!("sf=h,{DECISION_KEY}=\"{}\",sid={sid},v=2", hex(&d.digest("mixed_classes")?))),
("custom-keys-around", "a report with custom keys before and after the decision's", format!("ot=v,science.transaction.wai-ch=\"{}\",science.transaction.wai-tx=\"12\",sid={sid},v=2", hex(&[0xab; 32]))),
];
let mut cm = Vec::new();
for (name, about, report) in &cmcd_cases {
cm.push(json!({"name": name, "about": about, "vector": "three_metered", "report": report, "with_decision": with_decision(report, &dg)?}));
}
// Served by.
let served_cases: Vec<ServedCase> = vec![
("tree", "the relay tree's interior hop, then its edge hop, nearest the client last", "tree", vec![], vec![core_e.clone(), edge_e.clone()]),
("tree-one-line", "the same entries in one field line", "tree", vec![], vec![format!("{core_e},{edge_e}")]),
("tree-through-other-servers", "other servers' entries before, between and after the hops: passed over", "tree", vec![], vec!["\"cdn1-a7\";etp=900000;rtt=2".into(), core_e.clone(), "\"shield-2\"".into(), edge_e.clone(), "\"isp-cache\";rtt=1".into()]),
("tree-edge-only", "the edge hop alone, its interior hop writing no entry", "tree", vec![], vec![edge_e.clone()]),
("edge-a", "a hop of the pathway the client applied, edge-a", "edge-a", vec![], vec![a_edge_e.clone()]),
("unattributed-no-hop", "a field that names no hop", "tree", vec![], vec!["\"cdn1-a7\";etp=900000;rtt=2".into()]),
("unattributed-absent", "no CMSD-Dynamic field", "tree", vec![], vec![]),
("unattributed-unparsed", "a field that does not parse, ignored whole", "tree", vec![], vec![core_e.clone(), format!("{edge_e};")]),
("off-pathway", "the client applied the tree, and the field names edge-a's edge hop", "tree", vec![], vec![core_e.clone(), a_edge_e.clone()]),
("unknown-hop", "a key that is no hop of the policy", "tree", vec![], vec![core_e.clone(), stranger_e.clone()]),
("malformed-hex", "a hop key that is not 64 lowercase hexadecimal digits", "tree", vec![], vec![format!("\"relay-core\";{HOP_KEY}=\"{}\"", hex(&tree_core).to_uppercase())]),
("malformed-not-a-string", "a hop key that is a Boolean, not a String", "tree", vec![], vec![format!("\"relay-core\";{HOP_KEY}")]),
("malformed-twice", "one hop named by two entries", "tree", vec![], vec![core_e.clone(), core_e.clone(), edge_e.clone()]),
("out-of-order", "the interior hop named after the edge hop", "tree", vec![], vec![edge_e.clone(), core_e.clone()]),
("pathway-unknown", "a pathway the policy does not have", "nowhere", vec![], vec![core_e.clone(), edge_e.clone()]),
("unattributed-version-2", "a response whose CMSD-Static field is of a later version: its whole data set is ignored, the hops it names with it", "tree", vec!["v=2,ot=v".into()], vec![core_e.clone(), edge_e.clone()]),
];
let mut sv = Vec::new();
for (name, about, pathway, st, lines) in &served_cases {
let sl: Vec<&str> = st.iter().map(String::as_str).collect();
let l: Vec<&str> = lines.iter().map(String::as_str).collect();
let mut o = json!({"name": name, "about": about, "policy": "three", "pathway": pathway, "cmsd_static": st, "cmsd_dynamic": lines});
o.as_object_mut().expect("object").extend(served_json(served_by(&three, pathway, &sl, &l)).as_object().expect("object").clone());
sv.push(o);
}
let doc = json!({
"corpus": "wai.live.steering",
"extension": "extensions/energy-steering.md",
"section": "8.5",
"from": "vectors.json",
"written": {"steering_static": written, "hop_entries": hops},
"responses": rs,
"cmcd": cm,
"served": sv,
"codes": Reason::ALL.iter().map(|r| r.code()).collect::<Vec<_>>(),
});
Ok(pretty(&doc))
}
/// Every case of `cdn.json` against [`steering_static`], [`check_response`],
/// [`with_decision`], [`hop_entry`] and [`served_by`], from its own inputs and
/// `vectors.json`: the failures, as strings.
pub fn verify(vectors_json: &str, cdn_json: &str) -> Vec<String> {
let mut fails = Vec::new();
let (d, c): (Doc, Value) = match (doc_of(vectors_json), serde_json::from_str(cdn_json)) {
(Ok(d), Ok(c)) => (d, c),
(Err(e), _) => return vec![e],
(_, Err(e)) => return vec![format!("cdn.json: {e}")],
};
let arr = |k: &str| c[k].as_array().cloned().unwrap_or_default();
let lines = |v: &Value, k: &str| -> Vec<String> { v[k].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect()).unwrap_or_default() };
let mut seen = BTreeSet::new();
for w in c["written"]["steering_static"].as_array().cloned().unwrap_or_default() {
let got = w["vector"].as_str().ok_or("a vector").and_then(|v| d.digest(v).map_err(|_| "a digest")).map(|g| steering_static(w["server"].as_str().unwrap_or(""), &g));
if !matches!(got, Ok(Ok(ref s)) if Some(s.as_str()) == w["cmsd_static"].as_str()) {
fails.push(format!("written steering_static for {}: not the field §8.5 writes", w["vector"]));
}
}
for h in c["written"]["hop_entries"].as_array().cloned().unwrap_or_default() {
let ok = (|| -> Result<bool, String> {
let p = d.policy(h["policy"].as_str().ok_or("a policy")?)?;
let pl = if h["place"] == "edge" { place::EDGE } else { place::INTERIOR };
let key = hop_of(&p, h["pathway"].as_str().ok_or("a pathway")?, pl);
let e = CmsdDynamic {
server: h["server"].as_str().ok_or("a server")?.into(),
du: false,
etp: h["etp"].as_u64(),
mb: h["mb"].as_u64(),
rd: h["rd"].as_u64(),
rtt: h["rtt"].as_u64(),
drop: h["drop"].as_u64(),
};
Ok(hop_entry(&e, &key)? == h["cmsd_dynamic"].as_str().unwrap_or(""))
})();
if ok != Ok(true) {
fails.push(format!("written hop entry {}: not the entry §8.5 writes", h["server"]));
}
}
for r in arr("responses") {
let name = r["name"].as_str().unwrap_or("?");
let Ok(m) = d.manifest(r["vector"].as_str().unwrap_or("")) else {
fails.push(format!("response {name}: no manifest"));
continue;
};
let l = lines(&r, "cmsd_static");
let got = response_json(check_response(&m, &l.iter().map(String::as_str).collect::<Vec<_>>()));
if got["outcome"] != r["outcome"] || got.get("reason") != r.get("reason") {
fails.push(format!("response {name}: {got}, the corpus states {} {}", r["outcome"], r.get("reason").unwrap_or(&Value::Null)));
}
if let Some(x) = got["reason"].as_str() {
seen.insert(x.to_string());
}
}
for m in arr("cmcd") {
let name = m["name"].as_str().unwrap_or("?");
let got = d.digest(m["vector"].as_str().unwrap_or("")).and_then(|g| with_decision(m["report"].as_str().unwrap_or(""), &g));
if got.as_deref().ok() != m["with_decision"].as_str() {
fails.push(format!("cmcd {name}: the key is not added as §8.5 adds it"));
}
}
for s in arr("served") {
let name = s["name"].as_str().unwrap_or("?");
let Ok(p) = d.policy(s["policy"].as_str().unwrap_or("")) else {
fails.push(format!("served {name}: no policy"));
continue;
};
let (st, l) = (lines(&s, "cmsd_static"), lines(&s, "cmsd_dynamic"));
let got = served_json(served_by(&p, s["pathway"].as_str().unwrap_or(""), &st.iter().map(String::as_str).collect::<Vec<_>>(), &l.iter().map(String::as_str).collect::<Vec<_>>()));
for k in ["outcome", "reason", "entry", "hops"] {
if got.get(k) != s.get(k) {
fails.push(format!("served {name}: {k} is {}, the corpus states {}", got.get(k).unwrap_or(&Value::Null), s.get(k).unwrap_or(&Value::Null)));
}
}
if let Some(x) = got["reason"].as_str() {
seen.insert(x.to_string());
}
}
for r in Reason::ALL {
if !seen.contains(r.code()) {
fails.push(format!("nothing in cdn.json is refused {r}"));
}
}
if c["codes"] != json!(Reason::ALL.iter().map(|r| r.code()).collect::<Vec<_>>()) {
fails.push("cdn.json's codes are not §8.5's".into());
}
fails
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_decision_key_goes_in_bytewise_order() {
let d = [0x11; 32];
let h = hex(&d);
assert_eq!(with_decision("v=2", &d).unwrap(), format!("{DECISION_KEY}=\"{h}\",v=2"));
assert_eq!(with_decision("", &d).unwrap(), format!("{DECISION_KEY}=\"{h}\""));
assert_eq!(with_decision(&format!("a=1,{DECISION_KEY}=\"x\",z=2"), &d).unwrap(), format!("a=1,{DECISION_KEY}=\"{h}\",z=2"));
}
#[test]
fn the_steering_static_field_reads_back() {
let d = [0x2a; 32];
let f = steering_static("steer-1", &d).unwrap();
assert_eq!(f, format!("n=\"steer-1\",ot=m,{DECISION_KEY}=\"{}\"", hex(&d)));
let r = cmsd::read(&[&f], &[]);
let s = r.static_.unwrap();
assert_eq!((s.n.as_deref(), s.ot.as_deref(), s.custom.get(DECISION_KEY).cloned()), (Some("steer-1"), Some("m"), Some(hex(&d))));
}
#[test]
fn a_manifest_names_its_decision_by_the_digest_its_member_ends_in() {
let h = "ab".repeat(32);
let m = format!("{{\"VERSION\":1,\"{MANIFEST_KEY}\":\"https://x.example/d/{h}\"}}");
assert_eq!(manifest_decision(&m), Some([0xab; 32]));
assert_eq!(manifest_decision("{\"VERSION\":1}"), None);
assert_eq!(check_response(&m, &[&steering_static("s", &[0xab; 32]).unwrap()]), Ok(Binding::Bound));
assert_eq!(check_response(&m, &[&steering_static("s", &[0xac; 32]).unwrap()]), Err(Reason::CmsdDecisionMismatch));
assert_eq!(check_response(&m, &[]), Ok(Binding::Absent));
}
}

View file

@ -9,7 +9,10 @@
//! - [`cose`]: `COSE_Sign1_Tagged` with EdDSA and the OKP key (§4.3, §7);
//! - [`x509`]: the claim signer's certificates, their profile and chain (§4.4, §11.2);
//! - this module: the assertions (§4.5 to §9), the writer of initialization segments and
//! segments under either method or both, and the verifier of §11;
//! segments under either method or both, and the verifier of §11, which receives a stream
//! whole ([`verify`]) or as it arrives ([`LiveVerifier`], §11.7);
//! - [`live`]: a packager that signs each segment as its chunks close ([`live::LiveSigner`],
//! §10.4);
//! - [`corpus`]: the conformance corpus (§13).
//!
//! The segment of a room track's group *is* the group's objects behind its C2PA boxes, so
@ -22,6 +25,7 @@ pub mod bmff;
pub mod corpus;
pub mod cose;
pub mod jumbf;
pub mod live;
pub mod log;
pub mod x509;
@ -32,6 +36,7 @@ use crate::timed_text::{origin, DST_PREFIX, MODEL_URI};
use bmff::{BoxRef, Emsg};
use ed25519_dalek::SigningKey;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
/// The record string (SPEC §5).
pub const CAPABILITY: &str = "wai.live.provenance";
@ -1448,71 +1453,207 @@ fn chunk_leaves(chunks: &[&[u8]]) -> Vec<[u8; 32]> {
chunks.iter().map(|c| leaf_legacy(&content_hash(c))).collect()
}
/// §11: verify a stream.
/// §11: verify a stream. This is [`LiveVerifier`] given the whole stream at once: every
/// receipt held before the first segment, then each segment in the order received.
pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
use Reason::*;
let at_init = |reason| Refusal { reason, at: Place::Init };
let mut v = LiveVerifier::start(s.kind, s.init, ctx)?;
v.hold_receipts(&s.groups, &s.objects);
for seg in &s.segments {
v.segment(*seg)?;
}
v.finish()
}
// §11.3 step 1.
let init = s.init;
let top = bmff::top_level(init).ok_or(at_init(SegmentMalformed))?;
if top.iter().any(|r| &r.ty == b"mdat") {
return Err(at_init(InitInvalid));
}
let (ftyp, uuid, moov, update) = match top.as_slice() {
[f, u, m] => (f, u, m, None),
[f, u, m, x] => (f, u, m, Some(x)),
_ => return Err(at_init(InitInvalid)),
};
if &ftyp.ty != b"ftyp" || !bmff::is_c2pa_uuid(init, uuid) || &moov.ty != b"moov" || update.is_some_and(|x| !bmff::is_c2pa_uuid(init, x)) {
return Err(at_init(InitInvalid));
}
let track = bmff::read_moov(init, moov).ok_or(at_init(InitInvalid))?;
// Step 2.
let purpose = if update.is_some() { bmff::PURPOSE_ORIGINAL } else { bmff::PURPOSE_MANIFEST };
let store0 = bmff::read_c2pa_box(init, uuid, purpose).ok_or(at_init(ManifestMalformed))?;
let m0 = verify_manifest(init, &top, store0, true, s.kind, ctx).map_err(at_init)?;
// Step 3.
let ident_label = if s.kind == Kind::Track { L_STREAM } else { L_TAKE };
let ident_item = m0.item(ident_label).expect("the shape holds it");
let id = match s.kind {
Kind::Track => Identity::Track(StreamAssertion::from_item(ident_item).ok_or(at_init(StreamAssertionInvalid))?),
Kind::Take => Identity::Take(TakeAssertion::from_item(ident_item, true).ok_or(at_init(StreamAssertionInvalid))?),
};
let stream_id = id.stream_id();
if m0.id != derived_manifest_id(&stream_id, ROLE_INIT, 0) {
return Err(at_init(ManifestIdMismatch));
}
// Step 4.
check_label(&m0, &id).map_err(at_init)?;
// Step 5.
let device = match &id {
Identity::Take(t) => Some(t.device),
Identity::Track(_) => None,
};
let mut keys_held = read_keys(&m0, device.as_ref()).map_err(at_init)?;
// Step 6.
let registration = match update {
None => None,
Some(x) => {
let store = bmff::read_c2pa_box(init, x, bmff::PURPOSE_UPDATE).ok_or(at_init(UpdateManifestInvalid))?;
Some(verify_update(store, &m0.store, &stream_id, ctx).map_err(at_init)?)
/// A verifier of a stream as it arrives (§11.7): its initialization segment first
/// ([`LiveVerifier::start`], §11.3), then each segment in the order received
/// ([`LiveVerifier::segment`], §11.4), each once the receipts of its group and of every group
/// before it are held ([`LiveVerifier::hold_receipts`]), then the end of the stream
/// ([`LiveVerifier::finish`], §11.5).
///
/// What it holds does not grow with the stream, the summaries it states aside: §11.3's
/// results, the last segment's numbers, group and active manifest, the running count of the
/// receipts' objects (§3.3), the last group's receipt and the receipts of groups it has not
/// reached, and the first group left without a segment. After a refusal every call gives that
/// refusal again: it stops at the first, as §11.1 has it.
pub struct LiveVerifier {
ctx: Context,
kind: Kind,
/// A take's initialization segment, until its first segment (its chunk 0, §11.6).
init: Option<Vec<u8>>,
track: bmff::Moov,
id: Identity,
stream_id: String,
device: Option<[u8; 32]>,
keys_held: Vec<HeldKey>,
registration: Option<Registered>,
first_group: u64,
received: usize,
prev_seq: Option<u64>,
prev_group: Option<u64>,
prev_last_mfhd: u64,
prev_manifest: String,
active: String,
/// The receipts held, by group, each group's in the order received. Those of groups
/// before the last one reached are let go.
groups: BTreeMap<u64, Vec<GroupReceipt>>,
objects: BTreeMap<u64, Vec<ObjectReceipt>>,
/// The highest group of `firstGroup` or above any receipt held named (§11.5).
last_receipt_group: Option<u64>,
/// `sum` is one more than the objects of the groups from `firstGroup` to before
/// `counted_to`: the sequence number group `counted_to`'s segment carries (§3.3).
counted_to: u64,
sum: u64,
/// The first group a later segment passed over (§11.5).
first_gap: Option<u64>,
summaries: Vec<SegmentSummary>,
refused: Option<Refusal>,
}
impl LiveVerifier {
/// §11.3: the initialization segment, verified.
pub fn start(kind: Kind, init: &[u8], ctx: &Context) -> Result<LiveVerifier, Refusal> {
use Reason::*;
let at_init = |reason| Refusal { reason, at: Place::Init };
// Step 1.
let top = bmff::top_level(init).ok_or(at_init(SegmentMalformed))?;
if top.iter().any(|r| &r.ty == b"mdat") {
return Err(at_init(InitInvalid));
}
};
let (ftyp, uuid, moov, update) = match top.as_slice() {
[f, u, m] => (f, u, m, None),
[f, u, m, x] => (f, u, m, Some(x)),
_ => return Err(at_init(InitInvalid)),
};
if &ftyp.ty != b"ftyp" || !bmff::is_c2pa_uuid(init, uuid) || &moov.ty != b"moov" || update.is_some_and(|x| !bmff::is_c2pa_uuid(init, x)) {
return Err(at_init(InitInvalid));
}
let track = bmff::read_moov(init, moov).ok_or(at_init(InitInvalid))?;
// Step 2.
let purpose = if update.is_some() { bmff::PURPOSE_ORIGINAL } else { bmff::PURPOSE_MANIFEST };
let store0 = bmff::read_c2pa_box(init, uuid, purpose).ok_or(at_init(ManifestMalformed))?;
let m0 = verify_manifest(init, &top, store0, true, kind, ctx).map_err(at_init)?;
// Step 3.
let ident_label = if kind == Kind::Track { L_STREAM } else { L_TAKE };
let ident_item = m0.item(ident_label).expect("the shape holds it");
let id = match kind {
Kind::Track => Identity::Track(StreamAssertion::from_item(ident_item).ok_or(at_init(StreamAssertionInvalid))?),
Kind::Take => Identity::Take(TakeAssertion::from_item(ident_item, true).ok_or(at_init(StreamAssertionInvalid))?),
};
let stream_id = id.stream_id();
if m0.id != derived_manifest_id(&stream_id, ROLE_INIT, 0) {
return Err(at_init(ManifestIdMismatch));
}
// Step 4.
check_label(&m0, &id).map_err(at_init)?;
// Step 5.
let device = match &id {
Identity::Take(t) => Some(t.device),
Identity::Track(_) => None,
};
let keys_held = read_keys(&m0, device.as_ref()).map_err(at_init)?;
// Step 6.
let registration = match update {
None => None,
Some(x) => {
let store = bmff::read_c2pa_box(init, x, bmff::PURPOSE_UPDATE).ok_or(at_init(UpdateManifestInvalid))?;
Some(verify_update(store, &m0.store, &stream_id, ctx).map_err(at_init)?)
}
};
let first_group = match &id {
Identity::Track(sa) => sa.first_group,
Identity::Take(_) => 0,
};
Ok(LiveVerifier {
ctx: ctx.clone(),
kind,
init: (kind == Kind::Take).then(|| init.to_vec()),
track,
id,
stream_id,
device,
keys_held,
registration,
first_group,
received: 0,
prev_seq: None,
prev_group: None,
prev_last_mfhd: 0,
prev_manifest: m0.id.clone(),
active: m0.id,
groups: BTreeMap::new(),
objects: BTreeMap::new(),
last_receipt_group: None,
counted_to: first_group,
sum: 1,
first_gap: None,
summaries: Vec::new(),
refused: None,
})
}
let first_group = match &id {
Identity::Track(sa) => sa.first_group,
Identity::Take(_) => 0,
};
let mut prev_seq: Option<u64> = None;
let mut prev_group: Option<u64> = None;
let mut prev_last_mfhd: u64 = 0;
let mut prev_manifest = m0.id.clone();
let mut active = m0.id.clone();
let mut seen_groups: Vec<u64> = Vec::new();
let mut summaries = Vec::new();
/// The stream identifier (§3.4) the initialization segment established.
pub fn stream_id(&self) -> &str {
&self.stream_id
}
for (k, seg) in s.segments.iter().enumerate() {
/// Hold receipts as they arrive, a track's group receipts and object receipts (§11.1).
/// Those of groups the verifier has already passed are not needed and are let go.
pub fn hold_receipts(&mut self, groups: &[GroupReceipt], objects: &[ObjectReceipt]) {
let floor = self.prev_group.unwrap_or(0);
for r in groups {
if r.group_id >= self.first_group {
self.last_receipt_group = Some(self.last_receipt_group.map_or(r.group_id, |l| l.max(r.group_id)));
}
if self.prev_group.is_none() || r.group_id >= floor {
self.groups.entry(r.group_id).or_default().push(r.clone());
}
}
for o in objects {
if self.prev_group.is_none_or(|p| o.group_id > p) {
self.objects.entry(o.group_id).or_default().push(o.clone());
}
}
}
/// §11.4: the next segment received; for a track, once the receipts of its group and of
/// every group before it are held. What §11.6 states of it, or the refusal.
pub fn segment(&mut self, seg: SegmentIn) -> Result<SegmentSummary, Refusal> {
if let Some(r) = self.refused {
return Err(r);
}
let out = self.check(seg);
match &out {
Ok(s) => self.summaries.push(s.clone()),
Err(r) => self.refused = Some(*r),
}
self.received += 1;
out
}
/// §11.5: the end of the stream, and §11.6's statement of it.
pub fn finish(self) -> Result<Accepted, Refusal> {
if let Some(r) = self.refused {
return Err(r);
}
if self.kind == Kind::Track
&& let Some(last) = self.last_receipt_group
{
let next = self.prev_group.map_or(self.first_group, |p| p + 1);
if let Some(h) = self.first_gap.or((last >= next).then_some(next)) {
return Err(Refusal { reason: Reason::SegmentMissing, at: Place::Group(h) });
}
}
Ok(Accepted {
source_type: format!("{DST_PREFIX}{}", disclosure(&self.id).0),
stream_id: self.stream_id,
registration: self.registration,
segments: self.summaries,
})
}
fn check(&mut self, seg: SegmentIn) -> Result<SegmentSummary, Refusal> {
use Reason::*;
let k = self.received;
let at = |reason| Refusal { reason, at: Place::Segment(k) };
let b = seg.bytes;
// Step 1.
@ -1539,7 +1680,7 @@ pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
if i == 0 {
return Err(at(ProvenanceMissing));
}
if infos.iter().any(|x| x.track_id != track.track_id)
if infos.iter().any(|x| x.track_id != self.track.track_id)
|| infos.windows(2).any(|w| w[1].sequence_number as u64 != w[0].sequence_number as u64 + 1)
{
return Err(at(SegmentMalformed));
@ -1548,17 +1689,20 @@ pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
let last_mfhd = infos.last().expect("non-empty").sequence_number as u64;
let n = chunks.len() as u64;
// Step 2.
if prev_seq.is_some_and(|p| seq <= p) || (s.kind == Kind::Track && prev_group.is_some_and(|p| seg.group.is_none_or(|g| g <= p)))
if self.prev_seq.is_some_and(|p| seq <= p)
|| (self.kind == Kind::Track && self.prev_group.is_some_and(|p| seg.group.is_none_or(|g| g <= p)))
{
return Err(at(SequenceNotIncreasing));
}
// Step 3.
let mut per_segment: Option<Verified> = None;
let mut new_keys = Vec::new();
let (mut prev_manifest, mut active) = (self.prev_manifest.clone(), self.active.clone());
if let Some(r) = manifest_box {
let store = bmff::read_manifest_box(b, &r).ok_or(at(ManifestMalformed))?;
let v = verify_manifest(b, &top, store, false, s.kind, ctx).map_err(at)?;
let v = verify_manifest(b, &top, store, false, self.kind, &self.ctx).map_err(at)?;
let role = if v.shape == Shape::PerSegment { ROLE_SEGMENT } else { ROLE_ROTATION };
if v.id != derived_manifest_id(&stream_id, role, seq) {
if v.id != derived_manifest_id(&self.stream_id, role, seq) {
return Err(at(ManifestIdMismatch));
}
if v.sequence_number != Some(seq) {
@ -1570,7 +1714,7 @@ pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
if lseq != seq {
return Err(at(SequenceMismatch));
}
if lsid != stream_id {
if lsid != self.stream_id {
return Err(at(StreamIdMismatch));
}
if method.as_deref() != Some(CONTINUITY) || previous.is_none() {
@ -1579,7 +1723,7 @@ pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
if previous.as_deref() != Some(prev_manifest.as_str()) {
return Err(at(ContinuityBroken));
}
if let Identity::Take(t0) = &id {
if let Identity::Take(t0) = &self.id {
let t = TakeAssertion::from_item(v.item(L_TAKE).expect("shape"), false).ok_or(at(StreamAssertionInvalid))?;
if (t.take, t.room, t.device) != (t0.take, t0.room, t0.device) {
return Err(at(StreamAssertionInvalid));
@ -1590,28 +1734,28 @@ pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
if vsi.is_none() {
return Err(at(ProvenanceMissing));
}
let same = match &id {
let same = match &self.id {
Identity::Track(sa) => StreamAssertion::from_item(v.item(L_STREAM).expect("shape")).as_ref() == Some(sa),
Identity::Take(t0) => TakeAssertion::from_item(v.item(L_TAKE).expect("shape"), true).as_ref() == Some(t0),
};
if !same {
return Err(at(StreamAssertionInvalid));
}
let new = read_keys(&v, device.as_ref()).map_err(at)?;
if new.iter().any(|h| h.min < seq || keys_held.iter().any(|o| o.x == h.x)) {
new_keys = read_keys(&v, self.device.as_ref()).map_err(at)?;
if new_keys.iter().any(|h| h.min < seq || self.keys_held.iter().any(|o| o.x == h.x)) {
return Err(at(SessionKeyInvalid));
}
keys_held.extend(new);
}
Shape::Init => unreachable!("a segment's manifest is not the initialization's"),
}
check_label(&v, &id).map_err(at)?;
check_label(&v, &self.id).map_err(at)?;
prev_manifest = v.id.clone();
active = v.id.clone();
if v.shape == Shape::PerSegment {
per_segment = Some(v);
}
}
let keys_held: Vec<HeldKey> = self.keys_held.iter().chain(&new_keys).cloned().collect();
let methods = match (per_segment.is_some(), vsi.is_some()) {
(true, true) => "manifest+vsi",
(true, false) => "manifest",
@ -1625,7 +1769,7 @@ pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
let duration: u64 = infos.iter().map(|x| x.duration).sum();
if e.scheme_id_uri != bmff::VSI_SCHEME
|| e.value != bmff::VSI_VALUE
|| e.timescale != track.timescale
|| e.timescale != self.track.timescale
|| e.presentation_time_delta != 0
|| e.event_duration as u64 != duration
|| e.id as u64 != seq
@ -1651,8 +1795,8 @@ pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
if !cs.verify(&key.x, &payload) {
return Err(at(SegmentInfoSignatureFailed));
}
let presentation = id.epoch().saturating_add(
((infos[0].base_media_decode_time as u128 * 1_000_000) / track.timescale as u128).min(u64::MAX as u128) as u64,
let presentation = self.id.epoch().saturating_add(
((infos[0].base_media_decode_time as u128 * 1_000_000) / self.track.timescale as u128).min(u64::MAX as u128) as u64,
);
let superseded = keys_held.iter().any(|o| o.min > key.min && o.min <= seq);
if key.min > seq || superseded || presentation < key.from_us || presentation >= key.to_us {
@ -1672,29 +1816,29 @@ pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
// Step 5.
let digest;
let index;
match &id {
match &self.id {
Identity::Track(sa) => {
let g = seg.group.ok_or(at(SegmentMalformed))?;
let mut expect = 1u64;
for h in first_group..g {
let r = s.groups.iter().find(|r| r.group_id == h).ok_or(Refusal { reason: ReceiptMissing, at: Place::Group(h) })?;
expect += r.leaf_count;
let mut sum = self.sum;
for h in self.counted_to..g {
let r = self.groups.get(&h).and_then(|v| v.first()).ok_or(Refusal { reason: ReceiptMissing, at: Place::Group(h) })?;
sum += r.leaf_count;
}
let held: Vec<&GroupReceipt> = s.groups.iter().filter(|r| r.group_id == g).collect();
let [rg] = held.as_slice() else {
let held: &[GroupReceipt] = self.groups.get(&g).map_or(&[], Vec::as_slice);
let [rg] = held else {
return Err(Refusal { reason: if held.is_empty() { ReceiptMissing } else { ReceiptInvalid }, at: Place::Group(g) });
};
if seq != expect {
if seq != sum {
return Err(at(SequenceMismatch));
}
// Step 6.
let at_g = |reason| Refusal { reason, at: Place::Group(g) };
let prev = if g > first_group { s.groups.iter().find(|r| r.group_id == g - 1) } else { None };
let prev = if g > self.first_group { self.groups.get(&(g - 1)).and_then(|v| v.first()) } else { None };
let parent_ok = match prev {
Some(p) if p.signer_pubkey == rg.signer_pubkey => rg.parent_receipt_hash == Some(p.link_hash()),
_ => rg.parent_receipt_hash.is_none(),
};
if g < first_group || rg.label.is_some() || rg.verify().is_err() || rg.track_alias != sa.track_alias || !parent_ok {
if g < self.first_group || rg.label.is_some() || rg.verify().is_err() || rg.track_alias != sa.track_alias || !parent_ok {
return Err(at_g(ReceiptInvalid));
}
let leaves = chunk_leaves(&chunks);
@ -1709,7 +1853,7 @@ pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
if vsi_signer.is_some_and(|x| x != rg.signer_pubkey) {
return Err(at_g(ReceiptSignerMismatch));
}
let objs: Vec<&ObjectReceipt> = s.objects.iter().filter(|o| o.group_id == g).collect();
let objs: &[ObjectReceipt] = self.objects.get(&g).map_or(&[], Vec::as_slice);
if objs.len() as u64 != n {
return Err(at_g(ObjectReceiptInvalid));
}
@ -1720,33 +1864,38 @@ pub fn verify(s: &StreamIn, ctx: &Context) -> Result<Accepted, Refusal> {
return Err(at_g(ObjectReceiptInvalid));
}
}
seen_groups.push(g);
prev_group = Some(g);
digest = rg.root_hash;
index = g;
// Accepted: what the next segment needs is kept, the rest let go.
let next = self.prev_group.map_or(self.first_group, |p| p + 1);
if g > next && self.first_gap.is_none() {
self.first_gap = Some(next);
}
self.sum = sum;
self.counted_to = g;
self.prev_group = Some(g);
self.groups.retain(|h, _| *h >= g);
self.objects.retain(|h, _| *h > g);
}
Identity::Take(_) => {
if seq != prev_last_mfhd + 1 {
if seq != self.prev_last_mfhd + 1 {
return Err(at(SequenceMismatch));
}
let chunk_bytes = if k == 0 { [init, b].concat() } else { b.to_vec() };
let chunk_bytes = match self.init.take() {
Some(init) if k == 0 => [init.as_slice(), b].concat(),
_ => b.to_vec(),
};
digest = Sha256::digest(&chunk_bytes).into();
index = k as u64;
}
}
prev_seq = Some(seq);
prev_last_mfhd = last_mfhd;
summaries.push(SegmentSummary { sequence_number: seq, index, methods, manifest_id: active.clone(), chunks: n, digest });
self.keys_held = keys_held;
self.prev_manifest = prev_manifest;
self.active = active.clone();
self.prev_seq = Some(seq);
self.prev_last_mfhd = last_mfhd;
Ok(SegmentSummary { sequence_number: seq, index, methods, manifest_id: active, chunks: n, digest })
}
// §11.5.
let last = s.groups.iter().map(|r| r.group_id).filter(|g| *g >= first_group).max();
if let (Kind::Track, Some(last)) = (s.kind, last)
&& let Some(h) = (first_group..=last).find(|h| !seen_groups.contains(h))
{
return Err(Refusal { reason: SegmentMissing, at: Place::Group(h) });
}
Ok(Accepted { stream_id, source_type: format!("{DST_PREFIX}{}", disclosure(&id).0), registration, segments: summaries })
}
#[cfg(test)]

View file

@ -7,6 +7,7 @@
use super::bmff::{self, Sample};
use super::x509::{self, CertSpec};
use super::live::{LiveConfig, LiveSigner, LiveState};
use super::*;
use crate::jwp_delivery::GroupBuilder;
use serde_json::{json, Value};
@ -854,8 +855,8 @@ pub fn generate(media: &Media) -> Vec<(String, Vec<u8>)> {
files
}
/// Load vector `v` of `doc` from `files` and verify it.
pub fn run(v: &Value, doc: &Value, files: &BTreeMap<String, Vec<u8>>) -> Result<Result<Accepted, Refusal>, String> {
/// Vector `v` of `doc` read from `files`: the stream and the verifier's context.
fn load<'f>(v: &Value, doc: &Value, files: &'f BTreeMap<String, Vec<u8>>) -> Result<(StreamIn<'f>, Context), String> {
let file = |p: &Value| -> Result<&Vec<u8>, String> {
let p = p.as_str().ok_or("a path")?;
files.get(p).ok_or(format!("no file {p}"))
@ -898,11 +899,62 @@ pub fn run(v: &Value, doc: &Value, files: &BTreeMap<String, Vec<u8>>) -> Result<
time_us: super::read_rfc3339(doc["time"].as_str().ok_or("time")?).ok_or("time")? * 1_000_000,
};
let input = StreamIn { kind, init: file(&v["init"])?, segments, groups, objects };
Ok((input, ctx))
}
/// Load vector `v` of `doc` from `files` and verify it.
pub fn run(v: &Value, doc: &Value, files: &BTreeMap<String, Vec<u8>>) -> Result<Result<Accepted, Refusal>, String> {
let (input, ctx) = load(v, doc, files)?;
Ok(verify(&input, &ctx))
}
/// Load vector `v` and verify it as it would arrive live (§11.7): the initialization
/// segment, then each segment in turn, the receipts of its group and of the groups before it
/// held just before it, the receipts of any later group after the last segment.
pub fn run_live(v: &Value, doc: &Value, files: &BTreeMap<String, Vec<u8>>) -> Result<Result<Accepted, Refusal>, String> {
let (input, ctx) = load(v, doc, files)?;
let mut lv = match LiveVerifier::start(input.kind, input.init, &ctx) {
Ok(lv) => lv,
Err(r) => return Ok(Err(r)),
};
let (mut gi, mut oi) = (0usize, 0usize);
let mut hold_through = |lv: &mut LiveVerifier, g: Option<u64>| {
let (mut gs, mut os) = (Vec::new(), Vec::new());
while gi < input.groups.len() && g.is_none_or(|g| input.groups[gi].group_id <= g) {
gs.push(input.groups[gi].clone());
gi += 1;
}
while oi < input.objects.len() && g.is_none_or(|g| input.objects[oi].group_id <= g) {
os.push(input.objects[oi].clone());
oi += 1;
}
lv.hold_receipts(&gs, &os);
};
for seg in &input.segments {
if let Some(g) = seg.group {
hold_through(&mut lv, Some(g));
}
if let Err(r) = lv.segment(*seg) {
return Ok(Err(r));
}
}
hold_through(&mut lv, None);
Ok(lv.finish())
}
/// Every vector's outcome against the corpus's statement; the failures, empty when all hold.
pub fn verify_files(files: &BTreeMap<String, Vec<u8>>) -> Vec<String> {
check_files(files, run)
}
/// [`verify_files`], each vector verified as it would arrive live ([`run_live`], §11.7).
pub fn verify_files_live(files: &BTreeMap<String, Vec<u8>>) -> Vec<String> {
check_files(files, run_live)
}
type Runner = fn(&Value, &Value, &BTreeMap<String, Vec<u8>>) -> Result<Result<Accepted, Refusal>, String>;
fn check_files(files: &BTreeMap<String, Vec<u8>>, run: Runner) -> Vec<String> {
let mut fails = Vec::new();
let Some(doc) = files.get("vectors.json").and_then(|b| serde_json::from_slice::<Value>(b).ok()) else {
return vec!["vectors.json does not read".into()];
@ -935,3 +987,260 @@ pub fn verify_files(files: &BTreeMap<String, Vec<u8>>) -> Vec<String> {
let _ = time_us();
fails
}
// ───────────────────────────── live signing (§10.4) ─────────────────────────────
/// The chunks of a written segment: every `moof` and its `mdat` after its C2PA boxes.
fn chunks_of(b: &[u8]) -> Vec<&[u8]> {
let top = bmff::top_level(b).expect("a written segment");
let rest: Vec<&BoxRef> = top.iter().skip_while(|r| bmff::is_c2pa_uuid(b, r) || bmff::is_vsi(b, r)).collect();
rest.chunks(2).map(|p| &b[p[0].start..p[1].end]).collect()
}
/// The first chunk's sequence number and the segment's presentation instant (§3.5).
fn first_of(b: &[u8], media: &Media) -> (u64, u64) {
let c = chunks_of(b)[0];
let top = bmff::top_level(c).expect("a chunk");
let info = bmff::read_chunk(c, &top[0], &top[1]).expect("a chunk");
(info.sequence_number as u64, EPOCH_US + info.base_media_decode_time * 1_000_000 / media.timescale as u64)
}
fn ftyp_of(media: &Media) -> Vec<u8> {
if media.ftyp.is_empty() { bmff::write_box(b"ftyp", b"cmfc\0\0\0\0cmfc") } else { media.ftyp.clone() }
}
/// The live signer's configuration for plan `p` under claim signer `signer`, as [`build`]
/// signs it: its methods and its first session key.
fn live_config(p: &Plan, signer: &ClaimSigner) -> LiveConfig {
let vsi = matches!(p.method, Method::Vsi | Method::Both);
let first = match &p.identity {
Identity::Take(_) => SessionKey { key: key("device"), min_sequence_number: 1, created_at: EPOCH_US / 1_000_000, validity_period: 3600 },
Identity::Track(_) => session_key(&format!("{} session 1", p.name), 1, EPOCH_US),
};
LiveConfig {
identity: p.identity.clone(),
signer: signer.clone(),
manifests: matches!(p.method, Method::Manifests | Method::Both),
vsi,
session_keys: if vsi { vec![first] } else { vec![] },
}
}
/// Sign segment `k` of the built stream `b` of plan `p` with `s`, from its chunks and, for a
/// track, its receipt, delivering the plan's second session key where it rotates.
fn sign_next(s: &mut LiveSigner, p: &Plan, b: &Built, k: usize, media: &Media) -> Result<Vec<u8>, Reason> {
let seg = &b.segments[k].0;
let chunks = chunks_of(seg);
match &p.identity {
Identity::Track(_) => {
let deliver: Vec<SessionKey> = match p.rotate_at {
Some(r) if r == k => {
let (seq, at) = first_of(seg, media);
vec![session_key(&format!("{} session 2", p.name), seq, at)]
}
_ => vec![],
};
s.track_segment(&chunks, &b.groups[k], &deliver)
}
Identity::Take(_) => s.take_segment(&chunks, seed(&format!("{} commit {k}", p.name))),
}
}
/// Every good stream of the corpus written by [`LiveSigner`], one segment at a time from the
/// chunks and receipts the stream carries, against the corpus's own bytes; the camera track's
/// failover written by a second signer resumed from the first's [`LiveState`] at the third
/// segment (§10.3). The differences, empty when live signing and the corpus's writer are one
/// construction.
pub fn live_differences(media: &Media) -> Vec<String> {
let pki = pki();
let mut fails = Vec::new();
let mut runs: Vec<(Plan, Option<usize>)> = plans().into_iter().map(|p| (p, None)).collect();
let failover = plans().into_iter().find(|p| p.name == "camera-manifests").expect("a base plan");
runs.push((Plan { name: "camera-failover", knobs: Knobs { packager_b_from: Some(2), ..Knobs::default() }, ..failover }, Some(2)));
for (p, switch) in runs {
let b = build(&p, media, &pki);
let (mut s, init) = match LiveSigner::start(live_config(&p, &pki.good), &ftyp_of(media), &media.moov) {
Ok(x) => x,
Err(r) => {
fails.push(format!("{}: the live signer refused to start: {}", p.name, r.code()));
continue;
}
};
if init != b.init {
fails.push(format!("{}: the initialization segment differs", p.name));
}
for k in 0..b.segments.len() {
if switch == Some(k) {
let state = s.state().clone();
s = match LiveSigner::resume(live_config(&p, &pki.packager_b), &media.moov, state) {
Ok(s) => s,
Err(r) => {
fails.push(format!("{}: the second packager refused to resume: {}", p.name, r.code()));
break;
}
};
}
match sign_next(&mut s, &p, &b, k, media) {
Ok(seg) if seg == b.segments[k].0 => {}
Ok(_) => fails.push(format!("{}: segment {k} differs", p.name)),
Err(r) => fails.push(format!("{}: segment {k} refused: {}", p.name, r.code())),
}
}
}
fails
}
/// What the live signer refuses before it writes a segment (§10.4), each with the reason of
/// §12 a verifier gives the same defect: every check refuses its case, leaves the signer's
/// state as it was, and the stream then continues, byte for byte the corpus's. The defects
/// and the corpus vector a verifier refuses for each: a receipt over other bytes
/// (`receipt-other-objects`, `receipt_mismatch`), a receipt signed by a key that is not the
/// session key (`receipt-signer`, `receipt_signer_mismatch`), a forged receipt
/// (`receipt-forged`, `receipt_invalid`), a truncated chunk (`segment-truncated`,
/// `segment_malformed`), a group passed over (`segment-missing-vsi`, `segment_missing`), a
/// group again (`segment-reordered`, `sequence_not_increasing`), chunks not numbered from the
/// next sequence number (`sequence-mismatch`, `sequence_mismatch`), a session key delivered
/// for an earlier segment (`session_key_invalid`, §11.4 step 3) or outside its validity
/// (`session_key_out_of_range`, §11.4 step 4), keys delivered under the per-segment method
/// (`session_key_invalid`, §7.3), and no method at all (`provenance_missing`). The failures,
/// empty when every check holds.
pub fn live_refusals(media: &Media) -> Vec<String> {
use Reason::*;
let pki = pki();
let base: BTreeMap<&str, Plan> = plans().into_iter().map(|p| (p.name, p)).collect();
let mut fails: Vec<String> = Vec::new();
// A stream of plan `name`, signed up to segment `upto`; the next segment is the case's.
let started = |name: &str, upto: usize| {
let p = base[name].clone();
let b = build(&p, media, &pki);
let (mut s, _) = LiveSigner::start(live_config(&p, &pki.good), &ftyp_of(media), &media.moov).expect("a good start");
for k in 0..upto {
sign_next(&mut s, &p, &b, k, media).expect("a good segment");
}
(p, b, s)
};
let negative = |name: &str, k: Knobs| build(&Plan { knobs: k, ..base[name].clone() }, media, &pki);
// Receipts.
for (what, knobs, want) in [
("a receipt over other bytes", Knobs { receipt_other_bytes: Some(1), ..Knobs::default() }, ReceiptMismatch),
("a receipt not signed by the session key", Knobs { receipt_stranger: Some(1), ..Knobs::default() }, ReceiptSignerMismatch),
] {
let (p, b, mut s) = started("camera-vsi", 1);
let before = s.state().clone();
let bad = negative("camera-vsi", knobs);
let got = s.track_segment(&chunks_of(&b.segments[1].0), &bad.groups[1], &[]);
fails.extend(refused(what, got, want));
fails.extend(continues(what, &mut s, &before, &p, &b, 1, media));
}
{
let what = "a forged receipt";
let (p, b, mut s) = started("camera-manifests", 1);
let before = s.state().clone();
let mut forged = b.groups[1].clone();
forged.sig[0] ^= 0x01;
fails.extend(refused(what, s.track_segment(&chunks_of(&b.segments[1].0), &forged, &[]), ReceiptInvalid));
fails.extend(continues(what, &mut s, &before, &p, &b, 1, media));
}
// Chunks and order.
{
let what = "a truncated chunk";
let (p, b, mut s) = started("camera-manifests", 1);
let before = s.state().clone();
let mut chunks: Vec<Vec<u8>> = chunks_of(&b.segments[1].0).into_iter().map(<[u8]>::to_vec).collect();
chunks.last_mut().expect("a chunk").pop();
let refs: Vec<&[u8]> = chunks.iter().map(Vec::as_slice).collect();
fails.extend(refused(what, s.track_segment(&refs, &b.groups[1], &[]), SegmentMalformed));
fails.extend(continues(what, &mut s, &before, &p, &b, 1, media));
}
{
let what = "a group passed over";
let (p, b, mut s) = started("camera-vsi", 1);
let before = s.state().clone();
fails.extend(refused(what, s.track_segment(&chunks_of(&b.segments[2].0), &b.groups[2], &[]), SegmentMissing));
fails.extend(continues(what, &mut s, &before, &p, &b, 1, media));
}
{
let what = "a group again";
let (p, b, mut s) = started("camera-vsi", 2);
let before = s.state().clone();
fails.extend(refused(what, s.track_segment(&chunks_of(&b.segments[1].0), &b.groups[1], &[]), SequenceNotIncreasing));
fails.extend(continues(what, &mut s, &before, &p, &b, 2, media));
}
{
let what = "chunks of another segment";
let (p, b, mut s) = started("camera-manifests", 1);
let before = s.state().clone();
fails.extend(refused(what, s.track_segment(&chunks_of(&b.segments[2].0), &b.groups[1], &[]), SequenceMismatch));
fails.extend(continues(what, &mut s, &before, &p, &b, 1, media));
}
// Session keys.
{
let what = "a session key delivered for an earlier segment";
let (p, b, mut s) = started("camera-vsi", 2);
let before = s.state().clone();
let early = session_key("camera-vsi session 2", 1, EPOCH_US);
fails.extend(refused(what, s.track_segment(&chunks_of(&b.segments[2].0), &b.groups[2], &[early]), SessionKeyInvalid));
fails.extend(continues(what, &mut s, &before, &p, &b, 2, media));
}
{
let what = "keys delivered under the per-segment method";
let p = base["camera-vsi"].clone();
let b = build(&p, media, &pki);
let mut cfg = live_config(&p, &pki.good);
cfg.manifests = true;
let (mut s, _) = LiveSigner::start(cfg, &ftyp_of(media), &media.moov).expect("a start");
let before = s.state().clone();
let k = session_key("another session key", 1, EPOCH_US);
fails.extend(refused(what, s.track_segment(&chunks_of(&b.segments[0].0), &b.groups[0], &[k]), SessionKeyInvalid));
if s.state() != &before {
fails.push(format!("{what}: the refusal changed the signer's state"));
}
}
{
let what = "a session key outside its validity";
let p = base["camera-vsi"].clone();
let b = build(&p, media, &pki);
let mut cfg = live_config(&p, &pki.good);
cfg.session_keys[0].validity_period = 0;
let (mut s, _) = LiveSigner::start(cfg, &ftyp_of(media), &media.moov).expect("a start");
let before = s.state().clone();
fails.extend(refused(what, s.track_segment(&chunks_of(&b.segments[0].0), &b.groups[0], &[]), SessionKeyOutOfRange));
if s.state() != &before {
fails.push(format!("{what}: the refusal changed the signer's state"));
}
}
{
let what = "no method";
let mut cfg = live_config(&base["camera-manifests"], &pki.good);
cfg.manifests = false;
match LiveSigner::start(cfg, &ftyp_of(media), &media.moov) {
Err(ProvenanceMissing) => {}
Err(r) => fails.push(format!("{what}: refused {}, not provenance_missing", r.code())),
Ok(_) => fails.push(format!("{what}: started")),
}
}
fails
}
/// `got` is the refusal `want`, or the failure.
fn refused(what: &str, got: Result<Vec<u8>, Reason>, want: Reason) -> Option<String> {
match got {
Err(r) if r == want => None,
Err(r) => Some(format!("{what}: refused {}, not {}", r.code(), want.code())),
Ok(_) => Some(format!("{what}: written, not refused {}", want.code())),
}
}
/// After a refusal: the signer's state is `before`, and segment `k` then signs as the corpus's.
fn continues(what: &str, s: &mut LiveSigner, before: &LiveState, p: &Plan, b: &Built, k: usize, media: &Media) -> Vec<String> {
let mut out = Vec::new();
if s.state() != before {
out.push(format!("{what}: the refusal changed the signer's state"));
}
match sign_next(s, p, b, k, media) {
Ok(seg) if seg == b.segments[k].0 => {}
_ => out.push(format!("{what}: the stream did not continue as the corpus's after the refusal")),
}
out
}

View file

@ -0,0 +1,311 @@
//! Signing a stream as it runs (live-provenance §10.4): a packager's state between segments,
//! so that each segment's C2PA boxes are written as the segment's last chunk closes.
//!
//! [`LiveSigner::start`] writes the initialization segment; then [`LiveSigner::track_segment`]
//! (a room track's group, with its receipt) or [`LiveSigner::take_segment`] (a take's chunk)
//! writes each segment under the per-segment C2PA Manifest Box method (C2PA 2.4 §19.3), the
//! Verifiable Segment Info method in `emsg` (§19.4) or both, from the chunks exactly as they
//! travelled. Before it writes one it checks what a verifier will check of it (§11.4) and
//! refuses with the reason of §12 the verifier would give, so it never releases a segment the
//! stream's verifiers refuse. What it holds between segments is [`LiveState`], which is
//! public and is all another packager needs to continue the stream (§10.3).
use super::*;
/// How a stream is signed.
#[derive(Clone, Debug)]
pub struct LiveConfig {
/// The stream's identity: a room track's stream assertion (§5.1) or a take's (§5.3).
pub identity: Identity,
/// The claim signer of every manifest this packager writes.
pub signer: ClaimSigner,
/// The per-segment C2PA Manifest Box method (§6).
pub manifests: bool,
/// The Verifiable Segment Info method (§7).
pub vsi: bool,
/// The session keys the stream holds (§7.1): at the start, those the initialization
/// manifest delivers, one of them with `minSequenceNumber` 1; when resuming, every key the
/// stream has delivered so far. With the VSI method, at least one. For a take, the
/// device's key.
pub session_keys: Vec<SessionKey>,
}
/// What a packager holds between segments that another needs to continue the stream: every
/// field is public, none is a key (§10.4).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LiveState {
/// The `mfhd` sequence number the next chunk carries (§3.3).
pub next_sequence_number: u64,
/// The next segment's group (a track) or chunk index (a take).
pub next_index: u64,
/// The identifier of the last manifest the stream carried: the next segment's
/// `previousManifestId` (§6) and, where it has no manifest of its own, its segment-info
/// map's `manifestId` (§7.2).
pub last_manifest: String,
/// The previous group's receipt (a track), to which the next group's receipt links.
pub last_receipt: Option<GroupReceipt>,
}
/// A packager of one stream.
pub struct LiveSigner {
cfg: LiveConfig,
stream_id: String,
term: &'static str,
ai: Vec<(String, Item)>,
ident: (String, Item),
track: bmff::Moov,
state: LiveState,
}
/// The session key that signs sequence number `seq` among `keys`: the one with the greatest
/// `minSequenceNumber` not above it (§7.3).
fn current(keys: &[SessionKey], seq: u64) -> Option<&SessionKey> {
keys.iter().filter(|k| k.min_sequence_number <= seq).max_by_key(|k| k.min_sequence_number)
}
/// The chunks of a segment as §3.2 lays them out: each one `moof` and its `mdat`, of the
/// track's `track_ID`, their sequence numbers consecutive. The first sequence number, the
/// last and the sum of the sample durations.
fn read_chunks(chunks: &[&[u8]], track: &bmff::Moov) -> Result<(u64, u64, u64, u64), Reason> {
let mut infos = Vec::new();
for c in chunks {
let top = bmff::top_level(c).ok_or(Reason::SegmentMalformed)?;
let [moof, mdat] = top.as_slice() else { return Err(Reason::SegmentMalformed) };
infos.push(bmff::read_chunk(c, moof, mdat).ok_or(Reason::SegmentMalformed)?);
}
let (Some(first), Some(last)) = (infos.first(), infos.last()) else { return Err(Reason::SegmentMalformed) };
if infos.iter().any(|x| x.track_id != track.track_id)
|| infos.windows(2).any(|w| w[1].sequence_number as u64 != w[0].sequence_number as u64 + 1)
{
return Err(Reason::SegmentMalformed);
}
let duration = infos.iter().map(|x| x.duration).sum();
Ok((first.sequence_number as u64, last.sequence_number as u64, first.base_media_decode_time, duration))
}
impl LiveSigner {
/// Begin a stream: the initialization segment of `ftyp` and `moov` (§3.2) with its manifest
/// (§6; the session keys of §7.1 with the VSI method), and the signer, at sequence number 1.
pub fn start(cfg: LiveConfig, ftyp: &[u8], moov: &[u8]) -> Result<(LiveSigner, Vec<u8>), Reason> {
let first_index = match &cfg.identity {
Identity::Track(sa) => sa.first_group,
Identity::Take(_) => 0,
};
let stream_id = cfg.identity.stream_id();
let init_id = derived_manifest_id(&stream_id, ROLE_INIT, 0);
let state = LiveState { next_sequence_number: 1, next_index: first_index, last_manifest: init_id.clone(), last_receipt: None };
let signer = Self::resume(cfg, moov, state)?;
if !matches!(bmff::top_level(ftyp).as_deref(), Some([f]) if &f.ty == b"ftyp") {
return Err(Reason::InitInvalid);
}
let mut a = vec![(L_ACTIONS.to_string(), actions_item(signer.term))];
if signer.cfg.vsi {
// Every key the stream starts with, a later one's range beginning where it says
// (C2PA 2.4 §19.6.2).
a.push((L_KEYS.to_string(), keys_item(&signer.cfg.session_keys, &signer.cfg.signer.chain[0])));
}
a.extend(signer.ai.clone());
a.push(signer.ident.clone());
let plan = ManifestPlan { id: init_id, assertions: a, sequence_number: None, signer: signer.cfg.signer.clone() };
let init = init_segment(ftyp, &plan, moov);
Ok((signer, init))
}
/// Continue a stream from `state`: a packager that takes over from another (§10.3), or one
/// that restarts. `moov` is the initialization segment's. With the VSI method the
/// session keys are the publisher's, so a packager that is not the publisher continues
/// under the per-segment method alone.
pub fn resume(cfg: LiveConfig, moov: &[u8], state: LiveState) -> Result<LiveSigner, Reason> {
use Reason::*;
if !cfg.manifests && !cfg.vsi {
return Err(ProvenanceMissing);
}
let track = match bmff::top_level(moov).as_deref() {
Some([m]) if &m.ty == b"moov" => bmff::read_moov(moov, m).ok_or(InitInvalid)?,
_ => return Err(InitInvalid),
};
if cfg.signer.chain.is_empty() {
return Err(CredentialInvalid);
}
// The identity reads back as a verifier reads it (§11.3 step 3).
let ident = match &cfg.identity {
Identity::Track(sa) => {
if StreamAssertion::from_item(&sa.to_item()).as_ref() != Some(sa) {
return Err(StreamAssertionInvalid);
}
(L_STREAM.to_string(), sa.to_item())
}
Identity::Take(t) => {
if t.commit.is_some() || TakeAssertion::from_item(&t.to_item(), true).as_ref() != Some(t) {
return Err(StreamAssertionInvalid);
}
(L_TAKE.to_string(), t.to_item())
}
};
// Session keys: present with the VSI method, distinct, and for a take the device's.
let xs: Vec<[u8; 32]> = cfg.session_keys.iter().map(|k| k.key.verifying_key().to_bytes()).collect();
let take_device = match &cfg.identity {
Identity::Take(t) => Some(t.device),
Identity::Track(_) => None,
};
if (cfg.vsi && current(&cfg.session_keys, state.next_sequence_number.max(1)).is_none())
|| xs.iter().enumerate().any(|(i, x)| xs[..i].contains(x))
|| take_device.is_some_and(|d| xs.iter().any(|x| *x != d))
{
return Err(SessionKeyInvalid);
}
let stream_id = cfg.identity.stream_id();
let (term, _) = disclosure(&cfg.identity);
let ai = ai_assertions(&cfg.identity);
Ok(LiveSigner { cfg, stream_id, term, ai, ident, track, state })
}
/// The stream identifier (§3.4).
pub fn stream_id(&self) -> &str {
&self.stream_id
}
/// What this packager holds between segments.
pub fn state(&self) -> &LiveState {
&self.state
}
/// The segment of a room track's group, from its chunks (the group's objects as they
/// travelled, in object order) and its group receipt; `deliver`, under the VSI method
/// alone, are session keys delivered in this segment (§7.3), whose `minSequenceNumber` is
/// this segment's sequence number or a later one.
pub fn track_segment(&mut self, chunks: &[&[u8]], receipt: &GroupReceipt, deliver: &[SessionKey]) -> Result<Vec<u8>, Reason> {
use Reason::*;
let Identity::Track(sa) = &self.cfg.identity else { return Err(StreamAssertionInvalid) };
let (seq, last, bmdt, duration) = read_chunks(chunks, &self.track)?;
// §11.4 step 2 and step 5: the next group, at the next sequence number.
let g = receipt.group_id;
if g < self.state.next_index {
return Err(SequenceNotIncreasing);
}
if g > self.state.next_index {
return Err(SegmentMissing);
}
if seq != self.state.next_sequence_number {
return Err(SequenceMismatch);
}
// Step 6: the receipt is the group's, links to the one before it, and holds the chunks.
let parent_ok = match &self.state.last_receipt {
Some(p) if p.signer_pubkey == receipt.signer_pubkey => receipt.parent_receipt_hash == Some(p.link_hash()),
_ => receipt.parent_receipt_hash.is_none(),
};
if receipt.label.is_some() || receipt.verify().is_err() || receipt.track_alias != sa.track_alias || !parent_ok {
return Err(ReceiptInvalid);
}
if receipt.leaf_count != chunks.len() as u64 || receipt.root_hash != root(&chunk_leaves(chunks)) {
return Err(ReceiptMismatch);
}
let group = (L_GROUP.to_string(), group_item(receipt));
let out = self.write(chunks, seq, bmdt, duration, Some(group), deliver, Some(receipt.signer_pubkey))?;
self.state.next_sequence_number = last + 1;
self.state.next_index = g + 1;
self.state.last_receipt = Some(receipt.clone());
Ok(out)
}
/// A take's next segment, from its chunks and the digest of the device's last
/// `take_commit` before it was written (§5.3, capture-binding §11.2).
pub fn take_segment(&mut self, chunks: &[&[u8]], commit: [u8; 32]) -> Result<Vec<u8>, Reason> {
let Identity::Take(t) = &self.cfg.identity else { return Err(Reason::StreamAssertionInvalid) };
let (seq, last, bmdt, duration) = read_chunks(chunks, &self.track)?;
// §11.4 step 5: one more than the last `sequence_number` of the previous segment.
if seq != self.state.next_sequence_number {
return Err(Reason::SequenceMismatch);
}
let take = TakeAssertion { commit: Some(commit), epoch: None, ..t.clone() };
let ident = (L_TAKE.to_string(), take.to_item());
let out = self.write(chunks, seq, bmdt, duration, Some(ident), &[], None)?;
self.state.next_sequence_number = last + 1;
self.state.next_index += 1;
Ok(out)
}
/// Write the segment of `chunks` at sequence number `seq`: its manifest (§6, or §7.3 where
/// it delivers keys) and its VSI box (§7.2). `per_segment` is the last assertion of a §6
/// manifest; `receipt_key`, a track's receipt signer, which the VSI method's session key
/// must be (§8.3).
#[allow(clippy::too_many_arguments)]
fn write(
&mut self,
chunks: &[&[u8]],
seq: u64,
bmdt: u64,
duration: u64,
per_segment: Option<(String, Item)>,
deliver: &[SessionKey],
receipt_key: Option<[u8; 32]>,
) -> Result<Vec<u8>, Reason> {
use Reason::*;
// Keys delivered here: under the VSI method alone (§7.3), new, from this segment on.
if !deliver.is_empty() {
let held: Vec<[u8; 32]> = self.cfg.session_keys.iter().map(|k| k.key.verifying_key().to_bytes()).collect();
let fresh = deliver.iter().enumerate().all(|(i, k)| {
let x = k.key.verifying_key().to_bytes();
!held.contains(&x) && !deliver[..i].iter().any(|o| o.key.verifying_key().to_bytes() == x)
});
if !self.cfg.vsi || self.cfg.manifests || !fresh || deliver.iter().any(|k| k.min_sequence_number < seq) {
return Err(SessionKeyInvalid);
}
}
let keys: Vec<SessionKey> = self.cfg.session_keys.iter().chain(deliver).cloned().collect();
// §11.4 step 4: the current key, within its validity at the segment's instant.
let vsi_key = if self.cfg.vsi {
let k = current(&keys, seq).ok_or(SessionKeyOutOfRange)?;
let at = self.cfg.identity.epoch().saturating_add(
((bmdt as u128 * 1_000_000) / self.track.timescale as u128).min(u64::MAX as u128) as u64,
);
let (from, to) = (k.created_at.saturating_mul(1_000_000), k.created_at.saturating_add(k.validity_period).saturating_mul(1_000_000));
if at < from || at >= to {
return Err(SessionKeyOutOfRange);
}
if receipt_key.is_some_and(|r| r != k.key.verifying_key().to_bytes()) {
return Err(ReceiptSignerMismatch);
}
Some(k.clone())
} else {
None
};
let mut manifest = None;
if self.cfg.manifests {
let id = derived_manifest_id(&self.stream_id, ROLE_SEGMENT, seq);
let mut a = vec![
(L_ACTIONS.to_string(), actions_item(self.term)),
(L_LIVE.to_string(), live_item(seq, &self.stream_id, &self.state.last_manifest)),
];
a.extend(self.ai.clone());
a.extend(per_segment);
manifest = Some(ManifestPlan { id, assertions: a, sequence_number: Some(seq), signer: self.cfg.signer.clone() });
} else if !deliver.is_empty() {
let id = derived_manifest_id(&self.stream_id, ROLE_ROTATION, seq);
let mut a =
vec![(L_ACTIONS.to_string(), actions_item(self.term)), (L_KEYS.to_string(), keys_item(deliver, &self.cfg.signer.chain[0]))];
a.extend(self.ai.clone());
a.push(self.ident.clone());
manifest = Some(ManifestPlan { id, assertions: a, sequence_number: Some(seq), signer: self.cfg.signer.clone() });
}
let active = manifest.as_ref().map_or(self.state.last_manifest.clone(), |m| m.id.clone());
let vsi = match vsi_key {
None => None,
Some(k) => Some(VsiPlan {
kid: k.key.verifying_key().to_bytes(),
key: k.key,
sequence_number: seq,
manifest_id: active.clone(),
timescale: self.track.timescale,
duration: u32::try_from(duration).map_err(|_| SegmentInfoInvalid)?,
}),
};
if u32::try_from(seq).is_err() {
return Err(SequenceMismatch);
}
let out = segment(manifest.as_ref(), vsi.as_ref(), &chunks.concat());
self.cfg.session_keys = keys;
self.state.last_manifest = active;
Ok(out)
}
}

View file

@ -1,13 +1,21 @@
//! CMSD (CTA-5006) for a WAI object (`extensions/delivery-telemetry.md` §6): the
//! static description an origin or edge gives a response, and the dynamic state each
//! node adds. Keys and parameters are written in bytewise order; a key is written only
//! when its value is not `null`, and a Boolean only when true. CMSD version 1 is not
//! written (CTA-5006: a sender omits `v` for version 1).
//! CMSD (CTA-5006, November 2022) for a WAI object (`extensions/delivery-telemetry.md`
//! §6): the static description an origin or edge gives a response, and the dynamic state
//! each node adds. Keys and parameters are written in bytewise order; a key is written
//! only when its value is not `null`, and a Boolean only when true. CMSD version 1 is not
//! written (CTA-5006 §7 Table 1, `v`: a server omits it for version 1).
//!
//! [`read`] reads any party's CMSD as CTA-5006 §8.1 has a client read it (§6.6 to §6.9):
//! a field that is not a Structured Field is ignored whole, a later version ignores the
//! data set, a value not of its key's type is ignored alone, an unknown key is ignored,
//! and what was ignored is stated.
use std::collections::BTreeMap;
use serde_json::{Map, Value};
use super::cmcd::PREFIX;
use super::observation::OBJECT_TYPES;
use super::sf::full::{self, Bare, Item, Member};
use super::sf::{serialize_dict, serialize_list, SfBare, SfItem, SfMember};
use super::TelemetryError;
@ -30,7 +38,8 @@ pub struct CmsdStatic {
pub layers_total: Option<u64>,
/// A delivery receipt's link hash (`wai-rcpt`).
pub receipt: Option<String>,
/// A deriving node's operation token (`wai-op`).
/// A deriving node's operation token (`wai-op`), written as a String (§6.2; CTA-5006
/// §4 item 1).
pub op: Option<String>,
}
@ -115,7 +124,9 @@ impl CmsdStatic {
nrr: ostr(&o["nrr"], "nrr")?,
ot: otok(&o["ot"], OBJECT_TYPES, "ot")?,
sf: otok(&o["sf"], &["o", "h", "d"], "sf")?,
st: otok(&o["st"], &["v", "l", "ll"], "st")?,
// CTA-5006 §7 Table 1: `v` or `l`. CMSD has no value for CMCD's `ll`; a
// low-latency live object is `l` (delivery-telemetry §6.1).
st: otok(&o["st"], &["v", "l"], "st")?,
su: o["su"].as_bool().ok_or_else(|| bad("su: not a boolean"))?,
content_hash: ohex(&o["content_hash"], "content_hash")?,
layer: oint(&o["layer"], "layer")?,
@ -146,7 +157,7 @@ impl CmsdStatic {
put(c("ch"), st(&self.content_hash));
put(c("l"), self.layer.map(|n| SfBare::Str(n.to_string())));
put(c("lt"), self.layers_total.map(|n| SfBare::Str(n.to_string())));
put(c("op"), tk(&self.op));
put(c("op"), st(&self.op));
put(c("rcpt"), st(&self.receipt));
put("sf".into(), tk(&self.sf));
put("st".into(), tk(&self.st));
@ -195,3 +206,449 @@ pub fn encode_dynamic(entries: &[CmsdDynamic]) -> Result<String, TelemetryError>
.collect();
serialize_list(&items).map_err(|e| bad(e.to_string()))
}
// ---------------------------------------------------------------------------------------
// Reading (§6.6 to §6.9): CMSD as CTA-5006 §8.1 has a client read it.
// ---------------------------------------------------------------------------------------
/// CTA-5006 §7 Table 1: the object types of `ot`, the nine CMCD's `ot` has too.
pub const CMSD_OT: &[&str] = OBJECT_TYPES;
/// The streaming formats of `sf`.
pub const CMSD_SF: &[&str] = &["d", "h", "s", "o"];
/// The stream types of `st`.
pub const CMSD_ST: &[&str] = &["v", "l"];
/// The keys Table 1 reserves for `CMSD-Static`.
pub const STATIC_KEYS: &[&str] = &["at", "br", "d", "ht", "n", "nor", "nrr", "ot", "sf", "st", "su", "v"];
/// The keys Table 1 reserves for `CMSD-Dynamic`.
pub const DYNAMIC_KEYS: &[&str] = &["du", "etp", "mb", "rd", "rtt"];
/// `CMSD-Static` as read (§6.7). A key not read, or ignored, is `None`.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StaticRead {
pub at: Option<u64>,
pub br: Option<u64>,
pub d: Option<u64>,
pub ht: Option<u64>,
pub n: Option<String>,
/// `nor`, cut at each `|`.
pub nor: Option<Vec<String>>,
/// `nrr`, cut at each `|`: an empty element (the whole object) or a byte range.
pub nrr: Option<Vec<Option<(u64, u64)>>>,
pub ot: Option<String>,
/// `sf`: a Token, as a list of one, or an Inner List of Tokens.
pub sf: Option<Vec<String>>,
pub st: Option<String>,
pub su: Option<bool>,
/// The version of the data set: 1 where `v` is absent or ignored.
pub v: u64,
/// §6.1's custom keys.
pub content_hash: Option<String>,
pub layer: Option<u64>,
pub layers_total: Option<u64>,
pub receipt: Option<String>,
/// §6.2's operation token, written as a Token (§6.9) or a String.
pub op: Option<String>,
/// Every other custom key whose value is a String, by key.
pub custom: BTreeMap<String, String>,
}
/// One `CMSD-Dynamic` entry as read (§6.7), in the field's order: the first nearest the
/// origin, the last nearest the client (CTA-5006 §4).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct EntryRead {
/// The list member, the server's identifier; `None` when it is not a String.
pub server: Option<String>,
pub du: Option<bool>,
pub etp: Option<u64>,
pub mb: Option<u64>,
pub rd: Option<u64>,
pub rtt: Option<u64>,
/// §6.3's `science.transaction.wai-drop`.
pub drop: Option<u64>,
/// Every other custom parameter whose value is a String, by key.
pub custom: BTreeMap<String, String>,
}
/// What a reader ignored, and why (§6.8).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ignored {
/// `static` or `dynamic`.
pub field: &'static str,
/// The `CMSD-Dynamic` entry, from 0.
pub entry: Option<usize>,
/// The key; `None` when the whole field is ignored.
pub key: Option<String>,
/// `unparsed`: the field is not a Structured Field of its type (CTA-5006 §8.1 item 1);
/// `version`: the data set is of a later version (item 8); `type`: the value is not
/// of its key's type (item 4, §4 item 1); `unknown`: a key this header does not
/// reserve and that is not a custom key (item 3).
pub why: &'static str,
}
/// A response's CMSD as read: each field, or `None` when it is absent or ignored whole.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CmsdRead {
pub static_: Option<StaticRead>,
pub dynamic: Option<Vec<EntryRead>>,
pub ignored: Vec<Ignored>,
}
/// A custom key (CTA-5006 §4 item 1): one this header does not reserve, holding a hyphen.
fn is_custom(k: &str, reserved: &[&str]) -> bool {
!reserved.contains(&k) && k.contains('-')
}
fn int_of(b: &Bare) -> Option<u64> {
match b {
Bare::Integer(x) if *x >= 0 => Some(*x as u64),
_ => None,
}
}
fn str_of(b: &Bare) -> Option<String> {
match b {
Bare::String(s) => String::from_utf8(s.clone()).ok(),
_ => None,
}
}
fn hex64(s: &str) -> bool {
s.len() == 64 && s.bytes().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
}
/// A decimal String as §4.1 writes an integer: no leading zero but `0`, at most 10¹⁵ − 1.
fn decimal(s: &str) -> Option<u64> {
let ok = !s.is_empty() && s.len() <= 15 && s.bytes().all(|c| c.is_ascii_digit()) && (s == "0" || !s.starts_with('0'));
ok.then(|| s.parse().ok()).flatten()
}
fn range(s: &str) -> Option<Option<(u64, u64)>> {
if s.is_empty() {
return Some(None);
}
let (a, b) = s.split_once('-')?;
let d = |t: &str| (!t.is_empty() && t.len() <= 20 && t.bytes().all(|c| c.is_ascii_digit())).then(|| t.parse::<u64>().ok()).flatten();
let (a, b) = (d(a)?, d(b)?);
(a <= b).then_some(Some((a, b)))
}
fn token_in(b: &Bare, set: &[&str]) -> Option<String> {
match b {
Bare::Token(t) if set.contains(&t.as_str()) => Some(t.clone()),
_ => None,
}
}
/// Read a response's `CMSD-Static` and `CMSD-Dynamic`, each given as its field lines in
/// order (none when the field is absent), as CTA-5006 §8.1 has a client read them
/// (delivery-telemetry §6.6 to §6.8). Nothing is refused: what does not read is ignored,
/// and [`CmsdRead::ignored`] says what and why.
pub fn read(static_lines: &[&str], dynamic_lines: &[&str]) -> CmsdRead {
let mut out = CmsdRead::default();
let static_dict = if static_lines.is_empty() {
None
} else {
match full::parse_dictionary(&full::combine(static_lines)) {
Some(d) => Some(d),
None => {
out.ignored.push(Ignored { field: "static", entry: None, key: None, why: "unparsed" });
None
}
}
};
let dynamic_list = if dynamic_lines.is_empty() {
None
} else {
match full::parse_list(&full::combine(dynamic_lines)) {
Some(l) => Some(l),
None => {
out.ignored.push(Ignored { field: "dynamic", entry: None, key: None, why: "unparsed" });
None
}
}
};
// The version first (CTA-5006 §8.1 item 8): a later one ignores the whole data set.
let mut version = 1;
if let Some(d) = &static_dict
&& let Some((_, m)) = d.iter().find(|(k, _)| k == "v")
{
match m {
Member::Item(Item { bare: Bare::Integer(x), .. }) if *x == 1 => {}
Member::Item(Item { bare: Bare::Integer(x), .. }) if *x > 1 => version = *x as u64,
_ => out.ignored.push(Ignored { field: "static", entry: None, key: Some("v".into()), why: "type" }),
}
}
if version > 1 {
out.ignored.push(Ignored { field: "static", entry: None, key: Some("v".into()), why: "version" });
if dynamic_list.is_some() {
out.ignored.push(Ignored { field: "dynamic", entry: None, key: None, why: "version" });
}
return out;
}
if let Some(d) = static_dict {
out.static_ = Some(read_static(&d, &mut out.ignored));
}
if let Some(l) = dynamic_list {
out.dynamic = Some(l.iter().enumerate().map(|(i, m)| read_entry(i, m, &mut out.ignored)).collect());
}
out
}
/// Put `v` in `slot` when it read: whether it did.
fn set<T>(slot: &mut Option<T>, v: Option<T>) -> bool {
let read = v.is_some();
if read {
*slot = v;
}
read
}
fn read_static(d: &[(String, Member)], ignored: &mut Vec<Ignored>) -> StaticRead {
let mut s = StaticRead { v: 1, ..StaticRead::default() };
let wai = |x: &str| format!("{PREFIX}{x}");
for (k, m) in d {
if k == "v" {
continue; // read by `read`
}
// A member's parameters are not CTA-5006's; its value is read alone.
let bare = match m {
Member::Item(it) => Some(&it.bare),
Member::Inner(..) => None,
};
let read = match k.as_str() {
"at" => set(&mut s.at, bare.and_then(int_of)),
"br" => set(&mut s.br, bare.and_then(int_of)),
"d" => set(&mut s.d, bare.and_then(int_of)),
"ht" => set(&mut s.ht, bare.and_then(int_of)),
"n" => set(&mut s.n, bare.and_then(str_of)),
"nor" => set(
&mut s.nor,
bare.and_then(str_of).map(|x| x.split('|').map(String::from).collect::<Vec<_>>()).filter(|p| p.iter().all(|x| !x.is_empty())),
),
"nrr" => set(&mut s.nrr, bare.and_then(str_of).and_then(|x| x.split('|').map(range).collect::<Option<Vec<_>>>())),
"ot" => set(&mut s.ot, bare.and_then(|b| token_in(b, CMSD_OT))),
"sf" => set(
&mut s.sf,
match m {
Member::Item(it) => token_in(&it.bare, CMSD_SF).map(|t| vec![t]),
Member::Inner(items, _) if !items.is_empty() => items.iter().map(|it| token_in(&it.bare, CMSD_SF)).collect(),
Member::Inner(..) => None,
},
),
"st" => set(&mut s.st, bare.and_then(|b| token_in(b, CMSD_ST))),
"su" => set(
&mut s.su,
match bare {
Some(Bare::Boolean(b)) => Some(*b),
_ => None,
},
),
x if x == wai("ch") => set(&mut s.content_hash, bare.and_then(str_of).filter(|h| hex64(h))),
x if x == wai("rcpt") => set(&mut s.receipt, bare.and_then(str_of).filter(|h| hex64(h))),
x if x == wai("l") => set(&mut s.layer, bare.and_then(str_of).and_then(|t| decimal(&t))),
x if x == wai("lt") => set(&mut s.layers_total, bare.and_then(str_of).and_then(|t| decimal(&t))),
x if x == wai("op") => set(
&mut s.op,
// A String holding a token (§6.2); a Token is not a custom key's type, and is
// ignored (§6.7; CTA-5006 §4 item 1).
match bare {
Some(Bare::String(b)) => std::str::from_utf8(b).ok().filter(|t| super::sf::is_token(t)).map(String::from),
_ => None,
},
),
x if is_custom(x, STATIC_KEYS) => match bare.and_then(str_of) {
Some(v) => {
s.custom.insert(x.to_string(), v);
true
}
None => false,
},
_ => {
ignored.push(Ignored { field: "static", entry: None, key: Some(k.clone()), why: "unknown" });
true
}
};
if !read {
ignored.push(Ignored { field: "static", entry: None, key: Some(k.clone()), why: "type" });
}
}
s
}
fn read_entry(i: usize, m: &Member, ignored: &mut Vec<Ignored>) -> EntryRead {
let (server, params) = match m {
Member::Item(it) => (str_of(&it.bare), &it.params),
Member::Inner(_, p) => (None, p),
};
if server.is_none() {
ignored.push(Ignored { field: "dynamic", entry: Some(i), key: None, why: "type" });
}
let drop = format!("{PREFIX}drop");
let mut e = EntryRead { server, ..EntryRead::default() };
for (k, b) in params {
let read = match k.as_str() {
"du" => set(
&mut e.du,
match b {
Bare::Boolean(x) => Some(*x),
_ => None,
},
),
"etp" => set(&mut e.etp, int_of(b)),
"mb" => set(&mut e.mb, int_of(b)),
"rd" => set(&mut e.rd, int_of(b)),
"rtt" => set(&mut e.rtt, int_of(b)),
x if x == drop => set(&mut e.drop, str_of(b).and_then(|t| decimal(&t))),
x if is_custom(x, DYNAMIC_KEYS) => match str_of(b) {
Some(v) => {
e.custom.insert(x.to_string(), v);
true
}
None => false,
},
_ => {
ignored.push(Ignored { field: "dynamic", entry: Some(i), key: Some(k.clone()), why: "unknown" });
true
}
};
if !read {
ignored.push(Ignored { field: "dynamic", entry: Some(i), key: Some(k.clone()), why: "type" });
}
}
e
}
impl StaticRead {
pub fn to_json(&self) -> Value {
serde_json::json!({
"at": self.at, "br": self.br, "d": self.d, "ht": self.ht, "n": self.n, "nor": self.nor,
"nrr": self.nrr.as_ref().map(|r| r.iter().map(|x| x.map(|(a, b)| serde_json::json!([a, b]))).collect::<Vec<_>>()),
"ot": self.ot, "sf": self.sf, "st": self.st, "su": self.su, "v": self.v,
"content_hash": self.content_hash, "layer": self.layer, "layers_total": self.layers_total,
"receipt": self.receipt, "op": self.op, "custom": self.custom,
})
}
}
impl EntryRead {
pub fn to_json(&self) -> Value {
serde_json::json!({
"server": self.server, "du": self.du, "etp": self.etp, "mb": self.mb, "rd": self.rd,
"rtt": self.rtt, "drop": self.drop, "custom": self.custom,
})
}
}
impl CmsdRead {
/// The form the corpus states (`telemetry-conformance/expected/cmsd-read.json`).
pub fn to_json(&self) -> Value {
serde_json::json!({
"static": self.static_.as_ref().map(StaticRead::to_json),
"dynamic": self.dynamic.as_ref().map(|d| d.iter().map(EntryRead::to_json).collect::<Vec<_>>()),
"ignored": self.ignored.iter().map(|g| serde_json::json!({"field": g.field, "entry": g.entry, "key": g.key, "why": g.why})).collect::<Vec<_>>(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn what_this_crate_writes_reads_back() {
let s = CmsdStatic { at: Some(7), d: Some(40), nor: Some("a.wai".into()), ot: Some("v".into()), sf: Some("h".into()), st: Some("l".into()), su: true, layer: Some(2), op: Some("crop".into()), ..CmsdStatic::default() };
let f = s.encode().unwrap();
let r = read(&[&f], &[]);
assert!(r.ignored.is_empty(), "{:?}", r.ignored);
let x = r.static_.unwrap();
assert_eq!((x.at, x.d, x.su, x.layer, x.v), (Some(7), Some(40), Some(true), Some(2), 1));
assert_eq!((x.nor, x.sf, x.st, x.op), (Some(vec!["a.wai".to_string()]), Some(vec!["h".to_string()]), Some("l".into()), Some("crop".into())));
let d = encode_dynamic(&[CmsdDynamic { server: "e".into(), du: true, etp: Some(5), mb: None, rd: None, rtt: Some(9), drop: Some(3) }]).unwrap();
let e = &read(&[], &[&d]).dynamic.unwrap()[0];
assert_eq!((e.server.as_deref(), e.du, e.etp, e.rtt, e.drop), (Some("e"), Some(true), Some(5), Some(9), Some(3)));
}
#[test]
fn the_emitter_keeps_to_table_1() {
let v = serde_json::json!({"at": null, "d": null, "ht": null, "nor": null, "nrr": null, "ot": null, "sf": null, "st": "ll", "su": false, "content_hash": null, "layer": null, "layers_total": null, "receipt": null, "op": null});
assert!(CmsdStatic::from_json(&v).is_err(), "st=ll is CMCD's, not CTA-5006's");
}
#[test]
fn a_later_version_ignores_the_data_set() {
let r = read(&["v=2,ot=v"], &[r#""e";rtt=1"#]);
assert_eq!((r.static_.is_none(), r.dynamic.is_none()), (true, true));
assert_eq!(r.ignored.iter().map(|g| (g.field, g.why)).collect::<Vec<_>>(), vec![("static", "version"), ("dynamic", "version")]);
let r = read(&["v=1,ot=v"], &[]);
assert_eq!(r.static_.unwrap().ot.as_deref(), Some("v"));
let r = read(&["v=0,ot=v"], &[]);
assert_eq!((r.static_.as_ref().unwrap().v, r.ignored.len()), (1, 1));
}
#[test]
fn a_field_that_does_not_parse_is_ignored_whole_and_alone() {
let r = read(&["ot=v,,st=l"], &[r#""e";rtt=1"#]);
assert!(r.static_.is_none());
assert_eq!(r.dynamic.unwrap()[0].rtt, Some(1));
let r = read(&["ot=v"], &[r#""e";rtt=1"#, r#""f";rtt=2;"#]);
assert!(r.dynamic.is_none(), "one bad line spoils the combined field");
assert_eq!(r.static_.unwrap().ot.as_deref(), Some("v"));
}
#[test]
fn a_value_of_the_wrong_type_is_ignored_alone() {
let r = read(&[r#"at=-5,br=1.5,d="40",ot=x,st=ll,su=1,sf=(h x),nor="a||b",nrr="5-2",ht=12"#], &[]);
let s = r.static_.unwrap();
assert_eq!(s.ht, Some(12));
assert_eq!((s.at, s.br, s.d, s.ot, s.st, s.su, s.sf, s.nor, s.nrr), (None, None, None, None, None, None, None, None, None));
let mut why: Vec<(String, &str)> = r.ignored.iter().map(|g| (g.key.clone().unwrap(), g.why)).collect();
why.sort();
assert_eq!(why.len(), 9);
assert!(why.iter().all(|(_, w)| *w == "type"));
}
#[test]
fn keys_of_the_other_header_and_unknown_keys_are_ignored() {
let r = read(&["etp=100,tl=9,ot=v,org.example-a=\"1\""], &[r#""e";ot=v;rtt=5;org.example-b="2""#]);
let s = r.static_.unwrap();
assert_eq!(s.custom.get("org.example-a").map(String::as_str), Some("1"));
let e = &r.dynamic.unwrap()[0];
assert_eq!((e.rtt, e.custom.get("org.example-b").map(String::as_str)), (Some(5), Some("2")));
let unknown: Vec<&str> = r.ignored.iter().filter(|g| g.why == "unknown").filter_map(|g| g.key.as_deref()).collect();
assert_eq!(unknown, vec!["etp", "tl", "ot"]);
}
#[test]
fn sf_reads_as_a_token_or_an_inner_list() {
assert_eq!(read(&["sf=h"], &[]).static_.unwrap().sf, Some(vec!["h".into()]));
assert_eq!(read(&["sf=(d h)"], &[]).static_.unwrap().sf, Some(vec!["d".into(), "h".into()]));
assert_eq!(read(&["sf=()"], &[]).static_.unwrap().sf, None);
}
#[test]
fn nrr_aligns_with_nor() {
let s = read(&[r#"nor="a|b|c",nrr="|0-9|""#], &[]).static_.unwrap();
assert_eq!(s.nrr, Some(vec![None, Some((0, 9)), None]));
}
/// The reader on inputs drawn from a fixed seed: none panics.
#[test]
fn the_reader_never_panics_on_drawn_inputs() {
let mut z: u64 = 0x5eed_c35d;
let mut next = || {
z = z.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut x = z;
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^ (x >> 31)
};
const P: &[&str] = &["ot", "=", "v", ",", ";", " ", "(", ")", "\"", "\\", "?1", "?0", "-", ".", "5", "999999999999999", "9999999999999999", "@", "%", ":", "YQ==", "su", "du", "etp", "rtt", "nor", "nrr", "|", "science.transaction.wai-l", "x-y", "\t", "é", "1.5"];
for _ in 0..20_000 {
let n = (next() % 12) as usize;
let s: String = (0..n).map(|_| P[(next() % P.len() as u64) as usize]).collect();
let _ = read(&[&s], &[&s]);
let _ = full::parse_list(&s);
}
}
}

View file

@ -254,7 +254,7 @@ mod tests {
.unwrap();
assert_eq!(
s.encode().unwrap(),
format!("at=1700000000000,d=4000,nor=\"next.wai\",ot=v,science.transaction.wai-ch=\"{H}\",science.transaction.wai-l=\"1\",science.transaction.wai-lt=\"3\",science.transaction.wai-op=crop,sf=o,st=v,su")
format!("at=1700000000000,d=4000,nor=\"next.wai\",ot=v,science.transaction.wai-ch=\"{H}\",science.transaction.wai-l=\"1\",science.transaction.wai-lt=\"3\",science.transaction.wai-op=\"crop\",sf=o,st=v,su")
);
let d = [
CmsdDynamic { server: "edge-1".into(), du: true, etp: Some(25000), mb: None, rd: Some(5), rtt: Some(40), drop: Some(2) },

View file

@ -1,8 +1,9 @@
//! The subset of RFC 9651 Structured Field Values that CMCD and CMSD reports use:
//! dictionaries whose members are bare items (Integer, String, Token, Boolean) or inner
//! lists of Integers, and lists of items with parameters. Serialisation follows RFC
//! 9651 §4.1 and writes no optional whitespace; parsing follows §4.2 and refuses what
//! this subset does not hold.
//! RFC 9651 Structured Field Values for CMCD and CMSD. The top of this module is the
//! subset the reports this crate writes use: dictionaries whose members are bare items
//! (Integer, String, Token, Boolean) or inner lists of Integers, and lists of items with
//! parameters. Serialisation follows RFC 9651 §4.1 and writes no optional whitespace;
//! that subset's parsing follows §4.2 and refuses what the subset does not hold. [`full`]
//! parses the whole grammar, for readers of fields other parties write.
/// A bare item (RFC 9651 §3.3).
#[derive(Debug, Clone, PartialEq, Eq)]
@ -361,6 +362,350 @@ pub fn parse_list(s: &str) -> Result<Vec<SfItem>, SfError> {
}
}
/// RFC 9651 §4.2: Structured Field Values parsed whole, the grammar of every type, Dates and
/// Display Strings included, for the readers that must take any field and then judge its
/// members one at a time: the steering request's CMCD (`extensions/energy-steering.md`
/// §4.4) and CMSD (`extensions/delivery-telemetry.md` §6.6). A field that does not parse
/// gives `None`.
pub mod full {
/// A bare item (RFC 9651 §3.3).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Bare {
Integer(i64),
/// A Decimal, as its value × 1000.
Decimal(i64),
String(Vec<u8>),
Token(String),
Bytes(Vec<u8>),
Boolean(bool),
Date(i64),
Display(String),
}
/// An item with its parameters.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Item {
pub bare: Bare,
pub params: Vec<(String, Bare)>,
}
/// A dictionary member: an item, or an inner list with its parameters.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Member {
Item(Item),
Inner(Vec<Item>, Vec<(String, Bare)>),
}
struct P<'a> {
b: &'a [u8],
i: usize,
}
type R<T> = Result<T, ()>;
impl P<'_> {
fn peek(&self) -> Option<u8> {
self.b.get(self.i).copied()
}
fn sp(&mut self) {
while self.peek() == Some(b' ') {
self.i += 1;
}
}
fn ows(&mut self) {
while matches!(self.peek(), Some(b' ' | b'\t')) {
self.i += 1;
}
}
fn key(&mut self) -> R<String> {
match self.peek() {
Some(c) if c.is_ascii_lowercase() || c == b'*' => {}
_ => return Err(()),
}
let s = self.i;
while let Some(c) = self.peek() {
if c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, b'_' | b'-' | b'.' | b'*') {
self.i += 1;
} else {
break;
}
}
String::from_utf8(self.b[s..self.i].to_vec()).map_err(|_| ())
}
fn params(&mut self) -> R<Vec<(String, Bare)>> {
let mut out: Vec<(String, Bare)> = Vec::new();
while self.peek() == Some(b';') {
self.i += 1;
self.sp();
let k = self.key()?;
let v = if self.peek() == Some(b'=') {
self.i += 1;
self.bare()?
} else {
Bare::Boolean(true)
};
match out.iter_mut().find(|(x, _)| *x == k) {
Some(slot) => slot.1 = v,
None => out.push((k, v)),
}
}
Ok(out)
}
fn item(&mut self) -> R<Item> {
let bare = self.bare()?;
let params = self.params()?;
Ok(Item { bare, params })
}
fn inner(&mut self) -> R<Member> {
self.i += 1; // (
let mut items = Vec::new();
loop {
self.sp();
if self.peek() == Some(b')') {
self.i += 1;
let params = self.params()?;
return Ok(Member::Inner(items, params));
}
items.push(self.item()?);
match self.peek() {
Some(b' ' | b')') => {}
_ => return Err(()),
}
}
}
fn member(&mut self) -> R<Member> {
if self.peek() == Some(b'(') { self.inner() } else { Ok(Member::Item(self.item()?)) }
}
fn number(&mut self) -> R<Bare> {
let neg = if self.peek() == Some(b'-') {
self.i += 1;
true
} else {
false
};
if !self.peek().is_some_and(|c| c.is_ascii_digit()) {
return Err(());
}
let s = self.i;
let mut dot = None;
while let Some(c) = self.peek() {
if c.is_ascii_digit() {
self.i += 1;
} else if c == b'.' && dot.is_none() {
if self.i - s > 12 {
return Err(());
}
dot = Some(self.i);
self.i += 1;
} else {
break;
}
let len = self.i - s;
if dot.is_none() && len > 15 || dot.is_some() && len > 16 {
return Err(());
}
}
let digits = std::str::from_utf8(&self.b[s..self.i]).map_err(|_| ())?;
let sign = if neg { -1 } else { 1 };
match dot {
None => Ok(Bare::Integer(sign * digits.parse::<i64>().map_err(|_| ())?)),
Some(d) => {
let frac = self.i - d - 1;
if frac == 0 || frac > 3 {
return Err(());
}
let (int, f) = digits.split_at(d - s);
let f = &f[1..];
let scaled = int.parse::<i64>().map_err(|_| ())? * 1000 + f.parse::<i64>().map_err(|_| ())? * 10i64.pow(3 - frac as u32);
Ok(Bare::Decimal(sign * scaled))
}
}
}
fn bare(&mut self) -> R<Bare> {
match self.peek().ok_or(())? {
b'-' | b'0'..=b'9' => self.number(),
b'"' => {
self.i += 1;
let mut o = Vec::new();
loop {
let c = self.peek().ok_or(())?;
self.i += 1;
match c {
b'\\' => {
let n = self.peek().ok_or(())?;
if n != b'"' && n != b'\\' {
return Err(());
}
self.i += 1;
o.push(n);
}
b'"' => return Ok(Bare::String(o)),
0x20..=0x7e => o.push(c),
_ => return Err(()),
}
}
}
c if c.is_ascii_alphabetic() || c == b'*' => {
let s = self.i;
self.i += 1;
while let Some(c) = self.peek() {
if c.is_ascii_alphanumeric() || b"!#$%&'*+-.^_`|~:/".contains(&c) {
self.i += 1;
} else {
break;
}
}
Ok(Bare::Token(String::from_utf8(self.b[s..self.i].to_vec()).map_err(|_| ())?))
}
b':' => {
self.i += 1;
let s = self.i;
loop {
let c = self.peek().ok_or(())?;
if c == b':' {
break;
}
if !(c.is_ascii_alphanumeric() || matches!(c, b'+' | b'/' | b'=')) {
return Err(());
}
self.i += 1;
}
let v = self.b[s..self.i].to_vec();
self.i += 1;
if !base64_ok(&v) {
return Err(());
}
Ok(Bare::Bytes(v))
}
b'?' => {
self.i += 1;
let v = match self.peek() {
Some(b'0') => false,
Some(b'1') => true,
_ => return Err(()),
};
self.i += 1;
Ok(Bare::Boolean(v))
}
b'@' => {
self.i += 1;
match self.number()? {
Bare::Integer(x) => Ok(Bare::Date(x)),
_ => Err(()),
}
}
b'%' => {
self.i += 1;
if self.peek() != Some(b'"') {
return Err(());
}
self.i += 1;
let mut o = Vec::new();
loop {
let c = self.peek().ok_or(())?;
self.i += 1;
match c {
b'%' => {
let h = self.b.get(self.i..self.i + 2).ok_or(())?;
if !h.iter().all(|&x| x.is_ascii_digit() || (b'a'..=b'f').contains(&x)) {
return Err(());
}
o.push(u8::from_str_radix(std::str::from_utf8(h).map_err(|_| ())?, 16).map_err(|_| ())?);
self.i += 2;
}
b'"' => return Ok(Bare::Display(String::from_utf8(o).map_err(|_| ())?)),
0x20..=0x7e => o.push(c),
_ => return Err(()),
}
}
}
_ => Err(()),
}
}
}
/// Whether a Byte Sequence's content decodes as base64 (RFC 4648 §4), its padding
/// synthesized where it is missing (RFC 9651 §4.2.7): at most two `=`, only at the end,
/// and a length the padding completes to a multiple of four.
fn base64_ok(v: &[u8]) -> bool {
let q = v.iter().rev().take_while(|&&c| c == b'=').count();
let core = &v[..v.len() - q];
q <= 2 && core.iter().all(|&c| c.is_ascii_alphanumeric() || c == b'+' || c == b'/') && if q == 0 { core.len() % 4 != 1 } else { (core.len() + q).is_multiple_of(4) }
}
/// Parse a Dictionary field value (RFC 9651 §4.2, §4.2.2): `None` when it does not
/// parse. A key given twice keeps its first place and its last value.
pub fn parse_dictionary(s: &str) -> Option<Vec<(String, Member)>> {
dictionary(s).ok()
}
fn dictionary(s: &str) -> R<Vec<(String, Member)>> {
let mut p = P { b: s.as_bytes(), i: 0 };
p.sp();
let mut out: Vec<(String, Member)> = Vec::new();
while p.i < p.b.len() {
let k = p.key()?;
let v = if p.peek() == Some(b'=') {
p.i += 1;
p.member()?
} else {
Member::Item(Item { bare: Bare::Boolean(true), params: p.params()? })
};
match out.iter_mut().find(|(x, _)| *x == k) {
Some(slot) => slot.1 = v,
None => out.push((k, v)),
}
p.ows();
if p.i == p.b.len() {
break;
}
if p.peek() != Some(b',') {
return Err(());
}
p.i += 1;
p.ows();
if p.i == p.b.len() {
return Err(());
}
}
Ok(out)
}
/// Parse a List field value (RFC 9651 §4.2, §4.2.1): `None` when it does not parse.
/// Each member is an item or an inner list, with its parameters.
pub fn parse_list(s: &str) -> Option<Vec<Member>> {
list(s).ok()
}
fn list(s: &str) -> R<Vec<Member>> {
let mut p = P { b: s.as_bytes(), i: 0 };
p.sp();
let mut out = Vec::new();
while p.i < p.b.len() {
out.push(p.member()?);
p.ows();
if p.i == p.b.len() {
break;
}
if p.peek() != Some(b',') {
return Err(());
}
p.i += 1;
p.ows();
if p.i == p.b.len() {
return Err(());
}
}
Ok(out)
}
/// The value of a field sent as several field lines: the lines joined in order, each
/// pair by a comma and a space (RFC 9110 §5.3; RFC 9651 §4.2).
pub fn combine(lines: &[&str]) -> String {
lines.join(", ")
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -7,7 +7,7 @@
use std::collections::BTreeSet;
use std::path::PathBuf;
use wai::energy_steering::{corpus, Reason};
use wai::energy_steering::{cdn, corpus, Reason};
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
@ -103,3 +103,36 @@ fn the_extension_lists_the_reasons() {
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§10's table and Reason::ALL differ");
}
fn committed_cdn() -> String {
read("live-conformance/steering/cdn.json")
}
/// §8.5: `cdn.json` regenerates byte for byte from `vectors.json`, and every case in it gets
/// the outcome it states.
#[test]
fn the_cdn_fields_regenerate_and_verify() {
assert!(cdn::generate(&committed()).unwrap() == committed_cdn(), "live-conformance/steering/cdn.json differs from `wai_energy_steering_vectors gen`");
let fails = cdn::verify(&committed(), &committed_cdn());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// §8.5's table lists exactly `cdn::Reason::ALL`, in order; each refuses a case; and the
/// cases show each outcome: bound, absent, attributed and unattributed.
#[test]
fn the_cdn_section_lists_its_reasons_and_the_corpus_covers_them() {
let text = read("extensions/energy-steering.md");
let at = text.find("\n### 8.5 ").expect("a §8.5");
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
let listed: Vec<String> = text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
let want: Vec<String> = cdn::Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§8.5's table and cdn::Reason::ALL differ");
let doc: serde_json::Value = serde_json::from_str(&committed_cdn()).unwrap();
let outcomes: BTreeSet<&str> = doc["responses"].as_array().unwrap().iter().chain(doc["served"].as_array().unwrap()).filter_map(|v| v["outcome"].as_str()).collect();
for o in ["bound", "absent", "attributed", "unattributed", "refuse"] {
assert!(outcomes.contains(o), "no case is {o}");
}
let get = |n: &str| doc["served"].as_array().unwrap().iter().find(|v| v["name"] == n).unwrap_or_else(|| panic!("no case {n}")).clone();
assert_eq!(get("tree-through-other-servers")["hops"].as_array().unwrap().len(), 2, "other servers' entries are passed over");
assert_eq!(get("tree-through-other-servers")["hops"][1]["entry"], 3);
}

View file

@ -1,8 +1,9 @@
//! `wai.live.provenance` (extensions/live-provenance.md): the committed corpus
//! `live-conformance/c2pa-live/` regenerates byte for byte from its fixed seeds and the
//! packaging corpus's floor, holds no other file, every vector in it gets the outcome it
//! states, every reason of §12 refuses a vector, and §12 lists exactly the reasons the
//! reference reports.
//! states, whole and as it arrives (§11.7), every reason of §12 refuses a vector, and §12
//! lists exactly the reasons the reference reports; the live signer (§10.4) writes every good
//! stream byte for byte one segment at a time, and refuses what a verifier refuses.
#![cfg(feature = "live_provenance")]
use std::collections::BTreeMap;
@ -55,6 +56,34 @@ fn every_vector_gets_its_stated_outcome_and_every_reason_refuses_one() {
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// The incremental verifier (§11.7), given each segment as it would arrive live with the
/// receipts of its group and the groups before it, gives every vector the outcome the whole
/// stream gets.
#[test]
fn every_vector_gets_its_outcome_verified_as_it_arrives() {
let fails = corpus::verify_files_live(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// The live signer (§10.4), given each good stream's chunks and receipts one segment at a
/// time, writes the corpus's initialization segment and segments byte for byte, the
/// failover's second packager resumed from the first's state.
#[test]
fn live_signing_writes_every_good_stream_byte_for_byte() {
let media = Media::load(&wai_dir()).expect("the packaging corpus's floor");
let fails = corpus::live_differences(&media);
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// The live signer refuses, with the verifier's reason, a segment the verifier refuses, keeps
/// its state, and the stream then continues as the corpus's.
#[test]
fn the_live_signer_refuses_what_a_verifier_refuses() {
let media = Media::load(&wai_dir()).expect("the packaging corpus's floor");
let fails = corpus::live_refusals(&media);
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// §12 of the extension lists exactly the codes `Reason::ALL` has, each once, in order, with
/// the two C2PA codes the reference reports.
#[test]