From 356762c294e94be5110773543eb9871b1c938b30 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 21 Oct 2026 07:54:42 -0400 Subject: [PATCH 1/4] wai-rs live_ads: presentation claims, and a spend that counts only presented impressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A player's signed claim (WPC1) that it presented one impression of a live-ads break: the creative's objects it presented, spans of its media time with the fraction of pixels in view and audibility, the geometry they were measured over, a rendered-frame count, and an optional RFC 9577 token bound to the impression through its challenge and verified as RFC 9578 §6.4 does for type 0x0002. An ad policy of version 2 requires claims (and optionally an attestation and an audible run) under the video or cross-media viewability rule; the room verifier's spend-equivalence then counts only impressions whose claim verifies. The corpus live-conformance/presentation/ is generated from fixed seeds. Co-Authored-By: Claude Opus 5.5 --- .../ceiling_counts_presented.m3u8 | 1 + .../presentation/cross_media_attested.m3u8 | 3 + .../presentation/vectors.json | 1575 +++++++++++++++++ .../presentation/video_rule.m3u8 | 1 + wai/wai-rs/Cargo.toml | 22 +- .../src/bin/wai_ad_presentation_vectors.rs | 77 + wai/wai-rs/src/live_ads.rs | 104 +- wai/wai-rs/src/live_ads/corpus.rs | 96 +- wai/wai-rs/src/live_ads/presentation.rs | 1033 +++++++++++ .../src/live_ads/presentation_corpus.rs | 624 +++++++ wai/wai-rs/tests/ad_presentation.rs | 54 + 11 files changed, 3540 insertions(+), 50 deletions(-) create mode 100644 wai/live-conformance/presentation/ceiling_counts_presented.m3u8 create mode 100644 wai/live-conformance/presentation/cross_media_attested.m3u8 create mode 100644 wai/live-conformance/presentation/vectors.json create mode 100644 wai/live-conformance/presentation/video_rule.m3u8 create mode 100644 wai/wai-rs/src/bin/wai_ad_presentation_vectors.rs create mode 100644 wai/wai-rs/src/live_ads/presentation.rs create mode 100644 wai/wai-rs/src/live_ads/presentation_corpus.rs create mode 100644 wai/wai-rs/tests/ad_presentation.rs diff --git a/wai/live-conformance/presentation/ceiling_counts_presented.m3u8 b/wai/live-conformance/presentation/ceiling_counts_presented.m3u8 new file mode 100644 index 000000000..6ef4a0a6e --- /dev/null +++ b/wai/live-conformance/presentation/ceiling_counts_presented.m3u8 @@ -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 diff --git a/wai/live-conformance/presentation/cross_media_attested.m3u8 b/wai/live-conformance/presentation/cross_media_attested.m3u8 new file mode 100644 index 000000000..30f8ab0c7 --- /dev/null +++ b/wai/live-conformance/presentation/cross_media_attested.m3u8 @@ -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 diff --git a/wai/live-conformance/presentation/vectors.json b/wai/live-conformance/presentation/vectors.json new file mode 100644 index 000000000..df629a354 --- /dev/null +++ b/wai/live-conformance/presentation/vectors.json @@ -0,0 +1,1575 @@ +{ + "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": "30820152303d06092a864886f70d01010a3030a00d300b0609608648016503040202a11a301806092a864886f70d010108300b0609608648016503040202a2030201300382010f003082010a0282010100cb1aed6b6a95f5b1ce013a4cfcab25b94b2e64a23034e4250a7eab43c0df3a8c12993af12b111908d4b471bec31d4b6c9ad9cdda90612a2ee903523e6de5a224d6b02f09e5c374d0cfe01d8f529c500a78a2f67908fa682b5a2b430c81eaf1af72d7b5e794fc98a3139276879757ce453b526ef9bf6ceb99979b8423b90f4461a22af37aab0cf5733f7597abe44d31c732db68a181c6cbbe607d8c0e52e0655fd9996dc584eca0be87afbcd78a337d17b1dba9e828bbd81e291317144e7ff89f55619709b096cbb9ea474cead264c2073fe49740c01f00e109106066983d21e5f83f086e2e823c879cd43cef700d2a352a9babd612d03cad02db134b7e225a5f0203010001", + "token_challenge": "0002000e6973737565722e6578616d706c65208e7acc900e393381e8810b7c9e4a68b5163f1f880ab6688a6ffe780923609e88000e6f726967696e2e6578616d706c65", + "token": "0002aa72019d1f951df197021ce63876fe8b0a02dc1c31a12b0a2dd1508d07827f055969f643b4cfda5196d4aa86aeb5368834f4f06de46950ed435b3b81bd036d44ca572f8982a9ca248a3056186322d93ca147266121ddeb5632c07f1f71cd2708bc6a21b533d07294b5e900faf5537dd3eb33cee4e08c9670d1e5358fd184b0e00c637174f5206b14c7bb0e724ebf6b56271e5aa2ed94c051c4a433d302b23bc52460810d489fb050f9de5c868c6c1b06e3849fd087629f704cc724bc0d0984d5c339686fcdd75f9a9cdd25f37f855f6f4c584d84f716864f546b696d620c5bd41a811498de84ff9740ba3003ba2422d26b91eb745c084758974642a42078201543246ddb58030ea8e722376aa82484dca9610a8fb7e018e396165462e17a03e40ea7e128c090a911ecc708066cb201833010c1ebd4e910fc8e27a1be467f78671836a508257123a45e4e0ae2180a434bd1037713466347a8ebe46439d3da1970" + }, + "creatives": [ + { + "objects": [ + { + "content_hash": "a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2e", + "duration_ms": 4000 + }, + { + "content_hash": "e85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f70", + "duration_ms": 4000 + }, + { + "content_hash": "4ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc8043", + "duration_ms": 4000 + }, + { + "content_hash": "5a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f", + "duration_ms": 3000 + } + ] + }, + { + "objects": [ + { + "content_hash": "13ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd1", + "duration_ms": 5000 + }, + { + "content_hash": "2945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777", + "duration_ms": 5000 + }, + { + "content_hash": "c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298", + "duration_ms": 5000 + } + ] + }, + { + "objects": [ + { + "content_hash": "e1c6857dbe1fbd3f09e790af8e7e68c13dc012fdef9f5cb4c3bed0efef37fb22", + "duration_ms": 5000 + }, + { + "content_hash": "003f859ca4d04e316a0f77f4cddf5a8f7612ce85771225188938e6f7d8a2fd45", + "duration_ms": 5000 + } + ] + }, + { + "objects": [ + { + "content_hash": "b66f42322e52718d08a81552e72aca223d88bf8445137586d93f168ee63db4d3", + "duration_ms": 3000 + }, + { + "content_hash": "47fc7e7df25682a8cd9b085c73337203dc01bbdb31391101bb48294ac55a6afb", + "duration_ms": 3000 + }, + { + "content_hash": "8cf1a5469dc5caf745695f22864330f3508d7526e49b72cf9c85ab5e267fcab4", + "duration_ms": 3000 + }, + { + "content_hash": "02cd6f8e2c2e4529f918cc17308c7a78e63313823823f3ddeb22c96dcb560727", + "duration_ms": 3000 + }, + { + "content_hash": "395db5a3af9985ef25a181153dfe9063d8d23b7d1ca7b0f2d917d1cfd4306c83", + "duration_ms": 3000 + } + ] + } + ], + "issuers": [ + { + "name": "issuer.example", + "token_type": 2, + "keys": [ + "30820152303d06092a864886f70d01010a3030a00d300b0609608648016503040202a11a301806092a864886f70d010108300b0609608648016503040202a2030201300382010f003082010a0282010100cb1aed6b6a95f5b1ce013a4cfcab25b94b2e64a23034e4250a7eab43c0df3a8c12993af12b111908d4b471bec31d4b6c9ad9cdda90612a2ee903523e6de5a224d6b02f09e5c374d0cfe01d8f529c500a78a2f67908fa682b5a2b430c81eaf1af72d7b5e794fc98a3139276879757ce453b526ef9bf6ceb99979b8423b90f4461a22af37aab0cf5733f7597abe44d31c732db68a181c6cbbe607d8c0e52e0655fd9996dc584eca0be87afbcd78a337d17b1dba9e828bbd81e291317144e7ff89f55619709b096cbb9ea474cead264c2073fe49740c01f00e109106066983d21e5f83f086e2e823c879cd43cef700d2a352a9babd612d03cad02db134b7e225a5f0203010001" + ] + } + ], + "players": { + "1": "f67d521ed37b174448cb376c7b390acf158c00ac41211e282a79db9bbcb4021e", + "2": "76e6b877adff9a10d2cca759cf2949071cd7f3ac92b614d8b1a46877da56b23a", + "3": "2ff09f78cd3c4f9506aecd1cdb170cf0dbc635d7a97e0ea5c9cba1625b15b27b", + "4": "4ba5127cfca111cbe3237afa2d83d2aadb55b475b23e1743bff9cc102c636e95", + "5": "40caacc1e30b23e3628917a50e7d71172caf9974aa98539b416b9772b286d328", + "6": "134ff5aa797eda39e08db347ecbb913720755d5d9fdfa7192b4333651ff3b8bb", + "7": "7ca33fadaa0654f9da41d85a263d6e589b0c38b8ee6cff5d3196433d3c51a4e7", + "11": "a94f0330d101f3409e16910015185aea42688772aea51686f49eaf8ec9b083fe", + "12": "f83d8f4e1ba36e525ddef2b2c96d9dc1e459aaaddf00584b39eed823aa427573", + "13": "abce2969973591259d241b2872143bd72b690bba6e3e3802635477ff75dc799f", + "14": "51d452336d3b70ed5e8c98b595c58acc0f5fe9ed19994567175570a9bde26bff", + "21": "7d4a22c530e172ad6d94fb1293e6265d3787a6283bccbeb16aea8c335eedc853", + "22": "101162dc0f6bc5d2507e05ad346bf9c73f0001229a8157d464eec9ae3740c45f" + }, + "vectors": [ + { + "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", + "descriptor": "575244310198925abbec382eaa9716af19533e2da733f16f0c5e4211da533f86235b901ed496a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f9900406cd8e95d060040420f00000000000000100000000400000000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000030870726f6475636572000101000e000000000000000000000000000000000000000000000000000000000000000861756469656e636500010100080000000000000000000000000000000000000000000000000000000000000007646563696465720000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004b446b681c3acb0a952b495f6413b201a39ea15ac40bc16ef9f1fd0f5652046b64f3a198f43f5ab31d47561523c8703db71b7cb34fcc000493a220abc407180d", + "grants": [ + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e900000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a00000000000001010000000000000000000000000000000000000000000000000000000000000000000057455031010e000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff0000000000000043ef80f50c308a0dbbbe257a608bd4cb2621e48a3a643601e4ca941c848f3d7f1efc39cd7684c498110da78b3e77d18ae45d26047bd89743b835c54583e7a100", + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc5bf53717790addd310fcb1716ea58ff76cb693db9d9b09118167ddd0fc9098bf00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000100010100000000000000000000000000000000000000000000000000000000000000000000574550310108000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff000000000000002dbe370ef573d370c9094a346a461cbf226953b1da26196a856ebbf65858c5c4e4381fbb4fd6a2f4fe63d881f388453f6f7707b204eabfbebfaef77bf906d605", + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcdd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f468257500000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200000000000000000000000000000000000000000000000000000000000000000000000000574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffff020000000000000045555200000000633a9c6151bdd3a6731598ff60969d3221e79341705b24bce0aad94f1d77f940c503382d394978c630169e9531f553e06e5e382d819c7b0b1e1f1280d922180a" + ], + "key_log": null, + "chains": [ + [ + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc96a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f9900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000104000ebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e915257cf0af27250e92899911ce8d7406737413b8fb3ea07fe018cb13a181b321f80f31d2d11ba7e5adea2bce42b1d928b373d5cf77bbf6ea3294dc9f096f0faaed436bd052adb36b7abb3ccdc808d12d1c23566b33ddd66e0c828ca228aa650c", + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc96a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f990100000000000000dee4d8f77b286f5d5938a0cfac6ddd652d7e080f3f14c96be89726590905651a0000000000000000001040005bf53717790addd310fcb1716ea58ff76cb693db9d9b09118167ddd0fc9098bfe55d4127973ec5ca3829b5af51616fef6c8dbbe07600e1b1cc5f7fbbcd9ffe32bdf328188dd7c6d5c3f1e1aff9d69e0354fecfd4ef30a89552e7c85e7d149d1a6c3e992702aec2bb5d9327169d00ba4ba8a5095c181cf775741e0c6a7b997b04" + ], + [ + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e900000000000000000000000000000000000000000000000000000000000000000000000000000000000e27070000000000020a0004150000003075000000aa98625a6b3df58a538960d1a551c6f4f317841226a577f62f86c0242ba4e5d31565d9c76b65ff9ca3e4c59c9a5a6b3d77c40979a011cfb2f93baddc9dfe5706", + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e90100000000000000e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa000e270700000000000126005741423101e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa01527597e53dd1c817047d50eef49343e4757ae9f75c18b0f81f53176abd2a2caa66f6ad365e0b6937b20f77080aff5cb52ebe439bca83f5c5b07d4657912c8308" + ] + ], + "policy": "57415031020cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b416023702000000000000000000ffff001f0068747470733a2f2f6164732e726f6f6d732e6578616d706c652f736761692f240068747470733a2f2f63646e2e726f6f6d732e6578616d706c652f6372656174697665732f010100000000090c1237e74d6f9c60a7d3c6420d55d3e1c4cc1e108d0f6ee34f14cea7bd9565dfa8002eff9a08c862749107f94672321f33909c7ec33f0840db72d78913700e", + "pt0_ms": 0, + "receipts": [ + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "session": "0df27375ac69306db3c554c1316c28fa", + "start_offset_ms": 0, + "format": 1, + "asset_list_sha256": "33ceedc30b2797d3c8a10423893aa82fb866c4bf7ccb1e1720b0217059cb0a20", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 2000000, + "pod_hash": "c4b76a31ac436d11b321bb17c1d6816363432133b14b17b0a9627ade69b0f67c", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000, + "quartiles_reached": 5 + }, + { + "slot": 1, + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000, + "quartiles_reached": 4 + } + ], + "merkle_root": "d4b4f7bef7f8ded970866d3f932b55029814b09cb900682574084b10fcd2acac", + "budget_spent_micros": 70000, + "work_decide_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b4160237", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 2 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "186a6ac3a7da34c53f3ffd3c8cff10fdb139ea0d767ad20442e8f160dd7812ff11893c5ed6a5c104469273685253bbe098eb38710a0537129a646c0b9b21020b" + }, + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "session": "0eb873fe6fd3d9ab69d21ec031ce36df", + "start_offset_ms": 0, + "format": 2, + "asset_list_sha256": "87f4ffb5f7346243d7cc01c7f8509da9bd0f5a0087cec3ef55afe3a416cac673", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 2000000, + "pod_hash": "c4b76a31ac436d11b321bb17c1d6816363432133b14b17b0a9627ade69b0f67c", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000, + "quartiles_reached": 5 + }, + { + "slot": 1, + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000, + "quartiles_reached": 2 + } + ], + "merkle_root": "45bd5bbdc3fd9e320ea185d335a7a3602792f29027ca2f41dc8bb74b4e22bd09", + "budget_spent_micros": 70000, + "work_decide_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b4160237", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 2 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "420b7cb16d8d40e7e8a21fb5b9da5f313609382419d768d94e52294772bee7a0fa25e536db09001ba9bd1c9735f8b9882e47817ad5d63dcd34bbf8b5503bd10b" + }, + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "session": "f723833b66cc049001d83ca85d3e9ea3", + "start_offset_ms": 0, + "format": 1, + "asset_list_sha256": "33ceedc30b2797d3c8a10423893aa82fb866c4bf7ccb1e1720b0217059cb0a20", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 2000000, + "pod_hash": "c4b76a31ac436d11b321bb17c1d6816363432133b14b17b0a9627ade69b0f67c", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000, + "quartiles_reached": 5 + }, + { + "slot": 1, + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000, + "quartiles_reached": 5 + } + ], + "merkle_root": "6e00260218f5bbc72a0f012686eb1ad6e101c05355dfb711e214b964966fc967", + "budget_spent_micros": 70000, + "work_decide_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b4160237", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 2 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "7230bc0528392b224f821acf501094703fe43e611dbc73ed9bd02572bbe3382dbdaf243ef53d6f9092a92b04606ebdf6e48431884ec7e1383be997957d19b009" + }, + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "session": "d67c9c9715765cd08d686b0b418f8f4f", + "start_offset_ms": 0, + "format": 1, + "asset_list_sha256": "33ceedc30b2797d3c8a10423893aa82fb866c4bf7ccb1e1720b0217059cb0a20", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 2000000, + "pod_hash": "c4b76a31ac436d11b321bb17c1d6816363432133b14b17b0a9627ade69b0f67c", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000, + "quartiles_reached": 5 + }, + { + "slot": 1, + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000, + "quartiles_reached": 5 + } + ], + "merkle_root": "6e00260218f5bbc72a0f012686eb1ad6e101c05355dfb711e214b964966fc967", + "budget_spent_micros": 70000, + "work_decide_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b4160237", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 2 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "227de1e0f703ca5d664882aae25c76a421064092b7de486ef31f9d9aa96b51cc2c38eda128c236d3e4c2825ef8eba3d401877ca8e23264e012fe3ba8ae9e7600" + }, + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "session": "28d08ac9bb09ab45c22ff9a59e54ad9d", + "start_offset_ms": 0, + "format": 1, + "asset_list_sha256": "33ceedc30b2797d3c8a10423893aa82fb866c4bf7ccb1e1720b0217059cb0a20", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 2000000, + "pod_hash": "c4b76a31ac436d11b321bb17c1d6816363432133b14b17b0a9627ade69b0f67c", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000, + "quartiles_reached": 5 + }, + { + "slot": 1, + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000, + "quartiles_reached": 5 + } + ], + "merkle_root": "6e00260218f5bbc72a0f012686eb1ad6e101c05355dfb711e214b964966fc967", + "budget_spent_micros": 70000, + "work_decide_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b4160237", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 2 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "84c23ab9b71403f515515b153baf16e79ed24b8b5a95ab5994e5a5d8751467dc6d939aa52ed68f2af90628150d0ad2a31e1e96081748ddd477265a52eaeb5d00" + } + ], + "pods": [ + [ + { + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000 + }, + { + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000 + }, + { + "ad_id": "ad-z", + "creative_hash": "720d603e084faee4914d5d63564d25c26d8255990f1d5dd533b0002db7f377cf", + "duration_ms": 10000, + "price_micros": 25000 + } + ], + [ + { + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000 + }, + { + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000 + }, + { + "ad_id": "ad-z", + "creative_hash": "720d603e084faee4914d5d63564d25c26d8255990f1d5dd533b0002db7f377cf", + "duration_ms": 10000, + "price_micros": 25000 + } + ], + [ + { + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000 + }, + { + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000 + }, + { + "ad_id": "ad-z", + "creative_hash": "720d603e084faee4914d5d63564d25c26d8255990f1d5dd533b0002db7f377cf", + "duration_ms": 10000, + "price_micros": 25000 + } + ], + [ + { + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000 + }, + { + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000 + }, + { + "ad_id": "ad-z", + "creative_hash": "720d603e084faee4914d5d63564d25c26d8255990f1d5dd533b0002db7f377cf", + "duration_ms": 10000, + "price_micros": 25000 + } + ], + [ + { + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000 + }, + { + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000 + }, + { + "ad_id": "ad-z", + "creative_hash": "720d603e084faee4914d5d63564d25c26d8255990f1d5dd533b0002db7f377cf", + "duration_ms": 10000, + "price_micros": 25000 + } + ] + ], + "claims": [ + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa0df27375ac69306db3c554c1316c28fa00a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5df67d521ed37b174448cb376c7b390acf158c00ac41211e282a79db9bbcb4021e01ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a00001027010000793a60ef660c17af1c900c9cbb3ea971e9c0270d076fad8fcdf5c35b0182e33330bfb9845c6a2fb3143429dcd7cdb4ff4b34c027f1d77e8ac3e74fa2b821040a", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa0df27375ac69306db3c554c1316c28fa019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3f67d521ed37b174448cb376c7b390acf158c00ac41211e282a79db9bbcb4021e01ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd12945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298030000000000a00f0000b80b01a00f0000b80b0000701701581b000088130000d007010000d650d007cac82000d6da177bbbcda7598fa4bf8fab89f143f644a15d5601652416f30eb2ae16c2c03572177fa488e3a6b518a051b06d01669ee5731da4e53a08", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa0eb873fe6fd3d9ab69d21ec031ce36df00a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d76e6b877adff9a10d2cca759cf2949071cd7f3ac92b614d8b1a46877da56b23a01ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a0000a00f010000ba67773647ff55a933eed9a79918f65e20c04760525eb54261eae1f5e0c8bfeafd088de19183ffd4debbbaeba9dc91ac4e7c9e0f6b2c1dc21433abadb86e1c00", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa0eb873fe6fd3d9ab69d21ec031ce36df019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee376e6b877adff9a10d2cca759cf2949071cd7f3ac92b614d8b1a46877da56b23a01ffffffff010013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd1030000000000dc050000102701dc050000dc050000102703b80b0000e8030000e8030100000baa8b8e5b1435f3a5a5260e9aa16a330773ddd40b7b4a187f466422ad80ea06c0728951152a80a1732654794c5ad823c4095038411a2435b5f2c1e260dfb501", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aaf723833b66cc049001d83ca85d3e9ea300a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d2ff09f78cd3c4f9506aecd1cdb170cf0dbc635d7a97e0ea5c9cba1625b15b27b01ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a000010270100008123bcd4256cd79cd084154b30839a4a9b0430a5e783628dc8dc58ab8b77eb41e708c8326f366ffbeddb2a2aecb51e2318dfc891df0cba299d5599ec74a93a0e", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aaf723833b66cc049001d83ca85d3e9ea300a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d2ff09f78cd3c4f9506aecd1cdb170cf0dbc635d7a97e0ea5c9cba1625b15b27b01ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a000010270100008123bcd4256cd79cd084154b30839a4a9b0430a5e783628dc8dc58ab8b77eb41e708c8326f366ffbeddb2a2aecb51e2318dfc891df0cba299d5599ec74a93a0e", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aaf723833b66cc049001d83ca85d3e9ea3019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee34ba5127cfca111cbe3237afa2d83d2aadb55b475b23e1743bff9cc102c636e9501ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd12945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298010000000000983a00001027010000f1c3f0b23ecf62bfca90641c49e6c2c2529635bdee07dd8cb12a1cf17e794f3712452b97375b8ad5757bac1ce89ccc59ecbb3a2ff7a970138662430a12d80207", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aaf723833b66cc049001d83ca85d3e9ea3019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee32ff09f78cd3c4f9506aecd1cdb170cf0dbc635d7a97e0ea5c9cba1625b15b27b01ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd1771808a22abdf38cc8944c4019f15c93913e7b595c585a607e21fcd830f4dc7dc827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298010000000000983a00001027010000f942c30a21aff85cde278f9ac8b6b7e9abb56bf24ebf854d9306dd025150b3acbda582fe002eedfaeb359ae1324491b744125138776ec694359df7e204130709", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aad67c9c9715765cd08d686b0b418f8f4f00a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d40caacc1e30b23e3628917a50e7d71172caf9974aa98539b416b9772b286d32801ffffffff0200a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f70010000000000401f0000102701000019c57219b1cd94a8232de8f4b0765a45b227ed0f61c4b18b6527f77787a25895c70ca40e5a822ff1ee7ceae9dfc8c07a7a3dbb03c68fe407153beca0f5846d06", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aad67c9c9715765cd08d686b0b418f8f4f019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee340caacc1e30b23e3628917a50e7d71172caf9974aa98539b416b9772b286d32801ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd12945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298010000000000983a000010270100005d0350644ab1f45e307b858bc0d6172debc31a355ef298e44c846f4a52020d88372fd867edbaba1c0710d1d6c28def9dbe96a134b5dd3ef1dcdfca7c7d499308", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aad60faab986417f8eb4c7665128a7a0c800a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d134ff5aa797eda39e08db347ecbb913720755d5d9fdfa7192b4333651ff3b8bb01ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a00001027010000743333bd8698d5f0c53ff121e87b4f6a31fb206ef46658e08c40759ac35270fdda66cd8d5e61b516af47f1e83de829b7f425863d1fb931b16e6f459e5c57f405", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aad60faab986417f8eb4c7665128a7a0c800a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d134ff5aa797eda39e08db347ecbb913720755d5d9fdfa7192b4333651ff3b8bb01ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a00001027010000743333bd8698d5f0c53ff121e87b4f6a31fb206ef46658e08c40759ac35270fdda66cd8d5e61b516af47f1e83de829b7f425863d1fb931b16e6f459e5c57f4", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa28d08ac9bb09ab45c22ff9a59e54ad9d00a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d7ca33fadaa0654f9da41d85a263d6e589b0c38b8ee6cff5d3196433d3c51a4e702ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a00001027010000304f9e03a20ee09e687d0486f36e42a4c87a23fb194d7e5d682c50a3054d9a3b631ba7229531f629e1077d1b4f02597daca352d2d12aabaf4a87db942d3e440a", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa28d08ac9bb09ab45c22ff9a59e54ad9d019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee37ca33fadaa0654f9da41d85a263d6e589b0c38b8ee6cff5d3196433d3c51a4e703c2010000030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd12945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298010000000000983a000070170100004ea3818f20da18ec8aa90cea98cc797b90496d53b70bf80f1278887b4bf0590c02e74a89638bc82addc123e3bfebfcecf508e9492e44a1ce623875769579fc0e" + ], + "claim_about": [ + "viewer 1, ad-x: in view and audible from start to end", + "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", + "viewer 2, ad-x: 40% of the pixels in view throughout, below the video rule's half", + "viewer 2, ad-y: two stretches of 1.5 s in view with a pause between them, which is not 2 continuous seconds", + "viewer 3, ad-x: in view and audible from start to end", + "the same claim given a second time", + "a claim for viewer 3's ad-y signed by viewer 4's session key, from which viewer 3's session is not derived", + "viewer 3, ad-y: a presented object's hash is not the creative's second object", + "viewer 5, ad-x: 8 s of 15 presented, the midpoint, where the receipt states it complete", + "viewer 5, ad-y: one bit of the signature flipped, so the impression has no claim", + "viewer 6, whose session has no receipt for the break", + "a claim short of its last byte", + "viewer 7, ad-x: geometry stated as a full-screen television output, which the video rule does not admit", + "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" + ], + "outcome": "accept", + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "breaks": [ + { + "digest": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "id": "wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "event_id": 2187487373789201, + "author": "ebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e9", + "cue_id": 21, + "t": 120000000, + "type": "break", + "placement": "mid", + "planned_ms": 30000, + "avail_ms": 30000, + "duration_ms": 30000, + "closed_by": null, + "end": 150000000, + "hls_list_uri": "https://ads.rooms.example/sgai/wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa.json?_HLS_interstitial_id=wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "dash_list_uri": "https://ads.rooms.example/sgai/wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa.mpd" + } + ], + "chapters": [], + "hls": "video_rule.m3u8", + "dash": "", + "dash_open": [], + "receipt_facts": [ + { + "identity": "a685926b46841d22878e0c8f953c53002c539f2525a803d92631e114a709402c", + "asset_list": "{\"ASSETS\":[{\"URI\":\"https://cdn.rooms.example/creatives/a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d.m3u8\",\"DURATION\":15.000},{\"URI\":\"https://cdn.rooms.example/creatives/9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3.m3u8\",\"DURATION\":15.000}]}", + "spend_micros": 70000, + "presented_spend_micros": 70000, + "impressions": [ + { + "slot": 0, + "claim": 0, + "verdict": "presented" + }, + { + "slot": 1, + "claim": 1, + "verdict": "presented" + } + ] + }, + { + "identity": "1e4cbd4e8e8c222e666d430941885cc9b5f7f6f4328b4df38b582c18a71d905b", + "asset_list": "\nhttps://cdn.rooms.example/creatives/a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d.mpdhttps://cdn.rooms.example/creatives/9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3.mpd", + "spend_micros": 70000, + "presented_spend_micros": 0, + "impressions": [ + { + "slot": 0, + "claim": 2, + "verdict": "claim_not_viewable" + }, + { + "slot": 1, + "claim": 3, + "verdict": "claim_not_viewable" + } + ] + }, + { + "identity": "3e6703d3a0d334f8c5a8d3baf2ce176d78cd812a3e2b7fdd3c04142e7eb35695", + "asset_list": "{\"ASSETS\":[{\"URI\":\"https://cdn.rooms.example/creatives/a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d.m3u8\",\"DURATION\":15.000},{\"URI\":\"https://cdn.rooms.example/creatives/9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3.m3u8\",\"DURATION\":15.000}]}", + "spend_micros": 70000, + "presented_spend_micros": 40000, + "impressions": [ + { + "slot": 0, + "claim": 4, + "verdict": "presented" + }, + { + "slot": 1, + "claim": 7, + "verdict": "claim_asset_mismatch" + } + ] + }, + { + "identity": "8c044b98f9a891c0840265ac35f526f6b507adb64cd3a7929ed0f10cb9a0fe9b", + "asset_list": "{\"ASSETS\":[{\"URI\":\"https://cdn.rooms.example/creatives/a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d.m3u8\",\"DURATION\":15.000},{\"URI\":\"https://cdn.rooms.example/creatives/9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3.m3u8\",\"DURATION\":15.000}]}", + "spend_micros": 70000, + "presented_spend_micros": 0, + "impressions": [ + { + "slot": 0, + "claim": 8, + "verdict": "claim_quartile_mismatch" + }, + { + "slot": 1, + "claim": null, + "verdict": "claim_absent" + } + ] + }, + { + "identity": "811279c01d8d2c9deb6c0d3a2f5b14143a0fdad50951824ef97378fc810ee037", + "asset_list": "{\"ASSETS\":[{\"URI\":\"https://cdn.rooms.example/creatives/a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d.m3u8\",\"DURATION\":15.000},{\"URI\":\"https://cdn.rooms.example/creatives/9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3.m3u8\",\"DURATION\":15.000}]}", + "spend_micros": 70000, + "presented_spend_micros": 30000, + "impressions": [ + { + "slot": 0, + "claim": 12, + "verdict": "claim_not_viewable" + }, + { + "slot": 1, + "claim": 13, + "verdict": "presented" + } + ] + } + ], + "spend_micros": 140000, + "claim_facts": [ + { + "digest": "46be24fe2cf22bfa77910f9cfaad9d92dab6a4b1bec88f1cd1cf621a5701768c", + "verdict": "presented", + "receipt": 0, + "slot": 0, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + }, + { + "digest": "5d5e9b8c474daaa3e841a2ab0cecbdb0375ac3fb1231c9818a41a5a9f9a31eaf", + "verdict": "presented", + "receipt": 0, + "slot": 1, + "quartile": 4, + "run_ms": 3000, + "viewable_ms": 3000 + }, + { + "digest": "a2de513194881a4ee11377aa6c1701b52b5fbb82cdff49d725b39a1303ceecfe", + "verdict": "claim_not_viewable", + "receipt": 1, + "slot": 0, + "quartile": 5, + "run_ms": 0, + "viewable_ms": 0 + }, + { + "digest": "d3920e8dddffca8f01598c5465444a2fcb2e86333bb87493131173ede53ab829", + "verdict": "claim_not_viewable", + "receipt": 1, + "slot": 1, + "quartile": 2, + "run_ms": 1500, + "viewable_ms": 3000 + }, + { + "digest": "5981f226e204dd8f9ec4bb4ba53d39cf2026a881b3a92bd7e8dcccf09aa65fef", + "verdict": "presented", + "receipt": 2, + "slot": 0, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + }, + { + "digest": "5981f226e204dd8f9ec4bb4ba53d39cf2026a881b3a92bd7e8dcccf09aa65fef", + "verdict": "claim_duplicate", + "receipt": 2, + "slot": 0, + "quartile": null, + "run_ms": null, + "viewable_ms": null + }, + { + "digest": "6cfd5648a26dc864b170a3455c6df4a1c77b45f61b4a0c23c936e14ab43d680e", + "verdict": "claim_key_unbound", + "receipt": 2, + "slot": 1, + "quartile": null, + "run_ms": null, + "viewable_ms": null + }, + { + "digest": "e29408132068a989d84fa5e4404d2393715e2e1074613ebc77acd35ba7276bff", + "verdict": "claim_asset_mismatch", + "receipt": 2, + "slot": 1, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + }, + { + "digest": "82e66a104cae4d5c7153254bee825008f7b5383fc83e180fea57e689cca1ad09", + "verdict": "claim_quartile_mismatch", + "receipt": 3, + "slot": 0, + "quartile": 3, + "run_ms": 8000, + "viewable_ms": 8000 + }, + { + "digest": "84610c7818d7907e71ad803287875884826af7f2cded13645d91dc503fb304b1", + "verdict": "bad_signature", + "receipt": null, + "slot": null, + "quartile": null, + "run_ms": null, + "viewable_ms": null + }, + { + "digest": "04ecdb6b231ba15abef1ca3085aacecff064f4fb6af7f6ca82e18ce18118230d", + "verdict": "claim_no_decision", + "receipt": null, + "slot": null, + "quartile": null, + "run_ms": null, + "viewable_ms": null + }, + { + "digest": null, + "verdict": "claim_malformed", + "receipt": null, + "slot": null, + "quartile": null, + "run_ms": null, + "viewable_ms": null + }, + { + "digest": "bbc10c5007ad65dd899f7548a8b61f1ef3208c2c57195e3e62977880f134e0ff", + "verdict": "claim_not_viewable", + "receipt": 4, + "slot": 0, + "quartile": 5, + "run_ms": 0, + "viewable_ms": 0 + }, + { + "digest": "e20fbdd624230fb1f69e7adc59fb22ea90dc5eaa1770339bad2b9f19625bc356", + "verdict": "presented", + "receipt": 4, + "slot": 1, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + } + ] + }, + { + "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", + "descriptor": "575244310198925abbec382eaa9716af19533e2da733f16f0c5e4211da533f86235b901ed496a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f9900406cd8e95d060040420f00000000000000100000000400000000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000030870726f6475636572000101000e000000000000000000000000000000000000000000000000000000000000000861756469656e636500010100080000000000000000000000000000000000000000000000000000000000000007646563696465720000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004b446b681c3acb0a952b495f6413b201a39ea15ac40bc16ef9f1fd0f5652046b64f3a198f43f5ab31d47561523c8703db71b7cb34fcc000493a220abc407180d", + "grants": [ + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e900000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a00000000000001010000000000000000000000000000000000000000000000000000000000000000000057455031010e000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff0000000000000043ef80f50c308a0dbbbe257a608bd4cb2621e48a3a643601e4ca941c848f3d7f1efc39cd7684c498110da78b3e77d18ae45d26047bd89743b835c54583e7a100", + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc5bf53717790addd310fcb1716ea58ff76cb693db9d9b09118167ddd0fc9098bf00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000100010100000000000000000000000000000000000000000000000000000000000000000000574550310108000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff000000000000002dbe370ef573d370c9094a346a461cbf226953b1da26196a856ebbf65858c5c4e4381fbb4fd6a2f4fe63d881f388453f6f7707b204eabfbebfaef77bf906d605", + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcdd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f468257500000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200000000000000000000000000000000000000000000000000000000000000000000000000574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffff020000000000000045555200000000633a9c6151bdd3a6731598ff60969d3221e79341705b24bce0aad94f1d77f940c503382d394978c630169e9531f553e06e5e382d819c7b0b1e1f1280d922180a" + ], + "key_log": null, + "chains": [ + [ + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc96a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f9900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000104000ebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e915257cf0af27250e92899911ce8d7406737413b8fb3ea07fe018cb13a181b321f80f31d2d11ba7e5adea2bce42b1d928b373d5cf77bbf6ea3294dc9f096f0faaed436bd052adb36b7abb3ccdc808d12d1c23566b33ddd66e0c828ca228aa650c", + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc96a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f990100000000000000dee4d8f77b286f5d5938a0cfac6ddd652d7e080f3f14c96be89726590905651a0000000000000000001040005bf53717790addd310fcb1716ea58ff76cb693db9d9b09118167ddd0fc9098bfe55d4127973ec5ca3829b5af51616fef6c8dbbe07600e1b1cc5f7fbbcd9ffe32bdf328188dd7c6d5c3f1e1aff9d69e0354fecfd4ef30a89552e7c85e7d149d1a6c3e992702aec2bb5d9327169d00ba4ba8a5095c181cf775741e0c6a7b997b04" + ], + [ + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e900000000000000000000000000000000000000000000000000000000000000000000000000000000008793030000000000020a00011f0000000000000000a694d1169f48193eb2126186ee040338de412ae8c2c577eb6174c6a000b4951c1b428d58c73cc83d8e8f6135fa2e70398b37d67a871b57138e8141dd4ba70806", + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e901000000000000000291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce804a5d050000000000020a00021f0000003075000000cbc47be35476aee648c9ce1743929bd2608bf88343af0b062ad2cf47e9127921648d08bf0f87961d7789e9e5bda3379e45f3a051e7dc95f0ef6e0172059dc30f" + ] + ], + "policy": "57415031020cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b41602370230750000a00f000000ffff001f0068747470733a2f2f6164732e726f6f6d732e6578616d706c652f736761692f240068747470733a2f2f63646e2e726f6f6d732e6578616d706c652f6372656174697665732f070202000e006973737565722e6578616d706c65c8b99d54fe204aedd3eedaef6835a24ebae712438dc8df7811ed1dc42b957ecfbbc242bbc10a98082c417ca3d0ea1730ebe10ff1a652b26791a02ffb578ed203", + "pt0_ms": 0, + "receipts": [ + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce", + "session": "6ff333ffd1289ccf861bee6464024808", + "start_offset_ms": 0, + "format": 1, + "asset_list_sha256": "33ceedc30b2797d3c8a10423893aa82fb866c4bf7ccb1e1720b0217059cb0a20", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 2000000, + "pod_hash": "c4b76a31ac436d11b321bb17c1d6816363432133b14b17b0a9627ade69b0f67c", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000, + "quartiles_reached": 5 + }, + { + "slot": 1, + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000, + "quartiles_reached": 5 + } + ], + "merkle_root": "6e00260218f5bbc72a0f012686eb1ad6e101c05355dfb711e214b964966fc967", + "budget_spent_micros": 70000, + "work_decide_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b4160237", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 2 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "0f214a79ccd67251d2bf313cfc1da6ec503df4024827c8841bd3ef21aa9288624ba18c5dee247567be099f2caf4fef4c5aaa30160a681518405d596731cb6706" + }, + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce", + "session": "8ef3b5596bc4e1aacfb9cb03c647e3b7", + "start_offset_ms": 0, + "format": 1, + "asset_list_sha256": "33ceedc30b2797d3c8a10423893aa82fb866c4bf7ccb1e1720b0217059cb0a20", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 2000000, + "pod_hash": "c4b76a31ac436d11b321bb17c1d6816363432133b14b17b0a9627ade69b0f67c", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000, + "quartiles_reached": 5 + }, + { + "slot": 1, + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000, + "quartiles_reached": 5 + } + ], + "merkle_root": "6e00260218f5bbc72a0f012686eb1ad6e101c05355dfb711e214b964966fc967", + "budget_spent_micros": 70000, + "work_decide_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b4160237", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 2 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "7383a88123c88d0c6ef236fc0a0cd075dc77990b72b9529eb9b1ffd3a52344e319da3c94d4bfb03cd0e4f049035f46f0afaf51060c9d8c00d67bc0f60a24c107" + }, + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce", + "session": "20799a653f68e092f189ebce351eced9", + "start_offset_ms": 0, + "format": 2, + "asset_list_sha256": "87f4ffb5f7346243d7cc01c7f8509da9bd0f5a0087cec3ef55afe3a416cac673", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 2000000, + "pod_hash": "c4b76a31ac436d11b321bb17c1d6816363432133b14b17b0a9627ade69b0f67c", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000, + "quartiles_reached": 5 + }, + { + "slot": 1, + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000, + "quartiles_reached": 5 + } + ], + "merkle_root": "6e00260218f5bbc72a0f012686eb1ad6e101c05355dfb711e214b964966fc967", + "budget_spent_micros": 70000, + "work_decide_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b4160237", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 2 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "59372839706d38ce5e10a46a1ba8cec3a3ba25c9cc92224a84a19e9a83db35729598c0dee1808afdffdab1eba02e403d5353581f472d196dca7515af01127905" + }, + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce", + "session": "52435e2e0fac47ae4e39263ed9d04ad5", + "start_offset_ms": 0, + "format": 2, + "asset_list_sha256": "87f4ffb5f7346243d7cc01c7f8509da9bd0f5a0087cec3ef55afe3a416cac673", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 2000000, + "pod_hash": "c4b76a31ac436d11b321bb17c1d6816363432133b14b17b0a9627ade69b0f67c", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000, + "quartiles_reached": 5 + }, + { + "slot": 1, + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000, + "quartiles_reached": 5 + } + ], + "merkle_root": "6e00260218f5bbc72a0f012686eb1ad6e101c05355dfb711e214b964966fc967", + "budget_spent_micros": 70000, + "work_decide_ops": 3, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b4160237", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 2 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "a19a20b3b6aa416546f4df435cb7fafc95259517fbc06d4fc28880d277c0ccfe7bfff0c3a8162064b61722f5d739533cb01c1de87631c451c8254c7facfedb0f" + } + ], + "pods": [ + [ + { + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000 + }, + { + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000 + }, + { + "ad_id": "ad-z", + "creative_hash": "720d603e084faee4914d5d63564d25c26d8255990f1d5dd533b0002db7f377cf", + "duration_ms": 10000, + "price_micros": 25000 + } + ], + [ + { + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000 + }, + { + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000 + }, + { + "ad_id": "ad-z", + "creative_hash": "720d603e084faee4914d5d63564d25c26d8255990f1d5dd533b0002db7f377cf", + "duration_ms": 10000, + "price_micros": 25000 + } + ], + [ + { + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000 + }, + { + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000 + }, + { + "ad_id": "ad-z", + "creative_hash": "720d603e084faee4914d5d63564d25c26d8255990f1d5dd533b0002db7f377cf", + "duration_ms": 10000, + "price_micros": 25000 + } + ], + [ + { + "ad_id": "ad-x", + "creative_hash": "a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d", + "duration_ms": 15000, + "price_micros": 40000 + }, + { + "ad_id": "ad-y", + "creative_hash": "9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3", + "duration_ms": 15000, + "price_micros": 30000 + }, + { + "ad_id": "ad-z", + "creative_hash": "720d603e084faee4914d5d63564d25c26d8255990f1d5dd533b0002db7f377cf", + "duration_ms": 10000, + "price_micros": 25000 + } + ] + ], + "claims": [ + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce6ff333ffd1289ccf861bee646402480800a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5da94f0330d101f3409e16910015185aea42688772aea51686f49eaf8ec9b083fe02ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a000010270162010002bbfe4714f5175ccc0f69b46e4fedf3eb067c48e7af2bd90894f508a4bdae8cad676201f3e0e5bdf89010f820ce06bbb1c68b7205074da32a847133d257c6cd1bca572f8982a9ca248a3056186322d93ca147266121ddeb5632c07f1f71cd27084c3d6e8b8773f201c0ec075dc94ab7608d73ccb614c80ba85fdcdcaf6e612097dc8c85df425fdeb1b15ea1ccc2ae3253f273069d5cb8b35b749c61d8af92f118212f1c58a2d74483e2c916e94910641ddfb72c25baed64fe0d69e9ce4af1cfe40623024b41de126e1847ae18c7597fcf93f3274a91fb666dd71db689c37c2c328cd1c4f720e5070642d7691c4dbc7cced471653fb06b9a4b5fb38a45232773b021334936612a33f6bcbad124dc90927f84fc18f7eb52476f47c1328f4f876d2ed595d139fe9ba4dec225e8366aae7791b74a4629fd3b2a27d541c643991c56c1116bd0897fc28fc27ebf4f8c30a7f2283ca6cc92eee2cc2960242e09458bf8f0d384401bfee97d88e033a8bc539324f98ada785c753c4014ca500b27016d8a13189fb821ef8725cb7de6ad00454c6c510466fcea5ace21496de9befe5d80dc0c", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce6ff333ffd1289ccf861bee6464024808019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3a94f0330d101f3409e16910015185aea42688772aea51686f49eaf8ec9b083fe02ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd12945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298010000000000983a000010270100001ee481b34d2e5d8eec1ce9e0f99adfb76ba874fa1b250a36fb37a0e8cf7b35819d1bca1034ea9d044ca181067165f14f8542a26942b7fd3d5277b50e651cf501", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce8ef3b5596bc4e1aacfb9cb03c647e3b700a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5df83d8f4e1ba36e525ddef2b2c96d9dc1e459aaaddf00584b39eed823aa42757302ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a000010270162010002bbfe4714f5175ccc0f69b46e4fedf3eb067c48e7af2bd90894f508a4bdae8cad676201f3e0e5bdf89010f820ce06bbb1c68b7205074da32a847133d257c6cd1bca572f8982a9ca248a3056186322d93ca147266121ddeb5632c07f1f71cd27084c3d6e8b8773f201c0ec075dc94ab7608d73ccb614c80ba85fdcdcaf6e612097dc8c85df425fdeb1b15ea1ccc2ae3253f273069d5cb8b35b749c61d8af92f118212f1c58a2d74483e2c916e94910641ddfb72c25baed64fe0d69e9ce4af1cfe40623024b41de126e1847ae18c7597fcf93f3274a91fb666dd71db689c37c2c328cd1c4f720e5070642d7691c4dbc7cced471653fb06b9a4b5fb38a45232773b021334936612a33f6bcbad124dc90927f84fc18f7eb52476f47c1328f4f876d2ed595d139fe9ba4dec225e8366aae7791b74a4629fd3b2a27d541c643991c56c1116bd0897fc28fc27ebf4f8c30a7f2283ca6cc92eee2cc2960242e09458bf8f0194932f9fc63e91770d3c7c1b0f2a76eaefa2c8114d25f6ba15780f08038f8273b72ec886bfdbe8355392f2664c3791cd78775c67e47bc64f85d8b967e1ac308", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce8ef3b5596bc4e1aacfb9cb03c647e3b7019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3f83d8f4e1ba36e525ddef2b2c96d9dc1e459aaaddf00584b39eed823aa42757302ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd12945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298010000000000983a0000102701620100021f5775fc6642781052fbefc31b2dddb06d93e06d89fe2e7269698ee88e394adf810f2b75b8f3bf8ff9d137707a3bcb7adf0634664a829b5acec23f0caec8c0c6ca572f8982a9ca248a3056186322d93ca147266121ddeb5632c07f1f71cd270820cc8fbfc9896ab9024cd73ba9b6af16e3ee9c78064879e7bb1a82c2006e64803743af50924768319a86ec93a063f9081ce1e7efe41637468ad7f81f443f59922af9e9a634b0f10471a68c16d161d3befdaa023ea8b73891c6ba4dbc2de8e687a4b52cb228c1484f034ab3129e175a953451ccc50d9a5e2104b6f686b54fb99128d1f0987dcd58346bc2318f110339848fdbf9ea9a23e9850ca9d093116035dd4701ef7d266ea534db9caffa2d77afaab9ebe458889591763c7593174009d9cc7a1de18fa9adb2e5dfa0c49450e91c183a08e8ef1d529d3eb482bd4cda520d85855a358c4629010930b7118e6d2914030cad85a9eebacf032cf875ae71501d99edc8b18e39d7f3a3218d1700c30284d39b9ea8941f7442b0483c7112159a84ed5245750b5e6bd5b44f4fe9258f69135b28d24f4ca2ad6d564d95d9f53c8de007", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce20799a653f68e092f189ebce351eced900a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5dabce2969973591259d241b2872143bd72b690bba6e3e3802635477ff75dc799f01ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a000010270062010002e06c083134c7fde7e93ad08448d71f9b95e65057dfd827f4b7263403a1ad2dd3909c639befc3e1344af4e33a2372a5c7c18a8b14505ce555a4534eaa3f5e5df5ca572f8982a9ca248a3056186322d93ca147266121ddeb5632c07f1f71cd2708825c1986b4e177cc7795790b0599f57ada3d8876b8f48626883f086a00f8a57447fb9ecafa77357ea54dacb75ce7268cb79f3a213cbc2ebf259fbddf6a9e9f2b9ff871486736b9b9705c6410256f6451f1b7b953922b297bf5fa6e3d2c6243843bd2101fec6ee940b4e69745bf0a8d62cc2af1f72474f67a5ba209e47342f2880d12b2c4621d2bd234b14f199b220d20af1e474f393e1ce5b0c07019c9c0c6d94e3f47cc4a2b0740da1cef8c1f02ce9975295b9f2b4cb4c7391cf48736ce0117b8431d5d21b3780ed1873773d54e9b965f50a6cebce9ba538c2b6231e701ee64f03628f7d7db5503ec541796d98d09388017ace77410498e234609275db585350c49ac62e99cb8e0be7b0f26df0338859c88b5ce3b1a8d959d64c0d56b5cec1f60f39ba75fdccde0ef85116a6048de952e4fb82f57d4b342c7fcff0d6b97a80c", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce20799a653f68e092f189ebce351eced9019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3abce2969973591259d241b2872143bd72b690bba6e3e3802635477ff75dc799f01ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd12945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298010000000000983a0000282301620100029124f37a0e508ef45fbd2a5a316f7cc03425556e75046477055bd3873a62891358a487adebf2f6b32f8d9e95788a8122ab6334cce2fcc825e51c414cbe03a932ca572f8982a9ca248a3056186322d93ca147266121ddeb5632c07f1f71cd27086bfc00d1321a840810c868a0ee68d9247aa85174c4c43312c0d3958516ec11d9b290463ec52603a8f207932d68678963213baa4e1d7051744349c0b81e94fc66aa4a09cdcb063fd9425f097b1ac4d05f7e40a03274535a57b77af130d954dabcf4a9cef724170de9954656dded9403386c0135a380e72c6e4864bf3a3d170fa758ab2e8ada107a1b82d68234057ef3a44ab7f52553a6073c136270c981b105ef5c37ea76fdf2a1cdd2a496b6454e520c90a627cef7619674903da8ec7587f1bbceeab03d783d15c1fd57356f1547c5bb74cb7c2d7f6cee3762ec846ebb4bfe55bbe65524d1141e2ff2869a787f812263fd3921e4739e5a49c6170c34319e86642a5fef11221e28f360bcf1cc999c4ab660c21286e9afa44f654136466226bc98b387ef6c402075263fbf86647d29e6e7ed896a0fdbf53126c14ca3d228610e0f", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce52435e2e0fac47ae4e39263ed9d04ad500a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d51d452336d3b70ed5e8c98b595c58acc0f5fe9ed19994567175570a9bde26bff02ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a0000102701620100021ac59e3cec41c4a33cf940ba438dfda3e32d1108dfb62460913ea0eb04a4515c9a4096342758ef5730e14a69176fbddc0ec43e431bd70610f87eb020500997abca572f8982a9ca248a3056186322d93ca147266121ddeb5632c07f1f71cd27083b131161ca55bdefc8c2b0c9d85cd7b5727631a97d331c1eb52758a6aab9f6b00ab8f0b260def207b93f0c752d89a11d80181e3a994ece1b4ed26d5ea9fe242b1df42654968c56147fad90708357af9c18880a6c83aa1e312f6b13330c61920d6f19ced7a275be7712925990871355148ac92176bfd5a63effb029ab62b733170ba263b55b3e1a3de41f6aab7d41ebebcd114513c51dd07c51481a7cd1ce9c5e1a9265535aee7ba475744ede00f5d1d4aef31e44c4346aa1a86adf47ad49d5050fd7f7bf37a78265506a291efc9c40d5f930818a42dd5457be6a12124d70129f15050a641a01736e57d2b5a657f53e5b4cd6081b4066a7e5ebb9eabd9d90893f362f2ee08e7f9c83352064c2c49b7c57f928b1d46997c70e695cd7925106edbd0e635548c497fed0c95a8a99112d4674deb8cf42a1e44f3166112fc045cbf807", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce52435e2e0fac47ae4e39263ed9d04ad5019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee351d452336d3b70ed5e8c98b595c58acc0f5fe9ed19994567175570a9bde26bff02ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd12945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298010000000000983a0000102701620100025a03e58e5e55ac54de49f5978573fd281b127ccd02728167914870b692d546109d8039872ba2e9ba2f94bb91bf6f80db61072c61f01efc57f31ddcf5ab2e4dabca572f8982a9ca248a3056186322d93ca147266121ddeb5632c07f1f71cd2708aeaa9b4e6ab700c8021df66fd06b6f92441a7d21e8fa000c9f7e040e090b144116a4adc04afffaada09573bebb6983023fef8490f30a8ef0be9b4b52e0b87725c2e445a273368c2c2b3e32e3e991f67aa19e362be929291a8cf63a6ffb2a264e0df98065d3b82a18b2eab0a232a405f2c29d8e7bcf05789b784b02edb8fad9b8fbe413b0a72ad635cc5918e0dfc4e423b618b30ccbdc279b2fb66711a73dcb21580710f1eafa7eec1719092522d29b86176ca1e0c0f36c6115ca46681e90af77f648101c592536b78e1745989376d4dc8680278ed327225d6be2b78be4669a077c73f16137df4905411a0d8aa4ee2c51cc0148052d66cf1064c1cbfaedbe9fc4109ca313724c388ea9648ff4fd61101875c4ce4802029c93619d6b953a103c07270ebd3faa7aeb3aeb3696412592c94df647e82d5ecb297f093106799a4a5e02" + ], + "claim_about": [ + "viewer 11, ad-x, on a television: full screen, audible throughout, with a device attestation bound to this impression", + "viewer 11, ad-y: the same, with no attestation", + "viewer 12, ad-x: carrying viewer 11's token, whose challenge is another impression's", + "viewer 12, ad-y: a token for its own challenge with one bit of its authenticator flipped", + "viewer 13, ad-x: in view throughout and muted, where the policy requires the run to be audible", + "viewer 13, ad-y: 90% of the pixels measured in view, where the cross-media rule requires all of them", + "viewer 14, ad-x: full screen, audible, attested", + "viewer 14, ad-y: full screen, audible, attested" + ], + "outcome": "accept", + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "breaks": [ + { + "digest": "0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce", + "id": "wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce", + "event_id": 5054131591202562, + "author": "ebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e9", + "cue_id": 31, + "t": 60000000, + "type": "out", + "placement": "mid", + "planned_ms": 0, + "avail_ms": 30000, + "duration_ms": 30000, + "closed_by": "9887cdb4cd681565d7a578dbc04a5f84e103034c37bf7d45299c73d2904b9678", + "end": 90000000, + "hls_list_uri": "https://ads.rooms.example/sgai/wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce.json?_HLS_interstitial_id=wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce", + "dash_list_uri": "https://ads.rooms.example/sgai/wai-ad-0291f4b4b624470224cf7f68cdaf78881f8aaf45f90446c460892b1b36016fce.mpd" + } + ], + "chapters": [], + "hls": "cross_media_attested.m3u8", + "dash": "", + "dash_open": [ + { + "break": 0, + "event": "" + } + ], + "receipt_facts": [ + { + "identity": "e16a6107c3005f70f202d3058781a96194646a5272a799c7a913feac13894f86", + "asset_list": "{\"ASSETS\":[{\"URI\":\"https://cdn.rooms.example/creatives/a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d.m3u8\",\"DURATION\":15.000},{\"URI\":\"https://cdn.rooms.example/creatives/9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3.m3u8\",\"DURATION\":15.000}]}", + "spend_micros": 70000, + "presented_spend_micros": 40000, + "impressions": [ + { + "slot": 0, + "claim": 0, + "verdict": "presented" + }, + { + "slot": 1, + "claim": 1, + "verdict": "attestation_absent" + } + ] + }, + { + "identity": "92aa7f3c9f45cb53ac8d2dbbeb0c62c3346af6649530eb627d86f5cdda82f5e6", + "asset_list": "{\"ASSETS\":[{\"URI\":\"https://cdn.rooms.example/creatives/a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d.m3u8\",\"DURATION\":15.000},{\"URI\":\"https://cdn.rooms.example/creatives/9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3.m3u8\",\"DURATION\":15.000}]}", + "spend_micros": 70000, + "presented_spend_micros": 0, + "impressions": [ + { + "slot": 0, + "claim": 2, + "verdict": "attestation_invalid" + }, + { + "slot": 1, + "claim": 3, + "verdict": "attestation_invalid" + } + ] + }, + { + "identity": "479f725623250604c0c8024616befefc17ec5d96064aa5e99af9c5c75be602cb", + "asset_list": "\nhttps://cdn.rooms.example/creatives/a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d.mpdhttps://cdn.rooms.example/creatives/9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3.mpd", + "spend_micros": 70000, + "presented_spend_micros": 0, + "impressions": [ + { + "slot": 0, + "claim": 4, + "verdict": "claim_not_viewable" + }, + { + "slot": 1, + "claim": 5, + "verdict": "claim_not_viewable" + } + ] + }, + { + "identity": "607c987520f0eac409160f977c7b6c5796afcf9519f4f61e82dc22b5afc48a13", + "asset_list": "\nhttps://cdn.rooms.example/creatives/a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d.mpdhttps://cdn.rooms.example/creatives/9f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee3.mpd", + "spend_micros": 70000, + "presented_spend_micros": 70000, + "impressions": [ + { + "slot": 0, + "claim": 6, + "verdict": "presented" + }, + { + "slot": 1, + "claim": 7, + "verdict": "presented" + } + ] + } + ], + "spend_micros": 110000, + "claim_facts": [ + { + "digest": "b4a33f63eae51642ce17cc04d521a4fb7b965c99c30fbb94da9b58069f9f812d", + "verdict": "presented", + "receipt": 0, + "slot": 0, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + }, + { + "digest": "14869dfc86e1e983897deb26bc65a345fbe530991dfed80dab1bd0da86394cf4", + "verdict": "attestation_absent", + "receipt": 0, + "slot": 1, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + }, + { + "digest": "0c0b3ac9dbf885ed4251a4594f5e06fdf37c19225aba694dbcdb48c0f328a84d", + "verdict": "attestation_invalid", + "receipt": 1, + "slot": 0, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + }, + { + "digest": "5cb0be16a0403ed2f588ba8c6b7db28fdb8f05f9461fbc2c408bd10731fb7f02", + "verdict": "attestation_invalid", + "receipt": 1, + "slot": 1, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + }, + { + "digest": "b82d36d43fe74f515d90a3027ed01d0e61dbaffea2f150e9e02f6716494b4429", + "verdict": "claim_not_viewable", + "receipt": 2, + "slot": 0, + "quartile": 5, + "run_ms": 0, + "viewable_ms": 0 + }, + { + "digest": "9a03c3032a3c65d4c8515f580d022d68c61890bb664085151d84949f18af70be", + "verdict": "claim_not_viewable", + "receipt": 2, + "slot": 1, + "quartile": 5, + "run_ms": 0, + "viewable_ms": 0 + }, + { + "digest": "d1a94c238ff4fe73fd1fbf2c5598e17075cae033e174c6e98fd1a6541af5e909", + "verdict": "presented", + "receipt": 3, + "slot": 0, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + }, + { + "digest": "19ea1a3170bfd782159ee7e65d6e0f1e91c706c51631e388ae54fe6c60a26e1c", + "verdict": "presented", + "receipt": 3, + "slot": 1, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + } + ] + }, + { + "name": "ceiling_counts_presented", + "about": "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", + "descriptor": "575244310198925abbec382eaa9716af19533e2da733f16f0c5e4211da533f86235b901ed496a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f9900406cd8e95d060040420f00000000000000100000000400000000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000030870726f6475636572000101000e000000000000000000000000000000000000000000000000000000000000000861756469656e636500010100080000000000000000000000000000000000000000000000000000000000000007646563696465720000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004b446b681c3acb0a952b495f6413b201a39ea15ac40bc16ef9f1fd0f5652046b64f3a198f43f5ab31d47561523c8703db71b7cb34fcc000493a220abc407180d", + "grants": [ + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e900000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a00000000000001010000000000000000000000000000000000000000000000000000000000000000000057455031010e000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff0000000000000043ef80f50c308a0dbbbe257a608bd4cb2621e48a3a643601e4ca941c848f3d7f1efc39cd7684c498110da78b3e77d18ae45d26047bd89743b835c54583e7a100", + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc5bf53717790addd310fcb1716ea58ff76cb693db9d9b09118167ddd0fc9098bf00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000100010100000000000000000000000000000000000000000000000000000000000000000000574550310108000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff000000000000002dbe370ef573d370c9094a346a461cbf226953b1da26196a856ebbf65858c5c4e4381fbb4fd6a2f4fe63d881f388453f6f7707b204eabfbebfaef77bf906d605", + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcdd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f468257500000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200000000000000000000000000000000000000000000000000000000000000000000000000574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffff01000000000000004555520000000042d93313a0f5f3922a482385164cf6113495f0c4f36a984e1bcb89aa993c92da33b7fcb1736b6fc20e987e706e5378ca83d48417a52f98eafd2d56be6ab1200d" + ], + "key_log": null, + "chains": [ + [ + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc96a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f9900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000104000ebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e915257cf0af27250e92899911ce8d7406737413b8fb3ea07fe018cb13a181b321f80f31d2d11ba7e5adea2bce42b1d928b373d5cf77bbf6ea3294dc9f096f0faaed436bd052adb36b7abb3ccdc808d12d1c23566b33ddd66e0c828ca228aa650c", + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc96a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f990100000000000000dee4d8f77b286f5d5938a0cfac6ddd652d7e080f3f14c96be89726590905651a0000000000000000001040005bf53717790addd310fcb1716ea58ff76cb693db9d9b09118167ddd0fc9098bfe55d4127973ec5ca3829b5af51616fef6c8dbbe07600e1b1cc5f7fbbcd9ffe32bdf328188dd7c6d5c3f1e1aff9d69e0354fecfd4ef30a89552e7c85e7d149d1a6c3e992702aec2bb5d9327169d00ba4ba8a5095c181cf775741e0c6a7b997b04" + ], + [ + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e900000000000000000000000000000000000000000000000000000000000000000000000000000000000e27070000000000020a0004150000003075000000aa98625a6b3df58a538960d1a551c6f4f317841226a577f62f86c0242ba4e5d31565d9c76b65ff9ca3e4c59c9a5a6b3d77c40979a011cfb2f93baddc9dfe5706", + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e90100000000000000e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa000e270700000000000126005741423101e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa01527597e53dd1c817047d50eef49343e4757ae9f75c18b0f81f53176abd2a2caa66f6ad365e0b6937b20f77080aff5cb52ebe439bca83f5c5b07d4657912c8308" + ] + ], + "policy": "57415031020cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc11362735de4a5208fe9859cc9a0253cf0bbe757c1d3abaee1de54272857fde9f02000000000000000000ffff001f0068747470733a2f2f6164732e726f6f6d732e6578616d706c652f736761692f240068747470733a2f2f63646e2e726f6f6d732e6578616d706c652f6372656174697665732f010100000000b03ddf9a5063ad40716b05e8f79979bf5291fd9b5980c3b92bc5cb0bf68ddd1b2ba616fa5b46fc703d495bf3b8cad76aaf41104ff4145cc2848f3db6693c9908", + "pt0_ms": 0, + "receipts": [ + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "session": "9857427973585b59349ee607c2b3d426", + "start_offset_ms": 0, + "format": 1, + "asset_list_sha256": "0be5a087ca49212e28c35b36c55b4a0d71dc64e7b2098e5a8dbbd1ed1faba2d1", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 1000000, + "pod_hash": "d9775473b182dce786ae602bd492a5954fc13a3ed48abc6cb1d99dfcad7db188", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-w", + "creative_hash": "f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88", + "duration_ms": 15000, + "price_micros": 600000, + "quartiles_reached": 5 + } + ], + "merkle_root": "61774a76eb8a8724fa50422ed01ed974b795fe97d176ff5af3951cc3474c4d6d", + "budget_spent_micros": 600000, + "work_decide_ops": 1, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "11362735de4a5208fe9859cc9a0253cf0bbe757c1d3abaee1de54272857fde9f", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 1 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "91e50a2bdcd6b16ffa39128b335bb8e19f3849d4e5035938532bb567dde7514a21d2b47169c303af3bfc3aca4fbedd5dd7648918edbae456ff4a4026b4418004" + }, + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "session": "e7832a57261de786e36cd8b57cc023ee", + "start_offset_ms": 0, + "format": 1, + "asset_list_sha256": "0be5a087ca49212e28c35b36c55b4a0d71dc64e7b2098e5a8dbbd1ed1faba2d1", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 1000000, + "pod_hash": "d9775473b182dce786ae602bd492a5954fc13a3ed48abc6cb1d99dfcad7db188", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-w", + "creative_hash": "f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88", + "duration_ms": 15000, + "price_micros": 600000, + "quartiles_reached": 5 + } + ], + "merkle_root": "61774a76eb8a8724fa50422ed01ed974b795fe97d176ff5af3951cc3474c4d6d", + "budget_spent_micros": 600000, + "work_decide_ops": 1, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "11362735de4a5208fe9859cc9a0253cf0bbe757c1d3abaee1de54272857fde9f", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 1 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "674661164a9b4e5eda3303638a3fc52071f07da3ba1c0bf40bc131eb475dfb8b0bc8adf60389523c3dc84cf83553b8b5196f6b0b9363377c70120b4065582700" + } + ], + "pods": [ + [ + { + "ad_id": "ad-w", + "creative_hash": "f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88", + "duration_ms": 15000, + "price_micros": 600000 + } + ], + [ + { + "ad_id": "ad-w", + "creative_hash": "f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88", + "duration_ms": 15000, + "price_micros": 600000 + } + ] + ], + "claims": [ + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa9857427973585b59349ee607c2b3d42600f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f887d4a22c530e172ad6d94fb1293e6265d3787a6283bccbeb16aea8c335eedc85301ffffffff0500b66f42322e52718d08a81552e72aca223d88bf8445137586d93f168ee63db4d347fc7e7df25682a8cd9b085c73337203dc01bbdb31391101bb48294ac55a6afb8cf1a5469dc5caf745695f22864330f3508d7526e49b72cf9c85ab5e267fcab402cd6f8e2c2e4529f918cc17308c7a78e63313823823f3ddeb22c96dcb560727395db5a3af9985ef25a181153dfe9063d8d23b7d1ca7b0f2d917d1cfd4306c83010000000000983a0000102701000041521389361e01b16635a847ee0413ec979aa357a4842776a18f93c9054777a581546090299738a6ca7e12b51533337d26f763cad9277fb95b65169dbf904401" + ], + "claim_about": [ + "viewer 21, ad-w: in view and audible from start to end" + ], + "outcome": "accept", + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "breaks": [ + { + "digest": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "id": "wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "event_id": 2187487373789201, + "author": "ebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e9", + "cue_id": 21, + "t": 120000000, + "type": "break", + "placement": "mid", + "planned_ms": 30000, + "avail_ms": 30000, + "duration_ms": 30000, + "closed_by": null, + "end": 150000000, + "hls_list_uri": "https://ads.rooms.example/sgai/wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa.json?_HLS_interstitial_id=wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "dash_list_uri": "https://ads.rooms.example/sgai/wai-ad-e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa.mpd" + } + ], + "chapters": [], + "hls": "ceiling_counts_presented.m3u8", + "dash": "", + "dash_open": [], + "receipt_facts": [ + { + "identity": "7ac2cef86051ba9ca11b5a4d761707170d17bcdd63f457f3b205931e2668a262", + "asset_list": "{\"ASSETS\":[{\"URI\":\"https://cdn.rooms.example/creatives/f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88.m3u8\",\"DURATION\":15.000}]}", + "spend_micros": 600000, + "presented_spend_micros": 600000, + "impressions": [ + { + "slot": 0, + "claim": 0, + "verdict": "presented" + } + ] + }, + { + "identity": "3a60675887e7c954ab15ee9ef2cdca8200d94ed69379706ae67878c5d6de8e19", + "asset_list": "{\"ASSETS\":[{\"URI\":\"https://cdn.rooms.example/creatives/f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88.m3u8\",\"DURATION\":15.000}]}", + "spend_micros": 600000, + "presented_spend_micros": 0, + "impressions": [ + { + "slot": 0, + "claim": null, + "verdict": "claim_absent" + } + ] + } + ], + "spend_micros": 600000, + "claim_facts": [ + { + "digest": "6cc2ced7f21ca5ea8206939b25ef8f69ed99a3732db88334803604af34b1e34d", + "verdict": "presented", + "receipt": 0, + "slot": 0, + "quartile": 5, + "run_ms": 15000, + "viewable_ms": 15000 + } + ] + }, + { + "name": "ceiling_exceeded_by_presented", + "about": "the same two receipts, each with a claim that verifies: the presented spend of the second takes the room past the ceiling", + "descriptor": "575244310198925abbec382eaa9716af19533e2da733f16f0c5e4211da533f86235b901ed496a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f9900406cd8e95d060040420f00000000000000100000000400000000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000030870726f6475636572000101000e000000000000000000000000000000000000000000000000000000000000000861756469656e636500010100080000000000000000000000000000000000000000000000000000000000000007646563696465720000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004b446b681c3acb0a952b495f6413b201a39ea15ac40bc16ef9f1fd0f5652046b64f3a198f43f5ab31d47561523c8703db71b7cb34fcc000493a220abc407180d", + "grants": [ + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e900000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a00000000000001010000000000000000000000000000000000000000000000000000000000000000000057455031010e000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff0000000000000043ef80f50c308a0dbbbe257a608bd4cb2621e48a3a643601e4ca941c848f3d7f1efc39cd7684c498110da78b3e77d18ae45d26047bd89743b835c54583e7a100", + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc5bf53717790addd310fcb1716ea58ff76cb693db9d9b09118167ddd0fc9098bf00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000100010100000000000000000000000000000000000000000000000000000000000000000000574550310108000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff000000000000002dbe370ef573d370c9094a346a461cbf226953b1da26196a856ebbf65858c5c4e4381fbb4fd6a2f4fe63d881f388453f6f7707b204eabfbebfaef77bf906d605", + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcdd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f468257500000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200000000000000000000000000000000000000000000000000000000000000000000000000574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffff01000000000000004555520000000042d93313a0f5f3922a482385164cf6113495f0c4f36a984e1bcb89aa993c92da33b7fcb1736b6fc20e987e706e5378ca83d48417a52f98eafd2d56be6ab1200d" + ], + "key_log": null, + "chains": [ + [ + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc96a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f9900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000104000ebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e915257cf0af27250e92899911ce8d7406737413b8fb3ea07fe018cb13a181b321f80f31d2d11ba7e5adea2bce42b1d928b373d5cf77bbf6ea3294dc9f096f0faaed436bd052adb36b7abb3ccdc808d12d1c23566b33ddd66e0c828ca228aa650c", + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc96a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f990100000000000000dee4d8f77b286f5d5938a0cfac6ddd652d7e080f3f14c96be89726590905651a0000000000000000001040005bf53717790addd310fcb1716ea58ff76cb693db9d9b09118167ddd0fc9098bfe55d4127973ec5ca3829b5af51616fef6c8dbbe07600e1b1cc5f7fbbcd9ffe32bdf328188dd7c6d5c3f1e1aff9d69e0354fecfd4ef30a89552e7c85e7d149d1a6c3e992702aec2bb5d9327169d00ba4ba8a5095c181cf775741e0c6a7b997b04" + ], + [ + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e900000000000000000000000000000000000000000000000000000000000000000000000000000000000e27070000000000020a0004150000003075000000aa98625a6b3df58a538960d1a551c6f4f317841226a577f62f86c0242ba4e5d31565d9c76b65ff9ca3e4c59c9a5a6b3d77c40979a011cfb2f93baddc9dfe5706", + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e90100000000000000e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa000e270700000000000126005741423101e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa01527597e53dd1c817047d50eef49343e4757ae9f75c18b0f81f53176abd2a2caa66f6ad365e0b6937b20f77080aff5cb52ebe439bca83f5c5b07d4657912c8308" + ] + ], + "policy": "57415031020cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc11362735de4a5208fe9859cc9a0253cf0bbe757c1d3abaee1de54272857fde9f02000000000000000000ffff001f0068747470733a2f2f6164732e726f6f6d732e6578616d706c652f736761692f240068747470733a2f2f63646e2e726f6f6d732e6578616d706c652f6372656174697665732f010100000000b03ddf9a5063ad40716b05e8f79979bf5291fd9b5980c3b92bc5cb0bf68ddd1b2ba616fa5b46fc703d495bf3b8cad76aaf41104ff4145cc2848f3db6693c9908", + "pt0_ms": 0, + "receipts": [ + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "session": "9857427973585b59349ee607c2b3d426", + "start_offset_ms": 0, + "format": 1, + "asset_list_sha256": "0be5a087ca49212e28c35b36c55b4a0d71dc64e7b2098e5a8dbbd1ed1faba2d1", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 1000000, + "pod_hash": "d9775473b182dce786ae602bd492a5954fc13a3ed48abc6cb1d99dfcad7db188", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-w", + "creative_hash": "f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88", + "duration_ms": 15000, + "price_micros": 600000, + "quartiles_reached": 5 + } + ], + "merkle_root": "61774a76eb8a8724fa50422ed01ed974b795fe97d176ff5af3951cc3474c4d6d", + "budget_spent_micros": 600000, + "work_decide_ops": 1, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "11362735de4a5208fe9859cc9a0253cf0bbe757c1d3abaee1de54272857fde9f", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 1 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "91e50a2bdcd6b16ffa39128b335bb8e19f3849d4e5035938532bb567dde7514a21d2b47169c303af3bfc3aca4fbedd5dd7648918edbae456ff4a4026b4418004" + }, + { + "form": 2, + "room": "0cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc", + "cue": "e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa", + "session": "e7832a57261de786e36cd8b57cc023ee", + "start_offset_ms": 0, + "format": 1, + "asset_list_sha256": "0be5a087ca49212e28c35b36c55b4a0d71dc64e7b2098e5a8dbbd1ed1faba2d1", + "avail_ms": 30000, + "slots": 2, + "funds_remaining_micros": 1000000, + "pod_hash": "d9775473b182dce786ae602bd492a5954fc13a3ed48abc6cb1d99dfcad7db188", + "impressions": [ + { + "slot": 0, + "ad_id": "ad-w", + "creative_hash": "f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88", + "duration_ms": 15000, + "price_micros": 600000, + "quartiles_reached": 5 + } + ], + "merkle_root": "61774a76eb8a8724fa50422ed01ed974b795fe97d176ff5af3951cc3474c4d6d", + "budget_spent_micros": 600000, + "work_decide_ops": 1, + "joules_micro": 0, + "energy_provenance": null, + "grant": { + "grant_hash": "11362735de4a5208fe9859cc9a0253cf0bbe757c1d3abaee1de54272857fde9f", + "capability": "wai.live.room", + "joule_ceiling_micro": 18446744073709551615, + "funds_ceiling": 1 + }, + "signer_pubkey": "dd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f4682575", + "signer_id": "did:key:z6MkuN7U3zjRLZbdbmWCwVY3N2tj5s9j2pAyyxLBPBJPhw5v", + "sig": "674661164a9b4e5eda3303638a3fc52071f07da3ba1c0bf40bc131eb475dfb8b0bc8adf60389523c3dc84cf83553b8b5196f6b0b9363377c70120b4065582700" + } + ], + "pods": [ + [ + { + "ad_id": "ad-w", + "creative_hash": "f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88", + "duration_ms": 15000, + "price_micros": 600000 + } + ], + [ + { + "ad_id": "ad-w", + "creative_hash": "f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88", + "duration_ms": 15000, + "price_micros": 600000 + } + ] + ], + "claims": [ + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa9857427973585b59349ee607c2b3d42600f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f887d4a22c530e172ad6d94fb1293e6265d3787a6283bccbeb16aea8c335eedc85301ffffffff0500b66f42322e52718d08a81552e72aca223d88bf8445137586d93f168ee63db4d347fc7e7df25682a8cd9b085c73337203dc01bbdb31391101bb48294ac55a6afb8cf1a5469dc5caf745695f22864330f3508d7526e49b72cf9c85ab5e267fcab402cd6f8e2c2e4529f918cc17308c7a78e63313823823f3ddeb22c96dcb560727395db5a3af9985ef25a181153dfe9063d8d23b7d1ca7b0f2d917d1cfd4306c83010000000000983a0000102701000041521389361e01b16635a847ee0413ec979aa357a4842776a18f93c9054777a581546090299738a6ca7e12b51533337d26f763cad9277fb95b65169dbf904401", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aae7832a57261de786e36cd8b57cc023ee00f567fd7b6ecdddbdca42eb10f6c5f344b7e0851efdcb8afcdad1532677f55f88101162dc0f6bc5d2507e05ad346bf9c73f0001229a8157d464eec9ae3740c45f01ffffffff0500b66f42322e52718d08a81552e72aca223d88bf8445137586d93f168ee63db4d347fc7e7df25682a8cd9b085c73337203dc01bbdb31391101bb48294ac55a6afb8cf1a5469dc5caf745695f22864330f3508d7526e49b72cf9c85ab5e267fcab402cd6f8e2c2e4529f918cc17308c7a78e63313823823f3ddeb22c96dcb560727395db5a3af9985ef25a181153dfe9063d8d23b7d1ca7b0f2d917d1cfd4306c83010000000000983a000010270100003e7b4ca5ffcf733c8d707dfdff81c07b2fc485a70035cf1b7ca52e79e87206683086baeb7e1f189d5d9c034d6b23833ba9e4113710827e12773a88aa3ffaa60b" + ], + "claim_about": [ + "viewer 21, ad-w: in view and audible from start to end", + "viewer 22, ad-w: in view and audible from start to end" + ], + "outcome": "refuse", + "reason": "funds_exceeded", + "at": [ + "receipt", + 1 + ] + }, + { + "name": "policy_attestation_without_claim", + "about": "a policy of version 2 requiring an attestation and no claim to carry it", + "descriptor": "575244310198925abbec382eaa9716af19533e2da733f16f0c5e4211da533f86235b901ed496a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f9900406cd8e95d060040420f00000000000000100000000400000000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000030870726f6475636572000101000e000000000000000000000000000000000000000000000000000000000000000861756469656e636500010100080000000000000000000000000000000000000000000000000000000000000007646563696465720000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004b446b681c3acb0a952b495f6413b201a39ea15ac40bc16ef9f1fd0f5652046b64f3a198f43f5ab31d47561523c8703db71b7cb34fcc000493a220abc407180d", + "grants": [ + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e900000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a00000000000001010000000000000000000000000000000000000000000000000000000000000000000057455031010e000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff0000000000000043ef80f50c308a0dbbbe257a608bd4cb2621e48a3a643601e4ca941c848f3d7f1efc39cd7684c498110da78b3e77d18ae45d26047bd89743b835c54583e7a100", + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc5bf53717790addd310fcb1716ea58ff76cb693db9d9b09118167ddd0fc9098bf00000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000100010100000000000000000000000000000000000000000000000000000000000000000000574550310108000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffffffffffffffffffff000000000000002dbe370ef573d370c9094a346a461cbf226953b1da26196a856ebbf65858c5c4e4381fbb4fd6a2f4fe63d881f388453f6f7707b204eabfbebfaef77bf906d605", + "57524731010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcdd8db34c7d7be6dd1da6d72abf89889bd2353de94849c6b39107ca09f468257500000000000000000000000000000000000000000000000000000000000000000000000000000000f05ad16a0000000080bad26a000000000200000000000000000000000000000000000000000000000000000000000000000000000000574550310100000000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f0000000000ffffffffffffffff020000000000000045555200000000633a9c6151bdd3a6731598ff60969d3221e79341705b24bce0aad94f1d77f940c503382d394978c630169e9531f553e06e5e382d819c7b0b1e1f1280d922180a" + ], + "key_log": null, + "chains": [ + [ + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc96a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f9900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000104000ebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e915257cf0af27250e92899911ce8d7406737413b8fb3ea07fe018cb13a181b321f80f31d2d11ba7e5adea2bce42b1d928b373d5cf77bbf6ea3294dc9f096f0faaed436bd052adb36b7abb3ccdc808d12d1c23566b33ddd66e0c828ca228aa650c", + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc96a3c369a83402e79ea110b2bc1f9026cd10be235e0bc7e7ebc0c8b2141b7f990100000000000000dee4d8f77b286f5d5938a0cfac6ddd652d7e080f3f14c96be89726590905651a0000000000000000001040005bf53717790addd310fcb1716ea58ff76cb693db9d9b09118167ddd0fc9098bfe55d4127973ec5ca3829b5af51616fef6c8dbbe07600e1b1cc5f7fbbcd9ffe32bdf328188dd7c6d5c3f1e1aff9d69e0354fecfd4ef30a89552e7c85e7d149d1a6c3e992702aec2bb5d9327169d00ba4ba8a5095c181cf775741e0c6a7b997b04" + ], + [ + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e900000000000000000000000000000000000000000000000000000000000000000000000000000000000e27070000000000020a0004150000003075000000aa98625a6b3df58a538960d1a551c6f4f317841226a577f62f86c0242ba4e5d31565d9c76b65ff9ca3e4c59c9a5a6b3d77c40979a011cfb2f93baddc9dfe5706", + "57455631010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddcebd931f28bc8d5f3a74572b36618b4c6ae2166b28c44789a34bcf005572020e90100000000000000e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa000e270700000000000126005741423101e9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa01527597e53dd1c817047d50eef49343e4757ae9f75c18b0f81f53176abd2a2caa66f6ad365e0b6937b20f77080aff5cb52ebe439bca83f5c5b07d4657912c8308" + ] + ], + "policy": "57415031020cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddc76d868a117b51d032432e7068e5a366118be713477e95c28dcd649a4b41602370230750000a00f000000ffff001f0068747470733a2f2f6164732e726f6f6d732e6578616d706c652f736761692f240068747470733a2f2f63646e2e726f6f6d732e6578616d706c652f6372656174697665732f020002000e006973737565722e6578616d706c65c69bea1626e039a5745927f6e7c512a4268dbcc5febd32d667bcf634d3861e13ffee9216d854d9d9430c2343a540582a998ab9db63bc8a51d042ea570eec3d03", + "pt0_ms": 0, + "receipts": [], + "pods": [], + "claims": [], + "claim_about": [], + "outcome": "refuse", + "reason": "ad_policy_malformed", + "at": [ + "policy" + ] + } + ] +} diff --git a/wai/live-conformance/presentation/video_rule.m3u8 b/wai/live-conformance/presentation/video_rule.m3u8 new file mode 100644 index 000000000..6ef4a0a6e --- /dev/null +++ b/wai/live-conformance/presentation/video_rule.m3u8 @@ -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 diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 4eacec8be..682fb2cbd 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -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 @@ -738,6 +744,11 @@ name = "wai_live_ads_vectors" path = "src/bin/wai_live_ads_vectors.rs" required-features = ["live_ads"] +[[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" @@ -1443,6 +1454,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" diff --git a/wai/wai-rs/src/bin/wai_ad_presentation_vectors.rs b/wai/wai-rs/src/bin/wai_ad_presentation_vectors.rs new file mode 100644 index 000000000..a901012f6 --- /dev/null +++ b/wai/wai-rs/src/bin/wai_ad_presentation_vectors.rs @@ -0,0 +1,77 @@ +//! Conformance corpus for ad presentation (`live-conformance/presentation/`, +//! extensions/ad-presentation.md §12). +//! +//! wai_ad_presentation_vectors gen write vectors.json and each accepted +//! room's HLS tags (NAME.m3u8), from fixed +//! seeds +//! wai_ad_presentation_vectors verify 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 = 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 "); + 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 = 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 +} diff --git a/wai/wai-rs/src/live_ads.rs b/wai/wai-rs/src/live_ads.rs index 7bdc56148..015d4d89d 100644 --- a/wai/wai-rs/src/live_ads.rs +++ b/wai/wai-rs/src/live_ads.rs @@ -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, pub sig: [u8; 64], } @@ -260,7 +272,7 @@ impl AdPolicy { /// The policy without `sig` (§3.1). pub fn body(&self) -> Vec { 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 { 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, Vec)> { 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], + /// Presentation claims, in the order given (ad-presentation §9.1); read only under a + /// policy that requires them. + pub claims: &'a [Vec], + /// 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, 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, + pub impressions: Vec, } /// 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, + /// 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, } 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] diff --git a/wai/wai-rs/src/live_ads/corpus.rs b/wai/wai-rs/src/live_ads/corpus.rs index dd786e417..fd5feac93 100644 --- a/wai/wai-rs/src/live_ads/corpus.rs +++ b/wai/wai-rs/src/live_ads/corpus.rs @@ -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, - id: [u8; 32], - grants: Vec>, - head: Option, +pub(super) struct Room { + pub(super) descriptor: Vec, + pub(super) id: [u8; 32], + pub(super) grants: Vec>, + pub(super) head: Option, authors: Vec, decider: Grant, } impl Room { - fn raw(&mut self, k: &SigningKey, t: u64, kind: u8, payload: Vec) -> [u8; 32] { + pub(super) fn raw(&mut self, k: &SigningKey, t: u64, kind: u8, payload: Vec) -> [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>> { + pub(super) fn chains(&self) -> Vec>> { 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, - funds: u64, - quartiles: Vec, + pub(super) format: u8, + pub(super) pod: Vec, + pub(super) funds: u64, + pub(super) quartiles: Vec, 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, quartiles: &[u8]) -> Seal { + pub(super) fn new(cue: [u8; 32], avail_ms: u64, session: u8, format: u8, pod: Vec, 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]) -> Vec { +pub(super) fn hexes(v: &[Vec]) -> Vec { 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> { +pub(super) fn pod_of(v: &Value) -> Option> { v.as_array()? .iter() .map(|c| { @@ -749,17 +757,17 @@ fn pod_of(v: &Value) -> Option> { .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 { +pub(super) fn run_json(name: &str, run: &Run) -> Map { 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> { +pub(super) fn unhex(s: &str) -> Option> { 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>> { +pub(super) fn hex_list(v: &Value) -> Option>> { v.as_array()?.iter().map(|h| unhex(h.as_str()?)).collect() } @@ -920,7 +928,19 @@ pub fn verify(files: &BTreeMap) -> Vec { 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) { diff --git a/wai/wai-rs/src/live_ads/presentation.rs b/wai/wai-rs/src/live_ads/presentation.rs new file mode 100644 index 000000000..d6960a47f --- /dev/null +++ b/wai/wai-rs/src/live_ads/presentation.rs @@ -0,0 +1,1033 @@ +//! Ad presentation — `wai.live.ad_presentation` ([extensions/ad-presentation.md]). +//! +//! A player's signed statement that it presented one impression of a live-ads break: the +//! creative's media objects it presented, and what it measured while it did, as spans of +//! the creative's media time with the fraction of the ad's pixels in view and whether it +//! was audible. Signed by the playback session's own key, from which the session a +//! form-2 `wai.video.ssai` receipt names is derived, and optionally carrying a device +//! attestation token (RFC 9577 §2.2.1), bound to this one impression through its +//! challenge. +//! +//! Under an ad policy that requires claims (`WAP1` version 2), a receipt's spend counts +//! only the impressions whose claim verifies (live-ads §9.3 step 12): the [`Ledger`] here +//! gives each claim its verdict and each impression its count. +//! +//! What this module implements, by section of the extension: +//! +//! - §3: the session key and the session derived from it; +//! - §4: the creative digest; +//! - §5: the claim, its spans, its rules and its signature; +//! - §6: the viewability rules, the qualifying run and the quartile reached; +//! - §7: the attestation token, its challenge, and the publicly verifiable issuance type +//! of RFC 9578 §6.4 (RSASSA-PSS with SHA-384), from the issuer's key; +//! - §8: the policy's presentation fields; +//! - §9: each claim's verdict and each impression's count. +//! +//! The corpus `live-conformance/presentation/` is written by +//! [`super::presentation_corpus::generate`] and checked by +//! `live-conformance/ad_presentation_verify.py`, which shares no code with this module. +//! +//! [extensions/ad-presentation.md]: ../../../extensions/ad-presentation.md + +use ed25519_dalek::{Signer, SigningKey}; +use num_bigint::BigUint; +use sha2::{Digest, Sha256, Sha384}; + +use crate::wai_video_ssai::Impression; + +/// The capability string this extension registers. +pub const CAPABILITY: &str = "wai.live.ad_presentation"; +/// The magic of a presentation claim (§5.1). +pub const CLAIM_MAGIC: [u8; 4] = *b"WPC1"; +/// The version of a claim this revision defines. +pub const CLAIM_VERSION: u8 = 1; +/// The domain a claim's signature is made under (§5.3). +pub const CLAIM_DOMAIN: &[u8] = b"wai:ad-presentation-claim\x01"; +/// The domain of the session derived from a session key (§3). +pub const SESSION_DOMAIN: &[u8] = b"wai:ad-presentation-session\x01"; +/// The domain of an attestation's redemption context (§7.2). +pub const ATTEST_DOMAIN: &[u8] = b"wai:ad-presentation-attest\x01"; +/// The domain of a creative digest (§4). +pub const CREATIVE_DOMAIN: &[u8] = b"wai:ad-creative\x01"; +/// `frames` when the platform exposes no count of rendered frames (§5.1). +pub const NOT_EXPOSED: u32 = u32::MAX; +/// Every pixel, in basis points (§5.2). +pub const ALL_PIXELS: u16 = 10_000; +/// Half the pixels, in basis points: the video rule's threshold (§6.1). +pub const HALF_PIXELS: u16 = 5_000; +/// The continuous time both rules require, in milliseconds (§6.1). +pub const RUN_MS: u64 = 2_000; +/// The length of a span on the wire (§5.2). +pub const SPAN_LEN: usize = 11; +/// The id-RSASSA-PSS object identifier, 1.2.840.113549.1.1.10, as DER content (RFC 9578 §6.5). +const RSASSA_PSS_OID: [u8; 9] = [0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0a]; +/// The salt length of RFC 9578 §6.4's RSASSA-PSS, in bytes. +pub const PSS_SALT: usize = 48; +/// SHA-384's output, in bytes. +const H_LEN: usize = 48; + +/// The geometry a claim's visible fractions are measured over (§5.1). +pub mod geometry { + /// The ad's own pixels, measured. + pub const AD: u8 = 1; + /// A full-screen output to a television, whose pixels are assumed in view. + pub const SCREEN: u8 = 2; + /// The player's area, measured, where the ad's own pixels are not known. + pub const PLAYER: u8 = 3; +} + +/// The bits of a span's `flags` (§5.2). +pub mod span_flag { + /// The ad was audible throughout the span. + pub const AUDIBLE: u8 = 1; + /// Playback stopped between the span before and this one. + pub const RESUMED: u8 = 2; +} + +/// The bits of a policy's `presentation` (§8). +pub mod require { + /// A filled impression counts only when its claim verifies. + pub const CLAIM: u8 = 1; + /// The claim carries a device attestation that verifies. + pub const ATTEST: u8 = 2; + /// The qualifying run is audible too. + pub const AUDIBLE: u8 = 4; +} + +/// A policy's `viewability` (§6.1). +pub mod rule { + pub const NONE: u8 = 0; + /// At least half the ad's pixels in view for two continuous seconds. + pub const VIDEO: u8 = 1; + /// Every pixel in view for two continuous seconds; assumed on a television. + pub const CROSS_MEDIA: u8 = 2; +} + +/// The token types of RFC 9578 a policy may name (§7.1). +pub mod token_type { + /// VOPRF(P-384, SHA-384), privately verifiable (RFC 9578 §5). + pub const VOPRF: u16 = 0x0001; + /// Blind RSA (2048-bit), publicly verifiable (RFC 9578 §6). + pub const BLIND_RSA: u16 = 0x0002; +} + +/// The verdict a claim or an impression is given (§9, §11). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Reason { + /// live-events' `bad_key`: the claim's key is not a strict Ed25519 key. + BadKey, + /// live-events' `bad_signature`. + BadSignature, + ClaimMalformed, + ClaimNoDecision, + ClaimKeyUnbound, + ClaimDuplicate, + ClaimAssetMismatch, + ClaimQuartileMismatch, + ClaimNotViewable, + AttestationAbsent, + AttestationInvalid, + ClaimAbsent, +} + +impl Reason { + /// The codes this extension adds, in §11's order. + pub const ALL: [Reason; 10] = [ + Reason::ClaimMalformed, + Reason::ClaimNoDecision, + Reason::ClaimKeyUnbound, + Reason::ClaimDuplicate, + Reason::ClaimAssetMismatch, + Reason::ClaimQuartileMismatch, + Reason::ClaimNotViewable, + Reason::AttestationAbsent, + Reason::AttestationInvalid, + Reason::ClaimAbsent, + ]; + + pub fn code(self) -> &'static str { + match self { + Reason::BadKey => "bad_key", + Reason::BadSignature => "bad_signature", + Reason::ClaimMalformed => "claim_malformed", + Reason::ClaimNoDecision => "claim_no_decision", + Reason::ClaimKeyUnbound => "claim_key_unbound", + Reason::ClaimDuplicate => "claim_duplicate", + Reason::ClaimAssetMismatch => "claim_asset_mismatch", + Reason::ClaimQuartileMismatch => "claim_quartile_mismatch", + Reason::ClaimNotViewable => "claim_not_viewable", + Reason::AttestationAbsent => "attestation_absent", + Reason::AttestationInvalid => "attestation_invalid", + Reason::ClaimAbsent => "claim_absent", + } + } +} + +impl std::fmt::Display for Reason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} + +impl std::error::Error for Reason {} + +fn sha256(parts: &[&[u8]]) -> [u8; 32] { + let mut h = Sha256::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +// --------------------------------------------------------------------------------------- +// §8 The policy's presentation fields +// --------------------------------------------------------------------------------------- + +/// The presentation fields of an ad policy of version 2 (§8; live-ads §3.1). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Requirement { + /// [`require`]. + pub require: u8, + /// [`rule`]. + pub rule: u8, + /// A [`token_type`] when attestation is required, otherwise 0. + pub token_type: u16, + /// The attestation issuer's name, as its TokenChallenge carries it (§7.2); empty when + /// attestation is not required. + pub issuer: Vec, +} + +impl Requirement { + pub fn claims(&self) -> bool { + self.require & require::CLAIM != 0 + } + pub fn attest(&self) -> bool { + self.require & require::ATTEST != 0 + } + pub fn audible(&self) -> bool { + self.require & require::AUDIBLE != 0 + } + + /// §8's rules: no bit above bit 2; attestation or audibility only with claims; a rule + /// exactly when claims are required; a token type of RFC 9578 and an issuer exactly when + /// attestation is required. + pub fn well_formed(&self) -> bool { + let claim = self.claims(); + if self.require > 7 || (!claim && self.require != 0) || self.rule > rule::CROSS_MEDIA || claim != (self.rule != rule::NONE) { + return false; + } + if self.attest() { + matches!(self.token_type, token_type::VOPRF | token_type::BLIND_RSA) && !self.issuer.is_empty() + } else { + self.token_type == 0 && self.issuer.is_empty() + } + } +} + +// --------------------------------------------------------------------------------------- +// §3 The session key; §4 the creative digest +// --------------------------------------------------------------------------------------- + +/// The session a session key names in a room (§3): the first 16 bytes of SHA-256 over the +/// domain, the room id and the key. +pub fn session_of(room: &[u8; 32], player: &[u8; 32]) -> [u8; 16] { + sha256(&[SESSION_DOMAIN, room, player])[..16].try_into().expect("16 bytes") +} + +/// One media object of a creative (§4). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CreativeObject { + /// SHA-256 of the object's bytes as served. + pub content_hash: [u8; 32], + /// Its presentation duration. + pub duration_ms: u32, +} + +/// A creative: its media objects in presentation order (§4). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Creative { + pub objects: Vec, +} + +impl Creative { + /// The creative digest (§4): SHA-256 over the domain, the objects' count (u32) and each + /// object's content hash and duration (u32), little-endian. + pub fn digest(&self) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(CREATIVE_DOMAIN); + h.update((self.objects.len() as u32).to_le_bytes()); + for o in &self.objects { + h.update(o.content_hash); + h.update(o.duration_ms.to_le_bytes()); + } + h.finalize().into() + } + + /// The sum of its objects' durations. + pub fn duration_ms(&self) -> u64 { + self.objects.iter().map(|o| o.duration_ms as u64).sum() + } + + /// The content hashes of the objects a claim's spans touch, in order (§9.2 step 6): an + /// object of media time from `s` to `s + d` is touched by a span that holds an instant + /// of it. + pub fn touched(&self, spans: &[Span]) -> Vec<[u8; 32]> { + let mut out = Vec::new(); + let mut s = 0u64; + for o in &self.objects { + let e = s + o.duration_ms as u64; + if spans.iter().any(|x| x.start() < e && x.end() > s) { + out.push(o.content_hash); + } + s = e; + } + out + } +} + +// --------------------------------------------------------------------------------------- +// §5 The claim +// --------------------------------------------------------------------------------------- + +/// A span of the creative's media time, presented at normal speed, over which the player's +/// observations held (§5.2). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Span { + /// Where it starts, in the creative's media time. + pub start_ms: u32, + pub length_ms: u32, + /// The least fraction of the pixels in view the player observed over it, in basis points. + pub visible_bp: u16, + /// [`span_flag`]. + pub flags: u8, +} + +impl Span { + pub fn start(&self) -> u64 { + self.start_ms as u64 + } + pub fn end(&self) -> u64 { + self.start_ms as u64 + self.length_ms as u64 + } + fn audible(&self) -> bool { + self.flags & span_flag::AUDIBLE != 0 + } + fn resumed(&self) -> bool { + self.flags & span_flag::RESUMED != 0 + } +} + +/// A presentation claim (§5). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Claim { + pub room: [u8; 32], + /// The break's digest (live-ads §4.4). + pub cue: [u8; 32], + pub session: [u8; 16], + /// The impression's slot in its receipt. + pub slot: u8, + /// The creative presented: its creative digest (§4). + pub creative_hash: [u8; 32], + /// The session key (§3), which signs the claim. + pub player: [u8; 32], + /// [`geometry`]. + pub geometry: u8, + /// Frames rendered, or [`NOT_EXPOSED`]. + pub frames: u32, + /// The content hashes of the media objects presented, in order. + pub objects: Vec<[u8; 32]>, + pub spans: Vec, + /// A token of RFC 9577 §2.2.1, or empty. + pub attestation: Vec, + pub sig: [u8; 64], +} + +/// A cursor that reports running out as `None`. +struct Rd<'a> { + b: &'a [u8], + p: usize, +} + +impl<'a> Rd<'a> { + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let end = self.p.checked_add(n)?; + let s = self.b.get(self.p..end)?; + self.p = end; + Some(s) + } + fn u8(&mut self) -> Option { + Some(self.take(1)?[0]) + } + fn u16(&mut self) -> Option { + Some(u16::from_le_bytes(self.take(2)?.try_into().ok()?)) + } + fn u32(&mut self) -> Option { + Some(u32::from_le_bytes(self.take(4)?.try_into().ok()?)) + } + fn d(&mut self) -> Option<[u8; N]> { + self.take(N)?.try_into().ok() + } +} + +impl Claim { + /// The claim without `sig` (§5.1). + pub fn body(&self) -> Vec { + assert!(self.objects.len() <= u16::MAX as usize && self.spans.len() <= u16::MAX as usize && self.attestation.len() <= u16::MAX as usize); + let mut o = CLAIM_MAGIC.to_vec(); + o.push(CLAIM_VERSION); + o.extend_from_slice(&self.room); + o.extend_from_slice(&self.cue); + o.extend_from_slice(&self.session); + o.push(self.slot); + o.extend_from_slice(&self.creative_hash); + o.extend_from_slice(&self.player); + o.push(self.geometry); + o.extend_from_slice(&self.frames.to_le_bytes()); + o.extend_from_slice(&(self.objects.len() as u16).to_le_bytes()); + for h in &self.objects { + o.extend_from_slice(h); + } + o.extend_from_slice(&(self.spans.len() as u16).to_le_bytes()); + for s in &self.spans { + o.extend_from_slice(&s.start_ms.to_le_bytes()); + o.extend_from_slice(&s.length_ms.to_le_bytes()); + o.extend_from_slice(&s.visible_bp.to_le_bytes()); + o.push(s.flags); + } + o.extend_from_slice(&(self.attestation.len() as u16).to_le_bytes()); + o.extend_from_slice(&self.attestation); + o + } + + pub fn to_bytes(&self) -> Vec { + let mut o = self.body(); + o.extend_from_slice(&self.sig); + o + } + + /// The claim's digest (§5.3): SHA-256 over its body. + pub fn digest(&self) -> [u8; 32] { + sha256(&[&self.body()]) + } + + /// Sign with a session key, which becomes the claim's `player` (§5.3). + pub fn sign(mut self, key: &SigningKey) -> Claim { + self.player = key.verifying_key().to_bytes(); + let mut m = CLAIM_DOMAIN.to_vec(); + m.extend_from_slice(&self.body()); + self.sig = key.sign(&m).to_bytes(); + self + } + + /// Does the signature verify under `player`, strictly (§5.3)? + pub fn check_signature(&self) -> Result<(), Reason> { + let mut m = CLAIM_DOMAIN.to_vec(); + m.extend_from_slice(&self.body()); + crate::sig::verify_strict(&self.player, &m, &self.sig).map_err(|e| match e { + crate::sig::SigError::BadKey => Reason::BadKey, + crate::sig::SigError::BadSignature => Reason::BadSignature, + }) + } + + /// Read a claim (§5.4): its layout, then its rules. Anything that does not hold is + /// `claim_malformed`. + pub fn parse(b: &[u8]) -> Result { + let mut r = Rd { b, p: 0 }; + let c = (|| -> Option { + if r.d::<4>()? != CLAIM_MAGIC || r.u8()? != CLAIM_VERSION { + return None; + } + let room = r.d()?; + let cue = r.d()?; + let session = r.d()?; + let slot = r.u8()?; + let creative_hash = r.d()?; + let player = r.d()?; + let geometry = r.u8()?; + let frames = r.u32()?; + let n = r.u16()? as usize; + let objects = (0..n).map(|_| r.d::<32>()).collect::>>()?; + let m = r.u16()? as usize; + let spans = (0..m) + .map(|_| Some(Span { start_ms: r.u32()?, length_ms: r.u32()?, visible_bp: r.u16()?, flags: r.u8()? })) + .collect::>>()?; + let a = r.u16()? as usize; + let attestation = r.take(a)?.to_vec(); + let sig = r.d()?; + Some(Claim { room, cue, session, slot, creative_hash, player, geometry, frames, objects, spans, attestation, sig }) + })() + .ok_or(Reason::ClaimMalformed)?; + if r.p != b.len() || !c.rules_hold() { + return Err(Reason::ClaimMalformed); + } + Ok(c) + } + + /// §5.4's rules. + pub fn rules_hold(&self) -> bool { + if self.slot > 15 || !matches!(self.geometry, geometry::AD | geometry::SCREEN | geometry::PLAYER) || self.objects.is_empty() != self.spans.is_empty() { + return false; + } + let mut prev: Option = None; + for s in &self.spans { + if s.length_ms == 0 || s.visible_bp > ALL_PIXELS || s.flags > 3 { + return false; + } + match prev { + None if s.resumed() => return false, + Some(e) if s.start() < e || (s.start() > e && !s.resumed()) => return false, + _ => {} + } + prev = Some(s.end()); + } + true + } +} + +// --------------------------------------------------------------------------------------- +// §6 Viewability and the quartile +// --------------------------------------------------------------------------------------- + +/// The quartile a claim's spans reach for a creative of `duration_ms` (§6.3): 0 with no +/// span; otherwise 1, or the quarter its contiguous coverage from 0 reaches, 5 at the end. +pub fn quartile(spans: &[Span], duration_ms: u64) -> u8 { + if spans.is_empty() { + return 0; + } + let mut covered = 0u64; + for s in spans { + if s.start() != covered { + break; + } + covered = s.end(); + } + let c = covered as u128 * 4; + let d = duration_ms as u128; + match () { + _ if c >= 4 * d => 5, + _ if c >= 3 * d => 4, + _ if c >= 2 * d => 3, + _ if c >= d => 2, + _ => 1, + } +} + +/// The longest qualifying run and the qualifying time of a claim's spans under a +/// requirement (§6.2), in milliseconds. +pub fn runs(c: &Claim, req: &Requirement) -> (u64, u64) { + let (admitted, threshold) = match req.rule { + rule::VIDEO => (matches!(c.geometry, geometry::AD | geometry::PLAYER), HALF_PIXELS), + rule::CROSS_MEDIA => (true, ALL_PIXELS), + _ => (false, ALL_PIXELS), + }; + let (mut best, mut cur, mut total) = (0u64, 0u64, 0u64); + let mut prev: Option = None; + for s in &c.spans { + let qualifies = admitted && s.visible_bp >= threshold && (!req.audible() || s.audible()); + let continues = prev == Some(s.start()) && !s.resumed(); + if qualifies { + total += s.length_ms as u64; + cur = if continues { cur + s.length_ms as u64 } else { s.length_ms as u64 }; + best = best.max(cur); + } else { + cur = 0; + } + prev = Some(s.end()); + } + (best, total) +} + +// --------------------------------------------------------------------------------------- +// §7 Attestation +// --------------------------------------------------------------------------------------- + +/// The redemption context binding an attestation to one impression and one session key (§7.2). +pub fn redemption_context(c: &Claim) -> [u8; 32] { + sha256(&[ATTEST_DOMAIN, &c.room, &c.cue, &c.session, &[c.slot], &c.creative_hash, &c.player]) +} + +/// The TokenChallenge of RFC 9577 §2.1.1, in its TLS presentation (big-endian), with this +/// redemption context and no origin_info (§7.2). +pub fn token_challenge(token_type: u16, issuer: &[u8], context: &[u8; 32]) -> Vec { + let mut o = token_type.to_be_bytes().to_vec(); + o.extend_from_slice(&(issuer.len() as u16).to_be_bytes()); + o.extend_from_slice(issuer); + o.push(32); + o.extend_from_slice(context); + o.extend_from_slice(&0u16.to_be_bytes()); + o +} + +/// `Nk`, the authenticator's length, of a token type of RFC 9578 §8.2. +pub fn nk(token_type: u16) -> Option { + match token_type { + token_type::VOPRF => Some(48), + token_type::BLIND_RSA => Some(256), + _ => None, + } +} + +/// A token of RFC 9577 §2.2.1 whose type has `Nid` 32. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Token<'a> { + pub token_type: u16, + pub challenge_digest: [u8; 32], + pub key_id: [u8; 32], + /// `token_type`, `nonce`, `challenge_digest` and `token_key_id`: what the authenticator covers. + pub input: &'a [u8], + pub authenticator: &'a [u8], +} + +/// Read a token of a type of RFC 9578: exactly `2 + 32 + 32 + 32 + Nk` bytes. +pub fn read_token(b: &[u8]) -> Option> { + let tt = u16::from_be_bytes(b.get(..2)?.try_into().ok()?); + if b.len() != 98 + nk(tt)? { + return None; + } + Some(Token { + token_type: tt, + challenge_digest: b[34..66].try_into().ok()?, + key_id: b[66..98].try_into().ok()?, + input: &b[..98], + authenticator: &b[98..], + }) +} + +/// An issuer a verifier holds keys of (§7.3): its name as a TokenChallenge carries it, its +/// token type, and its keys, each the encoding RFC 9578 gives it (for type 0x0002 a DER +/// SubjectPublicKeyInfo, §6.5), whose SHA-256 is a token's `token_key_id`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Issuer { + pub name: Vec, + pub token_type: u16, + pub keys: Vec>, +} + +/// One DER element: its tag and contents, the cursor moved past it. +fn der<'a>(b: &'a [u8], p: &mut usize) -> Option<(u8, &'a [u8])> { + let tag = *b.get(*p)?; + let first = *b.get(*p + 1)?; + let (len, head) = if first < 0x80 { + (first as usize, 2) + } else { + let n = (first & 0x7f) as usize; + if !(1..=2).contains(&n) { + return None; + } + let len = b.get(*p + 2..*p + 2 + n)?.iter().fold(0usize, |a, &x| (a << 8) | x as usize); + (len, 2 + n) + }; + let start = *p + head; + let body = b.get(start..start.checked_add(len)?)?; + *p = start + len; + Some((tag, body)) +} + +/// The RSA public key of a SubjectPublicKeyInfo of id-RSASSA-PSS (RFC 9578 §6.5): a +/// 2048-bit modulus and its exponent. +pub fn rsa_public_key(spki: &[u8]) -> Option<(BigUint, BigUint)> { + let mut p = 0; + let (t, outer) = der(spki, &mut p)?; + if t != 0x30 || p != spki.len() { + return None; + } + let mut q = 0; + let (ta, alg) = der(outer, &mut q)?; + let (tb, bits) = der(outer, &mut q)?; + if ta != 0x30 || tb != 0x03 || q != outer.len() { + return None; + } + let mut a = 0; + let (to, oid) = der(alg, &mut a)?; + if to != 0x06 || oid != RSASSA_PSS_OID || bits.first() != Some(&0) { + return None; + } + let key = &bits[1..]; + let mut k = 0; + let (ts, seq) = der(key, &mut k)?; + if ts != 0x30 || k != key.len() { + return None; + } + let mut r = 0; + let (tn, n) = der(seq, &mut r)?; + let (te, e) = der(seq, &mut r)?; + if tn != 0x02 || te != 0x02 || r != seq.len() { + return None; + } + let n = n.strip_prefix(&[0]).unwrap_or(n); + if n.len() != 256 || n[0] < 0x80 || e.is_empty() || e[0] >= 0x80 { + return None; + } + Some((BigUint::from_bytes_be(n), BigUint::from_bytes_be(e))) +} + +/// MGF1 with SHA-384 (RFC 8017 Appendix B.2.1). +fn mgf1(seed: &[u8], len: usize) -> Vec { + let mut out = Vec::with_capacity(len + H_LEN); + let mut c = 0u32; + while out.len() < len { + let mut h = Sha384::new(); + h.update(seed); + h.update(c.to_be_bytes()); + out.extend_from_slice(&h.finalize()); + c += 1; + } + out.truncate(len); + out +} + +/// RSASSA-PSS-VERIFY (RFC 8017 §8.1.2) with SHA-384, MGF1 with SHA-384 and a 48-byte salt, +/// as RFC 9578 §6.4 verifies a token of type 0x0002, under a key of [`rsa_public_key`]. +pub fn pss_verify(spki: &[u8], message: &[u8], signature: &[u8]) -> bool { + let Some((n, e)) = rsa_public_key(spki) else { + return false; + }; + let em_len = 256; + if signature.len() != em_len { + return false; + } + let s = BigUint::from_bytes_be(signature); + if s >= n { + return false; + } + let m = s.modpow(&e, &n).to_bytes_be(); + if m.len() > em_len { + return false; + } + let mut em = vec![0u8; em_len - m.len()]; + em.extend_from_slice(&m); + // EMSA-PSS-VERIFY, emBits = 2047. + let m_hash = Sha384::digest(message); + if em[em_len - 1] != 0xbc { + return false; + } + let db_len = em_len - H_LEN - 1; + let (masked, h) = (&em[..db_len], &em[db_len..em_len - 1]); + if masked[0] & 0x80 != 0 { + return false; + } + let mut db: Vec = masked.iter().zip(mgf1(h, db_len)).map(|(a, b)| a ^ b).collect(); + db[0] &= 0x7f; + let ps = db_len - PSS_SALT - 1; + if db[..ps].iter().any(|&x| x != 0) || db[ps] != 0x01 { + return false; + } + let salt = &db[ps + 1..]; + let mut hh = Sha384::new(); + hh.update([0u8; 8]); + hh.update(m_hash); + hh.update(salt); + hh.finalize().as_slice() == h +} + +/// RSASSA-PSS-SIGN with the parameters of [`pss_verify`], from a 2048-bit modulus and a +/// private exponent, big-endian, and a given salt. The corpus signs its tokens with this. +pub fn pss_sign(n: &[u8], d: &[u8], message: &[u8], salt: &[u8; PSS_SALT]) -> Vec { + let em_len = 256; + let m_hash = Sha384::digest(message); + let mut hh = Sha384::new(); + hh.update([0u8; 8]); + hh.update(m_hash); + hh.update(salt); + let h = hh.finalize(); + let db_len = em_len - H_LEN - 1; + let mut db = vec![0u8; db_len - PSS_SALT - 1]; + db.push(0x01); + db.extend_from_slice(salt); + let mut em: Vec = db.iter().zip(mgf1(&h, db_len)).map(|(a, b)| a ^ b).collect(); + em[0] &= 0x7f; + em.extend_from_slice(&h); + em.push(0xbc); + let s = BigUint::from_bytes_be(&em).modpow(&BigUint::from_bytes_be(d), &BigUint::from_bytes_be(n)).to_bytes_be(); + let mut out = vec![0u8; em_len - s.len()]; + out.extend_from_slice(&s); + out +} + +/// Does a token verify with an issuer the verifier holds (§7.3)? For type 0x0002, under a +/// key of the issuer whose SHA-256 is the token's `token_key_id` (RFC 9578 §6.4). A type +/// 0x0001 token is verified by its issuer alone (RFC 9578 §5.4), which this reference is +/// not: it does not verify here. +pub fn issuer_verifies(issuers: &[Issuer], name: &[u8], t: &Token) -> bool { + issuers.iter().filter(|i| i.name == name && i.token_type == t.token_type).any(|i| { + i.keys.iter().any(|k| sha256(&[k]) == t.key_id && t.token_type == token_type::BLIND_RSA && pss_verify(k, t.input, t.authenticator)) + }) +} + +/// The attestation check of §9.2 step 9. +pub fn check_attestation(c: &Claim, req: &Requirement, issuers: &[Issuer]) -> Result<(), Reason> { + if c.attestation.is_empty() { + return Err(Reason::AttestationAbsent); + } + let t = read_token(&c.attestation).ok_or(Reason::AttestationInvalid)?; + let challenge = token_challenge(req.token_type, &req.issuer, &redemption_context(c)); + if t.token_type != req.token_type || t.challenge_digest != sha256(&[&challenge]) || !issuer_verifies(issuers, &req.issuer, &t) { + return Err(Reason::AttestationInvalid); + } + Ok(()) +} + +// --------------------------------------------------------------------------------------- +// §9 Each claim's verdict and each impression's count +// --------------------------------------------------------------------------------------- + +/// What a verifier is given beside the room for the claims (§9.1). +#[derive(Clone, Copy, Debug)] +pub struct Context<'a> { + pub requirement: &'a Requirement, + pub creatives: &'a [Creative], + pub issuers: &'a [Issuer], +} + +/// A claim, as a verification states it (§9.4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClaimFacts { + /// Its digest, when it reads. + pub digest: Option<[u8; 32]>, + /// `Ok` when it is presented: its impression counts. + pub verdict: Result<(), Reason>, + /// The receipt and slot of the impression it names, from step 3 on. + pub receipt: Option, + pub slot: Option, + /// From step 6 on: the quartile its spans reach, its longest qualifying run and its + /// qualifying time. + pub quartile: Option, + pub run_ms: Option, + pub viewable_ms: Option, +} + +/// An impression of an accepted receipt, as a verification states it (§9.4). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImpressionCount { + pub slot: u64, + /// The index of its claim among the claims given, when it has one. + pub claim: Option, + /// `Ok` when it counts. + pub verdict: Result<(), Reason>, +} + +/// The claims of a verification, read once and judged as the receipts are reached. +#[derive(Clone, Debug)] +pub struct Ledger { + read: Vec>, + facts: Vec>, +} + +impl Ledger { + /// Steps 1 and 2 of §9.2 for every claim: it reads, and its signature verifies. + pub fn read(claims: &[Vec]) -> Ledger { + let mut read = Vec::new(); + let mut facts = Vec::new(); + for b in claims { + match Claim::parse(b) { + Err(r) => { + read.push(Err(r)); + facts.push(Some(ClaimFacts { digest: None, verdict: Err(r), receipt: None, slot: None, quartile: None, run_ms: None, viewable_ms: None })); + } + Ok(c) => { + let digest = Some(c.digest()); + match c.check_signature() { + Err(r) => { + read.push(Err(r)); + facts.push(Some(ClaimFacts { digest, verdict: Err(r), receipt: None, slot: None, quartile: None, run_ms: None, viewable_ms: None })); + } + Ok(()) => { + read.push(Ok(c)); + facts.push(None); + } + } + } + } + } + Ledger { read, facts } + } + + /// Steps 3 to 9 of §9.2 for the claims that name an impression of receipt `k`, which has + /// passed live-ads §9.3 steps 1 to 11; each impression's count, in slot order. + pub fn receipt(&mut self, k: usize, room: &[u8; 32], cue: &[u8; 32], session: &[u8; 16], impressions: &[Impression], cx: &Context) -> Vec { + let mut out = Vec::new(); + for imp in impressions { + let mut own: Option = None; + let mut verdict = Err(Reason::ClaimAbsent); + for j in 0..self.read.len() { + let Ok(c) = &self.read[j] else { continue }; + if self.facts[j].is_some() || c.room != *room || c.cue != *cue || c.session != *session || c.slot as u64 != imp.slot { + continue; + } + let mut f = ClaimFacts { digest: Some(c.digest()), verdict: Ok(()), receipt: Some(k), slot: Some(c.slot), quartile: None, run_ms: None, viewable_ms: None }; + if session_of(room, &c.player) != *session { + f.verdict = Err(Reason::ClaimKeyUnbound); + } else if own.is_some() { + f.verdict = Err(Reason::ClaimDuplicate); + } else { + own = Some(j); + let (q, (run, viewable)) = (quartile(&c.spans, imp.duration_ms), runs(c, cx.requirement)); + f.quartile = Some(q); + f.run_ms = Some(run); + f.viewable_ms = Some(viewable); + f.verdict = judge(c, imp, q, run, cx); + verdict = f.verdict; + } + self.facts[j] = Some(f); + } + out.push(ImpressionCount { slot: imp.slot, claim: own, verdict }); + } + out + } + + /// Every claim's facts; a claim no accepted receipt's impression named is + /// `claim_no_decision`. + pub fn finish(self) -> Vec { + self.facts + .into_iter() + .zip(self.read) + .map(|(f, r)| { + f.unwrap_or_else(|| ClaimFacts { + digest: r.ok().map(|c| c.digest()), + verdict: Err(Reason::ClaimNoDecision), + receipt: None, + slot: None, + quartile: None, + run_ms: None, + viewable_ms: None, + }) + }) + .collect() + } +} + +/// Steps 6 to 9 of §9.2 for an impression's own claim. +fn judge(c: &Claim, imp: &Impression, q: u8, run: u64, cx: &Context) -> Result<(), Reason> { + let creative = cx.creatives.iter().find(|x| x.digest() == imp.creative_hash); + let asset_ok = c.creative_hash == imp.creative_hash + && creative.is_some_and(|cr| cr.duration_ms() == imp.duration_ms && c.spans.iter().all(|s| s.end() <= imp.duration_ms) && cr.touched(&c.spans) == c.objects); + if !asset_ok { + return Err(Reason::ClaimAssetMismatch); + } + if q != imp.quartiles_reached { + return Err(Reason::ClaimQuartileMismatch); + } + if run < RUN_MS { + return Err(Reason::ClaimNotViewable); + } + if cx.requirement.attest() { + check_attestation(c, cx.requirement, cx.issuers)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(i: u8) -> SigningKey { + SigningKey::from_bytes(&[i; 32]) + } + + fn span(start_ms: u32, length_ms: u32, visible_bp: u16, flags: u8) -> Span { + Span { start_ms, length_ms, visible_bp, flags } + } + + fn claim(spans: Vec) -> Claim { + Claim { + room: [1; 32], + cue: [2; 32], + session: session_of(&[1; 32], &key(9).verifying_key().to_bytes()), + slot: 0, + creative_hash: [3; 32], + player: [0; 32], + geometry: geometry::AD, + frames: NOT_EXPOSED, + objects: vec![[4; 32]], + spans, + attestation: vec![], + sig: [0; 64], + } + .sign(&key(9)) + } + + #[test] + fn a_claim_round_trips_and_its_rules_refuse() { + let c = claim(vec![span(0, 1_000, 10_000, 1), span(1_000, 500, 4_000, 0), span(3_000, 100, 0, 2)]); + assert_eq!(Claim::parse(&c.to_bytes()), Ok(c.clone())); + assert_eq!(c.check_signature(), Ok(())); + let with = |f: &dyn Fn(&mut Claim)| { + let mut x = c.clone(); + f(&mut x); + Claim::parse(&x.to_bytes()) + }; + assert_eq!(with(&|x| x.slot = 16), Err(Reason::ClaimMalformed)); + assert_eq!(with(&|x| x.geometry = 4), Err(Reason::ClaimMalformed)); + assert_eq!(with(&|x| x.spans[1].visible_bp = 10_001), Err(Reason::ClaimMalformed)); + assert_eq!(with(&|x| x.spans[1].length_ms = 0), Err(Reason::ClaimMalformed)); + assert_eq!(with(&|x| x.spans[0].flags = 2), Err(Reason::ClaimMalformed), "the first span resumes nothing"); + assert_eq!(with(&|x| x.spans[2].flags = 0), Err(Reason::ClaimMalformed), "a gap is a resumption"); + assert_eq!(with(&|x| x.spans[1].start_ms = 999), Err(Reason::ClaimMalformed), "no media time presented twice"); + assert_eq!(with(&|x| x.spans[0].flags = 4), Err(Reason::ClaimMalformed)); + assert_eq!(with(&|x| x.objects.clear()), Err(Reason::ClaimMalformed)); + let b = c.to_bytes(); + assert_eq!(Claim::parse(&b[..b.len() - 1]), Err(Reason::ClaimMalformed)); + assert_eq!(Claim::parse(&[b.as_slice(), &[0]].concat()), Err(Reason::ClaimMalformed)); + let mut bad = c.clone(); + bad.sig[3] ^= 1; + assert_eq!(bad.check_signature(), Err(Reason::BadSignature)); + } + + #[test] + fn the_quartile_is_the_contiguous_coverage_from_the_start() { + assert_eq!(quartile(&[], 15_000), 0); + assert_eq!(quartile(&[span(10, 100, 0, 0)], 15_000), 1, "started, not from 0"); + assert_eq!(quartile(&[span(0, 3_749, 0, 0)], 15_000), 1); + assert_eq!(quartile(&[span(0, 3_750, 0, 0)], 15_000), 2); + assert_eq!(quartile(&[span(0, 4_000, 0, 0), span(4_000, 4_000, 0, 2)], 15_000), 3, "a pause keeps the coverage"); + assert_eq!(quartile(&[span(0, 4_000, 0, 0), span(9_000, 6_000, 0, 2)], 15_000), 2, "a skip ends it"); + assert_eq!(quartile(&[span(0, 11_250, 0, 0)], 15_000), 4); + assert_eq!(quartile(&[span(0, 15_000, 0, 0)], 15_000), 5); + } + + #[test] + fn the_run_is_continuous_qualifying_play() { + let video = Requirement { require: require::CLAIM, rule: rule::VIDEO, ..Default::default() }; + let cross = Requirement { require: require::CLAIM | require::AUDIBLE, rule: rule::CROSS_MEDIA, ..Default::default() }; + let c = claim(vec![span(0, 1_500, 5_000, 1), span(1_500, 1_500, 10_000, 3), span(3_000, 1_000, 10_000, 1), span(4_000, 600, 4_999, 1)]); + assert_eq!(runs(&c, &video), (2_500, 4_000), "the pause breaks the run"); + assert_eq!(runs(&c, &cross), (2_500, 2_500)); + let mut screen = c.clone(); + screen.geometry = geometry::SCREEN; + assert_eq!(runs(&screen, &video), (0, 0), "the video rule measures pixels"); + let muted = claim(vec![span(0, 5_000, 10_000, 0)]); + assert_eq!(runs(&muted, &cross).0, 0); + } + + #[test] + fn a_requirement_is_well_formed_by_its_rules() { + let ok = |require, rule, token_type, issuer: &[u8]| Requirement { require, rule, token_type, issuer: issuer.to_vec() }.well_formed(); + assert!(ok(0, 0, 0, b"")); + assert!(ok(1, 1, 0, b"")); + assert!(ok(7, 2, 2, b"issuer.example")); + assert!(!ok(2, 0, 2, b"i"), "attestation without claims"); + assert!(!ok(1, 0, 0, b""), "claims with no rule"); + assert!(!ok(0, 1, 0, b""), "a rule with no claims"); + assert!(!ok(1, 3, 0, b"")); + assert!(!ok(3, 1, 3, b"i"), "a token type RFC 9578 does not define"); + assert!(!ok(3, 1, 2, b""), "no issuer"); + assert!(!ok(1, 1, 2, b""), "a token type without attestation"); + assert!(!ok(9, 1, 0, b"")); + } + + #[test] + fn touched_objects_follow_the_spans() { + let cr = Creative { objects: (0..4u8).map(|i| CreativeObject { content_hash: [i; 32], duration_ms: 2_000 }).collect() }; + assert_eq!(cr.duration_ms(), 8_000); + assert_eq!(cr.touched(&[span(0, 2_000, 0, 0)]), vec![[0; 32]]); + assert_eq!(cr.touched(&[span(0, 2_001, 0, 0), span(6_000, 10, 0, 2)]), vec![[0; 32], [1; 32], [3; 32]]); + } + + #[test] + fn a_challenge_is_rfc_9577s_struct() { + let t = token_challenge(2, b"issuer.example", &[7; 32]); + assert_eq!(&t[..4], &[0, 2, 0, 14]); + assert_eq!(t.len(), 2 + 2 + 14 + 1 + 32 + 2); + assert_eq!(t[18], 32); + } +} diff --git a/wai/wai-rs/src/live_ads/presentation_corpus.rs b/wai/wai-rs/src/live_ads/presentation_corpus.rs new file mode 100644 index 000000000..55d756e05 --- /dev/null +++ b/wai/wai-rs/src/live_ads/presentation_corpus.rs @@ -0,0 +1,624 @@ +//! 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, test vector 1: 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 { + 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 { + 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 { + vec![creative_x(), creative_y(), creative_z(), creative_w()] +} + +fn issuers() -> Vec { + 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 { + 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, + /// The key that signs it: `session_of`'s unless a control says otherwise. + signer: Option, + /// A token for this claim's own challenge, by this index (its nonce and salt). + token: Option, +} + +impl Draft { + fn new(room: &Room, cue: [u8; 32], session_of: u8, slot: u8, creative: Creative, spans: Vec) -> 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 { + 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, 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, + receipts: Vec, + pods: Vec>, + /// Each claim's bytes, what it is, and the verdict it was written to get. + claims: Vec<(Vec, &'static str, Result<(), pr::Reason>)>, + intent: Intent, +} + +impl Vector { + fn run(&self) -> Run { + let chains = self.room.chains(); + let claims: Vec> = 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 { + 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 = vec![ + 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| 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(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 = vec![ + 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| Draft::new(&r, out_, pl, slot, c.clone(), spans); + let tv = |pl: u8, slot: u8, c: &Creative, t: Option| 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 { + 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::>()}) +} + +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::>()}) +} + +fn vector_json(v: &Vector, run: &Run) -> Value { + let chains: Vec> = 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::>())); + o.insert("claim_about".into(), json!(v.claims.iter().map(|c| c.1).collect::>())); + 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 vector 1"); + 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 = [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::>(), + "issuers": issuers().iter().map(issuer_json).collect::>(), + "players": players, + "vectors": rooms.iter().map(|(v, run)| vector_json(v, run)).collect::>(), + }); + 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 { + 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::>()?, + }) +} + +fn issuer_of(v: &Value) -> Option { + 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::>()?, + }) +} + +/// 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) -> Vec { + let mut fails = Vec::new(); + let Some(doc) = files.get("vectors.json").and_then(|s| serde_json::from_str::(s).ok()) else { + return vec!["vectors.json does not read".into()]; + }; + let held = (|| Some((doc["creatives"].as_array()?.iter().map(creative_of).collect::>>()?, doc["issuers"].as_array()?.iter().map(issuer_of).collect::>>()?)))(); + 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>> = v["chains"].as_array()?.iter().map(base::hex_list).collect::>()?; + let pods: Vec> = v["pods"].as_array()?.iter().map(base::pod_of).collect::>()?; + 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 +} diff --git a/wai/wai-rs/tests/ad_presentation.rs b/wai/wai-rs/tests/ad_presentation.rs new file mode 100644 index 000000000..07b4b0600 --- /dev/null +++ b/wai/wai-rs/tests/ad_presentation.rs @@ -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 { + 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 = 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 = + text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect(); + let want: Vec = Reason::ALL.iter().map(|r| r.code().to_string()).collect(); + assert_eq!(listed, want, "§11's table and Reason::ALL differ"); +} -- 2.54.0 (Apple Git-157) From b6b9c174566d50b189241842a6e8ab3342755ef2 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 21 Oct 2026 12:18:44 -0400 Subject: [PATCH 2/4] wai: the ad-presentation extension, its corpus README and an independent verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extensions/ad-presentation.md defines the WPC1 claim, the session a session key names, the creative digest, the video and cross-media viewability rules as their texts state them, the quartile, the RFC 9577 token bound to one impression and verified as RFC 9578 §6.4 has it, the presentation fields of a WAP1 policy of version 2, and each claim's verdict; live-ads.md takes the version and the spend that counts. ad_presentation_verify.py is written from the text, verifies RFC 9578's own vector, gives every claim the corpus's verdict, and catches every mutated copy and every check dropped from itself. The corpus gains a signed claim that breaks a span rule. Co-Authored-By: Claude Opus 5.5 --- wai/extensions/ad-presentation.md | 567 ++++++++++++++++++ wai/extensions/live-ads.md | 47 +- wai/live-conformance/README.md | 9 +- .../ad_presentation_verify.py | 555 +++++++++++++++++ wai/live-conformance/live_ads_verify.py | 52 +- wai/live-conformance/presentation/README.md | 90 +++ .../presentation/vectors.json | 15 +- .../src/live_ads/presentation_corpus.rs | 5 + 8 files changed, 1313 insertions(+), 27 deletions(-) create mode 100644 wai/extensions/ad-presentation.md create mode 100644 wai/live-conformance/ad_presentation_verify.py create mode 100644 wai/live-conformance/presentation/README.md diff --git a/wai/extensions/ad-presentation.md b/wai/extensions/ad-presentation.md new file mode 100644 index 000000000..94ddf3e11 --- /dev/null +++ b/wai/extensions/ad-presentation.md @@ -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, test vector 1. +> +> 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; RFC 9578 Appendix A.2's +test vector 1 (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. diff --git a/wai/extensions/live-ads.md b/wai/extensions/live-ads.md index b3adf5d2c..1a3609b94 100644 --- a/wai/extensions/live-ads.md +++ b/wai/extensions/live-ads.md @@ -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 diff --git a/wai/live-conformance/README.md b/wai/live-conformance/README.md index 1a2e20d09..aae34f6e9 100644 --- a/wai/live-conformance/README.md +++ b/wai/live-conformance/README.md @@ -15,6 +15,7 @@ and the independent verifier of each, beside it. | [`steering/`](steering/) | [energy-steering](../extensions/energy-steering.md) (`wai.live.steering`) | `energy_steering_verify.py` | | [`moderation/`](moderation/) | [live-moderation](../extensions/live-moderation.md) (`wai.live.moderation`) | `live_moderation_verify.py` | | [`ads/`](ads/) | [live-ads](../extensions/live-ads.md) (`wai.live.ads`) | `live_ads_verify.py` | +| [`presentation/`](presentation/) | [ad-presentation](../extensions/ad-presentation.md) (`wai.live.ad_presentation`) | `ad_presentation_verify.py` | | [`e2ee/`](e2ee/) | [confidentiality](../extensions/confidentiality.md) (`wai.live.e2ee`) | `e2ee_verify.py` | | [`watermark/`](watermark/) | [forensic-watermark](../extensions/forensic-watermark.md) (`wai.live.watermark`) | `live_watermark_verify.py` | | [`c2pa-live/`](c2pa-live/) | [live-provenance](../extensions/live-provenance.md) (`wai.live.provenance`) | `c2pa_live_verify.py` | @@ -26,11 +27,14 @@ writes `recordings/`, `wai_timed_text_vectors gen` writes `captions/` and `wai_live_metering_vectors gen` writes `metering/`, `wai_energy_steering_vectors gen` writes `steering/`, `wai_live_moderation_vectors gen` writes `moderation/`, `wai_live_watermark_vectors gen` writes `watermark/`, `wai_live_ads_vectors gen` writes -`ads/` and `wai_live_provenance_vectors gen` writes `c2pa-live/`, each from records authored +`ads/`, `wai_ad_presentation_vectors gen` writes `presentation/` and +`wai_live_provenance_vectors gen` writes `c2pa-live/`, each from records authored in code, from fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`, `compose/README.md`, `recordings/README.md`, `captions/README.md`, `metering/README.md`, `steering/README.md`, `moderation/README.md`, -`watermark/README.md`, `ads/README.md`, `c2pa-live/README.md`); `c2pa-live/` cuts its media +`watermark/README.md`, `ads/README.md`, `presentation/README.md`, `c2pa-live/README.md`); +`presentation/` also carries the test vector of RFC 9578 Appendix A.2 its issuer key comes +from; `c2pa-live/` cuts its media from the packaging corpus's AV1 floor. `joule_moq_e2ee_vectors gen` (in `joule-code/joule-code-rs/crates/joule-moq-mls`) writes `e2ee/vectors.json` from fixed seeds, and `e2ee/` also holds the test vectors RFC 9605 and `draft-ietf-moq-secure-objects-01` @@ -52,6 +56,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 diff --git a/wai/live-conformance/ad_presentation_verify.py b/wai/live-conformance/ad_presentation_verify.py new file mode 100644 index 000000000..cb900bb43 --- /dev/null +++ b/wai/live-conformance/ad_presentation_verify.py @@ -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 RFC 9578 Appendix A.2's test vector 1; +- 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 + +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(" 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(" 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, test vector 1: 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 vector 1 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 test vector 1: 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 ", file=sys.stderr) + sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/wai/live-conformance/live_ads_verify.py b/wai/live-conformance/live_ads_verify.py index 7559deee6..ebcb6795b 100644 --- a/wai/live-conformance/live_ads_verify.py +++ b/wai/live-conformance/live_ads_verify.py @@ -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() diff --git a/wai/live-conformance/presentation/README.md b/wai/live-conformance/presentation/README.md new file mode 100644 index 000000000..cf58e56b7 --- /dev/null +++ b/wai/live-conformance/presentation/README.md @@ -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, test vector 1, 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. diff --git a/wai/live-conformance/presentation/vectors.json b/wai/live-conformance/presentation/vectors.json index df629a354..75100516e 100644 --- a/wai/live-conformance/presentation/vectors.json +++ b/wai/live-conformance/presentation/vectors.json @@ -466,6 +466,7 @@ "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aaf723833b66cc049001d83ca85d3e9ea3019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee32ff09f78cd3c4f9506aecd1cdb170cf0dbc635d7a97e0ea5c9cba1625b15b27b01ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd1771808a22abdf38cc8944c4019f15c93913e7b595c585a607e21fcd830f4dc7dc827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298010000000000983a00001027010000f942c30a21aff85cde278f9ac8b6b7e9abb56bf24ebf854d9306dd025150b3acbda582fe002eedfaeb359ae1324491b744125138776ec694359df7e204130709", "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aad67c9c9715765cd08d686b0b418f8f4f00a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d40caacc1e30b23e3628917a50e7d71172caf9974aa98539b416b9772b286d32801ffffffff0200a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f70010000000000401f0000102701000019c57219b1cd94a8232de8f4b0765a45b227ed0f61c4b18b6527f77787a25895c70ca40e5a822ff1ee7ceae9dfc8c07a7a3dbb03c68fe407153beca0f5846d06", "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aad67c9c9715765cd08d686b0b418f8f4f019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee340caacc1e30b23e3628917a50e7d71172caf9974aa98539b416b9772b286d32801ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd12945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298010000000000983a000010270100005d0350644ab1f45e307b858bc0d6172debc31a355ef298e44c846f4a52020d88372fd867edbaba1c0710d1d6c28def9dbe96a134b5dd3ef1dcdfca7c7d499308", + "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aad67c9c9715765cd08d686b0b418f8f4f019f3a53877e3f6331996e6759c117258756f1c083736908d54368f87683eeeee340caacc1e30b23e3628917a50e7d71172caf9974aa98539b416b9772b286d32801ffffffff030013ea9b7e608b702a3e4fab9d28a1fa2837fbd518c005e396cbfa052fcadf7cd12945e92a5c34a9348b2c143ec673a6d67d00189b9aa996cd3eb30aa1055cf777c827da95ea0d3661ff5a6802a9df61e784c8c493cdb7b0bcc33548806a020298020000000000a00f0000102701401f0000581b00001027010000fb2a7d521a2c1d6e7ff97d35642f27fcdc3462aedcfcecb7988e041d301c9cffa40fe039fdaefd2b036bb04c58a49caa8ad73c288f7f5130a69a574ffd35f10b", "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aad60faab986417f8eb4c7665128a7a0c800a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d134ff5aa797eda39e08db347ecbb913720755d5d9fdfa7192b4333651ff3b8bb01ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a00001027010000743333bd8698d5f0c53ff121e87b4f6a31fb206ef46658e08c40759ac35270fdda66cd8d5e61b516af47f1e83de829b7f425863d1fb931b16e6f459e5c57f405", "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aad60faab986417f8eb4c7665128a7a0c800a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d134ff5aa797eda39e08db347ecbb913720755d5d9fdfa7192b4333651ff3b8bb01ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a00001027010000743333bd8698d5f0c53ff121e87b4f6a31fb206ef46658e08c40759ac35270fdda66cd8d5e61b516af47f1e83de829b7f425863d1fb931b16e6f459e5c57f4", "57504331010cd040155378d4dd3132ac06126fe7ad98cf014c13f4381f3cd87d39cb332ddce9a7c58217dd8811ffb663763bd71832a9df5b06fd13ba1dc00328c8888860aa28d08ac9bb09ab45c22ff9a59e54ad9d00a056e7de984fa323c5e751f55f39ad8c95d2bc6ffa9eb044ba1e3ce7986a7f5d7ca33fadaa0654f9da41d85a263d6e589b0c38b8ee6cff5d3196433d3c51a4e702ffffffff0400a56293a5e2f4c58b299e04eccaeece2fd64a520fcf764d0391afce3ad503dd2ee85fbd87b131aa49cc8e8f1ddc3668eb291a7506e0838c7e4ae41384088c3f704ccccf8ccd1aeb72cf044e0d718c1d2fbb6fdc0c6f1c6e0b060d58555ebc80435a696e3581b9457f7b306f8e8d035e1c97e14a99ed69f5fa2dd8786e1115e20f010000000000983a00001027010000304f9e03a20ee09e687d0486f36e42a4c87a23fb194d7e5d682c50a3054d9a3b631ba7229531f629e1077d1b4f02597daca352d2d12aabaf4a87db942d3e440a", @@ -482,6 +483,7 @@ "viewer 3, ad-y: a presented object's hash is not the creative's second object", "viewer 5, ad-x: 8 s of 15 presented, the midpoint, where the receipt states it complete", "viewer 5, ad-y: one bit of the signature flipped, so the impression has no claim", + "viewer 5, ad-y, signed: a skip from 4 s to 8 s with no resumption stated, which breaks §5.4 rule 6", "viewer 6, whose session has no receipt for the break", "a claim short of its last byte", "viewer 7, ad-x: geometry stated as a full-screen television output, which the video rule does not admit", @@ -593,12 +595,12 @@ "impressions": [ { "slot": 0, - "claim": 12, + "claim": 13, "verdict": "claim_not_viewable" }, { "slot": 1, - "claim": 13, + "claim": 14, "verdict": "presented" } ] @@ -696,6 +698,15 @@ "run_ms": null, "viewable_ms": null }, + { + "digest": null, + "verdict": "claim_malformed", + "receipt": null, + "slot": null, + "quartile": null, + "run_ms": null, + "viewable_ms": null + }, { "digest": "04ecdb6b231ba15abef1ca3085aacecff064f4fb6af7f6ca82e18ce18118230d", "verdict": "claim_no_decision", diff --git a/wai/wai-rs/src/live_ads/presentation_corpus.rs b/wai/wai-rs/src/live_ads/presentation_corpus.rs index 55d756e05..1f27730c3 100644 --- a/wai/wai-rs/src/live_ads/presentation_corpus.rs +++ b/wai/wai-rs/src/live_ads/presentation_corpus.rs @@ -290,6 +290,11 @@ fn vectors() -> Vec { 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)), -- 2.54.0 (Apple Git-157) From 760e5c343d62fd3695592656bad9fbfa4e9a328f Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 21 Oct 2026 12:23:52 -0400 Subject: [PATCH 3/4] wai: register wai.live.ad_presentation; a reference producer in the browser client; CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry lists the record and the WPC1 magic (the export regenerated), SPEC §5 describes the claim and the billing rule, and the CHANGELOG records them. joule-moq-web gains web/ads/presentation.js, a span recorder over a video element and the claim's writer and signer on WebCrypto's Ed25519, whose node tests turn observation sequences into the corpus's claims byte for byte; no page plays ads yet. wai-conformance runs the module's tests, the corpus test, ad_presentation_verify.py and the producer's tests; joule-moq-web's e2e script runs the producer's tests with the page's other unit tests. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/joule-moq-web.yml | 2 + .github/workflows/wai-conformance.yml | 40 ++- .../crates/joule-moq-web/README.md | 6 +- .../crates/joule-moq-web/e2e/run.sh | 2 +- .../joule-moq-web/web/ads/presentation.js | 242 ++++++++++++++++++ .../web/ads/presentation.test.mjs | 95 +++++++ wai/CHANGELOG.md | 61 +++++ wai/REGISTRY.md | 6 +- wai/SPEC.md | 25 +- wai/registry/capabilities.json | 21 ++ wai/vocab/v2.ttl | 7 + wai/wai-rs/src/codecs/registry.rs | 10 +- .../src/live_ads/presentation_corpus.rs | 4 +- 13 files changed, 509 insertions(+), 12 deletions(-) create mode 100644 joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.js create mode 100644 joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.test.mjs diff --git a/.github/workflows/joule-moq-web.yml b/.github/workflows/joule-moq-web.yml index e3810f364..385e43907 100644 --- a/.github/workflows/joule-moq-web.yml +++ b/.github/workflows/joule-moq-web.yml @@ -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: diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 423bb966c..0813e1e42 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -106,6 +106,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. @@ -780,8 +787,9 @@ jobs: # live_metering_verify.py (the live-metering corpus), energy_steering_verify.py (the # energy-steering corpus), and live_moderation_verify.py (the live-moderation corpus), and # e2ee_verify.py (the end-to-end encrypted rooms corpus), live_watermark_verify.py (the - # forensic-watermark corpus), live_ads_verify.py (the live-ads corpus) and - # c2pa_live_verify.py (the live-provenance corpus). + # forensic-watermark corpus), live_ads_verify.py (the live-ads corpus), + # ad_presentation_verify.py (the ad-presentation corpus) and c2pa_live_verify.py (the + # live-provenance corpus). # Each exits non-zero on any failure or any difference, and the step fails with it. # One platform: what they check is the reading of the text, not the architecture. independent-verifiers: @@ -806,6 +814,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 @@ -1096,6 +1110,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, diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/README.md b/joule-code/joule-code-rs/crates/joule-moq-web/README.md index bea5fbc5e..87ddafdbf 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-web/README.md @@ -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 diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh index 1f648e6df..a5b06b4cb 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh @@ -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=() diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.js new file mode 100644 index 000000000..ef57c6f82 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.js @@ -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); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.test.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.test.mjs new file mode 100644 index 000000000..f92a5526e --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/ads/presentation.test.mjs @@ -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)); +}); diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 4cd3c90bd..8845ced92 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -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.live.provenance`: C2PA 2.4 live video provenance for room tracks and takes, tied to the JWP receipts - **The extension** ([live-provenance](extensions/live-provenance.md)) registers the record diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index 7066284de..7db852b6e 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -42,8 +42,8 @@ Every registered string has one kind. `wai.video.manifest` and its siblings; the live records `wai.live.events`, `wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`, `wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee`, - `wai.live.watermark`, `wai.live.ads` and `wai.live.provenance`; timed text, - `wai.text.timed`). + `wai.live.watermark`, `wai.live.ads`, `wai.live.ad_presentation` and + `wai.live.provenance`; timed text, `wai.text.timed`). It names no payload format. A derivation or record string is not a payload capability, so it is not @@ -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 | @@ -325,6 +326,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 | diff --git a/wai/SPEC.md b/wai/SPEC.md index 14d644d45..f3305e97e 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -594,8 +594,8 @@ kind: `wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`, `wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`, `wai.live.metering`, `wai.live.steering`, `wai.live.moderation`, `wai.live.e2ee`, - `wai.live.watermark`, `wai.live.ads` and `wai.live.provenance`, and timed text, - `wai.text.timed`), which names no payload format. + `wai.live.watermark`, `wai.live.ads`, `wai.live.ad_presentation` and + `wai.live.provenance`, and timed text, `wai.text.timed`), which names no payload format. Only payload, companion and refinement capabilities have a media class, a fidelity tier, a replicate class and a role. A derivation or record string is not a payload @@ -1071,6 +1071,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 diff --git a/wai/registry/capabilities.json b/wai/registry/capabilities.json index 0e28a7a91..ff57036fe 100644 --- a/wai/registry/capabilities.json +++ b/wai/registry/capabilities.json @@ -602,6 +602,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", @@ -2060,6 +2074,13 @@ "section": "§4.2", "status": "defined" }, + { + "name": "WPC1", + "kind": "magic", + "document": "extensions/ad-presentation.md", + "section": "§5.1", + "status": "defined" + }, { "name": "WRD1", "kind": "magic", diff --git a/wai/vocab/v2.ttl b/wai/vocab/v2.ttl index 093c3babe..cb0cb821a 100644 --- a/wai/vocab/v2.ttl +++ b/wai/vocab/v2.ttl @@ -534,6 +534,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 ; diff --git a/wai/wai-rs/src/codecs/registry.rs b/wai/wai-rs/src/codecs/registry.rs index 3deae314e..a7cd826d2 100644 --- a/wai/wai-rs/src/codecs/registry.rs +++ b/wai/wai-rs/src/codecs/registry.rs @@ -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"; /// One row per string SPEC §5 registers that is a payload, companion or record: @@ -311,6 +312,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_AUDIO_IAMF, S_IMMERSIVE, None, "2026-06-17"), (CAP_AUDIO_AC4, S_IMMERSIVE, None, "2026-06-17"), @@ -364,6 +366,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, ]; @@ -521,6 +524,7 @@ const LIVE_MODERATION: &str = "extensions/live-moderation.md"; const CONFIDENTIALITY: &str = "extensions/confidentiality.md"; const FORENSIC_WATERMARK: &str = "extensions/forensic-watermark.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 fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str, @@ -532,8 +536,9 @@ const fn wire(name: &'static str, kind: WireKind, document: &'static str, sectio /// (http-delivery, moq-streaming-format), of the key discovery those bindings serve /// (jwp-receipts §7), of packaging, of live events, of live rooms, of capture binding, of /// live composition, of live recording, of timed text, of live metering, of energy steering, -/// of live moderation, of confidentiality, of forensic watermark sessions, of live ads and of -/// live provenance, in document order. The export lists them sorted by name and kind. +/// of live moderation, of confidentiality, of forensic watermark sessions, of live ads, of ad +/// presentation and of live provenance, in document order. The export lists them sorted by +/// name and kind. pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[ wire("WAI1", WireKind::Magic, "SPEC.md", "§2", WireStatus::Defined), wire("WAI2", WireKind::Magic, "SPEC.md", "§7.1", WireStatus::Defined), @@ -619,6 +624,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), ]; diff --git a/wai/wai-rs/src/live_ads/presentation_corpus.rs b/wai/wai-rs/src/live_ads/presentation_corpus.rs index 1f27730c3..4b8434e74 100644 --- a/wai/wai-rs/src/live_ads/presentation_corpus.rs +++ b/wai/wai-rs/src/live_ads/presentation_corpus.rs @@ -246,7 +246,7 @@ fn vectors() -> Vec { // 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 = vec![ + 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]), @@ -316,7 +316,7 @@ fn vectors() -> Vec { 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 = vec![ + 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]), -- 2.54.0 (Apple Git-157) From a7853ad012858d86db328aa879da188d2dcb4d6a Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 21 Oct 2026 12:28:47 -0400 Subject: [PATCH 4/4] wai ad-presentation: name RFC 9578's vector by its place, not a count Co-Authored-By: Claude Opus 5.5 --- wai/extensions/ad-presentation.md | 6 +++--- wai/live-conformance/ad_presentation_verify.py | 8 ++++---- wai/live-conformance/presentation/README.md | 2 +- wai/wai-rs/src/live_ads/presentation_corpus.rs | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/wai/extensions/ad-presentation.md b/wai/extensions/ad-presentation.md index 94ddf3e11..69d9c1046 100644 --- a/wai/extensions/ad-presentation.md +++ b/wai/extensions/ad-presentation.md @@ -43,7 +43,7 @@ > 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, test vector 1. +> 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. @@ -496,8 +496,8 @@ the ceiling. [`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; RFC 9578 Appendix A.2's -test vector 1 (its `pkI`, TokenChallenge and token), whose issuer key signs the corpus's +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, diff --git a/wai/live-conformance/ad_presentation_verify.py b/wai/live-conformance/ad_presentation_verify.py index cb900bb43..6581fb623 100644 --- a/wai/live-conformance/ad_presentation_verify.py +++ b/wai/live-conformance/ad_presentation_verify.py @@ -13,7 +13,7 @@ here, from the standard library: - 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 RFC 9578 Appendix A.2's test vector 1; + 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 @@ -408,7 +408,7 @@ def held(doc): def rfc_vector(doc): - """RFC 9578 Appendix A.2, test vector 1: its token verifies under its key.""" + """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) @@ -424,7 +424,7 @@ def check(doc, files, quiet=False): 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 vector 1 does not verify") + fails.append("RFC 9578 A.2's first test vector does not verify") written = {} for v in doc.get("vectors", []): try: @@ -514,7 +514,7 @@ def main(corpus_dir): files[name] = f.read() held(doc) ok = rfc_vector(doc) - print(("PASS" if ok else "FAIL") + " RFC 9578 Appendix A.2 test vector 1: its token verifies under its key") + 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"}) diff --git a/wai/live-conformance/presentation/README.md b/wai/live-conformance/presentation/README.md index cf58e56b7..13c22bc7e 100644 --- a/wai/live-conformance/presentation/README.md +++ b/wai/live-conformance/presentation/README.md @@ -25,7 +25,7 @@ accepted room under a policy that requires claims (§9.4): 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, test vector 1, as printed there. A conforming verifier verifies that token under +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 diff --git a/wai/wai-rs/src/live_ads/presentation_corpus.rs b/wai/wai-rs/src/live_ads/presentation_corpus.rs index 4b8434e74..2628ccf8f 100644 --- a/wai/wai-rs/src/live_ads/presentation_corpus.rs +++ b/wai/wai-rs/src/live_ads/presentation_corpus.rs @@ -25,7 +25,7 @@ use super::corpus::{self as base, policy, room, seal, Room, Seal, Spec, PRODUCER 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, test vector 1: the issuer's public key (`pkI`), a DER +/// 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. @@ -521,7 +521,7 @@ 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 vector 1"); + 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 { -- 2.54.0 (Apple Git-157)