wai: proof that an ad was presented — a player-signed presentation claim, and a spend that counts only presented impressions (E7) #398
29 changed files with 5343 additions and 75 deletions
2
.github/workflows/joule-moq-web.yml
vendored
2
.github/workflows/joule-moq-web.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
35
.github/workflows/wai-conformance.yml
vendored
35
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
21
wai/SPEC.md
21
wai/SPEC.md
|
|
@ -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
|
||||
|
|
|
|||
567
wai/extensions/ad-presentation.md
Normal file
567
wai/extensions/ad-presentation.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
555
wai/live-conformance/ad_presentation_verify.py
Normal file
555
wai/live-conformance/ad_presentation_verify.py
Normal 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]))
|
||||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
90
wai/live-conformance/presentation/README.md
Normal file
90
wai/live-conformance/presentation/README.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
1586
wai/live-conformance/presentation/vectors.json
Normal file
1586
wai/live-conformance/presentation/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
1
wai/live-conformance/presentation/video_rule.m3u8
Normal file
1
wai/live-conformance/presentation/video_rule.m3u8
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
77
wai/wai-rs/src/bin/wai_ad_presentation_vectors.rs
Normal file
77
wai/wai-rs/src/bin/wai_ad_presentation_vectors.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
1033
wai/wai-rs/src/live_ads/presentation.rs
Normal file
1033
wai/wai-rs/src/live_ads/presentation.rs
Normal file
File diff suppressed because it is too large
Load diff
629
wai/wai-rs/src/live_ads/presentation_corpus.rs
Normal file
629
wai/wai-rs/src/live_ads/presentation_corpus.rs
Normal 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
|
||||
}
|
||||
54
wai/wai-rs/tests/ad_presentation.rs
Normal file
54
wai/wai-rs/tests/ad_presentation.rs
Normal 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");
|
||||
}
|
||||
Loading…
Reference in a new issue