From 3af54eb5f75f2777b882ba4b5ff7aa366653c3f6 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 09:15:33 -0400 Subject: [PATCH 01/16] wai: receipts, keys, grants and parameter sets over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http_binding::receipts (receipt_claims): receipt files named by their link hash, the receipt index read item by item, each delivery item through jwp-receipts §3 against the client's chains with missing parents walked by their links up to a bound, step 4 against what the client holds of the object, and claims by subject and parent; a soft-binding claim about another object attests the ancestor. http_binding::keys (key_discovery): a key log anchored only from the origin's own location over HTTPS with a 200 and no redirect, logs through caches adding revisions without moving the anchor, grants files as locators, and parameter sets held only by their pins and §7.9. B's retrofit: jwp_hash::EstablishedHash, built only from bytes or by a fetch session whose head proof verified (FetchSession::established); ObjectReceipt::accept_hash and verify_against_hash, and WaiReceiptProperty::check_against_object_hash, which accept and check_against_object call; ReceiptChains::verify_admitted_object. Corpus: group receipts, claims, indexes, a key log, status, grants file and a set served by digest, with receipt_file, receipts, key_log, grants_file and param_set cases; http_receipts.py checks them for http_verify.py. Co-Authored-By: Claude Opus 5.5 --- ...f595cae3d31cb7f9018699e29e738340272a4.json | 1 + .../.well-known/wai-key-status | 1 + wai/http-conformance/.well-known/wai-keys | 1 + wai/http-conformance/cases.json | 60 +- wai/http-conformance/cases/keys/wai-keys-r0 | 1 + .../cases/keys/wai-keys-revoked | 1 + ...7e5e20db3346ffc4e14bd4e30c0c6a9e9f358.json | 1 + .../receipts/O9a-bad-signature.wai.receipts | 1 + wai/http-conformance/http_receipts.py | 654 +++++++++++++ wai/http-conformance/http_verify.py | 31 + ...90032b187a93787be15f1478080a8.wai.receipts | 1 + ...d078690cb7bea2435ae3d1df5f4f3.wai.receipts | 1 + ...1b55a4b84245112a5a5ea948458ab.wai.receipts | 1 + ...5ee1e72bafe4892992fc6bef88be9.wai.receipts | 1 + ...18c0a4c17490499fbb31aefd5cd0c.wai.receipts | 1 + ...5ff9c7b189fb6699bf92894c29a6f.wai.receipts | 1 + ...aa0d4dc4c2e3dfe40aab759a90133.wai.receipts | 1 + ...65c640d73a040ebc5d5cf86e4ecbb.wai.receipts | 1 + ...7e429d4be353fb387c54edd9a24217853ae75.json | 1 + ...4bf633eab89bf035078137b89936002072768.json | 1 + ...92bad7a73ee9190fc7db33d15de262d535491.json | 1 + ...5488ff18a2be9bfb95efc7921142969ec3807.json | 1 + ...565c0588125ea25979e37e1d90723b674654c.json | 1 + ...500d5f1f1aa5befacff41f2ac4a19bbbc9765.json | 1 + ...edab53cf56a9be5afea2dc3d264ee978a03c5.json | 1 + ...ac54e44cf157fd26307fca69de8e1c7cd9d30.json | 1 + ...8ef939b8674098bc2e38910bba7b2244df018.json | 1 + ...68460f595cae3d31cb7f9018699e29e738340272a4 | Bin 0 -> 41781 bytes wai/wai-rs/src/bin/wai_cli/http_vectors.rs | 44 +- .../src/bin/wai_cli/http_vectors_receipts.rs | 912 ++++++++++++++++++ wai/wai-rs/src/http_binding/keys.rs | 285 ++++++ wai/wai-rs/src/http_binding/mod.rs | 14 +- wai/wai-rs/src/http_binding/receipts.rs | 493 ++++++++++ wai/wai-rs/src/http_binding/session.rs | 15 + wai/wai-rs/src/http_binding/tests_receipts.rs | 507 ++++++++++ wai/wai-rs/src/jwp_delivery.rs | 81 +- wai/wai-rs/src/jwp_hash.rs | 69 ++ 37 files changed, 3170 insertions(+), 19 deletions(-) create mode 100644 wai/http-conformance/.well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json create mode 100644 wai/http-conformance/.well-known/wai-key-status create mode 100644 wai/http-conformance/.well-known/wai-keys create mode 100644 wai/http-conformance/cases/keys/wai-keys-r0 create mode 100644 wai/http-conformance/cases/keys/wai-keys-revoked create mode 100644 wai/http-conformance/cases/rcpt/02154a175d9519491b6321797e67e5e20db3346ffc4e14bd4e30c0c6a9e9f358.json create mode 100644 wai/http-conformance/cases/receipts/O9a-bad-signature.wai.receipts create mode 100644 wai/http-conformance/http_receipts.py create mode 100644 wai/http-conformance/objects/1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8.wai.receipts create mode 100644 wai/http-conformance/objects/31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3.wai.receipts create mode 100644 wai/http-conformance/objects/53a48057cb99b88fd235b9165799122df2d1b55a4b84245112a5a5ea948458ab.wai.receipts create mode 100644 wai/http-conformance/objects/6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9.wai.receipts create mode 100644 wai/http-conformance/objects/753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c.wai.receipts create mode 100644 wai/http-conformance/objects/922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f.wai.receipts create mode 100644 wai/http-conformance/objects/a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133.wai.receipts create mode 100644 wai/http-conformance/objects/b7a711ab4b0a0939a561a16b3167039e7fe65c640d73a040ebc5d5cf86e4ecbb.wai.receipts create mode 100644 wai/http-conformance/objects/rcpt/0755611daa44a9474511562d9ac7e429d4be353fb387c54edd9a24217853ae75.json create mode 100644 wai/http-conformance/objects/rcpt/0c0b87f288cc459e99b914920284bf633eab89bf035078137b89936002072768.json create mode 100644 wai/http-conformance/objects/rcpt/14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491.json create mode 100644 wai/http-conformance/objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json create mode 100644 wai/http-conformance/objects/rcpt/8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c.json create mode 100644 wai/http-conformance/objects/rcpt/8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765.json create mode 100644 wai/http-conformance/objects/rcpt/dae83c921710311cbd34d8d4a2bedab53cf56a9be5afea2dc3d264ee978a03c5.json create mode 100644 wai/http-conformance/objects/rcpt/e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30.json create mode 100644 wai/http-conformance/objects/rcpt/ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018.json create mode 100644 wai/http-conformance/sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4 create mode 100644 wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs create mode 100644 wai/wai-rs/src/http_binding/keys.rs create mode 100644 wai/wai-rs/src/http_binding/receipts.rs create mode 100644 wai/wai-rs/src/http_binding/tests_receipts.rs diff --git a/wai/http-conformance/.well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json b/wai/http-conformance/.well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json new file mode 100644 index 000000000..f27160979 --- /dev/null +++ b/wai/http-conformance/.well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json @@ -0,0 +1 @@ +{"grants":[{"capabilities":["wai.neural.int_mlicpp"],"form":"decoder","kind":"wai-module-grant","module":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","not_after":"2026-10-06T12:00:00Z","not_before":"2026-10-05T12:00:00Z","origin":"https://origin.example","sig":"85283724547d6c4b72a44d4cb3b1f65aaf1e24c2ee0b5227fa8248e4ad7d7a38513354fadac9c7adc62b23725fa7156455ccd401d17d5ecc341a76b563b55504","signer_id":"did:web:origin.example#x9","signer_pubkey":"d62f016a1efd1e4fdf793eb42cd84471e1ba9f0cf04d1287b5cc71f616287cb8"},{"capabilities":["wai.neural.int_mlicpp"],"form":"decoder","kind":"wai-module-grant","module":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","not_after":"2026-10-06T12:00:00Z","not_before":"2026-10-05T12:00:00Z","origin":"https://origin.example","sig":"91958c6751f8d2a7fd0206a3f66e02e7b33ffca1c00b872a2f6951bb3c25dd4402054a772675b73a10c44b81d760a87932d9d5dbb805b5d4c38ac942f610b90c","signer_id":"did:web:origin.example#g1","signer_pubkey":"a7f6dfaf8f38b89ba8ce649b594f91e4d01fdc57f9c9493df43b5e50a9987367"}],"kind":"wai-module-grants","module":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4"} \ No newline at end of file diff --git a/wai/http-conformance/.well-known/wai-key-status b/wai/http-conformance/.well-known/wai-key-status new file mode 100644 index 000000000..83139e856 --- /dev/null +++ b/wai/http-conformance/.well-known/wai-key-status @@ -0,0 +1 @@ +{"head":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","issued_at":"2026-10-06T00:00:00Z","kind":"wai-key-status","origin":"https://origin.example","seq":1,"sig":"1c72d7f3c146edbf6f068e03bf444d66defb78dff1065593d17f82a388b1301511a0bbd45fb08b715f7551039dd555cb3baf794e39c4fabe8719b316aaa4a703"} \ No newline at end of file diff --git a/wai/http-conformance/.well-known/wai-keys b/wai/http-conformance/.well-known/wai-keys new file mode 100644 index 000000000..46bb7c375 --- /dev/null +++ b/wai/http-conformance/.well-known/wai-keys @@ -0,0 +1 @@ +{"kind":"wai-key-log","revisions":[{"grant_max_s":86400,"issued_at":"2026-10-01T00:00:00Z","keys":[{"compromised_since":null,"id":"d1","key":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","may_sign":["delivery","energy/Estimator","log-inclusion","soft-binding"],"status":"active","until":null},{"compromised_since":null,"id":"g1","key":"a7f6dfaf8f38b89ba8ce649b594f91e4d01fdc57f9c9493df43b5e50a9987367","may_sign":["module/decoder"],"status":"active","until":null}],"kind":"wai-key-set","next_root":"d481afb3d00a3168a515f522aec12623ae3ee77b919ad5db74353a9f55972d8c","origin":"https://origin.example","prev":null,"revoked_modules":[],"root":"4a72e4035bb435d0aa42d43b8873d9f07cb9bf476fe2ade0aba49983eebd425e","seq":0,"sig":"92de01efa058176b0dc77a5784d401427c6266eec4b5583b4a4ac3eea1f2e52c5cc30809c27fb6b3c1908bd29f6658c85452702677f88c392319249df6a9680f","status_max_age_s":3600},{"grant_max_s":86400,"issued_at":"2026-10-05T00:00:00Z","keys":[{"compromised_since":null,"id":"d1","key":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","may_sign":["delivery","energy/Estimator","log-inclusion","soft-binding"],"status":"active","until":null},{"compromised_since":null,"id":"g1","key":"a7f6dfaf8f38b89ba8ce649b594f91e4d01fdc57f9c9493df43b5e50a9987367","may_sign":["module/decoder"],"status":"active","until":null}],"kind":"wai-key-set","next_root":"d481afb3d00a3168a515f522aec12623ae3ee77b919ad5db74353a9f55972d8c","origin":"https://origin.example","prev":"7f06fd9425a3583d2007074cb32c75514d34b11d064c88304c6e4d4d4cb5ab01","revoked_modules":[],"root":"4a72e4035bb435d0aa42d43b8873d9f07cb9bf476fe2ade0aba49983eebd425e","seq":1,"sig":"abbca51a46e83d57166db431990bb3dc1340fc3effdff4537d3de2f1dbc53f36f73f8190bc59b5c718a00e03294c0bed5133f6806915af3eb1109b0de4dcf409","status_max_age_s":3600}]} \ No newline at end of file diff --git a/wai/http-conformance/cases.json b/wai/http-conformance/cases.json index 1765cf331..5a49f23e2 100644 --- a/wai/http-conformance/cases.json +++ b/wai/http-conformance/cases.json @@ -929,5 +929,63 @@ {"name":"same_pick_abr_O7_low","op":"same_pick","input":{"object":"O7","advertises":["wai.video.av1","wai.meta.hdr10plus"],"abr":{"policy":{"id":"throughput","last_n":1,"safety_permille":900},"state":{"buffer_ms":0,"samples":[[1000,12500,1000]],"last":null,"option_bytes":[],"option_joules_micro":[],"joule_budget_micro":null,"segment_ms":2000}}},"expect":{"pick":1}}, {"name":"fetch_abr_O3_high","op":"fetch","input":{"object":"O3","advertises":["wai.video.av1","wai.meta.hdr10plus"],"applies":["wai.meta.hdr10plus"],"abr":{"policy":{"id":"throughput","last_n":1,"safety_permille":900},"state":{"buffer_ms":0,"samples":[[1000,1250000,1000]],"last":null,"option_bytes":[],"option_joules_micro":[],"joule_budget_micro":null,"segment_ms":2000}},"fragment":"#h=1009"},"expect":{"role":"base","pick":1,"verified_by":"head-proof","why_whole":null,"body_bytes":78526,"requests":3,"companions":[2],"refinements":[],"abandoned":[],"dropped":[],"skipped":[],"component_sha256":"68a51be787c439226eb7bb1cf434e913fc1f0e221870da87f70615d667a7ab12"}}, {"name":"same_pick_abr_O3_high","op":"same_pick","input":{"object":"O3","advertises":["wai.video.av1","wai.meta.hdr10plus"],"applies":["wai.meta.hdr10plus"],"abr":{"policy":{"id":"throughput","last_n":1,"safety_permille":900},"state":{"buffer_ms":0,"samples":[[1000,1250000,1000]],"last":null,"option_bytes":[],"option_joules_micro":[],"joule_budget_micro":null,"segment_ms":2000}},"fragment":"#h=1009"},"expect":{"pick":1}}, -{"name":"same_pick_abr_O7_high","op":"same_pick","input":{"object":"O7","advertises":["wai.video.av1","wai.meta.hdr10plus"],"abr":{"policy":{"id":"throughput","last_n":1,"safety_permille":900},"state":{"buffer_ms":0,"samples":[[1000,1250000,1000]],"last":null,"option_bytes":[],"option_joules_micro":[],"joule_budget_micro":null,"segment_ms":2000}}},"expect":{"pick":1}} +{"name":"same_pick_abr_O7_high","op":"same_pick","input":{"object":"O7","advertises":["wai.video.av1","wai.meta.hdr10plus"],"abr":{"policy":{"id":"throughput","last_n":1,"safety_permille":900},"state":{"buffer_ms":0,"samples":[[1000,1250000,1000]],"last":null,"option_bytes":[],"option_joules_micro":[],"joule_budget_micro":null,"segment_ms":2000}}},"expect":{"pick":1}}, +{"name":"receipt_file_group","op":"receipt_file","input":{"file":"objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json","kind":"group"},"expect":{"link":"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807"}}, +{"name":"receipt_file_claim","op":"receipt_file","input":{"file":"objects/rcpt/0c0b87f288cc459e99b914920284bf633eab89bf035078137b89936002072768.json","kind":"claim"},"expect":{"link":"0c0b87f288cc459e99b914920284bf633eab89bf035078137b89936002072768"}}, +{"name":"receipt_file_name_mismatch","op":"receipt_file","input":{"file":"objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json","name":"e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30","kind":"group"},"expect":{"error":"receipt_name_mismatch","step":null}}, +{"name":"receipt_file_group_as_claim","op":"receipt_file","input":{"file":"objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json","kind":"claim"},"expect":{"error":"wrong_kind","step":1}}, +{"name":"receipt_file_claim_as_group","op":"receipt_file","input":{"file":"objects/rcpt/14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491.json","kind":"group"},"expect":{"error":"wrong_kind","step":1}}, +{"name":"receipt_file_member_twice","op":"receipt_file","input":{"file":"objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json","kind":"group","edit":[["{","{\"group_id\":0,"]]},"expect":{"error":"malformed","step":1}}, +{"name":"receipt_file_claim_member_twice","op":"receipt_file","input":{"file":"objects/rcpt/0c0b87f288cc459e99b914920284bf633eab89bf035078137b89936002072768.json","kind":"claim","edit":[["{","{\"seq\":1,"]]},"expect":{"error":"malformed","step":1}}, +{"name":"receipts_walk_two","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":2,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]}]}}, +{"name":"receipts_walk_one","op":"receipts","input":{"checks":[{"object":"O9b","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":1,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]}]}}, +{"name":"receipts_in_order","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole"},{"object":"O9b","held":"whole"},{"object":"O9c","held":"whole"},{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]}]}}, +{"name":"receipts_back_after_head","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"},{"object":"O9a","held":"none"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":2,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]},{"index":"ok","delivery":[{"admission":"accept again","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[]}]}}, +{"name":"receipts_join_anchors","op":"receipts","input":{"join":true,"checks":[{"object":"O9c","held":"whole"}],"serve":{"omit":["3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807","e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30"]}},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept anchor","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]}]}}, +{"name":"receipts_parent_not_served","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"}],"serve":{"omit":["3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807"]}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":1,"stop":"receipt_not_served"}],"claims":[]}]}}, +{"name":"receipts_walk_bound","op":"receipts","input":{"max_walk":1,"checks":[{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":1,"stop":"bound"}],"claims":[]}]}}, +{"name":"receipts_parent_renamed","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"}],"serve":{"as":{"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807":"objects/rcpt/e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30.json"}}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":2,"stop":"receipt_name_mismatch"}],"claims":[]}]}}, +{"name":"receipts_group_not_served","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole"}],"serve":{"omit":["3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807"]}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_not_served","step":null}],"claims":[]}]}}, +{"name":"receipts_group_renamed","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole"}],"serve":{"as":{"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807":"objects/rcpt/8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765.json"}}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_name_mismatch","step":null}],"claims":[]}]}}, +{"name":"receipts_group_bad_signature","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"checks":[{"object":"O9a","held":"whole","index":"cases/receipts/O9a-bad-signature.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"signature_verify_failed","step":2}],"claims":[]}]}}, +{"name":"receipts_head_proof","op":"receipts","input":{"checks":[{"object":"O3","held":{"fetch":{"advertises":["wai.video.av1"],"applies":["wai.meta.hdr10plus"]}}}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"head-proof","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"}],"fetched":"head-proof"}]}}, +{"name":"receipts_fetch_refused_holds_nothing","op":"receipts","input":{"checks":[{"object":"O3","held":{"fetch":{"advertises":["wai.video.av1"],"applies":["wai.meta.hdr10plus"],"tamper":{"patch_object":[[200,"23"]]}}}}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"}],"fetched":"head_proof_mismatch"}]}}, +{"name":"receipts_other_object_in_hand","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","held_object":"O9b"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"content_hash_mismatch","step":4}],"claims":[]}]}}, +{"name":"receipts_stage_set","op":"receipts","input":{"checks":[{"object":"O10","held":"whole"},{"object":"O10L1","held":"whole"},{"object":"O10L2","held":"none"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[{"class":"stage","attests":"object","subject":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","parent":"8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c"}]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[]}]}}, +{"name":"receipts_layer_fetched","op":"receipts","input":{"checks":[{"object":"O10L1","held":{"fetch":{"listed_as_layer":true}}}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"fetched":"whole-object"}]}}, +{"name":"receipts_claims_O3","op":"receipts","input":{"checks":[{"object":"O3","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"}]}]}}, +{"name":"receipts_claims_O7","op":"receipts","input":{"checks":[{"object":"O7","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"object","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null}]}]}}, +{"name":"receipts_claim_parent_not_served","op":"receipts","input":{"checks":[{"object":"O10","held":"whole"}],"serve":{"omit":["8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c"]}},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[{"error":"parent_not_held","step":null,"walked":0,"stop":"receipt_not_served"}]}]}}, +{"name":"receipts_index_other_object","op":"receipts","input":{"checks":[{"object":"O9b","held":"whole","index":"objects/31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3.wai.receipts"}]},"expect":{"checks":[{"index":"receipt_not_about_object"}]}}, +{"name":"receipts_property_disagrees","op":"receipts","input":{"checks":[{"object":"O3","held":"whole","edit_index":[["\"group_receipt\":\"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018\",\"joules_micro\":1500}","\"group_receipt\":\"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018\",\"joules_micro\":1501}"]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"property_disagrees","step":null}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"}]}]}}, +{"name":"receipts_item_not_about_object","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","edit_index":[["\"content_hash\":\"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3\",\"group_receipt\"","\"content_hash\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"group_receipt\""]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_not_about_object","step":null}],"claims":[]}]}}, +{"name":"receipts_index_member_added","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","edit_index":[["{","{\"note\":1,"]]}]},"expect":{"checks":[{"index":"receipt_index_malformed"}]}}, +{"name":"receipts_item_member_added","op":"receipts","input":{"checks":[{"object":"O3","held":"whole","edit_index":[["{\"object_receipt\"","{\"note\":1,\"object_receipt\""]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_index_malformed","step":null}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"error":"receipt_not_about_object","step":null}]}]}}, +{"name":"receipts_claim_entry_not_a_hash","op":"receipts","input":{"checks":[{"object":"O7","held":"whole","edit_index":[["14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","s0"]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"receipt_index_malformed","step":null}]}]}}, +{"name":"receipts_confirmed","op":"receipts","input":{"keys":[{"anchor":".well-known/wai-keys"}],"checks":[{"object":"O3","held":"whole"},{"object":"O10","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null,"confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"}]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"}],"claims":[{"class":"stage","attests":"object","subject":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","parent":"8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c","confirm":"unconfirmed step=1 reason=signer_id_not_origin_bound"}]}]}}, +{"name":"key_log_anchor","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys"}]},"expect":{"steps":[{"anchored":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","path":"fetched"}]}}, +{"name":"key_log_anchor_redirect","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","retrieval":{"requested":"https://origin.example/.well-known/wai-keys","answered":"https://mirror.example/.well-known/wai-keys"}}]},"expect":{"steps":[{"error":"key_log_not_from_origin"}]}}, +{"name":"key_log_anchor_elsewhere","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","retrieval":{"requested":"https://mirror.example/.well-known/wai-keys"}}]},"expect":{"steps":[{"error":"key_log_not_from_origin"}]}}, +{"name":"key_log_anchor_not_200","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","retrieval":{"status":304}}]},"expect":{"steps":[{"error":"key_log_not_from_origin"}]}}, +{"name":"key_log_anchor_plain_http","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","origin":"http://origin.example"}]},"expect":{"steps":[{"error":"key_log_not_from_origin"}]}}, +{"name":"key_log_anchor_other_origin","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","origin":"https://other.example"}]},"expect":{"steps":[{"error":"origin_mismatch"}]}}, +{"name":"key_log_anchor_malformed","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","edit":[["{\"kind\":\"wai-key-log\"","{\"kind\":\"wai-key-log\",\"note\":1"]]}]},"expect":{"steps":[{"error":"key_log_malformed"}]}}, +{"name":"key_log_anchor_forward","op":"key_log","input":{"steps":[{"anchor":"cases/keys/wai-keys-r0"},{"anchor":".well-known/wai-keys"}]},"expect":{"steps":[{"anchored":"7f06fd9425a3583d2007074cb32c75514d34b11d064c88304c6e4d4d4cb5ab01","path":"fetched"},{"anchored":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","path":"fetched"}]}}, +{"name":"key_log_anchor_rolled_back","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys"},{"anchor":"cases/keys/wai-keys-r0"}]},"expect":{"steps":[{"anchored":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","path":"fetched"},{"error":"key_log_rolled_back"}]}}, +{"name":"key_log_cached_newer","op":"key_log","input":{"steps":[{"anchor":"cases/keys/wai-keys-r0"},{"cached":".well-known/wai-keys"}]},"expect":{"steps":[{"anchored":"7f06fd9425a3583d2007074cb32c75514d34b11d064c88304c6e4d4d4cb5ab01","path":"fetched"},{"added":1}]}}, +{"name":"key_log_cached_rolled_back","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys"},{"cached":"cases/keys/wai-keys-r0"}]},"expect":{"steps":[{"anchored":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","path":"fetched"},{"error":"key_log_rolled_back"}]}}, +{"name":"key_log_cached_other_origin","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys"},{"cached":".well-known/wai-keys","origin":"https://other.example"}]},"expect":{"steps":[{"anchored":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","path":"fetched"},{"error":"origin_not_anchored"}]}}, +{"name":"grants_file","op":"grants_file","input":{"file":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json"},"expect":{"grants":["read","read"]}}, +{"name":"grants_file_other_module","op":"grants_file","input":{"file":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","module":"0000000000000000000000000000000000000000000000000000000000000000"},"expect":{"error":"grants_file_malformed"}}, +{"name":"grants_file_member_added","op":"grants_file","input":{"file":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","edit":[["{\"grants\"","{\"note\":1,\"grants\""]]},"expect":{"error":"grants_file_malformed"}}, +{"name":"grants_file_wrong_kind","op":"grants_file","input":{"file":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","edit":[["wai-module-grants","wai-module-grant"]]},"expect":{"error":"grants_file_malformed"}}, +{"name":"param_set_held","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","hold_to":["https://origin.example"],"now":"2026-10-06T00:30:00Z"},"expect":{"held":true}}, +{"name":"param_set_status_stale","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","hold_to":["https://origin.example"],"now":"2026-10-06T02:00:00Z"},"expect":{"error":"prior_not_authorised","check":"4","reason":"module_status_stale"}}, +{"name":"param_set_no_clock","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","hold_to":["https://origin.example"],"now":null},"expect":{"error":"prior_not_authorised","check":"4","reason":"module_status_stale"}}, +{"name":"param_set_no_grant","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":null,"hold_to":["https://origin.example"],"now":"2026-10-06T00:30:00Z"},"expect":{"error":"prior_not_authorised","check":null,"reason":null}}, +{"name":"param_set_other_bytes","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"../wai-rs/tests/vectors/int_mlicpp/stream.wai","keys":[{"anchor":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","hold_to":["https://origin.example"],"now":"2026-10-06T00:30:00Z"},"expect":{"error":"prior_not_held"}}, +{"name":"param_set_revoked","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":"cases/keys/wai-keys-revoked"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","hold_to":["https://origin.example"],"now":"2026-10-06T00:30:00Z"},"expect":{"error":"prior_revoked","revoked_by":"https://origin.example"}}, +{"name":"param_set_not_held_to","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","now":"2026-10-06T00:30:00Z"},"expect":{"held":true}}, +{"name":"param_set_revoked_not_held_to","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":"cases/keys/wai-keys-revoked"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","now":"2026-10-06T00:30:00Z"},"expect":{"error":"prior_revoked","revoked_by":"https://origin.example"}}, +{"name":"param_set_pinned_anchor","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"pin":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","hold_to":["https://origin.example"],"now":"2026-10-06T00:30:00Z"},"expect":{"held":true}} ] diff --git a/wai/http-conformance/cases/keys/wai-keys-r0 b/wai/http-conformance/cases/keys/wai-keys-r0 new file mode 100644 index 000000000..ffef74ad8 --- /dev/null +++ b/wai/http-conformance/cases/keys/wai-keys-r0 @@ -0,0 +1 @@ +{"kind":"wai-key-log","revisions":[{"grant_max_s":86400,"issued_at":"2026-10-01T00:00:00Z","keys":[{"compromised_since":null,"id":"d1","key":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","may_sign":["delivery","energy/Estimator","log-inclusion","soft-binding"],"status":"active","until":null},{"compromised_since":null,"id":"g1","key":"a7f6dfaf8f38b89ba8ce649b594f91e4d01fdc57f9c9493df43b5e50a9987367","may_sign":["module/decoder"],"status":"active","until":null}],"kind":"wai-key-set","next_root":"d481afb3d00a3168a515f522aec12623ae3ee77b919ad5db74353a9f55972d8c","origin":"https://origin.example","prev":null,"revoked_modules":[],"root":"4a72e4035bb435d0aa42d43b8873d9f07cb9bf476fe2ade0aba49983eebd425e","seq":0,"sig":"92de01efa058176b0dc77a5784d401427c6266eec4b5583b4a4ac3eea1f2e52c5cc30809c27fb6b3c1908bd29f6658c85452702677f88c392319249df6a9680f","status_max_age_s":3600}]} \ No newline at end of file diff --git a/wai/http-conformance/cases/keys/wai-keys-revoked b/wai/http-conformance/cases/keys/wai-keys-revoked new file mode 100644 index 000000000..f9516f614 --- /dev/null +++ b/wai/http-conformance/cases/keys/wai-keys-revoked @@ -0,0 +1 @@ +{"kind":"wai-key-log","revisions":[{"grant_max_s":86400,"issued_at":"2026-10-01T00:00:00Z","keys":[{"compromised_since":null,"id":"d1","key":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","may_sign":["delivery","energy/Estimator","log-inclusion","soft-binding"],"status":"active","until":null},{"compromised_since":null,"id":"g1","key":"a7f6dfaf8f38b89ba8ce649b594f91e4d01fdc57f9c9493df43b5e50a9987367","may_sign":["module/decoder"],"status":"active","until":null}],"kind":"wai-key-set","next_root":"d481afb3d00a3168a515f522aec12623ae3ee77b919ad5db74353a9f55972d8c","origin":"https://origin.example","prev":null,"revoked_modules":[],"root":"4a72e4035bb435d0aa42d43b8873d9f07cb9bf476fe2ade0aba49983eebd425e","seq":0,"sig":"92de01efa058176b0dc77a5784d401427c6266eec4b5583b4a4ac3eea1f2e52c5cc30809c27fb6b3c1908bd29f6658c85452702677f88c392319249df6a9680f","status_max_age_s":3600},{"grant_max_s":86400,"issued_at":"2026-10-05T00:00:00Z","keys":[{"compromised_since":null,"id":"d1","key":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","may_sign":["delivery","energy/Estimator","log-inclusion","soft-binding"],"status":"active","until":null},{"compromised_since":null,"id":"g1","key":"a7f6dfaf8f38b89ba8ce649b594f91e4d01fdc57f9c9493df43b5e50a9987367","may_sign":["module/decoder"],"status":"active","until":null}],"kind":"wai-key-set","next_root":"d481afb3d00a3168a515f522aec12623ae3ee77b919ad5db74353a9f55972d8c","origin":"https://origin.example","prev":"7f06fd9425a3583d2007074cb32c75514d34b11d064c88304c6e4d4d4cb5ab01","revoked_modules":["7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4"],"root":"4a72e4035bb435d0aa42d43b8873d9f07cb9bf476fe2ade0aba49983eebd425e","seq":1,"sig":"6d9dd2f33367dba777cca49da470d294e206d7a28a22a09348759e6bd974b93a8118c2f8573e536f9c36790e2ca808ed408322a267041bb7c0daf2dcc3b94905","status_max_age_s":3600}]} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/02154a175d9519491b6321797e67e5e20db3346ffc4e14bd4e30c0c6a9e9f358.json b/wai/http-conformance/cases/rcpt/02154a175d9519491b6321797e67e5e20db3346ffc4e14bd4e30c0c6a9e9f358.json new file mode 100644 index 000000000..65b6c453c --- /dev/null +++ b/wai/http-conformance/cases/rcpt/02154a175d9519491b6321797e67e5e20db3346ffc4e14bd4e30c0c6a9e9f358.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":0,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":null,"root_hash":"43c3a4e8ff2e9cb84fa4529dd16c18008f7ac1b324117853c0f45871e46f85c7","sig":"7f0a32e960222763ba31d025cb4fefc0f9b210310c66b319baaeefe6f5845864e5338dc1ddd89a49a694e4e0342b23fb5bf4813dd546e9518b8f79d93b1c3c04","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":1} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O9a-bad-signature.wai.receipts b/wai/http-conformance/cases/receipts/O9a-bad-signature.wai.receipts new file mode 100644 index 000000000..16318f69c --- /dev/null +++ b/wai/http-conformance/cases/receipts/O9a-bad-signature.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3","group_id":0,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3","group_receipt":"02154a175d9519491b6321797e67e5e20db3346ffc4e14bd4e30c0c6a9e9f358","joules_micro":0}}],"kind":"wai-receipt-index","object":"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3"} \ No newline at end of file diff --git a/wai/http-conformance/http_receipts.py b/wai/http-conformance/http_receipts.py new file mode 100644 index 000000000..197350ab5 --- /dev/null +++ b/wai/http-conformance/http_receipts.py @@ -0,0 +1,654 @@ +"""The receipt, key, grant and parameter-set side of http_verify.py (http-delivery §8.1-§8.5, +§10.5). + +Written from the extension text. It shares no code with wai-rs. What jwp-receipts defines it +takes from the corpora's own second verifiers, each written from that text and sharing no +code with wai-rs either: group and object receipts, properties and receipt chains from +../jwp-conformance/jwp_verify.py, claims and the strict JSON reading from +../claim-conformance/claim_verify.py, and key logs, anchoring, confirmation and module +grants from ../signer-conformance/signer_verify.py. Those use the `cryptography` package for +Ed25519's equation, so these cases need it; http_verify.py loads this file only for them. +What http-delivery adds is written here: receipt files named by their link hash, the index +read item by item, the parent walk and its bound, what a client holds of the object (the +whole envelope, the cid a head proof established, or nothing), claims by subject and +parent, anchoring only from the origin's own location, logs through caches, grants files as +locators, and parameter sets held only by their pins and jwp-receipts §7.9. +""" +import hashlib, os, sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +for _d in ("claim-conformance", "jwp-conformance", "signer-conformance"): + sys.path.insert(0, os.path.join(HERE, "..", _d)) +import claim_verify as CV # noqa: E402 +import jwp_verify as JV # noqa: E402 +import signer_verify as SV # noqa: E402 + +MUTATE = None # set by http_verify.py +ORIGIN = "https://origin.example" +KEY_LOG_PATH = "/.well-known/wai-keys" +RCPT = "objects/rcpt" +WALK_BOUND = 64 +INDEX_KIND = "wai-receipt-index" +GRANTS_KIND = "wai-module-grants" +ACQUIRED = "2026-10-06T00:00:00Z" +ONE_FILE = {"wai.neural.int_mlicpp"} # the capabilities the corpus pins: each decodes against one file + + +class Refused(Exception): + def __init__(self, code, step=None, walked=None, stop=None): + super().__init__(code) + self.code, self.step, self.walked, self.stop = code, step, walked, stop + + def json(self): + out = {"error": self.code, "step": self.step} + if self.code == "parent_not_held" and self.walked is not None: + out["walked"], out["stop"] = self.walked, self.stop + return out + + +def pnh(walked, stop): + return Refused("parent_not_held", None, walked, stop) + + +class Fault(Exception): + """A fault of the case itself: no outcome.""" + + +def edit_text(t, edits): + for e in edits or []: + if e[0] not in t: + raise Fault("an edit's text is not in the file") + t = t.replace(e[0], e[1], 1) + return t + + +def read_file(d, path): + return open(os.path.join(d, path), "rb").read() + + +def utf8(b): + try: + return b.decode("utf-8") + except UnicodeDecodeError: + return None + + +# ------------------------------------------------------------------ receipt files (§8.1) + +def read_group_file(name, b): + """A group receipt's file: JSON as §3.1 step 1 reads it, §3 step 1, then the name. + Returns the raw receipt and its reading.""" + text = utf8(b) + if text is None: + raise Refused("malformed", 1) + try: + raw = CV.parse_strict(text) + except ValueError: + raise Refused("malformed", 1) + try: + g = JV.read_group(raw) + except JV.Refuse as r: + raise Refused(r.reason, r.step) + if MUTATE != "rcpt-name-unchecked" and JV.link(g).hex() != name: + raise Refused("receipt_name_mismatch") + return raw, g + + +def read_claim_file(name, b): + """A claim's file: §3.1 steps 1-2, then the name. The verdict of steps 3-4, run later + in order, is returned beside the claim (None: they pass).""" + text = utf8(b) + if text is None: + raise Refused("malformed", 1) + later = None + try: + CV.verify_claim(text) + except CV.Reject as r: + if r.step <= 2: + raise Refused(r.reason, r.step) + later = r + c = CV.parse_strict(text) + if CV.link_hash(c) != name: + raise Refused("receipt_name_mismatch") + return c, later + + +# ------------------------------------------------------------------ the index (§8.2) + +def read_property(p): + """moq-streaming-format §5's property, as it reads: exactly its five members.""" + keys = {"content_hash", "joules_micro", "acquisition", "acquisition_evidence", "group_receipt"} + if not (isinstance(p, dict) and set(p) == keys and CV.is_hex(p["content_hash"], 32) and JV.u64(p["joules_micro"]) + and isinstance(p["acquisition"], str) and CV.is_hex(p["acquisition_evidence"]) and CV.is_hex(p["group_receipt"], 32)): + raise Refused("property_invalid") + return p + + +def read_index(b): + bad = Refused("receipt_index_malformed") + text = utf8(b) + if text is None: + raise bad + try: + v = CV.parse_strict(text) + except ValueError: + raise bad + if not isinstance(v, dict) or set(v) != {"kind", "object", "delivery", "claims"}: + raise bad + if v["kind"] != INDEX_KIND or not CV.is_hex(v["object"], 32): + raise bad + if not isinstance(v["delivery"], list) or not isinstance(v["claims"], list): + raise bad + items = [] + for it in v["delivery"]: + try: + if not isinstance(it, dict) or set(it) != {"property", "object_receipt"}: + raise Refused("receipt_index_malformed") + p = read_property(it["property"]) + try: + o = JV.read_object(it["object_receipt"]) + except JV.Refuse as r: + raise Refused(r.reason, r.step) + items.append((p, o)) + except Refused as r: + items.append(r) + claims = [c if CV.is_hex(c, 32) else Refused("receipt_index_malformed") for c in v["claims"]] + return v["object"], items, claims + + +# ------------------------------------------------------------------ chains and the walk (§8.1) + +class Chains: + """The client's chains (jwp-receipts §3 step 3): every group offered so far, in order, + run through jwp_verify.chain again for each offer; the last verdict is the offer's.""" + + def __init__(self, join): + self.seq, self.join = [], join + + def admit(self, raw): + self.seq.append(raw) + out = JV.chain({"groups": self.seq, "guard": False, "recovery": {"join": True} if self.join else {}}) + last = out[len("chain: "):].split(" / ")[-1] + if last.startswith("reject"): + f = dict(x.split("=", 1) for x in last.split()[1:]) + raise Refused(f["reason"], int(f["step"]) if "step" in f else None) + return last + + +def admit_walking(ch, raw, g, fetch, bound): + try: + return ch.admit(raw), 0 + except Refused as r: + if r.code != "parent_not_held": + raise + if MUTATE == "walk-skipped": + return "accept", 0 + pending, link, fetched = [], g["parent_receipt_hash"], 0 + while True: + if link is None: + raise pnh(fetched, "parent_not_held") + if fetched == bound: + raise pnh(fetched, "bound") + b = fetch(link) + if b is None: + raise pnh(fetched, "receipt_not_served") + fetched += 1 + try: + praw, p = read_group_file(link, b) + except Refused as r: + raise pnh(fetched, r.code) + if p["signer_pubkey"] != g["signer_pubkey"] or p["track_alias"] != g["track_alias"]: + raise pnh(fetched, "another_chain") + try: + ch.admit(praw) + break + except Refused as r: + if r.code != "parent_not_held": + raise pnh(fetched, r.code) + link = p["parent_receipt_hash"] + pending.append(praw) + for praw in reversed(pending): + try: + ch.admit(praw) + except Refused as r: + raise pnh(fetched, r.code) + return ch.admit(raw), fetched + + +# ------------------------------------------------------------------ delivery items (§8.2) + +def reading(p, o, g): + """The property against an object accepted under g (moq-streaming-format §5).""" + try: + c = JV.property_class(p) + except JV.Refuse as r: + raise Refused(r.reason) + if bytes.fromhex(p["content_hash"]) != o["content_hash"] or bytes.fromhex(p["group_receipt"]) != JV.link(g): + raise Refused("property_disagrees") + if not o["labelled"]: + return "unmetered" if c is None else "unsigned" + if o["class"] is None and c is None: + return "unmetered" + if o["class"] is not None and c is not None and o["class"] == c and o["joules_micro"] == p["joules_micro"]: + return f"signed class={c['label']} by={g['signer_pubkey']}" + raise Refused("property_disagrees") + + +def check_delivery(cid, item, held, fetch, ch, bound): + p, o = item + if p["content_hash"] != cid or o["content_hash"].hex() != cid: + raise Refused("receipt_not_about_object") + name = p["group_receipt"] + b = fetch(name) + if b is None: + raise Refused("receipt_not_served") + raw, g = read_group_file(name, b) + admission, walked = admit_walking(ch, raw, g, fetch, bound) + if MUTATE == "nothing-as-delivered" and held is None: + held = (cid, "head-proof") + if held is not None and held[0] != o["content_hash"].hex(): + raise Refused("content_hash_mismatch", 4) + try: + JV.inclusion(o, g) + except JV.Refuse as r: + raise Refused(r.reason, r.step) + if held is not None: + rd = reading(p, o, g) + else: + try: + JV.property_class(p) + except JV.Refuse as r: + raise Refused(r.reason) + rd = None + return {"admission": admission, "walked": walked, "evidence": "delivered-verified" if held is not None else "inclusion-verified", + "established": held[1] if held is not None else None, "reading": rd}, g + + +# ------------------------------------------------------------------ claims (§8.2) + +def check_claim(cid, groups, name, fetch): + b = fetch(name) + if b is None: + raise Refused("receipt_not_served") + c, later = read_claim_file(name, b) + if c["subject_type"] == "object" and c["subject"] == cid: + attests = "object" + elif c["subject_type"] == "receipt" and c["subject"] in groups: + attests = "receipt" + elif c["subject_type"] == "object" and c["class"] == "soft-binding": + attests = "object" if MUTATE == "ancestor-as-object" else "ancestor" + else: + raise Refused("receipt_not_about_object") + if later is not None: + raise Refused(later.reason, later.step) + parent = c["parent_receipt_hash"] + if parent is not None: + pb = fetch(parent) + if pb is None: + raise pnh(0, "receipt_not_served") + try: + prev, plater = read_claim_file(parent, pb) + except Refused as r: + raise pnh(1, r.code) + if plater is not None: + raise pnh(1, plater.reason) + try: + CV.check_chain(c, prev) + except CV.Reject as r: + raise Refused(r.reason, r.step) + return {"class": c["class"], "attests": attests, "subject": c["subject"], "parent": parent}, c + + +# ------------------------------------------------------------------ keys (§8.4) + +class KeyState: + def __init__(self): + self.anchor, self.held = None, [] + + def add(self, revs): + have = {SV.rev_hash(r) for r in self.held} + for r in revs: + if SV.rev_hash(r) not in have: + have.add(SV.rev_hash(r)) + self.held.append(r) + + def step(self, d, s): + """What the step did, or its refusal ({"error": …}); a step refused changes nothing.""" + origin = s.get("origin", ORIGIN) + f = s.get("anchor") or s.get("cached") or s.get("pin") + body = edit_text(read_file(d, f).decode("utf-8"), s.get("edit")) + if s.get("pin") is not None: + out = SV.anchor_log(origin, body, "pinned", None) + if out[0] != "anchored": + raise Fault("a pinned log that does not anchor") + self.held = SV.read_log(origin, body) + self.anchor = {"origin": origin, "revision": out[1], "path": "pinned", "pinned_revision": out[3]} + return {"anchored": out[1], "path": "pinned"} + if s.get("cached") is not None: + if self.anchor is None: + raise Fault("a cached log before any anchor") + return self.cached(origin, body) + loc = origin + KEY_LOG_PATH + r = s.get("retrieval") or {} + requested = r.get("requested", loc) + answered = r.get("answered", requested) + status = r.get("status", 200) + if not origin.startswith("https://") or requested != loc or (answered != loc and MUTATE != "anchor-follows-redirect") or status != 200: + return {"error": "key_log_not_from_origin"} + current = (self.anchor, self.held) if self.anchor is not None else None + out = SV.anchor_log(origin, body, "fetched", current) + if out[0] == "not_anchored": + return {"error": out[1]} + if out[0] == "replaced": + return {"error": "key_log_replaced"} + try: + self.add(SV.read_log(origin, body)) + except SV.Stop: + pass + self.anchor = {"origin": origin, "revision": out[1], "path": out[2], "pinned_revision": out[3]} + return {"anchored": out[1], "path": out[2]} + + def cached(self, origin, body): + if self.anchor["origin"] != origin: + return {"error": "origin_not_anchored"} + try: + revs = SV.read_log(origin, body) + except SV.Stop as e: + return {"error": e.reason} + by = {SV.rev_hash(r): r for r in self.held if SV.is_held(r, origin)} + trusted = [self.anchor["revision"]] + while trusted[-1] in by and by[trusted[-1]]["prev"] in by and by[trusted[-1]]["prev"] not in trusted: + trusted.append(by[trusted[-1]]["prev"]) + latest = SV.rev_hash(revs[-1]) + if latest != self.anchor["revision"] and latest in trusted and MUTATE != "cached-rollback-unchecked": + return {"error": "key_log_rolled_back"} + before = len(self.held) + self.add(revs) + return {"added": len(self.held) - before} + + +def key_material(d, steps): + st = KeyState() + for s in steps or []: + r = st.step(d, s) + if "error" in r: + raise Fault("the case's key material does not anchor") + return st.held, ([st.anchor] if st.anchor is not None else []) + + +def read_grants_file(module, b): + bad = Refused("grants_file_malformed") + text = utf8(b) + if text is None: + raise bad + try: + v = CV.parse_strict(text) + except ValueError: + raise bad + if not isinstance(v, dict) or set(v) != {"kind", "module", "grants"} or v["kind"] != GRANTS_KIND: + raise bad + if not (CV.is_hex(v["module"], 32) and (v["module"] == module or MUTATE == "grants-module-unchecked")): + raise bad + if not isinstance(v["grants"], list): + raise bad + out = [] + for g in v["grants"]: + try: + out.append(CV.jcs(g)) + except ValueError: + out.append("null") # outside the canonical subset: no grant reads from it + return out + + +# ------------------------------------------------------------------ parameter sets (§8.5) + +def module_check_digest(digest, grant_text, cap, origins, revisions, anchors, statuses, now): + """jwp-receipts §7.9's checks with the pin's digest standing for the bytes' (check 2): + ("run",) or ("not_held", step, reason, revoked_by).""" + def no(step, reason, by=None): + return ("not_held", step, reason, by) + g = SV.parse_grant(grant_text) + if g is None: + return no("1", "module_grant_invalid") + if digest != g["module"]: + return no("2", "module_digest_mismatch") + v = SV.confirm(bytes.fromhex(g["signer_pubkey"]), g["signer_id"], ["module/" + g["form"]], g["not_before"], revisions, anchors, origins) + if not v.startswith("confirmed"): + return no("3", v.split("reason=")[1]) + fields = dict(x.split("=", 1) for x in v.split()[1:]) + origin, hh = fields["origin"], fields["head"] + held = [r for r in revisions if SV.is_held(r, origin)] + hr = next(r for r in held if SV.rev_hash(r) == hh) + if now is None or not SV.is_log_time(now): + return no("4", "module_status_stale") + by = {SV.rev_hash(r): r for r in held} + line = [hh] + while by[line[-1]]["prev"] in by and by[line[-1]]["prev"] not in line: + line.append(by[line[-1]]["prev"]) + valid = [] + for t in statuses: + s = SV.parse_status(t) + if s is None or s["origin"] != origin or s["head"] not in line: + continue + for r in held: + if (SV.rev_hash(r) == s["head"] and r["seq"] == s["seq"] + and SV.verifies(bytes.fromhex(r["root"]), bytes.fromhex(s["sig"]), SV.D_STATUS + CV.jcs(SV.without_sig(s)).encode())): + valid.append(s) + break + current = [s["issued_at"] for s in valid if s["head"] == hh] + if not current: + return no("4", "module_status_not_descended" if valid else "module_status_stale") + age = SV.secs(now) - SV.secs(max(current)) + if age < 0 or age > hr["status_max_age_s"]: + return no("4", "module_status_stale") + if g["module"] in hr["revoked_modules"]: + return no("5", "module_revoked") + o = SV.revoked_by(g["module"], revisions, anchors) + if o is not None: + return no("5", "module_revoked", o) + if now < g["not_before"]: + return no("6", "module_grant_not_yet_valid") + if now > g["not_after"]: + return no("6", "module_grant_expired") + if SV.secs(g["not_after"]) - SV.secs(g["not_before"]) > hr["grant_max_s"]: + return no("7", "module_grant_too_long") + if cap not in g["capabilities"]: + return no("8", "module_capability_not_granted") + return ("run",) + + +def check_set(cap, digest, revisions, anchors, statuses, grants, hold_to, now): + """SPEC §3.1 check 5 at a sink that holds a key log: None where the set is held or + the check does not apply, else the refusal.""" + revoked = SV.revoked_by(digest, revisions, anchors) + if hold_to is None: + return None if revoked is None else {"error": "prior_revoked", "revoked_by": revoked} + if MUTATE == "set-grants-ignored": + return None + origins = [{"origin": o, "source": "relying-party"} for o in hold_to] + + def rank(o): + return 255 if o[0] == "run" or o[2] == "module_revoked" else int(o[1]) + reported = None + for g in grants: + pg = SV.parse_grant(g) + if pg is not None and pg["form"] != "decoder": + continue + out = module_check_digest(digest, g, cap, origins, revisions, anchors, statuses, now) + if out[0] == "run": + return None + if reported is None or rank(out) > rank(reported): + reported = out + if reported is not None and reported[2] == "module_revoked": + return {"error": "prior_revoked", "revoked_by": reported[3]} + if revoked is not None: + return {"error": "prior_revoked", "revoked_by": revoked} + if reported is None: + return {"error": "prior_not_authorised", "check": None, "reason": None} + return {"error": "prior_not_authorised", "check": reported[1], "reason": reported[2]} + + +def param_set(d, inp): + cap, sha = inp["capability"], inp["sha256"] + if not CV.is_hex(sha, 32): + return {"error": "pin_malformed"} + if cap not in ONE_FILE: + raise Fault("a capability whose set shape this verifier does not read") + served = read_file(d, inp["served"]) + revisions, anchors = key_material(d, inp.get("keys")) + statuses = [read_file(d, p).decode("utf-8") for p in inp.get("statuses") or []] + grants = [] + if inp.get("grants") is not None: + try: + grants = read_grants_file(sha, read_file(d, inp["grants"])) + except Refused: + raise Fault("the case's grants file does not read") + r = check_set(cap, sha, revisions, anchors, statuses, grants, inp.get("hold_to"), inp.get("now")) + if r is not None: + return r + if hashlib.sha256(served).hexdigest() != sha and MUTATE != "set-by-name": + return {"error": "prior_not_held"} + return {"held": True} + + +# ------------------------------------------------------------------ confirmation (§7.4) + +def confirm_group(g, o, revisions, anchors): + classes = [] + if g["labelled"] and g["cls"] is not None: + classes.append(g["cls"]["label"]) + if o["labelled"] and o["class"] is not None: + classes.append(o["class"]["label"]) + return SV.confirm(bytes.fromhex(g["signer_pubkey"]), g["signer_id"], SV.item_authorities("group", classes), g["group_end"], + revisions, anchors, None) + + +def confirm_claim(c, revisions, anchors): + return SV.confirm(bytes.fromhex(c["signer_pubkey"]), c["signer_id"], SV.claim_authorities(c), c["issued_at"], revisions, anchors, None) + + +# ------------------------------------------------------------------ the ops + +class Served: + def __init__(self, d, v): + self.d = d + self.dirs = v.get("rcpt") or [RCPT] + self.omit = set(v.get("omit") or []) + self.swap = v.get("as") or {} + + def get(self, h): + if h in self.omit: + return None + if h in self.swap: + p = os.path.join(self.d, self.swap[h]) + return open(p, "rb").read() if os.path.exists(p) else None + for dd in self.dirs: + p = os.path.join(self.d, dd, h + ".json") + if os.path.exists(p): + return open(p, "rb").read() + return None + + +def receipt_file(d, inp): + path = inp["file"] + t = edit_text(read_file(d, path).decode("utf-8"), inp.get("edit")) + name = inp.get("name") or path.rsplit("/", 1)[-1][:-len(".json")] + try: + if inp["kind"] == "group": + _, g = read_group_file(name, t.encode("utf-8")) + return {"link": JV.link(g).hex()} + c, _ = read_claim_file(name, t.encode("utf-8")) + return {"link": CV.link_hash(c)} + except Refused as r: + return r.json() + + +def receipts(d, objs, inp, fetch_object, content_hash): + served = Served(d, inp.get("serve") or {}) + ch = Chains(inp.get("join") is True) + bound = inp.get("max_walk", WALK_BOUND) + keys = key_material(d, inp["keys"]) if inp.get("keys") is not None else None + out = [] + for chk in inp["checks"]: + b = read_file(d, chk["object_file"]) if "object_file" in chk else objs[chk["object"]] + cid = content_hash(b).hex() + ix = edit_text(read_file(d, chk.get("index") or f"objects/{cid}.wai.receipts").decode("utf-8"), chk.get("edit_index")) + fetched = None + h = chk["held"] + if h == "none": + held = None + elif h == "whole": + hb = objs[chk["held_object"]] if "held_object" in chk else b + held = (content_hash(hb).hex(), "envelope") + else: + held, fetched = fetch_object(b, h["fetch"]) + try: + obj, items, claims = read_index(ix.encode("utf-8")) + if obj != cid: + raise Refused("receipt_not_about_object") + except Refused as r: + entry = {"index": r.code} + if fetched is not None: + entry["fetched"] = fetched + out.append(entry) + continue + groups = [it[0]["group_receipt"] for it in items if not isinstance(it, Refused)] + dl = [] + for it in items: + if isinstance(it, Refused): + dl.append(it.json()) + continue + try: + v, g = check_delivery(cid, it, held, served.get, ch, bound) + if keys is not None: + v["confirm"] = confirm_group(g, it[1], *keys) + dl.append(v) + except Refused as r: + dl.append(r.json()) + cl = [] + for c in claims: + if isinstance(c, Refused): + cl.append(c.json()) + continue + try: + v, claim = check_claim(cid, groups, c, served.get) + if keys is not None: + v["confirm"] = confirm_claim(claim, *keys) + cl.append(v) + except Refused as r: + cl.append(r.json()) + entry = {"index": "ok", "delivery": dl, "claims": cl} + if fetched is not None: + entry["fetched"] = fetched + out.append(entry) + return {"checks": out} + + +def key_log(d, inp): + st = KeyState() + return {"steps": [st.step(d, s) for s in inp["steps"]]} + + +def grants_file(d, inp): + path = inp["file"] + t = edit_text(read_file(d, path).decode("utf-8"), inp.get("edit")) + module = inp.get("module") or path.rsplit("/", 1)[-1][:-len(".json")] + try: + gs = read_grants_file(module, t.encode("utf-8")) + except Refused as r: + return {"error": r.code} + return {"grants": ["read" if SV.parse_grant(g) is not None else "module_grant_invalid" for g in gs]} + + +def evaluate(d, objs, op, inp, fetch_object, content_hash): + if op == "receipt_file": + return receipt_file(d, inp) + if op == "receipts": + return receipts(d, objs, inp, fetch_object, content_hash) + if op == "key_log": + return key_log(d, inp) + if op == "grants_file": + return grants_file(d, inp) + if op == "param_set": + return param_set(d, inp) + raise Fault("unknown op " + op) diff --git a/wai/http-conformance/http_verify.py b/wai/http-conformance/http_verify.py index 0ed418ed1..448eed91f 100644 --- a/wai/http-conformance/http_verify.py +++ b/wai/http-conformance/http_verify.py @@ -3260,6 +3260,35 @@ def refusal(e): return out +RECEIPT_OPS = ("receipt_file", "receipts", "key_log", "grants_file", "param_set") + + +def receipt_evaluate(d, objs, op, inp): + """§8.1-§8.5's cases, which http_receipts.py checks. They need the `cryptography` + package, through the receipt and key verifiers it reads; nothing else here does.""" + import http_receipts as RC + RC.MUTATE = MUTATE + + def fetch_object(env, f): + """What a client holds of `env` after fetching it as the `fetch` op does: the cid + and how it was established (§8.3), or nothing; and the fetch's verdict.""" + t = f.get("tamper") or {} + served = apply_patch(env, t.get("patch_object")) + try: + r = fetch(env, served, frag_of(f.get("fragment")), sink_of(d, f, objs), f.get("threshold_permille", 900), t, + f.get("listed_as_layer", False)) + except Refusal as e: + return None, e.code + if r["role"] == "layer" and 0 in r["abandoned"]: + return None, r["verified_by"] + return (content_hash(env).hex(), "head-proof" if r["verified_by"] == "head-proof" else "envelope"), r["verified_by"] + + try: + return RC.evaluate(d, objs, op, inp, fetch_object, content_hash) + except RC.Fault as e: + return {"fault": str(e)} + + def evaluate(d, objs, op, inp): def obj(i, patch=True): if "envelope_hex" in i: @@ -3336,6 +3365,8 @@ def evaluate(d, objs, op, inp): return {"supports": supports(parse_element(inp["floor"]), inp["containers"], inp["caps"])} if op == "layer_tag": return parse_layer_tag(inp["line"]) + if op in RECEIPT_OPS: + return receipt_evaluate(d, objs, op, inp) r = playlist_evaluate(d, objs, op, inp, obj) if r is not None: return r diff --git a/wai/http-conformance/objects/1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8.wai.receipts b/wai/http-conformance/objects/1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8.wai.receipts new file mode 100644 index 000000000..eb7044404 --- /dev/null +++ b/wai/http-conformance/objects/1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8.wai.receipts @@ -0,0 +1 @@ +{"claims":["0c0b87f288cc459e99b914920284bf633eab89bf035078137b89936002072768"],"delivery":[{"object_receipt":{"content_hash":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","group_id":0,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":["5a13748ae5901ba489bda77a5e4411b146ca3ad5d9ea1413165d3ad83b1583e4","a9823344677a8b16a55310735bf7278c19d83a70919eaa501bd02cc43b36a11b"]},"object_id":0,"origin":"origin","track_alias":3},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","group_receipt":"0755611daa44a9474511562d9ac7e429d4be353fb387c54edd9a24217853ae75","joules_micro":0}}],"kind":"wai-receipt-index","object":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8"} \ No newline at end of file diff --git a/wai/http-conformance/objects/31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3.wai.receipts b/wai/http-conformance/objects/31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3.wai.receipts new file mode 100644 index 000000000..1b315f699 --- /dev/null +++ b/wai/http-conformance/objects/31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3","group_id":0,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3","group_receipt":"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807","joules_micro":0}}],"kind":"wai-receipt-index","object":"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3"} \ No newline at end of file diff --git a/wai/http-conformance/objects/53a48057cb99b88fd235b9165799122df2d1b55a4b84245112a5a5ea948458ab.wai.receipts b/wai/http-conformance/objects/53a48057cb99b88fd235b9165799122df2d1b55a4b84245112a5a5ea948458ab.wai.receipts new file mode 100644 index 000000000..2d629a73d --- /dev/null +++ b/wai/http-conformance/objects/53a48057cb99b88fd235b9165799122df2d1b55a4b84245112a5a5ea948458ab.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"53a48057cb99b88fd235b9165799122df2d1b55a4b84245112a5a5ea948458ab","group_id":0,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":1,"leaf_index":1,"siblings":["5b7309ebd6e33a6f412a182d37c2c07587f79777ae71aaa57c15a8ae606b5add","a9823344677a8b16a55310735bf7278c19d83a70919eaa501bd02cc43b36a11b"]},"object_id":1,"origin":"origin","track_alias":3},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"53a48057cb99b88fd235b9165799122df2d1b55a4b84245112a5a5ea948458ab","group_receipt":"0755611daa44a9474511562d9ac7e429d4be353fb387c54edd9a24217853ae75","joules_micro":0}}],"kind":"wai-receipt-index","object":"53a48057cb99b88fd235b9165799122df2d1b55a4b84245112a5a5ea948458ab"} \ No newline at end of file diff --git a/wai/http-conformance/objects/6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9.wai.receipts b/wai/http-conformance/objects/6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9.wai.receipts new file mode 100644 index 000000000..bec86f26a --- /dev/null +++ b/wai/http-conformance/objects/6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9.wai.receipts @@ -0,0 +1 @@ +{"claims":["14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","dae83c921710311cbd34d8d4a2bedab53cf56a9be5afea2dc3d264ee978a03c5"],"delivery":[{"object_receipt":{"content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","energy_provenance":"Estimator","group_id":0,"joules_micro":1500,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":["368b22537cfa0d3d0bfcfe1f6256302af54c54711dfa411c193beda171c97edb"]},"object_id":0,"origin":"origin","track_alias":2},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","group_receipt":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","joules_micro":1500}}],"kind":"wai-receipt-index","object":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9"} \ No newline at end of file diff --git a/wai/http-conformance/objects/753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c.wai.receipts b/wai/http-conformance/objects/753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c.wai.receipts new file mode 100644 index 000000000..a6360d5cf --- /dev/null +++ b/wai/http-conformance/objects/753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","group_id":0,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":2,"leaf_index":2,"siblings":["d6cbe6c9d7da6adbcd392979eef608ca9831a20c65ef141478a0e71613d2c5e2","cf3ca3f2f8980312874592a512e91aa868b4bd835805a06e5e3b777540683581"]},"object_id":2,"origin":"origin","track_alias":3},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","group_receipt":"0755611daa44a9474511562d9ac7e429d4be353fb387c54edd9a24217853ae75","joules_micro":0}}],"kind":"wai-receipt-index","object":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c"} \ No newline at end of file diff --git a/wai/http-conformance/objects/922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f.wai.receipts b/wai/http-conformance/objects/922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f.wai.receipts new file mode 100644 index 000000000..1fe196772 --- /dev/null +++ b/wai/http-conformance/objects/922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f.wai.receipts @@ -0,0 +1 @@ +{"claims":["14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"],"delivery":[{"object_receipt":{"content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","energy_provenance":"Estimator","group_id":0,"joules_micro":300,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":1,"leaf_index":1,"siblings":["6ecdf4781b24002780c2fa637e034759ebd9b88ec7a341bc7a250cc36972f5bc"]},"object_id":1,"origin":"origin","track_alias":2},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","group_receipt":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","joules_micro":300}}],"kind":"wai-receipt-index","object":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f"} \ No newline at end of file diff --git a/wai/http-conformance/objects/a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133.wai.receipts b/wai/http-conformance/objects/a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133.wai.receipts new file mode 100644 index 000000000..2a1fd40bb --- /dev/null +++ b/wai/http-conformance/objects/a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}}],"kind":"wai-receipt-index","object":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133"} \ No newline at end of file diff --git a/wai/http-conformance/objects/b7a711ab4b0a0939a561a16b3167039e7fe65c640d73a040ebc5d5cf86e4ecbb.wai.receipts b/wai/http-conformance/objects/b7a711ab4b0a0939a561a16b3167039e7fe65c640d73a040ebc5d5cf86e4ecbb.wai.receipts new file mode 100644 index 000000000..89af76871 --- /dev/null +++ b/wai/http-conformance/objects/b7a711ab4b0a0939a561a16b3167039e7fe65c640d73a040ebc5d5cf86e4ecbb.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"b7a711ab4b0a0939a561a16b3167039e7fe65c640d73a040ebc5d5cf86e4ecbb","group_id":1,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"b7a711ab4b0a0939a561a16b3167039e7fe65c640d73a040ebc5d5cf86e4ecbb","group_receipt":"e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30","joules_micro":0}}],"kind":"wai-receipt-index","object":"b7a711ab4b0a0939a561a16b3167039e7fe65c640d73a040ebc5d5cf86e4ecbb"} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/0755611daa44a9474511562d9ac7e429d4be353fb387c54edd9a24217853ae75.json b/wai/http-conformance/objects/rcpt/0755611daa44a9474511562d9ac7e429d4be353fb387c54edd9a24217853ae75.json new file mode 100644 index 000000000..a2b48cad3 --- /dev/null +++ b/wai/http-conformance/objects/rcpt/0755611daa44a9474511562d9ac7e429d4be353fb387c54edd9a24217853ae75.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":0,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":3,"parent_receipt_hash":null,"root_hash":"9cf58f35f3c67de873d8751c94f65dd3e7bb6d3397eaf8a4c5a053042cea10da","sig":"de0352f2ea7398e64bdb768a81dc3073bcc36862060782df6897d490534f42a1d40c4cf867fdeefd5803aa6ec2c6558b6bee6713ef29c2def7d8becc7a596d00","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":3} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/0c0b87f288cc459e99b914920284bf633eab89bf035078137b89936002072768.json b/wai/http-conformance/objects/rcpt/0c0b87f288cc459e99b914920284bf633eab89bf035078137b89936002072768.json new file mode 100644 index 000000000..5d8d9c83d --- /dev/null +++ b/wai/http-conformance/objects/rcpt/0c0b87f288cc459e99b914920284bf633eab89bf035078137b89936002072768.json @@ -0,0 +1 @@ +{"body":{"bytes_envelopes":14536,"conflicts":0,"deadline_misses":0,"objects":[{"bytes":12711,"evidence":"delivered-verified","layer":1,"object":"53a48057cb99b88fd235b9165799122df2d1b55a4b84245112a5a5ea948458ab","reason":null,"status":"applied","units":{"count":1,"from":0}},{"bytes":0,"evidence":"inclusion-verified","layer":2,"object":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","reason":null,"status":"dropped","units":{"count":1,"from":0}}],"op_committed":{"wai-stage:layers":2},"op_delivered":{"wai-stage:layers":1},"op_requested":{"wai-stage:layers":2},"presented":{"wai-stage:layers":1},"stage_hash":null,"tier":"none","units_by_stage":[0,1],"vocabulary":"urn:wai:staged-delivery:1"},"class":"stage","issued_at":"2026-10-06T00:01:10Z","kind":"claim","parent_receipt_hash":"8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c","seq":1,"sig":"b69f73df4488fa8eb031415191cd851cc18196f0068020f9250cbd69f0c68ae7f5e8912567b1455830c107c0c8b6532ad2ee903d6dc1683a7537bf3641850f0d","signer_id":"did:example:http-sink","signer_pubkey":"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653","subject":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491.json b/wai/http-conformance/objects/rcpt/14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491.json new file mode 100644 index 000000000..4342a0167 --- /dev/null +++ b/wai/http-conformance/objects/rcpt/14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491.json @@ -0,0 +1 @@ +{"body":{"alg":"org.example.fingerprint","binding_type":"fingerprint","registry":"https://registry.example/soft-binding","resolution_endpoint":"https://origin.example/resolve","value":"0ff0"},"class":"soft-binding","issued_at":"2026-10-06T00:01:20Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"7b702db3b4c42f52e9c5379ab161909e21c49f2e41b613611a65e0f16c1176c18451abe4f1e7079b6100e019ef79297303afd969160543d33e322b80c66f3900","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json b/wai/http-conformance/objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json new file mode 100644 index 000000000..3ed7c5479 --- /dev/null +++ b/wai/http-conformance/objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":0,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":null,"root_hash":"43c3a4e8ff2e9cb84fa4529dd16c18008f7ac1b324117853c0f45871e46f85c7","sig":"7f0a32e960322763ba31d025cb4fefc0f9b210310c66b319baaeefe6f5845864e5338dc1ddd89a49a694e4e0342b23fb5bf4813dd546e9518b8f79d93b1c3c04","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":1} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c.json b/wai/http-conformance/objects/rcpt/8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c.json new file mode 100644 index 000000000..f3e015885 --- /dev/null +++ b/wai/http-conformance/objects/rcpt/8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c.json @@ -0,0 +1 @@ +{"body":{"bytes_envelopes":1825,"conflicts":0,"deadline_misses":0,"objects":[],"op_committed":null,"op_delivered":{"wai-stage:layers":0},"op_requested":null,"presented":{"wai-stage:layers":0},"stage_hash":null,"tier":"none","units_by_stage":[1],"vocabulary":"urn:wai:staged-delivery:1"},"class":"stage","issued_at":"2026-10-06T00:01:00Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"1a8121d6606828f292880f6e694bde4d81775eeb5568b4327773538ed072b38f7e32058b747eab0ef0ca2b96b8e524d886da715fd107238fcffbb97631443c0f","signer_id":"did:example:http-sink","signer_pubkey":"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653","subject":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765.json b/wai/http-conformance/objects/rcpt/8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765.json new file mode 100644 index 000000000..1f8a1eebc --- /dev/null +++ b/wai/http-conformance/objects/rcpt/8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:12Z","group_id":2,"group_start":"2026-10-06T00:00:08Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30","root_hash":"12404b3b6734a2e2bde882ae596f31a132e4e30d05beb6ed589bcba1b084a187","sig":"0735c5eccc2e1ef109c5758a39357855359033fbedf08ad2f36cd60bff49c0ea04abb1f869bc3da279badbdfbd3849f5624697c85ad6760bc32a674445d23e00","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":1} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/dae83c921710311cbd34d8d4a2bedab53cf56a9be5afea2dc3d264ee978a03c5.json b/wai/http-conformance/objects/rcpt/dae83c921710311cbd34d8d4a2bedab53cf56a9be5afea2dc3d264ee978a03c5.json new file mode 100644 index 000000000..e432999f6 --- /dev/null +++ b/wai/http-conformance/objects/rcpt/dae83c921710311cbd34d8d4a2bedab53cf56a9be5afea2dc3d264ee978a03c5.json @@ -0,0 +1 @@ +{"body":{"entry":"41","log":"https://log.example/","log_receipt":"01020304","profile":"https://log.example/profile"},"class":"log-inclusion","issued_at":"2026-10-06T00:01:30Z","kind":"claim","parent_receipt_hash":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","seq":1,"sig":"4e37f4d1e84dd20646315da68569cbe25c36815999e23b9116555fffefd6d1d51213db21eda080658a7f8d42a61d8574060a96092fc288d9a808d52d6200f20e","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","subject_type":"receipt"} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30.json b/wai/http-conformance/objects/rcpt/e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30.json new file mode 100644 index 000000000..365c5db9c --- /dev/null +++ b/wai/http-conformance/objects/rcpt/e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:08Z","group_id":1,"group_start":"2026-10-06T00:00:04Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807","root_hash":"24bc07e7f8394236e4365ba109e919ba9da1471ee4b0171c8879d27dd4bbe862","sig":"090d40d3f0e9a30a0086de99befe87dfb11fd12aff7f1fb470adc40c5edd03498675c2f4598842baec927bf5af1081941318b61bd93ad6809da4489c783c8500","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":1} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018.json b/wai/http-conformance/objects/rcpt/ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018.json new file mode 100644 index 000000000..6a5d31fcd --- /dev/null +++ b/wai/http-conformance/objects/rcpt/ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018.json @@ -0,0 +1 @@ +{"energy_coverage":"Whole","energy_provenance":"Estimator","group_end":"2026-10-06T00:00:04Z","group_id":0,"group_start":"2026-10-06T00:00:00Z","joules_total":1800,"kind":"group","leaf_count":2,"parent_receipt_hash":null,"root_hash":"27d390152992012872960999af3942bd9e794db9516bc915637316dee2850782","sig":"7296693723c77c5b6c2eb4f9f0b8bf66b9d825931473c51ec8329505a292e86b1063595188af10f6396148f7deafd52719b2f9792dd71436ee7920c83485a90c","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":2} \ No newline at end of file diff --git a/wai/http-conformance/sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4 b/wai/http-conformance/sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4 new file mode 100644 index 0000000000000000000000000000000000000000..4e335fe23531548d91d5a200f1f479e25da77e1b GIT binary patch literal 41781 zcmZ_0W7J(qv;|tO*tTtV&`HO(ZFTSk9otSiwr$(CZQCr@tA024&mHfbG4@zvpYwf3 zRePOz20|3MdN1|}xZ~zjHNa3L2K)(+d;&|7S4O3DULFvhB z`;nX>^+^FlYk`Ke*C-o_HW=p^@Q-UUU8C)Hn$0Y-6K|`ENjC76l zEtCeWFu(9JQkpJ8C-o*1!H?nzuBlCgit}05YC(*U` zGdn3Sk&K`QP2`t}4e&aZE+vX*HtNMbzPV_!@Wnhb^L!tO;kJ-EJh_dhGwI^+x`Uz} zYOJS%W;R0g;w{}*`InS*G)V^w%ORjX84Es&Hf|XHn{{SINjJR{6**GFqKoH#bFn`riCdlteb|Y!rKfr+`iE2)>sThF8ga;5iPTtr%Tq z7QkGzG%Akni-~rp>B&yAu{x?5glduKrZq|mS80LnyK1JgSi=7iUBfrsll-lrIB)N{ znYI+)&exc9vbGrD3c}K+0~uo@zzh}%{(jce=O>OQI|8^~*zf?>7;EY9bfD+t4WsEsQqBE$G} zyzLGf`r`PYZ-U~2{%`<~hW4o);=4ajCaKz@I!R*_v&*u8Sxt+$v;2bY$f~nR`Uu>} zcI&}>E!nRfV;Ye<`*o4OxPz}k{{A}cen2bEz}c97blwD=)6hU+1-t?NDz$?l?= z^gJr=hS>YMl^yGj@?3U@u>7(uDPGBG@&{4!g6}Pn`-i216O=Qf-A}R}717o3EdQ@8 zN?wWd?g!j${&mYy5jjdWmAO=DDr9b51+G$$MKM@fcL2t$wUO~I^2=FJ5YF~hbU)D7 z??4UQA0m(Mff|F9He6Y*;EM3w=HMp2#ZR5%XAg<}!7Bk2pVoqpp9#ddH}7eviiJF$eGcWb~m@fcO0 zm%tOWUOxb%L2P=T=K*V2e45L|mKD)ev>nxh8%Y9K%Vft}+&TYLPo^d4Gn-i5GfChL z>-h#;SoHIkz&d?PR>n(c8dnZfQu(cfWwn(}_<0uz=LS|ZqS@7IQy9dP_i!hjiKjFL z-4~dNwfDRA6>`NjAnDy2w@gH!E8=FzE%C%bjOjtt#jMfe@C}mDBrsi(MQykiQ+Yzt zhZGXgz(n>P@23y!QhUi#MnyG!&VS&qpkp!YCpj2&a|_gYGT7vI@%VK#M5R>g{ZIEo zH}{M9V?9uoV|lqEH9;y;OAcZQX=J+rMxukrbyiTkr!8GHbVfa}pU8PtomjIjvE>d%X0I@;sdHqOQ;BXgaLHJM+a5;B-$vf{A}|{ zp0ee^T2c*_(=$i(j; z=}os#wbK9K!D>6}ZNHGAcp#|lGwE)st$oU7i$i#r9YhYur~WdJBI4>xVwq_~SJ)1; zD((hm2_Y+}>Ef~3K^LHA+?tATwaLV;vzR6x_3FCq$&^YYXS>d_6-s7`x|}o{>gPYo zp0JP3>!;cs?xzvxPT1vRXczQWyf*noJif!U6!~FocUC41=TT&I6CDh~dF>BVO%|aE z*dyA&bTauu#<`}Ssn4=KZwp$XEi?n_6u#F@U^E@58}pRviXKj&n4@;$bZ#Oat*XFD z;DM=wrm=ZwA52NV!^C_TU+427#AmTvXqnv&TJc_ZjNPwqungoM{#-RRxllp;3m=rj zU4)z?gwJSe28`24=BC;8XqVZQQ+-TQ`Zri29@)Ko*dzA-DFY7 z0rs39b!}Z@G!{&-xp-_nlfB2E$$5A}rG@+HMz@$Q1YcYkHdBme9bH0`5DbEY#YQv` zzo4T+uKZzh;UlO5uO?cn;@a|psyjNo=sNwkzQ8w{FTNvq zr%&Rn_AqaP-@(WJ3`wEK@}4}UtS*+>1OBz)_?=y3&(m6_pK0pSyDNMtekJp}F)E7u zj@!eBu#8M)gt~8MixJOPF!3TR|C*_6kSH= z4`?kknVuBoSv_8jesC}BEp$m{RHt2HF@QH#@knyH+f`WKa-nm-T!Kv{0blFM*176Yd)~%prbg!3k0l?SWc89cuCSnyfwvaRg_=k2j4Bn zdBiT&hn5dJCl8qEH;AWfpt%h?vU6+{>hC}Jeju4#Wb5$NzOGBI*4pcM7)i%+&>?yw zS?Tw=tEMYUj&I5RGzJ}jF5-CLtw}5zVQRnW7rq#M8S-ft)1Pf}weT2z-_J4w`9mGo z-%$+JR6q1+6`NJ#GhubHK77^^{tJ9VhJv1MD@=mQnS|&L7^-uzP@ia1GmTodl~keC z-EM0{4b6aeZ@bylk6d@V!DbVY6Pju z-vLBkp#?gVctpmN2Qr>4BRH6=?%9_nJ)i26gLbwZScYC(}t+C9Ak1>>*9=OPOZ)h=1lBZcc&C6!O*scmaRnr{Fc*+U0e3 zP)9Zeyrn7BH@pVCQ0vJnH5A^W`fS-Ts4*vW7IO zYyxw@8ejq(h)dJ$u!ma8J5f75|`YXsehe zbNJn20(egD;rskG=_`8cmOL?NEw1{cz9k$Z_uHRz2QP~^vJEDxtuIcX2vr&!kcWIc zi19oU$E8tkWHFyn$KY}NpW*+GP>XVd;k+=vW^cl{Y@{4$`XBWN1%-)x{n)n$Ow`WO>r4aW17%E3fzIhC*UW7QvErc8#1n)9rInCCm|(Kb8UYfs{jJ{HQQ zx~id~g4}`yS}gn9Kfx6F)MP^=;84kBCAtn2gTrYH@BogWYw!tTLg@&l>q^ZU_uh^uaLiqvKi-eEfE=X!=+?W$91bBX&l z0gfl$=zH{&n&KjI8WdAt3?u%5v_HN zY&Ub-7H5%FN7{n@3Y||HY~euB$38LT$!x#Hj#8`e50csBkX_6o%pvuTz6?ELdRCT3_8&oc`3??8tx;Y$!p%>#LTXJVrX=4E2a*f+ zwT_IwT2B7(w@4Rw8jRv)R7TW9-a=1AL-2)t)|uQg*+9JnKk3-8s`qqC7GZm+e6W{K zu0tIa(b*T+i)?X?Boyn!Yi&^xcaqLwi*b3H#~ecA**BcVOfl71T6}_S5|zLKHH@{# zvuS*KLhex&RAbv#OysM;Eb|rDbiZ(}&=(Knl|oOQoIf-Om0|Zt2e%wYhgV@~J`&sx z_$`gNsgs4u{TxQs#I_a@wzWJZp6LE`J$dTxf=^;C$|r#2ZQv&qHgsQDx&vEnw85RQoa zWR>}3qwwl1KN@A$s{G;Ai;y{_teJ~Gs3`CQIU~RFXX*<{1J^s?C#xB}xsNF!z6b`I z>arntA!^x+s)hU8&W87S+rSxxea~K##SWvDe41@8=exAD4?ZHl5gBN_(=rb@!gb)% zO0wN@h9vx!9FDi5q_`%3sz<=%qMyk`YuZw%1_>LUeMDDHD|^Ak1?TV=e;on4Uq#q0 zDlm6G6&p?ag9myNJd3i1%6?0<$Hn+zAG4ak66>VfbbA&HoAIx{P%l|T~)oHl|RAd3`o0aylx+0di>MAchpuXq> zHV++0Y6N87)#cOM?0B39S2tye7Jrd9_zJlPW82xP3EyVQ@yKQ)(`*XL2?v0hBD?vj28-A-mTC_VD(+_jOExQJlggYZiLU2&yXZ0= zx{Zp-q^>$IYtH)4sJ6`wd%8Myk|j2!>uDnJEi=|hl8QW|E9GkXRP1!iU=%gmpQUAO z8gmt|G1*O8xk4p22T@&B6pmNVSx+7X^e4g=)~7@kkT7J>8?umz?YFV}kjq+bq@U^@ zvH0vK+KI|)rxS^4*dPN=xqs~kbv|V5!t4}Dt8)a#tp@EQ&cg+=9Lxp&^r^@IH%CQt zn^+vQLgb`Ej8`RTEcuZi^=b7>RRYH{xpjAa!>6;=a93Pk)#4dd89qd;a+h6AUq-jp z-Pvf86#hdK>1`@7dSZ!e4QrEuA_tmJE1ao}*oj`d7HTY_W^d?vU(-!+jgHF~ zdx<);asqf{R*UnhH{K){@RGitD`R@=?&iBZ$7bon@|e6YkH93j2i-}Ev$G~XdSr%# z@9i^RTNdAZ#qqc-Se~0CVxxsOs(A8q;Sa&#+zZO^2CY77~1!vi8<^niJ zuBnuQ+cM-SUa3w4V#b1);X=5_=euM=$_e5O${==uf})I`3}%Z4dZrp_SL(6k9NEI3 z`E@>}s)$ePF7gJKAp^gFob~cgOaW?C{?MD|(;s~l`zL+rqw`OqC@F4R*{LjoaWfUR zwUO*7u`jT*i^M2*Ti1o{(Oa@!M3!yz7WR^SW#3IzQ%Cj(wM}Y06#WaHiT853_{fry zZ{QY+EW6m+^e&l+qWdTQww)-N@;K%N#(0~#f`KYQ2Mg-zs1hiKSY#)|+=PWH+B5Ki zKmA!8oEsX!o)aXuYe*+iQg4=$CA+yefasqX%e@kc4`*t_|MW^6%rY6{Ait|M{x}5D1Yzm9WHDJDJsb1j&rZicg`p@HkU7mUcrsx4Wxl=~hB}B2<`XI-jCc-T+2eufjKaN}NM{EnBEN5-OOf+* zJRCy*fSVnM@A+*IO=XfV18Wy}C0$vSfK%X|fOpQjhCGv*gxlC*4EP0fKKcola5?uW}kQiwwBe+A|eDevg|<~>_MBXke;uSrPqi*n?ynCNQg zPWTJchZ1FS;g*;g4>I_$Z>_d8{bfsfM7xR3UEFE>;ouFx_oN8J~Bc*Wn}> z)30(__}^+KZ_g9ZhhTx6g?gcRbQQZpThVy1CfO4DwG4J8t0%UapK_Vpz-qdY#IQc} z5AuM1VSU{MVx7lJMP#?ss?ecTWz#`4kkRxeW95^;KTtLoZ}j8B`#xpL>8kD!7-E(M zek=`=pc%QP&qJj)o5iF(yKU0C>~gWJqU-S3+==m`nfxxG9D-lD8j#b1K{a^FE|^?; z8Ea(r1%z`s)bg(Ix`{#4sS_l&S_k6jWI>@@CT_!;f#F_ddImo5o$Q8^qx1Mz;K-Kp z2owu_HwD0G7FF!=r9=a;M32SoRT`6ro`JoI5^Xi18ck9wbZYQTdOi#P0)*BhUF}%z zWJ&Wujo^LEG`K`J6Q%Uua08BHWBVR8l@VV!61>EOgF<;G<{H__MpgQw0$lM^L{+4%dc9K!tPk$^}oX+_==cH z52^HIhrNWavhi*-&B5p5wDg@FY*vbat}IV1qhbeB(4S~7X=!%L7=BVdMMYwj{ZVF7V9GkD^QSrdAOhPI3SEE>qHBrk0vSD3)mkg|cN%nED5RWQD&E4RW2JdLX(Ur>d1 z>pw&mR0-A=jQ4WyysT|;*ZmH=`2etrpl26tRrp2JSok5oH!hzT+lWo5TQx0^(GG$ts8^XNs8 zf=AMGL|ReYCIWB3a&t`HLv!3=lmj-0&jKC#%fBHp-3*ZgH{u=jUGQ1f5M9IuJB>6# zWz|UY%$D{0>2*{RFp!Hp;q$;zJ>I`{7svxVAt2C}z6bxp&e42kgQ|hJ>wrJf!{P=V zWt*_VvYZ{tv$|OBysfV;lFn?0`3v3yN9i8h!^N?c!FRZm&HR8bP2*Ayk>d7jbUGgD}n(Er_$Nx-;JJDZa$BoVjtZ@bQnVi(y1e$iBL z=_!)C_4SFml^Y1mI@=c#R6Jb{FCD!c0~u9K7`TsN=?`>4{=M=C2wue$#y`G;GN z-?88C*!%yS|L@=R`F;QY#(#dF_y0fl|L=L<|DVVI_xX$dR%mMcj>~?>TEF9m-!bL? zyv@)T|Ih8=@B2&tj&**=Jii|=`8)Rb9e4kJe8}(fg}-B^YX9F>2>72T;(rCB|11#y z*&%kwcr*!Xz*lp}U(=#uCB5rT!pTyj;ncc=0*Zg#PMb}?_nFBgHir!hGEZlCK=enE z>>V^H6qXtKFni6?6K=nu)9#^fFGhN)zqv^?KNNCqXfoS_8Zy1Cs@CZ>W}v)|-jVm} zl^+=z6BVej=`=R{3taLU@zfxa-)A3n6g(JFwHT~NJ!KD2*iNIjbVu`!k9FJ)10T#) zI!W&cVmt(2g0wdteiZNcaGXYb)=%wyT2|KJ2T`g}gkzvCxD|flQqwtj7@Lk#N+lgj zkE8i}G=abC`}q2BppT?S`m^E`i|vMj`6>~JGH($=^=>q4vYF(epD za5j6>{Ar(o^r6}6AmjUafh;>9lFOQ`0eXrag3Ro1))AHhw{>BX1gx;tY%f)lFO$o8 zbyrX=U>$K%m5$=wrpsCqFHj71~sqQ15 z`d8wEDChFacc6^>Ci=jB@D4tvX1RebhIk3$1whzC6m{9@?pJstI1eHJdF(#7 zUA!_e;RV?rPZz00{UF0Vw~(xn2mE7@RrCVOLvmZB2J(h-w$^HvDy=Kqi|!xXiRQul%u0yqW`~aKXY&m37$_`mO{!XCIA0;$>^ykc9(LfrtR1sCaL0zo$j%`=_DM&eoVP&!}!Qq4tr zK1}0+H||h*fD_4Lgz*7N1x~c9q|@nN2Yc{Sfjw znAMVzRw#*m;v(n`mXBW44dHWB+)q$N0*I(7wdqb`dcbbu;qJEV=du|YWQf8p2B?6B zp%id0YD`|UWp0V@&uZxY?r+_WtrE#_;s8U-vp-oujdfJNnT=;%@d#hlu5)SKIGr>| zu#@NqR1{w(UZ+qQVSF=|cL>erI$e-Nv1IH zsmowZkoF$AxO@<;O3KMAWF;R4hpHN|7@UMx(3QF_>Ey6FXfEh1E*j2C${UQT$Uo2z z-hoZzr};=eSmee|eKOzlR`!)lgqpb!GGLND z1gF@yXe~(1GJ|t)uB-3lbvRl7eeFu6SQ}!1J%S;5jf4HwNEt-Mz+`bv*_fg8(@<& zFJs`MaJe2LlDP)%Ihz9#K#!vHByzPlW|E;N=rUY{I>Nj@4v!^L;$b3@=_o(>KCTf- z!D5(0V780O(z$&0sF$*ro5GfnVe|-iAS=4vt{k4kH`$`Hn&}}EiB&H>L$qcL#jISTPw1J`s|>ArD~$M0S&kK(pWy z8I`8yv3NqXKad@_-4L~rJ(PQbkTx6sDeIsHn&G=-51fVycd zM>W`W(VN*q-!900XW3Oc4zvy#rezQ&6Y8y`C4UO*!sRHfX(p?Hns5<&9;);ugV=qP z$k%WYq`EtZdyvm`x``t%n)Et1oIrc1i#n-Zs5gYGxdbiBtBB^Zy%@z}2Lhxnnn~7( zd3qp>ho{S{qBCyi&IEz|o{Slq<~_QkTZA6j_o|t!B*uc1bf0`+GP(senmsQwqgZ&X zJYb%J9I^s$C!dL_0eYsEY2ieDR$=qeMI#xICga#Sol-n9gYCp%D?8~HfY#(AE*$dg zHC0J=vLD@I(w(NZ)9pgG3=i~K_;7w2N0{ffps$TYFi&PiiChG(3LlDWAiZhk#^7vV z5>KQm*o`olF;oh&otKAYX-0e@h-(;jG3|p%xS-7sHJHLn>g8PPGAa^Y0I&G#wt(wQ zw%bfFQ6L0fs+i~%t4NueEZh1;U?d1t2JRy70>yb$1&oY_o3*5aC}|$R-ngR3;+F@q z{kz|!>+4Q_pyfUu`$<0GgJ2u4hEmyB=%{%YtT$;vJ8=*$!qOLIt=w3;Kz{KLy`mlH z4GMHB(HN!Yw}Qo~Ab;s2iyP{CusOD{yYN0X6hbvZGzQg4VILJA4+iABbTq8Ww)>vw zn!Cjx`{jO@tOTyX2P^@qV;7=kE}n|z4+j!&*gJ4j#}z&6FkJ zAW)I#=EZEsfalNH9{f3OMpMuYa)Q6lV(aFlJ-7|l1Vh(*wU$p+ThRrUGmr*GE|qyr z5m!auVf9=KR)&v~NysRlUq1ui^d2)8PT`yR2scJ_R!_h%SsvwJy}>P6kOQ|250D9L zA-2&Blh15NUy{WH`_#^WcU$mfE(b{?KL#9}R(xZDT+o-5);DAwWv~Da+z2~mZpiZK zSp_*)rwRmiOR`tzG(*Wpl>#&Z2hnPr-F+iBVFIdEPxcTW*KvGgK2*<<`DiOK1aMZ6 z92EwSY&vMJ<^HjPnQkU@X_EjRbEJs0?ya&v2S5J@wuWBS{EAr zgHDU8=mVaiKJawBhu>~1GV1Qr*{ULRIW&8i{+OAj zuHbILO4W_d4mO)x=rVhS{x;?Rt0myHzJX^4TUC{Smh_>QCKc&q{7LW&* z;7onf6I8j-_3a>ljpC2kct6-BKo!DTy*6j`ds^9cX7%9;yG0kL!)<*W6|d&$^mJ5| z6@cC8SU=B|_cwe^pV}0_H`D;S*DvO2K&xQQjc==}Y#^Sijy>3|Vyboim`}lG*-cmk z!8)>gtYY9*WSscSuCS=^Hs2z0z@_0@e84`d-MEyjuj;Fh`XxB8o~iBTHMlUZV6M7Xf_|Jh9LUvf1!yk8Eg-Sf+@BaZh%X2 zp&R%*Ag*j|+Mt_e1n49OkR?8X{>3J-zvNeUR}G~deG)MMWN^w1)eUJwJdIC~JKSWw zQ`8e_KyL(?6Eoo!p2d~d6;*^Ut6#fgaB2{}InAr1$+V8+jy8+EDWlsTK9$*zo57!U z1dRj^foZO(8-=e2!+R}z$PLx;XbCt+T=G%qQ_v+yn3AuPlSv}KFd*Bn`mXqcwF#z; zKXnlhoz&9jf=LgW+%|(tZO)6L0`sq;lGD7Fj*lfh4O591Aa<~y?M0u+aJiF32n(;$ zUp6xwP7<0f@O_|+2AN`@E-sG(EsWP&C^Et%Vm^pNr}0U4b+B>#WF2)EF<2ZRl}ubZ zkO3ZwyE%bJ;d=TPuPX@c5Jbu$B917h2jT9%omq~Snpx-@*{C3kjJD$hVwKG+ zhuE$xHs6fCsd(}UXD$niL!WX->*FT4JRFMRyB_wIOphnJzuhT08zv9cag{8<_VAb{ zAAIQ#kSyq~m9m5!2MY$IR}j|)gH;ARKt~IL!~hc+@8QuyXS!7mHrIma)75r1ZNsoZ zLo%FSa1YQYQ{K%}Nk|HxM(=Q4WJ~_pyf7!k6Y!8dM9t8(V9frB|B%i20y-xQjg;|! z%anL{Fa#b}Rm?ql04EU@acvO?6tXQu0yY~SpbG*DIR>TaM^BhIBDEf%CqPLf+;iC8Ezj3^va!#nI_7@X2mBKjblZD!gJ0(i=1t zy;v4d1AXz){9(`^#c*-F4#HAr)=8v;CD;RW)i(7N0;;QI94chu!oEBv(C-o>*oc9!O?X7g5UQ|5D^G78Mx%rN(2HW6*e6;KYhpB@yhZ&}h*{0?&*dv_kN%x1<%uXBe zS}FlP%QllvPDD9HW`2ZDQo~$+T_sq48-XEiihnDyd?LEAI&Qdb6!KrN!N_iE5U&QU7y-O2 zZiMWta@)G(fd0Wglip;XUdw;*LSaZF2OWi{h*7$TO+eE77I=h&Ho810TEcTdJnIKH zkTv9rn(W@f)hdl|&rgA3emC1Ar2H-Frf|hb9o;#C`Cc=TvvlEdH5{1U*Ed|2=gcnH6}KoYWz0!8X!dEjQ;}OZ$=*_u0frofH&@naEwf z0__QG!TQh*a=Z$~k*7&=6#-7*hqAJ4N8ix+<~YbAu95Vxo{=ztY@^bt&cOuv1D+(U z!}vlka9)hZFF|v>)FaYZPT@!<@`q3lbTHV&y5p{558PvdyV?{cCB#GXg~t}P#V}q| z?e$qmdj3$1aVnUwWBCCjrf%yq_&#=p=?k}nv8vUf)=G3Ztj1ni&}26IQC6(!Ts0St zR`EnZRw|4sz1E||PZiB3)whE2pbGrkJ@DUoCHR6pfXnep+}`$c2}u$>6wTwAa20q0 z)OJ1iB7?(NT4ejsrw36(g)AkWvHC0;1?Y)t&$9&UQ%Co}&V&bP932ZzwFk&?e=t}E zHgN|#n&ULBf1r=JVekW3%a7@@;)%FNko(DIl2%}$+m15^8MLFHBRBI>VhpIylf%b$ z6!bhGTK$jQ#-oEZ9H9E}iha(Tld`q|V90k5YV@-P8KYaR(K@2|~ zxVYt_lFnr>sLeccF#bLkK;M5y~BH`L^i&wfffn!-11gS+T2|cEF&@y0i;HWm6r{n@njrY6R-oRCICH%ua z##h2vQgn3>zV`X)eS$0vUdT52pWfTQTAxUAhp z(xMu=ue#>$>Rc`@$rzY{i*f@hM>@#@cn?kwC)rB;8p`f6q10%0*n<_rh+lNMSDgay z%mlX^9u=F>Wj>fkC7(&{aP@bCj=!=y0=68auIkw&kzFaK*sgr9J+58^0YBhAmRKjl z@q;imlQLJ@@0GjkG4KM7sUDp!60Tswr z^eYUf`#Wj^Jg{Ty~I2+)BTIdF5HynU_LkL%-TU%mtlX7WyFM%jNoS zHUOUpB3*TNDA*0^+Z9&%9JnRjtzV1j0ae6-rDPHtO*C^obt&ClJ&^NRYg}9vMOA}Y z{xC>P%A;zwx5d1fD;KO)NkF$?m+OQE`Pb+{;IVeFQz~r`;J<^3Je?e1S>Py2u|FhL zJ%Y9LAbt)a;Q~;q*Jd$4fp?nrYJz>nP6tLH3w>o%1RK$Cunm?BwzDGaoclzs+gxTz zu-hy*SqM>6U3uDU|tb>9=1k1Lm$yZ z&y&UU@?aC~MO(;qer^zd3-b~73}_ubr)C{!g1~9L9=aU>gVU0 z!}f$JA51bCSOz;?Hg&b^FJ@Usz<36Gn;j4Je7NoCCh=)-6mA5@lZp6cFa*u8+jK{H zlB5;GRb+XLpAkoRS$-D&8*CXzXgT*f*v($4$M`c!PG_2tzAbOBD7XU?`Q)mUQudXI zrZ?i5u7WOv;;Je#y*sOxx?{m+#8ol!$t>j2!f0Z2b^vHG9_Y}UUX~O6bd{e!r&+@O z_*=g<(|BfnTwcUCNoMHjRFD_02wKQCXH7;sRa_0GiW?r#fdRSXrr%UX*A)=aNt~7y zg4H5g;3i_KrUe(J&uk=!#)=yMPo(ROH6CDp4P6Y28 zSb&k>Tu|V;o2%@Y&B=ojXBXh@CN;|Cql(q)rTr}P$=K+TPfyaief)x&z&D4wcn~Ee zG5B}zmNXCI`3$=c$0k?x2D1U@H@ob26<22W#pH2XDcDe71d)6M7)r+3KS};DymMJ4 zC+QsKOrJ)F#n@nWh|3}c!(~gDf)yvd;c_}ioD61LiT{OT^=Dhirw!luWE-L9u-oV_ zzY9k(`-A=L1C=5Y#f!E z=Vs%@TAY!sr@Kru?_h42pJB0ztVW~g3_pdh#?MV!5!oie%Yy1Q8ukF6!E?S_e+ev8 zEIyr;G1o#h`x4AI)o2tr5DmpKK^^GBAog>S3tSPwHU?k7b7VQW;2VLBp>pmBV=a$^ z?f;wWuQKp%OoUO8#l9Kp3C7v%x`ddB>!OdeyA2h^m*%lqUr`Jk7BhV#R)Sm<59oNd z)HY|${S-DzHguExb`;HzCDrj*KaeC39q+%sk?!Cd+thk0ER3?Vf5j!V40WV$eMR~= znH?-=b!~J136BKz!g$|nwa+Ic&*Yzh6B$FlnYQ`@s85IS?{o#4A|pcgek9oQCz{4G zHb{%2u>F3xs;Wnzqb@U#Ab0d3I#>Q-#`}4;h6F(aDddu)duj;og}Ve@rxV-DbKtgq zyKd_a+66whVYrk}ftri6poS=hf9cLHsoMt{p*cM0rS82QLUxcQbaz-1K}43=>=37%#qIT3S&(z&<9e%_AxU8OH-V66A#!{nwh6_p?0rCp<-S}u2o@y3wZ^Vs5`5v?Y=p#iiLd~Tzd zC^jR3GKD%W@4*>-sm>5Gd2cY!zCqSok}r&FMHORhY5G)QUD@|+e2csWl zqg6+B67>Ox0z+B~t#;+iUbPJ_RhjKsFfa5EH`z(J-)S<8%vNR9YMLPo08AuFhXkaL81aGiztXt@L_Jf<|i+vTk%?knD zMh~O<$Hg!BHmv1k-dzr)(-;90T_!LbcXf5bFkLjT1hf)+g*hSCcu#1fC4U(s$Hxy_`D2#4EH$ z9}c59y-7@X5dAO{1B3XldlwY(?X0wwZbZQS(?}^^#%&ebSas2h*1-wkb-l%xgwsh= zGMgp#)!bSZMYkoxSzTAncL&GKI9nW_bnRuAz#YW^gW)fdiIips_+&C0%%cyy4QlU0 z_sgcmwbg^btHvVKJmy=_GSd}L*OBcMr{HokP)ve@;3afXCk*4tOTiIbna_pM;19ar z9M_#dEAz!w4XR&mJsgb^59Mlb(A;#fO+Qx5{0u*-5Fu*eT48WCpU8^`!3=tkeyk?@ zGhh?h2y()7bgvvMX9lFzSYBfc{t_f$u?Y0ZO&3dP8&y8&cge%>G(fLlSvDhBsOO+ObUGXDC}6M$8BT`KwIZ_WhLf1Z^t)JRcaz#)%U>vwL2f^pynUUuVLBlB7}3-Y^WUj|F9w4@zAL6#a>i8jQ7e+&pvtgtUE5+*K zGhtkNR?q=Q;`gwmI;R%;_xO8w7r8{GFy1wk%$NTUZ|?!DMbUMQ&h&IZ$siddBjEri z2auc;1Q9{91PPLp$kN;dC6{$gy;)dJa-v*>GT3pOy$Wunc}KfYFx2w^AT{ z=BKrlg9Ci1OvgSoxp^V^XRt~((JT0V8PM(ip75pa?Jjrnh6C6fals!1c;`NrE8M|z zd%uE?bI%)1K9rr|R~q6xXRn;o!A22^?QV1On_L3C$J?q4ed<;;H`zmPoWxr!EQ_e- zXqCpD;q(`+SV2gSRANy?MK-6J1>_}T79PTbKBW(}e$6<|dml z&rJ*ln^tV32*YU5ElRSQPR?ME_d<*bF9B}*wUeLK=4G9?f$=!+ULu9U$e?g=MdcAq zf`#q^%r`U2A-q!KPI3EWqO=fQ%X3$oQJ%*GGP-5~20LnLIDv&miO9niVtcsrEM^B+6g#bNn1Ibr(hVdRLv z#M{8OxgqH3wdKQbDM{mZ^e&U_&PFGZ7%|7I2)UTQg$j4`BC395U8Ok*X?NQjrr<=MjtrY3ny#Os=5nNv`ga&ns=^;mgS zHK)z}Cn`N%Pm01ej{*|yY}k$z2x823KG99W7*oSkbLacV-94Cvb^$(Wqu(~XC*QG0 zK;(`Kbg-9g2&0|xetx+ld`=&QL_YHdn0#yke=al1Z=IH=hp0_AI#k+L0+CE zY#vtfty>lmqvdujZy5Z@zSDWN^h)r@@FF+yyAr9>y5(eBI$l3u>-ai0OV+Tf#itmj zu6xFu@Y~ZB_HVU~Pqhu1FnK`2S)sC%%qEAf<8GxFgEHoT*{9}X)}I)}L!5|$h9r1@ zTgm_3aoj|&jwuwhAi3;p{+-(p(eRG8aFEu`Z^qbL_IOxKHli!&efHRSDZBcMbt^TQ zMut263=a3s!84W5bTOG|qW;m(iwJ%MP0LyUIy%<>N2l}O_Txoux4At*%gFYSfT(H4 zD5bx5?witL9En9VXC3|CY|$SC-;H?w-D^>NkAUEuY1bL)%Y8Lxub>^*T#J9kMx_C=5f?~wLpo=RYm&WhlH zbev5xlW67Ka(37z@(cX zzv3?g(KU+f6&dk#~z1T;=6|cEn5+-0ixzBCF*0Tr9$nV65 z!8zK(_E+%oP%1~NTeP-4 z01w-W@P^rncVh|G)g1@8^i@!oR{PD}>Y`w{NQSt&?BYXbC!}6n`Y}8Rece-j-Jq8H z5=gWz;onXMc3eG^pEkj&F9)MWQMB607j*>nV{ZO@p-pdg)bHqpU)Cf_D2 zvd=`MQ$;RNOV}~cXtIfNrjdxUSy&;ZozWg57jg}H$=5c&zlVQ>*>^nYFZ1cFbU!OB z9rzDwInBfFa+dRt+e{kOP4@`4vR7u7I>B}a&&?u?v$br8u$&&@Jw}B0XxP~vRY&AB zmRgqtRevPn%~$kd@~(O8K4bqP-qc&95ubX!g11#JQwY*b8|4Wn4H*DzT@loUA<^ME zoms6?HTeCY6OHtDso_$Z>Snms?ooaUh_Pq-sclT!(cIo0UdaDm789j;9+sPr=2yLz zPKn@5*k5iACtzl~8$wGqc#0^|nBb)Qj7-7%`MEnNIOqPZ$Jt@dA2g>ZA^ot3H`YvX zukul*SnwTRYrcsE}FLJ?SDfctc=PHFVf#mF_Rf@ zs|2Tob0hpqZV~s8)I-huAtEf$UK2V$Y zY#k>RBm@SCZSLi8H|YccufptOuLdY&gTx(Ko~(yx)Il$s{Xo^hdkuIt+ENuK+k$Cw zMz}(K5dNWS=n*CjivtyXzqf!7glD)0j|dOBgS^F{vrWVsf;bcHqu?Epp587XhU-}69zEPIHy{t&Lj6yR|?s26Ok9l$=d31psI zCn({cVy}1xZQRg1%r230wgq?PL9ZsL`rqo3&R^8k6X}<3KJPDG9F|9Ia$Cfa-mniU z+4uZ@@Ft%TMT0DIdf0(~6nyS6am@bcO@o&`ty$@3;)%A4$m)fDLpjMCz^V66FrBVY zrA$8-cmsm1bf>whJefup!K_BuWz50W9k#3;u3D-|&Ux>-_y9h~9L{{1Unw@iUZ$I6 zI=(R6#io$>poN;LYB`c@1!{D)I^>Nc|JqgTu6oaj@~Q;8?Z3eN)RYkhZqzg2b)9dkJFnOd^P3$7y6Y&JO)r+ygIi&K zyViN^G5?f)Mf3R+#Vp{qN6NrmWloA0VHN+VUx3bYng==EH2&uh^113qcy;Yu_|JyI z)Z8i;04uT-r1Gl%LwMY+zGCut`RP)(oGzp+M%mP4xy}v?wt?Es(gdHGT)Yeq6Z29`kdNPVyN<*@kuXG1{D1lK{2@*@0S7o({Qo# zgbgrRZ84E5c){+|Z~Uz4f&SZhpEUOxsq6L!rzziOC)&Uq6OGszczTZql6)u5(687l zuYQ<=z)lq#gN^F3>|^7?ulzw?03_H);Z0yYMw7SoWU(69>NTdLTQ}_CF0kjt0PkI$ z%B1%!@KzIj2vz_Od6(Vc@hpcP6AV&baNK=cRwB>UW)OYDaIpH$8Dw8M8|h#AqCZi# z^%fh>Z#Yv#gj&oK+}n1%SIA#)Uda#re}l_)_kyDJz}XRC zBJSO{Yt$)Vtse8N?6xHSPe6uz=JtjVi)N|SPC89IkQ0KUstn0ZX7K%1`t!*9&Mm%$ zR)T=adNM3L>UB}=+)BErzl*ImpYvW;sA55CU>W9-U2HFlV9nhEK#R2(M(qJ%Z4NDt z5#b`F?S_Oi-6igMTEgu1%gE-uI{(ACWS2A8oabN5YxbP~g`e3N(cRgsdx3mEM5HlE zVxJJSE~Fth(ozuXXs$Z3GvW}C#Ajg>6=SK`V7rhU2*0A~f}3tbzQZdTeB_iOd+l+x zL!J-Ms{(wsKc9Z)X7IA;^fok(vz7Ko56&xpfmq9X@aQ}>|jm@qsRyg*^Tf8X#bVu2rsukkB&5pY!^s+b#lIx9ppgO6fy_e=7um{&+w|cq96b=sbWWHbMKe0jUjmQZD&0=cFk9KDzH-;n z6RbMKL$mlNY2olMNJkzGGrF(vTpaLA@#+50Vz|+yj3_5JnWd_4SU`^Qes-1n4zCyX zp?lm&*^{>t8G(C0gSd+?7IH_u3!5>EnFdeGIzH2T%6Gy`xP^>y`}#FtsgLqT$UGu~ zTsLFF>3Rd6<~DSU^H9z}i%zu%+>dEz(O68=4qs_K%&V?IBFpu6%8R0HxX!L3?I6FD zn`d=q+8C#r-V}@tPtbC9E_rTp01duZe$OX~4XTDHPp9Lo2gy8dy?ExHLj04{&VGA2 zhs^Tlct3|_=tp`X$?Ns>`hUz31*tmDBtn_Upnl!KtAJx!jp-KX54hb=Rcft}8xW;z}HXeYD& z&d&B01W({kvff11M3tnQ!^*0r&Zt&~iQ=xiTd$L;AdDI->QT;Hl2US{ct@m_*?4!h z+17ApxFuA2&b)d$ANUuG^(4K*JHh{Uo|AVl!@okxc$M4&n4u)VZ@eNruX_cJoz|pa zcpf7&a1dcW?~q@UbkW5~CrC4HQ8h#1Px1dCqePVVjmKqG`j<%)PB#ts^Y9l$VG=Mm zaKiiE4fCGqYozn9?^zd2rE5VCy+Ks56`dCLdsEeH5g8$Xc;9KlXQ^`De7_9hL6gC! zxFVw=LN{Nu2Uh%?Q{1VdU-)an4f1z;OpUdpSub~`*Io96glfm|%b>aWmbK955x+iZ zin}dkM>;R?oG$);bBDAhJDrWp?kW~bA_#eV$Efy>vQV5$e~`DO{{uQQ^(Nl z@X1dGZG5NNqMC%CgARM2w+wyNMne);a#Bd$l!u%@=~38xJoKG1LGvK9_Yv!)hXhaj zyI?^TGdLTKevmkl&gEaB4$G5s)&lF_rm=4tD)qsRV zk-+fdA*A`dKZWlU<&z*s)0pn1S-1DZ%6lcLFy5r7;l-+H$RDa~Wuk)A-5Gc#(ZqSw4JF=tm zLN#>Lk;CxiKa>$dk=5i&eT4na-nHF;B^$(ddbh&sG#5=D=C)_Ruv=_1@kZWf`W?LU zeS5*B;Z9&762nA{eskqKF_hi}R4|o8S%)f0rA1X5HC8Z3_W?TTf2# zZXl|-Jsb=n!7aKxX!#`|t(Oh6)f41$vJ`^%i?=`x4gNL{+ypa>jCM;vg4c0AH@!uY z$!_<_VQ%Lj8{fh+ll4J;S(4m$m>muw>3ZT3ujDocR`IgFZ&LBPplN?hmdGW%80nx+ z>eAwC)1Gv4Zn@3b;b08gNnSXq{EvZpxoC5EeMtk_Sbhqw+{ZwOZ0D8z)DGBiZfCMf zYVt&tU@PI_s>Ppr_e@FqA>>I``94i&xLeCD;9XJ0z3-d^a^A~=Nd8%{Yet1F#0jU9 z>gu-O4})c-ExAfiZ@jo$z#V~BD?)zbear!81iZfmSuQm_XyA5)EakG`qcD*)W;5jt zrwr>EjPdUC)WI=%!Osktq0IbK`#_xJM_4u!AD;9stK(dN`Rf7y{Y)M6Ux@6M`3d1; z)}Fo;NqS#URxPuIrlEz@ z0{)JD5{?Lmn;+f1cpi@UOIc=;g?#Otpn-00D$oxgReAtcm2r2Ir6hv=XxGv!q=Gk} z&awZ9o_w|#t_PDZfrT#;j_{U>Y_Jmrya6Y5Hg^$U<*t@BfI}%EGJ~|75$Nv?dbC%{ z>B;svK5$)q-CcS%kUd$PL9i<;OV6tZL60aF=&cx42B`7=MgAGQb}xZ#dCM<}SnkIj z{NS{>-vYe2wLooOl$GfrzJR0`6%4b#vNg1YzUkgKBZA_jij4}Mhow|22z(C%j;(^% z+;wb3_&ck}m2DSp)Y-{+ae=2oob?xPFxwRrB11(l;E1=#&v^|sU*@#EX)U)S%SQ?$ zj{b|i5jJBl-5GX2NZqq+Ix`n6)lu@Kd(}&8+j;Fpk)SQFrapIO0lmJOj#HV;I@#OL z>(}s((s^PaxEFtUnPo2zwPYG1QhLtoOp7@UA-GjdTNJRTf3FXRemcRf!=W-=xSwNrQ-*;~FV=9>197;BB_ zzy@0<_>e3#KhRzf7JUIK{}S?xToiKnHHvy`*vIgg<}-aXR0qWsQdB3}&hBK4o$-in zOb3@|B(0DCA-(243pet4dPVRh$*QKyD817R4(q#@fuhXiHw;f(5K`A}BgDKP$uaP&B+CwLaA%7`JlZR2Y&aQ$ z0k`=3b`bo35C;T;`We~nJT-gd&+3d{R@HGz%lf2%8qOg#rvCQM0^hkX7$x_(5%gc# z#{VnGz}{!mf+Ora^JE`Yhgi%yzJ(|DSGLJ^^|qNTDlIseC%tCC+nfcrX$d&pFI23b zGbjat(P-JpKO0O3vZjlgNuJAz@|8*J-=eu`4X3StUjCpkZ&Vw+DG=Q|6Aov))D*r; zAD2&ooVqP)njvPDe5^Jj>ag5&=8M=}`Y*{1Cd<#H8>tYk5hqwRuQw?pe{^LdXz-swb-Amr@ z^h3lMyRemHq`S&Gz`GqH>qSF-LQ{I$-50!sC}MpFBLYnwF7^jtZje^&p_S!&(jjod z9I6X{>1QN&!s+%0cP64cA?p|xP*cQJ2uu}b>4I^zJ7hJE`lX$G-XOACOmp{;xlUtu zyT0X>=HHXb?j)K^WVCIKX7AZzx;(t?^W6ffj<>~ML@Jx3L3W!r)bdyT(4P}*3umZr zWOLHowz0LmCrXOr{=4wxuiyuDTgxG&zT538v+7%_zMf#Vcyq*YRnh&>W)%C(F7vDG z=3EdL-L$l&-#P3{sQkdjxJALl`#?vE8@88|C?Zv3)z}&6Z6ImHK|FnH-NjxUJu0_) z{euc7o%^Y?+W#(`33mK3`BD#uziOkm6k@|y+_D~()%X#bk);y1bv_d5#)Q>j8_s6S zO(~U#2=C5te3&_;^tRv3t8Vg{9FXv7?GF@Voont+F~Obee-gYc^1FrEQc{=x=sXCQ zk%sm+b=JEc{@|XM8%-`+H+(@mhxJ%LcFI2wNs6-M7x=>l`$P3+AQM|dP~=cQQv=ah}HNtfTLyqv@e6Nv7ps@@XS&)cHsV*dG_oF=!2 z&%Lj7b+^B@EFFBf9qEU7%QsOk^$c(X_K}us2zXqHNF6XwWC)w6e9i@1$9^JqI>$(Q zt3`Ei2&>sm>;guniL|`m-)3e-%v9cA6a#u^pJ?ht0wXnCZDfaeZt#RY4-V>P#GelJz@z6?GkgxS!GX_G|l{J`BwML~@Rn&@;pBVj85qUmK+vF1NBcI%*BjgOso-*?Yh%>DMpE}@6!gz0x zKU|F0wRJCNX|Pdu3fjplqK57O1pYcwPW-CQ(nx2D${&nGOrVgxY#)YqNralC&yraz zCXh}cNL;of7!NE`R^UwUic$V5{U>S8>p6$~TjV2IBRGUa2Bq~4GLRLA^mFFmviK8{ zKx9FVtcn`%AI3G$-rgcVMaMGQ@XB`KkH$EEcIQ8o3p~ z56qVb0#o>wC1;V$9>qn^ z=;kzCbzQUG&rClv*X4d~pu%w)0@|f8Qk=$2dMW&TaXJH8Ve7HK=t|hdoq%(@3Q5m2 zY${YOenG4+&eXJr^@i{rr#kGNT99A%^i7fvHqH)t81v-OYKAK9k8mQwkZ%n0m`8{) z8QIE-&=ui1-Q#Q$186m1R@;KvP)1$VZFnl)!AmSA@A9ndBCz)2?}?Gm|B zMd)g*DulF-dcD0)Mg^bPn?Y%QnfD9N1SiNmdKB1$L|B=BIXT78`T`P=oc2>gPBtVX z{ITv;u`TGJlI%V&4&1@>h}lIT6+Qn(5 zV4~VAhda;dP;r2?gAJ6HJq^oyk+3nJV*YSCOme$hN_x7FzziDe8~d)v!P~OT{x1^k13Glb3fw_t>MY+KL^$tsp3I*Oz#NJO$-xMBmj zZnNq+PAXt3i5bFMnulhIU*D^tD%xrEN4<&9@qc6S{vp_1Kj`*su>T@x<%Hq9pm)gC z3=#5yq8l)my@C_&C9YIfdN3%>Ht4c$U$)5|DF!;J*d{rL3a3Aii>E_|$YKt%Tzp7w z()rwazls7tam*%NgO0-5#dfVfjui{KxS!e0WU9Gj#J|w>QO~u*M(zgsj_ar& zSQ+P|@GR-4uKU+`8T%(+B7P(IzoiWV7GfM4r>9&IGRz-31wR5Gi40efIOxCwQ=Wy(>d;ggHk zzXnTy3HnsVy6Y4d?}t;uvG5L^$6RqXZ1Qwuc~}w{nU4_tl9*{)yv!=WH0}k4I^V=DZ7}9e=L9V29n}-sG1Iakyd1-zrs5X ztMoId4$O8H8D+ZJ(Kf3%X{VU0`l8!K@3FhdI6oCQ{EyxHvbMX1ZXgxpKV)uL)%lmz zwQoUQx{Lo|(Ae(+55^MbqNs)FMwy_dh(4dq1(y5O>2Q&HbE`1 zL){E^2Xrl(0~d5Ma5W2x(ivVp`n~QDe4{dX{ed7lfw@jLXcy!bi|}qK#DDRdxchZ1 zeW*rw38oj#3Owq3x16^P%!C*`!<<3rX64qHIu#O9hbnvxT5$%CAO0YFpWe%Pqy@Y6ZRlhuGYv-A?bSST7 zi@*ypfM(Py$ZhD%l(37SqS3&OP=!osq?$O%KZgC&P~KCy%^tckEU%l{1L`!m10Q;W zAh3D^qUT$IuugEsihbU5AVx1b+1NTe!S4fm@sjyYUsM^AWy=uRzT-?GiQZLP8+^g* zwl?pJBt9oqeeYa2Mor*LX&>=K#hBsV59G0U7x9hrc9!$5GfC$NGWd7t5U&X$0woxM zygQQvz@ywO2k167&KXC(B0GSs$_tsi-<@otb+}bDu-90U$pAs$8ty@}D&T581X(-F z=IVQ}K`xMHe7?$|9)aOM&+TY@l9x{iugG}%C+1t9hZ)07kU1RzwTJ6^QP2@lQUpd~QflqT%eF^o81AZ}ig$z?~=~T1}eV=Rso^SxmDHcI)cpfa5LE)#~bIig^ zxRYdcB&gU#{!s@V8Tt~b6TlXXm+OK9ep|8>bKf#Sdao_sGF|y>1tDo~AF-^@aez#tDUl3J*8ZovVM5V z@&_p;w7_2fjY}z?r0lmjrKD3*ZXj70%uW9H`b+Xc$`ME0?ZB7EB5JT3dNw6w1NdQU zfI(6L&wLdT1t!nXAU-Gqyka}D$v+TGhOA~`^$SMQEvl}(1}t2RQwy={miD}x+s{o8 zBl`1QaL>N*TY`l)({cS5iiCs08<0%BCzg{N!Hw{YJmmjQx^e~<=r`dUjLeZp%h4xj z?PgI~^p)w7MPXiKt0!^BC6F0%ch9YA-KmIvWAHE-jElV>$mo% z&~Kd`;+pr5-swY^gZBf!s=j;NMCr2Pqc8`~^2Dv^t?+k4Vd?=X9^5DYFc-q_6ZuV( zkyS_9kZyLcOhZ03C%v=eC!`b@O@0-X{V7N-)QYWx7cotkk=Atzx<50j(~x^&aIlc9 zhc}^<@Z5|#A78Fs*qMl_T~QfzZ?81oH{JC_UDlRmd7a66t+l~VW-WLzJ)L8sf?qF; zrMGb=Qm~Rd2h-QZwhBgO4&E1 z=cg<>yHdWiNh!yql$TTXJDC4}%IPJiTzZ{qqprKOY-`DdR|Us(5bv*Ag~lC-}vXuA(*lUlP4bG%dnqcFZd>$iD37z;E`== zq<7Bi3f;w}P7$|_o6XxrkK^@v$DbDTwgW*`_!Rj(q5z{l$@{q#!*R&1^qrrTRCEfv zE`J2&)>UqAnoph%N0M$J3iK8m!U;}DO6$$Yr*s+c_N?s3u!&nw-w7o}r3R z%8dy^Wm;PI({llB&8H(l+$x07OR zbg_mF*KP1ZyukYDg>njc3;~@C=5IBO9JWJ2q8{$2g3#4@eVyjee)yH!fc@$9;aQ+^ zxy)1%oq0iL2P6#2hj&aTn+o}Xgc&0~_SUndFni)zPLNX?*&4Q*eI@JgA@VxO%1`*| zVLHx+qP|swVWRIRr|da4)jlGbf?^QYm%o*eY`O^I2{NQ~ba=$i6 zIkCJ)sKeY22p6oJ|B+uFwA{)x6>6#)|HZqF%sH1KC9_&j5);irvez%?_pzsfIDfF* zOD@yX+m~tB|EA2U#POFZh6W}JC=F>&OLi`Z` zGOzSx_X4W{=S8e~iO|jzGR!R-=A`N5UcfZ#+Q{&0xR(ZrYGe^|RpfMfaz}pcy~o-* zo5S%ZW{z2pg*(CqQbGwjaG@ETxxd&N7JL;97(jEiF^Cf~2_stf6 zlo^DKiXEL!><-kt)B64V1_<(JP_@}Lx|)7&Tk~fuo%+e&BTG>Rx7R`tdv2N8ZuL+> zhU0P&BaX2gbSdkj^8{n)GiL*>3dy~FPHNr9Yz%eqv;VK#8M!$7dh6*X_p!H6tPWxz z({Yt{V1LQ(qAjxBY;op@$C9Z*&M-a_0#Y9bmFReYmZF_zrdqI6W@YOjCy|+o;E}%U zei&Mw3PkMDZtrk3=?a-UCffrvTq^t+VBXfOuzyFlfZ_9tKg-D^76hGO`XqURRZ?Gy zCt-1KuqjX83Xi~?D2*JNZ`(UQasPIwhpFi)x*H(y8U9^z%PD2+yRG>-FuV;$!BrnfK%`8WH?yyxxH zU&(p^zx81&VHSMMbAYr~8NxaH;7DJ>hkNny16^bzfGFK0sRSo-|d26LlfP|D-FShtmHkuf(`Qj78`=k z;ktl((OA0MPH}6P_WpNjr8``f2k~nc$N&SK)*?pjvbFv0{#j()sUz!!JDs*r4mLr4 z)7Tj&K9E=0RWgV-^giaj5$MQ{+=|uwGn_ed0JFQq3i!EMdM6b^8q0&F!SrCZcN*h& zCSF?XWmVNW?;pNB{8@crn~^Laxm6+U*q{1|+6OVp1n+zClTzeF=r}{g%&@l~2*EDO z)~2~r73A+(`aA^chB#ka4^+ebCM&xe)(y6bT6%y}R5xVVkSI^JrU z!+QeK<{dUj=cf%|n9op~03I$QipmI90aKx-eiK7zD^TTUvqofq`P8}X%z~uMSkWB8 z^UuUfcHj92p!#a=A9RHiY0Ci?c1^W&cJqTS_VFJ+I5t&U^XxV222|32Q1y$rW;G7nt6Ax{-n1lZ&s zdxI7xV?iXHV*W9+Rc$g5>h>Fu@hubR-;KyJX!iFAsO9=CmfF8%AL||vB4H$>edKqR zUyCG>!@KV1<8xIfSrZfB>&|$0yv?q1A;UqEP7it6Qltd5n45T?c&7m%d>n2J-lLUd zZI+$Xf*BJ{7KjDmWwYEH%3t}D_!IV2l!U8$BA(E-BBwjr4o8;TN^qfH3|4yILkzPg zB-*D#{tOftcGCU9c^Pa~S6G}s%GT8j#P}dDTtLmtIaUXw_EfhUpdbgr+Fq2dja;1T z+%smFOT`tNz*_?Z*w<|cE$|p#N8W+EcPdTI^Qj`vJG>pasG{WiUNb+Fz6rxA5um7k z02XBPehspcDWU=&X|L*6L0|Vg=%4S1J>p>45rMS2fKEIB%>P6<$;Mh9cIR2W-JmNy zA-?V$+{4>;H!_u!chcIE>L#C}2J;5|nryAMaH`tKgX~>eiJzc7+-_lv7#eh88&xHD zJhBEBh1&X;$TCp}t>2JdWmA!ZXf##&1G3o5h&fh%7~u)3s$KzkP411Q*Xcd+TsXmY zXPN9A6ch=dx%Z~qjiDLCEg;7A)aksn5b0Y5BEx<45IK(;Ljq8W&>tOMgz^5T=;J&O z&#)O`4*(FWv7urwCKJoh?Z&h=z7sidJ;%)Mt z`vXmfoM(N4$&gd-MTW?4;O0q0zPvHyExy8gn{|YGcC@VP9{2zB zT=6Hlo5LseBz*_6L|^Odz5_z#DHHGP0rjFY#GsCu{A8#+q?gGxBzJHQce)62ULUKo z$k0>EJ7+t9BALw;Fh|)Beg#O){07+ZVtby?Kws-lDhFecW#*CC;+H@^$*)zBaGqbt zoTpFiCFe8JhK}`K+4OFr{y~qod(>sff(FN$cV7H`#$qjI<_x(WEzD#JS`rJBf3ENk!?&5L!u`g-q%I=PJSdD53%710MEMq z4SG7r=-1KBbyt%GB(yaCQ&GS_hV0$jv@~mTDht?<1$ zjx#8Kz`EpXSdGtzSl0z5Pre?kAB#_KoVx=)tMF-$Pk?NST$^N=5(*aV% z_-k>h!0WPJRPNVhkq(((mpi`A^194@J?eG&zqKxxLmc5>ThQ&m4$6O&hH%PS8_ibR z&UAzPh~0DF;}h{T^)uYeqT|Dy5CQs^^_5d0MSDs=^fEf5B+{1ja*zw;*y-#sL@1^@_kzFN zGGSW(S{UW^*WH|SLFb?uNkuyQT_8Rft2&aZ;a#>&e=BOJBKkM+48jN{`E|SxcLraF zmF!M33y`uy>X7(~0!-=NG`~a4`A0})KXl?`Gt<$37nu+8>qsW)O)}eeK|?D>9|F#E zOQlwRkd2n6o#AlV@0P}#>x$oxUw4-H%Yy^7v0t4>$ckbbK$cZR{os__h`wzOc^TOW zanC-)o9stq!-5T=ku{V24avdfd^&9dIDS2P-dh0T#8msK8Q|3eq%2R+6Li2`usVJS z3U~*D>3RsV5!`d0>oa06vQzEtyoq6dla|lWp|{5Tf^nf3APp4|%>7D?2Ss*0GMdijeZ_Oe zk?W*^sERP*&q2t$VWeCFX`tb>0h?@Rh#}!J{{UtT<9+UygAn#$w%tF8T($9#H~U_H?c4;(_Z)wl-}MUyg^^)ww916M-A&y| zZY+EUTby*XaahzV>K?Zb)OpzgV4KiIOS39YOTUl%mzZt-2|r@X)f&5)W>Q}QDo|a0 z>Xvj50lvJD#><2DH5*-hHbiwWw`V`KbSs$n?+0HI5{FZfVMUPvVWamj~Ap(-4bpr>&6~hC`Fl- zda#}Ae~Nr#HTY1GPjvPY;aS+v%fW%V$eb5l%zix#S*zmx(cU9D1^KcL>rpJ1`3-QN zlP>19PFcAfIi5;7h5W*h*Qh1ehSG!7g04mTvR~~IwT$&~XTbvGcwVms=Oq(2OnH$Q zlqQ+{q6YQnO%@jwHAkJzb_WNri2lTXQ+>%Jl~+X~7%1GN5`n{d* zH&GzDb{9v~AM_dB38(A}W6fRCKe*$zw8QZ}``(VRk-=!&)HWfj*+5yw??Tq92#8N_ zR>R%Zd{1~(j?=5c5(s{N0O{Hhpy}m+SE{R??mUHk--QT-0Z~?s~hS`t&Grm;I)o=2A;MvV~lN-av z>OW{bjeMYA8mtkr*i~HPm(cc1qG6dqrWw>Q& z$Xi|}*(SK*j|SPRyJ({gttg5?q+*(gfVAn;pg2g)dBXnoUwPRr@5GU>f;?WR-Vf*N z#?As>+wBna3V(Mp={gYE1qlqH{|h{e%ouJ{IXo@@V(;5mv}^b=spnVovYYL+GRuq% zQ@bIi5hXu_C#416WupYLjYG8dch!-eb{P&+=AD`bRj=Z|8Q#A%4lbX0fjT_)5m{}5duCIvl2 zO?DRfSeGFGdIE3j{-{0+cLXg(*5D~<;75auVLN#}d}@1(_jEJ=n7>EG(FuA$Sk1c$ zct>NmsTc(y(KNFg(gm)cK?Z(9)5t9Geubr3KX?(WrQacQ)dJ)Z+7jk8TV(~zjb8y` z8Hs#5*~8D}Xpp>I`BYwkL_)NxAPbohY`s{cng@eyC(6S&9{y2(|Fz==C}m62zQFDvCn<2!zKDk#bH1^Xz+G#vqF`gv^4B z-z$tUy}*g~ zzI6A~z3wLaLAc)eCp@Ef+h2KUPt#NGD)FvrCi}6v2%9#Ry;MW9R@_#3g9rR6`oI^a zsQQrYb$=ywXgiSs8Lg|TY)BB1!Ibl-+UdbTKRH{{I)F+P8w`p01AMZ7kIdq2g85EA z&;uvRMDq#RAm2uP#?ndQLBoO!s$tMNG_06y=I&SfX?6(44Ak2pjb1X`fS{BB<@h#h ziWC9AgpFZ+{2mMqCbMxwLO{F%NW109zy5sN9R9$Q;u>40OCX3m2SLtW@(!8o^rTON ze8^tg3GyA);nQvoDDfd=BTj1vd+&SQ^cQMDtD{+kViQ2&QH8Fi8Cc2Cb}1y#hXE%CrfafRd&jTKgsUyH*^8K zErVrs@x*-(^w1)Z1IS@=u+*f7S{3XD6!k{i zZ)9ai83#cVQx8zaHhMiQMH|e(Z0U%<6`r--!H0Ta&>fJ^7MnSA7(9+S!!Gb5{;1p1UX<7m$YhX_+Q~<%u^5W%by?9; zP5r)>@-7a>70_L~%P8k#+TLv;UQyu-J56olTTC~^EONUi^h4cRUV}(&6Otup!Op-_ zU6Hq@omplGlb6#!lZGadE`)5vLyV;7SwmC94gpzc)X{@PzHrOEzO1P&VF_|FI(fV`bh*S%3Y%_WRFq<=!0Tv`?`b52uu| zDdn7$?GICy$E1`sQr5q=F4MpYI|9q=E&<_+wggT zPhWg8z(UK3?X|GqKrG*frIu{Pox=Cz^k4tL_DZ%OlW_cSd{2jR4Swr8l!LLpAdcIK z^%L>E9*(Jo&mZ_c8y4s~?9&F@lW!pTTpqSP!zUHK@5d(s*E0g!GvJeKVg7^W~%&yO+&7U+F^-+=FTui|A^ai02Ee+TQYW1sTfiejzftn}Yli}xPndA)qA zaJJXwgr||O%MnZ8dR_jTO}s82yc_+xd~IFgoc|+sxsy$WIPd_#!>f4DXmx;rYx<)& zH8;F_?s`$!UuN>ip2&hZ6{BqjRR|QdbNp9O3fF^xm=~hEpJzrQHye(tAsdm*p_aFLv8N|Mp0t$Yp-Vjcv~8&D8HE;Rzo=t61UKzhqEP6n zNPjn`{r?6YQcLBRwM2Km&&woS%(D9g)%X+u-b#gB_h&KejtkfzAjREvOK*=!qh|n) zbWiOuuaM5-OJoH6!_3t~91D2f5&15zd@0)`DvEnfcCpY)4N2$2$OAjYje+;+3-Xt! zM^-o&!=3so0Mhn{SKapNGjo`B4@M&k^{4uG+0WjhRq09A8GfYFNYs-ytncsAyM=Un z0a)=gxMn`|Qn`!7Syfme4k#`MtJKHgRfuZ(;b-EpA0xiCDl8fn;hzypvhz*0roOGl z`d6Kk!Dl*?Y66gWciWsbQ$rw9bW{}%UPvVC5huwSv6bKSnqfwEMRrq#AQgSj`NMe% zDe8j3Eaf6ZBHo+AavaDW{ivUlu5l`9+^eBthb3*`i4 z4a@m|h~Z>9(vsZ~*TYMqv|fs=e9hc3sH4_F0o4?6Ckw%!9uU|cCF+Sa&5Jl@z_8ducKIy1&J2F0t6oe6Q8xoQ&W zrL5-8V^*L2s}4Am!-bH_>IXnH4~hhT@w{la_I9o;4vJj%U?~5TWP_D4$>dOZb(XMm z@Dg<2T=J#4=P@tIe#xHl?*a5J?mrB=h+=HMJ`KyGx1FNTi-ID(?MVBR?O|o}3+N#w zNpX>n7g6u@-OF*`;2!6;W@(p$sQ(!AZL0Yc)%9nscGmOahC!1awU8e z_ObmzD7=gDW50=Z#?m!NrB+0g^?Ui7)#qLt?+3Y-7YXtswZUAkU-Br3zrJq@r+-RW zAjN6fBBflC0w&=ZG4K~?)vjl&FA9{2ijFAI>x)*M+qP)irhVI91RO#H83L>=GM~{|Pbii4oDU(UI}k zDPgXG2)Wq5^!W}d_wZI#3sDC_W$cEiiwDgh{DaI z1{3j#iAS?VMMWkgw^dvmS}8IqAu1v+_I0C2B}Kjdjif~UM$GH~aZ$JfT=koJ{lE8n zxZ8-R_=qIbBI-K|^%oHn7atRm7>yr{ijRuIkH;rQCt`C#WK={fdJ!%mAvz(s0+Zs= z4`R`$@Uu~|$v+;8&V)-yzO=Zw=r{f2O;!Fsy(skQ2;6@hsvsV>9*Z&(KNlT?-$!kr zLq{cI4>VXb+A$`%k`iMR6Cz>~B4g3-1p|Q8Bp6q}aHax6O@DcPo3XENFgEe^lks|M+?(eNXHLHH zb+X;6aa%O~jc|ov#N9 zjAi(q(vMyb8nO5-RNm`-;&6xfj=?#(I$u8xZwA{pgH-gJXXQ=5`~Sbw$>&F_B|rP|c(FyqCgNGZHO8WbQSs=!7!vUFc-C<_XhF1id}LH|$B&9m zZaX}3sQ>8XUx|;w`yu(UNWRF}7~EO%P@UYeiMT4XDaLh-XRlvR(XZP#=Jk6l=FM=9 zhdFtiOK$z-Q7QRk6AkZA~!<>^7X&}Q-@;hNXXNxBd!uy(-H*c21zYBdzS2(h^qT=-7GqkT#6B_rFB z%aQEIaRR_4iC(bSb(#)Ynh+T3wh+g zuVYa#7JWBPF~-x1yDi%-qZX01#95-dxz(>RLAIz$XMHUSgyYy`*d zmf8iSdlh0eA{)1_3i7C4Ry231QP1zaxS0OGc(WMt4T|rs9=skl2*6 zmy@Vc93f1C4et`v+{9lfHSW05hwwof|sI?uwum0rpPUQgonC3XNPb5^yGQG!#61UWCHRaABijAyMpC zNd-2z7~+gA#ZaTcK|NFv37X}y+qQ>L@kau{L)`bi^`#zHU zI$|WH@OOCMGJi{Q_TARLho_|P)@eJRO{HH_WOx0(Eoas*p-TDZ2bO#ZOzy5NDN1(Y z)RC-B?$Tzh3$)F?)T(XQ-WA?mnkRamc5OxA`Rz~<_;sbq{jvG-$Lr@0-#+G>Q?hv# ziUBhur!oW#Vv@SdBdiOEu|i>H^_TbW|9btFmBaMN-`v0DF|3badklxc(Z64+oB>1z zJRc-$K#bu!z2onf|2}x(LN+!55!%J~Bdn<;aVoMn#XcH4aist7jerx>XTD=CJX_4X<@zpZ%WMFrM zI9oU-qw1hwYnpB>wuE@w+#1(YLg#vsAXgIS3+!BPhfky4=Xw>w14Fr!Ik07p>nVtQ zW8^h7uNZ!QEAQ{#V);Wp&5I(*V;}yIE?JPJq^M8+eEt2?Z=YYj$|OyVxFt@KM@YA6 I9^d`*FU?T1EdT%j literal 0 HcmV?d00001 diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors.rs index ad5b200b3..0a1f69e8b 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_vectors.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_vectors.rs @@ -37,6 +37,8 @@ use wai::pin::HeldBytes; #[path = "http_vectors_playlists.rs"] mod playlists; +#[path = "http_vectors_receipts.rs"] +mod receipts; /// The committed vectors the corpus carries objects of, relative to the corpus. const STREAM_WAI: &str = "../wai-rs/tests/vectors/int_mlicpp/stream.wai"; @@ -452,6 +454,15 @@ fn select(c: &Corpus, input: &Value) -> Result { /// a playlist's layer. The sink is read before the producer writes the proof, so a case /// refused for both is refused for its sink. fn fetch(c: &Corpus, input: &Value) -> Result { + Ok(match fetch_session(c, input)?.1 { + Ok(f) => fetched(&f), + Err(e) => err(&e), + }) +} + +/// [`fetch`]'s session and its outcome: what a receipt case holds of an object it +/// fetched (`FetchSession::established`, http-delivery §8.3). +fn fetch_session(c: &Corpus, input: &Value) -> Result<(FetchSession, Result), Stop> { let env = c.original(input)?; let mut served = env.clone(); let tamper = &input["tamper"]; @@ -509,10 +520,7 @@ fn fetch(c: &Corpus, input: &Value) -> Result { } Some((meta, body)) }); - Ok(match r { - Ok(f) => fetched(&f), - Err(e) => err(&e), - }) + Ok((s, r)) } /// A fetch on a clock (§10.1, §10.2), driven by a script of steps, each at a time on @@ -765,7 +773,7 @@ fn outcome(c: &Corpus, op: &str, input: &Value) -> Result { Ok(t) => json!({ "n": t.n, "uri": t.uri }), Err(e) => err(&e), }, - other => match playlists::outcome(c, other, input) { + other => match playlists::outcome(c, other, input).or_else(|| receipts::outcome(c, other, input)) { Some(r) => r?, None => return Err(format!("unknown op {other}").into()), }, @@ -1158,6 +1166,21 @@ fn json_lines(cases: &[(String, &str, Value, Value)]) -> Vec { out.into_bytes() } +/// Whether an outcome gives the refusal `code`: as its own `error`, or, for an outcome of +/// several steps or checks, as the `error` of its last step, or the `error` or `index` +/// verdict of any check it holds. +fn gives(v: &Value, code: &str) -> bool { + if v["error"] == code { + return true; + } + if let Some(last) = v["steps"].as_array().and_then(|a| a.last()) { + return last["error"] == code; + } + v["checks"].as_array().into_iter().flatten().any(|c| { + c["index"] == code || ["delivery", "claims"].iter().any(|k| c[*k].as_array().into_iter().flatten().any(|x| x["error"] == code)) + }) +} + /// Every generated file, by path relative to the corpus. fn generate(dir: &Path) -> Result>, String> { check_sources(dir)?; @@ -1206,11 +1229,14 @@ fn generate(dir: &Path) -> Result>, String> { // The playlists, their receipts and their cases (§8.6, §10.4, §11, Appendix F). let playlist_files = playlists::generate(&mut corpus)?; list.extend(playlists::cases(&corpus)?); + // The receipts, keys, grants and parameter sets, and their cases (§8.1–§8.5, §10.5). + let receipt_files = receipts::generate(&mut corpus)?; + list.extend(receipts::cases(&corpus)?); let mut out = Vec::new(); for c in &list { let expect = eval(&corpus, c.op, &c.input).map_err(|e| format!("{}: {e}", c.name))?; if let Some(code) = c.intends { - if expect["error"] != code { + if !gives(&expect, code) { return Err(format!("{} exists for {code}, and this build gives {expect}", c.name)); } } @@ -1280,6 +1306,7 @@ fn generate(dir: &Path) -> Result>, String> { } } files.extend(playlist_files); + files.extend(receipt_files); files.insert("cases.json".into(), json_lines(&out)); Ok(files) } @@ -1699,6 +1726,11 @@ fn fuzz(dir: &Path, seed: u64, count: usize) -> Result<(Vec, usize, BTreeMap for (name, op, input) in playlists::fuzz_cases(&fuzz_corpus, &mut s, i)? { push(name, op, input)?; } + // The receipt side: receipt files, indexes, grants files and key logs, each changed + // one way or not at all. + for (name, op, input) in receipts::fuzz_cases(&fuzz_corpus, &mut s, i)? { + push(name, op, input)?; + } } let n = out.len(); Ok((json_lines(&out), n, stats)) diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs new file mode 100644 index 000000000..b8da8457d --- /dev/null +++ b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs @@ -0,0 +1,912 @@ +//! The receipt, key, grant and parameter-set side of the HTTP delivery corpus +//! (http-delivery §8.1–§8.5, §10.5): group receipts and claims served as receipt files +//! named by their link hash, a receipt index beside each object that has receipts, an +//! origin's key log, key status and grants file, and a parameter set served by its +//! digest; and the cases that hold all of it — receipt files read under their names, +//! indexes checked item by item against what a client holds of the object (the whole +//! envelope, the cid a head proof established, or nothing), chains walked by their +//! parents' links, claims by their subjects and parents, key logs anchored from their +//! origin only and taken through caches, grants files read as locators, and sets held +//! only as their pins and jwp-receipts §7.9 hold them. +//! +//! The corpus's keys are seeds of one byte repeated, published here: they attest +//! nothing. The origin is `https://origin.example`, an example host serving nothing. + +#[cfg(not(feature = "key_discovery"))] +use std::collections::BTreeMap; + +#[cfg(not(feature = "key_discovery"))] +use serde_json::Value; + +#[cfg(not(feature = "key_discovery"))] +use super::{Case, Corpus, Stop}; + +#[cfg(feature = "key_discovery")] +pub(super) use built::{cases, fuzz_cases, generate, outcome}; + +/// The ops this side evaluates. +const OPS: &[&str] = &["receipt_file", "receipts", "key_log", "grants_file", "param_set"]; + +#[cfg(not(feature = "key_discovery"))] +const NEEDS: &str = "built without key_discovery: the receipt and key cases need it (build with --features http,video_manifest,key_discovery)"; + +#[cfg(not(feature = "key_discovery"))] +pub(super) fn generate(_: &mut Corpus) -> Result>, String> { + Err(NEEDS.into()) +} + +#[cfg(not(feature = "key_discovery"))] +pub(super) fn cases(_: &Corpus) -> Result, String> { + Err(NEEDS.into()) +} + +#[cfg(not(feature = "key_discovery"))] +pub(super) fn outcome(_: &Corpus, op: &str, _: &Value) -> Option> { + OPS.contains(&op).then(|| Err(Stop::Fault(NEEDS.into()))) +} + +#[cfg(not(feature = "key_discovery"))] +pub(super) fn fuzz_cases(_: &Corpus, _: &mut u64, _: usize) -> Result, String> { + Err(NEEDS.into()) +} + +#[cfg(feature = "key_discovery")] +mod built { + use std::collections::BTreeMap; + + use ed25519_dalek::SigningKey; + use serde_json::{json, Value}; + + use super::super::{case, fetch_session, next, pick, refused, strs, Case, Corpus, Stop, STREAM_WAI}; + use super::OPS; + use wai::claim_receipt::{stage_point, BindingType, ClaimBody, ClaimReceipt, LogInclusionClaim, SoftBindingClaim, StageClaim, + StageEvidence, StageObject, StageStatus, SubjectType, STAGE_VOCABULARY}; + use wai::container::{PinRefusal, Prior}; + use wai::energy_class::EnergyProvenance; + use wai::http_binding::keys::{ + anchor_retrieved, check_fetched_set, confirm_claim, confirm_delivery, grants_file, hold_cached_revisions, key_log_url, + read_grants_file, KeysRefusal, Retrieval, + }; + use wai::http_binding::receipts::{ + check_index, claim_file, group_file, index_file, read_claim_file, read_group_file, receipt_path, ClaimVerdict, + DeliveryVerdict, IndexItem, ObjectHeld, ReceiptIndex, ReceiptRefusal, PARENT_WALK_BOUND, + }; + use wai::http_binding::{content_hash, hex, unhex32}; + use wai::jwp_delivery::{GroupBuilder, GroupReceipt, ObjectLabel, ObjectReceipt, PropertyReading, ReceiptChains, WaiReceiptProperty}; + use wai::jwp_hash::EstablishedHash; + use wai::key_log::{ + commitment, log_json, Anchor, KeyEntry, KeyStatus, ModuleForm, ModuleGrant, Revision, SinkKeyLog, + }; + use wai::pin::HeldBytes; + + /// The origin every key, grant and receipt signer of this side is under. + pub(crate) const ORIGIN: &str = "https://origin.example"; + const PUBLISHER: u8 = 0x61; + const PUBLISHER_ID: &str = "did:web:origin.example#d1"; + const ROOT: u8 = 0x60; + const NEXT_ROOT: u8 = 0x6f; + const GRANTER: u8 = 0x63; + const GRANTER_ID: &str = "did:web:origin.example#g1"; + const SINK: u8 = 0x64; + const SINK_ID: &str = "did:example:http-sink"; + const KEY_LOG: &str = ".well-known/wai-keys"; + const STATUS: &str = ".well-known/wai-key-status"; + const OLDER_LOG: &str = "cases/keys/wai-keys-r0"; + const REVOKED_LOG: &str = "cases/keys/wai-keys-revoked"; + const RCPT: &str = "objects/rcpt"; + const CASE_RCPT: &str = "cases/rcpt"; + const SET_CAP: &str = "wai.neural.int_mlicpp"; + const MLICPP_SET: &str = "../wai-rs/tests/vectors/int_mlicpp/model.wmb"; + /// The status is frozen at this time: §7.9 holds the set only with `now` inside + /// its `status_max_age_s`, as at [`NOW_HELD`]; at any real time it is stale. + const STATUS_AT: &str = "2026-10-06T00:00:00Z"; + const NOW_HELD: &str = "2026-10-06T00:30:00Z"; + const NOW_STALE: &str = "2026-10-06T02:00:00Z"; + const ACQUIRED: &str = "2026-10-06T00:00:00Z"; + + fn key(seed: u8) -> SigningKey { + SigningKey::from_bytes(&[seed; 32]) + } + + fn pk(seed: u8) -> [u8; 32] { + key(seed).verifying_key().to_bytes() + } + + fn fault(e: impl std::fmt::Display) -> String { + e.to_string() + } + + // ------------------------------------------------------------- the material + + /// One group: its receipt and its objects' receipts. + struct Group { + receipt: GroupReceipt, + objects: Vec, + } + + /// Everything this side signs, computed from the corpus's objects. + struct Material { + groups: Vec, + /// Claims by label. + claims: BTreeMap<&'static str, ClaimReceipt>, + /// Each indexed object: its id, its delivery items as (group, object) indices, and + /// the labels of the claims it lists. + indexes: Vec<(&'static str, Vec<(usize, usize)>, Vec<&'static str>)>, + r0: Revision, + r1: Revision, + r1_revoked: Revision, + grant: ModuleGrant, + set: Vec, + } + + fn object<'a>(c: &'a Corpus, id: &str) -> Result<&'a Vec, String> { + c.objects.get(id).ok_or(format!("no object {id}")) + } + + fn time(s: u64) -> String { + format!("2026-10-06T00:{:02}:{:02}Z", s / 60, s % 60) + } + + /// A group of `ids` on `track`, at `group_id`, after `parent`. + fn group(c: &Corpus, track: u64, group_id: u64, ids: &[(&'static str, u64, Option)], parent: Option<&Group>) -> Result { + let mut b = GroupBuilder::default(); + for (id, j, l) in ids { + b.add(content_hash(object(c, id)?), *j, "origin", l.clone()); + } + let (start, end) = (time(4 * group_id), time(4 * group_id + 4)); + let (receipt, objects) = b + .finish(&key(PUBLISHER), PUBLISHER_ID, track, group_id, &start, &end, parent.map(|p| p.receipt.link_hash()), true) + .ok_or("the objects compose to no class")?; + Ok(Group { receipt, objects }) + } + + fn revision(prev: Option<&Revision>, issued_at: &str, revoked: Vec<[u8; 32]>) -> Result { + let r = Revision { + origin: ORIGIN.into(), + seq: prev.map_or(0, |p| p.seq + 1), + prev: prev.map(Revision::revision_hash), + issued_at: issued_at.into(), + root: [0; 32], + next_root: commitment(&pk(NEXT_ROOT)), + keys: vec![ + KeyEntry::active("d1", pk(PUBLISHER), &["delivery", "energy/Estimator", "log-inclusion", "soft-binding"]), + KeyEntry::active("g1", pk(GRANTER), &["module/decoder"]), + ], + revoked_modules: revoked, + grant_max_s: 86_400, + status_max_age_s: 3_600, + sig: [0; 64], + }; + Revision::seal(&key(ROOT), r).map_err(|e| e.label().to_string()) + } + + fn stage(objects: Vec, units_by_stage: Vec, presented: u64, delivered: u64, requested: Option, bytes: u64) -> StageClaim { + StageClaim { + vocabulary: STAGE_VOCABULARY.into(), + op_requested: requested.map(stage_point), + op_committed: requested.map(stage_point), + op_delivered: Some(stage_point(delivered)), + presented: stage_point(presented), + units_by_stage, + tier: "none".into(), + stage_hash: None, + objects, + conflicts: 0, + deadline_misses: 0, + bytes_envelopes: bytes, + } + } + + fn material(c: &Corpus) -> Result { + let unmetered = || Some(ObjectLabel::Unmetered); + let estimator = || Some(ObjectLabel::Class(EnergyProvenance::Estimator)); + // Track 1: an on-demand sequence, one group per segment, chained. + let t0 = group(c, 1, 0, &[("O9a", 0, unmetered())], None)?; + let t1 = group(c, 1, 1, &[("O9b", 0, unmetered())], Some(&t0))?; + let t2 = group(c, 1, 2, &[("O9c", 0, unmetered())], Some(&t1))?; + // Track 2: two objects with signed figures of a stated class. + let v = group(c, 2, 0, &[("O3", 1500, estimator()), ("O7", 300, estimator())], None)?; + // Track 3: a stage set's base and its two layers. + let s = group(c, 3, 0, &[("O10", 0, unmetered()), ("O10L1", 0, unmetered()), ("O10L2", 0, unmetered())], None)?; + let o10 = object(c, "O10")?.len() as u64; + let l1 = object(c, "O10L1")?; + let base = content_hash(object(c, "O10")?); + let seal = |signer: u8, id: &str, t: SubjectType, subject: [u8; 32], seq: u64, at: &str, body: ClaimBody, parent: Option<[u8; 32]>| { + ClaimReceipt::seal(&key(signer), id, t, subject, seq, at, body, parent).map_err(|r| format!("a corpus claim: {r}")) + }; + // A sink's two stage claims about O10: before its layers, and after layer 1 was + // applied and layer 2 abandoned (http-delivery §10.5). + let c0 = seal(SINK, SINK_ID, SubjectType::Object, base, 0, &time(60), ClaimBody::Stage(stage(Vec::new(), vec![1], 0, 0, None, o10)), None)?; + let objs = vec![ + StageObject { layer: 1, object: content_hash(l1), units: (0, 1), bytes: l1.len() as u64, status: StageStatus::Applied, + evidence: StageEvidence::DeliveredVerified, reason: None }, + StageObject { layer: 2, object: content_hash(object(c, "O10L2")?), units: (0, 1), bytes: 0, status: StageStatus::Dropped, + evidence: StageEvidence::InclusionVerified, reason: None }, + ]; + let c1 = seal(SINK, SINK_ID, SubjectType::Object, base, 1, &time(70), ClaimBody::Stage(stage(objs, vec![0, 1], 1, 1, Some(2), o10 + l1.len() as u64)), + Some(c0.link_hash()))?; + // The publisher's soft binding of O7, which O3 is listed as derived from, and its + // log-inclusion claim about track 2's group, chained to it. + let soft = ClaimBody::SoftBinding(SoftBindingClaim { + alg: "org.example.fingerprint".into(), + registry: "https://registry.example/soft-binding".into(), + binding_type: BindingType::Fingerprint, + value: vec![0x0f, 0xf0], + resolution_endpoint: "https://origin.example/resolve".into(), + }); + let s0 = seal(PUBLISHER, PUBLISHER_ID, SubjectType::Object, content_hash(object(c, "O7")?), 0, &time(80), soft, None)?; + let logged = ClaimBody::LogInclusion(LogInclusionClaim { + log: "https://log.example/".into(), + profile: "https://log.example/profile".into(), + entry: "41".into(), + log_receipt: vec![1, 2, 3, 4], + }); + let l0 = seal(PUBLISHER, PUBLISHER_ID, SubjectType::Receipt, v.receipt.link_hash(), 1, &time(90), logged, Some(s0.link_hash()))?; + let mut claims = BTreeMap::new(); + claims.insert("c0", c0); + claims.insert("c1", c1); + claims.insert("s0", s0); + claims.insert("l0", l0); + let groups = vec![t0, t1, t2, v, s]; + let indexes = vec![ + ("O9a", vec![(0, 0)], vec![]), + ("O9b", vec![(1, 0)], vec![]), + ("O9c", vec![(2, 0)], vec![]), + ("O3", vec![(3, 0)], vec!["s0", "l0"]), + ("O7", vec![(3, 1)], vec!["s0"]), + ("O10", vec![(4, 0)], vec!["c1"]), + ("O10L1", vec![(4, 1)], vec![]), + ("O10L2", vec![(4, 2)], vec![]), + ]; + let set = std::fs::read(c.dir.join(MLICPP_SET)).map_err(|e| format!("{MLICPP_SET}: {e}"))?; + let digest = wai::pin::sha256(&set); + let r0 = revision(None, "2026-10-01T00:00:00Z", Vec::new())?; + let r1 = revision(Some(&r0), "2026-10-05T00:00:00Z", Vec::new())?; + let r1_revoked = revision(Some(&r0), "2026-10-05T00:00:00Z", vec![digest])?; + let grant = ModuleGrant::seal(&key(GRANTER), GRANTER_ID, ORIGIN, digest, ModuleForm::Decoder, &[SET_CAP], "2026-10-05T12:00:00Z", "2026-10-06T12:00:00Z") + .map_err(|e| e.label().to_string())?; + Ok(Material { groups, claims, indexes, r0, r1, r1_revoked, grant, set }) + } + + fn item(m: &Material, g: usize, o: usize) -> IndexItem { + let (gr, ob) = (&m.groups[g].receipt, &m.groups[g].objects[o]); + let (acquisition, evidence) = match &ob.label { + Some(ObjectLabel::Class(p)) => (p.label().to_string(), wai::jwp_delivery::property_evidence(p)), + _ => (String::new(), Vec::new()), + }; + IndexItem { + property: WaiReceiptProperty { + content_hash: ob.content_hash, + joules_micro: ob.joules_micro, + acquisition, + acquisition_evidence: evidence, + group_receipt: gr.link_hash(), + }, + object_receipt: ob.clone(), + } + } + + fn index_path(c: &Corpus, id: &str) -> Result { + Ok(format!("objects/{}.wai.receipts", hex(&content_hash(object(c, id)?)))) + } + + /// The committed file of the object `id`, as `objects.json` lists it: what a case + /// that holds no objects in memory (the fuzz) reads. + fn object_file_of(c: &Corpus, id: &str) -> Result { + let listing: Value = serde_json::from_slice(&c.file("objects.json")?).map_err(fault)?; + listing[id]["path"].as_str().map(String::from).ok_or(format!("objects.json lists no {id}")) + } + + /// Every file of this side, by path; each is also put where the cases read it. + pub(crate) fn generate(c: &mut Corpus) -> Result>, String> { + let m = material(c)?; + let mut out = BTreeMap::new(); + for g in &m.groups { + out.insert(format!("objects/{}", receipt_path(&g.receipt.link_hash())), group_file(&g.receipt)?); + } + for cl in m.claims.values() { + out.insert(format!("objects/{}", receipt_path(&cl.link_hash())), claim_file(cl)); + } + for (id, items, claims) in &m.indexes { + let items: Vec = items.iter().map(|&(g, o)| item(&m, g, o)).collect(); + let names: Vec<[u8; 32]> = claims.iter().map(|l| m.claims[l].link_hash()).collect(); + out.insert(index_path(c, id)?, index_file(&content_hash(object(c, id)?), &items, &names)?); + } + out.insert(KEY_LOG.into(), log_json(&[m.r0.clone(), m.r1.clone()]).into_bytes()); + out.insert(OLDER_LOG.into(), log_json(std::slice::from_ref(&m.r0)).into_bytes()); + out.insert(REVOKED_LOG.into(), log_json(&[m.r0.clone(), m.r1_revoked.clone()]).into_bytes()); + out.insert(STATUS.into(), KeyStatus::seal(&key(ROOT), &m.r1, STATUS_AT).to_json().into_bytes()); + let digest = wai::pin::sha256(&m.set); + // The grants file lists, beside the grant, one signed by a key the log does not + // list: each is checked on its own. + let stray = ModuleGrant::seal(&key(0x65), "did:web:origin.example#x9", ORIGIN, digest, ModuleForm::Decoder, &[SET_CAP], + "2026-10-05T12:00:00Z", "2026-10-06T12:00:00Z").map_err(|e| e.label().to_string())?; + out.insert(format!(".well-known/wai-grants/{}.json", hex(&digest)), grants_file(&digest, &[stray, m.grant.clone()])); + out.insert(format!("sets/{}", hex(&digest)), m.set.clone()); + // A group whose signature was changed, served under the link hash of what it then + // is, and an index of O9a naming it. + let mut bad = m.groups[0].receipt.clone(); + bad.sig[5] ^= 0x10; + out.insert(format!("{CASE_RCPT}/{}.json", hex(&bad.link_hash())), group_file(&bad)?); + let mut it = item(&m, 0, 0); + it.property.group_receipt = bad.link_hash(); + let cid = it.property.content_hash; + out.insert("cases/receipts/O9a-bad-signature.wai.receipts".into(), index_file(&cid, &[it], &[])?); + for (k, b) in &out { + c.extra.insert(k.clone(), b.clone()); + } + Ok(out) + } + + // ---------------------------------------------------------------- the outcomes + + fn text(c: &Corpus, path: &str) -> Result { + String::from_utf8(c.file(path)?).map_err(|_| Stop::Fault(format!("{path} is not UTF-8"))) + } + + /// Replace each `[from, to]` once, as the playlist side does; a `from` not in the text + /// is a fault of the case. + fn edit(mut t: String, edits: &Value) -> Result { + for e in edits.as_array().into_iter().flatten() { + let (from, to) = (e[0].as_str().ok_or("an edit's from")?, e[1].as_str().ok_or("an edit's to")?); + if !t.contains(from) { + return Err(Stop::Fault(format!("the edit's {from:?} is not in the text"))); + } + t = t.replacen(from, to, 1); + } + Ok(t) + } + + fn hash_of(v: &Value, what: &str) -> Result<[u8; 32], Stop> { + v.as_str().and_then(unhex32).ok_or_else(|| Stop::Fault(format!("{what} is not a hash"))) + } + + fn refusal(r: &ReceiptRefusal) -> Value { + let mut v = json!({ "error": r.code(), "step": r.step() }); + if let ReceiptRefusal::ParentNotHeld { walked, stop } = r { + v["walked"] = json!(walked); + v["stop"] = json!(stop); + } + v + } + + fn keys_refusal(r: &KeysRefusal) -> Value { + json!({ "error": r.code() }) + } + + /// `receipt_file`: read `file` (after `edit`) as the `kind` of receipt file named + /// `name`, by default its own file name. + fn receipt_file(c: &Corpus, input: &Value) -> Result { + let path = input["file"].as_str().ok_or("file")?; + let t = edit(text(c, path)?, &input["edit"])?; + let stem = path.rsplit('/').next().and_then(|n| n.strip_suffix(".json")).unwrap_or(""); + let name = match &input["name"] { + Value::Null => hash_of(&json!(stem), "the file's name")?, + v => hash_of(v, "name")?, + }; + let r = match input["kind"].as_str() { + Some("group") => read_group_file(&name, t.as_bytes()).map(|g| g.link_hash()), + Some("claim") => read_claim_file(&name, t.as_bytes()).map(|c| c.link_hash()), + _ => return Err("kind is group or claim".into()), + }; + Ok(match r { + Ok(h) => json!({ "link": hex(&h) }), + Err(e) => refusal(&e), + }) + } + + /// Where a case's receipt files are served from: the directories `rcpt` (by default + /// `objects/rcpt`), each searched for `.json`; `omit` lists hashes served + /// nothing; `as` serves at a hash the file at a path. + struct Served<'a> { + c: &'a Corpus, + dirs: Vec, + omit: Vec<[u8; 32]>, + swap: BTreeMap<[u8; 32], String>, + } + + impl Served<'_> { + fn of<'a>(c: &'a Corpus, v: &Value) -> Result, Stop> { + let dirs = match v["rcpt"].as_array() { + Some(a) => a.iter().map(|d| d.as_str().map(String::from).ok_or("an rcpt directory")).collect::>()?, + None => vec![RCPT.to_string()], + }; + let omit = v["omit"].as_array().into_iter().flatten().map(|h| hash_of(h, "omit")).collect::>()?; + let mut swap = BTreeMap::new(); + for (h, p) in v["as"].as_object().into_iter().flatten() { + swap.insert(hash_of(&json!(h), "as")?, p.as_str().ok_or("an as path")?.to_string()); + } + Ok(Served { c, dirs, omit, swap }) + } + + fn get(&self, h: &[u8; 32]) -> Option> { + if self.omit.contains(h) { + return None; + } + if let Some(p) = self.swap.get(h) { + return self.c.file(p).ok(); + } + self.dirs.iter().find_map(|d| self.c.file(&format!("{d}/{}.json", hex(h))).ok()) + } + } + + /// The key material a case holds: the revisions and anchors of each step of `log` + /// (as `key_log` runs them), or none. + fn key_material(c: &Corpus, steps: &Value) -> Result<(Vec, Vec), Stop> { + let mut state = KeyState::default(); + for s in steps.as_array().into_iter().flatten() { + if let Err(e) = state.step(c, s)? { + return Err(Stop::Fault(format!("the case's key material does not anchor: {}", e.code()))); + } + } + Ok((state.held, state.anchor.into_iter().collect())) + } + + fn reading(r: &PropertyReading) -> Value { + match r { + PropertyReading::Unmetered => json!("unmetered"), + PropertyReading::Unsigned => json!("unsigned"), + PropertyReading::Signed { class, by } => json!(format!("signed class={} by={}", class.label(), hex(by))), + } + } + + fn delivery(v: &DeliveryVerdict, item: Option<&IndexItem>, keys: Option<&(Vec, Vec)>) -> Value { + let mut out = json!({ + "admission": v.admission.label(), + "walked": v.walked, + "evidence": v.evidence.label(), + "established": v.established.map(|e| e.label()), + "reading": v.reading.as_ref().map_or(Value::Null, reading), + }); + if let (Some((revs, anchors)), Some(i)) = (keys, item) { + out["confirm"] = json!(confirm_delivery(&v.group, &i.object_receipt, revs, anchors).map(|c| c.verdict())); + } + out + } + + fn claim(v: &ClaimVerdict, keys: Option<&(Vec, Vec)>) -> Value { + let mut out = json!({ + "class": v.claim.class().label(), + "attests": v.attests.label(), + "subject": hex(&v.claim.subject), + "parent": v.parent.map(|p| hex(&p)), + }); + if let Some((revs, anchors)) = keys { + out["confirm"] = json!(confirm_claim(&v.claim, revs, anchors).verdict()); + } + out + } + + /// What a check's client holds of its object: `"none"`; `"whole"`, the object's + /// bytes (or `held_object`'s); or `{"fetch": {…}}`, a fetch of the object as the + /// `fetch` op runs one, its session's established hash, nothing where it was refused. + fn held(c: &Corpus, chk: &Value, bytes: &[u8]) -> Result<(ObjectHeld, Value), Stop> { + match &chk["held"] { + Value::String(s) if s == "none" => Ok((ObjectHeld::Nothing, Value::Null)), + Value::String(s) if s == "whole" => { + let b = match chk["held_object"].as_str() { + Some(o) => object(c, o)?.clone(), + None => bytes.to_vec(), + }; + Ok((ObjectHeld::Object(EstablishedHash::of_envelope(&b)), Value::Null)) + } + Value::Object(m) if m.contains_key("fetch") => { + let mut input = m["fetch"].clone(); + input["envelope_hex"] = json!(hex(bytes)); + let (s, r) = fetch_session(c, &input)?; + let fetched = match &r { + Ok(f) => json!(f.verified_by.label()), + Err(e) => json!(e.code()), + }; + Ok((s.established().map_or(ObjectHeld::Nothing, ObjectHeld::Object), fetched)) + } + _ => Err("held is none, whole or {fetch}".into()), + } + } + + /// `receipts`: a client's checks of objects' receipt indexes, in order, with one set + /// of chains (`join` declared or not) and one parent bound (`max_walk`), the receipt + /// files served as `serve` says, and, with `keys`, each accepted item's signer + /// confirmed against the key material those steps give (§7.4). + fn receipts(c: &Corpus, input: &Value) -> Result { + let served = Served::of(c, &input["serve"])?; + let mut chains = if input["join"].as_bool() == Some(true) { ReceiptChains::new().with_join() } else { ReceiptChains::new() }; + let bound = input["max_walk"].as_u64().map_or(PARENT_WALK_BOUND, |b| b as usize); + let keys = match &input["keys"] { + Value::Null => None, + k => Some(key_material(c, k)?), + }; + let mut out = Vec::new(); + for chk in input["checks"].as_array().ok_or("checks")? { + let bytes = match chk["object_file"].as_str() { + Some(f) => c.file(f)?, + None => object(c, chk["object"].as_str().ok_or("object")?)?.clone(), + }; + let cid = content_hash(&bytes); + let path = match chk["index"].as_str() { + Some(p) => p.to_string(), + None => format!("objects/{}.wai.receipts", hex(&cid)), + }; + let ix = edit(text(c, &path)?, &chk["edit_index"])?; + let (h, fetched) = held(c, chk, &bytes)?; + let mut fetch = |x: &[u8; 32]| served.get(x); + let items: Vec> = ReceiptIndex::from_bytes(ix.as_bytes()) + .map(|i| i.delivery.into_iter().map(Result::ok).collect()) + .unwrap_or_default(); + let mut entry = match check_index(&cid, ix.as_bytes(), &h, &mut fetch, &mut chains, 0, bound) { + Ok(r) => json!({ + "index": "ok", + "delivery": r.delivery.iter().enumerate().map(|(k, d)| match d { + Ok(v) => delivery(v, items.get(k).and_then(Option::as_ref), keys.as_ref()), + Err(e) => refusal(e), + }).collect::>(), + "claims": r.claims.iter().map(|x| match x { + Ok(v) => claim(v, keys.as_ref()), + Err(e) => refusal(e), + }).collect::>(), + }), + Err(e) => json!({ "index": e.code() }), + }; + if !fetched.is_null() { + entry["fetched"] = fetched; + } + out.push(entry); + } + Ok(json!({ "checks": out })) + } + + /// A client's key-log state across `key_log` steps. + #[derive(Default)] + struct KeyState { + anchor: Option, + held: Vec, + } + + impl KeyState { + /// One step: `{"anchor": file, "retrieval": {requested, answered, status}}` + /// anchors (or moves the anchor) from a log retrieved so; `{"pin": file}` pins it; + /// `{"cached": file}` takes a log through a cache. `origin` defaults to the + /// corpus's; `edit` changes the log's text first. + fn step(&mut self, c: &Corpus, s: &Value) -> Result, Stop> { + let origin = s["origin"].as_str().unwrap_or(ORIGIN); + let file = s["anchor"].as_str().or(s["cached"].as_str()).or(s["pin"].as_str()).ok_or("a step names a log")?; + let body = edit(text(c, file)?, &s["edit"])?; + if !s["pin"].is_null() { + let a = match wai::key_log::anchor_log(origin, &body, wai::key_log::AnchorPath::Pinned, None) { + wai::key_log::Anchoring::Anchored(a) => a, + _ => return Err("a pinned log that does not anchor".into()), + }; + self.held = wai::key_log::read_log(origin, &body).map_err(|e| Stop::Fault(e.label().into()))?; + let r = json!({ "anchored": hex(&a.revision), "path": a.path.label() }); + self.anchor = Some(a); + return Ok(Ok(r)); + } + if !s["cached"].is_null() { + let Some(a) = &self.anchor else { return Err("a cached log before any anchor".into()) }; + return Ok(hold_cached_revisions(origin, body.as_bytes(), a, &mut self.held).map(|n| json!({ "added": n }))); + } + let loc = key_log_url(origin); + let r = &s["retrieval"]; + let requested = r["requested"].as_str().unwrap_or(&loc); + let answered = r["answered"].as_str().unwrap_or(requested); + let status = r["status"].as_u64().unwrap_or(200) as u16; + let ret = Retrieval { requested, answered, status }; + let current = self.anchor.as_ref().map(|a| (a, self.held.as_slice())); + Ok(match anchor_retrieved(origin, &ret, body.as_bytes(), ACQUIRED, current) { + Ok(a) => { + if let Ok(revs) = wai::key_log::read_log(origin, &body) { + for r in revs { + if !self.held.iter().any(|h| h.revision_hash() == r.revision_hash()) { + self.held.push(r); + } + } + } + let v = json!({ "anchored": hex(&a.revision), "path": a.path.label() }); + self.anchor = Some(a); + Ok(v) + } + Err(e) => Err(e), + }) + } + } + + /// `key_log`: the steps in order, each giving what it did or its reason; a step + /// refused leaves the state as it was. + fn key_log(c: &Corpus, input: &Value) -> Result { + let mut st = KeyState::default(); + let mut out = Vec::new(); + for s in input["steps"].as_array().ok_or("steps")? { + out.push(match st.step(c, s)? { + Ok(v) => v, + Err(e) => keys_refusal(&e), + }); + } + Ok(json!({ "steps": out })) + } + + /// `grants_file`: read `file` (after `edit`) as the grants file of `module` (by + /// default the digest in its name); each grant's reading by §7.9's check 1. + fn grants(c: &Corpus, input: &Value) -> Result { + let path = input["file"].as_str().ok_or("file")?; + let t = edit(text(c, path)?, &input["edit"])?; + let stem = path.rsplit('/').next().and_then(|n| n.strip_suffix(".json")).unwrap_or(""); + let module = match &input["module"] { + Value::Null => hash_of(&json!(stem), "the file's name")?, + v => hash_of(v, "module")?, + }; + Ok(match read_grants_file(&module, t.as_bytes()) { + Ok(gs) => json!({ "grants": gs.iter().map(|g| match ModuleGrant::from_json(g) { + Ok(_) => "read".to_string(), + Err(r) => r.label().to_string(), + }).collect::>() }), + Err(e) => keys_refusal(&e), + }) + } + + /// `param_set`: a set served as `served`, held for `capability` under a pin of + /// `sha256`, at a sink with the key material of `keys`, the statuses and the grants + /// file it fetched, holding the capability's sets to §7.9 for `hold_to` (absent: not + /// held to it), at `now` (null: no trusted clock). + fn param_set(c: &Corpus, input: &Value) -> Result { + let cap = input["capability"].as_str().ok_or("capability")?; + let sha = input["sha256"].as_str().ok_or("sha256")?; + let prior = Prior::new("set", sha, "integer-exact"); + let served = c.file(input["served"].as_str().ok_or("served")?)?; + let (revs, anchors) = key_material(c, &input["keys"])?; + let statuses: Vec = strs(&input["statuses"]).iter().map(|p| text(c, p)).collect::>()?; + let grants = match input["grants"].as_str() { + Some(p) => { + let d = hash_of(&json!(sha), "sha256")?; + read_grants_file(&d, &c.file(p)?).map_err(|e| Stop::Fault(format!("the grants file: {e}")))? + } + None => Vec::new(), + }; + let mut sink = SinkKeyLog::new(revs, anchors).with_statuses(statuses).with_grants(grants); + if let Some(origins) = input["hold_to"].as_array() { + let o: Vec<&str> = origins.iter().filter_map(Value::as_str).collect(); + sink = sink.hold_pinned_sets(cap, &o); + } + if let Some(now) = input["now"].as_str() { + sink = sink.at(now); + } + Ok(match check_fetched_set(cap, &prior, &HeldBytes::File(&served), Some(&sink)) { + Ok(_) => json!({ "held": true }), + Err(e) => match &e { + PinRefusal::Revoked { revoked_by, .. } => json!({ "error": e.code(), "revoked_by": revoked_by }), + PinRefusal::NotAuthorised { check, reason, .. } => json!({ "error": e.code(), "check": check, "reason": reason }), + _ => json!({ "error": e.code() }), + }, + }) + } + + pub(crate) fn outcome(c: &Corpus, op: &str, input: &Value) -> Option> { + if !OPS.contains(&op) { + return None; + } + Some(match op { + "receipt_file" => receipt_file(c, input), + "receipts" => receipts(c, input), + "key_log" => key_log(c, input), + "grants_file" => grants(c, input), + _ => param_set(c, input), + }) + } + + // ------------------------------------------------------------------ the cases + + fn link_file(h: &[u8; 32]) -> String { + format!("{RCPT}/{}.json", hex(h)) + } + + fn chk(id: &str, held: Value) -> Value { + json!({ "object": id, "held": held }) + } + + pub(crate) fn cases(c: &Corpus) -> Result, String> { + let m = material(c)?; + let mut v = Vec::new(); + let g = |i: usize| m.groups[i].receipt.link_hash(); + let cl = |l: &str| m.claims[l].link_hash(); + let whole = json!("whole"); + let none = json!("none"); + let digest = hex(&wai::pin::sha256(&m.set)); + + // §8.1: receipt files under their names. + v.push(case("receipt_file_group", "receipt_file", json!({ "file": link_file(&g(0)), "kind": "group" }))); + v.push(case("receipt_file_claim", "receipt_file", json!({ "file": link_file(&cl("c1")), "kind": "claim" }))); + v.push(refused("receipt_file_name_mismatch", "receipt_file", json!({ "file": link_file(&g(0)), "name": hex(&g(1)), "kind": "group" }), "receipt_name_mismatch")); + v.push(refused("receipt_file_group_as_claim", "receipt_file", json!({ "file": link_file(&g(0)), "kind": "claim" }), "wrong_kind")); + v.push(refused("receipt_file_claim_as_group", "receipt_file", json!({ "file": link_file(&cl("s0")), "kind": "group" }), "wrong_kind")); + v.push(refused("receipt_file_member_twice", "receipt_file", + json!({ "file": link_file(&g(0)), "kind": "group", "edit": [["{", "{\"group_id\":0,"]] }), "malformed")); + v.push(refused("receipt_file_claim_member_twice", "receipt_file", + json!({ "file": link_file(&cl("c1")), "kind": "claim", "edit": [["{", "{\"seq\":1,"]] }), "malformed")); + + // §8.2: a chain walked from nothing held; one level up; again at the head; in order. + v.push(case("receipts_walk_two", "receipts", json!({ "checks": [chk("O9c", whole.clone())] }))); + v.push(case("receipts_walk_one", "receipts", json!({ "checks": [chk("O9b", whole.clone())] }))); + v.push(case("receipts_in_order", "receipts", json!({ "checks": [chk("O9a", whole.clone()), chk("O9b", whole.clone()), chk("O9c", whole.clone()), chk("O9c", whole.clone())] }))); + v.push(case("receipts_back_after_head", "receipts", json!({ "checks": [chk("O9c", whole.clone()), chk("O9a", none.clone())] }))); + v.push(case("receipts_join_anchors", "receipts", json!({ "join": true, "checks": [chk("O9c", whole.clone())], + "serve": { "omit": [hex(&g(0)), hex(&g(1))] } }))); + // §8.1: a parent not served, a walk past its bound, a parent under another name. + v.push(refused("receipts_parent_not_served", "receipts", json!({ "checks": [chk("O9c", whole.clone())], "serve": { "omit": [hex(&g(0))] } }), "parent_not_held")); + v.push(refused("receipts_walk_bound", "receipts", json!({ "max_walk": 1, "checks": [chk("O9c", whole.clone())] }), "parent_not_held")); + v.push(refused("receipts_parent_renamed", "receipts", json!({ "checks": [chk("O9c", whole.clone())], "serve": { "as": { hex(&g(0)): link_file(&g(1)) } } }), "parent_not_held")); + // The group itself not served, served under another name, or with its signature + // changed. + v.push(refused("receipts_group_not_served", "receipts", json!({ "checks": [chk("O9a", whole.clone())], "serve": { "omit": [hex(&g(0))] } }), "receipt_not_served")); + v.push(refused("receipts_group_renamed", "receipts", json!({ "checks": [chk("O9a", whole.clone())], "serve": { "as": { hex(&g(0)): link_file(&g(2)) } } }), "receipt_name_mismatch")); + v.push(refused("receipts_group_bad_signature", "receipts", json!({ "serve": { "rcpt": [RCPT, CASE_RCPT] }, + "checks": [{ "object": "O9a", "held": "whole", "index": "cases/receipts/O9a-bad-signature.wai.receipts" }] }), "signature_verify_failed")); + // §8.3: the cid a head proof established; a fetch refused holds nothing; another + // object's bytes in hand. + let fetch_o3 = json!({ "fetch": { "advertises": ["wai.video.av1"], "applies": ["wai.meta.hdr10plus"] } }); + v.push(case("receipts_head_proof", "receipts", json!({ "checks": [chk("O3", fetch_o3.clone())] }))); + // A byte of the head changed in transit: the head proof refuses it. + let o3 = object(c, "O3")?; + let at = 200; + let tampered = json!({ "fetch": { "advertises": ["wai.video.av1"], "applies": ["wai.meta.hdr10plus"], "tamper": { "patch_object": [[at, format!("{:02x}", o3[at] ^ 1)]] } } }); + v.push(case("receipts_fetch_refused_holds_nothing", "receipts", json!({ "checks": [chk("O3", tampered)] }))); + v.push(refused("receipts_other_object_in_hand", "receipts", json!({ "checks": [{ "object": "O9a", "held": "whole", "held_object": "O9b" }] }), "content_hash_mismatch")); + // §10.5: a layer not received is inclusion-verified; the base and its stage claim. + v.push(case("receipts_stage_set", "receipts", json!({ "checks": [chk("O10", whole.clone()), chk("O10L1", whole.clone()), chk("O10L2", none.clone())] }))); + v.push(case("receipts_layer_fetched", "receipts", json!({ "checks": [chk("O10L1", json!({ "fetch": { "listed_as_layer": true } }))] }))); + // Claims: the ancestor and a receipt claim with its parent; the object's own soft + // binding; a claim's parent not served. + v.push(case("receipts_claims_O3", "receipts", json!({ "checks": [chk("O3", whole.clone())] }))); + v.push(case("receipts_claims_O7", "receipts", json!({ "checks": [chk("O7", whole.clone())] }))); + v.push(refused("receipts_claim_parent_not_served", "receipts", json!({ "checks": [chk("O10", whole.clone())], "serve": { "omit": [hex(&cl("c0"))] } }), "parent_not_held")); + // The index and its items: another object's index; an item about another object; + // a property that disagrees with the signed leaf; members not of the form. + v.push(refused("receipts_index_other_object", "receipts", json!({ "checks": [{ "object": "O9b", "held": "whole", "index": index_path(c, "O9a")? }] }), "receipt_not_about_object")); + let p3 = item(&m, 3, 0).property; + let p_tail = format!("\"group_receipt\":\"{}\",\"joules_micro\":1500}}", hex(&p3.group_receipt)); + v.push(refused("receipts_property_disagrees", "receipts", json!({ "checks": [{ "object": "O3", "held": "whole", + "edit_index": [[p_tail.clone(), p_tail.replace(":1500}", ":1501}")]] }] }), "property_disagrees")); + let p9 = item(&m, 0, 0).property; + let ch = format!("\"content_hash\":\"{}\",\"group_receipt\"", hex(&p9.content_hash)); + v.push(refused("receipts_item_not_about_object", "receipts", json!({ "checks": [{ "object": "O9a", "held": "whole", + "edit_index": [[ch.clone(), ch.replacen(&hex(&p9.content_hash), &"00".repeat(32), 1)]] }] }), "receipt_not_about_object")); + v.push(refused("receipts_index_member_added", "receipts", json!({ "checks": [{ "object": "O9a", "held": "whole", "edit_index": [["{", "{\"note\":1,"]] }] }), "receipt_index_malformed")); + v.push(refused("receipts_item_member_added", "receipts", json!({ "checks": [{ "object": "O3", "held": "whole", + "edit_index": [["{\"object_receipt\"", "{\"note\":1,\"object_receipt\""]] }] }), "receipt_index_malformed")); + v.push(refused("receipts_claim_entry_not_a_hash", "receipts", json!({ "checks": [{ "object": "O7", "held": "whole", + "edit_index": [[hex(&cl("s0")), "s0"]] }] }), "receipt_index_malformed")); + // §7.4 beside each verdict: the signers confirmed against the origin's key log, + // anchored from its location. + let anchored = json!([{ "anchor": KEY_LOG }]); + v.push(case("receipts_confirmed", "receipts", json!({ "keys": anchored.clone(), + "checks": [chk("O3", whole.clone()), chk("O10", whole.clone())] }))); + + // §8.4: anchoring from the origin only. + let loc = key_log_url(ORIGIN); + v.push(case("key_log_anchor", "key_log", json!({ "steps": [{ "anchor": KEY_LOG }] }))); + v.push(refused_step("key_log_anchor_redirect", json!({ "steps": [{ "anchor": KEY_LOG, "retrieval": { "requested": loc, "answered": "https://mirror.example/.well-known/wai-keys" } }] }), "key_log_not_from_origin")); + v.push(refused_step("key_log_anchor_elsewhere", json!({ "steps": [{ "anchor": KEY_LOG, "retrieval": { "requested": "https://mirror.example/.well-known/wai-keys" } }] }), "key_log_not_from_origin")); + v.push(refused_step("key_log_anchor_not_200", json!({ "steps": [{ "anchor": KEY_LOG, "retrieval": { "status": 304 } }] }), "key_log_not_from_origin")); + v.push(refused_step("key_log_anchor_plain_http", json!({ "steps": [{ "anchor": KEY_LOG, "origin": "http://origin.example" }] }), "key_log_not_from_origin")); + v.push(refused_step("key_log_anchor_other_origin", json!({ "steps": [{ "anchor": KEY_LOG, "origin": "https://other.example" }] }), "origin_mismatch")); + v.push(refused_step("key_log_anchor_malformed", json!({ "steps": [{ "anchor": KEY_LOG, "edit": [["{\"kind\":\"wai-key-log\"", "{\"kind\":\"wai-key-log\",\"note\":1"]] }] }), "key_log_malformed")); + v.push(case("key_log_anchor_forward", "key_log", json!({ "steps": [{ "anchor": OLDER_LOG }, { "anchor": KEY_LOG }] }))); + v.push(case("key_log_anchor_rolled_back", "key_log", json!({ "steps": [{ "anchor": KEY_LOG }, { "anchor": OLDER_LOG }] }))); + // Through a cache: revisions added, the anchor kept; an older copy reported. + v.push(case("key_log_cached_newer", "key_log", json!({ "steps": [{ "anchor": OLDER_LOG }, { "cached": KEY_LOG }] }))); + v.push(case("key_log_cached_rolled_back", "key_log", json!({ "steps": [{ "anchor": KEY_LOG }, { "cached": OLDER_LOG }] }))); + v.push(case("key_log_cached_other_origin", "key_log", json!({ "steps": [{ "anchor": KEY_LOG }, { "cached": KEY_LOG, "origin": "https://other.example" }] }))); + + // §8.4: the grants file, a locator. + let gfile = format!(".well-known/wai-grants/{digest}.json"); + v.push(case("grants_file", "grants_file", json!({ "file": gfile }))); + v.push(refused("grants_file_other_module", "grants_file", json!({ "file": gfile, "module": "00".repeat(32) }), "grants_file_malformed")); + v.push(refused("grants_file_member_added", "grants_file", json!({ "file": gfile, "edit": [["{\"grants\"", "{\"note\":1,\"grants\""]] }), "grants_file_malformed")); + v.push(refused("grants_file_wrong_kind", "grants_file", json!({ "file": gfile, "edit": [["wai-module-grants", "wai-module-grant"]] }), "grants_file_malformed")); + + // §8.5: the set served by its digest, held only by its pin and §7.9. + let set = json!({ "capability": SET_CAP, "sha256": digest, "served": format!("sets/{digest}"), "keys": anchored, + "statuses": [STATUS], "grants": gfile, "hold_to": [ORIGIN], "now": NOW_HELD }); + let with = |k: &str, val: Value| { + let mut s = set.clone(); + s[k] = val; + s + }; + v.push(case("param_set_held", "param_set", set.clone())); + v.push(refused("param_set_status_stale", "param_set", with("now", json!(NOW_STALE)), "prior_not_authorised")); + v.push(refused("param_set_no_clock", "param_set", with("now", Value::Null), "prior_not_authorised")); + v.push(refused("param_set_no_grant", "param_set", with("grants", Value::Null), "prior_not_authorised")); + v.push(refused("param_set_other_bytes", "param_set", with("served", json!(STREAM_WAI)), "prior_not_held")); + v.push(refused("param_set_revoked", "param_set", with("keys", json!([{ "anchor": REVOKED_LOG }])), "prior_revoked")); + let mut not_held_to = set.clone(); + not_held_to.as_object_mut().expect("an object").remove("hold_to"); + v.push(case("param_set_not_held_to", "param_set", not_held_to.clone())); + let mut revoked_not_held_to = not_held_to; + revoked_not_held_to["keys"] = json!([{ "anchor": REVOKED_LOG }]); + v.push(refused("param_set_revoked_not_held_to", "param_set", revoked_not_held_to, "prior_revoked")); + v.push(case("param_set_pinned_anchor", "param_set", with("keys", json!([{ "pin": KEY_LOG }])))); + Ok(v) + } + + /// A `key_log` case whose last step exists for `code`. + fn refused_step(name: &str, input: Value, code: &'static str) -> Case { + refused(name, "key_log", input, code) + } + + // ------------------------------------------------------------------ the fuzz + + /// One member of `t` changed one way, or nothing (one time in five). + fn perturb(s: &mut u64, t: &str, members: &[&str]) -> Value { + let m = pick(s, members); + if !t.contains(m) || next(s) % 5 == 0 { + return json!([]); + } + let whole = !m.ends_with(':') && !m.ends_with('['); + let to = match next(s) % 10 { + 0 => format!("{m}0"), + 1 => m.replacen(':', ":\"", 1) + "\"", + 2 => m.replacen("\":", "x\":", 1), + 3 => format!("\"note\":1,{m}"), + 4 => m.replacen(':', ":-", 1), + 5 => m.replacen(':', ":1.0e0,\"z\":", 1), + 6 => m.to_uppercase(), + 7 if whole => format!("{m},{m}"), + 8 if m.ends_with('"') => format!("{}\\u{:x}\"", &m[..m.len() - 1], [0xd800u32, 0xdfff][(next(s) % 2) as usize]), + _ => m.replacen(':', ":null,\"y\":", 1), + }; + json!([[m, to]]) + } + + /// One iteration of the receipt-side fuzz families: a receipt file, an index, a grants + /// file and a key log, each changed one way or not at all, read as a client reads them. + pub(crate) fn fuzz_cases(c: &Corpus, s: &mut u64, i: usize) -> Result, String> { + let mut v = Vec::new(); + let rdir = c.dir.join(RCPT); + let mut files: Vec = std::fs::read_dir(&rdir).map_err(fault)?.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + files.sort(); + if files.is_empty() { + return Err(format!("{} holds no receipt file", rdir.display())); + } + let f = &files[(next(s) % files.len() as u64) as usize]; + let path = format!("{RCPT}/{f}"); + let t = String::from_utf8(c.file(&path)?).map_err(|_| "not UTF-8")?; + let kind = if t.contains("\"kind\":\"group\"") { "group" } else { "claim" }; + let members = ["\"group_id\":", "\"leaf_count\":", "\"track_alias\":", "\"kind\":\"group\"", "\"kind\":\"claim\"", "\"sign_version\":2", "\"seq\":", + "\"subject_type\":\"object\"", "\"parent_receipt_hash\":", "\"class\":\"stage\"", "\"tier\":\"none\"", "\"units_by_stage\":[", "\"joules_total\":", + "\"signer_id\":\"did:web:origin.example#d1\"", "\"value\":\"0ff0\"", "\"body\":{"]; + let as_kind = if next(s) % 6 == 0 { if kind == "group" { "claim" } else { "group" } } else { kind }; + v.push((format!("fuzz_receipt_file_{i}"), "receipt_file", json!({ "file": path, "kind": as_kind, "edit": perturb(s, &t, &members) }))); + // An index, its object held whole or not at all. + let ids = ["O9a", "O9b", "O9c", "O3", "O7", "O10", "O10L1", "O10L2"]; + let id = pick(s, &ids); + let ofile = object_file_of(c, id)?; + let ix = String::from_utf8(c.file(&format!("objects/{}.wai.receipts", hex(&content_hash(&c.file(&ofile)?))))?).map_err(|_| "not UTF-8")?; + let members = ["\"kind\":\"wai-receipt-index\"", "\"delivery\":[", "\"claims\":[", "\"property\":{", "\"object_receipt\":{", "\"joules_micro\":", + "\"acquisition\":", "\"acquisition_evidence\":", "\"leaf_index\":", "\"direction_bits\":", "\"object_id\":", "\"leaf_version\":2", + "\"siblings\":[", "\"origin\":\"origin\"", "\"energy_provenance\":\"Estimator\"", "\"group_id\":"]; + let held = if next(s) % 2 == 0 { "whole" } else { "none" }; + v.push((format!("fuzz_receipt_index_{i}"), "receipts", json!({ "checks": [{ "object_file": ofile, "held": held, + "edit_index": perturb(s, &ix, &members) }] }))); + // The grants file. + let gdir = c.dir.join(".well-known/wai-grants"); + let gname = std::fs::read_dir(&gdir).map_err(fault)?.filter_map(|e| e.ok()).map(|e| e.file_name().to_string_lossy().into_owned()).min().ok_or("no grants file")?; + let gpath = format!(".well-known/wai-grants/{gname}"); + let gt = String::from_utf8(c.file(&gpath)?).map_err(|_| "not UTF-8")?; + let members = ["\"kind\":\"wai-module-grants\"", "\"module\":", "\"grants\":[", "\"kind\":\"wai-module-grant\"", "\"form\":\"decoder\"", "\"capabilities\":[", + "\"not_after\":", "\"not_before\":", "\"origin\":\"https://origin.example\"", "\"signer_id\":"]; + v.push((format!("fuzz_grants_file_{i}"), "grants_file", json!({ "file": gpath, "edit": perturb(s, >, &members) }))); + // A key log anchored from a retrieval whose URLs or status may be off. + let lt = String::from_utf8(c.file(KEY_LOG)?).map_err(|_| "not UTF-8")?; + let members = ["\"kind\":\"wai-key-log\"", "\"revisions\":[", "\"seq\":", "\"prev\":", "\"issued_at\":", "\"status\":\"active\"", "\"may_sign\":[", + "\"grant_max_s\":", "\"status_max_age_s\":", "\"origin\":\"https://origin.example\"", "\"revoked_modules\":["]; + let loc = key_log_url(ORIGIN); + let urls = [loc.as_str(), "https://origin.example/.well-known/wai-keys/", "https://ORIGIN.example/.well-known/wai-keys", "https://origin.example:443/.well-known/wai-keys", + "http://origin.example/.well-known/wai-keys", "https://origin.example/.well-known/WAI-KEYS", "https://origin.example//.well-known/wai-keys"]; + let requested = if next(s) % 3 == 0 { pick(s, &urls) } else { loc.as_str() }; + let answered = if next(s) % 3 == 0 { pick(s, &urls) } else { requested }; + let status = [200u64, 200, 200, 301, 302, 304, 203][(next(s) % 7) as usize]; + let origin = if next(s) % 5 == 0 { pick(s, &["https://origin.example/", "HTTPS://origin.example", "https://origin.example:443", "http://origin.example"]) } else { ORIGIN }; + v.push((format!("fuzz_key_log_{i}"), "key_log", json!({ "steps": [{ "anchor": KEY_LOG, "origin": origin, "edit": perturb(s, <, &members), + "retrieval": { "requested": requested, "answered": answered, "status": status } }] }))); + Ok(v) + } + +} diff --git a/wai/wai-rs/src/http_binding/keys.rs b/wai/wai-rs/src/http_binding/keys.rs new file mode 100644 index 000000000..ad1572bc8 --- /dev/null +++ b/wai/wai-rs/src/http_binding/keys.rs @@ -0,0 +1,285 @@ +//! Keys, statuses, grants and parameter sets over HTTP (http-delivery §8.4, §8.5). +//! +//! What is served here is jwp-receipts §7's, checked by `key_log` and nowhere else: +//! +//! - an origin's key log at [`KEY_LOG_PATH`] (§7.2). A verifier anchors an origin +//! (§7.3) only from a log retrieved from that location over HTTPS, answered `200` +//! with no redirect ([`anchor_retrieved`], `key_log_not_from_origin` otherwise); a log +//! retrieved through any cache adds revisions, each checked against its parent, and +//! never moves the anchor ([`hold_cached_revisions`]); +//! - a key status at [`STATUS_PATH`] and an origin's module grants at +//! `/.well-known/wai-grants/.json` (§7.9). The grants file is a locator: +//! each grant in it is handed to §7.9's checks on its own ([`read_grants_file`]); +//! - a parameter set at `/`, named by the digest its pin states. The name +//! locates it and proves nothing: the bytes are held only as SPEC §3.1's checks hold +//! them, check 5 being §7.9's ([`check_fetched_set`]), so `pin::verify_bytes_with` and +//! `int_payload::resolve` decide, never the URL. +//! +//! A module grant is §7.9's form. WAI carries a JCP grant only as a reference by its +//! content hash inside the receipt that names it (`quantum_ops::GrantRef`, as a +//! playlist receipt carries its publisher's grant, §8.6); this binding serves no JCP +//! grant, and a client checks such a reference only as the receipt carrying it binds it. +//! +//! Nothing here performs I/O or reads a clock. + +use std::collections::BTreeMap; + +use serde_json::{Map, Value}; + +use super::hex; +use crate::container::{PinRefusal, Prior}; +use crate::jwp_delivery::ObjectReceipt; +use crate::key_log::{ + anchor_log, read_log, Anchor, AnchorPath, Anchoring, Confirmation, Item, ModuleGrant, Reason, Revision, SinkKeyLog, +}; +use crate::pin::{HeldBytes, PinAuthority, VerifiedRef}; + +pub use crate::signer_id::{key_log_url, KEY_LOG_PATH}; + +/// Where an origin serves its key status (§8.4). +pub const STATUS_PATH: &str = "/.well-known/wai-key-status"; +/// Where an origin serves its module grants, one file per module digest (§8.4). +pub const GRANTS_PATH: &str = "/.well-known/wai-grants/"; +/// `kind` of a module grants file (§8.4). +pub const GRANTS_KIND: &str = "wai-module-grants"; + +/// The URL of `origin`'s key status. +pub fn status_url(origin: &str) -> String { + format!("{origin}{STATUS_PATH}") +} + +/// The URL of `origin`'s grants file for the module or parameter set `digest`. +pub fn grants_url(origin: &str, digest: &[u8; 32]) -> String { + format!("{origin}{GRANTS_PATH}{}.json", hex(digest)) +} + +/// The URL of the parameter set whose digest is `sha256` (64 lowercase hexadecimal +/// digits, as a pin states it) under `base` (§8.5). +pub fn set_url(base: &str, sha256: &str) -> String { + format!("{}/{sha256}", base.trim_end_matches('/')) +} + +/// The headers a verifier sends when it retrieves a key log to anchor its origin (§8.4): +/// it asks every cache on the way to revalidate with the origin. +pub fn anchoring_request_headers() -> Vec<(&'static str, &'static str)> { + vec![("Cache-Control", "no-cache")] +} + +/// The largest `max-age`, in seconds, a key status may be served with (§8.4): a quarter +/// of the head's `status_max_age_s`, so a status stays fresh at the sink for most of its +/// life after any cache served it. +pub fn status_max_age_s(head: &Revision) -> u64 { + head.status_max_age_s / 4 +} + +/// Why a key log, a grants file or a status was not taken (http-delivery §13, and +/// jwp-receipts §7.7 unchanged). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum KeysRefusal { + /// §8.4: a log offered for anchoring that was not retrieved from the origin's own + /// key-log location over HTTPS, answered `200` with no redirect + /// (`key_log_not_from_origin`). + NotFromOrigin(&'static str), + /// §8.4: a grants file not of its form, or for another module than the one it is + /// fetched for (`grants_file_malformed`). + GrantsFileMalformed(&'static str), + /// jwp-receipts §7.3, §7.7: the log's own reason. + Log(Reason), +} + +impl KeysRefusal { + /// The reason code. + pub fn code(&self) -> &'static str { + match self { + KeysRefusal::NotFromOrigin(_) => "key_log_not_from_origin", + KeysRefusal::GrantsFileMalformed(_) => "grants_file_malformed", + KeysRefusal::Log(r) => r.label(), + } + } +} + +impl std::fmt::Display for KeysRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + KeysRefusal::NotFromOrigin(why) | KeysRefusal::GrantsFileMalformed(why) => write!(f, "{}: {why}", self.code()), + KeysRefusal::Log(r) => f.write_str(r.label()), + } + } +} + +impl std::error::Error for KeysRefusal {} + +/// How the answer to a key-log request reached the verifier: the URL it requested, the +/// URL that answered (another one after a redirect a client library followed), and the +/// status. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Retrieval<'a> { + pub requested: &'a str, + pub answered: &'a str, + pub status: u16, +} + +/// §8.4 and jwp-receipts §7.3: anchor `origin` from `body`, a key log retrieved as +/// `retrieval` states at `acquired_at`, the verifier holding `current` (its anchor and +/// revisions) where it has one. Refused (`key_log_not_from_origin`) unless `origin` is an +/// `https` origin, the request was for its key-log location ([`key_log_url`]), and the +/// answer is a `200` from that same URL. Then §7.3's checks and moves, as `key_log` +/// makes them: the log's reason, `key_log_rolled_back` for an older copy, and +/// `key_log_replaced` for a log that neither contains the anchor nor supersedes it. +pub fn anchor_retrieved( + origin: &str, + retrieval: &Retrieval<'_>, + body: &[u8], + acquired_at: &str, + current: Option<(&Anchor, &[Revision])>, +) -> Result { + let location = key_log_url(origin); + if !origin.starts_with("https://") { + return Err(KeysRefusal::NotFromOrigin("the origin is not an https origin")); + } + if retrieval.requested != location { + return Err(KeysRefusal::NotFromOrigin("the request was not for the origin's key-log location")); + } + if retrieval.answered != location { + return Err(KeysRefusal::NotFromOrigin("the answer came from another URL: a redirect was followed")); + } + if retrieval.status != 200 { + return Err(KeysRefusal::NotFromOrigin("the answer is not a 200")); + } + let text = std::str::from_utf8(body).map_err(|_| KeysRefusal::Log(Reason::KeyLogMalformed))?; + match anchor_log(origin, text, AnchorPath::Fetched { acquired_at: acquired_at.into() }, current) { + Anchoring::Anchored(a) => Ok(a), + Anchoring::NotAnchored(r) => Err(KeysRefusal::Log(r)), + Anchoring::Replaced => Err(KeysRefusal::Log(Reason::KeyLogReplaced)), + } +} + +/// §8.4: take the revisions of a key log retrieved other than to anchor — through any +/// cache, a mirror, or the origin without the anchoring rules. The log is read as §7.3 +/// reads one (its form, rules 1–3 for `origin`, its first `seq` 0, rules 4–6 between +/// consecutive revisions; that rule's reason). One whose latest revision is a trusted +/// revision older than `anchor` — the anchor's held ancestors — is an older copy +/// (`key_log_rolled_back`) and adds nothing. Otherwise every revision of it the +/// verifier does not hold is added to `held`, and §7.4's head walk decides what each is +/// worth; the anchor never moves. Returns how many were added. `anchor` must be for +/// `origin` (`origin_not_anchored`). +pub fn hold_cached_revisions(origin: &str, body: &[u8], anchor: &Anchor, held: &mut Vec) -> Result { + if anchor.origin != origin { + return Err(KeysRefusal::Log(Reason::OriginNotAnchored)); + } + let text = std::str::from_utf8(body).map_err(|_| KeysRefusal::Log(Reason::KeyLogMalformed))?; + let revs = read_log(origin, text).map_err(KeysRefusal::Log)?; + let by: BTreeMap<[u8; 32], &Revision> = + held.iter().filter(|r| r.check_held(origin).is_ok()).map(|r| (r.revision_hash(), r)).collect(); + let mut trusted = vec![anchor.revision]; + while let Some(p) = by.get(trusted.last().expect("non-empty")).and_then(|r| r.prev) { + if !by.contains_key(&p) || trusted.contains(&p) { + break; + } + trusted.push(p); + } + let latest = revs.last().expect("a log is non-empty").revision_hash(); + if latest != anchor.revision && trusted.contains(&latest) { + return Err(KeysRefusal::Log(Reason::KeyLogRolledBack)); + } + let mut have: Vec<[u8; 32]> = held.iter().map(Revision::revision_hash).collect(); + let mut added = 0; + for r in revs { + let h = r.revision_hash(); + if !have.contains(&h) { + have.push(h); + held.push(r); + added += 1; + } + } + Ok(added) +} + +/// §8.4: read the grants file fetched for `module`: JSON as jwp-receipts §3.1 step 1 +/// reads it, with exactly the members `kind` (`wai-module-grants`), `module` (64 lowercase +/// hexadecimal digits, the digest it is fetched for) and `grants` (an array), or +/// `grants_file_malformed`. Returns each grant's text, to be checked on its own by §7.9 +/// (`key_log::module_check`, `key_log::SinkKeyLog::with_grants`): its canonical form, or, +/// for one outside the canonical subset, its JSON as read, which check 1 refuses. +pub fn read_grants_file(module: &[u8; 32], bytes: &[u8]) -> Result, KeysRefusal> { + let bad = KeysRefusal::GrantsFileMalformed; + let text = std::str::from_utf8(bytes).map_err(|_| bad("not UTF-8"))?; + let v = crate::claim_receipt::parse_strict(text).map_err(|_| bad("not JSON as a receipt is read"))?; + let m = v.as_object().ok_or(bad("not an object"))?; + const MEMBERS: [&str; 3] = ["kind", "module", "grants"]; + if m.len() != MEMBERS.len() || MEMBERS.iter().any(|k| !m.contains_key(*k)) { + return Err(bad("the members are not kind, module and grants")); + } + if m["kind"].as_str() != Some(GRANTS_KIND) { + return Err(bad("kind is not wai-module-grants")); + } + if m["module"].as_str().and_then(super::unhex32) != Some(*module) { + return Err(bad("module is not the digest the file is fetched for")); + } + let grants = m["grants"].as_array().ok_or(bad("grants is not an array"))?; + Ok(grants.iter().map(|g| crate::jcs::jcs(g).unwrap_or_else(|_| g.to_string())).collect()) +} + +/// A grants file (§8.4) for `module`, listing `grants`, in JCS. +pub fn grants_file(module: &[u8; 32], grants: &[ModuleGrant]) -> Vec { + let mut m = Map::new(); + m.insert("kind".into(), GRANTS_KIND.into()); + m.insert("module".into(), hex(module).into()); + let gs: Vec = grants + .iter() + .map(|g| crate::claim_receipt::parse_strict(&g.to_json()).expect("a grant's canonical form reads")) + .collect(); + m.insert("grants".into(), Value::Array(gs)); + crate::jcs::jcs(&Value::Object(m)).expect("a grants file is inside the canonical subset").into_bytes() +} + +/// §8.5: hold a parameter set fetched by the digest `prior` states, as SPEC §3.1's +/// checks hold it over the bytes `served` (`pin::verify_bytes_with`, reading the forms +/// SPEC registers), check 5 run by `sink` where the sink holds a key log: jwp-receipts +/// §7.9 with the statuses and grants it fetched as §8.4 states. The URL the bytes came +/// from counts for nothing: bytes whose digest is not the pin's are not held +/// (`prior_not_held`), and a set §7.9 refuses is not held (`prior_revoked`, +/// `prior_not_authorised`). A set that is not held leaves its capability unsupported for +/// the envelope, and dispatch continues (SPEC §4, §7.1). +pub fn check_fetched_set( + capability: &str, + prior: &Prior, + served: &HeldBytes<'_>, + sink: Option<&SinkKeyLog>, +) -> Result { + crate::pin::verify_bytes_with( + capability, + prior, + std::slice::from_ref(served), + crate::pin::READABLE_FORMS, + sink.map(|s| s as &dyn PinAuthority), + ) +} + +/// The files a set listing names, each with the digest its URL is formed from (§8.5): +/// a set in the form `wai.prior.set` is fetched as its listing, by the pin's digest, then +/// each file by its own digest. The listing's own reading (`set_malformed`). +pub fn set_files(listing: &[u8]) -> Result, PinRefusal> { + crate::pin::parse_listing(listing) +} + +/// jwp-receipts §7.4 for the group of a delivery item accepted from an index (§8.2), +/// with its object receipt: its signer confirmed against the revisions and anchors the +/// verifier holds — anchored over HTTP as §8.4 states, or pinned. Reported beside the +/// item's verdict, never folded into it. `None` where the group or the object receipt +/// does not verify, which an accepted item's always do. +pub fn confirm_delivery( + group: &crate::jwp_delivery::GroupReceipt, + object: &ObjectReceipt, + revisions: &[Revision], + anchors: &[Anchor], +) -> Option { + let item = Item::from_group(group, &[object]).ok()?; + Some(crate::key_log::confirm(&item, revisions, anchors, None)) +} + +/// jwp-receipts §7.4 for a claim accepted from an index (§8.2), reported beside its +/// verdict. +pub fn confirm_claim(claim: &crate::claim_receipt::ClaimReceipt, revisions: &[Revision], anchors: &[Anchor]) -> Confirmation { + crate::key_log::confirm(&Item::from_claim(claim), revisions, anchors, None) +} diff --git a/wai/wai-rs/src/http_binding/mod.rs b/wai/wai-rs/src/http_binding/mod.rs index 975ad5cec..4811d0f4e 100644 --- a/wai/wai-rs/src/http_binding/mod.rs +++ b/wai/wai-rs/src/http_binding/mod.rs @@ -17,7 +17,13 @@ //! - [`dash`]: `wai.mpd` on the classical timeline (§11.1); //! - `playlist_receipt` (with `video_manifest`): playlist receipts, sealed and checked, //! and a live revision chain (§8.6, §11.6); -//! - [`origin`]: an origin's responses (§5.3) and byte-range answers. +//! - [`origin`]: an origin's responses (§5.3) and byte-range answers; +//! - `receipts` (with `receipt_claims`): receipt files named by link hash, an object's +//! receipt index, and the checks a client runs on what they locate, an object fetched +//! in part attested through the cid its head proof established (§8.1–§8.3, §10.5); +//! - `keys` (with `key_discovery`): key logs anchored over HTTP and taken through caches, +//! statuses, grants files, and parameter sets fetched by their digest and held only as +//! their pins and jwp-receipts §7.9 hold them (§8.4, §8.5). //! //! Nothing here performs I/O: the `wai http` commands and the reference origin do. @@ -28,11 +34,15 @@ pub mod dash_read; pub mod head; pub mod head_proof; pub mod hls; +#[cfg(feature = "key_discovery")] +pub mod keys; pub mod origin; pub mod plan; pub mod playlist; #[cfg(feature = "video_manifest")] pub mod playlist_receipt; +#[cfg(feature = "receipt_claims")] +pub mod receipts; pub mod session; pub mod uri; @@ -42,6 +52,8 @@ mod tests; mod tests_abr; #[cfg(test)] mod tests_playlists; +#[cfg(all(test, feature = "receipt_claims"))] +mod tests_receipts; /// The proposed media type of an object (§3). It is not registered. pub const MEDIA_TYPE: &str = "application/wai"; diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs new file mode 100644 index 000000000..95b77f2f3 --- /dev/null +++ b/wai/wai-rs/src/http_binding/receipts.rs @@ -0,0 +1,493 @@ +//! Receipts over HTTP (http-delivery §8.1–§8.3, §10.5): receipt files named by their +//! link hash, an object's receipt index, and the checks a client runs on what they locate. +//! +//! Every check here is jwp-receipts' own, bound to HTTP carriage and never re-created: +//! +//! - a receipt file `rcpt/.json` holds one group receipt or one claim, read as §3 +//! step 1 or §3.1 steps 1–2 read it, and is refused unless its link hash is `h` +//! (`receipt_name_mismatch`); +//! - an index `.wai.receipts` is a locator: each delivery item and each claim it +//! lists is checked on its own, and one that fails is ignored with its reason; +//! - a delivery item's group goes through §3 steps 1–3 against the client's +//! [`ReceiptChains`], the parents it lacks fetched by their links up to a bound, and its +//! object receipt through steps 4–5 against what the client holds of the object: the +//! content hash recomputed over the envelope, the cid a verified head proof establishes +//! for an object fetched in part (§8.3, [`EstablishedHash`]), or nothing, for an object +//! it did not receive, which is then `inclusion-verified` and never delivered (§10.5); +//! - a claim goes through §3.1 steps 1–5, its parent fetched by its link; a +//! soft-binding claim about another object is reported as attesting that object, the +//! ancestor (§3.2), never the object in hand. +//! +//! Nothing here performs I/O: `fetch` gives the bytes served at `rcpt/.json` beside +//! the object, or `None` where nothing is served. + +use serde_json::{Map, Value}; + +use super::{hex, unhex32}; +use crate::claim_receipt::{ClaimClass, ClaimReceipt, Reason as ClaimReason, Rejection, SubjectType}; +use crate::jwp_delivery::{ + Admission, DeliveryError, Evidence, GroupReceipt, ObjectReceipt, PropertyReading, Reason, ReceiptChains, WaiReceiptProperty, +}; +use crate::jwp_hash::{EstablishedHash, Establishment}; + +/// The directory of receipt files, resolved against an object's URI (§8.1). +pub const RECEIPT_DIR: &str = "rcpt/"; + +/// `kind` of a receipt index (§8.2). +pub const INDEX_KIND: &str = "wai-receipt-index"; + +/// How many parents a client fetches for one group, at most (§8.1). A group whose +/// chain is further back than this stays `parent_not_held`. +/// +/// cbindgen:ignore +pub const PARENT_WALK_BOUND: usize = 64; + +/// The path of the receipt file whose link hash is `h`, relative to the object (§8.1). +pub fn receipt_path(h: &[u8; 32]) -> String { + format!("{RECEIPT_DIR}{}.json", hex(h)) +} + +/// The last path segment of an object's receipt index, `.wai.receipts` (§8.2). +pub fn index_name(cid: &[u8; 32]) -> String { + format!("{}.wai.receipts", hex(cid)) +} + +/// Why a receipt file, an index, an item of it, or a claim it lists was refused or +/// ignored (http-delivery §13, and jwp-receipts' reasons unchanged). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ReceiptRefusal { + /// §8.2: the index, or an item of it, is not of its form (`receipt_index_malformed`). + IndexMalformed(&'static str), + /// §8.2: an index, a delivery item or a claim about another object + /// (`receipt_not_about_object`). + NotAboutObject, + /// §8.1: nothing is served at the receipt file's name (`receipt_not_served`). + NotServed, + /// §8.1: the file's link hash is not its name (`receipt_name_mismatch`). + NameMismatch, + /// jwp-receipts §3, §3.3: a group or object receipt, or a property, refused. + Delivery(DeliveryError), + /// jwp-receipts §3.1: a claim refused. + Claim(Rejection), + /// §8.1, jwp-receipts §3 step 3 and §3.1 step 5: a parent the client could not + /// recover (`parent_not_held`), after `walked` parents fetched; `stop` says why the + /// walk ended: `bound`, `receipt_not_served`, `another_chain`, or the reason the parent + /// fetched last was refused. + ParentNotHeld { walked: usize, stop: String }, +} + +impl ReceiptRefusal { + /// The reason code. + pub fn code(&self) -> &'static str { + match self { + ReceiptRefusal::IndexMalformed(_) => "receipt_index_malformed", + ReceiptRefusal::NotAboutObject => "receipt_not_about_object", + ReceiptRefusal::NotServed => "receipt_not_served", + ReceiptRefusal::NameMismatch => "receipt_name_mismatch", + ReceiptRefusal::Delivery(e) => e.reason.label(), + ReceiptRefusal::Claim(r) => r.reason.label(), + ReceiptRefusal::ParentNotHeld { .. } => "parent_not_held", + } + } + + /// The jwp-receipts step a refusal of jwp-receipts' own was raised at, where it has + /// one. + pub fn step(&self) -> Option { + match self { + ReceiptRefusal::Delivery(e) => e.step, + ReceiptRefusal::Claim(r) => Some(r.step), + _ => None, + } + } +} + +impl std::fmt::Display for ReceiptRefusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ReceiptRefusal::IndexMalformed(why) => write!(f, "receipt_index_malformed: {why}"), + ReceiptRefusal::ParentNotHeld { walked, stop } => write!(f, "parent_not_held: {walked} parents fetched, stopped at {stop}"), + other => match other.step() { + Some(s) => write!(f, "{} at step {s}", other.code()), + None => f.write_str(other.code()), + }, + } + } +} + +impl std::error::Error for ReceiptRefusal {} + +fn malformed_group() -> ReceiptRefusal { + ReceiptRefusal::Delivery(DeliveryError { step: Some(1), reason: Reason::Malformed }) +} + +/// §8.1: read the receipt file named `name` as a group receipt: JSON as §3.1 step 1 +/// reads it, then §3 step 1 (`malformed`, `wrong_kind`, `group_label_invalid`), then the +/// name (`receipt_name_mismatch`). The signature is not checked here. +pub fn read_group_file(name: &[u8; 32], bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes).map_err(|_| malformed_group())?; + let v = crate::claim_receipt::parse_strict(text).map_err(|_| malformed_group())?; + let g = GroupReceipt::from_json(&v).map_err(ReceiptRefusal::Delivery)?; + if g.link_hash() != *name { + return Err(ReceiptRefusal::NameMismatch); + } + Ok(g) +} + +/// §8.1: read the receipt file named `name` as a claim: §3.1 steps 1–2, then the name +/// (`receipt_name_mismatch`). The signature is not checked here. +pub fn read_claim_file(name: &[u8; 32], bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes) + .map_err(|_| ReceiptRefusal::Claim(Rejection { step: 1, reason: ClaimReason::Malformed }))?; + let c = ClaimReceipt::from_json(text).map_err(ReceiptRefusal::Claim)?; + if c.link_hash() != *name { + return Err(ReceiptRefusal::NameMismatch); + } + Ok(c) +} + +/// A group receipt's file (§8.1): its JSON form (jwp-receipts §2.4.1) in JCS. +pub fn group_file(g: &GroupReceipt) -> Result, String> { + crate::jcs::jcs(&g.to_json()).map(String::into_bytes).map_err(|e| e.to_string()) +} + +/// A claim's file (§8.1): its JCS form. +pub fn claim_file(c: &ClaimReceipt) -> Vec { + c.to_json().into_bytes() +} + +// ───────────────────────────── the index ───────────────────────────── + +/// One delivery item of an index: a `WAI_RECEIPT` property and the object receipt it +/// describes (§8.2). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexItem { + pub property: WaiReceiptProperty, + pub object_receipt: ObjectReceipt, +} + +/// A receipt index as read (§8.2): the object it names, and each delivery item and claim +/// link it lists, each read on its own. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReceiptIndex { + pub object: [u8; 32], + pub delivery: Vec>, + pub claims: Vec>, +} + +impl ReceiptIndex { + /// Read an index: JSON as jwp-receipts §3.1 step 1 reads it, with exactly the members + /// `kind` (`wai-receipt-index`), `object` (64 lowercase hexadecimal digits), + /// `delivery` and `claims` (arrays), or the whole index is refused + /// (`receipt_index_malformed`). Each delivery item is an object with exactly the + /// members `property` and `object_receipt` (`receipt_index_malformed`); its property + /// reads as moq-streaming-format §5's (`property_invalid`) and its object receipt as + /// jwp-receipts §2.4.2's (that reading's reason). Each claim entry is a link hash + /// (`receipt_index_malformed`). An item that does not read is kept as its refusal. + pub fn from_bytes(bytes: &[u8]) -> Result { + let bad = ReceiptRefusal::IndexMalformed; + let text = std::str::from_utf8(bytes).map_err(|_| bad("not UTF-8"))?; + let v = crate::claim_receipt::parse_strict(text).map_err(|_| bad("not JSON as a receipt is read"))?; + let m = v.as_object().ok_or(bad("not an object"))?; + const MEMBERS: [&str; 4] = ["kind", "object", "delivery", "claims"]; + if m.len() != MEMBERS.len() || MEMBERS.iter().any(|k| !m.contains_key(*k)) { + return Err(bad("the members are not kind, object, delivery and claims")); + } + if m["kind"].as_str() != Some(INDEX_KIND) { + return Err(bad("kind is not wai-receipt-index")); + } + let object = m["object"].as_str().and_then(unhex32).ok_or(bad("object is not a content hash"))?; + let delivery = m["delivery"].as_array().ok_or(bad("delivery is not an array"))?.iter().map(read_item).collect(); + let claims = m["claims"] + .as_array() + .ok_or(bad("claims is not an array"))? + .iter() + .map(|c| c.as_str().and_then(unhex32).ok_or(bad("a claim entry is not a link hash"))) + .collect(); + Ok(ReceiptIndex { object, delivery, claims }) + } +} + +fn read_item(v: &Value) -> Result { + let bad = ReceiptRefusal::IndexMalformed("a delivery item is not an object with exactly property and object_receipt"); + let m = v.as_object().ok_or_else(|| bad.clone())?; + if m.len() != 2 || !m.contains_key("property") || !m.contains_key("object_receipt") { + return Err(bad); + } + let property = WaiReceiptProperty::from_json(&m["property"]) + .ok_or(ReceiptRefusal::Delivery(DeliveryError { step: None, reason: Reason::PropertyInvalid }))?; + let object_receipt = ObjectReceipt::from_json(&m["object_receipt"]).map_err(ReceiptRefusal::Delivery)?; + Ok(IndexItem { property, object_receipt }) +} + +/// An index's bytes (§8.2), in JCS, listing `items` and `claims` for the object `cid`. +pub fn index_file(cid: &[u8; 32], items: &[IndexItem], claims: &[[u8; 32]]) -> Result, String> { + let mut m = Map::new(); + m.insert("kind".into(), INDEX_KIND.into()); + m.insert("object".into(), hex(cid).into()); + m.insert( + "delivery".into(), + Value::Array( + items + .iter() + .map(|i| { + let mut it = Map::new(); + it.insert("property".into(), i.property.to_json()); + it.insert("object_receipt".into(), i.object_receipt.to_json()); + Value::Object(it) + }) + .collect(), + ), + ); + m.insert("claims".into(), Value::Array(claims.iter().map(|c| Value::from(hex(c))).collect())); + crate::jcs::jcs(&Value::Object(m)).map(String::into_bytes).map_err(|e| e.to_string()) +} + +// ───────────────────────────── delivery items ───────────────────────────── + +/// What a client holds of the object an index is about. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectHeld { + /// The object, whole or, over HTTP, in part: the content hash jwp-receipts §3 step 4 + /// compares with ([`EstablishedHash`]). + Object(EstablishedHash), + /// Nothing: the client did not receive it (it abandoned it, or never asked). + Nothing, +} + +/// A delivery item that passed (§8.2): how its group joined the client's chain, the +/// parents fetched for it, what the object receipt establishes, and, for a delivered +/// object, how its content hash was established and the property's reading. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DeliveryVerdict { + pub admission: Admission, + pub walked: usize, + pub evidence: Evidence, + pub established: Option, + pub reading: Option, + pub group: GroupReceipt, +} + +/// §3 step 3 for `group` against `chains`, recovering the parents it lacks (§8.1): while +/// the group is `parent_not_held`, fetch the receipt file its missing parent's link +/// names, read it as a group file, require it on the group's key and track, and offer it +/// to the chains — at most `max_walk` files. Once one is admitted, the parents fetched +/// before it are offered again, nearest that one first, then `group`. Returns how +/// `group` was admitted and how many files were fetched. +fn admit_walking( + chains: &mut ReceiptChains, + group: &GroupReceipt, + fetch: &mut dyn FnMut(&[u8; 32]) -> Option>, + now_ms: u64, + max_walk: usize, +) -> Result<(Admission, usize), ReceiptRefusal> { + match chains.admit(group, now_ms) { + Ok(a) => return Ok((a, 0)), + Err(e) if e.reason != Reason::ParentNotHeld => return Err(ReceiptRefusal::Delivery(e)), + Err(_) => {} + } + let stop = |walked: usize, why: &str| ReceiptRefusal::ParentNotHeld { walked, stop: why.to_string() }; + let mut pending: Vec = Vec::new(); + let mut link = group.parent_receipt_hash; + let mut fetched = 0usize; + loop { + // §3 step 3 says `parent_not_held` only of a group that names a parent. + let Some(l) = link else { return Err(stop(fetched, "parent_not_held")) }; + if fetched == max_walk { + return Err(stop(fetched, "bound")); + } + let bytes = fetch(&l).ok_or_else(|| stop(fetched, "receipt_not_served"))?; + fetched += 1; + let p = read_group_file(&l, &bytes).map_err(|r| stop(fetched, r.code()))?; + // A group of another chain is never this group's predecessor (§3 step 3). + if p.signer_pubkey != group.signer_pubkey || p.track_alias != group.track_alias { + return Err(stop(fetched, "another_chain")); + } + match chains.admit(&p, now_ms) { + Ok(_) => break, + Err(e) if e.reason == Reason::ParentNotHeld => { + link = p.parent_receipt_hash; + pending.push(p); + } + Err(e) => return Err(stop(fetched, e.reason.label())), + } + } + for p in pending.iter().rev() { + chains.admit(p, now_ms).map_err(|e| stop(fetched, e.reason.label()))?; + } + chains.admit(group, now_ms).map(|a| (a, fetched)).map_err(ReceiptRefusal::Delivery) +} + +/// §8.2's check of one delivery item of the index of the object `cid`, in order: +/// +/// 1. the property's `content_hash` and the object receipt's are `cid` +/// (`receipt_not_about_object`); +/// 2. the group receipt is served at `rcpt/.json` +/// (`receipt_not_served`), reads as a group file of that name ([`read_group_file`]); +/// 3. jwp-receipts §3 steps 1–3 against `chains`, recovering missing parents as §8.1 +/// states, at most `max_walk` of them (`parent_not_held` past it); +/// 4. steps 4–5 for the object receipt, step 4 against what the client holds: the +/// object (`delivered-verified`), or nothing (`inclusion-verified`, never delivered); +/// 5. the property, against the object receipt for a delivered object +/// (moq-streaming-format §5: `property_invalid`, `property_disagrees`), alone for one +/// not delivered, which gives no reading. +#[allow(clippy::too_many_arguments)] +pub fn check_delivery( + cid: &[u8; 32], + item: &IndexItem, + held: &ObjectHeld, + fetch: &mut dyn FnMut(&[u8; 32]) -> Option>, + chains: &mut ReceiptChains, + now_ms: u64, + max_walk: usize, +) -> Result { + if item.property.content_hash != *cid || item.object_receipt.content_hash != *cid { + return Err(ReceiptRefusal::NotAboutObject); + } + let name = item.property.group_receipt; + let bytes = fetch(&name).ok_or(ReceiptRefusal::NotServed)?; + let group = read_group_file(&name, &bytes)?; + let (admission, walked) = admit_walking(chains, &group, fetch, now_ms, max_walk)?; + let established = match held { + ObjectHeld::Object(e) => Some(*e), + ObjectHeld::Nothing => None, + }; + let evidence = chains + .verify_admitted_object(&group, &item.object_receipt, established.as_ref()) + .map_err(ReceiptRefusal::Delivery)?; + let reading = match &established { + Some(e) => Some(item.property.check_against_object_hash(&item.object_receipt, &group, e).map_err(ReceiptRefusal::Delivery)?), + None => { + item.property.check().map_err(ReceiptRefusal::Delivery)?; + None + } + }; + Ok(DeliveryVerdict { admission, walked, evidence, established: established.map(|e| e.by()), reading, group }) +} + +// ───────────────────────────── claims ───────────────────────────── + +/// What an accepted claim attests (§8.2). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Attests { + /// The object in hand: its subject is the cid. + Object, + /// A group receipt the index lists: its subject is that group's link hash. + Receipt, + /// Another object, the ancestor the object in hand was derived from: a soft-binding + /// claim whose subject is not the cid (jwp-receipts §3.2's ancestor rule). It says + /// nothing of the object in hand. + Ancestor, +} + +impl Attests { + /// `object`, `receipt` or `ancestor`. + pub fn label(self) -> &'static str { + match self { + Attests::Object => "object", + Attests::Receipt => "receipt", + Attests::Ancestor => "ancestor", + } + } +} + +/// A claim that passed (§8.2): the claim, what it attests, and whether it starts its +/// signer's chain or links to the parent fetched for it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClaimVerdict { + pub claim: ClaimReceipt, + pub attests: Attests, + /// The parent fetched and linked to, `None` for a claim that starts its chain. + pub parent: Option<[u8; 32]>, +} + +/// §8.2's check of one claim the index of the object `cid` lists by its link hash +/// `name`, in order: +/// +/// 1. it is served at `rcpt/.json` (`receipt_not_served`) and reads as a claim of +/// that name ([`read_claim_file`]); +/// 2. its subject is `cid`, or, for a claim about a receipt, the link hash of a group +/// one of the index's delivery items names (`groups`); a soft-binding claim about +/// another object attests that object, the ancestor; any other claim is +/// `receipt_not_about_object`; +/// 3. jwp-receipts §3.1 steps 1–4; +/// 4. step 5: a claim that names a parent has it fetched at `rcpt/.json`, read +/// as a claim of that name and verified (`parent_not_held` otherwise), and links to it +/// (`chain_break` otherwise). +pub fn check_claim( + cid: &[u8; 32], + groups: &[[u8; 32]], + name: &[u8; 32], + fetch: &mut dyn FnMut(&[u8; 32]) -> Option>, +) -> Result { + let bytes = fetch(name).ok_or(ReceiptRefusal::NotServed)?; + let claim = read_claim_file(name, &bytes)?; + let attests = match claim.subject_type { + SubjectType::Object if claim.subject == *cid => Attests::Object, + SubjectType::Receipt if groups.contains(&claim.subject) => Attests::Receipt, + SubjectType::Object if claim.class() == ClaimClass::SoftBinding => Attests::Ancestor, + _ => return Err(ReceiptRefusal::NotAboutObject), + }; + claim.verify().map_err(ReceiptRefusal::Claim)?; + let parent = match claim.parent_receipt_hash { + None => None, + Some(p) => { + let stop = |walked: usize, why: &str| ReceiptRefusal::ParentNotHeld { walked, stop: why.to_string() }; + let pb = fetch(&p).ok_or_else(|| stop(0, "receipt_not_served"))?; + let prev = read_claim_file(&p, &pb).map_err(|r| stop(1, r.code()))?; + prev.verify().map_err(|r| stop(1, r.reason.label()))?; + claim.check_parent(Some(&prev)).map_err(ReceiptRefusal::Claim)?; + Some(p) + } + }; + Ok(ClaimVerdict { claim, attests, parent }) +} + +// ───────────────────────────── the whole index ───────────────────────────── + +/// What a client established from an index (§8.2): each delivery item's verdict or +/// reason, and each claim's, in the index's order. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IndexReport { + pub delivery: Vec>, + pub claims: Vec>, +} + +/// §8.2: check the index `index` of the object `cid` against what the client holds of +/// the object, with the receipt files `fetch` serves and the client's `chains`. The index +/// is refused whole when it does not read (`receipt_index_malformed`) or names another +/// object (`receipt_not_about_object`); otherwise each delivery item is checked in order +/// ([`check_delivery`]), then each claim ([`check_claim`]) against the groups the delivery +/// items that read name. +#[allow(clippy::too_many_arguments)] +pub fn check_index( + cid: &[u8; 32], + index: &[u8], + held: &ObjectHeld, + fetch: &mut dyn FnMut(&[u8; 32]) -> Option>, + chains: &mut ReceiptChains, + now_ms: u64, + max_walk: usize, +) -> Result { + let ix = ReceiptIndex::from_bytes(index)?; + if ix.object != *cid { + return Err(ReceiptRefusal::NotAboutObject); + } + let groups: Vec<[u8; 32]> = ix.delivery.iter().filter_map(|i| i.as_ref().ok().map(|i| i.property.group_receipt)).collect(); + let delivery = ix + .delivery + .iter() + .map(|i| match i { + Ok(item) => check_delivery(cid, item, held, fetch, chains, now_ms, max_walk), + Err(e) => Err(e.clone()), + }) + .collect(); + let claims = ix + .claims + .iter() + .map(|c| match c { + Ok(name) => check_claim(cid, &groups, name, fetch), + Err(e) => Err(e.clone()), + }) + .collect(); + Ok(IndexReport { delivery, claims }) +} diff --git a/wai/wai-rs/src/http_binding/session.rs b/wai/wai-rs/src/http_binding/session.rs index 10758f5be..2381b3fbb 100644 --- a/wai/wai-rs/src/http_binding/session.rs +++ b/wai/wai-rs/src/http_binding/session.rs @@ -375,6 +375,21 @@ impl FetchSession { self.result.as_ref().and_then(|r| r.as_ref().ok()) } + /// The content hash jwp-receipts §3 step 4 compares the delivered object's receipt + /// with (http-delivery §8.3), once the session has delivered: recomputed over the + /// whole envelope it fetched and verified against the cid, or, where it fetched the + /// object in part, the cid its head proof verified, every component it delivered + /// checked against its entry's digest. `None` before a delivery, after a refusal, and + /// for a layer whose request was abandoned, of which nothing was received. Only a + /// session builds the second kind, so a cid nothing verified never stands in for one. + pub fn established(&self) -> Option { + let f = self.done()?; + match f.verified_by { + VerifiedBy::WholeObject => f.envelope.as_deref().map(crate::jwp_hash::EstablishedHash::of_envelope), + VerifiedBy::HeadProof => Some(crate::jwp_hash::EstablishedHash::by_head_proof(self.cid)), + } + } + /// The base, as soon as it verified (§10.1): the picked component and the companions /// that are not refinements, before any refinement has arrived or been abandoned. Its /// `refinements` are empty; [`Self::done`] adds them. A layer, and an object fetched diff --git a/wai/wai-rs/src/http_binding/tests_receipts.rs b/wai/wai-rs/src/http_binding/tests_receipts.rs new file mode 100644 index 000000000..9ddb6854c --- /dev/null +++ b/wai/wai-rs/src/http_binding/tests_receipts.rs @@ -0,0 +1,507 @@ +//! Unit tests of receipts over HTTP (§8.1–§8.3, §10.5) and, with `key_discovery`, of +//! keys, statuses, grants and parameter sets (§8.4, §8.5). + +use std::collections::BTreeMap; + +use ed25519_dalek::SigningKey; + +use super::head_proof::HeadProof; +use super::plan::FetchPolicy; +use super::receipts::*; +use super::session::{drive, FetchSession, ResponseMeta, Selector, VerifiedBy, Want}; +use super::{content_hash, origin, Fragment}; +use crate::claim_receipt::{BindingType, ClaimBody, ClaimReceipt, LogInclusionClaim, SoftBindingClaim, SubjectType}; +use crate::container::{Rendition, WaiMulti}; +use crate::jwp_delivery::{ + resign, Admission, Evidence, GroupBuilder, GroupReceipt, ObjectLabel, ObjectReceipt, PropertyReading, Reason, ReceiptChains, + WaiReceiptProperty, +}; +use crate::jwp_hash::{EstablishedHash, Establishment}; + +fn key(s: u8) -> SigningKey { + SigningKey::from_bytes(&[s; 32]) +} + +fn pattern(len: usize, a: usize, b: usize) -> Vec { + (0..len).map(|i| ((a * i + b) % 256) as u8).collect() +} + +/// Appendix D's shape, its payloads varied by `v`: two AV1 renditions and a companion. +fn envelope(v: usize) -> Vec { + WaiMulti::new("video", "replicate", serde_json::Value::Null, vec![ + Rendition::media("wai.video.av1", "av1", pattern(3000, 7, v)).with_id("low").with_payload_digest(), + Rendition::media("wai.video.av1", "av1", pattern(50_000, 13, v)).with_id("high").with_payload_digest(), + Rendition::companion("wai.meta.hdr10plus", pattern(200, 31, v), "wai.video.av1").with_id("hdr") + .with_payload_digest().with_companion_of_id("high"), + ]).to_bytes().unwrap() +} + +const SIGNER: &str = "did:web:origin.example#d1"; + +/// A track of `n` labelled groups under key `seed`, each with the one object +/// `envelope(i)`, chained; and the receipt files an origin serves, by link hash. +struct Track { + envs: Vec>, + groups: Vec, + objects: Vec, + files: BTreeMap<[u8; 32], Vec>, +} + +fn track(seed: u8, track_alias: u64, n: usize) -> Track { + let mut t = Track { envs: Vec::new(), groups: Vec::new(), objects: Vec::new(), files: BTreeMap::new() }; + for i in 0..n { + let env = envelope(i + 10 * seed as usize); + let mut b = GroupBuilder::default(); + b.add(content_hash(&env), 0, "origin", Some(ObjectLabel::Unmetered)); + let parent = t.groups.last().map(GroupReceipt::link_hash); + let (g, mut o) = b + .finish(&key(seed), SIGNER, track_alias, i as u64, "2026-10-06T00:00:00Z", "2026-10-06T00:00:04Z", parent, true) + .unwrap(); + t.files.insert(g.link_hash(), group_file(&g).unwrap()); + t.envs.push(env); + t.groups.push(g); + t.objects.push(o.remove(0)); + } + t +} + +fn item(g: &GroupReceipt, o: &ObjectReceipt) -> IndexItem { + IndexItem { + property: WaiReceiptProperty { + content_hash: o.content_hash, + joules_micro: 0, + acquisition: String::new(), + acquisition_evidence: Vec::new(), + group_receipt: g.link_hash(), + }, + object_receipt: o.clone(), + } +} + +fn whole(env: &[u8]) -> ObjectHeld { + ObjectHeld::Object(EstablishedHash::of_envelope(env)) +} + +fn check( + t: &Track, + files: &BTreeMap<[u8; 32], Vec>, + i: usize, + held: &ObjectHeld, + chains: &mut ReceiptChains, + bound: usize, +) -> Result { + let cid = content_hash(&t.envs[i]); + let index = index_file(&cid, &[item(&t.groups[i], &t.objects[i])], &[]).unwrap(); + let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let r = check_index(&cid, &index, held, &mut fetch, chains, 0, bound).unwrap(); + r.delivery.into_iter().next().unwrap() +} + +/// B's retrofit: §3 step 4 compares with the hash established for the object, recomputed +/// over its bytes or the cid a head proof verified; anything else is a mismatch. +#[test] +fn step_4_compares_the_established_hash() { + let t = track(0x61, 1, 1); + let (g, o, env) = (&t.groups[0], &t.objects[0], &t.envs[0]); + assert_eq!(o.accept(g, env), Ok(())); + assert_eq!(o.accept_hash(g, &EstablishedHash::of_envelope(env)), Ok(())); + assert_eq!(o.accept_hash(g, &EstablishedHash::by_head_proof(content_hash(env))), Ok(())); + let other = EstablishedHash::of_envelope(b"another object"); + let e = o.accept_hash(g, &other).unwrap_err(); + assert_eq!((e.step, e.reason), (Some(4), Reason::ContentHashMismatch)); + assert_eq!(o.verify_against_hash(g, None), Ok(Evidence::InclusionVerified)); + assert_eq!(o.verify_against_hash(g, Some(&EstablishedHash::by_head_proof(o.content_hash))), Ok(Evidence::DeliveredVerified)); + let p = item(g, o).property; + assert_eq!(p.check_against_object_hash(o, g, &EstablishedHash::by_head_proof(o.content_hash)), Ok(PropertyReading::Unmetered)); + assert_eq!(p.check_against_object_hash(o, g, &other).unwrap_err().reason, Reason::ContentHashMismatch); + assert_eq!(p.check_against_object(o, g, env), Ok(PropertyReading::Unmetered)); +} + +/// An answerer for `env` and its proof, as an origin answers each request. +fn answer(env: &[u8], proof: &[u8], w: &Want) -> Option<(ResponseMeta, Vec)> { + let range = match w { + Want::HeadProof => return Some((ResponseMeta { status: 200, ..Default::default() }, proof.to_vec())), + Want::Whole(_) => None, + Want::Range(r, _) => Some(r.header()), + Want::Rest(r, _) => Some(format!("bytes={}-", r.first)), + }; + let s = origin::serve(env.len() as u64, range.as_deref()); + Some((ResponseMeta { status: s.status, content_range: s.content_range, content_encoding: None }, env[s.body.start as usize..s.body.end as usize].to_vec())) +} + +/// §8.3: a session that verified the head by its proof and the component by its digest +/// establishes the cid; one that fetched whole recomputes it; one refused establishes +/// nothing. +#[test] +fn a_session_establishes_only_what_it_verified() { + let env = envelope(1); + let cid = content_hash(&env); + let proof = HeadProof::build(&env).unwrap().to_bytes(); + let mut s = FetchSession::new(cid, Fragment::default(), FetchPolicy::default(), Selector::new(|c| c == "wai.video.av1")); + let f = drive(&mut s, |w| answer(&env, &proof, w)).unwrap(); + assert_eq!(f.verified_by, VerifiedBy::HeadProof); + let e = s.established().unwrap(); + assert_eq!((*e.hash(), e.by()), (cid, Establishment::HeadProof)); + // An object its URI says is below one chunk is fetched whole: the hash is recomputed + // over it. + let small = WaiMulti::new("video", "replicate", serde_json::Value::Null, vec![ + Rendition::media("wai.video.av1", "av1", pattern(100, 7, 1)).with_id("low").with_payload_digest(), + ]).to_bytes().unwrap(); + let small_proof = HeadProof::build(&small).unwrap().to_bytes(); + let frag = Fragment { n: Some(small.len() as u64), ..Default::default() }; + let mut s = FetchSession::new(content_hash(&small), frag, FetchPolicy::default(), Selector::new(|c| c == "wai.video.av1")); + let f = drive(&mut s, |w| answer(&small, &small_proof, w)).unwrap(); + assert_eq!(f.verified_by, VerifiedBy::WholeObject); + assert_eq!(s.established(), Some(EstablishedHash::of_envelope(&small))); + // A head changed in transit: refused, nothing established. + let mut bad = env.clone(); + bad[100] ^= 1; + let mut s = FetchSession::new(cid, Fragment::default(), FetchPolicy::default(), Selector::new(|c| c == "wai.video.av1")); + assert!(drive(&mut s, |w| answer(&bad, &proof, w)).is_err()); + assert_eq!(s.established(), None); +} + +/// §8.1: a receipt file is named by its link hash; a file read under another name, or +/// read as the other kind, or not JSON as a receipt is read, is refused. +#[test] +fn receipt_files_are_named_by_their_link_hash() { + let t = track(0x61, 1, 2); + let (g0, g1) = (&t.groups[0], &t.groups[1]); + let b0 = &t.files[&g0.link_hash()]; + assert_eq!(read_group_file(&g0.link_hash(), b0).as_ref(), Ok(g0)); + assert_eq!(read_group_file(&g1.link_hash(), b0), Err(ReceiptRefusal::NameMismatch)); + let text = String::from_utf8(b0.clone()).unwrap(); + let twice = text.replacen("{", "{\"group_id\":0,", 1); + assert_eq!(read_group_file(&g0.link_hash(), twice.as_bytes()).unwrap_err().code(), "malformed"); + assert_eq!(read_claim_file(&g0.link_hash(), b0).unwrap_err().code(), "wrong_kind"); + assert_eq!(receipt_path(&[0xab; 32]), format!("rcpt/{}.json", "ab".repeat(32))); +} + +/// §8.2: an index is read item by item; one item that does not read is kept as its +/// reason, and only an index not of its form is refused whole. +#[test] +fn an_index_reads_item_by_item() { + let t = track(0x61, 1, 1); + let cid = content_hash(&t.envs[0]); + let good = index_file(&cid, &[item(&t.groups[0], &t.objects[0])], &[[7; 32]]).unwrap(); + let ix = ReceiptIndex::from_bytes(&good).unwrap(); + assert_eq!((ix.object, ix.delivery.len(), ix.claims.len()), (cid, 1, 1)); + let text = String::from_utf8(good).unwrap(); + let broken_item = text.replacen("\"object_receipt\":{", "\"note\":1,\"object_receipt\":{", 1); + let ix = ReceiptIndex::from_bytes(broken_item.as_bytes()).unwrap(); + assert_eq!(ix.delivery[0].as_ref().unwrap_err().code(), "receipt_index_malformed"); + let broken_claim = text.replacen(&"07".repeat(32), "07", 1); + assert_eq!(ReceiptIndex::from_bytes(broken_claim.as_bytes()).unwrap().claims[0].as_ref().unwrap_err().code(), "receipt_index_malformed"); + let extra = text.replacen("{", "{\"extra\":1,", 1); + assert_eq!(ReceiptIndex::from_bytes(extra.as_bytes()).unwrap_err().code(), "receipt_index_malformed"); + let bad_property = text.replacen("\"acquisition\":\"\"", "\"acquisition\":0", 1); + assert_eq!(ReceiptIndex::from_bytes(bad_property.as_bytes()).unwrap().delivery[0].as_ref().unwrap_err().code(), "property_invalid"); + // An index about another object is refused whole. + let mut fetch = |h: &[u8; 32]| t.files.get(h).cloned(); + let other = content_hash(b"another"); + let e = check_index(&other, text.as_bytes(), &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, PARENT_WALK_BOUND).unwrap_err(); + assert_eq!(e, ReceiptRefusal::NotAboutObject); +} + +/// §8.1, §8.2: a client that holds nothing of a chain walks back to its start by the +/// parents' links; a later check finds the group at the head; an earlier object's group +/// is admitted again; an object not received is inclusion-verified. +#[test] +fn a_delivery_item_walks_to_its_chain() { + let t = track(0x61, 1, 3); + let mut chains = ReceiptChains::new(); + let v = check(&t, &t.files, 2, &whole(&t.envs[2]), &mut chains, PARENT_WALK_BOUND).unwrap(); + assert_eq!((v.admission, v.walked, v.evidence), (Admission::Linked, 2, Evidence::DeliveredVerified)); + assert_eq!((v.established, v.reading), (Some(Establishment::Envelope), Some(PropertyReading::Unmetered))); + let v = check(&t, &t.files, 2, &whole(&t.envs[2]), &mut chains, PARENT_WALK_BOUND).unwrap(); + assert_eq!((v.admission, v.walked), (Admission::Linked, 0)); + let v = check(&t, &t.files, 1, &ObjectHeld::Nothing, &mut chains, PARENT_WALK_BOUND).unwrap(); + assert_eq!((v.admission, v.walked, v.evidence, v.reading), (Admission::AlreadyAdmitted, 0, Evidence::InclusionVerified, None)); + // §8.3: the cid a head proof established attests the object in part. + let v = check(&t, &t.files, 0, &ObjectHeld::Object(EstablishedHash::by_head_proof(content_hash(&t.envs[0]))), &mut chains, 64).unwrap(); + assert_eq!((v.evidence, v.established), (Evidence::DeliveredVerified, Some(Establishment::HeadProof))); + // Another object's bytes in hand: step 4 refuses. + let e = check(&t, &t.files, 0, &whole(&t.envs[1]), &mut chains, 64).unwrap_err(); + assert_eq!((e.code(), e.step()), ("content_hash_mismatch", Some(4))); +} + +/// §8.1: a parent the walk cannot recover leaves the group `parent_not_held`, saying +/// why; a joining client anchors instead and walks nothing. +#[test] +fn an_unrecovered_parent_is_parent_not_held() { + let t = track(0x61, 1, 3); + let pnh = |walked: usize, stop: &str| ReceiptRefusal::ParentNotHeld { walked, stop: stop.into() }; + assert_eq!(check(&t, &t.files, 2, &ObjectHeld::Nothing, &mut ReceiptChains::new(), 1).unwrap_err(), pnh(1, "bound")); + let mut gone = t.files.clone(); + gone.remove(&t.groups[0].link_hash()); + assert_eq!(check(&t, &gone, 2, &ObjectHeld::Nothing, &mut ReceiptChains::new(), 64).unwrap_err(), pnh(1, "receipt_not_served")); + let mut swapped = t.files.clone(); + swapped.insert(t.groups[0].link_hash(), t.files[&t.groups[1].link_hash()].clone()); + assert_eq!(check(&t, &swapped, 2, &ObjectHeld::Nothing, &mut ReceiptChains::new(), 64).unwrap_err(), pnh(2, "receipt_name_mismatch")); + let v = check(&t, &gone, 2, &ObjectHeld::Nothing, &mut ReceiptChains::new().with_join(), 64).unwrap(); + assert_eq!((v.admission, v.walked), (Admission::Anchor, 0)); + // A parent link that names a group of another key is never a predecessor. + let other = track(0x62, 1, 1); + let mut b = GroupBuilder::default(); + b.add(content_hash(&t.envs[0]), 0, "origin", Some(ObjectLabel::Unmetered)); + let (g, o) = b.finish(&key(0x61), SIGNER, 1, 5, "2026-10-06T00:00:00Z", "2026-10-06T00:00:04Z", Some(other.groups[0].link_hash()), true).unwrap(); + let mut files = other.files.clone(); + files.insert(g.link_hash(), group_file(&g).unwrap()); + let index = index_file(&o[0].content_hash, &[item(&g, &o[0])], &[]).unwrap(); + let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let r = check_index(&o[0].content_hash, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, 64).unwrap(); + assert_eq!(r.delivery[0].as_ref().unwrap_err(), &pnh(1, "another_chain")); +} + +/// §8.2's item checks, each with its reason: another object, a group not served, a +/// signature that does not verify, a property that disagrees with the signed leaf. +#[test] +fn a_delivery_item_is_refused_for_its_reason() { + let t = track(0x61, 1, 1); + let (g, o) = (&t.groups[0], &t.objects[0]); + let cid = o.content_hash; + let mut chains = ReceiptChains::new(); + let mut fetch = |h: &[u8; 32]| t.files.get(h).cloned(); + let mut it = item(g, o); + it.property.content_hash = [9; 32]; + assert_eq!(check_delivery(&cid, &it, &ObjectHeld::Nothing, &mut fetch, &mut chains, 0, 64), Err(ReceiptRefusal::NotAboutObject)); + let mut it = item(g, o); + it.property.group_receipt = [9; 32]; + assert_eq!(check_delivery(&cid, &it, &ObjectHeld::Nothing, &mut fetch, &mut chains, 0, 64), Err(ReceiptRefusal::NotServed)); + // A signature changed: served under the link hash of the bytes as changed. + let mut bad = g.clone(); + bad.sig[0] ^= 1; + let files: BTreeMap<[u8; 32], Vec> = [(bad.link_hash(), group_file(&bad).unwrap())].into(); + let mut fetch_bad = |h: &[u8; 32]| files.get(h).cloned(); + let it = item(&bad, o); + let e = check_delivery(&cid, &it, &ObjectHeld::Nothing, &mut fetch_bad, &mut chains, 0, 64).unwrap_err(); + assert_eq!((e.code(), e.step()), ("signature_verify_failed", Some(2))); + // A property stating a figure the unmetered leaf does not. + let mut it = item(g, o); + it.property.joules_micro = 5; + it.property.acquisition = "Estimator".into(); + assert_eq!(check_delivery(&cid, &it, &whole(&t.envs[0]), &mut fetch, &mut chains, 0, 64).unwrap_err().code(), "property_disagrees"); + // The same property beside an object not received gives no reading, and passes. + assert_eq!(check_delivery(&cid, &it, &ObjectHeld::Nothing, &mut fetch, &mut chains, 0, 64).unwrap().reading, None); + // A resigned group whose root no longer covers the object: step 5. + let mut moved = g.clone(); + moved.root_hash = [3; 32]; + let moved = resign(moved, &key(0x61)); + let files: BTreeMap<[u8; 32], Vec> = [(moved.link_hash(), group_file(&moved).unwrap())].into(); + let mut fetch_moved = |h: &[u8; 32]| files.get(h).cloned(); + let e = check_delivery(&cid, &item(&moved, o), &ObjectHeld::Nothing, &mut fetch_moved, &mut ReceiptChains::new(), 0, 64).unwrap_err(); + assert_eq!((e.code(), e.step()), ("inclusion_proof_failed", Some(5))); +} + +fn soft(signer: u8, subject: [u8; 32], seq: u64, parent: Option<[u8; 32]>) -> ClaimReceipt { + let body = ClaimBody::SoftBinding(SoftBindingClaim { + alg: "org.example.fp".into(), + registry: "https://registry.example/soft-binding".into(), + binding_type: BindingType::Fingerprint, + value: vec![0, 0xff], + resolution_endpoint: "https://origin.example/resolve".into(), + }); + ClaimReceipt::seal(&key(signer), SIGNER, SubjectType::Object, subject, seq, "2026-10-06T00:00:05Z", body, parent).unwrap() +} + +fn logged(signer: u8, subject: [u8; 32], seq: u64, parent: Option<[u8; 32]>) -> ClaimReceipt { + let body = ClaimBody::LogInclusion(LogInclusionClaim { + log: "https://log.example/".into(), + profile: "https://log.example/profile".into(), + entry: "1".into(), + log_receipt: vec![1, 2, 3], + }); + ClaimReceipt::seal(&key(signer), SIGNER, SubjectType::Receipt, subject, seq, "2026-10-06T00:00:06Z", body, parent).unwrap() +} + +/// §8.2: a claim about the object attests it; one about a listed group attests that +/// receipt; a soft-binding claim about another object attests the ancestor; any other +/// claim about something else is not about the object. A claim's parent is fetched by its +/// link. +#[test] +fn claims_are_checked_by_subject_and_parent() { + let t = track(0x61, 1, 1); + let cid = t.objects[0].content_hash; + let group = t.groups[0].link_hash(); + let c0 = soft(0x63, cid, 0, None); + let c1 = logged(0x63, group, 1, Some(c0.link_hash())); + let ancestor = soft(0x64, [5; 32], 0, None); + let elsewhere = logged(0x65, [6; 32], 0, None); + let mut files = t.files.clone(); + for c in [&c0, &c1, &ancestor, &elsewhere] { + files.insert(c.link_hash(), claim_file(c)); + } + let names = [c0.link_hash(), c1.link_hash(), ancestor.link_hash(), elsewhere.link_hash(), [8; 32]]; + let index = index_file(&cid, &[item(&t.groups[0], &t.objects[0])], &names).unwrap(); + let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let r = check_index(&cid, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, 64).unwrap(); + let got: Vec), &'static str>> = + r.claims.iter().map(|c| c.as_ref().map(|v| (v.attests, v.parent)).map_err(ReceiptRefusal::code)).collect(); + assert_eq!(got, vec![ + Ok((Attests::Object, None)), + Ok((Attests::Receipt, Some(c0.link_hash()))), + Ok((Attests::Ancestor, None)), + Err("receipt_not_about_object"), + Err("receipt_not_served"), + ]); + // A parent not served, or served under another name. + let mut no_parent = files.clone(); + no_parent.remove(&c0.link_hash()); + let mut fetch = |h: &[u8; 32]| no_parent.get(h).cloned(); + assert_eq!(check_claim(&cid, &[group], &c1.link_hash(), &mut fetch).unwrap_err(), ReceiptRefusal::ParentNotHeld { walked: 0, stop: "receipt_not_served".into() }); + let mut wrong = files.clone(); + wrong.insert(c0.link_hash(), claim_file(&ancestor)); + let mut fetch = |h: &[u8; 32]| wrong.get(h).cloned(); + assert_eq!(check_claim(&cid, &[group], &c1.link_hash(), &mut fetch).unwrap_err(), ReceiptRefusal::ParentNotHeld { walked: 1, stop: "receipt_name_mismatch".into() }); + // A parent that is another signer's claim: the link does not hold (chain_break). + let c1x = logged(0x63, group, 1, Some(ancestor.link_hash())); + let mut other = files.clone(); + other.insert(c1x.link_hash(), claim_file(&c1x)); + let mut fetch = |h: &[u8; 32]| other.get(h).cloned(); + assert_eq!(check_claim(&cid, &[group], &c1x.link_hash(), &mut fetch).unwrap_err().code(), "chain_break"); +} + +#[cfg(feature = "key_discovery")] +mod keys { + use super::super::keys::*; + use super::key; + use crate::container::Prior; + use crate::key_log::{commitment, log_json, KeyEntry, KeyStatus, ModuleForm, ModuleGrant, Revision, SinkKeyLog}; + use crate::pin::{sha256_hex, HeldBytes, VerifiedRef}; + + const ORIGIN: &str = "https://origin.example"; + + fn revision(prev: Option<&Revision>, issued_at: &str, revoked: Vec<[u8; 32]>) -> Revision { + let r = Revision { + origin: ORIGIN.into(), + seq: prev.map_or(0, |p| p.seq + 1), + prev: prev.map(Revision::revision_hash), + issued_at: issued_at.into(), + root: [0; 32], + next_root: commitment(&key(0x6f).verifying_key().to_bytes()), + keys: vec![ + KeyEntry::active("d1", key(0x61).verifying_key().to_bytes(), &["delivery"]), + KeyEntry::active("g1", key(0x63).verifying_key().to_bytes(), &["module/decoder"]), + ], + revoked_modules: revoked, + grant_max_s: 86_400, + status_max_age_s: 3_600, + sig: [0; 64], + }; + Revision::seal(&key(0x60), r).unwrap() + } + + fn log_bytes(revs: &[Revision]) -> Vec { + log_json(revs).into_bytes() + } + + /// §8.4: an origin is anchored only from its own location over HTTPS, answered 200 + /// with no redirect; a cached copy adds revisions and never moves the anchor, and an + /// older copy is reported. + #[test] + fn a_key_log_anchors_only_from_its_origin() { + let r0 = revision(None, "2026-10-01T00:00:00Z", vec![]); + let r1 = revision(Some(&r0), "2026-10-02T00:00:00Z", vec![]); + let loc = key_log_url(ORIGIN); + assert_eq!(loc, "https://origin.example/.well-known/wai-keys"); + let ok = Retrieval { requested: &loc, answered: &loc, status: 200 }; + let a = anchor_retrieved(ORIGIN, &ok, &log_bytes(&[r0.clone()]), "2026-10-03T00:00:00Z", None).unwrap(); + assert_eq!(a.revision, r0.revision_hash()); + let mirror = "https://mirror.example/.well-known/wai-keys"; + for (r, why) in [ + (Retrieval { requested: &loc, answered: mirror, status: 200 }, "redirect"), + (Retrieval { requested: mirror, answered: mirror, status: 200 }, "another location"), + (Retrieval { requested: &loc, answered: &loc, status: 304 }, "not a 200"), + ] { + assert_eq!(anchor_retrieved(ORIGIN, &r, &log_bytes(&[r0.clone()]), "t", None).unwrap_err().code(), "key_log_not_from_origin", "{why}"); + } + let plain = "http://origin.example"; + let pl = key_log_url(plain); + let r = Retrieval { requested: &pl, answered: &pl, status: 200 }; + assert_eq!(anchor_retrieved(plain, &r, &log_bytes(&[r0.clone()]), "t", None).unwrap_err().code(), "key_log_not_from_origin"); + // Through a cache: the newer copy adds r1; the anchor stays at r0. + let mut held = vec![r0.clone()]; + assert_eq!(hold_cached_revisions(ORIGIN, &log_bytes(&[r0.clone(), r1.clone()]), &a, &mut held), Ok(1)); + assert_eq!(held.len(), 2); + // Anchored at r1, a cached copy ending at r0 is an older copy. + let a1 = anchor_retrieved(ORIGIN, &ok, &log_bytes(&[r0.clone(), r1.clone()]), "2026-10-03T00:00:00Z", Some((&a, &held))).unwrap(); + assert_eq!(a1.revision, r1.revision_hash()); + assert_eq!(hold_cached_revisions(ORIGIN, &log_bytes(&[r0.clone()]), &a1, &mut held).unwrap_err().code(), "key_log_rolled_back"); + // A log of another origin is not this one's. + assert_eq!(hold_cached_revisions("https://other.example", &log_bytes(&[r0]), &a1, &mut held).unwrap_err().code(), "origin_not_anchored"); + assert_eq!(status_max_age_s(&r1), 900); + } + + /// §8.4: a grants file is read whole, and each grant in it is handed on as it is, + /// to be checked on its own. + #[test] + fn a_grants_file_is_a_locator() { + let module = [7u8; 32]; + let g = ModuleGrant::seal(&key(0x63), "did:web:origin.example#g1", ORIGIN, module, ModuleForm::Decoder, &["wai.neural.int_mlicpp"], + "2026-10-01T00:00:00Z", "2026-10-01T12:00:00Z").unwrap(); + let file = grants_file(&module, &[g.clone()]); + assert_eq!(read_grants_file(&module, &file).unwrap(), vec![g.to_json()]); + assert_eq!(read_grants_file(&[8; 32], &file).unwrap_err().code(), "grants_file_malformed"); + let text = String::from_utf8(file).unwrap(); + let extra = text.replacen("{", "{\"note\":1,", 1); + assert_eq!(read_grants_file(&module, extra.as_bytes()).unwrap_err().code(), "grants_file_malformed"); + // A grant that does not read stays in the list, for §7.9's check 1 to refuse. + let junk = text.replacen("\"grants\":[", "\"grants\":[1.5,", 1); + let got = read_grants_file(&module, junk.as_bytes()).unwrap(); + assert_eq!(got.len(), 2); + assert!(ModuleGrant::from_json(&got[0]).is_err()); + assert_eq!(grants_url(ORIGIN, &module), format!("https://origin.example/.well-known/wai-grants/{}.json", "07".repeat(32))); + } + + /// §8.5: a set fetched by its digest is held only as its pin and §7.9 hold it; the + /// name proves nothing. + #[test] + fn a_fetched_set_is_held_only_by_its_pin_and_its_grant() { + let set = b"a parameter set's bytes".to_vec(); + let digest: [u8; 32] = crate::pin::sha256(&set); + let r0 = revision(None, "2026-10-01T00:00:00Z", vec![]); + let loc = key_log_url(ORIGIN); + let ok = Retrieval { requested: &loc, answered: &loc, status: 200 }; + let a = anchor_retrieved(ORIGIN, &ok, &log_bytes(&[r0.clone()]), "2026-10-01T00:00:00Z", None).unwrap(); + let g = ModuleGrant::seal(&key(0x63), "did:web:origin.example#g1", ORIGIN, digest, ModuleForm::Decoder, &["wai.neural.int_mlicpp"], + "2026-10-01T00:00:00Z", "2026-10-01T12:00:00Z").unwrap(); + let grants = read_grants_file(&digest, &grants_file(&digest, &[g])).unwrap(); + let status = KeyStatus::seal(&key(0x60), &r0, "2026-10-01T05:00:00Z").to_json(); + let cap = "wai.neural.int_mlicpp"; + let prior = Prior::new("set", sha256_hex(&set), "integer-exact"); + let sink = |now: &str| { + SinkKeyLog::new(vec![r0.clone()], vec![a.clone()]).with_statuses(vec![status.clone()]).with_grants(grants.clone()) + .hold_pinned_sets(cap, &[ORIGIN]).at(now) + }; + assert_eq!(check_fetched_set(cap, &prior, &HeldBytes::File(&set), Some(&sink("2026-10-01T06:00:00Z"))), Ok(VerifiedRef::File { index: 0 })); + let stale = check_fetched_set(cap, &prior, &HeldBytes::File(&set), Some(&sink("2026-10-01T08:00:00Z"))).unwrap_err(); + assert_eq!((stale.code(), format!("{stale:?}").contains("module_status_stale")), ("prior_not_authorised", true)); + // Other bytes served under the set's name are not the set. + assert_eq!(check_fetched_set(cap, &prior, &HeldBytes::File(b"other bytes"), Some(&sink("2026-10-01T06:00:00Z"))).unwrap_err().code(), "prior_not_held"); + // A digest the log revokes is refused whatever is served. + let r1 = revision(Some(&r0), "2026-10-01T01:00:00Z", vec![digest]); + let revoked = SinkKeyLog::new(vec![r0.clone(), r1.clone()], vec![a.clone()]); + assert_eq!(check_fetched_set(cap, &prior, &HeldBytes::File(&set), Some(&revoked)).unwrap_err().code(), "prior_revoked"); + assert_eq!(set_url("https://origin.example/sets/", &prior.sha256), format!("https://origin.example/sets/{}", prior.sha256)); + } + + /// §8.5 through the integer decode's own entry: `int_payload::resolve` holds the + /// fetched set only as the pin and §7.9 hold it. + #[cfg(feature = "neural_int")] + #[test] + fn a_fetched_set_resolves_only_through_its_pin() { + let set = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors/int_mlicpp/model.wmb")).unwrap(); + let cap = "wai.neural.int_mlicpp"; + let prior = Prior::new("set", sha256_hex(&set), "integer-exact"); + let held = [HeldBytes::File(&set)]; + assert!(crate::int_payload::resolve(cap, Some(&prior), &held, None).unwrap().is_pinned()); + let other = [HeldBytes::File(b"not the set")]; + assert_eq!(crate::int_payload::resolve(cap, Some(&prior), &other, None).unwrap_err().code(), "prior_not_held"); + let digest: [u8; 32] = crate::pin::sha256(&set); + let r0 = revision(None, "2026-10-01T00:00:00Z", vec![]); + let r1 = revision(Some(&r0), "2026-10-01T01:00:00Z", vec![digest]); + let anchor = crate::key_log::Anchor::pinned(ORIGIN, r1.revision_hash()); + let sink = SinkKeyLog::new(vec![r0, r1], vec![anchor]); + assert_eq!(crate::int_payload::resolve(cap, Some(&prior), &held, Some(&sink)).unwrap_err().code(), "prior_revoked"); + } +} diff --git a/wai/wai-rs/src/jwp_delivery.rs b/wai/wai-rs/src/jwp_delivery.rs index c6d9b1007..1af6eea45 100644 --- a/wai/wai-rs/src/jwp_delivery.rs +++ b/wai/wai-rs/src/jwp_delivery.rs @@ -44,6 +44,7 @@ //! [`GroupBuilder`] is not a publishing path. use crate::claim_receipt::{verify_ed25519_strict, ClaimBody, ClaimReceipt, Reason as ClaimReason}; +use crate::jwp_hash::EstablishedHash; use crate::energy_binding::Coverage; use crate::energy_class::{ check_label, json_fields, label_from_json, AcquisitionError, AcquisitionKind, CounterFiltering, EnergyProvenance, @@ -683,8 +684,16 @@ impl ObjectReceipt { /// `signer_pubkey`; and, where the leaf is labelled, the figure, its class, /// the class's evidence and the origin. pub fn accept(&self, group: &GroupReceipt, envelope: &[u8]) -> Result<(), DeliveryError> { + self.accept_hash(group, &EstablishedHash::of_envelope(envelope)) + } + + /// §3 steps 1–5 with step 4's comparison made against `established`: the content + /// hash recomputed over the envelope ([`ObjectReceipt::accept`]), or, for an object + /// received in part over HTTP, the cid its verified head proof establishes + /// (http-delivery §8.3). The object receipt then attests the object that hash names. + pub fn accept_hash(&self, group: &GroupReceipt, established: &EstablishedHash) -> Result<(), DeliveryError> { group.verify()?; - if crate::jwp_hash::content_hash(envelope) != self.content_hash { + if *established.hash() != self.content_hash { return Err(DeliveryError::at(4, Reason::ContentHashMismatch)); } self.verify_inclusion(group) @@ -700,20 +709,28 @@ impl ObjectReceipt { /// Step 3 is the caller's: [`ReceiptChains::verify_object`] runs it against the /// chain of the group's (`signer_pubkey`, `track_alias`). pub fn verify_against(&self, group: &GroupReceipt, envelope: Option<&[u8]>) -> Result { - group.verify()?; - self.evidence_under(group, envelope) + self.verify_against_hash(group, envelope.map(EstablishedHash::of_envelope).as_ref()) } - /// [`ObjectReceipt::verify_against`] under a group whose signature has verified: - /// steps 4 and 5. - fn evidence_under(&self, group: &GroupReceipt, envelope: Option<&[u8]>) -> Result { - if let Some(e) = envelope - && crate::jwp_hash::content_hash(e) != self.content_hash + /// [`ObjectReceipt::verify_against`] with step 4 run against `established` where + /// the verifier holds the object, whole or, over HTTP, in part + /// ([`ObjectReceipt::accept_hash`]); `None`, it holds nothing of it, and the object + /// is [`Evidence::InclusionVerified`]. + pub fn verify_against_hash(&self, group: &GroupReceipt, established: Option<&EstablishedHash>) -> Result { + group.verify()?; + self.evidence_under(group, established) + } + + /// [`ObjectReceipt::verify_against_hash`] under a group whose signature has + /// verified: steps 4 and 5. + fn evidence_under(&self, group: &GroupReceipt, established: Option<&EstablishedHash>) -> Result { + if let Some(e) = established + && *e.hash() != self.content_hash { return Err(DeliveryError::at(4, Reason::ContentHashMismatch)); } self.verify_inclusion(group)?; - Ok(if envelope.is_some() { Evidence::DeliveredVerified } else { Evidence::InclusionVerified }) + Ok(if established.is_some() { Evidence::DeliveredVerified } else { Evidence::InclusionVerified }) } /// Carried over MoQ (moq-streaming-format §4.1): the receipt is for the object of MOQT @@ -1593,7 +1610,29 @@ impl ReceiptChains { envelope: Option<&[u8]>, ) -> Result { self.accept_group(group)?; - object.evidence_under(group, envelope) + object.evidence_under(group, envelope.map(EstablishedHash::of_envelope).as_ref()) + } + + /// §3 steps 4 and 5 for `object` under `group`, which this verifier has admitted to + /// its chain (or is the head of it), with step 4 against `established` + /// ([`ObjectReceipt::verify_against_hash`]). The group's signature was verified when + /// it was admitted, and is not verified again. A group the chains have not admitted is + /// refused `parent_not_held`: an object is never accepted under a group step 3 has not + /// passed. + pub fn verify_admitted_object( + &self, + group: &GroupReceipt, + object: &ObjectReceipt, + established: Option<&EstablishedHash>, + ) -> Result { + let link = group.link_hash(); + let admitted = self.chains.get(&(group.signer_pubkey, group.track_alias)).is_some_and(|c| { + c.head_link == Some(link) || c.links.get(&link) == Some(&group.parent_receipt_hash) + }); + if !admitted { + return Err(DeliveryError::at(3, Reason::ParentNotHeld)); + } + object.evidence_under(group, established) } } @@ -1870,13 +1909,33 @@ impl WaiReceiptProperty { object: &ObjectReceipt, group: &GroupReceipt, envelope: &[u8], + ) -> Result { + self.check_against_object_hash(object, group, &EstablishedHash::of_envelope(envelope)) + } + + /// [`WaiReceiptProperty::check_against_object`] with the object accepted under + /// `established` ([`ObjectReceipt::accept_hash`]): the hash recomputed over its + /// envelope, or, for an object received in part over HTTP, the cid its verified head + /// proof establishes (http-delivery §8.3). + pub fn check_against_object_hash( + &self, + object: &ObjectReceipt, + group: &GroupReceipt, + established: &EstablishedHash, ) -> Result { let class = self.check()?; let disagrees = DeliveryError::of(Reason::PropertyDisagrees); if self.content_hash != object.content_hash || self.group_receipt != group.link_hash() { return Err(disagrees); } - object.accept(group, envelope)?; + object.accept_hash(group, established)?; + self.reading_of(object, group, class) + } + + /// The property's reading against an object accepted under `group`: its class + /// restated by the object's labelled leaf, or not. + fn reading_of(&self, object: &ObjectReceipt, group: &GroupReceipt, class: Option) -> Result { + let disagrees = DeliveryError::of(Reason::PropertyDisagrees); match (&object.label, class) { (None, None) => Ok(PropertyReading::Unmetered), (None, Some(_)) => Ok(PropertyReading::Unsigned), diff --git a/wai/wai-rs/src/jwp_hash.rs b/wai/wai-rs/src/jwp_hash.rs index d20bc358c..db6de21d4 100644 --- a/wai/wai-rs/src/jwp_hash.rs +++ b/wai/wai-rs/src/jwp_hash.rs @@ -18,6 +18,65 @@ pub fn content_hash(envelope_bytes: &[u8]) -> [u8; 32] { *h.finalize().as_bytes() } +/// How a verifier came to hold the content hash it compares an object receipt's with +/// (jwp-receipts §3 step 4). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Establishment { + /// Recomputed over the envelope bytes the verifier received. + Envelope, + /// The cid a verified head proof establishes, for an object the verifier received + /// only in part over HTTP, each component it uses checked against its entry's + /// digest (http-delivery §6, §7, §8.3). + HeadProof, +} + +impl Establishment { + /// `envelope` or `head-proof`. + pub fn label(self) -> &'static str { + match self { + Establishment::Envelope => "envelope", + Establishment::HeadProof => "head-proof", + } + } +} + +/// The content hash jwp-receipts §3 step 4 compares an object receipt's `content_hash` +/// with, and how the verifier came to hold it. It is built only from bytes — the +/// envelope, hashed here ([`EstablishedHash::of_envelope`]) — or by an HTTP fetch whose +/// head proof verified against the cid and whose every used component matched its +/// entry's digest (`http_binding::session::FetchSession::established`). A value a receipt +/// or an index states never becomes one, so a check over it cannot pass on the receipt's +/// own word. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct EstablishedHash { + hash: [u8; 32], + by: Establishment, +} + +impl EstablishedHash { + /// The content hash recomputed over `envelope`, the bytes as received. + pub fn of_envelope(envelope: &[u8]) -> EstablishedHash { + EstablishedHash { hash: content_hash(envelope), by: Establishment::Envelope } + } + + /// The cid a head proof verified, for an object received in part + /// (http-delivery §8.3). Built only by a fetch session that verified it. + #[cfg_attr(not(feature = "http"), allow(dead_code))] + pub(crate) fn by_head_proof(cid: [u8; 32]) -> EstablishedHash { + EstablishedHash { hash: cid, by: Establishment::HeadProof } + } + + /// The content hash. + pub fn hash(&self) -> &[u8; 32] { + &self.hash + } + + /// How it was established. + pub fn by(&self) -> Establishment { + self.by + } +} + #[cfg(test)] mod tests { use super::*; @@ -31,4 +90,14 @@ mod tests { let hex: String = h.iter().map(|b| format!("{b:02x}")).collect(); assert_eq!(hex, "af904358ba08fb542a845ce37edb3e5ee7f56eb362dfb1ee10b209e167d420de"); } + + /// An established hash over bytes is the content hash of those bytes, marked as + /// recomputed: the one public way to build one. + #[test] + fn an_established_hash_is_recomputed_from_bytes() { + let e = EstablishedHash::of_envelope(b"claim-conformance: canonical fixture object"); + assert_eq!(*e.hash(), content_hash(b"claim-conformance: canonical fixture object")); + assert_eq!(e.by(), Establishment::Envelope); + assert_eq!(EstablishedHash::by_head_proof([7; 32]).by().label(), "head-proof"); + } } -- 2.54.0 (Apple Git-157) From ff094adc64d9b26eba20f35876855699956c4432 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 09:43:40 -0400 Subject: [PATCH 02/16] wai: `wai http receipts`, the origin's index and status headers, and a non-string kind is wrong_kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `wai http receipts` fetches an object by §7, then its receipt index and the receipt files it locates, through http_binding::receipts only, and prints what each item established, with signers confirmed under a pinned key log. The reference origin serves an index for revalidation and a key status for a quarter of the head's status_max_age_s at most (origin::receipt_index_headers, key_status_headers). The receipt-side differential found that jwp_delivery read a group or object receipt whose `kind` is not a string as `malformed`, where jwp-receipts §3 step 1 and jwp_verify.py say `wrong_kind`: fixed, with a unit test and a corpus case. The fuzz tallies now count refusals inside steps and checks. Co-Authored-By: Claude Opus 5.5 --- wai/http-conformance/cases.json | 1 + wai/wai-rs/src/bin/wai_cli/http.rs | 201 +++++++++++++++++- wai/wai-rs/src/bin/wai_cli/http_vectors.rs | 13 +- .../src/bin/wai_cli/http_vectors_receipts.rs | 2 + wai/wai-rs/src/http_binding/origin.rs | 26 +++ wai/wai-rs/src/jwp_delivery.rs | 12 +- wai/wai-rs/src/jwp_delivery/tests.rs | 18 ++ wai/wai-rs/tests/cli_http.rs | 46 ++++ 8 files changed, 307 insertions(+), 12 deletions(-) diff --git a/wai/http-conformance/cases.json b/wai/http-conformance/cases.json index 5a49f23e2..869fbc948 100644 --- a/wai/http-conformance/cases.json +++ b/wai/http-conformance/cases.json @@ -935,6 +935,7 @@ {"name":"receipt_file_name_mismatch","op":"receipt_file","input":{"file":"objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json","name":"e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30","kind":"group"},"expect":{"error":"receipt_name_mismatch","step":null}}, {"name":"receipt_file_group_as_claim","op":"receipt_file","input":{"file":"objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json","kind":"claim"},"expect":{"error":"wrong_kind","step":1}}, {"name":"receipt_file_claim_as_group","op":"receipt_file","input":{"file":"objects/rcpt/14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491.json","kind":"group"},"expect":{"error":"wrong_kind","step":1}}, +{"name":"receipt_file_kind_null","op":"receipt_file","input":{"file":"objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json","kind":"group","edit":[["\"kind\":\"group\"","\"kind\":null"]]},"expect":{"error":"wrong_kind","step":1}}, {"name":"receipt_file_member_twice","op":"receipt_file","input":{"file":"objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json","kind":"group","edit":[["{","{\"group_id\":0,"]]},"expect":{"error":"malformed","step":1}}, {"name":"receipt_file_claim_member_twice","op":"receipt_file","input":{"file":"objects/rcpt/0c0b87f288cc459e99b914920284bf633eab89bf035078137b89936002072768.json","kind":"claim","edit":[["{","{\"seq\":1,"]]},"expect":{"error":"malformed","step":1}}, {"name":"receipts_walk_two","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":2,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]}]}}, diff --git a/wai/wai-rs/src/bin/wai_cli/http.rs b/wai/wai-rs/src/bin/wai_cli/http.rs index 1b0da2942..1d5307934 100644 --- a/wai/wai-rs/src/bin/wai_cli/http.rs +++ b/wai/wai-rs/src/bin/wai_cli/http.rs @@ -45,6 +45,16 @@ //! alias with 404). --live-swap serves OTHER's object under ALIAS (a test aid) //! wai http vectors gen|verify|diff … //! the corpus tool (as `wai_http_vectors`) +//! wai http receipts --url http://HOST:PORT/PATH/.wai[#frag] [--advertise CAP]… +//! [--held fetch|none] [--join] [--max-walk N] +//! [--pin-key-log FILE --key-origin ORIGIN] +//! fetch the object by §7 (unless --held none), then its receipt index beside it and +//! the receipt files it locates, walking parents by their links, and print what each +//! delivery item and claim established (§8.1–§8.3): its evidence, how the content +//! hash was established, the property's reading, and, with a pinned key log, each +//! signer's confirmation (§7.4 of jwp-receipts). Exit status 1 when the index is +//! refused or any item is (a build with `receipt_claims`; confirmation needs +//! `key_discovery`) //! wai http bench --url http://HOST:PORT/PATH[#frag] --runs N --advertise CAP… --out F //! [--refinement-deadline-ms MS] //! fetch an object N times by §7 over plain HTTP and write a measurement record: @@ -89,7 +99,7 @@ fn positional(args: &[String], flags: &[&str]) -> Vec { } fn usage() -> String { - "usage: wai http publish … --out \n wai http codecs …\n wai http origin --root [--port P] [--delay-range FIRST-LAST:MS]\n wai http hls --objects --out [--master NAME]\n wai http mpd --objects --out [--period-id ID] [--set-id N]\n wai http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]\n wai http check-playlist --receipt [--parent ] [--classical ]\n wai http vectors gen|verify|diff …\n wai http bench --url URL --runs N --advertise CAP [--advertise CAP]… --out F".into() + "usage: wai http publish … --out \n wai http codecs …\n wai http origin --root [--port P] [--delay-range FIRST-LAST:MS]\n wai http hls --objects --out [--master NAME]\n wai http mpd --objects --out [--period-id ID] [--set-id N]\n wai http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]\n wai http check-playlist --receipt [--parent ] [--classical ]\n wai http vectors gen|verify|diff …\n wai http receipts --url URL [--advertise CAP]… [--held fetch|none] [--join] [--max-walk N] [--pin-key-log FILE --key-origin ORIGIN]\n wai http bench --url URL --runs N --advertise CAP [--advertise CAP]… --out F".into() } pub fn main(args: &[String]) -> Result { @@ -103,6 +113,7 @@ pub fn main(args: &[String]) -> Result { Some("mpd") => crate::http_playlists::mpd(&args[1..]).map(|()| true), Some("seal-playlist") => crate::http_playlists::seal(&args[1..]).map(|()| true), Some("check-playlist") => crate::http_playlists::check(&args[1..]), + Some("receipts") => receipts_cmd(&args[1..]), _ => Err(usage()), } } @@ -425,17 +436,14 @@ fn resource(cfg: &OriginConfig, path: &str) -> Option<(Vec, Vec<(&'static st Some((prefix, max_age)) if rel.starts_with(prefix.as_str()) => origin::live_object_headers(&cid, codecs.as_deref(), *max_age), _ => origin::object_headers(&cid, codecs.as_deref()), } + } else if rel == ".well-known/wai-key-status" { + origin::key_status_headers(status_max_age(root, &bytes)) } else if rel.starts_with(".well-known/wai-") { origin::key_resource_headers() } else if name.ends_with(".wai.hp") { origin::immutable_headers("application/octet-stream") } else if name.ends_with(".wai.receipts") { - vec![ - ("Content-Type", "application/json".to_string()), - ("Cache-Control", "no-cache, no-transform".to_string()), - ("ETag", format!("\"{}\"", hex(&content_hash(&bytes)))), - ("Access-Control-Allow-Origin", "*".to_string()), - ] + origin::receipt_index_headers(&bytes) } else if name.ends_with(".m3u8") || name.ends_with(".mpd") { // A playlist changes at a live edge: revalidated on every use (§11). origin::playlist_headers(if name.ends_with(".mpd") { "application/dash+xml" } else { "application/vnd.apple.mpegurl" }) @@ -447,6 +455,24 @@ fn resource(cfg: &OriginConfig, path: &str) -> Option<(Vec, Vec<(&'static st Some((bytes, headers)) } +/// The `max-age` of the key status `status` served under `root` (§8.4): a quarter of the +/// `status_max_age_s` of the revision it names in the key log beside it. `None` where the +/// status or the log does not read, or names no revision of it. +#[cfg(feature = "key_discovery")] +fn status_max_age(root: &std::path::Path, status: &[u8]) -> Option { + let st = wai::key_log::KeyStatus::from_json(std::str::from_utf8(status).ok()?)?; + let log = std::fs::read_to_string(root.join(".well-known/wai-keys")).ok()?; + let head = wai::key_log::read_log(&st.origin, &log).ok()?.into_iter().find(|r| r.revision_hash() == st.head)?; + Some(wai::http_binding::keys::status_max_age_s(&head)) +} + +/// Without `key_discovery` the origin reads no status: it is served for revalidation on +/// every use. +#[cfg(not(feature = "key_discovery"))] +fn status_max_age(_: &std::path::Path, _: &[u8]) -> Option { + None +} + /// Write `bytes` to `s`, never past `until` (the end of the connection's lifetime): in /// pieces, each write waiting at most until then, so a peer that reads slowly cannot hold /// the connection longer. @@ -830,3 +856,164 @@ fn bench(args: &[String]) -> Result<(), String> { println!("wrote {out}"); Ok(()) } + +// ---------------------------------------------------------------- receipts (§8) + +/// The host and path of a plain-HTTP URL, and the object's cid (from `#cid=` or the +/// `.wai` name). +#[cfg(feature = "receipt_claims")] +fn object_url(url: &str) -> Result<(String, String, [u8; 32], wai::http_binding::Fragment), String> { + let (base, frag) = parse_fragment(url); + let rest = base.strip_prefix("http://").ok_or("this command fetches over plain http:// only")?; + let (host, path) = rest.split_once('/').map(|(h, p)| (h.to_string(), format!("/{p}"))).ok_or("URL has no path")?; + let stem = path.rsplit('/').next().and_then(|n| n.strip_suffix(".wai")).ok_or("the URL names .wai")?; + let cid = frag.cid.or_else(|| unhex32(stem)).ok_or("no cid: name the object .wai or give #cid=")?; + Ok((host, path, cid, frag)) +} + +#[cfg(not(feature = "receipt_claims"))] +fn receipts_cmd(_: &[String]) -> Result { + Err("built without receipt_claims: `wai http receipts` needs it".into()) +} + +/// `wai http receipts`: §7 then §8.2 over plain HTTP. Every receipt file it fetches is +/// checked as §8.1 states, by `http_binding::receipts`: no other path reads one. +#[cfg(feature = "receipt_claims")] +fn receipts_cmd(args: &[String]) -> Result { + use wai::http_binding::receipts::{check_index, ObjectHeld, PARENT_WALK_BOUND, RECEIPT_DIR}; + use wai::jwp_delivery::ReceiptChains; + let (host, path, cid, frag) = object_url(opt(args, "--url").ok_or("receipts needs --url")?)?; + let adv: Vec = args.iter().enumerate().filter(|(_, a)| *a == "--advertise").filter_map(|(i, _)| args.get(i + 1).cloned()).collect(); + let mut report = serde_json::Map::new(); + report.insert("object".into(), json!(hex(&cid))); + let held = match opt(args, "--held").unwrap_or("fetch") { + "none" => ObjectHeld::Nothing, + "fetch" => { + let mut s = FetchSession::new(cid, frag, FetchPolicy::default(), Selector::new(move |c| adv.iter().any(|a| a == c))); + let r = wai::http_binding::session::drive(&mut s, |w| { + let (p, range) = match w { + Want::HeadProof => (format!("{path}.hp"), None), + Want::Whole(_) => (path.clone(), None), + Want::Range(r, _) => (path.clone(), Some(r.header())), + Want::Rest(r, _) => (path.clone(), Some(format!("bytes={}-", r.first))), + }; + get(&host, &p, range.as_deref(), None).ok().map(|(m, b, _)| (m, b)) + }); + report.insert("fetch".into(), json!(match &r { Ok(f) => f.verified_by.label(), Err(e) => e.code() })); + s.established().map_or(ObjectHeld::Nothing, ObjectHeld::Object) + } + other => return Err(format!("--held {other}: fetch or none")), + }; + report.insert("held".into(), json!(match held { ObjectHeld::Object(e) => e.by().label(), ObjectHeld::Nothing => "nothing" })); + let (index_meta, index, _) = get(&host, &format!("{path}.receipts"), None, None)?; + if index_meta.status != 200 { + return Err(format!("no receipt index is served beside the object ({})", index_meta.status)); + } + // Receipt files resolve against the object's URI (§8.1). + let dir = path.rsplit_once('/').map_or("", |(d, _)| d).to_string(); + let mut fetch = |h: &[u8; 32]| -> Option> { + let (m, b, _) = get(&host, &format!("{dir}/{RECEIPT_DIR}{}.json", hex(h)), None, None).ok()?; + (m.status == 200).then_some(b) + }; + let mut chains = if args.iter().any(|a| a == "--join") { ReceiptChains::new().with_join() } else { ReceiptChains::new() }; + let bound = opt(args, "--max-walk").map_or(Ok(PARENT_WALK_BOUND), str::parse).map_err(|_| "--max-walk")?; + let keys = pinned_keys(args)?; + let items = wai::http_binding::receipts::ReceiptIndex::from_bytes(&index).map(|i| i.delivery).unwrap_or_default(); + let mut all = true; + match check_index(&cid, &index, &held, &mut fetch, &mut chains, 0, bound) { + Err(e) => { + all = false; + report.insert("index".into(), json!(e.to_string())); + } + Ok(r) => { + report.insert("index".into(), json!("ok")); + let mut ds = Vec::new(); + for (k, d) in r.delivery.iter().enumerate() { + ds.push(match d { + Ok(v) => { + let mut o = json!({ "admission": v.admission.label(), "walked": v.walked, "evidence": v.evidence.label(), + "established": v.established.map(|e| e.label()), "reading": v.reading.as_ref().map(|x| format!("{x:?}")) }); + if let (Some((revs, anchors)), Some(Ok(it))) = (&keys, items.get(k)) { + o["confirm"] = json!(confirm_group(&v.group, &it.object_receipt, revs, anchors)); + } + o + } + Err(e) => { + all = false; + json!({ "refused": e.to_string() }) + } + }); + } + report.insert("delivery".into(), json!(ds)); + let mut cs = Vec::new(); + for c in &r.claims { + cs.push(match c { + Ok(v) => { + let mut o = json!({ "class": v.claim.class().label(), "attests": v.attests.label(), "parent": v.parent.map(|p| hex(&p)) }); + if let Some((revs, anchors)) = &keys { + o["confirm"] = json!(confirm_claim(&v.claim, revs, anchors)); + } + o + } + Err(e) => { + all = false; + json!({ "refused": e.to_string() }) + } + }); + } + report.insert("claims".into(), json!(cs)); + } + } + println!("{}", serde_json::to_string_pretty(&serde_json::Value::Object(report)).expect("json")); + Ok(all) +} + +/// The key log a `--pin-key-log` file pins for `--key-origin` (jwp-receipts §7.3): the +/// revisions it holds and its anchor. Confirmation needs `key_discovery`. +#[cfg(all(feature = "receipt_claims", feature = "key_discovery"))] +type Keys = Option<(Vec, Vec)>; +#[cfg(all(feature = "receipt_claims", not(feature = "key_discovery")))] +type Keys = Option<((), ())>; + +#[cfg(all(feature = "receipt_claims", feature = "key_discovery"))] +fn pinned_keys(args: &[String]) -> Result { + let (Some(file), Some(origin)) = (opt(args, "--pin-key-log"), opt(args, "--key-origin")) else { + return Ok(None); + }; + let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?; + let anchor = match wai::key_log::anchor_log(origin, &text, wai::key_log::AnchorPath::Pinned, None) { + wai::key_log::Anchoring::Anchored(a) => a, + wai::key_log::Anchoring::NotAnchored(r) => return Err(format!("{file}: {}", r.label())), + wai::key_log::Anchoring::Replaced => return Err(format!("{file}: key_log_replaced")), + }; + let revs = wai::key_log::read_log(origin, &text).map_err(|r| r.label().to_string())?; + Ok(Some((revs, vec![anchor]))) +} + +#[cfg(all(feature = "receipt_claims", not(feature = "key_discovery")))] +fn pinned_keys(args: &[String]) -> Result { + if opt(args, "--pin-key-log").is_some() { + return Err("built without key_discovery: --pin-key-log needs it".into()); + } + Ok(None) +} + +#[cfg(all(feature = "receipt_claims", feature = "key_discovery"))] +fn confirm_group(g: &wai::jwp_delivery::GroupReceipt, o: &wai::jwp_delivery::ObjectReceipt, revs: &[wai::key_log::Revision], anchors: &[wai::key_log::Anchor]) -> Option { + wai::http_binding::keys::confirm_delivery(g, o, revs, anchors).map(|c| c.verdict()) +} + +#[cfg(all(feature = "receipt_claims", not(feature = "key_discovery")))] +fn confirm_group(_: &wai::jwp_delivery::GroupReceipt, _: &wai::jwp_delivery::ObjectReceipt, _: &(), _: &()) -> Option { + None +} + +#[cfg(all(feature = "receipt_claims", feature = "key_discovery"))] +fn confirm_claim(c: &wai::claim_receipt::ClaimReceipt, revs: &[wai::key_log::Revision], anchors: &[wai::key_log::Anchor]) -> String { + wai::http_binding::keys::confirm_claim(c, revs, anchors).verdict() +} + +#[cfg(all(feature = "receipt_claims", not(feature = "key_discovery")))] +fn confirm_claim(_: &wai::claim_receipt::ClaimReceipt, _: &(), _: &()) -> String { + String::new() +} diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors.rs index 0a1f69e8b..9eb607cfe 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_vectors.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_vectors.rs @@ -1181,6 +1181,17 @@ fn gives(v: &Value, code: &str) -> bool { }) } +/// Whether an outcome holds a refusal anywhere: its own `error` or `inert`, a step's +/// `error`, or a check's index or item refused (for the fuzz's tallies). +fn refuses(v: &Value) -> bool { + v.get("error").is_some() + || v.get("inert").is_some() + || v["steps"].as_array().into_iter().flatten().any(|s| s.get("error").is_some()) + || v["checks"].as_array().into_iter().flatten().any(|c| { + c["index"] != "ok" || ["delivery", "claims"].iter().any(|k| c[*k].as_array().into_iter().flatten().any(|x| x.get("error").is_some())) + }) +} + /// Every generated file, by path relative to the corpus. fn generate(dir: &Path) -> Result>, String> { check_sources(dir)?; @@ -1493,7 +1504,7 @@ fn fuzz(dir: &Path, seed: u64, count: usize) -> Result<(Vec, usize, BTreeMap let family = name.rsplit_once('_').map_or(name.as_str(), |(f, _)| f).to_string(); let tally = stats.entry(family).or_default(); tally.0 += 1; - if expect.get("error").is_some() || expect.get("inert").is_some() { + if refuses(&expect) { tally.1 += 1; } out.push((name, op, input, expect)); diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs index b8da8457d..96805467a 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs @@ -717,6 +717,8 @@ mod built { v.push(refused("receipt_file_name_mismatch", "receipt_file", json!({ "file": link_file(&g(0)), "name": hex(&g(1)), "kind": "group" }), "receipt_name_mismatch")); v.push(refused("receipt_file_group_as_claim", "receipt_file", json!({ "file": link_file(&g(0)), "kind": "claim" }), "wrong_kind")); v.push(refused("receipt_file_claim_as_group", "receipt_file", json!({ "file": link_file(&cl("s0")), "kind": "group" }), "wrong_kind")); + v.push(refused("receipt_file_kind_null", "receipt_file", + json!({ "file": link_file(&g(0)), "kind": "group", "edit": [["\"kind\":\"group\"", "\"kind\":null"]] }), "wrong_kind")); v.push(refused("receipt_file_member_twice", "receipt_file", json!({ "file": link_file(&g(0)), "kind": "group", "edit": [["{", "{\"group_id\":0,"]] }), "malformed")); v.push(refused("receipt_file_claim_member_twice", "receipt_file", diff --git a/wai/wai-rs/src/http_binding/origin.rs b/wai/wai-rs/src/http_binding/origin.rs index 111108acf..ff226d9e9 100644 --- a/wai/wai-rs/src/http_binding/origin.rs +++ b/wai/wai-rs/src/http_binding/origin.rs @@ -66,6 +66,32 @@ pub fn key_resource_headers() -> Vec<(&'static str, String)> { ] } +/// The headers for a receipt index `.wai.receipts` (§8.2): a mutable locator, +/// revalidated on every use, with an `ETag` of its own (its content hash). +pub fn receipt_index_headers(index: &[u8]) -> Vec<(&'static str, String)> { + vec![ + ("Content-Type", "application/json".to_string()), + ("Cache-Control", "no-cache, no-transform".to_string()), + ("ETag", format!("\"{}\"", hex(&super::content_hash(index)))), + ("Access-Control-Allow-Origin", "*".to_string()), + ] +} + +/// The headers for a key status (§8.4): `max-age` at most a quarter of the +/// `status_max_age_s` of the head it names, where the origin knows it; revalidated on +/// every use where it does not. +pub fn key_status_headers(max_age_s: Option) -> Vec<(&'static str, String)> { + let cc = match max_age_s { + Some(s) => format!("max-age={s}, no-transform"), + None => "no-cache, no-transform".to_string(), + }; + vec![ + ("Content-Type", "application/json".to_string()), + ("Cache-Control", cc), + ("Access-Control-Allow-Origin", "*".to_string()), + ] +} + /// Whether a `Range` is honoured under `If-Range` (RFC 9110 §13.1.5): with no /// `If-Range`, yes; with one, only when it is the representation's strong `etag` /// exactly. A weak validator never matches, and a date is not used for an object. diff --git a/wai/wai-rs/src/jwp_delivery.rs b/wai/wai-rs/src/jwp_delivery.rs index 1af6eea45..956f7b8b2 100644 --- a/wai/wai-rs/src/jwp_delivery.rs +++ b/wai/wai-rs/src/jwp_delivery.rs @@ -522,9 +522,11 @@ impl GroupReceipt { pub fn from_json(v: &Value) -> Result { let bad = DeliveryError::at(1, Reason::Malformed); let m = v.as_object().ok_or(bad)?; - match m.get("kind").and_then(Value::as_str) { + // §3 step 1: a `kind` that is not "group", of whatever type, is `wrong_kind`; one + // that is absent is `malformed`. + match m.get("kind") { None => return Err(bad), - Some("group") => {} + Some(k) if k.as_str() == Some("group") => {} Some(_) => return Err(DeliveryError::at(1, Reason::WrongKind)), } if GROUP_MEMBERS.iter().any(|k| !m.contains_key(*k)) @@ -783,9 +785,11 @@ impl ObjectReceipt { pub fn from_json(v: &Value) -> Result { let bad = DeliveryError::at(4, Reason::Malformed); let m = v.as_object().ok_or(bad)?; - match m.get("kind").and_then(Value::as_str) { + // A `kind` that is not "object", of whatever type, is `wrong_kind`; one that is + // absent is `malformed`. + match m.get("kind") { None => return Err(bad), - Some("object") => {} + Some(k) if k.as_str() == Some("object") => {} Some(_) => return Err(DeliveryError::at(4, Reason::WrongKind)), } if OBJECT_MEMBERS.iter().any(|k| !m.contains_key(*k)) diff --git a/wai/wai-rs/src/jwp_delivery/tests.rs b/wai/wai-rs/src/jwp_delivery/tests.rs index 7ce505609..1d54719ab 100644 --- a/wai/wai-rs/src/jwp_delivery/tests.rs +++ b/wai/wai-rs/src/jwp_delivery/tests.rs @@ -412,6 +412,24 @@ fn a_sign_version_other_than_2_is_group_label_invalid() { assert!(GroupReceipt::from_json(&g.to_json()).is_ok()); } +/// §3 step 1 refuses a `kind` other than the receipt's as `wrong_kind` whatever its JSON +/// type; only a `kind` that is absent is `malformed`. So does an object receipt's reading. +#[test] +fn a_kind_of_another_type_is_wrong_kind() { + let (g, o) = group(&[(1, None)], false); + for v in [json!(null), json!(1), json!(["group"]), json!("object")] { + let mut j = g.to_json(); + j["kind"] = v.clone(); + assert_eq!(GroupReceipt::from_json(&j), Err(DeliveryError::at(1, Reason::WrongKind)), "{v}"); + let mut k = o[0].to_json(); + k["kind"] = if v == json!("object") { json!("group") } else { v.clone() }; + assert_eq!(ObjectReceipt::from_json(&k), Err(DeliveryError::at(4, Reason::WrongKind)), "{v}"); + } + let mut j = g.to_json(); + j.as_object_mut().unwrap().remove("kind"); + assert_eq!(GroupReceipt::from_json(&j), Err(DeliveryError::at(1, Reason::Malformed))); +} + /// RFC 8032 §5.1.3 gives every point one encoding: `y = p + 3` encodes a point /// of large order that a reducing decoder would accept, and it is refused as a /// key, at step 1, before any signature is examined. diff --git a/wai/wai-rs/tests/cli_http.rs b/wai/wai-rs/tests/cli_http.rs index 6396b36ea..acb03b1e5 100644 --- a/wai/wai-rs/tests/cli_http.rs +++ b/wai/wai-rs/tests/cli_http.rs @@ -620,3 +620,49 @@ fn origin_replays_a_live_edge() { let (h, b) = get(o.port, "/live/720/1200.5.wai", &[("Range", "bytes=0-7")]); assert!(h.starts_with("HTTP/1.1 206") && b == part[..8], "{h}"); } + +/// `wai http receipts` (§8.1–§8.3) against the reference origin serving the corpus: +/// O3 fetched by its head proof, its group accepted on that cid, and its claims (the +/// ancestor's soft binding and a receipt claim with its parent fetched by link), each +/// signer confirmed under the pinned key log; O9c's group walked back two parents; a +/// layer not fetched inclusion-verified; and a walk past its bound refused. +#[cfg(feature = "key_discovery")] +#[test] +fn receipts_checks_an_index_over_http() { + let o = Origin::start(&corpus(), &[]); + let url = |id: &str| { + let p = object(id); + format!("http://127.0.0.1:{}/{}", o.port, p.strip_prefix(corpus()).unwrap().to_str().unwrap()) + }; + let log = corpus().join(".well-known/wai-keys"); + let run = wai(&["http", "receipts", "--url", &url("O3"), "--advertise", "wai.video.av1", "--pin-key-log", log.to_str().unwrap(), + "--key-origin", "https://origin.example"]); + let out = String::from_utf8_lossy(&run.stdout); + assert!(run.status.success(), "{out}{}", String::from_utf8_lossy(&run.stderr)); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert_eq!((v["fetch"].as_str(), v["held"].as_str()), (Some("head-proof"), Some("head-proof")), "{v}"); + let d = &v["delivery"][0]; + assert_eq!((d["evidence"].as_str(), d["established"].as_str()), (Some("delivered-verified"), Some("head-proof")), "{v}"); + assert!(d["confirm"].as_str().unwrap().starts_with("confirmed origin=https://origin.example key_id=d1"), "{v}"); + assert_eq!(v["claims"][0]["attests"], "ancestor"); + assert_eq!(v["claims"][1]["attests"], "receipt"); + assert!(v["claims"][1]["parent"].is_string(), "{v}"); + let run = wai(&["http", "receipts", "--url", &url("O9c"), "--advertise", "wai.video.av1"]); + let v: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap(); + assert!(run.status.success(), "{v}"); + assert_eq!((v["delivery"][0]["walked"].as_u64(), v["delivery"][0]["admission"].as_str()), (Some(2), Some("accept")), "{v}"); + let run = wai(&["http", "receipts", "--url", &url("O10L2"), "--held", "none"]); + let v: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap(); + assert_eq!((v["held"].as_str(), v["delivery"][0]["evidence"].as_str()), (Some("nothing"), Some("inclusion-verified")), "{v}"); + let run = wai(&["http", "receipts", "--url", &url("O9c"), "--held", "none", "--max-walk", "1"]); + assert!(!run.status.success()); + let v: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap(); + assert!(v["delivery"][0]["refused"].as_str().unwrap().starts_with("parent_not_held"), "{v}"); + // The origin serves the index for revalidation and the status for a quarter of its + // freshness at most. + let cid = hex(&content_hash(&std::fs::read(object("O3")).unwrap())); + let (h, _) = get(o.port, &format!("/objects/{cid}.wai.receipts"), &[]); + assert!(h.contains("Cache-Control: no-cache, no-transform"), "{h}"); + let (h, _) = get(o.port, "/.well-known/wai-key-status", &[]); + assert!(h.contains("Cache-Control: max-age=900, no-transform"), "{h}"); +} -- 2.54.0 (Apple Git-157) From 7c634f2e0ba5788659ac0c99b8a9f47b08f9c53e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 09:54:07 -0400 Subject: [PATCH 03/16] =?UTF-8?q?wai:=20http-delivery=20=C2=A78.1=E2=80=93?= =?UTF-8?q?=C2=A78.5=20state=20what=20is=20built;=20README,=20CHANGELOG=20?= =?UTF-8?q?and=20CI=20for=20the=20receipt=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit http-delivery §8.1–§8.5: the receipt file's form and reading, the parent walk and its bound, the index read item by item and each item's checks in order, claims by subject with the ancestor rule, where §8.3's content hash may come from and what an object not received gets, anchoring from the origin only, logs through caches, the grants file as a locator, the JCP grant carried only by reference, and sets held by their pins; four reasons in §13; §14 and §15. jwp-receipts §3 step 3 points to the HTTP recovery. CI: the corpus tool builds with key_discovery wherever it is built; the CLI test runs with it; the HTTP binding's receipts and keys build for wasm32; the falsify job installs the Ed25519 package its receipt-side mutations need, and its paths cover the receipt, claim, key-log and pin code and their Python verifiers. The key-log fuzz family anchors about half its cases. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 24 ++- .github/workflows/wai-http.yml | 32 +++- wai/CHANGELOG.md | 32 ++++ wai/extensions/http-delivery.md | 155 ++++++++++++++---- wai/extensions/jwp-receipts.md | 2 + wai/http-conformance/README.md | 46 +++++- wai/http-conformance/falsify/run.sh | 11 +- wai/http-conformance/http_verify.py | 9 +- .../src/bin/wai_cli/http_vectors_receipts.rs | 12 +- 9 files changed, 258 insertions(+), 65 deletions(-) diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index feafd20cc..e9d666fea 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -275,13 +275,15 @@ jobs: # does: objects named by their content hash with their head proofs, a sequence's union # and floor, the origin's headers, ranges and If-Range on loopback, and a fetch by # the byte-range procedure whose record states energy as not measured; and `hls`, - # `mpd`, `seal-playlist` and `check-playlist`, whose receipts need video_manifest. + # `mpd`, `seal-playlist` and `check-playlist`, whose receipts need video_manifest; + # and `receipts`, an object's receipt index checked over HTTP with its signers + # confirmed under a pinned key log, which needs key_discovery. # Cargo.toml declares it with required-features = ["http"]. tests/dash_read_bounds.rs # holds Appendix I's reader to the document's size under a counting allocator, and an # element's attributes to linear CPU time; a binary of its own, one test. - name: The wai http commands working-directory: wai/wai-rs - run: cargo test --no-default-features --features http,video_manifest --test cli_http --test dash_read_bounds + run: cargo test --no-default-features --features http,video_manifest,key_discovery --test cli_http --test dash_read_bounds # tests/staged_moq.rs runs the MoQ trace of staged-conformance/moq/ (moq-streaming-format # §4.1) with signed receipts: a chain for each track, a receipt held for its parent and # admitted when it arrives, the relay's per-hop delivery timeout, dropped objects checked @@ -330,17 +332,19 @@ jobs: working-directory: wai/wai-rs run: cargo test --no-default-features --features key_discovery --test wai_keys_cli # key_log has no I/O, clock or threads, so a browser sink could run it before it - # instantiates a module (§7.9); no browser sink does yet. This step checks that it - # builds for wasm32 on the unix legs (the target is added to the pinned toolchain - # here; the Windows host runner is not set up for it). - - name: key_log builds for wasm32 (unix) + # instantiates a module (§7.9); no browser sink does yet. Nor do the HTTP binding's + # receipts and keys (http-delivery §8.1–§8.5), which a browser client checks what it + # fetched with. This step checks that both build for wasm32 on the unix legs (the + # target is added to the pinned toolchain here; the Windows host runner is not set up + # for it). + - name: key_log and the HTTP receipts build for wasm32 (unix) if: matrix.os != 'windows' shell: bash working-directory: wai/wai-rs run: | set -euo pipefail rustup target add --toolchain 1.98.0 wasm32-unknown-unknown - cargo check --lib --target wasm32-unknown-unknown --no-default-features --features key_discovery + cargo check --lib --target wasm32-unknown-unknown --no-default-features --features key_discovery,http # The corpus-verify loop is genuinely shell-specific (a helper that groups # log output and propagates the first non-zero exit), so it ships once per @@ -532,7 +536,9 @@ jobs: # refines, layers declared against their bases, layers over WAI2 components, objects # past the first request through a tampering origin; playlists, adjacent pairs, # published masters, renderings, adaptation states, the integer log and changed - # receipt files) with wai-rs's outcomes, and + # receipt files; receipt files, indexes, grants files and key logs of §8.1–§8.5, + # whose cases need the `cryptography` package installed above) with wai-rs's + # outcomes, and # http_verify.py must give every one, a refusal by the same code. It prints each # family's count of cases and of refusals. Two fixed seeds, so a run is reproducible. - name: The HTTP differential outside the corpus (wai-rs against http_verify.py) @@ -540,7 +546,7 @@ jobs: shell: bash run: | set -euo pipefail - (cd wai-rs && cargo build --no-default-features --features http,video_manifest --bin wai_http_vectors) + (cd wai-rs && cargo build --no-default-features --features http,video_manifest,key_discovery --bin wai_http_vectors) for seed in 11 12; do wai-rs/target/debug/wai_http_vectors fuzz http-conformance --seed "$seed" --count 400 --out "$RUNNER_TEMP/http-fuzz-$seed.json" python3 http-conformance/http_verify.py cases "$RUNNER_TEMP/http-fuzz-$seed.json" http-conformance diff --git a/.github/workflows/wai-http.yml b/.github/workflows/wai-http.yml index 45f658044..c20d5141f 100644 --- a/.github/workflows/wai-http.yml +++ b/.github/workflows/wai-http.yml @@ -11,7 +11,10 @@ name: WAI HTTP delivery # plan, the class merge, the base-only threshold, refused refinement responses, stale # bytes after a mismatch, an unanswered base, the selection digest test; and on the # playlist side adjacency, rendering, bandwidths, durations, the classical master, the -# playlist receipt and its chain, listed cids, and the adaptation policies); it +# playlist receipt and its chain, listed cids, and the adaptation policies; and on the +# receipt side the receipt file's name, the parent walk and its bound, what a client +# holds of an object, the ancestor rule, a claim's parent, anchoring from the origin +# only, cached logs, grants files and sets held by their pins); it # is applied to a fresh worktree, the build must succeed, and `wai_http_vectors verify` # must fail naming the cases on the patch's first line. Then each `http_verify.py # --mutate` must fail, and the differential: wai-rs writes random envelopes' covered @@ -44,7 +47,17 @@ on: - 'wai/wai-rs/src/bin/wai_cli/http.rs' - 'wai/wai-rs/src/bin/wai_cli/http_vectors.rs' - 'wai/wai-rs/src/bin/wai_cli/http_vectors_playlists.rs' + - 'wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs' - 'wai/wai-rs/src/bin/wai_cli/http_playlists.rs' + # Receipts, keys, grants and sets over HTTP (§8.1–§8.5) are checked by the receipt, + # claim, key-log and pin code, and on the Python side by their own verifiers. + - 'wai/wai-rs/src/jwp_delivery.rs' + - 'wai/wai-rs/src/claim_receipt.rs' + - 'wai/wai-rs/src/key_log.rs' + - 'wai/wai-rs/src/pin.rs' + - 'wai/jwp-conformance/jwp_verify.py' + - 'wai/claim-conformance/claim_verify.py' + - 'wai/signer-conformance/signer_verify.py' # The playlists render through hls_render and are sealed by wai_video_manifest. - 'wai/wai-rs/src/hls_render.rs' - 'wai/wai-rs/src/wai_video_manifest.rs' @@ -71,7 +84,17 @@ on: - 'wai/wai-rs/src/bin/wai_cli/http.rs' - 'wai/wai-rs/src/bin/wai_cli/http_vectors.rs' - 'wai/wai-rs/src/bin/wai_cli/http_vectors_playlists.rs' + - 'wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs' - 'wai/wai-rs/src/bin/wai_cli/http_playlists.rs' + # Receipts, keys, grants and sets over HTTP (§8.1–§8.5) are checked by the receipt, + # claim, key-log and pin code, and on the Python side by their own verifiers. + - 'wai/wai-rs/src/jwp_delivery.rs' + - 'wai/wai-rs/src/claim_receipt.rs' + - 'wai/wai-rs/src/key_log.rs' + - 'wai/wai-rs/src/pin.rs' + - 'wai/jwp-conformance/jwp_verify.py' + - 'wai/claim-conformance/claim_verify.py' + - 'wai/signer-conformance/signer_verify.py' # The playlists render through hls_render and are sealed by wai_video_manifest. - 'wai/wai-rs/src/hls_render.rs' - 'wai/wai-rs/src/wai_video_manifest.rs' @@ -112,6 +135,13 @@ jobs: with: python-version: '3.11' + # http_verify.py checks the receipt and key cases through the corpora's receipt, + # claim and key-discovery verifiers, which check Ed25519's equation with this + # package; its mutations reach those cases. + - name: Install the Ed25519 library + shell: bash + run: python -m pip install cryptography + # One target directory for every patched build, so only the wai crate rebuilds. - name: Every patch is caught; every mutation is caught; the differential holds shell: bash diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 2335e2aa3..9c6ce18fe 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -477,6 +477,38 @@ wire format may change and such changes are recorded here. The corpus gains a case for each, and `http_verify.py` agrees with wai-rs on them and on a seeded differential outside the corpus, in which a refusal must match by its code and each family reports how many cases it generated and how many were refused. +- **Receipts, keys, grants and parameter sets** (http-delivery §8.1–§8.5, §10.5): + - a receipt file `rcpt/.json` holds one group receipt or claim in JCS, read as + jwp-receipts reads one and refused unless its link hash is its name + (`receipt_name_mismatch`, `receipt_not_served`); + - an index is read item by item (`receipt_index_malformed`): each delivery item's + group goes through jwp-receipts §3 against the client's chains, a missing parent + fetched by its link up to 64 of them and never skipped (`parent_not_held`, with + where the walk stopped), a joining client anchoring instead; its object receipt + through steps 4 and 5 against what the client holds — the hash recomputed over the + envelope, the cid a head proof established (§8.3), or nothing, which is + `inclusion-verified` and gives no property reading (§10.5); each claim by its + subject, its parent fetched by its link, a soft binding of another object reported + as attesting the ancestor; + - a key log anchors only from the origin's own location over HTTPS, answered `200` + with no redirect (`key_log_not_from_origin`); a log through any cache adds revisions + and never moves the anchor, an older copy reported (`key_log_rolled_back`); a grants + file is a locator (`grants_file_malformed`); a key status is served for a quarter of + the head's `status_max_age_s` at most; a module grant is §7.9's form, and a JCP grant + is carried only by reference in the receipt that names it; + - a parameter set served by its digest is held only by its pin and §7.9 + (`pin::verify_bytes_with`, `int_payload::resolve`), never by its name. + - `wai-rs`: `http_binding::receipts` and `http_binding::keys`; + `jwp_hash::EstablishedHash`, built only from bytes or by a fetch session whose head + proof verified (`FetchSession::established`); `ObjectReceipt::accept_hash` and + `verify_against_hash`, `WaiReceiptProperty::check_against_object_hash` and + `ReceiptChains::verify_admitted_object`, which the existing checks call; `wai http + receipts`. A group or object receipt whose `kind` is not a string is `wrong_kind` + (jwp-receipts §3 step 1), as the independent verifier already read it. + - The corpus serves receipts, indexes, a key log, a status, a grants file and a set; + `http_receipts.py` checks them for `http_verify.py` through the jwp-receipts, claim + and key-discovery verifiers, and the differential adds receipt files, indexes, + grants files and key logs. - No envelope byte or receipt byte changes for existing material. ### Packaging: objects, the stage index, the floor, catalogs and the package receipt diff --git a/wai/extensions/http-delivery.md b/wai/extensions/http-delivery.md index 1b907d1e0..1c1669290 100644 --- a/wai/extensions/http-delivery.md +++ b/wai/extensions/http-delivery.md @@ -7,8 +7,9 @@ > live. No envelope byte and no manifest member changes. The reference implementation > is in `wai-rs`: `http_binding` (feature `http`) for §3–§7, the origin side of §5 > and §9, the playlists of §10.4 and §11 and the adaptation policies of Appendix F; -> `wai_video_manifest` (feature `video_manifest`) for the playlist receipts of §8.6; -> and the `wai http` commands of the `wai` tool. Keywords **MUST**, **MUST NOT**, +> `http_binding::receipts` (with `receipt_claims`) and `http_binding::keys` (with +> `key_discovery`) for §8.1–§8.5; `wai_video_manifest` (feature `video_manifest`) for +> the playlist receipts of §8.6; and the `wai http` commands of the `wai` tool. Keywords **MUST**, **MUST NOT**, > **SHOULD**, **MAY** are RFC 2119 / RFC 8174. ## 1. Scope @@ -385,19 +386,40 @@ pin was refused may also fail its digest, which only its bytes would show. ### 8.1 Receipt files `rcpt/.json`, resolved against the object's URI, holds exactly one receipt: a group -receipt whose link hash ([jwp-receipts](jwp-receipts.md) §3 step 3) is `h`, in its -canonical form, or a claim whose `link_hash` (§2.4.3) is `h`, in its JCS form. It is -immutable and served under §5.3's cache headers with `Content-Type: application/json`. -A client refuses a receipt file whose computed hash is not its name -(`receipt_name_mismatch`). A group receipt's `parent_receipt_hash` is the name of its -parent's file, so a client resolves [jwp-receipts](jwp-receipts.md) §3 step 3 by -fetching `rcpt/.json`, and reports `parent_not_held` when it -cannot, never skipping the step. +receipt whose link hash ([jwp-receipts](jwp-receipts.md) §3 step 3) is `h`, in its JSON +form (§2.4.1 there), or a claim whose `link_hash` (§2.4.3) is `h`, each written in JCS +(RFC 8785). It is immutable and served under §5.3's cache headers with +`Content-Type: application/json`. A client reads a receipt file as +[jwp-receipts](jwp-receipts.md) §3.1 step 1 reads JSON, then as the receipt it fetched it +for — a group receipt by §3 step 1, a claim by §3.1 steps 1–2, with their reasons — and +refuses it unless its link hash, computed from what it read, is its name +(`receipt_name_mismatch`). A name at which nothing is served is `receipt_not_served`. + +A group receipt's `parent_receipt_hash` is the name of its parent's file. A client +resolves [jwp-receipts](jwp-receipts.md) §3 step 3's `parent_not_held` by fetching +`rcpt/.json`, never by skipping the step: it reads the file as a +group receipt of that name, requires it on the group's key and track (a group of another +chain is never a predecessor), and offers it to its chains before the group; while that +parent is itself `parent_not_held`, it fetches the parent's parent the same way. Once one +is admitted, the parents fetched after it are offered again, nearest it first, then the +group. A client fetches at most 64 parents for one group, and MAY hold a lower bound. A +parent that is not served, that does not read or is refused, that is of another chain, +and a walk past the bound, each leave the group `parent_not_held`, and the client says +which. A client that declares it joins (jwp-receipts §3 step 3, *Joining*) anchors a +chain it holds nothing of at the first group instead, walks nothing, and reports the +chain anchored. A claim's `parent_receipt_hash` names its parent's file the same way: +for §3.1 step 5 the client fetches it, reads it as a claim of that name and verifies it +under §3.1 steps 1–4, and a parent not served, or refused, leaves the claim +`parent_not_held`. ### 8.2 Receipt index `.wai.receipts` is the sidecar carriage of the `WAI_RECEIPT` property -([moq-streaming-format](moq-streaming-format.md) §5): +([moq-streaming-format](moq-streaming-format.md) §5), and the place a client finds an +object's receipts by their hashes: each delivery item's group receipt by the property's +`group_receipt`, each claim about the object by its link hash — a `stage` claim about a +stage set's base ([jwp-receipts](jwp-receipts.md) §2.4.10) among them — and each of their +parents by the link its child names (§8.1). ```json { "kind": "wai-receipt-index", "object": "", @@ -406,25 +428,55 @@ cannot, never skipping the step. ``` It is mutable and is served with `Cache-Control: no-cache, no-transform` and an `ETag` -of its own. It is a locator, never evidence. A client checks each item on its own and -ignores, item by item, one that fails: +of its own. It is a locator, never evidence. A client reads it as jwp-receipts §3.1 step +1 reads JSON, with exactly the members above, `kind` that string, `object` 64 lowercase +hexadecimal digits and the other two arrays, and refuses it whole otherwise +(`receipt_index_malformed`), or when `object` is not the cid +(`receipt_not_about_object`). Each item it lists is read and checked on its own, and one +that fails is ignored, with its reason, item by item: a delivery item is an object with +exactly `property` and `object_receipt` (`receipt_index_malformed`), whose property reads +as moq-streaming-format §5's (`property_invalid`) and whose object receipt reads as +jwp-receipts §2.4.2's; a claim entry is a link hash (`receipt_index_malformed`). -- a delivery item: the property's `content_hash` and the object receipt's - `content_hash` equal the cid (`receipt_not_about_object`); the group receipt is - fetched as `rcpt/.json` and verified under - [jwp-receipts](jwp-receipts.md) §3 steps 1–5, step 4 as §8.3 states; the property - is checked against the object receipt as moq-streaming-format §5 states; -- a claim: verified under [jwp-receipts](jwp-receipts.md) §3.1; its `subject` is the - cid or the link hash of a group listed in the index. A soft-binding claim - ([jwp-receipts](jwp-receipts.md) §2.6.2) is reported under the ancestor rule (§3.2): - as attesting the ancestor, never the object in hand. +A delivery item is checked in this order: + +1. the property's `content_hash` and the object receipt's are the cid + (`receipt_not_about_object`); +2. the group receipt is fetched as `rcpt/.json` and read as + §8.1 states; +3. [jwp-receipts](jwp-receipts.md) §3 steps 1–3, against the chains the client holds, + missing parents recovered as §8.1 states; +4. §3 steps 4 and 5 for the object receipt, step 4 against what the client holds of the + object (§8.3): the object is then `delivered-verified`; or, for an object the client + did not receive, step 4 not run: it is `inclusion-verified`, never delivered; +5. the property: for a delivered object, against its object receipt as + moq-streaming-format §5 states (`property_invalid`, `property_disagrees`), which + gives the property's reading; for one not delivered, alone, giving no reading. + +A claim is fetched as `rcpt/.json` and read as §8.1 states, then checked: its +`subject` is the cid, or, for a claim about a receipt, the link hash of a group one of the +index's delivery items names; a soft-binding claim ([jwp-receipts](jwp-receipts.md) +§2.6.2) whose subject is another object is reported under the ancestor rule (§3.2 there): +as attesting that object, the ancestor, never the object in hand; any other claim is +`receipt_not_about_object`. Then §3.1 steps 1–4, and step 5 with its parent fetched as +§8.1 states. + +A client MAY confirm the signer of each item it accepts under +[jwp-receipts](jwp-receipts.md) §7.4, against key logs it holds as §8.4 states, and +reports the confirmation beside the item's verdict, never folded into it. ### 8.3 A range-fetched object A client that holds only the head and some components cannot recompute the content hash over the envelope. Under [jwp-receipts](jwp-receipts.md) §3 step 4 it takes the cid its head proof verified, and the receipt then attests the object the cid names; the -components it holds are attested through the verified head's `sha256` values. +components it holds are attested through the verified head's `sha256` values. The +content hash step 4 compares with comes only from bytes the client hashed, or from a head +proof it verified with every component it uses checked against its entry's digest (§7): +never from a URI, an index, a playlist or a receipt, which only state one. A fetch that +was refused establishes nothing, and an object the client did not receive — a +refinement it abandoned (§10.5), or one it never requested — has its receipts checked +by inclusion alone. A component's exploded envelope ([SPEC](../SPEC.md) §7.1, *Exploding a component*) is a function of the verified head and the component's bytes, so a client that holds them @@ -441,20 +493,38 @@ only on a receipt checked over its envelope bytes. | key status (§7.9 there) | `/.well-known/wai-key-status` | `max-age` at most a quarter of the head's `status_max_age_s`, `no-transform` | | module grants (§7.9 there) | `/.well-known/wai-grants/.json`: `{"kind":"wai-module-grants","module":"","grants":[…]}` | `max-age=300, no-transform` | -All are `application/json`. A verifier that **anchors** an origin fetches its key log -from the origin over HTTPS without following a redirect, as [jwp-receipts](jwp-receipts.md) -§7.3 states, and with `Cache-Control: no-cache`. Later revisions MAY come through any -cache: each is checked against its parent, and a rolled-back copy is reported -(`key_log_rolled_back`). The grants file is a locator: each grant is checked on its -own. +All are `application/json`. A verifier **anchors** an origin ([jwp-receipts](jwp-receipts.md) +§7.3) only from a key log retrieved from the origin's key-log location — an `https` +origin followed by `/.well-known/wai-keys` — answered `200` by that same URL: it does +not follow a redirect, asks with `Cache-Control: no-cache`, and a log that reached it any +other way anchors nothing (`key_log_not_from_origin`). Later revisions MAY come through +any cache, a mirror, or the origin: such a log is read as §7.3 reads one, its revisions +are held and each is checked against its parent by §7.4's head walk, and it never moves +the anchor. A copy whose latest revision is a trusted revision older than the anchor is +reported (`key_log_rolled_back`) and adds nothing. + +The grants file is read as jwp-receipts §3.1 step 1 reads JSON, with exactly the members +above, `module` the digest it is fetched for, or not at all (`grants_file_malformed`). It +is a locator: each grant in it goes to §7.9's checks on its own, and one that fails them +is passed over. + +A module grant is §7.9's form. WAI carries a JCP grant only as a reference by its content +hash inside the receipt that names it, as a playlist receipt carries its publisher's +grant (§8.6); this binding serves no JCP grant, and a client checks such a reference only +as the receipt carrying it binds it. ### 8.5 Parameter sets -A parameter set MAY be served as an immutable resource named by its pin's digest -(`/`), under §5.3's cache headers with `application/octet-stream`. A -sink verifies it by its pin ([SPEC](../SPEC.md) §3.1) and decodes against it only if -the checks of [jwp-receipts](jwp-receipts.md) §7.9 pass. A set they refuse is not -held, and dispatch continues as [SPEC](../SPEC.md) §4 and §7.1 state. +A parameter set MAY be served as an immutable resource named by the digest its pin +states (`/`), under §5.3's cache headers with `application/octet-stream`; +a set in the form `wai.prior.set` is served as its listing under that digest and each +file the listing names under its own digest. The name locates the bytes and proves +nothing. A sink holds a fetched set only as [SPEC](../SPEC.md) §3.1's checks hold it over +the bytes it received, the digest among them, check 5 being +[jwp-receipts](jwp-receipts.md) §7.9 with the statuses and grants it fetched as §8.4 +states. Bytes served under a set's name that are not the set are not held +(`prior_not_held`), a set §7.9 refuses is not held (`prior_revoked`, +`prior_not_authorised`), and dispatch continues as [SPEC](../SPEC.md) §4 and §7.1 state. ### 8.6 Playlist receipts @@ -890,6 +960,8 @@ is a share of sessions, not of devices or people. | `component_digest_mismatch` | §7 step 6 ([SPEC](../SPEC.md) §7.1's reason) | | `object_digest_mismatch` | §7 step 7, or any whole fetch | | `receipt_name_mismatch`, `receipt_not_about_object` | §8.1, §8.2, §8.6 | +| `receipt_not_served`, `receipt_index_malformed` | §8.1, §8.2 | +| `key_log_not_from_origin`, `grants_file_malformed` | §8.4 | | `codecs_malformed`, `codecs_not_media`, `codecs_candidate`, `codecs_no_common_capability`, `codecs_undeclared`, `codecs_layer_unbound` | §4 | | `base_unanswered` | §10.1 | | `not_a_layer`, `layer_tag_malformed` | §10.4 | @@ -920,7 +992,16 @@ step 5 states. skipped or fetched again), a published classical master and a playable classical presentation for players, and the policies' choices; the differential adds playlists, adjacent pairs, published masters, renderings, receipt - files, adaptation states and MPD pairs. + files, adaptation states and MPD pairs. For §8.1–§8.5 it holds group receipts and + claims served as receipt files, an index beside each object that has receipts, an + origin's key log, key status and grants file, and a parameter set served by its + digest, with cases for receipt files read under their names, indexes checked against + the whole object, a head proof's cid and nothing, parents walked and not recovered, + claims by subject and parent, anchoring from the origin only, logs through caches, + grants files and sets held by their pins and §7.9; the differential adds receipt files, + indexes, grants files and key logs each changed one way. `http_verify.py` checks these + cases with the `cryptography` package, through the corpora's own second verifiers of + [jwp-receipts](jwp-receipts.md) §3, §3.1 and §7. - [`cache-conformance/`](../cache-conformance/) checks §9 against HTTP caches and reports, per cache label and version, the configuration that meets it. Products are not named in published reports. @@ -942,7 +1023,9 @@ step 5 states. of its capabilities, to every cache and the origin. A sink that does not want this fetches whole objects. - **Unsigned files.** Receipt indexes, grants files and aliases are locators, never - evidence. + evidence. A receipt file is named by its link hash and a parameter set by its digest, + so a cache or mirror that serves other bytes under the name is refused; a key log + anchors only from its origin (§8.4). ## Appendix A — CDNI configuration diff --git a/wai/extensions/jwp-receipts.md b/wai/extensions/jwp-receipts.md index b2fcc8f15..dde3ce060 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -1463,6 +1463,8 @@ match the algorithm style of WAI SPEC.md §4 (numbered, terminating). `parent_not_held` is recoverable: the verifier MAY request the missing receipt and offer it before the group again; it MUST NOT silently accept. + Over HTTP the missing receipt is fetched by the link hash the group names + ([http-delivery](http-delivery.md) §8.1). A verifier that keeps only the head cannot tell a fork from a gap and reports both `parent_not_held`; one that keeps the link hashes of earlier groups of the chain tells them apart, and refuses both either diff --git a/wai/http-conformance/README.md b/wai/http-conformance/README.md index 31a54dda6..ad0597e5a 100644 --- a/wai/http-conformance/README.md +++ b/wai/http-conformance/README.md @@ -6,15 +6,24 @@ for §4–§7 — the head, the head proof, the response checks, the plans, sele head and on the whole envelope, the byte-range procedure with its fallback, the `codecs` grammar, union, floor and support; and for §8.6, §10.4, §11 and Appendix F the WAI playlists beside their classical ones, their receipts, the live edge, and the -reference adaptation policies. Two implementations check every case and must agree: +reference adaptation policies; and for §8.1–§8.5 and §10.5 the receipts, keys, grants and +parameter sets served beside the objects. Two implementations check every case and must +agree: - `wai_http_vectors verify .` (also `wai http vectors verify .`), in `wai-rs`, built - with the features `http` and `video_manifest` (the playlist receipts); + with the features `http`, `video_manifest` (the playlist receipts) and + `key_discovery` (the receipts, keys, grants and sets); - `python3 http_verify.py .`, written from the extension text and SPEC §7.1, sharing no - code with `wai-rs`. It needs only the Python standard library (its Ed25519 check of the - playlist receipts is written from RFC 8032), and reads - `../claim-conformance/_blake3.py` (BLAKE3 from its specification) and - `../registry/capabilities.json` (the published registry export). + code with `wai-rs`. It reads `../claim-conformance/_blake3.py` (BLAKE3 from its + specification) and `../registry/capabilities.json` (the published registry export), + and needs only the Python standard library for every case but the receipt and key + cases (its Ed25519 check of the playlist receipts is written from RFC 8032). Those + (`receipt_file`, `receipts`, `key_log`, `grants_file`, `param_set`) are checked by + `http_receipts.py`, which takes what jwp-receipts defines from the corpora's own + second verifiers — `../jwp-conformance/jwp_verify.py`, + `../claim-conformance/claim_verify.py` and `../signer-conformance/signer_verify.py`, + none of which shares code with `wai-rs` — and so needs the `cryptography` package + they use. Without it those cases fail; they are never skipped. ## Files @@ -41,6 +50,12 @@ reference adaptation policies. Two implementations check every case and must agr | `cases/other-key/` | the third live revision's receipt chained to the second by hash and signed by another key (`r2.json`, and in `mr/` under its receipt hash), and the fourth revision's receipt chained to it (`r3.json`) | | `cases/relist/` | the third live revision listing the alias of part 0 of segment 1201 with part 1's object (`wai-r2.m3u8`), sealed and chained to the second (`r2.json`, and in `mr/`); the fourth revision's receipt chained to it (`r3-after.json`); the fourth revision listing segment 1201's object at sequence 1200 (`wai-r3.m3u8`, `r3.json`); the third revision listing that alias as `./720/1201.0.wai`, with part 1's object and with its own (`wai-r2-dot*.m3u8`, `r2-dot*.json`); and the fourth revision withdrawing segment 1200 as a gap (`wai-r3-gap.m3u8`, `r3-gap.json`) | | `cases/walk-substituted/`, `cases/walk-invalid/` | parents a walk fetches by their links: the second revision's receipt served under the third's receipt hash; and the third revision changed after it was signed, served under its receipt hash, with the fourth revision's receipt chained to it (`r3.json`) | +| `objects/.wai.receipts` | the receipt index of each object that has receipts (§8.2): O9a–O9c (one group per segment, chained), O3 and O7 (one group, figures of a stated class), O10, O10L1 and O10L2 (one group for the stage set) | +| `objects/rcpt/.json` | each group receipt and claim (§8.1), in JCS: the five groups; a sink's two `stage` claims about O10, chained, the second listing layer 1 applied and layer 2 dropped (§10.5); the publisher's soft binding of O7, which O3's index lists as its ancestor's; and a `log-inclusion` claim about O3's group, chained to it. The publisher signs as `did:web:origin.example#d1` (seed 32 bytes of `0x61`), the sink as `did:example:http-sink` (`0x64`): published test keys, attesting nothing | +| `.well-known/wai-keys`, `.well-known/wai-key-status`, `.well-known/wai-grants/.json` | the key log of `https://origin.example` (two revisions, root `0x60`), a status for its head frozen at `2026-10-06T00:00:00Z`, and the grants file for O2's pinned set: a grant by `g1` (`0x63`) and one by a key the log does not list (§8.4) | +| `sets/` | O2's pinned parameter set, served by its digest (§8.5) | +| `cases/keys/` | the log's first revision alone (an older copy), and a log whose second revision revokes the set's digest | +| `cases/rcpt/`, `cases/receipts/` | O9a's group with its signature changed, served under the link hash of what it then is, and an index naming it | | `cases/sig-*.json` | the O9 playlist's receipt re-signed at the edges of Ed25519 (Appendix H): the identity key, canonical and respelled `y` = p + 1, under `(R, S) = (B, 1)`; under the key B (scalar 1), `R` the identity, canonical and respelled, with `S` = k, so the cofactorless equation holds point for point; and under the key B a signature the strict rule accepts (`sig-key-base-point.json`) | ## Objects @@ -110,6 +125,11 @@ outside this repository, and the AV1 streams were checked to decode. | `playlist_receipt` | a `playlist`, its `receipt` file and the `name` it is fetched as (the file's own by default), each optionally edited | the receipt hash and its parent, or `error` | | `live` | `revisions` in the order a client fetched them (each a WAI playlist, its receipt, its classical playlist, `edit_wai`), `fetches` (an `alias` and the `object` served under it, optionally patched), the playlist's `url` (against which its lines resolve; `https://origin.example/live/wai-720.m3u8` by default), where parents are fetched from (`parents`, a directory holding `.json`; the first revision's `mr/` by default) and the walk's bound (`walk_bound`, 64 by default) | per revision its adjacency, its listing against what accepted revisions bound, its receipt, what it did to the chain (`true`, `unchanged`, `reanchored`, or the reason) and the parents walked; per fetch the accepted revision that first lists the alias, the outcome and what held (`listed`, or `signed` when a receipt in the chain binds the alias's URL to that cid) | | `uri_resolve` | a `base` and a `reference` | the `url` (RFC 3986 §5.2 and §6.2.2, without the fragment) | +| `receipt_file` | a `file` read as a receipt of `kind` (`group` or `claim`) under its `name` (its own by default), optionally edited | its `link`, or `error` with the jwp-receipts `step` | +| `receipts` | `checks` in order, each an `object` (or `object_file`), what the client holds of it (`held`: `none`; `whole`, its bytes or `held_object`'s; or `{"fetch": {…}}`, a fetch as the `fetch` op runs one), its `index` (beside it by default) optionally edited; one set of chains (`join`), a parent bound (`max_walk`, 64 by default), the receipt files `serve`d (`rcpt` directories searched, `omit`ted hashes, hashes served `as` another file), and `keys` (key-log steps, as `key_log` runs them) | per check the index's verdict and, when it reads, each delivery item's admission, parents `walked`, `evidence`, how the hash was `established` and the property's `reading`, or its `error` (with where a walk stopped); each claim's `class`, what it `attests`, its `subject` and `parent`; with `keys`, each signer's confirmation; the fetch's verdict | +| `key_log` | `steps`: `anchor` (a log retrieved as `retrieval` states: `requested`, `answered`, `status`), `pin`, or `cached` (a log through a cache), each for an `origin` (the corpus's by default) and optionally edited | what each step did (`anchored`, `added`) or its `error` | +| `grants_file` | a grants `file` for a `module` (the digest in its name by default), optionally edited | each grant's reading by §7.9's check 1, or `error` | +| `param_set` | a set `served` for a `capability` under a pin of `sha256`, the sink's `keys`, `statuses` and `grants` file, the origins it holds the capability's sets to §7.9 for (`hold_to`; absent, not held to it), and `now` (null: no trusted clock) | `held`, or `error` with §7.9's `check` and `reason`, or the origin a revocation is by | | `abr` | a `policy` and a `state` (Appendix F) | the `choice` and what the `budget` did, or `error` beyond Appendix F's bounds | | `abr_order` | an object, what the sink `advertises`, a `policy`, a `state` | the entry `order` | | `depth` | layer `objects` (or `layers_n`), a `policy`, a `state` | the `depth` and what the `budget` did, or `error` | @@ -160,7 +180,11 @@ fails the self-test; `base-at-done`, `tick-noop` and `refinement-unclipped` fail `timed_*` cases; `pdt-ignored`, `dseq-ignored`, `parts-after-extinf`, `average-floor`, `render-digest-off`, `name-unchecked`, `chain-unchecked`, `ilog2-no-interp`, `bola-waits`, `unlabelled-as-zero` and `round-half-down` fail the playlist, receipt and -adaptation cases they reach), and the job checks a differential: `wai_http_vectors diff` +adaptation cases they reach; `rcpt-name-unchecked`, `walk-skipped`, +`nothing-as-delivered`, `ancestor-as-object`, `anchor-follows-redirect`, +`cached-rollback-unchecked`, `grants-module-unchecked`, `set-grants-ignored` and +`set-by-name` fail the receipt, key, grant and set cases of §8.1–§8.5), and the job +checks a differential: `wai_http_vectors diff` writes random envelopes' covered prefixes and proofs, `http_verify.py diff` verifies every one, and, with one chaining-value byte changed in ten of them, refuses exactly those ten. @@ -202,7 +226,13 @@ inputs where two readers most easily part, in families: receipt file with one member changed; - `fuzz_abr`: states and parameters of the four policies, sizes out of order, samples of 0 ms, empty options and budgets; `fuzz_ilog2`: the integer log at random points - and the ends of `u64`. + and the ends of `u64`; +- `fuzz_receipt_file`: a committed group receipt or claim file with one member changed, + read as its kind or the other; `fuzz_receipt_index`: an index with one member changed, + checked with its object held whole or not at all; `fuzz_grants_file`: the grants file + with one member changed; `fuzz_key_log`: the key log anchored from a retrieval whose + URLs, status or origin may be off, the log sometimes changed one way. A refusal inside + an outcome of steps or checks counts as one. A refusal is an outcome like any other: `http_verify.py cases` must give the same code. A case wai-rs gives no outcome for stops the run, and `fuzz` prints, per family, diff --git a/wai/http-conformance/falsify/run.sh b/wai/http-conformance/falsify/run.sh index fd0b18100..f14edae8d 100755 --- a/wai/http-conformance/falsify/run.sh +++ b/wai/http-conformance/falsify/run.sh @@ -9,7 +9,9 @@ # # With patch names, only those patches run (and nothing else). # Run from the repository root. CARGO_TARGET_DIR, if set, is shared by the builds, which -# need the features http and video_manifest (the playlist receipts). +# need the features http, video_manifest (the playlist receipts) and key_discovery (the +# receipts, keys, grants and sets of §8.1–§8.5). The receipt and key cases of +# http_verify.py need the `cryptography` package. set -uo pipefail ROOT="$(git rev-parse --show-toplevel)" WORK="${1:-${RUNNER_TEMP:-/tmp}/wai-http-falsify}" @@ -26,7 +28,7 @@ for patch in "$ROOT"/wai/http-conformance/falsify/*.patch; do must="$(head -1 "$patch" | sed 's/^# must fail: //')" if ! git -C "$wt" apply "$patch"; then echo "::error::$name: the patch does not apply"; fail=1 - elif ! (cd "$wt/wai/wai-rs" && cargo build --release --no-default-features --features http,video_manifest --bin wai_http_vectors) > "$WORK/$name.build.log" 2>&1; then + elif ! (cd "$wt/wai/wai-rs" && cargo build --release --no-default-features --features http,video_manifest,key_discovery --bin wai_http_vectors) > "$WORK/$name.build.log" 2>&1; then echo "::error::$name: the patched tree does not build"; tail -20 "$WORK/$name.build.log"; fail=1 else bin="${CARGO_TARGET_DIR:-$wt/wai/wai-rs/target}/release/wai_http_vectors" @@ -53,7 +55,8 @@ for m in left-half no-root counter-zero floor-as-codecs blake3-iv base-at-done t unlabelled-escapes abr-unbounded gap-carries receipt-duplicates receipt-surrogates session-data-anywhere \ dash-r-expanded xml-attributes-scanned dash-timelines-unbounded budget-raises \ uri-unresolved gap-withdraws reanchor-backwards walk-exceeded-wedges walk-name-unchecked walk-verify-unchecked bindings-unbounded \ - file-numbers-unbounded; do + file-numbers-unbounded rcpt-name-unchecked walk-skipped nothing-as-delivered ancestor-as-object \ + anchor-follows-redirect cached-rollback-unchecked grants-module-unchecked set-grants-ignored set-by-name; do if python3 http_verify.py . --mutate "$m" > "$WORK/py-$m.log" 2>&1; then echo "::error::http_verify.py --mutate $m passed"; fail=1 else @@ -62,7 +65,7 @@ for m in left-half no-root counter-zero floor-as-codecs blake3-iv base-at-done t done # The differential runs the unpatched tool: build it again (the patched builds shared # its output path). -(cd "$ROOT/wai/wai-rs" && cargo build --release --no-default-features --features http,video_manifest --bin wai_http_vectors) > "$WORK/unpatched.build.log" 2>&1 || fail=1 +(cd "$ROOT/wai/wai-rs" && cargo build --release --no-default-features --features http,video_manifest,key_discovery --bin wai_http_vectors) > "$WORK/unpatched.build.log" 2>&1 || fail=1 bin="${CARGO_TARGET_DIR:-$ROOT/wai/wai-rs/target}/release/wai_http_vectors" if [ -x "$bin" ]; then "$bin" diff . --seed 7 --count 500 --out "$WORK/diff.jsonl" > /dev/null || fail=1 diff --git a/wai/http-conformance/http_verify.py b/wai/http-conformance/http_verify.py index 448eed91f..79a2248cc 100644 --- a/wai/http-conformance/http_verify.py +++ b/wai/http-conformance/http_verify.py @@ -10,7 +10,9 @@ companions and refinements, the codecs grammar, union, floor and support (§4), written here. BLAKE3 is ../claim-conformance/_blake3.py (written from its specification, shared with the other Python verifiers) and SHA-256 is hashlib. The registry's kinds, roles and classes are read from ../registry/capabilities.json, the published export. -Only the standard library is needed. +Only the standard library is needed, except for the receipt and key cases of §8.1-§8.5, +which http_receipts.py checks through the corpora's own receipt, claim and key-discovery +verifiers and so with the `cryptography` package; without it those cases fail. Pins (SPEC §3.1) are read as far as the corpus needs: a pin applies to a capability that decodes against a parameter set (registry class neural-int-exact, neural-entropy-exact, @@ -22,7 +24,10 @@ stops the verifier, never passes. python3 http_verify.py [dir] verify every case and the self-test python3 http_verify.py [dir] --mutate NAME the same with a deliberate defect: left-half | no-root | counter-zero | floor-as-codecs | blake3-iv | base-at-done | - tick-noop | refinement-unclipped + tick-noop | refinement-unclipped | … | rcpt-name-unchecked | walk-skipped | + nothing-as-delivered | ancestor-as-object | anchor-follows-redirect | + cached-rollback-unchecked | grants-module-unchecked | set-grants-ignored | + set-by-name (falsify/run.sh lists every one) python3 http_verify.py cases FILE [dir] give every case of a case file (wai_http_vectors fuzz) its stated outcome python3 http_verify.py diff FILE [--flip N --seed S] diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs index 96805467a..c37142fda 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs @@ -902,11 +902,13 @@ mod built { let loc = key_log_url(ORIGIN); let urls = [loc.as_str(), "https://origin.example/.well-known/wai-keys/", "https://ORIGIN.example/.well-known/wai-keys", "https://origin.example:443/.well-known/wai-keys", "http://origin.example/.well-known/wai-keys", "https://origin.example/.well-known/WAI-KEYS", "https://origin.example//.well-known/wai-keys"]; - let requested = if next(s) % 3 == 0 { pick(s, &urls) } else { loc.as_str() }; - let answered = if next(s) % 3 == 0 { pick(s, &urls) } else { requested }; - let status = [200u64, 200, 200, 301, 302, 304, 203][(next(s) % 7) as usize]; - let origin = if next(s) % 5 == 0 { pick(s, &["https://origin.example/", "HTTPS://origin.example", "https://origin.example:443", "http://origin.example"]) } else { ORIGIN }; - v.push((format!("fuzz_key_log_{i}"), "key_log", json!({ "steps": [{ "anchor": KEY_LOG, "origin": origin, "edit": perturb(s, <, &members), + // Each way off is rare enough that about half the cases anchor. + let requested = if next(s) % 8 == 0 { pick(s, &urls) } else { loc.as_str() }; + let answered = if next(s) % 8 == 0 { pick(s, &urls) } else { requested }; + let status = if next(s) % 8 == 0 { [301u64, 302, 304, 203][(next(s) % 4) as usize] } else { 200 }; + let origin = if next(s) % 8 == 0 { pick(s, &["https://origin.example/", "HTTPS://origin.example", "https://origin.example:443", "http://origin.example"]) } else { ORIGIN }; + let edit = if next(s) % 3 == 0 { perturb(s, <, &members) } else { json!([]) }; + v.push((format!("fuzz_key_log_{i}"), "key_log", json!({ "steps": [{ "anchor": KEY_LOG, "origin": origin, "edit": edit, "retrieval": { "requested": requested, "answered": answered, "status": status } }] }))); Ok(v) } -- 2.54.0 (Apple Git-157) From c0306b9cc3e10339f665a8653b6ebcde94760d13 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 09:56:47 -0400 Subject: [PATCH 04/16] =?UTF-8?q?http-conformance:=20falsify=20the=20recei?= =?UTF-8?q?pt=20side=20=E2=80=94=20the=20receipt=20file's=20name,=20the=20?= =?UTF-8?q?parent=20walk=20and=20its=20bound,=20an=20object=20not=20receiv?= =?UTF-8?q?ed,=20a=20refused=20fetch,=20the=20ancestor=20rule,=20a=20claim?= =?UTF-8?q?'s=20parent,=20the=20index's=20members,=20anchoring=20after=20a?= =?UTF-8?q?=20redirect,=20an=20older=20cached=20log,=20the=20grants=20file?= =?UTF-8?q?'s=20module,=20a=20set=20held=20without=20the=20key=20log,=20an?= =?UTF-8?q?d=20a=20non-string=20kind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../falsify/ancestor-as-object.patch | 15 +++++++++++++++ .../falsify/anchor-follows-redirect.patch | 15 +++++++++++++++ .../falsify/cached-rollback-unchecked.patch | 15 +++++++++++++++ .../falsify/claim-parent-unfetched.patch | 15 +++++++++++++++ .../falsify/established-on-refusal.patch | 15 +++++++++++++++ .../falsify/grants-module-unchecked.patch | 15 +++++++++++++++ .../falsify/index-member-added.patch | 15 +++++++++++++++ .../falsify/kind-type-malformed.patch | 14 ++++++++++++++ .../falsify/nothing-as-delivered.patch | 15 +++++++++++++++ .../falsify/rcpt-name-unchecked.patch | 15 +++++++++++++++ .../falsify/set-sink-dropped.patch | 15 +++++++++++++++ wai/http-conformance/falsify/walk-skipped.patch | 15 +++++++++++++++ wai/http-conformance/falsify/walk-unbounded.patch | 15 +++++++++++++++ 13 files changed, 194 insertions(+) create mode 100644 wai/http-conformance/falsify/ancestor-as-object.patch create mode 100644 wai/http-conformance/falsify/anchor-follows-redirect.patch create mode 100644 wai/http-conformance/falsify/cached-rollback-unchecked.patch create mode 100644 wai/http-conformance/falsify/claim-parent-unfetched.patch create mode 100644 wai/http-conformance/falsify/established-on-refusal.patch create mode 100644 wai/http-conformance/falsify/grants-module-unchecked.patch create mode 100644 wai/http-conformance/falsify/index-member-added.patch create mode 100644 wai/http-conformance/falsify/kind-type-malformed.patch create mode 100644 wai/http-conformance/falsify/nothing-as-delivered.patch create mode 100644 wai/http-conformance/falsify/rcpt-name-unchecked.patch create mode 100644 wai/http-conformance/falsify/set-sink-dropped.patch create mode 100644 wai/http-conformance/falsify/walk-skipped.patch create mode 100644 wai/http-conformance/falsify/walk-unbounded.patch diff --git a/wai/http-conformance/falsify/ancestor-as-object.patch b/wai/http-conformance/falsify/ancestor-as-object.patch new file mode 100644 index 000000000..2c743d951 --- /dev/null +++ b/wai/http-conformance/falsify/ancestor-as-object.patch @@ -0,0 +1,15 @@ +# must fail: receipts_claims_O3 receipts_head_proof +# a soft binding of another object reported as attesting the object in hand (receipts::check_claim) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 95b77f2f..bfb3638f 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -424,7 +424,7 @@ pub fn check_claim( + let attests = match claim.subject_type { + SubjectType::Object if claim.subject == *cid => Attests::Object, + SubjectType::Receipt if groups.contains(&claim.subject) => Attests::Receipt, +- SubjectType::Object if claim.class() == ClaimClass::SoftBinding => Attests::Ancestor, ++ SubjectType::Object if claim.class() == ClaimClass::SoftBinding => Attests::Object, + _ => return Err(ReceiptRefusal::NotAboutObject), + }; + claim.verify().map_err(ReceiptRefusal::Claim)?; diff --git a/wai/http-conformance/falsify/anchor-follows-redirect.patch b/wai/http-conformance/falsify/anchor-follows-redirect.patch new file mode 100644 index 000000000..62e4f78b5 --- /dev/null +++ b/wai/http-conformance/falsify/anchor-follows-redirect.patch @@ -0,0 +1,15 @@ +# must fail: key_log_anchor_redirect +# a key log anchored from the URL a redirect led to (keys::anchor_retrieved) +diff --git a/wai/wai-rs/src/http_binding/keys.rs b/wai/wai-rs/src/http_binding/keys.rs +index ad1572bc..042208df 100644 +--- a/wai/wai-rs/src/http_binding/keys.rs ++++ b/wai/wai-rs/src/http_binding/keys.rs +@@ -140,7 +140,7 @@ pub fn anchor_retrieved( + if retrieval.requested != location { + return Err(KeysRefusal::NotFromOrigin("the request was not for the origin's key-log location")); + } +- if retrieval.answered != location { ++ if retrieval.answered.is_empty() { + return Err(KeysRefusal::NotFromOrigin("the answer came from another URL: a redirect was followed")); + } + if retrieval.status != 200 { diff --git a/wai/http-conformance/falsify/cached-rollback-unchecked.patch b/wai/http-conformance/falsify/cached-rollback-unchecked.patch new file mode 100644 index 000000000..fe07b0e91 --- /dev/null +++ b/wai/http-conformance/falsify/cached-rollback-unchecked.patch @@ -0,0 +1,15 @@ +# must fail: key_log_cached_rolled_back +# an older copy of a key log through a cache taken without report (keys::hold_cached_revisions) +diff --git a/wai/wai-rs/src/http_binding/keys.rs b/wai/wai-rs/src/http_binding/keys.rs +index ad1572bc..5a1555c2 100644 +--- a/wai/wai-rs/src/http_binding/keys.rs ++++ b/wai/wai-rs/src/http_binding/keys.rs +@@ -179,7 +179,7 @@ pub fn hold_cached_revisions(origin: &str, body: &[u8], anchor: &Anchor, held: & + trusted.push(p); + } + let latest = revs.last().expect("a log is non-empty").revision_hash(); +- if latest != anchor.revision && trusted.contains(&latest) { ++ if latest != anchor.revision && trusted.is_empty() { + return Err(KeysRefusal::Log(Reason::KeyLogRolledBack)); + } + let mut have: Vec<[u8; 32]> = held.iter().map(Revision::revision_hash).collect(); diff --git a/wai/http-conformance/falsify/claim-parent-unfetched.patch b/wai/http-conformance/falsify/claim-parent-unfetched.patch new file mode 100644 index 000000000..68aafe8be --- /dev/null +++ b/wai/http-conformance/falsify/claim-parent-unfetched.patch @@ -0,0 +1,15 @@ +# must fail: receipts_claim_parent_not_served +# a claim's parent not served taken as linked (receipts::check_claim) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 95b77f2f..0b369af1 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -432,7 +432,7 @@ pub fn check_claim( + None => None, + Some(p) => { + let stop = |walked: usize, why: &str| ReceiptRefusal::ParentNotHeld { walked, stop: why.to_string() }; +- let pb = fetch(&p).ok_or_else(|| stop(0, "receipt_not_served"))?; ++ let Some(pb) = fetch(&p) else { return Ok(ClaimVerdict { claim, attests, parent: Some(p) }) }; + let prev = read_claim_file(&p, &pb).map_err(|r| stop(1, r.code()))?; + prev.verify().map_err(|r| stop(1, r.reason.label()))?; + claim.check_parent(Some(&prev)).map_err(ReceiptRefusal::Claim)?; diff --git a/wai/http-conformance/falsify/established-on-refusal.patch b/wai/http-conformance/falsify/established-on-refusal.patch new file mode 100644 index 000000000..5a3b61491 --- /dev/null +++ b/wai/http-conformance/falsify/established-on-refusal.patch @@ -0,0 +1,15 @@ +# must fail: receipts_fetch_refused_holds_nothing +# a fetch that was refused establishing its cid (session::FetchSession::established) +diff --git a/wai/wai-rs/src/http_binding/session.rs b/wai/wai-rs/src/http_binding/session.rs +index 2381b3fb..549fe94c 100644 +--- a/wai/wai-rs/src/http_binding/session.rs ++++ b/wai/wai-rs/src/http_binding/session.rs +@@ -383,7 +383,7 @@ impl FetchSession { + /// for a layer whose request was abandoned, of which nothing was received. Only a + /// session builds the second kind, so a cid nothing verified never stands in for one. + pub fn established(&self) -> Option { +- let f = self.done()?; ++ let Some(f) = self.done() else { return Some(crate::jwp_hash::EstablishedHash::by_head_proof(self.cid)) }; + match f.verified_by { + VerifiedBy::WholeObject => f.envelope.as_deref().map(crate::jwp_hash::EstablishedHash::of_envelope), + VerifiedBy::HeadProof => Some(crate::jwp_hash::EstablishedHash::by_head_proof(self.cid)), diff --git a/wai/http-conformance/falsify/grants-module-unchecked.patch b/wai/http-conformance/falsify/grants-module-unchecked.patch new file mode 100644 index 000000000..b04b81e98 --- /dev/null +++ b/wai/http-conformance/falsify/grants-module-unchecked.patch @@ -0,0 +1,15 @@ +# must fail: grants_file_other_module +# a grants file for another module read (keys::read_grants_file) +diff --git a/wai/wai-rs/src/http_binding/keys.rs b/wai/wai-rs/src/http_binding/keys.rs +index ad1572bc..1466070b 100644 +--- a/wai/wai-rs/src/http_binding/keys.rs ++++ b/wai/wai-rs/src/http_binding/keys.rs +@@ -213,7 +213,7 @@ pub fn read_grants_file(module: &[u8; 32], bytes: &[u8]) -> Result, + if m["kind"].as_str() != Some(GRANTS_KIND) { + return Err(bad("kind is not wai-module-grants")); + } +- if m["module"].as_str().and_then(super::unhex32) != Some(*module) { ++ if m["module"].as_str().and_then(super::unhex32).is_none() { + return Err(bad("module is not the digest the file is fetched for")); + } + let grants = m["grants"].as_array().ok_or(bad("grants is not an array"))?; diff --git a/wai/http-conformance/falsify/index-member-added.patch b/wai/http-conformance/falsify/index-member-added.patch new file mode 100644 index 000000000..441d266a7 --- /dev/null +++ b/wai/http-conformance/falsify/index-member-added.patch @@ -0,0 +1,15 @@ +# must fail: receipts_index_member_added +# an index with a member it does not define read (receipts::ReceiptIndex::from_bytes) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 95b77f2f..26bf86c7 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -189,7 +189,7 @@ impl ReceiptIndex { + let v = crate::claim_receipt::parse_strict(text).map_err(|_| bad("not JSON as a receipt is read"))?; + let m = v.as_object().ok_or(bad("not an object"))?; + const MEMBERS: [&str; 4] = ["kind", "object", "delivery", "claims"]; +- if m.len() != MEMBERS.len() || MEMBERS.iter().any(|k| !m.contains_key(*k)) { ++ if MEMBERS.iter().any(|k| !m.contains_key(*k)) { + return Err(bad("the members are not kind, object, delivery and claims")); + } + if m["kind"].as_str() != Some(INDEX_KIND) { diff --git a/wai/http-conformance/falsify/kind-type-malformed.patch b/wai/http-conformance/falsify/kind-type-malformed.patch new file mode 100644 index 000000000..f9b5ce1e9 --- /dev/null +++ b/wai/http-conformance/falsify/kind-type-malformed.patch @@ -0,0 +1,14 @@ +# must fail: receipt_file_kind_null +# a group receipt whose kind is not a string read as malformed (jwp_delivery::GroupReceipt::from_json) +diff --git a/wai/wai-rs/src/jwp_delivery.rs b/wai/wai-rs/src/jwp_delivery.rs +index 956f7b8b..db8d5373 100644 +--- a/wai/wai-rs/src/jwp_delivery.rs ++++ b/wai/wai-rs/src/jwp_delivery.rs +@@ -527,6 +527,7 @@ impl GroupReceipt { + match m.get("kind") { + None => return Err(bad), + Some(k) if k.as_str() == Some("group") => {} ++ Some(k) if !k.is_string() => return Err(bad), + Some(_) => return Err(DeliveryError::at(1, Reason::WrongKind)), + } + if GROUP_MEMBERS.iter().any(|k| !m.contains_key(*k)) diff --git a/wai/http-conformance/falsify/nothing-as-delivered.patch b/wai/http-conformance/falsify/nothing-as-delivered.patch new file mode 100644 index 000000000..08461369e --- /dev/null +++ b/wai/http-conformance/falsify/nothing-as-delivered.patch @@ -0,0 +1,15 @@ +# must fail: receipts_stage_set receipts_back_after_head +# an object not received taken as delivered on its cid (receipts::check_delivery) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 95b77f2f..7f137013 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -349,7 +349,7 @@ pub fn check_delivery( + let (admission, walked) = admit_walking(chains, &group, fetch, now_ms, max_walk)?; + let established = match held { + ObjectHeld::Object(e) => Some(*e), +- ObjectHeld::Nothing => None, ++ ObjectHeld::Nothing => Some(EstablishedHash::by_head_proof(*cid)), + }; + let evidence = chains + .verify_admitted_object(&group, &item.object_receipt, established.as_ref()) diff --git a/wai/http-conformance/falsify/rcpt-name-unchecked.patch b/wai/http-conformance/falsify/rcpt-name-unchecked.patch new file mode 100644 index 000000000..5bc42aaa6 --- /dev/null +++ b/wai/http-conformance/falsify/rcpt-name-unchecked.patch @@ -0,0 +1,15 @@ +# must fail: receipt_file_name_mismatch receipts_group_renamed +# a group receipt file read under another name (receipts::read_group_file) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 95b77f2f..cbbb3c83 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -127,7 +127,7 @@ pub fn read_group_file(name: &[u8; 32], bytes: &[u8]) -> Result return Ok((a, 0)), + Err(e) if e.reason != Reason::ParentNotHeld => return Err(ReceiptRefusal::Delivery(e)), +- Err(_) => {} ++ Err(_) => return Ok((Admission::Linked, 0)), + } + let stop = |walked: usize, why: &str| ReceiptRefusal::ParentNotHeld { walked, stop: why.to_string() }; + let mut pending: Vec = Vec::new(); diff --git a/wai/http-conformance/falsify/walk-unbounded.patch b/wai/http-conformance/falsify/walk-unbounded.patch new file mode 100644 index 000000000..1d39b5809 --- /dev/null +++ b/wai/http-conformance/falsify/walk-unbounded.patch @@ -0,0 +1,15 @@ +# must fail: receipts_walk_bound +# the parent walk past its bound (receipts::admit_walking) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 95b77f2f..0737ee0a 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -292,7 +292,7 @@ fn admit_walking( + loop { + // §3 step 3 says `parent_not_held` only of a group that names a parent. + let Some(l) = link else { return Err(stop(fetched, "parent_not_held")) }; +- if fetched == max_walk { ++ if fetched == usize::MAX - max_walk { + return Err(stop(fetched, "bound")); + } + let bytes = fetch(&l).ok_or_else(|| stop(fetched, "receipt_not_served"))?; -- 2.54.0 (Apple Git-157) From b3d7dfc044befaef9a1ba2f9f47c9ca46518ffbf Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 14:31:56 -0400 Subject: [PATCH 05/16] http-delivery: rewrap the status block Co-Authored-By: Claude Opus 5.5 --- wai/extensions/http-delivery.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wai/extensions/http-delivery.md b/wai/extensions/http-delivery.md index 1c1669290..f03abafe3 100644 --- a/wai/extensions/http-delivery.md +++ b/wai/extensions/http-delivery.md @@ -9,8 +9,8 @@ > and §9, the playlists of §10.4 and §11 and the adaptation policies of Appendix F; > `http_binding::receipts` (with `receipt_claims`) and `http_binding::keys` (with > `key_discovery`) for §8.1–§8.5; `wai_video_manifest` (feature `video_manifest`) for -> the playlist receipts of §8.6; and the `wai http` commands of the `wai` tool. Keywords **MUST**, **MUST NOT**, -> **SHOULD**, **MAY** are RFC 2119 / RFC 8174. +> the playlist receipts of §8.6; and the `wai http` commands of the `wai` tool. +> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119 / RFC 8174. ## 1. Scope -- 2.54.0 (Apple Git-157) From d29cd0731d04371952ed290c1de0c98efed0047b Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 22:13:53 -0400 Subject: [PATCH 06/16] http receipts: step 6, items that passed, recovery, bounds, legacy groups, anchoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 fixes to http-delivery §8.1-§8.4, in the text, wai-rs, http_receipts.py and the corpus together. 1. Claims run jwp-receipts §3.1 step 6 against what the passed delivery items deliver: energy against their object receipts (a named OER receipt is context_not_held); measurement/operating-point against the moq-jwp:object figure of an item delivered under the claimed group (energy_figure::HeldObject), else figure_not_held / context_not_held; delivery-gap against an item at its coordinates; decisions and recovery records against the declaration fetched by link; reconstruct-session context_not_held. Never acceptance without context. 2. A claim about a receipt attaches only through the group of an item whose delivery check passed. 3. A soft binding of another object is reported as attesting the ancestor only after §3.2 steps 2-5 pass (held object, a publisher key from a verified delivery, a matcher); otherwise receipt_not_about_object. 4. Bounds: receipt files 1 MiB (receipt_too_large), indexes 4 MiB with 256 items and 256 claims (receipt_index_too_large), each name fetched once per index (memo), 1024 fetches per index (receipt_fetch_budget_exhausted); the CLI fetches under a deadline (--deadline-ms) and these caps, never following a redirect. 5. A legacy (unlabelled) group file is refused (receipt_legacy_group): its name does not fix its parent. 6. The parent walk is clamped to 64 whatever bound is asked. 7. wai http receipts --key-origin retrieves the key log itself from --connect, sending anchoring_request_headers, following no redirect, and builds the Retrieval from the response. Corpus: measurement/energy/gap claims and O5's index, legacy group, a 66-deep chain, padded files and indexes, 256/257 items and claims, a fifty-fold item list; 1015 cases, Rust and Python agree. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 41 +- wai/extensions/http-delivery.md | 101 +++-- wai/http-conformance/README.md | 18 +- wai/http-conformance/cases.json | 73 ++- ...7f862368967295eaad10087ee86cd356a65dc.json | 1 + ...eceb2fc39b70d6f19ce28f029f0aa25489eac.json | 1 + ...6073b5e043ce831e96f8c411121fde6bdfa24.json | 1 + ...0c927d3d526151006f5d01dcd0a50d8fe5cc0.json | 1 + ...0b9583d3a3328544f90d1f6ec337e833da309.json | 1 + ...b40c94f9e8fc49c65d76e8fddd9dc4e879f4e.json | 1 + ...6afb81621f6a9226825ef597c860f306ff755.json | 1 + ...8d252c15d8fbba68d6bd6beb7c5db2bb8b284.json | 1 + ...66e33c4c94b20baefdee5924100ac13ad2631.json | 1 + ...1c42f9c67a3dcda5fee8b7ab924c14d05d02c.json | 1 + ...d2cb47d1d9a9846fc9f032311c4b156b3170a.json | 1 + ...6698d36124879649ebdef8104c66398a37580.json | 1 + ...457fdface7e18022d071f2332adf1dc42f60a.json | 1 + ...74b6c81098add4784395a59327037c411aa05.json | 1 + ...558478a3302b02c06c3192986bbcb2d5d27b1.json | 1 + ...593a38e586e45cf8d2d7c1c575e64d3322893.json | 1 + ...47cfba2ed713171f434a76a8f90c55facae9a.json | 1 + ...92feb3c2cce92e70eeaebea7ce2bd75c5e581.json | 1 + ...cf2f0b9f1bb4724dec179d2457576e6de493b.json | 1 + ...53e80223fa9f89c642fbc14bb736a4fcc0c19.json | 1 + ...00beb3ab39a637d985efa02f51f9ad9a788b1.json | 1 + ...ba447b2e6a5c6b6f7a7c355aeee5131614845.json | 1 + ...81f585fad641c0881076dae2384c0dab77350.json | 1 + ...89420bcc4855b7a9fbfa9c0c2fcfd4c8f6e02.json | 1 + ...524df96612c2b8572fb36187b36aaef695d19.json | 1 + ...5e2af6d8522ec13b20b3cb80579065cf3cb8f.json | 1 + ...36c62b9cb980de376565d29be1a36290673db.json | 1 + ...a526c4cfcf72207a793b8a75b457b0004cbb7.json | 1 + ...33b42d7455a91a2941aab05c31a54a0ddf30a.json | 1 + ...ad8f9080ae1faa6089a2632a9ce028562740a.json | 1 + ...227155c737d80c3119d6ef49ee37ac384f0f4.json | 1 + ...bf0511b48b25ad11139781874ca585dad39db.json | 1 + ...2a7d1265f5de70c40ecdbd514fc2412a9581d.json | 1 + ...0ba23863c7cc88b654d178acf61361fc81efa.json | 1 + ...2cf5875138daa33def243f8e7958095159d43.json | 1 + ...6f0bd53630f0cff4e303b0e2b5fa7032ab7b8.json | 1 + ...612128c54efa856c8d8feab4c2d768559a153.json | 1 + ...413044a37fdc1a05cb72c58d0ec16770bcfdf.json | 1 + ...4c02dd5862b28855783f836891ec26e911616.json | 1 + ...117a451713ae32d05176e8aae7d3cb928da3c.json | 1 + ...1db5b60c2e6aac29776766f60df21debaa7a6.json | 1 + ...74ea9520ec024639f33f9fcc8545d0535ea64.json | 1 + ...68bac928bd9e90969a20221cd7326e6a0cd1e.json | 1 + ...05aad51f5bcf1ad926f30e17475143abdaef7.json | 1 + ...09dbf27d11f1c7871850d11f37afd637b0d8a.json | 1 + ...a6cb91925f53377612947760bcabee6325e46.json | 1 + ...243eb58334442ebbc0f5170d219fc833c1b56.json | 1 + ...ee4d99b4dc4fe5aa6d43992a8c8d5be5bcbe9.json | 1 + ...45c607168f5f46caab52de55ade65a7dca6db.json | 1 + ...eeb0ae9260679c0ef38ee0c2db625d4690f08.json | 1 + ...f37a0e9e3cf6eb9f23dace8cadbcc68a1ce6e.json | 1 + ...a4a3bc50af0b97a75f9fc6fa213f438839b59.json | 1 + ...9d8319dfb7048ca2d965f1d34e5724e33d802.json | 1 + ...9355b8bc12aa8c1b8a0abfa2736dc8e52beca.json | 1 + ...b9f93f2dfef67c08fa99138c2bbcbfc5fb387.json | 1 + ...8a8dea0f39e2c1ffe5fe5fd18cbfbd38fcf13.json | 1 + ...3598bd49a5408497bea9048e2dc1519655d19.json | 1 + ...1c9f5885e366c534ec7cda62e6bbca91c1e6c.json | 1 + ...8464a9c30a11436d3efd3a8aff1e9cc065d3f.json | 1 + ...6dde6454c304a2b95c1150d244ab8d9868480.json | 1 + ...c80946a313a0c46a0d624c5083cfc4f9ab61b.json | 1 + ...8ff6a68eed7386ead966dd4c5f11baf14d41b.json | 1 + ...68eed633ebf2a1bfcee7f1fe8bc91d57b4b62.json | 1 + ...1f2392419aad7d8b53be5e4e8432d7100c7fe.json | 1 + ...06fd179f9fdd04b252535d937c6537004735c.json | 1 + ...7124039209dc2b8dfbb2efd9ee7dcbafc955a.json | 1 + ...5f3decfc703c081c832fb35e3e42f3d1c7043.json | 1 + .../cases/receipts/O1-legacy.wai.receipts | 1 + .../cases/receipts/O9a-256-items.wai.receipts | 1 + .../receipts/O9a-257-claims.wai.receipts | 1 + .../cases/receipts/O9a-257-items.wai.receipts | 1 + .../cases/receipts/O9c-x50.wai.receipts | 1 + .../cases/receipts/deep-64.wai.receipts | 1 + .../cases/receipts/deep-65.wai.receipts | 1 + wai/http-conformance/http_receipts.py | 218 +++++++-- ...5ee1e72bafe4892992fc6bef88be9.wai.receipts | 2 +- ...18c0a4c17490499fbb31aefd5cd0c.wai.receipts | 2 +- ...c3a946a3c7980431bc6e0a236093b.wai.receipts | 1 + ...022c5f130d46a45f27aedbf0377b62a0675c0.json | 1 + ...5870241b439eec6341f7b5dafa55eb5c5e936.json | 1 + ...c6add781cf5733e6d639d9a783ca0c0ddd61c.json | 1 + ...33c3aec1e0187ebd6f8374333f22373d9fe11.json | 1 + ...1352fd43f4a348f9d56f90e32e922c2c2b16f.json | 1 + ...7c8f1e6fd993ba6434f112392f11b482464f0.json | 1 + wai/wai-rs/src/bin/wai_cli/http.rs | 188 ++++++-- .../src/bin/wai_cli/http_vectors_receipts.rs | 259 ++++++++++- wai/wai-rs/src/energy_figure.rs | 28 ++ wai/wai-rs/src/http_binding/receipts.rs | 429 +++++++++++++++--- wai/wai-rs/src/http_binding/tests_receipts.rs | 303 ++++++++++++- wai/wai-rs/tests/cli_http.rs | 106 ++++- 94 files changed, 1584 insertions(+), 265 deletions(-) create mode 100644 wai/http-conformance/cases/rcpt-deep/003ce009852d8c0c3e808699db87f862368967295eaad10087ee86cd356a65dc.json create mode 100644 wai/http-conformance/cases/rcpt-deep/009454fd76395b07a8db1c205e1eceb2fc39b70d6f19ce28f029f0aa25489eac.json create mode 100644 wai/http-conformance/cases/rcpt-deep/01a07d8b4af7e85c22c5ecbba976073b5e043ce831e96f8c411121fde6bdfa24.json create mode 100644 wai/http-conformance/cases/rcpt-deep/17ef1654e9710960f846a1453e40c927d3d526151006f5d01dcd0a50d8fe5cc0.json create mode 100644 wai/http-conformance/cases/rcpt-deep/186babdb6cd3cfbc6d5ea3420bd0b9583d3a3328544f90d1f6ec337e833da309.json create mode 100644 wai/http-conformance/cases/rcpt-deep/1a0a5b813a9829960fc55d40e8db40c94f9e8fc49c65d76e8fddd9dc4e879f4e.json create mode 100644 wai/http-conformance/cases/rcpt-deep/21dcc9e852e59acff6ae0749f4a6afb81621f6a9226825ef597c860f306ff755.json create mode 100644 wai/http-conformance/cases/rcpt-deep/25632b6926e484b967f63df5a868d252c15d8fbba68d6bd6beb7c5db2bb8b284.json create mode 100644 wai/http-conformance/cases/rcpt-deep/258947cefedab4a8c44cf3d02bc66e33c4c94b20baefdee5924100ac13ad2631.json create mode 100644 wai/http-conformance/cases/rcpt-deep/296b25a0475ca8ba860b3db33ec1c42f9c67a3dcda5fee8b7ab924c14d05d02c.json create mode 100644 wai/http-conformance/cases/rcpt-deep/2d31d0bd821ee2578210482b192d2cb47d1d9a9846fc9f032311c4b156b3170a.json create mode 100644 wai/http-conformance/cases/rcpt-deep/316ef8d8a425cee1a88181a83f46698d36124879649ebdef8104c66398a37580.json create mode 100644 wai/http-conformance/cases/rcpt-deep/319e3bd23ecc66f714d2226d160457fdface7e18022d071f2332adf1dc42f60a.json create mode 100644 wai/http-conformance/cases/rcpt-deep/36f03021b405cf486db202f776174b6c81098add4784395a59327037c411aa05.json create mode 100644 wai/http-conformance/cases/rcpt-deep/3aa91d8e5f296a997f8473822a5558478a3302b02c06c3192986bbcb2d5d27b1.json create mode 100644 wai/http-conformance/cases/rcpt-deep/3c8ead78e4ccaaa1b49790f87ea593a38e586e45cf8d2d7c1c575e64d3322893.json create mode 100644 wai/http-conformance/cases/rcpt-deep/3d8bcf2693699485bf4560e6db247cfba2ed713171f434a76a8f90c55facae9a.json create mode 100644 wai/http-conformance/cases/rcpt-deep/45f8fa1e36cb9988a8ffecc639e92feb3c2cce92e70eeaebea7ce2bd75c5e581.json create mode 100644 wai/http-conformance/cases/rcpt-deep/4a929990963ba614aaac8bb2896cf2f0b9f1bb4724dec179d2457576e6de493b.json create mode 100644 wai/http-conformance/cases/rcpt-deep/4fddb12f5ebd443ad749945c70453e80223fa9f89c642fbc14bb736a4fcc0c19.json create mode 100644 wai/http-conformance/cases/rcpt-deep/5cf23a6733332727a616961d36300beb3ab39a637d985efa02f51f9ad9a788b1.json create mode 100644 wai/http-conformance/cases/rcpt-deep/628e127eff3ff5f3fc41008cd2aba447b2e6a5c6b6f7a7c355aeee5131614845.json create mode 100644 wai/http-conformance/cases/rcpt-deep/63379f1324fcb7c7d65bec7ea0d81f585fad641c0881076dae2384c0dab77350.json create mode 100644 wai/http-conformance/cases/rcpt-deep/64100b9ff95fe8517af6d53ffae89420bcc4855b7a9fbfa9c0c2fcfd4c8f6e02.json create mode 100644 wai/http-conformance/cases/rcpt-deep/6fe102acdfb722a4ac1e9708bd2524df96612c2b8572fb36187b36aaef695d19.json create mode 100644 wai/http-conformance/cases/rcpt-deep/702d3f032ec6e859379cc73da1b5e2af6d8522ec13b20b3cb80579065cf3cb8f.json create mode 100644 wai/http-conformance/cases/rcpt-deep/7209b5f3d838d80969f2bedb44b36c62b9cb980de376565d29be1a36290673db.json create mode 100644 wai/http-conformance/cases/rcpt-deep/727ec6e7702c42e445c28f7db5ca526c4cfcf72207a793b8a75b457b0004cbb7.json create mode 100644 wai/http-conformance/cases/rcpt-deep/73c6117b02857292a6b459ed1f433b42d7455a91a2941aab05c31a54a0ddf30a.json create mode 100644 wai/http-conformance/cases/rcpt-deep/82a9fe0631aaaa7a86edc18e09dad8f9080ae1faa6089a2632a9ce028562740a.json create mode 100644 wai/http-conformance/cases/rcpt-deep/8499e3ae0889231bb4cf44be763227155c737d80c3119d6ef49ee37ac384f0f4.json create mode 100644 wai/http-conformance/cases/rcpt-deep/8642ba98b3ef5233e6fe4c2b2f8bf0511b48b25ad11139781874ca585dad39db.json create mode 100644 wai/http-conformance/cases/rcpt-deep/89f6acee1ffd1ab9abcee3f448f2a7d1265f5de70c40ecdbd514fc2412a9581d.json create mode 100644 wai/http-conformance/cases/rcpt-deep/8ce05b04d49062f191ae072bf970ba23863c7cc88b654d178acf61361fc81efa.json create mode 100644 wai/http-conformance/cases/rcpt-deep/9437c7584edf8061151cc407cdc2cf5875138daa33def243f8e7958095159d43.json create mode 100644 wai/http-conformance/cases/rcpt-deep/9add9e068b526da6bd0d7dd6bac6f0bd53630f0cff4e303b0e2b5fa7032ab7b8.json create mode 100644 wai/http-conformance/cases/rcpt-deep/9bc7eb5b3a07d2c3bfa344a7f32612128c54efa856c8d8feab4c2d768559a153.json create mode 100644 wai/http-conformance/cases/rcpt-deep/9be1a59aa2577c7431548c5d117413044a37fdc1a05cb72c58d0ec16770bcfdf.json create mode 100644 wai/http-conformance/cases/rcpt-deep/9db4594f2e453f9cff014f3028d4c02dd5862b28855783f836891ec26e911616.json create mode 100644 wai/http-conformance/cases/rcpt-deep/9f4e6f8de572ae37b854c3f356c117a451713ae32d05176e8aae7d3cb928da3c.json create mode 100644 wai/http-conformance/cases/rcpt-deep/a14dd2629372b10738a35dcb3e71db5b60c2e6aac29776766f60df21debaa7a6.json create mode 100644 wai/http-conformance/cases/rcpt-deep/a577ff30a365f5af48f5ea6f2b874ea9520ec024639f33f9fcc8545d0535ea64.json create mode 100644 wai/http-conformance/cases/rcpt-deep/ae03f97d23795d99c4db5ddf04668bac928bd9e90969a20221cd7326e6a0cd1e.json create mode 100644 wai/http-conformance/cases/rcpt-deep/ba386a976d3bb07999fa2b48a0505aad51f5bcf1ad926f30e17475143abdaef7.json create mode 100644 wai/http-conformance/cases/rcpt-deep/bc54f7a82afbf23e1a81249785409dbf27d11f1c7871850d11f37afd637b0d8a.json create mode 100644 wai/http-conformance/cases/rcpt-deep/c018e85a69702e3966349a8b1a5a6cb91925f53377612947760bcabee6325e46.json create mode 100644 wai/http-conformance/cases/rcpt-deep/c24a80173b436fddaec2e9efe8a243eb58334442ebbc0f5170d219fc833c1b56.json create mode 100644 wai/http-conformance/cases/rcpt-deep/c40b1d684d5696efc5f2cc1f39cee4d99b4dc4fe5aa6d43992a8c8d5be5bcbe9.json create mode 100644 wai/http-conformance/cases/rcpt-deep/c5c6797e71d82c249afdfd5fa3e45c607168f5f46caab52de55ade65a7dca6db.json create mode 100644 wai/http-conformance/cases/rcpt-deep/cb54de1a2fb520d4b1b98a38732eeb0ae9260679c0ef38ee0c2db625d4690f08.json create mode 100644 wai/http-conformance/cases/rcpt-deep/ce8cdb9ba42eae1916d6543cc0df37a0e9e3cf6eb9f23dace8cadbcc68a1ce6e.json create mode 100644 wai/http-conformance/cases/rcpt-deep/d5c54a0582e4aae21a61b315843a4a3bc50af0b97a75f9fc6fa213f438839b59.json create mode 100644 wai/http-conformance/cases/rcpt-deep/dce4681c9a487a24a4b20f216f79d8319dfb7048ca2d965f1d34e5724e33d802.json create mode 100644 wai/http-conformance/cases/rcpt-deep/dddbdf295d8efc9890ea00b8cd09355b8bc12aa8c1b8a0abfa2736dc8e52beca.json create mode 100644 wai/http-conformance/cases/rcpt-deep/dde8580c86bd5c8b984f26ecf19b9f93f2dfef67c08fa99138c2bbcbfc5fb387.json create mode 100644 wai/http-conformance/cases/rcpt-deep/e13cff8676af896dc1c0a7b82178a8dea0f39e2c1ffe5fe5fd18cbfbd38fcf13.json create mode 100644 wai/http-conformance/cases/rcpt-deep/e2dfcd5e2cf232b3a0077e4a2c93598bd49a5408497bea9048e2dc1519655d19.json create mode 100644 wai/http-conformance/cases/rcpt-deep/ecd54bf262ce60576be4a2fb0aa1c9f5885e366c534ec7cda62e6bbca91c1e6c.json create mode 100644 wai/http-conformance/cases/rcpt-deep/eff8231145c90bca502126ff8268464a9c30a11436d3efd3a8aff1e9cc065d3f.json create mode 100644 wai/http-conformance/cases/rcpt-deep/f1d86cc4dc42d0dde7a760f09076dde6454c304a2b95c1150d244ab8d9868480.json create mode 100644 wai/http-conformance/cases/rcpt-deep/f2fed1d8ecfa414530d8bbc5114c80946a313a0c46a0d624c5083cfc4f9ab61b.json create mode 100644 wai/http-conformance/cases/rcpt-deep/f424f181500d06d27e5344e5ef28ff6a68eed7386ead966dd4c5f11baf14d41b.json create mode 100644 wai/http-conformance/cases/rcpt-deep/f8d4ee129c6d180112f714e02ac68eed633ebf2a1bfcee7f1fe8bc91d57b4b62.json create mode 100644 wai/http-conformance/cases/rcpt-deep/fbe11c778ba08a3178591ad782e1f2392419aad7d8b53be5e4e8432d7100c7fe.json create mode 100644 wai/http-conformance/cases/rcpt-deep/fcdf29a5c1f6567155dd0df394306fd179f9fdd04b252535d937c6537004735c.json create mode 100644 wai/http-conformance/cases/rcpt-deep/ff696c1fecfcfdf1b1fe0d3a3bc7124039209dc2b8dfbb2efd9ee7dcbafc955a.json create mode 100644 wai/http-conformance/cases/rcpt/6a29eb0066513cad092217b4f905f3decfc703c081c832fb35e3e42f3d1c7043.json create mode 100644 wai/http-conformance/cases/receipts/O1-legacy.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/O9a-256-items.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/O9a-257-claims.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/O9a-257-items.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/O9c-x50.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/deep-64.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/deep-65.wai.receipts create mode 100644 wai/http-conformance/objects/e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b.wai.receipts create mode 100644 wai/http-conformance/objects/rcpt/0c0de1e8d6eca027482086221a3022c5f130d46a45f27aedbf0377b62a0675c0.json create mode 100644 wai/http-conformance/objects/rcpt/144f86074d065d7f6f8bb30b3505870241b439eec6341f7b5dafa55eb5c5e936.json create mode 100644 wai/http-conformance/objects/rcpt/40ef9986d24aaf108f7d9ba9995c6add781cf5733e6d639d9a783ca0c0ddd61c.json create mode 100644 wai/http-conformance/objects/rcpt/8dc0815e30c004abc33175080c733c3aec1e0187ebd6f8374333f22373d9fe11.json create mode 100644 wai/http-conformance/objects/rcpt/95477f223c6bee89a57c7171c831352fd43f4a348f9d56f90e32e922c2c2b16f.json create mode 100644 wai/http-conformance/objects/rcpt/b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0.json diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 9c6ce18fe..465786bf6 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -478,20 +478,28 @@ wire format may change and such changes are recorded here. on a seeded differential outside the corpus, in which a refusal must match by its code and each family reports how many cases it generated and how many were refused. - **Receipts, keys, grants and parameter sets** (http-delivery §8.1–§8.5, §10.5): - - a receipt file `rcpt/.json` holds one group receipt or claim in JCS, read as - jwp-receipts reads one and refused unless its link hash is its name - (`receipt_name_mismatch`, `receipt_not_served`); - - an index is read item by item (`receipt_index_malformed`): each delivery item's - group goes through jwp-receipts §3 against the client's chains, a missing parent - fetched by its link up to 64 of them and never skipped (`parent_not_held`, with - where the walk stopped), a joining client anchoring instead; its object receipt - through steps 4 and 5 against what the client holds — the hash recomputed over the - envelope, the cid a head proof established (§8.3), or nothing, which is - `inclusion-verified` and gives no property reading (§10.5); each claim by its - subject, its parent fetched by its link, a soft binding of another object reported - as attesting the ancestor; + - a receipt file `rcpt/.json` holds one group receipt (in the labelled payload) or + claim in JCS, at most 1 MiB, read as jwp-receipts reads one and refused unless its + link hash is its name (`receipt_name_mismatch`, `receipt_not_served`, + `receipt_too_large`); a legacy group, whose name does not cover its parent, is + refused (`receipt_legacy_group`); + - an index, at most 4 MiB with at most 256 delivery items and 256 claims + (`receipt_index_too_large`), is read item by item (`receipt_index_malformed`), each + receipt file fetched once per index and at most 1024 in all + (`receipt_fetch_budget_exhausted`): each delivery item's group goes through + jwp-receipts §3 against the client's chains, a missing parent fetched by its link up + to 64 of them, never more, and never skipped (`parent_not_held`, with where the walk + stopped), a joining client anchoring instead; its object receipt through steps 4 + and 5 against what the client holds — the hash recomputed over the envelope, the cid + a head proof established (§8.3), or nothing, which is `inclusion-verified` and gives + no property reading (§10.5); each claim by its subject against the items that + passed, its parent fetched by its link, then §3.1 step 6 against what those items + deliver and the receipts it names (`figure_not_held`, `context_not_held` where the + client does not hold them), and a soft binding of another object attesting the + ancestor only through §3.2's recovery; - a key log anchors only from the origin's own location over HTTPS, answered `200` - with no redirect (`key_log_not_from_origin`); a log through any cache adds revisions + with no redirect (`key_log_not_from_origin`), from a retrieval the verifier made + itself, asking with `Cache-Control: no-cache`; a log through any cache adds revisions and never moves the anchor, an older copy reported (`key_log_rolled_back`); a grants file is a locator (`grants_file_malformed`); a key status is served for a quarter of the head's `status_max_age_s` at most; a module grant is §7.9's form, and a JCP grant @@ -502,8 +510,11 @@ wire format may change and such changes are recorded here. `jwp_hash::EstablishedHash`, built only from bytes or by a fetch session whose head proof verified (`FetchSession::established`); `ObjectReceipt::accept_hash` and `verify_against_hash`, `WaiReceiptProperty::check_against_object_hash` and - `ReceiptChains::verify_admitted_object`, which the existing checks call; `wai http - receipts`. A group or object receipt whose `kind` is not a string is `wrong_kind` + `ReceiptChains::verify_admitted_object`, which the existing checks call; + `energy_figure::HeldObject`, an object receipt's figure for step 6; `wai http + receipts`, which fetches under a deadline and the caps above, follows no redirect, + and with `--key-origin` retrieves the key log from the origin itself. A group or + object receipt whose `kind` is not a string is `wrong_kind` (jwp-receipts §3 step 1), as the independent verifier already read it. - The corpus serves receipts, indexes, a key log, a status, a grants file and a set; `http_receipts.py` checks them for `http_verify.py` through the jwp-receipts, claim diff --git a/wai/extensions/http-delivery.md b/wai/extensions/http-delivery.md index f03abafe3..b23a10786 100644 --- a/wai/extensions/http-delivery.md +++ b/wai/extensions/http-delivery.md @@ -386,14 +386,18 @@ pin was refused may also fail its digest, which only its bytes would show. ### 8.1 Receipt files `rcpt/.json`, resolved against the object's URI, holds exactly one receipt: a group -receipt whose link hash ([jwp-receipts](jwp-receipts.md) §3 step 3) is `h`, in its JSON -form (§2.4.1 there), or a claim whose `link_hash` (§2.4.3) is `h`, each written in JCS -(RFC 8785). It is immutable and served under §5.3's cache headers with -`Content-Type: application/json`. A client reads a receipt file as -[jwp-receipts](jwp-receipts.md) §3.1 step 1 reads JSON, then as the receipt it fetched it -for — a group receipt by §3 step 1, a claim by §3.1 steps 1–2, with their reasons — and -refuses it unless its link hash, computed from what it read, is its name -(`receipt_name_mismatch`). A name at which nothing is served is `receipt_not_served`. +receipt in the labelled payload whose link hash ([jwp-receipts](jwp-receipts.md) §3 step +3) is `h`, in its JSON form (§2.4.1 there), or a claim whose `link_hash` (§2.4.3) is `h`, +each written in JCS (RFC 8785), in at most 1 MiB (1 048 576 bytes). It is immutable and +served under §5.3's cache headers with `Content-Type: application/json`. A client reads at +most that many bytes of a receipt file and refuses a longer one (`receipt_too_large`); it +reads the file as [jwp-receipts](jwp-receipts.md) §3.1 step 1 reads JSON, then as the +receipt it fetched it for — a group receipt by §3 step 1, a claim by §3.1 steps 1–2, with +their reasons — and refuses it unless its link hash, computed from what it read, is its +name (`receipt_name_mismatch`). A group in the legacy payload is refused +(`receipt_legacy_group`): its link hash does not cover its parent, so a server could +serve other bytes, naming another parent, under the one name. A name at which nothing is +served is `receipt_not_served`. A group receipt's `parent_receipt_hash` is the name of its parent's file. A client resolves [jwp-receipts](jwp-receipts.md) §3 step 3's `parent_not_held` by fetching @@ -402,9 +406,10 @@ group receipt of that name, requires it on the group's key and track (a group of chain is never a predecessor), and offers it to its chains before the group; while that parent is itself `parent_not_held`, it fetches the parent's parent the same way. Once one is admitted, the parents fetched after it are offered again, nearest it first, then the -group. A client fetches at most 64 parents for one group, and MAY hold a lower bound. A -parent that is not served, that does not read or is refused, that is of another chain, -and a walk past the bound, each leave the group `parent_not_held`, and the client says +group. A client fetches at most 64 parents for one group, whatever bound it is asked to +use, and MAY hold a lower one. A parent that is not served, that does not read or is +refused, that is of another chain, a walk past the bound, and a walk that would pass the +index's fetch budget (§8.2), each leave the group `parent_not_held`, and the client says which. A client that declares it joins (jwp-receipts §3 step 3, *Joining*) anchors a chain it holds nothing of at the first group instead, walks nothing, and reports the chain anchored. A claim's `parent_receipt_hash` names its parent's file the same way: @@ -428,16 +433,22 @@ parents by the link its child names (§8.1). ``` It is mutable and is served with `Cache-Control: no-cache, no-transform` and an `ETag` -of its own. It is a locator, never evidence. A client reads it as jwp-receipts §3.1 step -1 reads JSON, with exactly the members above, `kind` that string, `object` 64 lowercase -hexadecimal digits and the other two arrays, and refuses it whole otherwise -(`receipt_index_malformed`), or when `object` is not the cid +of its own. It is a locator, never evidence. A client reads at most 4 MiB (4 194 304 +bytes) of it and refuses a longer one (`receipt_index_too_large`). It reads it as +jwp-receipts §3.1 step 1 reads JSON, with exactly the members above, `kind` that string, +`object` 64 lowercase hexadecimal digits and the other two arrays, and refuses it whole +otherwise (`receipt_index_malformed`), when it lists more than 256 delivery items or more +than 256 claims (`receipt_index_too_large`), or when `object` is not the cid (`receipt_not_about_object`). Each item it lists is read and checked on its own, and one that fails is ignored, with its reason, item by item: a delivery item is an object with exactly `property` and `object_receipt` (`receipt_index_malformed`), whose property reads as moq-streaming-format §5's (`property_invalid`) and whose object receipt reads as jwp-receipts §2.4.2's; a claim entry is a link hash (`receipt_index_malformed`). +To check one index, a client fetches each receipt file name once, keeping what was served +for the rest of the check, and fetches at most 1024 files in all; a fetch past that is +`receipt_fetch_budget_exhausted`. A client MAY hold a lower budget. + A delivery item is checked in this order: 1. the property's `content_hash` and the object receipt's are the cid @@ -453,13 +464,39 @@ A delivery item is checked in this order: moq-streaming-format §5 states (`property_invalid`, `property_disagrees`), which gives the property's reading; for one not delivered, alone, giving no reading. -A claim is fetched as `rcpt/.json` and read as §8.1 states, then checked: its -`subject` is the cid, or, for a claim about a receipt, the link hash of a group one of the -index's delivery items names; a soft-binding claim ([jwp-receipts](jwp-receipts.md) -§2.6.2) whose subject is another object is reported under the ancestor rule (§3.2 there): -as attesting that object, the ancestor, never the object in hand; any other claim is -`receipt_not_about_object`. Then §3.1 steps 1–4, and step 5 with its parent fetched as -§8.1 states. +A claim is fetched as `rcpt/.json` and read as §8.1 states, then checked +against the delivery items that passed the checks above for this cid, in the index's +order. Its `subject` is the cid, or, for a claim about a receipt, the link hash of the +group of one of those items: a group named only by an item that was refused, or by +nothing, attaches nothing, and the claim is `receipt_not_about_object`. Then +[jwp-receipts](jwp-receipts.md) §3.1 steps 1–4; step 5, its parent fetched as §8.1 +states; and step 6, against the receipts it names that the client holds here: + +- an `energy` claim, against the object receipt of each item delivered (step 4 run over + what the client holds of the object, §8.3), and against the OER receipt it names; +- a `measurement` or `operating-point` claim, against the receipt that carries its + figure: for the `moq-jwp:object` profile, the object receipt of an item delivered under + the group the claim names; +- a `delivery-gap` claim, against the object receipt of an item at its coordinates, + verified by inclusion at least; +- a lawful-basis decision, and a recovery record, against the declaration it names, + fetched as `rcpt/.json`; a recovery record also against the publisher key of + the group of an item that passed; +- a `reconstruct-session` claim, against its outputs, the objects it received and the + envelopes it received, none of which the index carries. + +A claim is accepted only when step 6 ran against what it needs. A `measurement` claim +whose figure the client does not hold is `figure_not_held`, as §3.1 states; any other +claim whose step 6 needs a receipt the client does not hold is `context_not_held`. Neither +is accepted. A claim of a class step 6 checks against nothing is accepted after step 5. + +A soft-binding claim ([jwp-receipts](jwp-receipts.md) §2.6.2) whose subject is another +object attests that object, the ancestor, never the object in hand, and only through +§3.2's recovery: the client holds the object, it holds the publisher key of a delivery +receipt for the ancestor that it verified under §3 over that object's bytes, and §3.2's +steps 2–5 pass with a matcher for the claim's algorithm, the claim then reported as +attesting the ancestor. A failed recovery is reported with §3.2's reason. Without such a +key or the object, no recovery runs, and the claim is `receipt_not_about_object`. A client MAY confirm the signer of each item it accepts under [jwp-receipts](jwp-receipts.md) §7.4, against key logs it holds as §8.4 states, and @@ -497,7 +534,9 @@ All are `application/json`. A verifier **anchors** an origin ([jwp-receipts](jwp §7.3) only from a key log retrieved from the origin's key-log location — an `https` origin followed by `/.well-known/wai-keys` — answered `200` by that same URL: it does not follow a redirect, asks with `Cache-Control: no-cache`, and a log that reached it any -other way anchors nothing (`key_log_not_from_origin`). Later revisions MAY come through +other way anchors nothing (`key_log_not_from_origin`). These are facts of the request +the verifier sent and the response it received, so it anchors only from a retrieval it +made itself; a statement that a log was so retrieved anchors nothing. Later revisions MAY come through any cache, a mirror, or the origin: such a log is read as §7.3 reads one, its revisions are held and each is checked against its parent by §7.4's head walk, and it never moves the anchor. A copy whose latest revision is a trusted revision older than the anchor is @@ -960,7 +999,8 @@ is a share of sessions, not of devices or people. | `component_digest_mismatch` | §7 step 6 ([SPEC](../SPEC.md) §7.1's reason) | | `object_digest_mismatch` | §7 step 7, or any whole fetch | | `receipt_name_mismatch`, `receipt_not_about_object` | §8.1, §8.2, §8.6 | -| `receipt_not_served`, `receipt_index_malformed` | §8.1, §8.2 | +| `receipt_not_served`, `receipt_index_malformed`, `receipt_index_too_large`, `receipt_too_large`, `receipt_legacy_group`, `receipt_fetch_budget_exhausted` | §8.1, §8.2 | +| `context_not_held` | §8.2 (jwp-receipts §3.1 step 6) | | `key_log_not_from_origin`, `grants_file_malformed` | §8.4 | | `codecs_malformed`, `codecs_not_media`, `codecs_candidate`, `codecs_no_common_capability`, `codecs_undeclared`, `codecs_layer_unbound` | §4 | | `base_unanswered` | §10.1 | @@ -997,7 +1037,10 @@ step 5 states. origin's key log, key status and grants file, and a parameter set served by its digest, with cases for receipt files read under their names, indexes checked against the whole object, a head proof's cid and nothing, parents walked and not recovered, - claims by subject and parent, anchoring from the origin only, logs through caches, + walks at and past the bound, legacy groups, files and indexes at and past their caps, + each name fetched once within a budget, claims by subject, parent and the receipts + §3.1 step 6 checks them against, ancestors by recovery, anchoring from the origin + only, logs through caches, grants files and sets held by their pins and §7.9; the differential adds receipt files, indexes, grants files and key logs each changed one way. `http_verify.py` checks these cases with the `cryptography` package, through the corpora's own second verifiers of @@ -1024,8 +1067,12 @@ step 5 states. fetches whole objects. - **Unsigned files.** Receipt indexes, grants files and aliases are locators, never evidence. A receipt file is named by its link hash and a parameter set by its digest, - so a cache or mirror that serves other bytes under the name is refused; a key log - anchors only from its origin (§8.4). + so a cache or mirror that serves other bytes under the name is refused; a group whose + name does not fix its bytes is not read (§8.1); a key log anchors only from its origin + (§8.4). +- **What a server can make a client do.** Every receipt file, index and item count is + capped, each name is fetched once, and the files one index costs are bounded (§8.1, + §8.2), so an index cannot make a client fetch or hold without limit. ## Appendix A — CDNI configuration diff --git a/wai/http-conformance/README.md b/wai/http-conformance/README.md index ad0597e5a..9fcdaf23c 100644 --- a/wai/http-conformance/README.md +++ b/wai/http-conformance/README.md @@ -50,12 +50,13 @@ agree: | `cases/other-key/` | the third live revision's receipt chained to the second by hash and signed by another key (`r2.json`, and in `mr/` under its receipt hash), and the fourth revision's receipt chained to it (`r3.json`) | | `cases/relist/` | the third live revision listing the alias of part 0 of segment 1201 with part 1's object (`wai-r2.m3u8`), sealed and chained to the second (`r2.json`, and in `mr/`); the fourth revision's receipt chained to it (`r3-after.json`); the fourth revision listing segment 1201's object at sequence 1200 (`wai-r3.m3u8`, `r3.json`); the third revision listing that alias as `./720/1201.0.wai`, with part 1's object and with its own (`wai-r2-dot*.m3u8`, `r2-dot*.json`); and the fourth revision withdrawing segment 1200 as a gap (`wai-r3-gap.m3u8`, `r3-gap.json`) | | `cases/walk-substituted/`, `cases/walk-invalid/` | parents a walk fetches by their links: the second revision's receipt served under the third's receipt hash; and the third revision changed after it was signed, served under its receipt hash, with the fourth revision's receipt chained to it (`r3.json`) | -| `objects/.wai.receipts` | the receipt index of each object that has receipts (§8.2): O9a–O9c (one group per segment, chained), O3 and O7 (one group, figures of a stated class), O10, O10L1 and O10L2 (one group for the stage set) | -| `objects/rcpt/.json` | each group receipt and claim (§8.1), in JCS: the five groups; a sink's two `stage` claims about O10, chained, the second listing layer 1 applied and layer 2 dropped (§10.5); the publisher's soft binding of O7, which O3's index lists as its ancestor's; and a `log-inclusion` claim about O3's group, chained to it. The publisher signs as `did:web:origin.example#d1` (seed 32 bytes of `0x61`), the sink as `did:example:http-sink` (`0x64`): published test keys, attesting nothing | +| `objects/.wai.receipts` | the receipt index of each object that has receipts (§8.2): O9a–O9c (one group per segment, chained), O3, O5 and O7 (one group each, figures of a stated class), O10, O10L1 and O10L2 (one group for the stage set) | +| `objects/rcpt/.json` | each group receipt and claim (§8.1), in JCS: the five groups; a sink's two `stage` claims about O10, chained, the second listing layer 1 applied and layer 2 dropped (§10.5); the publisher's soft binding of O7, which O3's index lists as its ancestor's; a `log-inclusion` claim about O3's group, chained to it; the publisher's `measurement` of O5's figure, chained to that claim; two `energy` claims about O3, one agreeing with its delivery and one not; and a relay's two `delivery-gap` claims about O10L2, one at its coordinates and one not. The publisher signs as `did:web:origin.example#d1` (seed 32 bytes of `0x61`), the sink as `did:example:http-sink` (`0x64`): published test keys, attesting nothing | | `.well-known/wai-keys`, `.well-known/wai-key-status`, `.well-known/wai-grants/.json` | the key log of `https://origin.example` (two revisions, root `0x60`), a status for its head frozen at `2026-10-06T00:00:00Z`, and the grants file for O2's pinned set: a grant by `g1` (`0x63`) and one by a key the log does not list (§8.4) | | `sets/` | O2's pinned parameter set, served by its digest (§8.5) | | `cases/keys/` | the log's first revision alone (an older copy), and a log whose second revision revokes the set's digest | -| `cases/rcpt/`, `cases/receipts/` | O9a's group with its signature changed, served under the link hash of what it then is, and an index naming it | +| `cases/rcpt/`, `cases/receipts/` | O9a's group with its signature changed, served under the link hash of what it then is, and an index naming it; O1's group in the legacy payload, under its legacy link hash, and an index naming it (§8.1); O9a's index with 256 and 257 delivery items and with 257 claims; O9c's index listing its item fifty times (§8.2) | +| `cases/rcpt-deep/`, `cases/receipts/deep-*.wai.receipts` | a chain of 66 groups over O1, each the parent of the next, and indexes naming the groups 64 and 65 parents from its root (§8.1's bound) | | `cases/sig-*.json` | the O9 playlist's receipt re-signed at the edges of Ed25519 (Appendix H): the identity key, canonical and respelled `y` = p + 1, under `(R, S) = (B, 1)`; under the key B (scalar 1), `R` the identity, canonical and respelled, with `S` = k, so the cofactorless equation holds point for point; and under the key B a signature the strict rule accepts (`sig-key-base-point.json`) | ## Objects @@ -126,7 +127,7 @@ outside this repository, and the AV1 streams were checked to decode. | `live` | `revisions` in the order a client fetched them (each a WAI playlist, its receipt, its classical playlist, `edit_wai`), `fetches` (an `alias` and the `object` served under it, optionally patched), the playlist's `url` (against which its lines resolve; `https://origin.example/live/wai-720.m3u8` by default), where parents are fetched from (`parents`, a directory holding `.json`; the first revision's `mr/` by default) and the walk's bound (`walk_bound`, 64 by default) | per revision its adjacency, its listing against what accepted revisions bound, its receipt, what it did to the chain (`true`, `unchanged`, `reanchored`, or the reason) and the parents walked; per fetch the accepted revision that first lists the alias, the outcome and what held (`listed`, or `signed` when a receipt in the chain binds the alias's URL to that cid) | | `uri_resolve` | a `base` and a `reference` | the `url` (RFC 3986 §5.2 and §6.2.2, without the fragment) | | `receipt_file` | a `file` read as a receipt of `kind` (`group` or `claim`) under its `name` (its own by default), optionally edited | its `link`, or `error` with the jwp-receipts `step` | -| `receipts` | `checks` in order, each an `object` (or `object_file`), what the client holds of it (`held`: `none`; `whole`, its bytes or `held_object`'s; or `{"fetch": {…}}`, a fetch as the `fetch` op runs one), its `index` (beside it by default) optionally edited; one set of chains (`join`), a parent bound (`max_walk`, 64 by default), the receipt files `serve`d (`rcpt` directories searched, `omit`ted hashes, hashes served `as` another file), and `keys` (key-log steps, as `key_log` runs them) | per check the index's verdict and, when it reads, each delivery item's admission, parents `walked`, `evidence`, how the hash was `established` and the property's `reading`, or its `error` (with where a walk stopped); each claim's `class`, what it `attests`, its `subject` and `parent`; with `keys`, each signer's confirmation; the fetch's verdict | +| `receipts` | `checks` in order, each an `object` (or `object_file`), what the client holds of it (`held`: `none`; `whole`, its bytes or `held_object`'s; or `{"fetch": {…}}`, a fetch as the `fetch` op runs one), its `index` (beside it by default) optionally edited, or another object's index (`object_cid`), optionally padded (`pad_index`, bytes); one set of chains (`join`), a parent bound (`max_walk`, 64 by default, never more) and a fetch budget (`fetch_budget`, 1024 by default), the receipt files `serve`d (`rcpt` directories searched, `omit`ted hashes, hashes served `as` another file, files `pad`ded past their cap), `recovery` (the soft-binding algorithms a matcher is held for; the publishers are those of the items delivered in earlier checks), and `keys` (key-log steps, as `key_log` runs them) | per check the index's verdict and, when it reads, each delivery item's admission, parents `walked`, `evidence`, how the hash was `established` and the property's `reading`, or its `error` (with where a walk stopped); each claim's `class`, what it `attests`, its `subject` and `parent`, or its `error`; the receipt files the check fetched (`files_fetched`); with `keys`, each signer's confirmation; the fetch's verdict | | `key_log` | `steps`: `anchor` (a log retrieved as `retrieval` states: `requested`, `answered`, `status`), `pin`, or `cached` (a log through a cache), each for an `origin` (the corpus's by default) and optionally edited | what each step did (`anchored`, `added`) or its `error` | | `grants_file` | a grants `file` for a `module` (the digest in its name by default), optionally edited | each grant's reading by §7.9's check 1, or `error` | | `param_set` | a set `served` for a `capability` under a pin of `sha256`, the sink's `keys`, `statuses` and `grants` file, the origins it holds the capability's sets to §7.9 for (`hold_to`; absent, not held to it), and `now` (null: no trusted clock) | `held`, or `error` with §7.9's `check` and `reason`, or the origin a revocation is by | @@ -180,10 +181,11 @@ fails the self-test; `base-at-done`, `tick-noop` and `refinement-unclipped` fail `timed_*` cases; `pdt-ignored`, `dseq-ignored`, `parts-after-extinf`, `average-floor`, `render-digest-off`, `name-unchecked`, `chain-unchecked`, `ilog2-no-interp`, `bola-waits`, `unlabelled-as-zero` and `round-half-down` fail the playlist, receipt and -adaptation cases they reach; `rcpt-name-unchecked`, `walk-skipped`, -`nothing-as-delivered`, `ancestor-as-object`, `anchor-follows-redirect`, -`cached-rollback-unchecked`, `grants-module-unchecked`, `set-grants-ignored` and -`set-by-name` fail the receipt, key, grant and set cases of §8.1–§8.5), and the job +adaptation cases they reach; `rcpt-name-unchecked`, `legacy-admitted`, +`walk-skipped`, `walk-unclamped`, `memo-off`, `nothing-as-delivered`, `step6-skipped`, +`ancestor-any`, `anchor-follows-redirect`, `cached-rollback-unchecked`, +`grants-module-unchecked`, `set-grants-ignored` and `set-by-name` fail the receipt, +key, grant and set cases of §8.1–§8.5), and the job checks a differential: `wai_http_vectors diff` writes random envelopes' covered prefixes and proofs, `http_verify.py diff` verifies every one, and, with one chaining-value byte changed in ten of them, refuses exactly diff --git a/wai/http-conformance/cases.json b/wai/http-conformance/cases.json index 869fbc948..9dcdaa09d 100644 --- a/wai/http-conformance/cases.json +++ b/wai/http-conformance/cases.json @@ -938,32 +938,57 @@ {"name":"receipt_file_kind_null","op":"receipt_file","input":{"file":"objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json","kind":"group","edit":[["\"kind\":\"group\"","\"kind\":null"]]},"expect":{"error":"wrong_kind","step":1}}, {"name":"receipt_file_member_twice","op":"receipt_file","input":{"file":"objects/rcpt/3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807.json","kind":"group","edit":[["{","{\"group_id\":0,"]]},"expect":{"error":"malformed","step":1}}, {"name":"receipt_file_claim_member_twice","op":"receipt_file","input":{"file":"objects/rcpt/0c0b87f288cc459e99b914920284bf633eab89bf035078137b89936002072768.json","kind":"claim","edit":[["{","{\"seq\":1,"]]},"expect":{"error":"malformed","step":1}}, -{"name":"receipts_walk_two","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":2,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]}]}}, -{"name":"receipts_walk_one","op":"receipts","input":{"checks":[{"object":"O9b","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":1,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]}]}}, -{"name":"receipts_in_order","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole"},{"object":"O9b","held":"whole"},{"object":"O9c","held":"whole"},{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]}]}}, -{"name":"receipts_back_after_head","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"},{"object":"O9a","held":"none"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":2,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]},{"index":"ok","delivery":[{"admission":"accept again","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[]}]}}, -{"name":"receipts_join_anchors","op":"receipts","input":{"join":true,"checks":[{"object":"O9c","held":"whole"}],"serve":{"omit":["3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807","e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30"]}},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept anchor","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]}]}}, -{"name":"receipts_parent_not_served","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"}],"serve":{"omit":["3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807"]}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":1,"stop":"receipt_not_served"}],"claims":[]}]}}, -{"name":"receipts_walk_bound","op":"receipts","input":{"max_walk":1,"checks":[{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":1,"stop":"bound"}],"claims":[]}]}}, -{"name":"receipts_parent_renamed","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"}],"serve":{"as":{"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807":"objects/rcpt/e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30.json"}}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":2,"stop":"receipt_name_mismatch"}],"claims":[]}]}}, -{"name":"receipts_group_not_served","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole"}],"serve":{"omit":["3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807"]}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_not_served","step":null}],"claims":[]}]}}, -{"name":"receipts_group_renamed","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole"}],"serve":{"as":{"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807":"objects/rcpt/8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765.json"}}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_name_mismatch","step":null}],"claims":[]}]}}, -{"name":"receipts_group_bad_signature","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"checks":[{"object":"O9a","held":"whole","index":"cases/receipts/O9a-bad-signature.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"signature_verify_failed","step":2}],"claims":[]}]}}, -{"name":"receipts_head_proof","op":"receipts","input":{"checks":[{"object":"O3","held":{"fetch":{"advertises":["wai.video.av1"],"applies":["wai.meta.hdr10plus"]}}}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"head-proof","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"}],"fetched":"head-proof"}]}}, -{"name":"receipts_fetch_refused_holds_nothing","op":"receipts","input":{"checks":[{"object":"O3","held":{"fetch":{"advertises":["wai.video.av1"],"applies":["wai.meta.hdr10plus"],"tamper":{"patch_object":[[200,"23"]]}}}}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"}],"fetched":"head_proof_mismatch"}]}}, -{"name":"receipts_other_object_in_hand","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","held_object":"O9b"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"content_hash_mismatch","step":4}],"claims":[]}]}}, -{"name":"receipts_stage_set","op":"receipts","input":{"checks":[{"object":"O10","held":"whole"},{"object":"O10L1","held":"whole"},{"object":"O10L2","held":"none"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[{"class":"stage","attests":"object","subject":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","parent":"8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c"}]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[]}]}}, -{"name":"receipts_layer_fetched","op":"receipts","input":{"checks":[{"object":"O10L1","held":{"fetch":{"listed_as_layer":true}}}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"fetched":"whole-object"}]}}, -{"name":"receipts_claims_O3","op":"receipts","input":{"checks":[{"object":"O3","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"}]}]}}, -{"name":"receipts_claims_O7","op":"receipts","input":{"checks":[{"object":"O7","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"object","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null}]}]}}, -{"name":"receipts_claim_parent_not_served","op":"receipts","input":{"checks":[{"object":"O10","held":"whole"}],"serve":{"omit":["8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c"]}},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[{"error":"parent_not_held","step":null,"walked":0,"stop":"receipt_not_served"}]}]}}, +{"name":"receipts_walk_two","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":2,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":3}]}}, +{"name":"receipts_walk_one","op":"receipts","input":{"checks":[{"object":"O9b","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":1,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":2}]}}, +{"name":"receipts_in_order","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole"},{"object":"O9b","held":"whole"},{"object":"O9c","held":"whole"},{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":1},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":1},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":1},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_back_after_head","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"},{"object":"O9a","held":"none"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":2,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":3},{"index":"ok","delivery":[{"admission":"accept again","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_join_anchors","op":"receipts","input":{"join":true,"checks":[{"object":"O9c","held":"whole"}],"serve":{"omit":["3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807","e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30"]}},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept anchor","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_parent_not_served","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"}],"serve":{"omit":["3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807"]}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":1,"stop":"receipt_not_served"}],"claims":[],"files_fetched":3}]}}, +{"name":"receipts_walk_bound","op":"receipts","input":{"max_walk":1,"checks":[{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":1,"stop":"bound"}],"claims":[],"files_fetched":2}]}}, +{"name":"receipts_parent_renamed","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole"}],"serve":{"as":{"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807":"objects/rcpt/e0e1d579fff00461f48124d31edac54e44cf157fd26307fca69de8e1c7cd9d30.json"}}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":2,"stop":"receipt_name_mismatch"}],"claims":[],"files_fetched":3}]}}, +{"name":"receipts_group_not_served","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole"}],"serve":{"omit":["3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807"]}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_not_served","step":null}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_group_renamed","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole"}],"serve":{"as":{"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807":"objects/rcpt/8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765.json"}}},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_name_mismatch","step":null}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_group_bad_signature","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"checks":[{"object":"O9a","held":"whole","index":"cases/receipts/O9a-bad-signature.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"signature_verify_failed","step":2}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_head_proof","op":"receipts","input":{"checks":[{"object":"O3","held":{"fetch":{"advertises":["wai.video.av1"],"applies":["wai.meta.hdr10plus"]}}}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"head-proof","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"receipt_not_about_object","step":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6,"fetched":"head-proof"}]}}, +{"name":"receipts_fetch_refused_holds_nothing","op":"receipts","input":{"checks":[{"object":"O3","held":{"fetch":{"advertises":["wai.video.av1"],"applies":["wai.meta.hdr10plus"],"tamper":{"patch_object":[[200,"23"]]}}}}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[{"error":"receipt_not_about_object","step":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"error":"context_not_held","step":6},{"error":"context_not_held","step":6}],"files_fetched":6,"fetched":"head_proof_mismatch"}]}}, +{"name":"receipts_other_object_in_hand","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","held_object":"O9b"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"content_hash_mismatch","step":4}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_stage_set","op":"receipts","input":{"checks":[{"object":"O10","held":"whole"},{"object":"O10L1","held":"whole"},{"object":"O10L2","held":"none"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[{"class":"stage","attests":"object","subject":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","parent":"8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c"}],"files_fetched":3},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":1},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[{"class":"delivery-gap","attests":"object","subject":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","parent":null},{"error":"context_not_held","step":6}],"files_fetched":3}]}}, +{"name":"receipts_layer_fetched","op":"receipts","input":{"checks":[{"object":"O10L1","held":{"fetch":{"listed_as_layer":true}}}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":1,"fetched":"whole-object"}]}}, +{"name":"receipts_claims_O3","op":"receipts","input":{"checks":[{"object":"O3","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"receipt_not_about_object","step":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6}]}}, +{"name":"receipts_claims_O7","op":"receipts","input":{"checks":[{"object":"O7","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"object","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null}],"files_fetched":2}]}}, +{"name":"receipts_claim_parent_not_served","op":"receipts","input":{"checks":[{"object":"O10","held":"whole"}],"serve":{"omit":["8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c"]}},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[{"error":"parent_not_held","step":null,"walked":0,"stop":"receipt_not_served"}],"files_fetched":3}]}}, {"name":"receipts_index_other_object","op":"receipts","input":{"checks":[{"object":"O9b","held":"whole","index":"objects/31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3.wai.receipts"}]},"expect":{"checks":[{"index":"receipt_not_about_object"}]}}, -{"name":"receipts_property_disagrees","op":"receipts","input":{"checks":[{"object":"O3","held":"whole","edit_index":[["\"group_receipt\":\"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018\",\"joules_micro\":1500}","\"group_receipt\":\"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018\",\"joules_micro\":1501}"]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"property_disagrees","step":null}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"}]}]}}, -{"name":"receipts_item_not_about_object","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","edit_index":[["\"content_hash\":\"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3\",\"group_receipt\"","\"content_hash\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"group_receipt\""]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_not_about_object","step":null}],"claims":[]}]}}, +{"name":"receipts_property_disagrees","op":"receipts","input":{"checks":[{"object":"O3","held":"whole","edit_index":[["\"group_receipt\":\"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018\",\"joules_micro\":1500}","\"group_receipt\":\"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018\",\"joules_micro\":1501}"]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"property_disagrees","step":null}],"claims":[{"error":"receipt_not_about_object","step":null},{"error":"receipt_not_about_object","step":null},{"error":"context_not_held","step":6},{"error":"context_not_held","step":6}],"files_fetched":6}]}}, +{"name":"receipts_item_not_about_object","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","edit_index":[["\"content_hash\":\"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3\",\"group_receipt\"","\"content_hash\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"group_receipt\""]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_not_about_object","step":null}],"claims":[],"files_fetched":0}]}}, {"name":"receipts_index_member_added","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","edit_index":[["{","{\"note\":1,"]]}]},"expect":{"checks":[{"index":"receipt_index_malformed"}]}}, -{"name":"receipts_item_member_added","op":"receipts","input":{"checks":[{"object":"O3","held":"whole","edit_index":[["{\"object_receipt\"","{\"note\":1,\"object_receipt\""]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_index_malformed","step":null}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"error":"receipt_not_about_object","step":null}]}]}}, -{"name":"receipts_claim_entry_not_a_hash","op":"receipts","input":{"checks":[{"object":"O7","held":"whole","edit_index":[["14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","s0"]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"receipt_index_malformed","step":null}]}]}}, -{"name":"receipts_confirmed","op":"receipts","input":{"keys":[{"anchor":".well-known/wai-keys"}],"checks":[{"object":"O3","held":"whole"},{"object":"O10","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null,"confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"}]},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"}],"claims":[{"class":"stage","attests":"object","subject":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","parent":"8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c","confirm":"unconfirmed step=1 reason=signer_id_not_origin_bound"}]}]}}, +{"name":"receipts_item_member_added","op":"receipts","input":{"checks":[{"object":"O3","held":"whole","edit_index":[["{\"object_receipt\"","{\"note\":1,\"object_receipt\""]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_index_malformed","step":null}],"claims":[{"error":"receipt_not_about_object","step":null},{"error":"receipt_not_about_object","step":null},{"error":"context_not_held","step":6},{"error":"context_not_held","step":6}],"files_fetched":5}]}}, +{"name":"receipts_claim_entry_not_a_hash","op":"receipts","input":{"checks":[{"object":"O7","held":"whole","edit_index":[["14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","s0"]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"receipt_index_malformed","step":null}],"files_fetched":1}]}}, +{"name":"receipts_measurement_figure","op":"receipts","input":{"checks":[{"object":"O5","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=OnChipCounter by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"measurement","attests":"object","subject":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b","parent":"dae83c921710311cbd34d8d4a2bedab53cf56a9be5afea2dc3d264ee978a03c5"}],"files_fetched":3}]}}, +{"name":"receipts_measurement_figure_not_held","op":"receipts","input":{"checks":[{"object":"O5","held":"none"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[{"error":"figure_not_held","step":6}],"files_fetched":3}]}}, +{"name":"receipts_energy_not_delivered","op":"receipts","input":{"checks":[{"object":"O3","held":"none"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[{"error":"receipt_not_about_object","step":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"error":"context_not_held","step":6},{"error":"context_not_held","step":6}],"files_fetched":6}]}}, +{"name":"receipts_delivery_gap","op":"receipts","input":{"checks":[{"object":"O10L2","held":"none"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[{"class":"delivery-gap","attests":"object","subject":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","parent":null},{"error":"context_not_held","step":6}],"files_fetched":3}]}}, +{"name":"receipts_claim_through_foreign_item","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","edit_index":[["\"claims\":[]","\"claims\":[\"dae83c921710311cbd34d8d4a2bedab53cf56a9be5afea2dc3d264ee978a03c5\"]"],["\"delivery\":[","\"delivery\":[{\"object_receipt\":{\"content_hash\":\"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9\",\"energy_provenance\":\"Estimator\",\"group_id\":0,\"joules_micro\":1500,\"kind\":\"object\",\"leaf_version\":2,\"merkle_proof\":{\"direction_bits\":0,\"leaf_index\":0,\"siblings\":[\"368b22537cfa0d3d0bfcfe1f6256302af54c54711dfa411c193beda171c97edb\"]},\"object_id\":0,\"origin\":\"origin\",\"track_alias\":2},\"property\":{\"acquisition\":\"Estimator\",\"acquisition_evidence\":\"\",\"content_hash\":\"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9\",\"group_receipt\":\"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018\",\"joules_micro\":1500}},"]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_not_about_object","step":null},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[{"error":"receipt_not_about_object","step":null}],"files_fetched":2}]}}, +{"name":"receipts_ancestor_recovered","op":"receipts","input":{"recovery":{"observed_value":"0ff0","exact_match_algs":["org.example.fingerprint"]},"checks":[{"object":"O7","held":"whole"},{"object":"O3","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"object","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null}],"files_fetched":2},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6}]}}, +{"name":"receipts_ancestor_no_publisher","op":"receipts","input":{"recovery":{"observed_value":"0ff0","exact_match_algs":["org.example.fingerprint"]},"checks":[{"object":"O3","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"receipt_not_about_object","step":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6}]}}, +{"name":"receipts_ancestor_value_mismatch","op":"receipts","input":{"recovery":{"observed_value":"0000","exact_match_algs":["org.example.fingerprint"]},"checks":[{"object":"O7","held":"whole"},{"object":"O3","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"object","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null}],"files_fetched":2},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"recovery_value_mismatch","step":6},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6}]}}, +{"name":"receipts_ancestor_no_matcher","op":"receipts","input":{"recovery":{"observed_value":"0ff0","exact_match_algs":[]},"checks":[{"object":"O7","held":"whole"},{"object":"O3","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"object","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null}],"files_fetched":2},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"recovery_no_matcher","step":6},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6}]}}, +{"name":"receipts_ancestor_any_object","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","edit_index":[["\"claims\":[]","\"claims\":[\"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491\"]"]]}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[{"error":"receipt_not_about_object","step":null}],"files_fetched":2}]}}, +{"name":"receipts_legacy_group","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"checks":[{"object":"O1","held":"whole","index":"cases/receipts/O1-legacy.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_legacy_group","step":null}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_walk_64","op":"receipts","input":{"serve":{"rcpt":["cases/rcpt-deep"]},"max_walk":null,"checks":[{"object_cid":"f6fa1ca70e69bee70aa2bd36620b3e93a50ca2e110e1fbb91f3c6747f5deb860","held":"none","index":"cases/receipts/deep-64.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":64,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[],"files_fetched":65}]}}, +{"name":"receipts_walk_65","op":"receipts","input":{"serve":{"rcpt":["cases/rcpt-deep"]},"max_walk":null,"checks":[{"object_cid":"002ebec0ca68dc089695fcc8b1375c6964fd8f25d07f79c1d3e568a684770b3b","held":"none","index":"cases/receipts/deep-65.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":64,"stop":"bound"}],"claims":[],"files_fetched":65}]}}, +{"name":"receipts_walk_65_asks_1000","op":"receipts","input":{"serve":{"rcpt":["cases/rcpt-deep"]},"max_walk":1000,"checks":[{"object_cid":"002ebec0ca68dc089695fcc8b1375c6964fd8f25d07f79c1d3e568a684770b3b","held":"none","index":"cases/receipts/deep-65.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":64,"stop":"bound"}],"claims":[],"files_fetched":65}]}}, +{"name":"receipts_items_fetch_each_name_once","op":"receipts","input":{"checks":[{"object":"O9c","held":"whole","index":"cases/receipts/O9c-x50.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":2,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":3}]}}, +{"name":"receipts_fetch_budget","op":"receipts","input":{"fetch_budget":2,"checks":[{"object":"O9c","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":1,"stop":"receipt_fetch_budget_exhausted"}],"claims":[],"files_fetched":2}]}}, +{"name":"receipts_file_too_large","op":"receipts","input":{"serve":{"pad":{"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807":1048577}},"checks":[{"object":"O9a","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_too_large","step":null}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_parent_too_large","op":"receipts","input":{"serve":{"pad":{"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807":1048577}},"checks":[{"object":"O9b","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":1,"stop":"receipt_too_large"}],"claims":[],"files_fetched":2}]}}, +{"name":"receipts_file_at_its_cap","op":"receipts","input":{"serve":{"pad":{"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807":1048576}},"checks":[{"object":"O9a","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_index_too_large","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","pad_index":4194305}]},"expect":{"checks":[{"index":"receipt_index_too_large"}]}}, +{"name":"receipts_index_at_its_cap","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","pad_index":4194304}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered"}],"claims":[],"files_fetched":1}]}}, +{"name":"receipts_index_256_items","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","index":"cases/receipts/O9a-256-items.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null}],"claims":[{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null},{"error":"receipt_index_malformed","step":null}],"files_fetched":0}]}}, +{"name":"receipts_index_257_items","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","index":"cases/receipts/O9a-257-items.wai.receipts"}]},"expect":{"checks":[{"index":"receipt_index_too_large"}]}}, +{"name":"receipts_index_257_claims","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","index":"cases/receipts/O9a-257-claims.wai.receipts"}]},"expect":{"checks":[{"index":"receipt_index_too_large"}]}}, +{"name":"receipt_file_legacy_group","op":"receipt_file","input":{"file":"cases/rcpt/6a29eb0066513cad092217b4f905f3decfc703c081c832fb35e3e42f3d1c7043.json","kind":"group"},"expect":{"error":"receipt_legacy_group","step":null}}, +{"name":"receipts_confirmed","op":"receipts","input":{"keys":[{"anchor":".well-known/wai-keys"}],"checks":[{"object":"O3","held":"whole"},{"object":"O10","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"}],"claims":[{"error":"receipt_not_about_object","step":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"}],"claims":[{"class":"stage","attests":"object","subject":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","parent":"8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c","confirm":"unconfirmed step=1 reason=signer_id_not_origin_bound"}],"files_fetched":3}]}}, {"name":"key_log_anchor","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys"}]},"expect":{"steps":[{"anchored":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","path":"fetched"}]}}, {"name":"key_log_anchor_redirect","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","retrieval":{"requested":"https://origin.example/.well-known/wai-keys","answered":"https://mirror.example/.well-known/wai-keys"}}]},"expect":{"steps":[{"error":"key_log_not_from_origin"}]}}, {"name":"key_log_anchor_elsewhere","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","retrieval":{"requested":"https://mirror.example/.well-known/wai-keys"}}]},"expect":{"steps":[{"error":"key_log_not_from_origin"}]}}, diff --git a/wai/http-conformance/cases/rcpt-deep/003ce009852d8c0c3e808699db87f862368967295eaad10087ee86cd356a65dc.json b/wai/http-conformance/cases/rcpt-deep/003ce009852d8c0c3e808699db87f862368967295eaad10087ee86cd356a65dc.json new file mode 100644 index 000000000..289c700c9 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/003ce009852d8c0c3e808699db87f862368967295eaad10087ee86cd356a65dc.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":25,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"82a9fe0631aaaa7a86edc18e09dad8f9080ae1faa6089a2632a9ce028562740a","root_hash":"1211874e6b3ecacd036dc0b1af531f5720cc5cf390bff94efba962c3bbf298aa","sig":"4ec5139386e521d1bde6cefb11ee020d0698331ca24379b7a8ba4c933b4c5bc0a5457d181a709d8ded89a0ff593bfd9887c6f3a69f3c38684b46de66af36810f","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/009454fd76395b07a8db1c205e1eceb2fc39b70d6f19ce28f029f0aa25489eac.json b/wai/http-conformance/cases/rcpt-deep/009454fd76395b07a8db1c205e1eceb2fc39b70d6f19ce28f029f0aa25489eac.json new file mode 100644 index 000000000..2719ddf3f --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/009454fd76395b07a8db1c205e1eceb2fc39b70d6f19ce28f029f0aa25489eac.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":43,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"ae03f97d23795d99c4db5ddf04668bac928bd9e90969a20221cd7326e6a0cd1e","root_hash":"eadf6410e5cfb8a63d64905d19009d4c618d1a86a420d53881d0f6763b2b57ea","sig":"d684427786c93ae9e782871c25ee11bfda7c53cc9949a7d2c98c616f78abac68bc26cb207e92a656aff836dc540ca942667d8c4c0349e0675e7627f10d5da007","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/01a07d8b4af7e85c22c5ecbba976073b5e043ce831e96f8c411121fde6bdfa24.json b/wai/http-conformance/cases/rcpt-deep/01a07d8b4af7e85c22c5ecbba976073b5e043ce831e96f8c411121fde6bdfa24.json new file mode 100644 index 000000000..bde518dac --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/01a07d8b4af7e85c22c5ecbba976073b5e043ce831e96f8c411121fde6bdfa24.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":32,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"296b25a0475ca8ba860b3db33ec1c42f9c67a3dcda5fee8b7ab924c14d05d02c","root_hash":"a7a29aa447946ff2ca37266cbbf4cd58c3d3f1b12f21931eee1adff1ad8aceb9","sig":"8d35a40f4e6751b32ae5453b87d4c388c3b032345fc936fe6c035778e1dfa7d13db666ef5f3a7d344fcbcf2de92d5bbcb0adedc7c6730f4b62e0fd6d8459f80a","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/17ef1654e9710960f846a1453e40c927d3d526151006f5d01dcd0a50d8fe5cc0.json b/wai/http-conformance/cases/rcpt-deep/17ef1654e9710960f846a1453e40c927d3d526151006f5d01dcd0a50d8fe5cc0.json new file mode 100644 index 000000000..06e72d73a --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/17ef1654e9710960f846a1453e40c927d3d526151006f5d01dcd0a50d8fe5cc0.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":39,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"c018e85a69702e3966349a8b1a5a6cb91925f53377612947760bcabee6325e46","root_hash":"e43da1d3bca87b9a4be2d64da5bcedd1880c31a2a0c88551fd6dadfd9844858c","sig":"deb4d975d400f50fca515599a5d4fea0e8942e37d6172429b0313f74da8002cedc80b5d4e971af494976578f23b88799a11dabdca50bc2fc97343caa56f6ba05","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/186babdb6cd3cfbc6d5ea3420bd0b9583d3a3328544f90d1f6ec337e833da309.json b/wai/http-conformance/cases/rcpt-deep/186babdb6cd3cfbc6d5ea3420bd0b9583d3a3328544f90d1f6ec337e833da309.json new file mode 100644 index 000000000..8ff33a160 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/186babdb6cd3cfbc6d5ea3420bd0b9583d3a3328544f90d1f6ec337e833da309.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":1,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"1a0a5b813a9829960fc55d40e8db40c94f9e8fc49c65d76e8fddd9dc4e879f4e","root_hash":"509c5b9572d70f7baea01627f6c0dc0ab69214872f6c99279e3302742c1dfcbb","sig":"5764f7cc86de56d32d0cf4dabd15ae61450c7686deaaf144035d60cbf7b7c129155c639967ad4e941c6eb65ba9fc68647db0e973de1fc06bf418842e3dc28601","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/1a0a5b813a9829960fc55d40e8db40c94f9e8fc49c65d76e8fddd9dc4e879f4e.json b/wai/http-conformance/cases/rcpt-deep/1a0a5b813a9829960fc55d40e8db40c94f9e8fc49c65d76e8fddd9dc4e879f4e.json new file mode 100644 index 000000000..1043e73bf --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/1a0a5b813a9829960fc55d40e8db40c94f9e8fc49c65d76e8fddd9dc4e879f4e.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":0,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":null,"root_hash":"da5a5c13d1a01cb3c5f05e29993e9808516d27d7b3e2608c7ec3ca13577a7337","sig":"8674712a2f4598ad9b981b4df6864300b8df77315ba4e10c8b2430314cf43e92ec73b4e01cd15f35857a3201f24f4e7945f070fed1507a9ccc0706079f45a20b","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/21dcc9e852e59acff6ae0749f4a6afb81621f6a9226825ef597c860f306ff755.json b/wai/http-conformance/cases/rcpt-deep/21dcc9e852e59acff6ae0749f4a6afb81621f6a9226825ef597c860f306ff755.json new file mode 100644 index 000000000..3f3f8b860 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/21dcc9e852e59acff6ae0749f4a6afb81621f6a9226825ef597c860f306ff755.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":33,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"01a07d8b4af7e85c22c5ecbba976073b5e043ce831e96f8c411121fde6bdfa24","root_hash":"d09a3b36b4ba20ba294c4c50b89832742d99989e8685ac6abc2aa47b5f96796c","sig":"87128fda6bb5637d562380237d9c56de409a8bf61c1d4d84255e346fa28d2157564eaed727194bb390fa6112e41516d81c6c1ecadd53140f7d31d79bc768d80d","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/25632b6926e484b967f63df5a868d252c15d8fbba68d6bd6beb7c5db2bb8b284.json b/wai/http-conformance/cases/rcpt-deep/25632b6926e484b967f63df5a868d252c15d8fbba68d6bd6beb7c5db2bb8b284.json new file mode 100644 index 000000000..f4cbb27a9 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/25632b6926e484b967f63df5a868d252c15d8fbba68d6bd6beb7c5db2bb8b284.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":28,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"6fe102acdfb722a4ac1e9708bd2524df96612c2b8572fb36187b36aaef695d19","root_hash":"aec3dcc41f2d3c2109d555ef3870523e174a11170ef4616fb3ff742627f07325","sig":"4d17b95bc95a55eedb3850f00ee49addcaaaea1cff30fc2201bf101658b6162f19cdcda205fe223b376684471491b70b519a07604de46a3ab44f19bc44209408","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/258947cefedab4a8c44cf3d02bc66e33c4c94b20baefdee5924100ac13ad2631.json b/wai/http-conformance/cases/rcpt-deep/258947cefedab4a8c44cf3d02bc66e33c4c94b20baefdee5924100ac13ad2631.json new file mode 100644 index 000000000..eb912f616 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/258947cefedab4a8c44cf3d02bc66e33c4c94b20baefdee5924100ac13ad2631.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":55,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"7209b5f3d838d80969f2bedb44b36c62b9cb980de376565d29be1a36290673db","root_hash":"14feae56e11597a25b41680f39a198e9e230ef9876bcaf76b822b55e0f5647ff","sig":"6203742512be52ba7593f542a98e536ba07c641838dee5c62cb0711006544fa94708df3dbf2bddc5fcc24bd17a09b9d0d1a78365e035195d92ce9c9eb0357a01","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/296b25a0475ca8ba860b3db33ec1c42f9c67a3dcda5fee8b7ab924c14d05d02c.json b/wai/http-conformance/cases/rcpt-deep/296b25a0475ca8ba860b3db33ec1c42f9c67a3dcda5fee8b7ab924c14d05d02c.json new file mode 100644 index 000000000..7ea7b2709 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/296b25a0475ca8ba860b3db33ec1c42f9c67a3dcda5fee8b7ab924c14d05d02c.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":31,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"64100b9ff95fe8517af6d53ffae89420bcc4855b7a9fbfa9c0c2fcfd4c8f6e02","root_hash":"11af67f8b4a51b1cfc3fd45d6a45d139143aad28d77ca24f830db80fa49af063","sig":"f23dfefaf26e6eb1bb59dd186489da7e06c05c38611b1ac7a7467d40e45a24921038d51f9269978c01690f98de13ac58d4defa4382376a3fa9b883acc18f4c04","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/2d31d0bd821ee2578210482b192d2cb47d1d9a9846fc9f032311c4b156b3170a.json b/wai/http-conformance/cases/rcpt-deep/2d31d0bd821ee2578210482b192d2cb47d1d9a9846fc9f032311c4b156b3170a.json new file mode 100644 index 000000000..910feaf15 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/2d31d0bd821ee2578210482b192d2cb47d1d9a9846fc9f032311c4b156b3170a.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":13,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"89f6acee1ffd1ab9abcee3f448f2a7d1265f5de70c40ecdbd514fc2412a9581d","root_hash":"a35f7025866f344f636c09c22631c7f4c1a7e694834739af9e18e0703c35eb90","sig":"0646d08d5c8cdc8f0bcd604fb7ea0cd8c9363e08adada9a0668ed3c9b669ef77f93543950bc9f67d89a14d6256eee1886fa25d5919c430e8f78263ddde5ddb0f","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/316ef8d8a425cee1a88181a83f46698d36124879649ebdef8104c66398a37580.json b/wai/http-conformance/cases/rcpt-deep/316ef8d8a425cee1a88181a83f46698d36124879649ebdef8104c66398a37580.json new file mode 100644 index 000000000..1e7d8b5c4 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/316ef8d8a425cee1a88181a83f46698d36124879649ebdef8104c66398a37580.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":40,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"17ef1654e9710960f846a1453e40c927d3d526151006f5d01dcd0a50d8fe5cc0","root_hash":"7dbbdd8640eba8cfccbff0a54849a101f0be8399a9b299e9b300018a7bb007cd","sig":"0506f65ac8538cdcc58a5a8fde8e9e81140966149d1cd32702beb0608330db9896a5a488577a8ba0bd9df61b82caad940b0ab252d118a0fb0de07c4fb9d08707","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/319e3bd23ecc66f714d2226d160457fdface7e18022d071f2332adf1dc42f60a.json b/wai/http-conformance/cases/rcpt-deep/319e3bd23ecc66f714d2226d160457fdface7e18022d071f2332adf1dc42f60a.json new file mode 100644 index 000000000..3e009b717 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/319e3bd23ecc66f714d2226d160457fdface7e18022d071f2332adf1dc42f60a.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":46,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"3d8bcf2693699485bf4560e6db247cfba2ed713171f434a76a8f90c55facae9a","root_hash":"883c6c212087e54d9956d6274d23d065bb85dd7dec7ac19a4a067b84d5bc3b72","sig":"22c7c0f913a779057176eb965937637a58e58a99eaa0d19d5bd10196708e6d27968d869353206c676138e5e19040393ac0df3bfbd3cfc03c7447075cfc00bd0a","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/36f03021b405cf486db202f776174b6c81098add4784395a59327037c411aa05.json b/wai/http-conformance/cases/rcpt-deep/36f03021b405cf486db202f776174b6c81098add4784395a59327037c411aa05.json new file mode 100644 index 000000000..a0a082b1d --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/36f03021b405cf486db202f776174b6c81098add4784395a59327037c411aa05.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":8,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"dde8580c86bd5c8b984f26ecf19b9f93f2dfef67c08fa99138c2bbcbfc5fb387","root_hash":"c23f6de9c2788364cf6fa7623dbd2dee3914c113adf5d38ed19c3ce4b339f7af","sig":"1eb2d4017bb2cc724997b2d288f4a16776a27a23ac159c4ca917cc3e6d18a40d2ef79830bb903c9d833dd878c3ce73afe5fc7cdb2ec8d4a007d008c7c14d960b","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/3aa91d8e5f296a997f8473822a5558478a3302b02c06c3192986bbcb2d5d27b1.json b/wai/http-conformance/cases/rcpt-deep/3aa91d8e5f296a997f8473822a5558478a3302b02c06c3192986bbcb2d5d27b1.json new file mode 100644 index 000000000..1d30dce83 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/3aa91d8e5f296a997f8473822a5558478a3302b02c06c3192986bbcb2d5d27b1.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":52,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"9add9e068b526da6bd0d7dd6bac6f0bd53630f0cff4e303b0e2b5fa7032ab7b8","root_hash":"9ecd4d472e6335329ea651a6198528363b3d5117f12d2987b2b29b31ad45d3fd","sig":"b88194e4fecc524c457cb14c895e2f6b85b2b0a240de04c328bdc33799a36cf357f1c076f614104d5138c0987bd872b8a3a3f6a5aa9dc0e91e6ff77a10737804","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/3c8ead78e4ccaaa1b49790f87ea593a38e586e45cf8d2d7c1c575e64d3322893.json b/wai/http-conformance/cases/rcpt-deep/3c8ead78e4ccaaa1b49790f87ea593a38e586e45cf8d2d7c1c575e64d3322893.json new file mode 100644 index 000000000..dcc0af325 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/3c8ead78e4ccaaa1b49790f87ea593a38e586e45cf8d2d7c1c575e64d3322893.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":56,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"258947cefedab4a8c44cf3d02bc66e33c4c94b20baefdee5924100ac13ad2631","root_hash":"600aca31e4294ce457830489261698ed98559673f1dac64b2a2cda891a30fba9","sig":"505d1f31276fe9f6fcba73a0789d5560809752f7dd1706c3134b7bdb955ee593379779498625ac002f5bc95de8c94a702d199785d8baa92a52165cad21e7e30d","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/3d8bcf2693699485bf4560e6db247cfba2ed713171f434a76a8f90c55facae9a.json b/wai/http-conformance/cases/rcpt-deep/3d8bcf2693699485bf4560e6db247cfba2ed713171f434a76a8f90c55facae9a.json new file mode 100644 index 000000000..517dc00db --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/3d8bcf2693699485bf4560e6db247cfba2ed713171f434a76a8f90c55facae9a.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":45,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"cb54de1a2fb520d4b1b98a38732eeb0ae9260679c0ef38ee0c2db625d4690f08","root_hash":"b11ff8e897523699345a87ae17abe8f6de5d0ce1263e67acbd4514949e250c35","sig":"d9e95b6ec5b60023e3c1ae73dc1174e9f05c8ddbbb443df11a0ce422cacf89fa0283ff3dc4727180cb205e75b41340ce89e278f94c545a8f77b1d64a3e55eb07","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/45f8fa1e36cb9988a8ffecc639e92feb3c2cce92e70eeaebea7ce2bd75c5e581.json b/wai/http-conformance/cases/rcpt-deep/45f8fa1e36cb9988a8ffecc639e92feb3c2cce92e70eeaebea7ce2bd75c5e581.json new file mode 100644 index 000000000..3626eb1d7 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/45f8fa1e36cb9988a8ffecc639e92feb3c2cce92e70eeaebea7ce2bd75c5e581.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":11,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"ce8cdb9ba42eae1916d6543cc0df37a0e9e3cf6eb9f23dace8cadbcc68a1ce6e","root_hash":"c7698fb31901c6d88bece812a242874c83afa5cc6f1cd3b1a6fc9b18b07b01db","sig":"2429af876ef9db23ee01d0b2d02532e1cb96ac35bd34e766195ed59c560d51d2b1af42ffda916676787d2b23f1493b4ff07dffed5aae3a7ceb5e4c554bc9f40c","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/4a929990963ba614aaac8bb2896cf2f0b9f1bb4724dec179d2457576e6de493b.json b/wai/http-conformance/cases/rcpt-deep/4a929990963ba614aaac8bb2896cf2f0b9f1bb4724dec179d2457576e6de493b.json new file mode 100644 index 000000000..8c1d82e30 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/4a929990963ba614aaac8bb2896cf2f0b9f1bb4724dec179d2457576e6de493b.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":49,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"9437c7584edf8061151cc407cdc2cf5875138daa33def243f8e7958095159d43","root_hash":"2c40459481075ebd4a519f164bc9e314bc1dc4883b7a31e1d005bd801c7cf3a6","sig":"4e4cf7d43629fc7b207ae6e5590c8bf08d0a6bf390d4c7e9e4fe5909f2e763068450ec5032d10d2f557eaccd562abcb31df7a76727c7f4165f97f5d33447de0c","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/4fddb12f5ebd443ad749945c70453e80223fa9f89c642fbc14bb736a4fcc0c19.json b/wai/http-conformance/cases/rcpt-deep/4fddb12f5ebd443ad749945c70453e80223fa9f89c642fbc14bb736a4fcc0c19.json new file mode 100644 index 000000000..9a209e0ff --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/4fddb12f5ebd443ad749945c70453e80223fa9f89c642fbc14bb736a4fcc0c19.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":65,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"63379f1324fcb7c7d65bec7ea0d81f585fad641c0881076dae2384c0dab77350","root_hash":"e95d9348d502540b2ec55b9ffbfcca3f28ddf098b621f2b3c7e8af71b312d066","sig":"acb1b2461b193ecb38f1e4f7d7621134140fba0c5f91bb40b74c9bc5d3652f34fa8b3f782fbb6e2b9eddd99b3d4c58991a08a16cd278817f89981a5fe1e1e701","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/5cf23a6733332727a616961d36300beb3ab39a637d985efa02f51f9ad9a788b1.json b/wai/http-conformance/cases/rcpt-deep/5cf23a6733332727a616961d36300beb3ab39a637d985efa02f51f9ad9a788b1.json new file mode 100644 index 000000000..12d0f52de --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/5cf23a6733332727a616961d36300beb3ab39a637d985efa02f51f9ad9a788b1.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":6,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"628e127eff3ff5f3fc41008cd2aba447b2e6a5c6b6f7a7c355aeee5131614845","root_hash":"febbe500a3ec7f780b74ed90e380bc6787f2aa58de33e1da2aa48cd020ff6d39","sig":"7cbe7c6fb1d8c8255f54aefd054f33ab447a93d8426eb0558ed2fbf1d5b08a24702830ccdb4cf8d3302eb3eb1a86ea080dfa1ad8dfdb62b385ba9e15f7b8d908","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/628e127eff3ff5f3fc41008cd2aba447b2e6a5c6b6f7a7c355aeee5131614845.json b/wai/http-conformance/cases/rcpt-deep/628e127eff3ff5f3fc41008cd2aba447b2e6a5c6b6f7a7c355aeee5131614845.json new file mode 100644 index 000000000..674d4c118 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/628e127eff3ff5f3fc41008cd2aba447b2e6a5c6b6f7a7c355aeee5131614845.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":5,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"8642ba98b3ef5233e6fe4c2b2f8bf0511b48b25ad11139781874ca585dad39db","root_hash":"78bcdb3b5b191cc6a2b18db12339bd6563b560115cdddd48b2c9d4ddc2db9c15","sig":"dd2e0d84a9f6f923d914f91e574050bca6c6eca0802f3004892694f88ddedd6f72a58f4f82332ccc3d8b504af5d4f4b51243f56cdf75bdbfa7d34387df4f4c0a","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/63379f1324fcb7c7d65bec7ea0d81f585fad641c0881076dae2384c0dab77350.json b/wai/http-conformance/cases/rcpt-deep/63379f1324fcb7c7d65bec7ea0d81f585fad641c0881076dae2384c0dab77350.json new file mode 100644 index 000000000..2001dd325 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/63379f1324fcb7c7d65bec7ea0d81f585fad641c0881076dae2384c0dab77350.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":64,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"dddbdf295d8efc9890ea00b8cd09355b8bc12aa8c1b8a0abfa2736dc8e52beca","root_hash":"b3616793d8b3218474f284c7e2785c7fe6776638f1aa57adc24fe8c55a376d54","sig":"38a1a257897e535520b610f77202f0ec8ab56223277b5b8dd227cfc7a70e31a49662687efdb95ac88354258dd47fce5ee39af7b0a99b2abe5afb40b708a69606","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/64100b9ff95fe8517af6d53ffae89420bcc4855b7a9fbfa9c0c2fcfd4c8f6e02.json b/wai/http-conformance/cases/rcpt-deep/64100b9ff95fe8517af6d53ffae89420bcc4855b7a9fbfa9c0c2fcfd4c8f6e02.json new file mode 100644 index 000000000..294d682d2 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/64100b9ff95fe8517af6d53ffae89420bcc4855b7a9fbfa9c0c2fcfd4c8f6e02.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":30,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"8499e3ae0889231bb4cf44be763227155c737d80c3119d6ef49ee37ac384f0f4","root_hash":"6f2a0d99a41838ea16e8036d802a883aacbe3032b534be0f58ad3fd13089d0ff","sig":"e1bb86183a28a3e66a840dc2791c504b50a3ba68c7ca9dbb65a41131d9108d3bf39c58946fc8f14c2eb95bfb68d84cd2833a5fb502a5286c53f47ffaa0048c09","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/6fe102acdfb722a4ac1e9708bd2524df96612c2b8572fb36187b36aaef695d19.json b/wai/http-conformance/cases/rcpt-deep/6fe102acdfb722a4ac1e9708bd2524df96612c2b8572fb36187b36aaef695d19.json new file mode 100644 index 000000000..39c9080f6 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/6fe102acdfb722a4ac1e9708bd2524df96612c2b8572fb36187b36aaef695d19.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":27,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"9f4e6f8de572ae37b854c3f356c117a451713ae32d05176e8aae7d3cb928da3c","root_hash":"4cc83a55bfc90d849b0634547ff5c539cbcb35ef73b638bf3a13c3e1d698e8d0","sig":"97719491e9c5079101f6196518b7479b436f3afa5251fe9c1cb5c4068c4a83411e8d7c4e8eedcc7b80c7af44aa1b8f04586eb38219a5408e17d1b4e9f48bb203","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/702d3f032ec6e859379cc73da1b5e2af6d8522ec13b20b3cb80579065cf3cb8f.json b/wai/http-conformance/cases/rcpt-deep/702d3f032ec6e859379cc73da1b5e2af6d8522ec13b20b3cb80579065cf3cb8f.json new file mode 100644 index 000000000..efd7c19f2 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/702d3f032ec6e859379cc73da1b5e2af6d8522ec13b20b3cb80579065cf3cb8f.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":9,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"36f03021b405cf486db202f776174b6c81098add4784395a59327037c411aa05","root_hash":"2bf3fcf47f10adda38d9e84e6daa104b7ad428c8a7569f2f1baebd0c86116c0c","sig":"a998bb7ee846b7842ce19937c1934b7e1cd3c0dff3032abb8de8b1dd240bc1a89075ca8f91ca1348cf916c1032cb68b28244541b483d083bda4a367f90e2fa08","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/7209b5f3d838d80969f2bedb44b36c62b9cb980de376565d29be1a36290673db.json b/wai/http-conformance/cases/rcpt-deep/7209b5f3d838d80969f2bedb44b36c62b9cb980de376565d29be1a36290673db.json new file mode 100644 index 000000000..262731688 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/7209b5f3d838d80969f2bedb44b36c62b9cb980de376565d29be1a36290673db.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":54,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"f424f181500d06d27e5344e5ef28ff6a68eed7386ead966dd4c5f11baf14d41b","root_hash":"3f0ce8fdce0974ea8a459ef05a50f49892ff45763b93d77091dd366437386b34","sig":"98186af124ee0d0870b0298ade1ec95b11319e7f0ba0c7855f345607e2bd05d9286e9c6a02496dd85a3f6c466f2626317ab896a3e4b801e25afde366ba6a6b05","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/727ec6e7702c42e445c28f7db5ca526c4cfcf72207a793b8a75b457b0004cbb7.json b/wai/http-conformance/cases/rcpt-deep/727ec6e7702c42e445c28f7db5ca526c4cfcf72207a793b8a75b457b0004cbb7.json new file mode 100644 index 000000000..1f4541d5c --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/727ec6e7702c42e445c28f7db5ca526c4cfcf72207a793b8a75b457b0004cbb7.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":16,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"a14dd2629372b10738a35dcb3e71db5b60c2e6aac29776766f60df21debaa7a6","root_hash":"1b0bc2320fc07c2dfb31afd5442e8960e7e057666dbf0e29ba010a090460696d","sig":"c0093f3d85b441b68875aeb16dc44a05faaeab9530ca88dc6ac734494af20acbc0e5e2eb9b038c8b26bf941990896ec19a5964d05f1c811e027b953f9a2b8b08","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/73c6117b02857292a6b459ed1f433b42d7455a91a2941aab05c31a54a0ddf30a.json b/wai/http-conformance/cases/rcpt-deep/73c6117b02857292a6b459ed1f433b42d7455a91a2941aab05c31a54a0ddf30a.json new file mode 100644 index 000000000..ff140a639 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/73c6117b02857292a6b459ed1f433b42d7455a91a2941aab05c31a54a0ddf30a.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":57,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"3c8ead78e4ccaaa1b49790f87ea593a38e586e45cf8d2d7c1c575e64d3322893","root_hash":"fd1fa378fc47672fac5acfbf23211009991aeeec45f905812b514cd1f4bf80c1","sig":"9cd1d0aa49c8a2eb7600853365fdfad741880273de4f35b008b500aacdb28a3ddf0a7b36f130e78d018a641a9692d39c63621864a7f4ea30ce67f2f72fb2fb02","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/82a9fe0631aaaa7a86edc18e09dad8f9080ae1faa6089a2632a9ce028562740a.json b/wai/http-conformance/cases/rcpt-deep/82a9fe0631aaaa7a86edc18e09dad8f9080ae1faa6089a2632a9ce028562740a.json new file mode 100644 index 000000000..2198a2f32 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/82a9fe0631aaaa7a86edc18e09dad8f9080ae1faa6089a2632a9ce028562740a.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":24,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"9db4594f2e453f9cff014f3028d4c02dd5862b28855783f836891ec26e911616","root_hash":"c239061f02af70e8a96990905ecf2a23e8fcedf71fa2b92c3dd16b42f5a8d0d1","sig":"fb2d679c452a49ae81e95487d8c3b30225bab6446162cd679c9de333b33d84995dd46c9e36385692605b0052a776dd51f705f8cf97fdbbc373a5ad3d26f35d01","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/8499e3ae0889231bb4cf44be763227155c737d80c3119d6ef49ee37ac384f0f4.json b/wai/http-conformance/cases/rcpt-deep/8499e3ae0889231bb4cf44be763227155c737d80c3119d6ef49ee37ac384f0f4.json new file mode 100644 index 000000000..3e1980e4c --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/8499e3ae0889231bb4cf44be763227155c737d80c3119d6ef49ee37ac384f0f4.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":29,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"25632b6926e484b967f63df5a868d252c15d8fbba68d6bd6beb7c5db2bb8b284","root_hash":"3f131d500a428e452804bf5fcd2671f9ce41c80164860772587eca6a9783b087","sig":"ed1f5dedc6610c846c4a3d8b66b6ed46db626b1aefe1fd637a19d723ae54f9917837acde9fa5cef0df93b9fbadc31eea66c6d0ad43b60af35ece73dae39a5d00","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/8642ba98b3ef5233e6fe4c2b2f8bf0511b48b25ad11139781874ca585dad39db.json b/wai/http-conformance/cases/rcpt-deep/8642ba98b3ef5233e6fe4c2b2f8bf0511b48b25ad11139781874ca585dad39db.json new file mode 100644 index 000000000..1081ac36b --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/8642ba98b3ef5233e6fe4c2b2f8bf0511b48b25ad11139781874ca585dad39db.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":4,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"9bc7eb5b3a07d2c3bfa344a7f32612128c54efa856c8d8feab4c2d768559a153","root_hash":"f367d35de324aa1583e8b7979bb5362be2ccaacd417a47dfd225402d9ef8fbdd","sig":"6a2d7f5928d55ea90a3c125781d5ee6f149c751d969576a52c88fe71ae10d9ca0005aaec0d1df2412554c155959f2ee54df146d2fbbc934ad75de4529440e301","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/89f6acee1ffd1ab9abcee3f448f2a7d1265f5de70c40ecdbd514fc2412a9581d.json b/wai/http-conformance/cases/rcpt-deep/89f6acee1ffd1ab9abcee3f448f2a7d1265f5de70c40ecdbd514fc2412a9581d.json new file mode 100644 index 000000000..609c41132 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/89f6acee1ffd1ab9abcee3f448f2a7d1265f5de70c40ecdbd514fc2412a9581d.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":12,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"45f8fa1e36cb9988a8ffecc639e92feb3c2cce92e70eeaebea7ce2bd75c5e581","root_hash":"ceb79bda8cc7d0af2e400d611198cbda13698f5ea38a99b14012c74ac7d13d66","sig":"79a9ba6333dfb5c79a7f75337b36fb01e6cb3be6ea86931f6beae44847a5103698562f92f352c9eb239f1ff21a764f14a8c28ba05c817cebeb1225906739910b","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/8ce05b04d49062f191ae072bf970ba23863c7cc88b654d178acf61361fc81efa.json b/wai/http-conformance/cases/rcpt-deep/8ce05b04d49062f191ae072bf970ba23863c7cc88b654d178acf61361fc81efa.json new file mode 100644 index 000000000..78a305c61 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/8ce05b04d49062f191ae072bf970ba23863c7cc88b654d178acf61361fc81efa.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":20,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"d5c54a0582e4aae21a61b315843a4a3bc50af0b97a75f9fc6fa213f438839b59","root_hash":"3293bd5b8d34c63bc98d733b5a46009650fe642a668ad1daa659b577b2a90fbf","sig":"2cf0713a80b268e8747273c8dd2b96e28ab1bc5302f12f44edd7468468489b99c99648c60c80f8eb0cb35ad9495d7548585c5fd74384c3517d219a3bab448608","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/9437c7584edf8061151cc407cdc2cf5875138daa33def243f8e7958095159d43.json b/wai/http-conformance/cases/rcpt-deep/9437c7584edf8061151cc407cdc2cf5875138daa33def243f8e7958095159d43.json new file mode 100644 index 000000000..6e15b9e5d --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/9437c7584edf8061151cc407cdc2cf5875138daa33def243f8e7958095159d43.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":48,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"c40b1d684d5696efc5f2cc1f39cee4d99b4dc4fe5aa6d43992a8c8d5be5bcbe9","root_hash":"2d38a57b8206d3359111802e9878315d9ad5b40ed728cc05f7ccfe739bd541eb","sig":"ba9bffe0bc4a80837120bbce5317ec44ce11adec2c8c0eb0e5e4d4e77538bd2ed29176ef508409789af8eb6e85cb0f35cb712dc78fc3fdb697aa8d748ae8c204","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/9add9e068b526da6bd0d7dd6bac6f0bd53630f0cff4e303b0e2b5fa7032ab7b8.json b/wai/http-conformance/cases/rcpt-deep/9add9e068b526da6bd0d7dd6bac6f0bd53630f0cff4e303b0e2b5fa7032ab7b8.json new file mode 100644 index 000000000..bacdeea7a --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/9add9e068b526da6bd0d7dd6bac6f0bd53630f0cff4e303b0e2b5fa7032ab7b8.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":51,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"9be1a59aa2577c7431548c5d117413044a37fdc1a05cb72c58d0ec16770bcfdf","root_hash":"58352e524be52f2772d3cad18651fd35ca9784258515a9b0c252deb56e2aa76e","sig":"0c579d3b232dd622ffc5eb886acbfb6d614f20d0768a1ad33ee59fb0327f87199fdb06a03a4cc8a716f4756d9bcd3129f9d11ffec2df984362471f436c68e60c","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/9bc7eb5b3a07d2c3bfa344a7f32612128c54efa856c8d8feab4c2d768559a153.json b/wai/http-conformance/cases/rcpt-deep/9bc7eb5b3a07d2c3bfa344a7f32612128c54efa856c8d8feab4c2d768559a153.json new file mode 100644 index 000000000..f9090e48f --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/9bc7eb5b3a07d2c3bfa344a7f32612128c54efa856c8d8feab4c2d768559a153.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":3,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"dce4681c9a487a24a4b20f216f79d8319dfb7048ca2d965f1d34e5724e33d802","root_hash":"608ab68252a3cba5e1af70044e718d4c195e38e1b302505bd13011eb96f98b7a","sig":"7b368cb784f34dcb41fac59d9050d465e2133bfe811d5a3929aac7e2401825e3b6733c9ac5e7b0cfc856bf3719aea19150eb71a43fa285bcee9f840ff86c2301","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/9be1a59aa2577c7431548c5d117413044a37fdc1a05cb72c58d0ec16770bcfdf.json b/wai/http-conformance/cases/rcpt-deep/9be1a59aa2577c7431548c5d117413044a37fdc1a05cb72c58d0ec16770bcfdf.json new file mode 100644 index 000000000..f042b898e --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/9be1a59aa2577c7431548c5d117413044a37fdc1a05cb72c58d0ec16770bcfdf.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":50,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"4a929990963ba614aaac8bb2896cf2f0b9f1bb4724dec179d2457576e6de493b","root_hash":"422d01d518378dd434dcabd52a104902f82008fbec8481f829c4461ad8662e1c","sig":"e0f1375e470ce6804c8e46c050c7bc9d8b8b2b5db1c1c4f70842e113e48e64922ae5a6bbe8bca2f95b08a4148b179988a28ff002625322b83a84b32f526a4008","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/9db4594f2e453f9cff014f3028d4c02dd5862b28855783f836891ec26e911616.json b/wai/http-conformance/cases/rcpt-deep/9db4594f2e453f9cff014f3028d4c02dd5862b28855783f836891ec26e911616.json new file mode 100644 index 000000000..af6201607 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/9db4594f2e453f9cff014f3028d4c02dd5862b28855783f836891ec26e911616.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":23,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"e13cff8676af896dc1c0a7b82178a8dea0f39e2c1ffe5fe5fd18cbfbd38fcf13","root_hash":"10fc6a3be74e8ade3e56dfc46a1ffd4626f1aa907b6ef691f50abfc9c946d00a","sig":"4c2a1559b57b581cfe3d8a7a9e8fae9da97681a9f094cf31b4704ea6bdafaa6f5d11f361f592762df4143920c13399731241ffb33136f01b266719801a8b3602","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/9f4e6f8de572ae37b854c3f356c117a451713ae32d05176e8aae7d3cb928da3c.json b/wai/http-conformance/cases/rcpt-deep/9f4e6f8de572ae37b854c3f356c117a451713ae32d05176e8aae7d3cb928da3c.json new file mode 100644 index 000000000..a6e2d529d --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/9f4e6f8de572ae37b854c3f356c117a451713ae32d05176e8aae7d3cb928da3c.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":26,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"003ce009852d8c0c3e808699db87f862368967295eaad10087ee86cd356a65dc","root_hash":"c6ed74cf5f60fdc95368574db8113aaf0113a56bd6e7d8a159e08047b5a84b93","sig":"6075493ab96746b061cb99dbdd3f583898abdf4b504f6345e11b3a0aad37a351af1e5fd756f905b6c9cba48729c23f55355479d567255c07f562b3fe5fdc6303","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/a14dd2629372b10738a35dcb3e71db5b60c2e6aac29776766f60df21debaa7a6.json b/wai/http-conformance/cases/rcpt-deep/a14dd2629372b10738a35dcb3e71db5b60c2e6aac29776766f60df21debaa7a6.json new file mode 100644 index 000000000..bdab9fa74 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/a14dd2629372b10738a35dcb3e71db5b60c2e6aac29776766f60df21debaa7a6.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":15,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"fcdf29a5c1f6567155dd0df394306fd179f9fdd04b252535d937c6537004735c","root_hash":"442c93c524f9de9be026d1b9a7bea2c1a94602ab1950e19d12494bb2e1ec7d68","sig":"5682bb506edee5371405e0eed42e4a1310115d70cbd3077fc3fe811eb3d290f81ebc0910053e32cf46ca4952326cc786ad80353575a78e38d214a0343c948d06","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/a577ff30a365f5af48f5ea6f2b874ea9520ec024639f33f9fcc8545d0535ea64.json b/wai/http-conformance/cases/rcpt-deep/a577ff30a365f5af48f5ea6f2b874ea9520ec024639f33f9fcc8545d0535ea64.json new file mode 100644 index 000000000..d36dfb92b --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/a577ff30a365f5af48f5ea6f2b874ea9520ec024639f33f9fcc8545d0535ea64.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":58,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"73c6117b02857292a6b459ed1f433b42d7455a91a2941aab05c31a54a0ddf30a","root_hash":"d38f2afa7a1fbea16f5176bb0ea463588572e7a2204db26818e97a0c276a4328","sig":"189319842f856bb56cd3fcb95e826f64b8a118ecbf37453d862bb385691830a13c9a15ab4a7439e2406d519ba8595809add1060ccf7bc111f58e97447a268a0f","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/ae03f97d23795d99c4db5ddf04668bac928bd9e90969a20221cd7326e6a0cd1e.json b/wai/http-conformance/cases/rcpt-deep/ae03f97d23795d99c4db5ddf04668bac928bd9e90969a20221cd7326e6a0cd1e.json new file mode 100644 index 000000000..48bfd2759 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/ae03f97d23795d99c4db5ddf04668bac928bd9e90969a20221cd7326e6a0cd1e.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":42,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"c5c6797e71d82c249afdfd5fa3e45c607168f5f46caab52de55ade65a7dca6db","root_hash":"1de3128d0455a29c3eea4460d02878a4335e9b467ffc2c115cb7e690081786cd","sig":"eb0d5bb76ff3e014f33b1eeae1847190f538bc6c2cbafa69c0d27741de80c6c9341e5f163955945029aa2f22b7ac48227d526747b77d59e33f9ae6f3b2880505","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/ba386a976d3bb07999fa2b48a0505aad51f5bcf1ad926f30e17475143abdaef7.json b/wai/http-conformance/cases/rcpt-deep/ba386a976d3bb07999fa2b48a0505aad51f5bcf1ad926f30e17475143abdaef7.json new file mode 100644 index 000000000..b8b9509e5 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/ba386a976d3bb07999fa2b48a0505aad51f5bcf1ad926f30e17475143abdaef7.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":61,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"fbe11c778ba08a3178591ad782e1f2392419aad7d8b53be5e4e8432d7100c7fe","root_hash":"5d33516bd5bfa72f37aaf04c6a4848df4eed06bd04b84dac8e4f5b28e406f6d1","sig":"8dd7b67f160bf89f5596ca913ba1bb8d9cbe908d3fdfdd75f1741ea2f7f4dff8b9e97b9c68fd3aad9c8e5b568b068a39b95fc982eb0e6b8ecfa1706c9f46810b","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/bc54f7a82afbf23e1a81249785409dbf27d11f1c7871850d11f37afd637b0d8a.json b/wai/http-conformance/cases/rcpt-deep/bc54f7a82afbf23e1a81249785409dbf27d11f1c7871850d11f37afd637b0d8a.json new file mode 100644 index 000000000..6c94f3e3e --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/bc54f7a82afbf23e1a81249785409dbf27d11f1c7871850d11f37afd637b0d8a.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":34,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"21dcc9e852e59acff6ae0749f4a6afb81621f6a9226825ef597c860f306ff755","root_hash":"3a89c7e674b975bdd0a4e191ee3d2cecf468e9ff9b7c362a212229caf3488be0","sig":"fcf5e28a000a1cf6800cf4221fe18630eee0c9917e8ced467b6c4be2cdf0980fc0575930a54a64176ac835557de187ee3d829e40296a88a094b74ac67b6bd10e","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/c018e85a69702e3966349a8b1a5a6cb91925f53377612947760bcabee6325e46.json b/wai/http-conformance/cases/rcpt-deep/c018e85a69702e3966349a8b1a5a6cb91925f53377612947760bcabee6325e46.json new file mode 100644 index 000000000..6d3dd1c98 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/c018e85a69702e3966349a8b1a5a6cb91925f53377612947760bcabee6325e46.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":38,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"c24a80173b436fddaec2e9efe8a243eb58334442ebbc0f5170d219fc833c1b56","root_hash":"10afe37e8a34ea9ef5f03c97bc61435ca36a16a3956885ffa6a1567b342b7337","sig":"9dfa4a3270750f942197fc8e52658cb4eeae00af4f7b483c83cc335df0834cc00d04e35c95b1ade49bc104ea853d85c2f9d12e98a8cf55cd4e6c5b4aced0970e","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/c24a80173b436fddaec2e9efe8a243eb58334442ebbc0f5170d219fc833c1b56.json b/wai/http-conformance/cases/rcpt-deep/c24a80173b436fddaec2e9efe8a243eb58334442ebbc0f5170d219fc833c1b56.json new file mode 100644 index 000000000..317025713 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/c24a80173b436fddaec2e9efe8a243eb58334442ebbc0f5170d219fc833c1b56.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":37,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"ff696c1fecfcfdf1b1fe0d3a3bc7124039209dc2b8dfbb2efd9ee7dcbafc955a","root_hash":"eb80ab8c660d2217484ea3dbcbb05d103de5d1b78dfbbfc97792b73285a8182f","sig":"a73fa90c1e7fc446ecad0ccc6eb5536c76e33e92f0215996fbbbae15ce7c4d6a13655c4eea4f0225becb2c48b834e67458e07e970c0ccb89ab3b5e473136260e","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/c40b1d684d5696efc5f2cc1f39cee4d99b4dc4fe5aa6d43992a8c8d5be5bcbe9.json b/wai/http-conformance/cases/rcpt-deep/c40b1d684d5696efc5f2cc1f39cee4d99b4dc4fe5aa6d43992a8c8d5be5bcbe9.json new file mode 100644 index 000000000..335789df8 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/c40b1d684d5696efc5f2cc1f39cee4d99b4dc4fe5aa6d43992a8c8d5be5bcbe9.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":47,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"319e3bd23ecc66f714d2226d160457fdface7e18022d071f2332adf1dc42f60a","root_hash":"a308d60a2fa2a48a80462991469a36c2d7be9ac69ed56ed4323d8e11357d39ce","sig":"e65c1a6f25cedb3675f846e44c388e314b2d3630c896b4dc753a430d5bc83fa203d5e566af69463558faecc4e3070567da8798294b380e8489bb4fb37f71eb00","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/c5c6797e71d82c249afdfd5fa3e45c607168f5f46caab52de55ade65a7dca6db.json b/wai/http-conformance/cases/rcpt-deep/c5c6797e71d82c249afdfd5fa3e45c607168f5f46caab52de55ade65a7dca6db.json new file mode 100644 index 000000000..733a713d2 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/c5c6797e71d82c249afdfd5fa3e45c607168f5f46caab52de55ade65a7dca6db.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":41,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"316ef8d8a425cee1a88181a83f46698d36124879649ebdef8104c66398a37580","root_hash":"199ec3d5e8002144a82aaf27b9be13c608de8a67795ce89d46ccf62f788a37c0","sig":"890c40427a1911a011d59dd35719931819e84803a9b327fc99d053540df9919a171e28f7ea314f6d18ffa7cd6e710422d45accde765845ee9379b8ee130d570c","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/cb54de1a2fb520d4b1b98a38732eeb0ae9260679c0ef38ee0c2db625d4690f08.json b/wai/http-conformance/cases/rcpt-deep/cb54de1a2fb520d4b1b98a38732eeb0ae9260679c0ef38ee0c2db625d4690f08.json new file mode 100644 index 000000000..35c920d26 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/cb54de1a2fb520d4b1b98a38732eeb0ae9260679c0ef38ee0c2db625d4690f08.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":44,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"009454fd76395b07a8db1c205e1eceb2fc39b70d6f19ce28f029f0aa25489eac","root_hash":"3e8cbc691a02138642542f384c7afe3c10c9c3433021a1c8390fef1f5bee3b91","sig":"97f6d9efc72c57715853a296748a13e6fde0193dbcb4bac184fb28d50a30f7f7da0d0268cc0fd735f18da5c1364ee0bf2508e3ea77c3808a7e45be4b43733603","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/ce8cdb9ba42eae1916d6543cc0df37a0e9e3cf6eb9f23dace8cadbcc68a1ce6e.json b/wai/http-conformance/cases/rcpt-deep/ce8cdb9ba42eae1916d6543cc0df37a0e9e3cf6eb9f23dace8cadbcc68a1ce6e.json new file mode 100644 index 000000000..62175cbc7 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/ce8cdb9ba42eae1916d6543cc0df37a0e9e3cf6eb9f23dace8cadbcc68a1ce6e.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":10,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"702d3f032ec6e859379cc73da1b5e2af6d8522ec13b20b3cb80579065cf3cb8f","root_hash":"5ef7f6d313365e53428317272b802b3deadb8a8f9e9e89625f0b27e4f9b40a38","sig":"24b032ca9c18e09ebbf5a302ba180bea90b25052e150d7b013a0ff6cdc094f138be69bfa8597ea4148455112bbea23afb1fa1c85031622dd62bdf023ccaf6d0e","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/d5c54a0582e4aae21a61b315843a4a3bc50af0b97a75f9fc6fa213f438839b59.json b/wai/http-conformance/cases/rcpt-deep/d5c54a0582e4aae21a61b315843a4a3bc50af0b97a75f9fc6fa213f438839b59.json new file mode 100644 index 000000000..957edb253 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/d5c54a0582e4aae21a61b315843a4a3bc50af0b97a75f9fc6fa213f438839b59.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":19,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"e2dfcd5e2cf232b3a0077e4a2c93598bd49a5408497bea9048e2dc1519655d19","root_hash":"5ee8619e187bbdecab6c6ee7cddfeff22f1bb8b8efd07a0e19671e0e858635fb","sig":"5320ce3f025426c22f9ff13c272746a33d9d5a6dddfe3f5c2ee1171213559c62fce2fa6470fefb5edeb39d06c20be30519deebc63e73490d424d432c22d9510c","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/dce4681c9a487a24a4b20f216f79d8319dfb7048ca2d965f1d34e5724e33d802.json b/wai/http-conformance/cases/rcpt-deep/dce4681c9a487a24a4b20f216f79d8319dfb7048ca2d965f1d34e5724e33d802.json new file mode 100644 index 000000000..810965117 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/dce4681c9a487a24a4b20f216f79d8319dfb7048ca2d965f1d34e5724e33d802.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":2,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"186babdb6cd3cfbc6d5ea3420bd0b9583d3a3328544f90d1f6ec337e833da309","root_hash":"76bc8ac36e4074bf211ba9c943ac7802c06c2feaab17d2adab88305e65149726","sig":"6ed03c784041ec9af4b1ec277881b1e8ff727cad2d6d0229335263bcf43e6b084ea2dc8e7386488f2112c980b755017ca2c5fca7e9b7fc83cfebcc6d0731030e","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/dddbdf295d8efc9890ea00b8cd09355b8bc12aa8c1b8a0abfa2736dc8e52beca.json b/wai/http-conformance/cases/rcpt-deep/dddbdf295d8efc9890ea00b8cd09355b8bc12aa8c1b8a0abfa2736dc8e52beca.json new file mode 100644 index 000000000..1a2d64b76 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/dddbdf295d8efc9890ea00b8cd09355b8bc12aa8c1b8a0abfa2736dc8e52beca.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":63,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"eff8231145c90bca502126ff8268464a9c30a11436d3efd3a8aff1e9cc065d3f","root_hash":"19d01e1ed00ed77f58f2c3e6cd3c5f8499636f23642f18daf1ed2ab30cf19c26","sig":"3cfc67a581a88b227f85f20a360e36ffd353864b0964bc3ea2fabbc9a3dc6835b6151a5561514209cab83a17cc0a797ca8650c7fce1272a84304720d08d6c107","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/dde8580c86bd5c8b984f26ecf19b9f93f2dfef67c08fa99138c2bbcbfc5fb387.json b/wai/http-conformance/cases/rcpt-deep/dde8580c86bd5c8b984f26ecf19b9f93f2dfef67c08fa99138c2bbcbfc5fb387.json new file mode 100644 index 000000000..0df87c2b9 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/dde8580c86bd5c8b984f26ecf19b9f93f2dfef67c08fa99138c2bbcbfc5fb387.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":7,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"5cf23a6733332727a616961d36300beb3ab39a637d985efa02f51f9ad9a788b1","root_hash":"820ca9b68f7cdee5aececf2c88e88eb93a26c75f68b6477434b49b1df4b1f8fe","sig":"7bacfbeedd9e6ace35b673b71ddf58045a68463b7bcc9d128d39f806832e34eadd1ca2aba7c69382c88def7611c2a1334f418601c255c03f474cc465e0fbb204","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/e13cff8676af896dc1c0a7b82178a8dea0f39e2c1ffe5fe5fd18cbfbd38fcf13.json b/wai/http-conformance/cases/rcpt-deep/e13cff8676af896dc1c0a7b82178a8dea0f39e2c1ffe5fe5fd18cbfbd38fcf13.json new file mode 100644 index 000000000..234ab2aa0 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/e13cff8676af896dc1c0a7b82178a8dea0f39e2c1ffe5fe5fd18cbfbd38fcf13.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":22,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"f8d4ee129c6d180112f714e02ac68eed633ebf2a1bfcee7f1fe8bc91d57b4b62","root_hash":"8b16dd1913dc61fdc34e54095e2823ea6b9af830a59d65798463cefaa6271af2","sig":"c252f417457c7b39dde25c68c33edc56ddb2543153dbdf95a5a560596a060b474b9e60645d05daf75d6fddfaf5eac18be979db128e8d6d2820c667e36c7f540c","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/e2dfcd5e2cf232b3a0077e4a2c93598bd49a5408497bea9048e2dc1519655d19.json b/wai/http-conformance/cases/rcpt-deep/e2dfcd5e2cf232b3a0077e4a2c93598bd49a5408497bea9048e2dc1519655d19.json new file mode 100644 index 000000000..2ea110fa9 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/e2dfcd5e2cf232b3a0077e4a2c93598bd49a5408497bea9048e2dc1519655d19.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":18,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"ecd54bf262ce60576be4a2fb0aa1c9f5885e366c534ec7cda62e6bbca91c1e6c","root_hash":"163b98a19991ffecd5abdc61168270c9417bd291af4e0713499225ab24fa30de","sig":"34538f032f22a9a0977a209dfad825ccf2f7d165aab1d58a5e6d770bb8f2671c74a579ccde74af2d4c447406fe80295df1afb0466d09d787103ccc29b5543d08","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/ecd54bf262ce60576be4a2fb0aa1c9f5885e366c534ec7cda62e6bbca91c1e6c.json b/wai/http-conformance/cases/rcpt-deep/ecd54bf262ce60576be4a2fb0aa1c9f5885e366c534ec7cda62e6bbca91c1e6c.json new file mode 100644 index 000000000..434acf6c8 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/ecd54bf262ce60576be4a2fb0aa1c9f5885e366c534ec7cda62e6bbca91c1e6c.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":17,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"727ec6e7702c42e445c28f7db5ca526c4cfcf72207a793b8a75b457b0004cbb7","root_hash":"e881b379ccc2bdd32f7e60cf192ced65e61f1f819db76637ce9d3c5e271ab553","sig":"6f00e7c85aa2b45cecb93bbf6b0c5877010d198217d84038f30d5a33da71a597e006966b851f7f0629f5dbe77d76bba63ba08fe4a0649ab1647d8e895cffe801","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/eff8231145c90bca502126ff8268464a9c30a11436d3efd3a8aff1e9cc065d3f.json b/wai/http-conformance/cases/rcpt-deep/eff8231145c90bca502126ff8268464a9c30a11436d3efd3a8aff1e9cc065d3f.json new file mode 100644 index 000000000..dac89fabf --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/eff8231145c90bca502126ff8268464a9c30a11436d3efd3a8aff1e9cc065d3f.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":62,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"ba386a976d3bb07999fa2b48a0505aad51f5bcf1ad926f30e17475143abdaef7","root_hash":"dc77c06bdb81ba441d0b64be464a4ee9aed5afe41f526b08179a950510f4a3eb","sig":"ffa6b894fa42c107454b811fc64f0d507f3c0a3bc9135469f6e0fe0dfca2b36c2b90ac63f3996c6c0c2c8b1cfc9b3ba71400eb7f1b002cd9c293bd866117200f","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/f1d86cc4dc42d0dde7a760f09076dde6454c304a2b95c1150d244ab8d9868480.json b/wai/http-conformance/cases/rcpt-deep/f1d86cc4dc42d0dde7a760f09076dde6454c304a2b95c1150d244ab8d9868480.json new file mode 100644 index 000000000..e4037ebfa --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/f1d86cc4dc42d0dde7a760f09076dde6454c304a2b95c1150d244ab8d9868480.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":59,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"a577ff30a365f5af48f5ea6f2b874ea9520ec024639f33f9fcc8545d0535ea64","root_hash":"c682354abc6bc3647d119e22ab284e863f451d1c42e3d60a3a2f3c5cca9c442a","sig":"75630b78b4d6ba8a12eb8949d371831fd2859c4ce0dfc0517ffbba54531e20e556be0e2a494abf89b7a9f5101eb0506240c9f2117eb2e778768160a1d4a68406","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/f2fed1d8ecfa414530d8bbc5114c80946a313a0c46a0d624c5083cfc4f9ab61b.json b/wai/http-conformance/cases/rcpt-deep/f2fed1d8ecfa414530d8bbc5114c80946a313a0c46a0d624c5083cfc4f9ab61b.json new file mode 100644 index 000000000..3dcc9ee91 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/f2fed1d8ecfa414530d8bbc5114c80946a313a0c46a0d624c5083cfc4f9ab61b.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":35,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"bc54f7a82afbf23e1a81249785409dbf27d11f1c7871850d11f37afd637b0d8a","root_hash":"82d65bab931e358299e96004ecf3751116988069c6aad5036de8334ca38af376","sig":"5433e450ffa6dc21a5efe41e9a6a348e5fb54bc1a2fb39bac198732b82196a5a85fb02008d631fd62d508b966861a78fb05b77dd85f7a0ffa44de3eed8bee608","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/f424f181500d06d27e5344e5ef28ff6a68eed7386ead966dd4c5f11baf14d41b.json b/wai/http-conformance/cases/rcpt-deep/f424f181500d06d27e5344e5ef28ff6a68eed7386ead966dd4c5f11baf14d41b.json new file mode 100644 index 000000000..a43ce8e1f --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/f424f181500d06d27e5344e5ef28ff6a68eed7386ead966dd4c5f11baf14d41b.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":53,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"3aa91d8e5f296a997f8473822a5558478a3302b02c06c3192986bbcb2d5d27b1","root_hash":"123c39a71f5af2019b8b6cc43e1368a1584df015d3c4a67c4ed879b40c18b820","sig":"20754edbbc0143a5d390b0705bdb9cf3ba79cbc89d15900a5b33a0a0e0ecea3e98ccadf39359f357ffb9c981cba2c81df06824ceaf3fe769e8a60ee1852b4b0c","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/f8d4ee129c6d180112f714e02ac68eed633ebf2a1bfcee7f1fe8bc91d57b4b62.json b/wai/http-conformance/cases/rcpt-deep/f8d4ee129c6d180112f714e02ac68eed633ebf2a1bfcee7f1fe8bc91d57b4b62.json new file mode 100644 index 000000000..71c6daa7f --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/f8d4ee129c6d180112f714e02ac68eed633ebf2a1bfcee7f1fe8bc91d57b4b62.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":21,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"8ce05b04d49062f191ae072bf970ba23863c7cc88b654d178acf61361fc81efa","root_hash":"b824ab92e657202ce8e9f4b978899246192028d4467fc6cc2f8f438c4f056e31","sig":"dd2945bb23417dbbf2c654c251c2e6b70c30f03ed17d3c1ea4429cb6a8bd5e0d3ca6c23f1808230bea909f02ec71bd17aaee0dd21f95271b8a2360953010af0a","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/fbe11c778ba08a3178591ad782e1f2392419aad7d8b53be5e4e8432d7100c7fe.json b/wai/http-conformance/cases/rcpt-deep/fbe11c778ba08a3178591ad782e1f2392419aad7d8b53be5e4e8432d7100c7fe.json new file mode 100644 index 000000000..aeb5d25be --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/fbe11c778ba08a3178591ad782e1f2392419aad7d8b53be5e4e8432d7100c7fe.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":60,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"f1d86cc4dc42d0dde7a760f09076dde6454c304a2b95c1150d244ab8d9868480","root_hash":"6458b7019b5695981f86d818372e4a8c2dcb3977d5a566fa29c38e461d13386a","sig":"869a6f1107e0780deeafacb92e6db68b5de4d513627c28dc796b1a774f939b098b7b1e55681469910e4ec56c3c325e9213605ff21a6956ade3f33ef96a30a301","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/fcdf29a5c1f6567155dd0df394306fd179f9fdd04b252535d937c6537004735c.json b/wai/http-conformance/cases/rcpt-deep/fcdf29a5c1f6567155dd0df394306fd179f9fdd04b252535d937c6537004735c.json new file mode 100644 index 000000000..4644d94e4 --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/fcdf29a5c1f6567155dd0df394306fd179f9fdd04b252535d937c6537004735c.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":14,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"2d31d0bd821ee2578210482b192d2cb47d1d9a9846fc9f032311c4b156b3170a","root_hash":"ced251a071645d86ee1c9230908d643ca77f95f31116470bc260652a3a7adfd0","sig":"26c6118f157aee8eabea938c20668b9de3299dc27b8eb87f08f411dd26ae3812e64a6bc830cbd13058cdf6898e535e4e0bf1feb0bc03259d246031e805e01906","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt-deep/ff696c1fecfcfdf1b1fe0d3a3bc7124039209dc2b8dfbb2efd9ee7dcbafc955a.json b/wai/http-conformance/cases/rcpt-deep/ff696c1fecfcfdf1b1fe0d3a3bc7124039209dc2b8dfbb2efd9ee7dcbafc955a.json new file mode 100644 index 000000000..0769782ea --- /dev/null +++ b/wai/http-conformance/cases/rcpt-deep/ff696c1fecfcfdf1b1fe0d3a3bc7124039209dc2b8dfbb2efd9ee7dcbafc955a.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":36,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"f2fed1d8ecfa414530d8bbc5114c80946a313a0c46a0d624c5083cfc4f9ab61b","root_hash":"dee97708247df3b4f351eb02434662b265f6e0b6ecf4b031cb9ce9ea528e34bc","sig":"93237e15424ba686f8ce91fd859d64b75a106f51ffbb05bcc8854f562f7801a0c95c4ba317184e1b8d8e67b011744773bd9bdc96972a332173b26b56697f750f","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":5} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/6a29eb0066513cad092217b4f905f3decfc703c081c832fb35e3e42f3d1c7043.json b/wai/http-conformance/cases/rcpt/6a29eb0066513cad092217b4f905f3decfc703c081c832fb35e3e42f3d1c7043.json new file mode 100644 index 000000000..d2576b3a4 --- /dev/null +++ b/wai/http-conformance/cases/rcpt/6a29eb0066513cad092217b4f905f3decfc703c081c832fb35e3e42f3d1c7043.json @@ -0,0 +1 @@ +{"group_end":"2026-10-06T00:00:04Z","group_id":1,"group_start":"2026-10-06T00:00:00Z","joules_total":0,"kind":"group","leaf_count":1,"parent_receipt_hash":"3fba8315347da348bbb2f7a4b3f5488ff18a2be9bfb95efc7921142969ec3807","root_hash":"a5d07c9c94c353d57371a2f316d0cf3acb17baa6980ad6ba8b82b0bda83dda9a","sig":"681691b46bd8de7541ec316d8bb144a9bd5e77a077f168e022ee262aefcecb1eba237f8a0226450188eb7ad072981886102f4fcaa64dadad4fbbf3066d5dab08","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":6} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O1-legacy.wai.receipts b/wai/http-conformance/cases/receipts/O1-legacy.wai.receipts new file mode 100644 index 000000000..bd45072f4 --- /dev/null +++ b/wai/http-conformance/cases/receipts/O1-legacy.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"df5337ea38c4d61fd15f006c08df937f480788fd669e7337b0f8105a5816a9d2","group_id":1,"joules_micro":0,"kind":"object","merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":6},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"df5337ea38c4d61fd15f006c08df937f480788fd669e7337b0f8105a5816a9d2","group_receipt":"6a29eb0066513cad092217b4f905f3decfc703c081c832fb35e3e42f3d1c7043","joules_micro":0}}],"kind":"wai-receipt-index","object":"df5337ea38c4d61fd15f006c08df937f480788fd669e7337b0f8105a5816a9d2"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O9a-256-items.wai.receipts b/wai/http-conformance/cases/receipts/O9a-256-items.wai.receipts new file mode 100644 index 000000000..f733faaf3 --- /dev/null +++ b/wai/http-conformance/cases/receipts/O9a-256-items.wai.receipts @@ -0,0 +1 @@ +{"claims":["x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x"],"delivery":[{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}],"kind":"wai-receipt-index","object":"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O9a-257-claims.wai.receipts b/wai/http-conformance/cases/receipts/O9a-257-claims.wai.receipts new file mode 100644 index 000000000..50f79ebb8 --- /dev/null +++ b/wai/http-conformance/cases/receipts/O9a-257-claims.wai.receipts @@ -0,0 +1 @@ +{"claims":["x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x","x"],"delivery":[],"kind":"wai-receipt-index","object":"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O9a-257-items.wai.receipts b/wai/http-conformance/cases/receipts/O9a-257-items.wai.receipts new file mode 100644 index 000000000..e6c60b0b0 --- /dev/null +++ b/wai/http-conformance/cases/receipts/O9a-257-items.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}],"kind":"wai-receipt-index","object":"31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O9c-x50.wai.receipts b/wai/http-conformance/cases/receipts/O9c-x50.wai.receipts new file mode 100644 index 000000000..3f1b2d6e6 --- /dev/null +++ b/wai/http-conformance/cases/receipts/O9c-x50.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}},{"object_receipt":{"content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_id":2,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":1},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133","group_receipt":"8ee7c8ed8cf488b5cffeb7c9c9c500d5f1f1aa5befacff41f2ac4a19bbbc9765","joules_micro":0}}],"kind":"wai-receipt-index","object":"a8ab2cd0cc277ef7109aacdee6bb55ff713aa0d4dc4c2e3dfe40aab759a90133"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/deep-64.wai.receipts b/wai/http-conformance/cases/receipts/deep-64.wai.receipts new file mode 100644 index 000000000..b42ee26dd --- /dev/null +++ b/wai/http-conformance/cases/receipts/deep-64.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"f6fa1ca70e69bee70aa2bd36620b3e93a50ca2e110e1fbb91f3c6747f5deb860","group_id":64,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":5},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"f6fa1ca70e69bee70aa2bd36620b3e93a50ca2e110e1fbb91f3c6747f5deb860","group_receipt":"63379f1324fcb7c7d65bec7ea0d81f585fad641c0881076dae2384c0dab77350","joules_micro":0}}],"kind":"wai-receipt-index","object":"f6fa1ca70e69bee70aa2bd36620b3e93a50ca2e110e1fbb91f3c6747f5deb860"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/deep-65.wai.receipts b/wai/http-conformance/cases/receipts/deep-65.wai.receipts new file mode 100644 index 000000000..e81d4918e --- /dev/null +++ b/wai/http-conformance/cases/receipts/deep-65.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"002ebec0ca68dc089695fcc8b1375c6964fd8f25d07f79c1d3e568a684770b3b","group_id":65,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":5},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"002ebec0ca68dc089695fcc8b1375c6964fd8f25d07f79c1d3e568a684770b3b","group_receipt":"4fddb12f5ebd443ad749945c70453e80223fa9f89c642fbc14bb736a4fcc0c19","joules_micro":0}}],"kind":"wai-receipt-index","object":"002ebec0ca68dc089695fcc8b1375c6964fd8f25d07f79c1d3e568a684770b3b"} \ No newline at end of file diff --git a/wai/http-conformance/http_receipts.py b/wai/http-conformance/http_receipts.py index 197350ab5..26d2eb170 100644 --- a/wai/http-conformance/http_receipts.py +++ b/wai/http-conformance/http_receipts.py @@ -27,7 +27,12 @@ MUTATE = None # set by http_verify.py ORIGIN = "https://origin.example" KEY_LOG_PATH = "/.well-known/wai-keys" RCPT = "objects/rcpt" -WALK_BOUND = 64 +WALK_BOUND = 64 # parents fetched for one group, at most (§8.1) +MAX_RECEIPT_FILE_BYTES = 1 << 20 # a receipt file's bytes, at most (§8.1) +MAX_INDEX_BYTES = 4 << 20 # an index's bytes, at most (§8.2) +MAX_INDEX_ITEMS = 256 # delivery items an index lists, at most +MAX_INDEX_CLAIMS = 256 # claims an index lists, at most +FETCH_BUDGET = 1024 # receipt files fetched for one index, at most INDEX_KIND = "wai-receipt-index" GRANTS_KIND = "wai-module-grants" ACQUIRED = "2026-10-06T00:00:00Z" @@ -89,6 +94,9 @@ def read_group_file(name, b): g = JV.read_group(raw) except JV.Refuse as r: raise Refused(r.reason, r.step) + if not g["labelled"] and MUTATE != "legacy-admitted": + # The legacy link hash does not cover the parent: the name does not fix the bytes. + raise Refused("receipt_legacy_group") if MUTATE != "rcpt-name-unchecked" and JV.link(g).hex() != name: raise Refused("receipt_name_mismatch") return raw, g @@ -113,6 +121,30 @@ def read_claim_file(name, b): return c, later +# ------------------------------------------------------------------ fetching (§8.1, §8.2) + +class Fetcher: + """One index check's receipt files: each name fetched once and what was served kept, + at most a budget of fetches (never above FETCH_BUDGET), none over the file cap.""" + + def __init__(self, get, budget): + self.get_raw, self.memo, self.fetched = get, {}, 0 + self.budget = min(budget, FETCH_BUDGET) + + def get(self, h): + if h not in self.memo or MUTATE == "memo-off": + if self.fetched >= self.budget: + raise Refused("receipt_fetch_budget_exhausted") + self.fetched += 1 + self.memo[h] = self.get_raw(h) + b = self.memo[h] + if b is None: + raise Refused("receipt_not_served") + if len(b) > MAX_RECEIPT_FILE_BYTES: + raise Refused("receipt_too_large") + return b + + # ------------------------------------------------------------------ the index (§8.2) def read_property(p): @@ -125,6 +157,8 @@ def read_property(p): def read_index(b): + if len(b) > MAX_INDEX_BYTES: + raise Refused("receipt_index_too_large") bad = Refused("receipt_index_malformed") text = utf8(b) if text is None: @@ -139,6 +173,8 @@ def read_index(b): raise bad if not isinstance(v["delivery"], list) or not isinstance(v["claims"], list): raise bad + if len(v["delivery"]) > MAX_INDEX_ITEMS or len(v["claims"]) > MAX_INDEX_CLAIMS: + raise Refused("receipt_index_too_large") items = [] for it in v["delivery"]: try: @@ -149,7 +185,7 @@ def read_index(b): o = JV.read_object(it["object_receipt"]) except JV.Refuse as r: raise Refused(r.reason, r.step) - items.append((p, o)) + items.append((p, o, it["object_receipt"])) except Refused as r: items.append(r) claims = [c if CV.is_hex(c, 32) else Refused("receipt_index_malformed") for c in v["claims"]] @@ -175,7 +211,7 @@ class Chains: return last -def admit_walking(ch, raw, g, fetch, bound): +def admit_walking(ch, raw, g, fetcher, bound): try: return ch.admit(raw), 0 except Refused as r: @@ -183,15 +219,18 @@ def admit_walking(ch, raw, g, fetch, bound): raise if MUTATE == "walk-skipped": return "accept", 0 + if MUTATE != "walk-unclamped": + bound = min(bound, WALK_BOUND) pending, link, fetched = [], g["parent_receipt_hash"], 0 while True: if link is None: raise pnh(fetched, "parent_not_held") if fetched == bound: raise pnh(fetched, "bound") - b = fetch(link) - if b is None: - raise pnh(fetched, "receipt_not_served") + try: + b = fetcher.get(link) + except Refused as r: + raise pnh(fetched + 1 if r.code == "receipt_too_large" else fetched, r.code) fetched += 1 try: praw, p = read_group_file(link, b) @@ -234,16 +273,14 @@ def reading(p, o, g): raise Refused("property_disagrees") -def check_delivery(cid, item, held, fetch, ch, bound): - p, o = item +def check_delivery(cid, item, held, fetcher, ch, bound): + p, o, raw_o = item if p["content_hash"] != cid or o["content_hash"].hex() != cid: raise Refused("receipt_not_about_object") name = p["group_receipt"] - b = fetch(name) - if b is None: - raise Refused("receipt_not_served") + b = fetcher.get(name) raw, g = read_group_file(name, b) - admission, walked = admit_walking(ch, raw, g, fetch, bound) + admission, walked = admit_walking(ch, raw, g, fetcher, bound) if MUTATE == "nothing-as-delivered" and held is None: held = (cid, "head-proof") if held is not None and held[0] != o["content_hash"].hex(): @@ -260,32 +297,96 @@ def check_delivery(cid, item, held, fetch, ch, bound): except JV.Refuse as r: raise Refused(r.reason) rd = None - return {"admission": admission, "walked": walked, "evidence": "delivered-verified" if held is not None else "inclusion-verified", - "established": held[1] if held is not None else None, "reading": rd}, g + out = {"admission": admission, "walked": walked, "evidence": "delivered-verified" if held is not None else "inclusion-verified", + "established": held[1] if held is not None else None, "reading": rd} + return out, {"g": g, "o": o, "raw": raw_o, "held": held} # ------------------------------------------------------------------ claims (§8.2) -def check_claim(cid, groups, name, fetch): - b = fetch(name) - if b is None: - raise Refused("receipt_not_served") +def declaration_text(link, fetcher): + """A declaration a claim names, fetched by its link: not served is context_not_held.""" + try: + b = fetcher.get(link) + except Refused as r: + if r.code == "receipt_not_served": + raise Refused("context_not_held", 6) + raise + t = utf8(b) + return t if t is not None else "\u0000" # not UTF-8: no claim reads from it (context_invalid) + + +def step6(c, cid, passed, fetcher, exact_algs): + """§3.1 step 6 over HTTP (§8.2): against the delivery items that passed and the + declarations the claim names.""" + if MUTATE == "step6-skipped": + return + cls, b = c["class"], c["body"] + delivered = [v for v in passed if v["held"] is not None] + try: + if cls == "energy": + for v in delivered: + CV.check_context(c, {"object": v["raw"]}) + if not delivered or b["oer_receipt"] is not None: + raise Refused("context_not_held", 6) + elif cls in ("measurement", "operating-point"): + v = next((v for v in delivered if JV.link(v["g"]).hex() == b["group"]), None) + if v is None: + raise Refused("figure_not_held" if cls == "measurement" else "context_not_held", 6) + fig = {k: v["raw"][k] for k in JV.CLASS_KEYS if k in v["raw"]} + fig.update(index=v["o"]["index"], joules_micro=v["o"]["joules_micro"]) + view = {"profile": "moq-jwp:object", "identity": cid, "group": JV.link(v["g"]).hex(), "figures": [fig], + "signer": v["g"]["signer_pubkey"]} + CV.check_figure(c, CV.figure_view({"view": view})) + elif cls == "delivery-gap": + at = (b["track_alias"], b["group_id"], b["object_id"]) + v = next((v for v in passed if (v["o"]["track_alias"], v["o"]["group_id"], v["o"]["object_id"]) == at), None) + if v is None: + raise Refused("context_not_held", 6) + CV.check_context(c, {"gap_object": {"track_alias": v["o"]["track_alias"], "group_id": v["o"]["group_id"], + "object_id": v["o"]["object_id"], "content_hash": v["o"]["content_hash"].hex()}}) + elif cls == "lawful-basis" and b["record"] == "decision": + CV.check_context(c, {"declaration": declaration_text(bytes.fromhex(b["declaration"]).hex(), fetcher)}) + elif cls == "binding-recovery": + if not passed: + raise Refused("context_not_held", 6) + t = declaration_text(b["declaration"], fetcher) + signers = [v["g"]["signer_pubkey"] for v in passed] + try: + d = CV.parse_strict(t) + ds = d.get("signer_pubkey") if isinstance(d, dict) else None + except ValueError: + ds = None + pub = ds if ds in signers else signers[0] + CV.check_context(c, {"declaration": t, "publisher": pub, "exact_match_algs": exact_algs}) + elif cls == "reconstruct-session": + raise Refused("context_not_held", 6) + except CV.Reject as r: + raise Refused(r.reason, r.step) + + +def check_claim(cid, passed, name, fetcher, recovery): + b = fetcher.get(name) c, later = read_claim_file(name, b) + groups = [JV.link(v["g"]).hex() for v in passed] + pubs = (recovery or {}).get("publishers") or [] + publisher = next((k for h, k in pubs if h == c["subject"]), None) if c["subject_type"] == "object" and c["subject"] == cid: attests = "object" elif c["subject_type"] == "receipt" and c["subject"] in groups: attests = "receipt" - elif c["subject_type"] == "object" and c["class"] == "soft-binding": - attests = "object" if MUTATE == "ancestor-as-object" else "ancestor" + elif c["subject_type"] == "object" and c["class"] == "soft-binding" and (publisher is not None or MUTATE == "ancestor-any"): + attests = "ancestor" else: raise Refused("receipt_not_about_object") if later is not None: raise Refused(later.reason, later.step) parent = c["parent_receipt_hash"] if parent is not None: - pb = fetch(parent) - if pb is None: - raise pnh(0, "receipt_not_served") + try: + pb = fetcher.get(parent) + except Refused as r: + raise pnh(1 if r.code == "receipt_too_large" else 0, r.code) try: prev, plater = read_claim_file(parent, pb) except Refused as r: @@ -296,6 +397,16 @@ def check_claim(cid, groups, name, fetch): CV.check_chain(c, prev) except CV.Reject as r: raise Refused(r.reason, r.step) + algs = (recovery or {}).get("exact_match_algs") or [] + if attests == "ancestor": + if MUTATE != "ancestor-any": + try: + CV.recover({"declaration": b.decode("utf-8"), "publisher": publisher, "observed_hash": cid, + "observed_value": recovery["observed_value"], "exact_match_algs": algs}) + except CV.Reject as r: + raise Refused(r.reason, r.step) + else: + step6(c, cid, passed, fetcher, algs) return {"class": c["class"], "attests": attests, "subject": c["subject"], "parent": parent}, c @@ -530,23 +641,33 @@ def confirm_claim(c, revisions, anchors): # ------------------------------------------------------------------ the ops class Served: + """A case's receipt files: the `rcpt` directories searched for .json, hashes + `omit`ted, hashes served `as` another file, and files `pad`ded with spaces to a + length.""" + def __init__(self, d, v): self.d = d self.dirs = v.get("rcpt") or [RCPT] self.omit = set(v.get("omit") or []) self.swap = v.get("as") or {} + self.pad = v.get("pad") or {} def get(self, h): if h in self.omit: return None + b = None if h in self.swap: p = os.path.join(self.d, self.swap[h]) - return open(p, "rb").read() if os.path.exists(p) else None - for dd in self.dirs: - p = os.path.join(self.d, dd, h + ".json") - if os.path.exists(p): - return open(p, "rb").read() - return None + b = open(p, "rb").read() if os.path.exists(p) else None + else: + for dd in self.dirs: + p = os.path.join(self.d, dd, h + ".json") + if os.path.exists(p): + b = open(p, "rb").read() + break + if b is not None and h in self.pad: + b = b + b" " * max(0, self.pad[h] - len(b)) + return b def receipt_file(d, inp): @@ -566,13 +687,21 @@ def receipt_file(d, inp): def receipts(d, objs, inp, fetch_object, content_hash): served = Served(d, inp.get("serve") or {}) ch = Chains(inp.get("join") is True) - bound = inp.get("max_walk", WALK_BOUND) + bound = WALK_BOUND if inp.get("max_walk") is None else inp["max_walk"] + budget = FETCH_BUDGET if inp.get("fetch_budget") is None else inp["fetch_budget"] keys = key_material(d, inp["keys"]) if inp.get("keys") is not None else None + rec_in = inp.get("recovery") + publishers = [] out = [] for chk in inp["checks"]: - b = read_file(d, chk["object_file"]) if "object_file" in chk else objs[chk["object"]] - cid = content_hash(b).hex() - ix = edit_text(read_file(d, chk.get("index") or f"objects/{cid}.wai.receipts").decode("utf-8"), chk.get("edit_index")) + if "object_cid" in chk: + b, cid = None, chk["object_cid"] + else: + b = read_file(d, chk["object_file"]) if "object_file" in chk else objs[chk["object"]] + cid = content_hash(b).hex() + ixb = edit_text(read_file(d, chk.get("index") or f"objects/{cid}.wai.receipts").decode("utf-8"), chk.get("edit_index")).encode("utf-8") + if "pad_index" in chk: + ixb = ixb + b" " * max(0, chk["pad_index"] - len(ixb)) fetched = None h = chk["held"] if h == "none": @@ -583,7 +712,7 @@ def receipts(d, objs, inp, fetch_object, content_hash): else: held, fetched = fetch_object(b, h["fetch"]) try: - obj, items, claims = read_index(ix.encode("utf-8")) + obj, items, claims = read_index(ixb) if obj != cid: raise Refused("receipt_not_about_object") except Refused as r: @@ -592,35 +721,44 @@ def receipts(d, objs, inp, fetch_object, content_hash): entry["fetched"] = fetched out.append(entry) continue - groups = [it[0]["group_receipt"] for it in items if not isinstance(it, Refused)] - dl = [] + fetcher = Fetcher(served.get, budget) + dl, passed = [], [] for it in items: if isinstance(it, Refused): dl.append(it.json()) continue try: - v, g = check_delivery(cid, it, held, served.get, ch, bound) + v, ctx = check_delivery(cid, it, held, fetcher, ch, bound) if keys is not None: - v["confirm"] = confirm_group(g, it[1], *keys) + v["confirm"] = confirm_group(ctx["g"], it[1], *keys) dl.append(v) + passed.append(ctx) except Refused as r: dl.append(r.json()) + # A recovery runs only for an object the client holds (§8.2, jwp-receipts §3.2). + recovery = None + if rec_in is not None and held is not None: + recovery = {"publishers": publishers, "observed_value": rec_in["observed_value"], + "exact_match_algs": rec_in.get("exact_match_algs") or []} cl = [] for c in claims: if isinstance(c, Refused): cl.append(c.json()) continue try: - v, claim = check_claim(cid, groups, c, served.get) + v, claim = check_claim(cid, passed, c, fetcher, recovery) if keys is not None: v["confirm"] = confirm_claim(claim, *keys) cl.append(v) except Refused as r: cl.append(r.json()) - entry = {"index": "ok", "delivery": dl, "claims": cl} + entry = {"index": "ok", "delivery": dl, "claims": cl, "files_fetched": fetcher.fetched} if fetched is not None: entry["fetched"] = fetched out.append(entry) + # The delivery receipts this check verified over the object's bytes: publisher keys + # a later check may recover through. + publishers += [(cid, v["g"]["signer_pubkey"]) for v in passed if v["held"] is not None] return {"checks": out} diff --git a/wai/http-conformance/objects/6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9.wai.receipts b/wai/http-conformance/objects/6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9.wai.receipts index bec86f26a..3504a1509 100644 --- a/wai/http-conformance/objects/6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9.wai.receipts +++ b/wai/http-conformance/objects/6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9.wai.receipts @@ -1 +1 @@ -{"claims":["14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","dae83c921710311cbd34d8d4a2bedab53cf56a9be5afea2dc3d264ee978a03c5"],"delivery":[{"object_receipt":{"content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","energy_provenance":"Estimator","group_id":0,"joules_micro":1500,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":["368b22537cfa0d3d0bfcfe1f6256302af54c54711dfa411c193beda171c97edb"]},"object_id":0,"origin":"origin","track_alias":2},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","group_receipt":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","joules_micro":1500}}],"kind":"wai-receipt-index","object":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9"} \ No newline at end of file +{"claims":["14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","dae83c921710311cbd34d8d4a2bedab53cf56a9be5afea2dc3d264ee978a03c5","8dc0815e30c004abc33175080c733c3aec1e0187ebd6f8374333f22373d9fe11","144f86074d065d7f6f8bb30b3505870241b439eec6341f7b5dafa55eb5c5e936"],"delivery":[{"object_receipt":{"content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","energy_provenance":"Estimator","group_id":0,"joules_micro":1500,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":["368b22537cfa0d3d0bfcfe1f6256302af54c54711dfa411c193beda171c97edb"]},"object_id":0,"origin":"origin","track_alias":2},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","group_receipt":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","joules_micro":1500}}],"kind":"wai-receipt-index","object":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9"} \ No newline at end of file diff --git a/wai/http-conformance/objects/753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c.wai.receipts b/wai/http-conformance/objects/753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c.wai.receipts index a6360d5cf..bdc355abe 100644 --- a/wai/http-conformance/objects/753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c.wai.receipts +++ b/wai/http-conformance/objects/753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c.wai.receipts @@ -1 +1 @@ -{"claims":[],"delivery":[{"object_receipt":{"content_hash":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","group_id":0,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":2,"leaf_index":2,"siblings":["d6cbe6c9d7da6adbcd392979eef608ca9831a20c65ef141478a0e71613d2c5e2","cf3ca3f2f8980312874592a512e91aa868b4bd835805a06e5e3b777540683581"]},"object_id":2,"origin":"origin","track_alias":3},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","group_receipt":"0755611daa44a9474511562d9ac7e429d4be353fb387c54edd9a24217853ae75","joules_micro":0}}],"kind":"wai-receipt-index","object":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c"} \ No newline at end of file +{"claims":["0c0de1e8d6eca027482086221a3022c5f130d46a45f27aedbf0377b62a0675c0","40ef9986d24aaf108f7d9ba9995c6add781cf5733e6d639d9a783ca0c0ddd61c"],"delivery":[{"object_receipt":{"content_hash":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","group_id":0,"joules_micro":0,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":2,"leaf_index":2,"siblings":["d6cbe6c9d7da6adbcd392979eef608ca9831a20c65ef141478a0e71613d2c5e2","cf3ca3f2f8980312874592a512e91aa868b4bd835805a06e5e3b777540683581"]},"object_id":2,"origin":"origin","track_alias":3},"property":{"acquisition":"","acquisition_evidence":"","content_hash":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","group_receipt":"0755611daa44a9474511562d9ac7e429d4be353fb387c54edd9a24217853ae75","joules_micro":0}}],"kind":"wai-receipt-index","object":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c"} \ No newline at end of file diff --git a/wai/http-conformance/objects/e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b.wai.receipts b/wai/http-conformance/objects/e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b.wai.receipts new file mode 100644 index 000000000..639172222 --- /dev/null +++ b/wai/http-conformance/objects/e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b.wai.receipts @@ -0,0 +1 @@ +{"claims":["b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0"],"delivery":[{"object_receipt":{"content_hash":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b","energy_counter_filtering":"Unknown","energy_provenance":"OnChipCounter","energy_uncertainty":{"relative_ppm":44669,"window_us":3000000},"group_id":0,"joules_micro":461538,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":4},"property":{"acquisition":"OnChipCounter","acquisition_evidence":"0000ae7d00000000002dc6c000","content_hash":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b","group_receipt":"95477f223c6bee89a57c7171c831352fd43f4a348f9d56f90e32e922c2c2b16f","joules_micro":461538}}],"kind":"wai-receipt-index","object":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b"} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/0c0de1e8d6eca027482086221a3022c5f130d46a45f27aedbf0377b62a0675c0.json b/wai/http-conformance/objects/rcpt/0c0de1e8d6eca027482086221a3022c5f130d46a45f27aedbf0377b62a0675c0.json new file mode 100644 index 000000000..3cb83df8f --- /dev/null +++ b/wai/http-conformance/objects/rcpt/0c0de1e8d6eca027482086221a3022c5f130d46a45f27aedbf0377b62a0675c0.json @@ -0,0 +1 @@ +{"body":{"group_id":0,"object_id":2,"observed_at":"2026-10-06T00:02:10Z","reason":"not-received","track_alias":3},"class":"delivery-gap","issued_at":"2026-10-06T00:02:10Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"2305227fe2c0111a0535e6fcfc1bb0e92706bd824e9b7cbe3a9ff843e8f17564bed3883c218b1ca9105d637eb9b5a080668d22a52a447c088ccb5dbfafb7ed09","signer_id":"did:example:http-relay","signer_pubkey":"34b4d9043156cb6dcf0beb0a2949b7559c940d2bcb6dbe8c53a9b30278e3a746","subject":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/144f86074d065d7f6f8bb30b3505870241b439eec6341f7b5dafa55eb5c5e936.json b/wai/http-conformance/objects/rcpt/144f86074d065d7f6f8bb30b3505870241b439eec6341f7b5dafa55eb5c5e936.json new file mode 100644 index 000000000..505ed3f91 --- /dev/null +++ b/wai/http-conformance/objects/rcpt/144f86074d065d7f6f8bb30b3505870241b439eec6341f7b5dafa55eb5c5e936.json @@ -0,0 +1 @@ +{"body":{"coverage":"Whole","energy_calibration_ref":null,"energy_counter_filtering":null,"energy_provenance":"Estimator","energy_uncertainty":null,"grade":"projected","joules_micro":1501,"method":"https://transaction.science/wai/extensions/energy-measurement","oer_receipt":null,"work_unit":"subpixel","work_units":1},"class":"energy","issued_at":"2026-10-06T00:02:00Z","kind":"claim","parent_receipt_hash":"8dc0815e30c004abc33175080c733c3aec1e0187ebd6f8374333f22373d9fe11","seq":4,"sig":"794648a32f165761c1ed74afca133dd48bec849ec58b58773d0e9330b45766cc554253a872c76bfdfb5f7b27a93aa5f8b1ccdc3cdf0903a9abfda26b7e4d5400","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/40ef9986d24aaf108f7d9ba9995c6add781cf5733e6d639d9a783ca0c0ddd61c.json b/wai/http-conformance/objects/rcpt/40ef9986d24aaf108f7d9ba9995c6add781cf5733e6d639d9a783ca0c0ddd61c.json new file mode 100644 index 000000000..b551e0d6b --- /dev/null +++ b/wai/http-conformance/objects/rcpt/40ef9986d24aaf108f7d9ba9995c6add781cf5733e6d639d9a783ca0c0ddd61c.json @@ -0,0 +1 @@ +{"body":{"group_id":0,"object_id":1,"observed_at":"2026-10-06T00:02:10Z","reason":"not-received","track_alias":3},"class":"delivery-gap","issued_at":"2026-10-06T00:02:11Z","kind":"claim","parent_receipt_hash":"0c0de1e8d6eca027482086221a3022c5f130d46a45f27aedbf0377b62a0675c0","seq":1,"sig":"1d6654635b68d4a253e5507efd4ce464e5204509900aa17025a961a3a48d38300bfab95cb8a53f6894158b4a498aed8f56efcccf9d6a031cbf3b68f0f768cd09","signer_id":"did:example:http-relay","signer_pubkey":"34b4d9043156cb6dcf0beb0a2949b7559c940d2bcb6dbe8c53a9b30278e3a746","subject":"753e8ac465d347547c688690eb51615f19118c0a4c17490499fbb31aefd5cd0c","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/8dc0815e30c004abc33175080c733c3aec1e0187ebd6f8374333f22373d9fe11.json b/wai/http-conformance/objects/rcpt/8dc0815e30c004abc33175080c733c3aec1e0187ebd6f8374333f22373d9fe11.json new file mode 100644 index 000000000..7e77eb33e --- /dev/null +++ b/wai/http-conformance/objects/rcpt/8dc0815e30c004abc33175080c733c3aec1e0187ebd6f8374333f22373d9fe11.json @@ -0,0 +1 @@ +{"body":{"coverage":"Whole","energy_calibration_ref":null,"energy_counter_filtering":null,"energy_provenance":"Estimator","energy_uncertainty":null,"grade":"projected","joules_micro":1500,"method":"https://transaction.science/wai/extensions/energy-measurement","oer_receipt":null,"work_unit":"subpixel","work_units":1},"class":"energy","issued_at":"2026-10-06T00:01:50Z","kind":"claim","parent_receipt_hash":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0","seq":3,"sig":"5720366f082ed88f95c1cbc776e22b77fbcabe0b9670b5e8e5dc92dc4343a4426334bd93273900a4c728d954c486e7ad1bd619e0ab34396117e159fd9ed8bf0d","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/95477f223c6bee89a57c7171c831352fd43f4a348f9d56f90e32e922c2c2b16f.json b/wai/http-conformance/objects/rcpt/95477f223c6bee89a57c7171c831352fd43f4a348f9d56f90e32e922c2c2b16f.json new file mode 100644 index 000000000..d67faa585 --- /dev/null +++ b/wai/http-conformance/objects/rcpt/95477f223c6bee89a57c7171c831352fd43f4a348f9d56f90e32e922c2c2b16f.json @@ -0,0 +1 @@ +{"energy_counter_filtering":"Unknown","energy_coverage":"Whole","energy_provenance":"OnChipCounter","energy_uncertainty":{"relative_ppm":44669,"window_us":3000000},"group_end":"2026-10-06T00:00:04Z","group_id":0,"group_start":"2026-10-06T00:00:00Z","joules_total":461538,"kind":"group","leaf_count":1,"parent_receipt_hash":null,"root_hash":"0164608dfa773751ba4de5edd778031dab500cbc8e9c76438377163773ec4c32","sig":"0a3f0fb7fb6a4e6b3dcc62aacfd85329df75639a1b7a62ff212657c0429326acc6166e0dba2b7b6290bc58c8a26e8eda3c935c3c15a284d66a8bd473bfd98a0b","sign_version":2,"signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","track_alias":4} \ No newline at end of file diff --git a/wai/http-conformance/objects/rcpt/b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0.json b/wai/http-conformance/objects/rcpt/b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0.json new file mode 100644 index 000000000..10e50c73c --- /dev/null +++ b/wai/http-conformance/objects/rcpt/b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0.json @@ -0,0 +1 @@ +{"body":{"bracket":{"busy":{"live_intervals":14,"longest_live_run":14,"mean_uw":30000000,"n":14,"sd_uw":115471},"completions":60,"idle":{"live_intervals":10,"longest_live_run":10,"mean_uw":20000000,"n":10,"sd_uw":115471},"in_flight":10,"opening":"from-rest","window_us":3000000},"build":{"profile":"release","target":"x86_64-unknown-linux-gnu","toolchain":"rustc 1.98.0 (example)"},"energy_calibration_ref":null,"energy_counter_filtering":"Unknown","energy_provenance":"OnChipCounter","energy_uncertainty":{"relative_ppm":44669,"window_us":3000000},"figure_index":0,"figure_profile":"moq-jwp:object","group":"95477f223c6bee89a57c7171c831352fd43f4a348f9d56f90e32e922c2c2b16f","joules_micro":461538,"power_reader":"reader:example"},"class":"measurement","issued_at":"2026-10-06T00:01:40Z","kind":"claim","parent_receipt_hash":"dae83c921710311cbd34d8d4a2bedab53cf56a9be5afea2dc3d264ee978a03c5","seq":2,"sig":"76511b02d5ab8b096df498cc4fa5b2ad29056c383e94c8c312606fec81ca2771875704c53399096bbdbe0eecd0548274cedaad7b37aa13dd14f422db7b9a1100","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","subject":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b","subject_type":"object"} \ No newline at end of file diff --git a/wai/wai-rs/src/bin/wai_cli/http.rs b/wai/wai-rs/src/bin/wai_cli/http.rs index 1d5307934..eec3217ab 100644 --- a/wai/wai-rs/src/bin/wai_cli/http.rs +++ b/wai/wai-rs/src/bin/wai_cli/http.rs @@ -46,15 +46,17 @@ //! wai http vectors gen|verify|diff … //! the corpus tool (as `wai_http_vectors`) //! wai http receipts --url http://HOST:PORT/PATH/.wai[#frag] [--advertise CAP]… -//! [--held fetch|none] [--join] [--max-walk N] -//! [--pin-key-log FILE --key-origin ORIGIN] +//! [--held fetch|none] [--join] [--max-walk N] [--fetch-budget N] +//! [--deadline-ms MS] [--key-origin ORIGIN [--pin-key-log FILE | +//! --connect HOST:PORT]] //! fetch the object by §7 (unless --held none), then its receipt index beside it and //! the receipt files it locates, walking parents by their links, and print what each //! delivery item and claim established (§8.1–§8.3): its evidence, how the content -//! hash was established, the property's reading, and, with a pinned key log, each -//! signer's confirmation (§7.4 of jwp-receipts). Exit status 1 when the index is -//! refused or any item is (a build with `receipt_claims`; confirmation needs -//! `key_discovery`) +//! hash was established, the property's reading, and, with a key log pinned or +//! anchored from the origin (§8.4), each signer's confirmation (§7.4 of +//! jwp-receipts). Every exchange is bounded by one deadline and each body by its cap. +//! Exit status 1 when the index, an item or the anchoring is refused (a build with +//! `receipt_claims`; confirmation needs `key_discovery`) //! wai http bench --url http://HOST:PORT/PATH[#frag] --runs N --advertise CAP… --out F //! [--refinement-deadline-ms MS] //! fetch an object N times by §7 over plain HTTP and write a measurement record: @@ -99,7 +101,7 @@ fn positional(args: &[String], flags: &[&str]) -> Vec { } fn usage() -> String { - "usage: wai http publish … --out \n wai http codecs …\n wai http origin --root [--port P] [--delay-range FIRST-LAST:MS]\n wai http hls --objects --out [--master NAME]\n wai http mpd --objects --out [--period-id ID] [--set-id N]\n wai http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]\n wai http check-playlist --receipt [--parent ] [--classical ]\n wai http vectors gen|verify|diff …\n wai http receipts --url URL [--advertise CAP]… [--held fetch|none] [--join] [--max-walk N] [--pin-key-log FILE --key-origin ORIGIN]\n wai http bench --url URL --runs N --advertise CAP [--advertise CAP]… --out F".into() + "usage: wai http publish … --out \n wai http codecs …\n wai http origin --root [--port P] [--delay-range FIRST-LAST:MS]\n wai http hls --objects --out [--master NAME]\n wai http mpd --objects --out [--period-id ID] [--set-id N]\n wai http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]\n wai http check-playlist --receipt [--parent ] [--classical ]\n wai http vectors gen|verify|diff …\n wai http receipts --url URL [--advertise CAP]… [--held fetch|none] [--join] [--max-walk N] [--fetch-budget N] [--deadline-ms MS] [--key-origin ORIGIN [--pin-key-log FILE | --connect HOST:PORT]]\n wai http bench --url URL --runs N --advertise CAP [--advertise CAP]… --out F".into() } pub fn main(args: &[String]) -> Result { @@ -859,6 +861,66 @@ fn bench(args: &[String]) -> Result<(), String> { // ---------------------------------------------------------------- receipts (§8) +/// One bounded plain-HTTP exchange: connect to `addr`, send `GET ` with `Host: +/// host` and `headers`, never follow a redirect, give up at `deadline`, and keep at most +/// `max_body + 1` bytes of the body — one past the cap, so a caller that refuses a body +/// over its cap sees one, while a server that never ends its body costs no more than that. +/// The status, the body, and whether the server sent more than was kept. +#[cfg(feature = "receipt_claims")] +fn get_bounded(addr: &str, host: &str, path: &str, headers: &[(&str, &str)], deadline: Instant, max_body: usize) + -> Result<(ResponseMeta, Vec), String> { + use std::net::ToSocketAddrs; + let left = |d: Instant| d.checked_duration_since(Instant::now()).filter(|l| !l.is_zero()).ok_or("the deadline passed".to_string()); + let sock = addr.to_socket_addrs().map_err(|e| format!("{addr}: {e}"))?.next().ok_or(format!("{addr}: no address"))?; + let mut s = TcpStream::connect_timeout(&sock, left(deadline)?).map_err(|e| format!("{addr}: {e}"))?; + let mut req = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nAccept-Encoding: identity\r\n"); + for (k, v) in headers { + req.push_str(&format!("{k}: {v}\r\n")); + } + req.push_str("\r\n"); + s.set_write_timeout(Some(left(deadline)?)).map_err(|e| e.to_string())?; + s.write_all(req.as_bytes()).map_err(|e| e.to_string())?; + let mut raw = Vec::new(); + let mut chunk = [0u8; 64 * 1024]; + let mut head_end: Option = None; + loop { + s.set_read_timeout(Some(left(deadline)?)).map_err(|e| e.to_string())?; + match s.read(&mut chunk) { + Ok(0) => break, + Ok(n) => raw.extend_from_slice(&chunk[..n]), + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return Err("the deadline passed".into()), + Err(e) => return Err(e.to_string()), + } + if head_end.is_none() { + head_end = raw.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4); + if head_end.is_none() && raw.len() > 64 * 1024 { + return Err("a response head past 64 KiB".into()); + } + } + if let Some(e) = head_end + && raw.len() - e > max_body + { + raw.truncate(e + max_body + 1); + break; + } + } + let end = head_end.ok_or("no header end")?; + let head = String::from_utf8_lossy(&raw[..end]).to_string(); + let mut lines = head.lines(); + let status: u16 = lines.next().and_then(|l| l.split_whitespace().nth(1)).and_then(|s| s.parse().ok()).ok_or("no status")?; + let mut meta = ResponseMeta { status, ..Default::default() }; + for l in lines { + if let Some((k, v)) = l.split_once(':') { + match k.trim().to_ascii_lowercase().as_str() { + "content-range" => meta.content_range = Some(v.trim().to_string()), + "content-encoding" => meta.content_encoding = Some(v.trim().to_string()), + _ => {} + } + } + } + Ok((meta, raw[end..].to_vec())) +} + /// The host and path of a plain-HTTP URL, and the object's cid (from `#cid=` or the /// `.wai` name). #[cfg(feature = "receipt_claims")] @@ -876,16 +938,30 @@ fn receipts_cmd(_: &[String]) -> Result { Err("built without receipt_claims: `wai http receipts` needs it".into()) } -/// `wai http receipts`: §7 then §8.2 over plain HTTP. Every receipt file it fetches is -/// checked as §8.1 states, by `http_binding::receipts`: no other path reads one. +/// The largest object response `wai http receipts` keeps (§7): a byte range, or the rest +/// of an object, that runs past this ends the command. +#[cfg(feature = "receipt_claims")] +const MAX_OBJECT_RESPONSE: usize = 1 << 30; + +/// `wai http receipts`: §7 then §8.2 over plain HTTP, every exchange bounded by one +/// deadline (`--deadline-ms`, 60 s by default) and each body by its cap. Every receipt file +/// it fetches is checked as §8.1 states, by `http_binding::receipts`: no other path reads +/// one. With `--key-origin`, the origin's key log is anchored as §8.4 states before any +/// signer is confirmed: fetched from its location with `Cache-Control: no-cache`, no +/// redirect followed, the retrieval built from the response. This tool speaks no TLS: +/// `--connect HOST:PORT` names the plain-HTTP address that answers for the origin, which +/// only a test uses. #[cfg(feature = "receipt_claims")] fn receipts_cmd(args: &[String]) -> Result { - use wai::http_binding::receipts::{check_index, ObjectHeld, PARENT_WALK_BOUND, RECEIPT_DIR}; + use wai::http_binding::receipts::{check_index, Limits, ObjectHeld, ReceiptIndex, FETCH_BUDGET, MAX_INDEX_BYTES, MAX_RECEIPT_FILE_BYTES, PARENT_WALK_BOUND, RECEIPT_DIR}; use wai::jwp_delivery::ReceiptChains; let (host, path, cid, frag) = object_url(opt(args, "--url").ok_or("receipts needs --url")?)?; + let deadline_ms: u64 = opt(args, "--deadline-ms").map_or(Ok(60_000), str::parse).map_err(|_| "--deadline-ms")?; + let deadline = Instant::now() + Duration::from_millis(deadline_ms); let adv: Vec = args.iter().enumerate().filter(|(_, a)| *a == "--advertise").filter_map(|(i, _)| args.get(i + 1).cloned()).collect(); let mut report = serde_json::Map::new(); report.insert("object".into(), json!(hex(&cid))); + let late = std::cell::Cell::new(false); let held = match opt(args, "--held").unwrap_or("fetch") { "none" => ObjectHeld::Nothing, "fetch" => { @@ -897,36 +973,66 @@ fn receipts_cmd(args: &[String]) -> Result { Want::Range(r, _) => (path.clone(), Some(r.header())), Want::Rest(r, _) => (path.clone(), Some(format!("bytes={}-", r.first))), }; - get(&host, &p, range.as_deref(), None).ok().map(|(m, b, _)| (m, b)) + let hdrs: Vec<(&str, &str)> = range.as_deref().map(|r| ("Range", r)).into_iter().collect(); + match get_bounded(&host, &host, &p, &hdrs, deadline, MAX_OBJECT_RESPONSE) { + Ok(x) => Some(x), + Err(e) => { + late.set(late.get() || e.contains("deadline")); + None + } + } }); + if late.get() { + return Err("the deadline passed fetching the object".into()); + } report.insert("fetch".into(), json!(match &r { Ok(f) => f.verified_by.label(), Err(e) => e.code() })); s.established().map_or(ObjectHeld::Nothing, ObjectHeld::Object) } other => return Err(format!("--held {other}: fetch or none")), }; report.insert("held".into(), json!(match held { ObjectHeld::Object(e) => e.by().label(), ObjectHeld::Nothing => "nothing" })); - let (index_meta, index, _) = get(&host, &format!("{path}.receipts"), None, None)?; + let (index_meta, index) = get_bounded(&host, &host, &format!("{path}.receipts"), &[], deadline, MAX_INDEX_BYTES)?; if index_meta.status != 200 { return Err(format!("no receipt index is served beside the object ({})", index_meta.status)); } // Receipt files resolve against the object's URI (§8.1). let dir = path.rsplit_once('/').map_or("", |(d, _)| d).to_string(); let mut fetch = |h: &[u8; 32]| -> Option> { - let (m, b, _) = get(&host, &format!("{dir}/{RECEIPT_DIR}{}.json", hex(h)), None, None).ok()?; - (m.status == 200).then_some(b) + match get_bounded(&host, &host, &format!("{dir}/{RECEIPT_DIR}{}.json", hex(h)), &[], deadline, MAX_RECEIPT_FILE_BYTES) { + Ok((m, b)) => (m.status == 200).then_some(b), + Err(e) => { + late.set(late.get() || e.contains("deadline")); + None + } + } }; let mut chains = if args.iter().any(|a| a == "--join") { ReceiptChains::new().with_join() } else { ReceiptChains::new() }; - let bound = opt(args, "--max-walk").map_or(Ok(PARENT_WALK_BOUND), str::parse).map_err(|_| "--max-walk")?; - let keys = pinned_keys(args)?; - let items = wai::http_binding::receipts::ReceiptIndex::from_bytes(&index).map(|i| i.delivery).unwrap_or_default(); + let limits = Limits { + max_walk: opt(args, "--max-walk").map_or(Ok(PARENT_WALK_BOUND), str::parse).map_err(|_| "--max-walk")?, + fetch_budget: opt(args, "--fetch-budget").map_or(Ok(FETCH_BUDGET), str::parse).map_err(|_| "--fetch-budget")?, + }; let mut all = true; - match check_index(&cid, &index, &held, &mut fetch, &mut chains, 0, bound) { + let keys = match keys(args, deadline, &mut report) { + Ok(k) => k, + Err(e) => { + all = false; + report.insert("anchor".into(), json!({ "refused": e })); + None + } + }; + let items = ReceiptIndex::from_bytes(&index).map(|i| i.delivery).unwrap_or_default(); + let checked = check_index(&cid, &index, &held, &mut fetch, &mut chains, 0, &limits, None); + if late.get() { + return Err("the deadline passed fetching receipt files".into()); + } + match checked { Err(e) => { all = false; report.insert("index".into(), json!(e.to_string())); } Ok(r) => { report.insert("index".into(), json!("ok")); + report.insert("files_fetched".into(), json!(r.fetched)); let mut ds = Vec::new(); for (k, d) in r.delivery.iter().enumerate() { ds.push(match d { @@ -968,32 +1074,50 @@ fn receipts_cmd(args: &[String]) -> Result { Ok(all) } -/// The key log a `--pin-key-log` file pins for `--key-origin` (jwp-receipts §7.3): the -/// revisions it holds and its anchor. Confirmation needs `key_discovery`. +/// The key log the signers are confirmed against: pinned from `--pin-key-log FILE` for +/// `--key-origin`, or, with `--key-origin` alone, anchored from the origin's key-log +/// location as http-delivery §8.4 states. Confirmation needs `key_discovery`. #[cfg(all(feature = "receipt_claims", feature = "key_discovery"))] type Keys = Option<(Vec, Vec)>; #[cfg(all(feature = "receipt_claims", not(feature = "key_discovery")))] type Keys = Option<((), ())>; #[cfg(all(feature = "receipt_claims", feature = "key_discovery"))] -fn pinned_keys(args: &[String]) -> Result { - let (Some(file), Some(origin)) = (opt(args, "--pin-key-log"), opt(args, "--key-origin")) else { - return Ok(None); - }; - let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?; - let anchor = match wai::key_log::anchor_log(origin, &text, wai::key_log::AnchorPath::Pinned, None) { - wai::key_log::Anchoring::Anchored(a) => a, - wai::key_log::Anchoring::NotAnchored(r) => return Err(format!("{file}: {}", r.label())), - wai::key_log::Anchoring::Replaced => return Err(format!("{file}: key_log_replaced")), +fn keys(args: &[String], deadline: Instant, report: &mut serde_json::Map) -> Result { + use wai::http_binding::keys::{anchor_retrieved, anchoring_request_headers, key_log_url, Retrieval, KEY_LOG_PATH}; + let Some(origin) = opt(args, "--key-origin") else { return Ok(None) }; + let (text, anchor) = match opt(args, "--pin-key-log") { + Some(file) => { + let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?; + match wai::key_log::anchor_log(origin, &text, wai::key_log::AnchorPath::Pinned, None) { + wai::key_log::Anchoring::Anchored(a) => (text, a), + wai::key_log::Anchoring::NotAnchored(r) => return Err(r.label().into()), + wai::key_log::Anchoring::Replaced => return Err("key_log_replaced".into()), + } + } + None => { + let host = origin.split_once("://").map_or(origin, |(_, h)| h); + let addr = opt(args, "--connect").unwrap_or(host); + let location = key_log_url(origin); + let (meta, body) = get_bounded(addr, host, KEY_LOG_PATH, &anchoring_request_headers(), deadline, + wai::http_binding::receipts::MAX_RECEIPT_FILE_BYTES)?; + // No redirect is followed: the answer is the location's own, whatever its status. + let retrieval = Retrieval { requested: &location, answered: &location, status: meta.status }; + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + let acquired = wai::claim_receipt::rfc3339_utc(now); + let a = anchor_retrieved(origin, &retrieval, &body, &acquired, None).map_err(|e| e.code().to_string())?; + (String::from_utf8_lossy(&body).into_owned(), a) + } }; + report.insert("anchor".into(), json!({ "revision": hex(&anchor.revision), "path": anchor.path.label() })); let revs = wai::key_log::read_log(origin, &text).map_err(|r| r.label().to_string())?; Ok(Some((revs, vec![anchor]))) } #[cfg(all(feature = "receipt_claims", not(feature = "key_discovery")))] -fn pinned_keys(args: &[String]) -> Result { - if opt(args, "--pin-key-log").is_some() { - return Err("built without key_discovery: --pin-key-log needs it".into()); +fn keys(args: &[String], _: Instant, _: &mut serde_json::Map) -> Result { + if opt(args, "--key-origin").is_some() { + return Err("built without key_discovery: --key-origin needs it".into()); } Ok(None) } diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs index c37142fda..dc41c7b2d 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs @@ -59,8 +59,10 @@ mod built { use super::super::{case, fetch_session, next, pick, refused, strs, Case, Corpus, Stop, STREAM_WAI}; use super::OPS; - use wai::claim_receipt::{stage_point, BindingType, ClaimBody, ClaimReceipt, LogInclusionClaim, SoftBindingClaim, StageClaim, - StageEvidence, StageObject, StageStatus, SubjectType, STAGE_VOCABULARY}; + use wai::claim_receipt::{stage_point, BindingType, ClaimBody, ClaimReceipt, DeliveryGapClaim, EnergyClaim, GapReason, LogInclusionClaim, + SoftBindingClaim, StageClaim, StageEvidence, StageObject, StageStatus, SubjectType, STAGE_VOCABULARY}; + use wai::energy_binding::Coverage; + use wai::energy_class::{CounterFiltering, Uncertainty}; use wai::container::{PinRefusal, Prior}; use wai::energy_class::EnergyProvenance; use wai::http_binding::keys::{ @@ -69,7 +71,8 @@ mod built { }; use wai::http_binding::receipts::{ check_index, claim_file, group_file, index_file, read_claim_file, read_group_file, receipt_path, ClaimVerdict, - DeliveryVerdict, IndexItem, ObjectHeld, ReceiptIndex, ReceiptRefusal, PARENT_WALK_BOUND, + DeliveryVerdict, IndexItem, Limits, ObjectHeld, ReceiptIndex, ReceiptRefusal, Recovery, FETCH_BUDGET, MAX_INDEX_BYTES, + MAX_RECEIPT_FILE_BYTES, PARENT_WALK_BOUND, }; use wai::http_binding::{content_hash, hex, unhex32}; use wai::jwp_delivery::{GroupBuilder, GroupReceipt, ObjectLabel, ObjectReceipt, PropertyReading, ReceiptChains, WaiReceiptProperty}; @@ -89,6 +92,13 @@ mod built { const GRANTER_ID: &str = "did:web:origin.example#g1"; const SINK: u8 = 0x64; const SINK_ID: &str = "did:example:http-sink"; + const RELAY: u8 = 0x66; + const RELAY_ID: &str = "did:example:http-relay"; + const DEEP_RCPT: &str = "cases/rcpt-deep"; + /// The claim-conformance vector whose `measurement` body the corpus's measurement + /// claim takes, about another object. + const MEASUREMENT_VECTOR: &str = "../claim-conformance/vectors.json"; + const METHOD: &str = "https://transaction.science/wai/extensions/energy-measurement"; const KEY_LOG: &str = ".well-known/wai-keys"; const STATUS: &str = ".well-known/wai-key-status"; const OLDER_LOG: &str = "cases/keys/wai-keys-r0"; @@ -147,6 +157,12 @@ mod built { format!("2026-10-06T00:{:02}:{:02}Z", s / 60, s % 60) } + /// The class of the corpus's measured figure: an on-chip counter at 44669 ppm over 3 s. + fn chip() -> EnergyProvenance { + EnergyProvenance::on_chip_counter(Uncertainty::new(44_669, 3_000_000).expect("a declared uncertainty"), CounterFiltering::Unknown) + .expect("a class") + } + /// A group of `ids` on `track`, at `group_id`, after `parent`. fn group(c: &Corpus, track: u64, group_id: u64, ids: &[(&'static str, u64, Option)], parent: Option<&Group>) -> Result { let mut b = GroupBuilder::default(); @@ -247,16 +263,44 @@ mod built { claims.insert("c1", c1); claims.insert("s0", s0); claims.insert("l0", l0); - let groups = vec![t0, t1, t2, v, s]; + // Track 4: an object whose leaf carries an on-chip counter's figure, and the + // publisher's measurement report of that figure (jwp-receipts §2.4.8), the body + // of claim-conformance's `measurement_object_accept` about this object. + let w = group(c, 4, 0, &[("O5", 461_538, Some(ObjectLabel::Class(chip())))], None)?; + let doc: Value = serde_json::from_slice(&std::fs::read(c.dir.join(MEASUREMENT_VECTOR)).map_err(fault)?).map_err(fault)?; + let vector = doc["vectors"].as_array().into_iter().flatten().find(|v| v["name"] == "measurement_object_accept").ok_or("no measurement vector")?; + let mut body = ClaimReceipt::from_json(vector["receipt"].as_str().ok_or("receipt")?).map_err(|r| r.to_string())?.body; + let ClaimBody::Measurement(mm) = &mut body else { return Err("the vector is not a measurement".into()) }; + mm.figure.figure_index = Some(0); + mm.figure.group = Some(w.receipt.link_hash()); + let m0 = seal(PUBLISHER, PUBLISHER_ID, SubjectType::Object, content_hash(object(c, "O5")?), 2, &time(100), body, Some(claims["l0"].link_hash()))?; + // The publisher's energy claims about O3: its leaf's figure, and another. + let energy = |j: u64| ClaimBody::Energy(EnergyClaim::new(j, &EnergyProvenance::Estimator, Coverage::Whole, "projected", METHOD, 1, "subpixel", None)); + let o3 = content_hash(object(c, "O3")?); + let e0 = seal(PUBLISHER, PUBLISHER_ID, SubjectType::Object, o3, 3, &time(110), energy(1500), Some(m0.link_hash()))?; + let e1 = seal(PUBLISHER, PUBLISHER_ID, SubjectType::Object, o3, 4, &time(120), energy(1501), Some(e0.link_hash()))?; + // A relay's delivery-gap claims about O10L2: at its coordinates, and at O10L1's. + let gap = |object_id: u64| ClaimBody::DeliveryGap(DeliveryGapClaim { track_alias: 3, group_id: 0, object_id, + reason: GapReason::NotReceived, observed_at: time(130) }); + let l2 = content_hash(object(c, "O10L2")?); + let g0 = seal(RELAY, RELAY_ID, SubjectType::Object, l2, 0, &time(130), gap(2), None)?; + let g1 = seal(RELAY, RELAY_ID, SubjectType::Object, l2, 1, &time(131), gap(1), Some(g0.link_hash()))?; + claims.insert("m0", m0); + claims.insert("e0", e0); + claims.insert("e1", e1); + claims.insert("g0", g0); + claims.insert("g1", g1); + let groups = vec![t0, t1, t2, v, s, w]; let indexes = vec![ ("O9a", vec![(0, 0)], vec![]), ("O9b", vec![(1, 0)], vec![]), ("O9c", vec![(2, 0)], vec![]), - ("O3", vec![(3, 0)], vec!["s0", "l0"]), + ("O3", vec![(3, 0)], vec!["s0", "l0", "e0", "e1"]), ("O7", vec![(3, 1)], vec!["s0"]), ("O10", vec![(4, 0)], vec!["c1"]), ("O10L1", vec![(4, 1)], vec![]), - ("O10L2", vec![(4, 2)], vec![]), + ("O10L2", vec![(4, 2)], vec!["g0", "g1"]), + ("O5", vec![(5, 0)], vec!["m0"]), ]; let set = std::fs::read(c.dir.join(MLICPP_SET)).map_err(|e| format!("{MLICPP_SET}: {e}"))?; let digest = wai::pin::sha256(&set); @@ -332,6 +376,59 @@ mod built { it.property.group_receipt = bad.link_hash(); let cid = it.property.content_hash; out.insert("cases/receipts/O9a-bad-signature.wai.receipts".into(), index_file(&cid, &[it], &[])?); + // A group in the legacy payload, whose link hash does not cover its parent, and an + // index of O1 naming it (§8.1). + let o1 = content_hash(object(c, "O1")?); + let mut b = GroupBuilder::default(); + b.add(o1, 0, "origin", None); + let (legacy, lo) = b.finish(&key(PUBLISHER), PUBLISHER_ID, 6, 1, &time(0), &time(4), Some(m.groups[0].receipt.link_hash()), false) + .ok_or("a legacy group")?; + out.insert(format!("{CASE_RCPT}/{}.json", hex(&legacy.link_hash())), group_file(&legacy)?); + let li = IndexItem { + property: WaiReceiptProperty { content_hash: o1, joules_micro: 0, acquisition: String::new(), acquisition_evidence: Vec::new(), + group_receipt: legacy.link_hash() }, + object_receipt: lo[0].clone(), + }; + out.insert("cases/receipts/O1-legacy.wai.receipts".into(), index_file(&o1, &[li], &[])?); + // A chain of 66 groups over objects the corpus does not carry: from the last, 65 + // parents back to the chain's start; from the one before, 64 (§8.1's bound). + let mut deep: Vec<(GroupReceipt, ObjectReceipt)> = Vec::new(); + for i in 0..66u64 { + let h = content_hash(format!("deep chain object {i}").as_bytes()); + let mut b = GroupBuilder::default(); + b.add(h, 0, "origin", Some(ObjectLabel::Unmetered)); + let parent = deep.last().map(|(g, _)| g.link_hash()); + let (g, mut o) = b.finish(&key(PUBLISHER), PUBLISHER_ID, 5, i, &time(0), &time(4), parent, true).ok_or("a deep group")?; + out.insert(format!("{DEEP_RCPT}/{}.json", hex(&g.link_hash())), group_file(&g)?); + deep.push((g, o.remove(0))); + } + for i in [64usize, 65] { + let (g, o) = &deep[i]; + let it = IndexItem { + property: WaiReceiptProperty { content_hash: o.content_hash, joules_micro: 0, acquisition: String::new(), + acquisition_evidence: Vec::new(), group_receipt: g.link_hash() }, + object_receipt: o.clone(), + }; + out.insert(format!("cases/receipts/deep-{i}.wai.receipts"), index_file(&o.content_hash, &[it], &[])?); + } + // O9c's index with its one item fifty times: each name is fetched once. + let it = item(&m, 2, 0); + let o9c = it.property.content_hash; + out.insert("cases/receipts/O9c-x50.wai.receipts".into(), index_file(&o9c, &vec![it; 50], &[])?); + // Indexes at and past the caps on items and claims (§8.2): items that do not read, + // claim entries that are not link hashes, and the count read first. + let o9a = content_hash(object(c, "O9a")?); + let capped = |items: usize, claims: usize| { + let mut mm = serde_json::Map::new(); + mm.insert("kind".into(), json!(wai::http_binding::receipts::INDEX_KIND)); + mm.insert("object".into(), json!(hex(&o9a))); + mm.insert("delivery".into(), Value::Array(vec![json!({}); items])); + mm.insert("claims".into(), Value::Array(vec![json!("x"); claims])); + wai::jcs::jcs(&Value::Object(mm)).map(String::into_bytes).map_err(|e| e.to_string()) + }; + out.insert("cases/receipts/O9a-256-items.wai.receipts".into(), capped(256, 256)?); + out.insert("cases/receipts/O9a-257-items.wai.receipts".into(), capped(257, 0)?); + out.insert("cases/receipts/O9a-257-claims.wai.receipts".into(), capped(0, 257)?); for (k, b) in &out { c.extra.insert(k.clone(), b.clone()); } @@ -397,12 +494,14 @@ mod built { /// Where a case's receipt files are served from: the directories `rcpt` (by default /// `objects/rcpt`), each searched for `.json`; `omit` lists hashes served - /// nothing; `as` serves at a hash the file at a path. + /// nothing; `as` serves at a hash the file at a path; `pad` serves a file padded with + /// spaces to a length. struct Served<'a> { c: &'a Corpus, dirs: Vec, omit: Vec<[u8; 32]>, swap: BTreeMap<[u8; 32], String>, + pad: BTreeMap<[u8; 32], usize>, } impl Served<'_> { @@ -416,17 +515,27 @@ mod built { for (h, p) in v["as"].as_object().into_iter().flatten() { swap.insert(hash_of(&json!(h), "as")?, p.as_str().ok_or("an as path")?.to_string()); } - Ok(Served { c, dirs, omit, swap }) + let mut pad = BTreeMap::new(); + for (h, n) in v["pad"].as_object().into_iter().flatten() { + pad.insert(hash_of(&json!(h), "pad")?, n.as_u64().ok_or("a pad length")? as usize); + } + Ok(Served { c, dirs, omit, swap, pad }) } fn get(&self, h: &[u8; 32]) -> Option> { if self.omit.contains(h) { return None; } - if let Some(p) = self.swap.get(h) { - return self.c.file(p).ok(); + let mut b = match self.swap.get(h) { + Some(p) => self.c.file(p).ok(), + None => self.dirs.iter().find_map(|d| self.c.file(&format!("{d}/{}.json", hex(h))).ok()), + }?; + if let Some(n) = self.pad.get(h) { + if b.len() < *n { + b.resize(*n, b' '); + } } - self.dirs.iter().find_map(|d| self.c.file(&format!("{d}/{}.json", hex(h))).ok()) + Some(b) } } @@ -505,35 +614,67 @@ mod built { } /// `receipts`: a client's checks of objects' receipt indexes, in order, with one set - /// of chains (`join` declared or not) and one parent bound (`max_walk`), the receipt - /// files served as `serve` says, and, with `keys`, each accepted item's signer - /// confirmed against the key material those steps give (§7.4). + /// of chains (`join` declared or not), one parent bound (`max_walk`) and fetch budget + /// per index (`fetch_budget`), the receipt files served as `serve` says, with + /// `recovery` (`observed_value`, `exact_match_algs`) the context of jwp-receipts + /// §3.2's recovery, its publisher keys those of the delivery receipts earlier checks + /// verified over their objects' bytes; and, with `keys`, each accepted item's signer + /// confirmed against the key material those steps give (§7.4). A check names its + /// object by id, by `object_file`, or by `object_cid` (held `none`); `pad_index` pads + /// its index with spaces to a length. fn receipts(c: &Corpus, input: &Value) -> Result { let served = Served::of(c, &input["serve"])?; let mut chains = if input["join"].as_bool() == Some(true) { ReceiptChains::new().with_join() } else { ReceiptChains::new() }; - let bound = input["max_walk"].as_u64().map_or(PARENT_WALK_BOUND, |b| b as usize); + let limits = Limits { + max_walk: input["max_walk"].as_u64().map_or(PARENT_WALK_BOUND, |b| b as usize), + fetch_budget: input["fetch_budget"].as_u64().map_or(FETCH_BUDGET, |b| b as usize), + }; let keys = match &input["keys"] { Value::Null => None, k => Some(key_material(c, k)?), }; + let observed = match input["recovery"]["observed_value"].as_str() { + Some(h) => Some(super::super::unhex(h)?), + None => None, + }; + let exact: Vec = + strs(&input["recovery"]["exact_match_algs"]).into_iter().map(|alg| wai::claim_receipt::ExactMatch { alg }).collect(); + let matchers: Vec<&dyn wai::claim_receipt::SoftBindingMatcher> = + exact.iter().map(|m| m as &dyn wai::claim_receipt::SoftBindingMatcher).collect(); + let mut publishers: Vec<([u8; 32], [u8; 32])> = Vec::new(); let mut out = Vec::new(); for chk in input["checks"].as_array().ok_or("checks")? { - let bytes = match chk["object_file"].as_str() { - Some(f) => c.file(f)?, - None => object(c, chk["object"].as_str().ok_or("object")?)?.clone(), + let (bytes, cid) = match (chk["object_cid"].as_str(), chk["object_file"].as_str()) { + (Some(h), _) => (Vec::new(), hash_of(&json!(h), "object_cid")?), + (None, Some(f)) => { + let b = c.file(f)?; + let h = content_hash(&b); + (b, h) + } + (None, None) => { + let b = object(c, chk["object"].as_str().ok_or("object")?)?.clone(); + let h = content_hash(&b); + (b, h) + } }; - let cid = content_hash(&bytes); let path = match chk["index"].as_str() { Some(p) => p.to_string(), None => format!("objects/{}.wai.receipts", hex(&cid)), }; - let ix = edit(text(c, &path)?, &chk["edit_index"])?; + let mut ix = edit(text(c, &path)?, &chk["edit_index"])?.into_bytes(); + if let Some(n) = chk["pad_index"].as_u64() { + if ix.len() < n as usize { + ix.resize(n as usize, b' '); + } + } let (h, fetched) = held(c, chk, &bytes)?; let mut fetch = |x: &[u8; 32]| served.get(x); - let items: Vec> = ReceiptIndex::from_bytes(ix.as_bytes()) + let items: Vec> = ReceiptIndex::from_bytes(&ix) .map(|i| i.delivery.into_iter().map(Result::ok).collect()) .unwrap_or_default(); - let mut entry = match check_index(&cid, ix.as_bytes(), &h, &mut fetch, &mut chains, 0, bound) { + let recovery = observed.as_ref().map(|v| Recovery { publishers: &publishers, observed_value: v, matchers: &matchers }); + let report = check_index(&cid, &ix, &h, &mut fetch, &mut chains, 0, &limits, recovery.as_ref()); + let mut entry = match &report { Ok(r) => json!({ "index": "ok", "delivery": r.delivery.iter().enumerate().map(|(k, d)| match d { @@ -544,6 +685,7 @@ mod built { Ok(v) => claim(v, keys.as_ref()), Err(e) => refusal(e), }).collect::>(), + "files_fetched": r.fetched, }), Err(e) => json!({ "index": e.code() }), }; @@ -551,6 +693,11 @@ mod built { entry["fetched"] = fetched; } out.push(entry); + // The delivery receipts this check verified over the object's bytes: publisher + // keys a later check may recover through. + if let Ok(r) = report { + publishers.extend(r.delivery.iter().flatten().filter(|v| v.held.is_some()).map(|v| (cid, v.group.signer_pubkey))); + } } Ok(json!({ "checks": out })) } @@ -775,6 +922,68 @@ mod built { "edit_index": [["{\"object_receipt\"", "{\"note\":1,\"object_receipt\""]] }] }), "receipt_index_malformed")); v.push(refused("receipts_claim_entry_not_a_hash", "receipts", json!({ "checks": [{ "object": "O7", "held": "whole", "edit_index": [[hex(&cl("s0")), "s0"]] }] }), "receipt_index_malformed")); + // §8.2 step 6: a measurement report against the figure it names, held only for a + // delivered object; energy claims against the delivered object's figure; a gap + // against the receipt at its coordinates. + v.push(case("receipts_measurement_figure", "receipts", json!({ "checks": [chk("O5", whole.clone())] }))); + v.push(refused("receipts_measurement_figure_not_held", "receipts", json!({ "checks": [chk("O5", none.clone())] }), "figure_not_held")); + v.push(refused("receipts_energy_not_delivered", "receipts", json!({ "checks": [chk("O3", none.clone())] }), "context_not_held")); + v.push(case("receipts_delivery_gap", "receipts", json!({ "checks": [chk("O10L2", none.clone())] }))); + // §8.2: a claim about a receipt attaches only through an item that passed: O3's + // item and its log-inclusion claim listed in O9a's index. + let o3_index = String::from_utf8(c.file(&index_path(c, "O3")?)?).map_err(|_| "not UTF-8")?; + let o3_item = { + let v: Value = serde_json::from_str(&o3_index).map_err(fault)?; + wai::jcs::jcs(&v["delivery"][0]).map_err(fault)? + }; + v.push(refused("receipts_claim_through_foreign_item", "receipts", json!({ "checks": [{ "object": "O9a", "held": "whole", + "edit_index": [["\"claims\":[]", format!("\"claims\":[\"{}\"]", hex(&cl("l0")))], ["\"delivery\":[", format!("\"delivery\":[{o3_item},")]] }] }), + "receipt_not_about_object")); + // §8.2, jwp-receipts §3.2: a soft binding of another object attests the ancestor only + // through a recovery: O7's delivery receipt verified first gives its publisher key. + let recovery = |value: &str, algs: Value| json!({ "observed_value": value, "exact_match_algs": algs }); + let o7_then_o3 = json!([chk("O7", whole.clone()), chk("O3", whole.clone())]); + v.push(case("receipts_ancestor_recovered", "receipts", json!({ "recovery": recovery("0ff0", json!(["org.example.fingerprint"])), "checks": o7_then_o3 }))); + v.push(refused("receipts_ancestor_no_publisher", "receipts", json!({ "recovery": recovery("0ff0", json!(["org.example.fingerprint"])), + "checks": [chk("O3", whole.clone())] }), "receipt_not_about_object")); + v.push(refused("receipts_ancestor_value_mismatch", "receipts", json!({ "recovery": recovery("0000", json!(["org.example.fingerprint"])), + "checks": o7_then_o3 }), "recovery_value_mismatch")); + v.push(refused("receipts_ancestor_no_matcher", "receipts", json!({ "recovery": recovery("0ff0", json!([])), "checks": o7_then_o3 }), + "recovery_no_matcher")); + v.push(refused("receipts_ancestor_any_object", "receipts", json!({ "checks": [{ "object": "O9a", "held": "whole", + "edit_index": [["\"claims\":[]", format!("\"claims\":[\"{}\"]", hex(&cl("s0")))]] }] }), "receipt_not_about_object")); + // §8.1: a legacy group is refused, its name not fixing its bytes. + v.push(refused("receipts_legacy_group", "receipts", json!({ "serve": { "rcpt": [RCPT, CASE_RCPT] }, + "checks": [{ "object": "O1", "held": "whole", "index": "cases/receipts/O1-legacy.wai.receipts" }] }), "receipt_legacy_group")); + // §8.1: 64 parents and no more, whatever bound a client asks for. + let deep = |i: usize, walk: Value| -> Result { + let ix = format!("cases/receipts/deep-{i}.wai.receipts"); + let cid = serde_json::from_slice::(&c.file(&ix)?).map_err(fault)?["object"].clone(); + Ok(json!({ "serve": { "rcpt": [DEEP_RCPT] }, "max_walk": walk, "checks": [{ "object_cid": cid, "held": "none", "index": ix }] })) + }; + v.push(case("receipts_walk_64", "receipts", deep(64, Value::Null)?)); + v.push(refused("receipts_walk_65", "receipts", deep(65, Value::Null)?, "parent_not_held")); + v.push(refused("receipts_walk_65_asks_1000", "receipts", deep(65, json!(1000))?, "parent_not_held")); + // §8.2: each name fetched once; a fetch budget; files and indexes over their caps. + v.push(case("receipts_items_fetch_each_name_once", "receipts", json!({ "checks": [{ "object": "O9c", "held": "whole", + "index": "cases/receipts/O9c-x50.wai.receipts" }] }))); + v.push(refused("receipts_fetch_budget", "receipts", json!({ "fetch_budget": 2, "checks": [chk("O9c", whole.clone())] }), "parent_not_held")); + v.push(refused("receipts_file_too_large", "receipts", json!({ "serve": { "pad": { hex(&g(0)): MAX_RECEIPT_FILE_BYTES + 1 } }, + "checks": [chk("O9a", whole.clone())] }), "receipt_too_large")); + v.push(refused("receipts_parent_too_large", "receipts", json!({ "serve": { "pad": { hex(&g(0)): MAX_RECEIPT_FILE_BYTES + 1 } }, + "checks": [chk("O9b", whole.clone())] }), "parent_not_held")); + v.push(case("receipts_file_at_its_cap", "receipts", json!({ "serve": { "pad": { hex(&g(0)): MAX_RECEIPT_FILE_BYTES } }, + "checks": [chk("O9a", whole.clone())] }))); + v.push(refused("receipts_index_too_large", "receipts", json!({ "checks": [{ "object": "O9a", "held": "whole", "pad_index": MAX_INDEX_BYTES + 1 }] }), + "receipt_index_too_large")); + v.push(case("receipts_index_at_its_cap", "receipts", json!({ "checks": [{ "object": "O9a", "held": "whole", "pad_index": MAX_INDEX_BYTES }] }))); + v.push(case("receipts_index_256_items", "receipts", json!({ "checks": [{ "object": "O9a", "held": "whole", "index": "cases/receipts/O9a-256-items.wai.receipts" }] }))); + v.push(refused("receipts_index_257_items", "receipts", json!({ "checks": [{ "object": "O9a", "held": "whole", "index": "cases/receipts/O9a-257-items.wai.receipts" }] }), + "receipt_index_too_large")); + v.push(refused("receipts_index_257_claims", "receipts", json!({ "checks": [{ "object": "O9a", "held": "whole", "index": "cases/receipts/O9a-257-claims.wai.receipts" }] }), + "receipt_index_too_large")); + v.push(refused("receipt_file_legacy_group", "receipt_file", json!({ "file": format!("{CASE_RCPT}/{}.json", hex(&legacy_link(c)?)), "kind": "group" }), + "receipt_legacy_group")); // §7.4 beside each verdict: the signers confirmed against the origin's key log, // anchored from its location. let anchored = json!([{ "anchor": KEY_LOG }]); @@ -828,6 +1037,12 @@ mod built { Ok(v) } + /// The link hash of the corpus's legacy group, from the index that names it. + fn legacy_link(c: &Corpus) -> Result<[u8; 32], String> { + let v: Value = serde_json::from_slice(&c.file("cases/receipts/O1-legacy.wai.receipts")?).map_err(fault)?; + v["delivery"][0]["property"]["group_receipt"].as_str().and_then(unhex32).ok_or("the legacy index names no group".into()) + } + /// A `key_log` case whose last step exists for `code`. fn refused_step(name: &str, input: Value, code: &'static str) -> Case { refused(name, "key_log", input, code) diff --git a/wai/wai-rs/src/energy_figure.rs b/wai/wai-rs/src/energy_figure.rs index 2c61edb78..d70df27f7 100644 --- a/wai/wai-rs/src/energy_figure.rs +++ b/wai/wai-rs/src/energy_figure.rs @@ -218,6 +218,34 @@ impl EnergyFigures for DeliveredObject<'_> { } } +/// A JWP object (`moq-jwp:object`) accepted under its group against the content hash the +/// verifier established for it: recomputed over the envelope, or the cid a verified head +/// proof established for an object fetched in part (http-delivery §8.3). Its figure is +/// [`DeliveredObject`]'s. +pub struct HeldObject<'a> { + pub object: &'a crate::jwp_delivery::ObjectReceipt, + pub group: &'a crate::jwp_delivery::GroupReceipt, + pub established: &'a crate::jwp_hash::EstablishedHash, +} + +impl EnergyFigures for HeldObject<'_> { + fn figure_view(&self) -> Option { + self.object.accept_hash(self.group, self.established).ok()?; + let class = self.object.label.as_ref().and_then(|l| l.class().cloned()); + Some(FigureView { + profile: "moq-jwp:object".into(), + identity: self.object.content_hash, + group: Some(self.group.link_hash()), + figures: vec![Figure { + index: Some(self.object.merkle_proof.leaf_index), + joules_micro: self.object.joules_micro, + class, + }], + signer: Some(self.group.signer_pubkey), + }) + } +} + /// The receipt families whose figure sits in `joules_micro` beside an optional or /// always-present `energy_provenance`, signed by `signer_pubkey`, identified by /// `receipt_hash()`. diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs index 95b77f2f3..f2bdba578 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs @@ -3,28 +3,41 @@ //! //! Every check here is jwp-receipts' own, bound to HTTP carriage and never re-created: //! -//! - a receipt file `rcpt/.json` holds one group receipt or one claim, read as §3 -//! step 1 or §3.1 steps 1–2 read it, and is refused unless its link hash is `h` -//! (`receipt_name_mismatch`); +//! - a receipt file `rcpt/.json` holds one group receipt in the labelled payload, or +//! one claim, read as §3 step 1 or §3.1 steps 1–2 read it, and is refused unless its +//! link hash is `h` (`receipt_name_mismatch`). A legacy group is refused +//! (`receipt_legacy_group`): its link hash does not cover its parent, so its name does +//! not fix its bytes; //! - an index `.wai.receipts` is a locator: each delivery item and each claim it //! lists is checked on its own, and one that fails is ignored with its reason; //! - a delivery item's group goes through §3 steps 1–3 against the client's -//! [`ReceiptChains`], the parents it lacks fetched by their links up to a bound, and its +//! [`ReceiptChains`], the parents it lacks fetched by their links up to 64, and its //! object receipt through steps 4–5 against what the client holds of the object: the //! content hash recomputed over the envelope, the cid a verified head proof establishes //! for an object fetched in part (§8.3, [`EstablishedHash`]), or nothing, for an object //! it did not receive, which is then `inclusion-verified` and never delivered (§10.5); -//! - a claim goes through §3.1 steps 1–5, its parent fetched by its link; a -//! soft-binding claim about another object is reported as attesting that object, the -//! ancestor (§3.2), never the object in hand. +//! - a claim goes through §3.1 steps 1–6: its subject the cid, or a group a delivery +//! item that passed names; its parent fetched by its link; and step 6 run against the +//! object receipts of the delivery items that passed, or the claims they name, a claim +//! whose step 6 needs a receipt the client does not hold not accepted. A soft-binding +//! claim about another object attests that object, the ancestor, only once §3.2's +//! recovery has passed; +//! - one index check fetches each name once, at most [`FETCH_BUDGET`] files, none over +//! [`MAX_RECEIPT_FILE_BYTES`], from an index of at most [`MAX_INDEX_BYTES`] listing at +//! most [`MAX_INDEX_ITEMS`] delivery items and [`MAX_INDEX_CLAIMS`] claims. //! //! Nothing here performs I/O: `fetch` gives the bytes served at `rcpt/.json` beside //! the object, or `None` where nothing is served. +use std::collections::BTreeMap; + use serde_json::{Map, Value}; use super::{hex, unhex32}; -use crate::claim_receipt::{ClaimClass, ClaimReceipt, Reason as ClaimReason, Rejection, SubjectType}; +use crate::claim_receipt::{ + ClaimBody, ClaimClass, ClaimReceipt, LawfulBasisClaim, Reason as ClaimReason, Rejection, SoftBindingMatcher, SubjectType, +}; +use crate::energy_figure::{EnergyFigures, HeldObject}; use crate::jwp_delivery::{ Admission, DeliveryError, Evidence, GroupReceipt, ObjectReceipt, PropertyReading, Reason, ReceiptChains, WaiReceiptProperty, }; @@ -37,11 +50,40 @@ pub const RECEIPT_DIR: &str = "rcpt/"; pub const INDEX_KIND: &str = "wai-receipt-index"; /// How many parents a client fetches for one group, at most (§8.1). A group whose -/// chain is further back than this stays `parent_not_held`. +/// chain is further back than this stays `parent_not_held`. A client MAY hold a lower +/// bound, never a higher one. /// /// cbindgen:ignore pub const PARENT_WALK_BOUND: usize = 64; +/// The most bytes a receipt file may hold (§8.1); one over it is `receipt_too_large`. +/// +/// cbindgen:ignore +pub const MAX_RECEIPT_FILE_BYTES: usize = 1 << 20; + +/// The most bytes a receipt index may hold (§8.2); one over it is +/// `receipt_index_too_large`. +/// +/// cbindgen:ignore +pub const MAX_INDEX_BYTES: usize = 4 << 20; + +/// The most delivery items an index may list (§8.2); more is `receipt_index_too_large`. +/// +/// cbindgen:ignore +pub const MAX_INDEX_ITEMS: usize = 256; + +/// The most claims an index may list (§8.2); more is `receipt_index_too_large`. +/// +/// cbindgen:ignore +pub const MAX_INDEX_CLAIMS: usize = 256; + +/// The most receipt files a client fetches to check one index (§8.2), each name fetched +/// once; past it a fetch is `receipt_fetch_budget_exhausted`. A client MAY hold a lower +/// budget, never a higher one. +/// +/// cbindgen:ignore +pub const FETCH_BUDGET: usize = 1024; + /// The path of the receipt file whose link hash is `h`, relative to the object (§8.1). pub fn receipt_path(h: &[u8; 32]) -> String { format!("{RECEIPT_DIR}{}.json", hex(h)) @@ -58,21 +100,37 @@ pub fn index_name(cid: &[u8; 32]) -> String { pub enum ReceiptRefusal { /// §8.2: the index, or an item of it, is not of its form (`receipt_index_malformed`). IndexMalformed(&'static str), + /// §8.2: the index holds more bytes, delivery items or claims than a client reads + /// (`receipt_index_too_large`). + IndexTooLarge(&'static str), /// §8.2: an index, a delivery item or a claim about another object /// (`receipt_not_about_object`). NotAboutObject, /// §8.1: nothing is served at the receipt file's name (`receipt_not_served`). NotServed, + /// §8.1: a receipt file over [`MAX_RECEIPT_FILE_BYTES`] (`receipt_too_large`). + TooLarge, + /// §8.2: the index's fetch budget is spent (`receipt_fetch_budget_exhausted`). + BudgetExhausted, /// §8.1: the file's link hash is not its name (`receipt_name_mismatch`). NameMismatch, + /// §8.1: a group receipt in the legacy payload, whose link hash does not cover its + /// parent, so the name does not fix the bytes (`receipt_legacy_group`). + LegacyGroup, /// jwp-receipts §3, §3.3: a group or object receipt, or a property, refused. Delivery(DeliveryError), /// jwp-receipts §3.1: a claim refused. Claim(Rejection), + /// §8.2: a claim whose §3.1 step 6 needs a receipt the client does not hold, other + /// than a `measurement` claim's figure (`figure_not_held`) (`context_not_held`). + ContextNotHeld, + /// §8.2, jwp-receipts §3.2: a soft-binding claim about another object whose recovery + /// the client ran and which failed, with the recovery's reason. + Recovery(Rejection), /// §8.1, jwp-receipts §3 step 3 and §3.1 step 5: a parent the client could not /// recover (`parent_not_held`), after `walked` parents fetched; `stop` says why the - /// walk ended: `bound`, `receipt_not_served`, `another_chain`, or the reason the parent - /// fetched last was refused. + /// walk ended: `bound`, `another_chain`, or the reason the parent fetched last was + /// refused or not fetched. ParentNotHeld { walked: usize, stop: String }, } @@ -81,21 +139,27 @@ impl ReceiptRefusal { pub fn code(&self) -> &'static str { match self { ReceiptRefusal::IndexMalformed(_) => "receipt_index_malformed", + ReceiptRefusal::IndexTooLarge(_) => "receipt_index_too_large", ReceiptRefusal::NotAboutObject => "receipt_not_about_object", ReceiptRefusal::NotServed => "receipt_not_served", + ReceiptRefusal::TooLarge => "receipt_too_large", + ReceiptRefusal::BudgetExhausted => "receipt_fetch_budget_exhausted", ReceiptRefusal::NameMismatch => "receipt_name_mismatch", + ReceiptRefusal::LegacyGroup => "receipt_legacy_group", ReceiptRefusal::Delivery(e) => e.reason.label(), - ReceiptRefusal::Claim(r) => r.reason.label(), + ReceiptRefusal::Claim(r) | ReceiptRefusal::Recovery(r) => r.reason.label(), + ReceiptRefusal::ContextNotHeld => "context_not_held", ReceiptRefusal::ParentNotHeld { .. } => "parent_not_held", } } /// The jwp-receipts step a refusal of jwp-receipts' own was raised at, where it has - /// one. + /// one; `context_not_held` is step 6's. pub fn step(&self) -> Option { match self { ReceiptRefusal::Delivery(e) => e.step, - ReceiptRefusal::Claim(r) => Some(r.step), + ReceiptRefusal::Claim(r) | ReceiptRefusal::Recovery(r) => Some(r.step), + ReceiptRefusal::ContextNotHeld => Some(6), _ => None, } } @@ -105,6 +169,7 @@ impl std::fmt::Display for ReceiptRefusal { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { ReceiptRefusal::IndexMalformed(why) => write!(f, "receipt_index_malformed: {why}"), + ReceiptRefusal::IndexTooLarge(why) => write!(f, "receipt_index_too_large: {why}"), ReceiptRefusal::ParentNotHeld { walked, stop } => write!(f, "parent_not_held: {walked} parents fetched, stopped at {stop}"), other => match other.step() { Some(s) => write!(f, "{} at step {s}", other.code()), @@ -122,11 +187,15 @@ fn malformed_group() -> ReceiptRefusal { /// §8.1: read the receipt file named `name` as a group receipt: JSON as §3.1 step 1 /// reads it, then §3 step 1 (`malformed`, `wrong_kind`, `group_label_invalid`), then the -/// name (`receipt_name_mismatch`). The signature is not checked here. +/// payload (`receipt_legacy_group` for the legacy one), then the name +/// (`receipt_name_mismatch`). The signature is not checked here. pub fn read_group_file(name: &[u8; 32], bytes: &[u8]) -> Result { let text = std::str::from_utf8(bytes).map_err(|_| malformed_group())?; let v = crate::claim_receipt::parse_strict(text).map_err(|_| malformed_group())?; let g = GroupReceipt::from_json(&v).map_err(ReceiptRefusal::Delivery)?; + if g.label.is_none() { + return Err(ReceiptRefusal::LegacyGroup); + } if g.link_hash() != *name { return Err(ReceiptRefusal::NameMismatch); } @@ -155,6 +224,48 @@ pub fn claim_file(c: &ClaimReceipt) -> Vec { c.to_json().into_bytes() } +// ───────────────────────────── fetching ───────────────────────────── + +/// The receipt files one index check fetches (§8.1, §8.2): each name fetched once and +/// what was served remembered, at most a budget of fetches (never above +/// [`FETCH_BUDGET`]), and a file over [`MAX_RECEIPT_FILE_BYTES`] refused. +pub struct ReceiptFetcher<'f> { + fetch: &'f mut dyn FnMut(&[u8; 32]) -> Option>, + memo: BTreeMap<[u8; 32], Option>>, + fetched: usize, + budget: usize, +} + +impl<'f> ReceiptFetcher<'f> { + /// A fetcher over `fetch` with `budget` fetches, at most [`FETCH_BUDGET`]. + pub fn new(fetch: &'f mut dyn FnMut(&[u8; 32]) -> Option>, budget: usize) -> Self { + ReceiptFetcher { fetch, memo: BTreeMap::new(), fetched: 0, budget: budget.min(FETCH_BUDGET) } + } + + /// How many files were fetched (each name once). + pub fn fetched(&self) -> usize { + self.fetched + } + + /// The file named `h`: `receipt_not_served`, `receipt_too_large`, or, past the + /// budget, `receipt_fetch_budget_exhausted`. + pub fn get(&mut self, h: &[u8; 32]) -> Result, ReceiptRefusal> { + if !self.memo.contains_key(h) { + if self.fetched >= self.budget { + return Err(ReceiptRefusal::BudgetExhausted); + } + self.fetched += 1; + let b = (self.fetch)(h); + self.memo.insert(*h, b); + } + match &self.memo[h] { + None => Err(ReceiptRefusal::NotServed), + Some(b) if b.len() > MAX_RECEIPT_FILE_BYTES => Err(ReceiptRefusal::TooLarge), + Some(b) => Ok(b.clone()), + } + } +} + // ───────────────────────────── the index ───────────────────────────── /// One delivery item of an index: a `WAI_RECEIPT` property and the object receipt it @@ -175,15 +286,21 @@ pub struct ReceiptIndex { } impl ReceiptIndex { - /// Read an index: JSON as jwp-receipts §3.1 step 1 reads it, with exactly the members - /// `kind` (`wai-receipt-index`), `object` (64 lowercase hexadecimal digits), - /// `delivery` and `claims` (arrays), or the whole index is refused - /// (`receipt_index_malformed`). Each delivery item is an object with exactly the - /// members `property` and `object_receipt` (`receipt_index_malformed`); its property - /// reads as moq-streaming-format §5's (`property_invalid`) and its object receipt as - /// jwp-receipts §2.4.2's (that reading's reason). Each claim entry is a link hash - /// (`receipt_index_malformed`). An item that does not read is kept as its refusal. + /// Read an index: at most [`MAX_INDEX_BYTES`] (`receipt_index_too_large`), JSON as + /// jwp-receipts §3.1 step 1 reads it, with exactly the members `kind` + /// (`wai-receipt-index`), `object` (64 lowercase hexadecimal digits), `delivery` and + /// `claims` (arrays), or the whole index is refused (`receipt_index_malformed`), as it + /// is when it lists more than [`MAX_INDEX_ITEMS`] delivery items or + /// [`MAX_INDEX_CLAIMS`] claims (`receipt_index_too_large`). Each delivery item is an + /// object with exactly the members `property` and `object_receipt` + /// (`receipt_index_malformed`); its property reads as moq-streaming-format §5's + /// (`property_invalid`) and its object receipt as jwp-receipts §2.4.2's (that + /// reading's reason). Each claim entry is a link hash (`receipt_index_malformed`). An + /// item that does not read is kept as its refusal. pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() > MAX_INDEX_BYTES { + return Err(ReceiptRefusal::IndexTooLarge("more bytes than a client reads")); + } let bad = ReceiptRefusal::IndexMalformed; let text = std::str::from_utf8(bytes).map_err(|_| bad("not UTF-8"))?; let v = crate::claim_receipt::parse_strict(text).map_err(|_| bad("not JSON as a receipt is read"))?; @@ -196,14 +313,19 @@ impl ReceiptIndex { return Err(bad("kind is not wai-receipt-index")); } let object = m["object"].as_str().and_then(unhex32).ok_or(bad("object is not a content hash"))?; - let delivery = m["delivery"].as_array().ok_or(bad("delivery is not an array"))?.iter().map(read_item).collect(); - let claims = m["claims"] - .as_array() - .ok_or(bad("claims is not an array"))? - .iter() - .map(|c| c.as_str().and_then(unhex32).ok_or(bad("a claim entry is not a link hash"))) - .collect(); - Ok(ReceiptIndex { object, delivery, claims }) + let delivery = m["delivery"].as_array().ok_or(bad("delivery is not an array"))?; + let claims = m["claims"].as_array().ok_or(bad("claims is not an array"))?; + if delivery.len() > MAX_INDEX_ITEMS { + return Err(ReceiptRefusal::IndexTooLarge("more delivery items than a client reads")); + } + if claims.len() > MAX_INDEX_CLAIMS { + return Err(ReceiptRefusal::IndexTooLarge("more claims than a client reads")); + } + Ok(ReceiptIndex { + object, + delivery: delivery.iter().map(read_item).collect(), + claims: claims.iter().map(|c| c.as_str().and_then(unhex32).ok_or(bad("a claim entry is not a link hash"))).collect(), + }) } } @@ -265,18 +387,21 @@ pub struct DeliveryVerdict { pub established: Option, pub reading: Option, pub group: GroupReceipt, + pub object: ObjectReceipt, + /// The hash the object receipt was accepted against, for a delivered object. + pub held: Option, } /// §3 step 3 for `group` against `chains`, recovering the parents it lacks (§8.1): while /// the group is `parent_not_held`, fetch the receipt file its missing parent's link /// names, read it as a group file, require it on the group's key and track, and offer it -/// to the chains — at most `max_walk` files. Once one is admitted, the parents fetched -/// before it are offered again, nearest that one first, then `group`. Returns how -/// `group` was admitted and how many files were fetched. +/// to the chains — at most `max_walk` files, never more than [`PARENT_WALK_BOUND`]. Once +/// one is admitted, the parents fetched before it are offered again, nearest that one +/// first, then `group`. Returns how `group` was admitted and how many files were fetched. fn admit_walking( chains: &mut ReceiptChains, group: &GroupReceipt, - fetch: &mut dyn FnMut(&[u8; 32]) -> Option>, + fetch: &mut ReceiptFetcher<'_>, now_ms: u64, max_walk: usize, ) -> Result<(Admission, usize), ReceiptRefusal> { @@ -285,6 +410,7 @@ fn admit_walking( Err(e) if e.reason != Reason::ParentNotHeld => return Err(ReceiptRefusal::Delivery(e)), Err(_) => {} } + let bound = max_walk.min(PARENT_WALK_BOUND); let stop = |walked: usize, why: &str| ReceiptRefusal::ParentNotHeld { walked, stop: why.to_string() }; let mut pending: Vec = Vec::new(); let mut link = group.parent_receipt_hash; @@ -292,10 +418,14 @@ fn admit_walking( loop { // §3 step 3 says `parent_not_held` only of a group that names a parent. let Some(l) = link else { return Err(stop(fetched, "parent_not_held")) }; - if fetched == max_walk { + if fetched == bound { return Err(stop(fetched, "bound")); } - let bytes = fetch(&l).ok_or_else(|| stop(fetched, "receipt_not_served"))?; + let bytes = match fetch.get(&l) { + Ok(b) => b, + Err(ReceiptRefusal::TooLarge) => return Err(stop(fetched + 1, "receipt_too_large")), + Err(r) => return Err(stop(fetched, r.code())), + }; fetched += 1; let p = read_group_file(&l, &bytes).map_err(|r| stop(fetched, r.code()))?; // A group of another chain is never this group's predecessor (§3 step 3). @@ -321,21 +451,20 @@ fn admit_walking( /// /// 1. the property's `content_hash` and the object receipt's are `cid` /// (`receipt_not_about_object`); -/// 2. the group receipt is served at `rcpt/.json` -/// (`receipt_not_served`), reads as a group file of that name ([`read_group_file`]); +/// 2. the group receipt is fetched at `rcpt/.json` and read as +/// a group file of that name ([`read_group_file`]); /// 3. jwp-receipts §3 steps 1–3 against `chains`, recovering missing parents as §8.1 -/// states, at most `max_walk` of them (`parent_not_held` past it); +/// states, at most `max_walk` of them (never more than [`PARENT_WALK_BOUND`]); /// 4. steps 4–5 for the object receipt, step 4 against what the client holds: the /// object (`delivered-verified`), or nothing (`inclusion-verified`, never delivered); /// 5. the property, against the object receipt for a delivered object /// (moq-streaming-format §5: `property_invalid`, `property_disagrees`), alone for one /// not delivered, which gives no reading. -#[allow(clippy::too_many_arguments)] pub fn check_delivery( cid: &[u8; 32], item: &IndexItem, held: &ObjectHeld, - fetch: &mut dyn FnMut(&[u8; 32]) -> Option>, + fetch: &mut ReceiptFetcher<'_>, chains: &mut ReceiptChains, now_ms: u64, max_walk: usize, @@ -344,7 +473,7 @@ pub fn check_delivery( return Err(ReceiptRefusal::NotAboutObject); } let name = item.property.group_receipt; - let bytes = fetch(&name).ok_or(ReceiptRefusal::NotServed)?; + let bytes = fetch.get(&name)?; let group = read_group_file(&name, &bytes)?; let (admission, walked) = admit_walking(chains, &group, fetch, now_ms, max_walk)?; let established = match held { @@ -361,7 +490,16 @@ pub fn check_delivery( None } }; - Ok(DeliveryVerdict { admission, walked, evidence, established: established.map(|e| e.by()), reading, group }) + Ok(DeliveryVerdict { + admission, + walked, + evidence, + established: established.map(|e| e.by()), + reading, + group, + object: item.object_receipt.clone(), + held: established, + }) } // ───────────────────────────── claims ───────────────────────────── @@ -371,11 +509,12 @@ pub fn check_delivery( pub enum Attests { /// The object in hand: its subject is the cid. Object, - /// A group receipt the index lists: its subject is that group's link hash. + /// A group receipt a delivery item that passed names: its subject is that group's + /// link hash. Receipt, /// Another object, the ancestor the object in hand was derived from: a soft-binding - /// claim whose subject is not the cid (jwp-receipts §3.2's ancestor rule). It says - /// nothing of the object in hand. + /// claim whose subject is not the cid, through which jwp-receipts §3.2's recovery + /// passed. It says nothing of the object in hand. Ancestor, } @@ -400,31 +539,142 @@ pub struct ClaimVerdict { pub parent: Option<[u8; 32]>, } +/// What a client brings to jwp-receipts §3.2's recovery through a soft binding: the +/// publisher keys of delivery receipts it has verified under §3 over the objects' bytes, +/// each with the content hash it is for; the value the registered algorithm computed from +/// the object in hand; and its matchers. Without it, or without a publisher key for the +/// soft binding's subject, no recovery is run. +pub struct Recovery<'a> { + pub publishers: &'a [([u8; 32], [u8; 32])], + pub observed_value: &'a [u8], + pub matchers: &'a [&'a dyn SoftBindingMatcher], +} + +/// §3.1 step 6 for `claim` over HTTP (§8.2): against the delivery items of the index +/// that passed (`passed`, in the index's order), and the declarations the claim names, +/// fetched by their links. A check whose receipt the client does not hold is +/// `figure_not_held` for a `measurement` claim and `context_not_held` for any other. +fn step6( + claim: &ClaimReceipt, + passed: &[DeliveryVerdict], + fetch: &mut ReceiptFetcher<'_>, + matchers: &[&dyn SoftBindingMatcher], +) -> Result<(), ReceiptRefusal> { + let c = ReceiptRefusal::Claim; + let delivered = || passed.iter().filter(|v| v.held.is_some()); + match &claim.body { + // §2.4.5: against the object receipt for its subject, verified under §3, and the + // OER receipt it names. + ClaimBody::Energy(e) => { + let mut any = false; + for v in delivered() { + any = true; + let class = v.object.label.as_ref().and_then(|l| l.class()); + claim.check_energy_against_labelled_object(&v.object.content_hash, v.object.joules_micro, class).map_err(c)?; + } + if !any || e.oer_receipt.is_some() { + return Err(ReceiptRefusal::ContextNotHeld); + } + Ok(()) + } + // §2.4.8, §2.4.9: against the receipt carrying the figure — for `moq-jwp:object`, + // an object receipt accepted under the group the claim names. + ClaimBody::Measurement(_) | ClaimBody::OperatingPoint(_) => { + let group = match &claim.body { + ClaimBody::Measurement(m) => m.figure.group, + ClaimBody::OperatingPoint(o) => o.figure.group, + _ => None, + }; + let view = delivered().find(|v| Some(v.group.link_hash()) == group).and_then(|v| { + HeldObject { object: &v.object, group: &v.group, established: v.held.as_ref()? }.figure_view() + }); + match view { + Some(view) => claim.check_figure_against(Some(&view)).map_err(c), + None if claim.needs_figure() => Err(c(Rejection { step: 6, reason: ClaimReason::FigureNotHeld })), + None => Err(ReceiptRefusal::ContextNotHeld), + } + } + // §2.4.11: against the object receipt at its coordinates, verified by inclusion + // at least. + ClaimBody::DeliveryGap(g) => { + let at = passed.iter().find(|v| { + (v.object.track_alias, v.object.group_id, v.object.object_id) == (g.track_alias, g.group_id, g.object_id) + }); + let v = at.ok_or(ReceiptRefusal::ContextNotHeld)?; + claim.check_gap_against(v.object.track_alias, v.object.group_id, v.object.object_id, &v.object.content_hash).map_err(c) + } + // §2.4.6: against the declaration it names, fetched by its link. + ClaimBody::LawfulBasis(LawfulBasisClaim::Decision { declaration, .. }) => { + let d = declaration_of(declaration, fetch)?; + claim.check_decision_against(&d).map_err(c) + } + // §2.6.3: against its declaration together with the publisher key of a verified + // delivery receipt for the declaration's subject. + ClaimBody::BindingRecovery(r) => { + if passed.is_empty() { + return Err(ReceiptRefusal::ContextNotHeld); + } + let d = declaration_of(&r.declaration, fetch)?; + let publisher = passed + .iter() + .map(|v| v.group.signer_pubkey) + .find(|k| *k == d.signer_pubkey) + .unwrap_or(passed[0].group.signer_pubkey); + claim.check_recovery_against(&d, &publisher, matchers).map_err(c) + } + // §2.4.12: its outputs, received objects and envelopes are the sink's, not + // served beside an object. + ClaimBody::ReconstructSession(_) => Err(ReceiptRefusal::ContextNotHeld), + _ => Ok(()), + } +} + +/// A declaration a claim names, fetched as `rcpt/.json`: not served is +/// `context_not_held`; one that is not a claim is `context_invalid`, as §3.1 step 6 +/// reports a declaration that does not verify. +fn declaration_of(link: &[u8; 32], fetch: &mut ReceiptFetcher<'_>) -> Result { + let bytes = match fetch.get(link) { + Ok(b) => b, + Err(ReceiptRefusal::NotServed) => return Err(ReceiptRefusal::ContextNotHeld), + Err(r) => return Err(r), + }; + let invalid = ReceiptRefusal::Claim(Rejection { step: 6, reason: ClaimReason::ContextInvalid }); + let text = std::str::from_utf8(&bytes).map_err(|_| invalid.clone())?; + ClaimReceipt::from_json(text).map_err(|_| invalid) +} + /// §8.2's check of one claim the index of the object `cid` lists by its link hash -/// `name`, in order: +/// `name`, against the delivery items of the index that passed, in order: /// -/// 1. it is served at `rcpt/.json` (`receipt_not_served`) and reads as a claim of -/// that name ([`read_claim_file`]); -/// 2. its subject is `cid`, or, for a claim about a receipt, the link hash of a group -/// one of the index's delivery items names (`groups`); a soft-binding claim about -/// another object attests that object, the ancestor; any other claim is -/// `receipt_not_about_object`; +/// 1. it is fetched at `rcpt/.json` and reads as a claim of that name +/// ([`read_claim_file`]); +/// 2. its subject is `cid`, or, for a claim about a receipt, the link hash of the group of +/// a delivery item that passed; any other claim is `receipt_not_about_object`, except a +/// soft-binding claim about another object when `recovery` holds a publisher key for +/// that object; /// 3. jwp-receipts §3.1 steps 1–4; /// 4. step 5: a claim that names a parent has it fetched at `rcpt/.json`, read /// as a claim of that name and verified (`parent_not_held` otherwise), and links to it -/// (`chain_break` otherwise). +/// (`chain_break` otherwise); +/// 5. step 6 against the items that passed and the declarations the claim names +/// (`figure_not_held`, `context_not_held` where the client holds no such receipt); or, +/// for a soft binding of another object, jwp-receipts §3.2's recovery with the +/// object in hand's content hash, whose reason a failure reports. pub fn check_claim( cid: &[u8; 32], - groups: &[[u8; 32]], + passed: &[DeliveryVerdict], name: &[u8; 32], - fetch: &mut dyn FnMut(&[u8; 32]) -> Option>, + fetch: &mut ReceiptFetcher<'_>, + recovery: Option<&Recovery<'_>>, ) -> Result { - let bytes = fetch(name).ok_or(ReceiptRefusal::NotServed)?; + let bytes = fetch.get(name)?; let claim = read_claim_file(name, &bytes)?; + let groups: Vec<[u8; 32]> = passed.iter().map(|v| v.group.link_hash()).collect(); + let publisher = |subject: &[u8; 32]| recovery.and_then(|r| r.publishers.iter().find(|(h, _)| h == subject).map(|(_, k)| *k)); let attests = match claim.subject_type { SubjectType::Object if claim.subject == *cid => Attests::Object, SubjectType::Receipt if groups.contains(&claim.subject) => Attests::Receipt, - SubjectType::Object if claim.class() == ClaimClass::SoftBinding => Attests::Ancestor, + SubjectType::Object if claim.class() == ClaimClass::SoftBinding && publisher(&claim.subject).is_some() => Attests::Ancestor, _ => return Err(ReceiptRefusal::NotAboutObject), }; claim.verify().map_err(ReceiptRefusal::Claim)?; @@ -432,32 +682,62 @@ pub fn check_claim( None => None, Some(p) => { let stop = |walked: usize, why: &str| ReceiptRefusal::ParentNotHeld { walked, stop: why.to_string() }; - let pb = fetch(&p).ok_or_else(|| stop(0, "receipt_not_served"))?; + let pb = match fetch.get(&p) { + Ok(b) => b, + Err(ReceiptRefusal::TooLarge) => return Err(stop(1, "receipt_too_large")), + Err(r) => return Err(stop(0, r.code())), + }; let prev = read_claim_file(&p, &pb).map_err(|r| stop(1, r.code()))?; prev.verify().map_err(|r| stop(1, r.reason.label()))?; claim.check_parent(Some(&prev)).map_err(ReceiptRefusal::Claim)?; Some(p) } }; + let no_matchers: [&dyn SoftBindingMatcher; 0] = []; + let matchers = recovery.map_or(&no_matchers[..], |r| r.matchers); + if attests == Attests::Ancestor { + let r = recovery.expect("a publisher key came from the recovery"); + let key = publisher(&claim.subject).expect("checked above"); + crate::claim_receipt::recover(&claim, &key, r.observed_value, Some(*cid), matchers).map_err(ReceiptRefusal::Recovery)?; + } else { + step6(&claim, passed, fetch, matchers)?; + } Ok(ClaimVerdict { claim, attests, parent }) } // ───────────────────────────── the whole index ───────────────────────────── /// What a client established from an index (§8.2): each delivery item's verdict or -/// reason, and each claim's, in the index's order. +/// reason, and each claim's, in the index's order, and how many receipt files it fetched. #[derive(Clone, Debug, PartialEq, Eq)] pub struct IndexReport { pub delivery: Vec>, pub claims: Vec>, + pub fetched: usize, +} + +/// The bounds a client checks an index under (§8.1, §8.2): the parents walked for one +/// group and the receipt files fetched for the index, each clamped to the protocol's. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + pub max_walk: usize, + pub fetch_budget: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { max_walk: PARENT_WALK_BOUND, fetch_budget: FETCH_BUDGET } + } } /// §8.2: check the index `index` of the object `cid` against what the client holds of -/// the object, with the receipt files `fetch` serves and the client's `chains`. The index -/// is refused whole when it does not read (`receipt_index_malformed`) or names another -/// object (`receipt_not_about_object`); otherwise each delivery item is checked in order -/// ([`check_delivery`]), then each claim ([`check_claim`]) against the groups the delivery -/// items that read name. +/// the object, with the receipt files `fetch` serves and the client's `chains`, under +/// `limits`, and, where the client brings one, `recovery`'s context. The index is refused +/// whole when it does not read (`receipt_index_malformed`), is too large +/// (`receipt_index_too_large`) or names another object (`receipt_not_about_object`); +/// otherwise each delivery item is checked in order ([`check_delivery`]), then each claim +/// ([`check_claim`]) against the items that passed. Each name is fetched once, and at +/// most `limits.fetch_budget` files in all. #[allow(clippy::too_many_arguments)] pub fn check_index( cid: &[u8; 32], @@ -466,28 +746,33 @@ pub fn check_index( fetch: &mut dyn FnMut(&[u8; 32]) -> Option>, chains: &mut ReceiptChains, now_ms: u64, - max_walk: usize, + limits: &Limits, + recovery: Option<&Recovery<'_>>, ) -> Result { let ix = ReceiptIndex::from_bytes(index)?; if ix.object != *cid { return Err(ReceiptRefusal::NotAboutObject); } - let groups: Vec<[u8; 32]> = ix.delivery.iter().filter_map(|i| i.as_ref().ok().map(|i| i.property.group_receipt)).collect(); - let delivery = ix + let mut fetcher = ReceiptFetcher::new(fetch, limits.fetch_budget); + let delivery: Vec> = ix .delivery .iter() .map(|i| match i { - Ok(item) => check_delivery(cid, item, held, fetch, chains, now_ms, max_walk), + Ok(item) => check_delivery(cid, item, held, &mut fetcher, chains, now_ms, limits.max_walk), Err(e) => Err(e.clone()), }) .collect(); + let passed: Vec = delivery.iter().filter_map(|d| d.as_ref().ok().cloned()).collect(); + // A recovery runs only for an object the client holds: the value it compares is + // computed from it. + let recovery = recovery.filter(|_| matches!(held, ObjectHeld::Object(_))); let claims = ix .claims .iter() .map(|c| match c { - Ok(name) => check_claim(cid, &groups, name, fetch), + Ok(name) => check_claim(cid, &passed, name, &mut fetcher, recovery), Err(e) => Err(e.clone()), }) .collect(); - Ok(IndexReport { delivery, claims }) + Ok(IndexReport { delivery, claims, fetched: fetcher.fetched() }) } diff --git a/wai/wai-rs/src/http_binding/tests_receipts.rs b/wai/wai-rs/src/http_binding/tests_receipts.rs index 9ddb6854c..078fde765 100644 --- a/wai/wai-rs/src/http_binding/tests_receipts.rs +++ b/wai/wai-rs/src/http_binding/tests_receipts.rs @@ -93,10 +93,35 @@ fn check( let cid = content_hash(&t.envs[i]); let index = index_file(&cid, &[item(&t.groups[i], &t.objects[i])], &[]).unwrap(); let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); - let r = check_index(&cid, &index, held, &mut fetch, chains, 0, bound).unwrap(); + let r = check_index(&cid, &index, held, &mut fetch, chains, 0, &Limits { max_walk: bound, fetch_budget: FETCH_BUDGET }, None).unwrap(); r.delivery.into_iter().next().unwrap() } +/// One delivery item checked against the files `files` serves. +fn deliver( + cid: &[u8; 32], + it: &IndexItem, + held: &ObjectHeld, + files: &BTreeMap<[u8; 32], Vec>, + chains: &mut ReceiptChains, +) -> Result { + let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut f = ReceiptFetcher::new(&mut fetch, FETCH_BUDGET); + check_delivery(cid, it, held, &mut f, chains, 0, PARENT_WALK_BOUND) +} + +/// One claim checked against the items that passed and the files `files` serves. +fn claim_of( + cid: &[u8; 32], + passed: &[DeliveryVerdict], + name: &[u8; 32], + files: &BTreeMap<[u8; 32], Vec>, +) -> Result { + let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut f = ReceiptFetcher::new(&mut fetch, FETCH_BUDGET); + check_claim(cid, passed, name, &mut f, None) +} + /// B's retrofit: §3 step 4 compares with the hash established for the object, recomputed /// over its bytes or the cid a head proof verified; anything else is a mismatch. #[test] @@ -199,7 +224,7 @@ fn an_index_reads_item_by_item() { // An index about another object is refused whole. let mut fetch = |h: &[u8; 32]| t.files.get(h).cloned(); let other = content_hash(b"another"); - let e = check_index(&other, text.as_bytes(), &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, PARENT_WALK_BOUND).unwrap_err(); + let e = check_index(&other, text.as_bytes(), &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap_err(); assert_eq!(e, ReceiptRefusal::NotAboutObject); } @@ -249,7 +274,7 @@ fn an_unrecovered_parent_is_parent_not_held() { files.insert(g.link_hash(), group_file(&g).unwrap()); let index = index_file(&o[0].content_hash, &[item(&g, &o[0])], &[]).unwrap(); let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); - let r = check_index(&o[0].content_hash, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, 64).unwrap(); + let r = check_index(&o[0].content_hash, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap(); assert_eq!(r.delivery[0].as_ref().unwrap_err(), &pnh(1, "another_chain")); } @@ -261,35 +286,32 @@ fn a_delivery_item_is_refused_for_its_reason() { let (g, o) = (&t.groups[0], &t.objects[0]); let cid = o.content_hash; let mut chains = ReceiptChains::new(); - let mut fetch = |h: &[u8; 32]| t.files.get(h).cloned(); let mut it = item(g, o); it.property.content_hash = [9; 32]; - assert_eq!(check_delivery(&cid, &it, &ObjectHeld::Nothing, &mut fetch, &mut chains, 0, 64), Err(ReceiptRefusal::NotAboutObject)); + assert_eq!(deliver(&cid, &it, &ObjectHeld::Nothing, &t.files, &mut chains), Err(ReceiptRefusal::NotAboutObject)); let mut it = item(g, o); it.property.group_receipt = [9; 32]; - assert_eq!(check_delivery(&cid, &it, &ObjectHeld::Nothing, &mut fetch, &mut chains, 0, 64), Err(ReceiptRefusal::NotServed)); + assert_eq!(deliver(&cid, &it, &ObjectHeld::Nothing, &t.files, &mut chains), Err(ReceiptRefusal::NotServed)); // A signature changed: served under the link hash of the bytes as changed. let mut bad = g.clone(); bad.sig[0] ^= 1; let files: BTreeMap<[u8; 32], Vec> = [(bad.link_hash(), group_file(&bad).unwrap())].into(); - let mut fetch_bad = |h: &[u8; 32]| files.get(h).cloned(); let it = item(&bad, o); - let e = check_delivery(&cid, &it, &ObjectHeld::Nothing, &mut fetch_bad, &mut chains, 0, 64).unwrap_err(); + let e = deliver(&cid, &it, &ObjectHeld::Nothing, &files, &mut chains).unwrap_err(); assert_eq!((e.code(), e.step()), ("signature_verify_failed", Some(2))); // A property stating a figure the unmetered leaf does not. let mut it = item(g, o); it.property.joules_micro = 5; it.property.acquisition = "Estimator".into(); - assert_eq!(check_delivery(&cid, &it, &whole(&t.envs[0]), &mut fetch, &mut chains, 0, 64).unwrap_err().code(), "property_disagrees"); + assert_eq!(deliver(&cid, &it, &whole(&t.envs[0]), &t.files, &mut chains).unwrap_err().code(), "property_disagrees"); // The same property beside an object not received gives no reading, and passes. - assert_eq!(check_delivery(&cid, &it, &ObjectHeld::Nothing, &mut fetch, &mut chains, 0, 64).unwrap().reading, None); + assert_eq!(deliver(&cid, &it, &ObjectHeld::Nothing, &t.files, &mut chains).unwrap().reading, None); // A resigned group whose root no longer covers the object: step 5. let mut moved = g.clone(); moved.root_hash = [3; 32]; let moved = resign(moved, &key(0x61)); let files: BTreeMap<[u8; 32], Vec> = [(moved.link_hash(), group_file(&moved).unwrap())].into(); - let mut fetch_moved = |h: &[u8; 32]| files.get(h).cloned(); - let e = check_delivery(&cid, &item(&moved, o), &ObjectHeld::Nothing, &mut fetch_moved, &mut ReceiptChains::new(), 0, 64).unwrap_err(); + let e = deliver(&cid, &item(&moved, o), &ObjectHeld::Nothing, &files, &mut ReceiptChains::new()).unwrap_err(); assert_eq!((e.code(), e.step()), ("inclusion_proof_failed", Some(5))); } @@ -334,31 +356,272 @@ fn claims_are_checked_by_subject_and_parent() { let names = [c0.link_hash(), c1.link_hash(), ancestor.link_hash(), elsewhere.link_hash(), [8; 32]]; let index = index_file(&cid, &[item(&t.groups[0], &t.objects[0])], &names).unwrap(); let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); - let r = check_index(&cid, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, 64).unwrap(); + let r = check_index(&cid, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap(); let got: Vec), &'static str>> = r.claims.iter().map(|c| c.as_ref().map(|v| (v.attests, v.parent)).map_err(ReceiptRefusal::code)).collect(); + // A soft binding of another object, with no recovery run, is about another object. assert_eq!(got, vec![ Ok((Attests::Object, None)), Ok((Attests::Receipt, Some(c0.link_hash()))), - Ok((Attests::Ancestor, None)), + Err("receipt_not_about_object"), Err("receipt_not_about_object"), Err("receipt_not_served"), ]); + let passed = vec![deliver(&cid, &item(&t.groups[0], &t.objects[0]), &ObjectHeld::Nothing, &files, &mut ReceiptChains::new()).unwrap()]; // A parent not served, or served under another name. let mut no_parent = files.clone(); no_parent.remove(&c0.link_hash()); - let mut fetch = |h: &[u8; 32]| no_parent.get(h).cloned(); - assert_eq!(check_claim(&cid, &[group], &c1.link_hash(), &mut fetch).unwrap_err(), ReceiptRefusal::ParentNotHeld { walked: 0, stop: "receipt_not_served".into() }); + assert_eq!(claim_of(&cid, &passed, &c1.link_hash(), &no_parent).unwrap_err(), ReceiptRefusal::ParentNotHeld { walked: 0, stop: "receipt_not_served".into() }); let mut wrong = files.clone(); wrong.insert(c0.link_hash(), claim_file(&ancestor)); - let mut fetch = |h: &[u8; 32]| wrong.get(h).cloned(); - assert_eq!(check_claim(&cid, &[group], &c1.link_hash(), &mut fetch).unwrap_err(), ReceiptRefusal::ParentNotHeld { walked: 1, stop: "receipt_name_mismatch".into() }); + assert_eq!(claim_of(&cid, &passed, &c1.link_hash(), &wrong).unwrap_err(), ReceiptRefusal::ParentNotHeld { walked: 1, stop: "receipt_name_mismatch".into() }); // A parent that is another signer's claim: the link does not hold (chain_break). let c1x = logged(0x63, group, 1, Some(ancestor.link_hash())); let mut other = files.clone(); other.insert(c1x.link_hash(), claim_file(&c1x)); - let mut fetch = |h: &[u8; 32]| other.get(h).cloned(); - assert_eq!(check_claim(&cid, &[group], &c1x.link_hash(), &mut fetch).unwrap_err().code(), "chain_break"); + assert_eq!(claim_of(&cid, &passed, &c1x.link_hash(), &other).unwrap_err().code(), "chain_break"); + // With no item that passed, a claim about the group is about nothing the client holds. + assert_eq!(claim_of(&cid, &[], &c1.link_hash(), &files).unwrap_err(), ReceiptRefusal::NotAboutObject); +} + +/// A track whose one object's leaf carries `label` at `joules`. +fn labelled_track(seed: u8, track_alias: u64, joules: u64, label: ObjectLabel) -> Track { + let env = envelope(200 + seed as usize); + let mut b = GroupBuilder::default(); + b.add(content_hash(&env), joules, "origin", Some(label)); + let (g, mut o) = b.finish(&key(seed), SIGNER, track_alias, 0, "2026-10-06T00:00:00Z", "2026-10-06T00:00:04Z", None, true).unwrap(); + let files = [(g.link_hash(), group_file(&g).unwrap())].into(); + Track { envs: vec![env], groups: vec![g], objects: vec![o.remove(0)], files } +} + +fn chip() -> crate::energy_class::EnergyProvenance { + crate::energy_class::EnergyProvenance::on_chip_counter( + crate::energy_class::Uncertainty::new(44_669, 3_000_000).unwrap(), + crate::energy_class::CounterFiltering::Unknown, + ) + .unwrap() +} + +/// The `measurement` claim of claim-conformance's `measurement_object_accept`, about the +/// object `subject` at leaf 0 of `group`, signed by `signer`. +fn measurement(signer: u8, subject: [u8; 32], group: [u8; 32], seq: u64) -> ClaimReceipt { + let doc: serde_json::Value = + serde_json::from_slice(&std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../claim-conformance/vectors.json")).unwrap()).unwrap(); + let v = doc["vectors"].as_array().unwrap().iter().find(|v| v["name"] == "measurement_object_accept").unwrap(); + let mut body = ClaimReceipt::from_json(v["receipt"].as_str().unwrap()).unwrap().body; + let ClaimBody::Measurement(m) = &mut body else { panic!("a measurement") }; + m.figure.figure_index = Some(0); + m.figure.group = Some(group); + ClaimReceipt::seal(&key(signer), SIGNER, SubjectType::Object, subject, seq, "2026-10-06T00:00:07Z", body, None).unwrap() +} + +fn energy(signer: u8, subject: [u8; 32], joules: u64, oer: Option, seq: u64) -> ClaimReceipt { + let e = crate::claim_receipt::EnergyClaim::new(joules, &chip(), crate::energy_binding::Coverage::Whole, "measured", + "https://transaction.science/wai/extensions/energy-measurement", 1, "subpixel", oer); + ClaimReceipt::seal(&key(signer), SIGNER, SubjectType::Object, subject, seq, "2026-10-06T00:00:08Z", ClaimBody::Energy(e), None).unwrap() +} + +fn gap(signer: u8, subject: [u8; 32], object_id: u64) -> ClaimReceipt { + let g = crate::claim_receipt::DeliveryGapClaim { + track_alias: 4, + group_id: 0, + object_id, + reason: crate::claim_receipt::GapReason::NotReceived, + observed_at: "2026-10-06T00:00:09Z".into(), + }; + ClaimReceipt::seal(&key(signer), "did:example:relay", SubjectType::Object, subject, 0, "2026-10-06T00:00:09Z", ClaimBody::DeliveryGap(g), None) + .unwrap() +} + +/// §8.2, jwp-receipts §3.1 step 6: a claim is checked against the object receipts of the +/// items that passed. A `measurement` claim with no figure held is `figure_not_held`; an +/// `energy` claim with no delivered object, or naming an OER receipt the client does not +/// hold, is `context_not_held`; a `delivery-gap` claim needs the receipt at its +/// coordinates, verified by inclusion at least. None is accepted on steps 1–5 alone. +#[test] +fn step_6_runs_against_the_items_that_passed() { + let t = labelled_track(0x61, 4, 461_538, ObjectLabel::Class(chip())); + let (g, o) = (&t.groups[0], &t.objects[0]); + let cid = o.content_hash; + let m = measurement(0x61, cid, g.link_hash(), 0); + let ok = energy(0x66, cid, 461_538, None, 0); + let other_figure = energy(0x67, cid, 461_539, None, 0); + let named_oer = energy(0x68, cid, 461_538, Some(format!("sha256:{}", "00".repeat(32))), 0); + let at = gap(0x69, cid, 0); + let elsewhere = gap(0x6a, cid, 1); + let mut files = t.files.clone(); + let claims = [&m, &ok, &other_figure, &named_oer, &at, &elsewhere]; + for c in claims { + files.insert(c.link_hash(), claim_file(c)); + } + let names: Vec<[u8; 32]> = claims.iter().map(|c| c.link_hash()).collect(); + let mut it = item(g, o); + it.property.joules_micro = 461_538; + it.property.acquisition = "OnChipCounter".into(); + it.property.acquisition_evidence = crate::jwp_delivery::property_evidence(&chip()); + let index = index_file(&cid, &[it], &names).unwrap(); + let run = |held: &ObjectHeld| { + let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let r = check_index(&cid, &index, held, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap(); + assert!(r.delivery[0].is_ok(), "{:?}", r.delivery[0]); + r.claims.iter().map(|c| c.as_ref().map(|v| v.attests.label()).map_err(|e| (e.code(), e.step()))).collect::>() + }; + assert_eq!(run(&whole(&t.envs[0])), vec![ + Ok("object"), + Ok("object"), + Err(("energy_disagrees_with_delivery", Some(6))), + Err(("context_not_held", Some(6))), + Ok("object"), + Err(("context_not_held", Some(6))), + ]); + // The object not received: no figure, no delivered object; the gap still has its + // receipt, by inclusion. + assert_eq!(run(&ObjectHeld::Nothing), vec![ + Err(("figure_not_held", Some(6))), + Err(("context_not_held", Some(6))), + Err(("context_not_held", Some(6))), + Err(("context_not_held", Some(6))), + Ok("object"), + Err(("context_not_held", Some(6))), + ]); +} + +/// §8.2: a claim about a receipt attaches only through a delivery item that passed for +/// this object: one about the group of an item refused, or of another object, is not +/// about this object. +#[test] +fn a_claim_about_a_receipt_needs_an_item_that_passed() { + let a = track(0x61, 1, 1); + let b = track(0x62, 2, 1); + let cid = a.objects[0].content_hash; + let about_b = logged(0x63, b.groups[0].link_hash(), 0, None); + let mut files = a.files.clone(); + files.extend(b.files.clone()); + files.insert(about_b.link_hash(), claim_file(&about_b)); + // B's item listed in A's index: refused, so B's group is no group of A's. + let index = index_file(&cid, &[item(&b.groups[0], &b.objects[0]), item(&a.groups[0], &a.objects[0])], &[about_b.link_hash()]).unwrap(); + let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let r = check_index(&cid, &index, &whole(&a.envs[0]), &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap(); + assert_eq!(r.delivery[0].as_ref().unwrap_err(), &ReceiptRefusal::NotAboutObject); + assert!(r.delivery[1].is_ok()); + assert_eq!(r.claims[0].as_ref().unwrap_err(), &ReceiptRefusal::NotAboutObject); +} + +/// §8.2, jwp-receipts §3.2: a soft binding of another object attests that ancestor only +/// once the recovery passed: a publisher key from a delivery receipt the client verified +/// for the ancestor, the declaration signed by it, and a matcher that matches. Otherwise +/// it is about another object, or the recovery's reason. +#[test] +fn a_soft_binding_attests_an_ancestor_only_through_recovery() { + let a = track(0x61, 1, 1); + let b = track(0x62, 2, 1); + let (ancestor, cid) = (a.objects[0].content_hash, b.objects[0].content_hash); + let by_publisher = soft(0x61, ancestor, 0, None); + let by_another = soft(0x64, ancestor, 0, None); + let mut files = b.files.clone(); + files.insert(by_publisher.link_hash(), claim_file(&by_publisher)); + files.insert(by_another.link_hash(), claim_file(&by_another)); + let index = index_file(&cid, &[item(&b.groups[0], &b.objects[0])], &[by_publisher.link_hash(), by_another.link_hash()]).unwrap(); + let publishers = [(ancestor, key(0x61).verifying_key().to_bytes())]; + let exact = crate::claim_receipt::ExactMatch { alg: "org.example.fp".into() }; + let matchers: [&dyn crate::claim_receipt::SoftBindingMatcher; 1] = [&exact]; + let run = |held: &ObjectHeld, rec: Option<&Recovery<'_>>| { + let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let r = check_index(&cid, &index, held, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), rec).unwrap(); + r.claims.iter().map(|c| c.as_ref().map(|v| v.attests.label()).map_err(|e| e.code())).collect::>() + }; + let held = whole(&b.envs[0]); + assert_eq!(run(&held, None), vec![Err("receipt_not_about_object"), Err("receipt_not_about_object")]); + let good = Recovery { publishers: &publishers, observed_value: &[0, 0xff], matchers: &matchers }; + assert_eq!(run(&held, Some(&good)), vec![Ok("ancestor"), Err("recovery_signer_mismatch")]); + let other_value = Recovery { publishers: &publishers, observed_value: &[1], matchers: &matchers }; + assert_eq!(run(&held, Some(&other_value))[0], Err("recovery_value_mismatch")); + let no_matcher = Recovery { publishers: &publishers, observed_value: &[0, 0xff], matchers: &[] }; + assert_eq!(run(&held, Some(&no_matcher))[0], Err("recovery_no_matcher")); + // Nothing held: no value to compare, no recovery. + assert_eq!(run(&ObjectHeld::Nothing, Some(&good))[0], Err("receipt_not_about_object")); +} + +/// §8.1: a legacy group's link hash does not cover its parent, so other bytes read under +/// its name; over HTTP it is refused, and so is the copy with its parent rewritten. +#[test] +fn a_legacy_group_is_refused_over_http() { + let env = envelope(7); + let mut b = GroupBuilder::default(); + b.add(content_hash(&env), 0, "origin", None); + let (g, o) = b.finish(&key(0x61), SIGNER, 1, 1, "2026-10-06T00:00:00Z", "2026-10-06T00:00:04Z", Some([4; 32]), false).unwrap(); + let mut forged = g.clone(); + forged.parent_receipt_hash = None; + assert_eq!(forged.link_hash(), g.link_hash(), "the legacy link hash does not cover the parent"); + for bytes in [group_file(&g).unwrap(), group_file(&forged).unwrap()] { + assert_eq!(read_group_file(&g.link_hash(), &bytes), Err(ReceiptRefusal::LegacyGroup)); + } + let files: BTreeMap<[u8; 32], Vec> = [(g.link_hash(), group_file(&forged).unwrap())].into(); + let index = index_file(&o[0].content_hash, &[item(&g, &o[0])], &[]).unwrap(); + let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let r = check_index(&o[0].content_hash, &index, &whole(&env), &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap(); + assert_eq!(r.delivery[0].as_ref().unwrap_err(), &ReceiptRefusal::LegacyGroup); +} + +/// §8.1: a client walks at most 64 parents for one group, whatever bound it asks for. +#[test] +fn the_walk_is_bounded_at_64() { + let t = track(0x62, 1, 66); + let wide = Limits { max_walk: 1000, fetch_budget: FETCH_BUDGET }; + let at = |i: usize| { + let cid = content_hash(&t.envs[i]); + let index = index_file(&cid, &[item(&t.groups[i], &t.objects[i])], &[]).unwrap(); + let mut fetch = |h: &[u8; 32]| t.files.get(h).cloned(); + check_index(&cid, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, &wide, None).unwrap().delivery.remove(0) + }; + // 64 parents reach the chain's start; 65 do not. + assert_eq!(at(64).map(|v| (v.admission, v.walked)), Ok((Admission::Linked, 64))); + assert_eq!(at(65).unwrap_err(), ReceiptRefusal::ParentNotHeld { walked: 64, stop: "bound".into() }); +} + +/// §8.2: one index check fetches each name once, at most its budget, no file over its +/// cap, from an index within its caps. +#[test] +fn one_index_fetches_each_name_once_within_its_bounds() { + let t = track(0x63, 1, 70); + let last = t.groups.len() - 1; + let cid = content_hash(&t.envs[last]); + let items: Vec = (0..50).map(|_| item(&t.groups[last], &t.objects[last])).collect(); + let index = index_file(&cid, &items, &[]).unwrap(); + let count = std::cell::Cell::new(0usize); + let run = |limits: &Limits, files: &BTreeMap<[u8; 32], Vec>| { + count.set(0); + let mut fetch = |h: &[u8; 32]| { + count.set(count.get() + 1); + files.get(h).cloned() + }; + check_index(&cid, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, limits, None).unwrap() + }; + // Fifty items naming one group: the group and 64 parents, fetched once. + let r = run(&Limits::default(), &t.files); + assert_eq!((r.fetched, count.get()), (65, 65)); + assert!(r.delivery.iter().all(|d| d.as_ref().unwrap_err() == &ReceiptRefusal::ParentNotHeld { walked: 64, stop: "bound".into() })); + // A budget of ten: the walk stops where it is spent. + let r = run(&Limits { max_walk: 64, fetch_budget: 10 }, &t.files); + assert_eq!((r.fetched, count.get()), (10, 10)); + assert_eq!(r.delivery[0].as_ref().unwrap_err(), &ReceiptRefusal::ParentNotHeld { walked: 9, stop: "receipt_fetch_budget_exhausted".into() }); + // A budget above the protocol's is the protocol's. + assert_eq!(ReceiptFetcher::new(&mut |_: &[u8; 32]| None, usize::MAX).get(&[0; 32]), Err(ReceiptRefusal::NotServed)); + // A receipt file over its cap. + let mut big = t.files.clone(); + let mut padded = big[&t.groups[last].link_hash()].clone(); + padded.resize(MAX_RECEIPT_FILE_BYTES + 1, b' '); + big.insert(t.groups[last].link_hash(), padded); + assert_eq!(run(&Limits::default(), &big).delivery[0].as_ref().unwrap_err(), &ReceiptRefusal::TooLarge); + // An index over its caps: bytes, items, claims. + let mut long = index.clone(); + long.resize(MAX_INDEX_BYTES + 1, b' '); + assert_eq!(ReceiptIndex::from_bytes(&long).unwrap_err().code(), "receipt_index_too_large"); + let many: Vec = (0..=MAX_INDEX_ITEMS).map(|_| item(&t.groups[last], &t.objects[last])).collect(); + assert_eq!(ReceiptIndex::from_bytes(&index_file(&cid, &many, &[]).unwrap()).unwrap_err().code(), "receipt_index_too_large"); + let claims = vec![[1u8; 32]; MAX_INDEX_CLAIMS + 1]; + assert_eq!(ReceiptIndex::from_bytes(&index_file(&cid, &[], &claims).unwrap()).unwrap_err().code(), "receipt_index_too_large"); + assert!(ReceiptIndex::from_bytes(&index_file(&cid, &many[1..], &claims[1..]).unwrap()).is_ok()); } #[cfg(feature = "key_discovery")] diff --git a/wai/wai-rs/tests/cli_http.rs b/wai/wai-rs/tests/cli_http.rs index acb03b1e5..d72b714e5 100644 --- a/wai/wai-rs/tests/cli_http.rs +++ b/wai/wai-rs/tests/cli_http.rs @@ -638,15 +638,20 @@ fn receipts_checks_an_index_over_http() { let run = wai(&["http", "receipts", "--url", &url("O3"), "--advertise", "wai.video.av1", "--pin-key-log", log.to_str().unwrap(), "--key-origin", "https://origin.example"]); let out = String::from_utf8_lossy(&run.stdout); - assert!(run.status.success(), "{out}{}", String::from_utf8_lossy(&run.stderr)); - let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + let v: serde_json::Value = serde_json::from_str(&out).unwrap_or_else(|e| panic!("{e}: {out}{}", String::from_utf8_lossy(&run.stderr))); assert_eq!((v["fetch"].as_str(), v["held"].as_str()), (Some("head-proof"), Some("head-proof")), "{v}"); let d = &v["delivery"][0]; assert_eq!((d["evidence"].as_str(), d["established"].as_str()), (Some("delivered-verified"), Some("head-proof")), "{v}"); assert!(d["confirm"].as_str().unwrap().starts_with("confirmed origin=https://origin.example key_id=d1"), "{v}"); - assert_eq!(v["claims"][0]["attests"], "ancestor"); + // O3's claims: a soft binding of O7, with no recovery run here, is about another + // object; a receipt claim with its parent; an energy claim checked at step 6 against + // the object's figure, and one that disagrees with it. + assert!(v["claims"][0]["refused"].as_str().unwrap().starts_with("receipt_not_about_object"), "{v}"); assert_eq!(v["claims"][1]["attests"], "receipt"); assert!(v["claims"][1]["parent"].is_string(), "{v}"); + assert_eq!(v["claims"][2]["attests"], "object"); + assert!(v["claims"][3]["refused"].as_str().unwrap().starts_with("energy_disagrees_with_delivery"), "{v}"); + assert!(!run.status.success(), "a refused claim makes the exit status 1"); let run = wai(&["http", "receipts", "--url", &url("O9c"), "--advertise", "wai.video.av1"]); let v: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap(); assert!(run.status.success(), "{v}"); @@ -666,3 +671,98 @@ fn receipts_checks_an_index_over_http() { let (h, _) = get(o.port, "/.well-known/wai-key-status", &[]); assert!(h.contains("Cache-Control: max-age=900, no-transform"), "{h}"); } + +/// A test server for `wai http receipts`: each connection's request head is kept, and it +/// answers `mode`: `log` (the corpus's key log, 200), `redirect` (302 to another host), +/// `hang` (a status line and headers, then nothing), `stream` (a body that does not end). +#[cfg(feature = "key_discovery")] +fn test_server(mode: &'static str) -> (u16, std::sync::Arc>>) { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let kept = seen.clone(); + std::thread::spawn(move || { + for s in l.incoming().flatten() { + let kept = kept.clone(); + std::thread::spawn(move || { + let mut s = s; + let mut head = Vec::new(); + let mut b = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") && s.read(&mut b).map_or(false, |n| n == 1) { + head.push(b[0]); + } + kept.lock().unwrap().push(String::from_utf8_lossy(&head).into_owned()); + match mode { + "log" => { + let body = std::fs::read(corpus().join(".well-known/wai-keys")).unwrap(); + let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes()); + let _ = s.write_all(&body); + } + "redirect" => { + let _ = s.write_all(b"HTTP/1.1 302 Found\r\nLocation: https://mirror.example/.well-known/wai-keys\r\nContent-Length: 0\r\n\r\n"); + } + "hang" => { + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n"); + std::thread::sleep(std::time::Duration::from_secs(30)); + } + _ => { + let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n"); + let chunk = vec![b' '; 1 << 20]; + for _ in 0..512 { + if s.write_all(&chunk).is_err() { + break; + } + } + } + } + }); + } + }); + (port, seen) +} + +/// §8.4 over HTTP: `wai http receipts --key-origin` anchors the origin from its key-log +/// location — asking caches to revalidate, following no redirect, the retrieval read from +/// the response — before it confirms any signer; a redirect anchors nothing. +#[cfg(feature = "key_discovery")] +#[test] +fn receipts_anchors_the_key_log_from_its_origin() { + let o = Origin::start(&corpus(), &[]); + let p = object("O3"); + let url = format!("http://127.0.0.1:{}/{}", o.port, p.strip_prefix(corpus()).unwrap().to_str().unwrap()); + let (kp, seen) = test_server("log"); + let run = wai(&["http", "receipts", "--url", &url, "--held", "none", "--key-origin", "https://origin.example", "--connect", &format!("127.0.0.1:{kp}")]); + let v: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&run.stderr))); + assert_eq!(v["anchor"]["path"], "fetched", "{v}"); + assert!(v["delivery"][0]["confirm"].as_str().unwrap().starts_with("confirmed origin=https://origin.example key_id=d1"), "{v}"); + let req = seen.lock().unwrap().join(""); + assert!(req.starts_with("GET /.well-known/wai-keys HTTP/1.1\r\n"), "{req}"); + assert!(req.contains("\r\nHost: origin.example\r\n") && req.contains("\r\nCache-Control: no-cache\r\n"), "{req}"); + // A redirect is not followed, and the answer anchors nothing. + let (rp, seen) = test_server("redirect"); + let run = wai(&["http", "receipts", "--url", &url, "--held", "none", "--key-origin", "https://origin.example", "--connect", &format!("127.0.0.1:{rp}")]); + assert!(!run.status.success()); + let v: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap(); + assert_eq!(v["anchor"]["refused"], "key_log_not_from_origin", "{v}"); + assert!(v["delivery"][0].get("confirm").is_none(), "{v}"); + assert_eq!(seen.lock().unwrap().len(), 1, "the redirect was followed"); +} + +/// §8.2 over HTTP: a server that stops after its headers, or never ends its body, costs +/// `wai http receipts` no more than its deadline and the index's cap. +#[cfg(feature = "key_discovery")] +#[test] +fn receipts_is_bounded_by_its_deadline_and_caps() { + let cid = "31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3"; + let (hp, _) = test_server("hang"); + let t0 = std::time::Instant::now(); + let run = wai(&["http", "receipts", "--held", "none", "--deadline-ms", "1500", "--url", &format!("http://127.0.0.1:{hp}/o/{cid}.wai")]); + assert!(!run.status.success()); + assert!(String::from_utf8_lossy(&run.stderr).contains("deadline"), "{}", String::from_utf8_lossy(&run.stderr)); + assert!(t0.elapsed() < std::time::Duration::from_secs(20), "{:?}", t0.elapsed()); + let (sp, _) = test_server("stream"); + let run = wai(&["http", "receipts", "--held", "none", "--deadline-ms", "60000", "--url", &format!("http://127.0.0.1:{sp}/o/{cid}.wai")]); + assert!(!run.status.success()); + let v: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&run.stderr))); + assert!(v["index"].as_str().unwrap().starts_with("receipt_index_too_large"), "{v}"); +} -- 2.54.0 (Apple Git-157) From 4b6d99ac5a4f82f0a575110cdf994e8185210be1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 22:13:53 -0400 Subject: [PATCH 07/16] CI: wai-http paths cover what the receipt checks read; http_verify.py needs cryptography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wai-http.yml now runs on changes to jcs, signer_id, energy_class, energy_figure, energy_binding, energy_report, claim_text, oer, claim_receipt/ and oer2_verify.py, which the receipt, claim and key checks of §8.1-§8.5 read. wai-conformance.yml no longer says http_verify.py is standard library only. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 8 +++++-- .github/workflows/wai-http.yml | 33 ++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index e9d666fea..58e7458ca 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -525,8 +525,12 @@ jobs: # selection, the byte-range procedure and its fallback, the codecs union and floor, the # playlists (read, rendered, held adjacent, the classical master unchanged), playlist # receipts (Ed25519 from RFC 8032) and the live edge, and the adaptation policies, - # checked first against published BLAKE3 vectors. Standard library only. Its - # mutations and the differential against wai-rs run in wai-http.yml's falsify job. + # checked first against published BLAKE3 vectors, with the standard library alone; + # and the receipts, key logs, grants files and parameter sets of §8.1–§8.5, through + # http_receipts.py and the corpora's own verifiers of jwp-receipts, which need the + # `cryptography` package installed above (without it those cases fail, never skip). + # Its mutations and the differential against wai-rs run in wai-http.yml's falsify + # job. - name: http_verify.py working-directory: wai shell: bash diff --git a/.github/workflows/wai-http.yml b/.github/workflows/wai-http.yml index c20d5141f..940315b0c 100644 --- a/.github/workflows/wai-http.yml +++ b/.github/workflows/wai-http.yml @@ -12,9 +12,12 @@ name: WAI HTTP delivery # bytes after a mismatch, an unanswered base, the selection digest test; and on the # playlist side adjacency, rendering, bandwidths, durations, the classical master, the # playlist receipt and its chain, listed cids, and the adaptation policies; and on the -# receipt side the receipt file's name, the parent walk and its bound, what a client -# holds of an object, the ancestor rule, a claim's parent, anchoring from the origin -# only, cached logs, grants files and sets held by their pins); it +# receipt side the receipt file's name, legacy groups, the parent walk and its bound, +# the caps on files, indexes and their items, fetching each name once within a budget, +# what a client holds of an object, the items a claim attaches through, §3.1 step 6, +# the ancestor rule and its recovery, a claim's parent, anchoring from the origin only +# and the request that anchors, cached logs, grants files and sets held by their +# pins); it # is applied to a fresh worktree, the build must succeed, and `wai_http_vectors verify` # must fail naming the cases on the patch's first line. Then each `http_verify.py # --mutate` must fail, and the differential: wai-rs writes random envelopes' covered @@ -53,11 +56,23 @@ on: # claim, key-log and pin code, and on the Python side by their own verifiers. - 'wai/wai-rs/src/jwp_delivery.rs' - 'wai/wai-rs/src/claim_receipt.rs' + - 'wai/wai-rs/src/claim_receipt/**' - 'wai/wai-rs/src/key_log.rs' - 'wai/wai-rs/src/pin.rs' + # ...and by what those read with: JCS, signer identities, step 6's figures and + # classes, and the OER receipts an energy claim names. + - 'wai/wai-rs/src/jcs.rs' + - 'wai/wai-rs/src/signer_id.rs' + - 'wai/wai-rs/src/energy_class.rs' + - 'wai/wai-rs/src/energy_figure.rs' + - 'wai/wai-rs/src/energy_binding.rs' + - 'wai/wai-rs/src/energy_report.rs' + - 'wai/wai-rs/src/claim_text.rs' + - 'wai/wai-rs/src/oer.rs' - 'wai/jwp-conformance/jwp_verify.py' - 'wai/claim-conformance/claim_verify.py' - 'wai/signer-conformance/signer_verify.py' + - 'wai/oer-conformance/oer2_verify.py' # The playlists render through hls_render and are sealed by wai_video_manifest. - 'wai/wai-rs/src/hls_render.rs' - 'wai/wai-rs/src/wai_video_manifest.rs' @@ -90,11 +105,23 @@ on: # claim, key-log and pin code, and on the Python side by their own verifiers. - 'wai/wai-rs/src/jwp_delivery.rs' - 'wai/wai-rs/src/claim_receipt.rs' + - 'wai/wai-rs/src/claim_receipt/**' - 'wai/wai-rs/src/key_log.rs' - 'wai/wai-rs/src/pin.rs' + # ...and by what those read with: JCS, signer identities, step 6's figures and + # classes, and the OER receipts an energy claim names. + - 'wai/wai-rs/src/jcs.rs' + - 'wai/wai-rs/src/signer_id.rs' + - 'wai/wai-rs/src/energy_class.rs' + - 'wai/wai-rs/src/energy_figure.rs' + - 'wai/wai-rs/src/energy_binding.rs' + - 'wai/wai-rs/src/energy_report.rs' + - 'wai/wai-rs/src/claim_text.rs' + - 'wai/wai-rs/src/oer.rs' - 'wai/jwp-conformance/jwp_verify.py' - 'wai/claim-conformance/claim_verify.py' - 'wai/signer-conformance/signer_verify.py' + - 'wai/oer-conformance/oer2_verify.py' # The playlists render through hls_render and are sealed by wai_video_manifest. - 'wai/wai-rs/src/hls_render.rs' - 'wai/wai-rs/src/wai_video_manifest.rs' -- 2.54.0 (Apple Git-157) From 71cb8cbed92890a0847529dbae1495e5928d5da3 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 7 Oct 2026 22:30:00 -0400 Subject: [PATCH 08/16] http receipts: falsify the round-2 fixes Patches (falsify/run.sh) for each fix, each failing the corpus cases it names: legacy-group-read, walk-unclamped, fetch-unmemoised, fetch-budget-unchecked, file-cap-unchecked, index-size-unchecked, index-items-uncapped, index-claims-uncapped, claim-through-any-receipt, ancestor-unrecovered, step6-skipped, figure-not-held-accepted, energy-context-assumed, gap-coordinates-unchecked; ancestor-as-object, claim-parent-unfetched, rcpt-name-unchecked, walk-skipped and walk-unbounded regenerated against the new receipts.rs. Python mutations groups-from-listed, budget-unchecked and caps-unchecked added, and the round's legacy-admitted, memo-off, walk-unclamped, step6-skipped and ancestor-any listed in run.sh in place of ancestor-as-object. The CLI's stream server now outlasts the deadline, so a client without the index cap reports the deadline instead; the wall-clock assertion is dropped (the deadline's own report is what the test reads). Co-Authored-By: Claude Opus 5.5 --- wai/http-conformance/README.md | 5 ++-- .../falsify/ancestor-as-object.patch | 8 +++--- .../falsify/ancestor-unrecovered.patch | 26 +++++++++++++++++++ .../falsify/claim-parent-unfetched.patch | 16 ++++++------ .../falsify/claim-through-any-receipt.patch | 15 +++++++++++ .../falsify/energy-context-assumed.patch | 15 +++++++++++ .../falsify/fetch-budget-unchecked.patch | 15 +++++++++++ .../falsify/fetch-unmemoised.patch | 15 +++++++++++ .../falsify/figure-not-held-accepted.patch | 15 +++++++++++ .../falsify/file-cap-unchecked.patch | 15 +++++++++++ .../falsify/gap-coordinates-unchecked.patch | 15 +++++++++++ .../falsify/index-claims-uncapped.patch | 15 +++++++++++ .../falsify/index-items-uncapped.patch | 15 +++++++++++ .../falsify/index-size-unchecked.patch | 15 +++++++++++ .../falsify/legacy-group-read.patch | 15 +++++++++++ .../falsify/rcpt-name-unchecked.patch | 10 +++---- wai/http-conformance/falsify/run.sh | 3 ++- .../falsify/step6-skipped.patch | 15 +++++++++++ .../falsify/walk-skipped.patch | 6 ++--- .../falsify/walk-unbounded.patch | 12 ++++----- .../falsify/walk-unclamped.patch | 15 +++++++++++ wai/http-conformance/http_receipts.py | 13 +++++++--- wai/http-conformance/http_verify.py | 6 +++-- wai/wai-rs/tests/cli_http.rs | 15 ++++++----- 24 files changed, 274 insertions(+), 41 deletions(-) create mode 100644 wai/http-conformance/falsify/ancestor-unrecovered.patch create mode 100644 wai/http-conformance/falsify/claim-through-any-receipt.patch create mode 100644 wai/http-conformance/falsify/energy-context-assumed.patch create mode 100644 wai/http-conformance/falsify/fetch-budget-unchecked.patch create mode 100644 wai/http-conformance/falsify/fetch-unmemoised.patch create mode 100644 wai/http-conformance/falsify/figure-not-held-accepted.patch create mode 100644 wai/http-conformance/falsify/file-cap-unchecked.patch create mode 100644 wai/http-conformance/falsify/gap-coordinates-unchecked.patch create mode 100644 wai/http-conformance/falsify/index-claims-uncapped.patch create mode 100644 wai/http-conformance/falsify/index-items-uncapped.patch create mode 100644 wai/http-conformance/falsify/index-size-unchecked.patch create mode 100644 wai/http-conformance/falsify/legacy-group-read.patch create mode 100644 wai/http-conformance/falsify/step6-skipped.patch create mode 100644 wai/http-conformance/falsify/walk-unclamped.patch diff --git a/wai/http-conformance/README.md b/wai/http-conformance/README.md index 9fcdaf23c..51d1d43b7 100644 --- a/wai/http-conformance/README.md +++ b/wai/http-conformance/README.md @@ -182,8 +182,9 @@ fails the self-test; `base-at-done`, `tick-noop` and `refinement-unclipped` fail `render-digest-off`, `name-unchecked`, `chain-unchecked`, `ilog2-no-interp`, `bola-waits`, `unlabelled-as-zero` and `round-half-down` fail the playlist, receipt and adaptation cases they reach; `rcpt-name-unchecked`, `legacy-admitted`, -`walk-skipped`, `walk-unclamped`, `memo-off`, `nothing-as-delivered`, `step6-skipped`, -`ancestor-any`, `anchor-follows-redirect`, `cached-rollback-unchecked`, +`walk-skipped`, `walk-unclamped`, `memo-off`, `budget-unchecked`, `caps-unchecked`, +`nothing-as-delivered`, `groups-from-listed`, `step6-skipped`, `ancestor-any`, +`anchor-follows-redirect`, `cached-rollback-unchecked`, `grants-module-unchecked`, `set-grants-ignored` and `set-by-name` fail the receipt, key, grant and set cases of §8.1–§8.5), and the job checks a differential: `wai_http_vectors diff` diff --git a/wai/http-conformance/falsify/ancestor-as-object.patch b/wai/http-conformance/falsify/ancestor-as-object.patch index 2c743d951..5661739a6 100644 --- a/wai/http-conformance/falsify/ancestor-as-object.patch +++ b/wai/http-conformance/falsify/ancestor-as-object.patch @@ -1,14 +1,14 @@ -# must fail: receipts_claims_O3 receipts_head_proof +# must fail: receipts_claims_O3 receipts_ancestor_no_publisher receipts_ancestor_any_object # a soft binding of another object reported as attesting the object in hand (receipts::check_claim) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index 95b77f2f..bfb3638f 100644 +index f2bdba57..023cf830 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -424,7 +424,7 @@ pub fn check_claim( +@@ -674,7 +674,7 @@ pub fn check_claim( let attests = match claim.subject_type { SubjectType::Object if claim.subject == *cid => Attests::Object, SubjectType::Receipt if groups.contains(&claim.subject) => Attests::Receipt, -- SubjectType::Object if claim.class() == ClaimClass::SoftBinding => Attests::Ancestor, +- SubjectType::Object if claim.class() == ClaimClass::SoftBinding && publisher(&claim.subject).is_some() => Attests::Ancestor, + SubjectType::Object if claim.class() == ClaimClass::SoftBinding => Attests::Object, _ => return Err(ReceiptRefusal::NotAboutObject), }; diff --git a/wai/http-conformance/falsify/ancestor-unrecovered.patch b/wai/http-conformance/falsify/ancestor-unrecovered.patch new file mode 100644 index 000000000..7ae753c23 --- /dev/null +++ b/wai/http-conformance/falsify/ancestor-unrecovered.patch @@ -0,0 +1,26 @@ +# must fail: receipts_ancestor_value_mismatch receipts_ancestor_no_matcher receipts_ancestor_no_publisher +# a soft binding of another object reported as attesting the ancestor without §3.2's recovery (receipts::check_claim) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..d0e7543b 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -674,7 +674,7 @@ pub fn check_claim( + let attests = match claim.subject_type { + SubjectType::Object if claim.subject == *cid => Attests::Object, + SubjectType::Receipt if groups.contains(&claim.subject) => Attests::Receipt, +- SubjectType::Object if claim.class() == ClaimClass::SoftBinding && publisher(&claim.subject).is_some() => Attests::Ancestor, ++ SubjectType::Object if claim.class() == ClaimClass::SoftBinding => Attests::Ancestor, + _ => return Err(ReceiptRefusal::NotAboutObject), + }; + claim.verify().map_err(ReceiptRefusal::Claim)?; +@@ -696,9 +696,7 @@ pub fn check_claim( + let no_matchers: [&dyn SoftBindingMatcher; 0] = []; + let matchers = recovery.map_or(&no_matchers[..], |r| r.matchers); + if attests == Attests::Ancestor { +- let r = recovery.expect("a publisher key came from the recovery"); +- let key = publisher(&claim.subject).expect("checked above"); +- crate::claim_receipt::recover(&claim, &key, r.observed_value, Some(*cid), matchers).map_err(ReceiptRefusal::Recovery)?; ++ let _ = (recovery, publisher, cid); + } else { + step6(&claim, passed, fetch, matchers)?; + } diff --git a/wai/http-conformance/falsify/claim-parent-unfetched.patch b/wai/http-conformance/falsify/claim-parent-unfetched.patch index 68aafe8be..361a5c5df 100644 --- a/wai/http-conformance/falsify/claim-parent-unfetched.patch +++ b/wai/http-conformance/falsify/claim-parent-unfetched.patch @@ -1,15 +1,15 @@ # must fail: receipts_claim_parent_not_served # a claim's parent not served taken as linked (receipts::check_claim) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index 95b77f2f..0b369af1 100644 +index f2bdba57..be265488 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -432,7 +432,7 @@ pub fn check_claim( - None => None, - Some(p) => { - let stop = |walked: usize, why: &str| ReceiptRefusal::ParentNotHeld { walked, stop: why.to_string() }; -- let pb = fetch(&p).ok_or_else(|| stop(0, "receipt_not_served"))?; -+ let Some(pb) = fetch(&p) else { return Ok(ClaimVerdict { claim, attests, parent: Some(p) }) }; +@@ -685,7 +685,7 @@ pub fn check_claim( + let pb = match fetch.get(&p) { + Ok(b) => b, + Err(ReceiptRefusal::TooLarge) => return Err(stop(1, "receipt_too_large")), +- Err(r) => return Err(stop(0, r.code())), ++ Err(_) => return Ok(ClaimVerdict { claim, attests, parent: Some(p) }), + }; let prev = read_claim_file(&p, &pb).map_err(|r| stop(1, r.code()))?; prev.verify().map_err(|r| stop(1, r.reason.label()))?; - claim.check_parent(Some(&prev)).map_err(ReceiptRefusal::Claim)?; diff --git a/wai/http-conformance/falsify/claim-through-any-receipt.patch b/wai/http-conformance/falsify/claim-through-any-receipt.patch new file mode 100644 index 000000000..8c069cf17 --- /dev/null +++ b/wai/http-conformance/falsify/claim-through-any-receipt.patch @@ -0,0 +1,15 @@ +# must fail: receipts_claim_through_foreign_item +# a claim about a receipt attached through a group no item that passed names (receipts::check_claim) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..c5614e3c 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -673,7 +673,7 @@ pub fn check_claim( + let publisher = |subject: &[u8; 32]| recovery.and_then(|r| r.publishers.iter().find(|(h, _)| h == subject).map(|(_, k)| *k)); + let attests = match claim.subject_type { + SubjectType::Object if claim.subject == *cid => Attests::Object, +- SubjectType::Receipt if groups.contains(&claim.subject) => Attests::Receipt, ++ SubjectType::Receipt if groups.contains(&claim.subject) || !groups.is_empty() => Attests::Receipt, + SubjectType::Object if claim.class() == ClaimClass::SoftBinding && publisher(&claim.subject).is_some() => Attests::Ancestor, + _ => return Err(ReceiptRefusal::NotAboutObject), + }; diff --git a/wai/http-conformance/falsify/energy-context-assumed.patch b/wai/http-conformance/falsify/energy-context-assumed.patch new file mode 100644 index 000000000..e9cd3a60c --- /dev/null +++ b/wai/http-conformance/falsify/energy-context-assumed.patch @@ -0,0 +1,15 @@ +# must fail: receipts_energy_not_delivered +# an energy claim accepted with no object delivered to check it against (receipts::step6) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..33b2bc57 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -572,7 +572,7 @@ fn step6( + let class = v.object.label.as_ref().and_then(|l| l.class()); + claim.check_energy_against_labelled_object(&v.object.content_hash, v.object.joules_micro, class).map_err(c)?; + } +- if !any || e.oer_receipt.is_some() { ++ if (!any && any) || e.oer_receipt.is_some() { + return Err(ReceiptRefusal::ContextNotHeld); + } + Ok(()) diff --git a/wai/http-conformance/falsify/fetch-budget-unchecked.patch b/wai/http-conformance/falsify/fetch-budget-unchecked.patch new file mode 100644 index 000000000..653707314 --- /dev/null +++ b/wai/http-conformance/falsify/fetch-budget-unchecked.patch @@ -0,0 +1,15 @@ +# must fail: receipts_fetch_budget +# an index's fetches unbounded (receipts::ReceiptFetcher::get) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..c37dc5c1 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -251,7 +251,7 @@ impl<'f> ReceiptFetcher<'f> { + /// budget, `receipt_fetch_budget_exhausted`. + pub fn get(&mut self, h: &[u8; 32]) -> Result, ReceiptRefusal> { + if !self.memo.contains_key(h) { +- if self.fetched >= self.budget { ++ if self.fetched >= usize::MAX - self.budget { + return Err(ReceiptRefusal::BudgetExhausted); + } + self.fetched += 1; diff --git a/wai/http-conformance/falsify/fetch-unmemoised.patch b/wai/http-conformance/falsify/fetch-unmemoised.patch new file mode 100644 index 000000000..a58088884 --- /dev/null +++ b/wai/http-conformance/falsify/fetch-unmemoised.patch @@ -0,0 +1,15 @@ +# must fail: receipts_items_fetch_each_name_once +# each name fetched again for every item and walk that names it (receipts::ReceiptFetcher::get) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..9e1776f9 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -250,7 +250,7 @@ impl<'f> ReceiptFetcher<'f> { + /// The file named `h`: `receipt_not_served`, `receipt_too_large`, or, past the + /// budget, `receipt_fetch_budget_exhausted`. + pub fn get(&mut self, h: &[u8; 32]) -> Result, ReceiptRefusal> { +- if !self.memo.contains_key(h) { ++ if !self.memo.contains_key(h) || self.fetched < usize::MAX { + if self.fetched >= self.budget { + return Err(ReceiptRefusal::BudgetExhausted); + } diff --git a/wai/http-conformance/falsify/figure-not-held-accepted.patch b/wai/http-conformance/falsify/figure-not-held-accepted.patch new file mode 100644 index 000000000..798e21be4 --- /dev/null +++ b/wai/http-conformance/falsify/figure-not-held-accepted.patch @@ -0,0 +1,15 @@ +# must fail: receipts_measurement_figure_not_held +# a measurement claim whose figure the client does not hold accepted (receipts::step6) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..8b6dcbb0 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -590,7 +590,7 @@ fn step6( + }); + match view { + Some(view) => claim.check_figure_against(Some(&view)).map_err(c), +- None if claim.needs_figure() => Err(c(Rejection { step: 6, reason: ClaimReason::FigureNotHeld })), ++ None if claim.needs_figure() => Ok(()), + None => Err(ReceiptRefusal::ContextNotHeld), + } + } diff --git a/wai/http-conformance/falsify/file-cap-unchecked.patch b/wai/http-conformance/falsify/file-cap-unchecked.patch new file mode 100644 index 000000000..3d5a6df21 --- /dev/null +++ b/wai/http-conformance/falsify/file-cap-unchecked.patch @@ -0,0 +1,15 @@ +# must fail: receipts_file_too_large receipts_parent_too_large +# a receipt file over 1 MiB read (receipts::ReceiptFetcher::get) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..b94d3156 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -260,7 +260,7 @@ impl<'f> ReceiptFetcher<'f> { + } + match &self.memo[h] { + None => Err(ReceiptRefusal::NotServed), +- Some(b) if b.len() > MAX_RECEIPT_FILE_BYTES => Err(ReceiptRefusal::TooLarge), ++ Some(b) if b.len() > usize::MAX - MAX_RECEIPT_FILE_BYTES => Err(ReceiptRefusal::TooLarge), + Some(b) => Ok(b.clone()), + } + } diff --git a/wai/http-conformance/falsify/gap-coordinates-unchecked.patch b/wai/http-conformance/falsify/gap-coordinates-unchecked.patch new file mode 100644 index 000000000..48b976ff0 --- /dev/null +++ b/wai/http-conformance/falsify/gap-coordinates-unchecked.patch @@ -0,0 +1,15 @@ +# must fail: receipts_delivery_gap +# a delivery-gap claim checked against an item at other coordinates (receipts::step6) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..d7049807 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -597,7 +597,7 @@ fn step6( + // §2.4.11: against the object receipt at its coordinates, verified by inclusion + // at least. + ClaimBody::DeliveryGap(g) => { +- let at = passed.iter().find(|v| { ++ let at = passed.iter().rev().find(|v| v.object.object_id < u64::MAX || { + (v.object.track_alias, v.object.group_id, v.object.object_id) == (g.track_alias, g.group_id, g.object_id) + }); + let v = at.ok_or(ReceiptRefusal::ContextNotHeld)?; diff --git a/wai/http-conformance/falsify/index-claims-uncapped.patch b/wai/http-conformance/falsify/index-claims-uncapped.patch new file mode 100644 index 000000000..d3108c31f --- /dev/null +++ b/wai/http-conformance/falsify/index-claims-uncapped.patch @@ -0,0 +1,15 @@ +# must fail: receipts_index_257_claims +# an index listing more than 256 claims read (receipts::ReceiptIndex::from_bytes) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..9a891749 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -318,7 +318,7 @@ impl ReceiptIndex { + if delivery.len() > MAX_INDEX_ITEMS { + return Err(ReceiptRefusal::IndexTooLarge("more delivery items than a client reads")); + } +- if claims.len() > MAX_INDEX_CLAIMS { ++ if claims.len() > usize::MAX - MAX_INDEX_CLAIMS { + return Err(ReceiptRefusal::IndexTooLarge("more claims than a client reads")); + } + Ok(ReceiptIndex { diff --git a/wai/http-conformance/falsify/index-items-uncapped.patch b/wai/http-conformance/falsify/index-items-uncapped.patch new file mode 100644 index 000000000..7577701c7 --- /dev/null +++ b/wai/http-conformance/falsify/index-items-uncapped.patch @@ -0,0 +1,15 @@ +# must fail: receipts_index_257_items +# an index listing more than 256 delivery items read (receipts::ReceiptIndex::from_bytes) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..13b89881 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -315,7 +315,7 @@ impl ReceiptIndex { + let object = m["object"].as_str().and_then(unhex32).ok_or(bad("object is not a content hash"))?; + let delivery = m["delivery"].as_array().ok_or(bad("delivery is not an array"))?; + let claims = m["claims"].as_array().ok_or(bad("claims is not an array"))?; +- if delivery.len() > MAX_INDEX_ITEMS { ++ if delivery.len() > usize::MAX - MAX_INDEX_ITEMS { + return Err(ReceiptRefusal::IndexTooLarge("more delivery items than a client reads")); + } + if claims.len() > MAX_INDEX_CLAIMS { diff --git a/wai/http-conformance/falsify/index-size-unchecked.patch b/wai/http-conformance/falsify/index-size-unchecked.patch new file mode 100644 index 000000000..3f2ed1fbb --- /dev/null +++ b/wai/http-conformance/falsify/index-size-unchecked.patch @@ -0,0 +1,15 @@ +# must fail: receipts_index_too_large +# an index over 4 MiB read (receipts::ReceiptIndex::from_bytes) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..0562284b 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -298,7 +298,7 @@ impl ReceiptIndex { + /// reading's reason). Each claim entry is a link hash (`receipt_index_malformed`). An + /// item that does not read is kept as its refusal. + pub fn from_bytes(bytes: &[u8]) -> Result { +- if bytes.len() > MAX_INDEX_BYTES { ++ if bytes.len() > usize::MAX - MAX_INDEX_BYTES { + return Err(ReceiptRefusal::IndexTooLarge("more bytes than a client reads")); + } + let bad = ReceiptRefusal::IndexMalformed; diff --git a/wai/http-conformance/falsify/legacy-group-read.patch b/wai/http-conformance/falsify/legacy-group-read.patch new file mode 100644 index 000000000..9038000e8 --- /dev/null +++ b/wai/http-conformance/falsify/legacy-group-read.patch @@ -0,0 +1,15 @@ +# must fail: receipts_legacy_group receipt_file_legacy_group +# a legacy group file read, its name not covering its parent (receipts::read_group_file) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..263dc3c9 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -193,7 +193,7 @@ pub fn read_group_file(name: &[u8; 32], bytes: &[u8]) -> Result Result Result "$WORK/py-$m.log" 2>&1; then echo "::error::http_verify.py --mutate $m passed"; fail=1 diff --git a/wai/http-conformance/falsify/step6-skipped.patch b/wai/http-conformance/falsify/step6-skipped.patch new file mode 100644 index 000000000..7350f976e --- /dev/null +++ b/wai/http-conformance/falsify/step6-skipped.patch @@ -0,0 +1,15 @@ +# must fail: receipts_measurement_figure_not_held receipts_energy_not_delivered receipts_delivery_gap receipts_claims_O3 +# a claim accepted without §3.1 step 6 (receipts::check_claim) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..972a6e39 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -700,7 +700,7 @@ pub fn check_claim( + let key = publisher(&claim.subject).expect("checked above"); + crate::claim_receipt::recover(&claim, &key, r.observed_value, Some(*cid), matchers).map_err(ReceiptRefusal::Recovery)?; + } else { +- step6(&claim, passed, fetch, matchers)?; ++ let _ = (step6, passed, matchers); + } + Ok(ClaimVerdict { claim, attests, parent }) + } diff --git a/wai/http-conformance/falsify/walk-skipped.patch b/wai/http-conformance/falsify/walk-skipped.patch index c7ebac826..da6e1e794 100644 --- a/wai/http-conformance/falsify/walk-skipped.patch +++ b/wai/http-conformance/falsify/walk-skipped.patch @@ -1,15 +1,15 @@ # must fail: receipts_walk_two receipts_parent_not_served # a group whose parent is not held accepted without its walk (receipts::admit_walking) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index 95b77f2f..de10f296 100644 +index f2bdba57..5bf15818 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -283,7 +283,7 @@ fn admit_walking( +@@ -408,7 +408,7 @@ fn admit_walking( match chains.admit(group, now_ms) { Ok(a) => return Ok((a, 0)), Err(e) if e.reason != Reason::ParentNotHeld => return Err(ReceiptRefusal::Delivery(e)), - Err(_) => {} + Err(_) => return Ok((Admission::Linked, 0)), } + let bound = max_walk.min(PARENT_WALK_BOUND); let stop = |walked: usize, why: &str| ReceiptRefusal::ParentNotHeld { walked, stop: why.to_string() }; - let mut pending: Vec = Vec::new(); diff --git a/wai/http-conformance/falsify/walk-unbounded.patch b/wai/http-conformance/falsify/walk-unbounded.patch index 1d39b5809..2e38ff057 100644 --- a/wai/http-conformance/falsify/walk-unbounded.patch +++ b/wai/http-conformance/falsify/walk-unbounded.patch @@ -1,15 +1,15 @@ -# must fail: receipts_walk_bound +# must fail: receipts_walk_bound receipts_walk_65 # the parent walk past its bound (receipts::admit_walking) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index 95b77f2f..0737ee0a 100644 +index f2bdba57..7d4eb0a9 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -292,7 +292,7 @@ fn admit_walking( +@@ -418,7 +418,7 @@ fn admit_walking( loop { // §3 step 3 says `parent_not_held` only of a group that names a parent. let Some(l) = link else { return Err(stop(fetched, "parent_not_held")) }; -- if fetched == max_walk { -+ if fetched == usize::MAX - max_walk { +- if fetched == bound { ++ if fetched == usize::MAX - bound { return Err(stop(fetched, "bound")); } - let bytes = fetch(&l).ok_or_else(|| stop(fetched, "receipt_not_served"))?; + let bytes = match fetch.get(&l) { diff --git a/wai/http-conformance/falsify/walk-unclamped.patch b/wai/http-conformance/falsify/walk-unclamped.patch new file mode 100644 index 000000000..5b9ffd245 --- /dev/null +++ b/wai/http-conformance/falsify/walk-unclamped.patch @@ -0,0 +1,15 @@ +# must fail: receipts_walk_65_asks_1000 +# the parent walk bounded by what a client asks, not by 64 (receipts::admit_walking) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index f2bdba57..a4f67ec1 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -410,7 +410,7 @@ fn admit_walking( + Err(e) if e.reason != Reason::ParentNotHeld => return Err(ReceiptRefusal::Delivery(e)), + Err(_) => {} + } +- let bound = max_walk.min(PARENT_WALK_BOUND); ++ let bound = max_walk; + let stop = |walked: usize, why: &str| ReceiptRefusal::ParentNotHeld { walked, stop: why.to_string() }; + let mut pending: Vec = Vec::new(); + let mut link = group.parent_receipt_hash; diff --git a/wai/http-conformance/http_receipts.py b/wai/http-conformance/http_receipts.py index 26d2eb170..2c2631be8 100644 --- a/wai/http-conformance/http_receipts.py +++ b/wai/http-conformance/http_receipts.py @@ -24,6 +24,7 @@ import jwp_verify as JV # noqa: E402 import signer_verify as SV # noqa: E402 MUTATE = None # set by http_verify.py +_LISTED = [] ORIGIN = "https://origin.example" KEY_LOG_PATH = "/.well-known/wai-keys" RCPT = "objects/rcpt" @@ -133,14 +134,14 @@ class Fetcher: def get(self, h): if h not in self.memo or MUTATE == "memo-off": - if self.fetched >= self.budget: + if self.fetched >= self.budget and MUTATE != "budget-unchecked": raise Refused("receipt_fetch_budget_exhausted") self.fetched += 1 self.memo[h] = self.get_raw(h) b = self.memo[h] if b is None: raise Refused("receipt_not_served") - if len(b) > MAX_RECEIPT_FILE_BYTES: + if len(b) > MAX_RECEIPT_FILE_BYTES and MUTATE != "caps-unchecked": raise Refused("receipt_too_large") return b @@ -157,7 +158,7 @@ def read_property(p): def read_index(b): - if len(b) > MAX_INDEX_BYTES: + if len(b) > MAX_INDEX_BYTES and MUTATE != "caps-unchecked": raise Refused("receipt_index_too_large") bad = Refused("receipt_index_malformed") text = utf8(b) @@ -173,7 +174,7 @@ def read_index(b): raise bad if not isinstance(v["delivery"], list) or not isinstance(v["claims"], list): raise bad - if len(v["delivery"]) > MAX_INDEX_ITEMS or len(v["claims"]) > MAX_INDEX_CLAIMS: + if (len(v["delivery"]) > MAX_INDEX_ITEMS or len(v["claims"]) > MAX_INDEX_CLAIMS) and MUTATE != "caps-unchecked": raise Refused("receipt_index_too_large") items = [] for it in v["delivery"]: @@ -369,6 +370,8 @@ def check_claim(cid, passed, name, fetcher, recovery): b = fetcher.get(name) c, later = read_claim_file(name, b) groups = [JV.link(v["g"]).hex() for v in passed] + if MUTATE == "groups-from-listed": + groups += _LISTED pubs = (recovery or {}).get("publishers") or [] publisher = next((k for h, k in pubs if h == c["subject"]), None) if c["subject_type"] == "object" and c["subject"] == cid: @@ -723,6 +726,8 @@ def receipts(d, objs, inp, fetch_object, content_hash): continue fetcher = Fetcher(served.get, budget) dl, passed = [], [] + global _LISTED # what the mutation groups-from-listed attaches claims through + _LISTED = [it[0]["group_receipt"] for it in items if not isinstance(it, Refused)] for it in items: if isinstance(it, Refused): dl.append(it.json()) diff --git a/wai/http-conformance/http_verify.py b/wai/http-conformance/http_verify.py index 79a2248cc..c9e4d6c5f 100644 --- a/wai/http-conformance/http_verify.py +++ b/wai/http-conformance/http_verify.py @@ -24,8 +24,10 @@ stops the verifier, never passes. python3 http_verify.py [dir] verify every case and the self-test python3 http_verify.py [dir] --mutate NAME the same with a deliberate defect: left-half | no-root | counter-zero | floor-as-codecs | blake3-iv | base-at-done | - tick-noop | refinement-unclipped | … | rcpt-name-unchecked | walk-skipped | - nothing-as-delivered | ancestor-as-object | anchor-follows-redirect | + tick-noop | refinement-unclipped | … | rcpt-name-unchecked | legacy-admitted | + walk-skipped | walk-unclamped | memo-off | budget-unchecked | caps-unchecked | + nothing-as-delivered | groups-from-listed | step6-skipped | ancestor-any | + anchor-follows-redirect | cached-rollback-unchecked | grants-module-unchecked | set-grants-ignored | set-by-name (falsify/run.sh lists every one) python3 http_verify.py cases FILE [dir] diff --git a/wai/wai-rs/tests/cli_http.rs b/wai/wai-rs/tests/cli_http.rs index d72b714e5..08d4f67c9 100644 --- a/wai/wai-rs/tests/cli_http.rs +++ b/wai/wai-rs/tests/cli_http.rs @@ -706,12 +706,15 @@ fn test_server(mode: &'static str) -> (u16, std::sync::Arc { + // A body that does not end before any deadline the test gives: 64 KiB + // every 5 ms, for longer than a minute, until the client hangs up. let _ = s.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n"); - let chunk = vec![b' '; 1 << 20]; - for _ in 0..512 { + let chunk = vec![b' '; 64 << 10]; + for _ in 0..(1 << 14) { if s.write_all(&chunk).is_err() { break; } + std::thread::sleep(std::time::Duration::from_millis(5)); } } } @@ -749,19 +752,19 @@ fn receipts_anchors_the_key_log_from_its_origin() { } /// §8.2 over HTTP: a server that stops after its headers, or never ends its body, costs -/// `wai http receipts` no more than its deadline and the index's cap. +/// `wai http receipts` no more than its deadline and the index's cap. The hanging server +/// holds its connection open for 30 s and the stream runs past a minute, so a client +/// without the deadline, or without the cap, reports something else. #[cfg(feature = "key_discovery")] #[test] fn receipts_is_bounded_by_its_deadline_and_caps() { let cid = "31abc1da2cdf927191f3f447f68508ead30d078690cb7bea2435ae3d1df5f4f3"; let (hp, _) = test_server("hang"); - let t0 = std::time::Instant::now(); let run = wai(&["http", "receipts", "--held", "none", "--deadline-ms", "1500", "--url", &format!("http://127.0.0.1:{hp}/o/{cid}.wai")]); assert!(!run.status.success()); assert!(String::from_utf8_lossy(&run.stderr).contains("deadline"), "{}", String::from_utf8_lossy(&run.stderr)); - assert!(t0.elapsed() < std::time::Duration::from_secs(20), "{:?}", t0.elapsed()); let (sp, _) = test_server("stream"); - let run = wai(&["http", "receipts", "--held", "none", "--deadline-ms", "60000", "--url", &format!("http://127.0.0.1:{sp}/o/{cid}.wai")]); + let run = wai(&["http", "receipts", "--held", "none", "--deadline-ms", "30000", "--url", &format!("http://127.0.0.1:{sp}/o/{cid}.wai")]); assert!(!run.status.success()); let v: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&run.stderr))); assert!(v["index"].as_str().unwrap().starts_with("receipt_index_too_large"), "{v}"); -- 2.54.0 (Apple Git-157) From 750047604edc184cb0c5fd35bd1bc9ad376a7847 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 8 Oct 2026 03:59:09 -0400 Subject: [PATCH 09/16] http receipts round 3: publisher key, matchers, every step-6 class, declarations by name, a bytes budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings 2-6 of the review of 30cefd1b, in the text, wai-rs, http_receipts.py and the corpus together. 2. A soft binding's recovery uses, of the publisher keys held for the ancestor, the one that signed the declaration, whichever delivery came first; the first key only when none did, for the reason (recovery_signer_mismatch). 3. Recovery through a soft binding is gated on the object being held inside check_claim (it now takes `held`); a recovery record's matchers are passed whether or not the object is held (§2.6.3 step 5 needs a matcher, not the object). 4. Corpus cases for lawful-basis decisions, binding-recovery records, operating points and a session claim, accepted and refused, their bodies claim-conformance's accepted vectors retargeted to the corpus's objects and signed with its keys; O7 delivered under a second publisher's key, listed before, after and alone. 5. A declaration fetched by link is read as §8.1 reads every file fetched by name (receipt_name_mismatch and steps 1-2 first); step 6's reasons only for one that names correctly and does not match. §8.2 states that this revision carries no OER receipt over HTTP, so an energy claim naming one is context_not_held. Each cap has its rationale; a 64 MiB read budget per index (receipt_fetch_bytes_exhausted), counting at most a byte past each file's cap. 6. param_set with an uppercase digest is pin_malformed in wai-rs too (the corpus tool no longer reads a grants file under a digest that does not read). Python mutations bytes-unchecked, decision-unchecked, recovery-record-unchecked, declaration-unnamed, first-publisher, matchers-need-object, operating-point-unchecked and session-accepted; corpus 1030 cases, Rust and Python agree. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 13 +- wai/extensions/http-delivery.md | 52 +++-- wai/http-conformance/README.md | 10 +- wai/http-conformance/cases.json | 17 +- ...bb1136f5228fdfcf05e599fb92ab318eba935.json | 1 + ...f3dd497a1e9f309741d3fa071b4fd71cac9a0.json | 1 + ...db8a486dfdef79026836dda43b4b7a790ac1e.json | 1 + ...64cd9a3c76f6a4f91d1f806895b7425280b28.json | 1 + ...52c77c396e1758466b8eb785b46d31f54760f.json | 1 + ...3a0881b61a53ff1b8e4ecaf1cef696a917650.json | 1 + ...ab6463c339212111830f251e7c61b0e3fe784.json | 1 + ...682cd67fba92f8474c1a4d604fbf5934f7045.json | 1 + ...d4ad911ce2194843a671678afe1a97a4a92f8.json | 1 + ...f17b68c9d307f82e22fa301083aa90c8def29.json | 1 + ...02caf242d51df0d76506a60481fd79a7ca2ca.json | 1 + ...1668c0896cf0d3e4010520c4482eeb33a821e.json | 1 + ...26653117ea3615b889975f0693168fe5b217c.json | 1 + ...34d9e4a9a7da0cd697729a2fe4547ddb3a62e.json | 1 + .../receipts/O3-binding-recovery.wai.receipts | 1 + .../receipts/O3-lawful-basis.wai.receipts | 1 + .../cases/receipts/O3-session.wai.receipts | 1 + .../receipts/O5-operating-point.wai.receipts | 1 + .../receipts/O7-other-first.wai.receipts | 1 + .../cases/receipts/O7-other-only.wai.receipts | 1 + .../receipts/O7-publisher-first.wai.receipts | 1 + wai/http-conformance/falsify/run.sh | 2 + wai/http-conformance/http_receipts.py | 58 ++++-- wai/http-conformance/http_verify.py | 7 +- .../src/bin/wai_cli/http_vectors_receipts.rs | 193 ++++++++++++++++-- wai/wai-rs/src/http_binding/receipts.rs | 121 ++++++++--- wai/wai-rs/src/http_binding/tests_receipts.rs | 31 ++- 31 files changed, 441 insertions(+), 84 deletions(-) create mode 100644 wai/http-conformance/cases/rcpt/11c2d4a654057161f6450907c69bb1136f5228fdfcf05e599fb92ab318eba935.json create mode 100644 wai/http-conformance/cases/rcpt/133ffb1355f912e7ea23db3373cf3dd497a1e9f309741d3fa071b4fd71cac9a0.json create mode 100644 wai/http-conformance/cases/rcpt/13e1857af31da433ed26408d5cfdb8a486dfdef79026836dda43b4b7a790ac1e.json create mode 100644 wai/http-conformance/cases/rcpt/21bc2df99c8f61e4b736e9df0ef64cd9a3c76f6a4f91d1f806895b7425280b28.json create mode 100644 wai/http-conformance/cases/rcpt/23e93ea54cbdbe5a203dc6d346752c77c396e1758466b8eb785b46d31f54760f.json create mode 100644 wai/http-conformance/cases/rcpt/3f4c9dcae5e9654eec359360cf03a0881b61a53ff1b8e4ecaf1cef696a917650.json create mode 100644 wai/http-conformance/cases/rcpt/3fa148d4b79df7eb77f3d813122ab6463c339212111830f251e7c61b0e3fe784.json create mode 100644 wai/http-conformance/cases/rcpt/67b2612eb6b1e6ff7eeae5a6004682cd67fba92f8474c1a4d604fbf5934f7045.json create mode 100644 wai/http-conformance/cases/rcpt/8bc21df138b97b63f5f4730ca6dd4ad911ce2194843a671678afe1a97a4a92f8.json create mode 100644 wai/http-conformance/cases/rcpt/a0410e703b803a08ec1b8ec0618f17b68c9d307f82e22fa301083aa90c8def29.json create mode 100644 wai/http-conformance/cases/rcpt/aedd5190e7eb2e5e61854f1009402caf242d51df0d76506a60481fd79a7ca2ca.json create mode 100644 wai/http-conformance/cases/rcpt/c608ce5df043fb58021d9360c131668c0896cf0d3e4010520c4482eeb33a821e.json create mode 100644 wai/http-conformance/cases/rcpt/f95a4c6858d22e963130429b04b26653117ea3615b889975f0693168fe5b217c.json create mode 100644 wai/http-conformance/cases/rcpt/fe7c805ae210c5a6d8f5bccd79b34d9e4a9a7da0cd697729a2fe4547ddb3a62e.json create mode 100644 wai/http-conformance/cases/receipts/O3-binding-recovery.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/O3-lawful-basis.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/O3-session.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/O5-operating-point.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/O7-other-first.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/O7-other-only.wai.receipts create mode 100644 wai/http-conformance/cases/receipts/O7-publisher-first.wai.receipts diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 97f8a67be..491d62bf6 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -542,8 +542,9 @@ wire format may change and such changes are recorded here. refused (`receipt_legacy_group`); - an index, at most 4 MiB with at most 256 delivery items and 256 claims (`receipt_index_too_large`), is read item by item (`receipt_index_malformed`), each - receipt file fetched once per index and at most 1024 in all - (`receipt_fetch_budget_exhausted`): each delivery item's group goes through + receipt file fetched once per index, at most 1024 in all + (`receipt_fetch_budget_exhausted`) and 64 MiB read in all + (`receipt_fetch_bytes_exhausted`), each cap with its reason stated: each delivery item's group goes through jwp-receipts §3 against the client's chains, a missing parent fetched by its link up to 64 of them, never more, and never skipped (`parent_not_held`, with where the walk stopped), a joining client anchoring instead; its object receipt through steps 4 @@ -552,8 +553,12 @@ wire format may change and such changes are recorded here. no property reading (§10.5); each claim by its subject against the items that passed, its parent fetched by its link, then §3.1 step 6 against what those items deliver and the receipts it names (`figure_not_held`, `context_not_held` where the - client does not hold them), and a soft binding of another object attesting the - ancestor only through §3.2's recovery; + client does not hold them; an energy claim naming an OER receipt, which this revision + does not carry over HTTP, is `context_not_held`), a declaration it names read as + §8.1 reads every file fetched by name before step 6 compares it, a recovery record + checked with the client's matchers whether or not it holds the object, and a soft + binding of another object attesting the ancestor only through §3.2's recovery, + under the held publisher key that signed it; - a key log anchors only from the origin's own location over HTTPS, answered `200` with no redirect (`key_log_not_from_origin`), from a retrieval the verifier made itself, asking with `Cache-Control: no-cache`; a log through any cache adds revisions diff --git a/wai/extensions/http-delivery.md b/wai/extensions/http-delivery.md index b01273cc4..fc5c656b5 100644 --- a/wai/extensions/http-delivery.md +++ b/wai/extensions/http-delivery.md @@ -397,7 +397,10 @@ their reasons — and refuses it unless its link hash, computed from what it rea name (`receipt_name_mismatch`). A group in the legacy payload is refused (`receipt_legacy_group`): its link hash does not cover its parent, so a server could serve other bytes, naming another parent, under the one name. A name at which nothing is -served is `receipt_not_served`. +served is `receipt_not_served`. Every file a client fetches by name — a group, a parent, +a claim, or a declaration a claim names (§8.2) — is read so first, and refused with these +reasons before any check of what it holds. A receipt is a few kilobytes; the cap leaves +room for claim bodies that list many objects, and bounds what one name costs. A group receipt's `parent_receipt_hash` is the name of its parent's file. A client resolves [jwp-receipts](jwp-receipts.md) §3 step 3's `parent_not_held` by fetching @@ -434,20 +437,29 @@ parents by the link its child names (§8.1). It is mutable and is served with `Cache-Control: no-cache, no-transform` and an `ETag` of its own. It is a locator, never evidence. A client reads at most 4 MiB (4 194 304 -bytes) of it and refuses a longer one (`receipt_index_too_large`). It reads it as +bytes) of it and refuses a longer one (`receipt_index_too_large`): room for the most +items and claims it may list. It reads it as jwp-receipts §3.1 step 1 reads JSON, with exactly the members above, `kind` that string, `object` 64 lowercase hexadecimal digits and the other two arrays, and refuses it whole otherwise (`receipt_index_malformed`), when it lists more than 256 delivery items or more than 256 claims (`receipt_index_too_large`), or when `object` is not the cid -(`receipt_not_about_object`). Each item it lists is read and checked on its own, and one +(`receipt_not_about_object`). An index lists the groups an object was delivered under, +one per track and publisher, and each signer's latest claims, which chain to their +parents, so these bound a listing, not a history. Each item it lists is read and checked on its own, and one that fails is ignored, with its reason, item by item: a delivery item is an object with exactly `property` and `object_receipt` (`receipt_index_malformed`), whose property reads as moq-streaming-format §5's (`property_invalid`) and whose object receipt reads as jwp-receipts §2.4.2's; a claim entry is a link hash (`receipt_index_malformed`). To check one index, a client fetches each receipt file name once, keeping what was served -for the rest of the check, and fetches at most 1024 files in all; a fetch past that is -`receipt_fetch_budget_exhausted`. A client MAY hold a lower budget. +for the rest of the check, and fetches at most 1024 files in all, enough for every item +and claim an index may list, a declaration for each claim and full parent walks for a +few groups; a fetch past that is `receipt_fetch_budget_exhausted`. It reads at most 64 MiB +(67 108 864 bytes) of receipt files in all, counting what it read of each, at most one +byte past the cap: the file that passes that is refused, and once it is reached no +further file is fetched (`receipt_fetch_bytes_exhausted`). Without it, 1024 files at +their cap would let one index make a client read and hold a gibibyte. A client MAY hold +lower budgets. A delivery item is checked in this order: @@ -473,15 +485,21 @@ nothing, attaches nothing, and the claim is `receipt_not_about_object`. Then states; and step 6, against the receipts it names that the client holds here: - an `energy` claim, against the object receipt of each item delivered (step 4 run over - what the client holds of the object, §8.3), and against the OER receipt it names; + what the client holds of the object, §8.3), and against the OER receipt it names. This + revision carries no OER receipt over HTTP, so an energy claim that names one is + `context_not_held`; - a `measurement` or `operating-point` claim, against the receipt that carries its figure: for the `moq-jwp:object` profile, the object receipt of an item delivered under the group the claim names; - a `delivery-gap` claim, against the object receipt of an item at its coordinates, verified by inclusion at least; - a lawful-basis decision, and a recovery record, against the declaration it names, - fetched as `rcpt/.json`; a recovery record also against the publisher key of - the group of an item that passed; + fetched as `rcpt/.json` and read as §8.1 states, whose reasons refuse it + first (another claim served under the declaration's name is `receipt_name_mismatch`); + a declaration that reads under its name and does not match is refused with step 6's + reason. A recovery record is also checked against the publisher key of the group of an + item that passed, the one that signed the declaration where there is one, and with + the client's matchers (§2.6.3 step 5), whether or not the client holds the object; - a `reconstruct-session` claim, against its outputs, the objects it received and the envelopes it received, none of which the index carries. @@ -495,7 +513,10 @@ object attests that object, the ancestor, never the object in hand, and only thr §3.2's recovery: the client holds the object, it holds the publisher key of a delivery receipt for the ancestor that it verified under §3 over that object's bytes, and §3.2's steps 2–5 pass with a matcher for the claim's algorithm, the claim then reported as -attesting the ancestor. A failed recovery is reported with §3.2's reason. Without such a +attesting the ancestor. Of the publisher keys it holds for the ancestor, it uses the one +that signed the claim, whichever delivery it verified first, so a delivery under another +key cannot block a recovery; holding none that did, it uses the first, and the recovery +fails with §3.2's reason. A failed recovery is reported with that reason. Without such a key or the object, no recovery runs, and the claim is `receipt_not_about_object`. A client MAY confirm the signer of each item it accepts under @@ -999,7 +1020,7 @@ is a share of sessions, not of devices or people. | `component_digest_mismatch` | §7 step 6 ([SPEC](../SPEC.md) §7.1's reason) | | `object_digest_mismatch` | §7 step 7, or any whole fetch | | `receipt_name_mismatch`, `receipt_not_about_object` | §8.1, §8.2, §8.6 | -| `receipt_not_served`, `receipt_index_malformed`, `receipt_index_too_large`, `receipt_too_large`, `receipt_legacy_group`, `receipt_fetch_budget_exhausted` | §8.1, §8.2 | +| `receipt_not_served`, `receipt_index_malformed`, `receipt_index_too_large`, `receipt_too_large`, `receipt_legacy_group`, `receipt_fetch_budget_exhausted`, `receipt_fetch_bytes_exhausted` | §8.1, §8.2 | | `context_not_held` | §8.2 (jwp-receipts §3.1 step 6) | | `key_log_not_from_origin`, `grants_file_malformed` | §8.4 | | `codecs_malformed`, `codecs_not_media`, `codecs_candidate`, `codecs_no_common_capability`, `codecs_undeclared`, `codecs_layer_unbound` | §4 | @@ -1038,9 +1059,10 @@ step 5 states. digest, with cases for receipt files read under their names, indexes checked against the whole object, a head proof's cid and nothing, parents walked and not recovered, walks at and past the bound, legacy groups, files and indexes at and past their caps, - each name fetched once within a budget, claims by subject, parent and the receipts - §3.1 step 6 checks them against, ancestors by recovery, anchoring from the origin - only, logs through caches, + each name fetched once within budgets of files and bytes, claims by subject, parent + and the receipts §3.1 step 6 checks them against for every class (declarations read + by their names first), ancestors by recovery under each publisher key held, + anchoring from the origin only, logs through caches, grants files and sets held by their pins and §7.9; the differential adds receipt files, indexes, grants files and key logs each changed one way. `http_verify.py` checks these cases with the `cryptography` package, through the corpora's own second verifiers of @@ -1071,8 +1093,8 @@ step 5 states. name does not fix its bytes is not read (§8.1); a key log anchors only from its origin (§8.4). - **What a server can make a client do.** Every receipt file, index and item count is - capped, each name is fetched once, and the files one index costs are bounded (§8.1, - §8.2), so an index cannot make a client fetch or hold without limit. + capped, each name is fetched once, and the files and bytes one index costs are bounded + (§8.1, §8.2), so an index cannot make a client fetch or hold without limit. ## Appendix A — CDNI configuration diff --git a/wai/http-conformance/README.md b/wai/http-conformance/README.md index cb550f59e..60c698a04 100644 --- a/wai/http-conformance/README.md +++ b/wai/http-conformance/README.md @@ -59,6 +59,7 @@ agree: | `sets/` | O2's pinned parameter set, served by its digest (§8.5) | | `cases/keys/` | the log's first revision alone (an older copy), and a log whose second revision revokes the set's digest | | `cases/rcpt/`, `cases/receipts/` | O9a's group with its signature changed, served under the link hash of what it then is, and an index naming it; O1's group in the legacy payload, under its legacy link hash, and an index naming it (§8.1); O9a's index with 256 and 257 delivery items and with 257 claims; O9c's index listing its item fifty times (§8.2) | +| `cases/rcpt/`, `cases/receipts/O3-*`, `O5-*`, `O7-*` | claims of the classes the published indexes carry none of, their bodies claim-conformance's accepted vectors retargeted to the corpus's objects and signed with its keys, and indexes listing them (§8.2 step 6): the publisher's lawful-basis declarations about O3 and O7 and a sink's decisions on a declared purpose, an undeclared one and O7's declaration; soft bindings of O3 by the publisher and by the relay and a sink's recovery records of a value found in O7, matching, not matching, and naming the relay's; operating points over O5's leaf, agreeing and not; and a session claim. And O7 delivered again under a second publisher's key (seed `0x67`), with indexes listing that delivery before the publisher's, after it, and alone (jwp-receipts §3.2's *P*) | | `cases/rcpt-deep/`, `cases/receipts/deep-*.wai.receipts` | a chain of 66 groups over O1, each the parent of the next, and indexes naming the groups 64 and 65 parents from its root (§8.1's bound) | | `cases/sig-*.json` | the O9 playlist's receipt re-signed at the edges of Ed25519 (Appendix H): the identity key, canonical and respelled `y` = p + 1, under `(R, S) = (B, 1)`; under the key B (scalar 1), `R` the identity, canonical and respelled, with `S` = k, so the cofactorless equation holds point for point; and under the key B a signature the strict rule accepts (`sig-key-base-point.json`) | @@ -130,7 +131,7 @@ outside this repository, and the AV1 streams were checked to decode. | `live` | `revisions` in the order a client fetched them (each a WAI playlist, its receipt, its classical playlist, `edit_wai`), `fetches` (an `alias` and the `object` served under it, optionally patched), the playlist's `url` (against which its lines resolve; `https://origin.example/live/wai-720.m3u8` by default), where parents are fetched from (`parents`, a directory holding `.json`; the first revision's `mr/` by default) and the walk's bound (`walk_bound`, 64 by default) | per revision its adjacency, its listing against what accepted revisions bound, its receipt, what it did to the chain (`true`, `unchanged`, `reanchored`, or the reason) and the parents walked; per fetch the accepted revision that first lists the alias, the outcome and what held (`listed`, or `signed` when a receipt in the chain binds the alias's URL to that cid) | | `uri_resolve` | a `base` and a `reference` | the `url` (RFC 3986 §5.2 and §6.2.2, without the fragment) | | `receipt_file` | a `file` read as a receipt of `kind` (`group` or `claim`) under its `name` (its own by default), optionally edited | its `link`, or `error` with the jwp-receipts `step` | -| `receipts` | `checks` in order, each an `object` (or `object_file`), what the client holds of it (`held`: `none`; `whole`, its bytes or `held_object`'s; or `{"fetch": {…}}`, a fetch as the `fetch` op runs one), its `index` (beside it by default) optionally edited, or another object's index (`object_cid`), optionally padded (`pad_index`, bytes); one set of chains (`join`), a parent bound (`max_walk`, 64 by default, never more) and a fetch budget (`fetch_budget`, 1024 by default), the receipt files `serve`d (`rcpt` directories searched, `omit`ted hashes, hashes served `as` another file, files `pad`ded past their cap), `recovery` (the soft-binding algorithms a matcher is held for; the publishers are those of the items delivered in earlier checks), and `keys` (key-log steps, as `key_log` runs them) | per check the index's verdict and, when it reads, each delivery item's admission, parents `walked`, `evidence`, how the hash was `established` and the property's `reading`, or its `error` (with where a walk stopped); each claim's `class`, what it `attests`, its `subject` and `parent`, or its `error`; the receipt files the check fetched (`files_fetched`); with `keys`, each signer's confirmation; the fetch's verdict | +| `receipts` | `checks` in order, each an `object` (or `object_file`), what the client holds of it (`held`: `none`; `whole`, its bytes or `held_object`'s; or `{"fetch": {…}}`, a fetch as the `fetch` op runs one), its `index` (beside it by default) optionally edited, or another object's index (`object_cid`), optionally padded (`pad_index`, bytes); one set of chains (`join`), a parent bound (`max_walk`, 64 by default, never more) and a fetch budget (`fetch_budget`, 1024 by default), the receipt files `serve`d (`rcpt` directories searched, `omit`ted hashes, hashes served `as` another file, files `pad`ded past their cap, or every file to `pad_all`), `recovery` (the soft-binding algorithms a matcher is held for; the publishers are those of the items delivered in earlier checks), and `keys` (key-log steps, as `key_log` runs them) | per check the index's verdict and, when it reads, each delivery item's admission, parents `walked`, `evidence`, how the hash was `established` and the property's `reading`, or its `error` (with where a walk stopped); each claim's `class`, what it `attests`, its `subject` and `parent`, or its `error`; the receipt files the check fetched (`files_fetched`); with `keys`, each signer's confirmation; the fetch's verdict | | `key_log` | `steps`: `anchor` (a log retrieved as `retrieval` states: `requested`, `answered`, `status`), `pin`, or `cached` (a log through a cache), each for an `origin` (the corpus's by default) and optionally edited | what each step did (`anchored`, `added`) or its `error` | | `grants_file` | a grants `file` for a `module` (the digest in its name by default), optionally edited | each grant's reading by §7.9's check 1, or `error` | | `param_set` | a set `served` for a `capability` under a pin of `sha256`, the sink's `keys`, `statuses` and `grants` file, the origins it holds the capability's sets to §7.9 for (`hold_to`; absent, not held to it), and `now` (null: no trusted clock) | `held`, or `error` with §7.9's `check` and `reason`, or the origin a revocation is by | @@ -185,8 +186,11 @@ fails the self-test; `base-at-done`, `tick-noop` and `refinement-unclipped` fail `render-digest-off`, `name-unchecked`, `chain-unchecked`, `ilog2-no-interp`, `bola-waits`, `unlabelled-as-zero` and `round-half-down` fail the playlist, receipt and adaptation cases they reach; `rcpt-name-unchecked`, `legacy-admitted`, -`walk-skipped`, `walk-unclamped`, `memo-off`, `budget-unchecked`, `caps-unchecked`, -`nothing-as-delivered`, `groups-from-listed`, `step6-skipped`, `ancestor-any`, +`walk-skipped`, `walk-unclamped`, `memo-off`, `budget-unchecked`, `bytes-unchecked`, +`caps-unchecked`, `nothing-as-delivered`, `groups-from-listed`, `step6-skipped`, +`decision-unchecked`, `recovery-record-unchecked`, `declaration-unnamed`, +`first-publisher`, `matchers-need-object`, `operating-point-unchecked`, +`session-accepted`, `ancestor-any`, `anchor-follows-redirect`, `cached-rollback-unchecked`, `grants-module-unchecked`, `set-grants-ignored` and `set-by-name` fail the receipt, key, grant and set cases of §8.1–§8.5), and the job diff --git a/wai/http-conformance/cases.json b/wai/http-conformance/cases.json index 9dcdaa09d..9b010c663 100644 --- a/wai/http-conformance/cases.json +++ b/wai/http-conformance/cases.json @@ -988,6 +988,20 @@ {"name":"receipts_index_257_items","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","index":"cases/receipts/O9a-257-items.wai.receipts"}]},"expect":{"checks":[{"index":"receipt_index_too_large"}]}}, {"name":"receipts_index_257_claims","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","index":"cases/receipts/O9a-257-claims.wai.receipts"}]},"expect":{"checks":[{"index":"receipt_index_too_large"}]}}, {"name":"receipt_file_legacy_group","op":"receipt_file","input":{"file":"cases/rcpt/6a29eb0066513cad092217b4f905f3decfc703c081c832fb35e3e42f3d1c7043.json","kind":"group"},"expect":{"error":"receipt_legacy_group","step":null}}, +{"name":"receipts_fetch_bytes_exhausted","op":"receipts","input":{"serve":{"rcpt":["cases/rcpt-deep"],"pad_all":1048576},"checks":[{"object_cid":"f6fa1ca70e69bee70aa2bd36620b3e93a50ca2e110e1fbb91f3c6747f5deb860","held":"none","index":"cases/receipts/deep-64.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":63,"stop":"receipt_fetch_bytes_exhausted"}],"claims":[],"files_fetched":64}]}}, +{"name":"receipts_fetch_bytes_within","op":"receipts","input":{"serve":{"rcpt":["cases/rcpt-deep"],"pad_all":1000000},"checks":[{"object_cid":"f6fa1ca70e69bee70aa2bd36620b3e93a50ca2e110e1fbb91f3c6747f5deb860","held":"none","index":"cases/receipts/deep-64.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":64,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[],"files_fetched":65}]}}, +{"name":"receipts_lawful_basis","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"checks":[{"object":"O3","held":"whole","index":"cases/receipts/O3-lawful-basis.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"lawful-basis","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":null},{"error":"decision_purpose_not_declared","step":6},{"error":"decision_declaration_mismatch","step":6}],"files_fetched":6}]}}, +{"name":"receipts_lawful_basis_declaration_not_served","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"],"omit":["13e1857af31da433ed26408d5cfdb8a486dfdef79026836dda43b4b7a790ac1e"]},"checks":[{"object":"O3","held":"whole","index":"cases/receipts/O3-lawful-basis.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"context_not_held","step":6},{"error":"context_not_held","step":6},{"error":"decision_declaration_mismatch","step":6}],"files_fetched":6}]}}, +{"name":"receipts_lawful_basis_declaration_renamed","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"],"as":{"13e1857af31da433ed26408d5cfdb8a486dfdef79026836dda43b4b7a790ac1e":"cases/rcpt/aedd5190e7eb2e5e61854f1009402caf242d51df0d76506a60481fd79a7ca2ca.json"}},"checks":[{"object":"O3","held":"whole","index":"cases/receipts/O3-lawful-basis.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"receipt_name_mismatch","step":null},{"error":"receipt_name_mismatch","step":null},{"error":"decision_declaration_mismatch","step":6}],"files_fetched":6}]}}, +{"name":"receipts_binding_recovery","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"recovery":{"observed_value":"00","exact_match_algs":["org.example.watermark.1"]},"checks":[{"object":"O3","held":"whole","index":"cases/receipts/O3-binding-recovery.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"binding-recovery","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":null},{"error":"recovery_value_mismatch","step":6},{"error":"recovery_signer_mismatch","step":6}],"files_fetched":6}]}}, +{"name":"receipts_binding_recovery_object_not_held","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"recovery":{"observed_value":"00","exact_match_algs":["org.example.watermark.1"]},"checks":[{"object":"O3","held":"none","index":"cases/receipts/O3-binding-recovery.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[{"class":"binding-recovery","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":null},{"error":"recovery_value_mismatch","step":6},{"error":"recovery_signer_mismatch","step":6}],"files_fetched":6}]}}, +{"name":"receipts_binding_recovery_no_matcher","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"checks":[{"object":"O3","held":"whole","index":"cases/receipts/O3-binding-recovery.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"recovery_no_matcher","step":6},{"error":"recovery_no_matcher","step":6},{"error":"recovery_signer_mismatch","step":6}],"files_fetched":6}]}}, +{"name":"receipts_operating_point","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"checks":[{"object":"O5","held":"whole","index":"cases/receipts/O5-operating-point.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=OnChipCounter by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"operating-point","attests":"object","subject":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b","parent":null},{"error":"figure_disagrees","step":6}],"files_fetched":3}]}}, +{"name":"receipts_operating_point_not_held","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"checks":[{"object":"O5","held":"none","index":"cases/receipts/O5-operating-point.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[{"error":"context_not_held","step":6},{"error":"context_not_held","step":6}],"files_fetched":3}]}}, +{"name":"receipts_reconstruct_session","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"checks":[{"object":"O3","held":"whole","index":"cases/receipts/O3-session.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"context_not_held","step":6}],"files_fetched":2}]}}, +{"name":"receipts_ancestor_other_publisher_first","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"recovery":{"observed_value":"0ff0","exact_match_algs":["org.example.fingerprint"]},"checks":[{"object":"O7","held":"whole","index":"cases/receipts/O7-other-first.wai.receipts"},{"object":"O3","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=12a41592c8b7c17d4059e7b29b61e8ff96c7415f2f803348f2f017e05b9ea1da"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[],"files_fetched":2},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6}]}}, +{"name":"receipts_ancestor_publisher_first","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"recovery":{"observed_value":"0ff0","exact_match_algs":["org.example.fingerprint"]},"checks":[{"object":"O7","held":"whole","index":"cases/receipts/O7-publisher-first.wai.receipts"},{"object":"O3","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"},{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=12a41592c8b7c17d4059e7b29b61e8ff96c7415f2f803348f2f017e05b9ea1da"}],"claims":[],"files_fetched":2},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"soft-binding","attests":"ancestor","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","parent":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6}]}}, +{"name":"receipts_ancestor_other_publisher_only","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"recovery":{"observed_value":"0ff0","exact_match_algs":["org.example.fingerprint"]},"checks":[{"object":"O7","held":"whole","index":"cases/receipts/O7-other-only.wai.receipts"},{"object":"O3","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=12a41592c8b7c17d4059e7b29b61e8ff96c7415f2f803348f2f017e05b9ea1da"}],"claims":[],"files_fetched":1},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"recovery_signer_mismatch","step":6},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6}]}}, {"name":"receipts_confirmed","op":"receipts","input":{"keys":[{"anchor":".well-known/wai-keys"}],"checks":[{"object":"O3","held":"whole"},{"object":"O10","held":"whole"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"}],"claims":[{"error":"receipt_not_about_object","step":null},{"class":"log-inclusion","attests":"receipt","subject":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","parent":"14c0d31e65ae842627c606581c492bad7a73ee9190fc7db33d15de262d535491","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"},{"class":"energy","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":"b766a9838403aa0b33528ac30947c8f1e6fd993ba6434f112392f11b482464f0","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"},{"error":"energy_disagrees_with_delivery","step":6}],"files_fetched":6},{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"unmetered","confirm":"confirmed origin=https://origin.example key_id=d1 head=fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33 anchor=fetched"}],"claims":[{"class":"stage","attests":"object","subject":"1eb764beb4a8a739054341df8b5b7d065e490032b187a93787be15f1478080a8","parent":"8754b1d2e028880a1d21fe29dca565c0588125ea25979e37e1d90723b674654c","confirm":"unconfirmed step=1 reason=signer_id_not_origin_bound"}],"files_fetched":3}]}}, {"name":"key_log_anchor","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys"}]},"expect":{"steps":[{"anchored":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","path":"fetched"}]}}, {"name":"key_log_anchor_redirect","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","retrieval":{"requested":"https://origin.example/.well-known/wai-keys","answered":"https://mirror.example/.well-known/wai-keys"}}]},"expect":{"steps":[{"error":"key_log_not_from_origin"}]}}, @@ -1013,5 +1027,6 @@ {"name":"param_set_revoked","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":"cases/keys/wai-keys-revoked"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","hold_to":["https://origin.example"],"now":"2026-10-06T00:30:00Z"},"expect":{"error":"prior_revoked","revoked_by":"https://origin.example"}}, {"name":"param_set_not_held_to","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","now":"2026-10-06T00:30:00Z"},"expect":{"held":true}}, {"name":"param_set_revoked_not_held_to","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":"cases/keys/wai-keys-revoked"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","now":"2026-10-06T00:30:00Z"},"expect":{"error":"prior_revoked","revoked_by":"https://origin.example"}}, -{"name":"param_set_pinned_anchor","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"pin":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","hold_to":["https://origin.example"],"now":"2026-10-06T00:30:00Z"},"expect":{"held":true}} +{"name":"param_set_pinned_anchor","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"pin":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","hold_to":["https://origin.example"],"now":"2026-10-06T00:30:00Z"},"expect":{"held":true}}, +{"name":"param_set_sha_uppercase","op":"param_set","input":{"capability":"wai.neural.int_mlicpp","sha256":"7EF44F3B1FC4A2DF6C3E3B68460F595CAE3D31CB7F9018699E29E738340272A4","served":"sets/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4","keys":[{"anchor":".well-known/wai-keys"}],"statuses":[".well-known/wai-key-status"],"grants":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","hold_to":["https://origin.example"],"now":"2026-10-06T00:30:00Z"},"expect":{"error":"pin_malformed"}} ] diff --git a/wai/http-conformance/cases/rcpt/11c2d4a654057161f6450907c69bb1136f5228fdfcf05e599fb92ab318eba935.json b/wai/http-conformance/cases/rcpt/11c2d4a654057161f6450907c69bb1136f5228fdfcf05e599fb92ab318eba935.json new file mode 100644 index 000000000..a586cd43b --- /dev/null +++ b/wai/http-conformance/cases/rcpt/11c2d4a654057161f6450907c69bb1136f5228fdfcf05e599fb92ab318eba935.json @@ -0,0 +1 @@ +{"body":{"declaration":"13e1857af31da433ed26408d5cfdb8a486dfdef79026836dda43b4b7a790ac1e","outcome":"dpv:ConsentGiven","purpose":"dpv:PersonalisedAdvertising","record":"decision","vocabulary":"https://w3id.org/dpv"},"class":"lawful-basis","issued_at":"2026-10-06T00:03:31Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"3092b7395f839e770892be7f8a054fb9003829d91961dea0e3e92f5befc01831c22df5b984aea10dd38e0394954f1a2e9c6c30c9eb901f9ac8bea02cc009af07","signer_id":"did:example:http-sink","signer_pubkey":"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/133ffb1355f912e7ea23db3373cf3dd497a1e9f309741d3fa071b4fd71cac9a0.json b/wai/http-conformance/cases/rcpt/133ffb1355f912e7ea23db3373cf3dd497a1e9f309741d3fa071b4fd71cac9a0.json new file mode 100644 index 000000000..a4eb01c1b --- /dev/null +++ b/wai/http-conformance/cases/rcpt/133ffb1355f912e7ea23db3373cf3dd497a1e9f309741d3fa071b4fd71cac9a0.json @@ -0,0 +1 @@ +{"body":{"alg":"org.example.watermark.1","attests":"ancestor","binding_type":"watermark","declaration":"f95a4c6858d22e963130429b04b26653117ea3615b889975f0693168fe5b217c","observed":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","resolution_endpoint":"https://resolver.example.org/matches","value":"636c61696d2d636f6e666f726d616e63652077617465726d61726b207061796c6f6164"},"class":"binding-recovery","issued_at":"2026-10-06T00:03:52Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"cbf00b096d3ffa22a49c3344101f7036ae786fbbc567af4c7b3031cb8b7850cc9e32f65fbd5be4c761f049401ada8cf65d5e1f8ea65e895b37226bd9225bdb06","signer_id":"did:example:http-sink","signer_pubkey":"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/13e1857af31da433ed26408d5cfdb8a486dfdef79026836dda43b4b7a790ac1e.json b/wai/http-conformance/cases/rcpt/13e1857af31da433ed26408d5cfdb8a486dfdef79026836dda43b4b7a790ac1e.json new file mode 100644 index 000000000..e30679e40 --- /dev/null +++ b/wai/http-conformance/cases/rcpt/13e1857af31da433ed26408d5cfdb8a486dfdef79026836dda43b4b7a790ac1e.json @@ -0,0 +1 @@ +{"body":{"fragment":"9109aa1bee9f220a0cfb9bf4c5794f4c11027b8f20f6a804462b8b0b86463376","purposes":[{"basis":"dpv:Contract","data":[],"purpose":"dpv:ServiceProvision"},{"basis":"dpv:Consent","data":["pd:Location"],"purpose":"dpv:ServiceOptimisation"}],"record":"declaration","vocabulary":"https://w3id.org/dpv"},"class":"lawful-basis","issued_at":"2026-10-06T00:03:20Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"74aa5c2f020964aa6a3c784fac8e7c1166768c70fe16fba000887506c545e9f4b7b51e1a274df8ca3483442e7e4d2943d0c0b6b67c960de1ea991e4c91b8600d","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/21bc2df99c8f61e4b736e9df0ef64cd9a3c76f6a4f91d1f806895b7425280b28.json b/wai/http-conformance/cases/rcpt/21bc2df99c8f61e4b736e9df0ef64cd9a3c76f6a4f91d1f806895b7425280b28.json new file mode 100644 index 000000000..0c4ac15ed --- /dev/null +++ b/wai/http-conformance/cases/rcpt/21bc2df99c8f61e4b736e9df0ef64cd9a3c76f6a4f91d1f806895b7425280b28.json @@ -0,0 +1 @@ +{"body":{"declaration":"13e1857af31da433ed26408d5cfdb8a486dfdef79026836dda43b4b7a790ac1e","outcome":"dpv:ConsentGiven","purpose":"dpv:ServiceOptimisation","record":"decision","vocabulary":"https://w3id.org/dpv"},"class":"lawful-basis","issued_at":"2026-10-06T00:03:30Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"840c67fce73952a3191772125e54ec2a92deb7bbcb872e1a4eba5f4e54dbac103121613196473c65aa120a02c155bc889426135a4cb2b3a82a2a84b62bd2e607","signer_id":"did:example:http-sink","signer_pubkey":"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/23e93ea54cbdbe5a203dc6d346752c77c396e1758466b8eb785b46d31f54760f.json b/wai/http-conformance/cases/rcpt/23e93ea54cbdbe5a203dc6d346752c77c396e1758466b8eb785b46d31f54760f.json new file mode 100644 index 000000000..7eb07e184 --- /dev/null +++ b/wai/http-conformance/cases/rcpt/23e93ea54cbdbe5a203dc6d346752c77c396e1758466b8eb785b46d31f54760f.json @@ -0,0 +1 @@ +{"body":{"alg":"org.example.watermark.1","attests":"ancestor","binding_type":"watermark","declaration":"3f4c9dcae5e9654eec359360cf03a0881b61a53ff1b8e4ecaf1cef696a917650","observed":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","resolution_endpoint":"https://resolver.example.org/matches","value":"636c61696d2d636f6e666f726d616e63652077617465726d61726b207061796c6f6164"},"class":"binding-recovery","issued_at":"2026-10-06T00:03:50Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"3ad58aef5854137d6c88d3108a0a1db204432b85c983e5392097b494a88cc2fbedc48d13b437e4e00d8e9ff3ff87c5c6ce94cc91d68ef14a7e5bf3e5a2f22800","signer_id":"did:example:http-sink","signer_pubkey":"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/3f4c9dcae5e9654eec359360cf03a0881b61a53ff1b8e4ecaf1cef696a917650.json b/wai/http-conformance/cases/rcpt/3f4c9dcae5e9654eec359360cf03a0881b61a53ff1b8e4ecaf1cef696a917650.json new file mode 100644 index 000000000..42e2a67b1 --- /dev/null +++ b/wai/http-conformance/cases/rcpt/3f4c9dcae5e9654eec359360cf03a0881b61a53ff1b8e4ecaf1cef696a917650.json @@ -0,0 +1 @@ +{"body":{"alg":"org.example.watermark.1","binding_type":"watermark","registry":"https://registry.example.org/soft-binding-algorithms","resolution_endpoint":"https://resolver.example.org/matches","value":"636c61696d2d636f6e666f726d616e63652077617465726d61726b207061796c6f6164"},"class":"soft-binding","issued_at":"2026-10-06T00:03:40Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"53af8446142c32089cee5030e9ce1278fb119f90869857671f45a9dfdaf01e81904a755f2b4b72ddfd68503f6a1aa30ede051b003d1af18222222353c7a00905","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/3fa148d4b79df7eb77f3d813122ab6463c339212111830f251e7c61b0e3fe784.json b/wai/http-conformance/cases/rcpt/3fa148d4b79df7eb77f3d813122ab6463c339212111830f251e7c61b0e3fe784.json new file mode 100644 index 000000000..f9cdb08f8 --- /dev/null +++ b/wai/http-conformance/cases/rcpt/3fa148d4b79df7eb77f3d813122ab6463c339212111830f251e7c61b0e3fe784.json @@ -0,0 +1 @@ +{"body":{"energy_calibration_ref":null,"energy_counter_filtering":"Unknown","energy_provenance":"OnChipCounter","energy_uncertainty":{"relative_ppm":44669,"window_us":3000000},"figure_index":0,"figure_profile":"moq-jwp:object","group":"95477f223c6bee89a57c7171c831352fd43f4a348f9d56f90e32e922c2c2b16f","joules_micro":461538,"op_committed":{"frame_rate_milli":30000,"height":1080},"op_delivered":{"frame_rate_milli":30000,"height":1080},"op_requested":{"frame_rate_milli":30000,"height":1080},"prediction":{"descriptor_hash":"1122437cacd17ba56225e487fcb4f5350858024b1fa7051cdd09dd526cba2f32","predicted_joules_micro":450000,"predictor":"https://models.example.org/complexity-to-energy"},"vocabulary":"https://vocabulary.example.org/operating-points"},"class":"operating-point","issued_at":"2026-10-06T00:04:00Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"afba2a669086a83c6e7bbd3133ae6f419c524667ff8e5d1ccc28b742c13ef10541e2450a976ecaf4b08f1fb3460eb26f3ca3f5ba31ff3a67bdf58e72a0828b06","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","subject":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/67b2612eb6b1e6ff7eeae5a6004682cd67fba92f8474c1a4d604fbf5934f7045.json b/wai/http-conformance/cases/rcpt/67b2612eb6b1e6ff7eeae5a6004682cd67fba92f8474c1a4d604fbf5934f7045.json new file mode 100644 index 000000000..4862fcf01 --- /dev/null +++ b/wai/http-conformance/cases/rcpt/67b2612eb6b1e6ff7eeae5a6004682cd67fba92f8474c1a4d604fbf5934f7045.json @@ -0,0 +1 @@ +{"body":{"alg":"org.example.watermark.1","attests":"ancestor","binding_type":"watermark","declaration":"3f4c9dcae5e9654eec359360cf03a0881b61a53ff1b8e4ecaf1cef696a917650","observed":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","resolution_endpoint":"https://resolver.example.org/matches","value":"616e6f74686572207061796c6f6164"},"class":"binding-recovery","issued_at":"2026-10-06T00:03:51Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"6f30b641a7fdb2480d462dd418dc1ead09b749beb442f6ffcdc3ebfdbfa48d28234b9d53198aa8d530a4737f0eedb578853747148eabe49dc6c152face821307","signer_id":"did:example:http-sink","signer_pubkey":"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/8bc21df138b97b63f5f4730ca6dd4ad911ce2194843a671678afe1a97a4a92f8.json b/wai/http-conformance/cases/rcpt/8bc21df138b97b63f5f4730ca6dd4ad911ce2194843a671678afe1a97a4a92f8.json new file mode 100644 index 000000000..497c42571 --- /dev/null +++ b/wai/http-conformance/cases/rcpt/8bc21df138b97b63f5f4730ca6dd4ad911ce2194843a671678afe1a97a4a92f8.json @@ -0,0 +1 @@ +{"body":{"declaration":"aedd5190e7eb2e5e61854f1009402caf242d51df0d76506a60481fd79a7ca2ca","outcome":"dpv:ConsentGiven","purpose":"dpv:ServiceOptimisation","record":"decision","vocabulary":"https://w3id.org/dpv"},"class":"lawful-basis","issued_at":"2026-10-06T00:03:32Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"febeeeb47fe8f0b4ce01185a1e422a708171b2819e17ed6f1984091e13ff2229b8e3b431807137757aed2d9a02d3bead9cec7fb5f216d1badb673aeaa43e8c09","signer_id":"did:example:http-sink","signer_pubkey":"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/a0410e703b803a08ec1b8ec0618f17b68c9d307f82e22fa301083aa90c8def29.json b/wai/http-conformance/cases/rcpt/a0410e703b803a08ec1b8ec0618f17b68c9d307f82e22fa301083aa90c8def29.json new file mode 100644 index 000000000..5c22e7a1d --- /dev/null +++ b/wai/http-conformance/cases/rcpt/a0410e703b803a08ec1b8ec0618f17b68c9d307f82e22fa301083aa90c8def29.json @@ -0,0 +1 @@ +{"body":{"energy_calibration_ref":null,"energy_counter_filtering":"Unknown","energy_provenance":"OnChipCounter","energy_uncertainty":{"relative_ppm":44669,"window_us":3000000},"figure_index":0,"figure_profile":"moq-jwp:object","group":"95477f223c6bee89a57c7171c831352fd43f4a348f9d56f90e32e922c2c2b16f","joules_micro":461539,"op_committed":{"frame_rate_milli":30000,"height":1080},"op_delivered":{"frame_rate_milli":30000,"height":1080},"op_requested":{"frame_rate_milli":30000,"height":1080},"prediction":{"descriptor_hash":"1122437cacd17ba56225e487fcb4f5350858024b1fa7051cdd09dd526cba2f32","predicted_joules_micro":450000,"predictor":"https://models.example.org/complexity-to-energy"},"vocabulary":"https://vocabulary.example.org/operating-points"},"class":"operating-point","issued_at":"2026-10-06T00:04:01Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"661a0293f236e45d55742c1d2cb8736e052cf885abb3416e42f583e1c79ad008d6bb761f0db2bee07da182657d4805360378f7df8b53217739ad995be6ffcf01","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","subject":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/aedd5190e7eb2e5e61854f1009402caf242d51df0d76506a60481fd79a7ca2ca.json b/wai/http-conformance/cases/rcpt/aedd5190e7eb2e5e61854f1009402caf242d51df0d76506a60481fd79a7ca2ca.json new file mode 100644 index 000000000..3bcdb174e --- /dev/null +++ b/wai/http-conformance/cases/rcpt/aedd5190e7eb2e5e61854f1009402caf242d51df0d76506a60481fd79a7ca2ca.json @@ -0,0 +1 @@ +{"body":{"fragment":"9109aa1bee9f220a0cfb9bf4c5794f4c11027b8f20f6a804462b8b0b86463376","purposes":[{"basis":"dpv:Contract","data":[],"purpose":"dpv:ServiceProvision"},{"basis":"dpv:Consent","data":["pd:Location"],"purpose":"dpv:ServiceOptimisation"}],"record":"declaration","vocabulary":"https://w3id.org/dpv"},"class":"lawful-basis","issued_at":"2026-10-06T00:03:21Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"4cdfff3a6a343148099eccb79ef2fcbe1439e847985027a409319c1e469b15513bf4efa298372fb233474a0c53370bb5a4731b0fc8dad787c6d48c6523e60407","signer_id":"did:web:origin.example#d1","signer_pubkey":"af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d","subject":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/c608ce5df043fb58021d9360c131668c0896cf0d3e4010520c4482eeb33a821e.json b/wai/http-conformance/cases/rcpt/c608ce5df043fb58021d9360c131668c0896cf0d3e4010520c4482eeb33a821e.json new file mode 100644 index 000000000..64fa5f5ee --- /dev/null +++ b/wai/http-conformance/cases/rcpt/c608ce5df043fb58021d9360c131668c0896cf0d3e4010520c4482eeb33a821e.json @@ -0,0 +1 @@ +{"energy_coverage":"Whole","energy_provenance":"Estimator","group_end":"2026-10-06T00:00:04Z","group_id":0,"group_start":"2026-10-06T00:00:00Z","joules_total":300,"kind":"group","leaf_count":1,"parent_receipt_hash":null,"root_hash":"368b22537cfa0d3d0bfcfe1f6256302af54c54711dfa411c193beda171c97edb","sig":"2042a90c80338d11fa12ce385bafb946f836ef8f1a2f3bbe55168ee53b79885e3bf7fac8b895015d75e98102ea0d22427c6c477dd17a6bd8dd78e8c6e028550f","sign_version":2,"signer_id":"did:example:other-publisher","signer_pubkey":"12a41592c8b7c17d4059e7b29b61e8ff96c7415f2f803348f2f017e05b9ea1da","track_alias":7} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/f95a4c6858d22e963130429b04b26653117ea3615b889975f0693168fe5b217c.json b/wai/http-conformance/cases/rcpt/f95a4c6858d22e963130429b04b26653117ea3615b889975f0693168fe5b217c.json new file mode 100644 index 000000000..803fee251 --- /dev/null +++ b/wai/http-conformance/cases/rcpt/f95a4c6858d22e963130429b04b26653117ea3615b889975f0693168fe5b217c.json @@ -0,0 +1 @@ +{"body":{"alg":"org.example.watermark.1","binding_type":"watermark","registry":"https://registry.example.org/soft-binding-algorithms","resolution_endpoint":"https://resolver.example.org/matches","value":"636c61696d2d636f6e666f726d616e63652077617465726d61726b207061796c6f6164"},"class":"soft-binding","issued_at":"2026-10-06T00:03:41Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"86d7f3d6263653d55cba2135507780fddef561f5692000362c57be7c1204d9302109263d6e5ec0e9ff7aeca0a2dfc47119bfbb226d655902cb219b28b699c502","signer_id":"did:example:http-relay","signer_pubkey":"34b4d9043156cb6dcf0beb0a2949b7559c940d2bcb6dbe8c53a9b30278e3a746","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/rcpt/fe7c805ae210c5a6d8f5bccd79b34d9e4a9a7da0cd697729a2fe4547ddb3a62e.json b/wai/http-conformance/cases/rcpt/fe7c805ae210c5a6d8f5bccd79b34d9e4a9a7da0cd697729a2fe4547ddb3a62e.json new file mode 100644 index 000000000..21ff62c7c --- /dev/null +++ b/wai/http-conformance/cases/rcpt/fe7c805ae210c5a6d8f5bccd79b34d9e4a9a7da0cd697729a2fe4547ddb3a62e.json @@ -0,0 +1 @@ +{"body":{"classical_declared":700,"classical_source":"wai2-table","counted_discarded":1904,"counted_fallback":0,"counted_primary":10607,"deadline_us":40000,"decoders":[{"capability":"wai.video.int_hyper","decoder":"builtin:wai@0.4.0","leaves":4,"module_status":null,"object":"be586ec3398df154e2927f3a690aa827c04e7aa261ccdb07dd76337c63bf72ec","path":"primary","pin":"7b4371854df0aa1645ac6525b373387c0617b873d5a57748932ee0f386ea164b","rendition":null,"tier":"decode-equivalence","variant":null},{"capability":"wai.video.int_hyper","decoder":"builtin:wai@0.4.0","leaves":1,"module_status":null,"object":"50dbd88478a116e4dc87b018127e8e95ece2049db8e9d897449e7e8e6ded420c","path":"primary","pin":"7b4371854df0aa1645ac6525b373387c0617b873d5a57748932ee0f386ea164b","rendition":0,"tier":"decode-equivalence","variant":null},{"capability":"wai.video.av1","decoder":"builtin:wai@0.4.0","leaves":1,"module_status":null,"object":"13b04249eca2b1a9061dba8ca2846169d3f5fa3548d9e138a0cf2cb553798cf4","path":"fallback","pin":null,"rendition":null,"tier":null,"variant":null},{"capability":"wai.video.int_refine","decoder":"builtin:wai@0.4.0","leaves":1,"module_status":null,"object":"47a2c23727d2eb9d2beef0931c3e9b2675408d372660420cce427dc27fd21fe1","path":"primary","pin":null,"rendition":null,"tier":null,"variant":null},{"capability":"wai.video.int_refine","decoder":"builtin:wai@0.4.0","leaves":1,"module_status":null,"object":"6b74be11e64461c7d972fc5cb6ba15e98427e9d6e60622b0a753222138de28cf","path":"primary","pin":null,"rendition":null,"tier":null,"variant":null},{"capability":"wai.video.int_refine","decoder":"builtin:wai@0.4.0","leaves":1,"module_status":null,"object":"5e7a38ca5c04442cbe00b040d58e4494f5aa36fb81e8bbe0d304fd2d1ce722b2","path":"primary","pin":null,"rendition":null,"tier":null,"variant":null},{"capability":"wai.video.int_refine","decoder":"builtin:wai@0.4.0","leaves":1,"module_status":null,"object":"be5055af8e32359265852c512667b10ab6a07455e5ab06603e278763f6633a85","path":"primary","pin":null,"rendition":null,"tier":null,"variant":null}],"energy":null,"fallback_units":1,"framing":"registered","host":{"build":{"profile":"release","target":"aarch64-unknown-linux-gnu","toolchain":"rustc 1.95.0"},"profile":"native-ffi","target":"aarch64-unknown-linux-gnu","threads":4},"leaf_count":10,"media":"video","missed":1,"missed_units":[{"late_us":1200,"layers":1,"unit":5}],"output_root":"b62a8425b7fe3a085a14f2f2156ea22ae3b4349884d799b5afe66dccc7d4dd7f","primary_units":5,"receipt_bytes":640,"session_nonce":"57c5462dfdabd1b7bdd204d34ba33649c057eb535349f29f45eb8f8c7d4a7e7f","stage_sets":[{"claim":"686dd5c467fbd879d476b39c09163dfc34de0d370ea029ad40c4887c01f9efb8","first_unit":0}],"switches":[{"from":"primary","reason":"set-incomplete","to":"fallback","unit":5}],"switches_total":1,"transferred":14715,"uncounted":2,"units":6},"class":"reconstruct-session","issued_at":"2026-10-06T00:04:10Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"03e940e5ddc8a9592147569f817bfb80c0cd29584c0d75a508835b5c69c133d5357bbe3796f4b29b41a4e72a5b111f4f70b941fa0d2744b15af5949ff810d804","signer_id":"did:example:http-sink","signer_pubkey":"2bc2800b3316e009209ffd757dab19ccf0ae84bc7ae90654e1e81712d270f653","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","subject_type":"object"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O3-binding-recovery.wai.receipts b/wai/http-conformance/cases/receipts/O3-binding-recovery.wai.receipts new file mode 100644 index 000000000..4e94c142c --- /dev/null +++ b/wai/http-conformance/cases/receipts/O3-binding-recovery.wai.receipts @@ -0,0 +1 @@ +{"claims":["23e93ea54cbdbe5a203dc6d346752c77c396e1758466b8eb785b46d31f54760f","67b2612eb6b1e6ff7eeae5a6004682cd67fba92f8474c1a4d604fbf5934f7045","133ffb1355f912e7ea23db3373cf3dd497a1e9f309741d3fa071b4fd71cac9a0"],"delivery":[{"object_receipt":{"content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","energy_provenance":"Estimator","group_id":0,"joules_micro":1500,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":["368b22537cfa0d3d0bfcfe1f6256302af54c54711dfa411c193beda171c97edb"]},"object_id":0,"origin":"origin","track_alias":2},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","group_receipt":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","joules_micro":1500}}],"kind":"wai-receipt-index","object":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O3-lawful-basis.wai.receipts b/wai/http-conformance/cases/receipts/O3-lawful-basis.wai.receipts new file mode 100644 index 000000000..9f01ffbcf --- /dev/null +++ b/wai/http-conformance/cases/receipts/O3-lawful-basis.wai.receipts @@ -0,0 +1 @@ +{"claims":["21bc2df99c8f61e4b736e9df0ef64cd9a3c76f6a4f91d1f806895b7425280b28","11c2d4a654057161f6450907c69bb1136f5228fdfcf05e599fb92ab318eba935","8bc21df138b97b63f5f4730ca6dd4ad911ce2194843a671678afe1a97a4a92f8"],"delivery":[{"object_receipt":{"content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","energy_provenance":"Estimator","group_id":0,"joules_micro":1500,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":["368b22537cfa0d3d0bfcfe1f6256302af54c54711dfa411c193beda171c97edb"]},"object_id":0,"origin":"origin","track_alias":2},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","group_receipt":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","joules_micro":1500}}],"kind":"wai-receipt-index","object":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O3-session.wai.receipts b/wai/http-conformance/cases/receipts/O3-session.wai.receipts new file mode 100644 index 000000000..50c73bdc9 --- /dev/null +++ b/wai/http-conformance/cases/receipts/O3-session.wai.receipts @@ -0,0 +1 @@ +{"claims":["fe7c805ae210c5a6d8f5bccd79b34d9e4a9a7da0cd697729a2fe4547ddb3a62e"],"delivery":[{"object_receipt":{"content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","energy_provenance":"Estimator","group_id":0,"joules_micro":1500,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":["368b22537cfa0d3d0bfcfe1f6256302af54c54711dfa411c193beda171c97edb"]},"object_id":0,"origin":"origin","track_alias":2},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","group_receipt":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","joules_micro":1500}}],"kind":"wai-receipt-index","object":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O5-operating-point.wai.receipts b/wai/http-conformance/cases/receipts/O5-operating-point.wai.receipts new file mode 100644 index 000000000..f8ce11eea --- /dev/null +++ b/wai/http-conformance/cases/receipts/O5-operating-point.wai.receipts @@ -0,0 +1 @@ +{"claims":["3fa148d4b79df7eb77f3d813122ab6463c339212111830f251e7c61b0e3fe784","a0410e703b803a08ec1b8ec0618f17b68c9d307f82e22fa301083aa90c8def29"],"delivery":[{"object_receipt":{"content_hash":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b","energy_counter_filtering":"Unknown","energy_provenance":"OnChipCounter","energy_uncertainty":{"relative_ppm":44669,"window_us":3000000},"group_id":0,"joules_micro":461538,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":4},"property":{"acquisition":"OnChipCounter","acquisition_evidence":"0000ae7d00000000002dc6c000","content_hash":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b","group_receipt":"95477f223c6bee89a57c7171c831352fd43f4a348f9d56f90e32e922c2c2b16f","joules_micro":461538}}],"kind":"wai-receipt-index","object":"e43e038c10c0494d9647444fd4daf54b19fc3a946a3c7980431bc6e0a236093b"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O7-other-first.wai.receipts b/wai/http-conformance/cases/receipts/O7-other-first.wai.receipts new file mode 100644 index 000000000..50dbac1ec --- /dev/null +++ b/wai/http-conformance/cases/receipts/O7-other-first.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","energy_provenance":"Estimator","group_id":0,"joules_micro":300,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":7},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","group_receipt":"c608ce5df043fb58021d9360c131668c0896cf0d3e4010520c4482eeb33a821e","joules_micro":300}},{"object_receipt":{"content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","energy_provenance":"Estimator","group_id":0,"joules_micro":300,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":1,"leaf_index":1,"siblings":["6ecdf4781b24002780c2fa637e034759ebd9b88ec7a341bc7a250cc36972f5bc"]},"object_id":1,"origin":"origin","track_alias":2},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","group_receipt":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","joules_micro":300}}],"kind":"wai-receipt-index","object":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O7-other-only.wai.receipts b/wai/http-conformance/cases/receipts/O7-other-only.wai.receipts new file mode 100644 index 000000000..f7ae91786 --- /dev/null +++ b/wai/http-conformance/cases/receipts/O7-other-only.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","energy_provenance":"Estimator","group_id":0,"joules_micro":300,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":7},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","group_receipt":"c608ce5df043fb58021d9360c131668c0896cf0d3e4010520c4482eeb33a821e","joules_micro":300}}],"kind":"wai-receipt-index","object":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f"} \ No newline at end of file diff --git a/wai/http-conformance/cases/receipts/O7-publisher-first.wai.receipts b/wai/http-conformance/cases/receipts/O7-publisher-first.wai.receipts new file mode 100644 index 000000000..a6d761c54 --- /dev/null +++ b/wai/http-conformance/cases/receipts/O7-publisher-first.wai.receipts @@ -0,0 +1 @@ +{"claims":[],"delivery":[{"object_receipt":{"content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","energy_provenance":"Estimator","group_id":0,"joules_micro":300,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":1,"leaf_index":1,"siblings":["6ecdf4781b24002780c2fa637e034759ebd9b88ec7a341bc7a250cc36972f5bc"]},"object_id":1,"origin":"origin","track_alias":2},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","group_receipt":"ed784e0aa394c85918460cb0e818ef939b8674098bc2e38910bba7b2244df018","joules_micro":300}},{"object_receipt":{"content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","energy_provenance":"Estimator","group_id":0,"joules_micro":300,"kind":"object","leaf_version":2,"merkle_proof":{"direction_bits":0,"leaf_index":0,"siblings":[]},"object_id":0,"origin":"origin","track_alias":7},"property":{"acquisition":"Estimator","acquisition_evidence":"","content_hash":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f","group_receipt":"c608ce5df043fb58021d9360c131668c0896cf0d3e4010520c4482eeb33a821e","joules_micro":300}}],"kind":"wai-receipt-index","object":"922db9b513790966272927c4f542fb01cb25ff9c7b189fb6699bf92894c29a6f"} \ No newline at end of file diff --git a/wai/http-conformance/falsify/run.sh b/wai/http-conformance/falsify/run.sh index 7c9f0e95d..8be312f30 100755 --- a/wai/http-conformance/falsify/run.sh +++ b/wai/http-conformance/falsify/run.sh @@ -57,6 +57,8 @@ for m in left-half no-root counter-zero floor-as-codecs blake3-iv base-at-done t uri-unresolved gap-withdraws reanchor-backwards walk-exceeded-wedges walk-name-unchecked walk-verify-unchecked bindings-unbounded \ file-numbers-unbounded rcpt-name-unchecked walk-skipped nothing-as-delivered ancestor-any \ legacy-admitted memo-off walk-unclamped step6-skipped groups-from-listed budget-unchecked caps-unchecked \ + bytes-unchecked decision-unchecked recovery-record-unchecked declaration-unnamed first-publisher matchers-need-object \ + operating-point-unchecked session-accepted \ anchor-follows-redirect cached-rollback-unchecked grants-module-unchecked set-grants-ignored set-by-name; do if python3 http_verify.py . --mutate "$m" > "$WORK/py-$m.log" 2>&1; then echo "::error::http_verify.py --mutate $m passed"; fail=1 diff --git a/wai/http-conformance/http_receipts.py b/wai/http-conformance/http_receipts.py index 2c2631be8..ad322995e 100644 --- a/wai/http-conformance/http_receipts.py +++ b/wai/http-conformance/http_receipts.py @@ -34,6 +34,7 @@ MAX_INDEX_BYTES = 4 << 20 # an index's bytes, at most (§8.2) MAX_INDEX_ITEMS = 256 # delivery items an index lists, at most MAX_INDEX_CLAIMS = 256 # claims an index lists, at most FETCH_BUDGET = 1024 # receipt files fetched for one index, at most +MAX_INDEX_FETCH_BYTES = 64 << 20 # bytes of receipt files read for one index, at most INDEX_KIND = "wai-receipt-index" GRANTS_KIND = "wai-module-grants" ACQUIRED = "2026-10-06T00:00:00Z" @@ -126,21 +127,35 @@ def read_claim_file(name, b): class Fetcher: """One index check's receipt files: each name fetched once and what was served kept, - at most a budget of fetches (never above FETCH_BUDGET), none over the file cap.""" + at most a budget of fetches (never above FETCH_BUDGET) and of bytes read + (MAX_INDEX_FETCH_BYTES, counting at most one byte past the file cap of each file), + none over the file cap. The file that passes the bytes is refused, and no further + file is fetched.""" + + PAST = object() def __init__(self, get, budget): - self.get_raw, self.memo, self.fetched = get, {}, 0 + self.get_raw, self.memo, self.fetched, self.bytes = get, {}, 0, 0 self.budget = min(budget, FETCH_BUDGET) def get(self, h): if h not in self.memo or MUTATE == "memo-off": if self.fetched >= self.budget and MUTATE != "budget-unchecked": raise Refused("receipt_fetch_budget_exhausted") + if self.bytes >= MAX_INDEX_FETCH_BYTES and MUTATE != "bytes-unchecked": + raise Refused("receipt_fetch_bytes_exhausted") self.fetched += 1 - self.memo[h] = self.get_raw(h) + b = self.get_raw(h) + if b is not None: + self.bytes += min(len(b), MAX_RECEIPT_FILE_BYTES + 1) + if self.bytes > MAX_INDEX_FETCH_BYTES and MUTATE != "bytes-unchecked": + b = Fetcher.PAST + self.memo[h] = b b = self.memo[h] if b is None: raise Refused("receipt_not_served") + if b is Fetcher.PAST: + raise Refused("receipt_fetch_bytes_exhausted") if len(b) > MAX_RECEIPT_FILE_BYTES and MUTATE != "caps-unchecked": raise Refused("receipt_too_large") return b @@ -306,13 +321,17 @@ def check_delivery(cid, item, held, fetcher, ch, bound): # ------------------------------------------------------------------ claims (§8.2) def declaration_text(link, fetcher): - """A declaration a claim names, fetched by its link: not served is context_not_held.""" + """A declaration a claim names, fetched by its link and read as §8.1 reads every file + fetched by name (steps 1-2, then the name): not served is context_not_held; its steps + 3-4 are step 6's, which reports a declaration that does not verify (context_invalid).""" try: b = fetcher.get(link) except Refused as r: if r.code == "receipt_not_served": raise Refused("context_not_held", 6) raise + if MUTATE != "declaration-unnamed": + read_claim_file(link, b) t = utf8(b) return t if t is not None else "\u0000" # not UTF-8: no claim reads from it (context_invalid) @@ -330,7 +349,7 @@ def step6(c, cid, passed, fetcher, exact_algs): CV.check_context(c, {"object": v["raw"]}) if not delivered or b["oer_receipt"] is not None: raise Refused("context_not_held", 6) - elif cls in ("measurement", "operating-point"): + elif cls in ("measurement", "operating-point") and not (cls == "operating-point" and MUTATE == "operating-point-unchecked"): v = next((v for v in delivered if JV.link(v["g"]).hex() == b["group"]), None) if v is None: raise Refused("figure_not_held" if cls == "measurement" else "context_not_held", 6) @@ -346,9 +365,9 @@ def step6(c, cid, passed, fetcher, exact_algs): raise Refused("context_not_held", 6) CV.check_context(c, {"gap_object": {"track_alias": v["o"]["track_alias"], "group_id": v["o"]["group_id"], "object_id": v["o"]["object_id"], "content_hash": v["o"]["content_hash"].hex()}}) - elif cls == "lawful-basis" and b["record"] == "decision": + elif cls == "lawful-basis" and b["record"] == "decision" and MUTATE != "decision-unchecked": CV.check_context(c, {"declaration": declaration_text(bytes.fromhex(b["declaration"]).hex(), fetcher)}) - elif cls == "binding-recovery": + elif cls == "binding-recovery" and MUTATE != "recovery-record-unchecked": if not passed: raise Refused("context_not_held", 6) t = declaration_text(b["declaration"], fetcher) @@ -360,20 +379,25 @@ def step6(c, cid, passed, fetcher, exact_algs): ds = None pub = ds if ds in signers else signers[0] CV.check_context(c, {"declaration": t, "publisher": pub, "exact_match_algs": exact_algs}) - elif cls == "reconstruct-session": + elif cls == "reconstruct-session" and MUTATE != "session-accepted": raise Refused("context_not_held", 6) except CV.Reject as r: raise Refused(r.reason, r.step) -def check_claim(cid, passed, name, fetcher, recovery): +def check_claim(cid, passed, held, name, fetcher, recovery): b = fetcher.get(name) c, later = read_claim_file(name, b) groups = [JV.link(v["g"]).hex() for v in passed] if MUTATE == "groups-from-listed": groups += _LISTED - pubs = (recovery or {}).get("publishers") or [] - publisher = next((k for h, k in pubs if h == c["subject"]), None) + # §3.2's P: a recovery through a soft binding needs the object in hand; of the keys + # held for the subject, the declaration's own signer's where held, else the first. + pubs = (recovery or {}).get("publishers") or [] if held is not None else [] + keys = [k for h, k in pubs if h == c["subject"]] + publisher = None + if keys: + publisher = keys[0] if MUTATE == "first-publisher" else next((k for k in keys if k == c["signer_pubkey"]), keys[0]) if c["subject_type"] == "object" and c["subject"] == cid: attests = "object" elif c["subject_type"] == "receipt" and c["subject"] in groups: @@ -646,7 +670,7 @@ def confirm_claim(c, revisions, anchors): class Served: """A case's receipt files: the `rcpt` directories searched for .json, hashes `omit`ted, hashes served `as` another file, and files `pad`ded with spaces to a - length.""" + length, or every file to `pad_all`.""" def __init__(self, d, v): self.d = d @@ -654,6 +678,7 @@ class Served: self.omit = set(v.get("omit") or []) self.swap = v.get("as") or {} self.pad = v.get("pad") or {} + self.pad_all = v.get("pad_all") def get(self, h): if h in self.omit: @@ -670,6 +695,8 @@ class Served: break if b is not None and h in self.pad: b = b + b" " * max(0, self.pad[h] - len(b)) + elif b is not None and self.pad_all is not None: + b = b + b" " * max(0, self.pad_all - len(b)) return b @@ -740,9 +767,10 @@ def receipts(d, objs, inp, fetch_object, content_hash): passed.append(ctx) except Refused as r: dl.append(r.json()) - # A recovery runs only for an object the client holds (§8.2, jwp-receipts §3.2). + # A recovery through a soft binding runs only for an object the client holds + # (check_claim gates it); the matchers serve a recovery record's step 6 either way. recovery = None - if rec_in is not None and held is not None: + if rec_in is not None and (held is not None or MUTATE != "matchers-need-object"): recovery = {"publishers": publishers, "observed_value": rec_in["observed_value"], "exact_match_algs": rec_in.get("exact_match_algs") or []} cl = [] @@ -751,7 +779,7 @@ def receipts(d, objs, inp, fetch_object, content_hash): cl.append(c.json()) continue try: - v, claim = check_claim(cid, passed, c, fetcher, recovery) + v, claim = check_claim(cid, passed, held, c, fetcher, recovery) if keys is not None: v["confirm"] = confirm_claim(claim, *keys) cl.append(v) diff --git a/wai/http-conformance/http_verify.py b/wai/http-conformance/http_verify.py index a50844a70..4052a3d57 100644 --- a/wai/http-conformance/http_verify.py +++ b/wai/http-conformance/http_verify.py @@ -25,8 +25,11 @@ stops the verifier, never passes. python3 http_verify.py [dir] --mutate NAME the same with a deliberate defect: left-half | no-root | counter-zero | floor-as-codecs | blake3-iv | base-at-done | tick-noop | refinement-unclipped | … | rcpt-name-unchecked | legacy-admitted | - walk-skipped | walk-unclamped | memo-off | budget-unchecked | caps-unchecked | - nothing-as-delivered | groups-from-listed | step6-skipped | ancestor-any | + walk-skipped | walk-unclamped | memo-off | budget-unchecked | bytes-unchecked | + caps-unchecked | nothing-as-delivered | groups-from-listed | step6-skipped | + decision-unchecked | recovery-record-unchecked | declaration-unnamed | + first-publisher | matchers-need-object | operating-point-unchecked | + session-accepted | ancestor-any | anchor-follows-redirect | cached-rollback-unchecked | grants-module-unchecked | set-grants-ignored | set-by-name (falsify/run.sh lists every one) diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs index dc41c7b2d..12a559822 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs @@ -59,8 +59,8 @@ mod built { use super::super::{case, fetch_session, next, pick, refused, strs, Case, Corpus, Stop, STREAM_WAI}; use super::OPS; - use wai::claim_receipt::{stage_point, BindingType, ClaimBody, ClaimReceipt, DeliveryGapClaim, EnergyClaim, GapReason, LogInclusionClaim, - SoftBindingClaim, StageClaim, StageEvidence, StageObject, StageStatus, SubjectType, STAGE_VOCABULARY}; + use wai::claim_receipt::{stage_point, BindingType, ClaimBody, ClaimReceipt, DeliveryGapClaim, EnergyClaim, GapReason, LawfulBasisClaim, + LogInclusionClaim, SoftBindingClaim, StageClaim, StageEvidence, StageObject, StageStatus, SubjectType, STAGE_VOCABULARY}; use wai::energy_binding::Coverage; use wai::energy_class::{CounterFiltering, Uncertainty}; use wai::container::{PinRefusal, Prior}; @@ -94,6 +94,9 @@ mod built { const SINK_ID: &str = "did:example:http-sink"; const RELAY: u8 = 0x66; const RELAY_ID: &str = "did:example:http-relay"; + /// A second publisher, whose delivery of O7 an index may list before the first's. + const OTHER: u8 = 0x67; + const OTHER_ID: &str = "did:example:other-publisher"; const DEEP_RCPT: &str = "cases/rcpt-deep"; /// The claim-conformance vector whose `measurement` body the corpus's measurement /// claim takes, about another object. @@ -312,8 +315,115 @@ mod built { Ok(Material { groups, claims, indexes, r0, r1, r1_revoked, grant, set }) } + /// The claims and the group the cases serve from `cases/rcpt/` beside the published + /// ones (§8.2): a second publisher's delivery of O7; the lawful-basis, recovery, + /// operating-point and session classes, whose bodies are claim-conformance's + /// accepted vectors retargeted to the corpus's objects and signed with its keys. + struct Extras { + other: Group, + claims: BTreeMap<&'static str, ClaimReceipt>, + /// Each case index: its file, its object, its delivery items and its claims. + indexes: Vec<(&'static str, &'static str, Vec, Vec<&'static str>)>, + } + + fn extras(c: &Corpus, m: &Material) -> Result { + let doc: Value = serde_json::from_slice(&std::fs::read(c.dir.join(MEASUREMENT_VECTOR)).map_err(fault)?).map_err(fault)?; + let vector = |name: &str| doc["vectors"].as_array().into_iter().flatten().find(|v| v["name"] == name).ok_or(format!("no vector {name}")); + let body = |name: &str, member: Option<&str>| -> Result { + let v = vector(name)?; + let text = match member { + None => v["receipt"].as_str(), + Some(k) => v["context"][k].as_str(), + } + .ok_or(format!("{name}: no receipt"))?; + Ok(ClaimReceipt::from_json(text).map_err(|r| r.to_string())?.body) + }; + let seal = |signer: u8, id: &str, subject: [u8; 32], at: u64, body: ClaimBody| { + ClaimReceipt::seal(&key(signer), id, SubjectType::Object, subject, 0, &time(at), body, None).map_err(|r| format!("a case claim: {r}")) + }; + let (o3, o5, o7) = (content_hash(object(c, "O3")?), content_hash(object(c, "O5")?), content_hash(object(c, "O7")?)); + // O7 delivered again, under a second publisher's key. + let mut b = GroupBuilder::default(); + b.add(o7, 300, "origin", Some(ObjectLabel::Class(EnergyProvenance::Estimator))); + let (receipt, objects) = b.finish(&key(OTHER), OTHER_ID, 7, 0, &time(0), &time(4), None, true).ok_or("the other publisher's group")?; + let other = Group { receipt, objects }; + let mut claims = BTreeMap::new(); + // Lawful basis: the publisher's declaration about O3, and about O7; a sink's + // decisions on a declared purpose, on an undeclared one, and against O7's. + let declaration = body("lawful_basis_declaration_accept", None)?; + let ld0 = seal(PUBLISHER, PUBLISHER_ID, o3, 200, declaration.clone())?; + let ld7 = seal(PUBLISHER, PUBLISHER_ID, o7, 201, declaration)?; + let decide = |on: [u8; 32], purpose: Option<&str>| -> Result { + let mut b = body("lawful_basis_decision_accept", None)?; + let ClaimBody::LawfulBasis(LawfulBasisClaim::Decision { declaration, purpose: p, .. }) = &mut b else { + return Err("the vector is not a decision".into()); + }; + *declaration = on; + if let Some(x) = purpose { + *p = x.into(); + } + Ok(b) + }; + claims.insert("ldd0", seal(SINK, SINK_ID, o3, 210, decide(ld0.link_hash(), None)?)?); + claims.insert("ldd1", seal(SINK, SINK_ID, o3, 211, decide(ld0.link_hash(), Some("dpv:PersonalisedAdvertising"))?)?); + claims.insert("ldd2", seal(SINK, SINK_ID, o3, 212, decide(ld7.link_hash(), None)?)?); + claims.insert("ld0", ld0); + claims.insert("ld7", ld7); + // Binding recovery: the publisher's soft binding of O3 and the relay's; records + // of a value found in O7 matching the publisher's, of another value, and naming + // the relay's. + let sb = body("binding_recovery_accept", Some("declaration"))?; + let sb3 = seal(PUBLISHER, PUBLISHER_ID, o3, 220, sb.clone())?; + let sbr = seal(RELAY, RELAY_ID, o3, 221, sb)?; + let record = |on: [u8; 32], value: Option<&[u8]>| -> Result { + let mut b = body("binding_recovery_accept", None)?; + let ClaimBody::BindingRecovery(r) = &mut b else { return Err("the vector is not a recovery".into()) }; + r.declaration = on; + r.observed = Some(o7); + if let Some(v) = value { + r.value = v.to_vec(); + } + Ok(b) + }; + claims.insert("rr0", seal(SINK, SINK_ID, o3, 230, record(sb3.link_hash(), None)?)?); + claims.insert("rr1", seal(SINK, SINK_ID, o3, 231, record(sb3.link_hash(), Some(b"another payload"))?)?); + claims.insert("rr2", seal(SINK, SINK_ID, o3, 232, record(sbr.link_hash(), None)?)?); + claims.insert("sb3", sb3); + claims.insert("sbr", sbr); + // An operating point whose figure is O5's leaf (moq-jwp:object), and one whose + // figure disagrees with it. + let point = |joules: u64| -> Result { + let mut b = body("operating_point_accept", None)?; + let ClaimBody::OperatingPoint(o) = &mut b else { return Err("the vector is not an operating point".into()) }; + o.figure.figure_profile = "moq-jwp:object".into(); + o.figure.figure_index = Some(0); + o.figure.group = Some(m.groups[5].receipt.link_hash()); + o.figure.joules_micro = joules; + Ok(b) + }; + claims.insert("op0", seal(PUBLISHER, PUBLISHER_ID, o5, 240, point(461_538)?)?); + claims.insert("op1", seal(PUBLISHER, PUBLISHER_ID, o5, 241, point(461_539)?)?); + // A session claim, whose context no index carries. + claims.insert("rs0", seal(SINK, SINK_ID, o3, 250, body("session_accept", None)?)?); + let (i3, i5, i7) = (item(m, 3, 0), item(m, 5, 0), item(m, 3, 1)); + let io = item_for(&other.receipt, &other.objects[0]); + let indexes = vec![ + ("cases/receipts/O3-lawful-basis.wai.receipts", "O3", vec![i3.clone()], vec!["ldd0", "ldd1", "ldd2"]), + ("cases/receipts/O3-binding-recovery.wai.receipts", "O3", vec![i3.clone()], vec!["rr0", "rr1", "rr2"]), + ("cases/receipts/O3-session.wai.receipts", "O3", vec![i3], vec!["rs0"]), + ("cases/receipts/O5-operating-point.wai.receipts", "O5", vec![i5], vec!["op0", "op1"]), + ("cases/receipts/O7-other-first.wai.receipts", "O7", vec![io.clone(), i7.clone()], vec![]), + ("cases/receipts/O7-publisher-first.wai.receipts", "O7", vec![i7, io.clone()], vec![]), + ("cases/receipts/O7-other-only.wai.receipts", "O7", vec![io], vec![]), + ]; + Ok(Extras { other, claims, indexes }) + } + fn item(m: &Material, g: usize, o: usize) -> IndexItem { - let (gr, ob) = (&m.groups[g].receipt, &m.groups[g].objects[o]); + item_for(&m.groups[g].receipt, &m.groups[g].objects[o]) + } + + fn item_for(gr: &GroupReceipt, ob: &ObjectReceipt) -> IndexItem { let (acquisition, evidence) = match &ob.label { Some(ObjectLabel::Class(p)) => (p.label().to_string(), wai::jwp_delivery::property_evidence(p)), _ => (String::new(), Vec::new()), @@ -429,6 +539,15 @@ mod built { out.insert("cases/receipts/O9a-256-items.wai.receipts".into(), capped(256, 256)?); out.insert("cases/receipts/O9a-257-items.wai.receipts".into(), capped(257, 0)?); out.insert("cases/receipts/O9a-257-claims.wai.receipts".into(), capped(0, 257)?); + let x = extras(c, &m)?; + out.insert(format!("{CASE_RCPT}/{}.json", hex(&x.other.receipt.link_hash())), group_file(&x.other.receipt)?); + for cl in x.claims.values() { + out.insert(format!("{CASE_RCPT}/{}.json", hex(&cl.link_hash())), claim_file(cl)); + } + for (path, id, items, names) in &x.indexes { + let names: Vec<[u8; 32]> = names.iter().map(|l| x.claims[l].link_hash()).collect(); + out.insert(path.to_string(), index_file(&content_hash(object(c, id)?), items, &names)?); + } for (k, b) in &out { c.extra.insert(k.clone(), b.clone()); } @@ -495,13 +614,14 @@ mod built { /// Where a case's receipt files are served from: the directories `rcpt` (by default /// `objects/rcpt`), each searched for `.json`; `omit` lists hashes served /// nothing; `as` serves at a hash the file at a path; `pad` serves a file padded with - /// spaces to a length. + /// spaces to a length, and `pad_all` every file. struct Served<'a> { c: &'a Corpus, dirs: Vec, omit: Vec<[u8; 32]>, swap: BTreeMap<[u8; 32], String>, pad: BTreeMap<[u8; 32], usize>, + pad_all: Option, } impl Served<'_> { @@ -519,7 +639,8 @@ mod built { for (h, n) in v["pad"].as_object().into_iter().flatten() { pad.insert(hash_of(&json!(h), "pad")?, n.as_u64().ok_or("a pad length")? as usize); } - Ok(Served { c, dirs, omit, swap, pad }) + let pad_all = v["pad_all"].as_u64().map(|n| n as usize); + Ok(Served { c, dirs, omit, swap, pad, pad_all }) } fn get(&self, h: &[u8; 32]) -> Option> { @@ -530,7 +651,7 @@ mod built { Some(p) => self.c.file(p).ok(), None => self.dirs.iter().find_map(|d| self.c.file(&format!("{d}/{}.json", hex(h))).ok()), }?; - if let Some(n) = self.pad.get(h) { + if let Some(n) = self.pad.get(h).or(self.pad_all.as_ref()) { if b.len() < *n { b.resize(*n, b' '); } @@ -801,12 +922,11 @@ mod built { let served = c.file(input["served"].as_str().ok_or("served")?)?; let (revs, anchors) = key_material(c, &input["keys"])?; let statuses: Vec = strs(&input["statuses"]).iter().map(|p| text(c, p)).collect::>()?; - let grants = match input["grants"].as_str() { - Some(p) => { - let d = hash_of(&json!(sha), "sha256")?; - read_grants_file(&d, &c.file(p)?).map_err(|e| Stop::Fault(format!("the grants file: {e}")))? - } - None => Vec::new(), + // A pin whose digest is not 64 lowercase hexadecimal digits locates no grants + // file: the pin's own check refuses it (pin_malformed). + let grants = match (input["grants"].as_str(), unhex32(sha)) { + (Some(p), Some(d)) => read_grants_file(&d, &c.file(p)?).map_err(|e| Stop::Fault(format!("the grants file: {e}")))?, + _ => Vec::new(), }; let mut sink = SinkKeyLog::new(revs, anchors).with_statuses(statuses).with_grants(grants); if let Some(origins) = input["hold_to"].as_array() { @@ -984,6 +1104,48 @@ mod built { "receipt_index_too_large")); v.push(refused("receipt_file_legacy_group", "receipt_file", json!({ "file": format!("{CASE_RCPT}/{}.json", hex(&legacy_link(c)?)), "kind": "group" }), "receipt_legacy_group")); + // §8.2: at most 64 MiB of receipt files read for one index. The walk of 64 parents + // reads 65 files: at 1 MiB each it passes the budget, at a million bytes it does not. + let deep_padded = |pad: usize| json!({ "serve": { "rcpt": [DEEP_RCPT], "pad_all": pad }, + "checks": [{ "object_cid": hex(&deep_cid(c, 64).unwrap_or_default()), "held": "none", "index": "cases/receipts/deep-64.wai.receipts" }] }); + v.push(refused("receipts_fetch_bytes_exhausted", "receipts", deep_padded(MAX_RECEIPT_FILE_BYTES), "parent_not_held")); + v.push(case("receipts_fetch_bytes_within", "receipts", deep_padded(1_000_000))); + // §8.2 step 6 for the classes the published indexes carry none of: lawful-basis + // decisions against the declaration fetched by its link, recovery records, + // operating points, and a session claim, whose context no index carries. + let x = extras(c, &m)?; + let xl = |l: &str| hex(&x.claims[l].link_hash()); + let both = json!({ "rcpt": [RCPT, CASE_RCPT] }); + let on = |id: &str, ix: &str, held: &Value| json!({ "object": id, "held": held, "index": format!("cases/receipts/{ix}.wai.receipts") }); + v.push(refused("receipts_lawful_basis", "receipts", json!({ "serve": both, "checks": [on("O3", "O3-lawful-basis", &whole)] }), + "decision_purpose_not_declared")); + v.push(refused("receipts_lawful_basis_declaration_not_served", "receipts", json!({ "serve": { "rcpt": [RCPT, CASE_RCPT], "omit": [xl("ld0")] }, + "checks": [on("O3", "O3-lawful-basis", &whole)] }), "context_not_held")); + // §8.1 first: another claim's bytes under the declaration's name. + v.push(refused("receipts_lawful_basis_declaration_renamed", "receipts", json!({ "serve": { "rcpt": [RCPT, CASE_RCPT], + "as": { xl("ld0"): format!("{CASE_RCPT}/{}.json", xl("ld7")) } }, "checks": [on("O3", "O3-lawful-basis", &whole)] }), "receipt_name_mismatch")); + let watermark = json!({ "observed_value": "00", "exact_match_algs": ["org.example.watermark.1"] }); + v.push(refused("receipts_binding_recovery", "receipts", json!({ "serve": both, "recovery": watermark, + "checks": [on("O3", "O3-binding-recovery", &whole)] }), "recovery_value_mismatch")); + // A recovery record's step 6 needs a matcher, not the object. + v.push(refused("receipts_binding_recovery_object_not_held", "receipts", json!({ "serve": both, "recovery": watermark, + "checks": [on("O3", "O3-binding-recovery", &none)] }), "recovery_signer_mismatch")); + v.push(refused("receipts_binding_recovery_no_matcher", "receipts", json!({ "serve": both, + "checks": [on("O3", "O3-binding-recovery", &whole)] }), "recovery_no_matcher")); + v.push(refused("receipts_operating_point", "receipts", json!({ "serve": both, "checks": [on("O5", "O5-operating-point", &whole)] }), + "figure_disagrees")); + v.push(refused("receipts_operating_point_not_held", "receipts", json!({ "serve": both, "checks": [on("O5", "O5-operating-point", &none)] }), + "context_not_held")); + v.push(refused("receipts_reconstruct_session", "receipts", json!({ "serve": both, "checks": [on("O3", "O3-session", &whole)] }), + "context_not_held")); + // jwp-receipts §3.2's P: of the publisher keys held for the ancestor, the + // declaration's signer's, whichever delivery came first; with none of them, the + // mismatch. + let ancestor = |ix: &str| json!({ "serve": both, "recovery": { "observed_value": "0ff0", "exact_match_algs": ["org.example.fingerprint"] }, + "checks": [on("O7", ix, &whole), chk("O3", whole.clone())] }); + v.push(case("receipts_ancestor_other_publisher_first", "receipts", ancestor("O7-other-first"))); + v.push(case("receipts_ancestor_publisher_first", "receipts", ancestor("O7-publisher-first"))); + v.push(refused("receipts_ancestor_other_publisher_only", "receipts", ancestor("O7-other-only"), "recovery_signer_mismatch")); // §7.4 beside each verdict: the signers confirmed against the origin's key log, // anchored from its location. let anchored = json!([{ "anchor": KEY_LOG }]); @@ -1034,6 +1196,7 @@ mod built { revoked_not_held_to["keys"] = json!([{ "anchor": REVOKED_LOG }]); v.push(refused("param_set_revoked_not_held_to", "param_set", revoked_not_held_to, "prior_revoked")); v.push(case("param_set_pinned_anchor", "param_set", with("keys", json!([{ "pin": KEY_LOG }])))); + v.push(refused("param_set_sha_uppercase", "param_set", with("sha256", json!(digest.to_uppercase())), "pin_malformed")); Ok(v) } @@ -1043,6 +1206,12 @@ mod built { v["delivery"][0]["property"]["group_receipt"].as_str().and_then(unhex32).ok_or("the legacy index names no group".into()) } + /// The object the deep chain's index `deep-` is about. + fn deep_cid(c: &Corpus, i: usize) -> Result<[u8; 32], String> { + let v: Value = serde_json::from_slice(&c.file(&format!("cases/receipts/deep-{i}.wai.receipts"))?).map_err(fault)?; + v["object"].as_str().and_then(unhex32).ok_or("the deep index names no object".into()) + } + /// A `key_log` case whose last step exists for `code`. fn refused_step(name: &str, input: Value, code: &'static str) -> Case { refused(name, "key_log", input, code) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs index f2bdba578..83cd7ccb6 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs @@ -22,9 +22,11 @@ //! whose step 6 needs a receipt the client does not hold not accepted. A soft-binding //! claim about another object attests that object, the ancestor, only once §3.2's //! recovery has passed; -//! - one index check fetches each name once, at most [`FETCH_BUDGET`] files, none over -//! [`MAX_RECEIPT_FILE_BYTES`], from an index of at most [`MAX_INDEX_BYTES`] listing at -//! most [`MAX_INDEX_ITEMS`] delivery items and [`MAX_INDEX_CLAIMS`] claims. +//! - one index check fetches each name once, at most [`FETCH_BUDGET`] files and +//! [`MAX_INDEX_FETCH_BYTES`] in all, none over [`MAX_RECEIPT_FILE_BYTES`], from an +//! index of at most [`MAX_INDEX_BYTES`] listing at most [`MAX_INDEX_ITEMS`] delivery +//! items and [`MAX_INDEX_CLAIMS`] claims. A declaration a claim names is fetched by +//! its link and read as §8.1 reads every file fetched by name. //! //! Nothing here performs I/O: `fetch` gives the bytes served at `rcpt/.json` beside //! the object, or `None` where nothing is served. @@ -51,39 +53,57 @@ pub const INDEX_KIND: &str = "wai-receipt-index"; /// How many parents a client fetches for one group, at most (§8.1). A group whose /// chain is further back than this stays `parent_not_held`. A client MAY hold a lower -/// bound, never a higher one. +/// bound, never a higher one. A client that checks receipts as they arrive holds its +/// chains and walks only across what it missed, so the bound is a gap, not a history. /// /// cbindgen:ignore pub const PARENT_WALK_BOUND: usize = 64; /// The most bytes a receipt file may hold (§8.1); one over it is `receipt_too_large`. +/// A file holds one receipt, a group's or a claim's, of a few kilobytes; the largest +/// claim bodies list objects or stages by the hundred and stay well inside this. /// /// cbindgen:ignore pub const MAX_RECEIPT_FILE_BYTES: usize = 1 << 20; /// The most bytes a receipt index may hold (§8.2); one over it is -/// `receipt_index_too_large`. +/// `receipt_index_too_large`. It holds the most delivery items and claims an index may +/// list, each item a property and an object receipt of under a kilobyte, with room. /// /// cbindgen:ignore pub const MAX_INDEX_BYTES: usize = 4 << 20; /// The most delivery items an index may list (§8.2); more is `receipt_index_too_large`. +/// An object is delivered under one group per track and publisher that carries it, and +/// an index lists those, not every hop's copy. /// /// cbindgen:ignore pub const MAX_INDEX_ITEMS: usize = 256; -/// The most claims an index may list (§8.2); more is `receipt_index_too_large`. +/// The most claims an index may list (§8.2); more is `receipt_index_too_large`. Claims +/// about one object chain per signer, so an index lists each signer's latest few, never +/// a log. /// /// cbindgen:ignore pub const MAX_INDEX_CLAIMS: usize = 256; /// The most receipt files a client fetches to check one index (§8.2), each name fetched /// once; past it a fetch is `receipt_fetch_budget_exhausted`. A client MAY hold a lower -/// budget, never a higher one. +/// budget, never a higher one. It covers every item and claim an index may list, a +/// declaration for each claim, and full parent walks for a few groups. /// /// cbindgen:ignore pub const FETCH_BUDGET: usize = 1024; +/// The most bytes of receipt files a client reads to check one index (§8.2), counting +/// what it read of each file, at most one byte past [`MAX_RECEIPT_FILE_BYTES`]; once +/// they are read, a fetch is `receipt_fetch_bytes_exhausted`. Without it, +/// [`FETCH_BUDGET`] files at their cap would let one index make a client read and hold +/// about a gibibyte. A client MAY hold a lower budget, never a higher one. +/// +/// cbindgen:ignore +pub const MAX_INDEX_FETCH_BYTES: usize = 64 << 20; + /// The path of the receipt file whose link hash is `h`, relative to the object (§8.1). pub fn receipt_path(h: &[u8; 32]) -> String { format!("{RECEIPT_DIR}{}.json", hex(h)) @@ -112,6 +132,9 @@ pub enum ReceiptRefusal { TooLarge, /// §8.2: the index's fetch budget is spent (`receipt_fetch_budget_exhausted`). BudgetExhausted, + /// §8.2: the bytes a client reads for one index are spent + /// (`receipt_fetch_bytes_exhausted`). + BytesExhausted, /// §8.1: the file's link hash is not its name (`receipt_name_mismatch`). NameMismatch, /// §8.1: a group receipt in the legacy payload, whose link hash does not cover its @@ -144,6 +167,7 @@ impl ReceiptRefusal { ReceiptRefusal::NotServed => "receipt_not_served", ReceiptRefusal::TooLarge => "receipt_too_large", ReceiptRefusal::BudgetExhausted => "receipt_fetch_budget_exhausted", + ReceiptRefusal::BytesExhausted => "receipt_fetch_bytes_exhausted", ReceiptRefusal::NameMismatch => "receipt_name_mismatch", ReceiptRefusal::LegacyGroup => "receipt_legacy_group", ReceiptRefusal::Delivery(e) => e.reason.label(), @@ -228,18 +252,28 @@ pub fn claim_file(c: &ClaimReceipt) -> Vec { /// The receipt files one index check fetches (§8.1, §8.2): each name fetched once and /// what was served remembered, at most a budget of fetches (never above -/// [`FETCH_BUDGET`]), and a file over [`MAX_RECEIPT_FILE_BYTES`] refused. +/// [`FETCH_BUDGET`]) and of bytes ([`MAX_INDEX_FETCH_BYTES`]), and a file over +/// [`MAX_RECEIPT_FILE_BYTES`] refused. pub struct ReceiptFetcher<'f> { fetch: &'f mut dyn FnMut(&[u8; 32]) -> Option>, - memo: BTreeMap<[u8; 32], Option>>, + memo: BTreeMap<[u8; 32], Served>, fetched: usize, budget: usize, + bytes: usize, +} + +/// What one name gave, kept for the rest of the index's check. +enum Served { + Bytes(Vec), + Nothing, + /// The file that took the index past its bytes. + PastBytes, } impl<'f> ReceiptFetcher<'f> { /// A fetcher over `fetch` with `budget` fetches, at most [`FETCH_BUDGET`]. pub fn new(fetch: &'f mut dyn FnMut(&[u8; 32]) -> Option>, budget: usize) -> Self { - ReceiptFetcher { fetch, memo: BTreeMap::new(), fetched: 0, budget: budget.min(FETCH_BUDGET) } + ReceiptFetcher { fetch, memo: BTreeMap::new(), fetched: 0, budget: budget.min(FETCH_BUDGET), bytes: 0 } } /// How many files were fetched (each name once). @@ -248,20 +282,33 @@ impl<'f> ReceiptFetcher<'f> { } /// The file named `h`: `receipt_not_served`, `receipt_too_large`, or, past the - /// budget, `receipt_fetch_budget_exhausted`. + /// budget, `receipt_fetch_budget_exhausted`, and past the bytes, which are counted + /// as a client reads them (at most one byte past the file cap of each), + /// `receipt_fetch_bytes_exhausted`: the file that passes them is refused, and no + /// further file is fetched. pub fn get(&mut self, h: &[u8; 32]) -> Result, ReceiptRefusal> { if !self.memo.contains_key(h) { if self.fetched >= self.budget { return Err(ReceiptRefusal::BudgetExhausted); } + if self.bytes >= MAX_INDEX_FETCH_BYTES { + return Err(ReceiptRefusal::BytesExhausted); + } self.fetched += 1; - let b = (self.fetch)(h); - self.memo.insert(*h, b); + let served = match (self.fetch)(h) { + None => Served::Nothing, + Some(b) => { + self.bytes += b.len().min(MAX_RECEIPT_FILE_BYTES + 1); + if self.bytes > MAX_INDEX_FETCH_BYTES { Served::PastBytes } else { Served::Bytes(b) } + } + }; + self.memo.insert(*h, served); } match &self.memo[h] { - None => Err(ReceiptRefusal::NotServed), - Some(b) if b.len() > MAX_RECEIPT_FILE_BYTES => Err(ReceiptRefusal::TooLarge), - Some(b) => Ok(b.clone()), + Served::Nothing => Err(ReceiptRefusal::NotServed), + Served::PastBytes => Err(ReceiptRefusal::BytesExhausted), + Served::Bytes(b) if b.len() > MAX_RECEIPT_FILE_BYTES => Err(ReceiptRefusal::TooLarge), + Served::Bytes(b) => Ok(b.clone()), } } } @@ -542,8 +589,10 @@ pub struct ClaimVerdict { /// What a client brings to jwp-receipts §3.2's recovery through a soft binding: the /// publisher keys of delivery receipts it has verified under §3 over the objects' bytes, /// each with the content hash it is for; the value the registered algorithm computed from -/// the object in hand; and its matchers. Without it, or without a publisher key for the -/// soft binding's subject, no recovery is run. +/// the object in hand; and its matchers, which step 6 also uses for a `binding-recovery` +/// record (§2.6.3), whether or not the object is held. Without it, or without the +/// object in hand or a publisher key for the soft binding's subject, no recovery +/// through a soft binding is run. pub struct Recovery<'a> { pub publishers: &'a [([u8; 32], [u8; 32])], pub observed_value: &'a [u8], @@ -629,18 +678,17 @@ fn step6( } } -/// A declaration a claim names, fetched as `rcpt/.json`: not served is -/// `context_not_held`; one that is not a claim is `context_invalid`, as §3.1 step 6 -/// reports a declaration that does not verify. +/// A declaration a claim names, fetched as `rcpt/.json` and read as §8.1 reads +/// every file fetched by name ([`read_claim_file`]: §3.1 steps 1–2, then the name, +/// `receipt_name_mismatch`). Not served is `context_not_held`. Its steps 3–4 are step +/// 6's, which reports a declaration that does not verify as `context_invalid`. fn declaration_of(link: &[u8; 32], fetch: &mut ReceiptFetcher<'_>) -> Result { let bytes = match fetch.get(link) { Ok(b) => b, Err(ReceiptRefusal::NotServed) => return Err(ReceiptRefusal::ContextNotHeld), Err(r) => return Err(r), }; - let invalid = ReceiptRefusal::Claim(Rejection { step: 6, reason: ClaimReason::ContextInvalid }); - let text = std::str::from_utf8(&bytes).map_err(|_| invalid.clone())?; - ClaimReceipt::from_json(text).map_err(|_| invalid) + read_claim_file(link, &bytes) } /// §8.2's check of one claim the index of the object `cid` lists by its link hash @@ -650,8 +698,10 @@ fn declaration_of(link: &[u8; 32], fetch: &mut ReceiptFetcher<'_>) -> Result.json`, read /// as a claim of that name and verified (`parent_not_held` otherwise), and links to it @@ -663,6 +713,7 @@ fn declaration_of(link: &[u8; 32], fetch: &mut ReceiptFetcher<'_>) -> Result, recovery: Option<&Recovery<'_>>, @@ -670,7 +721,15 @@ pub fn check_claim( let bytes = fetch.get(name)?; let claim = read_claim_file(name, &bytes)?; let groups: Vec<[u8; 32]> = passed.iter().map(|v| v.group.link_hash()).collect(); - let publisher = |subject: &[u8; 32]| recovery.and_then(|r| r.publishers.iter().find(|(h, _)| h == subject).map(|(_, k)| *k)); + // §3.2's P for the subject: of the publisher keys held for it, the declaration's own + // signer's where one is held, so an earlier delivery under another key cannot block a + // true recovery; else the first, whose recovery reports the mismatch. + let publisher = |subject: &[u8; 32]| { + let r = recovery.filter(|_| matches!(held, ObjectHeld::Object(_)))?; + let mut keys = r.publishers.iter().filter(|(h, _)| h == subject).map(|(_, k)| *k); + let first = keys.next()?; + Some(if first == claim.signer_pubkey { first } else { keys.find(|k| *k == claim.signer_pubkey).unwrap_or(first) }) + }; let attests = match claim.subject_type { SubjectType::Object if claim.subject == *cid => Attests::Object, SubjectType::Receipt if groups.contains(&claim.subject) => Attests::Receipt, @@ -763,14 +822,14 @@ pub fn check_index( }) .collect(); let passed: Vec = delivery.iter().filter_map(|d| d.as_ref().ok().cloned()).collect(); - // A recovery runs only for an object the client holds: the value it compares is - // computed from it. - let recovery = recovery.filter(|_| matches!(held, ObjectHeld::Object(_))); + // A recovery through a soft binding runs only for an object the client holds (the + // value it compares is computed from it): check_claim gates it on `held`. The + // matchers serve step 6 of a recovery record whether or not the object is held. let claims = ix .claims .iter() .map(|c| match c { - Ok(name) => check_claim(cid, &passed, name, &mut fetcher, recovery), + Ok(name) => check_claim(cid, &passed, held, name, &mut fetcher, recovery), Err(e) => Err(e.clone()), }) .collect(); diff --git a/wai/wai-rs/src/http_binding/tests_receipts.rs b/wai/wai-rs/src/http_binding/tests_receipts.rs index 078fde765..aa73d9791 100644 --- a/wai/wai-rs/src/http_binding/tests_receipts.rs +++ b/wai/wai-rs/src/http_binding/tests_receipts.rs @@ -119,7 +119,7 @@ fn claim_of( ) -> Result { let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); let mut f = ReceiptFetcher::new(&mut fetch, FETCH_BUDGET); - check_claim(cid, passed, name, &mut f, None) + check_claim(cid, passed, &ObjectHeld::Nothing, name, &mut f, None) } /// B's retrofit: §3 step 4 compares with the hash established for the object, recomputed @@ -624,6 +624,35 @@ fn one_index_fetches_each_name_once_within_its_bounds() { assert!(ReceiptIndex::from_bytes(&index_file(&cid, &many[1..], &claims[1..]).unwrap()).is_ok()); } +/// §8.2: the bytes read for one index are bounded too, counting at most a byte past the +/// file cap of each file: the file that passes them is refused, and none is fetched after. +#[test] +fn the_bytes_read_for_one_index_are_bounded() { + let count = std::cell::Cell::new(0usize); + let mut fetch = |h: &[u8; 32]| { + count.set(count.get() + 1); + Some(vec![b' '; if h[0] == 0xff { 10 * MAX_RECEIPT_FILE_BYTES } else { MAX_RECEIPT_FILE_BYTES }]) + }; + let mut f = ReceiptFetcher::new(&mut fetch, FETCH_BUDGET); + let full = MAX_INDEX_FETCH_BYTES / MAX_RECEIPT_FILE_BYTES; + for i in 0..full { + assert!(f.get(&[i as u8; 32]).is_ok(), "file {i}"); + } + assert_eq!(f.get(&[0xfe; 32]), Err(ReceiptRefusal::BytesExhausted)); + assert_eq!(count.get(), full, "nothing is fetched once the bytes are read"); + assert!(f.get(&[0; 32]).is_ok(), "a name already fetched is kept"); + // An oversized file counts what a client reads of it, one byte past the cap, and + // passing the budget with it refuses that file, remembered so. + let mut fetch = |h: &[u8; 32]| Some(vec![b' '; if h[0] == 0xff { 10 * MAX_RECEIPT_FILE_BYTES } else { MAX_RECEIPT_FILE_BYTES - 1 }]); + let mut f = ReceiptFetcher::new(&mut fetch, FETCH_BUDGET); + for i in 0..full { + assert!(f.get(&[i as u8; 32]).is_ok()); + } + assert_eq!(f.get(&[0xff; 32]), Err(ReceiptRefusal::BytesExhausted)); + assert_eq!(f.get(&[0xff; 32]), Err(ReceiptRefusal::BytesExhausted)); + assert_eq!(ReceiptRefusal::BytesExhausted.code(), "receipt_fetch_bytes_exhausted"); +} + #[cfg(feature = "key_discovery")] mod keys { use super::super::keys::*; -- 2.54.0 (Apple Git-157) From 4f49593b73d5fd36ee2d71f92c70d2f9a1a84017 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 8 Oct 2026 04:11:01 -0400 Subject: [PATCH 10/16] wai http receipts: anchor an origin over TLS only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 1 of the review of 30cefd1b. `--key-origin` anchored an https origin over plain HTTP, an origin with no port could not be anchored, and `--connect` let any server stand in for the origin with the output unchanged. - The CLI retrieves the key log over TLS itself (feature `cli_tls`, which only the binary uses): rustls with the ring provider already in the graph, and rustls-platform-verifier for the operating system's trust store. TLS to the origin's host and port (443 by default), SNI and certificate validation for that host, `Cache-Control: no-cache`, no redirect followed; the Retrieval built only from that request and its response. A response's end must be authenticated (close_notify) or framed (Content-Length, chunked). - Without `cli_tls`, the CLI refuses to anchor (key_log_not_from_origin, "no TLS") and connects to nothing. - `--connect` is gone; `--test-plaintext-connect` is a test aid whose anchor is labelled `test-plaintext` (relied_on false, "anchors nothing a verifier may rely on"), never `fetched`, and the command exits 1. - Tests: the binary's own TLS tests against servers under a test CA (tests/fixtures/tls/, test keys): an anchor from a TLS retrieval with the SNI, Host and no-cache seen by the server; another host's certificate, a plaintext server (never sent a plaintext request), a redirect and an unauthenticated end each anchor nothing; the origin parser. cli_http: the test anchor labelled as such, and a plaintext server at the origin's address never asked in plaintext, with and without cli_tls (CI runs both). - §8.4: a retrieval is an https retrieval the verifier made itself. - Licences: wai-rs gains deny.toml (wai-pack's allow-list, over the binary's features on its host targets), checked in the wai-pack licences job; wai-pack's lock is unchanged by this. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 11 +- .github/workflows/wai-pack.yml | 6 + wai/CHANGELOG.md | 8 +- wai/extensions/http-delivery.md | 10 +- wai/wai-rs/Cargo.lock | 201 +++++++ wai/wai-rs/Cargo.toml | 11 + wai/wai-rs/deny.toml | 43 ++ wai/wai-rs/src/bin/wai_cli/http.rs | 523 ++++++++++++++++-- wai/wai-rs/tests/cli_http.rs | 66 ++- wai/wai-rs/tests/fixtures/tls/ca.cert.der | Bin 0 -> 409 bytes wai/wai-rs/tests/fixtures/tls/origin.cert.der | Bin 0 -> 408 bytes wai/wai-rs/tests/fixtures/tls/origin.key.der | Bin 0 -> 138 bytes wai/wai-rs/tests/fixtures/tls/other.cert.der | Bin 0 -> 407 bytes wai/wai-rs/tests/fixtures/tls/other.key.der | Bin 0 -> 138 bytes 14 files changed, 816 insertions(+), 63 deletions(-) create mode 100644 wai/wai-rs/deny.toml create mode 100644 wai/wai-rs/tests/fixtures/tls/ca.cert.der create mode 100644 wai/wai-rs/tests/fixtures/tls/origin.cert.der create mode 100644 wai/wai-rs/tests/fixtures/tls/origin.key.der create mode 100644 wai/wai-rs/tests/fixtures/tls/other.cert.der create mode 100644 wai/wai-rs/tests/fixtures/tls/other.key.der diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 79cb2b365..9da04823e 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -282,13 +282,20 @@ jobs: # the byte-range procedure whose record states energy as not measured; and `hls`, # `mpd`, `seal-playlist` and `check-playlist`, whose receipts need video_manifest; # and `receipts`, an object's receipt index checked over HTTP with its signers - # confirmed under a pinned key log, which needs key_discovery. + # confirmed under a key log, which needs key_discovery: the log anchored over TLS + # (cli_tls; the binary's own tests run its TLS client against servers under a test + # CA, tests/fixtures/tls/), never over plaintext, and a plaintext test anchor + # labelled as one. The second run builds without cli_tls: the CLI then refuses to + # anchor and connects to nothing. # Cargo.toml declares it with required-features = ["http"]. tests/dash_read_bounds.rs # holds Appendix I's reader to the document's size under a counting allocator, and an # element's attributes to linear CPU time; a binary of its own, one test. - name: The wai http commands working-directory: wai/wai-rs - run: cargo test --no-default-features --features http,video_manifest,key_discovery --test cli_http --test dash_read_bounds + shell: bash + run: | + cargo test --no-default-features --features http,video_manifest,key_discovery,cli_tls --bin wai --test cli_http --test dash_read_bounds + cargo test --no-default-features --features http,video_manifest,key_discovery --test cli_http -- receipts_never_anchors_over_plaintext # tests/staged_moq.rs runs the MoQ trace of staged-conformance/moq/ (moq-streaming-format # §4.1) with signed receipts: a chain for each track, a receipt held for its parent and # admitted when it arrives, the relay's per-hop delivery timeout, dropped objects checked diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index 2fc67964a..3bebbdf3f 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -236,3 +236,9 @@ jobs: fi cargo tree --locked -e normal,build --target all -i cbindgen | grep -q '^cbindgen v' || { echo "::error::cbindgen not found as wai's build dependency"; exit 1; } echo "cbindgen is a build dependency only" + # The `wai` binary's HTTP commands with their TLS client (the cli_tls feature: + # rustls and the operating system's trust store), under wai-rs's own deny.toml: the + # same allow-list, over the binary's features on the hosts it is built for. + - name: cargo deny check licenses (the wai binary's TLS client) + working-directory: wai/wai-rs + run: cargo deny --locked check licenses diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 491d62bf6..c24fc73e5 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -575,7 +575,13 @@ wire format may change and such changes are recorded here. `ReceiptChains::verify_admitted_object`, which the existing checks call; `energy_figure::HeldObject`, an object receipt's figure for step 6; `wai http receipts`, which fetches under a deadline and the caps above, follows no redirect, - and with `--key-origin` retrieves the key log from the origin itself. A group or + and with `--key-origin` retrieves the key log from the origin itself over TLS + (the `cli_tls` feature: a TLS client validating against the operating system's + trust store; SNI and the certificate check name the origin's host, port 443 by + default); a build + without it refuses to anchor rather than fetch the log over plaintext, and the + test-only `--test-plaintext-connect` labels what it fetches `test-plaintext`, as + anchoring nothing a verifier may rely on. A group or object receipt whose `kind` is not a string is `wrong_kind` (jwp-receipts §3 step 1), as the independent verifier already read it. - The corpus serves receipts, indexes, a key log, a status, a grants file and a set; diff --git a/wai/extensions/http-delivery.md b/wai/extensions/http-delivery.md index fc5c656b5..bb0271634 100644 --- a/wai/extensions/http-delivery.md +++ b/wai/extensions/http-delivery.md @@ -555,9 +555,13 @@ All are `application/json`. A verifier **anchors** an origin ([jwp-receipts](jwp §7.3) only from a key log retrieved from the origin's key-log location — an `https` origin followed by `/.well-known/wai-keys` — answered `200` by that same URL: it does not follow a redirect, asks with `Cache-Control: no-cache`, and a log that reached it any -other way anchors nothing (`key_log_not_from_origin`). These are facts of the request -the verifier sent and the response it received, so it anchors only from a retrieval it -made itself; a statement that a log was so retrieved anchors nothing. Later revisions MAY come through +other way anchors nothing (`key_log_not_from_origin`). A retrieval is an `https` +retrieval the verifier made itself: TLS to the origin's host and port (443 when the +origin names none), the server's certificate validated for that host against the trust +anchors the verifier holds, the request and the `200` exchanged on that connection, and +the response's end authenticated by TLS or by its own framing. These are facts of the +request it sent and the response it received, so a log retrieved over plaintext, or one +another party states was so retrieved, anchors nothing. Later revisions MAY come through any cache, a mirror, or the origin: such a log is read as §7.3 reads one, its revisions are held and each is checked against its parent by §7.4's head walk, and it never moves the anchor. A copy whose latest revision is a trusted revision older than the anchor is diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 68a8339fe..926175eca 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -722,6 +722,16 @@ dependencies = [ "cc", ] +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "console_log" version = "1.1.0" @@ -763,6 +773,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1821,6 +1841,55 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -2248,6 +2317,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "opus" version = "0.3.1" @@ -3038,6 +3113,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3047,6 +3134,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" @@ -3064,12 +3178,30 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scan_fmt" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b53b0a5db882a8e2fdaae0a43f7b39e7e9082389e978398bdf223a55b581248" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.9.0" @@ -3108,6 +3240,29 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -3294,6 +3449,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -3303,6 +3468,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -3889,6 +4060,8 @@ dependencies = [ "ort", "pkg-config", "rav1e 0.8.1", + "rustls", + "rustls-platform-verifier", "serde", "serde_json", "sha2 0.10.9", @@ -3907,6 +4080,16 @@ dependencies = [ "serde_json", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3997,6 +4180,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -4022,6 +4214,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 4959ca69a..d4d1255f9 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -65,6 +65,13 @@ byteorder = { version = "1", optional = true } # right libonnxruntime build at compile time; no manual brew install. ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "download-binaries", "ndarray", "tls-rustls"], optional = true } ndarray = { version = "0.17", optional = true } +# ---- the `wai` CLI's TLS client (optional, behind the `cli_tls` feature) ---- +# `wai http receipts --key-origin` retrieves an origin's key log over HTTPS itself +# (http-delivery §8.4), the origin's certificate validated against the operating system's +# trust store. The library never opens a connection: only the binary uses these, and no +# library or wasm32 build enables the feature. ring is the provider already in the graph. +rustls = { version = "0.23", default-features = false, features = ["std", "ring", "tls12"], optional = true } +rustls-platform-verifier = { version = "0.7", optional = true } zstd-safe = { version = "7", optional = true, features = ["std"] } # for bmshj2018 latent unpack (std → WriteBuf for Vec) # SHA-256 is WAI's digest for parameter-set identity, component identity and decoded-output # conformance digests (SPEC §3.1, §7 *Digests*, §7.1). Pure Rust, so it is always on: the @@ -216,6 +223,10 @@ receipt_claims = ["provenance", "oer", "jwp_hash", "dep:curve25519-dalek"] # modules (§7.9). Adds no dependency: it signs and hashes with what `receipt_claims` # already brings, and a module digest is the SHA-256 `oer` already brings. key_discovery = ["receipt_claims"] +# The `wai` CLI's TLS client for anchoring an origin (`wai http receipts --key-origin`, +# http-delivery §8.4): rustls with the operating system's trust store. Built into the +# binary only; without it the CLI refuses to anchor rather than fetch over plaintext. +cli_tls = ["http", "key_discovery", "dep:rustls", "dep:rustls-platform-verifier"] # Emit a REAL C2PA manifest (not just the assertion shapes). Off by default: # the SDK is a large dependency chain and most WAI consumers only need the # binding types, which `provenance` already gives them. diff --git a/wai/wai-rs/deny.toml b/wai/wai-rs/deny.toml new file mode 100644 index 000000000..b931fed7a --- /dev/null +++ b/wai/wai-rs/deny.toml @@ -0,0 +1,43 @@ +# The licences the `wai` binary's HTTP commands may carry with their TLS client (the +# `cli_tls` feature: rustls and the operating system's trust store), checked in CI beside +# wai-pack's (.github/workflows/wai-pack.yml, job `licences`), under the same allow-list. +# +# The graph is the binary's HTTP features on the hosts it is built for. The library's +# other features reach the check through wai-pack's graph. rustls-platform-verifier's +# wasm32 and Android dependencies are not in it: no wasm32 or Android build enables +# `cli_tls`, and on wasm32 the library opens no connection at all. + +[graph] +no-default-features = true +features = ["http", "video_manifest", "key_discovery", "cli_tls"] +targets = [ + "aarch64-apple-darwin", + "x86_64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu", + "x86_64-pc-windows-msvc", +] + +[licenses] +allow = [ + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "MIT", + "MIT-0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "CC0-1.0", + "Unicode-3.0", + "Unlicense", +] +confidence-threshold = 0.9 +unused-allowed-license = "allow" + +# wai's build script generates its C header with cbindgen, a build-time tool: it is not +# linked into the binary, and its output is not covered by its licence. As in wai-pack's +# deny.toml, the exception names the version reviewed. +[[licenses.exceptions]] +crate = "cbindgen@0.29.4" +allow = ["MPL-2.0"] diff --git a/wai/wai-rs/src/bin/wai_cli/http.rs b/wai/wai-rs/src/bin/wai_cli/http.rs index eec3217ab..4ef2151c0 100644 --- a/wai/wai-rs/src/bin/wai_cli/http.rs +++ b/wai/wai-rs/src/bin/wai_cli/http.rs @@ -47,16 +47,23 @@ //! the corpus tool (as `wai_http_vectors`) //! wai http receipts --url http://HOST:PORT/PATH/.wai[#frag] [--advertise CAP]… //! [--held fetch|none] [--join] [--max-walk N] [--fetch-budget N] -//! [--deadline-ms MS] [--key-origin ORIGIN [--pin-key-log FILE | -//! --connect HOST:PORT]] +//! [--deadline-ms MS] [--key-origin https://HOST[:PORT] +//! [--pin-key-log FILE | --test-plaintext-connect HOST:PORT]] //! fetch the object by §7 (unless --held none), then its receipt index beside it and //! the receipt files it locates, walking parents by their links, and print what each //! delivery item and claim established (§8.1–§8.3): its evidence, how the content //! hash was established, the property's reading, and, with a key log pinned or //! anchored from the origin (§8.4), each signer's confirmation (§7.4 of -//! jwp-receipts). Every exchange is bounded by one deadline and each body by its cap. -//! Exit status 1 when the index, an item or the anchoring is refused (a build with -//! `receipt_claims`; confirmation needs `key_discovery`) +//! jwp-receipts). The origin's key log is retrieved over HTTPS by this command: TLS +//! to the origin's host (port 443 by default), its certificate validated for that +//! host against the operating system's trust store, no redirect followed (a build +//! with `cli_tls`; one without refuses to anchor, never fetching it over plaintext). +//! --test-plaintext-connect is a test aid: the log fetched over plain HTTP from +//! that address, reported with the anchor path `test-plaintext` as anchoring +//! nothing a verifier may rely on, and the command exits 1. Every exchange is +//! bounded by one deadline and each body by its cap. Exit status 1 when the index, +//! an item or the anchoring is refused (a build with `receipt_claims`; confirmation +//! needs `key_discovery`) //! wai http bench --url http://HOST:PORT/PATH[#frag] --runs N --advertise CAP… --out F //! [--refinement-deadline-ms MS] //! fetch an object N times by §7 over plain HTTP and write a measurement record: @@ -101,7 +108,7 @@ fn positional(args: &[String], flags: &[&str]) -> Vec { } fn usage() -> String { - "usage: wai http publish … --out \n wai http codecs …\n wai http origin --root [--port P] [--delay-range FIRST-LAST:MS]\n wai http hls --objects --out [--master NAME]\n wai http mpd --objects --out [--period-id ID] [--set-id N]\n wai http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]\n wai http check-playlist --receipt [--parent ] [--classical ]\n wai http vectors gen|verify|diff …\n wai http receipts --url URL [--advertise CAP]… [--held fetch|none] [--join] [--max-walk N] [--fetch-budget N] [--deadline-ms MS] [--key-origin ORIGIN [--pin-key-log FILE | --connect HOST:PORT]]\n wai http bench --url URL --runs N --advertise CAP [--advertise CAP]… --out F".into() + "usage: wai http publish … --out \n wai http codecs …\n wai http origin --root [--port P] [--delay-range FIRST-LAST:MS]\n wai http hls --objects --out [--master NAME]\n wai http mpd --objects --out [--period-id ID] [--set-id N]\n wai http seal-playlist --key [--signer-id ID] [--content-id ID] [--parent ] [--out ]\n wai http check-playlist --receipt [--parent ] [--classical ]\n wai http vectors gen|verify|diff …\n wai http receipts --url URL [--advertise CAP]… [--held fetch|none] [--join] [--max-walk N] [--fetch-budget N] [--deadline-ms MS] [--key-origin https://HOST[:PORT] [--pin-key-log FILE | --test-plaintext-connect HOST:PORT]]\n wai http bench --url URL --runs N --advertise CAP [--advertise CAP]… --out F".into() } pub fn main(args: &[String]) -> Result { @@ -861,35 +868,75 @@ fn bench(args: &[String]) -> Result<(), String> { // ---------------------------------------------------------------- receipts (§8) -/// One bounded plain-HTTP exchange: connect to `addr`, send `GET ` with `Host: -/// host` and `headers`, never follow a redirect, give up at `deadline`, and keep at most -/// `max_body + 1` bytes of the body — one past the cap, so a caller that refuses a body -/// over its cap sees one, while a server that never ends its body costs no more than that. -/// The status, the body, and whether the server sent more than was kept. +/// A plain-HTTP `GET` of `path` from `addr` with `Host: host`, every step before +/// `deadline`, keeping at most `max_body` bytes of the body (one more marks it longer), +/// and following no redirect. The head may be at most 64 KiB. #[cfg(feature = "receipt_claims")] fn get_bounded(addr: &str, host: &str, path: &str, headers: &[(&str, &str)], deadline: Instant, max_body: usize) -> Result<(ResponseMeta, Vec), String> { use std::net::ToSocketAddrs; - let left = |d: Instant| d.checked_duration_since(Instant::now()).filter(|l| !l.is_zero()).ok_or("the deadline passed".to_string()); let sock = addr.to_socket_addrs().map_err(|e| format!("{addr}: {e}"))?.next().ok_or(format!("{addr}: no address"))?; let mut s = TcpStream::connect_timeout(&sock, left(deadline)?).map_err(|e| format!("{addr}: {e}"))?; + s.set_write_timeout(Some(left(deadline)?)).map_err(|e| e.to_string())?; + let req = request(path, host, headers); + exchange(&mut s, &mut |s: &mut TcpStream, d| s.set_read_timeout(Some(d)), req.as_bytes(), deadline, max_body, false) +} + +/// What is left before `deadline`, or that it passed. +#[cfg(feature = "receipt_claims")] +fn left(deadline: Instant) -> Result { + deadline.checked_duration_since(Instant::now()).filter(|l| !l.is_zero()).ok_or("the deadline passed".to_string()) +} + +/// A `GET` request's bytes, asking for the body as it is stored and the connection closed. +#[cfg(feature = "receipt_claims")] +fn request(path: &str, host: &str, headers: &[(&str, &str)]) -> String { let mut req = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nAccept-Encoding: identity\r\n"); for (k, v) in headers { req.push_str(&format!("{k}: {v}\r\n")); } req.push_str("\r\n"); - s.set_write_timeout(Some(left(deadline)?)).map_err(|e| e.to_string())?; - s.write_all(req.as_bytes()).map_err(|e| e.to_string())?; + req +} + +/// One exchange on `s`: send `req`, then read the response before `deadline`, `timeout` +/// setting the read timeout of the transport under `s`, keeping at most `max_body` bytes +/// of the body. A chunked body is decoded; a body framed by `Content-Length` that ends +/// short is refused. Over TLS (`tls`), the end of a response is trusted only when the +/// peer closed with `close_notify` or the body's own framing ended it: a connection cut +/// before a framed body ends, or an unframed body without `close_notify`, is refused, so +/// no truncation passes for a whole response. +#[cfg(feature = "receipt_claims")] +fn exchange( + s: &mut S, + timeout: &mut dyn FnMut(&mut S, Duration) -> std::io::Result<()>, + req: &[u8], + deadline: Instant, + max_body: usize, + tls: bool, +) -> Result<(ResponseMeta, Vec), String> { + timeout(s, left(deadline)?).map_err(|e| e.to_string())?; + let io = |e: std::io::Error| match e.kind() { + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => "the deadline passed".to_string(), + _ => e.to_string(), + }; + s.write_all(req).map_err(io)?; + s.flush().map_err(io)?; let mut raw = Vec::new(); let mut chunk = [0u8; 64 * 1024]; let mut head_end: Option = None; + let mut capped = false; + let mut clean_end = true; loop { - s.set_read_timeout(Some(left(deadline)?)).map_err(|e| e.to_string())?; + timeout(s, left(deadline)?).map_err(|e| e.to_string())?; match s.read(&mut chunk) { Ok(0) => break, Ok(n) => raw.extend_from_slice(&chunk[..n]), - Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => return Err("the deadline passed".into()), - Err(e) => return Err(e.to_string()), + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { + clean_end = false; + break; + } + Err(e) => return Err(io(e)), } if head_end.is_none() { head_end = raw.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4); @@ -901,6 +948,7 @@ fn get_bounded(addr: &str, host: &str, path: &str, headers: &[(&str, &str)], dea && raw.len() - e > max_body { raw.truncate(e + max_body + 1); + capped = true; break; } } @@ -909,16 +957,52 @@ fn get_bounded(addr: &str, host: &str, path: &str, headers: &[(&str, &str)], dea let mut lines = head.lines(); let status: u16 = lines.next().and_then(|l| l.split_whitespace().nth(1)).and_then(|s| s.parse().ok()).ok_or("no status")?; let mut meta = ResponseMeta { status, ..Default::default() }; + let (mut length, mut chunked) = (None::, false); for l in lines { if let Some((k, v)) = l.split_once(':') { match k.trim().to_ascii_lowercase().as_str() { "content-range" => meta.content_range = Some(v.trim().to_string()), "content-encoding" => meta.content_encoding = Some(v.trim().to_string()), + "content-length" => length = Some(v.trim().parse().map_err(|_| "a Content-Length that is not a number")?), + "transfer-encoding" => chunked = v.split(',').any(|t| t.trim().eq_ignore_ascii_case("chunked")), _ => {} } } } - Ok((meta, raw[end..].to_vec())) + let body = raw[end..].to_vec(); + if capped { + return Ok((meta, body)); + } + if chunked { + return dechunk(&body).map(|b| (meta, b)).ok_or_else(|| "a chunked body that does not end".to_string()); + } + match length { + Some(n) if body.len() < n => Err("the body ended before its Content-Length".into()), + Some(n) => Ok((meta, body[..n].to_vec())), + None if tls && !clean_end => Err("the response's end is not authenticated: the connection closed without close_notify".into()), + None => Ok((meta, body)), + } +} + +/// A chunked body (RFC 9112 §7.1) decoded, or `None` when it does not end with its last +/// chunk. +#[cfg(feature = "receipt_claims")] +fn dechunk(mut b: &[u8]) -> Option> { + let mut out = Vec::new(); + loop { + let line = b.windows(2).position(|w| w == b"\r\n")?; + let size = std::str::from_utf8(&b[..line]).ok()?.split(';').next()?.trim(); + let n = usize::from_str_radix(size, 16).ok()?; + b = &b[line + 2..]; + if n == 0 { + return Some(out); + } + if b.len() < n + 2 || &b[n..n + 2] != b"\r\n" { + return None; + } + out.extend_from_slice(&b[..n]); + b = &b[n + 2..]; + } } /// The host and path of a plain-HTTP URL, and the object's cid (from `#cid=` or the @@ -947,10 +1031,9 @@ const MAX_OBJECT_RESPONSE: usize = 1 << 30; /// deadline (`--deadline-ms`, 60 s by default) and each body by its cap. Every receipt file /// it fetches is checked as §8.1 states, by `http_binding::receipts`: no other path reads /// one. With `--key-origin`, the origin's key log is anchored as §8.4 states before any -/// signer is confirmed: fetched from its location with `Cache-Control: no-cache`, no -/// redirect followed, the retrieval built from the response. This tool speaks no TLS: -/// `--connect HOST:PORT` names the plain-HTTP address that answers for the origin, which -/// only a test uses. +/// signer is confirmed ([`keys`]): retrieved over HTTPS by this command, with +/// `Cache-Control: no-cache`, no redirect followed, the retrieval built from that request +/// and its response. #[cfg(feature = "receipt_claims")] fn receipts_cmd(args: &[String]) -> Result { use wai::http_binding::receipts::{check_index, Limits, ObjectHeld, ReceiptIndex, FETCH_BUDGET, MAX_INDEX_BYTES, MAX_RECEIPT_FILE_BYTES, PARENT_WALK_BOUND, RECEIPT_DIR}; @@ -1012,14 +1095,19 @@ fn receipts_cmd(args: &[String]) -> Result { fetch_budget: opt(args, "--fetch-budget").map_or(Ok(FETCH_BUDGET), str::parse).map_err(|_| "--fetch-budget")?, }; let mut all = true; - let keys = match keys(args, deadline, &mut report) { + let keys = match keys(args, deadline, &mut report)? { Ok(k) => k, - Err(e) => { + Err(r) => { all = false; - report.insert("anchor".into(), json!({ "refused": e })); + report.insert("anchor".into(), json!({ "refused": r.code, "why": r.why })); None } }; + // A test-only plaintext anchor confirms this run's signers and nothing more: the + // command does not succeed on it. + if report.get("anchor").and_then(|a| a.get("path")).and_then(|p| p.as_str()) == Some("test-plaintext") { + all = false; + } let items = ReceiptIndex::from_bytes(&index).map(|i| i.delivery).unwrap_or_default(); let checked = check_index(&cid, &index, &held, &mut fetch, &mut chains, 0, &limits, None); if late.get() { @@ -1074,52 +1162,225 @@ fn receipts_cmd(args: &[String]) -> Result { Ok(all) } -/// The key log the signers are confirmed against: pinned from `--pin-key-log FILE` for -/// `--key-origin`, or, with `--key-origin` alone, anchored from the origin's key-log -/// location as http-delivery §8.4 states. Confirmation needs `key_discovery`. +/// The key log the signers are confirmed against, for `--key-origin ORIGIN` +/// (http-delivery §8.4), and the anchoring's refusal, if any: +/// +/// - with `--pin-key-log FILE`, pinned from that file; +/// - otherwise retrieved from the origin's key-log location over HTTPS by this command +/// itself ([`anchor_over_tls`]): TLS to the origin's host and port (443 by default), +/// its certificate validated for that host against the operating system's trust store, +/// asking with `Cache-Control: no-cache`, following no redirect, and the retrieval +/// built from that request and its response alone. A build without the `cli_tls` +/// feature refuses to anchor (`key_log_not_from_origin`): it never fetches the log +/// over plaintext; +/// - with `--test-plaintext-connect HOST:PORT`, a test aid: the log fetched over plain +/// HTTP from that address. The anchor is reported with the path `test-plaintext`, +/// never `fetched`, as anchoring nothing a verifier may rely on, and the command exits +/// with status 1. #[cfg(all(feature = "receipt_claims", feature = "key_discovery"))] type Keys = Option<(Vec, Vec)>; #[cfg(all(feature = "receipt_claims", not(feature = "key_discovery")))] type Keys = Option<((), ())>; +/// Why anchoring was refused: the reason code, and what happened. +#[cfg(feature = "receipt_claims")] +struct AnchorRefusal { + code: String, + why: String, +} + #[cfg(all(feature = "receipt_claims", feature = "key_discovery"))] -fn keys(args: &[String], deadline: Instant, report: &mut serde_json::Map) -> Result { - use wai::http_binding::keys::{anchor_retrieved, anchoring_request_headers, key_log_url, Retrieval, KEY_LOG_PATH}; - let Some(origin) = opt(args, "--key-origin") else { return Ok(None) }; - let (text, anchor) = match opt(args, "--pin-key-log") { - Some(file) => { +fn keys(args: &[String], deadline: Instant, report: &mut serde_json::Map) -> Result, String> { + use wai::http_binding::keys::{anchoring_request_headers, KEY_LOG_PATH}; + use wai::key_log::{anchor_log, AnchorPath, Anchoring}; + let Some(origin) = opt(args, "--key-origin") else { return Ok(Ok(None)) }; + if args.iter().any(|a| a == "--connect") { + return Err("--connect is gone: the key log is retrieved over HTTPS (--test-plaintext-connect HOST:PORT is a test aid that anchors nothing a verifier may rely on)".into()); + } + let refuse = |code: &str, why: String| Ok(Err(AnchorRefusal { code: code.into(), why })); + let not_from_origin = "key_log_not_from_origin"; + let (text, anchor) = match (opt(args, "--pin-key-log"), opt(args, "--test-plaintext-connect")) { + (Some(file), _) => { let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?; - match wai::key_log::anchor_log(origin, &text, wai::key_log::AnchorPath::Pinned, None) { - wai::key_log::Anchoring::Anchored(a) => (text, a), - wai::key_log::Anchoring::NotAnchored(r) => return Err(r.label().into()), - wai::key_log::Anchoring::Replaced => return Err("key_log_replaced".into()), + match anchor_log(origin, &text, AnchorPath::Pinned, None) { + Anchoring::Anchored(a) => { + report.insert("anchor".into(), json!({ "revision": hex(&a.revision), "path": a.path.label() })); + (text, a) + } + Anchoring::NotAnchored(r) => return refuse(r.label(), "the pinned log does not anchor".into()), + Anchoring::Replaced => return refuse("key_log_replaced", "the pinned log does not anchor".into()), } } - None => { + (None, Some(addr)) => { + // A test aid: plain HTTP from a stated address. What it retrieves anchors this + // run's confirmations and nothing a verifier may rely on. let host = origin.split_once("://").map_or(origin, |(_, h)| h); - let addr = opt(args, "--connect").unwrap_or(host); - let location = key_log_url(origin); let (meta, body) = get_bounded(addr, host, KEY_LOG_PATH, &anchoring_request_headers(), deadline, wai::http_binding::receipts::MAX_RECEIPT_FILE_BYTES)?; - // No redirect is followed: the answer is the location's own, whatever its status. - let retrieval = Retrieval { requested: &location, answered: &location, status: meta.status }; - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); - let acquired = wai::claim_receipt::rfc3339_utc(now); - let a = anchor_retrieved(origin, &retrieval, &body, &acquired, None).map_err(|e| e.code().to_string())?; - (String::from_utf8_lossy(&body).into_owned(), a) + if meta.status != 200 { + return refuse(not_from_origin, format!("the answer is a {}, not a 200; no redirect is followed", meta.status)); + } + let text = String::from_utf8_lossy(&body).into_owned(); + match anchor_log(origin, &text, AnchorPath::Fetched { acquired_at: now_rfc3339() }, None) { + Anchoring::Anchored(a) => { + report.insert("anchor".into(), json!({ "revision": hex(&a.revision), "path": "test-plaintext", "relied_on": false, + "note": "a plaintext retrieval made for tests: it anchors nothing a verifier may rely on" })); + (text, a) + } + Anchoring::NotAnchored(r) => return refuse(r.label(), "the log does not anchor".into()), + Anchoring::Replaced => return refuse("key_log_replaced", "the log does not anchor".into()), + } } + (None, None) => match anchor_over_tls(origin, deadline) { + Ok((text, a)) => { + report.insert("anchor".into(), json!({ "revision": hex(&a.revision), "path": a.path.label() })); + (text, a) + } + Err(r) => return Ok(Err(r)), + }, }; - report.insert("anchor".into(), json!({ "revision": hex(&anchor.revision), "path": anchor.path.label() })); let revs = wai::key_log::read_log(origin, &text).map_err(|r| r.label().to_string())?; - Ok(Some((revs, vec![anchor]))) + Ok(Ok(Some((revs, vec![anchor])))) +} + +/// Now, as RFC 3339 UTC: when a log was acquired. +#[cfg(all(feature = "receipt_claims", feature = "key_discovery"))] +fn now_rfc3339() -> String { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + wai::claim_receipt::rfc3339_utc(now) +} + +/// An `https` origin as §8.4 anchors it: `https://` and an authority, a host and an +/// optional port (443 when absent), with no user information, path, query or fragment. +#[cfg(all(feature = "receipt_claims", feature = "key_discovery", feature = "cli_tls"))] +#[derive(Debug, PartialEq, Eq)] +struct HttpsOrigin { + /// The host, as SNI and the certificate check name it (an IPv6 literal without its + /// brackets). + host: String, + port: u16, + /// The authority as the origin states it: the `Host` header and the URL requested. + authority: String, +} + +#[cfg(all(feature = "receipt_claims", feature = "key_discovery", feature = "cli_tls"))] +impl HttpsOrigin { + fn parse(origin: &str) -> Option { + let authority = origin.strip_prefix("https://")?; + if authority.is_empty() || authority.contains(['/', '?', '#', '@', ' ', '\\']) { + return None; + } + let port_of = |p: &str| (!p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())).then(|| p.parse::().ok()).flatten().filter(|p| *p != 0); + let (host, port) = match authority.strip_prefix('[') { + Some(rest) => { + let (h, after) = rest.split_once(']')?; + let port = match after { + "" => 443, + p => port_of(p.strip_prefix(':')?)?, + }; + (h.to_string(), port) + } + None => match authority.split_once(':') { + Some((h, p)) => (h.to_string(), port_of(p)?), + None => (authority.to_string(), 443), + }, + }; + if host.is_empty() { + return None; + } + Some(HttpsOrigin { host, port, authority: authority.to_string() }) + } + + /// The URL of the origin's key log, as requested. + fn key_log_location(&self) -> String { + format!("https://{}{}", self.authority, wai::http_binding::keys::KEY_LOG_PATH) + } +} + +/// §8.4 over TLS: resolve the origin's host, then [`anchor_from`] with the operating +/// system's trust store. +#[cfg(all(feature = "receipt_claims", feature = "key_discovery", feature = "cli_tls"))] +fn anchor_over_tls(origin: &str, deadline: Instant) -> Result<(String, wai::key_log::Anchor), AnchorRefusal> { + use rustls_platform_verifier::BuilderVerifierExt; + use std::net::ToSocketAddrs; + let refuse = |why: String| AnchorRefusal { code: "key_log_not_from_origin".into(), why }; + let o = HttpsOrigin::parse(origin).ok_or_else(|| refuse("not an https origin: https:// and a host, with no path".into()))?; + let addr = (o.host.as_str(), o.port).to_socket_addrs().map_err(|e| refuse(format!("{}: {e}", o.authority)))?.next() + .ok_or_else(|| refuse(format!("{}: no address", o.authority)))?; + let provider = std::sync::Arc::new(rustls::crypto::ring::default_provider()); + let config = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .and_then(|b| b.with_platform_verifier()) + .map_err(|e| refuse(format!("TLS: {e}")))? + .with_no_client_auth(); + anchor_from(&o, addr, std::sync::Arc::new(config), deadline) +} + +/// A build without TLS never anchors an origin: it would have to fetch the log over +/// plaintext. +#[cfg(all(feature = "receipt_claims", feature = "key_discovery", not(feature = "cli_tls")))] +fn anchor_over_tls(_: &str, _: Instant) -> Result<(String, wai::key_log::Anchor), AnchorRefusal> { + Err(AnchorRefusal { + code: "key_log_not_from_origin".into(), + why: "this build has no TLS client (the cli_tls feature): it never fetches a key log over plaintext".into(), + }) +} + +/// §8.4: `GET` the key log of `o` over TLS from `addr`, SNI and the certificate check +/// naming the origin's host under `config`, and anchor the origin from it. The retrieval +/// is what this request asked for and what answered it on the same connection: the +/// origin's key-log location, the status received, no redirect followed. +#[cfg(all(feature = "receipt_claims", feature = "key_discovery", feature = "cli_tls"))] +fn anchor_from( + o: &HttpsOrigin, + addr: std::net::SocketAddr, + config: std::sync::Arc, + deadline: Instant, +) -> Result<(String, wai::key_log::Anchor), AnchorRefusal> { + use wai::http_binding::keys::{anchor_retrieved, anchoring_request_headers, Retrieval, KEY_LOG_PATH}; + let refuse = |why: String| AnchorRefusal { code: "key_log_not_from_origin".into(), why }; + let (meta, body) = get_tls(addr, &o.host, &o.authority, KEY_LOG_PATH, &anchoring_request_headers(), deadline, + wai::http_binding::receipts::MAX_RECEIPT_FILE_BYTES, config).map_err(refuse)?; + let location = o.key_log_location(); + let retrieval = Retrieval { requested: &location, answered: &location, status: meta.status }; + let origin = format!("https://{}", o.authority); + let a = anchor_retrieved(&origin, &retrieval, &body, &now_rfc3339(), None) + .map_err(|e| AnchorRefusal { code: e.code().to_string(), why: e.to_string() })?; + Ok((String::from_utf8_lossy(&body).into_owned(), a)) +} + +/// A `GET` over TLS: connect to `addr`, handshake naming `host` (SNI, and the name the +/// certificate is validated for), send the request with `Host: authority`, and read the +/// response as [`exchange`] does, its end authenticated. +#[cfg(all(feature = "receipt_claims", feature = "cli_tls"))] +#[allow(clippy::too_many_arguments)] +fn get_tls( + addr: std::net::SocketAddr, + host: &str, + authority: &str, + path: &str, + headers: &[(&str, &str)], + deadline: Instant, + max_body: usize, + config: std::sync::Arc, +) -> Result<(ResponseMeta, Vec), String> { + let name = rustls::pki_types::ServerName::try_from(host.to_string()).map_err(|_| format!("{host}: not a server name"))?; + let tcp = TcpStream::connect_timeout(&addr, left(deadline)?).map_err(|e| format!("{addr}: {e}"))?; + tcp.set_write_timeout(Some(left(deadline)?)).map_err(|e| e.to_string())?; + let conn = rustls::ClientConnection::new(config, name).map_err(|e| format!("TLS: {e}"))?; + let mut s = rustls::StreamOwned::new(conn, tcp); + let req = request(path, authority, headers); + exchange(&mut s, &mut |s: &mut rustls::StreamOwned, d| s.sock.set_read_timeout(Some(d)), + req.as_bytes(), deadline, max_body, true) + .map_err(|e| format!("TLS: {e}")) } #[cfg(all(feature = "receipt_claims", not(feature = "key_discovery")))] -fn keys(args: &[String], _: Instant, _: &mut serde_json::Map) -> Result { +fn keys(args: &[String], _: Instant, _: &mut serde_json::Map) -> Result, String> { if opt(args, "--key-origin").is_some() { return Err("built without key_discovery: --key-origin needs it".into()); } - Ok(None) + Ok(Ok(None)) } #[cfg(all(feature = "receipt_claims", feature = "key_discovery"))] @@ -1141,3 +1402,165 @@ fn confirm_claim(c: &wai::claim_receipt::ClaimReceipt, revs: &[wai::key_log::Rev fn confirm_claim(_: &wai::claim_receipt::ClaimReceipt, _: &(), _: &()) -> String { String::new() } + +/// §8.4 over TLS, against servers under a test CA (`tests/fixtures/tls/`: a CA and +/// certificates for `origin.example` and `other.example`, test keys that attest nothing). +/// The client trusts that CA alone here; the command trusts the operating system's store. +#[cfg(all(test, feature = "receipt_claims", feature = "key_discovery", feature = "cli_tls"))] +mod tls_tests { + use super::*; + use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; + use std::net::SocketAddr; + use std::sync::{mpsc, Arc}; + + fn fixture(name: &str) -> Vec { + std::fs::read(format!("{}/tests/fixtures/tls/{name}", env!("CARGO_MANIFEST_DIR"))).expect("a TLS fixture") + } + + fn provider() -> Arc { + Arc::new(rustls::crypto::ring::default_provider()) + } + + fn client() -> Arc { + let mut roots = rustls::RootCertStore::empty(); + roots.add(CertificateDer::from(fixture("ca.cert.der"))).expect("the test CA"); + Arc::new( + rustls::ClientConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .expect("versions") + .with_root_certificates(roots) + .with_no_client_auth(), + ) + } + + #[derive(Clone, Copy)] + enum Mode { + /// The key log, `Content-Length` framed, closed with `close_notify`. + Log, + /// A redirect to a mirror. + Redirect, + /// The key log unframed, the connection cut without `close_notify`. + Unframed, + /// No TLS at all: a plain-HTTP server. + Plain, + } + + /// A server with the certificate of `name` answering one connection as `mode` says; + /// what it saw, the SNI and the request head (or, for `Plain`, the first bytes). + fn server(name: &'static str, mode: Mode) -> (SocketAddr, mpsc::Receiver<(Option, Vec)>) { + let l = TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = l.local_addr().expect("addr"); + let (tx, rx) = mpsc::channel(); + let body = std::fs::read(format!("{}/../http-conformance/.well-known/wai-keys", env!("CARGO_MANIFEST_DIR"))).expect("the corpus log"); + std::thread::spawn(move || { + let Ok((mut tcp, _)) = l.accept() else { return }; + let _ = tcp.set_read_timeout(Some(Duration::from_secs(10))); + if let Mode::Plain = mode { + let mut first = [0u8; 8]; + let n = tcp.read(&mut first).unwrap_or(0); + let _ = tcp.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + let _ = tx.send((None, first[..n].to_vec())); + return; + } + let config = rustls::ServerConfig::builder_with_provider(provider()) + .with_safe_default_protocol_versions() + .expect("versions") + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(fixture(&format!("{name}.cert.der")))], + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(fixture(&format!("{name}.key.der")))), + ) + .expect("a server certificate"); + let conn = rustls::ServerConnection::new(Arc::new(config)).expect("a server connection"); + let mut s = rustls::StreamOwned::new(conn, tcp); + let mut head = Vec::new(); + let mut b = [0u8; 1]; + while !head.ends_with(b"\r\n\r\n") { + match s.read(&mut b) { + Ok(1) => head.push(b[0]), + _ => break, + } + } + let sni = s.conn.server_name().map(String::from); + let answer: Vec = match mode { + Mode::Log => [format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len()).into_bytes(), body].concat(), + Mode::Redirect => b"HTTP/1.1 302 Found\r\nLocation: https://mirror.example/.well-known/wai-keys\r\nContent-Length: 0\r\n\r\n".to_vec(), + _ => [b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n".to_vec(), body].concat(), + }; + let _ = s.write_all(&answer); + if !matches!(mode, Mode::Unframed) { + s.conn.send_close_notify(); + } + let _ = s.flush(); + let _ = tx.send((sni, head)); + }); + (addr, rx) + } + + fn origin() -> HttpsOrigin { + HttpsOrigin::parse("https://origin.example").expect("an https origin") + } + + fn deadline() -> Instant { + Instant::now() + Duration::from_secs(10) + } + + #[test] + fn an_origin_anchors_from_a_tls_retrieval_it_made() { + let (addr, seen) = server("origin", Mode::Log); + let (text, anchor) = anchor_from(&origin(), addr, client(), deadline()).unwrap_or_else(|r| panic!("{}: {}", r.code, r.why)); + assert_eq!(anchor.path.label(), "fetched"); + assert!(text.contains("wai-key-log")); + let (sni, head) = seen.recv().expect("the server's view"); + assert_eq!(sni.as_deref(), Some("origin.example"), "SNI names the origin's host"); + let head = String::from_utf8(head).expect("a request head"); + assert!(head.starts_with("GET /.well-known/wai-keys HTTP/1.1\r\n"), "{head}"); + assert!(head.contains("\r\nHost: origin.example\r\n") && head.contains("\r\nCache-Control: no-cache\r\n"), "{head}"); + } + + #[test] + fn a_certificate_for_another_host_anchors_nothing() { + let (addr, _) = server("other", Mode::Log); + let r = anchor_from(&origin(), addr, client(), deadline()).expect_err("another host's certificate"); + assert_eq!(r.code, "key_log_not_from_origin"); + assert!(r.why.contains("certificate"), "{}", r.why); + } + + #[test] + fn a_plaintext_server_is_never_read() { + let (addr, seen) = server("origin", Mode::Plain); + let r = anchor_from(&origin(), addr, client(), deadline()).expect_err("no TLS"); + assert_eq!(r.code, "key_log_not_from_origin"); + let (_, first) = seen.recv().expect("the server's view"); + assert_eq!(first.first(), Some(&0x16), "the client opened with a TLS handshake, never a plaintext request"); + } + + #[test] + fn a_redirect_over_tls_anchors_nothing() { + let (addr, _) = server("origin", Mode::Redirect); + let r = anchor_from(&origin(), addr, client(), deadline()).expect_err("a redirect"); + assert_eq!(r.code, "key_log_not_from_origin"); + assert!(r.why.contains("200"), "{}", r.why); + } + + #[test] + fn a_response_whose_end_is_not_authenticated_anchors_nothing() { + let (addr, _) = server("origin", Mode::Unframed); + let r = anchor_from(&origin(), addr, client(), deadline()).expect_err("cut without close_notify"); + assert!(r.why.contains("close_notify"), "{}", r.why); + } + + #[test] + fn an_https_origin_is_a_host_and_a_port() { + let o = |s: &str| HttpsOrigin::parse(s).map(|o| (o.host, o.port, o.authority)); + assert_eq!(o("https://origin.example"), Some(("origin.example".into(), 443, "origin.example".into()))); + assert_eq!(o("https://origin.example:8443"), Some(("origin.example".into(), 8443, "origin.example:8443".into()))); + assert_eq!(o("https://[::1]:9443"), Some(("::1".into(), 9443, "[::1]:9443".into()))); + assert_eq!(o("https://[::1]"), Some(("::1".into(), 443, "[::1]".into()))); + for bad in ["http://origin.example", "https://", "https://origin.example/", "https://u@origin.example", "https://origin.example:", + "https://origin.example:0", "https://origin.example:65536", "https://origin.example:+1", "https://origin.example?x", "https://[::1]x"] { + assert_eq!(o(bad), None, "{bad}"); + } + assert_eq!(origin().key_log_location(), "https://origin.example/.well-known/wai-keys"); + } +} diff --git a/wai/wai-rs/tests/cli_http.rs b/wai/wai-rs/tests/cli_http.rs index 08d4f67c9..c8556ab6c 100644 --- a/wai/wai-rs/tests/cli_http.rs +++ b/wai/wai-rs/tests/cli_http.rs @@ -686,6 +686,18 @@ fn test_server(mode: &'static str) -> (u16, std::sync::Arc (u16, std::sync::ArcFISMutXa29|~rV+Xr|i4p1;W=3{qCkB?oiAPh9Zb;9Yvf%WL)=5uu zn0jU0Z~7i-yVaHWC)p&?*q!4}Nx07rzlEK;>h7EZ|0eEu&ijq^P+hN2`@PP#Q zK?1-aVPr!NAZB+40~aQRqQm(HGgi&2m;AKpxzLhq?WvakCwb{tC(V1fjW?zI<2NRS m` z4MQ~p6*lHj7G@sV^2AJqlGNf71!qSEjl>cVvsfW7za%3wFJ03>L7dmf%+SEV0tgL_ z&7vgujSP*<3@i;zpaKSxhT;aI5VQF5i!#$Q^Yl_H5_1c3QVk*vgxT1^mNGFyZD(d= zXLe#>Q3-X zGaB%K1cezH|FbX|Fc|QGc>Ew9FhtlJki&=Box#9`Nx`hrbjpNBLAh_0FWl9%=H6$= ze7G(#u5#9gq13zdgG?U+(dtjX}LmY2sSR`S)G} E04$4t4gdfE literal 0 HcmV?d00001 diff --git a/wai/wai-rs/tests/fixtures/tls/origin.key.der b/wai/wai-rs/tests/fixtures/tls/origin.key.der new file mode 100644 index 0000000000000000000000000000000000000000..c39b849bd3950463918824336539d808b786e8c1 GIT binary patch literal 138 zcmV;50CoQ`frkPC05B5<2P%e0&OHJF1_&yKNX|V20S5$aFlzz<0R$lOWmvR~4e^6I zKEp|=fUDzzwoYBPuUduu0eU}^!_o<%L<2$q1SC~MZv{ln$HHp#2;C5e?R5|P?{s(K sUr$G7&13;r@DiV>fqE$D(>` z4MQ~p6*lHj7G@sV^2AJqlGNf71!qSEjl>cVvsfW7za%3wFJ03>L7dmf%+SEV0tgL_ z&7vgujSP*<3@i;zpaKRGhGGUH5VLrJx>AeuQY#X33vyBoA`OJu*ui!(F+we8W@Kk} zVqoE#k-dM%!h0Oi=R(#W56%leGtcRR9lKN4q_g)II(OFmdS^GQ|GFTn%n#|%s27SB zhcBiI=+2Gh)-jlt^;w_KJ!7%Ifv#ahFpz~|TdOm*+FJ<&p3&Y^5YkK4cVGd=zB;Ezt==uXlE{`mYPJ%KVTHje4PIark1YfbQH_ZXJVZS)cX!+%o{Q_%uxb literal 0 HcmV?d00001 -- 2.54.0 (Apple Git-157) From ac602599d057e9db43187364a262e3a796d748a5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 8 Oct 2026 04:15:46 -0400 Subject: [PATCH 11/16] http receipts round 3: falsify the fixes Patches (falsify/run.sh), each failing the corpus cases it names: bytes-unchecked, decision-unchecked, recovery-record-unchecked, operating-point-unchecked, session-accepted, declaration-unnamed, first-publisher, matchers-need-object and set-sha-faults; claim-through-any-receipt, fetch-unmemoised, fetch-budget-unchecked and file-cap-unchecked regenerated against the new receipts.rs. Co-Authored-By: Claude Opus 5.5 --- .../falsify/bytes-unchecked.patch | 24 +++++++++++++++++++ .../falsify/claim-through-any-receipt.patch | 6 ++--- .../falsify/decision-unchecked.patch | 17 +++++++++++++ .../falsify/declaration-unnamed.patch | 16 +++++++++++++ .../falsify/fetch-budget-unchecked.patch | 8 +++---- .../falsify/fetch-unmemoised.patch | 8 +++---- .../falsify/file-cap-unchecked.patch | 14 +++++------ .../falsify/first-publisher.patch | 15 ++++++++++++ .../falsify/matchers-need-object.patch | 14 +++++++++++ .../falsify/operating-point-unchecked.patch | 16 +++++++++++++ .../falsify/recovery-record-unchecked.patch | 17 +++++++++++++ .../falsify/session-accepted.patch | 15 ++++++++++++ .../falsify/set-sha-faults.patch | 15 ++++++++++++ 13 files changed, 167 insertions(+), 18 deletions(-) create mode 100644 wai/http-conformance/falsify/bytes-unchecked.patch create mode 100644 wai/http-conformance/falsify/decision-unchecked.patch create mode 100644 wai/http-conformance/falsify/declaration-unnamed.patch create mode 100644 wai/http-conformance/falsify/first-publisher.patch create mode 100644 wai/http-conformance/falsify/matchers-need-object.patch create mode 100644 wai/http-conformance/falsify/operating-point-unchecked.patch create mode 100644 wai/http-conformance/falsify/recovery-record-unchecked.patch create mode 100644 wai/http-conformance/falsify/session-accepted.patch create mode 100644 wai/http-conformance/falsify/set-sha-faults.patch diff --git a/wai/http-conformance/falsify/bytes-unchecked.patch b/wai/http-conformance/falsify/bytes-unchecked.patch new file mode 100644 index 000000000..4f060a2c8 --- /dev/null +++ b/wai/http-conformance/falsify/bytes-unchecked.patch @@ -0,0 +1,24 @@ +# must fail: receipts_fetch_bytes_exhausted +# the bytes read for one index unbounded (receipts::ReceiptFetcher::get) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 83cd7ccb..f03b1c9e 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -291,7 +291,7 @@ impl<'f> ReceiptFetcher<'f> { + if self.fetched >= self.budget { + return Err(ReceiptRefusal::BudgetExhausted); + } +- if self.bytes >= MAX_INDEX_FETCH_BYTES { ++ if self.bytes >= usize::MAX - MAX_INDEX_FETCH_BYTES { + return Err(ReceiptRefusal::BytesExhausted); + } + self.fetched += 1; +@@ -299,7 +299,7 @@ impl<'f> ReceiptFetcher<'f> { + None => Served::Nothing, + Some(b) => { + self.bytes += b.len().min(MAX_RECEIPT_FILE_BYTES + 1); +- if self.bytes > MAX_INDEX_FETCH_BYTES { Served::PastBytes } else { Served::Bytes(b) } ++ if self.bytes > usize::MAX - MAX_INDEX_FETCH_BYTES { Served::PastBytes } else { Served::Bytes(b) } + } + }; + self.memo.insert(*h, served); diff --git a/wai/http-conformance/falsify/claim-through-any-receipt.patch b/wai/http-conformance/falsify/claim-through-any-receipt.patch index 8c069cf17..7906326b4 100644 --- a/wai/http-conformance/falsify/claim-through-any-receipt.patch +++ b/wai/http-conformance/falsify/claim-through-any-receipt.patch @@ -1,11 +1,11 @@ # must fail: receipts_claim_through_foreign_item # a claim about a receipt attached through a group no item that passed names (receipts::check_claim) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index f2bdba57..c5614e3c 100644 +index 83cd7ccb..42574240 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -673,7 +673,7 @@ pub fn check_claim( - let publisher = |subject: &[u8; 32]| recovery.and_then(|r| r.publishers.iter().find(|(h, _)| h == subject).map(|(_, k)| *k)); +@@ -732,7 +732,7 @@ pub fn check_claim( + }; let attests = match claim.subject_type { SubjectType::Object if claim.subject == *cid => Attests::Object, - SubjectType::Receipt if groups.contains(&claim.subject) => Attests::Receipt, diff --git a/wai/http-conformance/falsify/decision-unchecked.patch b/wai/http-conformance/falsify/decision-unchecked.patch new file mode 100644 index 000000000..d664d4122 --- /dev/null +++ b/wai/http-conformance/falsify/decision-unchecked.patch @@ -0,0 +1,17 @@ +# must fail: receipts_lawful_basis receipts_lawful_basis_declaration_not_served receipts_lawful_basis_declaration_renamed +# a lawful-basis decision accepted without its declaration (receipts::step6) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 83cd7ccb..d8cdcc81 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -654,8 +654,8 @@ fn step6( + } + // §2.4.6: against the declaration it names, fetched by its link. + ClaimBody::LawfulBasis(LawfulBasisClaim::Decision { declaration, .. }) => { +- let d = declaration_of(declaration, fetch)?; +- claim.check_decision_against(&d).map_err(c) ++ let _ = (declaration, &fetch); ++ Ok(()) + } + // §2.6.3: against its declaration together with the publisher key of a verified + // delivery receipt for the declaration's subject. diff --git a/wai/http-conformance/falsify/declaration-unnamed.patch b/wai/http-conformance/falsify/declaration-unnamed.patch new file mode 100644 index 000000000..6b0bf1515 --- /dev/null +++ b/wai/http-conformance/falsify/declaration-unnamed.patch @@ -0,0 +1,16 @@ +# must fail: receipts_lawful_basis_declaration_renamed +# a declaration fetched by its link read without §8.1's name check (receipts::declaration_of) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 83cd7ccb..a9187306 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -688,7 +688,8 @@ fn declaration_of(link: &[u8; 32], fetch: &mut ReceiptFetcher<'_>) -> Result return Err(ReceiptRefusal::ContextNotHeld), + Err(r) => return Err(r), + }; +- read_claim_file(link, &bytes) ++ let _ = link; ++ ClaimReceipt::from_json(std::str::from_utf8(&bytes).unwrap_or("")).map_err(ReceiptRefusal::Claim) + } + + /// §8.2's check of one claim the index of the object `cid` lists by its link hash diff --git a/wai/http-conformance/falsify/fetch-budget-unchecked.patch b/wai/http-conformance/falsify/fetch-budget-unchecked.patch index 653707314..5e8159444 100644 --- a/wai/http-conformance/falsify/fetch-budget-unchecked.patch +++ b/wai/http-conformance/falsify/fetch-budget-unchecked.patch @@ -1,15 +1,15 @@ # must fail: receipts_fetch_budget # an index's fetches unbounded (receipts::ReceiptFetcher::get) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index f2bdba57..c37dc5c1 100644 +index 83cd7ccb..30714725 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -251,7 +251,7 @@ impl<'f> ReceiptFetcher<'f> { - /// budget, `receipt_fetch_budget_exhausted`. +@@ -288,7 +288,7 @@ impl<'f> ReceiptFetcher<'f> { + /// further file is fetched. pub fn get(&mut self, h: &[u8; 32]) -> Result, ReceiptRefusal> { if !self.memo.contains_key(h) { - if self.fetched >= self.budget { + if self.fetched >= usize::MAX - self.budget { return Err(ReceiptRefusal::BudgetExhausted); } - self.fetched += 1; + if self.bytes >= MAX_INDEX_FETCH_BYTES { diff --git a/wai/http-conformance/falsify/fetch-unmemoised.patch b/wai/http-conformance/falsify/fetch-unmemoised.patch index a58088884..3ec0d0003 100644 --- a/wai/http-conformance/falsify/fetch-unmemoised.patch +++ b/wai/http-conformance/falsify/fetch-unmemoised.patch @@ -1,12 +1,12 @@ # must fail: receipts_items_fetch_each_name_once # each name fetched again for every item and walk that names it (receipts::ReceiptFetcher::get) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index f2bdba57..9e1776f9 100644 +index 83cd7ccb..7972c06a 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -250,7 +250,7 @@ impl<'f> ReceiptFetcher<'f> { - /// The file named `h`: `receipt_not_served`, `receipt_too_large`, or, past the - /// budget, `receipt_fetch_budget_exhausted`. +@@ -287,7 +287,7 @@ impl<'f> ReceiptFetcher<'f> { + /// `receipt_fetch_bytes_exhausted`: the file that passes them is refused, and no + /// further file is fetched. pub fn get(&mut self, h: &[u8; 32]) -> Result, ReceiptRefusal> { - if !self.memo.contains_key(h) { + if !self.memo.contains_key(h) || self.fetched < usize::MAX { diff --git a/wai/http-conformance/falsify/file-cap-unchecked.patch b/wai/http-conformance/falsify/file-cap-unchecked.patch index 3d5a6df21..12991ddeb 100644 --- a/wai/http-conformance/falsify/file-cap-unchecked.patch +++ b/wai/http-conformance/falsify/file-cap-unchecked.patch @@ -1,15 +1,15 @@ # must fail: receipts_file_too_large receipts_parent_too_large # a receipt file over 1 MiB read (receipts::ReceiptFetcher::get) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index f2bdba57..b94d3156 100644 +index 83cd7ccb..822b9975 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -260,7 +260,7 @@ impl<'f> ReceiptFetcher<'f> { - } +@@ -307,7 +307,7 @@ impl<'f> ReceiptFetcher<'f> { match &self.memo[h] { - None => Err(ReceiptRefusal::NotServed), -- Some(b) if b.len() > MAX_RECEIPT_FILE_BYTES => Err(ReceiptRefusal::TooLarge), -+ Some(b) if b.len() > usize::MAX - MAX_RECEIPT_FILE_BYTES => Err(ReceiptRefusal::TooLarge), - Some(b) => Ok(b.clone()), + Served::Nothing => Err(ReceiptRefusal::NotServed), + Served::PastBytes => Err(ReceiptRefusal::BytesExhausted), +- Served::Bytes(b) if b.len() > MAX_RECEIPT_FILE_BYTES => Err(ReceiptRefusal::TooLarge), ++ Served::Bytes(b) if b.len() > usize::MAX - MAX_RECEIPT_FILE_BYTES => Err(ReceiptRefusal::TooLarge), + Served::Bytes(b) => Ok(b.clone()), } } diff --git a/wai/http-conformance/falsify/first-publisher.patch b/wai/http-conformance/falsify/first-publisher.patch new file mode 100644 index 000000000..673d9351f --- /dev/null +++ b/wai/http-conformance/falsify/first-publisher.patch @@ -0,0 +1,15 @@ +# must fail: receipts_ancestor_other_publisher_first +# a soft binding's recovery under the first publisher key held, not the one that signed it (receipts::check_claim) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 83cd7ccb..c7f1b0d5 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -728,7 +728,7 @@ pub fn check_claim( + let r = recovery.filter(|_| matches!(held, ObjectHeld::Object(_)))?; + let mut keys = r.publishers.iter().filter(|(h, _)| h == subject).map(|(_, k)| *k); + let first = keys.next()?; +- Some(if first == claim.signer_pubkey { first } else { keys.find(|k| *k == claim.signer_pubkey).unwrap_or(first) }) ++ Some(if first == claim.signer_pubkey { first } else { keys.find(|k| *k == [0; 32]).unwrap_or(first) }) + }; + let attests = match claim.subject_type { + SubjectType::Object if claim.subject == *cid => Attests::Object, diff --git a/wai/http-conformance/falsify/matchers-need-object.patch b/wai/http-conformance/falsify/matchers-need-object.patch new file mode 100644 index 000000000..1b0cd6a71 --- /dev/null +++ b/wai/http-conformance/falsify/matchers-need-object.patch @@ -0,0 +1,14 @@ +# must fail: receipts_binding_recovery_object_not_held +# a recovery record's matchers dropped when the object is not held (receipts::check_index) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 83cd7ccb..fab80364 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -825,6 +825,7 @@ pub fn check_index( + // A recovery through a soft binding runs only for an object the client holds (the + // value it compares is computed from it): check_claim gates it on `held`. The + // matchers serve step 6 of a recovery record whether or not the object is held. ++ let recovery = recovery.filter(|_| matches!(held, ObjectHeld::Object(_))); + let claims = ix + .claims + .iter() diff --git a/wai/http-conformance/falsify/operating-point-unchecked.patch b/wai/http-conformance/falsify/operating-point-unchecked.patch new file mode 100644 index 000000000..c52b00d53 --- /dev/null +++ b/wai/http-conformance/falsify/operating-point-unchecked.patch @@ -0,0 +1,16 @@ +# must fail: receipts_operating_point receipts_operating_point_not_held +# an operating-point claim accepted without its figure (receipts::step6) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 83cd7ccb..865e7bb4 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -628,7 +628,8 @@ fn step6( + } + // §2.4.8, §2.4.9: against the receipt carrying the figure — for `moq-jwp:object`, + // an object receipt accepted under the group the claim names. +- ClaimBody::Measurement(_) | ClaimBody::OperatingPoint(_) => { ++ ClaimBody::OperatingPoint(_) => Ok(()), ++ ClaimBody::Measurement(_) => { + let group = match &claim.body { + ClaimBody::Measurement(m) => m.figure.group, + ClaimBody::OperatingPoint(o) => o.figure.group, diff --git a/wai/http-conformance/falsify/recovery-record-unchecked.patch b/wai/http-conformance/falsify/recovery-record-unchecked.patch new file mode 100644 index 000000000..78220df28 --- /dev/null +++ b/wai/http-conformance/falsify/recovery-record-unchecked.patch @@ -0,0 +1,17 @@ +# must fail: receipts_binding_recovery receipts_binding_recovery_object_not_held receipts_binding_recovery_no_matcher +# a binding-recovery record accepted without its declaration, publisher or match (receipts::step6) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 83cd7ccb..7cebbbd5 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -660,8 +660,8 @@ fn step6( + // §2.6.3: against its declaration together with the publisher key of a verified + // delivery receipt for the declaration's subject. + ClaimBody::BindingRecovery(r) => { +- if passed.is_empty() { +- return Err(ReceiptRefusal::ContextNotHeld); ++ if passed.len() < usize::MAX { ++ return Ok(()); + } + let d = declaration_of(&r.declaration, fetch)?; + let publisher = passed diff --git a/wai/http-conformance/falsify/session-accepted.patch b/wai/http-conformance/falsify/session-accepted.patch new file mode 100644 index 000000000..e0a583108 --- /dev/null +++ b/wai/http-conformance/falsify/session-accepted.patch @@ -0,0 +1,15 @@ +# must fail: receipts_reconstruct_session +# a reconstruct-session claim accepted with none of its context (receipts::step6) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index 83cd7ccb..ce2f2a04 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -673,7 +673,7 @@ fn step6( + } + // §2.4.12: its outputs, received objects and envelopes are the sink's, not + // served beside an object. +- ClaimBody::ReconstructSession(_) => Err(ReceiptRefusal::ContextNotHeld), ++ ClaimBody::ReconstructSession(_) => Ok(()), + _ => Ok(()), + } + } diff --git a/wai/http-conformance/falsify/set-sha-faults.patch b/wai/http-conformance/falsify/set-sha-faults.patch new file mode 100644 index 000000000..25fde0ce5 --- /dev/null +++ b/wai/http-conformance/falsify/set-sha-faults.patch @@ -0,0 +1,15 @@ +# must fail: param_set_sha_uppercase +# a pin whose digest is not lowercase hexadecimal read as a fault, not pin_malformed (the corpus tool's param_set) +diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs +index 12a55982..34d16fd9 100644 +--- a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs ++++ b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs +@@ -925,7 +925,7 @@ mod built { + // A pin whose digest is not 64 lowercase hexadecimal digits locates no grants + // file: the pin's own check refuses it (pin_malformed). + let grants = match (input["grants"].as_str(), unhex32(sha)) { +- (Some(p), Some(d)) => read_grants_file(&d, &c.file(p)?).map_err(|e| Stop::Fault(format!("the grants file: {e}")))?, ++ (Some(p), d) => read_grants_file(&d.ok_or(Stop::Fault("sha256 is not a hash".into()))?, &c.file(p)?).map_err(|e| Stop::Fault(format!("the grants file: {e}")))?, + _ => Vec::new(), + }; + let mut sink = SinkKeyLog::new(revs, anchors).with_statuses(statuses).with_grants(grants); -- 2.54.0 (Apple Git-157) From 837438a3a1969a2f292ddcdcf31693279e86d03b Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 8 Oct 2026 04:19:16 -0400 Subject: [PATCH 12/16] CI: the falsify job's comment names round 3's mutations Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-http.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wai-http.yml b/.github/workflows/wai-http.yml index 50dd45801..f19aed6cc 100644 --- a/.github/workflows/wai-http.yml +++ b/.github/workflows/wai-http.yml @@ -13,11 +13,12 @@ name: WAI HTTP delivery # playlist side adjacency, rendering, bandwidths, durations, the classical master, the # playlist receipt and its chain, listed cids, and the adaptation policies; and on the # receipt side the receipt file's name, legacy groups, the parent walk and its bound, -# the caps on files, indexes and their items, fetching each name once within a budget, -# what a client holds of an object, the items a claim attaches through, §3.1 step 6, -# the ancestor rule and its recovery, a claim's parent, anchoring from the origin only -# and the request that anchors, cached logs, grants files and sets held by their -# pins); it +# the caps on files, indexes and their items, fetching each name once within budgets +# of files and bytes, what a client holds of an object, the items a claim attaches +# through, §3.1 step 6 for every class and the declarations it reads by name, the +# ancestor rule, its recovery and the publisher key it uses, a claim's parent, +# anchoring from the origin only and the request that anchors, cached logs, grants +# files and sets held by their pins); it # is applied to a fresh worktree, the build must succeed, and `wai_http_vectors verify` # must fail naming the cases on the patch's first line. Then each `http_verify.py # --mutate` must fail, and the differential: wai-rs writes random envelopes' covered -- 2.54.0 (Apple Git-157) From 0f4b5d9e3376af2c18ef6d93fee3f7c8dc390985 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 14:38:57 -0400 Subject: [PATCH 13/16] http receipts round 4: a deadline TLS cannot stretch, a capped key log, a labelled test anchor, a byte budget never passed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five items of the TLS verification of 0ef0063a, and the smaller sixth. 1. --deadline-ms on the TLS path. Every socket read and write goes through DeadlineSocket, which waits at most what remains and is TimedOut once the deadline passed, so rustls's handshake loop and a trickling peer cannot extend it; the lookup runs on its own thread, joined within the deadline. Plain HTTP uses the same socket. 2. A key log is read to 1 MiB and a longer one refused whole (key_log_too_large): in anchor_retrieved and hold_cached_revisions (http_binding::keys::MAX_KEY_LOG_BYTES), and on the test path. §8.4 states the cap; corpus cases at and past it, anchored and cached, both ways. 3. The test path anchors with key_log::AnchorPath::TestPlaintext, labelled test-plaintext in the report and in every confirmation, never fetched. 4. The exit-status test moves to O7, whose index passes whole: rc 0 with the log pinned, rc 1 with the test anchor, the verdicts otherwise equal. 5. Each receipt fetch is asked for no more than what remains of the index's 64 MiB and of the file's cap plus one byte (the fetch closure now takes that limit, and what it returns past it is unread); a file that reaches the end of the budget is refused. The CLI reads no further than the limit. §8.2 says so exactly. 6. Every address a host resolves to is tried in turn, within the deadline, for TLS and plain HTTP alike. Python mutations bytes-read-whole and key-log-uncapped. Corpus 1045 cases, Rust and Python agree. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 16 +- wai/extensions/http-delivery.md | 18 +- wai/http-conformance/README.md | 5 +- wai/http-conformance/cases.json | 5 +- wai/http-conformance/falsify/run.sh | 2 +- wai/http-conformance/http_receipts.py | 24 +- wai/http-conformance/http_verify.py | 3 +- wai/wai-rs/src/bin/wai_cli/http.rs | 236 ++++++++++++++---- .../src/bin/wai_cli/http_vectors_receipts.rs | 16 +- wai/wai-rs/src/http_binding/keys.rs | 20 +- wai/wai-rs/src/http_binding/receipts.rs | 50 ++-- wai/wai-rs/src/http_binding/tests_receipts.rs | 84 +++++-- wai/wai-rs/src/key_log.rs | 7 + wai/wai-rs/tests/cli_http.rs | 54 +++- 14 files changed, 423 insertions(+), 117 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index fb5dc769c..a6d990be2 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -691,8 +691,9 @@ wire format may change and such changes are recorded here. - an index, at most 4 MiB with at most 256 delivery items and 256 claims (`receipt_index_too_large`), is read item by item (`receipt_index_malformed`), each receipt file fetched once per index, at most 1024 in all - (`receipt_fetch_budget_exhausted`) and 64 MiB read in all - (`receipt_fetch_bytes_exhausted`), each cap with its reason stated: each delivery item's group goes through + (`receipt_fetch_budget_exhausted`) and 64 MiB read in all, each file read no further + than what remains of that (`receipt_fetch_bytes_exhausted`), each cap with its reason + stated: each delivery item's group goes through jwp-receipts §3 against the client's chains, a missing parent fetched by its link up to 64 of them, never more, and never skipped (`parent_not_held`, with where the walk stopped), a joining client anchoring instead; its object receipt through steps 4 @@ -709,7 +710,8 @@ wire format may change and such changes are recorded here. under the held publisher key that signed it; - a key log anchors only from the origin's own location over HTTPS, answered `200` with no redirect (`key_log_not_from_origin`), from a retrieval the verifier made - itself, asking with `Cache-Control: no-cache`; a log through any cache adds revisions + itself, asking with `Cache-Control: no-cache`; a key log is read to 1 MiB and a longer + one refused, anchored or cached (`key_log_too_large`); a log through any cache adds revisions and never moves the anchor, an older copy reported (`key_log_rolled_back`); a grants file is a locator (`grants_file_malformed`); a key status is served for a quarter of the head's `status_max_age_s` at most; a module grant is §7.9's form, and a JCP grant @@ -726,10 +728,12 @@ wire format may change and such changes are recorded here. and with `--key-origin` retrieves the key log from the origin itself over TLS (the `cli_tls` feature: a TLS client validating against the operating system's trust store; SNI and the certificate check name the origin's host, port 443 by - default); a build + default, each address the host resolves to tried in turn), every step within one + deadline however slowly a peer answers; a build without it refuses to anchor rather than fetch the log over plaintext, and the - test-only `--test-plaintext-connect` labels what it fetches `test-plaintext`, as - anchoring nothing a verifier may rely on. A group or + test-only `--test-plaintext-connect` anchors with its own path, `test-plaintext`, + wherever the anchor or a confirmation is reported, as anchoring nothing a verifier + may rely on. A group or object receipt whose `kind` is not a string is `wrong_kind` (jwp-receipts §3 step 1), as the independent verifier already read it. - The corpus serves receipts, indexes, a key log, a status, a grants file and a set; diff --git a/wai/extensions/http-delivery.md b/wai/extensions/http-delivery.md index 3b86d9feb..ce80c3b22 100644 --- a/wai/extensions/http-delivery.md +++ b/wai/extensions/http-delivery.md @@ -459,11 +459,12 @@ To check one index, a client fetches each receipt file name once, keeping what w for the rest of the check, and fetches at most 1024 files in all, enough for every item and claim an index may list, a declaration for each claim and full parent walks for a few groups; a fetch past that is `receipt_fetch_budget_exhausted`. It reads at most 64 MiB -(67 108 864 bytes) of receipt files in all, counting what it read of each, at most one -byte past the cap: the file that passes that is refused, and once it is reached no -further file is fetched (`receipt_fetch_bytes_exhausted`). Without it, 1024 files at -their cap would let one index make a client read and hold a gibibyte. A client MAY hold -lower budgets. +(67 108 864 bytes) of receipt files in all: it reads each file no further than what +remains of that budget, or than one byte past the file's cap where less remains of +neither; a file that reaches the end of the budget is refused, since whether it ends +there is not read, and once the budget is spent no further file is fetched +(`receipt_fetch_bytes_exhausted`). Without it, 1024 files at their cap would let one +index make a client read and hold a gibibyte. A client MAY hold lower budgets. A delivery item is checked in this order: @@ -565,7 +566,10 @@ origin names none), the server's certificate validated for that host against the anchors the verifier holds, the request and the `200` exchanged on that connection, and the response's end authenticated by TLS or by its own framing. These are facts of the request it sent and the response it received, so a log retrieved over plaintext, or one -another party states was so retrieved, anchors nothing. Later revisions MAY come through +another party states was so retrieved, anchors nothing. A verifier reads at most 1 MiB +(1 048 576 bytes) of a key log, however it reaches it, and refuses a longer one whole +(`key_log_too_large`): a log is a few revisions of a few keys each, and the cap bounds +what one retrieval costs. Later revisions MAY come through any cache, a mirror, or the origin: such a log is read as §7.3 reads one, its revisions are held and each is checked against its parent by §7.4's head walk, and it never moves the anchor. A copy whose latest revision is a trusted revision older than the anchor is @@ -1032,7 +1036,7 @@ is a share of sessions, not of devices or people. | `receipt_name_mismatch`, `receipt_not_about_object` | §8.1, §8.2, §8.6 | | `receipt_not_served`, `receipt_index_malformed`, `receipt_index_too_large`, `receipt_too_large`, `receipt_legacy_group`, `receipt_fetch_budget_exhausted`, `receipt_fetch_bytes_exhausted` | §8.1, §8.2 | | `context_not_held` | §8.2 (jwp-receipts §3.1 step 6) | -| `key_log_not_from_origin`, `grants_file_malformed` | §8.4 | +| `key_log_not_from_origin`, `key_log_too_large`, `grants_file_malformed` | §8.4 | | `codecs_malformed`, `codecs_not_media`, `codecs_candidate`, `codecs_no_common_capability`, `codecs_undeclared`, `codecs_layer_unbound` | §4 | | `base_unanswered` | §10.1 | | `not_a_layer`, `layer_tag_malformed` | §10.4 | diff --git a/wai/http-conformance/README.md b/wai/http-conformance/README.md index b1fd492cc..498342969 100644 --- a/wai/http-conformance/README.md +++ b/wai/http-conformance/README.md @@ -135,7 +135,7 @@ outside this repository, and the AV1 streams were checked to decode. | `uri_resolve` | a `base` and a `reference` | the `url` (RFC 3986 §5.2 and §6.2.2, without the fragment) | | `receipt_file` | a `file` read as a receipt of `kind` (`group` or `claim`) under its `name` (its own by default), optionally edited | its `link`, or `error` with the jwp-receipts `step` | | `receipts` | `checks` in order, each an `object` (or `object_file`), what the client holds of it (`held`: `none`; `whole`, its bytes or `held_object`'s; or `{"fetch": {…}}`, a fetch as the `fetch` op runs one), its `index` (beside it by default) optionally edited, or another object's index (`object_cid`), optionally padded (`pad_index`, bytes); one set of chains (`join`), a parent bound (`max_walk`, 64 by default, never more) and a fetch budget (`fetch_budget`, 1024 by default), the receipt files `serve`d (`rcpt` directories searched, `omit`ted hashes, hashes served `as` another file, files `pad`ded past their cap, or every file to `pad_all`), `recovery` (the soft-binding algorithms a matcher is held for; the publishers are those of the items delivered in earlier checks), and `keys` (key-log steps, as `key_log` runs them) | per check the index's verdict and, when it reads, each delivery item's admission, parents `walked`, `evidence`, how the hash was `established` and the property's `reading`, or its `error` (with where a walk stopped); each claim's `class`, what it `attests`, its `subject` and `parent`, or its `error`; the receipt files the check fetched (`files_fetched`); with `keys`, each signer's confirmation; the fetch's verdict | -| `key_log` | `steps`: `anchor` (a log retrieved as `retrieval` states: `requested`, `answered`, `status`), `pin`, or `cached` (a log through a cache), each for an `origin` (the corpus's by default) and optionally edited | what each step did (`anchored`, `added`) or its `error` | +| `key_log` | `steps`: `anchor` (a log retrieved as `retrieval` states: `requested`, `answered`, `status`), `pin`, or `cached` (a log through a cache), each for an `origin` (the corpus's by default), optionally edited and `pad`ded with spaces to a length | what each step did (`anchored`, `added`) or its `error` | | `grants_file` | a grants `file` for a `module` (the digest in its name by default), optionally edited | each grant's reading by §7.9's check 1, or `error` | | `param_set` | a set `served` for a `capability` under a pin of `sha256`, the sink's `keys`, `statuses` and `grants` file, the origins it holds the capability's sets to §7.9 for (`hold_to`; absent, not held to it), and `now` (null: no trusted clock) | `held`, or `error` with §7.9's `check` and `reason`, or the origin a revocation is by | | `abr` | a `policy` and a `state` (Appendix F) | the `choice` and what the `budget` did, or `error` beyond Appendix F's bounds | @@ -194,7 +194,8 @@ adaptation cases they reach; `nonconforming-offered`, `conforming-withheld` and `bytes-unchecked`, `caps-unchecked`, `nothing-as-delivered`, `groups-from-listed`, `step6-skipped`, `decision-unchecked`, `recovery-record-unchecked`, `declaration-unnamed`, `first-publisher`, `matchers-need-object`, -`operating-point-unchecked`, `session-accepted`, `ancestor-any`, +`operating-point-unchecked`, `session-accepted`, `bytes-read-whole`, +`key-log-uncapped`, `ancestor-any`, `anchor-follows-redirect`, `cached-rollback-unchecked`, `grants-module-unchecked`, `set-grants-ignored` and `set-by-name` fail the receipt, key, grant and set cases of §8.1–§8.5; `falsify/run.sh` runs every one), and the job checks a differential: diff --git a/wai/http-conformance/cases.json b/wai/http-conformance/cases.json index f4c824639..218aa2ebf 100644 --- a/wai/http-conformance/cases.json +++ b/wai/http-conformance/cases.json @@ -1000,7 +1000,7 @@ {"name":"receipts_index_257_items","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","index":"cases/receipts/O9a-257-items.wai.receipts"}]},"expect":{"checks":[{"index":"receipt_index_too_large"}]}}, {"name":"receipts_index_257_claims","op":"receipts","input":{"checks":[{"object":"O9a","held":"whole","index":"cases/receipts/O9a-257-claims.wai.receipts"}]},"expect":{"checks":[{"index":"receipt_index_too_large"}]}}, {"name":"receipt_file_legacy_group","op":"receipt_file","input":{"file":"cases/rcpt/6a29eb0066513cad092217b4f905f3decfc703c081c832fb35e3e42f3d1c7043.json","kind":"group"},"expect":{"error":"receipt_legacy_group","step":null}}, -{"name":"receipts_fetch_bytes_exhausted","op":"receipts","input":{"serve":{"rcpt":["cases/rcpt-deep"],"pad_all":1048576},"checks":[{"object_cid":"f6fa1ca70e69bee70aa2bd36620b3e93a50ca2e110e1fbb91f3c6747f5deb860","held":"none","index":"cases/receipts/deep-64.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":63,"stop":"receipt_fetch_bytes_exhausted"}],"claims":[],"files_fetched":64}]}}, +{"name":"receipts_fetch_bytes_exhausted","op":"receipts","input":{"serve":{"rcpt":["cases/rcpt-deep"],"pad_all":1048576},"checks":[{"object_cid":"f6fa1ca70e69bee70aa2bd36620b3e93a50ca2e110e1fbb91f3c6747f5deb860","held":"none","index":"cases/receipts/deep-64.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"error":"parent_not_held","step":null,"walked":62,"stop":"receipt_fetch_bytes_exhausted"}],"claims":[],"files_fetched":64}]}}, {"name":"receipts_fetch_bytes_within","op":"receipts","input":{"serve":{"rcpt":["cases/rcpt-deep"],"pad_all":1000000},"checks":[{"object_cid":"f6fa1ca70e69bee70aa2bd36620b3e93a50ca2e110e1fbb91f3c6747f5deb860","held":"none","index":"cases/receipts/deep-64.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":64,"evidence":"inclusion-verified","established":null,"reading":null}],"claims":[],"files_fetched":65}]}}, {"name":"receipts_lawful_basis","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"]},"checks":[{"object":"O3","held":"whole","index":"cases/receipts/O3-lawful-basis.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"class":"lawful-basis","attests":"object","subject":"6e485f112e849422fb9cfd279cb16b88c8d5ee1e72bafe4892992fc6bef88be9","parent":null},{"error":"decision_purpose_not_declared","step":6},{"error":"decision_declaration_mismatch","step":6}],"files_fetched":6}]}}, {"name":"receipts_lawful_basis_declaration_not_served","op":"receipts","input":{"serve":{"rcpt":["objects/rcpt","cases/rcpt"],"omit":["13e1857af31da433ed26408d5cfdb8a486dfdef79026836dda43b4b7a790ac1e"]},"checks":[{"object":"O3","held":"whole","index":"cases/receipts/O3-lawful-basis.wai.receipts"}]},"expect":{"checks":[{"index":"ok","delivery":[{"admission":"accept","walked":0,"evidence":"delivered-verified","established":"envelope","reading":"signed class=Estimator by=af06a3e3291714e4f356c19c9b15cd1951ec6e6662aa77be07547f289383341d"}],"claims":[{"error":"context_not_held","step":6},{"error":"context_not_held","step":6},{"error":"decision_declaration_mismatch","step":6}],"files_fetched":6}]}}, @@ -1027,6 +1027,9 @@ {"name":"key_log_cached_newer","op":"key_log","input":{"steps":[{"anchor":"cases/keys/wai-keys-r0"},{"cached":".well-known/wai-keys"}]},"expect":{"steps":[{"anchored":"7f06fd9425a3583d2007074cb32c75514d34b11d064c88304c6e4d4d4cb5ab01","path":"fetched"},{"added":1}]}}, {"name":"key_log_cached_rolled_back","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys"},{"cached":"cases/keys/wai-keys-r0"}]},"expect":{"steps":[{"anchored":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","path":"fetched"},{"error":"key_log_rolled_back"}]}}, {"name":"key_log_cached_other_origin","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys"},{"cached":".well-known/wai-keys","origin":"https://other.example"}]},"expect":{"steps":[{"anchored":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","path":"fetched"},{"error":"origin_not_anchored"}]}}, +{"name":"key_log_anchor_at_its_cap","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","pad":1048576}]},"expect":{"steps":[{"anchored":"fab4c950188cdacaee2b290bda245bf8c7bbe5ba5f0549451a72a9241cf4da33","path":"fetched"}]}}, +{"name":"key_log_anchor_too_large","op":"key_log","input":{"steps":[{"anchor":".well-known/wai-keys","pad":1048577}]},"expect":{"steps":[{"error":"key_log_too_large"}]}}, +{"name":"key_log_cached_too_large","op":"key_log","input":{"steps":[{"anchor":"cases/keys/wai-keys-r0"},{"cached":".well-known/wai-keys","pad":1048577}]},"expect":{"steps":[{"anchored":"7f06fd9425a3583d2007074cb32c75514d34b11d064c88304c6e4d4d4cb5ab01","path":"fetched"},{"error":"key_log_too_large"}]}}, {"name":"grants_file","op":"grants_file","input":{"file":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json"},"expect":{"grants":["read","read"]}}, {"name":"grants_file_other_module","op":"grants_file","input":{"file":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","module":"0000000000000000000000000000000000000000000000000000000000000000"},"expect":{"error":"grants_file_malformed"}}, {"name":"grants_file_member_added","op":"grants_file","input":{"file":".well-known/wai-grants/7ef44f3b1fc4a2df6c3e3b68460f595cae3d31cb7f9018699e29e738340272a4.json","edit":[["{\"grants\"","{\"note\":1,\"grants\""]]},"expect":{"error":"grants_file_malformed"}}, diff --git a/wai/http-conformance/falsify/run.sh b/wai/http-conformance/falsify/run.sh index fe6d26275..cdf1d20e8 100755 --- a/wai/http-conformance/falsify/run.sh +++ b/wai/http-conformance/falsify/run.sh @@ -59,7 +59,7 @@ for m in left-half no-root counter-zero floor-as-codecs blake3-iv base-at-done t rcpt-name-unchecked walk-skipped nothing-as-delivered ancestor-any \ legacy-admitted memo-off walk-unclamped step6-skipped groups-from-listed budget-unchecked caps-unchecked \ bytes-unchecked decision-unchecked recovery-record-unchecked declaration-unnamed first-publisher matchers-need-object \ - operating-point-unchecked session-accepted \ + operating-point-unchecked session-accepted bytes-read-whole key-log-uncapped \ anchor-follows-redirect cached-rollback-unchecked grants-module-unchecked set-grants-ignored set-by-name; do if python3 http_verify.py . --mutate "$m" > "$WORK/py-$m.log" 2>&1; then echo "::error::http_verify.py --mutate $m passed"; fail=1 diff --git a/wai/http-conformance/http_receipts.py b/wai/http-conformance/http_receipts.py index ad322995e..812521973 100644 --- a/wai/http-conformance/http_receipts.py +++ b/wai/http-conformance/http_receipts.py @@ -35,6 +35,7 @@ MAX_INDEX_ITEMS = 256 # delivery items an index lists, at most MAX_INDEX_CLAIMS = 256 # claims an index lists, at most FETCH_BUDGET = 1024 # receipt files fetched for one index, at most MAX_INDEX_FETCH_BYTES = 64 << 20 # bytes of receipt files read for one index, at most +MAX_KEY_LOG_BYTES = 1 << 20 # a key log's bytes, at most (§8.4) INDEX_KIND = "wai-receipt-index" GRANTS_KIND = "wai-module-grants" ACQUIRED = "2026-10-06T00:00:00Z" @@ -128,9 +129,9 @@ def read_claim_file(name, b): class Fetcher: """One index check's receipt files: each name fetched once and what was served kept, at most a budget of fetches (never above FETCH_BUDGET) and of bytes read - (MAX_INDEX_FETCH_BYTES, counting at most one byte past the file cap of each file), - none over the file cap. The file that passes the bytes is refused, and no further - file is fetched.""" + (MAX_INDEX_FETCH_BYTES), none over the file cap. Each file is read only as far as + what remains of the bytes and of its cap plus one byte; the file that reaches the end + of the bytes is refused, and once they are spent no further file is fetched.""" PAST = object() @@ -142,13 +143,18 @@ class Fetcher: if h not in self.memo or MUTATE == "memo-off": if self.fetched >= self.budget and MUTATE != "budget-unchecked": raise Refused("receipt_fetch_budget_exhausted") - if self.bytes >= MAX_INDEX_FETCH_BYTES and MUTATE != "bytes-unchecked": + remaining = MAX_INDEX_FETCH_BYTES - self.bytes + if remaining <= 0 and MUTATE != "bytes-unchecked": raise Refused("receipt_fetch_bytes_exhausted") self.fetched += 1 + whole = MUTATE in ("bytes-unchecked", "bytes-read-whole") + limit = MAX_RECEIPT_FILE_BYTES + 1 if whole else min(remaining, MAX_RECEIPT_FILE_BYTES + 1) b = self.get_raw(h) if b is not None: - self.bytes += min(len(b), MAX_RECEIPT_FILE_BYTES + 1) - if self.bytes > MAX_INDEX_FETCH_BYTES and MUTATE != "bytes-unchecked": + b = b[:limit] + self.bytes += len(b) + past = self.bytes > MAX_INDEX_FETCH_BYTES if MUTATE == "bytes-read-whole" else len(b) == remaining + if past and MUTATE != "bytes-unchecked": b = Fetcher.PAST self.memo[h] = b b = self.memo[h] @@ -455,6 +461,8 @@ class KeyState: origin = s.get("origin", ORIGIN) f = s.get("anchor") or s.get("cached") or s.get("pin") body = edit_text(read_file(d, f).decode("utf-8"), s.get("edit")) + if s.get("pad") is not None: + body = body + " " * max(0, s["pad"] - len(body.encode("utf-8"))) if s.get("pin") is not None: out = SV.anchor_log(origin, body, "pinned", None) if out[0] != "anchored": @@ -473,6 +481,8 @@ class KeyState: status = r.get("status", 200) if not origin.startswith("https://") or requested != loc or (answered != loc and MUTATE != "anchor-follows-redirect") or status != 200: return {"error": "key_log_not_from_origin"} + if len(body.encode("utf-8")) > MAX_KEY_LOG_BYTES and MUTATE != "key-log-uncapped": + return {"error": "key_log_too_large"} current = (self.anchor, self.held) if self.anchor is not None else None out = SV.anchor_log(origin, body, "fetched", current) if out[0] == "not_anchored": @@ -489,6 +499,8 @@ class KeyState: def cached(self, origin, body): if self.anchor["origin"] != origin: return {"error": "origin_not_anchored"} + if len(body.encode("utf-8")) > MAX_KEY_LOG_BYTES and MUTATE != "key-log-uncapped": + return {"error": "key_log_too_large"} try: revs = SV.read_log(origin, body) except SV.Stop as e: diff --git a/wai/http-conformance/http_verify.py b/wai/http-conformance/http_verify.py index 1fbd2deb2..83f6b757c 100644 --- a/wai/http-conformance/http_verify.py +++ b/wai/http-conformance/http_verify.py @@ -30,7 +30,8 @@ stops the verifier, never passes. caps-unchecked | nothing-as-delivered | groups-from-listed | step6-skipped | decision-unchecked | recovery-record-unchecked | declaration-unnamed | first-publisher | matchers-need-object | operating-point-unchecked | - session-accepted | ancestor-any | anchor-follows-redirect | + session-accepted | bytes-read-whole | key-log-uncapped | ancestor-any | + anchor-follows-redirect | cached-rollback-unchecked | grants-module-unchecked | set-grants-ignored | set-by-name (falsify/run.sh runs every one) python3 http_verify.py cases FILE [dir] diff --git a/wai/wai-rs/src/bin/wai_cli/http.rs b/wai/wai-rs/src/bin/wai_cli/http.rs index 4ef2151c0..7beb60378 100644 --- a/wai/wai-rs/src/bin/wai_cli/http.rs +++ b/wai/wai-rs/src/bin/wai_cli/http.rs @@ -869,17 +869,99 @@ fn bench(args: &[String]) -> Result<(), String> { // ---------------------------------------------------------------- receipts (§8) /// A plain-HTTP `GET` of `path` from `addr` with `Host: host`, every step before -/// `deadline`, keeping at most `max_body` bytes of the body (one more marks it longer), -/// and following no redirect. The head may be at most 64 KiB. +/// `deadline` (the lookup, each address tried in turn, every read and write), keeping at +/// most `max_body` bytes of the body (one more marks it longer), and following no +/// redirect. The head may be at most 64 KiB. #[cfg(feature = "receipt_claims")] fn get_bounded(addr: &str, host: &str, path: &str, headers: &[(&str, &str)], deadline: Instant, max_body: usize) -> Result<(ResponseMeta, Vec), String> { - use std::net::ToSocketAddrs; - let sock = addr.to_socket_addrs().map_err(|e| format!("{addr}: {e}"))?.next().ok_or(format!("{addr}: no address"))?; - let mut s = TcpStream::connect_timeout(&sock, left(deadline)?).map_err(|e| format!("{addr}: {e}"))?; - s.set_write_timeout(Some(left(deadline)?)).map_err(|e| e.to_string())?; + let addrs = resolve(addr.to_string(), deadline)?; + let mut s = DeadlineSocket { tcp: connect_any(&addrs, deadline)?, deadline }; let req = request(path, host, headers); - exchange(&mut s, &mut |s: &mut TcpStream, d| s.set_read_timeout(Some(d)), req.as_bytes(), deadline, max_body, false) + exchange(&mut s, req.as_bytes(), deadline, max_body, false) +} + +/// A TCP connection every read and write of which is bounded by `deadline`: each call +/// waits at most what remains, and once the deadline has passed a call is `TimedOut` +/// without touching the socket. A peer that trickles bytes, or a TLS handshake that +/// loops over many reads, cannot extend it. +#[cfg(feature = "receipt_claims")] +struct DeadlineSocket { + tcp: TcpStream, + deadline: Instant, +} + +#[cfg(feature = "receipt_claims")] +impl DeadlineSocket { + fn remaining(&self) -> std::io::Result { + self.deadline + .checked_duration_since(Instant::now()) + .filter(|l| !l.is_zero()) + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::TimedOut, "the deadline passed")) + } +} + +#[cfg(feature = "receipt_claims")] +fn timed_out(e: std::io::Error) -> std::io::Error { + match e.kind() { + std::io::ErrorKind::WouldBlock => std::io::Error::new(std::io::ErrorKind::TimedOut, "the deadline passed"), + _ => e, + } +} + +#[cfg(feature = "receipt_claims")] +impl Read for DeadlineSocket { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let d = self.remaining()?; + self.tcp.set_read_timeout(Some(d))?; + self.tcp.read(buf).map_err(timed_out) + } +} + +#[cfg(feature = "receipt_claims")] +impl Write for DeadlineSocket { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let d = self.remaining()?; + self.tcp.set_write_timeout(Some(d))?; + self.tcp.write(buf).map_err(timed_out) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.tcp.flush() + } +} + +/// The addresses `target` (`host:port`) names, looked up within `deadline`: the lookup +/// runs on a thread of its own, and a lookup still running when the deadline passes is +/// left to finish unread. +#[cfg(feature = "receipt_claims")] +fn resolve(target: String, deadline: Instant) -> Result, String> { + use std::net::ToSocketAddrs; + let (tx, rx) = std::sync::mpsc::channel(); + let t = target.clone(); + std::thread::spawn(move || { + let _ = tx.send(t.to_socket_addrs().map(|a| a.collect::>())); + }); + match rx.recv_timeout(left(deadline)?) { + Ok(Ok(a)) if !a.is_empty() => Ok(a), + Ok(Ok(_)) => Err(format!("{target}: no address")), + Ok(Err(e)) => Err(format!("{target}: {e}")), + Err(_) => Err(format!("{target}: the deadline passed resolving it")), + } +} + +/// A connection to the first of `addrs` that answers, each tried in turn within +/// `deadline`. +#[cfg(feature = "receipt_claims")] +fn connect_any(addrs: &[std::net::SocketAddr], deadline: Instant) -> Result { + let mut last = "no address".to_string(); + for a in addrs { + match TcpStream::connect_timeout(a, left(deadline)?) { + Ok(s) => return Ok(s), + Err(e) => last = format!("{a}: {e}"), + } + } + Err(last) } /// What is left before `deadline`, or that it passed. @@ -899,9 +981,10 @@ fn request(path: &str, host: &str, headers: &[(&str, &str)]) -> String { req } -/// One exchange on `s`: send `req`, then read the response before `deadline`, `timeout` -/// setting the read timeout of the transport under `s`, keeping at most `max_body` bytes -/// of the body. A chunked body is decoded; a body framed by `Content-Length` that ends +/// One exchange on `s`, a transport whose every read and write is bounded by +/// `deadline` ([`DeadlineSocket`], or TLS over one): send `req`, then read the response, +/// keeping at most `max_body` bytes of the body, reading it no further than one byte +/// past that. A chunked body is decoded; a body framed by `Content-Length` that ends /// short is refused. Over TLS (`tls`), the end of a response is trusted only when the /// peer closed with `close_notify` or the body's own framing ended it: a connection cut /// before a framed body ends, or an unframed body without `close_notify`, is refused, so @@ -909,13 +992,12 @@ fn request(path: &str, host: &str, headers: &[(&str, &str)]) -> String { #[cfg(feature = "receipt_claims")] fn exchange( s: &mut S, - timeout: &mut dyn FnMut(&mut S, Duration) -> std::io::Result<()>, req: &[u8], deadline: Instant, max_body: usize, tls: bool, ) -> Result<(ResponseMeta, Vec), String> { - timeout(s, left(deadline)?).map_err(|e| e.to_string())?; + left(deadline)?; let io = |e: std::io::Error| match e.kind() { std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut => "the deadline passed".to_string(), _ => e.to_string(), @@ -928,8 +1010,14 @@ fn exchange( let mut capped = false; let mut clean_end = true; loop { - timeout(s, left(deadline)?).map_err(|e| e.to_string())?; - match s.read(&mut chunk) { + left(deadline)?; + // Before the head ends, small reads, so little of the body comes with it; after, + // no further than one byte past the body's cap. + let want = match head_end { + None => 1024, + Some(e) => (max_body + 1).saturating_sub(raw.len() - e).clamp(1, chunk.len()), + }; + match s.read(&mut chunk[..want]) { Ok(0) => break, Ok(n) => raw.extend_from_slice(&chunk[..n]), Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => { @@ -1036,7 +1124,7 @@ const MAX_OBJECT_RESPONSE: usize = 1 << 30; /// and its response. #[cfg(feature = "receipt_claims")] fn receipts_cmd(args: &[String]) -> Result { - use wai::http_binding::receipts::{check_index, Limits, ObjectHeld, ReceiptIndex, FETCH_BUDGET, MAX_INDEX_BYTES, MAX_RECEIPT_FILE_BYTES, PARENT_WALK_BOUND, RECEIPT_DIR}; + use wai::http_binding::receipts::{check_index, Limits, ObjectHeld, ReceiptIndex, FETCH_BUDGET, MAX_INDEX_BYTES, PARENT_WALK_BOUND, RECEIPT_DIR}; use wai::jwp_delivery::ReceiptChains; let (host, path, cid, frag) = object_url(opt(args, "--url").ok_or("receipts needs --url")?)?; let deadline_ms: u64 = opt(args, "--deadline-ms").map_or(Ok(60_000), str::parse).map_err(|_| "--deadline-ms")?; @@ -1080,8 +1168,10 @@ fn receipts_cmd(args: &[String]) -> Result { } // Receipt files resolve against the object's URI (§8.1). let dir = path.rsplit_once('/').map_or("", |(d, _)| d).to_string(); - let mut fetch = |h: &[u8; 32]| -> Option> { - match get_bounded(&host, &host, &format!("{dir}/{RECEIPT_DIR}{}.json", hex(h)), &[], deadline, MAX_RECEIPT_FILE_BYTES) { + // Each file is read no further than the fetcher allows: what remains of the index's + // bytes, and of the file's cap plus one byte (§8.1, §8.2). + let mut fetch = |h: &[u8; 32], limit: usize| -> Option> { + match get_bounded(&host, &host, &format!("{dir}/{RECEIPT_DIR}{}.json", hex(h)), &[], deadline, limit.saturating_sub(1)) { Ok((m, b)) => (m.status == 200).then_some(b), Err(e) => { late.set(late.get() || e.contains("deadline")); @@ -1216,14 +1306,17 @@ fn keys(args: &[String], deadline: Instant, report: &mut serde_json::Map wai::http_binding::keys::MAX_KEY_LOG_BYTES { + return refuse("key_log_too_large", "a key log longer than its cap".into()); + } let text = String::from_utf8_lossy(&body).into_owned(); - match anchor_log(origin, &text, AnchorPath::Fetched { acquired_at: now_rfc3339() }, None) { + match anchor_log(origin, &text, AnchorPath::TestPlaintext { acquired_at: now_rfc3339() }, None) { Anchoring::Anchored(a) => { - report.insert("anchor".into(), json!({ "revision": hex(&a.revision), "path": "test-plaintext", "relied_on": false, + report.insert("anchor".into(), json!({ "revision": hex(&a.revision), "path": a.path.label(), "relied_on": false, "note": "a plaintext retrieval made for tests: it anchors nothing a verifier may rely on" })); (text, a) } @@ -1302,18 +1395,17 @@ impl HttpsOrigin { #[cfg(all(feature = "receipt_claims", feature = "key_discovery", feature = "cli_tls"))] fn anchor_over_tls(origin: &str, deadline: Instant) -> Result<(String, wai::key_log::Anchor), AnchorRefusal> { use rustls_platform_verifier::BuilderVerifierExt; - use std::net::ToSocketAddrs; let refuse = |why: String| AnchorRefusal { code: "key_log_not_from_origin".into(), why }; let o = HttpsOrigin::parse(origin).ok_or_else(|| refuse("not an https origin: https:// and a host, with no path".into()))?; - let addr = (o.host.as_str(), o.port).to_socket_addrs().map_err(|e| refuse(format!("{}: {e}", o.authority)))?.next() - .ok_or_else(|| refuse(format!("{}: no address", o.authority)))?; + let target = if o.host.contains(':') { format!("[{}]:{}", o.host, o.port) } else { format!("{}:{}", o.host, o.port) }; + let addrs = resolve(target, deadline).map_err(refuse)?; let provider = std::sync::Arc::new(rustls::crypto::ring::default_provider()); let config = rustls::ClientConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() .and_then(|b| b.with_platform_verifier()) .map_err(|e| refuse(format!("TLS: {e}")))? .with_no_client_auth(); - anchor_from(&o, addr, std::sync::Arc::new(config), deadline) + anchor_from(&o, &addrs, std::sync::Arc::new(config), deadline) } /// A build without TLS never anchors an origin: it would have to fetch the log over @@ -1326,21 +1418,23 @@ fn anchor_over_tls(_: &str, _: Instant) -> Result<(String, wai::key_log::Anchor) }) } -/// §8.4: `GET` the key log of `o` over TLS from `addr`, SNI and the certificate check -/// naming the origin's host under `config`, and anchor the origin from it. The retrieval -/// is what this request asked for and what answered it on the same connection: the -/// origin's key-log location, the status received, no redirect followed. +/// §8.4: `GET` the key log of `o` over TLS from the first of `addrs` that answers, SNI +/// and the certificate check naming the origin's host under `config`, and anchor the +/// origin from it. The retrieval is what this request asked for and what answered it on +/// the same connection: the origin's key-log location, the status received, no redirect +/// followed. A log longer than its cap is `key_log_too_large`, read no further than one +/// byte past it. #[cfg(all(feature = "receipt_claims", feature = "key_discovery", feature = "cli_tls"))] fn anchor_from( o: &HttpsOrigin, - addr: std::net::SocketAddr, + addrs: &[std::net::SocketAddr], config: std::sync::Arc, deadline: Instant, ) -> Result<(String, wai::key_log::Anchor), AnchorRefusal> { - use wai::http_binding::keys::{anchor_retrieved, anchoring_request_headers, Retrieval, KEY_LOG_PATH}; + use wai::http_binding::keys::{anchor_retrieved, anchoring_request_headers, Retrieval, KEY_LOG_PATH, MAX_KEY_LOG_BYTES}; let refuse = |why: String| AnchorRefusal { code: "key_log_not_from_origin".into(), why }; - let (meta, body) = get_tls(addr, &o.host, &o.authority, KEY_LOG_PATH, &anchoring_request_headers(), deadline, - wai::http_binding::receipts::MAX_RECEIPT_FILE_BYTES, config).map_err(refuse)?; + let (meta, body) = get_tls(addrs, &o.host, &o.authority, KEY_LOG_PATH, &anchoring_request_headers(), deadline, + MAX_KEY_LOG_BYTES, config).map_err(refuse)?; let location = o.key_log_location(); let retrieval = Retrieval { requested: &location, answered: &location, status: meta.status }; let origin = format!("https://{}", o.authority); @@ -1355,7 +1449,7 @@ fn anchor_from( #[cfg(all(feature = "receipt_claims", feature = "cli_tls"))] #[allow(clippy::too_many_arguments)] fn get_tls( - addr: std::net::SocketAddr, + addrs: &[std::net::SocketAddr], host: &str, authority: &str, path: &str, @@ -1365,14 +1459,11 @@ fn get_tls( config: std::sync::Arc, ) -> Result<(ResponseMeta, Vec), String> { let name = rustls::pki_types::ServerName::try_from(host.to_string()).map_err(|_| format!("{host}: not a server name"))?; - let tcp = TcpStream::connect_timeout(&addr, left(deadline)?).map_err(|e| format!("{addr}: {e}"))?; - tcp.set_write_timeout(Some(left(deadline)?)).map_err(|e| e.to_string())?; + let tcp = DeadlineSocket { tcp: connect_any(addrs, deadline)?, deadline }; let conn = rustls::ClientConnection::new(config, name).map_err(|e| format!("TLS: {e}"))?; let mut s = rustls::StreamOwned::new(conn, tcp); let req = request(path, authority, headers); - exchange(&mut s, &mut |s: &mut rustls::StreamOwned, d| s.sock.set_read_timeout(Some(d)), - req.as_bytes(), deadline, max_body, true) - .map_err(|e| format!("TLS: {e}")) + exchange(&mut s, req.as_bytes(), deadline, max_body, true).map_err(|e| format!("TLS: {e}")) } #[cfg(all(feature = "receipt_claims", not(feature = "key_discovery")))] @@ -1443,6 +1534,12 @@ mod tls_tests { Unframed, /// No TLS at all: a plain-HTTP server. Plain, + /// A peer that reads the ClientHello and then sends a byte at a time, slowly, + /// never finishing the handshake. + Drip, + /// The key log padded past its cap, `Content-Length` framed, closed with + /// `close_notify`. + OverCap, } /// A server with the certificate of `name` answering one connection as `mode` says; @@ -1462,6 +1559,20 @@ mod tls_tests { let _ = tx.send((None, first[..n].to_vec())); return; } + if let Mode::Drip = mode { + let mut first = [0u8; 512]; + let n = tcp.read(&mut first).unwrap_or(0); + let _ = tx.send((None, first[..n].to_vec())); + // A handshake record's header, then its body a byte at a time: every read + // the client makes gets a byte before any timeout of its own runs out. + for b in [0x16u8, 0x03, 0x03, 0x40, 0x00].into_iter().chain(std::iter::repeat(0x02).take(100)) { + if tcp.write_all(&[b]).is_err() { + return; + } + std::thread::sleep(Duration::from_millis(300)); + } + return; + } let config = rustls::ServerConfig::builder_with_provider(provider()) .with_safe_default_protocol_versions() .expect("versions") @@ -1484,6 +1595,11 @@ mod tls_tests { let sni = s.conn.server_name().map(String::from); let answer: Vec = match mode { Mode::Log => [format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len()).into_bytes(), body].concat(), + Mode::OverCap => { + let mut b = body; + b.resize(1_048_621, b' '); + [format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", b.len()).into_bytes(), b].concat() + } Mode::Redirect => b"HTTP/1.1 302 Found\r\nLocation: https://mirror.example/.well-known/wai-keys\r\nContent-Length: 0\r\n\r\n".to_vec(), _ => [b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n".to_vec(), body].concat(), }; @@ -1508,7 +1624,7 @@ mod tls_tests { #[test] fn an_origin_anchors_from_a_tls_retrieval_it_made() { let (addr, seen) = server("origin", Mode::Log); - let (text, anchor) = anchor_from(&origin(), addr, client(), deadline()).unwrap_or_else(|r| panic!("{}: {}", r.code, r.why)); + let (text, anchor) = anchor_from(&origin(), &[addr], client(), deadline()).unwrap_or_else(|r| panic!("{}: {}", r.code, r.why)); assert_eq!(anchor.path.label(), "fetched"); assert!(text.contains("wai-key-log")); let (sni, head) = seen.recv().expect("the server's view"); @@ -1521,7 +1637,7 @@ mod tls_tests { #[test] fn a_certificate_for_another_host_anchors_nothing() { let (addr, _) = server("other", Mode::Log); - let r = anchor_from(&origin(), addr, client(), deadline()).expect_err("another host's certificate"); + let r = anchor_from(&origin(), &[addr], client(), deadline()).expect_err("another host's certificate"); assert_eq!(r.code, "key_log_not_from_origin"); assert!(r.why.contains("certificate"), "{}", r.why); } @@ -1529,7 +1645,7 @@ mod tls_tests { #[test] fn a_plaintext_server_is_never_read() { let (addr, seen) = server("origin", Mode::Plain); - let r = anchor_from(&origin(), addr, client(), deadline()).expect_err("no TLS"); + let r = anchor_from(&origin(), &[addr], client(), deadline()).expect_err("no TLS"); assert_eq!(r.code, "key_log_not_from_origin"); let (_, first) = seen.recv().expect("the server's view"); assert_eq!(first.first(), Some(&0x16), "the client opened with a TLS handshake, never a plaintext request"); @@ -1538,7 +1654,7 @@ mod tls_tests { #[test] fn a_redirect_over_tls_anchors_nothing() { let (addr, _) = server("origin", Mode::Redirect); - let r = anchor_from(&origin(), addr, client(), deadline()).expect_err("a redirect"); + let r = anchor_from(&origin(), &[addr], client(), deadline()).expect_err("a redirect"); assert_eq!(r.code, "key_log_not_from_origin"); assert!(r.why.contains("200"), "{}", r.why); } @@ -1546,10 +1662,44 @@ mod tls_tests { #[test] fn a_response_whose_end_is_not_authenticated_anchors_nothing() { let (addr, _) = server("origin", Mode::Unframed); - let r = anchor_from(&origin(), addr, client(), deadline()).expect_err("cut without close_notify"); + let r = anchor_from(&origin(), &[addr], client(), deadline()).expect_err("cut without close_notify"); assert!(r.why.contains("close_notify"), "{}", r.why); } + /// The deadline bounds the whole exchange, the handshake's many reads among them: a + /// peer that sends a byte before each read's own wait runs out still ends at it. + #[test] + fn a_trickling_peer_ends_at_the_deadline() { + let (addr, _) = server("origin", Mode::Drip); + let r = anchor_from(&origin(), &[addr], client(), Instant::now() + Duration::from_millis(1500)).expect_err("a peer that never finishes"); + assert_eq!(r.code, "key_log_not_from_origin"); + assert!(r.why.contains("deadline"), "{}", r.why); + } + + /// A key log longer than its cap is refused, read no further than one byte past it, + /// however it is framed and closed. + #[test] + fn a_key_log_past_its_cap_anchors_nothing() { + let (addr, _) = server("origin", Mode::OverCap); + let r = anchor_from(&origin(), &[addr], client(), deadline()).expect_err("a log past its cap"); + assert_eq!(r.code, "key_log_too_large", "{}", r.why); + } + + /// Each address a name resolves to is tried in turn: a server on 127.0.0.1 alone is + /// reached through `localhost`, whichever address the lookup gives first. + #[test] + fn every_address_is_tried_in_turn() { + let l = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = l.local_addr().expect("addr").port(); + let addrs = resolve(format!("localhost:{port}"), deadline()).expect("localhost resolves"); + let mut tried = addrs.clone(); + tried.retain(|a| a.is_ipv6()); + tried.extend(addrs.iter().filter(|a| a.is_ipv4())); + assert!(connect_any(&tried, deadline()).is_ok(), "{tried:?}"); + let dead = TcpListener::bind("127.0.0.1:0").expect("bind").local_addr().expect("addr"); + assert!(connect_any(&[dead, l.local_addr().expect("addr")], deadline()).is_ok(), "a refused address is passed over"); + } + #[test] fn an_https_origin_is_a_host_and_a_port() { let o = |s: &str| HttpsOrigin::parse(s).map(|o| (o.host, o.port, o.authority)); diff --git a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs index 12a559822..cf62eeb16 100644 --- a/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs +++ b/wai/wai-rs/src/bin/wai_cli/http_vectors_receipts.rs @@ -789,7 +789,7 @@ mod built { } } let (h, fetched) = held(c, chk, &bytes)?; - let mut fetch = |x: &[u8; 32]| served.get(x); + let mut fetch = |x: &[u8; 32], _: usize| served.get(x); let items: Vec> = ReceiptIndex::from_bytes(&ix) .map(|i| i.delivery.into_iter().map(Result::ok).collect()) .unwrap_or_default(); @@ -838,7 +838,13 @@ mod built { fn step(&mut self, c: &Corpus, s: &Value) -> Result, Stop> { let origin = s["origin"].as_str().unwrap_or(ORIGIN); let file = s["anchor"].as_str().or(s["cached"].as_str()).or(s["pin"].as_str()).ok_or("a step names a log")?; - let body = edit(text(c, file)?, &s["edit"])?; + let mut body = edit(text(c, file)?, &s["edit"])?; + // `pad`: the log served with spaces after it, to a length in bytes. + if let Some(n) = s["pad"].as_u64() { + while body.len() < n as usize { + body.push(' '); + } + } if !s["pin"].is_null() { let a = match wai::key_log::anchor_log(origin, &body, wai::key_log::AnchorPath::Pinned, None) { wai::key_log::Anchoring::Anchored(a) => a, @@ -1167,6 +1173,12 @@ mod built { v.push(case("key_log_cached_newer", "key_log", json!({ "steps": [{ "anchor": OLDER_LOG }, { "cached": KEY_LOG }] }))); v.push(case("key_log_cached_rolled_back", "key_log", json!({ "steps": [{ "anchor": KEY_LOG }, { "cached": OLDER_LOG }] }))); v.push(case("key_log_cached_other_origin", "key_log", json!({ "steps": [{ "anchor": KEY_LOG }, { "cached": KEY_LOG, "origin": "https://other.example" }] }))); + // §8.4: a key log is read to 1 MiB; a longer one is refused, anchored or cached. + let cap = wai::http_binding::keys::MAX_KEY_LOG_BYTES; + v.push(case("key_log_anchor_at_its_cap", "key_log", json!({ "steps": [{ "anchor": KEY_LOG, "pad": cap }] }))); + v.push(refused_step("key_log_anchor_too_large", json!({ "steps": [{ "anchor": KEY_LOG, "pad": cap + 1 }] }), "key_log_too_large")); + v.push(refused_step("key_log_cached_too_large", json!({ "steps": [{ "anchor": OLDER_LOG }, { "cached": KEY_LOG, "pad": cap + 1 }] }), + "key_log_too_large")); // §8.4: the grants file, a locator. let gfile = format!(".well-known/wai-grants/{digest}.json"); diff --git a/wai/wai-rs/src/http_binding/keys.rs b/wai/wai-rs/src/http_binding/keys.rs index ad1572bc8..68832c797 100644 --- a/wai/wai-rs/src/http_binding/keys.rs +++ b/wai/wai-rs/src/http_binding/keys.rs @@ -38,6 +38,13 @@ pub use crate::signer_id::{key_log_url, KEY_LOG_PATH}; /// Where an origin serves its key status (§8.4). pub const STATUS_PATH: &str = "/.well-known/wai-key-status"; + +/// The most bytes of a key log a client reads (§8.4); a longer one is +/// `key_log_too_large`, whatever its first bytes say. A log is a few revisions of a few +/// keys each, kilobytes long; the cap bounds what one retrieval costs. +/// +/// cbindgen:ignore +pub const MAX_KEY_LOG_BYTES: usize = 1 << 20; /// Where an origin serves its module grants, one file per module digest (§8.4). pub const GRANTS_PATH: &str = "/.well-known/wai-grants/"; /// `kind` of a module grants file (§8.4). @@ -83,6 +90,8 @@ pub enum KeysRefusal { /// §8.4: a grants file not of its form, or for another module than the one it is /// fetched for (`grants_file_malformed`). GrantsFileMalformed(&'static str), + /// §8.4: a key log longer than [`MAX_KEY_LOG_BYTES`] (`key_log_too_large`). + TooLarge, /// jwp-receipts §7.3, §7.7: the log's own reason. Log(Reason), } @@ -93,6 +102,7 @@ impl KeysRefusal { match self { KeysRefusal::NotFromOrigin(_) => "key_log_not_from_origin", KeysRefusal::GrantsFileMalformed(_) => "grants_file_malformed", + KeysRefusal::TooLarge => "key_log_too_large", KeysRefusal::Log(r) => r.label(), } } @@ -103,6 +113,7 @@ impl std::fmt::Display for KeysRefusal { match self { KeysRefusal::NotFromOrigin(why) | KeysRefusal::GrantsFileMalformed(why) => write!(f, "{}: {why}", self.code()), KeysRefusal::Log(r) => f.write_str(r.label()), + KeysRefusal::TooLarge => f.write_str(self.code()), } } } @@ -123,7 +134,8 @@ pub struct Retrieval<'a> { /// `retrieval` states at `acquired_at`, the verifier holding `current` (its anchor and /// revisions) where it has one. Refused (`key_log_not_from_origin`) unless `origin` is an /// `https` origin, the request was for its key-log location ([`key_log_url`]), and the -/// answer is a `200` from that same URL. Then §7.3's checks and moves, as `key_log` +/// answer is a `200` from that same URL; a body longer than [`MAX_KEY_LOG_BYTES`] is +/// `key_log_too_large`. Then §7.3's checks and moves, as `key_log` /// makes them: the log's reason, `key_log_rolled_back` for an older copy, and /// `key_log_replaced` for a log that neither contains the anchor nor supersedes it. pub fn anchor_retrieved( @@ -146,6 +158,9 @@ pub fn anchor_retrieved( if retrieval.status != 200 { return Err(KeysRefusal::NotFromOrigin("the answer is not a 200")); } + if body.len() > MAX_KEY_LOG_BYTES { + return Err(KeysRefusal::TooLarge); + } let text = std::str::from_utf8(body).map_err(|_| KeysRefusal::Log(Reason::KeyLogMalformed))?; match anchor_log(origin, text, AnchorPath::Fetched { acquired_at: acquired_at.into() }, current) { Anchoring::Anchored(a) => Ok(a), @@ -167,6 +182,9 @@ pub fn hold_cached_revisions(origin: &str, body: &[u8], anchor: &Anchor, held: & if anchor.origin != origin { return Err(KeysRefusal::Log(Reason::OriginNotAnchored)); } + if body.len() > MAX_KEY_LOG_BYTES { + return Err(KeysRefusal::TooLarge); + } let text = std::str::from_utf8(body).map_err(|_| KeysRefusal::Log(Reason::KeyLogMalformed))?; let revs = read_log(origin, text).map_err(KeysRefusal::Log)?; let by: BTreeMap<[u8; 32], &Revision> = diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs index 83cd7ccb6..bdec9436b 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs @@ -95,11 +95,12 @@ pub const MAX_INDEX_CLAIMS: usize = 256; /// cbindgen:ignore pub const FETCH_BUDGET: usize = 1024; -/// The most bytes of receipt files a client reads to check one index (§8.2), counting -/// what it read of each file, at most one byte past [`MAX_RECEIPT_FILE_BYTES`]; once -/// they are read, a fetch is `receipt_fetch_bytes_exhausted`. Without it, -/// [`FETCH_BUDGET`] files at their cap would let one index make a client read and hold -/// about a gibibyte. A client MAY hold a lower budget, never a higher one. +/// The most bytes of receipt files a client reads to check one index (§8.2). Each file +/// is read only as far as what remains of this budget and of its own cap (one byte past +/// [`MAX_RECEIPT_FILE_BYTES`]); a file that reaches the end of the budget is refused, +/// and once the budget is spent no file is fetched (`receipt_fetch_bytes_exhausted`). +/// Without it, [`FETCH_BUDGET`] files at their cap would let one index make a client +/// read and hold about a gibibyte. A client MAY hold a lower budget, never a higher one. /// /// cbindgen:ignore pub const MAX_INDEX_FETCH_BYTES: usize = 64 << 20; @@ -255,7 +256,7 @@ pub fn claim_file(c: &ClaimReceipt) -> Vec { /// [`FETCH_BUDGET`]) and of bytes ([`MAX_INDEX_FETCH_BYTES`]), and a file over /// [`MAX_RECEIPT_FILE_BYTES`] refused. pub struct ReceiptFetcher<'f> { - fetch: &'f mut dyn FnMut(&[u8; 32]) -> Option>, + fetch: &'f mut dyn FnMut(&[u8; 32], usize) -> Option>, memo: BTreeMap<[u8; 32], Served>, fetched: usize, budget: usize, @@ -266,13 +267,15 @@ pub struct ReceiptFetcher<'f> { enum Served { Bytes(Vec), Nothing, - /// The file that took the index past its bytes. + /// The file that reached the end of the index's bytes. PastBytes, } impl<'f> ReceiptFetcher<'f> { - /// A fetcher over `fetch` with `budget` fetches, at most [`FETCH_BUDGET`]. - pub fn new(fetch: &'f mut dyn FnMut(&[u8; 32]) -> Option>, budget: usize) -> Self { + /// A fetcher over `fetch` with `budget` fetches, at most [`FETCH_BUDGET`]. `fetch` + /// gives what is served at a name, reading at most the number of bytes it is given; + /// anything more it returns is taken as unread. + pub fn new(fetch: &'f mut dyn FnMut(&[u8; 32], usize) -> Option>, budget: usize) -> Self { ReceiptFetcher { fetch, memo: BTreeMap::new(), fetched: 0, budget: budget.min(FETCH_BUDGET), bytes: 0 } } @@ -282,24 +285,28 @@ impl<'f> ReceiptFetcher<'f> { } /// The file named `h`: `receipt_not_served`, `receipt_too_large`, or, past the - /// budget, `receipt_fetch_budget_exhausted`, and past the bytes, which are counted - /// as a client reads them (at most one byte past the file cap of each), - /// `receipt_fetch_bytes_exhausted`: the file that passes them is refused, and no - /// further file is fetched. + /// budget, `receipt_fetch_budget_exhausted`. Each fetch is asked for no more than + /// what remains of the bytes ([`MAX_INDEX_FETCH_BYTES`]) and of the file's cap plus + /// one byte, so the bytes read never pass the budget; a file that reaches the end of + /// the bytes is refused (`receipt_fetch_bytes_exhausted`), since whether it ends + /// there is not read, and once they are spent no further file is fetched. pub fn get(&mut self, h: &[u8; 32]) -> Result, ReceiptRefusal> { if !self.memo.contains_key(h) { if self.fetched >= self.budget { return Err(ReceiptRefusal::BudgetExhausted); } - if self.bytes >= MAX_INDEX_FETCH_BYTES { + let remaining = MAX_INDEX_FETCH_BYTES.saturating_sub(self.bytes); + if remaining == 0 { return Err(ReceiptRefusal::BytesExhausted); } self.fetched += 1; - let served = match (self.fetch)(h) { + let limit = remaining.min(MAX_RECEIPT_FILE_BYTES + 1); + let served = match (self.fetch)(h, limit) { None => Served::Nothing, - Some(b) => { - self.bytes += b.len().min(MAX_RECEIPT_FILE_BYTES + 1); - if self.bytes > MAX_INDEX_FETCH_BYTES { Served::PastBytes } else { Served::Bytes(b) } + Some(mut b) => { + b.truncate(limit); + self.bytes += b.len(); + if b.len() == remaining { Served::PastBytes } else { Served::Bytes(b) } } }; self.memo.insert(*h, served); @@ -795,14 +802,15 @@ impl Default for Limits { /// whole when it does not read (`receipt_index_malformed`), is too large /// (`receipt_index_too_large`) or names another object (`receipt_not_about_object`); /// otherwise each delivery item is checked in order ([`check_delivery`]), then each claim -/// ([`check_claim`]) against the items that passed. Each name is fetched once, and at -/// most `limits.fetch_budget` files in all. +/// ([`check_claim`]) against the items that passed. Each name is fetched once, at most +/// `limits.fetch_budget` files and [`MAX_INDEX_FETCH_BYTES`] bytes in all; `fetch` is +/// given, with each name, the most bytes it may read of it. #[allow(clippy::too_many_arguments)] pub fn check_index( cid: &[u8; 32], index: &[u8], held: &ObjectHeld, - fetch: &mut dyn FnMut(&[u8; 32]) -> Option>, + fetch: &mut dyn FnMut(&[u8; 32], usize) -> Option>, chains: &mut ReceiptChains, now_ms: u64, limits: &Limits, diff --git a/wai/wai-rs/src/http_binding/tests_receipts.rs b/wai/wai-rs/src/http_binding/tests_receipts.rs index aa73d9791..3ed7b798a 100644 --- a/wai/wai-rs/src/http_binding/tests_receipts.rs +++ b/wai/wai-rs/src/http_binding/tests_receipts.rs @@ -92,7 +92,7 @@ fn check( ) -> Result { let cid = content_hash(&t.envs[i]); let index = index_file(&cid, &[item(&t.groups[i], &t.objects[i])], &[]).unwrap(); - let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned(); let r = check_index(&cid, &index, held, &mut fetch, chains, 0, &Limits { max_walk: bound, fetch_budget: FETCH_BUDGET }, None).unwrap(); r.delivery.into_iter().next().unwrap() } @@ -105,7 +105,7 @@ fn deliver( files: &BTreeMap<[u8; 32], Vec>, chains: &mut ReceiptChains, ) -> Result { - let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned(); let mut f = ReceiptFetcher::new(&mut fetch, FETCH_BUDGET); check_delivery(cid, it, held, &mut f, chains, 0, PARENT_WALK_BOUND) } @@ -117,7 +117,7 @@ fn claim_of( name: &[u8; 32], files: &BTreeMap<[u8; 32], Vec>, ) -> Result { - let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned(); let mut f = ReceiptFetcher::new(&mut fetch, FETCH_BUDGET); check_claim(cid, passed, &ObjectHeld::Nothing, name, &mut f, None) } @@ -222,7 +222,7 @@ fn an_index_reads_item_by_item() { let bad_property = text.replacen("\"acquisition\":\"\"", "\"acquisition\":0", 1); assert_eq!(ReceiptIndex::from_bytes(bad_property.as_bytes()).unwrap().delivery[0].as_ref().unwrap_err().code(), "property_invalid"); // An index about another object is refused whole. - let mut fetch = |h: &[u8; 32]| t.files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| t.files.get(h).cloned(); let other = content_hash(b"another"); let e = check_index(&other, text.as_bytes(), &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap_err(); assert_eq!(e, ReceiptRefusal::NotAboutObject); @@ -273,7 +273,7 @@ fn an_unrecovered_parent_is_parent_not_held() { let mut files = other.files.clone(); files.insert(g.link_hash(), group_file(&g).unwrap()); let index = index_file(&o[0].content_hash, &[item(&g, &o[0])], &[]).unwrap(); - let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned(); let r = check_index(&o[0].content_hash, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap(); assert_eq!(r.delivery[0].as_ref().unwrap_err(), &pnh(1, "another_chain")); } @@ -355,7 +355,7 @@ fn claims_are_checked_by_subject_and_parent() { } let names = [c0.link_hash(), c1.link_hash(), ancestor.link_hash(), elsewhere.link_hash(), [8; 32]]; let index = index_file(&cid, &[item(&t.groups[0], &t.objects[0])], &names).unwrap(); - let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned(); let r = check_index(&cid, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap(); let got: Vec), &'static str>> = r.claims.iter().map(|c| c.as_ref().map(|v| (v.attests, v.parent)).map_err(ReceiptRefusal::code)).collect(); @@ -461,7 +461,7 @@ fn step_6_runs_against_the_items_that_passed() { it.property.acquisition_evidence = crate::jwp_delivery::property_evidence(&chip()); let index = index_file(&cid, &[it], &names).unwrap(); let run = |held: &ObjectHeld| { - let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned(); let r = check_index(&cid, &index, held, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap(); assert!(r.delivery[0].is_ok(), "{:?}", r.delivery[0]); r.claims.iter().map(|c| c.as_ref().map(|v| v.attests.label()).map_err(|e| (e.code(), e.step()))).collect::>() @@ -500,7 +500,7 @@ fn a_claim_about_a_receipt_needs_an_item_that_passed() { files.insert(about_b.link_hash(), claim_file(&about_b)); // B's item listed in A's index: refused, so B's group is no group of A's. let index = index_file(&cid, &[item(&b.groups[0], &b.objects[0]), item(&a.groups[0], &a.objects[0])], &[about_b.link_hash()]).unwrap(); - let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned(); let r = check_index(&cid, &index, &whole(&a.envs[0]), &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap(); assert_eq!(r.delivery[0].as_ref().unwrap_err(), &ReceiptRefusal::NotAboutObject); assert!(r.delivery[1].is_ok()); @@ -526,7 +526,7 @@ fn a_soft_binding_attests_an_ancestor_only_through_recovery() { let exact = crate::claim_receipt::ExactMatch { alg: "org.example.fp".into() }; let matchers: [&dyn crate::claim_receipt::SoftBindingMatcher; 1] = [&exact]; let run = |held: &ObjectHeld, rec: Option<&Recovery<'_>>| { - let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned(); let r = check_index(&cid, &index, held, &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), rec).unwrap(); r.claims.iter().map(|c| c.as_ref().map(|v| v.attests.label()).map_err(|e| e.code())).collect::>() }; @@ -558,7 +558,7 @@ fn a_legacy_group_is_refused_over_http() { } let files: BTreeMap<[u8; 32], Vec> = [(g.link_hash(), group_file(&forged).unwrap())].into(); let index = index_file(&o[0].content_hash, &[item(&g, &o[0])], &[]).unwrap(); - let mut fetch = |h: &[u8; 32]| files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| files.get(h).cloned(); let r = check_index(&o[0].content_hash, &index, &whole(&env), &mut fetch, &mut ReceiptChains::new(), 0, &Limits::default(), None).unwrap(); assert_eq!(r.delivery[0].as_ref().unwrap_err(), &ReceiptRefusal::LegacyGroup); } @@ -571,7 +571,7 @@ fn the_walk_is_bounded_at_64() { let at = |i: usize| { let cid = content_hash(&t.envs[i]); let index = index_file(&cid, &[item(&t.groups[i], &t.objects[i])], &[]).unwrap(); - let mut fetch = |h: &[u8; 32]| t.files.get(h).cloned(); + let mut fetch = |h: &[u8; 32], _: usize| t.files.get(h).cloned(); check_index(&cid, &index, &ObjectHeld::Nothing, &mut fetch, &mut ReceiptChains::new(), 0, &wide, None).unwrap().delivery.remove(0) }; // 64 parents reach the chain's start; 65 do not. @@ -591,7 +591,7 @@ fn one_index_fetches_each_name_once_within_its_bounds() { let count = std::cell::Cell::new(0usize); let run = |limits: &Limits, files: &BTreeMap<[u8; 32], Vec>| { count.set(0); - let mut fetch = |h: &[u8; 32]| { + let mut fetch = |h: &[u8; 32], _: usize| { count.set(count.get() + 1); files.get(h).cloned() }; @@ -606,7 +606,7 @@ fn one_index_fetches_each_name_once_within_its_bounds() { assert_eq!((r.fetched, count.get()), (10, 10)); assert_eq!(r.delivery[0].as_ref().unwrap_err(), &ReceiptRefusal::ParentNotHeld { walked: 9, stop: "receipt_fetch_budget_exhausted".into() }); // A budget above the protocol's is the protocol's. - assert_eq!(ReceiptFetcher::new(&mut |_: &[u8; 32]| None, usize::MAX).get(&[0; 32]), Err(ReceiptRefusal::NotServed)); + assert_eq!(ReceiptFetcher::new(&mut |_: &[u8; 32], _: usize| None, usize::MAX).get(&[0; 32]), Err(ReceiptRefusal::NotServed)); // A receipt file over its cap. let mut big = t.files.clone(); let mut padded = big[&t.groups[last].link_hash()].clone(); @@ -624,32 +624,49 @@ fn one_index_fetches_each_name_once_within_its_bounds() { assert!(ReceiptIndex::from_bytes(&index_file(&cid, &many[1..], &claims[1..]).unwrap()).is_ok()); } -/// §8.2: the bytes read for one index are bounded too, counting at most a byte past the -/// file cap of each file: the file that passes them is refused, and none is fetched after. +/// §8.2: the bytes read for one index are bounded too. Each fetch is asked for no more +/// than what remains of them (and of the file's cap plus one byte), so what is read never +/// passes them; the file that reaches their end is refused, and none is fetched after. #[test] fn the_bytes_read_for_one_index_are_bounded() { - let count = std::cell::Cell::new(0usize); - let mut fetch = |h: &[u8; 32]| { - count.set(count.get() + 1); - Some(vec![b' '; if h[0] == 0xff { 10 * MAX_RECEIPT_FILE_BYTES } else { MAX_RECEIPT_FILE_BYTES }]) + let cap = MAX_RECEIPT_FILE_BYTES; + let full = MAX_INDEX_FETCH_BYTES / cap; + let asked = std::cell::RefCell::new(Vec::new()); + let read = std::cell::Cell::new(0usize); + // A fetch that reads no more than it is asked for. + let mut fetch = |h: &[u8; 32], limit: usize| { + asked.borrow_mut().push(limit); + let n = (if h[0] == 0xff { 10 * cap } else { cap }).min(limit); + read.set(read.get() + n); + Some(vec![b' '; n]) }; let mut f = ReceiptFetcher::new(&mut fetch, FETCH_BUDGET); - let full = MAX_INDEX_FETCH_BYTES / MAX_RECEIPT_FILE_BYTES; - for i in 0..full { + for i in 0..full - 1 { assert!(f.get(&[i as u8; 32]).is_ok(), "file {i}"); } + assert_eq!(f.get(&[(full - 1) as u8; 32]), Err(ReceiptRefusal::BytesExhausted), "the file that reaches the end"); + assert_eq!(asked.borrow().last(), Some(&cap), "asked for what remained, not the cap plus one"); + let n = asked.borrow().len(); assert_eq!(f.get(&[0xfe; 32]), Err(ReceiptRefusal::BytesExhausted)); - assert_eq!(count.get(), full, "nothing is fetched once the bytes are read"); + assert_eq!(asked.borrow().len(), n, "nothing is fetched once the bytes are spent"); + assert_eq!(read.get(), MAX_INDEX_FETCH_BYTES, "what was read never passes the budget"); assert!(f.get(&[0; 32]).is_ok(), "a name already fetched is kept"); - // An oversized file counts what a client reads of it, one byte past the cap, and - // passing the budget with it refuses that file, remembered so. - let mut fetch = |h: &[u8; 32]| Some(vec![b' '; if h[0] == 0xff { 10 * MAX_RECEIPT_FILE_BYTES } else { MAX_RECEIPT_FILE_BYTES - 1 }]); + // A fetch that returns more than it was asked for: the rest is taken as unread, and + // the file that then reaches the end is refused, remembered so. + let mut fetch = |h: &[u8; 32], _: usize| Some(vec![b' '; if h[0] == 0xff { 10 * cap } else { cap - 1 }]); let mut f = ReceiptFetcher::new(&mut fetch, FETCH_BUDGET); for i in 0..full { assert!(f.get(&[i as u8; 32]).is_ok()); } assert_eq!(f.get(&[0xff; 32]), Err(ReceiptRefusal::BytesExhausted)); assert_eq!(f.get(&[0xff; 32]), Err(ReceiptRefusal::BytesExhausted)); + // A file that ends inside what remains is read whole. + let mut fetch = |h: &[u8; 32], _: usize| Some(vec![b' '; if h[0] == 0xee { 10 } else { cap - 1 }]); + let mut f = ReceiptFetcher::new(&mut fetch, FETCH_BUDGET); + for i in 0..full { + assert!(f.get(&[i as u8; 32]).is_ok()); + } + assert_eq!(f.get(&[0xee; 32]).map(|b| b.len()), Ok(10)); assert_eq!(ReceiptRefusal::BytesExhausted.code(), "receipt_fetch_bytes_exhausted"); } @@ -724,6 +741,23 @@ mod keys { assert_eq!(status_max_age_s(&r1), 900); } + /// §8.4: a key log longer than its cap is refused whole, over the anchoring path and + /// through a cache, whatever its first bytes hold; one at the cap reads as usual. + #[test] + fn a_key_log_past_its_cap_is_refused() { + let r0 = revision(None, "2026-10-01T00:00:00Z", vec![]); + let loc = key_log_url(ORIGIN); + let ok = Retrieval { requested: &loc, answered: &loc, status: 200 }; + let mut at_cap = log_bytes(&[r0.clone()]); + at_cap.resize(MAX_KEY_LOG_BYTES, b' '); + let a = anchor_retrieved(ORIGIN, &ok, &at_cap, "t", None).expect("a log at the cap anchors"); + let mut past = at_cap.clone(); + past.push(b' '); + assert_eq!(anchor_retrieved(ORIGIN, &ok, &past, "t", None).unwrap_err().code(), "key_log_too_large"); + let mut held = vec![r0]; + assert_eq!(hold_cached_revisions(ORIGIN, &past, &a, &mut held).unwrap_err().code(), "key_log_too_large"); + } + /// §8.4: a grants file is read whole, and each grant in it is handed on as it is, /// to be checked on its own. #[test] diff --git a/wai/wai-rs/src/key_log.rs b/wai/wai-rs/src/key_log.rs index dea0c2257..8f3ed3a03 100644 --- a/wai/wai-rs/src/key_log.rs +++ b/wai/wai-rs/src/key_log.rs @@ -772,6 +772,11 @@ pub enum AnchorPath { Pinned, /// Retrieved from the origin's key-log location over HTTPS, at `acquired_at`. Fetched { acquired_at: String }, + /// A test aid's record: a log a test fetched over plain HTTP at `acquired_at`. It is + /// held as a fetched record is, so a test can exercise confirmation, and is labelled + /// `test-plaintext` wherever it is reported: it anchors nothing a verifier may rely + /// on, and no retrieval path in this crate builds one. + TestPlaintext { acquired_at: String }, } impl AnchorPath { @@ -779,6 +784,7 @@ impl AnchorPath { match self { AnchorPath::Pinned => "pinned", AnchorPath::Fetched { .. } => "fetched", + AnchorPath::TestPlaintext { .. } => "test-plaintext", } } } @@ -843,6 +849,7 @@ pub fn anchor_log(origin: &str, log_text: &str, path: AnchorPath, current: Optio let fresh = match &path { AnchorPath::Pinned => Anchor::pinned(origin, latest), AnchorPath::Fetched { acquired_at } => Anchor::fetched(origin, latest, acquired_at), + AnchorPath::TestPlaintext { .. } => Anchor { path: path.clone(), ..Anchor::fetched(origin, latest, "") }, }; let Some((cur, held)) = current.filter(|_| path != AnchorPath::Pinned) else { return Anchoring::Anchored(fresh); diff --git a/wai/wai-rs/tests/cli_http.rs b/wai/wai-rs/tests/cli_http.rs index c8556ab6c..3af2e0a87 100644 --- a/wai/wai-rs/tests/cli_http.rs +++ b/wai/wai-rs/tests/cli_http.rs @@ -710,6 +710,13 @@ fn test_server(mode: &'static str) -> (u16, std::sync::Arc { + // The log padded past its cap (1 MiB), framed by Content-Length. + let mut body = std::fs::read(corpus().join(".well-known/wai-keys")).unwrap(); + body.resize(1_048_621, b' '); + let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", body.len()).as_bytes()); + let _ = s.write_all(&body); + } "redirect" => { let _ = s.write_all(b"HTTP/1.1 302 Found\r\nLocation: https://mirror.example/.well-known/wai-keys\r\nContent-Length: 0\r\n\r\n"); } @@ -750,12 +757,20 @@ fn receipts_labels_a_plaintext_test_anchor_as_such() { let (kp, seen) = test_server("log"); let run = wai(&["http", "receipts", "--url", &url, "--held", "none", "--key-origin", "https://origin.example", "--test-plaintext-connect", &format!("127.0.0.1:{kp}")]); - assert!(!run.status.success(), "a plaintext anchor never makes the command succeed"); let v: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&run.stderr))); assert_eq!(v["anchor"]["path"], "test-plaintext", "{v}"); assert_eq!(v["anchor"]["relied_on"], false, "{v}"); assert!(v["anchor"]["note"].as_str().unwrap().contains("anchors nothing a verifier may rely on"), "{v}"); assert!(v["delivery"][0]["confirm"].as_str().unwrap().starts_with("confirmed origin=https://origin.example key_id=d1"), "{v}"); + // Every confirmation names the test anchor, and no anchor anywhere is `fetched`. + let confirms: Vec<&str> = ["delivery", "claims"].iter().flat_map(|k| v[*k].as_array().unwrap().iter()) + .filter_map(|x| x["confirm"].as_str()).collect(); + assert!(confirms.iter().filter(|c| c.starts_with("confirmed")).count() >= 2, "{v}"); + for c in confirms.iter().filter(|c| c.starts_with("confirmed")) { + assert!(c.contains(" anchor=test-plaintext"), "{c}"); + } + let out = String::from_utf8_lossy(&run.stdout); + assert!(!out.contains("anchor=fetched") && !out.contains("\"fetched\""), "{v}"); let req = seen.lock().unwrap().join(""); assert!(req.starts_with("GET /.well-known/wai-keys HTTP/1.1\r\n"), "{req}"); assert!(req.contains("\r\nHost: origin.example\r\n") && req.contains("\r\nCache-Control: no-cache\r\n"), "{req}"); @@ -768,11 +783,48 @@ fn receipts_labels_a_plaintext_test_anchor_as_such() { assert_eq!(v["anchor"]["refused"], "key_log_not_from_origin", "{v}"); assert!(v["delivery"][0].get("confirm").is_none(), "{v}"); assert_eq!(seen.lock().unwrap().len(), 1, "the redirect was followed"); + // A log past its cap is refused, read no further than one byte past it. + let (bp, _) = test_server("biglog"); + let run = wai(&["http", "receipts", "--url", &url, "--held", "none", "--key-origin", "https://origin.example", + "--test-plaintext-connect", &format!("127.0.0.1:{bp}")]); + let v: serde_json::Value = serde_json::from_slice(&run.stdout).unwrap(); + assert_eq!(v["anchor"]["refused"], "key_log_too_large", "{v}"); // The flag that let any server stand in for the origin is gone. let run = wai(&["http", "receipts", "--url", &url, "--held", "none", "--key-origin", "https://origin.example", "--connect", &format!("127.0.0.1:{kp}")]); assert!(!run.status.success() && String::from_utf8_lossy(&run.stderr).contains("--connect is gone")); } +/// The test aid's anchor never lets the command succeed: on an object whose index passes +/// whole (O7), the same check exits 0 with the log pinned and 1 with it fetched over +/// plaintext from a stated address. +#[cfg(feature = "key_discovery")] +#[test] +fn receipts_never_succeeds_on_a_test_anchor() { + let o = Origin::start(&corpus(), &[]); + let p = object("O7"); + let url = format!("http://127.0.0.1:{}/{}", o.port, p.strip_prefix(corpus()).unwrap().to_str().unwrap()); + let log = corpus().join(".well-known/wai-keys"); + let pinned = wai(&["http", "receipts", "--url", &url, "--held", "none", "--key-origin", "https://origin.example", + "--pin-key-log", log.to_str().unwrap()]); + let v: serde_json::Value = serde_json::from_slice(&pinned.stdout).unwrap_or_else(|e| panic!("{e}: {}", String::from_utf8_lossy(&pinned.stderr))); + assert!(pinned.status.success(), "a pinned anchor on a whole index succeeds: {v}"); + assert_eq!(v["anchor"]["path"], "pinned", "{v}"); + let (kp, _) = test_server("log"); + let test = wai(&["http", "receipts", "--url", &url, "--held", "none", "--key-origin", "https://origin.example", + "--test-plaintext-connect", &format!("127.0.0.1:{kp}")]); + let w: serde_json::Value = serde_json::from_slice(&test.stdout).unwrap(); + assert_eq!(w["anchor"]["path"], "test-plaintext", "{w}"); + let strip = |x: &serde_json::Value| x.as_array().map(|d| json_without_confirm(d)).unwrap_or_default(); + assert_eq!((strip(&w["delivery"]), &w["index"]), (strip(&v["delivery"]), &v["index"]), "the same check but for its anchor"); + assert!(!test.status.success(), "a plaintext anchor never makes the command succeed: {w}"); +} + +/// Delivery verdicts with their confirmations dropped: the part an anchor does not +/// decide. +fn json_without_confirm(d: &[serde_json::Value]) -> Vec { + d.iter().map(|x| { let mut x = x.clone(); x.as_object_mut().map(|m| m.remove("confirm")); x }).collect() +} + /// §8.4: without the test aid, the key log is retrieved over TLS from the origin itself, /// so a server that speaks plain HTTP at the origin's address is never asked for it in /// plaintext and anchors nothing; a build without TLS asks nothing at all. -- 2.54.0 (Apple Git-157) From 8f89b25ed46df5908d13b890d5386d2eb1b95675 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 14:46:34 -0400 Subject: [PATCH 14/16] http receipts round 4: falsify the bounded reads and the key-log cap Patches bytes-read-whole and key-log-uncapped; bytes-unchecked, fetch-budget-unchecked and fetch-unmemoised regenerated against the new fetcher. Co-Authored-By: Claude Opus 5.5 --- .../falsify/bytes-read-whole.patch | 22 ++++++++++++++++ .../falsify/bytes-unchecked.patch | 25 +++++++----------- .../falsify/fetch-budget-unchecked.patch | 8 +++--- .../falsify/fetch-unmemoised.patch | 8 +++--- .../falsify/key-log-uncapped.patch | 26 +++++++++++++++++++ 5 files changed, 65 insertions(+), 24 deletions(-) create mode 100644 wai/http-conformance/falsify/bytes-read-whole.patch create mode 100644 wai/http-conformance/falsify/key-log-uncapped.patch diff --git a/wai/http-conformance/falsify/bytes-read-whole.patch b/wai/http-conformance/falsify/bytes-read-whole.patch new file mode 100644 index 000000000..eb343f5ce --- /dev/null +++ b/wai/http-conformance/falsify/bytes-read-whole.patch @@ -0,0 +1,22 @@ +# must fail: receipts_fetch_bytes_exhausted +# each receipt file read to its cap plus one byte whatever remains of the index's bytes, the budget counted after (receipts::ReceiptFetcher::get) +diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs +index bdec9436..9d68b900 100644 +--- a/wai/wai-rs/src/http_binding/receipts.rs ++++ b/wai/wai-rs/src/http_binding/receipts.rs +@@ -300,13 +300,13 @@ impl<'f> ReceiptFetcher<'f> { + return Err(ReceiptRefusal::BytesExhausted); + } + self.fetched += 1; +- let limit = remaining.min(MAX_RECEIPT_FILE_BYTES + 1); ++ let limit = MAX_RECEIPT_FILE_BYTES + 1; + let served = match (self.fetch)(h, limit) { + None => Served::Nothing, + Some(mut b) => { + b.truncate(limit); + self.bytes += b.len(); +- if b.len() == remaining { Served::PastBytes } else { Served::Bytes(b) } ++ if b.len() > remaining { Served::PastBytes } else { Served::Bytes(b) } + } + }; + self.memo.insert(*h, served); diff --git a/wai/http-conformance/falsify/bytes-unchecked.patch b/wai/http-conformance/falsify/bytes-unchecked.patch index 4f060a2c8..4bc18a293 100644 --- a/wai/http-conformance/falsify/bytes-unchecked.patch +++ b/wai/http-conformance/falsify/bytes-unchecked.patch @@ -1,24 +1,17 @@ # must fail: receipts_fetch_bytes_exhausted # the bytes read for one index unbounded (receipts::ReceiptFetcher::get) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index 83cd7ccb..f03b1c9e 100644 +index bdec9436..6df45459 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -291,7 +291,7 @@ impl<'f> ReceiptFetcher<'f> { - if self.fetched >= self.budget { +@@ -296,9 +296,7 @@ impl<'f> ReceiptFetcher<'f> { return Err(ReceiptRefusal::BudgetExhausted); } -- if self.bytes >= MAX_INDEX_FETCH_BYTES { -+ if self.bytes >= usize::MAX - MAX_INDEX_FETCH_BYTES { - return Err(ReceiptRefusal::BytesExhausted); - } + let remaining = MAX_INDEX_FETCH_BYTES.saturating_sub(self.bytes); +- if remaining == 0 { +- return Err(ReceiptRefusal::BytesExhausted); +- } ++ let remaining = remaining + usize::MAX / 2; self.fetched += 1; -@@ -299,7 +299,7 @@ impl<'f> ReceiptFetcher<'f> { - None => Served::Nothing, - Some(b) => { - self.bytes += b.len().min(MAX_RECEIPT_FILE_BYTES + 1); -- if self.bytes > MAX_INDEX_FETCH_BYTES { Served::PastBytes } else { Served::Bytes(b) } -+ if self.bytes > usize::MAX - MAX_INDEX_FETCH_BYTES { Served::PastBytes } else { Served::Bytes(b) } - } - }; - self.memo.insert(*h, served); + let limit = remaining.min(MAX_RECEIPT_FILE_BYTES + 1); + let served = match (self.fetch)(h, limit) { diff --git a/wai/http-conformance/falsify/fetch-budget-unchecked.patch b/wai/http-conformance/falsify/fetch-budget-unchecked.patch index 5e8159444..c2baedcf9 100644 --- a/wai/http-conformance/falsify/fetch-budget-unchecked.patch +++ b/wai/http-conformance/falsify/fetch-budget-unchecked.patch @@ -1,15 +1,15 @@ # must fail: receipts_fetch_budget # an index's fetches unbounded (receipts::ReceiptFetcher::get) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index 83cd7ccb..30714725 100644 +index bdec9436..3cb67b43 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -288,7 +288,7 @@ impl<'f> ReceiptFetcher<'f> { - /// further file is fetched. +@@ -292,7 +292,7 @@ impl<'f> ReceiptFetcher<'f> { + /// there is not read, and once they are spent no further file is fetched. pub fn get(&mut self, h: &[u8; 32]) -> Result, ReceiptRefusal> { if !self.memo.contains_key(h) { - if self.fetched >= self.budget { + if self.fetched >= usize::MAX - self.budget { return Err(ReceiptRefusal::BudgetExhausted); } - if self.bytes >= MAX_INDEX_FETCH_BYTES { + let remaining = MAX_INDEX_FETCH_BYTES.saturating_sub(self.bytes); diff --git a/wai/http-conformance/falsify/fetch-unmemoised.patch b/wai/http-conformance/falsify/fetch-unmemoised.patch index 3ec0d0003..a175d532d 100644 --- a/wai/http-conformance/falsify/fetch-unmemoised.patch +++ b/wai/http-conformance/falsify/fetch-unmemoised.patch @@ -1,12 +1,12 @@ # must fail: receipts_items_fetch_each_name_once # each name fetched again for every item and walk that names it (receipts::ReceiptFetcher::get) diff --git a/wai/wai-rs/src/http_binding/receipts.rs b/wai/wai-rs/src/http_binding/receipts.rs -index 83cd7ccb..7972c06a 100644 +index bdec9436..f8e7ba27 100644 --- a/wai/wai-rs/src/http_binding/receipts.rs +++ b/wai/wai-rs/src/http_binding/receipts.rs -@@ -287,7 +287,7 @@ impl<'f> ReceiptFetcher<'f> { - /// `receipt_fetch_bytes_exhausted`: the file that passes them is refused, and no - /// further file is fetched. +@@ -291,7 +291,7 @@ impl<'f> ReceiptFetcher<'f> { + /// the bytes is refused (`receipt_fetch_bytes_exhausted`), since whether it ends + /// there is not read, and once they are spent no further file is fetched. pub fn get(&mut self, h: &[u8; 32]) -> Result, ReceiptRefusal> { - if !self.memo.contains_key(h) { + if !self.memo.contains_key(h) || self.fetched < usize::MAX { diff --git a/wai/http-conformance/falsify/key-log-uncapped.patch b/wai/http-conformance/falsify/key-log-uncapped.patch new file mode 100644 index 000000000..0e38ed3eb --- /dev/null +++ b/wai/http-conformance/falsify/key-log-uncapped.patch @@ -0,0 +1,26 @@ +# must fail: key_log_anchor_too_large key_log_cached_too_large +# a key log past its cap read, anchored or cached (keys::anchor_retrieved, keys::hold_cached_revisions) +diff --git a/wai/wai-rs/src/http_binding/keys.rs b/wai/wai-rs/src/http_binding/keys.rs +index 68832c79..79700d2c 100644 +--- a/wai/wai-rs/src/http_binding/keys.rs ++++ b/wai/wai-rs/src/http_binding/keys.rs +@@ -158,9 +158,6 @@ pub fn anchor_retrieved( + if retrieval.status != 200 { + return Err(KeysRefusal::NotFromOrigin("the answer is not a 200")); + } +- if body.len() > MAX_KEY_LOG_BYTES { +- return Err(KeysRefusal::TooLarge); +- } + let text = std::str::from_utf8(body).map_err(|_| KeysRefusal::Log(Reason::KeyLogMalformed))?; + match anchor_log(origin, text, AnchorPath::Fetched { acquired_at: acquired_at.into() }, current) { + Anchoring::Anchored(a) => Ok(a), +@@ -182,9 +179,6 @@ pub fn hold_cached_revisions(origin: &str, body: &[u8], anchor: &Anchor, held: & + if anchor.origin != origin { + return Err(KeysRefusal::Log(Reason::OriginNotAnchored)); + } +- if body.len() > MAX_KEY_LOG_BYTES { +- return Err(KeysRefusal::TooLarge); +- } + let text = std::str::from_utf8(body).map_err(|_| KeysRefusal::Log(Reason::KeyLogMalformed))?; + let revs = read_log(origin, text).map_err(KeysRefusal::Log)?; + let by: BTreeMap<[u8; 32], &Revision> = -- 2.54.0 (Apple Git-157) From 01516a3e1a46a807ba8e37de405b95d316e39d72 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Fri, 9 Oct 2026 22:22:46 -0400 Subject: [PATCH 15/16] CI: falsify the HTTP corpus in shards, each patch rebuilt incrementally The single falsify job ran past the runner's 3 h limit: each patch built in a fresh worktree under the release profile (LTO, one codegen unit), about 72 s apiece, and the Python mutations ran one after another after them. - falsify/run.sh builds one worktree of the head once, then applies each patch, rebuilds incrementally, verifies, reverses the patch and requires a clean tree. The new `falsify` profile (wai-rs/Cargo.toml) keeps overflow checks and debug assertions on, as in the tests, so the oracle is unchanged; the unpatched tree must build and pass first. - `--patches I/N` and `--mutations I/N` run the ones whose index mod N is I; each shard prints what it ran and fails unless that is exactly what was assigned. `--check-shards` fails unless the declared shards' totals agree, their indices are 0..N-1 and their assignments cover every patch and mutation once. `--differential` runs the differential alone. - wai-http.yml: a matrix of three patch shards and three mutation shards; a `shards` job checks the matrix's own list with --check-shards; the corpus verify and the differential in their own job; a `relevant` job gates the work on the pull request's own diff (scripts/pr-touches-paths.py), as wai-pack.yml does. The concurrency block is kept. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-http.yml | 118 ++++++++++-- wai/http-conformance/README.md | 13 +- wai/http-conformance/falsify/run.sh | 286 ++++++++++++++++++++++------ wai/wai-rs/Cargo.toml | 12 ++ 4 files changed, 353 insertions(+), 76 deletions(-) diff --git a/.github/workflows/wai-http.yml b/.github/workflows/wai-http.yml index b0c44c2c9..c093f2da0 100644 --- a/.github/workflows/wai-http.yml +++ b/.github/workflows/wai-http.yml @@ -1,6 +1,7 @@ name: WAI HTTP delivery -# extensions/http-delivery.md: two jobs beside the corpus checks in wai-conformance.yml. +# extensions/http-delivery.md: the falsification, the differential and the cache rules, +# beside the corpus checks in wai-conformance.yml. # # falsify — the HTTP corpus tool and its independent verifier must catch defects. Each # patch in wai/http-conformance/falsify/ breaks one function of wai-rs (the content @@ -20,11 +21,18 @@ name: WAI HTTP delivery # ancestor rule, its recovery and the publisher key it uses, a claim's parent, # anchoring from the origin only and the request that anchors, cached logs, grants # files and sets held by their pins); it -# is applied to a fresh worktree, the build must succeed, and `wai_http_vectors verify` -# must fail naming the cases on the patch's first line. Then each `http_verify.py -# --mutate` must fail, and the differential: wai-rs writes random envelopes' covered -# prefixes and head proofs, the Python verifier accepts every one, and refuses exactly -# the ten it changes one chaining-value byte in. +# is applied to one worktree of the head, built once and rebuilt incrementally per +# patch under the `falsify` profile (overflow checks and debug assertions on, as in the +# tests), and `wai_http_vectors verify` must fail naming the cases on the patch's first +# line; the patch is then reversed and the tree must be clean. Each `http_verify.py +# --mutate` must fail. Both run in shards (falsify/run.sh --patches I/N, --mutations +# I/N), each printing what it ran and failing unless that is exactly what was assigned +# to it; `shards` checks the shards the matrix declares cover every patch and every +# mutation once, so none is dropped silently. +# +# differential — the corpus verified both ways, and the differential: wai-rs writes +# random envelopes' covered prefixes and head proofs, the Python verifier accepts every +# one, and refuses exactly the ten it changes one chaining-value byte in. # # cache-conformance — the cache rules (§9). wai/cache-conformance/run.sh stages the # corpus's objects behind the reference origin (`wai http origin`) and runs driver.py @@ -150,13 +158,44 @@ on: - cron: '17 4 * * 1' jobs: - falsify: - name: HTTP corpus · falsification and differential + # The path filters above gate the trigger, which on this forge is the push delta: a + # rebase fires them all. This gates the work, from the pull request's own diff, reading + # the path list out of this file (scripts/pr-touches-paths.py); a push to main or the + # schedule has no pull-request context and runs everything. + relevant: + name: WAI HTTP · does this change touch it runs-on: linux + outputs: + run: ${{ steps.gate.outputs.run }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - id: gate + run: | + set -euo pipefail + r=$(python3 scripts/pr-touches-paths.py .github/workflows/wai-http.yml \ + "${{ github.event.pull_request.base.sha }}" \ + "${{ github.event.pull_request.head.sha }}") + echo "this change touches WAI HTTP: $r" + echo "run=$r" >> "$GITHUB_OUTPUT" + + # Every patch and every mutation runs in exactly one shard: the one whose index mod N + # it is, in the sorted list. The shards job reads the list below; keep it on one line. + falsify: + name: HTTP corpus · falsify ${{ matrix.shard }} + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + strategy: + fail-fast: false + matrix: + shard: ['patches 0/3', 'patches 1/3', 'patches 2/3', 'mutations 0/3', 'mutations 1/3', 'mutations 2/3'] steps: - uses: actions/checkout@v5 - name: Install Rust 1.98.0 + if: startsWith(matrix.shard, 'patches') shell: bash run: | export PATH="$HOME/.cargo/bin:$PATH" @@ -177,17 +216,74 @@ jobs: shell: bash run: python -m pip install cryptography - # One target directory for every patched build, so only the wai crate rebuilds. - - name: Every patch is caught; every mutation is caught; the differential holds + - name: Every patch or mutation of this shard is caught + shell: bash + env: + CARGO_TARGET_DIR: ${{ runner.temp }}/falsify-target + SHARD: ${{ matrix.shard }} + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + set -- $SHARD + bash wai/http-conformance/falsify/run.sh "$RUNNER_TEMP/falsify" "--$1" "$2" + + # The shards the matrix above declares cover every patch and every mutation exactly + # once: a shard dropped from the list, or a total that disagrees, fails here. + shards: + name: HTTP corpus · every patch and mutation in exactly one shard + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + steps: + - uses: actions/checkout@v5 + - shell: bash + run: | + set -euo pipefail + line=$(grep -E "^ +shard: \[" .github/workflows/wai-http.yml) + mapfile -t shards < <(printf '%s\n' "$line" | grep -oE "'(patches|mutations) [0-9]+/[0-9]+'" | tr -d "'") + bash wai/http-conformance/falsify/run.sh "$RUNNER_TEMP/falsify" --check-shards "${shards[@]}" + + differential: + name: HTTP corpus · verified both ways, and the differential + needs: relevant + if: needs.relevant.outputs.run == 'true' + runs-on: linux + steps: + - uses: actions/checkout@v5 + + - name: Install Rust 1.98.0 + shell: bash + run: | + export PATH="$HOME/.cargo/bin:$PATH" + command -v rustup >/dev/null 2>&1 || \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0 + rustup toolchain install 1.98.0 --profile minimal + rustup default 1.98.0 + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install the Ed25519 library + shell: bash + run: python -m pip install cryptography + + - name: The corpus, both ways; the differential shell: bash env: CARGO_TARGET_DIR: ${{ runner.temp }}/falsify-target run: | + set -euo pipefail git config --global --add safe.directory "$GITHUB_WORKSPACE" - bash wai/http-conformance/falsify/run.sh "$RUNNER_TEMP/falsify" + (cd wai/wai-rs && cargo build --profile falsify --no-default-features --features http,video_manifest,key_discovery --bin wai_http_vectors) + "$CARGO_TARGET_DIR/falsify/wai_http_vectors" verify wai/http-conformance + (cd wai/http-conformance && python3 http_verify.py .) + bash wai/http-conformance/falsify/run.sh "$RUNNER_TEMP/falsify" --differential cache-conformance: name: cache rules · controls and labelled caches + needs: relevant + if: needs.relevant.outputs.run == 'true' runs-on: linux steps: - uses: actions/checkout@v5 diff --git a/wai/http-conformance/README.md b/wai/http-conformance/README.md index 498342969..e483c0f52 100644 --- a/wai/http-conformance/README.md +++ b/wai/http-conformance/README.md @@ -179,9 +179,16 @@ to the reason it exists for, and OD to Appendix D's cid, length and proof size. ## Falsification Each patch in `falsify/` names the function it breaks and, on its first line, the -cases that must fail. The `falsify` job of `.github/workflows/wai-http.yml` applies -each to a fresh worktree, requires the build to succeed, and requires -`wai_http_vectors verify` to exit non-zero naming those cases. `http_verify.py +cases that must fail. `falsify/run.sh` applies each to one worktree of the head, built +once and rebuilt incrementally per patch under the `falsify` profile (overflow checks +and debug assertions on, as in the tests), requires the build to succeed and +`wai_http_vectors verify` to exit non-zero naming those cases, then reverses the patch +and requires the tree to be clean; the unpatched tree must build and pass first. The +`falsify` job of `.github/workflows/wai-http.yml` runs the patches and the mutations in +shards (`run.sh --patches I/N`, `--mutations I/N`, each taking the ones whose index mod +N is I), each printing what it ran and failing unless that is exactly what was assigned +to it, and its `shards` job checks the shards the matrix declares cover every patch and +every mutation once (`run.sh --check-shards`). `http_verify.py --mutate NAME` does the same on the Python side (`left-half`, `no-root`, `counter-zero` fail the proof cases; `floor-as-codecs` fails `union_O9`; `blake3-iv` fails the self-test; `base-at-done`, `tick-noop` and `refinement-unclipped` fail the diff --git a/wai/http-conformance/falsify/run.sh b/wai/http-conformance/falsify/run.sh index cdf1d20e8..e847bf696 100755 --- a/wai/http-conformance/falsify/run.sh +++ b/wai/http-conformance/falsify/run.sh @@ -1,81 +1,243 @@ #!/usr/bin/env bash -# Falsify the HTTP corpus tool: each patch here breaks one function of wai-rs. For each, -# in a fresh worktree of HEAD: apply it, require the build to succeed, and require -# `wai_http_vectors verify` to exit non-zero and to name every case the patch's first -# line lists. Then the Python side: each `http_verify.py --mutate` must fail, and the -# differential must verify every proof and refuse exactly the flipped ones. +# Falsify the HTTP corpus tool and its independent verifier. # -# bash wai/http-conformance/falsify/run.sh [work-dir [patch-name…]] +# Patches: each patch here breaks one function of wai-rs. In one worktree of HEAD, +# built once, each patch is applied, the tree rebuilt incrementally, and +# `wai_http_vectors verify` must exit non-zero naming every case the patch's first line +# lists; then the patch is reversed and the tree must be clean again. The builds use +# the `falsify` profile (wai-rs/Cargo.toml): overflow checks and debug assertions on, as +# in the tests, so a patch is judged by the code's own checks, and incremental, so each +# patch rebuilds only what it touched. The unpatched tree must pass first. # -# With patch names, only those patches run (and nothing else). -# Run from the repository root. CARGO_TARGET_DIR, if set, is shared by the builds, which -# need the features http, video_manifest (the playlist receipts) and key_discovery (the +# Mutations: each `http_verify.py --mutate NAME` must fail. +# +# The differential: wai-rs writes random envelopes' covered prefixes and head proofs, +# http_verify.py verifies every one, and refuses exactly the ten it changes one +# chaining-value byte in. +# +# bash wai/http-conformance/falsify/run.sh [work-dir] [--patches I/N | --mutations I/N | +# --differential | --check-shards 'patches I/N …' 'mutations I/N …' | patch-name…] +# +# With no selector, all three run. `--patches I/N` runs the patches whose index in the +# sorted list, mod N, is I; `--mutations I/N` the same over the mutations. Each shard +# prints every patch or mutation it ran, and fails unless what it ran is exactly what was +# assigned to it. `--check-shards` takes the shards a workflow declares and fails unless +# their totals agree, their indices are 0..N-1, and their assignments cover every patch +# and every mutation exactly once. With patch names, only those patches run. +# +# Run from the repository root. CARGO_TARGET_DIR, if set, holds the builds, which need +# the features http, video_manifest (the playlist receipts) and key_discovery (the # receipts, keys, grants and sets of §8.1–§8.5). The receipt and key cases of # http_verify.py need the `cryptography` package. set -uo pipefail ROOT="$(git rev-parse --show-toplevel)" -WORK="${1:-${RUNNER_TEMP:-/tmp}/wai-http-falsify}" -shift || true -ONLY="$*" +FALSIFY="$ROOT/wai/http-conformance/falsify" +WORK="${RUNNER_TEMP:-/tmp}/wai-http-falsify" +if [ $# -gt 0 ] && [ "${1#--}" = "$1" ] && [ ! -f "$FALSIFY/$1.patch" ]; then + WORK="$1" + shift +fi mkdir -p "$WORK" +FEATURES=http,video_manifest,key_discovery fail=0 -for patch in "$ROOT"/wai/http-conformance/falsify/*.patch; do - name="$(basename "$patch" .patch)" - if [ -n "$ONLY" ] && ! printf ' %s ' $ONLY | grep -q " $name "; then continue; fi - wt="$WORK/f-$name" + +MUTATIONS=(left-half no-root counter-zero floor-as-codecs blake3-iv base-at-done tick-noop refinement-unclipped + pdt-ignored dseq-ignored parts-after-extinf average-floor render-digest-off name-unchecked chain-unchecked + ilog2-no-interp bola-waits unlabelled-as-zero round-half-down small-order-key small-order-r + rebind-uri rebind-sequence key-unchecked walk-unbounded reload-walked + xml-char-unchecked xml-attribute-twice dash-start-ignored dash-timescale-ignored + unlabelled-escapes abr-unbounded gap-carries receipt-duplicates receipt-surrogates session-data-anywhere + dash-r-expanded xml-attributes-scanned dash-timelines-unbounded budget-raises + uri-unresolved gap-withdraws reanchor-backwards walk-exceeded-wedges walk-name-unchecked walk-verify-unchecked bindings-unbounded + file-numbers-unbounded nonconforming-offered conforming-withheld refines-read-whole + rcpt-name-unchecked walk-skipped nothing-as-delivered ancestor-any + legacy-admitted memo-off walk-unclamped step6-skipped groups-from-listed budget-unchecked caps-unchecked + bytes-unchecked decision-unchecked recovery-record-unchecked declaration-unnamed first-publisher matchers-need-object + operating-point-unchecked session-accepted bytes-read-whole key-log-uncapped + anchor-follows-redirect cached-rollback-unchecked grants-module-unchecked set-grants-ignored set-by-name) + +# Every patch, by name, in sorted order: the list shards index into. +all_patches() { + local p + for p in "$FALSIFY"/*.patch; do basename "$p" .patch; done | LC_ALL=C sort +} + +# The members of a list (one per line on stdin) whose index mod N is I. +shard_of() { + awk -v i="$1" -v n="$2" '(NR - 1) % n == i' +} + +# Parse I/N into the globals SI and SN, refusing anything else. +parse_shard() { + if ! [[ "$1" =~ ^([0-9]+)/([0-9]+)$ ]] || [ "${BASH_REMATCH[2]}" -eq 0 ] || [ "${BASH_REMATCH[1]}" -ge "${BASH_REMATCH[2]}" ]; then + echo "::error::a shard is I/N with 0 <= I < N, not '$1'" + exit 2 + fi + SI="${BASH_REMATCH[1]}" + SN="${BASH_REMATCH[2]}" +} + +# The lines of stdin into the array NAMES (bash 3.2 has no mapfile). +read_names() { + NAMES=() + local line + while IFS= read -r line; do [ -n "$line" ] && NAMES+=("$line"); done +} + +# The patches stage over the names on stdin, in one worktree of HEAD. +run_patches() { + local names wt bin name patch must rc missing ran=() t0 t1 + read_names + names=("${NAMES[@]}") + wt="$WORK/tree" + git -C "$ROOT" worktree remove --force "$wt" > /dev/null 2>&1 rm -rf "$wt" git -C "$ROOT" worktree add --detach --quiet "$wt" HEAD - must="$(head -1 "$patch" | sed 's/^# must fail: //')" - if ! git -C "$wt" apply "$patch"; then - echo "::error::$name: the patch does not apply"; fail=1 - elif ! (cd "$wt/wai/wai-rs" && cargo build --release --no-default-features --features http,video_manifest,key_discovery --bin wai_http_vectors) > "$WORK/$name.build.log" 2>&1; then - echo "::error::$name: the patched tree does not build"; tail -20 "$WORK/$name.build.log"; fail=1 - else - bin="${CARGO_TARGET_DIR:-$wt/wai/wai-rs/target}/release/wai_http_vectors" - "$bin" verify "$wt/wai/http-conformance" > "$WORK/$name.verify.log" 2>&1 - rc=$? - missing="" - for c in $must; do grep -q "^FAIL $c:" "$WORK/$name.verify.log" || missing="$missing $c"; done - if [ $rc -eq 0 ] || [ -n "$missing" ]; then - echo "::error::$name: verify exited $rc; cases not failed:$missing"; fail=1 - else - echo "ok $name: verify exited $rc and failed$(printf ' %s' $must) ($(grep -c '^FAIL' "$WORK/$name.verify.log") failures)" + bin="${CARGO_TARGET_DIR:-$wt/wai/wai-rs/target}/falsify/wai_http_vectors" + build() { (cd "$wt/wai/wai-rs" && cargo build --profile falsify --no-default-features --features "$FEATURES" --bin wai_http_vectors); } + # The unpatched tree builds and passes, or no patch's failure means anything. + t0=$(date +%s) + if ! build > "$WORK/unpatched.build.log" 2>&1; then + echo "::error::the unpatched tree does not build"; tail -20 "$WORK/unpatched.build.log" + git -C "$ROOT" worktree remove --force "$wt"; return 1 + fi + if ! "$bin" verify "$wt/wai/http-conformance" > "$WORK/unpatched.verify.log" 2>&1; then + echo "::error::the unpatched tree does not pass"; tail -20 "$WORK/unpatched.verify.log" + git -C "$ROOT" worktree remove --force "$wt"; return 1 + fi + t1=$(date +%s) + echo "the unpatched tree builds and passes ($((t1 - t0)) s)" + for name in "${names[@]}"; do + patch="$FALSIFY/$name.patch" + t0=$(date +%s) + must="$(head -1 "$patch" | sed 's/^# must fail: //')" + if ! git -C "$wt" apply "$patch"; then + echo "::error::$name: the patch does not apply"; fail=1; continue fi - fi + if ! build > "$WORK/$name.build.log" 2>&1; then + echo "::error::$name: the patched tree does not build"; tail -20 "$WORK/$name.build.log"; fail=1 + else + "$bin" verify "$wt/wai/http-conformance" > "$WORK/$name.verify.log" 2>&1 + rc=$? + missing="" + for c in $must; do grep -q "^FAIL $c:" "$WORK/$name.verify.log" || missing="$missing $c"; done + t1=$(date +%s) + if [ $rc -eq 0 ] || [ -n "$missing" ]; then + echo "::error::$name: verify exited $rc; cases not failed:$missing"; fail=1 + else + echo "ok $name: verify exited $rc and failed$(printf ' %s' $must) ($(grep -c '^FAIL' "$WORK/$name.verify.log") failures, $((t1 - t0)) s)" + fi + fi + ran+=("$name") + # Back to HEAD exactly, or every later patch is judged on a changed tree. + if ! git -C "$wt" apply -R "$patch" || [ -n "$(git -C "$wt" status --porcelain --untracked-files=no)" ]; then + echo "::error::$name: the tree is not clean after reversing the patch"; git -C "$ROOT" worktree remove --force "$wt"; return 1 + fi + done git -C "$ROOT" worktree remove --force "$wt" -done + check_ran patches "$(printf '%s\n' "${names[@]}")" "$(printf '%s\n' "${ran[@]}")" + return $fail +} -[ -n "$ONLY" ] && exit $fail -cd "$ROOT/wai/http-conformance" -for m in left-half no-root counter-zero floor-as-codecs blake3-iv base-at-done tick-noop refinement-unclipped \ - pdt-ignored dseq-ignored parts-after-extinf average-floor render-digest-off name-unchecked chain-unchecked \ - ilog2-no-interp bola-waits unlabelled-as-zero round-half-down small-order-key small-order-r \ - rebind-uri rebind-sequence key-unchecked walk-unbounded reload-walked \ - xml-char-unchecked xml-attribute-twice dash-start-ignored dash-timescale-ignored \ - unlabelled-escapes abr-unbounded gap-carries receipt-duplicates receipt-surrogates session-data-anywhere \ - dash-r-expanded xml-attributes-scanned dash-timelines-unbounded budget-raises \ - uri-unresolved gap-withdraws reanchor-backwards walk-exceeded-wedges walk-name-unchecked walk-verify-unchecked bindings-unbounded \ - file-numbers-unbounded nonconforming-offered conforming-withheld refines-read-whole \ - rcpt-name-unchecked walk-skipped nothing-as-delivered ancestor-any \ - legacy-admitted memo-off walk-unclamped step6-skipped groups-from-listed budget-unchecked caps-unchecked \ - bytes-unchecked decision-unchecked recovery-record-unchecked declaration-unnamed first-publisher matchers-need-object \ - operating-point-unchecked session-accepted bytes-read-whole key-log-uncapped \ - anchor-follows-redirect cached-rollback-unchecked grants-module-unchecked set-grants-ignored set-by-name; do - if python3 http_verify.py . --mutate "$m" > "$WORK/py-$m.log" 2>&1; then - echo "::error::http_verify.py --mutate $m passed"; fail=1 - else - echo "ok http_verify.py --mutate $m: $(grep -c '^FAIL' "$WORK/py-$m.log") failures" +# The mutations stage over the names on stdin. +run_mutations() { + local names m ran=() + read_names + names=("${NAMES[@]}") + for m in "${names[@]}"; do + if (cd "$ROOT/wai/http-conformance" && python3 http_verify.py . --mutate "$m") > "$WORK/py-$m.log" 2>&1; then + echo "::error::http_verify.py --mutate $m passed"; fail=1 + else + echo "ok http_verify.py --mutate $m: $(grep -c '^FAIL' "$WORK/py-$m.log") failures" + fi + ran+=("$m") + done + check_ran mutations "$(printf '%s\n' "${names[@]}")" "$(printf '%s\n' "${ran[@]}")" + return $fail +} + +# A shard ran exactly what it was assigned (one name a line in each): printed, and +# compared. +check_ran() { + local kind="$1" want got + want="$(printf '%s\n' "$2" | grep -v '^$' | LC_ALL=C sort)" + got="$(printf '%s\n' "$3" | grep -v '^$' | LC_ALL=C sort)" + echo "$kind run in this shard ($(printf '%s\n' "$got" | grep -c .)): $(echo $got)" + if [ "$want" != "$got" ]; then + echo "::error::this shard did not run exactly the $kind assigned to it" + diff <(printf '%s\n' "$want") <(printf '%s\n' "$got") + fail=1 fi -done -# The differential runs the unpatched tool: build it again (the patched builds shared -# its output path). -(cd "$ROOT/wai/wai-rs" && cargo build --release --no-default-features --features http,video_manifest,key_discovery --bin wai_http_vectors) > "$WORK/unpatched.build.log" 2>&1 || fail=1 -bin="${CARGO_TARGET_DIR:-$ROOT/wai/wai-rs/target}/release/wai_http_vectors" -if [ -x "$bin" ]; then +} + +# The shards a workflow declares cover every patch and every mutation exactly once. +check_shards() { + local kind list declared n total covered expected + for kind in patches mutations; do + if [ "$kind" = patches ]; then list="$(all_patches)"; else list="$(printf '%s\n' "${MUTATIONS[@]}")"; fi + declared="$(printf '%s\n' "$@" | awk -v k="$kind" '$1 == k { print $2 }')" + if [ -z "$declared" ]; then echo "::error::no $kind shard is declared"; fail=1; continue; fi + total="$(printf '%s\n' "$declared" | cut -d/ -f2 | sort -u)" + if [ "$(printf '%s\n' "$total" | wc -l)" -ne 1 ]; then echo "::error::$kind shards disagree on their total: $(echo $total)"; fail=1; continue; fi + n="$total" + if [ "$(printf '%s\n' "$declared" | cut -d/ -f1 | LC_ALL=C sort -n | tr '\n' ' ')" != "$(seq 0 $((n - 1)) | tr '\n' ' ')" ]; then + echo "::error::$kind shards are not 0..$((n - 1)) of $n, each once: $(echo $declared)"; fail=1; continue + fi + covered="$(for s in $declared; do parse_shard "$s"; printf '%s\n' "$list" | shard_of "$SI" "$SN"; done | LC_ALL=C sort)" + expected="$(printf '%s\n' "$list" | LC_ALL=C sort)" + if [ "$covered" != "$expected" ]; then + echo "::error::the $kind shards do not cover every one exactly once" + diff <(printf '%s\n' "$expected") <(printf '%s\n' "$covered") + fail=1 + else + echo "the $n $kind shards cover all $(printf '%s\n' "$expected" | wc -l | tr -d ' ') $kind, each once" + fi + done +} + +# The differential, with the unpatched tool. +run_differential() { + local bin + if ! (cd "$ROOT/wai/wai-rs" && cargo build --profile falsify --no-default-features --features "$FEATURES" --bin wai_http_vectors) > "$WORK/differential.build.log" 2>&1; then + echo "::error::the unpatched tool does not build"; tail -20 "$WORK/differential.build.log"; fail=1; return + fi + bin="${CARGO_TARGET_DIR:-$ROOT/wai/wai-rs/target}/falsify/wai_http_vectors" + cd "$ROOT/wai/http-conformance" || return 1 "$bin" diff . --seed 7 --count 500 --out "$WORK/diff.jsonl" > /dev/null || fail=1 python3 http_verify.py diff "$WORK/diff.jsonl" || fail=1 python3 http_verify.py diff "$WORK/diff.jsonl" --flip 10 --seed 3 || fail=1 -else - echo "::error::no release wai_http_vectors at $bin for the differential"; fail=1 -fi + cd "$ROOT" || return 1 +} + +case "${1:-}" in + --patches) + parse_shard "${2:?--patches I/N}" + all_patches | shard_of "$SI" "$SN" | run_patches || fail=1 + ;; + --mutations) + parse_shard "${2:?--mutations I/N}" + printf '%s\n' "${MUTATIONS[@]}" | shard_of "$SI" "$SN" | run_mutations || fail=1 + ;; + --differential) + run_differential + ;; + --check-shards) + shift + check_shards "$@" + ;; + "") + all_patches | run_patches || fail=1 + printf '%s\n' "${MUTATIONS[@]}" | run_mutations || fail=1 + run_differential + ;; + --*) + echo "::error::unknown option $1"; exit 2 + ;; + *) + for name in "$@"; do + [ -f "$FALSIFY/$name.patch" ] || { echo "::error::no patch $name"; exit 2; } + done + printf '%s\n' "$@" | run_patches || fail=1 + ;; +esac exit $fail diff --git a/wai/wai-rs/Cargo.toml b/wai/wai-rs/Cargo.toml index 8fa7abea8..f93673075 100644 --- a/wai/wai-rs/Cargo.toml +++ b/wai/wai-rs/Cargo.toml @@ -815,6 +815,18 @@ opt-level = 3 lto = true codegen-units = 1 +# The HTTP corpus's falsification (http-conformance/falsify/run.sh): one target +# directory, each patch rebuilt incrementally on the last; overflow checks and debug +# assertions on, as in the tests, so a patch is judged by the code's own checks; enough +# optimisation that each verify runs quickly. +[profile.falsify] +inherits = "dev" +opt-level = 1 +debug = false +incremental = true +overflow-checks = true +debug-assertions = true + [[bin]] name = "wai_score_vectors" path = "src/bin/wai_score_vectors.rs" -- 2.54.0 (Apple Git-157) From 379425e5556b2f6cffe51a718e7bd854aaa020bc Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sat, 10 Oct 2026 01:42:38 -0400 Subject: [PATCH 16/16] falsify: every mutation names the cases it must fail; receipt-surrogates reaches one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A mutation used to pass when http_verify.py merely exited non-zero, so a crash counted as a catch: receipt-surrogates printed "ok … 0 failures" because the mutated verifier raised UnicodeEncodeError in lp() on the lone surrogate it had just admitted. - falsify/mutations.txt lists each of the 77 mutations with the cases it must fail, as each patch does; run.sh reads the mutations from it. A mutation passes only when http_verify.py exits non-zero and prints a FAIL line for every listed case; a traceback, or a non-zero exit with no named FAIL, is reported as a harness failure. The mutation count, and so the shard totals, are unchanged. - receipt-surrogates models a reader that admits an unpaired surrogate (RFC 8259 §8.2) and carries it on. Receipt text is now encoded with text_bytes(), which never raises (the unpaired surrogate goes as the bytes a lenient reader would hash), so the mutated verifier reaches a verdict: playlist_receipt_lone_surrogate and playlist_receipt_lone_low_surrogate give playlist_receipt_invalid where the corpus says playlist_receipt_malformed. The unmutated reader still refuses such text first, by name (playlist_receipt_malformed), as wai-rs does; no corpus case changes. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-http.yml | 3 +- wai/http-conformance/README.md | 6 +- wai/http-conformance/falsify/mutations.txt | 82 ++++++++++++++++++++++ wai/http-conformance/falsify/run.sh | 48 ++++++++----- wai/http-conformance/http_verify.py | 19 +++-- 5 files changed, 134 insertions(+), 24 deletions(-) create mode 100644 wai/http-conformance/falsify/mutations.txt diff --git a/.github/workflows/wai-http.yml b/.github/workflows/wai-http.yml index c093f2da0..29cddddae 100644 --- a/.github/workflows/wai-http.yml +++ b/.github/workflows/wai-http.yml @@ -25,7 +25,8 @@ name: WAI HTTP delivery # patch under the `falsify` profile (overflow checks and debug assertions on, as in the # tests), and `wai_http_vectors verify` must fail naming the cases on the patch's first # line; the patch is then reversed and the tree must be clean. Each `http_verify.py -# --mutate` must fail. Both run in shards (falsify/run.sh --patches I/N, --mutations +# --mutate` must exit non-zero naming the cases falsify/mutations.txt lists for it; a +# crash, or a non-zero exit that names none, is a failure. Both run in shards (falsify/run.sh --patches I/N, --mutations # I/N), each printing what it ran and failing unless that is exactly what was assigned # to it; `shards` checks the shards the matrix declares cover every patch and every # mutation once, so none is dropped silently. diff --git a/wai/http-conformance/README.md b/wai/http-conformance/README.md index e483c0f52..57d01d49d 100644 --- a/wai/http-conformance/README.md +++ b/wai/http-conformance/README.md @@ -43,6 +43,7 @@ agree: | `blake3_vectors.json` | a subset of the published BLAKE3 test vectors (CC0), which `http_verify.py` checks itself against first | | `http_verify.py` | the independent verifier | | `falsify/*.patch` | defects that `wai_http_vectors verify` must catch (below) | +| `falsify/mutations.txt` | the `http_verify.py --mutate` defects, each with the cases it must fail (below) | | `src/live/`, `src/players/`, `src/playlists/` | frozen sources of the playlist side: the integer video payloads of O12 and O13, the classical playlists the WAI ones sit beside, and the player fixture's classical master as authored | | `playlists/o9/`, `playlists/o10/`, `playlists/o11/` | WAI media and multivariant playlists (and MPDs) over O9 (and over O9 with its second segment a gap, which `wai-gap.mpd` leaves out), O10 with its layers, and O11, each beside its classical playlist in `src/playlists/`; O9's MPDs are on the timeline of `src/playlists/o9-classical.mpd` (90 kHz, written with `@r`, beside an audio set on its own timescale) | | `live/` | four revisions of a low-latency WAI playlist over O12 beside `src/live/classical-r*.m3u8`, and its multivariant playlist; the parts are listed under aliases `720/..wai` with their cids (the alias files are not in the corpus: the cases serve the objects under them, and `wai http origin --root . --live-edge live/wai-720.m3u8=live/wai-r0.m3u8,live/wai-r1.m3u8,live/wai-r2.m3u8,live/wai-r3.m3u8` replays the edge over HTTP, blocking reloads and hinted parts included) | @@ -188,7 +189,10 @@ and requires the tree to be clean; the unpatched tree must build and pass first. shards (`run.sh --patches I/N`, `--mutations I/N`, each taking the ones whose index mod N is I), each printing what it ran and failing unless that is exactly what was assigned to it, and its `shards` job checks the shards the matrix declares cover every patch and -every mutation once (`run.sh --check-shards`). `http_verify.py +every mutation once (`run.sh --check-shards`). Each mutation in `falsify/mutations.txt` +lists the cases it must fail, as each patch does: it passes only when `http_verify.py +--mutate NAME` exits non-zero and names every one in a `FAIL` line, and a crash, or a +non-zero exit that names no case, is a failure of the harness. `http_verify.py --mutate NAME` does the same on the Python side (`left-half`, `no-root`, `counter-zero` fail the proof cases; `floor-as-codecs` fails `union_O9`; `blake3-iv` fails the self-test; `base-at-done`, `tick-noop` and `refinement-unclipped` fail the diff --git a/wai/http-conformance/falsify/mutations.txt b/wai/http-conformance/falsify/mutations.txt new file mode 100644 index 000000000..a8b1754ea --- /dev/null +++ b/wai/http-conformance/falsify/mutations.txt @@ -0,0 +1,82 @@ +# The Python side's mutations (http_verify.py --mutate NAME), each with the cases it must +# fail, named: falsify/run.sh passes a mutation only when http_verify.py exits non-zero and +# prints a FAIL line for every case listed here. A crash, or a non-zero exit with no named +# FAIL, is a failure of the harness, never a catch. One mutation a line: its name, then the +# cases, in the order the run prints them; the shards index into this list as it stands. +left-half head_proof_ok_O10L1 head_proof_ok_O10L2 head_proof_ok_O12p0_00 +no-root head_proof_ok_O10 head_proof_ok_O10L1 head_proof_ok_O10L2 +counter-zero head_proof_ok_OH head_proof_not_formula_OH fetch_OH +floor-as-codecs union_O9 multivariant_playlists_o9_wai_m3u8 multivariant_playlists_o9_wai_gap_m3u8 +blake3-iv self-test +base-at-done timed_OS_base_ready_first timed_OS_refinement_after_base timed_OS_clock_never_ticked +tick-noop timed_OS_base_ready_first timed_OS_clock_never_ticked timed_OS_clock_ticked_0 +refinement-unclipped timed_OS_200_before_base +pdt-ignored adjacency_program_date_time +dseq-ignored adjacency_discontinuity_sequence +parts-after-extinf media_playlist_playlists_o9_wai_180_gap_m3u8 media_playlist_live_wai_r1_m3u8 media_playlist_live_wai_r2_m3u8 +average-floor multivariant_playlists_o9_wai_m3u8 multivariant_playlists_o9_wai_gap_m3u8 multivariant_live_wai_m3u8 +render-digest-off playlist_receipt_playlists_o9_wai_180_m3u8 playlist_receipt_playlists_o10_wai_a_m3u8 playlist_receipt_playlists_o11_wai_v_m3u8 +name-unchecked playlist_receipt_name +chain-unchecked live_edge_revision_changed live_edge_unchained live_edge_skip +ilog2-no-interp abr_bola_like_23 abr_bola_like_34 abr_mpc_00 +bola-waits abr_bola_like_00 abr_bola_like_02 abr_bola_like_03 +unlabelled-as-zero abr_throughput_05 abr_throughput_09 abr_throughput_11 +round-half-down playlist_parse_rounding +small-order-key playlist_receipt_sig_key_identity +small-order-r playlist_receipt_sig_r_small_order +rebind-uri self-test live_relisted_alias live_relisted_alias_walked +rebind-sequence live_resequenced +key-unchecked live_edge_other_key live_edge_other_key_walked +walk-unbounded live_edge_reanchored live_edge_reanchored_then_reloaded live_edge_reanchored_skipping +reload-walked live_edge_unchanged_reload live_edge_reanchored_then_reloaded +xml-char-unchecked mpd_rep_id_not_a_char +xml-attribute-twice dash_adjacency_attribute_twice +dash-start-ignored dash_adjacency_period_start +dash-timescale-ignored dash_adjacency_timescale dash_adjacency_the_audio_timescale +unlabelled-escapes abr_throughput_05 abr_throughput_09 abr_throughput_11 +abr-unbounded abr_out_of_bounds_options abr_out_of_bounds_figures depth_out_of_bounds_bytes +gap-carries media_playlist_gap_with_layers render_gap_with_parts render_gap_with_layers +receipt-duplicates playlist_receipt_duplicate_first playlist_receipt_duplicate_last playlist_receipt_duplicate_same_value +receipt-surrogates playlist_receipt_lone_surrogate playlist_receipt_lone_low_surrogate +session-data-anywhere unchanged_players unchanged_before_version unchanged_crlf_line +dash-r-expanded self-test +xml-attributes-scanned self-test +dash-timelines-unbounded dash_adjacency_timelines_past_the_subset +budget-raises abr_throughput_13 abr_throughput_19 abr_buffer_based_03 +uri-unresolved self-test uri_resolve_rfc_01 uri_resolve_rfc_02 +gap-withdraws live_gap_withdraws_a_segment +reanchor-backwards live_edge_reanchor_not_backwards +walk-exceeded-wedges live_edge_reanchored live_edge_reanchored_then_reloaded live_edge_reanchored_skipping +walk-name-unchecked live_edge_parent_substituted +walk-verify-unchecked live_edge_parent_invalid +bindings-unbounded self-test +file-numbers-unbounded playlist_receipt_ceiling_past_2_53 playlist_receipt_ceiling_written_out playlist_receipt_window_past_2_53 +nonconforming-offered fetch_OR_malformed_refines fetch_ORN_whole fetch_ORN_ranged +conforming-withheld fetch_OCF_whole fetch_OCF_ranged +refines-read-whole fetch_OR_malformed_refines fetch_ONM_whole fetch_ONM_ranged +rcpt-name-unchecked receipt_file_name_mismatch receipts_parent_renamed receipts_group_renamed +walk-skipped receipts_walk_two receipts_walk_one receipts_back_after_head +nothing-as-delivered receipts_back_after_head receipts_fetch_refused_holds_nothing receipts_stage_set +ancestor-any receipts_head_proof receipts_fetch_refused_holds_nothing receipts_claims_O3 +legacy-admitted receipts_legacy_group receipt_file_legacy_group +memo-off receipts_head_proof receipts_fetch_refused_holds_nothing receipts_stage_set +walk-unclamped receipts_walk_65_asks_1000 +step6-skipped receipts_head_proof receipts_fetch_refused_holds_nothing receipts_stage_set +groups-from-listed receipts_property_disagrees receipts_claim_through_foreign_item +budget-unchecked receipts_fetch_budget +caps-unchecked receipts_file_too_large receipts_parent_too_large receipts_index_too_large +bytes-unchecked receipts_fetch_bytes_exhausted +decision-unchecked receipts_lawful_basis receipts_lawful_basis_declaration_not_served receipts_lawful_basis_declaration_renamed +recovery-record-unchecked receipts_binding_recovery receipts_binding_recovery_object_not_held receipts_binding_recovery_no_matcher +declaration-unnamed receipts_lawful_basis_declaration_renamed +first-publisher receipts_ancestor_other_publisher_first +matchers-need-object receipts_binding_recovery_object_not_held +operating-point-unchecked receipts_operating_point receipts_operating_point_not_held +session-accepted receipts_reconstruct_session +bytes-read-whole receipts_fetch_bytes_exhausted +key-log-uncapped key_log_anchor_too_large key_log_cached_too_large +anchor-follows-redirect key_log_anchor_redirect +cached-rollback-unchecked key_log_cached_rolled_back +grants-module-unchecked grants_file_other_module +set-grants-ignored param_set_status_stale param_set_no_clock param_set_no_grant +set-by-name param_set_other_bytes diff --git a/wai/http-conformance/falsify/run.sh b/wai/http-conformance/falsify/run.sh index e847bf696..ff86604f9 100755 --- a/wai/http-conformance/falsify/run.sh +++ b/wai/http-conformance/falsify/run.sh @@ -9,7 +9,9 @@ # in the tests, so a patch is judged by the code's own checks, and incremental, so each # patch rebuilds only what it touched. The unpatched tree must pass first. # -# Mutations: each `http_verify.py --mutate NAME` must fail. +# Mutations: each `http_verify.py --mutate NAME` must exit non-zero naming, as FAIL lines, +# every case mutations.txt lists for it; a crash, or a non-zero exit that names none, is +# a failure of the harness. # # The differential: wai-rs writes random envelopes' covered prefixes and head proofs, # http_verify.py verifies every one, and refuses exactly the ten it changes one @@ -41,20 +43,18 @@ mkdir -p "$WORK" FEATURES=http,video_manifest,key_discovery fail=0 -MUTATIONS=(left-half no-root counter-zero floor-as-codecs blake3-iv base-at-done tick-noop refinement-unclipped - pdt-ignored dseq-ignored parts-after-extinf average-floor render-digest-off name-unchecked chain-unchecked - ilog2-no-interp bola-waits unlabelled-as-zero round-half-down small-order-key small-order-r - rebind-uri rebind-sequence key-unchecked walk-unbounded reload-walked - xml-char-unchecked xml-attribute-twice dash-start-ignored dash-timescale-ignored - unlabelled-escapes abr-unbounded gap-carries receipt-duplicates receipt-surrogates session-data-anywhere - dash-r-expanded xml-attributes-scanned dash-timelines-unbounded budget-raises - uri-unresolved gap-withdraws reanchor-backwards walk-exceeded-wedges walk-name-unchecked walk-verify-unchecked bindings-unbounded - file-numbers-unbounded nonconforming-offered conforming-withheld refines-read-whole - rcpt-name-unchecked walk-skipped nothing-as-delivered ancestor-any - legacy-admitted memo-off walk-unclamped step6-skipped groups-from-listed budget-unchecked caps-unchecked - bytes-unchecked decision-unchecked recovery-record-unchecked declaration-unnamed first-publisher matchers-need-object - operating-point-unchecked session-accepted bytes-read-whole key-log-uncapped - anchor-follows-redirect cached-rollback-unchecked grants-module-unchecked set-grants-ignored set-by-name) +# The mutations and the cases each must fail, from mutations.txt (one a line: the name, +# then the cases). +MUTATIONS=() +while read -r m _; do + case "$m" in ""|"#"*) continue ;; esac + MUTATIONS+=("$m") +done < "$FALSIFY/mutations.txt" + +# The cases the mutation $1 must fail, as mutations.txt lists them. +mutation_cases() { + awk -v m="$1" '$1 == m { $1 = ""; print; exit }' "$FALSIFY/mutations.txt" +} # Every patch, by name, in sorted order: the list shards index into. all_patches() { @@ -145,10 +145,24 @@ run_mutations() { read_names names=("${NAMES[@]}") for m in "${names[@]}"; do - if (cd "$ROOT/wai/http-conformance" && python3 http_verify.py . --mutate "$m") > "$WORK/py-$m.log" 2>&1; then + local must log rc missing c + must="$(mutation_cases "$m")" + log="$WORK/py-$m.log" + (cd "$ROOT/wai/http-conformance" && python3 http_verify.py . --mutate "$m") > "$log" 2>&1 + rc=$? + missing="" + for c in $must; do grep -q "^FAIL $c:" "$log" || missing="$missing $c"; done + if [ -z "$must" ]; then + echo "::error::$m: mutations.txt declares no case it must fail"; fail=1 + elif [ $rc -eq 0 ]; then echo "::error::http_verify.py --mutate $m passed"; fail=1 + elif grep -q '^Traceback' "$log" || ! grep -q '^FAIL ' "$log"; then + # A crash, or a refusal to run, is not a catch: the harness failed. + echo "::error::$m: a harness failure: http_verify.py exited $rc without naming the cases it failed"; tail -5 "$log"; fail=1 + elif [ -n "$missing" ]; then + echo "::error::$m: http_verify.py exited $rc; cases not failed:$missing"; fail=1 else - echo "ok http_verify.py --mutate $m: $(grep -c '^FAIL' "$WORK/py-$m.log") failures" + echo "ok http_verify.py --mutate $m: failed$(printf ' %s' $must) ($(grep -c '^FAIL' "$log") failures)" fi ran+=("$m") done diff --git a/wai/http-conformance/http_verify.py b/wai/http-conformance/http_verify.py index 83f6b757c..a8bf62d9c 100644 --- a/wai/http-conformance/http_verify.py +++ b/wai/http-conformance/http_verify.py @@ -2771,8 +2771,17 @@ def be(n, width): return n.to_bytes(width, "big") +def text_bytes(s): + """A receipt's text as the bytes hashed and compared. The reader has already refused + an unpaired surrogate (RFC 8259 §8.2) with its reason, so valid text is plain UTF-8; + encoding never raises, so a reader that admitted one (the receipt-surrogates + mutation) reaches a verdict over the bytes a lenient reader would hash, never a + crash.""" + return s.encode("utf-8", "surrogatepass") + + def lp(s): - b = s.encode("utf-8") + b = text_bytes(s) return be(len(b), 4) + b @@ -2793,7 +2802,7 @@ def merkle_root(hashes): def class_hash(c): - bs = sorted(set(c["blackouts"]), key=lambda x: x.encode("utf-8")) + bs = sorted(set(c["blackouts"]), key=text_bytes) out = [b"wai:video-manifest-class\x01"] + [lp(c[k]) for k in ("content_id", "entitlement", "geo", "ad_pod_id")] out.append(be(len(bs), 4)) out += [lp(b) for b in bs] @@ -2810,7 +2819,7 @@ def render_digest(r): c = jcs({"render": r["render_json"][0], "extras": r["render_json"][1]}) except ValueError: return None - return b3(b"wai:video-manifest-render\x01", c.encode("utf-8")) + return b3(b"wai:video-manifest-render\x01", text_bytes(c)) def signing_payload(r): @@ -2846,7 +2855,7 @@ def receipt_verifies(r): return False if render_digest(r) is None: return False - if blake3(rendered(r).encode("utf-8")) != r["manifest_hash"]: + if blake3(text_bytes(rendered(r))) != r["manifest_hash"]: return False if merkle_root([segment_leaf(sg) for sg in r["segments"]]) != r["merkle_root"] or class_hash(r["class"]) != r["class_hash"]: return False @@ -2867,7 +2876,7 @@ def check_playlist(playlist, receipt_bytes, name): raise Refusal("playlist_receipt_malformed") if name is not None and name != r["manifest_hash"].hex() + ".json" and MUTATE != "name-unchecked": raise Refusal("receipt_name_mismatch") - if blake3(playlist) != r["manifest_hash"] or rendered(r).encode("utf-8") != playlist: + if blake3(playlist) != r["manifest_hash"] or text_bytes(rendered(r)) != playlist: raise Refusal("playlist_receipt_mismatch") if not receipt_verifies(r): raise Refusal("playlist_receipt_invalid") -- 2.54.0 (Apple Git-157)