// 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); }