OttSession::new seals whatever non-zero joules_micro the page hands it as ModelBased. Nothing in the session measured, modelled or calibrated that figure, so labelling it a model claims better provenance than how it was obtained (the family's ladder, JCP SPEC §5.1: a model earns ModelBased by its calibration). It is now sealed Estimator; zero stays the unmetered marker with no class. New test a_page_supplied_figure_is_sealed_as_an_estimate. No vector changes: the OTT session is a demo surface, nothing under a conformance or vectors directory is produced by it, and reach/record keeps the package of record as it was. Co-Authored-By: Claude Opus 5.5 <[email protected]>
298 lines
10 KiB
Rust
298 lines
10 KiB
Rust
//! Browser sink for the OTT workflow — `wai.video.manifest` + `wai.video.ssai`.
|
|
//!
|
|
//! The same `wai` crate a native packager runs, compiled to wasm32: the page
|
|
//! composes a session class, renders the real HLS manifest, seals the receipt
|
|
//! and verifies it in the tab. No second implementation, so what the browser
|
|
//! shows is what a native sink would produce.
|
|
//!
|
|
//! Three things the page can demonstrate that a dashboard cannot:
|
|
//!
|
|
//! - **The manifest is the receipt's subject.** `manifest_hash ==
|
|
//! hash(render(segments))` is recomputed here, in the tab.
|
|
//! - **Tamper-evidence.** [`OttSession::swap_ad`] substitutes a different
|
|
//! creative without re-signing — exactly the ad-fraud move — and `verify()`
|
|
//! flips to false.
|
|
//! - **The efficiency is amortised, not asserted.** Raising the viewers served
|
|
//! by one class drops `uj_per_viewer`, because the manifest is a pure function
|
|
//! of the class and is generated once for all of them.
|
|
|
|
use wasm_bindgen::prelude::*;
|
|
|
|
use wai::quantum_ops::GrantRef;
|
|
use wai::wai_video_manifest::{
|
|
render_hls, EnergyProvenance, ManifestReceipt, Segment, SegmentKind, SessionClass,
|
|
};
|
|
use wai::wai_video_ssai::{decide, pod_hash, AdCandidate, SsaiReceipt};
|
|
|
|
fn hex(b: &[u8]) -> String {
|
|
b.iter().map(|x| format!("{x:02x}")).collect()
|
|
}
|
|
|
|
/// A demo signing key — the page is a sink, not a licensor; a real deployment
|
|
/// holds its key in the packager.
|
|
fn demo_key() -> ed25519_dalek::SigningKey {
|
|
ed25519_dalek::SigningKey::from_bytes(&[42u8; 32])
|
|
}
|
|
|
|
/// Build a plausible break-bearing composition: `n_content` programme segments
|
|
/// of 6s with a single 4s ad spliced in after the first.
|
|
fn compose(n_content: u32, ad_id: &str) -> Vec<Segment> {
|
|
let mut segs = Vec::new();
|
|
let mut idx = 0u64;
|
|
for i in 0..n_content {
|
|
if i == 1 {
|
|
segs.push(Segment {
|
|
index: idx,
|
|
kind: SegmentKind::Ad,
|
|
uri: format!("ad/{ad_id}/0.ts"),
|
|
duration_ms: 4000,
|
|
ad_id: Some(ad_id.to_string()),
|
|
});
|
|
idx += 1;
|
|
}
|
|
segs.push(Segment {
|
|
index: idx,
|
|
kind: SegmentKind::Content,
|
|
uri: format!("seg{i}.ts"),
|
|
duration_ms: 6000,
|
|
ad_id: None,
|
|
});
|
|
idx += 1;
|
|
}
|
|
segs
|
|
}
|
|
|
|
/// An attested per-session manifest, live in the page.
|
|
#[wasm_bindgen]
|
|
pub struct OttSession {
|
|
receipt: ManifestReceipt,
|
|
/// The bytes the player was handed — kept as delivered, so a later tamper
|
|
/// of the composition is visible against them.
|
|
delivered: String,
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
impl OttSession {
|
|
/// Compose a session class, render its manifest and seal the receipt.
|
|
/// `blackouts` is a comma-separated set (order-independent by construction).
|
|
/// `joules_micro` is the page's figure: sealed `Estimator` when non-zero,
|
|
/// since nothing here measured or calibrated it, and unmetered at zero.
|
|
#[wasm_bindgen(constructor)]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
content_id: &str,
|
|
entitlement: &str,
|
|
geo: &str,
|
|
blackouts: &str,
|
|
ad_pod_id: &str,
|
|
window: u64,
|
|
n_content: u32,
|
|
ad_id: &str,
|
|
viewers_served: u64,
|
|
joules_micro: u64,
|
|
) -> OttSession {
|
|
let class = SessionClass {
|
|
content_id: content_id.to_string(),
|
|
entitlement: entitlement.to_string(),
|
|
geo: geo.to_string(),
|
|
blackouts: blackouts
|
|
.split(',')
|
|
.map(str::trim)
|
|
.filter(|s| !s.is_empty())
|
|
.map(String::from)
|
|
.collect(),
|
|
ad_pod_id: ad_pod_id.to_string(),
|
|
window,
|
|
};
|
|
let segments = compose(n_content.max(1), ad_id);
|
|
let delivered = render_hls(&segments);
|
|
let grant = GrantRef {
|
|
grant_hash: [7u8; 32],
|
|
capability: "video.manifest".to_string(),
|
|
joule_ceiling_micro: u64::MAX,
|
|
funds_ceiling: None,
|
|
};
|
|
// The page supplies the figure; nothing here measured or modelled it,
|
|
// and no host calibrated it, so it is sealed as what it is: an
|
|
// estimate (`Estimator`), never `ModelBased`. Zero is the unmetered
|
|
// marker, with no class.
|
|
let prov = (joules_micro > 0).then_some(EnergyProvenance::Estimator);
|
|
let receipt = ManifestReceipt::seal(
|
|
&demo_key(),
|
|
"did:key:demo-packager",
|
|
class,
|
|
segments,
|
|
viewers_served.max(1),
|
|
joules_micro,
|
|
prov,
|
|
grant,
|
|
None,
|
|
);
|
|
OttSession { receipt, delivered }
|
|
}
|
|
|
|
/// The rendered HLS the player receives — ad breaks appear as real
|
|
/// `EXT-X-DISCONTINUITY` splices.
|
|
pub fn manifest(&self) -> String {
|
|
self.delivered.clone()
|
|
}
|
|
|
|
/// The manifest currently implied by the sealed composition. After a tamper
|
|
/// this diverges from [`OttSession::manifest`].
|
|
pub fn composed_manifest(&self) -> String {
|
|
render_hls(&self.receipt.segments)
|
|
}
|
|
|
|
pub fn manifest_hash_hex(&self) -> String {
|
|
hex(&self.receipt.manifest_hash)
|
|
}
|
|
|
|
/// The dedup key: every viewer of this class gets these identical bytes.
|
|
pub fn class_hash_hex(&self) -> String {
|
|
hex(&self.receipt.session_class_hash)
|
|
}
|
|
|
|
pub fn receipt_hash_hex(&self) -> String {
|
|
hex(&self.receipt.receipt_hash())
|
|
}
|
|
|
|
/// Signature + internal consistency, recomputed in the tab.
|
|
pub fn verify(&self) -> bool {
|
|
self.receipt.verify()
|
|
}
|
|
|
|
/// The independent check against the bytes actually delivered.
|
|
pub fn verify_delivered(&self) -> bool {
|
|
self.receipt.verify_render(self.delivered.as_bytes())
|
|
}
|
|
|
|
/// The monetisation audit, derived from the ad-bearing segments:
|
|
/// `[[break_index, ad_id, duration_ms], ...]` as JSON.
|
|
pub fn ads_json(&self) -> String {
|
|
let rows: Vec<String> = self
|
|
.receipt
|
|
.ad_insertions()
|
|
.iter()
|
|
.map(|(i, a, d)| format!("[{i},\"{a}\",{d}]"))
|
|
.collect();
|
|
format!("[{}]", rows.join(","))
|
|
}
|
|
|
|
pub fn segment_count(&self) -> u32 {
|
|
self.receipt.segments.len() as u32
|
|
}
|
|
|
|
pub fn viewers_served(&self) -> u64 {
|
|
self.receipt.viewers_served
|
|
}
|
|
|
|
/// Amortised microjoules per viewer, or `-1` when unmetered. This is the
|
|
/// figure a 1:1-unique generator cannot report below its per-viewer cost.
|
|
pub fn uj_per_viewer(&self) -> f64 {
|
|
self.receipt.uj_per_viewer().map_or(-1.0, |(v, _)| v)
|
|
}
|
|
|
|
/// Substitute a different creative into the break **without re-signing** —
|
|
/// the ad-fraud move. `verify()` flips to false, because the sealed
|
|
/// `manifest_hash` no longer matches what the composition renders.
|
|
pub fn swap_ad(&mut self, new_ad_id: &str) {
|
|
for s in self.receipt.segments.iter_mut() {
|
|
if s.ad_id.is_some() {
|
|
s.ad_id = Some(new_ad_id.to_string());
|
|
s.uri = format!("ad/{new_ad_id}/0.ts");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// An attested ad break — the budget half of the story.
|
|
#[wasm_bindgen]
|
|
pub struct OttAdBreak {
|
|
receipt: SsaiReceipt,
|
|
}
|
|
|
|
#[wasm_bindgen]
|
|
impl OttAdBreak {
|
|
/// Run a deterministic decision over a three-ad pod against `budget_micros`,
|
|
/// mark the filled slots as played, and seal. Set `budget_micros` below the
|
|
/// resulting spend to watch a ceiling breach refuse to verify.
|
|
#[wasm_bindgen(constructor)]
|
|
pub fn new(avail_slots: u32, avail_ms: u64, budget_micros: u64, ceiling_micros: u64) -> OttAdBreak {
|
|
let pod = vec![
|
|
AdCandidate { ad_id: "ad-alpha".into(), creative_hash: [1u8; 32], duration_ms: 15000, price_micros: 40_000 },
|
|
AdCandidate { ad_id: "ad-beta".into(), creative_hash: [2u8; 32], duration_ms: 15000, price_micros: 30_000 },
|
|
AdCandidate { ad_id: "ad-gamma".into(), creative_hash: [3u8; 32], duration_ms: 30000, price_micros: 90_000 },
|
|
];
|
|
let mut imps = decide(&pod, avail_slots as u64, avail_ms, budget_micros);
|
|
for i in imps.iter_mut() {
|
|
i.quartiles_reached = 5; // played to completion
|
|
}
|
|
let grant = GrantRef {
|
|
grant_hash: [8u8; 32],
|
|
capability: "video.ssai".to_string(),
|
|
joule_ceiling_micro: u64::MAX,
|
|
funds_ceiling: Some(ceiling_micros),
|
|
};
|
|
let receipt = SsaiReceipt::seal(
|
|
&demo_key(),
|
|
"did:key:demo-ssai",
|
|
"avail-1",
|
|
[9u8; 32],
|
|
pod_hash(&pod),
|
|
imps,
|
|
3,
|
|
0,
|
|
None,
|
|
grant,
|
|
None,
|
|
);
|
|
OttAdBreak { receipt }
|
|
}
|
|
|
|
/// `false` when the spend exceeds the grant's ceiling — overspend cannot be
|
|
/// signed into a valid receipt.
|
|
pub fn verify(&self) -> bool {
|
|
self.receipt.verify()
|
|
}
|
|
|
|
pub fn budget_spent(&self) -> u64 {
|
|
self.receipt.budget_spent_micros
|
|
}
|
|
|
|
pub fn billable(&self) -> u32 {
|
|
self.receipt.billable_impressions() as u32
|
|
}
|
|
|
|
/// `[[slot, ad_id, price_micros, quartiles], ...]` as JSON.
|
|
pub fn slots_json(&self) -> String {
|
|
let rows: Vec<String> = self
|
|
.receipt
|
|
.impressions
|
|
.iter()
|
|
.map(|i| format!("[{},\"{}\",{},{}]", i.slot, i.ad_id, i.price_micros, i.quartiles_reached))
|
|
.collect();
|
|
format!("[{}]", rows.join(","))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The page hands the session a joule figure that nothing here measured,
|
|
/// modelled or calibrated: a non-zero one is sealed `Estimator`, never
|
|
/// `ModelBased`, and zero is the unmetered marker, with no class. The
|
|
/// receipt verifies either way.
|
|
#[test]
|
|
fn a_page_supplied_figure_is_sealed_as_an_estimate() {
|
|
let session = |uj: u64| OttSession::new("film-1", "svod", "US", "", "pod-1", 60, 3, "ad-1", 4, uj);
|
|
let metered = session(8_000);
|
|
assert_eq!(metered.receipt.energy_provenance, Some(EnergyProvenance::Estimator));
|
|
assert_eq!(metered.uj_per_viewer(), 2_000.0);
|
|
assert!(metered.verify() && metered.verify_delivered());
|
|
let unmetered = session(0);
|
|
assert_eq!(unmetered.receipt.energy_provenance, None);
|
|
assert_eq!(unmetered.uj_per_viewer(), -1.0);
|
|
assert!(unmetered.verify());
|
|
}
|
|
}
|