WAI delivery bindings: secure-objects AAD in receipts (MOQT 22/18/16), CMSD read, steering named to CDNs, live C2PA signing #458
86 changed files with 7035 additions and 630 deletions
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
344
joule-code/joule-code-rs/crates/joule-moq-mls/src/identity.rs
Normal file
344
joule-code/joule-code-rs/crates/joule-moq-mls/src/identity.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
|
||||
pub mod corpus;
|
||||
pub mod group;
|
||||
pub mod identity;
|
||||
pub mod model;
|
||||
pub mod record;
|
||||
pub mod strict;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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] })
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ fn object_of(v: &Value) -> Option<ObjectReceipt> {
|
|||
leaf_index: p.get("leaf_index")?.as_u64()?,
|
||||
},
|
||||
energy_class,
|
||||
identity: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
16
wai/SPEC.md
16
wai/SPEC.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
377
wai/live-conformance/e2ee/identity.json
Normal file
377
wai/live-conformance/e2ee/identity.json
Normal 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"
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
444
wai/live-conformance/steering/cdn.json
Normal file
444
wai/live-conformance/steering/cdn.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()))}`);
|
||||
|
|
|
|||
5
wai/telemetry-conformance/cmsd-read/absent.json
Normal file
5
wai/telemetry-conformance/cmsd-read/absent.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"about": "no CMSD field: nothing is read and nothing is ignored",
|
||||
"static": [],
|
||||
"dynamic": []
|
||||
}
|
||||
7
wai/telemetry-conformance/cmsd-read/custom-key.json
Normal file
7
wai/telemetry-conformance/cmsd-read/custom-key.json
Normal 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": []
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
7
wai/telemetry-conformance/cmsd-read/display-string.json
Normal file
7
wai/telemetry-conformance/cmsd-read/display-string.json
Normal 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"
|
||||
]
|
||||
}
|
||||
7
wai/telemetry-conformance/cmsd-read/duplicate-key.json
Normal file
7
wai/telemetry-conformance/cmsd-read/duplicate-key.json
Normal 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": []
|
||||
}
|
||||
9
wai/telemetry-conformance/cmsd-read/duress.json
Normal file
9
wai/telemetry-conformance/cmsd-read/duress.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
7
wai/telemetry-conformance/cmsd-read/nor-escaped-bar.json
Normal file
7
wai/telemetry-conformance/cmsd-read/nor-escaped-bar.json
Normal 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": []
|
||||
}
|
||||
7
wai/telemetry-conformance/cmsd-read/nor-parent.json
Normal file
7
wai/telemetry-conformance/cmsd-read/nor-parent.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"about": "Annex A: a relative path into a parent folder",
|
||||
"static": [
|
||||
"nor=\"../../720/s12.wai\""
|
||||
],
|
||||
"dynamic": []
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"about": "a CMSD-Static field that is not a Dictionary: ignored whole (item 1)",
|
||||
"static": [
|
||||
"ot=v,,sf=h"
|
||||
],
|
||||
"dynamic": []
|
||||
}
|
||||
7
wai/telemetry-conformance/cmsd-read/origin-manifest.json
Normal file
7
wai/telemetry-conformance/cmsd-read/origin-manifest.json
Normal 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": []
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
7
wai/telemetry-conformance/cmsd-read/rfc9651-date.json
Normal file
7
wai/telemetry-conformance/cmsd-read/rfc9651-date.json
Normal 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": []
|
||||
}
|
||||
7
wai/telemetry-conformance/cmsd-read/sf-inner-list.json
Normal file
7
wai/telemetry-conformance/cmsd-read/sf-inner-list.json
Normal 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": []
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
12
wai/telemetry-conformance/cmsd-read/through-two-cdns.json
Normal file
12
wai/telemetry-conformance/cmsd-read/through-two-cdns.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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;"
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"about": "`v=1`: version 1, read",
|
||||
"static": [
|
||||
"v=1,ot=v,st=v"
|
||||
],
|
||||
"dynamic": []
|
||||
}
|
||||
9
wai/telemetry-conformance/cmsd-read/version-2.json
Normal file
9
wai/telemetry-conformance/cmsd-read/version-2.json
Normal 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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -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\""
|
||||
]
|
||||
}
|
||||
9
wai/telemetry-conformance/cmsd-read/wai-keys.json
Normal file
9
wai/telemetry-conformance/cmsd-read/wai-keys.json
Normal 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\""
|
||||
]
|
||||
}
|
||||
7
wai/telemetry-conformance/cmsd-read/wai-op-a-string.json
Normal file
7
wai/telemetry-conformance/cmsd-read/wai-op-a-string.json
Normal 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": []
|
||||
}
|
||||
7
wai/telemetry-conformance/cmsd-read/wai-op-a-token.json
Normal file
7
wai/telemetry-conformance/cmsd-read/wai-op-a-token.json
Normal 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": []
|
||||
}
|
||||
9
wai/telemetry-conformance/cmsd-read/whitespace.json
Normal file
9
wai/telemetry-conformance/cmsd-read/whitespace.json
Normal 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\""
|
||||
]
|
||||
}
|
||||
1029
wai/telemetry-conformance/expected/cmsd-read.json
Normal file
1029
wai/telemetry-conformance/expected/cmsd-read.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
555
wai/wai-rs/src/energy_steering/cdn.rs
Normal file
555
wai/wai-rs/src/energy_steering/cdn.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
311
wai/wai-rs/src/live_provenance/live.rs
Normal file
311
wai/wai-rs/src/live_provenance/live.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) },
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue