wai: proof that an ad was presented — a player-signed presentation claim, and a spend that counts only presented impressions (E7) #398

Merged
dcharlot merged 9 commits from wai/ad-presentation into main 2026-10-21 22:28:49 -04:00
29 changed files with 5343 additions and 75 deletions

View file

@ -55,6 +55,7 @@ on:
- 'wai/wai-rs/**'
- 'wai/wai-ops/**'
- 'wai/live-conformance/compose/**'
- 'wai/live-conformance/presentation/**'
- '.github/workflows/joule-moq-web.yml'
pull_request:
paths:
@ -71,6 +72,7 @@ on:
- 'wai/wai-rs/**'
- 'wai/wai-ops/**'
- 'wai/live-conformance/compose/**'
- 'wai/live-conformance/presentation/**'
- '.github/workflows/joule-moq-web.yml'
jobs:

View file

@ -110,6 +110,13 @@ on:
# reports, live_ads_verify.py reads that table, and the registry's wire identifiers test
# reads its sections.
- 'wai/extensions/live-ads.md'
# tests/ad_presentation.rs requires this extension's §11 to list the codes the reference
# reports, ad_presentation_verify.py reads that table, and the registry's wire identifiers
# test reads its sections.
- 'wai/extensions/ad-presentation.md'
# The ad-presentation step runs the browser client's reference producer against the
# corpus.
- 'joule-code/joule-code-rs/crates/joule-moq-web/web/ads/**'
# tests/live_provenance.rs requires this extension's §12 to list the codes and the C2PA
# codes the reference reports, c2pa_live_verify.py reads that table, and the registry's
# wire identifiers test reads its sections.
@ -823,6 +830,12 @@ jobs:
with:
python-version: '3.11'
# The ad-presentation step runs the browser client's reference producer under Node, whose
# WebCrypto signs with Ed25519.
- uses: actions/setup-node@v4
with:
node-version: '22'
# The verifiers check Ed25519's equation with the `cryptography` package and make the
# rest of jwp-receipts §3.1 step 3 themselves.
- name: Install the Ed25519 library
@ -1132,6 +1145,28 @@ jobs:
(cd wai-rs && cargo test --no-default-features --features live_ads --lib wai_video_ssai::)
(cd wai-rs && cargo test --no-default-features --features live_ads --test live_ads)
python3 -I live-conformance/live_ads_verify.py live-conformance/ads
# wai.live.ad_presentation (extensions/ad-presentation.md): the reference module's unit
# tests (claims round-trip and their rules refuse, the quartile, the qualifying run, the
# touched objects, a policy's presentation fields, RFC 9577's TokenChallenge) and
# tests/ad_presentation.rs (every file of the committed corpus regenerates byte for byte
# from its fixed seeds and no other file is there, RFC 9578's own token verifies under its
# key, wai-rs gives every room its outcome and every claim its verdict, and §11 lists
# exactly the reference's codes); then the corpus's second implementation,
# ad_presentation_verify.py, written from the extension's text and sharing no code with
# wai-rs, which verifies each room with live_ads_verify.py, judges every claim and its
# RSASSA-PSS itself, requires every mutated copy of the corpus to fail and catches each of
# its own checks dropped; standard library only, run isolated (-I). Last, the browser
# client's reference producer (joule-moq-web/web/ads), which records observation
# sequences into spans and writes and signs the corpus's claims from them, byte for byte.
- name: ad presentation · wai-rs, ad_presentation_verify.py and the reference producer
working-directory: wai
shell: bash
run: |
set -euo pipefail
(cd wai-rs && cargo test --no-default-features --features live_ads --lib live_ads::presentation::)
(cd wai-rs && cargo test --no-default-features --features live_ads --test ad_presentation)
python3 -I live-conformance/ad_presentation_verify.py live-conformance/presentation
node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.test.mjs
# wai.live.provenance (extensions/live-provenance.md): the reference module's unit tests
# (boxes, chunks, emsg and the BMFF hash; the JUMBF store; COSE_Sign1 and the OKP key;
# certificates written and read, their profile and chain; identifiers, track renderings,

View file

@ -39,7 +39,7 @@ does not have, and one implementation keeps the page's verdicts the relay's.
## The page
`web/room/` is the room client; `web/moq/` its transport, session and media framing;
`web/compose/` runs `live-conformance/compose` in the page.
`web/compose/` runs `live-conformance/compose` in the page; `web/ads/` the reference producer of WAI ad-presentation claims (a span recorder over a video element and the claim's writer and signer), which no page of this crate loads yet: the room page plays no ads.
| Step | What it does |
|---|---|
@ -78,6 +78,10 @@ closed. Delivery timeouts still apply at the relay, before a write.
refusal an object can meet.
- `node --test web/moq/capsule.test.mjs web/moq/media.test.mjs`: the binding's capsules
against the bytes the relay's README states, and the payload framings.
- `node --test web/ads/presentation.test.mjs`: the reference producer of WAI ad-presentation
claims (`web/ads/presentation.js`, WAI `extensions/ad-presentation.md` §10): observation
sequences recorded into spans, and claims written and signed from them, byte for byte as
`wai/live-conformance/presentation/` holds them.
- `e2e/run.sh`: everything built (the relay, both wasm builds, the host's half of
`wai-live-web`), the browser-automation package from `e2e/package-lock.json` with
install scripts off, then `e2e/room.e2e.mjs` in the host's installed browser (not a

View file

@ -66,7 +66,7 @@ fi
BROWSER_BIN=${BROWSER_BIN:-}
step "unit tests of the page's modules"
node --test "$CRATE/web/moq/capsule.test.mjs" "$CRATE/web/moq/media.test.mjs"
node --test "$CRATE/web/moq/capsule.test.mjs" "$CRATE/web/moq/media.test.mjs" "$CRATE/web/ads/presentation.test.mjs"
step "end-to-end"
browser=()

View file

@ -0,0 +1,242 @@
// The reference producer of WAI ad-presentation claims (wai/extensions/ad-presentation.md):
// what a player measures while it presents one ad of a live-ads break, turned into spans by
// the rules of §10, and the WPC1 claim (§5) written and signed with the session key (§3).
//
// SpanRecorder observations (media position, playing, visible fraction, audible) to spans
// attach a video element's observations, every 200 ms and on its events
// claimBody the claim's bytes before its signature (§5.1, §5.2)
// signClaim the claim, signed with the platform's Ed25519 (§5.3)
// sessionOf, creativeDigest, touched, redemptionContext, tokenChallenge (§3, §4, §7.2)
//
// Everything here is the platform's: WebCrypto's SHA-256 and Ed25519, IntersectionObserver,
// the media element. No dependency. An IntersectionObserver measures the element's box against
// the viewport, not what other elements cover; a page that can cover its player states
// geometry 3 (the player's area) or measures what covers it itself (§10).
const enc = new TextEncoder();
const subtle = globalThis.crypto.subtle;
export const CLAIM_MAGIC = enc.encode("WPC1");
export const CLAIM_DOMAIN = enc.encode("wai:ad-presentation-claim\x01");
export const SESSION_DOMAIN = enc.encode("wai:ad-presentation-session\x01");
export const ATTEST_DOMAIN = enc.encode("wai:ad-presentation-attest\x01");
export const CREATIVE_DOMAIN = enc.encode("wai:ad-creative\x01");
export const NOT_EXPOSED = 0xffffffff;
export const GEOMETRY = { AD: 1, SCREEN: 2, PLAYER: 3 };
export const AUDIBLE = 1;
export const RESUMED = 2;
const concat = (...parts) => {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let at = 0;
for (const p of parts) {
out.set(p, at);
at += p.length;
}
return out;
};
const u16le = (v) => Uint8Array.of(v & 0xff, (v >>> 8) & 0xff);
const u32le = (v) => {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, v >>> 0, true);
return b;
};
export async function sha256(...parts) {
return new Uint8Array(await subtle.digest("SHA-256", concat(...parts)));
}
/** The session a session key names in a room (§3). */
export async function sessionOf(room, key) {
return (await sha256(SESSION_DOMAIN, room, key)).subarray(0, 16);
}
/** The creative digest of objects `[{ contentHash, durationMs }]` (§4). */
export async function creativeDigest(objects) {
return sha256(CREATIVE_DOMAIN, u32le(objects.length), ...objects.flatMap((o) => [o.contentHash, u32le(o.durationMs)]));
}
/** The content hashes of the objects the spans touch, in order (§9.2 step 6). */
export function touched(objects, spans) {
const out = [];
let s = 0;
for (const o of objects) {
const e = s + o.durationMs;
if (spans.some((x) => x.startMs < e && x.startMs + x.lengthMs > s)) out.push(o.contentHash);
s = e;
}
return out;
}
/** The redemption context binding an attestation to one impression and session key (§7.2). */
export async function redemptionContext(c) {
return sha256(ATTEST_DOMAIN, c.room, c.cue, c.session, Uint8Array.of(c.slot), c.creativeHash, c.player);
}
/** RFC 9577 §2.1.1's TokenChallenge with this redemption context and no origin_info (§7.2). */
export function tokenChallenge(tokenType, issuer, context) {
const name = typeof issuer === "string" ? enc.encode(issuer) : issuer;
return concat(Uint8Array.of(tokenType >>> 8, tokenType & 0xff, name.length >>> 8, name.length & 0xff), name, Uint8Array.of(32), context, Uint8Array.of(0, 0));
}
/**
* Spans by the rules of §10: a span is media time presented at normal speed; a new one begins
* whenever the quantised visible fraction or the audible state changes, or playback stops,
* skips or is seeked (then the next one is a resumption); its visible fraction is the least
* observed over it; and media time already presented is never stated again.
*/
export class SpanRecorder {
/** `quantumBp`: the visible fraction's step, in basis points (100 is OMID's whole percent). */
constructor({ quantumBp = 100 } = {}) {
this.quantum = quantumBp;
this.done = [];
this.cur = null; // { startMs, endMs, visibleBp, cls, audible }
this.maxEnd = 0; // the end of the media time stated so far
this.resumeNext = false;
}
close() {
if (this.cur && this.cur.endMs > this.cur.startMs) {
const flags = (this.cur.audible ? AUDIBLE : 0) | (this.cur.resumed ? RESUMED : 0);
this.done.push({ startMs: this.cur.startMs, lengthMs: this.cur.endMs - this.cur.startMs, visibleBp: this.cur.visibleBp, flags });
this.maxEnd = Math.max(this.maxEnd, this.cur.endMs);
}
this.cur = null;
}
/** One observation: `{ positionMs, playing, visibleBp, audible, seeked }`. */
observe({ positionMs, playing, visibleBp, audible, seeked = false }) {
const pos = Math.max(0, Math.floor(positionMs));
if (!playing || seeked) {
this.close();
if (this.done.length > 0) this.resumeNext = true;
if (!playing) return;
}
const vis = Math.max(0, Math.min(10_000, Math.round(visibleBp)));
const cls = Math.floor(vis / this.quantum);
if (this.cur) {
if (pos < this.cur.endMs) return; // no media time stated twice
if (cls !== this.cur.cls || !!audible !== this.cur.audible) {
this.cur.endMs = pos;
this.close();
} else {
this.cur.endMs = pos;
this.cur.visibleBp = Math.min(this.cur.visibleBp, vis);
return;
}
}
if (pos < this.maxEnd) return; // still within media time already presented
const continues = !this.resumeNext && this.done.length > 0 && pos === this.maxEnd;
const resumed = this.done.length > 0 && !continues;
this.cur = { startMs: pos, endMs: pos, visibleBp: vis, cls, audible: !!audible, resumed };
this.resumeNext = false;
}
/** The spans, the open one closed at its last observation. */
spans() {
this.close();
return this.done.slice();
}
}
/**
* Observe a video element: every `intervalMs` (at most the 200 ms of §10) and on its playback,
* volume and seek events, and on the page's visibility and focus. A fraction of 0 while the
* page is hidden or unfocused; audible while unmuted at a volume above 0; normal speed only.
*/
export function attach(video, recorder, { intervalMs = 200 } = {}) {
let ratio = 0;
let seeked = false;
const thresholds = Array.from({ length: 101 }, (_, i) => i / 100);
const io = new IntersectionObserver((entries) => {
for (const e of entries) ratio = e.intersectionRatio;
}, { threshold: thresholds });
io.observe(video);
const tick = () => {
const shown = document.visibilityState === "visible" && document.hasFocus();
recorder.observe({
positionMs: video.currentTime * 1000,
playing: !video.paused && !video.ended && video.readyState >= 3 && video.playbackRate === 1,
visibleBp: shown ? ratio * 10_000 : 0,
audible: !video.muted && video.volume > 0,
seeked,
});
seeked = false;
};
const onSeek = () => {
seeked = true;
tick();
};
const events = ["play", "pause", "ended", "waiting", "playing", "volumechange", "ratechange"];
for (const e of events) video.addEventListener(e, tick);
video.addEventListener("seeking", onSeek);
document.addEventListener("visibilitychange", tick);
window.addEventListener("blur", tick);
window.addEventListener("focus", tick);
const timer = setInterval(tick, Math.min(intervalMs, 200));
return {
/** Frames presented and not dropped, where the element exposes them (§5.1 `frames`). */
frames() {
const q = video.getVideoPlaybackQuality?.();
return q ? Math.max(0, q.totalVideoFrames - q.droppedVideoFrames) : NOT_EXPOSED;
},
stop() {
tick();
clearInterval(timer);
io.disconnect();
for (const e of events) video.removeEventListener(e, tick);
video.removeEventListener("seeking", onSeek);
document.removeEventListener("visibilitychange", tick);
window.removeEventListener("blur", tick);
window.removeEventListener("focus", tick);
return recorder.spans();
},
};
}
/** The claim's bytes before `sig` (§5.1, §5.2). */
export function claimBody(c) {
const spans = c.spans.flatMap((s) => [u32le(s.startMs), u32le(s.lengthMs), u16le(s.visibleBp), Uint8Array.of(s.flags)]);
const att = c.attestation ?? new Uint8Array(0);
return concat(
CLAIM_MAGIC,
Uint8Array.of(1),
c.room,
c.cue,
c.session,
Uint8Array.of(c.slot),
c.creativeHash,
c.player,
Uint8Array.of(c.geometry),
u32le(c.frames ?? NOT_EXPOSED),
u16le(c.objects.length),
...c.objects,
u16le(c.spans.length),
...spans,
u16le(att.length),
att,
);
}
/** A session key from 32 bytes of seed (the corpus's keys; a player makes a fresh one). */
export async function sessionKeyFromSeed(seed) {
const pkcs8 = concat(Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20), seed);
const privateKey = await subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]);
const jwk = await subtle.exportKey("jwk", privateKey);
const b64 = jwk.x.replace(/-/g, "+").replace(/_/g, "/");
const pub = Uint8Array.from(atob(b64 + "=".repeat((4 - (b64.length % 4)) % 4)), (ch) => ch.charCodeAt(0));
return { privateKey, publicKey: pub };
}
/** A fresh session key for one playback session of one room (§3). */
export async function newSessionKey() {
const k = await subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]);
return { privateKey: k.privateKey, publicKey: new Uint8Array(await subtle.exportKey("raw", k.publicKey)) };
}
/** The claim, signed (§5.3): its body and Ed25519 over the domain and the body. */
export async function signClaim(c, privateKey) {
const body = claimBody(c);
const sig = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, concat(CLAIM_DOMAIN, body)));
return concat(body, sig);
}

View file

@ -0,0 +1,95 @@
// The reference producer of ad-presentation claims (wai/extensions/ad-presentation.md §10),
// against the corpus wai/live-conformance/presentation/ (`node --test web/ads/presentation.test.mjs`):
// observation sequences recorded into spans, and the claims written and signed from them,
// byte for byte as the corpus holds them.
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import * as ap from "./presentation.js";
const doc = JSON.parse(readFileSync(new URL("../../../../../../wai/live-conformance/presentation/vectors.json", import.meta.url), "utf8"));
const vector = (name) => doc.vectors.find((v) => v.name === name);
const hex = (s) => Uint8Array.from(s.match(/../g) ?? [], (h) => parseInt(h, 16));
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const creative = (k) => doc.creatives[k].objects.map((o) => ({ contentHash: hex(o.content_hash), durationMs: o.duration_ms }));
/** Session key `i` of the corpus: SHA-256 of its label and `i` (its README). */
const key = async (i) => ap.sessionKeyFromSeed(await ap.sha256(new TextEncoder().encode("wai:ad-presentation/corpus/player\x00"), Uint8Array.of(i)));
/** Observations every 100 ms of media time from `from` to `to`, playing at normal speed. */
function play(rec, from, to, visibleBp, audible = true) {
for (let p = from; p <= to; p += 100) rec.observe({ positionMs: p, playing: true, visibleBp, audible });
}
async function claimFrom(vectorName, i, slot, objects, spans, geometry = ap.GEOMETRY.AD) {
const v = vector(vectorName);
const k = await key(i);
const room = hex(v.room);
const c = {
room,
cue: hex(v.breaks[0].digest),
session: await ap.sessionOf(room, k.publicKey),
slot,
creativeHash: await ap.creativeDigest(objects),
player: k.publicKey,
geometry,
frames: ap.NOT_EXPOSED,
objects: ap.touched(objects, spans),
spans,
};
return toHex(await ap.signClaim(c, k.privateKey));
}
test("a viewer in view and audible throughout: one span, the corpus's claim", async () => {
const rec = new ap.SpanRecorder();
play(rec, 0, 15_000, 10_000);
rec.observe({ positionMs: 15_000, playing: false, visibleBp: 10_000, audible: true }); // ended
const spans = rec.spans();
assert.deepEqual(spans, [{ startMs: 0, lengthMs: 15_000, visibleBp: 10_000, flags: ap.AUDIBLE }]);
assert.equal(await claimFrom("video_rule", 1, 0, creative(0), spans), vector("video_rule").claims[0]);
});
test("scrolled partly out of view: a span at each change, the least fraction of each", async () => {
const rec = new ap.SpanRecorder();
play(rec, 0, 3_900, 3_000);
play(rec, 4_000, 4_000, 6_000);
play(rec, 4_100, 6_900, 6_050); // within the same whole percent as 60.00%: one span, its least
play(rec, 7_000, 12_000, 2_000);
rec.observe({ positionMs: 12_000, playing: false, visibleBp: 2_000, audible: true });
const spans = rec.spans();
assert.deepEqual(spans.map((s) => [s.startMs, s.lengthMs, s.visibleBp, s.flags]), [[0, 4_000, 3_000, 1], [4_000, 3_000, 6_000, 1], [7_000, 5_000, 2_000, 1]]);
assert.equal(await claimFrom("video_rule", 1, 1, creative(1), spans), vector("video_rule").claims[1]);
});
test("a pause: the span after it is a resumption, and the run is broken", async () => {
const rec = new ap.SpanRecorder();
play(rec, 0, 1_500, 10_000);
rec.observe({ positionMs: 1_500, playing: false, visibleBp: 10_000, audible: true });
play(rec, 1_500, 2_900, 10_000);
play(rec, 3_000, 4_000, 1_000);
rec.observe({ positionMs: 4_000, playing: false, visibleBp: 1_000, audible: true });
const spans = rec.spans();
assert.deepEqual(spans.map((s) => [s.startMs, s.lengthMs, s.flags]), [[0, 1_500, 1], [1_500, 1_500, 3], [3_000, 1_000, 1]]);
assert.equal(await claimFrom("video_rule", 2, 1, creative(1), spans), vector("video_rule").claims[3]);
});
test("a seek back states no media time twice, and play past it resumes", () => {
const rec = new ap.SpanRecorder();
play(rec, 0, 5_000, 10_000);
rec.observe({ positionMs: 2_000, playing: true, visibleBp: 10_000, audible: true, seeked: true });
play(rec, 2_100, 6_000, 10_000);
const spans = rec.spans();
assert.deepEqual(spans.map((s) => [s.startMs, s.lengthMs, s.flags]), [[0, 5_000, 1], [5_000, 1_000, 3]]);
});
test("the attestation challenge is the corpus's: an attested claim's token answers it", async () => {
const v = vector("cross_media_attested");
const b = hex(v.claims[0]);
// The claim's fields at their offsets (§5.1), and its token: the long field before the signature.
const c = { room: b.subarray(5, 37), cue: b.subarray(37, 69), session: b.subarray(69, 85), slot: b[85], creativeHash: b.subarray(86, 118), player: b.subarray(118, 150) };
const tokenLen = 98 + 256;
const token = b.subarray(b.length - 64 - tokenLen, b.length - 64);
const challenge = ap.tokenChallenge(2, doc.issuers[0].name, await ap.redemptionContext(c));
assert.equal(toHex(token.subarray(34, 66)), toHex(await ap.sha256(challenge)));
assert.equal(toHex(await ap.sessionOf(c.room, c.player)), toHex(c.session));
});

View file

@ -6,6 +6,67 @@ wire format may change and such changes are recorded here.
## v0.4.0 — current
### `wai.live.ad_presentation`: a player's signed claim that an ad was presented, and a spend that counts only presented impressions
- **The extension** ([ad-presentation](extensions/ad-presentation.md)) registers the record
`wai.live.ad_presentation`. A presentation claim, `WPC1`, is a player's signed statement
about one impression of a live-ads break: the break, the session and the slot of its
form-2 `wai.video.ssai` receipt; the content hashes of the creative's media objects it
presented; spans of the creative's media time, each with the least fraction of the pixels it
observed in view, in basis points, and whether the ad was audible, measured over the ad's
pixels, the player's area (the disclosure the video viewability guidelines ask for) or a
television's full screen; a count of rendered frames where the platform exposes one; and
optionally a device attestation.
- **The session key.** A player makes an Ed25519 key for each playback session of a room and
signs its claims with it; the session its receipts name is derived from the key and the
room, so the decider never chooses it and it links nothing across rooms.
- **The creative digest.** A creative is its media objects in order; its hash, in the pod,
the receipt and the asset list's URIs, digests their content hashes and durations, so a
claim's presented objects are checked against the bytes the creative's URI names.
- **Viewability rules, as their texts state them.** The video rule: at least 50% of the ad's
pixels for 2 continuous seconds of played, unduplicated content (*Viewable Ad Impression
Measurement Guidelines (Desktop)*, Version 2.0). The cross-media rule: 100% of the pixels for
2 continuous seconds, which on a television output may be assumed (*Cross-Media Audience
Measurement Standards (Phase I Video)* §2.2.1). A pause, stall or skip ends a run. The
quartile a claim reaches is its contiguous coverage at VAST 4.3 §3.14.1's marks, and must be
the receipt's `quartiles_reached`.
- **Device attestation.** A Privacy Pass token of RFC 9577 §2.2.1 whose TokenChallenge has as
its redemption context a digest of the impression and the session key, so one token answers
one impression; type 0x0002 is verified from the issuer's key as RFC 9578 §6.4 does
(RSASSA-PSS with SHA-384), type 0x0001 by its issuer. A token attests the device an attester
vouched for, not the rendering.
- **The billing rule.** An ad policy of version 2 (`WAP1`) names a viewability rule and can
require a claim for each impression, an audible run and an attestation. Each claim then gets
a verdict (reads, signed by the key its session is derived from, the impression's first,
the creative's own objects, the receipt's quartile, viewable, attested when required), and
the room verifier's spend-equivalence (live-ads §9.3 step 12) holds the **presented spend**,
the prices of the impressions whose claim verifies, to the decider grant's ceiling. A
stripped, redirected or forged beacon counts nothing. The text states plainly what a claim
cannot prove without attestation (a modified player, or a party making session keys of its
own, can state anything) and what attestation bounds and does not (a vouched device running
modified software still signs).
- **live-ads** takes the version-2 policy, the derived session and the spend that counts; a
policy of version 1 and every existing receipt verify as before, and `live-conformance/ads/`
is unchanged.
- **Reference and corpus.** `wai-rs` feature `live_ads` (`src/live_ads/presentation.rs` and
`presentation_corpus.rs`), which now takes `num-bigint` (already `oer`'s) for the RSA
arithmetic. `live-conformance/presentation/`, written from fixed seeds by
`wai_ad_presentation_vectors`, its issuer key RFC 9578 Appendix A.2's test key and its token
checked first: good impressions under each rule, attested ones on television outputs, and a
claim for each reason (too few pixels, a broken run, muted, a duplicate, a key that does not
name the session, an object not the creative's, a misstated quartile, no token, another
impression's token, an altered authenticator, a bad signature, a malformed claim, a session
with no decision, and an impression with no claim), with a room whose receipts pass the
ceiling only if every billed impression counted. Checked by `tests/ad_presentation.rs` and by
`live-conformance/ad_presentation_verify.py`, a second implementation from the text with its
own RSASSA-PSS, which requires every mutated copy to fail and catches each of its own checks
dropped.
- **A reference producer** in the browser client, `joule-moq-web/web/ads/presentation.js`: a
span recorder over a video element (an `IntersectionObserver`, the page's visibility and
focus, volume, playback position and frame counts) and the claim's writer and signer on
WebCrypto's Ed25519. Its tests turn observation sequences into the corpus's claims, byte for
byte. No page of the client plays ads yet.
### `wai.access.services`: caption quality anyone can recompute, audio description and sign language
- **The extension** ([access-services](extensions/access-services.md)) registers the record

View file

@ -197,6 +197,7 @@ and a role are fixed once registered.
| `wai.image.jpegai` | payload | registered | image | standalone | royalty-free | neural-entropy-exact | — | §5 Neural capabilities |
| `wai.image.jxl` | payload | registered | image | standalone | royalty-free | standard-tolerance | recommended | §5 Recommended modern set |
| `wai.image.png` | payload | registered | image | standalone | royalty-free | standard-defined | floor | §5 Mandatory floor |
| `wai.live.ad_presentation` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.ads` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.compose` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
| `wai.live.e2ee` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
@ -331,6 +332,7 @@ and a role are fixed once registered.
| `WMP1` | magic | defined | live-moderation §3.1 |
| `WMR1` | magic | defined | live-metering §4.1 |
| `WMS1` | magic | defined | live-moderation §4.2 |
| `WPC1` | magic | defined | ad-presentation §5.1 |
| `WRD1` | magic | defined | live-rooms §3.1 |
| `WRE1` | magic | defined | confidentiality §3.1 |
| `WRG1` | magic | defined | live-rooms §4.1 |

View file

@ -1107,6 +1107,27 @@ recomputes the fill and the asset list, refuses a second receipt of one session
and holds the spend of every receipt together to the decider grant's ceiling:
[`extensions/live-ads.md`](extensions/live-ads.md).
And for the **presentation** of a room's ads — `wai.live.ad_presentation`: a player's signed
claim, `WPC1`, that it presented one impression of a break. The player makes a key for each
playback session of a room, from which the session its receipts name is derived, and signs
with it: the impression (the break, the session, the slot), the content hashes of the
creative's media objects it presented, whose ordered list the creative's hash digests; spans
of the creative's media time, each with the least fraction of pixels it observed in view and
whether the ad was audible, measured over the ad's pixels, the player's area or a television's
full screen; a count of rendered frames where the platform has one; and optionally a device
attestation, a Privacy Pass token (RFC 9577) whose challenge is a digest of this impression
and this key, verified as RFC 9578 §6.4 verifies its publicly verifiable type. An ad policy of
version 2 names a viewability rule (at least half the ad's pixels, or every pixel, for two
continuous seconds, as the video and cross-media measurement guidelines state them), and can
require a claim for each impression, an audible run and an attestation. A verifier then gives
each claim a verdict: the claim reads, is signed by the key its session is derived from, is
the impression's first, presented the creative's own objects, reaches the quartile the
receipt states, and is viewable under the rule, with its token when one is required; and the
spend it holds to the decider grant's ceiling counts only the impressions whose claim does.
Without an attestation a claim is its key holder's statement, which a modified player can
make falsely; the attestation bounds that to a device its attester vouched for, one token per
impression: [`extensions/ad-presentation.md`](extensions/ad-presentation.md).
And for the **provenance** of a room's CMAF tracks and a device's CMAF take —
`wai.live.provenance`: the live video provenance of the C2PA Technical Specification, version
2.4, §19, written as it defines it. A room track packaged as CMAF travels under

View file

@ -0,0 +1,567 @@
# WAI Extension: Ad Presentation
> Status: Draft. Registers the record `wai.live.ad_presentation`: a player's signed claim that
> it presented one impression of a [live-ads](live-ads.md) break, with what it measured while
> it did. The claim, `WPC1`, names the impression by the break, the session and the slot of a
> `wai.video.ssai` receipt of the break form; it lists the creative's media objects the player
> presented, by content hash, and spans of the creative's media time with the fraction of
> pixels in view and whether the ad was audible; and it may carry a device attestation, a
> Privacy Pass token (RFC 9577) whose challenge binds it to this one impression. It is signed
> by the playback session's own key, from which the session the receipt names is derived. An
> ad policy of version 2 can require a claim for each impression, a viewability rule, an
> audible run and an attestation; then a receipt's spend counts only the impressions whose
> claim verifies, and the room verifier holds that spend to the decider grant's ceiling. No
> new transport. The reference implementation is the `presentation` module of `wai-rs`'s
> `live_ads` (feature `live_ads`); the corpus is
> [`live-conformance/presentation/`](../live-conformance/presentation/), and its second
> implementation is `live-conformance/ad_presentation_verify.py`.
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
>
> Texts read for this revision, each at its source:
>
> - *Viewable Ad Impression Measurement Guidelines (Desktop)*, Version 2.0, 18 August 2015, in
> full. Its video requirements are word for word those of its Version 1.0, 30 June 2014. It
> numbers no section, so it is cited by heading and printed page.
> - *Mobile Viewable Ad Impression Measurement Guidelines*, 28 June 2016: its video section
> only (pp. 10–11).
> - *Cross-Media Audience Measurement Standards (Phase I Video)*, September 2019: its executive
> summary and §2.1.1, §2.1.5, §2.2.1, §2.3.1 and §4.1.1, which hold all of its viewability,
> duration and audio text. The rest of it was not read.
> - *Server-Side Ad Insertion and OTT Guidance*, August 2021, in full.
> - *Digital Video Impression Measurement Guidelines*, Version 1.1, June 2018: §3.0, §4.1, §4.4
> and §4.5.
> - *Video Ad Serving Template (VAST)* 4.3, December 2022: §1.1 to §1.2.1, §2.3.4, §2.4 to
> §2.4.3, §3.4.4, §3.5 to §3.5.3, §3.14 to §3.14.2, §3.16 to §3.17.5 and §6. Its other
> sections were not read.
> - *Secure Interactive Media Interface Definition (SIMID)* 1.2: §1 to §3.5.1, §4.2.10,
> §4.2.11, §4.4.5, §4.4.7, §8.1 and §8.4.
> - *Open Measurement SDK – OMID API*, Version 1.5, June 2024: its sections on the impression,
> the media events, `volumeChange`, `playerStateChange`, `geometryChange`, the ad view and its
> reasons.
> - *Open Measurement Device Attestation Implementation Guidance*, Version 1.0, November 2025,
> in full. Not read: the native SDKs that implement it, and any text of the exchange between
> a player and a verifier's URL; no public text of that exchange was found.
> - RFC 9576, RFC 9577 and RFC 9578 (June 2024), in full, and RFC 9474 §5. RFC 8017's
> RSASSA-PSS is used as RFC 9578 §6.4 cites it, and this revision's implementation of it is
> checked against the token of RFC 9578 Appendix A.2's first test vector.
>
> A section number of one of these texts is that revision's.
## 1. Scope
In scope: the session key and the session it names (§3); the creative digest (§4); the
presentation claim, its spans, its signature and its rules (§5); the viewability rules, the
qualifying run and the quartile a claim reaches (§6); the device attestation a claim carries
and how it is bound and verified (§7); the presentation fields of an ad policy (§8); each
claim's verdict, each impression's count and the spend that counts (§9); and what a player
measures and how (§10).
Out of scope: a transport, and how a claim reaches the decider or a buyer; how a player
obtains a token from an attester and its issuer (the issuance protocols of RFC 9578, run by a
platform); the detection of invalid traffic, which stays statistical and the measurer's
(§13); attention, and anything a sensor of the viewer would measure; and the stitched form of
`wai.video.ssai`, whose receipts name no session of a room.
Relationship to the core spec: `wai.live.ad_presentation` is a **record** ([SPEC](../SPEC.md)
§5): a signed record a WAI tool emits and verifies. It names no payload format and is not
dispatchable.
Relationship to [live-ads](live-ads.md): this extension adds the presentation fields of an ad
policy of version 2 (its §3.1, here §8), the session a session key names (its §2, here §3),
and the spend its §9.3 step 12 counts under a policy that requires claims (here §9.3). Every
other rule of live-ads stands, and a policy of version 1 is verified as before.
Relationship to [jwp-receipts](jwp-receipts.md): a `reconstruct-session` claim (its §2.4.12)
is a sink's record of a title it reconstructed, signed by its decoder host's key, and "not
evidence that a person watched". A presentation claim is a player's record of one ad
impression, made for the billing rule of §9; it states what the player measured and is no more
evidence that a person watched (§13).
## 2. Terms
- **Room**, **break**, **decider**, **pod**, **fill**, **asset list**, **session**, **policy**:
as in live-ads §2.
- **Receipt**: a `wai.video.ssai` receipt of the break form (live-ads §8).
- **Impression**: one filled slot of a receipt: its `slot`, `ad_id`, `creative_hash`,
`duration_ms`, `price_micros` and `quartiles_reached` (live-ads §8.2).
- **Player**: the software that presents the ad to a viewer and makes the claim (§10).
- **Session key**: an Ed25519 key a player makes for one playback session of one room (§3).
- **Creative**: an ad's media, as an ordered list of media objects (§4).
- **Span**: a stretch of the creative's media time the player presented at normal speed, over
which its observations held (§5.2).
- **Claim**: a presentation claim (§5).
- **Token**, **TokenChallenge**, **issuer**, **attester**: as in RFC 9576 §2 and RFC 9577 §2.
- **Basis point**: one ten-thousandth; a fraction of pixels is written in basis points, 0 to
10 000.
Integers of the records here are little-endian unless this text says big-endian, as live-ads
§2 has them; a digest is SHA-256 (FIPS 180-4), 32 bytes. Ed25519 is RFC 8032's, verified
strictly ([SPEC](../SPEC.md) §7 *Signatures*): a key that fails step 1 or 2 is `bad_key`, and a
signature that fails a later step is `bad_signature`. A *long* field is a u16 length and that
many bytes.
## 3. The session key
Under a policy that requires claims (§8), a player **MUST** make a fresh Ed25519 key for each
playback session of a room, from its platform's random generator, and **MUST NOT** use it for
another session or another room, or derive it from an identifier of the viewer, the device or
an account. It presents the key's public part with each asset-list request of the session
(live-ads §7), by a means this revision does not fix.
The **session** a session key names in a room is the first 16 bytes of SHA-256 over
`wai:ad-presentation-session` (ASCII) and 0x01, the room id (32 bytes) and the key (32 bytes).
Under such a policy the decider **MUST** give each receipt of the session that session
(live-ads §8.2 `session`), and a claim is counted only when its key names its session (§9.2
step 4). A player computes the session itself; the decider never chooses it.
The session is a function of a key made for this room alone, so it links nothing across
rooms; live-ads §12's rule that a session not be derived from a viewer's key is met, since the
session key is not one.
## 4. The creative digest
A creative is its media objects in presentation order: each object's **content hash**, SHA-256
of the object's bytes as served, and its presentation duration in milliseconds (a u32, at
least 1). The object at index *j* holds the creative's media time from the sum of the
durations before it, *s*, to *s* + its duration.
The **creative digest** is SHA-256 over `wai:ad-creative` (ASCII) and 0x01, the objects' count
(u32), and for each object in order its content hash and its duration (u32).
Under a policy that requires claims, an ad's `creative_hash` in the pod, in the receipt and in
the asset list's URIs (live-ads §7.2, §7.3) is its creative digest, and its `duration_ms` the
sum of its objects' durations. A creative's URI then names the very bytes a player presents,
object by object.
## 5. The presentation claim
### 5.1 Layout
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | `magic` | `WPC1` (ASCII) |
| 4 | 1 | `version` | 1 in this revision |
| 5 | 32 | `room` | the room id |
| 37 | 32 | `cue` | the break's digest (live-ads §4.4) |
| 69 | 16 | `session` | the session (§3) |
| 85 | 1 | `slot` | the impression's slot, 0 to 15 |
| 86 | 32 | `creative_hash` | the creative presented: its creative digest (§4) |
| 118 | 32 | `player` | the session key (§3), which signs the claim |
| 150 | 1 | `geometry` | what the visible fractions are measured over: 1 **ad**, the ad's own pixels, measured; 2 **screen**, a full-screen output to a television, whose pixels are assumed in view; 3 **player**, the player's area, measured, where the ad's own pixels are not known |
| 151 | 4 | `frames` | u32: the frames rendered over the spans, where the platform exposes a count; 4 294 967 295 where it does not |
| 155 | 2 | `n` | u16: the presented objects' count |
| 157 | 32 × `n` | `objects` | the content hashes (§4) of the creative's objects the spans touch, in order (§9.2 step 6) |
| … | 2 | `m` | u16: the spans' count |
| … | 11 × `m` | `spans` | §5.2 |
| … | long | `attestation` | a token (§7.1), or no bytes |
| … | 64 | `sig` | §5.3 |
### 5.2 Spans
A span is 11 bytes:
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | `start_ms` | u32: where it starts, in the creative's media time |
| 4 | 4 | `length_ms` | u32: its length, at least 1 |
| 8 | 2 | `visible_bp` | u16: the least fraction of the pixels in view the player observed over it, in basis points (§10) |
| 10 | 1 | `flags` | bit 0 **audible**: the ad was audible throughout it; bit 1 **resumed**: playback stopped between the span before it and this one |
A span's **end** is `start_ms` + `length_ms`, computed without overflow. The spans are in
presentation order, and no media time is in two of them: a player that presents a stretch
again states it once. A span whose start is after the end of the span before it follows a
skip, and is a resumption.
### 5.3 Digest and signature
The **digest** of a claim is SHA-256 over its bytes from `magic` through `attestation`. The
**signature** is Ed25519 by `player` over `wai:ad-presentation-claim` (ASCII) and 0x01,
followed by the bytes the digest covers.
### 5.4 Reading and rules
A reader reads exactly the bytes it is given as one claim, in the order of §5.1, and refuses it
`claim_malformed` when:
1. its magic is not `WPC1` or its version not 1;
2. the bytes end before a field the layout requires, or there are bytes after `sig`;
3. `slot` is above 15, or `geometry` is not 1, 2 or 3;
4. `n` is 0 and `m` is not, or `m` is 0 and `n` is not;
5. a span's `length_ms` is 0, its `visible_bp` above 10 000 or its `flags` above 3;
6. the first span has **resumed** set; or a later span starts before the end of the span
before it, or after that end without **resumed** set.
## 6. Viewability and the quartile
### 6.1 The rules
A policy names one of two viewability rules (§8). Both require two continuous seconds; they
differ in the pixels.
| `viewability` | rule | a span qualifies when | admits `geometry` | text |
|---|---|---|---|---|
| 1 | **video** | `visible_bp` is at least 5 000 | 1, 3 | *Viewable Ad Impression Measurement Guidelines (Desktop)* 2.0, "Requirements for Viewable Video Advertising Impressions" (p. 8); the 2016 mobile guidelines' video section states the same time |
| 2 | **cross-media** | `visible_bp` is 10 000 | 1, 2, 3 | *Cross-Media Audience Measurement Standards (Phase I Video)* §2.2.1 and §4.1.1 |
and, under a policy whose `presentation` sets **audible**, the span is audible too. A span of
a `geometry` the rule does not admit never qualifies.
- The video rule is the guidelines' "50% of the ad's pixels" and "2 continuous seconds of the
video advertisement is played", where "This required time is not necessarily the first 2
seconds of the video ad; any unduplicated content of the ad comprising 2 continuous seconds
qualifies". The same guidelines (footnote 2 of p. 8) ask that a measurement over the
player's pixels rather than the ad's be disclosed: geometry 3 is that disclosure, and the
verifier states it (§9.4).
- The cross-media rule is "a viewability qualification threshold of 100% of pixels on screen
for at least two continuous seconds". Of television it says: "Where ads and content are
displayed to a TV (linear or OTT), it may be assumed that the 100% pixel criteria for
viewability is met without direct measurement, although time threshold requirements still
apply (2 continuous seconds for video)". Geometry 2 is that assumption, and the video rule
does not admit it.
- No text read for this revision defines a viewability rule for connected television of its
own. The *Server-Side Ad Insertion and OTT Guidance* (2021) §2 holds such impressions "To
the extent it can be measured" to the rules of online digital video; the cross-media rule
above is the one that speaks of a television's pixels. A room's authority picks the rule its
policy names.
- Audio: the video rule does not require it ("detection of audio is not currently a
requirement for a viewable video ad impression", p. 8); the cross-media standard requires
"the presence of audio (non-zero volume or non-mute conditions) to be measured" where
feasible (§2.1.5). A policy that wants an audible run says so (§8).
### 6.2 The qualifying run
Over a claim's spans in order, a span **continues** the one before it when it starts at that
span's end and does not have **resumed** set. A **run** is a sequence of qualifying spans each
of which, after the first, continues the one before it; its length is the sum of their
`length_ms`. A claim's **run** is the length of its longest run, and its **viewable time** the
sum of `length_ms` over its qualifying spans. A claim is **viewable** when its run is at least
2 000 ms.
A pause, a stall or a skip between two spans ends a run, so two stretches of 1.5 s in view are
not two continuous seconds. That spans state no media time twice is how a run holds only
"unduplicated content".
### 6.3 The quartile
The **coverage** of a claim's spans is 0 when the first does not start at 0; otherwise it is
the end of the longest prefix of the spans in which each starts at the end of the one before
(a pause does not end it, a skip does). With *D* the impression's `duration_ms` and *C* the
coverage, the **quartile** a claim reaches is:
- 0 when it has no span;
- otherwise 5 when *C* ≥ *D*; 4 when 4*C* ≥ 3*D*; 3 when 4*C* ≥ 2*D*; 2 when 4*C* ≥ *D*; and
1 otherwise.
These are `quartiles_reached`'s values (live-ads §8.2): 1 started, then a quarter, a half,
three quarters and all of it, at the marks VAST 4.3 §3.14.1 names for `firstQuartile`,
`midpoint`, `thirdQuartile` and `complete` ("The creative played continuously for at least 25%
of the total duration at normal speed", and so on). A claim with a span has begun to play,
which is when an impression is counted (*Digital Video Impression Measurement Guidelines* 1.1
§4.1: "begins to play").
## 7. Device attestation
### 7.1 The token
A claim's `attestation` is a token of RFC 9577 §2.2.1:
```
struct {
uint16_t token_type;
uint8_t nonce[32];
uint8_t challenge_digest[32];
uint8_t token_key_id[Nid];
uint8_t authenticator[Nk];
} Token;
```
in its TLS presentation, big-endian. A policy names one of RFC 9578's two token types, each of
`Nid` 32: 0x0001, VOPRF(P-384, SHA-384), privately verifiable, `Nk` 48 (§5, §8.2.1 there);
and 0x0002, Blind RSA (2048-bit), publicly verifiable, `Nk` 256 (§6, §8.2.2 there). A token
**reads** when it is exactly 98 + `Nk` bytes for the type its first two bytes name, and that
type is one of the two.
A token attests what its issuer's attester checked of the device that asked for it (RFC 9576
§3): that a device of a kind the attester vouches for asked. It attests nothing of the
rendering or of what was in view.
### 7.2 The challenge
The TokenChallenge a claim's token answers is RFC 9577 §2.1.1's:
```
struct {
uint16_t token_type;
opaque issuer_name<1..2^16-1>;
opaque redemption_context<0..32>;
opaque origin_info<0..2^16-1>;
} TokenChallenge;
```
with `token_type` and `issuer_name` the policy's `token_type` and `issuer` (§8),
`redemption_context` the 32-byte **redemption context** below, and `origin_info` empty: in its
TLS presentation, the type as a big-endian u16, the issuer's length as a big-endian u16 and its
bytes, the byte 32 and the context, and two zero bytes. The **redemption context** is SHA-256
over `wai:ad-presentation-attest` (ASCII) and 0x01, `room`, `cue`, `session`, `slot` (one
byte), `creative_hash` and `player`, the claim's own.
The challenge is a function of the impression and the session key alone, each known before the
ad plays, so a party that issues it as a Privacy Pass origin (RFC 9577 §2.1) issues these
bytes, and every verifier recomputes them. A token answers one impression of one session key
and no other: lifted onto another claim, its `challenge_digest` is not that claim's. This is
the *Open Measurement Device Attestation Implementation Guidance*'s "Against one impression ID,
only one attestation request may be counted" (§5.1), made a property of the token. A
TokenChallenge with no `origin_info` scopes the token to no origin (RFC 9577 §2.1.1.3), which
the redemption context makes unnecessary.
### 7.3 The issuer's verifier
A token is verified by its issuer's verifier. A verifier holds, for each issuer it trusts, the
issuer's name, its token type and its keys, from its issuer directory (RFC 9578 §4); a key's
`token_key_id` is SHA-256 of its encoding (for type 0x0002, the DER SubjectPublicKeyInfo of
§6.5 there; for 0x0001, `SerializeElement(pkI)` of §5.5).
- **Type 0x0002** is publicly verifiable. The token verifies when the verifier holds a key of
the issuer whose `token_key_id` is the token's, the key is a SubjectPublicKeyInfo of the
object identifier id-RSASSA-PSS (1.2.840.113549.1.1.10) with a 2048-bit modulus, and
RSASSA-PSS-VERIFY (RFC 8017 §8.1.2), with SHA-384, MGF1 with SHA-384 and a 48-byte salt
(RFC 9578 §6.4), accepts the token's `authenticator` as a signature over its first 98 bytes
(`token_type`, `nonce`, `challenge_digest`, `token_key_id`). RFC 9578 §8.2.2 also lists a
variant of zero-length salt; §6.4 states 48 bytes, and this revision verifies with 48, so a
token of the other variant does not verify here.
- **Type 0x0001** is privately verifiable: only its issuer, holding the private key, computes
the authenticator (RFC 9578 §5.4). A verifier of a policy of this type asks its issuer, by a
means this revision does not fix, and a verifier that cannot does not count the token. The
reference implementation verifies type 0x0002 only.
The verifier requires, too, that the token's `challenge_digest` is SHA-256 of the
TokenChallenge of §7.2: what WAI checks itself. Double spending (RFC 9577 §2.2.2) needs no
nonce ledger here: a token answers one impression, and an impression counts once (§9.2 step 5).
## 8. The policy's presentation fields
An ad policy of version 2 (live-ads §3.1) carries, after `creative_base` and before `sig`:
| size | field | meaning |
|---|---|---|
| 1 | `presentation` | bit 0 **claim**: an impression counts only when its claim verifies; bit 1 **attest**: the claim carries a token that verifies (§7); bit 2 **audible**: a qualifying span is audible too (§6.1) |
| 1 | `viewability` | 0 none; 1 the video rule; 2 the cross-media rule (§6.1) |
| 2 | `token_type` | u16: the token type of §7.1 when **attest** is set, otherwise 0 |
| long | `issuer` | the issuer's name, the TokenChallenge's `issuer_name` (§7.2), when **attest** is set; otherwise no bytes |
A policy of version 2 is refused `ad_policy_malformed` (live-ads §3.3) when `presentation` sets
a bit above bit 2; sets **attest** or **audible** without **claim**; has `viewability` above 2,
or 0 with **claim** set, or not 0 with **claim** clear; or, with **attest** set, has a
`token_type` other than 1 or 2 or an empty `issuer`, or with it clear, a `token_type` other
than 0 or an `issuer` that is not empty. A policy of version 2 with `presentation` 0 is verified
as a policy of version 1.
## 9. Verification
### 9.1 Inputs
Under a policy that requires claims, an ads verifier (live-ads §9.1) is given, too:
- **claims**: the claims, a list of byte strings, in an order;
- **creatives**: the creatives it holds (§4), each its objects;
- **issuers**: the issuers it trusts (§7.3), each a name, a token type and its keys.
Under a policy that does not, it reads none of them.
### 9.2 Each claim
The claims are read once, before the receipts (steps 1 and 2), and the rest of each claim's
steps run when the verifier reaches the receipt whose impression it names, after that
receipt's live-ads §9.3 steps 1 to 11. Each claim gets one verdict, **presented** or the
reason of the first step it fails:
1. It reads (§5.4): `claim_malformed`.
2. Its signature verifies under `player` (§5.3): `bad_key`, `bad_signature`.
3. Its `room`, `cue` and `session` are a receipt's, among the receipts that passed steps 1 to
11, and that receipt has an impression of its `slot`: `claim_no_decision`. A claim that no
such receipt names when the verifier has read every receipt gets this verdict then.
4. Its `player` names its `session` (§3): `claim_key_unbound`.
5. No claim before it in the list passed steps 1 to 4 for the same impression:
`claim_duplicate`. The first such claim is the impression's **own claim**, whatever its
verdict; steps 6 to 9 are for it alone.
6. **The asset.** Its `creative_hash` is the impression's; the verifier holds a creative whose
creative digest that is (§4), and the sum of that creative's durations is the impression's
`duration_ms`; every span's end is at most that `duration_ms`; and `objects` is exactly the
content hashes of the creative's objects that the spans touch, in the creative's order,
where a span touches the object of media time *s* to *e* when it starts before *e* and ends
after *s*: `claim_asset_mismatch`.
7. **The quartile.** The quartile it reaches (§6.3), with *D* the impression's `duration_ms`,
is the impression's `quartiles_reached`: `claim_quartile_mismatch`.
8. **Viewability.** It is viewable (§6.2) under the policy's rule: `claim_not_viewable`.
9. **Attestation**, when the policy sets **attest**: `attestation` is not empty
(`attestation_absent`); it reads as a token (§7.1) of the policy's `token_type`, its
`challenge_digest` is SHA-256 of the TokenChallenge of §7.2, and its issuer's verifier
accepts it under the policy's `issuer` (§7.3): `attestation_invalid`.
An impression **counts** when its own claim is presented. An impression with no own claim is
stated `claim_absent`; one whose own claim was refused is stated with that claim's reason.
### 9.3 The spend that counts
Under a policy that requires claims, a receipt's **presented spend** is the sum of
`price_micros` over its impressions that count, and live-ads §9.3 step 12 holds the presented
spend of this receipt and every receipt before it, together, to the decider grant's ceiling,
in place of their billable spend. A receipt's own rules (live-ads §8.5) are unchanged: its
`budget_spent_micros` is still the decider's billable spend, signed, and the verifier states
both.
So a stripped, redirected or spoofed beacon counts nothing: an impression counts when a claim
the session key signed is presented, and no other party holds that key. The decider's
`quartiles_reached` must be the quartile the player's own claim reaches (step 7), so buyer and
seller reconcile one figure from the same signed bytes.
### 9.4 What a verification states
With accept, beside live-ads §9.4, the verifier states:
- for each receipt: its presented spend, and each impression's slot, the index of its own
claim among the claims given or none, and whether it counts or the reason it does not;
- for each claim, in the order given: its digest when it reads; its verdict; from step 3 on,
the receipt and the slot it names; and for an impression's own claim, the quartile it
reaches, its run and its viewable time;
- the spend of every receipt together, which under such a policy is their presented spend.
The claims' facts state each claim's `geometry` through its bytes; a buyer that weighs a
measurement over the player's area (geometry 3) or a television assumption (geometry 2) reads
it there.
## 10. The player
A player that makes claims **MUST**:
- observe the ad at least every 200 ms while it plays, the minimum polling of the video
guidelines ("for viewable video impression processes, 200 milliseconds", p. 9), and begin a
new span whenever its quantised visible fraction, its audible state or the continuity of
playback changes;
- state as a span's `visible_bp` the least fraction it observed over the span, of the ad's
rendered picture (geometry 1), of its own area (geometry 3), or 10 000 on a full-screen
television output (geometry 2), less any part it knows its own overlays cover;
- state a fraction of 0 while the page or application is hidden or in the background, while
its window does not have focus, or while the display is locked: the guidelines' "in-focus
browser tab";
- set **audible** only for a span throughout which the ad's audio played at a volume above
zero, unmuted, at the player and, where the platform exposes it, at the device;
- set **resumed** on a span that follows a pause, a stall, a seek or a stretch hidden from
view; and state no media time twice;
- list as `objects` the content hashes of the objects it decoded for the spans, which it
computes over each object's bytes as it received them;
- state `frames` as the frames presented and not dropped over the spans, where the platform
exposes such a count.
A player that measures through an Open Measurement integration reads the same facts from it:
the ad view's `percentageInView` (0 to 100, so `visible_bp` is 100 times it) and its
`reasons`, the `mediaPlayerVolume` and `deviceVolume` of `volumeChange`, and the media events
(OMID API 1.5). An interactive layer of SIMID 1.2 does not measure: "SIMID should not be set up
to measure viewability" (§3.5).
**The reference producer** is `web/ads/presentation.js` of the browser client `joule-moq-web`:
a recorder that turns observations (a visible fraction from an `IntersectionObserver` over the
video element, the page's visibility and focus, the element's volume and muted state, its
current time, and its playback-quality frame counts) into spans by the rules above, and writes
and signs the claim with the platform's Ed25519. Its tests drive it with observation sequences
and require the claims of the corpus, byte for byte. An `IntersectionObserver` measures the
element's box against the viewport, not what other elements obstruct; a page that can obstruct
its player states geometry 3 or measures obstruction itself.
## 11. Reasons
The codes this extension adds, each with where it is raised:
| reason | raised at |
|---|---|
| `claim_malformed` | §5.4, §9.2 step 1 |
| `claim_no_decision` | §9.2 step 3 |
| `claim_key_unbound` | §9.2 step 4 |
| `claim_duplicate` | §9.2 step 5 |
| `claim_asset_mismatch` | §9.2 step 6 |
| `claim_quartile_mismatch` | §9.2 step 7 |
| `claim_not_viewable` | §9.2 step 8 |
| `attestation_absent` | §9.2 step 9 |
| `attestation_invalid` | §9.2 step 9 |
| `claim_absent` | §9.2, an impression with no own claim |
A claim also gets live-events' `bad_key` and `bad_signature` (§9.2 step 2), and a policy
live-ads' `ad_policy_malformed` (§8). These verdicts never refuse a room: a room whose
receipts verify is accepted, and its claims' verdicts decide what counts. A room is refused
only as live-ads §9 refuses it, `funds_exceeded` among them when the presented spend passes
the ceiling.
## 12. Conformance
[`live-conformance/presentation/`](../live-conformance/presentation/) holds the corpus, written
from fixed seeds by `wai_ad_presentation_vectors gen`: `vectors.json`, and each accepted room's
HLS tags as a file of its own, as live-ads §11 writes them. `vectors.json` holds the creatives
and the issuer the verifier holds; the public keys of the session keys; the first test vector of
RFC 9578 Appendix A.2 (its `pkI`, TokenChallenge and token), whose issuer key signs the corpus's
tokens; and:
- **Rooms**, each a room, its policy of version 2, its receipts with their pods and its claims,
and the outcome with everything §9.4 and live-ads §9.4 state:
- a mid-roll under the video rule: impressions in view throughout, one partly out of view
with three continuous seconds at more than half its pixels, and one measured over the
player's area; and a claim for each control: too few pixels, two runs broken by a pause,
a duplicate, a key that does not name the session, a presented object that is not the
creative's, a quartile the receipt misstates, a flipped signature (whose impression is
then `claim_absent`), a signed claim whose spans skip with no resumption stated, a session
with no receipt, a truncated claim, and a television assumption the video rule does not
admit;
- a live break under the cross-media rule with an audible run and attestation: attested
impressions on television outputs and screens, and a claim with no token, one carrying
another impression's token, one whose authenticator is altered, one muted and one with 90%
of its pixels in view;
- two receipts that together pass the decider grant's ceiling, one with a claim and one
without: accepted, the presented spend within it; and the same with both claims, refused
`funds_exceeded`;
- a policy requiring an attestation without a claim, refused `ad_policy_malformed`.
A conforming verifier verifies RFC 9578's own token under its key, and gives every room the
outcome, the reason, the place and everything else the corpus states, each claim its verdict
and facts, and every tag file byte for byte. `wai_ad_presentation_vectors verify` and the test
`tests/ad_presentation.rs` check that every file regenerates byte for byte, that `wai-rs` gives
every case its outcome, that every reason of §11 appears, and that §11 lists exactly the
reference's codes. `live-conformance/ad_presentation_verify.py` is the second implementation,
written from this text: it shares no code with `wai-rs`; it verifies each room, policy and
receipt with the live-ads corpus's second implementation (`live_ads_verify.py`), each Ed25519
signature with WAI's strict rule (`claim-conformance/_ed25519.py`), and RSASSA-PSS with an
implementation of its own over the standard library; and it mutates copies of the corpus and
requires every copy to fail. The reference producer's tests (§10) require the corpus's claims
from observation sequences.
## 13. Security and privacy considerations
- **What a claim proves without attestation.** A claim proves that the holder of the session
key the receipt's session is derived from stated these facts about this impression. It does
not prove that they are true. A modified player can state anything; so can a decider, or
anyone, that makes a session key of its own, asks for asset lists under it and signs claims
for the sessions it names. Without attestation the billing rule removes the third party who
strips, redirects or forges beacons on the way, since such a party holds no session key; it
does not remove a dishonest player or decider.
- **What attestation bounds.** A token is issued only to a device its attester vouched for,
answering a challenge of this impression and this session key (§7.2). A party that makes
claims of its own then needs a vouched device to ask for each token, impression by
impression; one token cannot serve two claims. The bound is the attester's: a genuine device
running modified or malicious software, or a farm of genuine devices, attests truthfully and
still signs what its software tells it to. Attestation makes such fraud cost a device per
token and makes every claim attributable to a session key; it does not make it impossible.
- **Invalid traffic.** The detection of general and sophisticated invalid traffic stays
statistical and the measurer's. Claims give it signed, non-repudiable inputs: per-impression
facts bound to a session key, and, with attestation, to a vouched device.
- **What no claim states.** That a person watched; that a television was on (the 2021 guidance
does not require "TV Off" detection, §2); that nothing outside the player covered it, beyond
what its geometry discloses (§10).
- **Privacy.** The session key is made for one session of one room (§3), and the session is
derived from it and the room, so claims link nothing across rooms or to a viewer, device or
account. A token is unlinkable to its issuance (RFC 9576 §3.3), and its redemption context is
a digest of the impression. A claim carries no identifier of the device; the *Device
Attestation Implementation Guidance* likewise states that "Device-level information such as
Device ID will not be available" (§4.1.3).
- **Withholding.** A party between the player and the decider can withhold a claim, and the
impression then does not count: the failure is the seller's lost spend, never the buyer's
overpayment.
- **Keys.** A session key is held by the player for one session and then discarded; a claim
whose key does not derive its session is never counted (§9.2 step 4), whoever signed it.

View file

@ -12,7 +12,10 @@
> by `wai.video.ssai`'s deterministic decision. Each viewer's impressions are sealed in a
> `wai.video.ssai` receipt of its **break form**, which names the break, the decision's
> inputs and the asset list served, and a verifier recomputes the decision, the asset list and
> the spend against the decider grant's funds ceiling. No new transport. The reference
> the spend against the decider grant's funds ceiling. A policy of version 2 can require that
> each impression count only when the player's signed presentation claim verifies
> ([ad-presentation](ad-presentation.md)), and the spend is then held to the ceiling counting
> only those impressions. No new transport. The reference
> implementation is the `live_ads` module of `wai-rs` (feature `live_ads`) with the break form
> in `wai_video_ssai`; the corpus is [`live-conformance/ads/`](../live-conformance/ads/), and
> its second implementation is `live-conformance/live_ads_verify.py`.
@ -84,7 +87,8 @@ the stitched form keeps its bytes.
- **Asset list**: the response to the request a player makes for a break: an HLS asset list
(§7.2) or a DASH list MPD (§7.3).
- **Session**: one viewer's playback session, named in a receipt by 16 bytes the decider
assigns (§8.2).
assigns (§8.2); under a policy that requires presentation claims, the 16 bytes the player's
session key names (ad-presentation §3), which the decider does not choose.
Integers in the records of §3 and §4 are little-endian, and a digest there is SHA-256 (FIPS
180-4), 32 bytes. Integers of the receipt's signed bytes (§8) are big-endian and its hashes
@ -99,7 +103,7 @@ later step is `bad_signature`. A *long* field is a u16 length and that many byte
| offset | size | field | meaning |
|---|---|---|---|
| 0 | 4 | `magic` | `WAP1` (ASCII) |
| 4 | 1 | `version` | 1 in this revision |
| 4 | 1 | `version` | 1, or 2 for a policy with presentation fields |
| 5 | 32 | `room` | the room id (live-rooms §3.2) |
| 37 | 32 | `decider_grant` | the digest of the room grant the decider runs under (§3.4) |
| 69 | 1 | `slots` | the most ads one break holds, 1 to 16 |
@ -110,6 +114,7 @@ later step is `bad_signature`. A *long* field is a u16 length and that many byte
| 81 | 1 | `snap` | bit 0 **out**: leave the program at the segment boundary nearest the break's start; bit 1 **in**: return at the one nearest its end |
| 82 | long | `asset_base` | the base of every asset-list URI (§7) |
| … | long | `creative_base` | the base of every creative's URI (§7) |
| … | 4 + long | presentation fields | version 2 only: `presentation`, `viewability`, `token_type` and `issuer`, as ad-presentation §8 defines them |
| … | 64 | `sig` | §3.2 |
A **base** is `https://` followed by 1 to 492 bytes, each of `!` to `~` (0x21–0x7E) other
@ -119,7 +124,8 @@ and a URI built on it (§7) is the base followed by a name of the break or of th
### 3.2 Digest and signature
The **digest** of a policy is SHA-256 over its record from `magic` through `creative_base`.
The **digest** of a policy is SHA-256 over its record from `magic` through `creative_base`, or
through `issuer` for a policy of version 2.
The **signature** is Ed25519 by the room descriptor's `authority` over the 20 bytes
`wai:live-ads-policy` (ASCII) and 0x01, followed by the bytes the digest covers.
@ -129,13 +135,13 @@ A reader reads exactly the bytes it is given as one policy, in the order of §3.
at the first of these that holds:
1. Fewer than 4 bytes: `truncated`. A magic other than `WAP1`: `malformed`. No byte after the
magic: `truncated`. A version other than 1: `unknown_version`.
magic: `truncated`. A version other than 1 or 2: `unknown_version`.
2. The bytes end before a field the layout requires: `truncated`.
3. Bytes after `sig`: `malformed`.
It then refuses as `ad_policy_malformed` a policy whose `slots` is not 1 to 16, whose
`restrict` or `snap` sets a bit above bit 1, or whose `asset_base` or `creative_base` is not a
base (§3.1).
`restrict` or `snap` sets a bit above bit 1, whose `asset_base` or `creative_base` is not a
base (§3.1), or, of version 2, whose presentation fields break a rule of ad-presentation §8.
### 3.4 The decider
@ -550,7 +556,9 @@ a key-log head or none, and the chains), and:
- **policy**: the room's ad policy;
- **pt0**: the presentation time of `t0`, in milliseconds on the Period's timeline (§6.2);
- **receipts**: form-2 receipts in JSON (§8.2), in a list, each with the **pod** its decision
ran over, the candidates in the order the decider gave them.
ran over, the candidates in the order the decider gave them;
- under a policy that requires presentation claims, the **claims**, **creatives** and
**issuers** of ad-presentation §9.1.
### 9.2 Steps
@ -570,7 +578,9 @@ where:
4. **The renderings** (§5, §6): the tags, the events and the asset-list URIs of every break.
No refusal.
5. **The receipts**, in the list's order, each by §9.3: the refusal, at `receipt` and its
index.
index. Under a policy that requires presentation claims, the claims are read before the
first receipt, and each receipt's claims are judged after its step 11 (ad-presentation
§9.2); a claim's verdict never refuses the room.
6. **The outcome** (§9.4).
### 9.3 Each receipt
@ -596,8 +606,10 @@ For receipt *k*, with its pod, in this order:
11. No receipt before it in the list names this `cue` and this `session`: `receipt_duplicate`.
A viewer is filled once for a break.
12. **Spend-equivalence.** `funds_remaining_micros` is within the decider grant's ceiling, and
so is the billable spend of this receipt and every receipt before it, together (§3.4):
otherwise `funds_exceeded`.
so is the spend that counts of this receipt and every receipt before it, together (§3.4):
otherwise `funds_exceeded`. The spend that counts is a receipt's billable spend, or, under
a policy that requires presentation claims, its presented spend: the prices of the
impressions whose claim verifies (ad-presentation §9.3).
Step 12 holds the room's ad spend to one ceiling across every viewer and break, in the order the
receipts are given. A decision's stated funds are the decider's statement; that the spend of
@ -618,7 +630,9 @@ With accept, the verifier states:
- the HLS tags, in the order of §5.4; each break's HLS and DASH asset-list URIs; the DASH event
streams (§6.3), and each out's event as it was written before its in;
- each receipt: its identity (§8.4), the asset list it binds, written by the verifier, and its
billable spend; and the spend of every receipt together.
billable spend; and the spend of every receipt together, the spend that counts (§9.3 step
12);
- under a policy that requires presentation claims, what ad-presentation §9.4 states.
## 10. Reasons
@ -703,12 +717,17 @@ copies of the corpus and requires every copy to fail.
verification at the receipt that crosses the ceiling.
- `quartiles_reached` is the decider's statement of the beacons it received; a receipt binds it
and its billing, not the beacons. The impressions a buyer and a seller reconcile are the same
signed bytes.
signed bytes. A policy of version 2 can require more: that an impression count only when the
player's signed presentation claim verifies, reaching the quartile the receipt states under
a viewability rule, optionally with a device attestation ([ad-presentation](ad-presentation.md)
§9, §13 for what that does and does not prove).
- A receipt names a session by 16 bytes the decider assigns. A decider **SHOULD** assign a
value with no meaning outside its own records, and **MUST NOT** derive it from a viewer's key
or another identifier a third party holds, so that receipts do not link a viewer across
rooms; within a room, one session's receipts are linkable to one another by design, so that a
viewer is filled once for a break (§9.3 step 11).
viewer is filled once for a break (§9.3 step 11). Under a policy that requires presentation
claims the session is derived from a key the player makes for that session of that room
alone (ad-presentation §3), which is no viewer's key and links nothing across rooms.
- The asset-list request carries the viewer's playback-session parameter (Appendix D.4) to the
decider, and nowhere else: the manifests every viewer shares carry nothing of any viewer.
- The policy is signed by the room authority and the receipts by the decider. A decider whose

View file

@ -23,6 +23,7 @@ and the independent verifier of each, beside it.
| [`contribution/`](contribution/) | [contribution](../extensions/contribution.md) (`wai.media.contribution`) | `wai_contribution_vectors gen` | `contribution_verify.py` |
| [`settlement/`](settlement/) | [settlement](../extensions/settlement.md) (`wai.delivery.settlement`) | `wai_settlement_vectors gen` | `settlement_verify.py` |
| [`access/`](access/) | [access-services](../extensions/access-services.md) (`wai.access.services`) | `wai_access_services_vectors gen` | `access_services_verify.py` |
| [`presentation/`](presentation/) | [ad-presentation](../extensions/ad-presentation.md) (`wai.live.ad_presentation`) | `wai_ad_presentation_vectors gen` | `ad_presentation_verify.py` |
**Provenance.** Generated here: each directory's corpus is written by its row's generator
(`gen`, in `wai/wai-rs`), from records authored in code and from fixed seeds, and each
@ -50,6 +51,7 @@ python3 live_metering_verify.py metering
python3 energy_steering_verify.py steering
python3 live_moderation_verify.py moderation
python3 live_ads_verify.py ads
python3 ad_presentation_verify.py presentation
python3 e2ee_verify.py e2ee
python3 live_watermark_verify.py watermark
python3 c2pa_live_verify.py c2pa-live

View file

@ -0,0 +1,555 @@
#!/usr/bin/env python3
"""Independent verifier of the ad-presentation corpus (extensions/ad-presentation.md).
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
and never runs it. Each room, its policy, schedule and receipts are verified by the live-ads
corpus's own second implementation, live_ads_verify.py (live-ads §9), to which this verifier
gives the judge of the claims. Ed25519 signatures are checked with
../claim-conformance/_ed25519.py, WAI's strict rule (SPEC §7 *Signatures*). Everything else is
here, from the standard library:
- the session a session key names (§3) and the creative digest (§4);
- the claim, its spans and its rules (§5);
- the viewability rules, the qualifying run and the quartile (§6);
- the token, its challenge, and RSASSA-PSS-VERIFY with SHA-384 and a 48-byte salt over a DER
SubjectPublicKeyInfo of id-RSASSA-PSS (§7, RFC 9577, RFC 9578 §6.4, RFC 8017 §8.1.2), checked
first against the first test vector of RFC 9578 Appendix A.2;
- each claim's verdict, each impression's count and the presented spend (§9).
For every room it requires the outcome, reason and place, everything live-ads §9.4 and §9.4
here state, each claim's verdict and facts, and every HLS tag file byte for byte; of the corpus,
that every reason of §11 appears in it and that no other file is there. Then it mutates copies
of the corpus, one defect each, and requires every copy to fail; and it drops each of its own
checks in turn and requires the corpus to catch the weakened verifier.
python3 ad_presentation_verify.py <live-conformance/presentation>
Exit status 0 only if every case verifies, every mutated copy fails and every weakened
verifier is caught. Python 3.8 or later; standard library only.
"""
import copy
import hashlib
import json
import os
import struct
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
import live_ads_verify as LAV # noqa: E402
import _ed25519 as ED # noqa: E402
EXTENSION = os.path.join(HERE, "..", "extensions", "ad-presentation.md")
CLAIM_MAGIC = b"WPC1"
CLAIM_DOMAIN = b"wai:ad-presentation-claim\x01" # §5.3
SESSION_DOMAIN = b"wai:ad-presentation-session\x01" # §3
ATTEST_DOMAIN = b"wai:ad-presentation-attest\x01" # §7.2
CREATIVE_DOMAIN = b"wai:ad-creative\x01" # §4
AUDIBLE, RESUMED = 1, 2 # §5.2
CLAIM, ATTEST, AUDIBLE_RUN = 1, 2, 4 # §8
VIDEO, CROSS_MEDIA = 1, 2 # §6.1
NK = {1: 48, 2: 256} # §7.1
PSS_OID = bytes.fromhex("2a864886f70d01010a") # 1.2.840.113549.1.1.10
SALT = 48
# The checks a weakened verifier may drop (main(), the last part).
SKIP = set()
def sha(*parts):
h = hashlib.sha256()
for p in parts:
h.update(p)
return h.digest()
def reasons():
"""§11's codes, from the text."""
with open(EXTENSION, encoding="utf-8") as f:
text = f.read()
at = text.index("\n## 11. Reasons")
end = text.index("\n## ", at + 1)
return [line.split("`")[1] for line in text[at:end].splitlines() if line.startswith("| `")]
REASONS = reasons()
class Bad(Exception):
"""A claim's verdict other than presented."""
# ---------------------------------------------------------------- §3, §4
def session_of(room, key):
return sha(SESSION_DOMAIN, room, key)[:16]
def creative_digest(objects):
out = [CREATIVE_DOMAIN, struct.pack("<I", len(objects))]
for o in objects:
out += [o["content_hash"], struct.pack("<I", o["duration_ms"])]
return sha(*out)
def touched(objects, spans):
"""§9.2 step 6: the objects the spans touch, in order."""
out, s = [], 0
for o in objects:
e = s + o["duration_ms"]
if any(x["start"] < e and x["start"] + x["length"] > s for x in spans):
out.append(o["content_hash"])
s = e
return out
# ---------------------------------------------------------------- §5 the claim
def read_claim(b):
"""§5.4: the claim, or Bad(claim_malformed)."""
p = 0
def take(n):
nonlocal p
if p + n > len(b):
raise Bad("claim_malformed")
p += n
return b[p - n:p]
if take(4) != CLAIM_MAGIC or take(1) != b"\x01":
raise Bad("claim_malformed")
c = {"room": take(32), "cue": take(32), "session": take(16), "slot": take(1)[0], "creative_hash": take(32),
"player": take(32), "geometry": take(1)[0], "frames": struct.unpack("<I", take(4))[0]}
n = struct.unpack("<H", take(2))[0]
c["objects"] = [take(32) for _ in range(n)]
m = struct.unpack("<H", take(2))[0]
c["spans"] = []
for _ in range(m):
start, length, vis, flags = struct.unpack("<IIHB", take(11))
c["spans"].append({"start": start, "length": length, "visible": vis, "flags": flags})
c["attestation"] = take(struct.unpack("<H", take(2))[0])
c["body"] = b[:p]
c["sig"] = take(64)
if p != len(b):
raise Bad("claim_malformed")
if "rules" in SKIP:
return c
if c["slot"] > 15 or c["geometry"] not in (1, 2, 3) or (n == 0) != (m == 0):
raise Bad("claim_malformed")
prev = None
for s in c["spans"]:
if s["length"] == 0 or s["visible"] > 10000 or s["flags"] > 3:
raise Bad("claim_malformed")
if prev is None and s["flags"] & RESUMED:
raise Bad("claim_malformed")
if prev is not None and (s["start"] < prev or (s["start"] > prev and not s["flags"] & RESUMED)):
raise Bad("claim_malformed")
prev = s["start"] + s["length"]
return c
# ---------------------------------------------------------------- §6
def quartile(spans, d):
"""§6.3."""
if not spans:
return 0
cov = 0
if spans[0]["start"] == 0:
for s in spans:
if s["start"] != cov:
break
cov = s["start"] + s["length"]
if cov >= d:
return 5
if 4 * cov >= 3 * d:
return 4
if 4 * cov >= 2 * d:
return 3
if 4 * cov >= d:
return 2
return 1
def runs(c, q):
"""§6.2: (the run, the viewable time)."""
rule = q["rule"]
admitted = (rule == VIDEO and c["geometry"] in (1, 3)) or rule == CROSS_MEDIA
need = 5000 if rule == VIDEO else 10000
best = cur = total = 0
prev = None
for s in c["spans"]:
ok = admitted and s["visible"] >= need and (not q["require"] & AUDIBLE_RUN or s["flags"] & AUDIBLE)
cont = prev == s["start"] and not s["flags"] & RESUMED
if ok:
total += s["length"]
cur = cur + s["length"] if cont else s["length"]
best = max(best, cur)
else:
cur = 0
prev = s["start"] + s["length"]
return best, total
# ---------------------------------------------------------------- §7 attestation
def redemption_context(c):
return sha(ATTEST_DOMAIN, c["room"], c["cue"], c["session"], bytes([c["slot"]]), c["creative_hash"], c["player"])
def token_challenge(tt, issuer, ctx):
"""RFC 9577 §2.1.1, with no origin_info (§7.2)."""
return struct.pack(">HH", tt, len(issuer)) + issuer + bytes([32]) + ctx + b"\x00\x00"
def der(b, i):
"""One DER element at i: (tag, contents, the index after it)."""
if i + 2 > len(b):
raise ValueError("short")
tag, first = b[i], b[i + 1]
if first < 0x80:
n, h = first, 2
else:
k = first & 0x7F
if not 1 <= k <= 2 or i + 2 + k > len(b):
raise ValueError("length")
n, h = int.from_bytes(b[i + 2:i + 2 + k], "big"), 2 + k
if i + h + n > len(b):
raise ValueError("short")
return tag, b[i + h:i + h + n], i + h + n
def rsa_key(spki):
"""A DER SubjectPublicKeyInfo of id-RSASSA-PSS with a 2048-bit modulus (§7.3): (n, e)."""
t, outer, end = der(spki, 0)
if t != 0x30 or end != len(spki):
raise ValueError("outer")
ta, alg, i = der(outer, 0)
tb, bits, j = der(outer, i)
if ta != 0x30 or tb != 0x03 or j != len(outer):
raise ValueError("spki")
to, oid, _ = der(alg, 0)
if to != 0x06 or oid != PSS_OID or bits[:1] != b"\x00":
raise ValueError("algorithm")
ts, seq, k = der(bits, 1)
if ts != 0x30 or k != len(bits):
raise ValueError("key")
tn, n, a = der(seq, 0)
te, e, z = der(seq, a)
if tn != 0x02 or te != 0x02 or z != len(seq):
raise ValueError("integers")
if n[:1] == b"\x00":
n = n[1:]
if len(n) != 256 or n[0] < 0x80 or not e or e[0] >= 0x80:
raise ValueError("size")
return int.from_bytes(n, "big"), int.from_bytes(e, "big")
def mgf1(seed, length):
out, c = b"", 0
while len(out) < length:
out += hashlib.sha384(seed + struct.pack(">I", c)).digest()
c += 1
return out[:length]
def pss_verify(spki, msg, sig):
"""RSASSA-PSS-VERIFY, RFC 8017 §8.1.2, SHA-384, MGF1-SHA-384, sLen 48 (RFC 9578 §6.4)."""
try:
n, e = rsa_key(spki)
except ValueError:
return False
if len(sig) != 256:
return False
s = int.from_bytes(sig, "big")
if s >= n:
return False
em = pow(s, e, n).to_bytes(256, "big")
h_len = 48
if em[-1] != 0xBC:
return False
db_len = 256 - h_len - 1
masked, h = em[:db_len], em[db_len:-1]
if masked[0] & 0x80:
return False
db = bytes(x ^ y for x, y in zip(masked, mgf1(h, db_len)))
db = bytes([db[0] & 0x7F]) + db[1:]
ps = db_len - SALT - 1
if db[:ps] != bytes(ps) or db[ps] != 1:
return False
return hashlib.sha384(bytes(8) + hashlib.sha384(msg).digest() + db[ps + 1:]).digest() == h
def read_token(b):
if len(b) < 2:
return None
tt = struct.unpack(">H", b[:2])[0]
if tt not in NK or len(b) != 98 + NK[tt]:
return None
return {"type": tt, "digest": b[34:66], "key_id": b[66:98], "input": b[:98], "auth": b[98:]}
def attestation(c, q, issuers):
"""§9.2 step 9: None, or the reason."""
if not c["attestation"]:
return None if "attest_absent" in SKIP else "attestation_absent"
t = read_token(c["attestation"])
if t is None or t["type"] != q["token_type"]:
return "attestation_invalid"
if "attest_challenge" not in SKIP:
if t["digest"] != sha(token_challenge(q["token_type"], q["issuer"], redemption_context(c))):
return "attestation_invalid"
if "attest_authenticator" in SKIP:
return None
for i in issuers:
if i["name"] == q["issuer"] and i["token_type"] == t["type"]:
for k in i["keys"]:
if sha(k) == t["key_id"] and t["type"] == 2 and pss_verify(k, t["input"], t["auth"]):
return None
return "attestation_invalid"
# ---------------------------------------------------------------- §9 the judge
class Judge:
"""The claims of one room: read once (steps 1, 2), judged as each receipt is reached."""
def __init__(self, doc, v, policy):
self.q = policy["presentation"]
self.room_creatives = [{"digest": creative_digest(c), "objects": c} for c in doc["_creatives"]]
self.issuers = doc["_issuers"]
self.read, self.facts = [], []
for h in v["claims"]:
b = bytes.fromhex(h)
fact = {"digest": None, "verdict": None, "receipt": None, "slot": None, "quartile": None, "run_ms": None,
"viewable_ms": None}
try:
c = read_claim(b)
fact["digest"] = sha(c["body"]).hex()
bad = None if "signature" in SKIP else ED.verify(c["player"], CLAIM_DOMAIN + c["body"], c["sig"])
if bad:
raise Bad(bad)
self.read.append(c)
self.facts.append(None)
except Bad as e:
fact["verdict"] = str(e)
self.read.append(None)
self.facts.append(fact)
def receipt(self, k, r):
"""Steps 3 to 9 for receipt k's impressions: (its presented spend, what is stated of it)."""
counts, spend = [], 0
for imp in r["impressions"]:
own, verdict = None, "claim_absent"
for j, c in enumerate(self.read):
if c is None or self.facts[j] is not None:
continue
if (c["room"], c["cue"], c["session"], c["slot"]) != (r["room"], r["cue"], r["session"], imp["slot"]):
continue
f = {"digest": sha(c["body"]).hex(), "verdict": "presented", "receipt": k, "slot": c["slot"],
"quartile": None, "run_ms": None, "viewable_ms": None}
if session_of(r["room"], c["player"]) != r["session"] and "session" not in SKIP:
f["verdict"] = "claim_key_unbound"
elif own is not None and "duplicate" not in SKIP:
f["verdict"] = "claim_duplicate"
else:
if own is None:
own = j
qt = quartile(c["spans"], imp["duration_ms"])
run, viewable = runs(c, self.q)
f.update(quartile=qt, run_ms=run, viewable_ms=viewable)
f["verdict"] = self.judge(c, imp, qt, run) or "presented"
if own == j:
verdict = f["verdict"]
self.facts[j] = f
if verdict == "presented":
spend += imp["price_micros"]
counts.append({"slot": imp["slot"], "claim": own, "verdict": verdict})
return spend, {"presented_spend_micros": spend, "impressions": counts}
def judge(self, c, imp, qt, run):
"""Steps 6 to 9."""
if "asset" not in SKIP:
cr = next((x for x in self.room_creatives if x["digest"] == imp["creative_hash"]), None)
if (c["creative_hash"] != imp["creative_hash"] or cr is None
or sum(o["duration_ms"] for o in cr["objects"]) != imp["duration_ms"]
or any(s["start"] + s["length"] > imp["duration_ms"] for s in c["spans"])
or touched(cr["objects"], c["spans"]) != c["objects"]):
return "claim_asset_mismatch"
if qt != imp["quartiles_reached"] and "quartile" not in SKIP:
return "claim_quartile_mismatch"
if run < 2000 and "viewability" not in SKIP:
return "claim_not_viewable"
if self.q["require"] & ATTEST:
return attestation(c, self.q, self.issuers)
return None
def finish(self):
out = []
for c, f in zip(self.read, self.facts):
if f is None:
f = {"digest": sha(c["body"]).hex(), "verdict": "claim_no_decision", "receipt": None, "slot": None,
"quartile": None, "run_ms": None, "viewable_ms": None}
out.append(f)
return out
# ---------------------------------------------------------------- the corpus
def held(doc):
"""The creatives and issuers the verifier holds."""
doc["_creatives"] = [[{"content_hash": bytes.fromhex(o["content_hash"]), "duration_ms": o["duration_ms"]}
for o in c["objects"]] for c in doc["creatives"]]
doc["_issuers"] = [{"name": i["name"].encode(), "token_type": i["token_type"],
"keys": [bytes.fromhex(k) for k in i["keys"]]} for i in doc["issuers"]]
def rfc_vector(doc):
"""RFC 9578 Appendix A.2, its first test vector: its token verifies under its key."""
r = doc["rfc9578_a2_vector1"]
pki, ch, tok = bytes.fromhex(r["pkI"]), bytes.fromhex(r["token_challenge"]), bytes.fromhex(r["token"])
t = read_token(tok)
return (t is not None and t["type"] == 2 and t["digest"] == sha(ch) and t["key_id"] == sha(pki)
and pss_verify(pki, t["input"], t["auth"]))
def check(doc, files, quiet=False):
"""Every room against its statement, and every tag file: the failures."""
fails = []
try:
held(doc)
except Exception as e: # a mutated copy that does not read
return ["the creatives or issuers do not read: %s" % e], {}
if not rfc_vector(doc):
fails.append("RFC 9578 A.2's first test vector does not verify")
written = {}
for v in doc.get("vectors", []):
try:
got, tags = LAV.ads(v, judge=lambda vv, d, p: Judge(doc, vv, p))
except Exception as e: # a crash on a mutated copy is a failure to read it
got, tags = None, None
bad = ["the vector does not verify: %s: %s" % (type(e).__name__, e)]
else:
bad = [k for k in got if v.get(k) != got[k]]
bad += [k for k in ("outcome", "reason", "at", "receipt_facts", "claim_facts") if k in v and k not in got]
if tags is not None:
name = v["name"] + ".m3u8"
written[name] = tags
if files.get(name) != tags:
bad.append("its tag file %s" % name)
if not quiet:
what = "?" if got is None else got["outcome"] + (" " + got["reason"] if got["outcome"] == "refuse" else "")
verdicts = "" if got is None else " (%s)" % ", ".join(f["verdict"] for f in got.get("claim_facts", []))
print(("PASS" if not bad else "FAIL") + " room %s: %s%s" % (v.get("name"), what, verdicts))
fails += ["room %s: %s differs from the corpus" % (v.get("name"), k) for k in bad]
return fails, written
def flip(h, at):
b = bytearray(bytes.fromhex(h))
b[at] ^= 0x01
return b.hex()
def mutations(doc, files):
"""Copies of the corpus, each with one defect a conforming verifier must catch."""
vn = {v["name"]: k for k, v in enumerate(doc["vectors"])}
video, cross, under = vn["video_rule"], vn["cross_media_attested"], vn["ceiling_counts_presented"]
over = vn["ceiling_exceeded_by_presented"]
def mutate(label, f):
d, fs = copy.deepcopy(doc), dict(files)
f(d, fs)
return label, d, fs
def vs(d):
return d["vectors"]
def swap(d, k, a, b):
c = vs(d)[k]["claims"]
c[a], c[b] = c[b], c[a]
return [
mutate("a byte of a presented claim's signature",
lambda d, fs: vs(d)[video]["claims"].__setitem__(0, flip(vs(d)[video]["claims"][0], -3))),
mutate("a byte of a presented claim's span", lambda d, fs: vs(d)[video]["claims"].__setitem__(1, flip(vs(d)[video]["claims"][1], 300))),
mutate("a receipt's stated presented spend",
lambda d, fs: vs(d)[video]["receipt_facts"][0].update(presented_spend_micros=69999)),
mutate("the room's stated spend", lambda d, fs: vs(d)[cross].update(spend_micros=vs(d)[cross]["spend_micros"] + 1)),
mutate("the duplicate claim withheld", lambda d, fs: vs(d)[video]["claims"].pop(5)),
mutate("the unbound claim and the mismatched one given in the other order", lambda d, fs: swap(d, video, 6, 7)),
mutate("a claim's stated quartile", lambda d, fs: vs(d)[video]["claim_facts"][1].update(quartile=5)),
mutate("a claim's stated run", lambda d, fs: vs(d)[video]["claim_facts"][3].update(run_ms=3000)),
mutate("an impression stated as counting", lambda d, fs: vs(d)[video]["receipt_facts"][3]["impressions"][1].update(verdict="presented")),
mutate("a creative's object one millisecond longer", lambda d, fs: d["creatives"][1]["objects"][0].update(duration_ms=5001)),
mutate("a creative's object hash", lambda d, fs: d["creatives"][0]["objects"][2].update(content_hash="00" * 32)),
mutate("the issuer's key withheld", lambda d, fs: d["issuers"][0].update(keys=[])),
mutate("the issuer named otherwise", lambda d, fs: d["issuers"][0].update(name="issuer.example.")),
mutate("a byte of the RFC's token", lambda d, fs: d["rfc9578_a2_vector1"].update(token=flip(d["rfc9578_a2_vector1"]["token"], 200))),
mutate("the second presented claim given to the room within its ceiling",
lambda d, fs: vs(d)[under]["claims"].append(vs(d)[over]["claims"][1])),
mutate("the attested claim given without its token",
lambda d, fs: vs(d)[cross]["claims"].__setitem__(0, vs(d)[cross]["claims"][1])),
mutate("a tag file removed", lambda d, fs: fs.pop("video_rule.m3u8")),
]
def main(corpus_dir):
fails = []
st = ED._self_test()
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
fails += ["ed25519 self-test"] if st else []
ok = len(REASONS) == 10
print(("PASS" if ok else "FAIL") + " §11 read from the extension: %d reasons" % len(REASONS))
fails += [] if ok else ["§11 does not read"]
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
doc = json.load(f)
files = {}
for name in sorted(os.listdir(corpus_dir)):
if name.endswith(".m3u8"):
with open(os.path.join(corpus_dir, name), "rb") as f:
files[name] = f.read()
held(doc)
ok = rfc_vector(doc)
print(("PASS" if ok else "FAIL") + " RFC 9578 Appendix A.2, its first test vector: its token verifies under its key")
got, written = check(copy.deepcopy(doc), files)
fails += got
extra = sorted(set(os.listdir(corpus_dir)) - set(written) - {"vectors.json", "README.md"})
print(("PASS" if not extra else "FAIL") + " no file but vectors.json, README.md and the tag files the rooms write")
fails += ["an extra file: %s" % n for n in extra]
seen = set()
for v in doc["vectors"]:
seen |= {f["verdict"] for f in v.get("claim_facts", [])}
seen |= {i["verdict"] for r in v.get("receipt_facts", []) for i in r.get("impressions", [])}
missing = [r for r in REASONS if r not in seen]
print(("PASS" if not missing else "FAIL") + " every reason of §11 appears in the corpus")
fails += ["no claim or impression gives %s" % r for r in missing]
for label, d, fs in mutations(doc, files):
caught, _ = check(d, fs, quiet=True)
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:100] if caught else ""))
if not caught:
fails.append("a mutated copy verifies: %s" % label)
for name in ("rules", "signature", "session", "duplicate", "asset", "quartile", "viewability", "attest_absent",
"attest_challenge", "attest_authenticator"):
SKIP.clear()
SKIP.add(name)
caught, _ = check(copy.deepcopy(doc), files, quiet=True)
SKIP.clear()
print(("PASS" if caught else "FAIL") + " the corpus catches a verifier without its %s check%s"
% (name, " (%s)" % caught[0][:80] if caught else ""))
if not caught:
fails.append("a verifier without its %s check passes the corpus" % name)
for f in fails:
print("FAIL " + f)
print(("PASS" if not fails else "FAIL") + " ad-presentation corpus: %d rooms, %d tag files" % (len(doc["vectors"]), len(files)))
return 1 if fails else 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print("usage: ad_presentation_verify.py <live-conformance/presentation>", file=sys.stderr)
sys.exit(2)
sys.exit(main(sys.argv[1]))

View file

@ -16,6 +16,10 @@ is verified by the live-rooms corpus's own second implementation, live_rooms_ver
payload, identity and rules;
- verification, from the room to the spend (§9).
A policy of version 2 is read here with its presentation fields (§3.1, ad-presentation §8). A
room whose policy requires presentation claims is verified with ad_presentation_verify.py,
which gives accept() the judge of those claims (ad-presentation §9).
For every room it requires the outcome, reason and place, and everything else the corpus
states; every HLS tag file, byte for byte; of the corpus, that every reason of §10 appears in
it and that no other file is there. Then it mutates copies of the corpus, one defect each, and
@ -108,7 +112,7 @@ def read_policy(b):
raise Refuse("malformed")
if len(b) < 5:
raise Refuse("truncated")
if b[4] != 1:
if b[4] not in (1, 2):
raise Refuse("unknown_version")
r = LEV.Reader(b, "truncated")
r.take(5)
@ -116,17 +120,35 @@ def read_policy(b):
"preload_ms": r.u32(), "restrict": r.u8(), "skip_after_s": r.u16(), "snap": r.u8()}
p["asset_base"] = r.take(r.u16())
p["creative_base"] = r.take(r.u16())
p["presentation"] = None
if b[4] == 2: # ad-presentation §8
p["presentation"] = {"require": r.u8(), "rule": r.u8(), "token_type": r.u16(), "issuer": r.take(r.u16())}
p["body"] = b[:r.p]
p["sig"] = r.take(64)
if not r.done():
raise Refuse("malformed")
if (not 1 <= p["slots"] <= 16 or p["restrict"] > 3 or p["snap"] > 3
or not is_base(p["asset_base"]) or not is_base(p["creative_base"])):
or not is_base(p["asset_base"]) or not is_base(p["creative_base"])
or (p["presentation"] is not None and not presentation_ok(p["presentation"]))):
raise Refuse("ad_policy_malformed")
p["asset_base"], p["creative_base"] = p["asset_base"].decode(), p["creative_base"].decode()
return p
def presentation_ok(q):
"""ad-presentation §8's rules for a policy of version 2."""
req, claim, attest = q["require"], q["require"] & 1, q["require"] & 2
if req > 7 or (not claim and req != 0) or q["rule"] > 2 or bool(claim) != (q["rule"] != 0):
return False
if attest:
return q["token_type"] in (1, 2) and len(q["issuer"]) > 0
return q["token_type"] == 0 and q["issuer"] == b""
def requires_claims(p):
return p["presentation"] is not None and p["presentation"]["require"] & 1 == 1
# ---------------------------------------------------------------- §4 breaks
def read_annotation(b):
@ -533,8 +555,9 @@ class Stop(Exception):
self.reason, self.at = reason, at
def ads(v):
"""§9: a dict of what the corpus states for vector v, and the run's HLS tags."""
def ads(v, judge=None):
"""§9: a dict of what the corpus states for vector v, and the run's HLS tags. judge, when
given, makes the judge of a policy's presentation claims (ad-presentation §9)."""
descriptor = bytes.fromhex(v["descriptor"])
grants = [bytes.fromhex(g) for g in v["grants"]]
chains = [[bytes.fromhex(h) for h in c] for c in v["chains"]]
@ -544,12 +567,12 @@ def ads(v):
if room["outcome"] == "gap":
return {"outcome": "gap", "gaps": room["gaps"]}, None
try:
return accept(v, descriptor, grants, chains, room)
return accept(v, descriptor, grants, chains, room, judge)
except Stop as e:
return {"outcome": "refuse", "reason": e.reason, "at": e.at}, None
def accept(v, descriptor, grants, chains, room):
def accept(v, descriptor, grants, chains, room, judge=None):
d = LRV.read_descriptor(descriptor)
held_grants, _ = LRV.check_grants(d, grants)
try: # step 2
@ -576,6 +599,11 @@ def accept(v, descriptor, grants, chains, room):
t0, pt0 = d["t0"], v["pt0_ms"] # step 4
tags = hls_tags(p, t0, breaks, written)
funds = None if decider["funds"] == NO_CEILING else decider["funds"]
claims = None
if requires_claims(p): # ad-presentation §9.2 steps 1, 2
if judge is None:
raise ValueError("a policy that requires presentation claims: verify it with ad_presentation_verify.py")
claims = judge(v, d, p)
stated, sessions, total = [], set(), 0 # step 5
for k, (o, pod_json) in enumerate(zip(v["receipts"], v["pods"] + [None] * len(v["receipts"]))):
at = ["receipt", k]
@ -613,11 +641,15 @@ def accept(v, descriptor, grants, chains, room):
if (r["cue"], r["session"]) in sessions:
raise Stop("receipt_duplicate", at)
sessions.add((r["cue"], r["session"]))
total += r["budget_spent_micros"]
counted, extra = r["budget_spent_micros"], {} # step 12
if claims is not None:
counted, extra = claims.receipt(k, r)
total += counted
if funds is not None and (r["funds_remaining_micros"] > funds * 10 ** 6 or total > funds * 10 ** 6):
raise Stop("funds_exceeded", at)
stated.append({"identity": identity(r).hex(), "asset_list": listed.decode(),
"spend_micros": r["budget_spent_micros"]})
fact = {"identity": identity(r).hex(), "asset_list": listed.decode(), "spend_micros": r["budget_spent_micros"]}
fact.update(extra)
stated.append(fact)
out = {
"outcome": "accept",
"room": d["room"].hex(),
@ -634,6 +666,8 @@ def accept(v, descriptor, grants, chains, room):
"receipt_facts": stated,
"spend_micros": min(total, U64),
}
if claims is not None:
out["claim_facts"] = claims.finish()
return out, "".join(t + "\n" for t in tags).encode()

View file

@ -0,0 +1,90 @@
# live-conformance/presentation — ad presentation
The corpus of [`extensions/ad-presentation.md`](../../extensions/ad-presentation.md) §12: rooms
of [live-ads](../../extensions/live-ads.md) breaks under ad policies of version 2 that require
presentation claims; each viewer's break-form `wai.video.ssai` receipt, its session derived
from the viewer's session key; the `WPC1` claims the viewers' players signed, good ones and a
control of each check; the creatives and the attestation issuer a verifier holds; what a
verifier states of them; and each accepted room's HLS tags, byte for byte.
**Presentation equivalence.** For each vector, a conforming verifier given its `descriptor`,
`grants`, `key_log`, `chains`, `policy`, `pt0_ms`, `receipts`, `pods` and `claims`, and the
corpus's `creatives` and `issuers` (§9.1), reaches the outcome the vector states, with
everything live-ads §9.4 states (see [`../ads/README.md`](../ads/README.md)) and, for an
accepted room under a policy that requires claims (§9.4):
- `receipt_facts`, each receipt's `presented_spend_micros` beside its `spend_micros`, and its
`impressions`, each `slot`, the index of its own `claim` among the claims or `null`, and its
`verdict`: `presented` or the reason it does not count;
- `claim_facts`, each claim in the order given: its `digest` (`null` when it does not read),
its `verdict`, the `receipt` and `slot` it names (from §9.2 step 3 on), and, for an
impression's own claim, the `quartile` it reaches, its `run_ms` and its `viewable_ms`;
- `spend_micros`, the presented spend of every receipt together.
`claim_about` says what each claim is, and is not checked. A verifier that reaches another
outcome, reason, place, verdict, fact, spend or tag byte does not conform.
The corpus also carries `rfc9578_a2_vector1`: the `pkI`, TokenChallenge and token of RFC 9578
Appendix A.2's first test vector, as printed there. A conforming verifier verifies that token under
that key before it reads any token of the corpus.
**Provenance.** Generated here: `wai_ad_presentation_vectors gen` writes every file from rooms
authored in code, from fixed seeds, as `../ads/` is written. Each session key is SHA-256 of
`wai:ad-presentation/corpus/player`, a zero byte and its number (`players` lists their public
keys); each media object's content hash SHA-256 of a fixed label and two numbers; each token's
nonce SHA-256, and its salt SHA-384, of a fixed label and its number. The issuer's key is the
test key RFC 9578 Appendix A.2 publishes; the generator signs the tokens with it. Ed25519 and
RSASSA-PSS over a given salt are deterministic, so every file is the same bytes on every
machine. The generator asserts that RFC 9578's token verifies, that each vector reaches the
outcome and each claim the verdict it was written to have, and that the corpus gives every
reason of §11, before it writes. No recorded data.
## Layout
- `vectors.json`: `rfc9578_a2_vector1`; `creatives`, each its `objects` (`content_hash`,
`duration_ms`); `issuers`, each its `name`, `token_type` and `keys` (DER
SubjectPublicKeyInfo, hexadecimal); `players` (informative); and `vectors`.
- `NAME.m3u8`: the HLS tags of each accepted room.
The rooms, each built as `../ads/`'s are (a producer, a viewer and the decider, whose grant
carries the ad budget):
- `video_rule`: a 30 s mid-roll under the video rule, five viewers each filled with ad-x
(15 s) and ad-y (15 s). Presented: viewer 1's two (one partly out of view, with 3 s at 60%),
viewer 3's ad-x, and viewer 7's ad-y measured over the player's area. Controls:
`claim_not_viewable` (40% of the pixels; two runs of 1.5 s broken by a pause; a television
assumption the video rule does not admit), `claim_duplicate`, `claim_key_unbound`,
`claim_asset_mismatch`, `claim_quartile_mismatch`, `bad_signature` (its impression then
`claim_absent`), `claim_malformed` (a signed claim that skips with no resumption stated, and
a truncated claim), and `claim_no_decision`.
- `cross_media_attested`: an out and its in under the cross-media rule, an audible run and an
attestation of type 0x0002 from `issuer.example`. Presented: viewer 11's ad-x and viewer 14's
two. Controls: `attestation_absent`, `attestation_invalid` (another impression's token; an
altered authenticator) and `claim_not_viewable` (muted; 90% of the pixels).
- `ceiling_counts_presented`: a budget of 1 minor unit and two receipts each billing 600 000
millionths; one has a claim, so the presented spend is within the ceiling: accepted.
- `ceiling_exceeded_by_presented`: the same, both with claims: `funds_exceeded`.
- `policy_attestation_without_claim`: `ad_policy_malformed`.
## Running
```
cd ../../wai-rs
cargo run --no-default-features --features live_ads --bin wai_ad_presentation_vectors -- verify ../live-conformance/presentation
cargo run --no-default-features --features live_ads --bin wai_ad_presentation_vectors -- gen ../live-conformance/presentation # regenerate
cargo test --no-default-features --features live_ads --test ad_presentation
cd ../live-conformance
python3 -I ad_presentation_verify.py presentation
```
`wai_ad_presentation_vectors verify` and `tests/ad_presentation.rs` require every file to
regenerate byte for byte, no other file but this one to be there, and `wai-rs` to give every
case its outcome; the test also requires §11 to list exactly the reasons the reference reports.
`ad_presentation_verify.py` is the second implementation, written from the extension's text and
sharing no code with `wai-rs`: it verifies each room, policy and receipt with
`live_ads_verify.py`, reads and judges every claim itself, verifies RSASSA-PSS with an
implementation of its own, reads §11 from the extension, requires every reason to appear, then
mutates copies of the corpus, one defect each, each of which must fail, and drops each of its
own checks in turn, each weakened verifier of which the corpus must catch. The browser
client's reference producer (`joule-moq-web/web/ads/presentation.test.mjs`) requires
`video_rule`'s first claim from an observation sequence. Pass = an exit status of 0 from each.

View file

@ -0,0 +1 @@
#EXT-X-DATERANGE:ID="wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa",CLASS="com.apple.hls.interstitial",START-DATE="2026-10-16T00:02:00.000Z",CUE="ONCE",DURATION=30.000,X-ASSET-LIST="https://ads.rooms.example/sgai/wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa.json?_HLS_interstitial_id=wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa",X-RESUME-OFFSET=30.000,X-PLAYOUT-LIMIT=30.000

View file

@ -0,0 +1,3 @@
#EXT-X-DATERANGE:ID="wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce-preload",CLASS="com.apple.hls.preload",START-DATE="2026-10-16T00:00:56.000Z",DURATION=4.000,X-URI="https://ads.rooms.example/sgai/wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce.json?_HLS_interstitial_id=wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce",X-TARGET-ID="wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce",X-TARGET-CLASS="com.apple.hls.interstitial"
#EXT-X-DATERANGE:ID="wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce",CLASS="com.apple.hls.interstitial",START-DATE="2026-10-16T00:01:00.000Z",CUE="ONCE",X-ASSET-LIST="https://ads.rooms.example/sgai/wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce.json?_HLS_interstitial_id=wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce"
#EXT-X-DATERANGE:ID="wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce",CLASS="com.apple.hls.interstitial",DURATION=30.000,X-RESUME-OFFSET=30.000,X-PLAYOUT-LIMIT=30.000

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
#EXT-X-DATERANGE:ID="wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa",CLASS="com.apple.hls.interstitial",START-DATE="2026-10-16T00:02:00.000Z",CUE="ONCE",DURATION=30.000,X-ASSET-LIST="https://ads.rooms.example/sgai/wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa.json?_HLS_interstitial_id=wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa",X-RESUME-OFFSET=30.000,X-PLAYOUT-LIMIT=30.000

View file

@ -658,6 +658,20 @@
"edition": null,
"registered": "2026-06-12"
},
{
"capability": "wai.live.ad_presentation",
"kind": "record",
"status": "registered",
"media": null,
"role": null,
"tier": null,
"licence": null,
"replicate": null,
"sets": [],
"spec_section": "§5 Interactive, temporal & computed content",
"edition": null,
"registered": "2026-10-21"
},
{
"capability": "wai.live.ads",
"kind": "record",
@ -2172,6 +2186,13 @@
"section": "§4.2",
"status": "defined"
},
{
"name": "WPC1",
"kind": "magic",
"document": "extensions/ad-presentation.md",
"section": "§5.1",
"status": "defined"
},
{
"name": "WRD1",
"kind": "magic",

View file

@ -562,6 +562,13 @@ wai:wai.image.png a wai:Capability ;
wai:specSection "§5 Mandatory floor" ;
wai:registeredOn "2026-06-12"^^xsd:date .
wai:wai.live.ad_presentation a wai:Capability ;
rdfs:label "wai.live.ad_presentation" ;
wai:kind wai:kind-record ;
wai:status wai:status-registered ;
wai:specSection "§5 Interactive, temporal & computed content" ;
wai:registeredOn "2026-10-21"^^xsd:date .
wai:wai.live.ads a wai:Capability ;
rdfs:label "wai.live.ads" ;
wai:kind wai:kind-record ;

View file

@ -90,7 +90,7 @@ c2pa = { version = "0.90", optional = true, default-features = false, features =
# deterministic fixed-point simulation, replay-equivalence conformance.
blake3 = { version = "1.8", optional = true } # state hashes, node minting, op/seal commitments; `hazmat` (1.8) for the HTTP head proof
ed25519-dalek = { version = "2", optional = true } # op + seal signatures
num-bigint = { version = "0.4", optional = true } # oer: exact decimal arithmetic in the composition checks
num-bigint = { version = "0.4", optional = true } # oer: exact decimal arithmetic in the composition checks; live_ads: RFC 9578's RSASSA-PSS
# The curve ed25519-dalek is built on, named directly only by `receipt_claims`: its
# conformance corpus builds the signatures jwp-receipts §3.1 step 3 must refuse (keys of
# small order, non-canonical encodings), which the signing API cannot produce, and the
@ -262,8 +262,14 @@ live_watermark = ["live_room"]
# ranges and DASH alternative-presentation event written exactly, the HLS asset lists and DASH
# list MPDs each viewer is served, and the room verifier extended to the break form of
# wai.video.ssai's receipt: the decision recomputed, the asset list bound, the spend held to the
# decider grant's ceiling. SHA-256, BLAKE3, Ed25519 and the key log only; no new dependency.
live_ads = ["live_room", "video_ssai"]
# decider grant's ceiling. With it, ad presentation (extensions/ad-presentation):
# `wai.live.ad_presentation` — a player's signed claim (WPC1) that it presented one impression,
# its spans of visible and audible play under the viewability rule an ad policy of version 2
# names, an optional device attestation token of RFC 9577 checked against the impression's own
# challenge and RFC 9578's publicly verifiable type, and the room verifier's spend counting only
# the impressions whose claim verifies. SHA-256, SHA-384, BLAKE3, Ed25519 and the key log, and
# num-bigint (already a dependency of `oer`) for the RSA arithmetic.
live_ads = ["live_room", "video_ssai", "dep:num-bigint"]
# Live provenance (extensions/live-provenance): `wai.live.provenance` — the live video provenance
# of C2PA 2.4 §19 for a room's CMAF tracks and a device's CMAF take, written and verified with no
# C2PA library: ISO BMFF segments and the BMFF hash over whole-box exclusions, the JUMBF manifest
@ -819,6 +825,11 @@ name = "wai_edge"
path = "src/bin/wai_edge.rs"
required-features = ["derive_edge"]
[[bin]]
name = "wai_ad_presentation_vectors"
path = "src/bin/wai_ad_presentation_vectors.rs"
required-features = ["live_ads"]
[[bin]]
name = "wai_live_provenance_vectors"
path = "src/bin/wai_live_provenance_vectors.rs"
@ -1549,6 +1560,11 @@ name = "live_ads"
path = "tests/live_ads.rs"
required-features = ["live_ads"]
[[test]]
name = "ad_presentation"
path = "tests/ad_presentation.rs"
required-features = ["live_ads"]
[[test]]
name = "live_provenance"
path = "tests/live_provenance.rs"

View file

@ -0,0 +1,77 @@
//! Conformance corpus for ad presentation (`live-conformance/presentation/`,
//! extensions/ad-presentation.md §12).
//!
//! wai_ad_presentation_vectors gen <corpus-dir> write vectors.json and each accepted
//! room's HLS tags (NAME.m3u8), from fixed
//! seeds
//! wai_ad_presentation_vectors verify <corpus-dir> every file regenerates byte for byte, no
//! other file but README.md is there, and
//! every case gets the outcome it states
//!
//! The generator and this verifier are one implementation. The corpus's second
//! implementation is `live-conformance/ad_presentation_verify.py`, written from the
//! extension's text and sharing no code with this binary.
use std::collections::BTreeMap;
use std::path::Path;
use wai::live_ads::presentation_corpus as 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)) => {
if let Err(e) = std::fs::create_dir_all(d) {
eprintln!("create {d}: {e}");
std::process::exit(1);
}
for (name, body) in corpus::generate() {
let p = Path::new(d).join(&name);
if let Err(e) = std::fs::write(&p, body) {
eprintln!("write {}: {e}", p.display());
std::process::exit(1);
}
}
println!("wrote {d}");
}
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
_ => {
eprintln!("usage: wai_ad_presentation_vectors gen|verify <corpus-dir>");
std::process::exit(2);
}
}
}
fn verify(dir: &Path) -> i32 {
let mut files = BTreeMap::new();
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(e) => {
eprintln!("read {}: {e}", dir.display());
return 1;
}
};
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name == "README.md" {
continue;
}
match std::fs::read_to_string(e.path()) {
Ok(s) => {
files.insert(name, s);
}
Err(err) => {
eprintln!("read {name}: {err}");
return 1;
}
}
}
let generated: BTreeMap<String, String> = corpus::generate().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);
for f in &fails {
println!("FAIL {f}");
}
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
(!same || !fails.is_empty()) as i32
}

View file

@ -221,6 +221,7 @@ pub const CAP_LIVE_MODERATION: &str = "wai.live.moderation";
pub const CAP_LIVE_E2EE: &str = "wai.live.e2ee";
pub const CAP_LIVE_WATERMARK: &str = "wai.live.watermark";
pub const CAP_LIVE_ADS: &str = "wai.live.ads";
pub const CAP_LIVE_AD_PRESENTATION: &str = "wai.live.ad_presentation";
pub const CAP_LIVE_PROVENANCE: &str = "wai.live.provenance";
pub const CAP_EDGE_DERIVATION: &str = "wai.edge.derivation";
pub const CAP_MEDIA_SYNTHESIS: &str = "wai.media.synthesis";
@ -318,6 +319,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
(CAP_LIVE_E2EE, S_INTERACTIVE, None, "2026-10-19"),
(CAP_LIVE_WATERMARK, S_INTERACTIVE, None, "2026-10-20"),
(CAP_LIVE_ADS, S_INTERACTIVE, None, "2026-10-20"),
(CAP_LIVE_AD_PRESENTATION, S_INTERACTIVE, None, "2026-10-21"),
(CAP_LIVE_PROVENANCE, S_INTERACTIVE, None, "2026-10-21"),
(CAP_MEDIA_SYNTHESIS, S_INTERACTIVE, None, "2026-10-21"),
(CAP_AI_USAGE, S_INTERACTIVE, None, "2026-10-21"),
@ -376,6 +378,7 @@ const RECORDS: &[&str] = &[
CAP_LIVE_METERING, CAP_LIVE_STEERING, CAP_TEXT_TIMED, CAP_LIVE_MODERATION, CAP_LIVE_E2EE,
CAP_LIVE_WATERMARK,
CAP_LIVE_ADS,
CAP_LIVE_AD_PRESENTATION,
CAP_LIVE_PROVENANCE,
CAP_EDGE_DERIVATION,
CAP_MEDIA_SYNTHESIS,
@ -540,6 +543,7 @@ const CONFIDENTIALITY: &str = "extensions/confidentiality.md";
const FORENSIC_WATERMARK: &str = "extensions/forensic-watermark.md";
const AI_USAGE: &str = "extensions/ai-usage.md";
const LIVE_ADS: &str = "extensions/live-ads.md";
const AD_PRESENTATION: &str = "extensions/ad-presentation.md";
const LIVE_PROVENANCE: &str = "extensions/live-provenance.md";
const DERIVING_EDGE: &str = "extensions/deriving-edge.md";
const SYNTHESIS: &str = "extensions/synthesis.md";
@ -642,6 +646,7 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
wire("WWD1", WireKind::Magic, FORENSIC_WATERMARK, "§8.1", WireStatus::Defined),
wire("WAP1", WireKind::Magic, LIVE_ADS, "§3.1", WireStatus::Defined),
wire("WAB1", WireKind::Magic, LIVE_ADS, "§4.2", WireStatus::Defined),
wire("WPC1", WireKind::Magic, AD_PRESENTATION, "§5.1", WireStatus::Defined),
wire("wai_c2pa_track", WireKind::Catalog, LIVE_PROVENANCE, "§10.2", WireStatus::Defined),
wire("wai-c2pa", WireKind::Catalog, LIVE_PROVENANCE, "§10.2", WireStatus::Defined),
wire("wai-derivation", WireKind::DocumentKind, DERIVING_EDGE, "§5.1", WireStatus::Defined),

View file

@ -16,7 +16,9 @@
//! - §4: the break annotation and the schedule, with its refusals;
//! - §5, §6: the HLS tags and the DASH events, written exactly;
//! - §7: the asset lists, HLS and DASH;
//! - §9: verification from the room's events to the spend.
//! - §9: verification from the room's events to the spend; under a policy of version 2
//! that requires presentation claims, the spend of each receipt counts only the
//! impressions whose claim verifies ([`presentation`], extensions/ad-presentation.md).
//!
//! The corpus `live-conformance/ads/` is written by [`corpus::generate`] and checked by
//! [`corpus::verify`], and by `live-conformance/live_ads_verify.py`, which shares no code
@ -34,6 +36,10 @@ use crate::live_room as room;
use crate::wai_video_ssai::{asset_format, decide, pod_hash, within_ceiling, AdCandidate, BreakReceiptFault, Impression, SsaiBreakReceipt};
pub mod corpus;
pub mod presentation;
pub mod presentation_corpus;
use presentation::{ClaimFacts, Creative, ImpressionCount, Issuer, Ledger, Requirement};
/// The capability string this extension registers.
pub const CAPABILITY: &str = "wai.live.ads";
@ -41,8 +47,11 @@ pub const CAPABILITY: &str = "wai.live.ads";
pub const POLICY_MAGIC: [u8; 4] = *b"WAP1";
/// The magic of a break annotation (§4.2).
pub const ANNOTATION_MAGIC: [u8; 4] = *b"WAB1";
/// The version of every record this revision defines.
/// The version of every record this revision defines, and of a policy with no presentation
/// fields.
pub const RECORD_VERSION: u8 = 1;
/// The version of a policy with presentation fields (§3.1; ad-presentation §8).
pub const POLICY_VERSION_PRESENTATION: u8 = 2;
/// The domain a policy's signature is made under (§3.2).
pub const POLICY_DOMAIN: &[u8] = b"wai:live-ads-policy\x01";
/// The length of a break annotation (§4.2).
@ -203,6 +212,9 @@ pub struct AdPolicy {
pub snap: u8,
pub asset_base: String,
pub creative_base: String,
/// The presentation fields of a policy of version 2 (ad-presentation §8); `None` for a
/// policy of version 1.
pub presentation: Option<Requirement>,
pub sig: [u8; 64],
}
@ -260,7 +272,7 @@ impl AdPolicy {
/// The policy without `sig` (§3.1).
pub fn body(&self) -> Vec<u8> {
let mut o = POLICY_MAGIC.to_vec();
o.push(RECORD_VERSION);
o.push(if self.presentation.is_some() { POLICY_VERSION_PRESENTATION } else { RECORD_VERSION });
o.extend_from_slice(&self.room);
o.extend_from_slice(&self.decider_grant);
o.push(self.slots);
@ -271,9 +283,20 @@ impl AdPolicy {
o.push(self.snap);
put_long(&mut o, self.asset_base.as_bytes());
put_long(&mut o, self.creative_base.as_bytes());
if let Some(q) = &self.presentation {
o.push(q.require);
o.push(q.rule);
o.extend_from_slice(&q.token_type.to_le_bytes());
put_long(&mut o, &q.issuer);
}
o
}
/// Does the policy require presentation claims (ad-presentation §8)?
pub fn requires_claims(&self) -> bool {
self.presentation.as_ref().is_some_and(Requirement::claims)
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut o = self.body();
o.extend_from_slice(&self.sig);
@ -304,9 +327,10 @@ impl AdPolicy {
if b.len() < 5 {
return Err(ev(events::Reason::Truncated));
}
if b[4] != RECORD_VERSION {
if !matches!(b[4], RECORD_VERSION | POLICY_VERSION_PRESENTATION) {
return Err(ev(events::Reason::UnknownVersion));
}
let v2 = b[4] == POLICY_VERSION_PRESENTATION;
let mut r = Rd { b, p: 5 };
let read = |r: &mut Rd| -> Option<(AdPolicy, Vec<u8>, Vec<u8>)> {
let room = r.d32()?;
@ -319,6 +343,11 @@ impl AdPolicy {
let snap = r.u8()?;
let asset = r.long()?.to_vec();
let creative = r.long()?.to_vec();
let presentation = if v2 {
Some(Requirement { require: r.u8()?, rule: r.u8()?, token_type: r.u16()?, issuer: r.long()?.to_vec() })
} else {
None
};
let sig: [u8; 64] = r.take(64)?.try_into().ok()?;
let p = AdPolicy {
room,
@ -331,6 +360,7 @@ impl AdPolicy {
snap,
asset_base: String::new(),
creative_base: String::new(),
presentation,
sig,
};
Some((p, asset, creative))
@ -339,7 +369,13 @@ impl AdPolicy {
if r.p != b.len() {
return Err(ev(events::Reason::Malformed));
}
if !(1..=16).contains(&p.slots) || p.restrict > 3 || p.snap > 3 || !base_ok(&asset) || !base_ok(&creative) {
if !(1..=16).contains(&p.slots)
|| p.restrict > 3
|| p.snap > 3
|| !base_ok(&asset)
|| !base_ok(&creative)
|| p.presentation.as_ref().is_some_and(|q| !q.well_formed())
{
return Err(Reason::AdPolicyMalformed);
}
// A base is ASCII by its rule, so it is UTF-8.
@ -764,6 +800,13 @@ pub struct Input<'a> {
pub receipts: &'a [serde_json::Value],
/// Each receipt's pod, in the order the decider gave it.
pub pods: &'a [Vec<AdCandidate>],
/// Presentation claims, in the order given (ad-presentation §9.1); read only under a
/// policy that requires them.
pub claims: &'a [Vec<u8>],
/// The creatives the claims are checked against (ad-presentation §4).
pub creatives: &'a [Creative],
/// The attestation issuers the verifier holds keys of (ad-presentation §7.3).
pub issuers: &'a [Issuer],
}
/// Where a verification stopped (§9.2).
@ -791,6 +834,10 @@ pub struct ReceiptFacts {
/// The asset list it binds, as the verifier writes it.
pub asset_list: Vec<u8>,
pub spend_micros: u64,
/// Under a policy that requires claims: the spend of the impressions that count
/// (ad-presentation §9.3), and each impression's count.
pub presented_spend_micros: Option<u64>,
pub impressions: Vec<ImpressionCount>,
}
/// What a verification holds at its end.
@ -806,7 +853,11 @@ pub struct Run {
/// Each out's event as written before its in, by the out's index among the breaks.
pub dash_open: Vec<(usize, String)>,
pub receipts: Vec<ReceiptFacts>,
/// The spend of every receipt together: their billable spend, or under a policy that
/// requires claims their presented spend.
pub spend_micros: u64,
/// Under a policy that requires claims, each claim given, in order (ad-presentation §9.4).
pub claims: Vec<ClaimFacts>,
}
impl Run {
@ -883,8 +934,12 @@ pub fn verify(inp: &Input) -> Run {
run.dash_open = s.breaks.iter().enumerate().filter(|(_, b)| b.cue_type == cue_type::OUT).map(|(i, b)| (i, dash_event(&p, inp.pt0_ms, b, false))).collect();
run.schedule = s;
run.policy = Some(p.clone());
// Step 5: the receipts.
// Step 5: the receipts; under a policy that requires claims, the claims read once here
// and judged as their receipts are reached (ad-presentation §9).
let (grant_hash, capability, joule_ceiling, funds) = decider.grant.grant_ref();
let requirement = p.presentation.clone().unwrap_or_default();
let mut ledger = p.requires_claims().then(|| Ledger::read(inp.claims));
let cx = presentation::Context { requirement: &requirement, creatives: inp.creatives, issuers: inp.issuers };
let mut sessions: BTreeSet<([u8; 32], [u8; 16])> = BTreeSet::new();
let mut total: u128 = 0;
for (k, v) in inp.receipts.iter().enumerate() {
@ -933,15 +988,26 @@ pub fn verify(inp: &Input) -> Run {
if !sessions.insert((b.cue, b.session)) {
return run.refuse(at, Reason::ReceiptDuplicate);
}
total += r.budget_spent_micros as u128;
// Step 12 counts the billable spend, or under a policy that requires claims the
// spend of the impressions whose claim verifies (ad-presentation §9.3).
let (counted, presented, impressions) = match ledger.as_mut() {
Some(l) => {
let counts = l.receipt(k, &room_id, &b.cue, &b.session, &r.impressions, &cx);
let spend: u64 = r.impressions.iter().zip(&counts).filter(|(_, c)| c.verdict.is_ok()).map(|(i, _)| i.price_micros).sum();
(spend, Some(spend), counts)
}
None => (r.budget_spent_micros, None, Vec::new()),
};
total += counted as u128;
if let Some(c) = funds
&& (!within_ceiling(b.funds_remaining_micros, c) || total > c as u128 * 1_000_000)
{
return run.refuse(at, Reason::FundsExceeded);
}
run.receipts.push(ReceiptFacts { identity: r.receipt_hash(), asset_list: list, spend_micros: r.budget_spent_micros });
run.receipts.push(ReceiptFacts { identity: r.receipt_hash(), asset_list: list, spend_micros: r.budget_spent_micros, presented_spend_micros: presented, impressions });
}
run.spend_micros = u64::try_from(total).unwrap_or(u64::MAX);
run.claims = ledger.map(Ledger::finish).unwrap_or_default();
run.outcome = Some(Outcome::Accept);
run
}
@ -962,6 +1028,7 @@ mod tests {
snap: snap::OUT,
asset_base: "https://ads.example/r/".into(),
creative_base: "https://cdn.example/c/".into(),
presentation: None,
sig: [0; 64],
}
.sign(&SigningKey::from_bytes(&[7; 32]))
@ -988,7 +1055,26 @@ mod tests {
assert_eq!(AdPolicy::parse(&b[..b.len() - 1]), Err(ev(events::Reason::Truncated)));
assert_eq!(AdPolicy::parse(&[b.as_slice(), &[0]].concat()), Err(ev(events::Reason::Malformed)));
assert_eq!(AdPolicy::parse(b"WAP2"), Err(ev(events::Reason::Malformed)));
assert_eq!(AdPolicy::parse(b"WAP1\x02"), Err(ev(events::Reason::UnknownVersion)));
assert_eq!(AdPolicy::parse(b"WAP1\x03"), Err(ev(events::Reason::UnknownVersion)));
assert_eq!(AdPolicy::parse(b"WAP1\x02"), Err(ev(events::Reason::Truncated)), "version 2 is a policy with presentation fields");
}
#[test]
fn a_policy_of_version_two_carries_its_presentation_fields() {
use presentation::{require, rule, token_type};
let q = Requirement { require: require::CLAIM | require::ATTEST, rule: rule::VIDEO, token_type: token_type::BLIND_RSA, issuer: b"issuer.example".to_vec() };
let p = AdPolicy { presentation: Some(q.clone()), ..policy() }.sign(&SigningKey::from_bytes(&[7; 32]));
let b = p.to_bytes();
assert_eq!(b[4], POLICY_VERSION_PRESENTATION);
assert_eq!(AdPolicy::parse(&b), Ok(p.clone()));
assert!(p.requires_claims());
let v1 = policy();
assert!(!v1.requires_claims());
assert_eq!(v1.to_bytes()[4], RECORD_VERSION, "a policy with no presentation fields keeps version 1 and its bytes");
let bad = AdPolicy { presentation: Some(Requirement { require: require::ATTEST, ..q }), ..policy() };
assert_eq!(AdPolicy::parse(&bad.to_bytes()), Err(Reason::AdPolicyMalformed));
let none = AdPolicy { presentation: Some(Requirement::default()), ..policy() };
assert_eq!(AdPolicy::parse(&none.to_bytes()).map(|x| x.requires_claims()), Ok(false), "version 2 with no requirement reads");
}
#[test]

View file

@ -24,11 +24,11 @@ use serde_json::{json, Map, Value};
const T0_US: u64 = 1_792_108_800_000_000;
const T0_S: u64 = T0_US / 1_000_000;
const S: u64 = 1_000_000;
pub(super) const S: u64 = 1_000_000;
const AUTHORITY: u8 = 0;
const CLOCK: u8 = 1;
const PRODUCER: u8 = 2;
pub(super) const PRODUCER: u8 = 2;
const VIEWER: u8 = 3;
const DECIDER: u8 = 4;
const LOG_ROOT: u8 = 5;
@ -43,7 +43,7 @@ const CREATIVE_BASE: &str = "https://cdn.rooms.example/creatives/";
/// The decider grant's ceiling, in minor units of EUR: 2 000 000 millionths.
const BUDGET_MINOR: u64 = 2;
fn sha(parts: &[&[u8]]) -> [u8; 32] {
pub(super) fn sha(parts: &[&[u8]]) -> [u8; 32] {
sha256(parts)
}
@ -85,30 +85,30 @@ struct Author {
/// How a room is set up.
#[derive(Clone, Copy)]
struct Spec {
pub(super) struct Spec {
/// The decider grant's `exp`, seconds since 1970.
decider_exp: u64,
pub(super) decider_exp: u64,
/// The decider grant's ceiling, in minor units.
budget_minor: u64,
pub(super) budget_minor: u64,
/// The descriptor names an origin and the key log's head revokes the decider grant.
revoked: bool,
pub(super) revoked: bool,
}
const SPEC: Spec = Spec { decider_exp: T0_S + 86_400, budget_minor: BUDGET_MINOR, revoked: false };
pub(super) const SPEC: Spec = Spec { decider_exp: T0_S + 86_400, budget_minor: BUDGET_MINOR, revoked: false };
/// A room being written.
#[derive(Clone)]
struct Room {
descriptor: Vec<u8>,
id: [u8; 32],
grants: Vec<Vec<u8>>,
head: Option<String>,
pub(super) struct Room {
pub(super) descriptor: Vec<u8>,
pub(super) id: [u8; 32],
pub(super) grants: Vec<Vec<u8>>,
pub(super) head: Option<String>,
authors: Vec<Author>,
decider: Grant,
}
impl Room {
fn raw(&mut self, k: &SigningKey, t: u64, kind: u8, payload: Vec<u8>) -> [u8; 32] {
pub(super) fn raw(&mut self, k: &SigningKey, t: u64, kind: u8, payload: Vec<u8>) -> [u8; 32] {
let me = k.verifying_key().to_bytes();
let i = match self.authors.iter().position(|a| a.pk == me) {
Some(i) => i,
@ -125,12 +125,12 @@ impl Room {
a.prev
}
fn cue(&mut self, who: u8, t: u64, ctype: u8, cue_id: u32, duration_ms: u32) -> [u8; 32] {
pub(super) fn cue(&mut self, who: u8, t: u64, ctype: u8, cue_id: u32, duration_ms: u32) -> [u8; 32] {
let c = Payload::Cue(events::Cue { cue_type: ctype, cue_id, duration_ms, scte: None });
self.raw(&key(who), t, kind::CUE, c.encode())
}
fn annotate(&mut self, who: u8, t: u64, cue: [u8; 32], placement: u8) -> [u8; 32] {
pub(super) fn annotate(&mut self, who: u8, t: u64, cue: [u8; 32], placement: u8) -> [u8; 32] {
self.raw(&key(who), t, kind::META, Annotation { cue, placement }.encode())
}
@ -138,7 +138,7 @@ impl Room {
self.raw(&key(who), t, kind::META, bytes)
}
fn chains(&self) -> Vec<Vec<Vec<u8>>> {
pub(super) fn chains(&self) -> Vec<Vec<Vec<u8>>> {
self.authors.iter().map(|a| a.records.clone()).collect()
}
@ -165,7 +165,7 @@ fn role(name: &str, subscribe: bool, events: &[u8]) -> Role {
/// A room: a producer that cues breaks, a viewer that chats, and the decider, which holds a
/// grant with the room's ad budget and never joins. The clock admits the producer and the
/// viewer at the room's start.
fn room(spec: Spec) -> Room {
pub(super) fn room(spec: Spec) -> Room {
let d = Descriptor {
authority: pk(AUTHORITY),
clock: pk(CLOCK),
@ -243,7 +243,7 @@ fn room(spec: Spec) -> Room {
}
/// The room's ad policy: the defaults, changed by `f`, then signed.
fn policy(room: &Room, f: impl FnOnce(&mut AdPolicy)) -> AdPolicy {
pub(super) fn policy(room: &Room, f: impl FnOnce(&mut AdPolicy)) -> AdPolicy {
let mut p = AdPolicy {
room: room.id,
decider_grant: room.decider.digest(),
@ -255,6 +255,7 @@ fn policy(room: &Room, f: impl FnOnce(&mut AdPolicy)) -> AdPolicy {
snap: 0,
asset_base: ASSET_BASE.into(),
creative_base: CREATIVE_BASE.into(),
presentation: None,
sig: [0; 64],
};
f(&mut p);
@ -283,15 +284,18 @@ fn chip() -> EnergyProvenance {
/// What one receipt is sealed from, with the defects a control writes in.
#[derive(Clone)]
struct Seal {
cue: [u8; 32],
pub(super) struct Seal {
pub(super) cue: [u8; 32],
avail_ms: u64,
session: u8,
/// A session given as its bytes (a session a session key names, ad-presentation §3),
/// in place of `session`'s.
pub(super) session_bytes: Option<[u8; 16]>,
start_offset_ms: u64,
format: u8,
pod: Vec<AdCandidate>,
funds: u64,
quartiles: Vec<u8>,
pub(super) format: u8,
pub(super) pod: Vec<AdCandidate>,
pub(super) funds: u64,
pub(super) quartiles: Vec<u8>,
energy: Option<(u64, EnergyProvenance)>,
signer: u8,
// Defects.
@ -305,11 +309,12 @@ struct Seal {
}
impl Seal {
fn new(cue: [u8; 32], avail_ms: u64, session: u8, format: u8, pod: Vec<AdCandidate>, quartiles: &[u8]) -> Seal {
pub(super) fn new(cue: [u8; 32], avail_ms: u64, session: u8, format: u8, pod: Vec<AdCandidate>, quartiles: &[u8]) -> Seal {
Seal {
cue,
avail_ms,
session,
session_bytes: None,
start_offset_ms: 0,
format,
pod,
@ -327,7 +332,7 @@ impl Seal {
}
}
fn seal(room: &Room, p: &AdPolicy, s: &Seal) -> SsaiBreakReceipt {
pub(super) fn seal(room: &Room, p: &AdPolicy, s: &Seal) -> SsaiBreakReceipt {
let fill = decide(&s.pod, p.slots as u64, s.avail_ms.saturating_sub(s.start_offset_ms), s.funds);
assert_eq!(fill.len(), s.quartiles.len(), "a corpus receipt states a quartile for each ad of its fill");
let mut imps = fill.clone();
@ -338,7 +343,7 @@ fn seal(room: &Room, p: &AdPolicy, s: &Seal) -> SsaiBreakReceipt {
let binding = BreakBinding {
room: s.stated_room.unwrap_or(room.id),
cue: s.cue,
session: session(s.session),
session: s.session_bytes.unwrap_or_else(|| session(s.session)),
start_offset_ms: s.start_offset_ms,
format: s.format,
asset_list_sha256: sha(&[&list]),
@ -393,6 +398,9 @@ impl Vector {
pt0_ms: self.pt0_ms,
receipts: &self.receipts,
pods: &self.pods,
claims: &[],
creatives: &[],
issuers: &[],
})
}
}
@ -711,7 +719,7 @@ fn build() -> Vec<(Vector, Run)> {
// ---- JSON ----
fn hexes(v: &[Vec<u8>]) -> Vec<String> {
pub(super) fn hexes(v: &[Vec<u8>]) -> Vec<String> {
v.iter().map(|b| hex(b)).collect()
}
@ -719,7 +727,7 @@ fn opt_hex(d: &Option<[u8; 32]>) -> Value {
d.map_or(Value::Null, |d| json!(hex(&d)))
}
fn place_json(at: &At) -> Value {
pub(super) fn place_json(at: &At) -> Value {
match at {
At::Room(room::At::Descriptor) => json!(["room", "descriptor"]),
At::Room(room::At::KeyLog) => json!(["room", "key_log"]),
@ -731,11 +739,11 @@ fn place_json(at: &At) -> Value {
}
}
fn pod_json(pod: &[AdCandidate]) -> Value {
pub(super) fn pod_json(pod: &[AdCandidate]) -> Value {
Value::Array(pod.iter().map(|c| json!({"ad_id": c.ad_id, "creative_hash": hex(&c.creative_hash), "duration_ms": c.duration_ms, "price_micros": c.price_micros})).collect())
}
fn pod_of(v: &Value) -> Option<Vec<AdCandidate>> {
pub(super) fn pod_of(v: &Value) -> Option<Vec<AdCandidate>> {
v.as_array()?
.iter()
.map(|c| {
@ -749,17 +757,17 @@ fn pod_of(v: &Value) -> Option<Vec<AdCandidate>> {
.collect()
}
fn hls_file(name: &str) -> String {
pub(super) fn hls_file(name: &str) -> String {
format!("{name}.m3u8")
}
/// The HLS file of an accepted run: one tag a line.
fn hls_body(run: &Run) -> String {
pub(super) fn hls_body(run: &Run) -> String {
run.hls.iter().map(|t| format!("{t}\n")).collect()
}
/// What a vector states of its run (§9.4).
fn run_json(name: &str, run: &Run) -> Map<String, Value> {
pub(super) fn run_json(name: &str, run: &Run) -> Map<String, Value> {
let mut o = Map::new();
match run.outcome() {
Outcome::Refuse { at, reason } => {
@ -882,14 +890,14 @@ pub fn generate() -> Vec<(String, String)> {
files
}
fn unhex(s: &str) -> Option<Vec<u8>> {
pub(super) fn unhex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(s.get(i..i + 2)?, 16).ok()).collect()
}
fn hex_list(v: &Value) -> Option<Vec<Vec<u8>>> {
pub(super) fn hex_list(v: &Value) -> Option<Vec<Vec<u8>>> {
v.as_array()?.iter().map(|h| unhex(h.as_str()?)).collect()
}
@ -920,7 +928,19 @@ pub fn verify(files: &BTreeMap<String, String>) -> Vec<String> {
fails.push(format!("vector {name}: its inputs do not read"));
continue;
};
let run = super::verify(&Input { descriptor: &descriptor, grants: &grants, head: head.as_deref(), chains: &chains, policy: &policy, pt0_ms, receipts: &receipts, pods: &pods });
let run = super::verify(&Input {
descriptor: &descriptor,
grants: &grants,
head: head.as_deref(),
chains: &chains,
policy: &policy,
pt0_ms,
receipts: &receipts,
pods: &pods,
claims: &[],
creatives: &[],
issuers: &[],
});
let want = run_json(&name, &run);
for (k, w) in &want {
if v.get(k) != Some(w) {

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,629 @@
//! The conformance corpus of extensions/ad-presentation.md §12: `live-conformance/presentation/`.
//!
//! [`generate`] writes it from fixed seeds, as the live-ads corpus is written (its rooms are
//! built by [`super::corpus`]'s helpers): every session key is SHA-256 of this corpus's label
//! and an index, every media object's content hash SHA-256 of a label and two numbers,
//! every token's nonce and salt likewise, and Ed25519 and the RSASSA-PSS of RFC 9578 §6 over
//! a given salt are deterministic, so every file is the same bytes on every machine. The
//! attestation issuer's key is the published test key of RFC 9578 Appendix A.2, test vector
//! 1, whose own token the corpus also carries, so that a verifier checks its RSASSA-PSS
//! against the RFC's vector before it reads any token of the corpus.
//!
//! Each vector states the outcome it was written to have, and the generator asserts that
//! this module reaches it, that each claim gets the verdict it was written to get, and that
//! the corpus gives every reason of §11.
//!
//! [`verify`] checks a corpus directory's files against this module.
//! `live-conformance/ad_presentation_verify.py` checks the same files from the extension's
//! text, sharing no code with this module.
use std::collections::{BTreeMap, BTreeSet};
use serde_json::{json, Map, Value};
use super::corpus::{self as base, policy, room, seal, Room, Seal, Spec, PRODUCER, S, SPEC};
use super::presentation::{self as pr, geometry, require, rule, span_flag, token_type, Claim, ClaimFacts, Creative, CreativeObject, Issuer, Requirement, Span};
use super::*;
/// RFC 9578 Appendix A.2, its first test vector: the issuer's public key (`pkI`), a DER
/// SubjectPublicKeyInfo of id-RSASSA-PSS.
pub const RFC9578_PKI: &str = "30820152303d06092a864886f70d01010a3030a00d300b0609608648016503040202a11a301806092a864886f70d010108300b0609608648016503040202a2030201300382010f003082010a0282010100cb1aed6b6a95f5b1ce013a4cfcab25b94b2e64a23034e4250a7eab43c0df3a8c12993af12b111908d4b471bec31d4b6c9ad9cdda90612a2ee903523e6de5a224d6b02f09e5c374d0cfe01d8f529c500a78a2f67908fa682b5a2b430c81eaf1af72d7b5e794fc98a3139276879757ce453b526ef9bf6ceb99979b8423b90f4461a22af37aab0cf5733f7597abe44d31c732db68a181c6cbbe607d8c0e52e0655fd9996dc584eca0be87afbcd78a337d17b1dba9e828bbd81e291317144e7ff89f55619709b096cbb9ea474cead264c2073fe49740c01f00e109106066983d21e5f83f086e2e823c879cd43cef700d2a352a9babd612d03cad02db134b7e225a5f0203010001";
/// The same vector's TokenChallenge.
pub const RFC9578_CHALLENGE: &str = "0002000e6973737565722e6578616d706c65208e7acc900e393381e8810b7c9e4a68b5163f1f880ab6688a6ffe780923609e88000e6f726967696e2e6578616d706c65";
/// The same vector's token.
pub const RFC9578_TOKEN: &str = "0002aa72019d1f951df197021ce63876fe8b0a02dc1c31a12b0a2dd1508d07827f055969f643b4cfda5196d4aa86aeb5368834f4f06de46950ed435b3b81bd036d44ca572f8982a9ca248a3056186322d93ca147266121ddeb5632c07f1f71cd2708bc6a21b533d07294b5e900faf5537dd3eb33cee4e08c9670d1e5358fd184b0e00c637174f5206b14c7bb0e724ebf6b56271e5aa2ed94c051c4a433d302b23bc52460810d489fb050f9de5c868c6c1b06e3849fd087629f704cc724bc0d0984d5c339686fcdd75f9a9cdd25f37f855f6f4c584d84f716864f546b696d620c5bd41a811498de84ff9740ba3003ba2422d26b91eb745c084758974642a42078201543246ddb58030ea8e722376aa82484dca9610a8fb7e018e396165462e17a03e40ea7e128c090a911ecc708066cb201833010c1ebd4e910fc8e27a1be467f78671836a508257123a45e4e0ae2180a434bd1037713466347a8ebe46439d3da1970";
/// The modulus of that issuer key, big-endian.
const ISSUER_N: &str = "cb1aed6b6a95f5b1ce013a4cfcab25b94b2e64a23034e4250a7eab43c0df3a8c12993af12b111908d4b471bec31d4b6c9ad9cdda90612a2ee903523e6de5a224d6b02f09e5c374d0cfe01d8f529c500a78a2f67908fa682b5a2b430c81eaf1af72d7b5e794fc98a3139276879757ce453b526ef9bf6ceb99979b8423b90f4461a22af37aab0cf5733f7597abe44d31c732db68a181c6cbbe607d8c0e52e0655fd9996dc584eca0be87afbcd78a337d17b1dba9e828bbd81e291317144e7ff89f55619709b096cbb9ea474cead264c2073fe49740c01f00e109106066983d21e5f83f086e2e823c879cd43cef700d2a352a9babd612d03cad02db134b7e225a5f";
/// Its private exponent, big-endian: a test key the RFC publishes, used here to sign the
/// corpus's tokens.
const ISSUER_D: &str = "2f309b7738b58cf779f0c915f822dfc9f490dbeadf1e7adfac578ffd5703c6bba2da9d5a49725889b7ba112f8c6ff30551d9473e000bc35c50e167ab7fa73a0c2b21965c4b59257c1ac06cbdcf28e863f6718ea8c29043c1c6da84629490fd0491bf52d172c959c1921b96949c725747b8a7c7871995fa0b9eb1107ba854c6c32a6f4747c5d1c304c7467d55b080aa233a3a52ffdf26114cbb541ffa6b696b7a096f8dcff69ca08ab58efb0fb753aa5185c73001696bbce8c099e3f435dcbbfe05e7dbaad23db2a659c23add521407f150b1af2d9054c4164229d6840b7a32a4f8481493ae8959cf0aea8def0c3c9f75b9a34c75718b7072c8dbab17cefeca49";
/// The name the corpus's ad policies give the issuer.
const ISSUER_NAME: &[u8] = b"issuer.example";
fn unhex(s: &str) -> Vec<u8> {
base::unhex(s).expect("a hexadecimal constant")
}
fn sha(parts: &[&[u8]]) -> [u8; 32] {
base::sha(parts)
}
/// Session key `i` (ad-presentation §3).
fn player(i: u8) -> SigningKey {
SigningKey::from_bytes(&sha(&[b"wai:ad-presentation/corpus/player\x00", &[i]]))
}
fn player_pk(i: u8) -> [u8; 32] {
player(i).verifying_key().to_bytes()
}
/// A creative of objects of these durations; object `j`'s bytes are named by SHA-256 of the
/// label, the creative's number and `j`.
fn creative(n: u8, durations: &[u32]) -> Creative {
Creative {
objects: durations
.iter()
.enumerate()
.map(|(j, &d)| CreativeObject { content_hash: sha(&[b"wai:ad-presentation/corpus/object\x00", &[n, j as u8]]), duration_ms: d })
.collect(),
}
}
fn creative_x() -> Creative {
creative(1, &[4_000, 4_000, 4_000, 3_000])
}
fn creative_y() -> Creative {
creative(2, &[5_000, 5_000, 5_000])
}
fn creative_z() -> Creative {
creative(3, &[5_000, 5_000])
}
fn creative_w() -> Creative {
creative(4, &[3_000, 3_000, 3_000, 3_000, 3_000])
}
fn ad(id: &str, c: &Creative, price_micros: u64) -> AdCandidate {
AdCandidate { ad_id: id.into(), creative_hash: c.digest(), duration_ms: c.duration_ms(), price_micros }
}
/// Three ads over two slots and a 30 s avail: ad-x and ad-y fill it.
fn pod() -> Vec<AdCandidate> {
vec![ad("ad-x", &creative_x(), 40_000), ad("ad-y", &creative_y(), 30_000), ad("ad-z", &creative_z(), 25_000)]
}
fn creatives() -> Vec<Creative> {
vec![creative_x(), creative_y(), creative_z(), creative_w()]
}
fn issuers() -> Vec<Issuer> {
vec![Issuer { name: ISSUER_NAME.to_vec(), token_type: token_type::BLIND_RSA, keys: vec![unhex(RFC9578_PKI)] }]
}
fn span(start_ms: u32, length_ms: u32, visible_bp: u16, flags: u8) -> Span {
Span { start_ms, length_ms, visible_bp, flags }
}
/// One span over a whole creative, every pixel in view, audible.
fn whole(c: &Creative) -> Vec<Span> {
vec![span(0, c.duration_ms() as u32, 10_000, span_flag::AUDIBLE)]
}
/// What a claim is written from.
#[derive(Clone)]
struct Draft {
room: [u8; 32],
cue: [u8; 32],
/// The session key whose session the claim names.
session_of: u8,
slot: u8,
creative: Creative,
geometry: u8,
frames: u32,
spans: Vec<Span>,
/// The key that signs it: `session_of`'s unless a control says otherwise.
signer: Option<u8>,
/// A token for this claim's own challenge, by this index (its nonce and salt).
token: Option<u8>,
}
impl Draft {
fn new(room: &Room, cue: [u8; 32], session_of: u8, slot: u8, creative: Creative, spans: Vec<Span>) -> Draft {
Draft { room: room.id, cue, session_of, slot, creative, geometry: geometry::AD, frames: pr::NOT_EXPOSED, spans, signer: None, token: None }
}
fn unsigned(&self) -> Claim {
let signer = self.signer.unwrap_or(self.session_of);
Claim {
room: self.room,
cue: self.cue,
session: pr::session_of(&self.room, &player_pk(self.session_of)),
slot: self.slot,
creative_hash: self.creative.digest(),
player: player_pk(signer),
geometry: self.geometry,
frames: self.frames,
objects: self.creative.touched(&self.spans),
spans: self.spans.clone(),
attestation: vec![],
sig: [0; 64],
}
}
fn claim(&self) -> Claim {
let mut c = self.unsigned();
if let Some(i) = self.token {
c.attestation = token_for(&c, i);
}
c.sign(&player(self.signer.unwrap_or(self.session_of)))
}
}
/// A token of type 0x0002 for a claim's own challenge (ad-presentation §7.2), signed with the
/// RFC's test issuer key, its nonce and salt by index `i`.
fn token_for(c: &Claim, i: u8) -> Vec<u8> {
let challenge = pr::token_challenge(token_type::BLIND_RSA, ISSUER_NAME, &pr::redemption_context(c));
let mut t = token_type::BLIND_RSA.to_be_bytes().to_vec();
t.extend_from_slice(&sha(&[b"wai:ad-presentation/corpus/nonce\x00", &[i]]));
t.extend_from_slice(&sha(&[&challenge]));
t.extend_from_slice(&sha(&[&unhex(RFC9578_PKI)]));
let mut salt = [0u8; pr::PSS_SALT];
salt.copy_from_slice(&sha2::Sha384::digest([b"wai:ad-presentation/corpus/salt\x00".as_slice(), &[i]].concat()));
let auth = pr::pss_sign(&unhex(ISSUER_N), &unhex(ISSUER_D), &t, &salt);
t.extend_from_slice(&auth);
t
}
/// A receipt sealed for session key `p`'s session.
fn sealed(room: &Room, p: &AdPolicy, cue: [u8; 32], player: u8, format: u8, pod: Vec<AdCandidate>, quartiles: &[u8]) -> SsaiBreakReceipt {
let mut s = Seal::new(cue, 30_000, 0, format, pod, quartiles);
s.session_bytes = Some(pr::session_of(&room.id, &player_pk(player)));
seal(room, p, &s)
}
/// What a vector was written to do.
#[derive(Clone, Copy, Debug)]
enum Intent {
Accept,
Refuse(&'static str, Reason),
}
struct Vector {
name: &'static str,
about: &'static str,
room: Room,
policy: Vec<u8>,
receipts: Vec<Value>,
pods: Vec<Vec<AdCandidate>>,
/// Each claim's bytes, what it is, and the verdict it was written to get.
claims: Vec<(Vec<u8>, &'static str, Result<(), pr::Reason>)>,
intent: Intent,
}
impl Vector {
fn run(&self) -> Run {
let chains = self.room.chains();
let claims: Vec<Vec<u8>> = self.claims.iter().map(|c| c.0.clone()).collect();
let (cr, is) = (creatives(), issuers());
super::verify(&Input {
descriptor: &self.room.descriptor,
grants: &self.room.grants,
head: self.room.head.as_deref(),
chains: &chains,
policy: &self.policy,
pt0_ms: 0,
receipts: &self.receipts,
pods: &self.pods,
claims: &claims,
creatives: &cr,
issuers: &is,
})
}
}
fn requirement(require: u8, rule: u8, attest: bool) -> Requirement {
Requirement { require, rule, token_type: if attest { token_type::BLIND_RSA } else { 0 }, issuer: if attest { ISSUER_NAME.to_vec() } else { vec![] } }
}
/// A room with one 30 s mid-roll at 120 s, under a policy of version 2.
fn mid_roll(spec: Spec, req: Requirement) -> (Room, AdPolicy, [u8; 32]) {
let mut r = room(spec);
let b = r.cue(PRODUCER, 120 * S, cue_type::BREAK, 21, 30_000);
r.annotate(PRODUCER, 120 * S, b, placement::MID);
let p = policy(&r, |p| {
p.open_avail_ms = 0;
p.preload_ms = 0;
p.presentation = Some(req);
});
(r, p, b)
}
fn vectors() -> Vec<Vector> {
let mut out = Vec::new();
let (x, y) = (creative_x(), creative_y());
let ok = Ok(());
let no = Err;
// The video rule: good impressions, and a control of each claim check.
let (r, p, b) = mid_roll(SPEC, requirement(require::CLAIM, rule::VIDEO, false));
let fmt = asset_format::HLS;
let receipts = [
sealed(&r, &p, b, 1, fmt, pod(), &[5, 4]),
sealed(&r, &p, b, 2, asset_format::DASH, pod(), &[5, 2]),
sealed(&r, &p, b, 3, fmt, pod(), &[5, 5]),
sealed(&r, &p, b, 5, fmt, pod(), &[5, 5]),
sealed(&r, &p, b, 7, fmt, pod(), &[5, 5]),
];
let d = |pl: u8, slot: u8, c: &Creative, spans: Vec<Span>| Draft::new(&r, b, pl, slot, c.clone(), spans);
let good = d(3, 0, &x, whole(&x)).claim();
let mut swapped = d(3, 1, &y, whole(&y)).claim();
swapped.objects[1] = sha(&[b"wai:ad-presentation/corpus/object\x00", &[9, 9]]);
let swapped = swapped.sign(&player(3));
let unbound = Draft { signer: Some(4), ..d(3, 1, &y, whole(&y)) }.claim();
let mut forged = d(5, 1, &y, whole(&y)).claim();
forged.sig[20] ^= 0x01;
let screen = Draft { geometry: geometry::SCREEN, ..d(7, 0, &x, whole(&x)) }.claim();
let player_area = Draft { geometry: geometry::PLAYER, frames: 450, ..d(7, 1, &y, vec![span(0, 15_000, 6_000, span_flag::AUDIBLE)]) }.claim();
let truncated = {
let b = d(6, 0, &x, whole(&x)).claim().to_bytes();
b[..b.len() - 1].to_vec()
};
let claims = vec![
(d(1, 0, &x, whole(&x)).claim().to_bytes(), "viewer 1, ad-x: in view and audible from start to end", ok),
(
d(1, 1, &y, vec![span(0, 4_000, 3_000, 1), span(4_000, 3_000, 6_000, 1), span(7_000, 5_000, 2_000, 1)]).claim().to_bytes(),
"viewer 1, ad-y: scrolled partly out of view; 3 s at 60% of the pixels qualify, and 12 s of 15 reach the third quartile",
ok,
),
(d(2, 0, &x, vec![span(0, 15_000, 4_000, 1)]).claim().to_bytes(), "viewer 2, ad-x: 40% of the pixels in view throughout, below the video rule's half", no(pr::Reason::ClaimNotViewable)),
(
d(2, 1, &y, vec![span(0, 1_500, 10_000, 1), span(1_500, 1_500, 10_000, 3), span(3_000, 1_000, 1_000, 1)]).claim().to_bytes(),
"viewer 2, ad-y: two stretches of 1.5 s in view with a pause between them, which is not 2 continuous seconds",
no(pr::Reason::ClaimNotViewable),
),
(good.to_bytes(), "viewer 3, ad-x: in view and audible from start to end", ok),
(good.to_bytes(), "the same claim given a second time", no(pr::Reason::ClaimDuplicate)),
(unbound.to_bytes(), "a claim for viewer 3's ad-y signed by viewer 4's session key, from which viewer 3's session is not derived", no(pr::Reason::ClaimKeyUnbound)),
(swapped.to_bytes(), "viewer 3, ad-y: a presented object's hash is not the creative's second object", no(pr::Reason::ClaimAssetMismatch)),
(
d(5, 0, &x, vec![span(0, 8_000, 10_000, 1)]).claim().to_bytes(),
"viewer 5, ad-x: 8 s of 15 presented, the midpoint, where the receipt states it complete",
no(pr::Reason::ClaimQuartileMismatch),
),
(forged.to_bytes(), "viewer 5, ad-y: one bit of the signature flipped, so the impression has no claim", no(pr::Reason::BadSignature)),
(
d(5, 1, &y, vec![span(0, 4_000, 10_000, 1), span(8_000, 7_000, 10_000, 1)]).claim().to_bytes(),
"viewer 5, ad-y, signed: a skip from 4 s to 8 s with no resumption stated, which breaks §5.4 rule 6",
no(pr::Reason::ClaimMalformed),
),
(d(6, 0, &x, whole(&x)).claim().to_bytes(), "viewer 6, whose session has no receipt for the break", no(pr::Reason::ClaimNoDecision)),
(truncated, "a claim short of its last byte", no(pr::Reason::ClaimMalformed)),
(screen.to_bytes(), "viewer 7, ad-x: geometry stated as a full-screen television output, which the video rule does not admit", no(pr::Reason::ClaimNotViewable)),
(player_area.to_bytes(), "viewer 7, ad-y: 60% of the player's area in view, measured over the player and stated so, with a count of rendered frames", ok),
];
out.push(Vector {
name: "video_rule",
about: "a 30 s mid-roll under a policy that requires a claim for each impression and the video rule: five viewers, each filled with ad-x and ad-y; each claim is good or a control of one check, and each receipt's spend counts only the impressions whose claim verifies",
room: r.clone(),
policy: p.to_bytes(),
receipts: receipts.iter().map(SsaiBreakReceipt::to_json).collect(),
pods: vec![pod(); 5],
claims,
intent: Intent::Accept,
});
// The cross-media rule with attestation and audibility, over an out and its in.
let mut r = room(SPEC);
let out_ = r.cue(PRODUCER, 60 * S, cue_type::OUT, 31, 0);
r.cue(PRODUCER, 90 * S, cue_type::IN, 31, 30_000);
let p = policy(&r, |p| p.presentation = Some(requirement(require::CLAIM | require::ATTEST | require::AUDIBLE, rule::CROSS_MEDIA, true)));
let receipts = [
sealed(&r, &p, out_, 11, asset_format::HLS, pod(), &[5, 5]),
sealed(&r, &p, out_, 12, asset_format::HLS, pod(), &[5, 5]),
sealed(&r, &p, out_, 13, asset_format::DASH, pod(), &[5, 5]),
sealed(&r, &p, out_, 14, asset_format::DASH, pod(), &[5, 5]),
];
let d = |pl: u8, slot: u8, c: &Creative, spans: Vec<Span>| Draft::new(&r, out_, pl, slot, c.clone(), spans);
let tv = |pl: u8, slot: u8, c: &Creative, t: Option<u8>| Draft { geometry: geometry::SCREEN, token: t, ..d(pl, slot, c, whole(c)) };
let first = tv(11, 0, &x, Some(1)).claim();
let lifted = {
let mut c = tv(12, 0, &x, None).unsigned();
c.attestation = first.attestation.clone();
c.sign(&player(12))
};
let broken = {
let mut c = tv(12, 1, &y, None).unsigned();
let mut t = token_for(&c, 2);
let n = t.len();
t[n - 7] ^= 0x01;
c.attestation = t;
c.sign(&player(12))
};
let claims = vec![
(first.to_bytes(), "viewer 11, ad-x, on a television: full screen, audible throughout, with a device attestation bound to this impression", ok),
(tv(11, 1, &y, None).claim().to_bytes(), "viewer 11, ad-y: the same, with no attestation", no(pr::Reason::AttestationAbsent)),
(lifted.to_bytes(), "viewer 12, ad-x: carrying viewer 11's token, whose challenge is another impression's", no(pr::Reason::AttestationInvalid)),
(broken.to_bytes(), "viewer 12, ad-y: a token for its own challenge with one bit of its authenticator flipped", no(pr::Reason::AttestationInvalid)),
(
Draft { token: Some(3), ..d(13, 0, &x, vec![span(0, 15_000, 10_000, 0)]) }.claim().to_bytes(),
"viewer 13, ad-x: in view throughout and muted, where the policy requires the run to be audible",
no(pr::Reason::ClaimNotViewable),
),
(
Draft { token: Some(4), ..d(13, 1, &y, vec![span(0, 15_000, 9_000, 1)]) }.claim().to_bytes(),
"viewer 13, ad-y: 90% of the pixels measured in view, where the cross-media rule requires all of them",
no(pr::Reason::ClaimNotViewable),
),
(tv(14, 0, &x, Some(5)).claim().to_bytes(), "viewer 14, ad-x: full screen, audible, attested", ok),
(tv(14, 1, &y, Some(6)).claim().to_bytes(), "viewer 14, ad-y: full screen, audible, attested", ok),
];
out.push(Vector {
name: "cross_media_attested",
about: "a live break, an out and its in 30 s later, under a policy that requires a claim, a device attestation of RFC 9578's publicly verifiable type from one issuer, the cross-media rule and an audible run: four viewers on television outputs and screens",
room: r.clone(),
policy: p.to_bytes(),
receipts: receipts.iter().map(SsaiBreakReceipt::to_json).collect(),
pods: vec![pod(); 4],
claims,
intent: Intent::Accept,
});
// The ceiling holds the presented spend.
let w = creative_w();
let costly = vec![ad("ad-w", &w, 600_000)];
let one_minor = Spec { budget_minor: 1, ..SPEC };
for (name, about, both, intent) in [
(
"ceiling_counts_presented",
"a decider grant of 1 minor unit and two receipts each billing 600 000 µ-units: together they pass the ceiling, but only the first has a claim, so the presented spend, 600 000, is within it",
false,
Intent::Accept,
),
(
"ceiling_exceeded_by_presented",
"the same two receipts, each with a claim that verifies: the presented spend of the second takes the room past the ceiling",
true,
Intent::Refuse("receipt", Reason::FundsExceeded),
),
] {
let (r, p, b) = mid_roll(one_minor, requirement(require::CLAIM, rule::VIDEO, false));
let mk = |pl: u8| {
let mut s = Seal::new(b, 30_000, 0, asset_format::HLS, costly.clone(), &[5]);
s.session_bytes = Some(pr::session_of(&r.id, &player_pk(pl)));
s.funds = 1_000_000;
seal(&r, &p, &s)
};
let mut claims = vec![(Draft::new(&r, b, 21, 0, w.clone(), whole(&w)).claim().to_bytes(), "viewer 21, ad-w: in view and audible from start to end", Ok(()))];
if both {
claims.push((Draft::new(&r, b, 22, 0, w.clone(), whole(&w)).claim().to_bytes(), "viewer 22, ad-w: in view and audible from start to end", Ok(())));
}
out.push(Vector { name, about, room: r.clone(), policy: p.to_bytes(), receipts: vec![mk(21).to_json(), mk(22).to_json()], pods: vec![costly.clone(); 2], claims, intent });
}
// A policy whose presentation fields break §8.
let (r, _, _) = mid_roll(SPEC, requirement(require::CLAIM, rule::VIDEO, false));
let bad = policy(&r, |p| p.presentation = Some(Requirement { require: require::ATTEST, rule: rule::NONE, token_type: token_type::BLIND_RSA, issuer: ISSUER_NAME.to_vec() }));
out.push(Vector {
name: "policy_attestation_without_claim",
about: "a policy of version 2 requiring an attestation and no claim to carry it",
room: r,
policy: bad.to_bytes(),
receipts: vec![],
pods: vec![],
claims: vec![],
intent: Intent::Refuse("policy", Reason::AdPolicyMalformed),
});
out
}
fn build() -> Vec<(Vector, Run)> {
let mut out = Vec::new();
for v in vectors() {
let run = v.run();
let ok = match (v.intent, run.outcome()) {
(Intent::Accept, Outcome::Accept) => true,
(Intent::Refuse(place, reason), Outcome::Refuse { at, reason: got }) => {
*got == reason
&& place
== match at {
At::Room(_) => "room",
At::Policy => "policy",
At::Record { .. } => "record",
At::Receipt(_) => "receipt",
}
}
_ => false,
};
assert!(ok, "vector {} was written to {:?} and gives {:?}", v.name, v.intent, run.outcome());
if matches!(run.outcome(), Outcome::Accept) {
for (k, ((_, about, want), got)) in v.claims.iter().zip(&run.claims).enumerate() {
assert_eq!(&got.verdict, want, "vector {} claim {k} ({about})", v.name);
}
}
out.push((v, run));
}
out
}
// ---- JSON ----
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn verdict(v: &Result<(), pr::Reason>) -> Value {
match v {
Ok(()) => json!("presented"),
Err(r) => json!(r.code()),
}
}
fn claim_json(f: &ClaimFacts) -> Value {
json!({
"digest": f.digest.map(|d| hex(&d)),
"verdict": verdict(&f.verdict),
"receipt": f.receipt,
"slot": f.slot,
"quartile": f.quartile,
"run_ms": f.run_ms,
"viewable_ms": f.viewable_ms,
})
}
/// What a vector states of its run: live-ads §9.4, and ad-presentation §9.4.
fn run_json(name: &str, run: &Run) -> Map<String, Value> {
let mut o = base::run_json(name, run);
if !matches!(run.outcome(), Outcome::Accept) {
return o;
}
if let Some(Value::Array(facts)) = o.get_mut("receipt_facts") {
for (f, r) in facts.iter_mut().zip(&run.receipts) {
let m = f.as_object_mut().expect("an object");
m.insert("presented_spend_micros".into(), json!(r.presented_spend_micros));
m.insert(
"impressions".into(),
Value::Array(r.impressions.iter().map(|i| json!({"slot": i.slot, "claim": i.claim, "verdict": verdict(&i.verdict)})).collect()),
);
}
}
o.insert("claim_facts".into(), Value::Array(run.claims.iter().map(claim_json).collect()));
o
}
fn creative_json(c: &Creative) -> Value {
json!({"objects": c.objects.iter().map(|o| json!({"content_hash": hex(&o.content_hash), "duration_ms": o.duration_ms})).collect::<Vec<_>>()})
}
fn issuer_json(i: &Issuer) -> Value {
json!({"name": String::from_utf8(i.name.clone()).expect("an ASCII name"), "token_type": i.token_type, "keys": i.keys.iter().map(|k| hex(k)).collect::<Vec<_>>()})
}
fn vector_json(v: &Vector, run: &Run) -> Value {
let chains: Vec<Vec<String>> = v.room.chains().iter().map(|c| base::hexes(c)).collect();
let mut o = Map::new();
o.insert("name".into(), json!(v.name));
o.insert("about".into(), json!(v.about));
o.insert("descriptor".into(), json!(hex(&v.room.descriptor)));
o.insert("grants".into(), json!(base::hexes(&v.room.grants)));
o.insert("key_log".into(), json!(v.room.head));
o.insert("chains".into(), json!(chains));
o.insert("policy".into(), json!(hex(&v.policy)));
o.insert("pt0_ms".into(), json!(0));
o.insert("receipts".into(), Value::Array(v.receipts.clone()));
o.insert("pods".into(), Value::Array(v.pods.iter().map(|p| base::pod_json(p)).collect()));
o.insert("claims".into(), json!(v.claims.iter().map(|c| hex(&c.0)).collect::<Vec<_>>()));
o.insert("claim_about".into(), json!(v.claims.iter().map(|c| c.1).collect::<Vec<_>>()));
o.extend(run_json(v.name, run));
Value::Object(o)
}
/// Write the corpus: `vectors.json` and each accepted room's HLS tags.
pub fn generate() -> Vec<(String, String)> {
// The RFC's own token verifies under its key; the corpus's tokens are signed with it.
let (pki, challenge, token) = (unhex(RFC9578_PKI), unhex(RFC9578_CHALLENGE), unhex(RFC9578_TOKEN));
let t = pr::read_token(&token).expect("the RFC's token reads");
assert!(t.challenge_digest == sha(&[&challenge]) && t.key_id == sha(&[&pki]) && pr::pss_verify(&pki, t.input, t.authenticator), "RFC 9578 A.2's first test vector");
let rooms = build();
let mut seen: BTreeSet<&str> = BTreeSet::new();
for (_, run) in &rooms {
seen.extend(run.claims.iter().filter_map(|c| c.verdict.err().map(pr::Reason::code)));
seen.extend(run.receipts.iter().flat_map(|r| r.impressions.iter().filter_map(|i| i.verdict.err().map(pr::Reason::code))));
}
for r in pr::Reason::ALL {
assert!(seen.contains(r.code()), "no claim or impression gives {}", r.code());
}
let mut files = Vec::new();
for (v, run) in &rooms {
if matches!(run.outcome(), Outcome::Accept) {
files.push((base::hls_file(v.name), base::hls_body(run)));
}
}
let players: Map<String, Value> = [1u8, 2, 3, 4, 5, 6, 7, 11, 12, 13, 14, 21, 22].iter().map(|&i| (i.to_string(), json!(hex(&player_pk(i))))).collect();
let doc = json!({
"about": "extensions/ad-presentation.md §12: rooms of live-ads breaks under ad policies that require presentation claims, each viewer's break-form wai.video.ssai receipt, the claims its player signed, the creatives and the attestation issuer the verifier holds, each claim's verdict and each receipt's presented spend against the decider grant's ceiling",
"rfc9578_a2_vector1": {"pkI": RFC9578_PKI, "token_challenge": RFC9578_CHALLENGE, "token": RFC9578_TOKEN},
"creatives": creatives().iter().map(creative_json).collect::<Vec<_>>(),
"issuers": issuers().iter().map(issuer_json).collect::<Vec<_>>(),
"players": players,
"vectors": rooms.iter().map(|(v, run)| vector_json(v, run)).collect::<Vec<_>>(),
});
let mut body = serde_json::to_string_pretty(&doc).expect("JSON");
body.push('\n');
files.push(("vectors.json".into(), body));
files.sort();
files
}
fn creative_of(v: &Value) -> Option<Creative> {
Some(Creative {
objects: v["objects"]
.as_array()?
.iter()
.map(|o| Some(CreativeObject { content_hash: base::unhex(o["content_hash"].as_str()?)?.try_into().ok()?, duration_ms: u32::try_from(o["duration_ms"].as_u64()?).ok()? }))
.collect::<Option<_>>()?,
})
}
fn issuer_of(v: &Value) -> Option<Issuer> {
Some(Issuer {
name: v["name"].as_str()?.as_bytes().to_vec(),
token_type: u16::try_from(v["token_type"].as_u64()?).ok()?,
keys: v["keys"].as_array()?.iter().map(|k| base::unhex(k.as_str()?)).collect::<Option<_>>()?,
})
}
/// Check a corpus's files against this module: every room gets the outcome and everything
/// else it states, and every tag file is the run's. Returns the failures.
pub fn verify(files: &BTreeMap<String, String>) -> Vec<String> {
let mut fails = Vec::new();
let Some(doc) = files.get("vectors.json").and_then(|s| serde_json::from_str::<Value>(s).ok()) else {
return vec!["vectors.json does not read".into()];
};
let held = (|| Some((doc["creatives"].as_array()?.iter().map(creative_of).collect::<Option<Vec<_>>>()?, doc["issuers"].as_array()?.iter().map(issuer_of).collect::<Option<Vec<_>>>()?)))();
let Some((creatives, issuers)) = held else {
return vec!["the creatives or issuers do not read".into()];
};
for v in doc["vectors"].as_array().into_iter().flatten() {
let name = v["name"].as_str().unwrap_or("?").to_string();
let parsed = (|| {
let chains: Vec<Vec<Vec<u8>>> = v["chains"].as_array()?.iter().map(base::hex_list).collect::<Option<_>>()?;
let pods: Vec<Vec<AdCandidate>> = v["pods"].as_array()?.iter().map(base::pod_of).collect::<Option<_>>()?;
Some((
base::unhex(v["descriptor"].as_str()?)?,
base::hex_list(&v["grants"])?,
v["key_log"].as_str().map(String::from),
chains,
base::unhex(v["policy"].as_str()?)?,
v["pt0_ms"].as_u64()?,
v["receipts"].as_array()?.clone(),
pods,
base::hex_list(&v["claims"])?,
))
})();
let Some((descriptor, grants, head, chains, policy, pt0_ms, receipts, pods, claims)) = parsed else {
fails.push(format!("vector {name}: its inputs do not read"));
continue;
};
let run = super::verify(&Input {
descriptor: &descriptor,
grants: &grants,
head: head.as_deref(),
chains: &chains,
policy: &policy,
pt0_ms,
receipts: &receipts,
pods: &pods,
claims: &claims,
creatives: &creatives,
issuers: &issuers,
});
for (k, w) in &run_json(&name, &run) {
if v.get(k) != Some(w) {
fails.push(format!("vector {name}: {k} is {w}, the corpus states {}", v.get(k).unwrap_or(&Value::Null)));
}
}
if matches!(run.outcome(), Outcome::Accept) && files.get(&base::hls_file(&name)) != Some(&base::hls_body(&run)) {
fails.push(format!("vector {name}: {} differs from the tags the room writes", base::hls_file(&name)));
}
}
fails
}

View file

@ -0,0 +1,54 @@
//! `wai.live.ad_presentation` (extensions/ad-presentation.md): the committed corpus
//! `live-conformance/presentation/` regenerates byte for byte from its fixed seeds, holds no
//! other file, every case in it gets the outcome it states, and the extension's §11 lists
//! exactly the reasons the reference reports. The generator itself asserts that the corpus
//! gives every one of them, and that RFC 9578's own token verifies under its issuer key.
#![cfg(feature = "live_ads")]
use std::collections::BTreeMap;
use std::path::PathBuf;
use wai::live_ads::presentation::Reason;
use wai::live_ads::presentation_corpus as corpus;
fn wai_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn committed() -> BTreeMap<String, String> {
let dir = wai_dir().join("live-conformance/presentation");
std::fs::read_dir(&dir)
.expect("the committed corpus")
.flatten()
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
.filter(|(n, _)| n != "README.md")
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
.collect()
}
#[test]
fn the_committed_corpus_regenerates_from_its_seeds() {
let files = committed();
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_ad_presentation_vectors gen`");
}
#[test]
fn every_case_gets_its_stated_outcome() {
let fails = corpus::verify(&committed());
assert!(fails.is_empty(), "{}", fails.join("\n"));
}
/// §11 of the extension lists exactly the codes `Reason::ALL` has, each once, in order.
#[test]
fn the_extension_lists_the_reasons() {
let text = std::fs::read_to_string(wai_dir().join("extensions/ad-presentation.md")).expect("the extension");
let at = text.find("\n## 11. Reasons").expect("a §11");
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> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
assert_eq!(listed, want, "§11's table and Reason::ALL differ");
}