diff --git a/.github/workflows/joule-code-conformance.yml b/.github/workflows/joule-code-conformance.yml index c3fa5104b..129b18f02 100644 --- a/.github/workflows/joule-code-conformance.yml +++ b/.github/workflows/joule-code-conformance.yml @@ -39,6 +39,10 @@ on: # joule-moq-ingest's tests/room_corpus.rs holds its reading of room descriptors # and grants to the live-rooms corpus. - 'wai/live-conformance/rooms/**' + # joule-moq's tests/secure_objects_vectors.rs and joule-moq-mls's tests/corpus.rs + # read the end-to-end encrypted rooms corpus, and the latter that extension's §13. + - 'wai/live-conformance/e2ee/**' + - 'wai/extensions/confidentiality.md' - '.github/workflows/joule-code-conformance.yml' push: branches: @@ -55,6 +59,10 @@ on: # joule-moq-ingest's tests/room_corpus.rs holds its reading of room descriptors # and grants to the live-rooms corpus. - 'wai/live-conformance/rooms/**' + # joule-moq's tests/secure_objects_vectors.rs and joule-moq-mls's tests/corpus.rs + # read the end-to-end encrypted rooms corpus, and the latter that extension's §13. + - 'wai/live-conformance/e2ee/**' + - 'wai/extensions/confidentiality.md' - '.github/workflows/joule-code-conformance.yml' jobs: @@ -114,6 +122,22 @@ jobs: working-directory: joule-code/joule-code-rs run: cargo test -p joule-moq --features quic --all-targets + # End-to-end encrypted rooms (WAI extensions/confidentiality.md). joule-moq's sealed + # objects, named so they stay built and tested whatever the workspace's other members + # enable: RFC 9605's and draft-ietf-moq-secure-objects-01's published vectors, and + # the receiving checks. Then joule-moq-mls: real MLS groups (a room of three each + # sending, a removal, a late joiner, a change of committer, and every misbehaviour + # refused), and the corpus of wai/live-conformance/e2ee regenerating byte for byte + # with every vector's outcome. The relay's own job runs tests/sealed_objects.rs. + - name: Sealed objects and the room's MLS binding + working-directory: joule-code/joule-code-rs + run: | + set -e + cargo test -p joule-moq --features secure-objects --lib secure_objects + cargo test -p joule-moq --features secure-objects --test secure_objects_vectors + cargo test -p joule-moq-mls + cargo run -q -p joule-moq-mls --bin joule_moq_e2ee_vectors verify ../../wai/live-conformance/e2ee + # joule-moq's draft-ietf-moq-transport-22 vectors, read by a second decoder # written from the draft's text rather than from the crate. The step above # (tests/draft22_vectors.rs) holds the crate to the same corpus and fails if @@ -149,7 +173,9 @@ jobs: run: cargo test # The MOQT draft-22 relay. Every test is on loopback: two publishers and three - # subscribers over native QUIC, WebTransport over HTTP/3 and the WebSocket fallback; + # subscribers over native QUIC, WebTransport over HTTP/3 and the WebSocket fallback; a + # room's sealed objects, forwarded byte for byte and checked at the door on their + # receipts over the sealed bytes, opened by members and by no one else; # a slow subscriber whose late layers time out while every receipt still arrives; a # late joiner served by FETCH and a fill from the cache, and a FETCH forwarded # upstream; GOAWAY on shutdown; tokens by alias; Request IDs and the stream limit; diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 6b5d3d43b..291a16793 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -95,6 +95,9 @@ on: # reports, live_moderation_verify.py reads its §9.5, §9.6 and §10 tables, and the # registry's wire identifiers test reads its sections. - 'wai/extensions/live-moderation.md' + # e2ee_verify.py reads this extension's §13 table, and the registry's wire identifiers + # test reads its sections. + - 'wai/extensions/confidentiality.md' - 'wai/wai-web/**' # The audio provenance check below reads the corpora above with this tool. - 'wai/tools/wai_audio_provenance_check.py' @@ -763,7 +766,8 @@ jobs: # live_compose_verify.py (the live-compose corpus), live_record_verify.py (the # live-recording corpus), timed_text_verify.py (the timed-text corpus), # live_metering_verify.py (the live-metering corpus), energy_steering_verify.py (the - # energy-steering corpus), and live_moderation_verify.py (the live-moderation corpus). + # energy-steering corpus), and live_moderation_verify.py (the live-moderation corpus), and + # e2ee_verify.py (the end-to-end encrypted rooms corpus). # Each exits non-zero on any failure or any difference, and the step fails with it. # One platform: what they check is the reading of the text, not the architecture. independent-verifiers: @@ -1055,6 +1059,20 @@ jobs: (cd wai-rs && cargo test --no-default-features --features live_moderation --lib live_moderation::) (cd wai-rs && cargo test --no-default-features --features live_moderation --test live_moderation) python3 -I live-conformance/live_moderation_verify.py live-conformance/moderation + # wai.live.e2ee (extensions/confidentiality.md): the corpus's second implementation, + # e2ee_verify.py, written from the extension's text and the documents it reads and + # sharing no code with the reference (joule-moq's secure_objects and joule-moq-mls, + # whose own checks run in joule-code-conformance.yml). Standard library only, run + # isolated (-I): AES, AES-GCM and RFC 9605's counter mode written from their + # specifications for test vectors only, HKDF and HMAC from the standard library, + # BLAKE3 and strict Ed25519 from claim-conformance. It checks every vector RFC 9605 + # and draft-ietf-moq-secure-objects-01 publish, every record, key schedule, sealed + # object and room of vectors.json (taking each epoch's application secret as given, + # §14), and requires every mutated copy of the corpus to fail. + - name: end-to-end encrypted rooms · e2ee_verify.py + working-directory: wai + shell: bash + run: python3 -I live-conformance/e2ee_verify.py live-conformance/e2ee # The quantum corpus's second implementation (quantum-sim extension), integers only: # it derives the circuit/1 phase table by its recurrence and the circuit2 table by an # interval-certified procedure, and requires both literal tables and their digests, diff --git a/joule-code/joule-code-rs/Cargo.lock b/joule-code/joule-code-rs/Cargo.lock index c43b9657a..81fce2b67 100644 --- a/joule-code/joule-code-rs/Cargo.lock +++ b/joule-code/joule-code-rs/Cargo.lock @@ -37,6 +37,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "android_system_properties" version = "0.1.6" @@ -165,6 +174,26 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-vec" version = "0.9.1" @@ -247,7 +276,7 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -262,6 +291,15 @@ dependencies = [ "subtle", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -361,6 +399,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "cmake" version = "0.1.58" @@ -370,6 +419,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" @@ -429,6 +484,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-models" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3344d349528f449816cfa85e558c439bdca5a6d8a738cfb21e69f0b2b1952f" +dependencies = [ + "hax-lib", + "pastey", + "rand 0.10.2", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -447,6 +513,17 @@ dependencies = [ "libc", ] +[[package]] +name = "crabgrind" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7459e07732f6de74001fcf73a3fdfbb0f368e4fd8e2392f4a2206a065e6e95" +dependencies = [ + "bindgen", + "cc", + "pkg-config", +] + [[package]] name = "crc" version = "3.4.0" @@ -528,7 +605,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -540,6 +619,15 @@ dependencies = [ "cipher", ] +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -550,13 +638,27 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", "rand_core 0.6.4", "rustc_version", "subtle", "zeroize", ] +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", +] + [[package]] name = "curve25519-dalek-derive" version = "0.1.1" @@ -593,6 +695,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + [[package]] name = "der-parser" version = "10.0.0" @@ -656,6 +768,7 @@ dependencies = [ "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -671,27 +784,27 @@ dependencies = [ "ccm", "chacha20 0.9.1", "chacha20poly1305", - "der", + "der 0.7.10", "ecdsa", "generic-array", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "log", "nom 8.0.0", "once_cell", "p256", "p384", - "pkcs8", + "pkcs8 0.10.2", "rand 0.9.5", "rand_core 0.6.4", "rcgen", "sec1", "sha2 0.10.9", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "time", - "x25519-dalek", + "x25519-dalek 2.0.1", "x509-cert", ] @@ -727,12 +840,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -741,9 +854,9 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", + "pkcs8 0.10.2", "serde", - "signature", + "signature 2.2.0", ] [[package]] @@ -752,7 +865,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "ed25519", "rand_core 0.6.4", "serde", @@ -779,9 +892,9 @@ dependencies = [ "ff", "generic-array", "group", - "hkdf", + "hkdf 0.12.4", "pem-rfc7468", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "subtle", @@ -850,6 +963,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -889,7 +1008,7 @@ dependencies = [ "derive-getters", "document-features", "hex", - "itertools", + "itertools 0.14.0", "postcard", "rand_core 0.6.4", "serde", @@ -905,7 +1024,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f73eb5fa9311d33450c2320199ad1663b5af7a50061d9627d2e0dc776f0acb27" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "document-features", "frost-core", "frost-rerandomized", @@ -1080,6 +1199,12 @@ dependencies = [ "polyval", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "group" version = "0.13.0" @@ -1117,6 +1242,43 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hax-lib" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a33cb227f97ee5c419064c31d7c55a7d895f28d8019ccdadc311cc036500c4" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28da835a5f153e9f3d0cc1f42ee605a1cfa9093c93348193793d60a1b0908b51" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed327e9a28d6ee018a4a9ba842d4ee27e378c8a591d2beb6f42f32b24a02fcb" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "heapless" version = "0.7.17" @@ -1143,7 +1305,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -1155,16 +1326,42 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "hpke-rs" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e11bd4ee27b79fa1820e72ef8489cc729c87299ec3f7f52b8fc8dcb87cb2d485" dependencies = [ - "hpke-rs-crypto", + "hpke-rs-crypto 0.2.0", "log", "serde", - "tls_codec", + "tls_codec 0.4.2", + "zeroize", +] + +[[package]] +name = "hpke-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812de62ab573b876c6c40d7553bae9402ae3bad95b64af06f964388eecb9b7b1" +dependencies = [ + "hpke-rs-crypto 0.7.0", + "hpke-rs-libcrux", + "hpke-rs-rust-crypto 0.7.0", + "libcrux-sha3", + "log", + "serde", + "subtle", + "tls_codec 0.5.0", "zeroize", ] @@ -1177,6 +1374,34 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "hpke-rs-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c461da2e0c9c93d875b597f0c78214fb0d2d5c57834b2ef2a2fa057fa20e24" +dependencies = [ + "rand 0.10.2", + "zeroize", +] + +[[package]] +name = "hpke-rs-libcrux" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1ace95ef11fbbd84527eada5fc1304f66720fc4c008db30d888d62d3c52613" +dependencies = [ + "hpke-rs-crypto 0.7.0", + "libcrux-aead", + "libcrux-ecdh", + "libcrux-hkdf", + "libcrux-kem", + "libcrux-traits", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "zeroize", +] + [[package]] name = "hpke-rs-rust-crypto" version = "0.2.0" @@ -1185,14 +1410,38 @@ checksum = "a08d4500baf0aced746723d3515d08212bdb9d941df6d1aca3d46d1619b2a1cf" dependencies = [ "aes-gcm", "chacha20poly1305", - "hkdf", - "hpke-rs-crypto", + "hkdf 0.12.4", + "hpke-rs-crypto 0.2.0", "p256", "p384", "rand_chacha 0.3.1", "rand_core 0.6.4", "sha2 0.10.9", - "x25519-dalek", + "x25519-dalek 2.0.1", +] + +[[package]] +name = "hpke-rs-rust-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a606ac19851da841862ef5f39d26d6227bfcfb4047417801a66c1f6e6652d71" +dependencies = [ + "aes-gcm", + "chacha20poly1305", + "hkdf 0.13.0", + "hpke-rs-crypto 0.7.0", + "k256", + "ml-kem", + "p256", + "p384", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "sha2 0.11.1", + "subtle", + "x-wing", + "x25519-dalek 2.0.1", + "zeroize", ] [[package]] @@ -1252,6 +1501,7 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "ctutils", "typenum", ] @@ -1450,6 +1700,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1596,9 +1855,9 @@ dependencies = [ "frost-ed25519", "joule-code-cargo", "joule-code-schema", - "openmls", + "openmls 0.6.0", "openmls_basic_credential", - "openmls_rust_crypto", + "openmls_rust_crypto 0.3.0", "rand 0.8.8", "thiserror 2.0.20", ] @@ -1766,12 +2025,17 @@ dependencies = [ name = "joule-moq" version = "0.3.0" dependencies = [ + "aes", + "aes-gcm", "async-trait", "blake3", "bytes", "chrono", + "ctr", "ed25519-dalek", "futures", + "hkdf 0.12.4", + "hmac 0.12.1", "jwp", "quinn", "rand_core 0.6.4", @@ -1815,6 +2079,23 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "joule-moq-mls" +version = "0.1.0" +dependencies = [ + "blake3", + "ed25519-dalek", + "hex", + "joule-moq", + "openmls 0.9.1", + "openmls_rust_crypto 0.6.0", + "openmls_traits 0.6.0", + "serde", + "serde_json", + "sha2 0.10.9", + "tls_codec 0.5.0", +] + [[package]] name = "joule-moq-relay" version = "0.1.0" @@ -1952,6 +2233,36 @@ dependencies = [ "zstd", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "elliptic-curve", +] + +[[package]] +name = "keccak" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common 0.2.2", + "rand_core 0.10.1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1964,6 +2275,225 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libcrux-aead" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22acbe68be84b7b41aaba6e39b87036fc4324dee628d94773750bf3d7e906d69" +dependencies = [ + "libcrux-aes", + "libcrux-chacha20poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-aes" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cc95b11dbc797b1e169467b1fc4205c4e6ee2ce516a035ad7342ce5f6ae971" +dependencies = [ + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-chacha20poly1305" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537e6eee5cdc9a980014d058784b0be09614f0596df789b114f7833aa9f35d75" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-curve25519" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f3cfd5e31cb9745e290ee061222c380ec42884654b09fc7d12a5b0ed63e028" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-ecdh" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a227590b89ff55ce7cd97b5a7468c82d231db8f3b890bb4d4fb6d271e7524d7" +dependencies = [ + "libcrux-curve25519", + "libcrux-p256", + "rand 0.10.2", + "tls_codec 0.5.0", +] + +[[package]] +name = "libcrux-hacl-rs" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66106db376ae249af86911aee048cf73ea1f394d6653026fc988dd22cf2a4ace" +dependencies = [ + "libcrux-macros", +] + +[[package]] +name = "libcrux-hkdf" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc94e651dca4d47edbcdd88bb2428ce16a2bd86b7a4e7170da87b505478f9839" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-hmac", + "libcrux-secrets", +] + +[[package]] +name = "libcrux-hmac" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500ad9b32c715161b594172ca1fb11503a1c033b393ff4c0c33505ce51c1943c" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a0c574d4eb81d0814bc2b91e4a433d7e0b35851b1d62eb5dd95c064f1f76d0" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-kem" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "541a7377fb35060892e0620982e224e47419f10da8c212453bf642dafe529691" +dependencies = [ + "libcrux-curve25519", + "libcrux-ecdh", + "libcrux-ml-kem", + "libcrux-p256", + "libcrux-sha3", + "libcrux-traits", + "rand 0.10.2", +] + +[[package]] +name = "libcrux-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8160f7d64fd2716b4fd05cc886a042f8dcda18d9206c0d506e2c67bdf97daa" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.10.2", + "tls_codec 0.5.0", +] + +[[package]] +name = "libcrux-p256" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3400732702d578be622257b98cec85e9b0cd34a67f1f06d3ebe9ae34963cdf02" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a9144949845813a0b8787d08cbba47baabe59c83f3043e558e6e92385c40cc" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79054fc9037cb70d6a546cf094cea7d7df06af5e49d230ba24103d27ccc886f1" +dependencies = [ + "crabgrind", + "hax-lib", +] + +[[package]] +name = "libcrux-sha2" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e46e1be0b77098cc6ce82137864936ecad3a1b9fd4303dd51202ca140dd1e" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-traits", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09f5c39afae0528e1f70a3c1e3c6ee649ec1f19dda26fc9b6ea91108fb879ef" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa7a21e8c2e8baa8b40f0b176740f2ca4baadd79d1b00e48d6b1e363c42085a" +dependencies = [ + "libcrux-secrets", + "rand 0.10.2", +] + [[package]] name = "libloading" version = "0.8.9" @@ -2060,6 +2590,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", +] + +[[package]] +name = "ml-kem" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ba01ebcf90f6aff8e9b9f55bb69b19e6250e4ade5787d0040eaa27dd226238" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "rand_core 0.10.1", + "sha3", + "shake", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "nom" version = "7.1.3" @@ -2156,11 +2727,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f1295188c6665fb9e96466180ac0a919ef52b5861cf7e86523823661348a597" dependencies = [ "log", - "openmls_traits", + "openmls_traits 0.3.0", "rayon", "serde", "thiserror 1.0.69", - "tls_codec", + "tls_codec 0.4.2", +] + +[[package]] +name = "openmls" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ea97f8931047590bda498a9502e8fe6c9bb2bb04547a9a463618783481e0ec" +dependencies = [ + "log", + "openmls_serialization_helpers", + "openmls_traits 0.6.0", + "rayon", + "serde", + "serde_bytes", + "thiserror 2.0.20", + "tls_codec 0.5.0", + "zeroize", ] [[package]] @@ -2170,11 +2758,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "465874276a4e2a3e391b25a464059a224c70c5c77ca1e3f595032bb310ab8984" dependencies = [ "ed25519-dalek", - "openmls_traits", + "openmls_traits 0.3.0", "p256", "rand 0.8.8", "serde", - "tls_codec", + "tls_codec 0.4.2", ] [[package]] @@ -2184,12 +2772,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea9cc9eb04ed97262fa96382d7dbf0adfebd5ecde7dbafc7070f9231dd9cf08b" dependencies = [ "log", - "openmls_traits", + "openmls_traits 0.3.0", "serde", "serde_json", "thiserror 1.0.69", ] +[[package]] +name = "openmls_memory_storage" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce927945a16eaa19302ed5ccef052f01ba68f2ba7ae67e4ac4ff2fc661445364" +dependencies = [ + "log", + "openmls_traits 0.6.0", + "serde", + "serde_json", + "thiserror 2.0.20", +] + [[package]] name = "openmls_rust_crypto" version = "0.3.0" @@ -2199,20 +2800,59 @@ dependencies = [ "aes-gcm", "chacha20poly1305", "ed25519-dalek", - "hkdf", - "hmac", - "hpke-rs", - "hpke-rs-crypto", - "hpke-rs-rust-crypto", - "openmls_memory_storage", - "openmls_traits", + "hkdf 0.12.4", + "hmac 0.12.1", + "hpke-rs 0.2.0", + "hpke-rs-crypto 0.2.0", + "hpke-rs-rust-crypto 0.2.0", + "openmls_memory_storage 0.3.0", + "openmls_traits 0.3.0", "p256", "rand 0.8.8", "rand_chacha 0.3.1", "serde", "sha2 0.10.9", "thiserror 1.0.69", - "tls_codec", + "tls_codec 0.4.2", +] + +[[package]] +name = "openmls_rust_crypto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bf3fe824ead6e81d22f49a9cd7feffca3494979fac14c1248e19516e3625f1" +dependencies = [ + "aes-gcm", + "chacha20poly1305", + "ed25519-dalek", + "hkdf 0.13.0", + "hmac 0.13.0", + "hpke-rs 0.7.0", + "hpke-rs-crypto 0.7.0", + "hpke-rs-rust-crypto 0.7.0", + "ml-dsa", + "openmls_memory_storage 0.6.0", + "openmls_traits 0.6.0", + "p256", + "p384", + "rand_chacha 0.3.1", + "rand_core 0.10.1", + "rand_core 0.6.4", + "sha2 0.11.1", + "thiserror 2.0.20", + "tls_codec 0.5.0", +] + +[[package]] +name = "openmls_serialization_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2cf2193541e83ce1a89aaa44c2fd4783abd54853823075a32b60ef032284c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.20", ] [[package]] @@ -2222,7 +2862,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "443afa05406adc75fbfa0c3e04db93c5647b763861a474ae1aa8a99c362b80f8" dependencies = [ "serde", - "tls_codec", + "tls_codec 0.4.2", +] + +[[package]] +name = "openmls_traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac13edad8698eb1917cbc1dab99591fa1594d84320904fe3fc5121854f95831c" +dependencies = [ + "serde", + "tls_codec 0.5.0", ] [[package]] @@ -2255,6 +2905,12 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + [[package]] name = "pem" version = "3.0.6" @@ -2292,8 +2948,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.2", + "spki 0.8.1", ] [[package]] @@ -2368,6 +3034,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2377,6 +3053,28 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2545,6 +3243,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2613,13 +3321,42 @@ dependencies = [ "yasna", ] +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rfc6979" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -2813,9 +3550,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] @@ -2859,6 +3596,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -2950,6 +3697,28 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2959,6 +3728,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -2985,6 +3760,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -3045,9 +3830,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.2", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3099,7 +3900,7 @@ dependencies = [ "aes-gcm", "ctr", "dimpl", - "hmac", + "hmac 0.12.1", "p256", "sha1", "sha2 0.10.9", @@ -3268,7 +4069,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" dependencies = [ "serde", - "tls_codec_derive", + "tls_codec_derive 0.4.2", + "zeroize", +] + +[[package]] +name = "tls_codec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18cc98286004cea38f717e2b03d990fc774fbfd38a82720de40e5c94365067c8" +dependencies = [ + "serde", + "serde_bytes", + "tls_codec_derive 0.5.0", "zeroize", ] @@ -3283,6 +4096,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tls_codec_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "674f41dd95f76cdeb005c83f9444e20cf43daebef37cde9e49a9ca6f4e87b423" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -3857,18 +4681,41 @@ dependencies = [ "url", ] +[[package]] +name = "x-wing" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01576e56599d01d75c5efcd5b30fa5de29e776f5a225e43fbf8c92c7302d4c8b" +dependencies = [ + "kem", + "ml-kem", + "sha3", + "shake", + "x25519-dalek 3.0.0", +] + [[package]] name = "x25519-dalek" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 4.1.3", "rand_core 0.6.4", "serde", "zeroize", ] +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek 5.0.0", + "rand_core 0.10.1", +] + [[package]] name = "x509-cert" version = "0.2.5" @@ -3876,8 +4723,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid 0.9.6", - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", ] [[package]] diff --git a/joule-code/joule-code-rs/Cargo.toml b/joule-code/joule-code-rs/Cargo.toml index 5173613a4..af6d8f1f3 100644 --- a/joule-code/joule-code-rs/Cargo.toml +++ b/joule-code/joule-code-rs/Cargo.toml @@ -17,6 +17,7 @@ members = [ "crates/joule-code-identity", "crates/jwp", "crates/joule-moq", + "crates/joule-moq-mls", "crates/joule-moq-relay", "crates/joule-moq-ingest", "crates/joule-moq-web", diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-mls/Cargo.toml new file mode 100644 index 000000000..112abcd8d --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "joule-moq-mls" +version = "0.1.0" +description = "End-to-end encrypted WAI live rooms: one MLS group (RFC 9420) per room, its membership driven by the room's grants and lifecycle, the room's clock as the group's external sender and a committer every member computes alike, and each epoch's per-sender keys for joule-moq's sealed objects (draft-ietf-moq-secure-objects-01). With the room's confidentiality record and the corpus generator of WAI live-conformance/e2ee." +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +repository = "https://git.transaction.science/Transaction-Science/open-standards" + +[[bin]] +name = "joule_moq_e2ee_vectors" +path = "src/bin/joule_moq_e2ee_vectors.rs" + +[dependencies] +joule-moq = { path = "../joule-moq", default-features = false, features = ["secure-objects"] } +# MLS (RFC 9420), MIT, pinned exactly: the group, its key schedule and exporter, external +# proposals, and the public view of a group a non-member keeps. +openmls = "=0.9.1" +openmls_rust_crypto = "=0.6.0" +openmls_traits = "=0.6.0" +tls_codec = "0.5" +ed25519-dalek = "2" +sha2 = "0.10" +blake3 = "1" +hex = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/bin/joule_moq_e2ee_vectors.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/bin/joule_moq_e2ee_vectors.rs new file mode 100644 index 000000000..99b4281a2 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/bin/joule_moq_e2ee_vectors.rs @@ -0,0 +1,57 @@ +//! The corpus of WAI `live-conformance/e2ee/` (confidentiality §14). +//! +//! joule_moq_e2ee_vectors gen write vectors.json from the fixed seeds +//! joule_moq_e2ee_vectors verify vectors.json regenerates byte for byte, and +//! the reference gives every vector its outcome + +use joule_moq_mls::corpus; +use std::process::ExitCode; + +fn main() -> ExitCode { + let args: Vec = std::env::args().collect(); + let (Some(mode), Some(dir)) = (args.get(1), args.get(2)) else { + eprintln!("usage: joule_moq_e2ee_vectors gen|verify "); + return ExitCode::from(2); + }; + let path = std::path::Path::new(dir).join(corpus::FILE); + let v = corpus::generate(); + let bytes = corpus::to_bytes(&v); + match mode.as_str() { + "gen" => { + if let Err(e) = std::fs::write(&path, &bytes) { + eprintln!("{}: {e}", path.display()); + return ExitCode::FAILURE; + } + println!("wrote {}", path.display()); + ExitCode::SUCCESS + } + "verify" => { + let on_disk = match std::fs::read(&path) { + Ok(b) => b, + Err(e) => { + eprintln!("{}: {e}", path.display()); + return ExitCode::FAILURE; + } + }; + if on_disk != bytes { + eprintln!("{} does not regenerate byte for byte", path.display()); + return ExitCode::FAILURE; + } + let parsed: serde_json::Value = serde_json::from_slice(&on_disk).expect("JSON"); + match corpus::check(&parsed) { + Ok(()) => { + println!("{}: every vector regenerates and gives its stated outcome", path.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } + } + _ => { + eprintln!("usage: joule_moq_e2ee_vectors gen|verify "); + ExitCode::from(2) + } + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/corpus.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/corpus.rs new file mode 100644 index 000000000..561e9ca2c --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/corpus.rs @@ -0,0 +1,841 @@ +//! The corpus of WAI `live-conformance/e2ee/` (confidentiality §14): `vectors.json`, written +//! from fixed seeds by [`generate`], and the reference's reading of every vector, +//! [`check`], which the generator also uses to state each outcome. +//! +//! The corpus starts where the MLS layer hands over: each epoch's application secret is +//! stated (drawn from a seed, standing for `MLS-Exporter("wai live e2ee", room_id, 32)`), +//! and everything after it is checked: rosters and committers from the transcript, track +//! base keys, KIDs, keys, salts, nonces, AADs, every seal and open, and every refusal. The +//! crate's tests run real MLS groups through the same model and keyring. + +use crate::model::{Change, Event, From as By, Principal, Proposal, Room, Roster}; +use crate::record::{Record, MLS_SUITE}; +use ed25519_dalek::SigningKey; +use joule_moq::draft22::kvp::{KeyValuePair, KvValue, Properties}; +use joule_moq::secure_objects::room::{derive, seal_with, Incoming, Keyring, NotOpened, Outgoing}; +use joule_moq::secure_objects::{aad, ctr, nonce, object_properties, Plaintext, Refusal, Suite}; +use joule_moq::{TrackName, TrackNamespace}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +/// The file the generator writes. +pub const FILE: &str = "vectors.json"; + +fn sha(parts: &[&[u8]]) -> [u8; 32] { + let mut h = Sha256::new(); + for p in parts { + h.update(p); + } + h.finalize().into() +} + +/// A key drawn from the corpus's seed for `label`. +pub fn key(label: &str) -> SigningKey { + SigningKey::from_bytes(&sha(&[b"wai:e2ee/corpus/key\x00", label.as_bytes()])) +} + +/// The application secret standing for epoch `epoch` of room `label`'s generation `g`. +pub fn secret(label: &str, g: u32, epoch: u64) -> [u8; 32] { + sha(&[b"wai:e2ee/corpus/secret\x00", label.as_bytes(), &g.to_be_bytes(), &epoch.to_be_bytes()]) +} + +/// `n` bytes drawn for `label`. +pub fn bytes(label: &str, n: usize) -> Vec { + let mut out = Vec::with_capacity(n + 32); + let mut i = 0u32; + while out.len() < n { + out.extend_from_slice(&sha(&[b"wai:e2ee/corpus/bytes\x00", label.as_bytes(), &i.to_be_bytes()])); + i += 1; + } + out.truncate(n); + out +} + +fn h(b: &[u8]) -> String { + hex::encode(b) +} + +fn unhex(v: &Value) -> Result, String> { + hex::decode(v.as_str().ok_or("not a string")?).map_err(|e| e.to_string()) +} + +fn unhex32(v: &Value) -> Result<[u8; 32], String> { + unhex(v)?.try_into().map_err(|_| "not 32 bytes".to_string()) +} + +fn num(v: &Value) -> Result { + v.as_u64().ok_or_else(|| format!("not a number: {v}")) +} + +fn ns_strings(ns: &TrackNamespace) -> Value { + Value::Array(ns.0.iter().map(|f| Value::String(String::from_utf8(f.clone()).expect("ASCII"))).collect()) +} + +fn ns_of(v: &Value) -> Result { + let a = v.as_array().ok_or("namespace not an array")?; + Ok(TrackNamespace::from_strs(a.iter().map(|f| f.as_str().unwrap_or_default()))) +} + +/// A room track's namespace: the room id, the owner, the media kind (live-rooms §6.1). +pub fn room_ns(room: &[u8; 32], owner: &Principal, kind: &str) -> TrackNamespace { + TrackNamespace::from_strs([h(room), h(owner), kind.to_string()]) +} + +fn roster_json(r: &Roster) -> Value { + Value::Object(r.iter().map(|(l, p)| (l.to_string(), Value::String(h(p)))).collect()) +} + +fn roster_of(v: &Value) -> Result { + let o = v.as_object().ok_or("roster not an object")?; + o.iter().map(|(k, p)| Ok((k.parse::().map_err(|e| e.to_string())?, unhex32(p)?))).collect() +} + +fn props_json(p: &[KeyValuePair]) -> Value { + Value::Array( + p.iter() + .map(|k| match &k.value { + KvValue::Varint(v) => json!({"type": k.ty, "varint": v}), + KvValue::Bytes(b) => json!({"type": k.ty, "bytes": h(b)}), + }) + .collect(), + ) +} + +fn props_of(v: &Value) -> Result, String> { + let a = v.as_array().ok_or("properties not an array")?; + a.iter() + .map(|k| { + let ty = num(&k["type"])?; + Ok(match k.get("varint") { + Some(x) => KeyValuePair::varint(ty, num(x)?), + None => KeyValuePair::bytes(ty, unhex(&k["bytes"])?), + }) + }) + .collect() +} + +/// Sort every object's keys, so the file's bytes do not depend on how a JSON map orders. +fn canonical(v: Value) -> Value { + match v { + Value::Object(m) => { + let sorted: BTreeMap = m.into_iter().map(|(k, v)| (k, canonical(v))).collect(); + Value::Object(sorted.into_iter().collect::>()) + } + Value::Array(a) => Value::Array(a.into_iter().map(canonical).collect()), + x => x, + } +} + +fn outcome_of(r: Result) -> Value { + match r { + Ok(o) => json!({"outcome": "opened", "payload_blake3": h(blake3::hash(&o.plaintext.payload).as_bytes())}), + Err(NotOpened::Refused(r)) => json!({"outcome": r.code()}), + Err(NotOpened::Pending(e)) => json!({"outcome": "pending", "epoch": e}), + } +} + +// ── Records ───────────────────────────────────────────────────────────────── + +const ROOM_LABEL: &str = "lesson"; + +fn the_room() -> [u8; 32] { + sha(&[b"wai:e2ee/corpus/room\x00", ROOM_LABEL.as_bytes()]) +} + +fn records() -> Vec { + let auth = key("authority"); + let a = auth.verifying_key().to_bytes(); + let room = the_room(); + let good = Record::sign(room, 4, MLS_SUITE, &auth).to_bytes(); + let mut identity = [0u8; 32]; + identity[0] = 1; + let mut v = vec![ + ("accepted, AES_128_GCM_SHA256_128", good.clone(), a, 0u8), + ("accepted, AES_256_GCM_SHA512_128", Record::sign(room, 5, MLS_SUITE, &auth).to_bytes(), a, 0), + ("fewer than four bytes", good[..3].to_vec(), a, 0), + ("magic only", good[..4].to_vec(), a, 0), + ("cut inside the signature", good[..100].to_vec(), a, 0), + ("another magic", [b"WRD1".as_slice(), &good[4..]].concat(), a, 0), + ("a byte after the signature", [good.as_slice(), &[0]].concat(), a, 0), + ("version 2", { let mut b = good.clone(); b[4] = 2; b }, a, 0), + ("another room's", Record::sign([0x11; 32], 4, MLS_SUITE, &auth).to_bytes(), a, 0), + ("a counter-mode suite", Record::sign(room, 1, MLS_SUITE, &auth).to_bytes(), a, 0), + ("an unregistered suite", Record::sign(room, 6, MLS_SUITE, &auth).to_bytes(), a, 0), + ("another MLS suite", Record::sign(room, 4, 3, &auth).to_bytes(), a, 0), + ("a room that records", good.clone(), a, 1), + ("a room always recorded", good.clone(), a, 2), + ("signed by another key", Record::sign(room, 4, MLS_SUITE, &key("intruder")).to_bytes(), a, 0), + ("the identity as the authority", good.clone(), identity, 0), + ("a changed suite under the old signature", { let mut b = good.clone(); b[37] = 5; b }, a, 0), + ]; + v.drain(..) + .map(|(name, rec, authority, mode)| { + json!({"name": name, "record": h(&rec), "room": h(&room), "authority": h(&authority), "recording_mode": mode}) + }) + .collect() +} + +fn check_record(v: &Value) -> Result { + let r = Record::check(&unhex(&v["record"])?, &unhex32(&v["room"])?, &unhex32(&v["authority"])?, num(&v["recording_mode"])? as u8); + Ok(match r { + Ok((_, s)) => json!({"outcome": "accept", "suite": s.id()}), + Err(e) => json!({"outcome": e.code()}), + }) +} + +// ── Key schedule ──────────────────────────────────────────────────────────── + +fn key_schedule() -> Vec { + let room = the_room(); + let mut out = Vec::new(); + for (suite, epoch, leaf, owner, kind, track) in [ + (Suite::AesGcm128Sha256, 0u64, 0u32, "a", "video", "0"), + (Suite::AesGcm128Sha256, 0, 0, "a", "video", "1"), + (Suite::AesGcm128Sha256, 1, 1, "b", "audio", "0"), + (Suite::AesGcm128Sha256, 2, 2, "c", "screen", "0"), + (Suite::AesGcm256Sha512, 7, 3, "d", "video", "2"), + (Suite::AesGcm256Sha512, (1 << 40) - 1, (1 << 24) - 1, "e", "audio", "0"), + ] { + let s = secret(ROOM_LABEL, 0, epoch); + let ns = room_ns(&room, &key(owner).verifying_key().to_bytes(), kind); + let name = TrackName::from_str(track); + let d = derive(suite, &s, epoch, leaf, &ns, &name).expect("in range"); + out.push(json!({ + "suite": suite.id(), "application_secret": h(&s), "epoch": epoch, "leaf": leaf, + "namespace": ns_strings(&ns), "track": track, + "track_base_key": h(&d.track_base_key), "kid": d.kid, "serialized_full_track_name": h(&d.ftn), + "moq_key": h(&d.keys.key), "moq_salt": h(&d.keys.salt), + })); + } + out +} + +fn check_key_schedule(v: &Value) -> Result<(), String> { + let suite = Suite::from_id(num(&v["suite"])? as u16).ok_or("suite")?; + let ns = ns_of(&v["namespace"])?; + let name = TrackName::from_str(v["track"].as_str().ok_or("track")?); + let d = derive(suite, &unhex32(&v["application_secret"])?, num(&v["epoch"])?, num(&v["leaf"])? as u32, &ns, &name).ok_or("out of range")?; + let got = json!({"track_base_key": h(&d.track_base_key), "kid": d.kid, "serialized_full_track_name": h(&d.ftn), "moq_key": h(&d.keys.key), "moq_salt": h(&d.keys.salt)}); + for (k, x) in got.as_object().unwrap() { + if &v[k] != x { + return Err(format!("{k}: {} != {x}", v[k])); + } + } + Ok(()) +} + +// ── Single objects ────────────────────────────────────────────────────────── + +/// A receiver holding one epoch of a room, at which every leaf of `roster` is a member. +fn context(receiver: &Principal, epoch: u64, s: &[u8; 32], roster: &Roster) -> Value { + json!({"receiver": h(receiver), "epochs": [{"epoch": epoch, "application_secret": h(s), "roster": roster_json(roster)}]}) +} + +fn keyring_of(suite: Suite, ctx: &Value) -> Result { + let mut k = Keyring::new(suite, unhex32(&ctx["receiver"])?); + for e in ctx["epochs"].as_array().ok_or("epochs")? { + k.enter(num(&e["epoch"])?, unhex32(&e["application_secret"])?, roster_of(&e["roster"])?); + } + Ok(k) +} + +fn object_json(ns: &TrackNamespace, track: &str, group: u64, object: u64, priority: u8, props: &Properties, payload: &[u8]) -> Value { + json!({ + "namespace": ns_strings(ns), "track": track, "group": group, "object": object, "priority": priority, + "properties": h(&props.to_bytes().expect("encodes")), "payload": h(payload), + }) +} + +fn open_json(ring: &mut Keyring, o: &Value) -> Result { + let ns = ns_of(&o["namespace"])?; + let name = TrackName::from_str(o["track"].as_str().ok_or("track")?); + let props = Properties::decode(&unhex(&o["properties"])?, "object properties").map_err(|_| "properties").ok(); + let payload = unhex(&o["payload"])?; + let Some(props) = props else { return Ok(json!({"outcome": Refusal::PropertiesMalformed.code()})) }; + let inc = Incoming { ns: &ns, name: &name, group: num(&o["group"])?, object: num(&o["object"])?, priority: num(&o["priority"])? as u8, properties: &props, payload: &payload }; + Ok(outcome_of(ring.open(&inc, None))) +} + +fn objects() -> Vec { + let room = the_room(); + let (a, b) = (key("a").verifying_key().to_bytes(), key("b").verifying_key().to_bytes()); + let roster: Roster = [(0, a), (1, b)].into(); + let mut out = Vec::new(); + struct Case<'a> { + name: &'a str, + suite: Suite, + kind: &'a str, + track: &'a str, + group: u64, + object: u64, + priority: u8, + payload: Vec, + digest: bool, + padding: Option, + entries: Vec<(u16, Vec)>, + immutable: Vec, + } + let cases = [ + Case { name: "a video frame with its inner digest", suite: Suite::AesGcm128Sha256, kind: "video", track: "0", group: 12, object: 0, priority: 64, payload: bytes("frame", 1200), digest: true, padding: None, entries: vec![], immutable: vec![] }, + Case { name: "a frame of speech, no list", suite: Suite::AesGcm128Sha256, kind: "audio", track: "0", group: 3, object: 41, priority: 16, payload: bytes("speech", 60), digest: false, padding: None, entries: vec![], immutable: vec![] }, + Case { name: "padded, with an entry of another type", suite: Suite::AesGcm128Sha256, kind: "screen", track: "0", group: 1, object: 2, priority: 128, payload: bytes("screen", 300), digest: true, padding: Some(200), entries: vec![(0x0100, b"for-eyes-only".to_vec())], immutable: vec![] }, + Case { name: "a refinement layer with the layer properties", suite: Suite::AesGcm256Sha512, kind: "video", track: "1", group: 12, object: 3, priority: 96, payload: bytes("layer", 700), digest: true, padding: None, entries: vec![], immutable: vec![KeyValuePair::varint(0x3a40, 1), KeyValuePair::varint(0x3a42, 3)] }, + Case { name: "an envelope, AES_256_GCM_SHA512_128", suite: Suite::AesGcm256Sha512, kind: "audio", track: "0", group: u32::MAX as u64 + 7, object: u32::MAX as u64, priority: 0, payload: bytes("envelope", 64), digest: true, padding: Some(0), entries: vec![], immutable: vec![KeyValuePair::varint(0x3a44, 1)] }, + Case { name: "an empty payload", suite: Suite::AesGcm128Sha256, kind: "video", track: "0", group: 0, object: 0, priority: 255, payload: vec![], digest: true, padding: None, entries: vec![], immutable: vec![] }, + ]; + for c in cases { + let (epoch, leaf, sender) = (2u64, 1u32, b); + let s = secret(ROOM_LABEL, 0, epoch); + let ns = room_ns(&room, &sender, c.kind); + let name = TrackName::from_str(c.track); + let d = derive(c.suite, &s, epoch, leaf, &ns, &name).expect("in range"); + let mut pt = if c.digest { Plaintext::with_digest(c.payload.clone()) } else { Plaintext { payload: c.payload.clone(), ..Default::default() } }; + pt.padding = c.padding; + pt.other = c.entries.clone(); + let o = Outgoing { ns: &ns, name: &name, group: c.group, object: c.object, priority: c.priority, plaintext: &pt, immutable: &c.immutable, mutable: &[] }; + let sealed = seal_with(c.suite, &d, &o).expect("seals"); + let a_ = aad(c.group, c.object, c.priority, &sealed.sip).unwrap(); + let n = nonce(&d.keys.salt, &ctr(c.group, c.object).unwrap()); + out.push(json!({ + "name": c.name, "suite": c.suite.id(), + "input": { + "application_secret": h(&s), "epoch": epoch, "leaf": leaf, "namespace": ns_strings(&ns), "track": c.track, + "group": c.group, "object": c.object, "priority": c.priority, "payload": h(&c.payload), "inner_digest": c.digest, + "padding": c.padding, "entries": Value::Array(c.entries.iter().map(|(t, v)| json!({"type": t, "value": h(v)})).collect()), + "immutable": props_json(&c.immutable), + }, + "derived": { + "track_base_key": h(&d.track_base_key), "kid": d.kid, "serialized_full_track_name": h(&d.ftn), + "moq_key": h(&d.keys.key), "moq_salt": h(&d.keys.salt), "immutable_properties": h(&sealed.sip), + "plaintext": h(&pt.encode()), "aad": h(&a_), "nonce": h(&n), + }, + "object": object_json(&ns, c.track, c.group, c.object, c.priority, &sealed.properties, &sealed.payload), + "content_hash": h(blake3::hash(&sealed.payload).as_bytes()), + "context": context(&a, epoch, &s, &roster), + })); + } + // Negatives, each a control of one defect, opened by A in the same epoch. + let epoch = 2u64; + let s = secret(ROOM_LABEL, 0, epoch); + let ns = room_ns(&room, &b, "video"); + let name = TrackName::from_str("0"); + let d = derive(Suite::AesGcm128Sha256, &s, epoch, 1, &ns, &name).unwrap(); + let pt = Plaintext::with_digest(bytes("neg", 80)); + let good = seal_with(Suite::AesGcm128Sha256, &d, &Outgoing { ns: &ns, name: &name, group: 5, object: 1, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap(); + let seal_raw = |raw: &[u8], group: u64, object: u64| { + let at = joule_moq::secure_objects::Location { ftn: &d.ftn, group, object, priority: 64 }; + joule_moq::secure_objects::seal_object(Suite::AesGcm128Sha256, &d.keys, at, &good.sip, raw).unwrap() + }; + let no_kid = object_properties(&Properties(vec![KeyValuePair::varint(0x3a40, 0)]).to_bytes().unwrap(), &[]); + let two_kids = object_properties(&Properties(vec![KeyValuePair::varint(0x3a4a, d.kid), KeyValuePair::varint(0x3a4a, d.kid)]).to_bytes().unwrap(), &[]); + let mut outside = good.properties.clone(); + outside.push(KeyValuePair::varint(0x3a4a, d.kid)); + let garbage = object_properties(&[0x0b, 0x05, 0x01], &[]); + let mut bad_list = Plaintext { payload: bytes("neg", 80), ..Default::default() }.encode(); + bad_list.extend_from_slice(&[0x00, 0x0b, 0x00]); + let lie = Plaintext { payload: bytes("neg", 80), inner_digest: Some(*blake3::hash(b"something else").as_bytes()), ..Default::default() }; + let big = 1u64 << 32; + // (what is wrong, group, object, publisher priority, properties, payload) + type Negative<'a> = (&'a str, u64, u64, u8, Properties, Vec); + let negs: Vec = vec![ + ("no Key ID", 5, 1, 64, no_kid, good.payload.clone()), + ("no Immutable Properties", 5, 1, 64, Properties::new(), good.payload.clone()), + ("two Key IDs", 5, 1, 64, two_kids, good.payload.clone()), + ("a Key ID outside Immutable Properties", 5, 1, 64, outside, good.payload.clone()), + ("Immutable Properties that do not parse", 5, 1, 64, garbage, good.payload.clone()), + ("an Object ID past 32 bits", 5, big, 64, good.properties.clone(), good.payload.clone()), + ("another priority", 5, 1, 65, good.properties.clone(), good.payload.clone()), + ("another object", 5, 2, 64, good.properties.clone(), good.payload.clone()), + ("a changed tag", 5, 1, 64, good.properties.clone(), { let mut p = good.payload.clone(); *p.last_mut().unwrap() ^= 0x80; p }), + ("cut short of a tag", 5, 1, 64, good.properties.clone(), good.payload[..10].to_vec()), + ("an Encrypted Properties List of another type", 6, 0, 64, good.properties.clone(), seal_raw(&bad_list, 6, 0)), + ("a payload length past the plaintext", 6, 1, 64, good.properties.clone(), seal_raw(&[0x40, 0x01], 6, 1)), + ("an inner digest of other bytes", 6, 2, 64, good.properties.clone(), seal_raw(&lie.encode(), 6, 2)), + ]; + let roster: Roster = [(0, a), (1, b)].into(); + for (n, g, o, p, props, payload) in negs { + out.push(json!({"name": n, "suite": 4, "object": object_json(&ns, "0", g, o, p, &props, &payload), "context": context(&a, epoch, &s, &roster)})); + } + out +} + +fn check_object(v: &Value) -> Result { + let suite = Suite::from_id(num(&v["suite"])? as u16).ok_or("suite")?; + if let Some(input) = v.get("input") { + // Seal from the inputs and require every derived value and the object itself. + let ns = ns_of(&input["namespace"])?; + let track = input["track"].as_str().ok_or("track")?; + let name = TrackName::from_str(track); + let (epoch, leaf) = (num(&input["epoch"])?, num(&input["leaf"])? as u32); + let d = derive(suite, &unhex32(&input["application_secret"])?, epoch, leaf, &ns, &name).ok_or("range")?; + let payload = unhex(&input["payload"])?; + let mut pt = if input["inner_digest"].as_bool() == Some(true) { Plaintext::with_digest(payload) } else { Plaintext { payload, ..Default::default() } }; + pt.padding = input["padding"].as_u64().map(|p| p as u16); + for e in input["entries"].as_array().ok_or("entries")? { + pt.other.push((num(&e["type"])? as u16, unhex(&e["value"])?)); + } + let imm = props_of(&input["immutable"])?; + let (g, o, p) = (num(&input["group"])?, num(&input["object"])?, num(&input["priority"])? as u8); + let sealed = seal_with(suite, &d, &Outgoing { ns: &ns, name: &name, group: g, object: o, priority: p, plaintext: &pt, immutable: &imm, mutable: &[] }).map_err(|e| e.to_string())?; + let derived = json!({ + "track_base_key": h(&d.track_base_key), "kid": d.kid, "serialized_full_track_name": h(&d.ftn), + "moq_key": h(&d.keys.key), "moq_salt": h(&d.keys.salt), "immutable_properties": h(&sealed.sip), + "plaintext": h(&pt.encode()), "aad": h(&aad(g, o, p, &sealed.sip).unwrap()), "nonce": h(&nonce(&d.keys.salt, &ctr(g, o).unwrap())), + }); + if derived != v["derived"] { + return Err(format!("derived values differ: {derived}")); + } + if object_json(&ns, track, g, o, p, &sealed.properties, &sealed.payload) != v["object"] { + return Err("the sealed object differs".into()); + } + if h(blake3::hash(&sealed.payload).as_bytes()) != v["content_hash"] { + return Err("the content hash differs".into()); + } + } + let mut ring = keyring_of(suite, &v["context"])?; + open_json(&mut ring, &v["object"]) +} + +// ── Rooms ─────────────────────────────────────────────────────────────────── + +/// A room replayed step by step (§5, §6): the model, each principal's keyring, and each +/// epoch's roster. +struct Replay { + suite: Suite, + model: Room, + rings: BTreeMap, + rosters: Vec, +} + +impl Replay { + fn new(suite: Suite, room: [u8; 32], clock: Principal) -> Self { + Replay { suite, model: Room::new(room, clock), rings: BTreeMap::new(), rosters: Vec::new() } + } + + fn enter_all(&mut self, secret: [u8; 32]) { + let g = self.model.group().expect("a group").clone(); + self.rosters.push(json!({"generation": g.generation, "epoch": g.epoch, "roster": roster_json(&g.roster)})); + for p in g.roster.values() { + self.rings.entry(*p).or_insert_with(|| Keyring::new(self.suite, *p)).enter(g.epoch, secret, g.roster.clone()); + } + } + + /// Apply one step; a delivery's outcomes are returned, a refusal stops the room. + fn step(&mut self, s: &Value) -> Result>, Refusal> { + fn bad(_: E) -> Refusal { + Refusal::Malformed + } + if let Some(e) = s.get("event") { + let ev: Event = serde_json::from_value(e.clone()).map_err(bad)?; + self.model.event(&ev); + return Ok(None); + } + if let Some(c) = s.get("create") { + let ext: Vec = c["external_senders"].as_array().ok_or(Refusal::Malformed)?.iter().map(unhex32).collect::>().map_err(bad)?; + self.model.create(unhex32(&c["by"]).map_err(bad)?, &unhex(&c["group_id"]).map_err(bad)?, &ext)?; + self.enter_all(unhex32(&c["application_secret"]).map_err(bad)?); + return Ok(None); + } + if let Some(p) = s.get("proposal") { + let p: Proposal = serde_json::from_value(p.clone()).map_err(bad)?; + self.model.propose(p)?; + return Ok(None); + } + if let Some(c) = s.get("commit") { + let holds: Vec = c["holds"].as_array().ok_or(Refusal::Malformed)?.iter().map(|x| x.as_u64().map(|x| x as usize).ok_or(Refusal::Malformed)).collect::>()?; + let done = self.model.commit(num(&c["sender"]).map_err(bad)? as u32, &holds)?; + for (_, p) in &done.removed { + if let Some(r) = self.rings.get_mut(p) { + r.removed_at(done.epoch); + } + } + self.enter_all(unhex32(&c["application_secret"]).map_err(bad)?); + return Ok(None); + } + if let Some(o) = s.get("object") { + let mut out = Vec::new(); + for d in s["deliveries"].as_array().ok_or(Refusal::Malformed)? { + let who = unhex32(&d["receiver"]).map_err(bad)?; + let ring = self.rings.entry(who).or_insert_with(|| Keyring::new(self.suite, who)); + let mut r = open_json(ring, o).map_err(bad)?; + r["receiver"] = Value::String(h(&who)); + out.push(r); + } + return Ok(Some(out)); + } + Err(Refusal::Malformed) + } +} + +/// Replay a room: its outcome, each epoch's roster, and each delivery's outcome. +fn replay(v: &Value) -> Result { + let suite = Suite::from_id(num(&v["suite"])? as u16).ok_or("suite")?; + let mut r = Replay::new(suite, unhex32(&v["room"])?, unhex32(&v["clock"])?); + let mut deliveries = Vec::new(); + let mut outcome = json!("accept"); + for (i, s) in v["steps"].as_array().ok_or("steps")?.iter().enumerate() { + match r.step(s) { + Ok(Some(d)) => deliveries.push(json!({"step": i, "deliveries": d})), + Ok(None) => {} + Err(e) => { + outcome = json!({"refuse": e.code(), "at": i}); + break; + } + } + } + Ok(json!({"outcome": outcome, "rosters": r.rosters, "deliveries": deliveries})) +} + +/// Builds a room's steps while replaying them, so each object is sealed by its sender in +/// the epoch it holds. +struct Builder { + label: &'static str, + room: [u8; 32], + clock: Principal, + suite: Suite, + generation: u32, + steps: Vec, + r: Replay, +} + +impl Builder { + fn new(label: &'static str, suite: Suite) -> Self { + let room = the_room(); + let clock = key("clock").verifying_key().to_bytes(); + Builder { label, room, clock, suite, generation: 0, steps: Vec::new(), r: Replay::new(suite, room, clock) } + } + + fn push(&mut self, s: Value) { + self.r.step(&s).expect("the builder's steps are good"); + self.steps.push(s); + } + + fn push_raw(&mut self, s: Value) { + self.steps.push(s); + } + + fn event(&mut self, ev: Event) { + self.push(json!({"event": serde_json::to_value(ev).unwrap()})); + } + + fn epoch(&self) -> u64 { + self.r.model.group().map(|g| g.epoch).unwrap_or(0) + } + + fn create(&mut self, by: &Principal) { + let gid = Room::group_id_of(&self.room, self.generation); + let s = secret(self.label, self.generation, 0); + self.push(json!({"create": {"by": h(by), "group_id": h(&gid), "external_senders": [h(&self.clock)], "application_secret": h(&s)}})); + } + + fn add(&mut self, p: &Principal) { + let pr = Proposal { from: By::Clock, epoch: self.epoch(), change: Change::Add { identity: *p, signature_key: *p } }; + self.push(json!({"proposal": serde_json::to_value(pr).unwrap()})); + } + + fn remove(&mut self, leaf: u32) { + let pr = Proposal { from: By::Clock, epoch: self.epoch(), change: Change::Remove { leaf } }; + self.push(json!({"proposal": serde_json::to_value(pr).unwrap()})); + } + + fn commit(&mut self, sender: u32, holds: &[usize]) { + let s = secret(self.label, self.generation, self.epoch() + 1); + self.push(json!({"commit": {"sender": sender, "holds": holds, "application_secret": h(&s)}})); + } + + /// Seal an object by `who` in its current epoch. + #[allow(clippy::too_many_arguments)] + fn seal(&self, who: &Principal, owner: &Principal, kind: &str, group: u64, object: u64, digest: bool, label: &str) -> Value { + let ring = &self.r.rings[who]; + let ns = room_ns(&self.room, owner, kind); + let name = TrackName::from_str("0"); + let payload = bytes(label, 90); + let pt = if digest { Plaintext::with_digest(payload) } else { Plaintext { payload, ..Default::default() } }; + let o = Outgoing { ns: &ns, name: &name, group, object, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] }; + let s = ring.seal(&o).expect("a member").expect("seals"); + object_json(&ns, "0", group, object, 64, &s.properties, &s.payload) + } + + fn deliver(&mut self, object: &Value, to: &[&Principal]) { + let d: Vec = to.iter().map(|p| json!({"receiver": h(*p)})).collect(); + self.push(json!({"object": object, "deliveries": d})); + } + + fn finish(self, name: &str) -> Value { + json!({"name": name, "room": h(&self.room), "clock": h(&self.clock), "suite": self.suite.id(), "steps": self.steps}) + } +} + +fn principals() -> [Principal; 5] { + ["a", "b", "c", "d", "e"].map(|l| key(l).verifying_key().to_bytes()) +} + +fn join(p: &Principal) -> Event { + Event::Join { subject: *p, keyed: true } +} + +/// The accepted room (§14): three members joining in turn, each publishing, the third +/// removed, a fourth joining late, the first leaving and the second committing. +fn lesson() -> Value { + let [a, b, c, d, e] = principals(); + let mut x = Builder::new(ROOM_LABEL, Suite::AesGcm128Sha256); + x.event(join(&a)); + x.create(&a); + // Every epoch's keys are used: A alone at epoch 0 opens its own object. + let a0 = x.seal(&a, &a, "screen", 0, 0, true, "a0"); + x.deliver(&a0, &[&a, &b]); + x.event(join(&b)); + x.add(&b); + x.commit(0, &[0]); + let a1 = x.seal(&a, &a, "screen", 1, 0, true, "a1"); + x.deliver(&a1, &[&b]); + x.event(join(&c)); + x.add(&c); + x.commit(0, &[0]); + x.event(Event::Join { subject: e, keyed: false }); + // Epoch 2: everyone publishes, everyone else opens. + let a2 = x.seal(&a, &a, "video", 0, 0, true, "a2"); + let b2 = x.seal(&b, &b, "audio", 0, 0, false, "b2"); + let c2 = x.seal(&c, &c, "video", 0, 0, true, "c2"); + x.deliver(&a2, &[&b, &c, &e]); + x.deliver(&b2, &[&a, &c]); + x.deliver(&c2, &[&a, &b]); + let c_late = x.seal(&c, &c, "video", 1, 0, true, "c-late"); + // C is removed (its grant revoked): epoch 3 excludes it. + x.event(Event::Leave { subject: c, reason: 2 }); + x.remove(2); + x.commit(0, &[0]); + x.deliver(&c_late, &[&a, &b]); + let a3 = x.seal(&a, &a, "video", 1, 0, true, "a3"); + x.deliver(&a3, &[&b, &c]); + // D joins late and takes C's leaf. + x.event(join(&d)); + x.add(&d); + x.commit(0, &[0]); + let d4 = x.seal(&d, &d, "video", 0, 0, true, "d4"); + let a4 = x.seal(&a, &a, "video", 2, 0, true, "a4"); + x.deliver(&d4, &[&a, &b]); + x.deliver(&a4, &[&d, &c]); + x.deliver(&a2, &[&d]); + x.deliver(&a3, &[&d]); + // A leaves: B, at the lowest leaf left, commits. + x.event(Event::Leave { subject: a, reason: 1 }); + x.remove(0); + x.commit(1, &[0]); + let b5 = x.seal(&b, &b, "audio", 1, 0, false, "b5"); + x.deliver(&b5, &[&d, &d, &a]); + let mut tampered = x.seal(&b, &b, "audio", 1, 1, false, "b5-1"); + let mut pl = hex::decode(tampered["payload"].as_str().unwrap()).unwrap(); + pl[3] ^= 0x01; + tampered["payload"] = json!(h(&pl)); + x.deliver(&tampered, &[&d]); + let forged = x.seal(&d, &b, "video", 7, 0, true, "forged"); + x.deliver(&forged, &[&b]); + // A KID naming a leaf no member holds in epoch 5 (A's, blank since it left). + let ghost = { + let ns = room_ns(&x.room, &b, "audio"); + let name = TrackName::from_str("0"); + let s = secret(ROOM_LABEL, 0, 5); + let dd = derive(Suite::AesGcm128Sha256, &s, 5, 0, &ns, &name).unwrap(); + let pt = Plaintext::with_digest(bytes("ghost", 40)); + let so = seal_with(Suite::AesGcm128Sha256, &dd, &Outgoing { ns: &ns, name: &name, group: 2, object: 0, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap(); + object_json(&ns, "0", 2, 0, 64, &so.properties, &so.payload) + }; + x.deliver(&ghost, &[&d]); + x.finish("three members, a removal, a late joiner, a change of committer") +} + +/// The refused rooms (§13), each a control of one defect, cut at the step it refuses. +fn refused_rooms() -> Vec { + let [a, b, c, d, _] = principals(); + let base = |x: &mut Builder| { + x.event(join(&a)); + x.create(&a); + x.event(join(&b)); + x.add(&b); + x.commit(0, &[0]); + }; + let mut out = Vec::new(); + // A commit by a member that is not the committer. + let mut x = Builder::new("refused-committer", Suite::AesGcm128Sha256); + base(&mut x); + x.event(join(&c)); + x.add(&c); + x.push_raw(json!({"commit": {"sender": 1, "holds": [0], "application_secret": h(&secret("refused-committer", 0, 2))}})); + out.push(x.finish("a commit by a member that is not the committer")); + // After A leaves, A commits its own removal. + let mut x = Builder::new("refused-leaver", Suite::AesGcm128Sha256); + base(&mut x); + x.event(Event::Leave { subject: a, reason: 1 }); + x.remove(0); + x.push_raw(json!({"commit": {"sender": 0, "holds": [0], "application_secret": h(&secret("refused-leaver", 0, 2))}})); + out.push(x.finish("the member being removed commits")); + // A commit that skips the clock's first proposal. + let mut x = Builder::new("refused-skip", Suite::AesGcm128Sha256); + base(&mut x); + x.event(join(&c)); + x.add(&c); + x.event(join(&d)); + x.add(&d); + x.push_raw(json!({"commit": {"sender": 0, "holds": [1], "application_secret": h(&secret("refused-skip", 0, 2))}})); + out.push(x.finish("a commit that skips the clock's first proposal")); + // A commit that holds a proposal twice. + let mut x = Builder::new("refused-twice", Suite::AesGcm128Sha256); + base(&mut x); + x.event(join(&c)); + x.add(&c); + x.push_raw(json!({"commit": {"sender": 0, "holds": [0, 0], "application_secret": h(&secret("refused-twice", 0, 2))}})); + out.push(x.finish("a commit that holds one proposal twice")); + // A commit holding a member's own proposal. + let mut x = Builder::new("refused-member", Suite::AesGcm128Sha256); + base(&mut x); + let own = Proposal { from: By::Member(1), epoch: 1, change: Change::Remove { leaf: 0 } }; + x.push(json!({"proposal": serde_json::to_value(own).unwrap()})); + x.push_raw(json!({"commit": {"sender": 1, "holds": [0], "application_secret": h(&secret("refused-member", 0, 2))}})); + out.push(x.finish("a commit holding a member's own proposal")); + // The clock removes a member that did not leave. + let mut x = Builder::new("refused-remove", Suite::AesGcm128Sha256); + base(&mut x); + x.event(join(&c)); + x.add(&c); + x.commit(0, &[0]); + x.event(Event::Leave { subject: c, reason: 3 }); + x.push_raw(json!({"proposal": serde_json::to_value(Proposal { from: By::Clock, epoch: 2, change: Change::Remove { leaf: 1 } }).unwrap()})); + out.push(x.finish("the clock removes a member that did not leave")); + // The clock adds a principal under a grant that is not keyed. + let mut x = Builder::new("refused-unkeyed", Suite::AesGcm128Sha256); + base(&mut x); + x.event(Event::Join { subject: c, keyed: false }); + x.push_raw(json!({"proposal": serde_json::to_value(Proposal { from: By::Clock, epoch: 1, change: Change::Add { identity: c, signature_key: c } }).unwrap()})); + out.push(x.finish("the clock adds a principal whose grant is not keyed")); + // A proposal for an epoch that has ended. + let mut x = Builder::new("refused-stale", Suite::AesGcm128Sha256); + base(&mut x); + x.event(join(&c)); + x.push_raw(json!({"proposal": serde_json::to_value(Proposal { from: By::Clock, epoch: 0, change: Change::Add { identity: c, signature_key: c } }).unwrap()})); + out.push(x.finish("a proposal for an epoch that has ended")); + // An Add whose credential's identity is not its signature key. + let mut x = Builder::new("refused-credential", Suite::AesGcm128Sha256); + base(&mut x); + x.event(join(&c)); + x.push_raw(json!({"proposal": serde_json::to_value(Proposal { from: By::Clock, epoch: 1, change: Change::Add { identity: c, signature_key: d } }).unwrap()})); + out.push(x.finish("an Add whose credential's identity is not its signature key")); + // The wrong creator, another group ID, another external sender. + for (label, by, gid_gen, ext) in [("refused-creator", b, 0u32, None), ("refused-group-id", a, 1, None), ("refused-external", a, 0, Some(a))] { + let mut x = Builder::new(label, Suite::AesGcm128Sha256); + x.event(join(&a)); + x.event(join(&b)); + let gid = Room::group_id_of(&x.room, gid_gen); + let ext = ext.unwrap_or(x.clock); + x.push_raw(json!({"create": {"by": h(&by), "group_id": h(&gid), "external_senders": [h(&ext)], "application_secret": h(&secret(label, 0, 0))}})); + out.push(x.finish(match label { + "refused-creator" => "a group created by a principal the clock does not owe it to", + "refused-group-id" => "a group whose ID names another generation", + _ => "a group whose external sender is not the clock", + })); + } + out +} + +// ── The file ──────────────────────────────────────────────────────────────── + +/// The whole corpus, every outcome stated by [`check`]. +pub fn generate() -> Value { + let mut recs = records(); + for r in &mut recs { + let o = check_record(r).expect("reads"); + merge(r, o); + } + let ks = key_schedule(); + let mut objs = objects(); + for o in &mut objs { + let res = check_object(o).expect("checks"); + merge(o, res); + } + let mut rooms = vec![lesson()]; + rooms.extend(refused_rooms()); + for r in &mut rooms { + let res = replay(r).expect("replays"); + merge(r, res); + } + canonical(json!({ + "corpus": "WAI live-conformance/e2ee", + "spec": "wai/extensions/confidentiality.md", + "generator": "joule_moq_e2ee_vectors gen (joule-code/joule-code-rs/crates/joule-moq-mls)", + "seeds": "every key, application secret and payload is SHA-256 of a fixed label: keys wai:e2ee/corpus/key, secrets wai:e2ee/corpus/secret, bytes wai:e2ee/corpus/bytes, the room id wai:e2ee/corpus/room", + "codes": Refusal::ALL.iter().map(|r| r.code()).collect::>(), + "records": recs, + "key_schedule": ks, + "objects": objs, + "rooms": rooms, + })) +} + +fn merge(v: &mut Value, extra: Value) { + if let (Value::Object(m), Value::Object(e)) = (v, extra) { + m.extend(e); + } +} + +/// The corpus as bytes: canonical JSON, two-space indent, a final newline. +pub fn to_bytes(v: &Value) -> Vec { + let mut s = serde_json::to_string_pretty(v).expect("serialises"); + s.push('\n'); + s.into_bytes() +} + +/// Check every vector of `v` with the reference: each outcome, roster and delivery as +/// stated, every key schedule value, and every code of §13 raised somewhere. +pub fn check(v: &Value) -> Result<(), String> { + let mut seen = std::collections::BTreeSet::new(); + let mut note = |o: &Value| { + if let Some(c) = o.as_str() { + seen.insert(c.to_string()); + } + if let Some(c) = o.get("refuse").and_then(Value::as_str) { + seen.insert(c.to_string()); + } + }; + for r in v["records"].as_array().ok_or("records")? { + let got = check_record(r)?; + if got["outcome"] != r["outcome"] || got.get("suite") != r.get("suite") { + return Err(format!("record {}: {got}", r["name"])); + } + note(&r["outcome"]); + } + for k in v["key_schedule"].as_array().ok_or("key_schedule")? { + check_key_schedule(k)?; + } + for o in v["objects"].as_array().ok_or("objects")? { + let got = check_object(o).map_err(|e| format!("object {}: {e}", o["name"]))?; + if got["outcome"] != o["outcome"] || got.get("payload_blake3") != o.get("payload_blake3") { + return Err(format!("object {}: {got}", o["name"])); + } + note(&o["outcome"]); + } + for r in v["rooms"].as_array().ok_or("rooms")? { + let got = replay(r)?; + for k in ["outcome", "rosters", "deliveries"] { + if got[k] != r[k] { + return Err(format!("room {}: {k} {}", r["name"], got[k])); + } + } + note(&r["outcome"]); + for d in r["deliveries"].as_array().ok_or("deliveries")? { + for x in d["deliveries"].as_array().ok_or("deliveries")? { + note(&x["outcome"]); + } + } + } + for c in Refusal::ALL { + if !seen.contains(c.code()) { + return Err(format!("no vector raises {}", c.code())); + } + } + let codes: Vec<&str> = Refusal::ALL.iter().map(|r| r.code()).collect(); + if v["codes"] != json!(codes) { + return Err("the codes listed are not the reference's".into()); + } + Ok(()) +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs new file mode 100644 index 000000000..1b4ab45ff --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group.rs @@ -0,0 +1,539 @@ +//! A room's MLS group (WAI confidentiality §5): a member, which holds a leaf and the +//! epochs' keys, and the clock, the group's one external sender, which holds neither and +//! follows the group from its public handshake messages. +//! +//! Every MLS message travels as the keying draft §3.4 frames it: a one-byte type, then the +//! `MLSMessage` (RFC 9420 §6). Before applying a proposal or a commit, a member checks it +//! against the room's lifecycle with [`crate::model::Room`]; a commit that fails is never +//! merged, so its epoch never exists for that member. + +use crate::model::{self, Change, Event, From as By, Owed, Principal, Proposal as Proposed, Room, Roster}; +use ed25519_dalek::{Signer as _, SigningKey}; +use joule_moq::secure_objects::room::{Incoming, Keyring, NotOpened, Opened, Outgoing, SealedObject, EXPORTER_LABEL}; +use joule_moq::secure_objects::{Refusal, Suite}; +use openmls::prelude::*; +use openmls::prelude::hash_ref::ProposalRef; +use openmls_rust_crypto::OpenMlsRustCrypto; +use openmls_traits::signatures::{Signer, SignerError}; +use openmls_traits::types::SignatureScheme; +use openmls_traits::OpenMlsProvider; +use std::collections::{BTreeMap, BTreeSet}; +use tls_codec::{Deserialize as _, Serialize as _}; + +/// The room's MLS ciphersuite (§3.3 step 4): Ed25519 signatures, the principals' scheme. +pub const CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; + +/// The keying draft §3.4's type byte before each MLS message. +pub mod frame { + pub const PROPOSAL: u8 = 0x01; + pub const COMMIT: u8 = 0x02; + pub const WELCOME: u8 = 0x03; + pub const KEY_PACKAGE: u8 = 0x04; + /// Not of the keying draft: the creator's GroupInfo, with its ratchet tree, which the + /// clock reads to follow the group (§5.1). + pub const GROUP_INFO: u8 = 0x05; +} + +/// Why the binding did not do what it was asked. +#[derive(Debug)] +pub enum Error { + /// Refused, with the extension's reason. + Refused(Refusal), + /// MLS refused it (a message that does not verify or parse, a state it cannot apply). + Mls(String), + /// Not now: the room's state does not call for it. + NotNow(&'static str), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Refused(r) => write!(f, "refused: {r}"), + Error::Mls(m) => write!(f, "MLS: {m}"), + Error::NotNow(m) => write!(f, "not now: {m}"), + } + } +} + +impl std::error::Error for Error {} + +impl std::convert::From for Error { + fn from(r: Refusal) -> Self { + Error::Refused(r) + } +} + +fn mls(e: E) -> Error { + Error::Mls(format!("{e:?}")) +} + +/// A principal's or the clock's Ed25519 room key, as MLS signs with it (§5.2). +pub struct RoomKey(pub SigningKey); + +impl Signer for RoomKey { + fn sign(&self, payload: &[u8]) -> Result, SignerError> { + Ok(self.0.sign(payload).to_bytes().to_vec()) + } + + fn signature_scheme(&self) -> SignatureScheme { + SignatureScheme::ED25519 + } +} + +impl RoomKey { + pub fn principal(&self) -> Principal { + self.0.verifying_key().to_bytes() + } +} + +/// The credential of a principal (§5.2): a BasicCredential whose identity is its key, and +/// that key as the leaf's signature key. +pub fn credential(p: &Principal) -> CredentialWithKey { + CredentialWithKey { credential: BasicCredential::new(p.to_vec()).into(), signature_key: p.to_vec().into() } +} + +/// The `external_senders` extension of a room's group (§5.3): the clock alone. +pub fn external_senders(clock: &Principal) -> ExternalSendersExtension { + vec![ExternalSender::new(clock.to_vec().into(), BasicCredential::new(clock.to_vec()).into())] +} + +/// The identity of a credential, where it is a BasicCredential of 32 bytes. +fn identity(c: &Credential) -> Option { + BasicCredential::try_from(c.clone()).ok()?.identity().try_into().ok() +} + +/// A credential and signature key read as §5.2 binds them: `credential_mismatch` unless the +/// identity is the 32-byte signature key. +fn bound(c: &Credential, signature_key: &[u8]) -> Result { + let id = identity(c).ok_or(Refusal::CredentialMismatch)?; + if id.as_slice() != signature_key { + return Err(Refusal::CredentialMismatch); + } + Ok(id) +} + +fn roster_of<'a>(members: impl Iterator + 'a) -> Result { + members.map(|m| Ok((m.index.u32(), bound(&m.credential, &m.signature_key)?))).collect() +} + +/// Frame an MLS message. +pub fn framed(ty: u8, m: &MlsMessageOut) -> Result, Error> { + let mut b = vec![ty]; + b.extend_from_slice(&m.tls_serialize_detached().map_err(mls)?); + Ok(b) +} + +/// Read a framed MLS message. +pub fn unframe(b: &[u8]) -> Result<(u8, MlsMessageIn), Error> { + let (&ty, rest) = b.split_first().ok_or(Error::Mls("an empty frame".into()))?; + Ok((ty, MlsMessageIn::tls_deserialize_exact(rest).map_err(mls)?)) +} + +/// What a proposal of the group does, read from MLS: its sender, epoch and change. +fn read_proposal(q: &QueuedProposal, epoch: u64) -> Result { + let from = match q.sender() { + Sender::External(i) if *i == SenderExtensionIndex::new(0) => By::Clock, + Sender::Member(l) => By::Member(l.u32()), + _ => return Err(Refusal::ProposalNotClock), + }; + let change = match q.proposal() { + Proposal::Add(a) => { + let leaf = a.key_package().leaf_node(); + let id = identity(leaf.credential()).ok_or(Refusal::CredentialMismatch)?; + let key: Principal = leaf.signature_key().as_slice().try_into().map_err(|_| Refusal::CredentialMismatch)?; + Change::Add { identity: id, signature_key: key } + } + Proposal::Remove(r) => Change::Remove { leaf: r.removed().u32() }, + _ => return Err(Refusal::ProposalMismatch), + }; + Ok(Proposed { from, epoch, change }) +} + +/// The checks §5.5 makes of a commit before it is applied, given the group's roster, its +/// pending proposals' references, and the model. Returns the indexes it holds. +fn check_commit(model: &Room, refs: &[ProposalRef], sender: &Sender, staged: &StagedCommit) -> Result<(u32, Vec), Refusal> { + let g = model.group().ok_or(Refusal::CommitMismatch)?; + let Sender::Member(s) = sender else { return Err(Refusal::CommitNotCommitter) }; + let removed: BTreeSet = staged.remove_proposals().map(|r| r.remove_proposal().removed().u32()).collect(); + if model::committer(&g.roster, &removed) != Some(s.u32()) { + return Err(Refusal::CommitNotCommitter); + } + let mut holds = Vec::new(); + for q in staged.queued_proposals() { + let clock = matches!(q.sender(), Sender::External(i) if *i == SenderExtensionIndex::new(0)); + let at = refs.iter().position(|r| r == q.proposal_reference_ref()); + match (clock, at) { + (true, Some(i)) if model.pending()[i].from == By::Clock => holds.push(i), + _ => return Err(Refusal::ProposalNotClock), + } + } + Ok((s.u32(), holds)) +} + +/// What a member made of a message it received. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Received { + /// A proposal, kept. + Proposal, + /// A commit, applied: the member is in this epoch. + Epoch(u64), + /// A commit that removes this member: it holds nothing from this epoch on. + Removed(u64), + /// A Welcome: the member joined at this epoch. + Joined(u64), +} + +/// A commit a member made, with the Welcome for those it adds. +#[derive(Clone, Debug)] +pub struct Commit { + pub commit: Vec, + pub welcome: Option>, + pub epoch: u64, +} + +/// A principal of a room as a member of its group. +pub struct RoomMember { + provider: OpenMlsRustCrypto, + key: RoomKey, + room: [u8; 32], + model: Room, + group: Option, + /// For each of the model's pending proposals, its MLS reference. + refs: Vec, + keyring: Keyring, +} + +impl RoomMember { + pub fn new(room: [u8; 32], clock: Principal, key: SigningKey, suite: Suite) -> Self { + let key = RoomKey(key); + let keyring = Keyring::new(suite, key.principal()); + RoomMember { provider: OpenMlsRustCrypto::default(), key, room, model: Room::new(room, clock), group: None, refs: Vec::new(), keyring } + } + + pub fn principal(&self) -> Principal { + self.key.principal() + } + + pub fn keyring(&self) -> &Keyring { + &self.keyring + } + + pub fn keyring_mut(&mut self) -> &mut Keyring { + &mut self.keyring + } + + pub fn model(&self) -> &Room { + &self.model + } + + pub fn epoch(&self) -> Option { + self.group.as_ref().map(|g| g.epoch().as_u64()) + } + + /// A lifecycle event of the room, as every member reads it from the clock's events. + pub fn event(&mut self, ev: &Event) { + self.model.event(ev); + if self.model.group().is_none() && self.group.is_some() { + self.group = None; + self.refs.clear(); + } + } + + /// A KeyPackage for the clock to propose this principal with (the keying draft §4.7). + pub fn key_package(&self) -> Result, Error> { + let kp = KeyPackage::builder().build(CIPHERSUITE, &self.provider, &self.key, credential(&self.principal())).map_err(mls)?; + framed(frame::KEY_PACKAGE, &MlsMessageOut::from(kp.key_package().clone())) + } + + fn export(&self, g: &MlsGroup) -> Result<[u8; 32], Error> { + let s = g.export_secret(self.provider.crypto(), EXPORTER_LABEL, &self.room, 32).map_err(mls)?; + Ok(s.try_into().expect("32 bytes")) + } + + fn enter(&mut self) -> Result { + let g = self.group.as_ref().expect("a group"); + let epoch = g.epoch().as_u64(); + let roster = roster_of(g.members())?; + if self.model.group().map(|m| &m.roster) != Some(&roster) { + return Err(Error::Mls("the group's roster is not the room's".into())); + } + let secret = self.export(g)?; + self.keyring.enter(epoch, secret, roster); + Ok(epoch) + } + + /// Create the group, when the clock owes it to this principal (§5.4): epoch 0, this + /// principal at leaf 0. Returns the GroupInfo, with its ratchet tree, for the clock. + pub fn create(&mut self) -> Result, Error> { + if self.model.next_owed() != Some(Owed::Create(self.principal())) { + return Err(Error::NotNow("the room does not owe this principal a group")); + } + let gid = self.model.group_id(); + let config = MlsGroupCreateConfig::builder() + .ciphersuite(CIPHERSUITE) + .wire_format_policy(PURE_PLAINTEXT_WIRE_FORMAT_POLICY) + .use_ratchet_tree_extension(true) + .with_group_context_extensions(Extensions::single(Extension::ExternalSenders(external_senders(&self.model.clock()))).map_err(mls)?) + .build(); + let g = MlsGroup::new_with_group_id(&self.provider, &self.key, &config, GroupId::from_slice(&gid), credential(&self.principal())).map_err(mls)?; + self.model.create(self.principal(), &gid, &[self.model.clock()])?; + let info = g.export_group_info(self.provider.crypto(), &self.key, true).map_err(mls)?; + self.group = Some(g); + self.enter()?; + framed(frame::GROUP_INFO, &info) + } + + fn join(&mut self, welcome: Welcome) -> Result { + let config = MlsGroupJoinConfig::builder().wire_format_policy(PURE_PLAINTEXT_WIRE_FORMAT_POLICY).use_ratchet_tree_extension(true).build(); + let g = StagedWelcome::new_from_welcome(&self.provider, &config, welcome, None).map_err(mls)?.into_group(&self.provider).map_err(mls)?; + if g.extensions().external_senders() != Some(&external_senders(&self.model.clock())) { + return Err(Refusal::GroupMismatch.into()); + } + let roster = roster_of(g.members())?; + self.model.joined(g.group_id().as_slice(), &[self.model.clock()], g.epoch().as_u64(), roster)?; + self.refs.clear(); + self.group = Some(g); + self.enter() + } + + /// A framed MLS message from the room's `mls` tracks (§5.4, §5.5). + pub fn receive(&mut self, framed_msg: &[u8]) -> Result { + let (ty, msg) = unframe(framed_msg)?; + if ty == frame::WELCOME { + let MlsMessageBodyIn::Welcome(w) = msg.extract() else { return Err(Error::Mls("not a Welcome".into())) }; + if self.group.is_some() { + return Err(Error::NotNow("already a member")); + } + return self.join(w).map(Received::Joined); + } + let pm = msg.try_into_protocol_message().map_err(mls)?; + let g = self.group.as_mut().ok_or(Error::NotNow("not a member"))?; + let epoch = g.epoch().as_u64(); + let processed = g.process_message(&self.provider, pm).map_err(mls)?; + let sender = processed.sender().clone(); + match processed.into_content() { + ProcessedMessageContent::ProposalMessage(q) => { + let p = read_proposal(&q, epoch)?; + self.model.propose(p)?; + self.refs.push(q.proposal_reference_ref().clone()); + if p.from == By::Clock { + g.store_pending_proposal(self.provider.storage(), *q).map_err(mls)?; + } + Ok(Received::Proposal) + } + ProcessedMessageContent::StagedCommitMessage(staged) => { + let (s, holds) = check_commit(&self.model, &self.refs, &sender, &staged)?; + let removed = staged.self_removed(); + self.model.commit(s, &holds)?; + self.refs.clear(); + g.merge_staged_commit(&self.provider, *staged).map_err(mls)?; + let now = self.model.group().expect("a group").epoch; + if removed { + self.keyring.removed_at(now); + self.group = None; + return Ok(Received::Removed(now)); + } + self.enter().map(Received::Epoch) + } + _ => Err(Error::Mls("not a proposal or a commit".into())), + } + } + + /// Whether this member is the committer of the clock's pending proposals. + pub fn is_committer(&self) -> bool { + let (Some(g), Some(me)) = (self.model.group(), self.keyring.my_leaf()) else { return false }; + let removed: BTreeSet = self.model.pending().iter().filter(|p| p.from == By::Clock).filter_map(|p| if let Change::Remove { leaf } = p.change { Some(leaf) } else { None }).collect(); + model::committer(&g.roster, &removed) == Some(me) + } + + /// Commit the clock's pending proposals, when this member is the committer (§5.5), with + /// an UpdatePath that renews its own leaf. `force` commits with none pending. + pub fn commit(&mut self, force: bool) -> Result { + if !self.is_committer() { + return Err(Error::NotNow("not the committer")); + } + if !force && !self.model.pending().iter().any(|p| p.from == By::Clock) { + return Err(Error::NotNow("nothing pending")); + } + let g = self.group.as_mut().ok_or(Error::NotNow("not a member"))?; + let bundle = g + .commit_builder() + .consume_proposal_store(true) + .force_self_update(true) + .load_psks(self.provider.storage()) + .map_err(mls)? + .build(self.provider.rand(), self.provider.crypto(), &self.key, |_| true) + .map_err(mls)? + .stage_commit(&self.provider) + .map_err(mls)?; + let (commit, welcome, _) = bundle.into_messages(); + let staged = g.pending_commit().expect("a staged commit"); + let me = Sender::Member(g.own_leaf_index()); + let (s, holds) = check_commit(&self.model, &self.refs, &me, staged)?; + self.model.commit(s, &holds)?; + self.refs.clear(); + g.merge_pending_commit(&self.provider).map_err(mls)?; + let epoch = self.enter()?; + Ok(Commit { commit: framed(frame::COMMIT, &commit)?, welcome: welcome.map(|w| framed(frame::WELCOME, &w)).transpose()?, epoch }) + } + + /// Seal an object under this member's current epoch (§4.9). + pub fn seal(&self, o: &Outgoing<'_>) -> Result { + self.keyring.seal(o).ok_or(Error::NotNow("no epoch to seal under"))?.map_err(Error::Refused) + } + + /// Open an object through the receiving checks (§6). + pub fn open(&mut self, o: &Incoming<'_>) -> Result { + self.keyring.open(o, None) + } +} + +/// The room's clock as the group's external sender (§5.3): it proposes every change the +/// room's lifecycle calls for and follows the group from its public messages, holding no +/// epoch's secret. +pub struct Clock { + provider: OpenMlsRustCrypto, + key: RoomKey, + model: Room, + public: Option, + refs: Vec, + key_packages: BTreeMap, + outbox: Vec>, +} + +impl Clock { + pub fn new(room: [u8; 32], key: SigningKey) -> Self { + let key = RoomKey(key); + let model = Room::new(room, key.principal()); + Clock { provider: OpenMlsRustCrypto::default(), key, model, public: None, refs: Vec::new(), key_packages: BTreeMap::new(), outbox: Vec::new() } + } + + pub fn principal(&self) -> Principal { + self.key.principal() + } + + pub fn model(&self) -> &Room { + &self.model + } + + /// The framed proposals made since the last call, in order, for the clock's `mls` track. + pub fn take_outbox(&mut self) -> Vec> { + std::mem::take(&mut self.outbox) + } + + /// A lifecycle event the clock recorded. + pub fn event(&mut self, ev: &Event) -> Result<(), Error> { + self.model.event(ev); + if self.model.group().is_none() { + self.public = None; + self.refs.clear(); + } + self.flush() + } + + /// A KeyPackage a joining principal published: kept for its Add (§5.4). + pub fn key_package(&mut self, framed_kp: &[u8]) -> Result { + let (_, msg) = unframe(framed_kp)?; + let MlsMessageBodyIn::KeyPackage(kp) = msg.extract() else { return Err(Error::Mls("not a KeyPackage".into())) }; + let kp = kp.validate(self.provider.crypto(), ProtocolVersion::Mls10).map_err(mls)?; + let p = bound(kp.leaf_node().credential(), kp.leaf_node().signature_key().as_slice())?; + self.key_packages.insert(p, kp); + self.flush()?; + Ok(p) + } + + /// The creator's GroupInfo: the clock starts following the group (§5.1). + pub fn created(&mut self, framed_info: &[u8]) -> Result<(), Error> { + let (_, msg) = unframe(framed_info)?; + let MlsMessageBodyIn::GroupInfo(vgi) = msg.extract() else { return Err(Error::Mls("not a GroupInfo".into())) }; + let tree = vgi.extensions().ratchet_tree().map(|t| t.ratchet_tree().clone()).ok_or(Error::Mls("no ratchet tree".into()))?; + let (pg, _) = PublicGroup::from_external(self.provider.crypto(), self.provider.storage(), tree, vgi, ProposalStore::new()).map_err(mls)?; + if pg.group_context().extensions().external_senders() != Some(&external_senders(&self.principal())) { + return Err(Refusal::GroupMismatch.into()); + } + let roster = roster_of(pg.members())?; + let by = *roster.get(&0).ok_or(Refusal::GroupMismatch)?; + if roster.len() != 1 || pg.group_context().epoch().as_u64() != 0 { + return Err(Refusal::GroupMismatch.into()); + } + self.model.create(by, pg.group_id().as_slice(), &[self.principal()])?; + self.public = Some(pg); + self.refs.clear(); + self.flush() + } + + fn process_own(&mut self, out: MlsMessageOut, p: Proposed) -> Result<(), Error> { + let bytes = framed(frame::PROPOSAL, &out)?; + let (_, msg) = unframe(&bytes)?; + let pg = self.public.as_mut().expect("a group"); + let processed = pg.process_message(self.provider.crypto(), msg.try_into_protocol_message().map_err(mls)?).map_err(mls)?; + let ProcessedMessageContent::ProposalMessage(q) = processed.into_content() else { return Err(Error::Mls("not a proposal".into())) }; + self.model.propose(p)?; + self.refs.push(q.proposal_reference_ref().clone()); + pg.add_proposal(self.provider.storage(), *q).map_err(mls)?; + self.outbox.push(bytes); + Ok(()) + } + + /// Propose everything owed that can be proposed now. + fn flush(&mut self) -> Result<(), Error> { + while let Some(owed) = self.model.next_owed() { + let Some(pg) = &self.public else { return Ok(()) }; + let (gid, epoch) = (pg.group_id().clone(), pg.group_context().epoch()); + match owed { + Owed::Create(_) => return Ok(()), + Owed::Add(p) => { + let Some(kp) = self.key_packages.get(&p).cloned() else { return Ok(()) }; + let out = ExternalProposal::new_add::(kp, gid, epoch, &self.key, SenderExtensionIndex::new(0)).map_err(mls)?; + let change = Change::Add { identity: p, signature_key: p }; + self.process_own(out, Proposed { from: By::Clock, epoch: epoch.as_u64(), change })?; + } + Owed::Remove(_, leaf) => { + let out = ExternalProposal::new_remove::(LeafNodeIndex::new(leaf), gid, epoch, &self.key, SenderExtensionIndex::new(0)).map_err(mls)?; + self.process_own(out, Proposed { from: By::Clock, epoch: epoch.as_u64(), change: Change::Remove { leaf } })?; + } + } + } + Ok(()) + } + + /// A framed message from a member's `mls` track: a proposal is kept; a commit is + /// checked as a member checks it and followed, and what it left out proposed again. + pub fn observe(&mut self, framed_msg: &[u8]) -> Result<(), Error> { + let (ty, msg) = unframe(framed_msg)?; + if ty == frame::WELCOME { + return Ok(()); + } + let pg = self.public.as_mut().ok_or(Error::NotNow("no group"))?; + let epoch = pg.group_context().epoch().as_u64(); + let processed = pg.process_message(self.provider.crypto(), msg.try_into_protocol_message().map_err(mls)?).map_err(mls)?; + let sender = processed.sender().clone(); + match processed.into_content() { + ProcessedMessageContent::ProposalMessage(q) => { + let p = read_proposal(&q, epoch)?; + self.model.propose(p)?; + self.refs.push(q.proposal_reference_ref().clone()); + pg.add_proposal(self.provider.storage(), *q).map_err(mls)?; + Ok(()) + } + ProcessedMessageContent::StagedCommitMessage(staged) => { + let (s, holds) = check_commit(&self.model, &self.refs, &sender, &staged)?; + let c = self.model.commit(s, &holds)?; + self.refs.clear(); + for (_, p) in &c.added { + self.key_packages.remove(p); + } + pg.merge_commit(self.provider.storage(), *staged).map_err(mls)?; + if self.model.group().map(|g| &g.roster) != Some(&roster_of(pg.members())?) { + return Err(Error::Mls("the group's roster is not the room's".into())); + } + self.flush() + } + _ => Err(Error::Mls("not a proposal or a commit".into())), + } + } +} + +#[cfg(test)] +#[path = "group_tests.rs"] +mod tests; diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs new file mode 100644 index 000000000..4b73b8f64 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/group_tests.rs @@ -0,0 +1,333 @@ +//! Rooms run on real MLS groups: three members each sending and every other opening, a +//! removed member, a late joiner, a change of committer, and every way a member or the +//! clock can misbehave, refused by the others. Every MLS message crosses as its framed +//! bytes, and every sealed object as a -22 subgroup stream, encoded and decoded. + +use super::*; +use crate::model::Event; +use joule_moq::draft22::data::{ObjectStatus as WireStatus, SubgroupCodec, SubgroupHeader, SubgroupId, SubgroupObject}; +use joule_moq::draft22::kvp::Properties; +use joule_moq::draft22::{decode_uni_stream, GroupOrder, StreamHeader, UniEvent}; +use joule_moq::secure_objects::room::{split_key_id, Incoming, NotOpened, Outgoing}; +use joule_moq::secure_objects::Plaintext; +use joule_moq::{TrackName, TrackNamespace}; + +const SUITE: Suite = Suite::AesGcm128Sha256; + +fn sk(label: &str) -> SigningKey { + SigningKey::from_bytes(&crate::corpus::bytes(label, 32).try_into().unwrap()) +} + +/// A room's clock and principals, the tracks between them simulated: each MLS message is +/// delivered, as its bytes, to every party that reads it. +struct Net { + room: [u8; 32], + clock_key: SigningKey, + clock: Clock, + members: Vec, +} + +impl Net { + fn new(names: &[&str]) -> Net { + let room = [0x52; 32]; + let clock_key = sk("clock"); + let clock = Clock::new(room, clock_key.clone()); + let cp = clock.principal(); + let members = names.iter().map(|n| RoomMember::new(room, cp, sk(n), SUITE)).collect(); + Net { room, clock_key, clock, members } + } + + fn p(&self, i: usize) -> Principal { + self.members[i].principal() + } + + fn event(&mut self, ev: Event) { + self.clock.event(&ev).unwrap(); + for m in &mut self.members { + m.event(&ev); + } + self.pump(); + } + + /// The clock's proposals, to every member in the group. + fn pump(&mut self) { + for msg in self.clock.take_outbox() { + for m in self.members.iter_mut().filter(|m| m.epoch().is_some()) { + assert_eq!(m.receive(&msg).unwrap(), Received::Proposal); + } + } + } + + /// Principal `i` joins under a keyed grant: it creates the group, or publishes its + /// KeyPackage for the clock's Add. + fn join(&mut self, i: usize) { + self.event(Event::Join { subject: self.p(i), keyed: true }); + if self.members[i].model().next_owed() == Some(Owed::Create(self.p(i))) { + let info = self.members[i].create().unwrap(); + self.clock.created(&info).unwrap(); + } else { + let kp = self.members[i].key_package().unwrap(); + self.clock.key_package(&kp).unwrap(); + } + self.pump(); + } + + fn leave(&mut self, i: usize, reason: u8) { + self.event(Event::Leave { subject: self.p(i), reason }); + } + + /// The committer commits the clock's proposals; everyone else applies it, and those it + /// adds join by its Welcome. Returns the committer's index. + fn commit(&mut self) -> usize { + let c = (0..self.members.len()).find(|i| self.members[*i].is_committer()).expect("a committer"); + let out = self.members[c].commit(false).unwrap(); + self.clock.observe(&out.commit).unwrap(); + for (j, m) in self.members.iter_mut().enumerate() { + if j != c && m.epoch().is_some() { + let r = m.receive(&out.commit).unwrap(); + assert!(matches!(r, Received::Epoch(e) | Received::Removed(e) if e == out.epoch), "{r:?}"); + } + } + if let Some(w) = &out.welcome { + let roster = self.clock.model().group().unwrap().roster.clone(); + for m in self.members.iter_mut().filter(|m| m.epoch().is_none() && roster.values().any(|p| *p == m.principal())) { + assert_eq!(m.receive(w).unwrap(), Received::Joined(out.epoch)); + } + } + c + } + + fn ns(&self, owner: &Principal, kind: &str) -> TrackNamespace { + TrackNamespace::from_strs([hex::encode(self.room), hex::encode(owner), kind.to_string()]) + } + + /// Principal `i` seals `payload` as object (`g`, 0) of its own `kind` track, and the + /// object crosses as a -22 subgroup stream. + fn send(&self, i: usize, owner: usize, kind: &str, g: u64, payload: &[u8]) -> Vec { + let ns = self.ns(&self.p(owner), kind); + let name = TrackName::from_str("0"); + let pt = Plaintext::with_digest(payload.to_vec()); + let s = self.members[i].seal(&Outgoing { ns: &ns, name: &name, group: g, object: 0, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap(); + let sh = SubgroupHeader { track_alias: 1, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: true, end_of_group: true, first_object: true }; + let mut wire = Vec::new(); + sh.encode(&mut wire); + SubgroupCodec::new(sh).encode_object(&SubgroupObject { object_id: 0, properties: s.properties, status: WireStatus::Normal, payload: s.payload }, &mut wire).unwrap(); + wire + } + + /// Member `j` reads a stream `send` made, for `owner`'s `kind` track. + fn recv(&mut self, j: usize, owner: usize, kind: &str, wire: &[u8]) -> Result, NotOpened> { + let ns = self.ns(&self.p(owner), kind); + let name = TrackName::from_str("0"); + let ev = decode_uni_stream(wire, GroupOrder::Ascending).unwrap(); + let [UniEvent::Header(StreamHeader::Subgroup(sh)), UniEvent::SubgroupObject(o)] = ev.as_slice() else { panic!("one object") }; + let props: &Properties = &o.properties; + let inc = Incoming { ns: &ns, name: &name, group: sh.group_id, object: o.object_id, priority: sh.publisher_priority.unwrap(), properties: props, payload: &o.payload }; + self.members[j].open(&inc).map(|op| op.plaintext.payload) + } +} + +fn refused(r: Result, NotOpened>) -> Refusal { + match r { + Err(NotOpened::Refused(x)) => x, + other => panic!("not refused: {other:?}"), + } +} + +fn err_refusal(r: Result) -> Refusal { + match r { + Err(Error::Refused(x)) => x, + other => panic!("not refused: {other:?}"), + } +} + +#[test] +fn three_members_each_send_and_every_other_opens() { + let mut n = Net::new(&["a", "b", "c"]); + n.join(0); + n.join(1); + assert_eq!(n.commit(), 0); + n.join(2); + assert_eq!(n.commit(), 0); + for m in &n.members { + assert_eq!(m.epoch(), Some(2)); + } + for i in 0..3 { + let payload = format!("frame from member {i}").into_bytes(); + let wire = n.send(i, i, "video", 10 + i as u64, &payload); + for j in 0..3 { + assert_eq!(n.recv(j, i, "video", &wire).unwrap(), payload, "{j} opens {i}"); + } + // The same location again, from a relay replaying it. + assert_eq!(refused(n.recv((i + 1) % 3, i, "video", &wire)), Refusal::Replayed); + } + // Each sender's KID names the epoch and its own leaf. + let wire = n.send(1, 1, "audio", 1, b"x"); + let ev = decode_uni_stream(&wire, GroupOrder::Ascending).unwrap(); + let UniEvent::SubgroupObject(o) = &ev[1] else { panic!() }; + let (kid, _) = joule_moq::secure_objects::key_id_of(&o.properties).unwrap(); + assert_eq!(split_key_id(kid), (2, 1)); +} + +#[test] +fn a_removed_member_cannot_open_the_next_epoch_and_a_late_joiner_cannot_open_before_its_join() { + let mut n = Net::new(&["a", "b", "c", "d"]); + n.join(0); + n.join(1); + n.commit(); + n.join(2); + n.commit(); + let before = n.send(0, 0, "video", 1, b"epoch two"); + let c_late = n.send(2, 2, "video", 1, b"sealed by c before its removal"); + // C's grant is revoked: the clock records its leave, the clock removes it, A commits. + n.leave(2, 2); + assert_eq!(n.commit(), 0); + assert_eq!(n.members[2].epoch(), None); + let after = n.send(0, 0, "video", 2, b"epoch three"); + assert_eq!(n.recv(1, 0, "video", &after).unwrap(), b"epoch three"); + assert_eq!(refused(n.recv(2, 0, "video", &after)), Refusal::RemovedBeforeEpoch); + // C kept epoch 2's keys and sealed under them; no one who applied its removal takes it. + assert_eq!(refused(n.recv(0, 2, "video", &c_late)), Refusal::SenderRemoved); + assert_eq!(refused(n.recv(1, 2, "video", &c_late)), Refusal::SenderRemoved); + // D joins late, at C's old leaf. + n.join(3); + assert_eq!(n.commit(), 0); + assert_eq!(n.members[3].keyring().my_leaf(), Some(2)); + let now = n.send(0, 0, "video", 3, b"epoch four"); + assert_eq!(n.recv(3, 0, "video", &now).unwrap(), b"epoch four"); + assert_eq!(refused(n.recv(3, 0, "video", &before)), Refusal::EpochNotJoined); + assert_eq!(refused(n.recv(3, 0, "video", &after)), Refusal::EpochNotJoined); + // A leaves: B, at the lowest leaf left, is the committer now. + n.leave(0, 1); + assert!(!n.members[3].is_committer()); + assert_eq!(n.commit(), 1); + let b5 = n.send(1, 1, "audio", 1, b"epoch five"); + assert_eq!(n.recv(3, 1, "audio", &b5).unwrap(), b"epoch five"); + assert_eq!(refused(n.recv(0, 1, "audio", &b5)), Refusal::RemovedBeforeEpoch); + // D seals under its own KID on B's track: not the track's owner. + let forged = n.send(3, 1, "audio", 2, b"not b"); + assert_eq!(refused(n.recv(1, 1, "audio", &forged)), Refusal::SenderNotOwner); + // The clock followed every epoch and never held a key: its model's roster is the group's. + assert_eq!(n.clock.model().group().unwrap().roster, n.members[1].model().group().unwrap().roster); +} + +/// A room of A and B at epoch 1, with C's Add pending. +fn pending_add() -> Net { + let mut n = Net::new(&["a", "b", "c", "d"]); + n.join(0); + n.join(1); + n.commit(); + n.join(2); + n +} + +#[test] +fn a_commit_by_a_member_that_is_not_the_committer_is_refused() { + let mut n = pending_add(); + let rogue = { + let b = &mut n.members[1]; + let g = b.group.as_mut().unwrap(); + let bundle = g.commit_builder().consume_proposal_store(true).force_self_update(true).load_psks(b.provider.storage()).unwrap().build(b.provider.rand(), b.provider.crypto(), &b.key, |_| true).unwrap().stage_commit(&b.provider).unwrap(); + let (c, _, _) = bundle.into_messages(); + g.clear_pending_commit(b.provider.storage()).unwrap(); + framed(frame::COMMIT, &c).unwrap() + }; + assert_eq!(err_refusal(n.members[0].receive(&rogue)), Refusal::CommitNotCommitter); + assert!(matches!(n.clock.observe(&rogue), Err(Error::Refused(Refusal::CommitNotCommitter)))); + // Nothing was applied: the true committer's commit goes through. + assert_eq!(n.commit(), 0); + assert_eq!(n.members[2].epoch(), Some(2)); +} + +#[test] +fn a_commit_holding_a_members_own_proposal_is_refused() { + let mut n = pending_add(); + let rogue = { + let a = &mut n.members[0]; + let g = a.group.as_mut().unwrap(); + let bundle = g + .commit_builder() + .consume_proposal_store(true) + .propose_removals([LeafNodeIndex::new(1)]) + .force_self_update(true) + .load_psks(a.provider.storage()) + .unwrap() + .build(a.provider.rand(), a.provider.crypto(), &a.key, |_| true) + .unwrap() + .stage_commit(&a.provider) + .unwrap(); + let (c, _, _) = bundle.into_messages(); + g.clear_pending_commit(a.provider.storage()).unwrap(); + framed(frame::COMMIT, &c).unwrap() + }; + assert_eq!(err_refusal(n.members[1].receive(&rogue)), Refusal::ProposalNotClock); +} + +#[test] +fn a_commit_that_leaves_out_the_clocks_first_proposal_is_refused() { + let mut n = pending_add(); + n.join(3); + let rogue = { + let a = &mut n.members[0]; + let g = a.group.as_mut().unwrap(); + let first = g.pending_proposals().next().unwrap().proposal_reference_ref().clone(); + g.remove_pending_proposal(a.provider.storage(), &first).unwrap(); + let (c, _, _) = g.commit_to_pending_proposals(&a.provider, &a.key).unwrap(); + g.clear_pending_commit(a.provider.storage()).unwrap(); + framed(frame::COMMIT, &c).unwrap() + }; + assert_eq!(err_refusal(n.members[1].receive(&rogue)), Refusal::CommitMismatch); +} + +#[test] +fn proposals_the_room_does_not_call_for_are_refused() { + let mut n = pending_add(); + let g = n.members[0].group.as_ref().unwrap(); + let (gid, epoch) = (g.group_id().clone(), g.epoch()); + let clock = RoomKey(n.clock_key.clone()); + // A Remove of B, who did not leave, signed by the clock's key. + let out = ExternalProposal::new_remove::(LeafNodeIndex::new(1), gid.clone(), epoch, &clock, SenderExtensionIndex::new(0)).unwrap(); + let msg = framed(frame::PROPOSAL, &out).unwrap(); + assert_eq!(err_refusal(n.members[0].receive(&msg)), Refusal::ProposalMismatch); + // An Add of D, whose credential names another principal than its signature key. + let d = RoomKey(sk("d")); + let liar = CredentialWithKey { credential: BasicCredential::new(n.p(1).to_vec()).into(), signature_key: d.principal().to_vec().into() }; + let provider = OpenMlsRustCrypto::default(); + let kp = KeyPackage::builder().build(CIPHERSUITE, &provider, &d, liar).unwrap(); + let out = ExternalProposal::new_add::(kp.key_package().clone(), gid, epoch, &clock, SenderExtensionIndex::new(0)).unwrap(); + let msg = framed(frame::PROPOSAL, &out).unwrap(); + assert_eq!(err_refusal(n.members[0].receive(&msg)), Refusal::CredentialMismatch); + // The clock itself refuses to propose such a KeyPackage. + let framed_kp = framed(frame::KEY_PACKAGE, &MlsMessageOut::from(kp.key_package().clone())).unwrap(); + assert!(matches!(n.clock.key_package(&framed_kp), Err(Error::Refused(Refusal::CredentialMismatch)))); +} + +#[test] +fn a_group_whose_external_sender_is_not_the_clock_is_refused() { + let mut n = Net::new(&["a", "b"]); + let (a, b) = (n.p(0), n.p(1)); + n.event(Event::Join { subject: a, keyed: true }); + n.event(Event::Join { subject: b, keyed: true }); + // A builds the room's group with another external sender, and adds B. + let b_kp = n.members[1].key_package().unwrap(); + let (info, welcome) = { + let a = &mut n.members[0]; + let other = sk("not the clock").verifying_key().to_bytes(); + let config = MlsGroupCreateConfig::builder() + .ciphersuite(CIPHERSUITE) + .wire_format_policy(PURE_PLAINTEXT_WIRE_FORMAT_POLICY) + .use_ratchet_tree_extension(true) + .with_group_context_extensions(Extensions::single(Extension::ExternalSenders(external_senders(&other))).unwrap()) + .build(); + let mut g = MlsGroup::new_with_group_id(&a.provider, &a.key, &config, GroupId::from_slice(&a.model.group_id()), credential(&a.principal())).unwrap(); + let info = framed(frame::GROUP_INFO, &g.export_group_info(a.provider.crypto(), &a.key, true).unwrap()).unwrap(); + let (_, msg) = unframe(&b_kp).unwrap(); + let MlsMessageBodyIn::KeyPackage(kp) = msg.extract() else { panic!() }; + let kp = kp.validate(a.provider.crypto(), ProtocolVersion::Mls10).unwrap(); + let (_, w, _) = g.add_members(&a.provider, &a.key, &[kp]).unwrap(); + g.merge_pending_commit(&a.provider).unwrap(); + (info, framed(frame::WELCOME, &w).unwrap()) + }; + assert!(matches!(n.clock.created(&info), Err(Error::Refused(Refusal::GroupMismatch)))); + assert_eq!(err_refusal(n.members[1].receive(&welcome)), Refusal::GroupMismatch); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/lib.rs new file mode 100644 index 000000000..d8c62e12a --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/lib.rs @@ -0,0 +1,33 @@ +//! **joule-moq-mls** — end-to-end encrypted WAI live rooms (WAI +//! `extensions/confidentiality.md`). +//! +//! A room's media objects are sealed by `joule-moq`'s `secure_objects` under keys this crate +//! derives from one MLS group (RFC 9420) per room: +//! +//! - [`record`]: the room's confidentiality record, `WRE1` (`wai.live.e2ee`, §3): the +//! authority's signed statement that the room is end-to-end encrypted, with which suites, +//! and never recorded. +//! - [`model`]: how the group follows the room (§5.4 to §5.6), as pure state: what the +//! clock owes after each lifecycle event, who commits, what a commit holds, the roster it +//! leaves. Members check every proposal and commit against it before applying them. +//! - [`group`]: the MLS binding. A [`group::RoomMember`] holds a leaf whose credential is +//! its principal's Ed25519 room key, checks and applies the group's messages, commits when +//! the rule names it, and enters each epoch's application secret +//! (`MLS-Exporter("wai live e2ee", room_id, 32)`) into its keyring; a [`group::Clock`] is +//! the group's one external sender, proposes every Add and Remove the room's lifecycle +//! calls for, and follows the group from its public handshake messages, holding no key. +//! - [`corpus`]: WAI `live-conformance/e2ee/vectors.json`, written from fixed seeds and +//! checked by the same code. +//! +//! What this crate does not do: carry MLS messages (a room's tracks or a room service do), +//! or decide who is present (the room's clock does, and this crate reads its events). + +pub mod corpus; +pub mod group; +pub mod model; +pub mod record; +pub mod strict; + +pub use group::{Clock, Commit, Error, Received, RoomMember, CIPHERSUITE}; +pub use model::{Event, Room}; +pub use record::Record; diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/model.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/model.rs new file mode 100644 index 000000000..cc1c1ea63 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/model.rs @@ -0,0 +1,414 @@ +//! How a room's MLS group follows the room (WAI confidentiality §5.4 to §5.6), as pure +//! state: what the clock owes the group after each lifecycle event, whether a proposal is +//! the one it owes, who commits, whether a commit holds what it must, and the roster the +//! commit leaves. Members check every proposal and commit against it before applying them, +//! the clock proposes from it, and a verifier replays a room's transcript through it; none +//! of them needs MLS for this. + +use joule_moq::secure_objects::Refusal; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +pub use joule_moq::secure_objects::room::{Principal, Roster}; + +/// A lifecycle event of live-rooms §7.2, reduced to what decides who holds keys. A grant +/// is **keyed** when it may subscribe or may publish any media kind. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum Event { + Join { + #[serde(with = "hex32")] + subject: Principal, + keyed: bool, + }, + Leave { + #[serde(with = "hex32")] + subject: Principal, + reason: u8, + }, + GrantExchange { + #[serde(with = "hex32")] + subject: Principal, + keyed_before: bool, + keyed_after: bool, + }, +} + +/// Who sent a proposal: the clock (the group's one external sender) or a member. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum From { + Clock, + Member(u32), +} + +/// What a proposal does. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Change { + /// An Add of a KeyPackage whose leaf has this credential identity and signature key. + Add { + #[serde(with = "hex32")] + identity: Principal, + #[serde(with = "hex32")] + signature_key: Principal, + }, + /// A Remove of a leaf. + Remove { leaf: u32 }, +} + +/// A proposal: its sender, the epoch it is for, and what it does. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Proposal { + pub from: From, + pub epoch: u64, + pub change: Change, +} + +/// What the clock owes the group next. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Owed { + /// No group: this principal creates one. + Create(Principal), + /// An Add of this principal. + Add(Principal), + /// A Remove of this principal, at this leaf. + Remove(Principal, u32), +} + +/// The committer of a commit that ends an epoch with `roster`, where the commit removes +/// `removed`: the lowest leaf index not removed (§5.5). +pub fn committer(roster: &Roster, removed: &BTreeSet) -> Option { + roster.keys().copied().find(|l| !removed.contains(l)) +} + +/// The roster a commit leaves (§5.6, RFC 9420 §12.3): the removes blank their leaves, then +/// each add, in the commit's order, takes the lowest blank leaf, which is the leftmost +/// blank leaf of the tree or, when none is blank, the next at its right edge. +pub fn apply(roster: &Roster, removed: &BTreeSet, added: &[Principal]) -> (Roster, Vec) { + let mut r: Roster = roster.iter().filter(|(l, _)| !removed.contains(l)).map(|(l, p)| (*l, *p)).collect(); + let mut leaves = Vec::new(); + for p in added { + let leaf = (0u32..).find(|i| !r.contains_key(i)).expect("a free index"); + r.insert(leaf, *p); + leaves.push(leaf); + } + (r, leaves) +} + +/// The group as it stands. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Group { + pub generation: u32, + pub epoch: u64, + pub roster: Roster, +} + +/// What a commit did. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Committed { + pub epoch: u64, + pub removed: Vec<(u32, Principal)>, + pub added: Vec<(u32, Principal)>, +} + +/// A room's membership as its group should follow it. +#[derive(Clone, Debug)] +pub struct Room { + room: [u8; 32], + clock: Principal, + /// The generation of the current group, or of the next when there is none. + generation: u32, + group: Option, + seq: u64, + /// Principals present under a keyed grant, by the event that made them so. + keyed: BTreeMap, + /// Principals that stopped being keyed while the group may still hold them. + gone: BTreeMap, + /// The proposals for the current epoch, in the order they arrived. + pending: Vec, +} + +impl Room { + pub fn new(room: [u8; 32], clock: Principal) -> Self { + Room { room, clock, generation: 0, group: None, seq: 0, keyed: BTreeMap::new(), gone: BTreeMap::new(), pending: Vec::new() } + } + + pub fn room(&self) -> [u8; 32] { + self.room + } + + pub fn clock(&self) -> Principal { + self.clock + } + + /// The group ID of generation `generation` (§5.1): the room id and the generation as + /// 4 bytes big-endian. + pub fn group_id_of(room: &[u8; 32], generation: u32) -> Vec { + [room.as_slice(), &generation.to_be_bytes()].concat() + } + + pub fn group_id(&self) -> Vec { + Self::group_id_of(&self.room, self.generation) + } + + pub fn group(&self) -> Option<&Group> { + self.group.as_ref() + } + + pub fn pending(&self) -> &[Proposal] { + &self.pending + } + + /// A lifecycle event (§5.4). + pub fn event(&mut self, ev: &Event) { + self.seq += 1; + let (subject, keyed_now) = match *ev { + Event::Join { subject, keyed } => (subject, keyed.then_some(true)), + Event::Leave { subject, .. } => (subject, Some(false)), + Event::GrantExchange { subject, keyed_before, keyed_after } => (subject, (keyed_before != keyed_after).then_some(keyed_after)), + }; + match keyed_now { + Some(true) => { + self.keyed.insert(subject, self.seq); + self.gone.remove(&subject); + } + Some(false) if self.keyed.remove(&subject).is_some() => { + self.gone.insert(subject, self.seq); + } + Some(false) | None => {} + } + // A group no keyed principal holds a leaf of can never commit again: it ends, and the + // next keyed principal starts the next generation. + if let Some(g) = &self.group + && !g.roster.values().any(|p| self.keyed.contains_key(p)) + { + self.group = None; + self.generation += 1; + self.pending.clear(); + self.gone.clear(); + } + } + + /// The membership the group will have once its pending clock proposals are committed. + fn reflected(&self) -> BTreeSet { + let Some(g) = &self.group else { return BTreeSet::new() }; + let mut s: BTreeSet = g.roster.values().copied().collect(); + for p in self.pending.iter().filter(|p| p.from == From::Clock) { + match p.change { + Change::Add { identity, .. } => { + s.insert(identity); + } + Change::Remove { leaf } => { + if let Some(x) = g.roster.get(&leaf) { + s.remove(x); + } + } + } + } + s + } + + /// What the clock owes next (§5.4): the earliest change, in the order of the events that + /// call for it, that can be proposed now. An Add can be proposed into any group; a + /// Remove only of a principal that holds a leaf. + pub fn next_owed(&self) -> Option { + let Some(g) = &self.group else { + return self.keyed.iter().min_by_key(|(p, s)| (**s, **p)).map(|(p, _)| Owed::Create(*p)); + }; + let reflected = self.reflected(); + let mut owed: Vec<(u64, Owed)> = Vec::new(); + for (p, s) in &self.keyed { + if !reflected.contains(p) { + owed.push((*s, Owed::Add(*p))); + } + } + for p in &reflected { + if !self.keyed.contains_key(p) + && let Some((leaf, _)) = g.roster.iter().find(|(_, x)| *x == p) + { + owed.push((self.gone.get(p).copied().unwrap_or(0), Owed::Remove(*p, *leaf))); + } + } + owed.sort_by_key(|(s, o)| (*s, matches!(o, Owed::Add(_)))); + owed.first().map(|(_, o)| *o) + } + + /// The group's creation (§5.4): by the principal the clock owes it to, with the room's + /// group ID and the clock as its one external sender. + pub fn create(&mut self, by: Principal, group_id: &[u8], external_senders: &[Principal]) -> Result<(), Refusal> { + if group_id != self.group_id().as_slice() || external_senders != [self.clock] { + return Err(Refusal::GroupMismatch); + } + if self.next_owed() != Some(Owed::Create(by)) { + return Err(Refusal::ProposalMismatch); + } + self.group = Some(Group { generation: self.generation, epoch: 0, roster: [(0, by)].into() }); + Ok(()) + } + + /// A member joined by a Welcome into a group whose state it now holds: take that state. + /// What it holds before is unknown to it, and is checked by the members who were there. + pub fn joined(&mut self, group_id: &[u8], external_senders: &[Principal], epoch: u64, roster: Roster) -> Result<(), Refusal> { + let generation = group_id + .strip_prefix(self.room.as_slice()) + .and_then(|g| <[u8; 4]>::try_from(g).ok()) + .map(u32::from_be_bytes) + .ok_or(Refusal::GroupMismatch)?; + if generation < self.generation || external_senders != [self.clock] { + return Err(Refusal::GroupMismatch); + } + self.generation = generation; + self.group = Some(Group { generation, epoch, roster }); + self.pending.clear(); + Ok(()) + } + + /// A proposal received (§5.4, §5.5). A member's is kept, and refused if a commit holds + /// it; the clock's must be for the current epoch, carry a credential whose identity is + /// its signature key, and be the change the clock owes. + pub fn propose(&mut self, p: Proposal) -> Result<(), Refusal> { + let Some(g) = &self.group else { return Err(Refusal::ProposalMismatch) }; + if p.from == From::Clock { + if p.epoch != g.epoch { + return Err(Refusal::ProposalMismatch); + } + if let Change::Add { identity, signature_key } = p.change + && identity != signature_key + { + return Err(Refusal::CredentialMismatch); + } + let ok = match (p.change, self.next_owed()) { + (Change::Add { identity, .. }, Some(Owed::Add(q))) => identity == q, + (Change::Remove { leaf }, Some(Owed::Remove(_, l))) => leaf == l, + _ => false, + }; + if !ok { + return Err(Refusal::ProposalMismatch); + } + } + self.pending.push(p); + Ok(()) + } + + /// A commit (§5.5): `sender` is its sender's leaf, and `holds` the indexes in + /// [`Room::pending`] of the proposals it holds, in the commit's order. Checked in the + /// order of §5.5, then applied. + pub fn commit(&mut self, sender: u32, holds: &[usize]) -> Result { + let Some(g) = &self.group else { return Err(Refusal::CommitMismatch) }; + let held: Vec = holds.iter().map(|i| self.pending.get(*i).copied().ok_or(Refusal::CommitMismatch)).collect::>()?; + let removed: BTreeSet = held.iter().filter_map(|p| if let Change::Remove { leaf } = p.change { Some(leaf) } else { None }).collect(); + if committer(&g.roster, &removed) != Some(sender) { + return Err(Refusal::CommitNotCommitter); + } + if held.iter().any(|p| p.from != From::Clock) { + return Err(Refusal::ProposalNotClock); + } + // The clock's proposals it holds are the first k the clock sent for the epoch, each + // once, for some k. + let clock: Vec = (0..self.pending.len()).filter(|i| self.pending[*i].from == From::Clock).collect(); + let mut sorted = holds.to_vec(); + sorted.sort_unstable(); + if sorted != clock[..sorted.len().min(clock.len())] || sorted.len() > clock.len() { + return Err(Refusal::CommitMismatch); + } + if removed.iter().any(|l| !g.roster.contains_key(l)) { + return Err(Refusal::CommitMismatch); + } + let added: Vec = held.iter().filter_map(|p| if let Change::Add { identity, .. } = p.change { Some(identity) } else { None }).collect(); + let (roster, leaves) = apply(&g.roster, &removed, &added); + let out = Committed { + epoch: g.epoch + 1, + removed: removed.iter().map(|l| (*l, g.roster[l])).collect(), + added: leaves.into_iter().zip(added).collect(), + }; + for (_, p) in &out.removed { + self.gone.remove(p); + } + self.group = Some(Group { generation: g.generation, epoch: out.epoch, roster }); + self.pending.clear(); + Ok(out) + } +} + +/// Hexadecimal for 32-byte keys in the corpus. +pub mod hex32 { + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(v: &[u8; 32], s: S) -> Result { + s.serialize_str(&hex::encode(v)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> { + let s = String::deserialize(d)?; + let v = hex::decode(&s).map_err(serde::de::Error::custom)?; + v.try_into().map_err(|_| serde::de::Error::custom("32 bytes")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const R: [u8; 32] = [9; 32]; + const K: Principal = [0xc; 32]; + const A: Principal = [0xa; 32]; + const B: Principal = [0xb; 32]; + const C: Principal = [0xcc; 32]; + + fn add(epoch: u64, p: Principal) -> Proposal { + Proposal { from: From::Clock, epoch, change: Change::Add { identity: p, signature_key: p } } + } + + #[test] + fn a_room_grows_shrinks_and_reuses_a_leaf() { + let mut m = Room::new(R, K); + m.event(&Event::Join { subject: A, keyed: true }); + m.event(&Event::Join { subject: [0xe; 32], keyed: false }); + assert_eq!(m.next_owed(), Some(Owed::Create(A))); + assert_eq!(m.create(A, &m.group_id(), &[A]), Err(Refusal::GroupMismatch)); + assert_eq!(m.create(B, &m.group_id(), &[K]), Err(Refusal::ProposalMismatch)); + m.create(A, &m.group_id(), &[K]).unwrap(); + m.event(&Event::Join { subject: B, keyed: true }); + m.event(&Event::Join { subject: C, keyed: true }); + assert_eq!(m.propose(add(0, C)), Err(Refusal::ProposalMismatch), "B is owed first"); + assert_eq!(m.propose(add(1, B)), Err(Refusal::ProposalMismatch), "another epoch"); + let lie = Proposal { from: From::Clock, epoch: 0, change: Change::Add { identity: B, signature_key: C } }; + assert_eq!(m.propose(lie), Err(Refusal::CredentialMismatch)); + m.propose(add(0, B)).unwrap(); + m.propose(add(0, C)).unwrap(); + // Only A commits; it may hold the first proposal alone, never the second alone. + assert_eq!(m.clone().commit(1, &[0, 1]), Err(Refusal::CommitNotCommitter)); + assert_eq!(m.clone().commit(0, &[1]), Err(Refusal::CommitMismatch)); + let c = m.clone().commit(0, &[0]).unwrap(); + assert_eq!(c.added, vec![(1, B)]); + let c = m.commit(0, &[1, 0]).unwrap(); + assert_eq!((c.epoch, c.added), (1, vec![(1, C), (2, B)]), "adds take leaves in the commit's order"); + // B leaves: removed, and only then can a new joiner take its leaf. + m.event(&Event::Leave { subject: B, reason: 1 }); + assert_eq!(m.next_owed(), Some(Owed::Remove(B, 2))); + m.propose(Proposal { from: From::Clock, epoch: 1, change: Change::Remove { leaf: 2 } }).unwrap(); + // A member's own proposal cannot ride a commit. + m.propose(Proposal { from: From::Member(1), epoch: 1, change: Change::Remove { leaf: 0 } }).unwrap(); + assert_eq!(m.clone().commit(1, &[0, 1]), Err(Refusal::ProposalNotClock), "it would remove A, so C commits"); + let c = m.commit(0, &[0]).unwrap(); + assert_eq!(c.removed, vec![(2, B)]); + let d: Principal = [0xd; 32]; + m.event(&Event::Join { subject: d, keyed: true }); + m.propose(add(2, d)).unwrap(); + let c = m.commit(0, &[0]).unwrap(); + assert_eq!(c.added, vec![(2, d)]); + // A leaves: C, at the next lowest leaf, commits. + m.event(&Event::Leave { subject: A, reason: 4 }); + m.propose(Proposal { from: From::Clock, epoch: 3, change: Change::Remove { leaf: 0 } }).unwrap(); + assert_eq!(m.clone().commit(0, &[0]), Err(Refusal::CommitNotCommitter)); + assert_eq!(m.commit(1, &[0]).unwrap().epoch, 4); + // Everyone leaves: the group ends, and the next joiner starts generation 1. + m.event(&Event::Leave { subject: C, reason: 1 }); + m.event(&Event::Leave { subject: d, reason: 1 }); + assert!(m.group().is_none()); + m.event(&Event::Join { subject: B, keyed: true }); + assert_eq!(m.next_owed(), Some(Owed::Create(B))); + assert_eq!(&m.group_id()[32..], &1u32.to_be_bytes()); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/record.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/record.rs new file mode 100644 index 000000000..c75cdb3bd --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/record.rs @@ -0,0 +1,133 @@ +//! The room's confidentiality record, `WRE1` (`wai.live.e2ee`, WAI confidentiality §3). + +use crate::strict; +use ed25519_dalek::{Signer, SigningKey}; +use joule_moq::secure_objects::{Refusal, Suite}; + +pub const MAGIC: &[u8; 4] = b"WRE1"; +pub const VERSION: u8 = 1; +/// A record's length: magic, version, room, two suites, signature. +pub const LEN: usize = 4 + 1 + 32 + 2 + 2 + 64; +/// What the signature covers before the record (§3.2): 18 bytes and 0x01. +pub const DOMAIN: &[u8] = b"wai:live-room-e2ee\x01"; +/// The MLS ciphersuite of a room: `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`. +pub const MLS_SUITE: u16 = 0x0001; +/// The registered string of the record (SPEC §5). +pub const CAPABILITY: &str = "wai.live.e2ee"; + +/// A confidentiality record. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Record { + pub room: [u8; 32], + pub suite: u16, + pub mls_suite: u16, + pub sig: [u8; 64], +} + +impl Record { + /// The bytes the signature follows: `magic` through `mls_suite`. + pub fn body(room: &[u8; 32], suite: u16, mls_suite: u16) -> Vec { + let mut b = MAGIC.to_vec(); + b.push(VERSION); + b.extend_from_slice(room); + b.extend_from_slice(&suite.to_le_bytes()); + b.extend_from_slice(&mls_suite.to_le_bytes()); + b + } + + /// A record signed by the room's authority. + pub fn sign(room: [u8; 32], suite: u16, mls_suite: u16, authority: &SigningKey) -> Record { + let msg = [DOMAIN, &Self::body(&room, suite, mls_suite)].concat(); + Record { room, suite, mls_suite, sig: authority.sign(&msg).to_bytes() } + } + + pub fn to_bytes(&self) -> Vec { + let mut b = Self::body(&self.room, self.suite, self.mls_suite); + b.extend_from_slice(&self.sig); + b + } + + /// §3.3 step 1: read the bytes as one record. + pub fn read(b: &[u8]) -> Result { + if b.len() < 4 { + return Err(Refusal::Truncated); + } + if &b[..4] != MAGIC { + return Err(Refusal::Malformed); + } + let Some(&v) = b.get(4) else { return Err(Refusal::Truncated) }; + if v != VERSION { + return Err(Refusal::UnknownVersion); + } + if b.len() < LEN { + return Err(Refusal::Truncated); + } + if b.len() > LEN { + return Err(Refusal::Malformed); + } + Ok(Record { + room: b[5..37].try_into().expect("32"), + suite: u16::from_le_bytes([b[37], b[38]]), + mls_suite: u16::from_le_bytes([b[39], b[40]]), + sig: b[41..105].try_into().expect("64"), + }) + } + + /// §3.3 in full, given the room id, the descriptor's authority and its recording + /// `mode`: the record, with its suite, or the first refusal. + pub fn check(b: &[u8], room: &[u8; 32], authority: &[u8; 32], recording_mode: u8) -> Result<(Record, Suite), Refusal> { + let r = Self::read(b)?; + if &r.room != room { + return Err(Refusal::WrongRoom); + } + let suite = match Suite::from_id(r.suite) { + Some(s @ (Suite::AesGcm128Sha256 | Suite::AesGcm256Sha512)) => s, + _ => return Err(Refusal::SuiteUnsupported), + }; + if r.mls_suite != MLS_SUITE { + return Err(Refusal::MlsSuiteUnsupported); + } + if recording_mode != 0 { + return Err(Refusal::RecordingRefused); + } + let msg = [DOMAIN, &b[..LEN - 64]].concat(); + strict::verify(authority, &msg, &r.sig)?; + Ok((r, suite)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_record_and_each_refusal() { + let auth = SigningKey::from_bytes(&[1; 32]); + let a = auth.verifying_key().to_bytes(); + let room = [7; 32]; + let good = Record::sign(room, 4, 1, &auth).to_bytes(); + assert_eq!(good.len(), LEN); + assert_eq!(Record::check(&good, &room, &a, 0).unwrap().1, Suite::AesGcm128Sha256); + assert_eq!(Record::check(&good[..3], &room, &a, 0).err(), Some(Refusal::Truncated)); + assert_eq!(Record::check(&good[..4], &room, &a, 0).err(), Some(Refusal::Truncated)); + assert_eq!(Record::check(&good[..60], &room, &a, 0).err(), Some(Refusal::Truncated)); + assert_eq!(Record::check(&[good.as_slice(), &[0]].concat(), &room, &a, 0).err(), Some(Refusal::Malformed)); + let mut magic = good.clone(); + magic[0] = b'X'; + assert_eq!(Record::check(&magic, &room, &a, 0).err(), Some(Refusal::Malformed)); + let mut ver = good.clone(); + ver[4] = 2; + assert_eq!(Record::check(&ver, &room, &a, 0).err(), Some(Refusal::UnknownVersion)); + assert_eq!(Record::check(&good, &[8; 32], &a, 0).err(), Some(Refusal::WrongRoom)); + let ctr = Record::sign(room, 1, 1, &auth).to_bytes(); + assert_eq!(Record::check(&ctr, &room, &a, 0).err(), Some(Refusal::SuiteUnsupported)); + let mls = Record::sign(room, 5, 2, &auth).to_bytes(); + assert_eq!(Record::check(&mls, &room, &a, 0).err(), Some(Refusal::MlsSuiteUnsupported)); + assert_eq!(Record::check(&good, &room, &a, 1).err(), Some(Refusal::RecordingRefused)); + let other = SigningKey::from_bytes(&[2; 32]).verifying_key().to_bytes(); + assert_eq!(Record::check(&good, &room, &other, 0).err(), Some(Refusal::BadSignature)); + let mut identity = [0u8; 32]; + identity[0] = 1; + assert_eq!(Record::check(&good, &room, &identity, 0).err(), Some(Refusal::BadKey)); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/src/strict.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/src/strict.rs new file mode 100644 index 000000000..c87a31c6c --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/src/strict.rs @@ -0,0 +1,73 @@ +//! Ed25519 verified under WAI's strict rule (SPEC §7 *Signatures*): steps 1 and 2 are +//! the key's (`bad_key`), steps 3 to 5 the signature's (`bad_signature`). + +use ed25519_dalek::{Signature, VerifyingKey}; +use joule_moq::secure_objects::Refusal; + +/// The order of the base point, L, little-endian (RFC 8032 §5.1). +const L: [u8; 32] = [ + 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0x10, +]; + +fn below_l(s: &[u8]) -> bool { + for (a, l) in s.iter().zip(L.iter()).rev() { + if a != l { + return a < l; + } + } + false +} + +/// Steps 1 and 2: the key is its point's one encoding and not of small order. +pub fn key(bytes: &[u8; 32]) -> Result { + let k = VerifyingKey::from_bytes(bytes).map_err(|_| Refusal::BadKey)?; + if k.to_edwards().compress().to_bytes() != *bytes || k.is_weak() { + return Err(Refusal::BadKey); + } + Ok(k) +} + +/// Steps 1 to 5 over `msg`. +pub fn verify(key_bytes: &[u8; 32], msg: &[u8], sig: &[u8; 64]) -> Result<(), Refusal> { + let k = key(key_bytes)?; + if !below_l(&sig[32..]) { + return Err(Refusal::BadSignature); + } + // verify_strict refuses an R of small order and holds the cofactorless equation; an R + // that is not its point's canonical encoding never equals the recomputed one. + k.verify_strict(msg, &Signature::from_bytes(sig)).map_err(|_| Refusal::BadSignature) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + #[test] + fn the_rule_refuses_keys_and_signatures_apart() { + let sk = SigningKey::from_bytes(&[3; 32]); + let pk = sk.verifying_key().to_bytes(); + let sig = sk.sign(b"m").to_bytes(); + verify(&pk, b"m", &sig).unwrap(); + assert_eq!(verify(&pk, b"n", &sig), Err(Refusal::BadSignature)); + // The identity, of small order: (R, S) = (B, 1) would verify over every message. + let mut identity = [0u8; 32]; + identity[0] = 1; + assert_eq!(verify(&identity, b"m", &sig), Err(Refusal::BadKey)); + // A non-canonical y (y = p, which reduces to 0). + let mut p = [0xffu8; 32]; + p[0] = 0xed; + p[31] = 0x7f; + assert_eq!(key(&p).err(), Some(Refusal::BadKey)); + // S + L, the same equation, refused for S ≥ L. + let mut big = sig; + let mut carry = 0u16; + for i in 0..32 { + let v = big[32 + i] as u16 + L[i] as u16 + carry; + big[32 + i] = v as u8; + carry = v >> 8; + } + assert_eq!(verify(&pk, b"m", &big), Err(Refusal::BadSignature)); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-mls/tests/corpus.rs b/joule-code/joule-code-rs/crates/joule-moq-mls/tests/corpus.rs new file mode 100644 index 000000000..176bda2c2 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-mls/tests/corpus.rs @@ -0,0 +1,53 @@ +//! WAI `live-conformance/e2ee/` (confidentiality §14): the committed `vectors.json` +//! regenerates byte for byte from its fixed seeds, the reference gives every vector its +//! stated outcome, and §13 of the extension lists exactly the reference's codes. + +use joule_moq::secure_objects::Refusal; +use joule_moq_mls::corpus; + +const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/live-conformance/e2ee/"); +const SPEC: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/extensions/confidentiality.md"); + +#[test] +fn the_committed_corpus_regenerates_byte_for_byte() { + let on_disk = std::fs::read(format!("{DIR}{}", corpus::FILE)).expect("vectors.json"); + assert!(on_disk == corpus::to_bytes(&corpus::generate()), "vectors.json is not what the generator writes; run joule_moq_e2ee_vectors gen"); +} + +#[test] +fn the_reference_gives_every_vector_its_outcome() { + let v: serde_json::Value = serde_json::from_slice(&std::fs::read(format!("{DIR}{}", corpus::FILE)).unwrap()).unwrap(); + corpus::check(&v).unwrap(); +} + +#[test] +fn a_mutated_corpus_fails() { + let v: serde_json::Value = serde_json::from_slice(&std::fs::read(format!("{DIR}{}", corpus::FILE)).unwrap()).unwrap(); + for (path, value) in [ + ("/records/0/outcome", serde_json::json!("bad_signature")), + ("/objects/0/derived/nonce", serde_json::json!("00")), + ("/objects/6/outcome", serde_json::json!("opened")), + ("/rooms/0/deliveries/0/deliveries/0/outcome", serde_json::json!("bad_tag")), + ("/rooms/0/rosters/1/roster/1", serde_json::json!("00")), + ("/rooms/1/outcome/refuse", serde_json::json!("commit_mismatch")), + ("/key_schedule/0/moq_key", serde_json::json!("00")), + ] { + let mut m = v.clone(); + *m.pointer_mut(path).unwrap_or_else(|| panic!("{path}")) = value; + assert!(corpus::check(&m).is_err(), "{path} mutated and still checks"); + } +} + +#[test] +fn section_13_lists_exactly_the_references_codes() { + let spec = std::fs::read_to_string(SPEC).unwrap(); + let start = spec.find("## 13. Reasons").expect("§13"); + let end = spec[start..].find("\n## ").map(|e| start + e).unwrap_or(spec.len()); + let mut listed = Vec::new(); + for line in spec[start..end].lines().filter(|l| l.starts_with("| `")) { + let cell = line.split('|').nth(1).unwrap(); + listed.extend(cell.split('`').skip(1).step_by(2).map(str::to_string)); + } + let codes: Vec = Refusal::ALL.iter().map(|r| r.code().to_string()).collect(); + assert_eq!(listed, codes, "§13's table and Refusal::ALL, in order"); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml index 7bc70bcc8..685dc9654 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/Cargo.toml @@ -41,3 +41,5 @@ thiserror = "2" [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +# tests/sealed_objects.rs seals a room's objects end to end; the relay itself never opens one. +joule-moq = { path = "../joule-moq", features = ["secure-objects"] } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/sealed_objects.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/sealed_objects.rs new file mode 100644 index 000000000..d46db1c74 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/sealed_objects.rs @@ -0,0 +1,143 @@ +//! A room's sealed objects (WAI `extensions/confidentiality.md`) through this relay, which +//! has no notion of them: three members each publish sealed video with its receipts over +//! the sealed bytes, the door holds each publisher's namespace to its own key and checks +//! every object against its receipt, and every member opens what the others sent while a +//! subscriber outside the group verifies every receipt and opens nothing. The relay +//! forwards each object's payload and properties byte for byte. + +mod common; + +use common::*; +use ed25519_dalek::SigningKey; +use joule_moq::draft22::data::{ObjectStatus as WireStatus, SubgroupHeader, SubgroupId, SubgroupObject}; +use joule_moq::draft22::Parameters; +use joule_moq::receipt::{ObjectReceipt, ReceiptBuilder}; +use joule_moq::secure_objects::room::{Incoming, Keyring, NotOpened, Outgoing, Roster}; +use joule_moq::secure_objects::{Plaintext, Refusal, Suite}; +use joule_moq::{GroupId, ObjectId, TrackAlias, TrackName}; +use joule_moq_relay::client::{Client, ClientOptions}; +use joule_moq_relay::config::DoorRule; + +const GROUPS: u64 = 6; +const EPOCH: u64 = 3; +const ROOM: [u8; 32] = [0x5e; 32]; + +/// Publish group `g` of `t`: the receipts over the sealed payload first, then the sealed +/// object, whose Immutable Properties carry its Key ID and no receipt (confidentiality §4.3). +async fn publish_sealed(c: &Client, key: &SigningKey, t: &Tracks, g: u64, properties: joule_moq::draft22::kvp::Properties, payload: &[u8]) { + let hash = ObjectReceipt::hash_payload(payload); + let mut b = ReceiptBuilder::new(TrackAlias(t.receipt_alias), GroupId(g)); + b.add_labelled_object(hash, 0, "sealed-publisher", "2026-10-19T00:00:00Z", vec![0]).unwrap(); + let receipt = ObjectReceipt { + track_alias: TrackAlias(t.receipt_alias), + group_id: GroupId(g), + object_id: ObjectId(0), + content_hash: hash, + joules_micro: 0, + origin: "sealed-publisher".into(), + merkle_proof: b.proof_for(0).unwrap(), + energy_class: Some(vec![0]), + }; + let group = b.finalize_labelled(key, "sealed-publisher", "2026-10-19T00:00:01Z", None).unwrap(); + let bundle = joule_moq::carriage::ReceiptBundle { group, objects: vec![receipt] }; + let rh = SubgroupHeader { track_alias: t.receipts_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: false, end_of_group: true, first_object: true }; + c.send_subgroup(rh, &[SubgroupObject::normal(0, bundle.to_payload().unwrap())]).await.expect("the receipts"); + let mh = SubgroupHeader { track_alias: t.media_alias, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(t.priority), properties: true, end_of_group: true, first_object: true }; + c.send_subgroup(mh, &[SubgroupObject { object_id: 0, properties, status: WireStatus::Normal, payload: payload.to_vec() }]).await.expect("the object"); +} + +async fn room_of_three(via: Via) { + let keys: Vec = [0xa1u8, 0xb2, 0xc3].iter().map(|b| SigningKey::from_bytes(&[*b; 32])).collect(); + let principals: Vec<[u8; 32]> = keys.iter().map(|k| k.verifying_key().to_bytes()).collect(); + let roster: Roster = principals.iter().enumerate().map(|(i, p)| (i as u32, *p)).collect(); + let secret = [0x77; 32]; + let rings: Vec = principals + .iter() + .map(|p| { + let mut k = Keyring::new(Suite::AesGcm128Sha256, *p); + k.enter(EPOCH, secret, roster.clone()); + k + }) + .collect(); + let room = hex::encode(ROOM); + let server = relay(|c| { + c.receipts.door = vec![DoorRule { prefix: vec![room.clone(), "*".into(), "*".into()], trust: vec![], trust_field: Some(1), hold_ms: 5_000 }]; + }) + .await; + let tracks: Vec = principals.iter().enumerate().map(|(i, p)| Tracks::new(ns(&[&room, &hex::encode(p), "video"]), "0", 10 * (i as u64 + 1), 7 + i as u64, 64)).collect(); + + let mut pubs = Vec::new(); + let mut held = Vec::new(); + for t in &tracks { + let (c, e) = connect(&server, via, ClientOptions::default()).await; + held.push(publish_tracks(&c, t, None).await); + pubs.push((c, e)); + } + // The three members, and one subscriber outside the group. + let mut subs = Vec::new(); + let mut views = Vec::new(); + for _ in 0..4 { + let (s, e) = connect(&server, via, ClientOptions::default()).await; + let mut v = Vec::new(); + for t in &tracks { + v.push(subscribe_both(&s, t, Parameters::new()).await); + } + views.push(v); + subs.push((s, e)); + } + + // Every member seals and publishes its groups. + let mut sent = Vec::new(); + for (i, t) in tracks.iter().enumerate() { + let name = TrackName::from_str("0"); + for g in 0..GROUPS { + let pt = Plaintext::with_digest(payload(&format!("member {i}"), g, 700)); + let s = rings[i].seal(&Outgoing { ns: &t.ns, name: &name, group: g, object: 0, priority: t.priority, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap().unwrap(); + publish_sealed(&pubs[i].0, &keys[i], t, g, s.properties.clone(), &s.payload).await; + sent.push((i, g, s)); + } + } + let reads = futures::future::join_all(subs.iter().map(|(s, _)| collect(s, STEP * 2, |got| got.len() as u64 >= 2 * 3 * GROUPS))); + let got = reads.await; + + for (j, (g, v)) in got.iter().zip(&views).enumerate() { + let mut ring = if j < 3 { rings[j].clone() } else { Keyring::new(Suite::AesGcm128Sha256, [0x0f; 32]) }; + for (i, t) in tracks.iter().enumerate() { + // Every receipt verifies over the sealed bytes the relay forwarded. + let (objects, bundles, _, _) = verify(g, &v[i], &keys[i]); + assert_eq!((objects, bundles), (GROUPS as usize, GROUPS as usize), "subscriber {j}, publisher {i}"); + let name = TrackName::from_str("0"); + for x in g.iter().filter(|x| x.header.track_alias == v[i].media_alias) { + let o = &x.objects[0]; + let (_, _, s) = sent.iter().find(|(k, gg, _)| *k == i && *gg == x.header.group_id).unwrap(); + assert_eq!(o.payload, s.payload, "the sealed payload, byte for byte"); + assert_eq!(o.properties, s.properties, "the properties, the Key ID among them, byte for byte"); + let inc = Incoming { ns: &t.ns, name: &name, group: x.header.group_id, object: 0, priority: x.header.publisher_priority.unwrap(), properties: &o.properties, payload: &o.payload }; + match ring.open(&inc, None) { + Ok(op) if j < 3 => assert_eq!(op.plaintext.payload, payload(&format!("member {i}"), x.header.group_id, 700)), + Err(NotOpened::Refused(Refusal::EpochNotJoined)) if j == 3 => {} + other => panic!("subscriber {j}, publisher {i}: {other:?}"), + } + } + } + } + assert_eq!(server.relay().stats().refused_at_door, 0, "every sealed object passed the door on its receipt"); + drop(held); + for (s, _) in &subs { + s.close(0, "done"); + } + for (p, _) in &pubs { + p.close(0, "done"); + } + server.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn sealed_objects_over_websocket() { + tokio::time::timeout(std::time::Duration::from_secs(120), room_of_three(Via::WebSocket)).await.expect("the room finishes in time"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn sealed_objects_over_quic() { + tokio::time::timeout(std::time::Duration::from_secs(120), room_of_three(Via::Quic)).await.expect("the room finishes in time"); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml index 3483b66e0..2ecc84d1e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq/Cargo.toml @@ -25,6 +25,14 @@ uuid = { version = "1", features = ["v4", "v5", "v7", "serde", "js"], o chrono = { version = "0.4", features = ["serde"], optional = true } rand_core_06 = { package = "rand_core", version = "0.6", features = ["getrandom"], optional = true } futures = { version = "0.3", optional = true } +# End-to-end sealed objects (feature `secure-objects`): RustCrypto's AES-GCM, AES-CTR, +# HMAC and HKDF, all Apache-2.0 OR MIT, at the versions this workspace's lock already +# holds. No default features: nothing here draws randomness. +aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc"], optional = true } +aes = { version = "0.8", optional = true } +ctr = { version = "0.9", optional = true } +hmac = { version = "0.12", optional = true } +hkdf = { version = "0.12", optional = true } [features] default = ["runtime"] @@ -35,6 +43,9 @@ default = ["runtime"] # (`joule-moq-web` builds it so). runtime = ["dep:jwp", "dep:async-trait", "dep:tracing", "dep:tokio", "dep:uuid", "dep:chrono", "dep:rand_core_06", "dep:futures"] quic = ["runtime", "dep:quinn", "dep:rustls", "dep:rcgen"] +# Sealing and opening objects end to end (WAI confidentiality §4, §6): no runtime, so it +# builds for wasm32-unknown-unknown beside the codec. +secure-objects = ["dep:aes-gcm", "dep:aes", "dep:ctr", "dep:hmac", "dep:hkdf"] [dependencies.quinn] version = "0.11" diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs index e352e5e56..5fb7fffe6 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs @@ -41,6 +41,11 @@ //! - A native QUIC transport behind the `quic` feature (`transport_quic`): -22 //! control streams, request streams, subgroup and fetch streams and datagrams //! over quinn. +//! - End-to-end sealed objects behind the `secure-objects` feature +//! (`secure_objects`): `draft-ietf-moq-secure-objects-01` on -22 objects, as WAI +//! `extensions/confidentiality.md` places it; RFC 9605 SFrame for its vectors; and a +//! room member's keys and receiving checks. No runtime and no native dependency, so +//! it builds for `wasm32-unknown-unknown` with the codec. //! //! The endpoint traits and the mock are the default feature `runtime`, on tokio. //! Without it the crate is the codec, the carriage, the receipts and the session's @@ -81,6 +86,8 @@ pub mod messages; #[cfg(feature = "runtime")] pub mod mock; pub mod receipt; +#[cfg(feature = "secure-objects")] +pub mod secure_objects; pub mod session; pub mod staged; pub mod track; diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/aead.rs b/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/aead.rs new file mode 100644 index 000000000..e51ff48b9 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/aead.rs @@ -0,0 +1,192 @@ +//! The AEADs and KDFs of the ciphersuites secure objects shares with SFrame (RFC 9605 +//! §4.5, `draft-ietf-moq-secure-objects-01` §7.2): AES-GCM, and RFC 9605 §4.5.1's +//! AES-CTR with HMAC-SHA-256 and a truncated tag. + +use aes::cipher::{KeyIvInit, StreamCipher}; +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use sha2::{Sha256, Sha512}; + +/// A ciphersuite of the SFrame registry (RFC 9605 §8.1), which secure objects §7.2 uses. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Suite { + /// `0x0001` `AES_128_CTR_HMAC_SHA256_80` + AesCtr128HmacSha256_80, + /// `0x0002` `AES_128_CTR_HMAC_SHA256_64` + AesCtr128HmacSha256_64, + /// `0x0003` `AES_128_CTR_HMAC_SHA256_32` + AesCtr128HmacSha256_32, + /// `0x0004` `AES_128_GCM_SHA256_128`, mandatory to implement (secure objects §7.2). + AesGcm128Sha256, + /// `0x0005` `AES_256_GCM_SHA512_128` + AesGcm256Sha512, +} + +impl Suite { + pub const ALL: [Suite; 5] = + [Suite::AesCtr128HmacSha256_80, Suite::AesCtr128HmacSha256_64, Suite::AesCtr128HmacSha256_32, Suite::AesGcm128Sha256, Suite::AesGcm256Sha512]; + + pub fn from_id(id: u16) -> Option { + Suite::ALL.into_iter().find(|s| s.id() == id) + } + + pub fn id(self) -> u16 { + match self { + Suite::AesCtr128HmacSha256_80 => 1, + Suite::AesCtr128HmacSha256_64 => 2, + Suite::AesCtr128HmacSha256_32 => 3, + Suite::AesGcm128Sha256 => 4, + Suite::AesGcm256Sha512 => 5, + } + } + + /// The hash's output size. + pub fn nh(self) -> usize { + match self { + Suite::AesGcm256Sha512 => 64, + _ => 32, + } + } + + /// For the counter-mode suites, the AES key's size within the AEAD key. + pub fn nka(self) -> Option { + match self { + Suite::AesGcm128Sha256 | Suite::AesGcm256Sha512 => None, + _ => Some(16), + } + } + + /// The AEAD key's size: for the counter-mode suites, the AES key and the HMAC key. + pub fn nk(self) -> usize { + match self { + Suite::AesGcm128Sha256 => 16, + Suite::AesGcm256Sha512 => 32, + _ => 48, + } + } + + /// The nonce's size. + pub fn nn(self) -> usize { + 12 + } + + /// The tag's size. + pub fn nt(self) -> usize { + match self { + Suite::AesCtr128HmacSha256_80 => 10, + Suite::AesCtr128HmacSha256_64 => 8, + Suite::AesCtr128HmacSha256_32 => 4, + Suite::AesGcm128Sha256 | Suite::AesGcm256Sha512 => 16, + } + } + + /// HKDF-Extract (RFC 5869 §2.2) with the suite's hash. + pub fn hkdf_extract(self, salt: &[u8], ikm: &[u8]) -> Vec { + match self { + Suite::AesGcm256Sha512 => Hkdf::::extract(Some(salt), ikm).0.to_vec(), + _ => Hkdf::::extract(Some(salt), ikm).0.to_vec(), + } + } + + /// HKDF-Expand (RFC 5869 §2.3) with the suite's hash, from a PRK of at least the + /// hash's size, as HKDF-Extract gives. + pub fn hkdf_expand(self, prk: &[u8], info: &[u8], len: usize) -> Vec { + let mut out = vec![0u8; len]; + match self { + Suite::AesGcm256Sha512 => Hkdf::::from_prk(prk).expect("a PRK of the hash's size").expand(info, &mut out), + _ => Hkdf::::from_prk(prk).expect("a PRK of the hash's size").expand(info, &mut out), + } + .expect("an output HKDF can give"); + out + } +} + +/// `AEAD.Encrypt(key, nonce, aad, pt)`: the ciphertext with the tag after it (RFC 5116). +/// `key` is `nk()` bytes and `nonce` 12. +pub fn seal(suite: Suite, key: &[u8], nonce: &[u8; 12], aad: &[u8], pt: &[u8]) -> Vec { + assert_eq!(key.len(), suite.nk(), "the key is the suite's size"); + match suite { + Suite::AesGcm128Sha256 => aes_gcm::Aes128Gcm::new_from_slice(key) + .expect("16 bytes") + .encrypt(aes_gcm::Nonce::from_slice(nonce), Payload { msg: pt, aad }) + .expect("GCM seals any length this crate carries"), + Suite::AesGcm256Sha512 => aes_gcm::Aes256Gcm::new_from_slice(key) + .expect("32 bytes") + .encrypt(aes_gcm::Nonce::from_slice(nonce), Payload { msg: pt, aad }) + .expect("GCM seals any length this crate carries"), + _ => { + let (enc, auth) = key.split_at(16); + let mut ct = pt.to_vec(); + ctr(enc, nonce, &mut ct); + let tag = ctr_tag(suite, auth, nonce, aad, &ct); + ct.extend_from_slice(&tag); + ct + } + } +} + +/// `AEAD.Decrypt(key, nonce, aad, ct)`: the plaintext, or `None` for any failure, alike +/// whatever the cause. +pub fn open(suite: Suite, key: &[u8], nonce: &[u8; 12], aad: &[u8], ct: &[u8]) -> Option> { + if key.len() != suite.nk() || ct.len() < suite.nt() { + return None; + } + match suite { + Suite::AesGcm128Sha256 => aes_gcm::Aes128Gcm::new_from_slice(key).ok()?.decrypt(aes_gcm::Nonce::from_slice(nonce), Payload { msg: ct, aad }).ok(), + Suite::AesGcm256Sha512 => aes_gcm::Aes256Gcm::new_from_slice(key).ok()?.decrypt(aes_gcm::Nonce::from_slice(nonce), Payload { msg: ct, aad }).ok(), + _ => { + let (enc, auth) = key.split_at(16); + let (inner, tag) = ct.split_at(ct.len() - suite.nt()); + let mut mac = as Mac>::new_from_slice(auth).expect("HMAC takes any key"); + mac.update(&ctr_auth_data(suite, nonce, aad, inner)); + mac.verify_truncated_left(tag).ok()?; + let mut pt = inner.to_vec(); + ctr(enc, nonce, &mut pt); + Some(pt) + } + } +} + +/// AES-128 in counter mode from the nonce followed by four zero bytes (RFC 9605 §4.5.1). +fn ctr(enc_key: &[u8], nonce: &[u8; 12], buf: &mut [u8]) { + let mut iv = [0u8; 16]; + iv[..12].copy_from_slice(nonce); + let mut c = ctr::Ctr32BE::::new_from_slices(enc_key, &iv).expect("a 16-byte key and IV"); + c.apply_keystream(buf); +} + +/// What RFC 9605 §4.5.1's `compute_tag` MACs: the lengths of the AAD, the ciphertext and +/// the tag as 8-byte big-endian integers, then the nonce, the AAD and the ciphertext. +fn ctr_auth_data(suite: Suite, nonce: &[u8; 12], aad: &[u8], ct: &[u8]) -> Vec { + let mut d = Vec::with_capacity(24 + 12 + aad.len() + ct.len()); + d.extend_from_slice(&(aad.len() as u64).to_be_bytes()); + d.extend_from_slice(&(ct.len() as u64).to_be_bytes()); + d.extend_from_slice(&(suite.nt() as u64).to_be_bytes()); + d.extend_from_slice(nonce); + d.extend_from_slice(aad); + d.extend_from_slice(ct); + d +} + +/// RFC 9605 §4.5.1's tag: HMAC-SHA-256 over [`ctr_auth_data`], truncated to `nt()` bytes. +pub fn ctr_tag(suite: Suite, auth_key: &[u8], nonce: &[u8; 12], aad: &[u8], ct: &[u8]) -> Vec { + let mut mac = as Mac>::new_from_slice(auth_key).expect("HMAC takes any key"); + mac.update(&ctr_auth_data(suite, nonce, aad, ct)); + mac.finalize().into_bytes()[..suite.nt()].to_vec() +} + +/// HMAC-SHA-256 of `parts` concatenated, under `key`: for the corpus's statement of how +/// secure objects' Appendix B computes its counter-mode tags (WAI confidentiality §11.2). +pub fn hmac_sha256(key: &[u8], parts: &[&[u8]]) -> [u8; 32] { + let mut mac = as Mac>::new_from_slice(key).expect("HMAC takes any key"); + for p in parts { + mac.update(p); + } + mac.finalize().into_bytes().into() +} + +/// AES-128-CTR from `nonce ‖ 0x00000000` over `buf`, as the counter-mode suites encrypt. +pub fn ctr_keystream(enc_key: &[u8], nonce: &[u8; 12], buf: &mut [u8]) { + ctr(enc_key, nonce, buf) +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/mod.rs b/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/mod.rs new file mode 100644 index 000000000..5192262d1 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/mod.rs @@ -0,0 +1,479 @@ +//! End-to-end sealed objects: `draft-ietf-moq-secure-objects-01` on the objects of +//! `draft-ietf-moq-transport-22`, as WAI `extensions/confidentiality.md` §4 places it. +//! +//! The publisher seals each object's payload, and any Encrypted Properties, under a key +//! and salt derived from its track's base key, its Key ID and its Full Track Name; the +//! AAD binds the Group ID, Object ID, Publisher Priority and Immutable Properties, the +//! Key ID among them. A relay forwards the sealed object as any other: everything it +//! reads stays readable, and nothing it reads lets it open the payload. +//! +//! - [`aead`]: the ciphersuites' AEADs and KDFs (AES-GCM; RFC 9605's AES-CTR with HMAC). +//! - [`sframe`]: RFC 9605 itself, whose Appendix C vectors check those primitives. +//! - This module: the Serialized Full Track Name, the Key ID property, the plaintext and +//! its Encrypted Properties List, key and salt, nonce, AAD, and seal and open of one +//! object. +//! - [`room`]: a room's keys (the epoch's application secret, each namespace's base key, +//! the KID) and a member's receiving checks, in the order of confidentiality §6. +//! +//! Where secure objects and MOQT -22 disagree (the Key ID's code point, the property +//! forms, the counter-mode suites' tag), this module takes WAI confidentiality §11's +//! reading; each is named where it applies. + +pub mod aead; +pub mod room; +pub mod sframe; + +pub use aead::Suite; + +use crate::draft22::io::{put_varint, Reader}; +use crate::draft22::kvp::{prop as wire_prop, KeyValuePair, KvValue, Properties}; +use crate::track::{TrackName, TrackNamespace}; + +/// The Key ID property's type on a -22 object (confidentiality §4.3, §11.1): from the +/// range -22 §8.4 sets aside for applications, since -22 §16.8 gives secure objects' +/// `0x2` to OBJECT_DELIVERY_TIMEOUT. Even, so its value is one varint. +pub const KEY_ID_PROPERTY: u64 = 0x3a4a; +/// The Encrypted Properties List's type at the head of the list in the plaintext +/// (secure objects §3.3.3 step 4, §4.2). +pub const ENCRYPTED_LIST: u16 = 0x000a; +/// The Padding property's type (secure objects §4.3). +pub const PADDING: u16 = 0x0032; +/// `WAI_INNER_DIGEST` (confidentiality §4.4): BLAKE3 of the original payload, sealed with it. +pub const INNER_DIGEST: u16 = 0x3a4b; +/// The largest Object ID a nonce can hold (secure objects §3.6). +pub const MAX_OBJECT_ID: u64 = u32::MAX as u64; + +/// Why a receiver refuses a sealed object, a record or a transcript: the codes of WAI +/// confidentiality §13. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Refusal { + // The confidentiality record (§3.3). + Truncated, + Malformed, + UnknownVersion, + WrongRoom, + SuiteUnsupported, + MlsSuiteUnsupported, + RecordingRefused, + BadKey, + BadSignature, + // The MLS binding (§5). + GroupMismatch, + CredentialMismatch, + ProposalNotClock, + ProposalMismatch, + CommitNotCommitter, + CommitMismatch, + // A sealed object (§6). + PropertiesMalformed, + KeyIdMissing, + ObjectIdTooLarge, + EpochNotJoined, + RemovedBeforeEpoch, + KeyIdUnknown, + SenderNotOwner, + SenderRemoved, + Replayed, + BadTag, + PlaintextMalformed, + InnerDigestMismatch, +} + +impl Refusal { + /// Every code, in the order of confidentiality §13. + pub const ALL: [Refusal; 27] = [ + Refusal::Truncated, + Refusal::Malformed, + Refusal::UnknownVersion, + Refusal::WrongRoom, + Refusal::SuiteUnsupported, + Refusal::MlsSuiteUnsupported, + Refusal::RecordingRefused, + Refusal::BadKey, + Refusal::BadSignature, + Refusal::GroupMismatch, + Refusal::CredentialMismatch, + Refusal::ProposalNotClock, + Refusal::ProposalMismatch, + Refusal::CommitNotCommitter, + Refusal::CommitMismatch, + Refusal::PropertiesMalformed, + Refusal::KeyIdMissing, + Refusal::ObjectIdTooLarge, + Refusal::EpochNotJoined, + Refusal::RemovedBeforeEpoch, + Refusal::KeyIdUnknown, + Refusal::SenderNotOwner, + Refusal::SenderRemoved, + Refusal::Replayed, + Refusal::BadTag, + Refusal::PlaintextMalformed, + Refusal::InnerDigestMismatch, + ]; + + pub fn code(self) -> &'static str { + match self { + Refusal::Truncated => "truncated", + Refusal::Malformed => "malformed", + Refusal::UnknownVersion => "unknown_version", + Refusal::WrongRoom => "wrong_room", + Refusal::SuiteUnsupported => "suite_unsupported", + Refusal::MlsSuiteUnsupported => "mls_suite_unsupported", + Refusal::RecordingRefused => "recording_refused", + Refusal::BadKey => "bad_key", + Refusal::BadSignature => "bad_signature", + Refusal::GroupMismatch => "group_mismatch", + Refusal::CredentialMismatch => "credential_mismatch", + Refusal::ProposalNotClock => "proposal_not_clock", + Refusal::ProposalMismatch => "proposal_mismatch", + Refusal::CommitNotCommitter => "commit_not_committer", + Refusal::CommitMismatch => "commit_mismatch", + Refusal::PropertiesMalformed => "properties_malformed", + Refusal::KeyIdMissing => "key_id_missing", + Refusal::ObjectIdTooLarge => "object_id_too_large", + Refusal::EpochNotJoined => "epoch_not_joined", + Refusal::RemovedBeforeEpoch => "removed_before_epoch", + Refusal::KeyIdUnknown => "key_id_unknown", + Refusal::SenderNotOwner => "sender_not_owner", + Refusal::SenderRemoved => "sender_removed", + Refusal::Replayed => "replayed", + Refusal::BadTag => "bad_tag", + Refusal::PlaintextMalformed => "plaintext_malformed", + Refusal::InnerDigestMismatch => "inner_digest_mismatch", + } + } + + pub fn from_code(code: &str) -> Option { + Refusal::ALL.into_iter().find(|r| r.code() == code) + } +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.code()) + } +} + +impl std::error::Error for Refusal {} + +// ── Names ─────────────────────────────────────────────────────────────────── + +/// A Track Namespace as -22 §8.7 encodes it, every varint shortest. +pub fn serialize_namespace(ns: &TrackNamespace) -> Vec { + let mut out = Vec::new(); + put_varint(&mut out, ns.0.len() as u64); + for f in &ns.0 { + put_varint(&mut out, f.len() as u64); + out.extend_from_slice(f); + } + out +} + +/// The Serialized Full Track Name (secure objects §3.3.1; confidentiality §4.2). +pub fn serialize_full_track_name(ns: &TrackNamespace, name: &TrackName) -> Vec { + let mut out = serialize_namespace(ns); + put_varint(&mut out, name.0.len() as u64); + out.extend_from_slice(&name.0); + out +} + +// ── Keys, nonce, AAD ──────────────────────────────────────────────────────── + +/// `moq_key` and `moq_salt` for one track and Key ID. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct KeySalt { + pub key: Vec, + pub salt: [u8; 12], +} + +/// The labels of secure objects §3.7: `(moq_key_label, moq_salt_label)`. +pub fn labels(suite: Suite, key_id: u64, ftn: &[u8]) -> (Vec, Vec) { + let tail = [ftn, &suite.id().to_be_bytes(), &key_id.to_be_bytes()].concat(); + ([b"MOQ 1.0 Secure Objects Secret key ".as_slice(), &tail].concat(), [b"MOQ 1.0 Secret salt ".as_slice(), &tail].concat()) +} + +/// `derive_key_salt` of secure objects §3.7, with the suite's hash. +pub fn derive_key_salt(suite: Suite, key_id: u64, track_base_key: &[u8], ftn: &[u8]) -> KeySalt { + let secret = suite.hkdf_extract(&[], track_base_key); + let (kl, sl) = labels(suite, key_id, ftn); + KeySalt { key: suite.hkdf_expand(&secret, &kl, suite.nk()), salt: suite.hkdf_expand(&secret, &sl, 12).try_into().expect("12 bytes") } +} + +/// The 96-bit CTR of secure objects §3.6: the Group ID's 8 bytes, the Object ID's 4. +pub fn ctr(group: u64, object: u64) -> Result<[u8; 12], Refusal> { + let object = u32::try_from(object).map_err(|_| Refusal::ObjectIdTooLarge)?; + let mut c = [0u8; 12]; + c[..8].copy_from_slice(&group.to_be_bytes()); + c[8..].copy_from_slice(&object.to_be_bytes()); + Ok(c) +} + +/// The nonce: `moq_salt XOR CTR`. +pub fn nonce(salt: &[u8; 12], ctr: &[u8; 12]) -> [u8; 12] { + std::array::from_fn(|i| salt[i] ^ ctr[i]) +} + +/// `SECURE_OBJECT_AAD` of secure objects §3.5: Group ID (8), Object ID (4), Publisher +/// Priority (1), then the Serialized Immutable Properties as the object carries them. +pub fn aad(group: u64, object: u64, priority: u8, sip: &[u8]) -> Result, Refusal> { + let mut a = ctr(group, object)?.to_vec(); + a.push(priority); + a.extend_from_slice(sip); + Ok(a) +} + +// ── The plaintext ─────────────────────────────────────────────────────────── + +/// What the AEAD seals (confidentiality §4.4): the original payload, and the Encrypted +/// Properties List when it has an entry. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Plaintext { + pub payload: Vec, + /// `WAI_INNER_DIGEST`: BLAKE3 of `payload`. + pub inner_digest: Option<[u8; 32]>, + /// Entries of other types, in order, each `(type, value)`; a reader skips them. + pub other: Vec<(u16, Vec)>, + /// Padding, the last entry: its length in zero bytes. + pub padding: Option, +} + +impl Plaintext { + /// A payload with its inner digest, as a publisher seals a video frame. + pub fn with_digest(payload: Vec) -> Self { + let d = *blake3::hash(&payload).as_bytes(); + Plaintext { payload, inner_digest: Some(d), other: Vec::new(), padding: None } + } + + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(self.payload.len() + 48); + put_varint(&mut out, self.payload.len() as u64); + out.extend_from_slice(&self.payload); + let mut entries = Vec::new(); + if let Some(d) = &self.inner_digest { + entries.extend_from_slice(&INNER_DIGEST.to_be_bytes()); + put_varint(&mut entries, 32); + entries.extend_from_slice(d); + } + for (ty, v) in &self.other { + entries.extend_from_slice(&ty.to_be_bytes()); + put_varint(&mut entries, v.len() as u64); + entries.extend_from_slice(v); + } + if let Some(n) = self.padding { + entries.extend_from_slice(&PADDING.to_be_bytes()); + entries.extend_from_slice(&n.to_be_bytes()); + entries.resize(entries.len() + n as usize, 0); + } + if !entries.is_empty() { + out.extend_from_slice(&ENCRYPTED_LIST.to_be_bytes()); + put_varint(&mut out, entries.len() as u64); + out.extend_from_slice(&entries); + } + out + } + + /// Read a plaintext (confidentiality §4.4); `plaintext_malformed` where it does not read. + pub fn decode(b: &[u8]) -> Result { + let bad = |_| Refusal::PlaintextMalformed; + let mut r = Reader::new(b); + let payload = r.length_prefixed(None, "payload").map_err(bad)?.to_vec(); + let mut p = Plaintext { payload, ..Default::default() }; + if r.is_empty() { + return Ok(p); + } + if r.u16("list type").map_err(bad)? != ENCRYPTED_LIST { + return Err(Refusal::PlaintextMalformed); + } + let n = r.varint("list length").map_err(bad)?; + if n != r.remaining() as u64 { + return Err(Refusal::PlaintextMalformed); + } + while !r.is_empty() { + let ty = r.u16("entry type").map_err(bad)?; + if ty == PADDING { + let len = r.u16("padding length").map_err(bad)?; + r.bytes(len as u64, "padding").map_err(bad)?; + if !r.is_empty() { + return Err(Refusal::PlaintextMalformed); + } + p.padding = Some(len); + break; + } + let v = r.length_prefixed(None, "entry").map_err(bad)?; + if ty == INNER_DIGEST { + if p.inner_digest.is_some() || v.len() != 32 { + return Err(Refusal::PlaintextMalformed); + } + p.inner_digest = Some(v.try_into().expect("32 bytes")); + } else { + p.other.push((ty, v.to_vec())); + } + } + Ok(p) + } + + /// `inner_digest_mismatch` where the plaintext carries an inner digest that is not + /// BLAKE3 of its payload. + pub fn check_digest(&self) -> Result<(), Refusal> { + match &self.inner_digest { + Some(d) if d != blake3::hash(&self.payload).as_bytes() => Err(Refusal::InnerDigestMismatch), + _ => Ok(()), + } + } +} + +// ── The Key ID property ───────────────────────────────────────────────────── + +/// The Serialized Immutable Properties a publisher writes: the Key ID property and +/// `extra` (properties fixed before sealing), as -22 §8.3 encodes a list. +pub fn immutable_properties(key_id: u64, extra: &[KeyValuePair]) -> Vec { + let mut inner = Properties::new(); + for p in extra.iter().filter(|p| p.ty != KEY_ID_PROPERTY) { + inner.push(p.clone()); + } + inner.push(KeyValuePair::varint(KEY_ID_PROPERTY, key_id)); + inner.to_bytes().expect("a list of varints and short byte values encodes") +} + +/// An object's property list whose Immutable Properties are `sip` exactly, beside +/// `mutable` (none of them a Key ID or Immutable Properties). +pub fn object_properties(sip: &[u8], mutable: &[KeyValuePair]) -> Properties { + let mut p = Properties::new(); + for m in mutable.iter().filter(|m| m.ty != KEY_ID_PROPERTY && m.ty != wire_prop::IMMUTABLE_PROPERTIES) { + p.push(m.clone()); + } + p.push(KeyValuePair::bytes(wire_prop::IMMUTABLE_PROPERTIES, sip.to_vec())); + p +} + +/// The Key ID and the Serialized Immutable Properties of an object (confidentiality §6 +/// step 1): `properties_malformed` where the list does not read, a Key ID appears twice, +/// outside Immutable Properties, or is not a varint; `key_id_missing` where none appears. +pub fn key_id_of(props: &Properties) -> Result<(u64, Vec), Refusal> { + let outside = props.0.iter().filter(|p| p.ty == KEY_ID_PROPERTY).count(); + let imm: Vec<&KeyValuePair> = props.0.iter().filter(|p| p.ty == wire_prop::IMMUTABLE_PROPERTIES).collect(); + if outside > 0 || imm.len() > 1 { + return Err(Refusal::PropertiesMalformed); + } + let Some(imm) = imm.first() else { return Err(Refusal::KeyIdMissing) }; + let KvValue::Bytes(sip) = &imm.value else { return Err(Refusal::PropertiesMalformed) }; + let inner = Properties::decode(sip, "immutable properties").map_err(|_| Refusal::PropertiesMalformed)?; + if inner.0.iter().any(|p| p.ty == wire_prop::IMMUTABLE_PROPERTIES) { + return Err(Refusal::PropertiesMalformed); + } + let kids: Vec<&KeyValuePair> = inner.0.iter().filter(|p| p.ty == KEY_ID_PROPERTY).collect(); + match kids.as_slice() { + [] => Err(Refusal::KeyIdMissing), + [k] => Ok((k.as_varint().ok_or(Refusal::PropertiesMalformed)?, sip.clone())), + _ => Err(Refusal::PropertiesMalformed), + } +} + +// ── Seal and open ─────────────────────────────────────────────────────────── + +/// The fields of one object that its seal and open bind. +#[derive(Clone, Copy, Debug)] +pub struct Location<'a> { + /// The Serialized Full Track Name. + pub ftn: &'a [u8], + pub group: u64, + pub object: u64, + pub priority: u8, +} + +/// Seal `pt` (an encoded [`Plaintext`]) as the object at `at` whose Serialized Immutable +/// Properties are `sip` (confidentiality §4.9): the object's payload. +pub fn seal_object(suite: Suite, keys: &KeySalt, at: Location<'_>, sip: &[u8], pt: &[u8]) -> Result, Refusal> { + let aad = aad(at.group, at.object, at.priority, sip)?; + let n = nonce(&keys.salt, &ctr(at.group, at.object)?); + Ok(aead::seal(suite, &keys.key, &n, &aad, pt)) +} + +/// Open the payload of the object at `at` (confidentiality §4.10): the plaintext's bytes, +/// or `bad_tag`. +pub fn open_object(suite: Suite, keys: &KeySalt, at: Location<'_>, sip: &[u8], sealed: &[u8]) -> Result, Refusal> { + let aad = aad(at.group, at.object, at.priority, sip)?; + let n = nonce(&keys.salt, &ctr(at.group, at.object)?); + aead::open(suite, &keys.key, &n, &aad, sealed).ok_or(Refusal::BadTag) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_plaintext_round_trips_and_its_malformations_are_refused() { + let mut p = Plaintext::with_digest(b"frame".to_vec()); + p.other.push((0x0100, b"for-eyes-only".to_vec())); + p.padding = Some(5); + let b = p.encode(); + assert_eq!(Plaintext::decode(&b).unwrap(), p); + p.check_digest().unwrap(); + let bare = Plaintext { payload: b"x".to_vec(), ..Default::default() }; + assert_eq!(bare.encode(), vec![1, b'x'], "no list when there is no entry"); + // A list type other than 0x000A, a list length that is not the rest, padding + // before another entry, two inner digests, a short digest. + let mut wrong_type = bare.encode(); + wrong_type.extend_from_slice(&[0x00, 0x0b, 0x00]); + let mut wrong_len = bare.encode(); + wrong_len.extend_from_slice(&[0x00, 0x0a, 0x05, 0x00, 0x32, 0x00, 0x00]); + let mut pad_first = bare.encode(); + pad_first.extend_from_slice(&[0x00, 0x0a, 0x07, 0x00, 0x32, 0x00, 0x00, 0x01, 0x00, 0x00]); + let two = { + let mut e = Vec::new(); + for _ in 0..2 { + e.extend_from_slice(&INNER_DIGEST.to_be_bytes()); + e.push(32); + e.extend_from_slice(&[0; 32]); + } + let mut b = bare.encode(); + b.extend_from_slice(&ENCRYPTED_LIST.to_be_bytes()); + put_varint(&mut b, e.len() as u64); + b.extend_from_slice(&e); + b + }; + let mut short = bare.encode(); + short.extend_from_slice(&[0x00, 0x0a, 0x04, 0x3a, 0x4b, 0x01, 0x00]); + for b in [wrong_type, wrong_len, pad_first, two, short, vec![5, 1]] { + assert_eq!(Plaintext::decode(&b), Err(Refusal::PlaintextMalformed), "{b:02x?}"); + } + let lie = Plaintext { inner_digest: Some([0; 32]), ..bare }; + assert_eq!(lie.check_digest(), Err(Refusal::InnerDigestMismatch)); + } + + #[test] + fn the_key_id_lives_inside_immutable_properties_once() { + let sip = immutable_properties(0x0100_0002, &[KeyValuePair::varint(0x3a40, 1)]); + let p = object_properties(&sip, &[KeyValuePair::varint(wire_prop::OBJECT_DELIVERY_TIMEOUT, 50)]); + assert_eq!(key_id_of(&p).unwrap(), (0x0100_0002, sip.clone())); + assert_eq!(key_id_of(&Properties::new()), Err(Refusal::KeyIdMissing)); + let mut outside = p.clone(); + outside.push(KeyValuePair::varint(KEY_ID_PROPERTY, 1)); + assert_eq!(key_id_of(&outside), Err(Refusal::PropertiesMalformed)); + let mut twice_inner = Properties::new(); + twice_inner.push(KeyValuePair::varint(KEY_ID_PROPERTY, 1)).push(KeyValuePair::varint(KEY_ID_PROPERTY, 2)); + let twice = object_properties(&twice_inner.to_bytes().unwrap(), &[]); + assert_eq!(key_id_of(&twice), Err(Refusal::PropertiesMalformed)); + let garbage = object_properties(&[0x01], &[]); + assert_eq!(key_id_of(&garbage), Err(Refusal::PropertiesMalformed)); + let empty = object_properties(&[], &[]); + assert_eq!(key_id_of(&empty), Err(Refusal::KeyIdMissing)); + } + + #[test] + fn every_code_is_listed_once() { + let mut codes: Vec<&str> = Refusal::ALL.iter().map(|r| r.code()).collect(); + codes.sort(); + codes.dedup(); + assert_eq!(codes.len(), Refusal::ALL.len()); + for r in Refusal::ALL { + assert_eq!(Refusal::from_code(r.code()), Some(r)); + } + } + + #[test] + fn an_object_id_past_32_bits_has_no_nonce() { + assert_eq!(ctr(1, 1 << 32), Err(Refusal::ObjectIdTooLarge)); + assert_eq!(ctr(u64::MAX, u32::MAX as u64).unwrap(), [0xff; 12]); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/room.rs b/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/room.rs new file mode 100644 index 000000000..5ea1635ea --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/room.rs @@ -0,0 +1,405 @@ +//! A room's keys and a member's checks (WAI confidentiality §5.7, §5.8 and §6). +//! +//! The MLS group is outside this module: it gives each epoch's application secret (the +//! exporter's output, `MLS-Exporter("wai live e2ee", room_id, 32)`) and roster, and +//! `joule-moq-mls` feeds them in as the member enters each epoch. From them this module +//! derives each namespace's base key and each sender's KID, seals, and opens through the +//! receiving checks in the order the extension gives them. + +use super::{aead::Suite, derive_key_salt, key_id_of, open_object, seal_object, serialize_full_track_name, serialize_namespace}; +use super::{immutable_properties, object_properties, KeySalt, Location, Plaintext, Refusal}; +use crate::draft22::kvp::{KeyValuePair, Properties}; +use crate::track::{TrackName, TrackNamespace}; +use hkdf::Hkdf; +use sha2::Sha256; +use std::collections::{BTreeMap, HashSet}; + +/// The MLS exporter label of the room's application secret (confidentiality §5.7). The +/// exporter's context is the room id, and its length 32. +pub const EXPORTER_LABEL: &str = "wai live e2ee"; +/// The prefix of a track base key's HKDF info: the keying draft §7.1's label. +pub const TRACK_LABEL: &[u8] = b"mocha_track"; +/// The largest epoch a KID carries: 40 bits (the keying draft §7.2). +pub const MAX_EPOCH: u64 = (1 << 40) - 1; +/// The largest leaf index a KID carries: 24 bits. +pub const MAX_LEAF: u32 = (1 << 24) - 1; + +/// A principal's Ed25519 key, the identity of its leaf. +pub type Principal = [u8; 32]; +/// An epoch's roster: each occupied leaf index and its member. +pub type Roster = BTreeMap; + +/// `track_base_key[E, ns]`: HKDF-Expand with SHA-256 (the MLS suite's hash) from the +/// epoch's application secret, info `mocha_track` and the serialized namespace, 32 bytes. +pub fn track_base_key(application_secret: &[u8; 32], ns: &TrackNamespace) -> [u8; 32] { + let mut info = TRACK_LABEL.to_vec(); + info.extend_from_slice(&serialize_namespace(ns)); + let mut out = [0u8; 32]; + Hkdf::::from_prk(application_secret).expect("32 bytes is SHA-256's size").expand(&info, &mut out).expect("32 bytes"); + out +} + +/// The KID of a member at leaf index `leaf` in `epoch`: `epoch × 2^24 + leaf`; `None` +/// past 40 bits of epoch or 24 of leaf. +pub fn key_id(epoch: u64, leaf: u32) -> Option { + (epoch <= MAX_EPOCH && leaf <= MAX_LEAF).then_some((epoch << 24) | leaf as u64) +} + +/// The epoch and the leaf index a KID names. +pub fn split_key_id(kid: u64) -> (u64, u32) { + (kid >> 24, (kid & MAX_LEAF as u64) as u32) +} + +/// The owner a room's track names (live-rooms §6.1): the second namespace field, when it +/// is a principal's 64 lowercase hexadecimal digits; `None` for a slot's track. +pub fn track_owner(ns: &TrackNamespace) -> Option { + let f = ns.0.get(1)?; + if f.len() != 64 || !f.iter().all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(c)) { + return None; + } + let mut out = [0u8; 32]; + for (i, o) in out.iter_mut().enumerate() { + let h = |c: u8| if c.is_ascii_digit() { c - b'0' } else { c - b'a' + 10 }; + *o = (h(f[2 * i]) << 4) | h(f[2 * i + 1]); + } + Some(out) +} + +/// Everything a seal derives, stated so a corpus can show each step. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Derivation { + pub track_base_key: [u8; 32], + pub kid: u64, + pub ftn: Vec, + pub keys: KeySalt, +} + +/// The derivation for the member at `leaf` sealing on `ns`/`name` in an epoch whose +/// application secret is `secret`. +pub fn derive(suite: Suite, secret: &[u8; 32], epoch: u64, leaf: u32, ns: &TrackNamespace, name: &TrackName) -> Option { + let kid = key_id(epoch, leaf)?; + let base = track_base_key(secret, ns); + let ftn = serialize_full_track_name(ns, name); + let keys = derive_key_salt(suite, kid, &base, &ftn); + Some(Derivation { track_base_key: base, kid, ftn, keys }) +} + +/// A sealed object as its publisher sends it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SealedObject { + pub kid: u64, + /// The Serialized Immutable Properties: the value of the Immutable Properties pair. + pub sip: Vec, + /// The object's property list, the Immutable Properties pair among it. + pub properties: Properties, + /// The sealed payload: ciphertext and tag. + pub payload: Vec, +} + +/// One object to seal: where it goes, what it says, and the properties it fixes. +#[derive(Clone, Debug)] +pub struct Outgoing<'a> { + pub ns: &'a TrackNamespace, + pub name: &'a TrackName, + pub group: u64, + pub object: u64, + pub priority: u8, + pub plaintext: &'a Plaintext, + /// Properties inside Immutable Properties besides the Key ID. + pub immutable: &'a [KeyValuePair], + /// Properties outside them. + pub mutable: &'a [KeyValuePair], +} + +/// Seal `o` with the derivation `d` (confidentiality §4.9). +pub fn seal_with(suite: Suite, d: &Derivation, o: &Outgoing<'_>) -> Result { + let sip = immutable_properties(d.kid, o.immutable); + let at = Location { ftn: &d.ftn, group: o.group, object: o.object, priority: o.priority }; + let payload = seal_object(suite, &d.keys, at, &sip, &o.plaintext.encode())?; + Ok(SealedObject { kid: d.kid, properties: object_properties(&sip, o.mutable), sip, payload }) +} + +/// One object as a receiver gets it. +#[derive(Clone, Copy, Debug)] +pub struct Incoming<'a> { + pub ns: &'a TrackNamespace, + pub name: &'a TrackName, + pub group: u64, + pub object: u64, + pub priority: u8, + pub properties: &'a Properties, + pub payload: &'a [u8], +} + +/// An object opened. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Opened { + pub epoch: u64, + pub leaf: u32, + pub sender: Principal, + pub plaintext: Plaintext, +} + +/// Why an object did not open. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NotOpened { + /// Refused, with the extension's reason. + Refused(Refusal), + /// Sealed in an epoch the receiver has not reached: held until it applies the commit + /// that opens it (confidentiality §5.8), and not a refusal. + Pending(u64), +} + +impl From for NotOpened { + fn from(r: Refusal) -> Self { + NotOpened::Refused(r) + } +} + +/// What a member holds to seal and open in one room's group. +#[derive(Clone, Debug)] +pub struct Keyring { + suite: Suite, + me: Principal, + /// The first epoch it held. + joined: Option, + /// The epoch its removal opened: it holds nothing from it on. + removed: Option, + secrets: BTreeMap, + rosters: BTreeMap, + opened: HashSet<(Vec, u64, u64)>, +} + +impl Keyring { + pub fn new(suite: Suite, me: Principal) -> Self { + Keyring { suite, me, joined: None, removed: None, secrets: BTreeMap::new(), rosters: BTreeMap::new(), opened: HashSet::new() } + } + + pub fn suite(&self) -> Suite { + self.suite + } + + pub fn me(&self) -> Principal { + self.me + } + + /// Enter `epoch`: the member created the group, joined by a Welcome, or applied the + /// commit that opens it, and holds its application secret and roster. + pub fn enter(&mut self, epoch: u64, application_secret: [u8; 32], roster: Roster) { + self.joined.get_or_insert(epoch); + self.secrets.insert(epoch, application_secret); + self.rosters.insert(epoch, roster); + } + + /// The member was removed by the commit that opens `epoch`. + pub fn removed_at(&mut self, epoch: u64) { + self.removed = Some(epoch); + } + + /// Delete `epoch`'s keys (confidentiality §5.8); its roster is kept, to check senders. + pub fn forget(&mut self, epoch: u64) { + self.secrets.remove(&epoch); + } + + /// The latest epoch the member entered. + pub fn latest(&self) -> Option { + self.rosters.keys().next_back().copied() + } + + pub fn roster(&self, epoch: u64) -> Option<&Roster> { + self.rosters.get(&epoch) + } + + /// The member's own leaf in its latest epoch. + pub fn my_leaf(&self) -> Option { + let r = self.rosters.get(&self.latest()?)?; + r.iter().find(|(_, p)| **p == self.me).map(|(l, _)| *l) + } + + /// Seal `o` under the member's latest epoch and its own leaf. `None` when it holds no + /// epoch, or has been removed. + pub fn seal(&self, o: &Outgoing<'_>) -> Option> { + if self.removed.is_some() { + return None; + } + let epoch = self.latest()?; + let d = derive(self.suite, self.secrets.get(&epoch)?, epoch, self.my_leaf()?, o.ns, o.name)?; + Some(seal_with(self.suite, &d, o)) + } + + /// Open `o` through the checks of confidentiality §6, in order. `owners` names the + /// principals that may publish on the track when its namespace does not (a slot's); + /// otherwise the namespace's own principal is the owner. + pub fn open(&mut self, o: &Incoming<'_>, owners: Option<&[Principal]>) -> Result { + // 1, 2: the properties and the Object ID. + let (kid, sip) = key_id_of(o.properties)?; + if o.object > super::MAX_OBJECT_ID { + return Err(Refusal::ObjectIdTooLarge.into()); + } + // 3, 4: the epoch. + let (epoch, leaf) = split_key_id(kid); + if self.removed.is_some_and(|x| epoch >= x) { + return Err(Refusal::RemovedBeforeEpoch.into()); + } + let Some(latest) = self.latest() else { return Err(Refusal::EpochNotJoined.into()) }; + if self.joined.is_none_or(|j| epoch < j) { + return Err(Refusal::EpochNotJoined.into()); + } + if epoch > latest { + return Err(NotOpened::Pending(epoch)); + } + let (Some(secret), Some(roster)) = (self.secrets.get(&epoch), self.rosters.get(&epoch)) else { + return Err(Refusal::EpochNotJoined.into()); + }; + // 5: the leaf. + let Some(sender) = roster.get(&leaf).copied() else { return Err(Refusal::KeyIdUnknown.into()) }; + // 6: the owner. + let owned = match owners { + Some(list) => list.contains(&sender), + None => track_owner(o.ns) == Some(sender), + }; + if !owned { + return Err(Refusal::SenderNotOwner.into()); + } + // 7: the sender held its leaf in every epoch since. + if self.rosters.range(epoch..=latest).any(|(_, r)| r.get(&leaf) != Some(&sender)) { + return Err(Refusal::SenderRemoved.into()); + } + // 8: a location opened before. + let ftn = serialize_full_track_name(o.ns, o.name); + let place = (ftn.clone(), o.group, o.object); + if self.opened.contains(&place) { + return Err(Refusal::Replayed.into()); + } + // 9 to 11. + let base = track_base_key(secret, o.ns); + let keys = derive_key_salt(self.suite, kid, &base, &ftn); + let pt = open_object(self.suite, &keys, Location { ftn: &ftn, group: o.group, object: o.object, priority: o.priority }, &sip, o.payload)?; + let plaintext = Plaintext::decode(&pt)?; + plaintext.check_digest()?; + self.opened.insert(place); + Ok(Opened { epoch, leaf, sender, plaintext }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(k: &Principal) -> String { + k.iter().map(|b| format!("{b:02x}")).collect() + } + + fn room_ns(room: &[u8; 32], who: &Principal, kind: &str) -> TrackNamespace { + TrackNamespace::from_strs([hex(room), hex(who), kind.to_string()]) + } + + #[test] + fn kids_carry_epoch_and_leaf() { + assert_eq!(key_id(5, 3), Some((5 << 24) | 3)); + assert_eq!(split_key_id(key_id(MAX_EPOCH, MAX_LEAF).unwrap()), (MAX_EPOCH, MAX_LEAF)); + assert_eq!(key_id(MAX_EPOCH + 1, 0), None); + assert_eq!(key_id(0, MAX_LEAF + 1), None); + } + + #[test] + fn three_members_each_open_the_others() { + let suite = Suite::AesGcm128Sha256; + let room = [9u8; 32]; + let who: [Principal; 3] = [[1; 32], [2; 32], [3; 32]]; + let roster: Roster = who.iter().enumerate().map(|(i, p)| (i as u32, *p)).collect(); + let secret = [0x5a; 32]; + let mut rings: Vec = who + .iter() + .map(|p| { + let mut k = Keyring::new(suite, *p); + k.enter(4, secret, roster.clone()); + k + }) + .collect(); + let name = TrackName::from_str("0"); + for (i, p) in who.iter().enumerate() { + let ns = room_ns(&room, p, "video"); + let pt = Plaintext::with_digest(format!("frame of {i}").into_bytes()); + let o = Outgoing { ns: &ns, name: &name, group: 7, object: 0, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] }; + let s = rings[i].seal(&o).unwrap().unwrap(); + assert_eq!(split_key_id(s.kid), (4, i as u32)); + for (j, ring) in rings.iter_mut().enumerate() { + let inc = Incoming { ns: &ns, name: &name, group: 7, object: 0, priority: 64, properties: &s.properties, payload: &s.payload }; + let got = ring.open(&inc, None); + if j == i { + // A member opens its own objects too; a sender is a receiver like any other. + assert!(got.is_ok()); + continue; + } + assert_eq!(got.unwrap().plaintext, pt); + // The same location again is a replay. + assert_eq!(ring.open(&inc, None), Err(NotOpened::Refused(Refusal::Replayed))); + } + // Another priority, another track, or a changed byte: the tag fails. + let other = TrackName::from_str("1"); + let mut wrong = Keyring::new(suite, who[(i + 1) % 3]); + wrong.enter(4, secret, roster.clone()); + for (n, prio, payload) in [(&other, 64u8, s.payload.clone()), (&name, 65, s.payload.clone()), (&name, 64, { let mut b = s.payload.clone(); b[0] ^= 1; b })] { + let inc = Incoming { ns: &ns, name: n, group: 7, object: 0, priority: prio, properties: &s.properties, payload: &payload }; + assert_eq!(wrong.open(&inc, None), Err(NotOpened::Refused(Refusal::BadTag))); + } + // A member's KID on another member's track: not its owner. + let theirs = room_ns(&room, &who[(i + 1) % 3], "video"); + let o2 = Outgoing { ns: &theirs, name: &name, group: 9, object: 0, priority: 64, plaintext: &pt, immutable: &[], mutable: &[] }; + let forged = rings[i].seal(&o2).unwrap().unwrap(); + let inc = Incoming { ns: &theirs, name: &name, group: 9, object: 0, priority: 64, properties: &forged.properties, payload: &forged.payload }; + assert_eq!(wrong.open(&inc, None), Err(NotOpened::Refused(Refusal::SenderNotOwner))); + } + } + + #[test] + fn removal_late_join_and_retired_senders() { + let suite = Suite::AesGcm256Sha512; + let room = [7u8; 32]; + let (a, b, c, d) = ([1u8; 32], [2u8; 32], [3u8; 32], [4u8; 32]); + let r1: Roster = [(0, a), (1, b), (2, c)].into(); + let r2: Roster = [(0, a), (1, b)].into(); + let r3: Roster = [(0, a), (1, b), (2, d)].into(); + let mut ka = Keyring::new(suite, a); + ka.enter(1, [1; 32], r1.clone()); + let mut kc = Keyring::new(suite, c); + kc.enter(1, [1; 32], r1.clone()); + // C's object of epoch 1, sealed before its removal. + let name = TrackName::from_str("0"); + let cns = room_ns(&room, &c, "audio"); + let pt = Plaintext { payload: b"late".to_vec(), ..Default::default() }; + let early = kc.seal(&Outgoing { ns: &cns, name: &name, group: 1, object: 0, priority: 1, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap().unwrap(); + // Epoch 2 removes C; epoch 3 adds D at C's old leaf. + ka.enter(2, [2; 32], r2.clone()); + kc.removed_at(2); + ka.enter(3, [3; 32], r3.clone()); + let mut kd = Keyring::new(suite, d); + kd.enter(3, [3; 32], r3.clone()); + let ans = room_ns(&room, &a, "audio"); + let a2 = ka.seal(&Outgoing { ns: &ans, name: &name, group: 2, object: 0, priority: 1, plaintext: &pt, immutable: &[], mutable: &[] }).unwrap().unwrap(); + fn at<'a>(s: &'a SealedObject, ns: &'a TrackNamespace, name: &'a TrackName, g: u64) -> Incoming<'a> { + Incoming { ns, name, group: g, object: 0, priority: 1, properties: &s.properties, payload: &s.payload } + } + // ka sealed under epoch 3 (its latest); a removed member cannot open it. + assert_eq!(kc.open(&at(&a2, &ans, &name, 2), None), Err(NotOpened::Refused(Refusal::RemovedBeforeEpoch))); + // D opens epoch 3, and nothing before its join. + assert!(kd.open(&at(&a2, &ans, &name, 2), None).is_ok()); + assert_eq!(kd.open(&at(&early, &cns, &name, 1), None), Err(NotOpened::Refused(Refusal::EpochNotJoined))); + // A, which applied C's removal, takes nothing more under C's old KID. + assert_eq!(ka.open(&at(&early, &cns, &name, 1), None), Err(NotOpened::Refused(Refusal::SenderRemoved))); + // An unoccupied leaf. + let mut ghost = early.clone(); + let sip = immutable_properties(key_id(2, 2).unwrap(), &[]); + ghost.properties = object_properties(&sip, &[]); + assert_eq!(ka.open(&at(&ghost, &cns, &name, 1), None), Err(NotOpened::Refused(Refusal::KeyIdUnknown))); + // An epoch ahead is held, not refused. + let ahead = object_properties(&immutable_properties(key_id(9, 0).unwrap(), &[]), &[]); + let i = Incoming { ns: &ans, name: &name, group: 5, object: 0, priority: 1, properties: &ahead, payload: &a2.payload }; + assert_eq!(ka.open(&i, None), Err(NotOpened::Pending(9))); + // Deleted keys: the epoch is no longer joined. + let mut kd2 = kd.clone(); + kd2.forget(3); + assert_eq!(kd2.open(&at(&a2, &ans, &name, 2), None), Err(NotOpened::Refused(Refusal::EpochNotJoined))); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/sframe.rs b/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/sframe.rs new file mode 100644 index 000000000..ef8049a7f --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/secure_objects/sframe.rs @@ -0,0 +1,125 @@ +//! SFrame (RFC 9605): the header, the key schedule, and seal and open of an SFrame +//! ciphertext. Secure objects is SFrame "virtualised" onto an MoQ object (secure objects +//! §6.2): the same AEADs and the same shape of key schedule, with the KID and CTR taken +//! from the object rather than carried in a header. This module is RFC 9605 as written, +//! so its Appendix C vectors check the AEADs and the HKDF this crate's sealing shares. + +use super::aead::{self, Suite}; + +/// The compact big-endian form of `v`: its bytes with no leading zero, at least one. +fn compact(v: u64) -> Vec { + let b = v.to_be_bytes(); + let skip = b.iter().take(7).take_while(|x| **x == 0).count(); + b[skip..].to_vec() +} + +/// The SFrame header of RFC 9605 §4.3 for `kid` and `ctr`. +pub fn encode_header(kid: u64, ctr: u64) -> Vec { + let mut config = 0u8; + let mut tail = Vec::new(); + if kid < 8 { + config |= (kid as u8) << 4; + } else { + let k = compact(kid); + config |= 0x80 | (((k.len() - 1) as u8) << 4); + tail.extend_from_slice(&k); + } + if ctr < 8 { + config |= ctr as u8; + } else { + let c = compact(ctr); + config |= 0x08 | ((c.len() - 1) as u8); + tail.extend_from_slice(&c); + } + let mut out = vec![config]; + out.extend_from_slice(&tail); + out +} + +/// Read an SFrame header from the front of `b`: `(kid, ctr, header length)`. `None` where +/// `b` ends inside it, or where an extended value is not in its shortest form (RFC 9605 +/// §4.3: "the minimum number of bytes required"). +pub fn decode_header(b: &[u8]) -> Option<(u64, u64, usize)> { + let config = *b.first()?; + let mut at = 1usize; + let mut field = |extended: bool, bits: u8| -> Option { + if !extended { + return Some(bits as u64); + } + let n = bits as usize + 1; + let v = b.get(at..at + n)?; + at += n; + if n > 1 && v[0] == 0 { + return None; + } + let x = v.iter().fold(0u64, |a, x| (a << 8) | *x as u64); + if x < 8 { + return None; + } + Some(x) + }; + let kid = field(config & 0x80 != 0, (config >> 4) & 0x07)?; + let ctr = field(config & 0x08 != 0, config & 0x07)?; + Some((kid, ctr, at)) +} + +/// `sframe_key` and `sframe_salt` of RFC 9605 §4.4.2. +pub fn derive_key_salt(suite: Suite, kid: u64, base_key: &[u8]) -> (Vec, [u8; 12]) { + let secret = suite.hkdf_extract(&[], base_key); + let mut key_label = b"SFrame 1.0 Secret key ".to_vec(); + key_label.extend_from_slice(&kid.to_be_bytes()); + key_label.extend_from_slice(&suite.id().to_be_bytes()); + let mut salt_label = b"SFrame 1.0 Secret salt ".to_vec(); + salt_label.extend_from_slice(&kid.to_be_bytes()); + salt_label.extend_from_slice(&suite.id().to_be_bytes()); + let key = suite.hkdf_expand(&secret, &key_label, suite.nk()); + let salt: [u8; 12] = suite.hkdf_expand(&secret, &salt_label, 12).try_into().expect("12 bytes"); + (key, salt) +} + +fn nonce(salt: &[u8; 12], ctr: u64) -> [u8; 12] { + let mut n = *salt; + for (i, b) in ctr.to_be_bytes().iter().enumerate() { + n[4 + i] ^= b; + } + n +} + +/// RFC 9605 §4.4.3 `encrypt`: the header followed by the AEAD output. +pub fn seal(suite: Suite, kid: u64, ctr: u64, base_key: &[u8], metadata: &[u8], pt: &[u8]) -> Vec { + let (key, salt) = derive_key_salt(suite, kid, base_key); + let header = encode_header(kid, ctr); + let mut aad = header.clone(); + aad.extend_from_slice(metadata); + let mut out = header; + out.extend_from_slice(&aead::seal(suite, &key, &nonce(&salt, ctr), &aad, pt)); + out +} + +/// RFC 9605 §4.4.4 `decrypt`, with the base key of the header's KID given by `key_of`: +/// the plaintext, or `None`. +pub fn open(suite: Suite, metadata: &[u8], ct: &[u8], key_of: impl Fn(u64) -> Option>) -> Option> { + let (kid, ctr, n) = decode_header(ct)?; + let base = key_of(kid)?; + let (key, salt) = derive_key_salt(suite, kid, &base); + let mut aad = ct[..n].to_vec(); + aad.extend_from_slice(metadata); + aead::open(suite, &key, &nonce(&salt, ctr), &aad, &ct[n..]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn headers_round_trip_in_their_shortest_form() { + for (kid, ctr) in [(0u64, 0u64), (7, 7), (8, 0), (0, 8), (0x123, 0x4567), (u64::MAX, u64::MAX), (255, 256)] { + let h = encode_header(kid, ctr); + assert_eq!(decode_header(&h), Some((kid, ctr, h.len()))); + } + // An extended value below 8, or with a leading zero byte, is not shortest. + assert_eq!(decode_header(&[0x08, 0x05]), None); + assert_eq!(decode_header(&[0x09, 0x00, 0x10]), None); + assert_eq!(decode_header(&[0x80 | 0x10]), None, "a KID of two bytes with none behind the config byte"); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/tests/secure_objects_vectors.rs b/joule-code/joule-code-rs/crates/joule-moq/tests/secure_objects_vectors.rs new file mode 100644 index 000000000..e8b3695e2 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/tests/secure_objects_vectors.rs @@ -0,0 +1,147 @@ +//! The published test vectors the sealing builds on, read from WAI's corpus +//! (`wai/live-conformance/e2ee/`): RFC 9605 Appendix C, every header, AEAD and SFrame +//! vector; and `draft-ietf-moq-secure-objects-01` Appendix B, every Full Track Name, key +//! derivation, nonce and AAD vector, and its encryption vectors, exact in the GCM suites +//! and, in the counter-mode suites, the difference WAI confidentiality §11.2 states. +#![cfg(feature = "secure-objects")] + +use joule_moq::secure_objects::{aad, aead, ctr, derive_key_salt, labels, nonce, serialize_full_track_name, sframe, Suite}; +use joule_moq::{TrackName, TrackNamespace}; +use serde_json::Value; + +const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/live-conformance/e2ee/"); + +fn load(name: &str) -> Value { + serde_json::from_str(&std::fs::read_to_string(format!("{DIR}{name}")).expect("the corpus file")).expect("JSON") +} + +fn h(v: &Value) -> Vec { + let s = v.as_str().expect("a hex string"); + (0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex")).collect() +} + +fn n(v: &Value) -> u64 { + v.as_u64().expect("a number") +} + +fn suite(v: &Value) -> Suite { + Suite::from_id(n(v) as u16).expect("a registered suite") +} + +#[test] +fn rfc9605_appendix_c() { + let c = load("rfc9605.json"); + let headers = c["header"].as_array().unwrap(); + assert!(headers.len() > 100); + for v in headers { + let (kid, ctr, hdr) = (n(&v["kid"]), n(&v["ctr"]), h(&v["header"])); + assert_eq!(sframe::encode_header(kid, ctr), hdr, "{v}"); + assert_eq!(sframe::decode_header(&hdr), Some((kid, ctr, hdr.len())), "{v}"); + } + let aeads = c["aead"].as_array().unwrap(); + assert_eq!(aeads.len(), 3); + for v in aeads { + let s = suite(&v["cipher_suite"]); + let key = h(&v["key"]); + assert_eq!(&key[..16], h(&v["enc_key"]).as_slice()); + assert_eq!(&key[16..], h(&v["auth_key"]).as_slice()); + let nonce: [u8; 12] = h(&v["nonce"]).try_into().unwrap(); + let ct = aead::seal(s, &key, &nonce, &h(&v["aad"]), &h(&v["pt"])); + assert_eq!(ct, h(&v["ct"]), "{v}"); + assert_eq!(aead::open(s, &key, &nonce, &h(&v["aad"]), &ct), Some(h(&v["pt"]))); + let mut bad = ct.clone(); + *bad.last_mut().unwrap() ^= 1; + assert_eq!(aead::open(s, &key, &nonce, &h(&v["aad"]), &bad), None); + } + let frames = c["sframe"].as_array().unwrap(); + assert_eq!(frames.len(), 5, "one SFrame vector per suite"); + for v in frames { + let s = suite(&v["cipher_suite"]); + let (kid, ctr) = (n(&v["kid"]), n(&v["ctr"])); + let base = h(&v["base_key"]); + assert_eq!(s.hkdf_extract(&[], &base), h(&v["sframe_secret"])); + let (key, salt) = sframe::derive_key_salt(s, kid, &base); + assert_eq!((key, salt.to_vec()), (h(&v["sframe_key"]), h(&v["sframe_salt"])), "{v}"); + let ct = sframe::seal(s, kid, ctr, &base, &h(&v["metadata"]), &h(&v["pt"])); + assert_eq!(ct, h(&v["ct"]), "{v}"); + let opened = sframe::open(s, &h(&v["metadata"]), &ct, |k| (k == kid).then(|| base.clone())); + assert_eq!(opened, Some(h(&v["pt"]))); + assert_eq!(sframe::open(s, b"other metadata", &ct, |_| Some(base.clone())), None); + } +} + +fn names(v: &Value) -> (TrackNamespace, TrackName) { + let ns = TrackNamespace::from_strs(v["namespace_tuples"].as_array().unwrap().iter().map(|t| t.as_str().unwrap())); + (ns, TrackName::from_str(v["track_name"].as_str().unwrap())) +} + +#[test] +fn secure_objects_01_appendix_b() { + let c = load("secure-objects-01.json"); + for v in c["full_track_name"].as_array().unwrap() { + let (ns, name) = names(v); + assert_eq!(serialize_full_track_name(&ns, &name), h(&v["serialized_full_track_name"])); + } + for v in c["key_derivation"].as_array().unwrap() { + let s = suite(&v["cipher_suite"]); + let (kid, base, ftn) = (n(&v["key_id"]), h(&v["track_base_key"]), h(&v["serialized_full_track_name"])); + assert_eq!(s.hkdf_extract(&[], &base), h(&v["moq_secret"])); + let (kl, sl) = labels(s, kid, &ftn); + assert_eq!((kl, sl), (h(&v["moq_key_label"]), h(&v["moq_salt_label"]))); + let ks = derive_key_salt(s, kid, &base, &ftn); + assert_eq!((ks.key, ks.salt.to_vec()), (h(&v["moq_key"]), h(&v["moq_salt"])), "{v}"); + } + // B.3's AAD holds the Key ID property, which the vector's + // `serialized_immutable_properties` leaves out, in Appendix B's form: a 16-bit type 0x0002, + // a length and the value (confidentiality §11.3). + for v in c["nonce_aad"].as_array().unwrap() { + let (g, o, p, kid) = (n(&v["group_id"]), n(&v["object_id"]), n(&v["publisher_priority"]) as u8, n(&v["key_id"])); + let c = ctr(g, o).unwrap(); + assert_eq!(c.to_vec(), h(&v["ctr"])); + let salt: [u8; 12] = h(&v["moq_salt"]).try_into().unwrap(); + assert_eq!(nonce(&salt, &c).to_vec(), h(&v["nonce"])); + let mut sip = vec![0x00, 0x02, 0x01, kid as u8]; + sip.extend_from_slice(&h(&v["serialized_immutable_properties"])); + assert_eq!(aad(g, o, p, &sip).unwrap(), h(&v["aad"])); + } + let mut gcm = 0; + for v in c["encryption"].as_array().unwrap() { + let s = suite(&v["cipher_suite"]); + let (ns, name) = names(v); + let ftn = serialize_full_track_name(&ns, &name); + assert_eq!(ftn, h(&v["serialized_full_track_name"])); + let ks = derive_key_salt(s, n(&v["key_id"]), &h(&v["track_base_key"]), &ftn); + assert_eq!((ks.key.clone(), ks.salt.to_vec()), (h(&v["moq_key"]), h(&v["moq_salt"]))); + let (g, o, p) = (n(&v["group_id"]), n(&v["object_id"]), n(&v["publisher_priority"]) as u8); + let nonce_ = nonce(&ks.salt, &ctr(g, o).unwrap()); + assert_eq!(nonce_.to_vec(), h(&v["nonce"])); + let payload = h(&v["original_payload"]); + let mut pt = vec![payload.len() as u8]; + pt.extend_from_slice(&payload); + pt.extend_from_slice(&h(&v["encrypted_properties_list"])); + assert_eq!(pt, h(&v["plaintext"])); + let aad_ = h(&v["aad"]); + assert_eq!(aad_[..13], aad(g, o, p, &[]).unwrap()[..], "the AAD's head: Group ID, Object ID, priority"); + let expected = h(&v["ciphertext"]); + let sealed = aead::seal(s, &ks.key, &nonce_, &aad_, &pt); + match s { + Suite::AesGcm128Sha256 | Suite::AesGcm256Sha512 => { + assert_eq!(sealed, expected, "{v}"); + assert_eq!(aead::open(s, &ks.key, &nonce_, &aad_, &expected), Some(pt)); + gcm += 1; + } + _ => { + // Counter mode: the encryption is RFC 9605's, the tag is not (§11.2). + let nt = s.nt(); + let body = expected.len() - nt; + assert_eq!(&sealed[..body], &expected[..body], "the counter-mode encryption is RFC 9605's"); + assert_ne!(&sealed[body..], &expected[body..], "RFC 9605 §4.5.1's tag differs from the vector's"); + let (enc_ct, auth) = (&expected[..body], &ks.key[16..]); + let alt = aead::hmac_sha256(auth, &[&aad_, &nonce_, enc_ct]); + assert_eq!(&alt[..nt], &expected[body..], "the vector's tag is HMAC(auth_key, aad ‖ nonce ‖ ct)"); + assert_eq!(aead::ctr_tag(s, auth, &nonce_, &aad_, enc_ct), sealed[body..].to_vec()); + } + } + } + assert_eq!(gcm, 2); +} diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index fc8480310..8991995ee 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -41,8 +41,8 @@ Every registered string has one kind. `wai.asset.provenance`, the quantum-operations receipts, the delivery records `wai.video.manifest` and its siblings; the live records `wai.live.events`, `wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`, - `wai.live.metering`, `wai.live.steering` and `wai.live.moderation`; timed text, - `wai.text.timed`). + `wai.live.metering`, `wai.live.steering`, `wai.live.moderation` and `wai.live.e2ee`; + timed text, `wai.text.timed`). It names no payload format. A derivation or record string is not a payload capability, so it is not @@ -197,6 +197,7 @@ and a role are fixed once registered. | `wai.image.jxl` | payload | registered | image | standalone | royalty-free | standard-tolerance | recommended | §5 Recommended modern set | | `wai.image.png` | payload | registered | image | standalone | royalty-free | standard-defined | floor | §5 Mandatory floor | | `wai.live.compose` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content | +| `wai.live.e2ee` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content | | `wai.live.events` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content | | `wai.live.metering` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content | | `wai.live.moderation` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content | @@ -298,10 +299,12 @@ and a role are fixed once registered. | `/.well-known/wai-keys` | well-known | defined | jwp-receipts §7.1 | | `GRANT_CHALLENGE` | setup-option | proposed | live-rooms §10.5.2 | | `GRANT_PROOF` | setup-option | proposed | live-rooms §10.5.2 | +| `SECURE_OBJECT_KEY_ID` | catalog | defined | confidentiality §4.3 | | `WAI-Room-Challenge` | http-header | proposed | live-rooms §10.5.3 | | `WAI-Room-Proof` | http-header | proposed | live-rooms §10.5.3 | | `WAI1` | magic | defined | SPEC §2 | | `WAI2` | magic | defined | SPEC §7.1 | +| `WAI_INNER_DIGEST` | catalog | defined | confidentiality §4.4 | | `WAI_RECEIPT` | catalog | defined | moq-streaming-format §5 | | `WCD1` | magic | defined | live-compose §3.1 | | `WCL1` | magic | defined | live-recording §5.1 | @@ -317,6 +320,7 @@ and a role are fixed once registered. | `WMR1` | magic | defined | live-metering §4.1 | | `WMS1` | magic | defined | live-moderation §4.2 | | `WRD1` | magic | defined | live-rooms §3.1 | +| `WRE1` | magic | defined | confidentiality §3.1 | | `WRG1` | magic | defined | live-rooms §4.1 | | `WRM1` | magic | defined | live-recording §4.1 | | `WRP1` | magic | defined | live-rooms §10.5.1 | diff --git a/wai/SECURITY.md b/wai/SECURITY.md index 6b77c8767..51d6c03a5 100644 --- a/wai/SECURITY.md +++ b/wai/SECURITY.md @@ -259,7 +259,7 @@ or sends the descriptor. ## Protected content -End-to-end encryption at the application layer (`mls-sframe`, SPEC §6) keeps content +End-to-end encryption at the application layer (a room's sealed objects, SPEC §6 and [confidentiality](extensions/confidentiality.md)) keeps content from relays and from anyone outside the group, not from a member's device: the decrypted payload and the decoded output are in the application's memory. It is not a protected-path robustness level ([SPEC](SPEC.md) §6.5). diff --git a/wai/SPEC.md b/wai/SPEC.md index 8fd799a2f..162bd070b 100644 --- a/wai/SPEC.md +++ b/wai/SPEC.md @@ -593,8 +593,8 @@ kind: delivery records `wai.video.manifest`, `wai.video.ssai`, `wai.video.keys`, `wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`, `wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording`, - `wai.live.metering`, `wai.live.steering` and `wai.live.moderation`, and timed text, - `wai.text.timed`), which names no payload format. + `wai.live.metering`, `wai.live.steering`, `wai.live.moderation` and `wai.live.e2ee`, and + timed text, `wai.text.timed`), which names no payload format. Only payload, companion and refinement capabilities have a media class, a fidelity tier, a replicate class and a role. A derivation or record string is not a payload @@ -768,8 +768,9 @@ receipt families that sealed a figure before the class existed carry it as an optional signed label that leaves every issued receipt's bytes unchanged. A `joules_micro` of `0` is the unmetered marker, never a figure of zero joules. See [`extensions/confidentiality.md`](extensions/confidentiality.md) for the -private-channel sub-spec: MLS (RFC 9420) group keys + SFrame (RFC 9605) -object encryption, the byte-level framing behind §6. +end-to-end encrypted rooms behind §6: objects sealed as MoQ secure objects +(`draft-ietf-moq-secure-objects-01`, SFrame RFC 9605 on an MoQ object) under keys from +one MLS group (RFC 9420) per room. See [`extensions/reconstruction-binding.md`](extensions/reconstruction-binding.md) for the reproducible-reconstruction binding: a C2PA / JPEG-Trust hard binding over the *deterministically decoded pixels* of a neural-coded asset (SHA-256 over @@ -999,6 +1000,24 @@ format of the database the European Commission keeps under Regulation (EU) 2022/ (Article 24(5)), with no personal data: [`extensions/live-moderation.md`](extensions/live-moderation.md). +And for the **confidentiality** of a room — `wai.live.e2ee`: the room authority signs a +confidentiality record, `WRE1`, stating that a room is end-to-end encrypted, with which +secure-objects and MLS ciphersuites, and that it is never recorded. Every object of the +room's media tracks is sealed by its publisher as an MoQ secure object +(`draft-ietf-moq-secure-objects-01`), its Key ID among the object's Immutable Properties, +so relays route, cache, meter and receipt ciphertext alone; the keys come from one MLS +group (RFC 9420) per room, whose credentials are the principals' Ed25519 room keys, whose +one external sender is the room's clock, proposing an Add for each join under a grant that +may receive or publish and a Remove for each leave, revocation or expiry, and whose +committer every member computes alike, the member at the lowest leaf the commit does not +remove. Each epoch's application secret is an MLS export, from which each publisher's track +base key and KID (the epoch and its leaf index) follow, so a receiver selects the one key +that opens an object; a receiver refuses, each with a named reason, an object of an epoch it +never joined, one after its own removal, a Key ID naming no member, a sender that is not the +track's owner or was removed since, a replay, a bad tag and a plaintext that does not read +or does not match its sealed inner digest. Receipts bind the sealed bytes: +[`extensions/confidentiality.md`](extensions/confidentiality.md). + ### Payloads of the WAI-native integer codecs This revision pins a payload format to each WAI-native integer codec whose row named @@ -1415,33 +1434,36 @@ two lanes protected content travels in, and what a WAI record covers in each. A manifest MAY carry a `security` object: ```json -"security": { "encryption": "mls-sframe", "kid": 0 } +"security": { "encryption": "", "kid": 0 } ``` - `security.encryption` — encryption scheme id. Absent, `null`, or `"none"` ⇒ the payload is plaintext (the default; §1–§5 unchanged). - `"mls-sframe"` ⇒ the payload is an SFrame (RFC 9605) ciphertext object - whose group key is agreed by MLS (RFC 9420), following the construction - of `draft-jennings-moq-e2ee-mls`, an individual Internet-Draft that has - since expired ([confidentiality](extensions/confidentiality.md) §1). -- `security.kid` — the SFrame key id selecting which sender/epoch key - opens the object. It is also carried in the SFrame header; the - manifest copy lets a multiplexer route without parsing the payload. + `"mls-sframe"` named the construction of `draft-jennings-moq-e2ee-mls`, an + individual Internet-Draft that has expired, under which an envelope's payload alone + was sealed. That construction is withdrawn: no revision defines it, and a sink + refuses an envelope that names it as an unsupported scheme (§6.4 step 2). +- `security.kid` — the key id of the scheme named. -`media`, `intent`, `conditioning.kind`, and -`model_requirement.capability` continue to describe the **plaintext** -codec. They are NOT encrypted and remain visible for routing — -confidentiality protects the *content*, not the *shape*. +In a room, confidentiality is at the MoQ object, not in the manifest +([confidentiality](extensions/confidentiality.md) §4): the whole object payload, a WAI +envelope or a media frame, is sealed, so an envelope travels inside the ciphertext and +carries no `security` member, and once opened it is plaintext. What a relay routes by +stays in the clear: the track's name, the object's Group ID, Object ID, priority and +Immutable Properties (the Key ID among them), and each publisher's catalog track, whose +codec, dimensions and rates describe the **plaintext** — confidentiality protects the +*content*, not the *shape*. This revision registers no scheme that seals an envelope's +payload on its own. ### 6.2 Keys are a requirement of existence Exactly as the model behind a neural capability is never transported -(§1), the **group key is never transported in the WAI file**. Group -membership is established out of band via MLS; each member derives the -32-byte SFrame base key from the group's exporter secret under the label -`"moq-jwp sframe base key"`. A sink that is not a group member cannot -derive the key and the file is **inert at that sink** — the -confidentiality analog of a missing capability. +(§1), the **group key is never transported**. A room's MLS group is formed and +changed by the room's lifecycle; each member exports each epoch's application secret +(`MLS-Exporter("wai live e2ee", room_id, 32)`) and derives every sender's keys from it +([confidentiality](extensions/confidentiality.md) §5.7). A sink that is not a member of +the epoch holds no key, and the object is **inert at that sink** — the confidentiality +analog of a missing capability. This is the content key. Checking a receipt needs no content key, only the signer's verifying key, which the receipt itself carries @@ -1465,8 +1487,8 @@ are the publisher's statement, unsigned beside the signed bytes; such an object's figure is signed only where an `energy` claim restates it (jwp-receipts §2.4.5). A legacy group payload signs the total, but no class, coverage or parent link. -Content secrecy (SFrame/MLS) and delivery attestation (JWP) are independent -layers over the same bytes and compose: a relay forwards the exact sealed +Content secrecy (secure objects keyed by MLS) and delivery attestation (JWP) are +independent layers over the same bytes and compose: a relay forwards the exact sealed bytes and their receipts unaltered, signs nothing in the publisher's name (jwp-receipts §4.3), and never learns what the bytes say. Checking the attestation needs the publisher's verifying key, which the receipt carries, @@ -1483,23 +1505,22 @@ A confidentiality-aware sink MUST, before running §4: clear "no key / unsupported encryption" error. It MUST NOT feed ciphertext to a codec. (The reference dispatchers in `wai-rs` implement no scheme and report `unsupported_encryption`, for a `WAI2` envelope - against every entry.) -3. Else recover the plaintext payload via the named scheme (for - `mls-sframe`: SFrame `open` under the member's base key), then run §4 + against every entry; this revision defines no scheme of the manifest, §6.1.) +3. Else recover the plaintext payload via the named scheme, then run §4 on the recovered plaintext. A sink that does not implement confidentiality at all MUST refuse an envelope whose manifest carries a non-`none` `security.encryption` cleanly, rather than mis-decode ciphertext. -Byte-level SFrame framing, the SFrame key schedule, and the MLS binding -(including per-sender keying for multi-sender channels) are specified in -[`extensions/confidentiality.md`](extensions/confidentiality.md). Its -implementation is a companion implementation, outside this tree and under -its own licence: the `moq-sframe` (SFrame) and `joule-comm-mls` (MLS -exporter → base key) crates, with browser bindings in -`joule-comm-mls-wasm`; `Track::e2e_encrypted` is the switch that selects -this path. Live: https://wai.transaction.science/private-channel +A receiver of a room's sealed objects opens each object first, through the checks of +[confidentiality](extensions/confidentiality.md) §6, and only then reads the envelope +inside and runs §4 on it; an object that does not open is never fed to a decoder. The +sealing, the key schedule, the MLS binding (per-sender keys in rooms where several +principals publish, membership following the room's grants and lifecycle) and the +receiving checks are specified there. The reference implementation is open and in this +tree: the `secure_objects` module of `joule-moq` (feature `secure-objects`) and the +`joule-moq-mls` crate. ### 6.5 Protected content: two lanes @@ -1532,9 +1553,9 @@ and decode happen inside that path. cannot read its output, so such synthesis has no tier and no output digest, and a record about it states at most which capability the sink declared it ran. -**Lane B: end-to-end encryption at the application layer.** The payload is sealed per -object under a group key, `mls-sframe` (§6.1–§6.4, -[confidentiality](extensions/confidentiality.md) §3–§5). A member's application opens +**Lane B: end-to-end encryption at the application layer.** Each object of a room's media +tracks is sealed under its sender's key from the room's MLS group (§6.1–§6.4, +[confidentiality](extensions/confidentiality.md) §4–§6). A member's application opens it and runs §4 on the plaintext, so any decoder a sink has can decode it: native code, WebAssembly, or a neural decoder. diff --git a/wai/extensions/confidentiality.md b/wai/extensions/confidentiality.md index 8ecdba045..4df5c872d 100644 --- a/wai/extensions/confidentiality.md +++ b/wai/extensions/confidentiality.md @@ -1,257 +1,784 @@ -# WAI Extension: Confidentiality (MLS + SFrame) +# WAI Extension: Confidentiality (end-to-end encrypted rooms) -> Status: Draft. Sub-spec of WAI v1.0. Its implementation is a companion -> implementation, outside this tree and under its own licence: the -> `moq-sframe` (SFrame object encryption) and `joule-comm-mls` (MLS group -> key agreement) crates, with browser bindings in `joule-comm-mls-wasm`. -> Live demonstration: https://wai.transaction.science/private-channel +> Status: Draft. Sub-spec of WAI v1.0, and Lane B of SPEC §6.5: end-to-end encryption at the +> application layer. Registers the record `wai.live.e2ee`: the room authority's signed +> statement, `WRE1`, that a room of [live-rooms](live-rooms.md) is end-to-end encrypted and +> with which ciphersuites. In such a room every object of a media track is sealed by its +> publisher as an **MoQ secure object** (§4), so a relay routes, caches, meters and receipts +> ciphertext alone, and only the room's current members open it. The keys come from an MLS +> group (RFC 9420) whose membership follows the room's grants and lifecycle (§5): a principal +> that joins under a grant that may receive or publish is added, a principal that leaves, +> whose grant is revoked or whose grant ends is removed, and the next epoch's keys exclude it. +> The room's clock proposes every change and a member commits it, by a rule every member +> computes alike (§5.5). A recording of such a room is refused (§9). The reference +> implementation is open and in this tree: the `secure_objects` module of `joule-moq` +> (feature `secure-objects`) seals and opens objects and holds a receiver's keys and checks; +> the `joule-moq-mls` crate binds a room to an MLS group and exports each epoch's +> per-sender keys. The corpus is [`live-conformance/e2ee/`](../live-conformance/e2ee/), and +> its second implementation is `live-conformance/e2ee_verify.py`. +> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. > -> This is Lane B of SPEC §6.5: end-to-end encryption at the application layer. The -> sink that holds the key decodes the plaintext, so the tier and the output digests it -> states hold as they do for plaintext; staged-measurement counts no encrypted object, -> and a verifier without the key checks none of a claim's rules that need a payload -> (SPEC §6.5). It is not a protected-path robustness level. - -Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY**, **REQUIRED**, -**OPTIONAL** are RFC 2119 / RFC 8174. - ---- +> Documents read for this revision, each pinned to the revision named: +> `draft-ietf-moq-secure-objects-01` (6 July 2026, "secure objects"), +> `draft-jennings-moq-mocha-mls-keying-00` (6 July 2026, "the keying draft") and +> `draft-ietf-moq-transport-22` (1 October 2026, "MOQT"), all Internet-Drafts and none past +> Working Group Last Call; RFC 9605 (SFrame), RFC 9420 (MLS) and RFC 9750 (the MLS +> architecture); with RFC 5869 (HKDF), RFC 5116, NIST SP 800-38D (GCM) and FIPS 197 (AES) +> for the primitives. A section number of one of those documents in this text is that +> revision's. Where the documents disagree with one another, §11 says which reading this +> extension takes and why. ## 1. Scope and model -This extension makes a WAI payload **private**: only members of a group -may recover the content, while any relay or multiplexer that carries the -payload can still route, meter, and attest the bytes **without reading -them**. It is the `e2e_encrypted` path — the confidential counterpart to -the public floor, where a shared world or a broadcast film is plaintext -by design. +A room ([live-rooms](live-rooms.md)) carries each publisher's media on tracks named by the +room id, the publisher and the media kind (live-rooms §6.1), through relays that the room's +participants do not trust with the content. This extension makes those media tracks +private: the publisher seals each object before it leaves the device, every relay forwards +the sealed bytes as they are, and a member opens them after they arrive. -The construction is the `draft-jennings-moq-e2ee-mls` binding *(2026-09-30: -draft-jennings-moq-e2ee-mls is an individual Internet-Draft that has expired; the -MoQ working group's draft-ietf-moq-secure-objects is related work)*: +In scope: -- **MLS** (RFC 9420) — the group agrees a shared secret out of band and - every member derives the same **SFrame base key** from it. Membership, - not a transported key, is the requirement of existence. -- **SFrame** (RFC 9605) — each object payload is sealed under that base - key with an AEAD, so the wire carries only ciphertext. +- the room's confidentiality record (§3); +- object sealing, exactly as secure objects specifies it, on the objects of MOQT -22 (§4); +- keying: one MLS group per room, its credentials bound to the principals' room keys, its + membership driven by the room's lifecycle, the rule that says who commits, and each + epoch's per-sender keys (§5); +- what a receiver checks, in order, and the reason for each refusal (§6); +- rooms where several principals publish at once (§7); +- receipts and metering over ciphertext, and what a relay can and cannot prove (§8); +- recording and clips (§9); +- decrypt-then-dispatch and relay transparency (§10). -Confidentiality is **orthogonal to the capability** (WAI SPEC.md §5). -SFrame wraps the payload; the capability still names how to decode the -*plaintext* once recovered. A sink decrypts first, then runs capability -dispatch (SPEC.md §4) on the plaintext. +Sealed in this revision: the objects of every **media track** of the room (live-rooms §6.1: +`audio`, `video`, `screen`, each layer). In the clear, as in a room without this extension: -In scope: the SFrame object format, its key schedule, the MLS exporter -binding, the decrypt-then-dispatch integration, and relay transparency. +- each publisher's **catalog track**, which states codecs and dimensions: the shape of what + is sent, never its content; +- each media track's **receipts track**, whose receipts commit to the sealed bytes (§8); +- every **events track** (live-events §8.1). The clock's lifecycle events must be read by + the clock itself, which is not a member (§5.3), and by every relay that admits grants; a + participant's events (chat, reactions, markers) are held to its grant's policy by relays + and by any room verifier (live-rooms §9), and sealing them would move those checks to the + members alone. Events stay signed in the clear in this revision; a participant that needs + a private message sends it on a media track. Out of scope: -- **A transport.** Sealed objects ride in the carrier protocol exactly - as plaintext objects do; this extension does not define how they move. -- **MLS itself.** Group formation, commits, welcomes, and epoch - advancement are RFC 9420; this extension only names the ciphersuite - and the exporter call that yields the SFrame base key. -- **The receipt chain.** JWP attestation is - [`jwp-receipts.md`](jwp-receipts.md); §6 states only how the two - compose. +- **A transport.** Sealed objects ride MOQT exactly as plaintext objects do, through the + same relays, with no relay change (§10). +- **MLS itself.** Group state, the ratchet tree, commits, welcomes and the key schedule are + RFC 9420's; this extension fixes the ciphersuite, the credentials, the external sender, + who commits and what a commit holds, and the exporter call (§5). +- **The browser.** A browser member needs MLS in WebAssembly and the sealing of encoded + frames as the W3C encoded-transform interface does it; that is a later slice, and nothing + here depends on it. +- **Hardware-protected content** (SPEC §6.5, Lane A). This extension is Lane B: the + plaintext is in the member's application memory (§12). ---- +## 2. Terms -## 2. Ciphersuites +- **Room**, **room id**, **room authority**, **clock**, **principal**, **grant**, + **grant in effect**, **present**: as in live-rooms §2 and §9.3. +- **Keyed grant**: a grant that may subscribe (`subscribe` 1) or may publish any media kind + (`publish` not 0). A principal present under a keyed grant needs the room's keys: to open + what it receives, or to seal what it publishes. +- **Member**: a principal that holds a leaf of the room's MLS group in an epoch. +- **Epoch**, **leaf index**, **commit**, **proposal**, **Welcome**, **KeyPackage**: as in + RFC 9420. The **roster** of an epoch maps each occupied leaf index to its member. +- **Committer**: the member that commits the end of an epoch (§5.5). +- **Sealed object**: an object of a media track whose payload is a secure-objects + ciphertext and whose Immutable Properties carry its Key ID (§4). +- **KID**: the Key ID of a sealed object (§5.7). +- **vi64**: the variable-length integer of MOQT §8.1. Where this text writes one, it is the + shortest encoding of its value; where it reads one, any encoding MOQT allows is read. -- **SFrame:** cipher suite `0x0006`, `AES_256_GCM_SHA512_128` - (RFC 9605 §4.5): AEAD = AES-256-GCM (`Nk = 32`, `Nn = 12`, `Nt = 16`), - KDF = HKDF-SHA-512. The two-byte suite id `0x00 0x06` is a constant of - the key schedule (§3.2), not carried on the wire. -- **MLS:** ciphersuite `0x0001`, - `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (RFC 9420 §17.1) — the - mandatory-to-implement baseline. Credentials are BasicCredential over - Ed25519. +## 3. The room's confidentiality record (`wai.live.e2ee`) -A sink MUST reject an envelope whose declared `security.encryption` -names a scheme or suite it does not implement (SPEC.md §6.4), rather -than attempt to decode ciphertext. +A room is end-to-end encrypted when its authority signs a **confidentiality record** for +it. The record names the room and the two ciphersuites; members and publishers seal and open +under it, and a verifier is given it beside the room's descriptor. ---- +### 3.1 Layout -## 3. SFrame object format +Integers are little-endian, as in the records of live-rooms. -A sealed payload is `header ‖ AEAD.Seal(...)`. The manifest's payload -bytes ARE this sealed object; nothing else wraps it. +| field | size | meaning | +|---|---|---| +| `magic` | 4 | `WRE1` (ASCII) | +| `version` | 1 | 1 in this revision | +| `room` | 32 | the room id (live-rooms §3.2) | +| `suite` | 2 | u16: the secure-objects ciphersuite (secure objects §7.2) every sealed object of the room uses | +| `mls_suite` | 2 | u16: the MLS ciphersuite (RFC 9420 §17.1) of the room's group | +| `sig` | 64 | §3.2 | -### 3.1 Header (RFC 9605 §4.3) +### 3.2 Signature -The first byte is: +The **signature** is Ed25519 by the descriptor's `authority` over the 18 bytes +`wai:live-room-e2ee` (ASCII) and 0x01, followed by the record from `magic` through +`mls_suite`. It is verified strictly, as [SPEC](../SPEC.md) §7 *Signatures* requires: a key +that fails step 1 or 2 is `bad_key`, and a signature that fails a later step is +`bad_signature`. + +### 3.3 Checking the record + +A verifier is given the record, the room id, the descriptor's `authority` and the +descriptor's recording `mode` (live-rooms §3.7), and refuses at the first of these that +holds: + +1. Fewer than 4 bytes: `truncated`. A magic other than `WRE1`: `malformed`. No byte after + the magic: `truncated`. A version other than 1: `unknown_version`. Fewer than 105 bytes: + `truncated`. More: `malformed`. +2. `room` is not the room id: `wrong_room`. +3. `suite` is not `0x0004` (`AES_128_GCM_SHA256_128`) or `0x0005` + (`AES_256_GCM_SHA512_128`): `suite_unsupported`. Secure objects makes `0x0004` + mandatory to implement; the counter-mode suites are not used in rooms in this revision + (§11.2). +4. `mls_suite` is not `0x0001` (`MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519`): + `mls_suite_unsupported`. It is the suite whose signatures are Ed25519, the scheme of the + principals' room keys (§5.2). +5. The descriptor's recording `mode` is not 0: `recording_refused` (§9). +6. The signature (§3.2): `bad_key` or `bad_signature`. + +A room has at most one confidentiality record: a room id is the digest of its descriptor, +and a room whose confidentiality changed would be a new room. + +## 4. Object sealing + +Sealing is secure objects' construction, applied to the objects of a room's media tracks +as MOQT -22 carries them. Nothing below changes a cryptographic step of secure objects; +§4.3 and §4.4 say how its fields sit on a -22 object, and §11 where the documents leave a +choice. + +### 4.1 What is protected + +| protection | fields | +|---|---| +| hop by hop only (TLS of each MOQT session) | Track Alias; the object's mutable properties, those outside Immutable Properties | +| end to end, authenticated | the Full Track Name; Group ID; Object ID; Publisher Priority; the Immutable Properties, the Key ID among them | +| end to end, encrypted and authenticated | the original payload; the Encrypted Properties List | + +This is secure objects' Table 1. A relay reads everything in the first two rows, which is +all it needs to route, cache, prioritise and meter (MOQT §8.4, §10.7); it can change only +the first row without the receiver noticing. + +### 4.2 The Serialized Full Track Name + +The Serialized Full Track Name is the object's Track Namespace as MOQT §8.7 encodes it, a +vi64 count of fields and each field as a vi64 length and its bytes, followed by its Track +Name as a vi64 length and its bytes (secure objects §3.3.1). In a room the fields are those +of live-rooms §6.1: for `--video--1`, the count 3, the 64 hexadecimal +digits of the room id, the principal's 64 digits, `video`, then the Track Name `1`. + +### 4.3 The Key ID property + +The Key ID is an Object Property, `SECURE_OBJECT_KEY_ID`, whose value is a vi64, the KID +(§5.7), carried inside the object's Immutable Properties (MOQT §10.7), exactly once (secure +objects §4.1). Its type in this revision is **`0x3A4A`**, from the range MOQT §8.4 sets aside for applications, and not +the `0x2` secure objects §7.1 asks for: MOQT -22 §16.8 has given `0x02` to +OBJECT_DELIVERY_TIMEOUT, so a -22 relay that read a Key ID at `0x2` would hold it for a +delivery timeout of that many milliseconds (§11.1). The type is even, so the value is one +vi64 (MOQT §8.3). A sealed object carries no Key ID property outside its Immutable +Properties. + +The **Serialized Immutable Properties** that secure objects §3.5 puts in the AAD are the +value of the object's Immutable Properties property: its Key-Value-Pairs exactly as the +object carries them, which MOQT §10.7 forbids anyone after the publisher to re-encode. A +publisher writes them in the order MOQT §8.3 requires, each type the delta from the one +before, each vi64 the shortest. + +The object receipt properties of [moq-streaming-format](moq-streaming-format.md) §5 +(`WAI_RECEIPT`, `JWP_OBJECT_RECEIPT`) commit to the sealed bytes, which depend on the +Immutable Properties through the AAD; a sealed object therefore carries neither inside its +Immutable Properties. A sealed track's receipts ride its receipts track (live-rooms §6.1; +§8). + +### 4.4 The plaintext + +The AEAD's plaintext is the original payload and, optionally, the Encrypted Properties List +(secure objects §3.3.2): ``` - bit 7 6 5 4 3 2 1 0 - +---+-------+---+-------+ - | R | LEN | X | KID/ | - |=0 | (3) | | KLEN | - +---+-------+---+-------+ +pt = vi64(len(payload)) ‖ payload ‖ [ list ] +list = 0x000A (2 bytes) ‖ vi64(len(entries)) ‖ entries +entry = type (2 bytes, not 0x0032) ‖ vi64(len(value)) ‖ value +padding = 0x0032 (2 bytes) ‖ length (2 bytes) ‖ length zero bytes ``` -- `R` (1 bit) — reserved, MUST be 0. -- `LEN` (3 bits) — `ctr_len − 1`; the counter is `LEN+1` bytes, - big-endian, minimal (no leading zeros; a zero counter is one `0x00`). -- `X` (1 bit) — key-id form. `X = 0`: the key id is the low 3 bits of - byte 0 (values 0–7). `X = 1`: the low 3 bits are `KLEN = kid_len − 1` - and the `kid_len`-byte big-endian minimal key id follows byte 0. -- The counter bytes follow the key id. +Types and the padding length are big-endian. The list's form is secure objects §3.3.3 +step 4 and §4.2, an entry's form and the padding's are the forms of secure objects' +Appendix B and §4.3 (§11.3). The **original payload** is what the object would carry in a +room without this extension: one WAI envelope ([moq-streaming-format](moq-streaming-format.md) +§3) or one media frame. -The header is therefore `1 + (X ? kid_len : 0) + ctr_len` bytes and is -self-delimiting: `(kid, ctr)` and the header length are recoverable from -the payload alone. +This extension defines one entry: -### 3.2 Key schedule (RFC 9605 §4.4.3) +- **`WAI_INNER_DIGEST`** (type `0x3A4B`): the 32-byte BLAKE3 hash of the original payload, + the content hash [jwp-receipts](jwp-receipts.md) §2.1 computes over an object's payload. + It is the hash a receipt of the object would carry in the clear, sealed with the object, so + a member checks the plaintext against a digest the publisher committed to (§8.2) and + plaintext-level records attach to it. A publisher **SHOULD** carry it on every sealed + object of a video or screen track and **MAY** leave it out where its 35 bytes weigh on a + small object, as a frame of speech is. -From the 32-byte base key (§4), with `suite = 0x00 0x06`: +Padding (secure objects §4.3) is the last entry when present; a receiver ignores its bytes. + +A receiver reads the plaintext so, and refuses with `plaintext_malformed` where it does not +read: a vi64 length and that many bytes of payload; if nothing remains, no list; otherwise +2 bytes that are `0x000A`, a vi64 length that is exactly the number of bytes left, and +entries that fill them, each read by its form, padding only as the last, at most one +`WAI_INNER_DIGEST`, of exactly 32 bytes. An entry of another type is skipped. + +### 4.5 Key and salt + +For the object's KID, the base key of its track (§5.7) and its Serialized Full Track Name, +with the suite's hash (`SHA-256` for `0x0004`, `SHA-512` for `0x0005`), as secure objects +§3.7 derives them: ``` -secret = HKDF-Extract(salt = "", IKM = base_key) ; HMAC-SHA-512 -key = HKDF-Expand(secret, "SFrame 1.0 Secret key " ‖ suite, Nk=32) -salt = HKDF-Expand(secret, "SFrame 1.0 Secret salt " ‖ suite, Nn=12) +moq_secret = HKDF-Extract("", track_base_key) +moq_key = HKDF-Expand(moq_secret, "MOQ 1.0 Secure Objects Secret key " ‖ ftn ‖ suite ‖ kid, Nk) +moq_salt = HKDF-Expand(moq_secret, "MOQ 1.0 Secret salt " ‖ ftn ‖ suite ‖ kid, Nn) ``` -The label strings include their trailing space, immediately followed by -the two suite bytes. +`suite` is the record's `suite` as 2 bytes big-endian, `kid` the KID as 8 bytes big-endian, +and the label strings end in the space shown. -### 3.3 Nonce (RFC 9605 §4.4.4) +### 4.6 Nonce -The per-object nonce is `salt XOR ctr`, where `ctr` is the 64-bit -counter written big-endian and right-aligned into the 12-byte nonce -(the low 8 bytes are XORed; the high 4 bytes are `salt` unchanged). +`CTR` is the Group ID as 8 bytes big-endian followed by the Object ID as 4 bytes big-endian, +and the nonce is `moq_salt XOR CTR` (secure objects §3.6). An Object ID above 2^32 − 1 has +no nonce: such an object is not sealed, and a receiver refuses it `object_id_too_large`. A +publisher of a sealed track numbers its objects within that bound. -### 3.4 Seal / open - -- **Seal:** `AES-256-GCM(key, nonce, plaintext, aad = header)` → - `ct ‖ tag`; the object is `header ‖ ct ‖ tag`. The header is the AEAD - associated data, so any edit to `(kid, ctr)` fails authentication. -- **Open:** parse the header, derive `(key, salt)`, recompute the nonce - from the parsed `ctr`, and AEAD-open with the header as AAD. A wrong - key, a tampered header, or a tampered ciphertext/tag MUST fail with a - single indistinguishable authentication error. - -The sender MUST NOT reuse a `(base_key, ctr)` pair — GCM nonce reuse is -catastrophic. Counters are per base key and strictly increasing. - ---- - -## 4. MLS binding — deriving the base key - -Every group member derives the SFrame base key from the MLS group's -exporter secret (RFC 9420 §8.5): +### 4.7 AAD ``` -base_key = MLS-Exporter(label = "moq-jwp sframe base key", - context, - length = 32) +aad = Group ID (8 bytes) ‖ Object ID (4 bytes) ‖ Publisher Priority (1 byte) ‖ Serialized Immutable Properties ``` -- All members at the same MLS **epoch** derive the same key; a non-member - has no group state and cannot derive it. Advancing the epoch (any MLS - commit — add, remove, update) rotates the base key, giving the channel - MLS's forward secrecy and post-compromise security for free. -- **Single-sender channel:** `context = ""`. One publisher, monotonic - counter, no `(key, ctr)` collision. This is the profile the reference - `sframe_base_key()` and the live private-channel demo implement. -- **Multi-sender channel:** a sender MUST derive a per-sender base key by - binding its identity into the exporter — `context = KID` (or the - member's MLS leaf index) — so two senders never share a `(key, ctr)` - space. The SFrame `kid` then selects the sender whose base key opens - the object. A multi-sender sink MUST use per-sender derivation; it MUST - NOT seal two senders under one base key. +big-endian (secure objects §3.5). The Publisher Priority is the object's as its publisher +sent it; a publisher of a sealed track states it in every subgroup header and datagram, so +it never falls back to a track default a relay could hold differently (MOQT §10.4). -The SFrame `kid` (§3.1) names *which* base key opens an object; the MLS -epoch names *which generation* of that key. A sink resolves both before -`open`. +### 4.8 Ciphersuites ---- +| `suite` | name | AEAD | hash | Nk | Nn | Nt | +|---|---|---|---|---|---|---| +| `0x0004` | `AES_128_GCM_SHA256_128` | AES-128-GCM | SHA-256 | 16 | 12 | 16 | +| `0x0005` | `AES_256_GCM_SHA512_128` | AES-256-GCM | SHA-512 | 32 | 12 | 16 | -## 5. Decrypt-then-dispatch +GCM is SP 800-38D's with a 96-bit nonce and a 128-bit tag, the tag following the ciphertext +(RFC 5116). The reference implements RFC 9605's counter-mode suites as well (§11.2), and +checks them against RFC 9605's vectors; a room does not use them in this revision. -This refines SPEC.md §6.4. Before capability dispatch (SPEC.md §4) a -sink MUST: +### 4.9 Seal -1. If the manifest has no `security` object, or `security.encryption` is - `null` / `"none"`, dispatch the payload unchanged. -2. Else, for `"mls-sframe"`: if the sink is not a member of the group - (holds no MLS state) or holds no base key for `security.kid`, the - file is **inert at this sink** — return a clear "no key" error. The - sink MUST NOT feed ciphertext to a codec. -3. Else `open` the SFrame object (§3.4) to recover the plaintext payload, - then run SPEC.md §4 on the plaintext. +To seal `payload` as object (`group`, `object`) of a track, at priority `p`, with the KID and +track base key of the publisher's current epoch (§5.7): -`media`, `intent`, `conditioning.kind`, and -`model_requirement.capability` describe the **plaintext** codec and are -NOT encrypted: confidentiality protects the *content*, not the *shape*, -so a multiplexer can still route by capability without a key. +1. Build the Immutable Properties: the Key ID property (§4.3) and any other property the + publisher fixes before sealing (the layer properties of moq-streaming-format §4.1, the + envelope flag), serialized per §4.3. +2. Build the plaintext (§4.4). +3. Derive `moq_key` and `moq_salt` (§4.5); form the nonce (§4.6) and the AAD (§4.7). +4. The object's payload is `AEAD.Encrypt(moq_key, nonce, aad, pt)`, the tag included. ---- +A publisher counts the objects it seals under each `moq_key` and stops before the limit of +secure objects §6.3 for its suite; a key is one track's in one epoch, and the next epoch +gives a fresh one (§5.8). -## 6. Relay transparency and JWP composition +### 4.10 Open -A relay or multiplexer that carries a sealed payload MUST treat it as -opaque: it MUST NOT require the plaintext to route, meter, or attest, and -MUST NOT attempt capability dispatch on ciphertext. +A receiver opens an object only through the checks of §6, which end in +`AEAD.Decrypt(moq_key, nonce, aad, payload)`. A wrong key, a changed property, priority, +Group ID, Object ID or track, and a changed byte of the payload or tag all fail alike, as +`bad_tag`, and the receiver discards the object (secure objects §3.9). -Attestation composes with confidentiality because the JWP receipt chain -([`jwp-receipts.md`](jwp-receipts.md)) commits over the **ciphertext** -content hash. The publisher signs the group's Merkle root over the -objects' leaves, and a Merkle leaf commits to the object's content hash -and, for a labelled object, its figure, class and origin (jwp-receipts -§2.2); an unlabelled object's figure is signed only where an `energy` -claim restates it (jwp-receipts §2.4.5). A relay forwards the -sealed bytes and their receipts unaltered, signs nothing in the -publisher's name (jwp-receipts §4.3), and never learns what the bytes -say. Content secrecy -(SFrame/MLS) and delivery attestation (JWP) are independent layers over -the same bytes: +## 5. Keying from MLS -- SFrame answers *what does it say* — only members can. -- JWP answers *which bytes the publisher signed for, and the group's - declared energy* — anyone can check it with the publisher's verifying - key, which the receipt carries, and no content key; whose key it is, - [`jwp-receipts.md`](jwp-receipts.md) §7 confirms. +### 5.1 The group ---- +Each room has one MLS group at a time, of the record's `mls_suite`. Its group ID is the 32 +bytes of the room id followed by a 4-byte big-endian **generation**, 0 for the room's first +group. Handshake messages (proposals and commits) are sent as PublicMessage (RFC 9420 +§6.2), which RFC 9750 §8.1.2 weighs: here membership is already public in the room's +lifecycle events, and the clock, which is not a member, follows each epoch from them. MLS protocol messages ride the tracks of the +keying draft §3 within the room's namespace, under the names live-rooms §6.1 leaves free: +each member's `mls` track and `keypackage` track, owner its principal. A deployment that +carries them otherwise, a room service passing them directly, changes nothing below. -## 7. Security considerations +### 5.2 Credentials: the principal's room key -- **Counter uniqueness** (§3.4) is the sender's obligation; a persisted - or monotonic counter per base key is REQUIRED. Epoch rotation (§4) - resets the counter space safely because it rotates the key. -- **Replay.** SFrame authenticates each object but does not by itself - reject a replayed object. Freshness is enforced above this layer — the - MLS epoch bounds a key's lifetime, and the application op-log (e.g. a - Lamport/causal clock) orders and de-duplicates. Note that a channel - which legitimately re-sends old sealed objects for late-joiner resync - MUST NOT treat re-delivery as an attack. -- **Metadata.** The manifest shape (capability, dimensions, timing) and - object sizes/timing are visible to relays by design. This extension - protects payload content, not traffic analysis. -- **Trust boundary.** A relay is untrusted for confidentiality (it never - holds a content key) and signs nothing in the JWP chain (jwp-receipts - §4.3). A relay attestation, where a deployment wants one, is a separate - receipt under the relay's own key, and no class for it is registered - yet. +A member's leaf carries a BasicCredential (RFC 9420 §5.3) whose `identity` is the 32-byte +Ed25519 key of the principal its grant names, and whose leaf `signature_key` is that same +key: the principal signs its MLS messages with the key that signs its events and its proofs +of possession (live-rooms §10.5). MLS signs with its own labels (`"MLS 1.0 "` and the +structure's name, RFC 9420 §5.1.2) and WAI with its own domains, so no signature of one can +be taken for the other. ---- +A member validates a credential (RFC 9420 §5.3.1, RFC 9750 §4) against the room: an Add +whose leaf's identity is not its signature key is refused `credential_mismatch`, and one +whose principal is not the subject the clock owes an Add (§5.4) `proposal_mismatch`; a +commit that holds a proposal a member refused never applies for that member. The room authority stands as the authentication service of RFC 9750: its grants say who +the principals are, and the clock's lifecycle says which of them are present. -## 8. Conformance +### 5.3 The clock as the external sender -- A **confidentiality-aware sink** MUST implement §3 (SFrame open), §4 - (MLS base-key derivation for the profiles it supports), and §5 - (decrypt-then-dispatch), and MUST fail closed on a missing key or - unsupported scheme. -- A **confidentiality-aware encoder** MUST emit `security.encryption` - in the manifest whenever the payload is sealed, and MUST seal per §3 - under a base key derived per §4. -- A sink that does NOT implement this extension MUST refuse an envelope - whose manifest carries a non-`none` `security.encryption` cleanly - (SPEC.md §6.4) — never mis-decode ciphertext as a codec. -- Conformance is verified against the reference `moq-sframe` / - `joule-comm-mls` crates: byte-level SFrame round-trip and rejection - vectors, MLS group derivation of a shared base key, and the end-to-end - check that a member opens what a non-member cannot — the same flow the - live `/private-channel` demo runs in-browser over the real relay. +The group's GroupContext carries the `external_senders` extension (RFC 9420 §12.1.8.1) with +exactly one entry: the descriptor's `clock` key, with a BasicCredential whose identity is +that key. The clock is never a member and holds no epoch's secret; it signs external Add and +Remove proposals (RFC 9420 §12.1.8), as PublicMessage, for the group's current epoch. A +member that joins a group whose group ID or external sender is not the room's (§5.1) refuses +it `group_mismatch`. + +### 5.4 Membership follows the room + +The clock turns each lifecycle event of live-rooms §7.2 that changes who holds keys into one +proposal, in the order of the events: + +- **`join`** under a keyed grant: an Add of the subject's KeyPackage. When no group exists + (the room's first keyed join, or every member has left), there is nothing to add to: the + subject creates the group, of the next generation, at epoch 0, itself at leaf 0, with the + clock as its external sender. +- **`leave`** of a member, for any reason of live-rooms §7.2: a Remove of its leaf. The + clock records a `leave` when a principal leaves (reason 1), when the authority removes it, + a revocation through the key log among the ways (live-rooms §5; reason 2), when its grant's + window ends (reason 3), and when its connection is lost (reason 4). Each is a Remove, and + the epoch the commit opens excludes the principal. +- **`grant_exchange`** from a keyed grant to one that is not: a Remove; from a grant that is + not keyed to a keyed one: an Add. An exchange between keyed grants changes nothing here. + +Nothing else is proposed: a principal present under a grant that is not keyed (it sends +events only) is never a member, and the clock sends no Update and no other proposal. + +What the clock owes the group is the difference between the principals present under keyed +grants and the group's membership once its pending proposals are committed. It proposes, one +at a time, the earliest owed change, in the order of the lifecycle events that call for +them, that can be proposed now: an Add into any group, a Remove only of a principal that +holds a leaf. So a principal that leaves before the clock proposes its Add is never added, +and one that leaves while its Add is pending is removed once a commit has added it. When a +commit ends an epoch without some of the clock's proposals for it, those are void, as RFC +9420 makes every proposal for an ended epoch, and the same rule proposes them again for the +new epoch, before anything owed later. + +A group in which no member is still present under a keyed grant can never commit again: it +ends, and the next keyed join creates the group of the next generation. + +A KeyPackage reaches the clock as the keying draft §4.7 has it: the joining principal +publishes it on its `keypackage` track, signed by its room key (§5.2), and each is used +once. A Welcome reaches the new member on the committer's `mls` track (the keying draft +§4.8). + +### 5.5 Who commits + +**The members commit, by one rule: the committer of the commit that ends epoch *E* is the +member at the lowest leaf index of epoch *E* that the commit's proposals do not remove.** + +The commit holds, by reference, the first *k* proposals the clock sent for epoch *E*, for +some *k*, each once, in any order, and no other proposal: the clock's proposals reach the +committer in order on the clock's track, so the ones it holds when it commits are always the +first. It carries an UpdatePath, so the committer's own leaf is renewed with every epoch. A +commit with no proposal is allowed, from the committer alone: it renews the epoch's keys and +nothing else. + +A member checks each proposal when it arrives, and keeps it only if it passes: the clock's +must be for the current epoch, its Add's credential bound as §5.2 requires +(`credential_mismatch`), and the change the clock owes (`proposal_mismatch`). A member's own +proposal is kept but never committed. A member applies a commit only after checking it, in +this order: + +- its sender is the committer: otherwise `commit_not_committer`; +- every proposal it holds is the clock's: otherwise `proposal_not_clock`; +- the proposals it holds are the clock's first *k* for the epoch, each once: otherwise + `commit_mismatch`. + +A commit that fails is discarded, and its epoch never exists for that member. + +Why the members, and why this rule: + +- **The clock cannot commit.** In MLS only a member commits, and a member holds every + epoch's secret. The clock is the room's own service; were it the committer, the room's + host could open every object, and "only the room's current members" would include a party + no participant joined with. As an external sender it orders every membership change and + holds no key. +- **One committer, computed alike everywhere.** The keying draft §4.2 has one committer per + group so that every epoch has exactly one commit and nothing forks. Here every member + computes the committer from the epoch's tree and the commit's own proposals, with no + election and no extra message: the rule is the keying draft's failover rule (§4.4 there), + the lowest eligible member, applied at every epoch rather than only after a timeout. +- **It heals itself.** A committer that goes away is a member whose connection is lost: the + clock records its `leave` (reason 4), and the commit that removes it is, by the rule, the + next member's. A committer that stays but does not commit is, to the room, a member that + has stopped responding, and is removed the same way. +- **It can be checked.** The rule needs the tree and the commit alone, so a member checks + every commit before applying it, and a verifier checks a room's whole transcript (§13). + +### 5.6 Roster + +A commit applies its proposals as RFC 9420 §12.3 orders them: every Remove blanks its leaf, +then every Add, in the order the commit lists them, takes the leftmost blank leaf, or a new +leaf at the right edge when none is blank (RFC 9420 §7.7, §12.1.1): the lowest leaf index +no member holds. The roster of the new epoch follows from the +roster before it and the commit alone, so the clock, which is not a member, knows each +member's leaf index, and so does a verifier. + +### 5.7 Per-epoch, per-sender keys + +At each epoch *E*, every member derives the epoch's **application secret** with the MLS +exporter (RFC 9420 §8.5): + +``` +application_secret[E] = MLS-Exporter("wai live e2ee", room_id, 32) +``` + +and from it the **track base key** of each namespace, as the keying draft §7.1 derives a +track key, with HKDF-Expand of the MLS suite's hash (SHA-256): + +``` +track_base_key[E, ns] = HKDF-Expand(application_secret[E], "mocha_track" ‖ Serialize(ns), 32) +``` + +`Serialize(ns)` is the namespace as §4.2 serializes it, and `mocha_track` is ASCII with no +separator. The **KID** of an object a member at leaf index *L* seals in epoch *E* is the +keying draft's §7.2: + +``` +KID = E × 2^24 + L (E < 2^40, L < 2^24) +``` + +The base key is the namespace's, not the track's: secure objects §3.2 scopes a Key ID to a +namespace, so the tracks of one namespace (a publisher's layers of one media kind) share it, +and secure objects' own derivation (§4.5) mixes in the Full Track Name, so each layer's key +and salt are its own. Every sender's own tracks are in a namespace of their own (live-rooms +§6.1), and the KID names the sender, so a sender's key is its own in every epoch: two +members never seal under one `(moq_key, nonce)`, even in a slot where a live publisher and +its standby send on the same track (live-rooms §7.1). A receiver reads the epoch and the +leaf index from the KID and derives the one key that opens the object, with no trial +decryption. + +The KID's 40 bits of epoch and 24 of leaf index fit in one vi64 (MOQT §8.1); a group whose +epoch would reach 2^40 or whose tree would exceed 2^24 leaves ends, and the next join starts +the next generation. + +### 5.8 Rotation and retention + +Every commit opens an epoch whose application secret, and so every key derived from it, is +new. A member derives an epoch's keys when it applies the commit that opens it, and a +publisher seals with them from then on. A receiver that holds an object whose KID names an +epoch it has not reached yet **MAY** keep it until it applies the commit (secure objects +§3.9), and opens it then. + +A member keeps an epoch's keys while objects of that epoch can still arrive, and deletes +them after; for a receiver, an epoch whose keys it deleted is one it no longer joins (§6 +step 4). Keeping them no longer than needed is what gives MLS's forward secrecy to the room's +media: a member's keys taken later open nothing sent before the epochs it still holds. + +## 6. Receiving + +A receiver is a member. For an object of a media track of the room it runs these steps in +order, and refuses at the first that fails: + +1. The object's properties read (MOQT §8.3, §10.7), and its Immutable Properties hold + exactly one Key ID property and the mutable list none: `properties_malformed` for a list + that does not read or a Key ID twice, outside, or not a vi64; `key_id_missing` for none. + A media object of an end-to-end encrypted room is always sealed: one without a Key ID is + refused, never decoded as plaintext. +2. The Object ID is at most 2^32 − 1: `object_id_too_large`. +3. The KID gives the epoch *E* and leaf index *L* (§5.7). +4. The receiver was a member in epoch *E*: `epoch_not_joined` when *E* precedes the epoch it + joined at, or it deleted *E*'s keys (§5.8); `removed_before_epoch` when it was removed at + or before *E*. (*E* later than the receiver's latest epoch is held, §5.8, not refused.) +5. *L* is occupied in epoch *E*'s roster: `key_id_unknown`. +6. The member at *L* in epoch *E* is the track's owner (live-rooms §6.1: the namespace's + second field is its key; for a slot's track, a publisher the room state binds to the + slot): `sender_not_owner`. A member holds every sender's keys in its epochs, so it could + seal under another's KID; this step, and the publisher's signed receipts (§8), are what + keep a member from speaking as another. +7. The member at *L* held *L* in every epoch from *E* to the receiver's latest: otherwise + `sender_removed`. Once a receiver has applied the commit that removed a member, it takes + nothing more sealed under that member's earlier KIDs, so a removed member cannot go on + publishing under the last epoch it held. +8. The receiver has not opened an object of this Full Track Name, Group ID and Object ID + before: `replayed`. MOQT forbids two objects at one location (secure objects §6.1); one + presented again is a relay's replay, and a receiver keeps the locations it opened while it + keeps the epoch's keys. +9. `AEAD.Decrypt` (§4.10): `bad_tag`. +10. The plaintext reads (§4.4): `plaintext_malformed`. +11. Where the plaintext carries `WAI_INNER_DIGEST`, it is BLAKE3 of the original payload: + `inner_digest_mismatch`. + +The original payload is then dispatched (§10). Steps 4 to 7 need only the receiver's epochs, +their rosters and the track's name, so a receiver refuses them before deriving any key, and +records a location as opened (step 8) only once the object has opened. + +## 7. Multi-sender rooms + +Every member may publish, each on its own tracks, at once. Nothing in a sealed object needs +coordinating between them: each sender's KID carries its leaf index (§5.7), so its key is +its own and its nonces never meet another's; a receiver selects the key from the KID alone. +Each publisher numbers its own tracks' groups and objects, as MOQT has it, and each signs its +own tracks' receipts with its own room key (§8.1). + +When a commit opens epoch *E* + 1, each member moves to it when it applies the commit, at its +own pace: a publisher's objects of *E* and *E* + 1 can interleave at a receiver, and each +opens under its own epoch (§5.8). Two commits for one epoch cannot both be applied: the +rule of §5.5 names one committer, and a member discards any other's commit. + +A slot (live-rooms §7.1) has one live publisher and, with redundancy, a standby; both are +members, and their objects on the slot's tracks carry their own KIDs. A takeover or failover +changes who publishes, not who is a member, and needs no commit. + +## 8. Receipts and metering over ciphertext + +### 8.1 What receipts bind + +A sealed object's receipt is the receipt of [jwp-receipts](jwp-receipts.md), unchanged, +over the object's payload as it travels: its content hash is BLAKE3 over the sealed bytes, +ciphertext and tag. The publisher signs its group receipts with its room key, and they ride +the track's receipts track (live-rooms §6.1, moq-streaming-format §4.1), in the clear. A +relay with a receipt door ([jwp-receipts](jwp-receipts.md) §4.1) checks each sealed object +against its receipt exactly as it checks a plaintext one, and holds each publisher's tracks +to its own key (the namespace names it). + +### 8.2 What a member checks besides + +A member that opens an object knows the plaintext came from the sealed bytes the publisher +signed for, under a key of the publisher's leaf: the AEAD authenticates the plaintext +under the KID's key, and the receipt binds those exact bytes to the publisher's room key. +Where the object carries `WAI_INNER_DIGEST` (§4.4), the member also checks the plaintext +against the digest the publisher sealed into it, the content hash a receipt of the plaintext +would carry; records made over plaintext (a decoded output's digest, a reconstruction +binding) attach to that hash. + +### 8.3 What a relay can prove, and what it cannot + +A relay, and anyone holding the receipts, can prove: which sealed bytes each publisher signed +for, at which track, group and object; that the relay forwarded exactly those bytes, since a +changed byte fails the receipt; how many objects and bytes of each track it carried, received +and dropped, and the energy each receipt states, which the per-hop records of +[live-metering](live-metering.md) count from what it carried (their byte counts are of sealed +bytes, the AEAD's tag and the Key ID among them); the track names, priorities and Immutable +Properties, which the AEAD authenticates but does not hide; and the room's membership over +time, which the room's events and the group's public handshake messages state. + +A relay cannot prove, or learn: what any object says; that a sealed payload opens at all, or +that its plaintext is a frame of the codec its catalog names; that a member received or +opened what it was sent; or anything a measurement over plaintext states (staged-measurement +counts no sealed object, code `encrypted`; a decoded output's tier and digest are the +member's). A moderator who reviews media is a member and reviews what it opens; a relay can +withhold an object a moderator's decision names by its receipt leaf, and cannot review one by +its content ([live-moderation](live-moderation.md)). + +## 9. Recording and clips: refused + +**A room with a confidentiality record is never recorded.** Its descriptor's recording +`mode` is 0 (live-rooms §3.7), and §3.3 step 5 refuses a record for a room whose mode is not +(`recording_refused`): no principal holds the record power to any effect, no device commits a +take (capture-binding needs a `record_start`), no recording manifest is signed, no clip is +cut, and a relay keeps nothing of the room's tracks beyond delivery. + +The alternative was a recording of the relay's ciphertext with each epoch's keys escrowed to +the room authority. It is not taken, for these reasons: + +- **Escrow makes a reader the members cannot see.** The MLS roster is where a member sees + who can open what it sends. An authority holding every epoch's keys from outside the tree + would open everything while no roster named it, and the room's claim to be end-to-end + encrypted would be false for every recorded room. +- **Escrowed keys outlive their epoch.** Forward secrecy, and a removed member being cut off, + rest on each epoch's keys being deleted (§5.8). Keys kept for a recording are kept for its + retention, and one later compromise of the escrow opens the whole session. +- **It is not what a recording is here.** A recording of [live-recording](live-recording.md) + is a package of the participants' takes, masters their devices upload to the room's store + in the clear, under a manifest the authority signs. A store of ciphertext and keys would be + a second format that live-recording's verifier does not read, and the takes would still + reach the store as plaintext. A room whose session must be recorded is not end-to-end + encrypted, and says so by having no confidentiality record. +- **It needs no new field.** Mode 0 already means "never recorded", so the rule is a check of + what the descriptor states. + +A member keeps what it receives as any participant of an end-to-end encrypted system can; +that is the member's own act, outside the room's recording policy, and no record of this +family attests it. + +## 10. Decrypt-then-dispatch and relay transparency + +This refines SPEC §6.4. A receiver of a room's media track opens each object by §6, then +treats the original payload as it would the payload of a plaintext room: a WAI envelope is +read and dispatched by SPEC §4, a media frame decoded by the track's codec. It **MUST NOT** +feed a payload that has not opened to a decoder. The envelope inside a sealed object carries +no `security` member: the object around it was sealed, and the envelope is plaintext once +opened. + +A relay forwards a sealed object as it forwards any object: it reads the track name, the +Group ID, the Object ID, the priority and the properties, applies its delivery timeouts, +caches and serves FETCH, checks receipts at a door, and meters; it never needs a key, and +the Key ID property is one more property it forwards unchanged (MOQT §8.4). Nothing in a +relay's forwarding changes for a sealed object, and the reference relay +(`joule-moq-relay`) forwards them with no change of its own. + +## 11. Reading the documents together + +The documents of this revision were written against different revisions of one another. +Where they disagree, this extension takes the reading below. + +### 11.1 The Key ID's code point + +Secure objects (§4.1, §7.1) gives the Key ID property the type `0x2`, written against MOQT +-18. MOQT -22 §16.8 gives `0x02` to OBJECT_DELIVERY_TIMEOUT, a Track and Object Property a +relay acts on, and its table of provisional registrations lists secure objects' `0x0A` and +`0x32` but no Key ID. This extension carries the Key ID at `0x3A4A` (§4.3), in the +application range of MOQT §8.4, until the two agree; every other rule of the property is +secure objects'. + +### 11.2 The counter-mode suites' tag + +Secure objects §7.2 says its suites' algorithms are those of RFC 9605. RFC 9605 §4.5.1 +computes the counter-mode suites' tag as HMAC over the lengths of the AAD, the ciphertext and +the tag, then the nonce, the AAD and the ciphertext. The ciphertexts of secure objects' +Appendix B for suites `0x0001` and `0x0002` carry instead the HMAC of the AAD, the nonce and +the ciphertext, with no lengths; their counter-mode encryption, keys, salts, nonces and AADs +are RFC 9605's. This extension implements RFC 9605's construction and keeps the +counter-mode suites out of rooms until the text and the vectors agree; its corpus checks +both readings against those two vectors, so the difference is stated exactly. + +### 11.3 Property forms + +Secure objects' Appendix B serializes properties as a 16-bit type, a variable-length length +and the value, whatever the type's parity; MOQT -22 §8.3 serializes them with delta-coded +types, an even type's value a single integer. On a -22 object the Immutable Properties are +-22's (§4.3), which is what MOQT §10.7 keeps unchanged end to end; inside the plaintext, which +no relay reads, the Encrypted Properties List keeps Appendix B's form (§4.4). Padding is +secure objects §4.3's form, a 16-bit type and a 16-bit length; §4.3.1's processing steps +speak of a 1-byte type and a 4-byte length, and are not followed. Appendix B's own vectors are +checked with the property bytes they state, which the AEAD takes as they are. + +### 11.4 The keying draft's track key + +The keying draft §7.1 derives a track key and a track nonce from an "application secret" +and the Track Name alone. RFC 9420 gives an application one interface to its key schedule, +the exporter (§8.5), so the application secret here is an exported value (§5.7); a room's +Track Names are layer numbers that every publisher shares (live-rooms §6.1), and secure +objects §3.2 scopes a Key ID to a namespace, so the derivation is over the namespace; and +secure objects derives its own key and salt from a base key (§4.5), so the derived value is +that base key, and no nonce is derived beside it. The KID is the keying draft's §7.2 as +written. + +### 11.5 Variable-length integers + +Secure objects writes its lengths and its Key ID as the varints of the MOQT revision it was +written against. This extension writes them as MOQT -22's (§8.1), shortest, everywhere: in +the Serialized Full Track Name, the payload's length and the list's. Every value in secure +objects' Appendix B has the same bytes in both encodings, so those vectors hold unchanged. + +## 12. Security and privacy considerations + +- **Who can open.** The members of an epoch, and no one else: not a relay, not the clock, + not the room authority. A member is a principal present under a keyed grant, admitted by + the clock's proposal and a commit every member checked. The authority decides who is + present, so an authority that wants to read can admit a principal of its own, and that + principal is a member, named in the roster every member holds; the authority cannot read + without being seen to. +- **Within the group.** MLS gives every member every sender's keys (RFC 9605 §7.2: + SFrame has no per-sender authentication). A member's objects are bound to it by the + receiver's owner check (§6 step 6), by the relay's door holding each publisher's + namespace to its key, and by its signed receipts (§8.1). A grant that may publish but not + subscribe is a member, and can open what others send: subscription is a relay's + authorisation, not a line inside a group. +- **Removal.** A removed member keeps the keys of the epochs it held. It opens nothing of a + later epoch (§6 step 4), and once its removal is applied a receiver takes nothing more + sealed under its earlier KIDs (§6 step 7). Objects of earlier epochs that it received are + its own to keep. +- **Late joiners.** A member added at epoch *E* derives nothing before *E*: a Welcome carries + the epoch's secrets, not earlier ones, and objects a relay serves by FETCH from before the + join are refused `epoch_not_joined`. +- **Replay and reordering.** The AEAD binds each payload to its track, group, object, + priority and properties (secure objects §6.2); a receiver refuses a location presented + twice (§6 step 8). A relay can still drop, delay and reorder objects (secure objects §6.1); + the receipts chain, End of Group and End of Track statuses and the gap properties are how + drops are seen (secure objects §6.4, §6.5). +- **Forgery against fan-out.** A relay could try forgeries on many subscribers at once + (secure objects §6.1.1). The suites of rooms have 128-bit tags and the base keys are 256 + bits, and a receiver discards a failed object indistinguishably from one it processed. +- **Metadata.** Track names, sizes, timing, priorities, the catalog and the membership are + visible by design. Padding (§4.4) hides an object's exact size from a relay where a + deployment needs it. +- **Key reuse.** The principal's room key signs its events, its proofs of possession and its + MLS messages, each under its own domain or label (§5.2). A compromise of that key lets an + attacker speak as the principal in all three until the authority revokes the grant (and, + with it, the member's leaf at the next commit). +- **The device.** The plaintext, and every key a member derives, are in the application's + memory; Lane B is not a protected-path robustness level (SPEC §6.5). + +## 13. Reasons + +| reason | raised at | +|---|---| +| `truncated`, `malformed`, `unknown_version` | §3.3 step 1 | +| `wrong_room` | §3.3 step 2 | +| `suite_unsupported` | §3.3 step 3 | +| `mls_suite_unsupported` | §3.3 step 4 | +| `recording_refused` | §3.3 step 5, §9 | +| `bad_key`, `bad_signature` | §3.3 step 6 | +| `group_mismatch` | §5.3 | +| `credential_mismatch` | §5.2 | +| `proposal_not_clock` | §5.5 | +| `proposal_mismatch` | §5.4, §5.5 | +| `commit_not_committer` | §5.5 | +| `commit_mismatch` | §5.5 | +| `properties_malformed` | §6 step 1 | +| `key_id_missing` | §6 step 1 | +| `object_id_too_large` | §4.6, §6 step 2 | +| `epoch_not_joined` | §6 step 4 | +| `removed_before_epoch` | §6 step 4 | +| `key_id_unknown` | §6 step 5 | +| `sender_not_owner` | §6 step 6 | +| `sender_removed` | §6 step 7 | +| `replayed` | §6 step 8 | +| `bad_tag` | §4.10, §6 step 9 | +| `plaintext_malformed` | §4.4, §6 step 10 | +| `inner_digest_mismatch` | §6 step 11 | + +## 14. Conformance + +[`live-conformance/e2ee/`](../live-conformance/e2ee/) holds the corpus. `vectors.json` is +written from fixed seeds by `joule_moq_e2ee_vectors gen`; beside it, `rfc9605.json` and +`secure-objects-01.json` hold the test vectors of RFC 9605 Appendix C and of secure objects' +Appendix B as those documents print them. The corpus has: + +- **Published vectors.** Every RFC 9605 vector (header encoding, the counter-mode AEAD, and + SFrame encryption in all five suites), and every secure-objects vector (the Full Track + Name, key derivation, nonce and AAD, and encryption: exact in suites `0x0004` and `0x0005`, + and in `0x0001` and `0x0002` the difference of §11.2 stated). +- **Records**: an accepted confidentiality record, and one refused for each reason of §3.3. +- **Key schedule**: per-epoch application secrets and, for each sender and namespace, the + track base key, the KID, and each track's `moq_key` and `moq_salt`. +- **Objects**: sealed objects with every input and key stated (the Immutable Properties as + carried, the plaintext, the AAD, the nonce, the sealed payload and its content hash), with + and without an inner digest and padding, in both suites. +- **A room**: three members joining in turn, each publishing, the third removed (its grant + revoked), a fourth joining late at the third's old leaf, and the first leaving so that the + second commits, with the lifecycle, the clock's proposals, each commit and its committer, + each epoch's roster and application secret, and every sealed object delivered to its + receivers with the outcome §6 gives it: opened, with its payload's BLAKE3 hash, or refused + with its reason. Every epoch's keys open at least one object. +- **Refused rooms**: one transcript for each reason a proposal, a commit or a group's + creation raises, cut at the step it refuses. +- **Negatives**: one control for each reason of §13 an object, a transcript or a record + raises. + +A conforming implementation gives every vector its stated outcome. +`joule_moq_e2ee_vectors verify` and the crate's tests check that the file regenerates byte for +byte, that the reference gives every vector its outcome, and that §13 lists exactly the +reference's codes. `live-conformance/e2ee_verify.py` is the second implementation, written +from this text and the documents it reads, standard library only: AES (FIPS 197) and GCM (SP +800-38D) written from their specifications for vectors only, HKDF and HMAC from Python's +standard library, BLAKE3 from `claim-conformance/_blake3.py`, and Ed25519 under WAI's strict +rule from `claim-conformance/_ed25519.py`. It requires every mutated copy of the corpus to +fail. + +**The verifier's boundary.** It does not implement MLS. It takes each epoch's application +secret (§5.7) from the corpus as given and checks everything after it: the roster of each +epoch from the commits (§5.6), the committer of each commit and what it holds (§5.5), the +proposals against the room's lifecycle (§5.4), each track base key and KID, each key, salt, +nonce and AAD, every seal and open, and every refusal. That the application secrets are what +an MLS group of the room exports is checked by the reference's tests, which run the group. diff --git a/wai/extensions/moq-streaming-format.md b/wai/extensions/moq-streaming-format.md index 9223e613a..6aac407cb 100644 --- a/wai/extensions/moq-streaming-format.md +++ b/wai/extensions/moq-streaming-format.md @@ -25,9 +25,9 @@ MoQ separates two things that other stacks fuse: into objects, maps each object to a stream of its own, and declares what a publisher emits in a **catalog**. -WAI already references MoQ for confidentiality (MLS + SFrame; see -[confidentiality](confidentiality.md)). That binding treats MoQ as a pipe. This -one treats it as what it is — a two-layer design with an open slot at the upper +WAI's confidentiality extension seals MoQ objects end to end (secure objects keyed by +MLS; see [confidentiality](confidentiality.md)), and leaves what the objects mean to +this one. This one treats MoQ as what it is — a two-layer design with an open slot at the upper layer, which is precisely where a container that carries capability-dispatched objects belongs. diff --git a/wai/live-conformance/README.md b/wai/live-conformance/README.md index 5945c4943..8649c4422 100644 --- a/wai/live-conformance/README.md +++ b/wai/live-conformance/README.md @@ -14,6 +14,7 @@ and the independent verifier of each, beside it. | [`metering/`](metering/) | [live-metering](../extensions/live-metering.md) (`wai.live.metering`) | `live_metering_verify.py` | | [`steering/`](steering/) | [energy-steering](../extensions/energy-steering.md) (`wai.live.steering`) | `energy_steering_verify.py` | | [`moderation/`](moderation/) | [live-moderation](../extensions/live-moderation.md) (`wai.live.moderation`) | `live_moderation_verify.py` | +| [`e2ee/`](e2ee/) | [confidentiality](../extensions/confidentiality.md) (`wai.live.e2ee`) | `e2ee_verify.py` | **Provenance.** Generated here: `wai_live_events_vectors gen` writes `events/`, `wai_live_room_vectors gen` writes `rooms/`, `wai_capture_take_vectors gen` writes @@ -23,11 +24,15 @@ writes `recordings/`, `wai_timed_text_vectors gen` writes `captions/` and `steering/` and `wai_live_moderation_vectors gen` writes `moderation/`, each from records authored in code, from fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`, `compose/README.md`, `recordings/README.md`, `captions/README.md`, `metering/README.md`, -`steering/README.md`, `moderation/README.md`). One recorded record: -`metering/relay-record.wmh` and its ledger, which the reference relay signed in its test. +`steering/README.md`, `moderation/README.md`). `joule_moq_e2ee_vectors gen` (in +`joule-code/joule-code-rs/crates/joule-moq-mls`) writes `e2ee/vectors.json` from fixed seeds, +and `e2ee/` also holds the test vectors RFC 9605 and `draft-ietf-moq-secure-objects-01` +publish, as printed (`e2ee/README.md`). One recorded record: `metering/relay-record.wmh` and +its ledger, which the reference relay signed in its test. -Each verifier is written from its extension's text, shares no code with `wai-rs`, and is -run by `.github/workflows/wai-conformance.yml` beside the reference's own check: +Each verifier is written from its extension's text, shares no code with the reference +(`wai-rs`, or for `e2ee/`, `joule-moq` and `joule-moq-mls`), and is run by +`.github/workflows/wai-conformance.yml` beside the reference's own check: ``` python3 live_events_verify.py events @@ -39,4 +44,5 @@ python3 timed_text_verify.py captions python3 live_metering_verify.py metering python3 energy_steering_verify.py steering python3 live_moderation_verify.py moderation +python3 e2ee_verify.py e2ee ``` diff --git a/wai/live-conformance/e2ee/README.md b/wai/live-conformance/e2ee/README.md new file mode 100644 index 000000000..7eaeafec7 --- /dev/null +++ b/wai/live-conformance/e2ee/README.md @@ -0,0 +1,70 @@ +# live-conformance/e2ee — end-to-end encrypted rooms + +The corpus of [`extensions/confidentiality.md`](../../extensions/confidentiality.md) §14: +rooms whose media objects are sealed as MoQ secure objects under keys from one MLS group per +room. + +## Files + +- `vectors.json`, written by `joule_moq_e2ee_vectors gen`: + - `records`: confidentiality records (`WRE1`, §3), each with the room id, the authority's + key and the descriptor's recording mode it is checked against, and its outcome: `accept` + with its `suite`, or the reason of §3.3; + - `key_schedule`: for an epoch's application secret, a sender's leaf and a track, the + track base key, the KID, the Serialized Full Track Name, `moq_key` and `moq_salt` + (§4.2, §4.5, §5.7), in both suites and at the KID's limits; + - `objects`: sealed objects. A sealed one states its `input` (the application secret, + epoch, leaf, track, Group ID, Object ID, priority, payload, whether it carries an inner + digest, padding, other encrypted entries, other Immutable Properties), every `derived` + value (track base key, KID, Full Track Name, key, salt, Immutable Properties, plaintext, + AAD, nonce), the `object` as a -22 object carries it (its property list and sealed + payload, hexadecimal), and its `content_hash`, the BLAKE3 hash a receipt commits to. The + negatives are controls of one defect each. Each is opened by the receiver its `context` + names and has its `outcome`: `opened` with the BLAKE3 hash of its original payload, or a + reason of §6; + - `rooms`: transcripts, each a list of `steps` (a lifecycle `event`, the group's `create`, + a `proposal`, a `commit` with its sender's leaf, the indexes of the epoch's proposals it + holds and the new epoch's application secret, or an `object` with its `deliveries`), and + the outcome a replay gives it (§5.4 to §5.6, §6): `accept` or `refuse` with the reason and + the step, each epoch's roster, and each delivery's outcome; + - `codes`: the reasons of §13, in its order. +- `rfc9605.json`: RFC 9605 Appendix C, its header, AES-CTR with HMAC, and SFrame encryption + vectors. +- `secure-objects-01.json`: `draft-ietf-moq-secure-objects-01` Appendix B, its Full Track + Name, key derivation, nonce and AAD, and encryption vectors. + +**Equivalence.** A conforming implementation gives every record, object and delivery its +outcome, every room its outcome, rosters and deliveries, and every key schedule entry and +derived value the bytes stated; it reproduces RFC 9605's vectors, and secure objects' exactly +in suites `0x0004` and `0x0005`, with the difference §11.2 states in `0x0001` and `0x0002`. + +**The boundary.** Each epoch's application secret is given, standing for +`MLS-Exporter("wai live e2ee", room_id, 32)` (§5.7); everything after it is checked. That a +real MLS group exports them, and that its rosters are the replay's, is checked by +`joule-moq-mls`'s tests, which run the groups. + +**Provenance.** Generated here: `joule_moq_e2ee_vectors gen` writes `vectors.json` from rooms +authored in code, from fixed seeds: every key is an Ed25519 key whose seed is SHA-256 of +`wai:e2ee/corpus/key`, a zero byte and a label; every application secret SHA-256 of +`wai:e2ee/corpus/secret`, a zero byte, the room's label, the generation and the epoch; every +payload SHA-256 in counter mode over `wai:e2ee/corpus/bytes` and a label; the room id SHA-256 +of `wai:e2ee/corpus/room` and a label. AES-GCM and Ed25519 signing are deterministic, so the +file is the same bytes on every machine. No recorded data. `rfc9605.json` and +`secure-objects-01.json` are the test vectors those documents print, copied with the +whitespace inside each value removed and each document's numbers as JSON numbers; nothing in +them is generated here. + +## Checking + +``` +cd joule-code/joule-code-rs +cargo run -p joule-moq-mls --bin joule_moq_e2ee_vectors verify ../../wai/live-conformance/e2ee +cargo test -p joule-moq --features secure-objects +cargo test -p joule-moq-mls + +cd wai +python3 -I live-conformance/e2ee_verify.py live-conformance/e2ee +``` + +`e2ee_verify.py` is the second implementation, written from the text with the standard +library alone; it writes AES and GCM itself, for these vectors only. diff --git a/wai/live-conformance/e2ee/rfc9605.json b/wai/live-conformance/e2ee/rfc9605.json new file mode 100644 index 000000000..68e5649c6 --- /dev/null +++ b/wai/live-conformance/e2ee/rfc9605.json @@ -0,0 +1,1564 @@ +{ + "source": "RFC 9605 Appendix C (C.1 header encoding, C.2 AES-CTR with HMAC, C.3 SFrame encryption), values as printed, whitespace inside values removed; numeric values as JSON numbers, byte strings as hexadecimal", + "header": [ + { + "kid": 0, + "ctr": 0, + "header": "00" + }, + { + "kid": 0, + "ctr": 1, + "header": "01" + }, + { + "kid": 0, + "ctr": 255, + "header": "08ff" + }, + { + "kid": 0, + "ctr": 256, + "header": "090100" + }, + { + "kid": 0, + "ctr": 65535, + "header": "09ffff" + }, + { + "kid": 0, + "ctr": 65536, + "header": "0a010000" + }, + { + "kid": 0, + "ctr": 16777215, + "header": "0affffff" + }, + { + "kid": 0, + "ctr": 16777216, + "header": "0b01000000" + }, + { + "kid": 0, + "ctr": 4294967295, + "header": "0bffffffff" + }, + { + "kid": 0, + "ctr": 4294967296, + "header": "0c0100000000" + }, + { + "kid": 0, + "ctr": 1099511627775, + "header": "0cffffffffff" + }, + { + "kid": 0, + "ctr": 1099511627776, + "header": "0d010000000000" + }, + { + "kid": 0, + "ctr": 281474976710655, + "header": "0dffffffffffff" + }, + { + "kid": 0, + "ctr": 281474976710656, + "header": "0e01000000000000" + }, + { + "kid": 0, + "ctr": 72057594037927935, + "header": "0effffffffffffff" + }, + { + "kid": 0, + "ctr": 72057594037927936, + "header": "0f0100000000000000" + }, + { + "kid": 0, + "ctr": 18446744073709551615, + "header": "0fffffffffffffffff" + }, + { + "kid": 1, + "ctr": 0, + "header": "10" + }, + { + "kid": 1, + "ctr": 1, + "header": "11" + }, + { + "kid": 1, + "ctr": 255, + "header": "18ff" + }, + { + "kid": 1, + "ctr": 256, + "header": "190100" + }, + { + "kid": 1, + "ctr": 65535, + "header": "19ffff" + }, + { + "kid": 1, + "ctr": 65536, + "header": "1a010000" + }, + { + "kid": 1, + "ctr": 16777215, + "header": "1affffff" + }, + { + "kid": 1, + "ctr": 16777216, + "header": "1b01000000" + }, + { + "kid": 1, + "ctr": 4294967295, + "header": "1bffffffff" + }, + { + "kid": 1, + "ctr": 4294967296, + "header": "1c0100000000" + }, + { + "kid": 1, + "ctr": 1099511627775, + "header": "1cffffffffff" + }, + { + "kid": 1, + "ctr": 1099511627776, + "header": "1d010000000000" + }, + { + "kid": 1, + "ctr": 281474976710655, + "header": "1dffffffffffff" + }, + { + "kid": 1, + "ctr": 281474976710656, + "header": "1e01000000000000" + }, + { + "kid": 1, + "ctr": 72057594037927935, + "header": "1effffffffffffff" + }, + { + "kid": 1, + "ctr": 72057594037927936, + "header": "1f0100000000000000" + }, + { + "kid": 1, + "ctr": 18446744073709551615, + "header": "1fffffffffffffffff" + }, + { + "kid": 255, + "ctr": 0, + "header": "80ff" + }, + { + "kid": 255, + "ctr": 1, + "header": "81ff" + }, + { + "kid": 255, + "ctr": 255, + "header": "88ffff" + }, + { + "kid": 255, + "ctr": 256, + "header": "89ff0100" + }, + { + "kid": 255, + "ctr": 65535, + "header": "89ffffff" + }, + { + "kid": 255, + "ctr": 65536, + "header": "8aff010000" + }, + { + "kid": 255, + "ctr": 16777215, + "header": "8affffffff" + }, + { + "kid": 255, + "ctr": 16777216, + "header": "8bff01000000" + }, + { + "kid": 255, + "ctr": 4294967295, + "header": "8bffffffffff" + }, + { + "kid": 255, + "ctr": 4294967296, + "header": "8cff0100000000" + }, + { + "kid": 255, + "ctr": 1099511627775, + "header": "8cffffffffffff" + }, + { + "kid": 255, + "ctr": 1099511627776, + "header": "8dff010000000000" + }, + { + "kid": 255, + "ctr": 281474976710655, + "header": "8dffffffffffffff" + }, + { + "kid": 255, + "ctr": 281474976710656, + "header": "8eff01000000000000" + }, + { + "kid": 255, + "ctr": 72057594037927935, + "header": "8effffffffffffffff" + }, + { + "kid": 255, + "ctr": 72057594037927936, + "header": "8fff0100000000000000" + }, + { + "kid": 255, + "ctr": 18446744073709551615, + "header": "8fffffffffffffffffff" + }, + { + "kid": 256, + "ctr": 0, + "header": "900100" + }, + { + "kid": 256, + "ctr": 1, + "header": "910100" + }, + { + "kid": 256, + "ctr": 255, + "header": "980100ff" + }, + { + "kid": 256, + "ctr": 256, + "header": "9901000100" + }, + { + "kid": 256, + "ctr": 65535, + "header": "990100ffff" + }, + { + "kid": 256, + "ctr": 65536, + "header": "9a0100010000" + }, + { + "kid": 256, + "ctr": 16777215, + "header": "9a0100ffffff" + }, + { + "kid": 256, + "ctr": 16777216, + "header": "9b010001000000" + }, + { + "kid": 256, + "ctr": 4294967295, + "header": "9b0100ffffffff" + }, + { + "kid": 256, + "ctr": 4294967296, + "header": "9c01000100000000" + }, + { + "kid": 256, + "ctr": 1099511627775, + "header": "9c0100ffffffffff" + }, + { + "kid": 256, + "ctr": 1099511627776, + "header": "9d0100010000000000" + }, + { + "kid": 256, + "ctr": 281474976710655, + "header": "9d0100ffffffffffff" + }, + { + "kid": 256, + "ctr": 281474976710656, + "header": "9e010001000000000000" + }, + { + "kid": 256, + "ctr": 72057594037927935, + "header": "9e0100ffffffffffffff" + }, + { + "kid": 256, + "ctr": 72057594037927936, + "header": "9f01000100000000000000" + }, + { + "kid": 256, + "ctr": 18446744073709551615, + "header": "9f0100ffffffffffffffff" + }, + { + "kid": 65535, + "ctr": 0, + "header": "90ffff" + }, + { + "kid": 65535, + "ctr": 1, + "header": "91ffff" + }, + { + "kid": 65535, + "ctr": 255, + "header": "98ffffff" + }, + { + "kid": 65535, + "ctr": 256, + "header": "99ffff0100" + }, + { + "kid": 65535, + "ctr": 65535, + "header": "99ffffffff" + }, + { + "kid": 65535, + "ctr": 65536, + "header": "9affff010000" + }, + { + "kid": 65535, + "ctr": 16777215, + "header": "9affffffffff" + }, + { + "kid": 65535, + "ctr": 16777216, + "header": "9bffff01000000" + }, + { + "kid": 65535, + "ctr": 4294967295, + "header": "9bffffffffffff" + }, + { + "kid": 65535, + "ctr": 4294967296, + "header": "9cffff0100000000" + }, + { + "kid": 65535, + "ctr": 1099511627775, + "header": "9cffffffffffffff" + }, + { + "kid": 65535, + "ctr": 1099511627776, + "header": "9dffff010000000000" + }, + { + "kid": 65535, + "ctr": 281474976710655, + "header": "9dffffffffffffffff" + }, + { + "kid": 65535, + "ctr": 281474976710656, + "header": "9effff01000000000000" + }, + { + "kid": 65535, + "ctr": 72057594037927935, + "header": "9effffffffffffffffff" + }, + { + "kid": 65535, + "ctr": 72057594037927936, + "header": "9fffff0100000000000000" + }, + { + "kid": 65535, + "ctr": 18446744073709551615, + "header": "9fffffffffffffffffffff" + }, + { + "kid": 65536, + "ctr": 0, + "header": "a0010000" + }, + { + "kid": 65536, + "ctr": 1, + "header": "a1010000" + }, + { + "kid": 65536, + "ctr": 255, + "header": "a8010000ff" + }, + { + "kid": 65536, + "ctr": 256, + "header": "a90100000100" + }, + { + "kid": 65536, + "ctr": 65535, + "header": "a9010000ffff" + }, + { + "kid": 65536, + "ctr": 65536, + "header": "aa010000010000" + }, + { + "kid": 65536, + "ctr": 16777215, + "header": "aa010000ffffff" + }, + { + "kid": 65536, + "ctr": 16777216, + "header": "ab01000001000000" + }, + { + "kid": 65536, + "ctr": 4294967295, + "header": "ab010000ffffffff" + }, + { + "kid": 65536, + "ctr": 4294967296, + "header": "ac0100000100000000" + }, + { + "kid": 65536, + "ctr": 1099511627775, + "header": "ac010000ffffffffff" + }, + { + "kid": 65536, + "ctr": 1099511627776, + "header": "ad010000010000000000" + }, + { + "kid": 65536, + "ctr": 281474976710655, + "header": "ad010000ffffffffffff" + }, + { + "kid": 65536, + "ctr": 281474976710656, + "header": "ae01000001000000000000" + }, + { + "kid": 65536, + "ctr": 72057594037927935, + "header": "ae010000ffffffffffffff" + }, + { + "kid": 65536, + "ctr": 72057594037927936, + "header": "af0100000100000000000000" + }, + { + "kid": 65536, + "ctr": 18446744073709551615, + "header": "af010000ffffffffffffffff" + }, + { + "kid": 16777215, + "ctr": 0, + "header": "a0ffffff" + }, + { + "kid": 16777215, + "ctr": 1, + "header": "a1ffffff" + }, + { + "kid": 16777215, + "ctr": 255, + "header": "a8ffffffff" + }, + { + "kid": 16777215, + "ctr": 256, + "header": "a9ffffff0100" + }, + { + "kid": 16777215, + "ctr": 65535, + "header": "a9ffffffffff" + }, + { + "kid": 16777215, + "ctr": 65536, + "header": "aaffffff010000" + }, + { + "kid": 16777215, + "ctr": 16777215, + "header": "aaffffffffffff" + }, + { + "kid": 16777215, + "ctr": 16777216, + "header": "abffffff01000000" + }, + { + "kid": 16777215, + "ctr": 4294967295, + "header": "abffffffffffffff" + }, + { + "kid": 16777215, + "ctr": 4294967296, + "header": "acffffff0100000000" + }, + { + "kid": 16777215, + "ctr": 1099511627775, + "header": "acffffffffffffffff" + }, + { + "kid": 16777215, + "ctr": 1099511627776, + "header": "adffffff010000000000" + }, + { + "kid": 16777215, + "ctr": 281474976710655, + "header": "adffffffffffffffffff" + }, + { + "kid": 16777215, + "ctr": 281474976710656, + "header": "aeffffff01000000000000" + }, + { + "kid": 16777215, + "ctr": 72057594037927935, + "header": "aeffffffffffffffffffff" + }, + { + "kid": 16777215, + "ctr": 72057594037927936, + "header": "afffffff0100000000000000" + }, + { + "kid": 16777215, + "ctr": 18446744073709551615, + "header": "afffffffffffffffffffffff" + }, + { + "kid": 16777216, + "ctr": 0, + "header": "b001000000" + }, + { + "kid": 16777216, + "ctr": 1, + "header": "b101000000" + }, + { + "kid": 16777216, + "ctr": 255, + "header": "b801000000ff" + }, + { + "kid": 16777216, + "ctr": 256, + "header": "b9010000000100" + }, + { + "kid": 16777216, + "ctr": 65535, + "header": "b901000000ffff" + }, + { + "kid": 16777216, + "ctr": 65536, + "header": "ba01000000010000" + }, + { + "kid": 16777216, + "ctr": 16777215, + "header": "ba01000000ffffff" + }, + { + "kid": 16777216, + "ctr": 16777216, + "header": "bb0100000001000000" + }, + { + "kid": 16777216, + "ctr": 4294967295, + "header": "bb01000000ffffffff" + }, + { + "kid": 16777216, + "ctr": 4294967296, + "header": "bc010000000100000000" + }, + { + "kid": 16777216, + "ctr": 1099511627775, + "header": "bc01000000ffffffffff" + }, + { + "kid": 16777216, + "ctr": 1099511627776, + "header": "bd01000000010000000000" + }, + { + "kid": 16777216, + "ctr": 281474976710655, + "header": "bd01000000ffffffffffff" + }, + { + "kid": 16777216, + "ctr": 281474976710656, + "header": "be0100000001000000000000" + }, + { + "kid": 16777216, + "ctr": 72057594037927935, + "header": "be01000000ffffffffffffff" + }, + { + "kid": 16777216, + "ctr": 72057594037927936, + "header": "bf010000000100000000000000" + }, + { + "kid": 16777216, + "ctr": 18446744073709551615, + "header": "bf01000000ffffffffffffffff" + }, + { + "kid": 4294967295, + "ctr": 0, + "header": "b0ffffffff" + }, + { + "kid": 4294967295, + "ctr": 1, + "header": "b1ffffffff" + }, + { + "kid": 4294967295, + "ctr": 255, + "header": "b8ffffffffff" + }, + { + "kid": 4294967295, + "ctr": 256, + "header": "b9ffffffff0100" + }, + { + "kid": 4294967295, + "ctr": 65535, + "header": "b9ffffffffffff" + }, + { + "kid": 4294967295, + "ctr": 65536, + "header": "baffffffff010000" + }, + { + "kid": 4294967295, + "ctr": 16777215, + "header": "baffffffffffffff" + }, + { + "kid": 4294967295, + "ctr": 16777216, + "header": "bbffffffff01000000" + }, + { + "kid": 4294967295, + "ctr": 4294967295, + "header": "bbffffffffffffffff" + }, + { + "kid": 4294967295, + "ctr": 4294967296, + "header": "bcffffffff0100000000" + }, + { + "kid": 4294967295, + "ctr": 1099511627775, + "header": "bcffffffffffffffffff" + }, + { + "kid": 4294967295, + "ctr": 1099511627776, + "header": "bdffffffff010000000000" + }, + { + "kid": 4294967295, + "ctr": 281474976710655, + "header": "bdffffffffffffffffffff" + }, + { + "kid": 4294967295, + "ctr": 281474976710656, + "header": "beffffffff01000000000000" + }, + { + "kid": 4294967295, + "ctr": 72057594037927935, + "header": "beffffffffffffffffffffff" + }, + { + "kid": 4294967295, + "ctr": 72057594037927936, + "header": "bfffffffff0100000000000000" + }, + { + "kid": 4294967295, + "ctr": 18446744073709551615, + "header": "bfffffffffffffffffffffffff" + }, + { + "kid": 4294967296, + "ctr": 0, + "header": "c00100000000" + }, + { + "kid": 4294967296, + "ctr": 1, + "header": "c10100000000" + }, + { + "kid": 4294967296, + "ctr": 255, + "header": "c80100000000ff" + }, + { + "kid": 4294967296, + "ctr": 256, + "header": "c901000000000100" + }, + { + "kid": 4294967296, + "ctr": 65535, + "header": "c90100000000ffff" + }, + { + "kid": 4294967296, + "ctr": 65536, + "header": "ca0100000000010000" + }, + { + "kid": 4294967296, + "ctr": 16777215, + "header": "ca0100000000ffffff" + }, + { + "kid": 4294967296, + "ctr": 16777216, + "header": "cb010000000001000000" + }, + { + "kid": 4294967296, + "ctr": 4294967295, + "header": "cb0100000000ffffffff" + }, + { + "kid": 4294967296, + "ctr": 4294967296, + "header": "cc01000000000100000000" + }, + { + "kid": 4294967296, + "ctr": 1099511627775, + "header": "cc0100000000ffffffffff" + }, + { + "kid": 4294967296, + "ctr": 1099511627776, + "header": "cd0100000000010000000000" + }, + { + "kid": 4294967296, + "ctr": 281474976710655, + "header": "cd0100000000ffffffffffff" + }, + { + "kid": 4294967296, + "ctr": 281474976710656, + "header": "ce010000000001000000000000" + }, + { + "kid": 4294967296, + "ctr": 72057594037927935, + "header": "ce0100000000ffffffffffffff" + }, + { + "kid": 4294967296, + "ctr": 72057594037927936, + "header": "cf01000000000100000000000000" + }, + { + "kid": 4294967296, + "ctr": 18446744073709551615, + "header": "cf0100000000ffffffffffffffff" + }, + { + "kid": 1099511627775, + "ctr": 0, + "header": "c0ffffffffff" + }, + { + "kid": 1099511627775, + "ctr": 1, + "header": "c1ffffffffff" + }, + { + "kid": 1099511627775, + "ctr": 255, + "header": "c8ffffffffffff" + }, + { + "kid": 1099511627775, + "ctr": 256, + "header": "c9ffffffffff0100" + }, + { + "kid": 1099511627775, + "ctr": 65535, + "header": "c9ffffffffffffff" + }, + { + "kid": 1099511627775, + "ctr": 65536, + "header": "caffffffffff010000" + }, + { + "kid": 1099511627775, + "ctr": 16777215, + "header": "caffffffffffffffff" + }, + { + "kid": 1099511627775, + "ctr": 16777216, + "header": "cbffffffffff01000000" + }, + { + "kid": 1099511627775, + "ctr": 4294967295, + "header": "cbffffffffffffffffff" + }, + { + "kid": 1099511627775, + "ctr": 4294967296, + "header": "ccffffffffff0100000000" + }, + { + "kid": 1099511627775, + "ctr": 1099511627775, + "header": "ccffffffffffffffffffff" + }, + { + "kid": 1099511627775, + "ctr": 1099511627776, + "header": "cdffffffffff010000000000" + }, + { + "kid": 1099511627775, + "ctr": 281474976710655, + "header": "cdffffffffffffffffffffff" + }, + { + "kid": 1099511627775, + "ctr": 281474976710656, + "header": "ceffffffffff01000000000000" + }, + { + "kid": 1099511627775, + "ctr": 72057594037927935, + "header": "ceffffffffffffffffffffffff" + }, + { + "kid": 1099511627775, + "ctr": 72057594037927936, + "header": "cfffffffffff0100000000000000" + }, + { + "kid": 1099511627775, + "ctr": 18446744073709551615, + "header": "cfffffffffffffffffffffffffff" + }, + { + "kid": 1099511627776, + "ctr": 0, + "header": "d0010000000000" + }, + { + "kid": 1099511627776, + "ctr": 1, + "header": "d1010000000000" + }, + { + "kid": 1099511627776, + "ctr": 255, + "header": "d8010000000000ff" + }, + { + "kid": 1099511627776, + "ctr": 256, + "header": "d90100000000000100" + }, + { + "kid": 1099511627776, + "ctr": 65535, + "header": "d9010000000000ffff" + }, + { + "kid": 1099511627776, + "ctr": 65536, + "header": "da010000000000010000" + }, + { + "kid": 1099511627776, + "ctr": 16777215, + "header": "da010000000000ffffff" + }, + { + "kid": 1099511627776, + "ctr": 16777216, + "header": "db01000000000001000000" + }, + { + "kid": 1099511627776, + "ctr": 4294967295, + "header": "db010000000000ffffffff" + }, + { + "kid": 1099511627776, + "ctr": 4294967296, + "header": "dc0100000000000100000000" + }, + { + "kid": 1099511627776, + "ctr": 1099511627775, + "header": "dc010000000000ffffffffff" + }, + { + "kid": 1099511627776, + "ctr": 1099511627776, + "header": "dd010000000000010000000000" + }, + { + "kid": 1099511627776, + "ctr": 281474976710655, + "header": "dd010000000000ffffffffffff" + }, + { + "kid": 1099511627776, + "ctr": 281474976710656, + "header": "de01000000000001000000000000" + }, + { + "kid": 1099511627776, + "ctr": 72057594037927935, + "header": "de010000000000ffffffffffffff" + }, + { + "kid": 1099511627776, + "ctr": 72057594037927936, + "header": "df0100000000000100000000000000" + }, + { + "kid": 1099511627776, + "ctr": 18446744073709551615, + "header": "df010000000000ffffffffffffffff" + }, + { + "kid": 281474976710655, + "ctr": 0, + "header": "d0ffffffffffff" + }, + { + "kid": 281474976710655, + "ctr": 1, + "header": "d1ffffffffffff" + }, + { + "kid": 281474976710655, + "ctr": 255, + "header": "d8ffffffffffffff" + }, + { + "kid": 281474976710655, + "ctr": 256, + "header": "d9ffffffffffff0100" + }, + { + "kid": 281474976710655, + "ctr": 65535, + "header": "d9ffffffffffffffff" + }, + { + "kid": 281474976710655, + "ctr": 65536, + "header": "daffffffffffff010000" + }, + { + "kid": 281474976710655, + "ctr": 16777215, + "header": "daffffffffffffffffff" + }, + { + "kid": 281474976710655, + "ctr": 16777216, + "header": "dbffffffffffff01000000" + }, + { + "kid": 281474976710655, + "ctr": 4294967295, + "header": "dbffffffffffffffffffff" + }, + { + "kid": 281474976710655, + "ctr": 4294967296, + "header": "dcffffffffffff0100000000" + }, + { + "kid": 281474976710655, + "ctr": 1099511627775, + "header": "dcffffffffffffffffffffff" + }, + { + "kid": 281474976710655, + "ctr": 1099511627776, + "header": "ddffffffffffff010000000000" + }, + { + "kid": 281474976710655, + "ctr": 281474976710655, + "header": "ddffffffffffffffffffffffff" + }, + { + "kid": 281474976710655, + "ctr": 281474976710656, + "header": "deffffffffffff01000000000000" + }, + { + "kid": 281474976710655, + "ctr": 72057594037927935, + "header": "deffffffffffffffffffffffffff" + }, + { + "kid": 281474976710655, + "ctr": 72057594037927936, + "header": "dfffffffffffff0100000000000000" + }, + { + "kid": 281474976710655, + "ctr": 18446744073709551615, + "header": "dfffffffffffffffffffffffffffff" + }, + { + "kid": 281474976710656, + "ctr": 0, + "header": "e001000000000000" + }, + { + "kid": 281474976710656, + "ctr": 1, + "header": "e101000000000000" + }, + { + "kid": 281474976710656, + "ctr": 255, + "header": "e801000000000000ff" + }, + { + "kid": 281474976710656, + "ctr": 256, + "header": "e9010000000000000100" + }, + { + "kid": 281474976710656, + "ctr": 65535, + "header": "e901000000000000ffff" + }, + { + "kid": 281474976710656, + "ctr": 65536, + "header": "ea01000000000000010000" + }, + { + "kid": 281474976710656, + "ctr": 16777215, + "header": "ea01000000000000ffffff" + }, + { + "kid": 281474976710656, + "ctr": 16777216, + "header": "eb0100000000000001000000" + }, + { + "kid": 281474976710656, + "ctr": 4294967295, + "header": "eb01000000000000ffffffff" + }, + { + "kid": 281474976710656, + "ctr": 4294967296, + "header": "ec010000000000000100000000" + }, + { + "kid": 281474976710656, + "ctr": 1099511627775, + "header": "ec01000000000000ffffffffff" + }, + { + "kid": 281474976710656, + "ctr": 1099511627776, + "header": "ed01000000000000010000000000" + }, + { + "kid": 281474976710656, + "ctr": 281474976710655, + "header": "ed01000000000000ffffffffffff" + }, + { + "kid": 281474976710656, + "ctr": 281474976710656, + "header": "ee0100000000000001000000000000" + }, + { + "kid": 281474976710656, + "ctr": 72057594037927935, + "header": "ee01000000000000ffffffffffffff" + }, + { + "kid": 281474976710656, + "ctr": 72057594037927936, + "header": "ef010000000000000100000000000000" + }, + { + "kid": 281474976710656, + "ctr": 18446744073709551615, + "header": "ef01000000000000ffffffffffffffff" + }, + { + "kid": 72057594037927935, + "ctr": 0, + "header": "e0ffffffffffffff" + }, + { + "kid": 72057594037927935, + "ctr": 1, + "header": "e1ffffffffffffff" + }, + { + "kid": 72057594037927935, + "ctr": 255, + "header": "e8ffffffffffffffff" + }, + { + "kid": 72057594037927935, + "ctr": 256, + "header": "e9ffffffffffffff0100" + }, + { + "kid": 72057594037927935, + "ctr": 65535, + "header": "e9ffffffffffffffffff" + }, + { + "kid": 72057594037927935, + "ctr": 65536, + "header": "eaffffffffffffff010000" + }, + { + "kid": 72057594037927935, + "ctr": 16777215, + "header": "eaffffffffffffffffffff" + }, + { + "kid": 72057594037927935, + "ctr": 16777216, + "header": "ebffffffffffffff01000000" + }, + { + "kid": 72057594037927935, + "ctr": 4294967295, + "header": "ebffffffffffffffffffffff" + }, + { + "kid": 72057594037927935, + "ctr": 4294967296, + "header": "ecffffffffffffff0100000000" + }, + { + "kid": 72057594037927935, + "ctr": 1099511627775, + "header": "ecffffffffffffffffffffffff" + }, + { + "kid": 72057594037927935, + "ctr": 1099511627776, + "header": "edffffffffffffff010000000000" + }, + { + "kid": 72057594037927935, + "ctr": 281474976710655, + "header": "edffffffffffffffffffffffffff" + }, + { + "kid": 72057594037927935, + "ctr": 281474976710656, + "header": "eeffffffffffffff01000000000000" + }, + { + "kid": 72057594037927935, + "ctr": 72057594037927935, + "header": "eeffffffffffffffffffffffffffff" + }, + { + "kid": 72057594037927935, + "ctr": 72057594037927936, + "header": "efffffffffffffff0100000000000000" + }, + { + "kid": 72057594037927935, + "ctr": 18446744073709551615, + "header": "efffffffffffffffffffffffffffffff" + }, + { + "kid": 72057594037927936, + "ctr": 0, + "header": "f00100000000000000" + }, + { + "kid": 72057594037927936, + "ctr": 1, + "header": "f10100000000000000" + }, + { + "kid": 72057594037927936, + "ctr": 255, + "header": "f80100000000000000ff" + }, + { + "kid": 72057594037927936, + "ctr": 256, + "header": "f901000000000000000100" + }, + { + "kid": 72057594037927936, + "ctr": 65535, + "header": "f90100000000000000ffff" + }, + { + "kid": 72057594037927936, + "ctr": 65536, + "header": "fa0100000000000000010000" + }, + { + "kid": 72057594037927936, + "ctr": 16777215, + "header": "fa0100000000000000ffffff" + }, + { + "kid": 72057594037927936, + "ctr": 16777216, + "header": "fb010000000000000001000000" + }, + { + "kid": 72057594037927936, + "ctr": 4294967295, + "header": "fb0100000000000000ffffffff" + }, + { + "kid": 72057594037927936, + "ctr": 4294967296, + "header": "fc01000000000000000100000000" + }, + { + "kid": 72057594037927936, + "ctr": 1099511627775, + "header": "fc0100000000000000ffffffffff" + }, + { + "kid": 72057594037927936, + "ctr": 1099511627776, + "header": "fd0100000000000000010000000000" + }, + { + "kid": 72057594037927936, + "ctr": 281474976710655, + "header": "fd0100000000000000ffffffffffff" + }, + { + "kid": 72057594037927936, + "ctr": 281474976710656, + "header": "fe010000000000000001000000000000" + }, + { + "kid": 72057594037927936, + "ctr": 72057594037927935, + "header": "fe0100000000000000ffffffffffffff" + }, + { + "kid": 72057594037927936, + "ctr": 72057594037927936, + "header": "ff01000000000000000100000000000000" + }, + { + "kid": 72057594037927936, + "ctr": 18446744073709551615, + "header": "ff0100000000000000ffffffffffffffff" + }, + { + "kid": 18446744073709551615, + "ctr": 0, + "header": "f0ffffffffffffffff" + }, + { + "kid": 18446744073709551615, + "ctr": 1, + "header": "f1ffffffffffffffff" + }, + { + "kid": 18446744073709551615, + "ctr": 255, + "header": "f8ffffffffffffffffff" + }, + { + "kid": 18446744073709551615, + "ctr": 256, + "header": "f9ffffffffffffffff0100" + }, + { + "kid": 18446744073709551615, + "ctr": 65535, + "header": "f9ffffffffffffffffffff" + }, + { + "kid": 18446744073709551615, + "ctr": 65536, + "header": "faffffffffffffffff010000" + }, + { + "kid": 18446744073709551615, + "ctr": 16777215, + "header": "faffffffffffffffffffffff" + }, + { + "kid": 18446744073709551615, + "ctr": 16777216, + "header": "fbffffffffffffffff01000000" + }, + { + "kid": 18446744073709551615, + "ctr": 4294967295, + "header": "fbffffffffffffffffffffffff" + }, + { + "kid": 18446744073709551615, + "ctr": 4294967296, + "header": "fcffffffffffffffff0100000000" + }, + { + "kid": 18446744073709551615, + "ctr": 1099511627775, + "header": "fcffffffffffffffffffffffffff" + }, + { + "kid": 18446744073709551615, + "ctr": 1099511627776, + "header": "fdffffffffffffffff010000000000" + }, + { + "kid": 18446744073709551615, + "ctr": 281474976710655, + "header": "fdffffffffffffffffffffffffffff" + }, + { + "kid": 18446744073709551615, + "ctr": 281474976710656, + "header": "feffffffffffffffff01000000000000" + }, + { + "kid": 18446744073709551615, + "ctr": 72057594037927935, + "header": "feffffffffffffffffffffffffffffff" + }, + { + "kid": 18446744073709551615, + "ctr": 72057594037927936, + "header": "ffffffffffffffffff0100000000000000" + }, + { + "kid": 18446744073709551615, + "ctr": 18446744073709551615, + "header": "ffffffffffffffffffffffffffffffffff" + } + ], + "aead": [ + { + "cipher_suite": 1, + "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f", + "enc_key": "000102030405060708090a0b0c0d0e0f", + "auth_key": "101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f", + "nonce": "101112131415161718191a1b", + "aad": "4945544620534672616d65205747", + "pt": "64726166742d696574662d736672616d652d656e63", + "ct": "6339af04ada1d064688a442b8dc69d5b6bfa40f4bef0583e8081069cc60705" + }, + { + "cipher_suite": 2, + "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f", + "enc_key": "000102030405060708090a0b0c0d0e0f", + "auth_key": "101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f", + "nonce": "101112131415161718191a1b", + "aad": "4945544620534672616d65205747", + "pt": "64726166742d696574662d736672616d652d656e63", + "ct": "6339af04ada1d064688a442b8dc69d5b6bfa40f4be6e93b7da076927bb" + }, + { + "cipher_suite": 3, + "key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f", + "enc_key": "000102030405060708090a0b0c0d0e0f", + "auth_key": "101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f", + "nonce": "101112131415161718191a1b", + "aad": "4945544620534672616d65205747", + "pt": "64726166742d696574662d736672616d652d656e63", + "ct": "6339af04ada1d064688a442b8dc69d5b6bfa40f4be09480509" + } + ], + "sframe": [ + { + "cipher_suite": 1, + "kid": 291, + "ctr": 17767, + "base_key": "000102030405060708090a0b0c0d0e0f", + "sframe_key_label": "534672616d6520312e3020536563726574206b65792000000000000001230001", + "sframe_salt_label": "534672616d6520312e30205365637265742073616c742000000000000001230001", + "sframe_secret": "d926952ca8b7ec4a95941d1ada3a5203ceff8cceee34f574d23909eb314c40c0", + "sframe_key": "3f7d9a7c83ae8e1c8a11ae695ab59314b367e359fadac7b9c46b2bc6f81f46e16b96f0811868d59402b7e870102720b3", + "sframe_salt": "50b29329a04dc0f184ac3168", + "metadata": "4945544620534672616d65205747", + "nonce": "50b29329a04dc0f184ac740f", + "aad": "99012345674945544620534672616d65205747", + "pt": "64726166742d696574662d736672616d652d656e63", + "ct": "9901234567449408b6f490086165b9d6f62b24ae1a59a56486b4ae8ed036b88912e24f11" + }, + { + "cipher_suite": 2, + "kid": 291, + "ctr": 17767, + "base_key": "000102030405060708090a0b0c0d0e0f", + "sframe_key_label": "534672616d6520312e3020536563726574206b65792000000000000001230002", + "sframe_salt_label": "534672616d6520312e30205365637265742073616c742000000000000001230002", + "sframe_secret": "d926952ca8b7ec4a95941d1ada3a5203ceff8cceee34f574d23909eb314c40c0", + "sframe_key": "e2ec5c797540310483b16bf6e7a570d2a27d192fe869c7ccd8584a8d9dab91549fbe553f5113461ec6aa83bf3865553e", + "sframe_salt": "e68ac8dd3d02fbcd368c5577", + "metadata": "4945544620534672616d65205747", + "nonce": "e68ac8dd3d02fbcd368c1010", + "aad": "99012345674945544620534672616d65205747", + "pt": "64726166742d696574662d736672616d652d656e63", + "ct": "99012345673f31438db4d09434e43afa0f8a2f00867a2be085046a9f5cb4f101d607" + }, + { + "cipher_suite": 3, + "kid": 291, + "ctr": 17767, + "base_key": "000102030405060708090a0b0c0d0e0f", + "sframe_key_label": "534672616d6520312e3020536563726574206b65792000000000000001230003", + "sframe_salt_label": "534672616d6520312e30205365637265742073616c742000000000000001230003", + "sframe_secret": "d926952ca8b7ec4a95941d1ada3a5203ceff8cceee34f574d23909eb314c40c0", + "sframe_key": "2c5703089cbb8c583475e4fc461d97d18809df79b6d550f78eb6d50ffa80d89211d57909934f46f5405e38cd583c69fe", + "sframe_salt": "38c16e4f5159700c00c7f350", + "metadata": "4945544620534672616d65205747", + "nonce": "38c16e4f5159700c00c7b637", + "aad": "99012345674945544620534672616d65205747", + "pt": "64726166742d696574662d736672616d652d656e63", + "ct": "990123456717fc8af28a5a695afcfc6c8df6358a17e26b2fcb3bae32e443" + }, + { + "cipher_suite": 4, + "kid": 291, + "ctr": 17767, + "base_key": "000102030405060708090a0b0c0d0e0f", + "sframe_key_label": "534672616d6520312e3020536563726574206b65792000000000000001230004", + "sframe_salt_label": "534672616d6520312e30205365637265742073616c742000000000000001230004", + "sframe_secret": "d926952ca8b7ec4a95941d1ada3a5203ceff8cceee34f574d23909eb314c40c0", + "sframe_key": "d34f547f4ca4f9a7447006fe7fcbf768", + "sframe_salt": "75234edefe07819026751816", + "metadata": "4945544620534672616d65205747", + "nonce": "75234edefe07819026755d71", + "aad": "99012345674945544620534672616d65205747", + "pt": "64726166742d696574662d736672616d652d656e63", + "ct": "9901234567b7412c2513a1b66dbb48841bbaf17f598751176ad847681a69c6d0b091c07018ce4adb34eb" + }, + { + "cipher_suite": 5, + "kid": 291, + "ctr": 17767, + "base_key": "000102030405060708090a0b0c0d0e0f", + "sframe_key_label": "534672616d6520312e3020536563726574206b65792000000000000001230005", + "sframe_salt_label": "534672616d6520312e30205365637265742073616c742000000000000001230005", + "sframe_secret": "0fc3ea6de6aac97a35f194cf9bed94d4b5230f1cb45a785c9fe5dce9c188938ab6ba005bc4c0a19181599e9d1bcf7b74aca48b60bf5e254e546d809313e083a3", + "sframe_key": "d3e27b0d4a5ae9e55df01a70e6d4d28d969b246e2936f4b7a5d9b494da6b9633", + "sframe_salt": "84991c167b8cd23c93708ec7", + "metadata": "4945544620534672616d65205747", + "nonce": "84991c167b8cd23c9370cba0", + "aad": "99012345674945544620534672616d65205747", + "pt": "64726166742d696574662d736672616d652d656e63", + "ct": "990123456794f509d36e9beacb0e261d99c7d1e972f1fed787d4049f17ca21353c1cc24d56ceabced279" + } + ] +} diff --git a/wai/live-conformance/e2ee/secure-objects-01.json b/wai/live-conformance/e2ee/secure-objects-01.json new file mode 100644 index 000000000..a73e538f8 --- /dev/null +++ b/wai/live-conformance/e2ee/secure-objects-01.json @@ -0,0 +1,220 @@ +{ + "source": "draft-ietf-moq-secure-objects-01 Appendix B (B.1 to B.4), values as printed, whitespace inside values removed", + "full_track_name": [ + { + "namespace_tuples": [ + "example.com" + ], + "track_name": "audio", + "serialized_full_track_name": "010b6578616d706c652e636f6d05617564696f" + }, + { + "namespace_tuples": [ + "example.com", + "meeting-123" + ], + "track_name": "video", + "serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f" + }, + { + "namespace_tuples": [ + "example.com", + "meeting-123", + "floor-1" + ], + "track_name": "slides", + "serialized_full_track_name": "030b6578616d706c652e636f6d0b6d656574696e672d31323307666c6f6f722d3106736c69646573" + } + ], + "key_derivation": [ + { + "cipher_suite": 1, + "key_id": 7, + "track_base_key": "7369787465656e2062797465206b6579", + "serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f", + "moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18", + "moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00010000000000000007", + "moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00010000000000000007", + "moq_key": "36543f1f0d00ea1c6ecacaab3ec878772ef6e4bf7e91f29a1fa99af410802420fa92560c1850654f5ae7fa0281d3d1d9", + "moq_salt": "e905de3e41c5feb642a8ea58" + }, + { + "cipher_suite": 2, + "key_id": 7, + "track_base_key": "7369787465656e2062797465206b6579", + "serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f", + "moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18", + "moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00020000000000000007", + "moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00020000000000000007", + "moq_key": "12acb1032ea6b0c8ee2c1f9cfe052f0d13220782a559c50e669645951bb15ed1c93ae2ff02b411649e0aa99aec19b865", + "moq_salt": "17efc7dfcc10786fa00771b8" + }, + { + "cipher_suite": 4, + "key_id": 7, + "track_base_key": "7369787465656e2062797465206b6579", + "serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f", + "moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18", + "moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00040000000000000007", + "moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00040000000000000007", + "moq_key": "eb3b95606d7c4775688c121e8f06832c", + "moq_salt": "dc11a8d516c299f44b24be8d" + }, + { + "cipher_suite": 5, + "key_id": 7, + "track_base_key": "615d3235362d6269742d626173652d6b65792d666f722d746573742121212121", + "serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f", + "moq_secret": "f633519ec99705bae4bcc7fe0f88ccd3c0301355d97d2265773d8b80ccf23ca39d500bc2d5399315bf78bfc5ed69f8be7b6799cfb35691ffe2db6126ab9e897d", + "moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00050000000000000007", + "moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00050000000000000007", + "moq_key": "03a7b0a238bde6d398c0c8de14538c31c8f84a4356a01f5ca00d9a46d4046953", + "moq_salt": "2c82451648c8d25e94b8dbdc" + } + ], + "nonce_aad": [ + { + "group_id": 0, + "object_id": 0, + "publisher_priority": 0, + "key_id": 7, + "serialized_immutable_properties": "", + "moq_salt": "dc11a8d516c299f44b24be8d", + "ctr": "000000000000000000000000", + "nonce": "dc11a8d516c299f44b24be8d", + "aad": "0000000000000000000000000000020107" + }, + { + "group_id": 42, + "object_id": 1, + "publisher_priority": 128, + "key_id": 7, + "serialized_immutable_properties": "", + "moq_salt": "dc11a8d516c299f44b24be8d", + "ctr": "000000000000002a00000001", + "nonce": "dc11a8d516c299de4b24be8c", + "aad": "000000000000002a000000018000020107" + }, + { + "group_id": 18446744073709551615, + "object_id": 4294967295, + "publisher_priority": 255, + "key_id": 7, + "serialized_immutable_properties": "", + "moq_salt": "dc11a8d516c299f44b24be8d", + "ctr": "ffffffffffffffffffffffff", + "nonce": "23ee572ae93d660bb4db4172", + "aad": "ffffffffffffffffffffffffff00020107" + } + ], + "encryption": [ + { + "cipher_suite": 1, + "key_id": 7, + "track_base_key": "7369787465656e2062797465206b6579", + "namespace_tuples": [ + "example.com", + "meeting-123" + ], + "track_name": "video", + "serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f", + "group_id": 42, + "object_id": 1, + "publisher_priority": 128, + "immutable_properties": "00040872656c61792d6f6b", + "original_payload": "68656c6c6f2066726f6d206d6f71", + "encrypted_properties_list": "000a1001000d666f722d657965732d6f6e6c79", + "plaintext": "0e68656c6c6f2066726f6d206d6f71000a1001000d666f722d657965732d6f6e6c79", + "moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18", + "moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00010000000000000007", + "moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00010000000000000007", + "moq_key": "36543f1f0d00ea1c6ecacaab3ec878772ef6e4bf7e91f29a1fa99af410802420fa92560c1850654f5ae7fa0281d3d1d9", + "moq_salt": "e905de3e41c5feb642a8ea58", + "ctr": "000000000000002a00000001", + "nonce": "e905de3e41c5fe9c42a8ea59", + "aad": "000000000000002a00000001800002010700040872656c61792d6f6b", + "ciphertext": "170654428664c29b3eab20a76e86ab2c2fc210b218b38eb556974021b7804b1d7ced925e33e3d6080e8157d4" + }, + { + "cipher_suite": 2, + "key_id": 7, + "track_base_key": "7369787465656e2062797465206b6579", + "namespace_tuples": [ + "example.com", + "meeting-123" + ], + "track_name": "video", + "serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f", + "group_id": 42, + "object_id": 1, + "publisher_priority": 128, + "immutable_properties": "00040872656c61792d6f6b", + "original_payload": "68656c6c6f2066726f6d206d6f71", + "encrypted_properties_list": "000a1001000d666f722d657965732d6f6e6c79", + "plaintext": "0e68656c6c6f2066726f6d206d6f71000a1001000d666f722d657965732d6f6e6c79", + "moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18", + "moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00020000000000000007", + "moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00020000000000000007", + "moq_key": "12acb1032ea6b0c8ee2c1f9cfe052f0d13220782a559c50e669645951bb15ed1c93ae2ff02b411649e0aa99aec19b865", + "moq_salt": "17efc7dfcc10786fa00771b8", + "ctr": "000000000000002a00000001", + "nonce": "17efc7dfcc107845a00771b9", + "aad": "000000000000002a00000001800002010700040872656c61792d6f6b", + "ciphertext": "df0fd1431b6883bcb841fa14d4588630aaf11c92363b536ecc08273a27ac4f47bd72fca55f29cd1fad9b" + }, + { + "cipher_suite": 4, + "key_id": 7, + "track_base_key": "7369787465656e2062797465206b6579", + "namespace_tuples": [ + "example.com", + "meeting-123" + ], + "track_name": "video", + "serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f", + "group_id": 42, + "object_id": 1, + "publisher_priority": 128, + "immutable_properties": "00040872656c61792d6f6b", + "original_payload": "68656c6c6f2066726f6d206d6f71", + "encrypted_properties_list": "000a1001000d666f722d657965732d6f6e6c79", + "plaintext": "0e68656c6c6f2066726f6d206d6f71000a1001000d666f722d657965732d6f6e6c79", + "moq_secret": "e5e76a372058231c56eedca06436c94e6f6bab38d759687367c92048faf93f18", + "moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00040000000000000007", + "moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00040000000000000007", + "moq_key": "eb3b95606d7c4775688c121e8f06832c", + "moq_salt": "dc11a8d516c299f44b24be8d", + "ctr": "000000000000002a00000001", + "nonce": "dc11a8d516c299de4b24be8c", + "aad": "000000000000002a00000001800002010700040872656c61792d6f6b", + "ciphertext": "facafee5c450bd140759bdffa6eef6948ae6dbfdb7ba87cf3e161a641b27f952eb4f13b18e351d4e7e344675f0dc96e0cd5e" + }, + { + "cipher_suite": 5, + "key_id": 7, + "track_base_key": "615d3235362d6269742d626173652d6b65792d666f722d746573742121212121", + "namespace_tuples": [ + "example.com", + "meeting-123" + ], + "track_name": "video", + "serialized_full_track_name": "020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f", + "group_id": 42, + "object_id": 1, + "publisher_priority": 128, + "immutable_properties": "00040872656c61792d6f6b", + "original_payload": "68656c6c6f2066726f6d206d6f71", + "encrypted_properties_list": "000a1001000d666f722d657965732d6f6e6c79", + "plaintext": "0e68656c6c6f2066726f6d206d6f71000a1001000d666f722d657965732d6f6e6c79", + "moq_secret": "f633519ec99705bae4bcc7fe0f88ccd3c0301355d97d2265773d8b80ccf23ca39d500bc2d5399315bf78bfc5ed69f8be7b6799cfb35691ffe2db6126ab9e897d", + "moq_key_label": "4d4f5120312e3020536563757265204f626a6563747320536563726574206b657920020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00050000000000000007", + "moq_salt_label": "4d4f5120312e30205365637265742073616c7420020b6578616d706c652e636f6d0b6d656574696e672d31323305766964656f00050000000000000007", + "moq_key": "03a7b0a238bde6d398c0c8de14538c31c8f84a4356a01f5ca00d9a46d4046953", + "moq_salt": "2c82451648c8d25e94b8dbdc", + "ctr": "000000000000002a00000001", + "nonce": "2c82451648c8d27494b8dbdd", + "aad": "000000000000002a00000001800002010700040872656c61792d6f6b", + "ciphertext": "09399b821a1452a320a8bf927c004c71f8fd34449070f99746ff6ab0ef67a4f93b7f889e64d194a7b18a7a946184fed15386" + } + ] +} diff --git a/wai/live-conformance/e2ee/vectors.json b/wai/live-conformance/e2ee/vectors.json new file mode 100644 index 000000000..5749c7b62 --- /dev/null +++ b/wai/live-conformance/e2ee/vectors.json @@ -0,0 +1,2886 @@ +{ + "codes": [ + "truncated", + "malformed", + "unknown_version", + "wrong_room", + "suite_unsupported", + "mls_suite_unsupported", + "recording_refused", + "bad_key", + "bad_signature", + "group_mismatch", + "credential_mismatch", + "proposal_not_clock", + "proposal_mismatch", + "commit_not_committer", + "commit_mismatch", + "properties_malformed", + "key_id_missing", + "object_id_too_large", + "epoch_not_joined", + "removed_before_epoch", + "key_id_unknown", + "sender_not_owner", + "sender_removed", + "replayed", + "bad_tag", + "plaintext_malformed", + "inner_digest_mismatch" + ], + "corpus": "WAI live-conformance/e2ee", + "generator": "joule_moq_e2ee_vectors gen (joule-code/joule-code-rs/crates/joule-moq-mls)", + "key_schedule": [ + { + "application_secret": "8ab2bebe07877940c4ca3d3b75022d06b8de2bb28544c3ddf50c6db92af9758b", + "epoch": 0, + "kid": 0, + "leaf": 0, + "moq_key": "a91de374ae141fbb9573d981353399fe", + "moq_salt": "4055f1277a14b380771e71ef", + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "video" + ], + "serialized_full_track_name": "034064356630346462626565343632383238623931326534376463343838656231316239636334336637343631373736633135653435313234643661623964646365403230633762346366323237373262626331383137366331316339343663336136636533643763346163376661333062656632396538653635353631623464666605766964656f0130", + "suite": 4, + "track": "0", + "track_base_key": "91359ab111491a541bc5365f5b75d9a8370f30de72825c8e9f4cc7a45939ed38" + }, + { + "application_secret": "8ab2bebe07877940c4ca3d3b75022d06b8de2bb28544c3ddf50c6db92af9758b", + "epoch": 0, + "kid": 0, + "leaf": 0, + "moq_key": "7745d7487aca9119f44c9ad9c9e048a2", + "moq_salt": "111438804a31ef31e3a1a7a3", + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "video" + ], + "serialized_full_track_name": "034064356630346462626565343632383238623931326534376463343838656231316239636334336637343631373736633135653435313234643661623964646365403230633762346366323237373262626331383137366331316339343663336136636533643763346163376661333062656632396538653635353631623464666605766964656f0131", + "suite": 4, + "track": "1", + "track_base_key": "91359ab111491a541bc5365f5b75d9a8370f30de72825c8e9f4cc7a45939ed38" + }, + { + "application_secret": "68ae22e032814f3ba8e5997d3d2be70e1d761e17d5d2b75a6e4ed48cae03129d", + "epoch": 1, + "kid": 16777217, + "leaf": 1, + "moq_key": "1af5ecce6b80bc06ac20e9f586005505", + "moq_salt": "7037bc04be34d5bc09c40ae8", + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "audio" + ], + "serialized_full_track_name": "034064356630346462626565343632383238623931326534376463343838656231316239636334336637343631373736633135653435313234643661623964646365406663393837396337666364373535356665653836323131343934393964353337356332653537356335363338613933353666323162623330316632316461336405617564696f0130", + "suite": 4, + "track": "0", + "track_base_key": "a73926cfd3ab05009dfad58be0f542c4e3fac8a53a7ac255f1881857c57c0cb0" + }, + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "kid": 33554434, + "leaf": 2, + "moq_key": "c5f3b2a0d60d428630b368de7bc7c504", + "moq_salt": "184117f5acc0bbc304c84d1f", + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "screen" + ], + "serialized_full_track_name": "03406435663034646262656534363238323862393132653437646334383865623131623963633433663734363137373663313565343531323464366162396464636540626132373864616538366433323036613238623432656562396632643435386536666536363338633935333433313539646631656432623062383363663061370673637265656e0130", + "suite": 4, + "track": "0", + "track_base_key": "49920543ebc43d66e4a462eafa5f2dec8c87c69682dfdf69d267f81914e339f9" + }, + { + "application_secret": "b82906ad786800886949401fa84b637c8d2ce584f44a84162fbf0c19d27527f7", + "epoch": 7, + "kid": 117440515, + "leaf": 3, + "moq_key": "6297ea1c21d6963ec6e0413d9da33bdae945fbd813c1355f5773831ac89fbb1b", + "moq_salt": "d8b3c2b32edb7a4b1352a787", + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8", + "video" + ], + "serialized_full_track_name": "034064356630346462626565343632383238623931326534376463343838656231316239636334336637343631373736633135653435313234643661623964646365406430633961666561623133633464333762313338353365346665383931643736303936363731346530623838613032643735313762376465356236616661633805766964656f0132", + "suite": 5, + "track": "2", + "track_base_key": "dcc14bdf0b23bf5ec7dfd6f1f73506072d21be680ca755d36ad68371a2fba88a" + }, + { + "application_secret": "a676930791114ec06672a37a7e2108558d67b2f4dea7740942c8531ffbd2e6ba", + "epoch": 1099511627775, + "kid": 18446744073709551615, + "leaf": 16777215, + "moq_key": "5d1b7f3708c3756dfdc2fe9c16f19cb98f8c3aea4fa9285df4ef2a9c04773dfa", + "moq_salt": "abe0bc8227383ff4b74dc383", + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "f6274c60975bba2438a8c725080411620660ab382c42891b2abd4caf580011a2", + "audio" + ], + "serialized_full_track_name": "034064356630346462626565343632383238623931326534376463343838656231316239636334336637343631373736633135653435313234643661623964646365406636323734633630393735626261323433386138633732353038303431313632303636306162333832633432383931623261626434636166353830303131613205617564696f0130", + "suite": 5, + "track": "0", + "track_base_key": "8bfac7534530c61aaa3f3cead843763b4de50017cbb3571d3deb1679b553702f" + } + ], + "objects": [ + { + "content_hash": "1381b8a128960c76584d1a964b14facd5bce98e5f5ab6d9398d0d033015e7d35", + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "derived": { + "aad": "000000000000000c0000000040ba4ae2000001", + "immutable_properties": "ba4ae2000001", + "kid": 33554433, + "moq_key": "6926d85be6bdd903ca1e34c5abb1de7f", + "moq_salt": "a8369177dc7e71ae53d6ae2e", + "nonce": "a8369177dc7e71a253d6ae2e", + "plaintext": "84b032b58c35fbc11480b1143f26ba62ad5e4c8510cdd6f6a79ca4f109e46631acb5463272dfa26f2f49d4de5e38c3876780c7110c18e8de0d25818dbb571ff0a85beec1215ea970b909d7d97f8b5b9c6d8b25f9871eb690c3171d7290f616382272ff964efcccbdbb92c3a16eee3f554060f6b96cf6becdbec8ddf94711e472172a16e25fc0e4eb57e63412e5614d643c4b6652c1a448de4474ca8e0d6796527135b27c79c705aad3b0511ea6ff821c45be1b2c171d3fde1aeab6b949dea54746cde018eb41f5faa4ad84f3980f9d4eb2882afcca40092192f60ad912dae65c1e19c9910a47a5da4e9fca0aefe6512a69e38d1b11b4c6a7322cb98e184bbb63c345977cac71e0492614004cc3dbfdc81c07736bcf923497add5352d0feb150c32d2e66adafd8562bda47033edde8e1464aee0569a64236217d46502b83eb62140af1fb1bb662e9333d3ceb6d7ffbe1ea1f97eb17e87dd87962ce3ecead052be09d933a6183491e1e4b7eb8a81494b5acfff5fdb7c910768f90f21612d86791daee78c7c11d8ad008609dc22083022a2b7ecf3c1ecb4c778e60831a9beff2052246611c6b5520a281feefee3b35657d37cedbbd567e749cf06688a2d607663722842e23d2a0ae3409c651ba796054c02140948d2c5d51eb8a4d52779367d6cee500259c5052ec197ef1beb00c204a089b942020ce13e6ea672a6a007ec95fbf7d6673b22284e919409f1fac76eaa7405455fdab4374e0a9b3e58decb6a4e8e45a342796b6c907780c74e31d8e58e033f63c56e678a0e71b3550aa99d485fd3dd1cd9697f85d4b35e8391fd924ee074a3b3a25a9df7a8930e00d8f744919d23c0e51ff2f1ce4692edbc252e57e32a27ea0edc17a3561210fed2bc09694b4a444963a354ba80338e5c283f25eee3ac66f3e4ecd915c3718e4ba857c6b5d74a59b389ed41b4b36ecdf81d72b83f2f1029f89781e8e562eb332fd23421cd02213b9982dee416af890c52555751480bf4e5e5b1cdb9fd5a9193ad16ce5f8acd95f06190635cbc04af03b675a7aff5592e63d68e76090bfe141167316667452c8b875816f3ac5345bada9a5684f4640155932aacce08436cb0a6c01d6dccf5ab19bb181689316c190dfc435a40fb9219a17c14a2857090b719a7f2269d10e059ae3f0c5fa857f607eb38f622e036fa22b7f7ad9636e1be7a7099f325500a9f4db9af9644f4d745addde821d316d505d136601ee54aca09660d5fb945d7ac2a04b0d126d5c8f6072bbc4df7385c87d51c1a8b959fe78e00c5c6654f1bbfc7befca9fd6faa339c3a82ac944ab749f961d202dfb4076cd8e4f535fa92d7f6a9ccc4e5156611be8152c77f819632779131095a4fe6c9b0df6e5cf3ec57a1a520117edf714add39358b94aaa784e6207598d914c34c804195e6688dbf4f61ca89775bce54c06b151fd3a8d88631b7d7b55646bbfaf65c62565c26d68d0116db23b88040300dba7b1a6e6909a721ed7243de06810c29fd53cd9e11d96a741c8c9e11f52a123b29c7295b8bb7e4863549ed5fda8b7d03258a292d5686885f4e08928462adf650b2a6aae281af4273bfd8073896bc5ad90dbe41390b04137437c598a1aaa8d7a4a94fabcf0fa3adc09b8561b12b227c19c8dcc38d17911841238378ed92dac35ad8b4523d6598543657274f15701475cc086e000a233a4b20681e324029b6bda57510be46a0e4409353c9cc88d368e62872990f9eacf3b5e4", + "serialized_full_track_name": "034064356630346462626565343632383238623931326534376463343838656231316239636334336637343631373736633135653435313234643661623964646365406663393837396337666364373535356665653836323131343934393964353337356332653537356335363338613933353666323162623330316632316461336405766964656f0130", + "track_base_key": "bb207a4128d2025166bdf3519eff73dd2a87b37a5d06cffbd9da21eec497c743" + }, + "input": { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "entries": [], + "epoch": 2, + "group": 12, + "immutable": [], + "inner_digest": true, + "leaf": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 0, + "padding": null, + "payload": "32b58c35fbc11480b1143f26ba62ad5e4c8510cdd6f6a79ca4f109e46631acb5463272dfa26f2f49d4de5e38c3876780c7110c18e8de0d25818dbb571ff0a85beec1215ea970b909d7d97f8b5b9c6d8b25f9871eb690c3171d7290f616382272ff964efcccbdbb92c3a16eee3f554060f6b96cf6becdbec8ddf94711e472172a16e25fc0e4eb57e63412e5614d643c4b6652c1a448de4474ca8e0d6796527135b27c79c705aad3b0511ea6ff821c45be1b2c171d3fde1aeab6b949dea54746cde018eb41f5faa4ad84f3980f9d4eb2882afcca40092192f60ad912dae65c1e19c9910a47a5da4e9fca0aefe6512a69e38d1b11b4c6a7322cb98e184bbb63c345977cac71e0492614004cc3dbfdc81c07736bcf923497add5352d0feb150c32d2e66adafd8562bda47033edde8e1464aee0569a64236217d46502b83eb62140af1fb1bb662e9333d3ceb6d7ffbe1ea1f97eb17e87dd87962ce3ecead052be09d933a6183491e1e4b7eb8a81494b5acfff5fdb7c910768f90f21612d86791daee78c7c11d8ad008609dc22083022a2b7ecf3c1ecb4c778e60831a9beff2052246611c6b5520a281feefee3b35657d37cedbbd567e749cf06688a2d607663722842e23d2a0ae3409c651ba796054c02140948d2c5d51eb8a4d52779367d6cee500259c5052ec197ef1beb00c204a089b942020ce13e6ea672a6a007ec95fbf7d6673b22284e919409f1fac76eaa7405455fdab4374e0a9b3e58decb6a4e8e45a342796b6c907780c74e31d8e58e033f63c56e678a0e71b3550aa99d485fd3dd1cd9697f85d4b35e8391fd924ee074a3b3a25a9df7a8930e00d8f744919d23c0e51ff2f1ce4692edbc252e57e32a27ea0edc17a3561210fed2bc09694b4a444963a354ba80338e5c283f25eee3ac66f3e4ecd915c3718e4ba857c6b5d74a59b389ed41b4b36ecdf81d72b83f2f1029f89781e8e562eb332fd23421cd02213b9982dee416af890c52555751480bf4e5e5b1cdb9fd5a9193ad16ce5f8acd95f06190635cbc04af03b675a7aff5592e63d68e76090bfe141167316667452c8b875816f3ac5345bada9a5684f4640155932aacce08436cb0a6c01d6dccf5ab19bb181689316c190dfc435a40fb9219a17c14a2857090b719a7f2269d10e059ae3f0c5fa857f607eb38f622e036fa22b7f7ad9636e1be7a7099f325500a9f4db9af9644f4d745addde821d316d505d136601ee54aca09660d5fb945d7ac2a04b0d126d5c8f6072bbc4df7385c87d51c1a8b959fe78e00c5c6654f1bbfc7befca9fd6faa339c3a82ac944ab749f961d202dfb4076cd8e4f535fa92d7f6a9ccc4e5156611be8152c77f819632779131095a4fe6c9b0df6e5cf3ec57a1a520117edf714add39358b94aaa784e6207598d914c34c804195e6688dbf4f61ca89775bce54c06b151fd3a8d88631b7d7b55646bbfaf65c62565c26d68d0116db23b88040300dba7b1a6e6909a721ed7243de06810c29fd53cd9e11d96a741c8c9e11f52a123b29c7295b8bb7e4863549ed5fda8b7d03258a292d5686885f4e08928462adf650b2a6aae281af4273bfd8073896bc5ad90dbe41390b04137437c598a1aaa8d7a4a94fabcf0fa3adc09b8561b12b227c19c8dcc38d17911841238378ed92dac35ad8b4523d6598543657274f15701475cc086e", + "priority": 64, + "track": "0" + }, + "name": "a video frame with its inner digest", + "object": { + "group": 12, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 0, + "payload": "8d7bd7685b363e2b9beb1943ff336e845e0119da85c4a9170f55c2fe0e4e10504c07980307dac95d790074afb98c4851e3b43c09f9b49c528641c284c62b5f09136516449eb0e540e3c5453147dd1db3078bed59191014665c782e4a72a19cec0a398fbb1bf92008114f111e522046908d19a43240683564dbc73cffd705e01b57a1afef59a2e00bb704972fb602361c3b1a796e994535780bb0390977c8738a0b8db611d3525b011d3a979617d31ddfda2cbf56ee18610a9837bbb7536472a44aebcf8de78f6d88594928841320d8b3f36f98460a0d653fbe50a2c541c0fb13e1e6b29de5dc2e09d9da01142d63100c2cafd22d46e300ffb1a007b963412cb394123e3830c1c5c369cf16037a6ce6ae1b6220b4eb2302970f720e742b0ff4d5fb56f94bed5599f6ad4447d11444910c6ef120061a5ccb039dd2336e34ecfc9b37b24cbe5590a1ec91712e2529ca0946a4f5edd9ca291e58243f7524c47f8ff092bf5207de58a848952d9ce8d8d387d264674f40218aa935f5cb314c351f5d243964a0a510b88b9c2c6c0402f24755a88e10b9cbb2442ff679794ad05169896577b638da5f2108b75e0e461bacfac545ccb05ee8b8664e4c761c32a9ae60c2e407bb7ba247b1e848da2705b1b0fe8bef978d47fec52bf2bd5d04f623f6a2e7431fa2702ba98d5594557ffffd837c5b58df2a2e44cb559016850b2d2e0d565abffbf787625fd629b651614b7d568370bf59f637197b146bf1e13726f108291dafd2e023f70136aae0330633fdd06158dfb044e86b52497713aa961e1f746edcc292edc1e5df906731cc88c613288def0606ed18dff6aaadf8056e68816377990b242fb331392f57f81f5295fab50df716937938114974eda3fbc2f4f30df4301d7d3dba963fd5b618f79f7c64efcdc4e764e5c922f7acbc99df18e2f6008b8ede2c492f7515b408259cdd7c7bbc35d6802cf370b7f580be1136528dcaa03ba8d3a6ba491a7270d8e60fc67baca7d61b6fd081a7d95809f23e25295fe5c7fb4f7fc2ab945ffccd331d76316607dfc8b62ea8df283526a54c47a7cd1aecf2333a6e85d19550d97f21a4a972c0ced053da9713b87994c57b0c8cab6c78ac6b9c27dd3a22d869616db8d7fd3d89edda20a5f56c504b3931b7d2654f094d29773af2c770b08e8a4028a9207943608a196e662951d0f4ff5b7cdaa7b34802bb58cbc33157daebbdf4cdac932067874152a32248c6fc28adb4435f5ba0adca2bf25913f51415a6ac91180043026d2b7dd35df3c35951fd8b9214ad9a0f8cb59eb9dfb4f85b60ec0f5ec97f83f88ad3ed6dba797962be5cca821907d867e5720056e4f858cd7b8339ac03408090b6ef87c41353e3ae9b0a1eef80fb99c91a6a67ebce3ffd0090ff001b5f7a91a9d20c1b3390faacbc6a7c9b934386dd30816eba2eac149ea18004d776d08707074b1d0249fc00fc2bc0d20ed975f0c874753a5ee89c668b515ebde87bf9c8e58fae5348e06a1a94fab20e2373b3293afa455099ff7a70a53816ed1ced6cfef8c81951b244599cc64274fe042712a6e8c8aa23e3d6692e0afceba09c2edfe746da8dcf89f1ad5de134a8eb790e84e36f2b8521682d9810070f88eb8c4eb60d719f9a0c23529daa1e73d995e7d5801d019469a1a9413364201f6ad055d46393c78187673d0b8778236462a36cb066f078e388acec63035a32c9c001ae8d5d051b3ee04191394007851ce0761344c1f68c08b0b70c976d3ba24440fcabf01a0811", + "priority": 64, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "opened", + "payload_blake3": "681e324029b6bda57510be46a0e4409353c9cc88d368e62872990f9eacf3b5e4", + "suite": 4 + }, + { + "content_hash": "e3bef618dd9d77c27de88306cdbc80be4386befa549cfc84b3f257262526a56e", + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "derived": { + "aad": "00000000000000030000002910ba4ae2000001", + "immutable_properties": "ba4ae2000001", + "kid": 33554433, + "moq_key": "572359b27756e6e081fd378547be1785", + "moq_salt": "3e7772a0d8d6f163ca07610b", + "nonce": "3e7772a0d8d6f160ca076122", + "plaintext": "3c7f4c287dc19f9afa7bd2e5627ca56f03a958ee6b7b1a82ce0e9c0d05319f0c0d4d5fbcdd48961b7db03dc414bb3c5222a413b16556434b5dfc28f719", + "serialized_full_track_name": "034064356630346462626565343632383238623931326534376463343838656231316239636334336637343631373736633135653435313234643661623964646365406663393837396337666364373535356665653836323131343934393964353337356332653537356335363338613933353666323162623330316632316461336405617564696f0130", + "track_base_key": "9314e22cb2c8c21cb76b0515cfd9f98979a21b9b9111f067d2f490cdb059fc17" + }, + "input": { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "entries": [], + "epoch": 2, + "group": 3, + "immutable": [], + "inner_digest": false, + "leaf": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "audio" + ], + "object": 41, + "padding": null, + "payload": "7f4c287dc19f9afa7bd2e5627ca56f03a958ee6b7b1a82ce0e9c0d05319f0c0d4d5fbcdd48961b7db03dc414bb3c5222a413b16556434b5dfc28f719", + "priority": 16, + "track": "0" + }, + "name": "a frame of speech, no list", + "object": { + "group": 3, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "audio" + ], + "object": 41, + "payload": "7b413b4817bdf9496057d1d6e011f772c06345a93008b9add14474a489487073b49fea6d444f41405def8328d7a74e57ff60fc3708dc6c09d40175dcbdba6fbe053bca9dbe478a721f3487b54d", + "priority": 16, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "opened", + "payload_blake3": "5a01e22697c341e24b0e462f893f10d0dab8f58c2353b9edf3c1141b87ed14b1", + "suite": 4 + }, + { + "content_hash": "b9642b578c888354a47ee40bb3d0605843308f9048c388ecc70f3453d47a5313", + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "derived": { + "aad": "00000000000000010000000280ba4ae2000001", + "immutable_properties": "ba4ae2000001", + "kid": 33554433, + "moq_key": "f24fdfcd669e1600e8cf27cfd4935918", + "moq_salt": "98704ca7fb5c2f455edaa7ae", + "nonce": "98704ca7fb5c2f445edaa7ac", + "plaintext": "812c89fa448570087fa51bc8c261b08a8017d75a15b8685dd57c05e2a7d1072f8f73e879eeff4f6d02c16e6e22edd4d682417bd6e2df980b06f59c36d4dd34f48585da6eaafc3ab11368c56e0b5a102d443955bb23c064f8589f0b9d0db5b6d41c74063e75d68ac7e06387de73c0b462e9bc8adcd7a58d83b15216dbee1d3aaa7dae5c95d9c0fc425feed5f582e726aec8fb210646a1dc0f5a6e2dc3067c365d5346ccfa7d75199e5fd03230bb404b36875fba7ee966de93deb693147be5be2bd9b80c948f97ed67618e1144ba3321d4baf7518a277a35065181d01ad0cb0b9f1d3f8413b9afa457138b22d1d37dc7411f361183ec70d37405a1afca72cdb8b833b5eafb1a987f7f750b6b7f9292bbe43c5809db406d28cda3168ae7aee0b16d8a1a5aa9f370584d43009a3ddc5f000a80ff3a4b20d723b2b9eb8eb58f84a8c73aaff2f20d796b6730b7f1a28eef6d94243193b99001000d666f722d657965732d6f6e6c79003200c80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "serialized_full_track_name": "03406435663034646262656534363238323862393132653437646334383865623131623963633433663734363137373663313565343531323464366162396464636540666339383739633766636437353535666565383632313134393439396435333735633265353735633536333861393335366632316262333031663231646133640673637265656e0130", + "track_base_key": "b5ac94e68a9786f6a9e51ca1988fccc8a912e664d4636463688d9d9e0db6013c" + }, + "input": { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "entries": [ + { + "type": 256, + "value": "666f722d657965732d6f6e6c79" + } + ], + "epoch": 2, + "group": 1, + "immutable": [], + "inner_digest": true, + "leaf": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "screen" + ], + "object": 2, + "padding": 200, + "payload": "89fa448570087fa51bc8c261b08a8017d75a15b8685dd57c05e2a7d1072f8f73e879eeff4f6d02c16e6e22edd4d682417bd6e2df980b06f59c36d4dd34f48585da6eaafc3ab11368c56e0b5a102d443955bb23c064f8589f0b9d0db5b6d41c74063e75d68ac7e06387de73c0b462e9bc8adcd7a58d83b15216dbee1d3aaa7dae5c95d9c0fc425feed5f582e726aec8fb210646a1dc0f5a6e2dc3067c365d5346ccfa7d75199e5fd03230bb404b36875fba7ee966de93deb693147be5be2bd9b80c948f97ed67618e1144ba3321d4baf7518a277a35065181d01ad0cb0b9f1d3f8413b9afa457138b22d1d37dc7411f361183ec70d37405a1afca72cdb8b833b5eafb1a987f7f750b6b7f9292bbe43c5809db406d28cda3168ae7aee0b16d8a1a5aa9f370584d43009a3ddc5f", + "priority": 128, + "track": "0" + }, + "name": "padded, with an entry of another type", + "object": { + "group": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "screen" + ], + "object": 2, + "payload": "3ae39289ab958b4378b989571bc4d4cd0eb151504c9e9c6c0674c1ce96949c655131eb491a109039e3a8657fbd211c654add552cbba23e243949ca991dedfb1f851e2e78c4b68533de7a49a2cad8843a90e32ca38ce1051ba454b6f957d84b4faff1f518e6101e80c557be6dac1716f64a53ad0c8c47f5247e069a51c05f02b2f4350814e2379f572c8e99daf27e4c461fd7ee11372412c9ca0d5036d7e023affe5fef2a3613a4d376eabea8d9cf94bb87f10b5433447c6112692ae289ccbbabbac3dffce8177364f5f68659fb3abb0d26f045143ace77f0b03596eaf8e69d740d7ed612a724b4c159fdab96dc9962ea7318986f0b83ec4ef60f2c7392ed322d9648292d4f0b7dd80f27088b5732a7d020afaf4a33f07e15d765d6490f51f586699a3fab67e1d0bffc75dbda988c635f21dbf862c842f8bd4f90a00cf8e8e6c1e5f9d0b8b4d5d34dc0400dc805e68e09066a050e9e73916a39b485c18e77703605e3dea7a9c0d0e5398627d75440667eaae04c59648dac9776c894a9aed340dfe80b30e0a5b0d1a37c763dbec0c8245922dad4bb16f62e92d089d1513070a164e553c798600b596a8f571188c6d6245f866747f015bd815bd518d6039b54b91016a8a3db91d0215538a8237b30cd5a30532a033b2d9eed5743c02aba62ebc711ffaf3af3b66afb27495d6dab1e3e8dafac66096c82a7f5d6e40ee854fc2ad5ffc6968dd9db34de4435fb45f0fd52de67bd99be95621288e2e7fa83fed9377b8b5bccb63e3b7ba92a5e6423c1755d8a12303223a9319d1e8caf23dd3a0e55e79258", + "priority": 128, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "opened", + "payload_blake3": "d723b2b9eb8eb58f84a8c73aaff2f20d796b6730b7f1a28eef6d94243193b990", + "suite": 4 + }, + { + "content_hash": "106328c7adb4724ddc3c77b6785c967c06db7fbd5d9b4e1a6dadce86743b08a6", + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "derived": { + "aad": "000000000000000c0000000360ba4001020308e2000001", + "immutable_properties": "ba4001020308e2000001", + "kid": 33554433, + "moq_key": "5eef6597caaa5f0e1e899ff6fae526879473681d5d122487da924ec79b76ef69", + "moq_salt": "71b1c0acd25a6210f4b13751", + "nonce": "71b1c0acd25a621cf4b13752", + "plaintext": "82bc1ea87bed2320d81a53704af1b4a04cd362da14e0a00fc9035a9f6d5b525273c525f5c5afbed97a7bb6ff3d457d2ff564840f17941eaf40c5523cb3de62f300325971c79e91cdb42dbd806d487e4f80debdd5acc592ad84fe585e7e2c97b30072f4fe4a94b6c1f60c777795ec56a20745d30cfe903349260735ab940a4096f88690af8a556a70a17bba458f930f1bf034481a7951ddde5e475cd2ce918e14b80174368baf1ea8e59426da0dc2749154d2192a2cc5ca780d429fc05c2c00e3123893180dcf41d8be388ae2846abe680b97bcfca188b5b84b0258694055eb3944a758315253f9f5453eff0e490402809b59a4b9ff06d3149381dd58098ffa205952a09eb096640b72484a81774a0ffb072fb58905f91b6ba0e58dc25c7663ff9f81daa7bdd67940cb3d69ccae080b65315db1b97e0cd7ecfddf8e69fba2c9f4cf197eafb3db36872c7b4ae2adc523d3778fcdbc09f0f56c35a110763fdd232febbafa1a16fab537865a7d82ceca8e128705058c998356ca32d17a82cc27df601590c2510a9102214795412891294e0cbb2b6d2aa31035e5a252487cbbbd6579cf4cc0c68594f5d17539e5a157733ceb5f1368b191f645f65df9796158dbc0afb728036a8f3f029728c316cc495f3385b450566c9a0df1ec1b8ce8d14a1bc73e8858a7ece4bd51ca8666a5cf4fedf702e403540a91c30255c623b20165da16e257b596b592ac627e19c4b33cddc131643d455d339fe5a0d61286ff513cdb2a303e9c75868e286e228703e9a93c6d6c47f4bc5ec8e5fdb9df781f60d5c000b56a5d2fb7762e091d80f46d0ff4206c0856cc12a14e96c17f03b02e1966ea886dc4d4fae7920cd7a7e16d99d7ac20cbe341b26972b4bfcad757d5d4725e7e05745485788df357597244ed2a16b13de721f8a7cd199bb30b93fbaa1ed0e51611b678287d0419427e180536b75e25997525b745875ec24544f5b37479243b21e7000a233a4b2018f9b499a177057256292fd9504ea6d61ead2f20e4ab462c3b9ba2ee9c4c1723", + "serialized_full_track_name": "034064356630346462626565343632383238623931326534376463343838656231316239636334336637343631373736633135653435313234643661623964646365406663393837396337666364373535356665653836323131343934393964353337356332653537356335363338613933353666323162623330316632316461336405766964656f0131", + "track_base_key": "bb207a4128d2025166bdf3519eff73dd2a87b37a5d06cffbd9da21eec497c743" + }, + "input": { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "entries": [], + "epoch": 2, + "group": 12, + "immutable": [ + { + "type": 14912, + "varint": 1 + }, + { + "type": 14914, + "varint": 3 + } + ], + "inner_digest": true, + "leaf": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 3, + "padding": null, + "payload": "1ea87bed2320d81a53704af1b4a04cd362da14e0a00fc9035a9f6d5b525273c525f5c5afbed97a7bb6ff3d457d2ff564840f17941eaf40c5523cb3de62f300325971c79e91cdb42dbd806d487e4f80debdd5acc592ad84fe585e7e2c97b30072f4fe4a94b6c1f60c777795ec56a20745d30cfe903349260735ab940a4096f88690af8a556a70a17bba458f930f1bf034481a7951ddde5e475cd2ce918e14b80174368baf1ea8e59426da0dc2749154d2192a2cc5ca780d429fc05c2c00e3123893180dcf41d8be388ae2846abe680b97bcfca188b5b84b0258694055eb3944a758315253f9f5453eff0e490402809b59a4b9ff06d3149381dd58098ffa205952a09eb096640b72484a81774a0ffb072fb58905f91b6ba0e58dc25c7663ff9f81daa7bdd67940cb3d69ccae080b65315db1b97e0cd7ecfddf8e69fba2c9f4cf197eafb3db36872c7b4ae2adc523d3778fcdbc09f0f56c35a110763fdd232febbafa1a16fab537865a7d82ceca8e128705058c998356ca32d17a82cc27df601590c2510a9102214795412891294e0cbb2b6d2aa31035e5a252487cbbbd6579cf4cc0c68594f5d17539e5a157733ceb5f1368b191f645f65df9796158dbc0afb728036a8f3f029728c316cc495f3385b450566c9a0df1ec1b8ce8d14a1bc73e8858a7ece4bd51ca8666a5cf4fedf702e403540a91c30255c623b20165da16e257b596b592ac627e19c4b33cddc131643d455d339fe5a0d61286ff513cdb2a303e9c75868e286e228703e9a93c6d6c47f4bc5ec8e5fdb9df781f60d5c000b56a5d2fb7762e091d80f46d0ff4206c0856cc12a14e96c17f03b02e1966ea886dc4d4fae7920cd7a7e16d99d7ac20cbe341b26972b4bfcad757d5d4725e7e05745485788df357597244ed2a16b13de721f8a7cd199bb30b93fbaa1ed0e51611b678287d0419427e180536b75e25997525b745875ec24544f5b37479243b21e7", + "priority": 96, + "track": "1" + }, + "name": "a refinement layer with the layer properties", + "object": { + "group": 12, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 3, + "payload": "904d09b32f6080e7e8cb8fd9d717ddb308eb7e7f31072821048cc170ab5a348a6302417498fcad3fd929f3fdb3f4a6a0e6cce74e80c528edaca49da2a527ed62750a1fc71d2fd2088c35e78d4c91f711d3e0168b4159e286c7f0266a884e586ee6ed5b492cd60a28251bdace131c2b329ea49b4e06824390f0b391c3d0d91e73f921c1b580d39a875ac09759ac2b4b7e9546c5b6132da82207de0d1af64a98b68f23eefa3741877991e0cf07df9d4136dd50673b80635a4b06d7d8238e359589a4c48782fd3f4d28f3051e0ee3a5e4f524469b18af205f13585be000f19e625759de3cb6493bbf0faeeab722d1cf6d3f449c57de98efa5f4285ae6f39553d2b20870048048829020b97899bc2ea880d94d9cb636547cf3f7d7b7f9d63d12fadb52ed0d72849394d73cf6fe7cacf7b39e196b9a189c4748a47832daf571263f729a43069d1f448a257bdceca82b4605821f32cda2d37a46495cf676289c823fbbdcfa06e2a7ed502082287d4976faec28c8bd7bc763f80f5cff04b88cddf81b6fad2a184b564d4b76653831cacf6f00d87cfddc5f877d4f2a828213004fbacb7051470941f18ba1b5ab5af85f994fc255973b68038d1e96a935a7b4c236403a8ec04937d1e297be98f25b3a7563f23bc9e496b2ea1301a25f9a75f231e39b5e654c521d33e1b3b8d96d11ba62326410553663a9968f9d0d39e3e2bc16eb121dc789ba8a1450d3666530ccb3074d59dce2f81ee9c6907f5181ce7195bfa346a2348886f1d31e4c165330d5df6fcea9a714200ce098155b80f30bec34472ff7e83e76b4f60b308a3e7d348db4e0ab9915713e1eeb394d4f7a299aab20f97cfd571491ab1aa7d2fabaa47d626e9e46c8c6f2def2a56903e2b5e6dfd15bd3efb6a0b06265baff962d1bec16c0647ec5b295e75046ab0b213aeaf1d2ad15157befc563bc32043c8d3a44609faddcbbac83271c17e9fb9f24c17ba3b9ab77b58bb36dfb999c9ac521ded36b7af097136cefdbe9c1d9d438d2215115e977629704cfe3b4d3b9be026711b5b4bda162a1fe83ac88c5ab3dbf", + "priority": 96, + "properties": "0b0aba4001020308e2000001", + "track": "1" + }, + "outcome": "opened", + "payload_blake3": "18f9b499a177057256292fd9504ea6d61ead2f20e4ab462c3b9ba2ee9c4c1723", + "suite": 5 + }, + { + "content_hash": "71daf5f865ca7ac31142ab93918f49583a1bd955058994c0afdbb3db9e6447cc", + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "derived": { + "aad": "0000000100000006ffffffff00ba440106e2000001", + "immutable_properties": "ba440106e2000001", + "kid": 33554433, + "moq_key": "9c7f2c95c4bff140056afcb6e15fddbe26b900848fdf0f9eee9d13d785dcc9c1", + "moq_salt": "8301f82093d543caa74f7a51", + "nonce": "8301f82193d543cc58b085ae", + "plaintext": "407dfa5f7c9053159e365d18fd18627f3f140e92c09fb248f09c65f5b68e905ae55956d54ee6496433f2d85c96176cfc2e3d2c8aba4b03ef94de972aa68c9b0c60000a273a4b20df652b2b11ae81e7c4492cc27122da8c79b2379b9a3be8a4a68fcb7a8fced0e700320000", + "serialized_full_track_name": "034064356630346462626565343632383238623931326534376463343838656231316239636334336637343631373736633135653435313234643661623964646365406663393837396337666364373535356665653836323131343934393964353337356332653537356335363338613933353666323162623330316632316461336405617564696f0130", + "track_base_key": "9314e22cb2c8c21cb76b0515cfd9f98979a21b9b9111f067d2f490cdb059fc17" + }, + "input": { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "entries": [], + "epoch": 2, + "group": 4294967302, + "immutable": [ + { + "type": 14916, + "varint": 1 + } + ], + "inner_digest": true, + "leaf": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "audio" + ], + "object": 4294967295, + "padding": 0, + "payload": "7dfa5f7c9053159e365d18fd18627f3f140e92c09fb248f09c65f5b68e905ae55956d54ee6496433f2d85c96176cfc2e3d2c8aba4b03ef94de972aa68c9b0c60", + "priority": 0, + "track": "0" + }, + "name": "an envelope, AES_256_GCM_SHA512_128", + "object": { + "group": 4294967302, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "audio" + ], + "object": 4294967295, + "payload": "d8708bb661549f2bbaae864a89d1342af2711249829886fc0ada85455f8cd472b9c1690ed8e119b7b88abe9a21e6b0b0b7630dd59bec04a7ffe9009819522500bb4d8d4e230c773a76778cfd44b6a9887201b552887427268e0660e1995fd7c994d2ba3f19829734199e16438f908449d0f67de0cfb4ff60147709", + "priority": 0, + "properties": "0b08ba440106e2000001", + "track": "0" + }, + "outcome": "opened", + "payload_blake3": "df652b2b11ae81e7c4492cc27122da8c79b2379b9a3be8a4a68fcb7a8fced0e7", + "suite": 5 + }, + { + "content_hash": "1298a34580076a870d93824f5108946f344e2c1e69f41023351db7d66da5bcf3", + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "derived": { + "aad": "000000000000000000000000ffba4ae2000001", + "immutable_properties": "ba4ae2000001", + "kid": 33554433, + "moq_key": "6926d85be6bdd903ca1e34c5abb1de7f", + "moq_salt": "a8369177dc7e71ae53d6ae2e", + "nonce": "a8369177dc7e71ae53d6ae2e", + "plaintext": "00000a233a4b20af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262", + "serialized_full_track_name": "034064356630346462626565343632383238623931326534376463343838656231316239636334336637343631373736633135653435313234643661623964646365406663393837396337666364373535356665653836323131343934393964353337356332653537356335363338613933353666323162623330316632316461336405766964656f0130", + "track_base_key": "bb207a4128d2025166bdf3519eff73dd2a87b37a5d06cffbd9da21eec497c743" + }, + "input": { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "entries": [], + "epoch": 2, + "group": 0, + "immutable": [], + "inner_digest": true, + "leaf": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 0, + "padding": null, + "payload": "", + "priority": 255, + "track": "0" + }, + "name": "an empty payload", + "object": { + "group": 0, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 0, + "payload": "410b805bedf49bd7586278f65ef6d4113262cbdc394f498f9e33df90d56bb5da18db8934fa91fa5b2f25d2752fdc4eaef229529d4f322a", + "priority": 255, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "opened", + "payload_blake3": "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "no Key ID", + "object": { + "group": 5, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 1, + "payload": "4ec64a5916087b3ba0ffc3c32e445bed149bda606a877d22dfd234dbd65316fe23b953b1eedfe9b5a8a52fe835f13cce005d873f7c7978ccb539027721107ef6f35d009f5584f5078627cfee74ccaf3d417bbb0db38a87456be384554ab61726628162cd9365567391f8c91c2dec3025065e7040a9f704ebb4f40fdd11f1bb7937ff741664e657", + "priority": 64, + "properties": "0b03ba4000", + "track": "0" + }, + "outcome": "key_id_missing", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "no Immutable Properties", + "object": { + "group": 5, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 1, + "payload": "4ec64a5916087b3ba0ffc3c32e445bed149bda606a877d22dfd234dbd65316fe23b953b1eedfe9b5a8a52fe835f13cce005d873f7c7978ccb539027721107ef6f35d009f5584f5078627cfee74ccaf3d417bbb0db38a87456be384554ab61726628162cd9365567391f8c91c2dec3025065e7040a9f704ebb4f40fdd11f1bb7937ff741664e657", + "priority": 64, + "properties": "", + "track": "0" + }, + "outcome": "key_id_missing", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "two Key IDs", + "object": { + "group": 5, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 1, + "payload": "4ec64a5916087b3ba0ffc3c32e445bed149bda606a877d22dfd234dbd65316fe23b953b1eedfe9b5a8a52fe835f13cce005d873f7c7978ccb539027721107ef6f35d009f5584f5078627cfee74ccaf3d417bbb0db38a87456be384554ab61726628162cd9365567391f8c91c2dec3025065e7040a9f704ebb4f40fdd11f1bb7937ff741664e657", + "priority": 64, + "properties": "0b0bba4ae200000100e2000001", + "track": "0" + }, + "outcome": "properties_malformed", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "a Key ID outside Immutable Properties", + "object": { + "group": 5, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 1, + "payload": "4ec64a5916087b3ba0ffc3c32e445bed149bda606a877d22dfd234dbd65316fe23b953b1eedfe9b5a8a52fe835f13cce005d873f7c7978ccb539027721107ef6f35d009f5584f5078627cfee74ccaf3d417bbb0db38a87456be384554ab61726628162cd9365567391f8c91c2dec3025065e7040a9f704ebb4f40fdd11f1bb7937ff741664e657", + "priority": 64, + "properties": "0b06ba4ae2000001ba3fe2000001", + "track": "0" + }, + "outcome": "properties_malformed", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "Immutable Properties that do not parse", + "object": { + "group": 5, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 1, + "payload": "4ec64a5916087b3ba0ffc3c32e445bed149bda606a877d22dfd234dbd65316fe23b953b1eedfe9b5a8a52fe835f13cce005d873f7c7978ccb539027721107ef6f35d009f5584f5078627cfee74ccaf3d417bbb0db38a87456be384554ab61726628162cd9365567391f8c91c2dec3025065e7040a9f704ebb4f40fdd11f1bb7937ff741664e657", + "priority": 64, + "properties": "0b030b0501", + "track": "0" + }, + "outcome": "properties_malformed", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "an Object ID past 32 bits", + "object": { + "group": 5, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 4294967296, + "payload": "4ec64a5916087b3ba0ffc3c32e445bed149bda606a877d22dfd234dbd65316fe23b953b1eedfe9b5a8a52fe835f13cce005d873f7c7978ccb539027721107ef6f35d009f5584f5078627cfee74ccaf3d417bbb0db38a87456be384554ab61726628162cd9365567391f8c91c2dec3025065e7040a9f704ebb4f40fdd11f1bb7937ff741664e657", + "priority": 64, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "object_id_too_large", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "another priority", + "object": { + "group": 5, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 1, + "payload": "4ec64a5916087b3ba0ffc3c32e445bed149bda606a877d22dfd234dbd65316fe23b953b1eedfe9b5a8a52fe835f13cce005d873f7c7978ccb539027721107ef6f35d009f5584f5078627cfee74ccaf3d417bbb0db38a87456be384554ab61726628162cd9365567391f8c91c2dec3025065e7040a9f704ebb4f40fdd11f1bb7937ff741664e657", + "priority": 65, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "bad_tag", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "another object", + "object": { + "group": 5, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 2, + "payload": "4ec64a5916087b3ba0ffc3c32e445bed149bda606a877d22dfd234dbd65316fe23b953b1eedfe9b5a8a52fe835f13cce005d873f7c7978ccb539027721107ef6f35d009f5584f5078627cfee74ccaf3d417bbb0db38a87456be384554ab61726628162cd9365567391f8c91c2dec3025065e7040a9f704ebb4f40fdd11f1bb7937ff741664e657", + "priority": 64, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "bad_tag", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "a changed tag", + "object": { + "group": 5, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 1, + "payload": "4ec64a5916087b3ba0ffc3c32e445bed149bda606a877d22dfd234dbd65316fe23b953b1eedfe9b5a8a52fe835f13cce005d873f7c7978ccb539027721107ef6f35d009f5584f5078627cfee74ccaf3d417bbb0db38a87456be384554ab61726628162cd9365567391f8c91c2dec3025065e7040a9f704ebb4f40fdd11f1bb7937ff741664e6d7", + "priority": 64, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "bad_tag", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "cut short of a tag", + "object": { + "group": 5, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 1, + "payload": "4ec64a5916087b3ba0ff", + "priority": 64, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "bad_tag", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "an Encrypted Properties List of another type", + "object": { + "group": 6, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 0, + "payload": "ba3843d73a53a85e87dd585d9bacc90340edb74b2ace5fd09d80b6d5f05a8ac05d68958c84376a8edb9ba71ded82b52e2a522d86d844ed82d4449757d8adbef02307a6734a6ddbe546553c19170c9aca324f1b46110122a1d437a021bc956051aca37e0d", + "priority": 64, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "plaintext_malformed", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "a payload length past the plaintext", + "object": { + "group": 6, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 1, + "payload": "86f1c9140ce2dff39f1985a3ed4ff1809789", + "priority": 64, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "plaintext_malformed", + "suite": 4 + }, + { + "context": { + "epochs": [ + { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "epoch": 2, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + "name": "an inner digest of other bytes", + "object": { + "group": 6, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 2, + "payload": "1d9f74d1ba580059d6c61ea89af93e340d8f24514b0e909683cee7b4e64c488743e41f9dd694ce9bcbff2c9cb4fb8c2f465b808ec33d7c842a7be5784b6942587bd54e5010dbbdbc4ff73489a70d4511f1db5bf2cc4fc87aeace1a988737e384efd16a08543caa392e1bb989acf271bbfe0a12fe83dfb3da307e2593066e3b8e4435b5847762f9", + "priority": 64, + "properties": "0b06ba4ae2000001", + "track": "0" + }, + "outcome": "inner_digest_mismatch", + "suite": 4 + } + ], + "records": [ + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "accepted, AES_128_GCM_SHA256_128", + "outcome": "accept", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce04000100279a29fd7081c59cb255a8f2715810d1d668be6f54e7da7f51a30a7b48716ce3fa9418e9db15deba61fc882aec6aefdd2249cf25160af2fcf1f005cc7b89f204", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "suite": 4 + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "accepted, AES_256_GCM_SHA512_128", + "outcome": "accept", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce0500010045f9a3f4de2c523aed0b8793b33d42a1542cfca68e43e6cd7f089d54e9b01b2059914871e669786fad9fb427a858a92a7e11ef7dbc12d8d19630c8150fb05c0a", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "suite": 5 + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "fewer than four bytes", + "outcome": "truncated", + "record": "575245", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "magic only", + "outcome": "truncated", + "record": "57524531", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "cut inside the signature", + "outcome": "truncated", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce04000100279a29fd7081c59cb255a8f2715810d1d668be6f54e7da7f51a30a7b48716ce3fa9418e9db15deba61fc882aec6aefdd2249cf25160af2fcf1f005", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "another magic", + "outcome": "malformed", + "record": "5752443101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce04000100279a29fd7081c59cb255a8f2715810d1d668be6f54e7da7f51a30a7b48716ce3fa9418e9db15deba61fc882aec6aefdd2249cf25160af2fcf1f005cc7b89f204", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "a byte after the signature", + "outcome": "malformed", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce04000100279a29fd7081c59cb255a8f2715810d1d668be6f54e7da7f51a30a7b48716ce3fa9418e9db15deba61fc882aec6aefdd2249cf25160af2fcf1f005cc7b89f20400", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "version 2", + "outcome": "unknown_version", + "record": "5752453102d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce04000100279a29fd7081c59cb255a8f2715810d1d668be6f54e7da7f51a30a7b48716ce3fa9418e9db15deba61fc882aec6aefdd2249cf25160af2fcf1f005cc7b89f204", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "another room's", + "outcome": "wrong_room", + "record": "575245310111111111111111111111111111111111111111111111111111111111111111110400010014cb6a29ed5a85ebb18f40bf77728225e2c07d85c6fd07e1d47112bd7636c2c3b6e8e2431385ae1667d7311b949ef1b060401a08e495d95e8f6aa751d720ae04", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "a counter-mode suite", + "outcome": "suite_unsupported", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce01000100ac17640a3e71cafc9b1d211c18bdfa7378962dcd75a61f6786bcf3f85fad203ee0df61e86cd963070f5d2e73f3ac18f875568e270292e7cf2e17016858352406", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "an unregistered suite", + "outcome": "suite_unsupported", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce06000100dc3726a8e35f49cb72c505bae4b9babfe307fe481d2699a1c330492809a8faf0cc5190b27c5f6e94de316dc2775c67e2f4cde87d68dcfabbf04d989ec6b0b600", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "another MLS suite", + "outcome": "mls_suite_unsupported", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce04000300969c64f4d1b6a6ccb1b1e0a1a3c94fa014d35d0020e661644a19b1ac0428eb373be0e9a0da5445cae711177ca36bcd145395576b3ecf4f016dbb2959131d1c0e", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "a room that records", + "outcome": "recording_refused", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce04000100279a29fd7081c59cb255a8f2715810d1d668be6f54e7da7f51a30a7b48716ce3fa9418e9db15deba61fc882aec6aefdd2249cf25160af2fcf1f005cc7b89f204", + "recording_mode": 1, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "a room always recorded", + "outcome": "recording_refused", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce04000100279a29fd7081c59cb255a8f2715810d1d668be6f54e7da7f51a30a7b48716ce3fa9418e9db15deba61fc882aec6aefdd2249cf25160af2fcf1f005cc7b89f204", + "recording_mode": 2, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "signed by another key", + "outcome": "bad_signature", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce04000100e9e5f8deb6e15ad25695722d994ed337c79f74d033bae502f7b27d82aa85e25649c335c9e547548cd7c1af48110796de37d5715078543e86e2278012c809a00e", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "0100000000000000000000000000000000000000000000000000000000000000", + "name": "the identity as the authority", + "outcome": "bad_key", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce04000100279a29fd7081c59cb255a8f2715810d1d668be6f54e7da7f51a30a7b48716ce3fa9418e9db15deba61fc882aec6aefdd2249cf25160af2fcf1f005cc7b89f204", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + }, + { + "authority": "5d52aa67fdcfa4219a3a5ec5accc9f276e7fe0420f5546ab690aa9c5fb6058fd", + "name": "a changed suite under the old signature", + "outcome": "bad_signature", + "record": "5752453101d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce05000100279a29fd7081c59cb255a8f2715810d1d668be6f54e7da7f51a30a7b48716ce3fa9418e9db15deba61fc882aec6aefdd2249cf25160af2fcf1f005cc7b89f204", + "recording_mode": 0, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce" + } + ], + "rooms": [ + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [ + { + "deliveries": [ + { + "outcome": "opened", + "payload_blake3": "696bcf52d50bd70642545d762ec84d6f3ff815c97c65fb112b20a8722a32c7b7", + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + { + "outcome": "epoch_not_joined", + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "step": 2 + }, + { + "deliveries": [ + { + "outcome": "opened", + "payload_blake3": "8174661adf5f76b635508ec55276502955be56090f8d896b75c53eca3afed188", + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "step": 6 + }, + { + "deliveries": [ + { + "outcome": "opened", + "payload_blake3": "bcb880f53fa03641cd162e56a76362e484d30cc1ea4cb5bf169bc002ce47dbca", + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + }, + { + "outcome": "opened", + "payload_blake3": "bcb880f53fa03641cd162e56a76362e484d30cc1ea4cb5bf169bc002ce47dbca", + "receiver": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + }, + { + "outcome": "epoch_not_joined", + "receiver": "f6274c60975bba2438a8c725080411620660ab382c42891b2abd4caf580011a2" + } + ], + "step": 11 + }, + { + "deliveries": [ + { + "outcome": "opened", + "payload_blake3": "0143441e2e2e0f1bf69c0d3e39bf44f22765fbce6b29eb15c6ef3415a369f29c", + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + { + "outcome": "opened", + "payload_blake3": "0143441e2e2e0f1bf69c0d3e39bf44f22765fbce6b29eb15c6ef3415a369f29c", + "receiver": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + ], + "step": 12 + }, + { + "deliveries": [ + { + "outcome": "opened", + "payload_blake3": "affd894fd9ae8ca1f148375207a699c091f194a44fdcb2a2b8eb76c44564dce7", + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + { + "outcome": "opened", + "payload_blake3": "affd894fd9ae8ca1f148375207a699c091f194a44fdcb2a2b8eb76c44564dce7", + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "step": 13 + }, + { + "deliveries": [ + { + "outcome": "sender_removed", + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + { + "outcome": "sender_removed", + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "step": 17 + }, + { + "deliveries": [ + { + "outcome": "opened", + "payload_blake3": "d817f916e47242e25f9a66e832dae505e8808c181b797ef1e6688d5d158e295c", + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + }, + { + "outcome": "removed_before_epoch", + "receiver": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + ], + "step": 18 + }, + { + "deliveries": [ + { + "outcome": "opened", + "payload_blake3": "eda42ff8225e38323bcfb27fe0a09753b30903b24354cd47a823c6462c7f492b", + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + { + "outcome": "opened", + "payload_blake3": "eda42ff8225e38323bcfb27fe0a09753b30903b24354cd47a823c6462c7f492b", + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "step": 22 + }, + { + "deliveries": [ + { + "outcome": "opened", + "payload_blake3": "df34a4740d03963e484ba41e4cada3c4d1cf0c50b9f50021acbaba5885ee5f01", + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + }, + { + "outcome": "removed_before_epoch", + "receiver": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + ], + "step": 23 + }, + { + "deliveries": [ + { + "outcome": "epoch_not_joined", + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + ], + "step": 24 + }, + { + "deliveries": [ + { + "outcome": "epoch_not_joined", + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + ], + "step": 25 + }, + { + "deliveries": [ + { + "outcome": "opened", + "payload_blake3": "d0f8b3e64f537181acc566ea80a0d2e5fdcac3e3854d7bd23d7ef3a762765b61", + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + }, + { + "outcome": "replayed", + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + }, + { + "outcome": "removed_before_epoch", + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + ], + "step": 29 + }, + { + "deliveries": [ + { + "outcome": "bad_tag", + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + ], + "step": 30 + }, + { + "deliveries": [ + { + "outcome": "sender_not_owner", + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "step": 31 + }, + { + "deliveries": [ + { + "outcome": "key_id_unknown", + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + ], + "step": 32 + } + ], + "name": "three members, a removal, a late joiner, a change of committer", + "outcome": "accept", + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [ + { + "epoch": 0, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "epoch": 1, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "epoch": 2, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "2": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "epoch": 3, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "epoch": 4, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "2": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + }, + { + "epoch": 5, + "generation": 0, + "roster": { + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "2": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + } + ], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "create": { + "application_secret": "8ab2bebe07877940c4ca3d3b75022d06b8de2bb28544c3ddf50c6db92af9758b", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + }, + { + "deliveries": [ + { + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + { + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "object": { + "group": 0, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "screen" + ], + "object": 0, + "payload": "73da23eac3e536abb3999560e02f684254a42bfeafb0e0423404041229cf822e4a7cc1bc3827e0695aad121633fb6d66f6616bed21702c4cab4418560a7ea0606aec967c9d38dc64266da0d9cad23355e6f768ec1104a01d97a401c6a6f81db3b146d63cb059338d37c59c80f7c5545841f17ccc5ce38a51dbe186819df58b56c8a3322bfb818bed25df2d2f86d4f85c41", + "priority": 64, + "properties": "0b03ba4a00", + "track": "0" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "signature_key": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + "epoch": 0, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "68ae22e032814f3ba8e5997d3d2be70e1d761e17d5d2b75a6e4ed48cae03129d", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "deliveries": [ + { + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "object": { + "group": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "screen" + ], + "object": 0, + "payload": "8812842bea9549e839be615d4f4da984085ce95a7aca3ae15f26ba69ea2141ed3b992a328e8cf9da51d5e9e8eb0949b8cb1337d8d49fb7ede1ba8646a87ef7ceb4ac6e3110b8de6eaab402717e9353761d5d1e680882cabc18e69906232112feec4ddb90044c82ef3e5c64fdb86a60669f83a973db79a230794f720456a6be4bcd88fa1ff0e3ffa310676614cb05a17656", + "priority": 64, + "properties": "0b06ba4ae1000000", + "track": "0" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "signature_key": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + "epoch": 1, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "91167a5af0cd20d01fed12ea402eb3bfdef6cb5bfc86b562cbd5142173d63a93", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "event": { + "event": "join", + "keyed": false, + "subject": "f6274c60975bba2438a8c725080411620660ab382c42891b2abd4caf580011a2" + } + }, + { + "deliveries": [ + { + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + }, + { + "receiver": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + }, + { + "receiver": "f6274c60975bba2438a8c725080411620660ab382c42891b2abd4caf580011a2" + } + ], + "object": { + "group": 0, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "video" + ], + "object": 0, + "payload": "4a622df1b26bd80b8554478fbe2c1323d5ef2345916160fe4bb0a3bbe25edeae63cc95f246919b53b5c9817302a5da21ee80d86962927267ee88b9208f8880faa6ee435adf05252cfa7d23c5b81576c33e4869cb6a43667be5240dd0e0414fe182458f93dec608170438c66a95895eda15de8f47effec79607770c2147c6085fd27600f2ee562956457378f6e6fda85a07", + "priority": 64, + "properties": "0b06ba4ae2000000", + "track": "0" + } + }, + { + "deliveries": [ + { + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + { + "receiver": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + ], + "object": { + "group": 0, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "audio" + ], + "object": 0, + "payload": "e3cc298e0d286f4b45ffb8672c63038dd32ed8d2b7dc11f1fc30e79d1cccad2e611374b8dc9fb500c946faa1f7f7ec42774d5bb310ee3ab4ac854a18e658d38018b084eb0f478d341ed313358ee8f5ec836009c5712cff260fcbda2aeeb3478e33d3e3b06b9606cf4e3069", + "priority": 64, + "properties": "0b06ba4ae2000001", + "track": "0" + } + }, + { + "deliveries": [ + { + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + { + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "object": { + "group": 0, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "video" + ], + "object": 0, + "payload": "9ff4de10c6d77d0cd30b70f0eafa9df2eb8bae0bab46260ff15d32d5c08cd9dccb0d7e2c433328e087e0fc15f4a99300f20849165e52f6b4c366b9734f278bbd29199b204cf3cff3b9e9d6322fc70c13cebc2f9217c0dd90e1f147061d5fccf9f9b022d70bf724a601dd16041dd1fbabea703b330440a37a66d4f99dd548511a2cb1e29012256e2f2cb3509d826744ea42", + "priority": 64, + "properties": "0b06ba4ae2000002", + "track": "0" + } + }, + { + "event": { + "event": "leave", + "reason": 2, + "subject": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "proposal": { + "change": { + "remove": { + "leaf": 2 + } + }, + "epoch": 2, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "770a135373ce1d1d508a81a3c327716269521b9775a201548e63bd9fb60577b5", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "deliveries": [ + { + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + { + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "object": { + "group": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "video" + ], + "object": 0, + "payload": "22f847e930fb68325d88fc6bd6af7961f052b2da64f117b19e0ad768ddb811b2ee2d6c124484551bc19cf9ae6e564d23252c75c4fabad1956d09901125cb0fd2a6e0bc41d81c4214571a147a906216563184a50fbeca3916deffee972004fa49f55640b2a79819d6d3d5b80cf2f31a517d3199efe9b443215150386078f65ee641796976e333129d48b804779a870921da", + "priority": 64, + "properties": "0b06ba4ae2000002", + "track": "0" + } + }, + { + "deliveries": [ + { + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + }, + { + "receiver": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + ], + "object": { + "group": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "video" + ], + "object": 0, + "payload": "d783808e4786729ce4de989cebd0371805242c5293f4510b34cb9224561030da002fd37c56f80980689f369f78da38e53dbd286cba929a31bf136c7fa78fd023ff845dfd8b521a4b240ca3526ff33ab14d3c0ca6858c4d9159ed6a800ab1a051ca4c94e448eaff981a34091f907ebdfc5d91c0454267676560fe3c81cffc98a8796cd0b97abb6c7e18448fffb95e6062ad", + "priority": 64, + "properties": "0b06ba4ae3000000", + "track": "0" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8", + "signature_key": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + }, + "epoch": 3, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "2fb8b4c13cdcc60ed318c6661204bd77f3265fe365c104521741aa117af2929c", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "deliveries": [ + { + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + }, + { + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "object": { + "group": 0, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8", + "video" + ], + "object": 0, + "payload": "20c0e01fdd2d63d71938f781e128c6c58e7afe1252f5eec83fe74f738db3bffa42aeb3f3041879d764849be1c2c8352395d44083fbd0b53e166447db1fc074c05346a12b45a2b31b6f9b4be7e498c76c41097901e30fce2e104329e1c41ba8011115fae565a2bdd3db2e0ac713eb6a86914ebd823c74fb4d1cb5453b261aa9b28db82864ee77a36b491881a02b876a0ee4", + "priority": 64, + "properties": "0b06ba4ae4000002", + "track": "0" + } + }, + { + "deliveries": [ + { + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + }, + { + "receiver": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + ], + "object": { + "group": 2, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "video" + ], + "object": 0, + "payload": "83fb6487e2bb3bd3d5fe58c5987f89b190942601fb42f38d0d09c1ad46d651e6743fa72e6d5dbe3796c543eaf9551104f4f9d86598d29af85b4b8aaed0cd1a3b91257b7297a7a34e1a8c222888ed7ecc55f2a327de388dc57f3162ba6e831d4055ab37532c42343f4d883ba0549460d8e57be39c1584b7205334d53269460170b4e2a7863174613745dadf3a85c6ab45b7", + "priority": 64, + "properties": "0b06ba4ae4000000", + "track": "0" + } + }, + { + "deliveries": [ + { + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + ], + "object": { + "group": 0, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "video" + ], + "object": 0, + "payload": "4a622df1b26bd80b8554478fbe2c1323d5ef2345916160fe4bb0a3bbe25edeae63cc95f246919b53b5c9817302a5da21ee80d86962927267ee88b9208f8880faa6ee435adf05252cfa7d23c5b81576c33e4869cb6a43667be5240dd0e0414fe182458f93dec608170438c66a95895eda15de8f47effec79607770c2147c6085fd27600f2ee562956457378f6e6fda85a07", + "priority": 64, + "properties": "0b06ba4ae2000000", + "track": "0" + } + }, + { + "deliveries": [ + { + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + ], + "object": { + "group": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "video" + ], + "object": 0, + "payload": "d783808e4786729ce4de989cebd0371805242c5293f4510b34cb9224561030da002fd37c56f80980689f369f78da38e53dbd286cba929a31bf136c7fa78fd023ff845dfd8b521a4b240ca3526ff33ab14d3c0ca6858c4d9159ed6a800ab1a051ca4c94e448eaff981a34091f907ebdfc5d91c0454267676560fe3c81cffc98a8796cd0b97abb6c7e18448fffb95e6062ad", + "priority": 64, + "properties": "0b06ba4ae3000000", + "track": "0" + } + }, + { + "event": { + "event": "leave", + "reason": 1, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "proposal": { + "change": { + "remove": { + "leaf": 0 + } + }, + "epoch": 4, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "7fbd4d42428f3081e7438446f255f8c369fc43c18e87a884dfdc942ed4688b84", + "holds": [ + 0 + ], + "sender": 1 + } + }, + { + "deliveries": [ + { + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + }, + { + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + }, + { + "receiver": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + ], + "object": { + "group": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "audio" + ], + "object": 0, + "payload": "f7165db3f61737f3b120ffc053aabb83d3e87f46e512baf45f2ca872748ca92a5d966a72cf68993f32211b1a52f750afb178788cefaa42b4191eb6b20797b78b320c3449542b5cf9ace72c6c52ba62ac58f9e9bb3b206faa386047601f31556ebb9408e0868fe38a094224", + "priority": 64, + "properties": "0b06ba4ae5000001", + "track": "0" + } + }, + { + "deliveries": [ + { + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + ], + "object": { + "group": 1, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "audio" + ], + "object": 1, + "payload": "357873e9592d996b85c9e9c6aecebdd6482f735401f4b4a0f1c3590d21f9144aa6ef2e7efc02ee05b5b9049a752500dfd5ef2cef02b26cb285b24ec094b2e653aabc419f886a8401850d05b9a2fecca5efe0a53dbbd50717010521ae12725d4d832f8c6530c3dcdd85f788", + "priority": 64, + "properties": "0b06ba4ae5000001", + "track": "0" + } + }, + { + "deliveries": [ + { + "receiver": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + ], + "object": { + "group": 7, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "video" + ], + "object": 0, + "payload": "f5d9884e0b65e88fad8a28c3c759ed9d765098069e5baa2ec38ac17a3a103e447c3fc64c6db463728d317078d124c976e726f6ea6229cc9be2077b63d1d8c7bff4d3eca78de1fe5ab84055444c442ae74aca7b482b148adbc06d83f6d5456b4f396a2fc5d1a44caa6b1600ebdc9f049969f13fbfae053262bb6c4cf97dc2b8758bd59b59d227525805f22676e5e73ec158", + "priority": 64, + "properties": "0b06ba4ae5000002", + "track": "0" + } + }, + { + "deliveries": [ + { + "receiver": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + ], + "object": { + "group": 2, + "namespace": [ + "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "audio" + ], + "object": 0, + "payload": "19feaaa5eb39e3f3b4e339750eeec64d6c2adf224e9cdc8823b2a6bb1701e728f1f6e8b596081f84ac9d4aebc70748b016663c62f02bbcb2d41266965ad69d7cdaa7dc3bb88f95666c7796eeaa95f8b13ad14e1df6b76069e297788e5684d9", + "priority": 64, + "properties": "0b06ba4ae5000000", + "track": "0" + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "a commit by a member that is not the committer", + "outcome": { + "at": 7, + "refuse": "commit_not_committer" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [ + { + "epoch": 0, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "epoch": 1, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "create": { + "application_secret": "cadac80ac8ca4184939e6e9e5d3a4383b4f55b30260b0946f47d10c6b3e3d53d", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "signature_key": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + "epoch": 0, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "9c8f7b116b205bf88bbff6e235c1678e4c8a0e0a73063dd9e2fae58edafa66f5", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "signature_key": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + "epoch": 1, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "c283197db27d84468f2d6ae0a0fb7508ff29da8c1bead1bee4c8574ca364a171", + "holds": [ + 0 + ], + "sender": 1 + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "the member being removed commits", + "outcome": { + "at": 7, + "refuse": "commit_not_committer" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [ + { + "epoch": 0, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "epoch": 1, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "create": { + "application_secret": "f448c311ffedbe4ef1f6a244ef671f50c8dcc7a9db8c7f577546a095206b1425", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "signature_key": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + "epoch": 0, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "937e95a805b8f2c0373f99309cddc747ab9d39341a05e7be6ac6b98d272941ed", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "event": { + "event": "leave", + "reason": 1, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "proposal": { + "change": { + "remove": { + "leaf": 0 + } + }, + "epoch": 1, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "dd17974eab3b063cf27e729dd5602b4d996417068621b2ba122c7721ee621514", + "holds": [ + 0 + ], + "sender": 0 + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "a commit that skips the clock's first proposal", + "outcome": { + "at": 9, + "refuse": "commit_mismatch" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [ + { + "epoch": 0, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "epoch": 1, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "create": { + "application_secret": "0f956c961e63be3aa0a379888a6fa474f90be83345fa1a67ccc416690c672e0e", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "signature_key": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + "epoch": 0, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "ed3fe95e58c21765678cc53ba27093d87dc9b8b66430c732db8d0a271d4c59f1", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "signature_key": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + "epoch": 1, + "from": "clock" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8", + "signature_key": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + }, + "epoch": 1, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "2b572f4b410ee39945fc8ac2f8d76f91afb62458fcda83b568d0dcae0ed33071", + "holds": [ + 1 + ], + "sender": 0 + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "a commit that holds one proposal twice", + "outcome": { + "at": 7, + "refuse": "commit_mismatch" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [ + { + "epoch": 0, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "epoch": 1, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "create": { + "application_secret": "5d19d0c6445b2c9e124e315161f9084b003a71a92cc44637cead269e99d84690", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "signature_key": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + "epoch": 0, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "296271e660b7181fad1051799c76d5503f7ccfcd9c2c6e8ebd600943e53d4e38", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "signature_key": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + "epoch": 1, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "e80006d8f62838e813e4b1495c0478d282739dc3ab12f75a3e83c5d2bc5e83f7", + "holds": [ + 0, + 0 + ], + "sender": 0 + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "a commit holding a member's own proposal", + "outcome": { + "at": 6, + "refuse": "proposal_not_clock" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [ + { + "epoch": 0, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "epoch": 1, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "create": { + "application_secret": "f2bd4c1209da2905625cd320fbaaed488316f6b90b43983073ab213832d740cc", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "signature_key": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + "epoch": 0, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "c03733393f36ef600dbfce76dfbb3f3a27542f3bede13f1397b1d94ee3b51ea7", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "proposal": { + "change": { + "remove": { + "leaf": 0 + } + }, + "epoch": 1, + "from": { + "member": 1 + } + } + }, + { + "commit": { + "application_secret": "e1f7209b1151000528937acf9ebfa57c8d2a61c1ca8dce6aa718410cb4e1eed5", + "holds": [ + 0 + ], + "sender": 1 + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "the clock removes a member that did not leave", + "outcome": { + "at": 9, + "refuse": "proposal_mismatch" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [ + { + "epoch": 0, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "epoch": 1, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "epoch": 2, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "2": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + } + ], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "create": { + "application_secret": "66ed6853cce7e18c1a0e6844de0c9646a9a65b7f1f56c2876b0c6a344eec7862", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "signature_key": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + "epoch": 0, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "2e822e2075c0b1f59d00562ce39fa86c146ad1aa8ce9e75f8cfc0c3867dca04a", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "signature_key": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + "epoch": 1, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "063233458298a3d7ddd3a90c06e25342ad61b12ea5f8ee65a0b40b559ad32b7f", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "event": { + "event": "leave", + "reason": 3, + "subject": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "proposal": { + "change": { + "remove": { + "leaf": 1 + } + }, + "epoch": 2, + "from": "clock" + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "the clock adds a principal whose grant is not keyed", + "outcome": { + "at": 6, + "refuse": "proposal_mismatch" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [ + { + "epoch": 0, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "epoch": 1, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "create": { + "application_secret": "ad4a7cd34ee5ec67f4f9758e2148ad380bcfa6710391c804a7e6bdb28f854be8", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "signature_key": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + "epoch": 0, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "d2a2e1458e985cf6ebfea84fadfa5ecc16eac60c30f16959635432936ad657cd", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "event": { + "event": "join", + "keyed": false, + "subject": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "signature_key": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + "epoch": 1, + "from": "clock" + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "a proposal for an epoch that has ended", + "outcome": { + "at": 6, + "refuse": "proposal_mismatch" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [ + { + "epoch": 0, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "epoch": 1, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "create": { + "application_secret": "fcb409071dc153f73042e8f5b6361e1d2377bba11ecf91662dd059efb62b8f26", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "signature_key": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + "epoch": 0, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "d901f967dea605654f39c78ce0f1695e953aad308a4e1f620d09040f5ec09492", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "signature_key": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + "epoch": 0, + "from": "clock" + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "an Add whose credential's identity is not its signature key", + "outcome": { + "at": 6, + "refuse": "credential_mismatch" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [ + { + "epoch": 0, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "epoch": 1, + "generation": 0, + "roster": { + "0": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "1": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + } + ], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "create": { + "application_secret": "90b233da3f54cbe8bc91d156e73dae978a5fe8590df1b2d41394481e81bdfb09", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "signature_key": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + "epoch": 0, + "from": "clock" + } + }, + { + "commit": { + "application_secret": "01e59c26cde1794d4ec70d0f90968de5e4f42ffb57b7b3ea280916f5af2ca4be", + "holds": [ + 0 + ], + "sender": 0 + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7" + } + }, + { + "proposal": { + "change": { + "add": { + "identity": "ba278dae86d3206a28b42eeb9f2d458e6fe6638c95343159df1ed2b0b83cf0a7", + "signature_key": "d0c9afeab13c4d37b13853e4fe891d760966714e0b88a02d7517b7de5b6afac8" + } + }, + "epoch": 1, + "from": "clock" + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "a group created by a principal the clock does not owe it to", + "outcome": { + "at": 2, + "refuse": "proposal_mismatch" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "create": { + "application_secret": "c80235feda1f836f197244a2eb346081ef6ba74a81b25d6570172fce970171b8", + "by": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "a group whose ID names another generation", + "outcome": { + "at": 2, + "refuse": "group_mismatch" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "create": { + "application_secret": "6135704e3fafc7639d3c1d1f34f6de38977ac90e705af9c41d49aeac2b7d5e59", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000001" + } + } + ], + "suite": 4 + }, + { + "clock": "576f527fb9701d24c22eb2dd59c95193327e68ed233fa5e45721471c0cfaf05a", + "deliveries": [], + "name": "a group whose external sender is not the clock", + "outcome": { + "at": 2, + "refuse": "group_mismatch" + }, + "room": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce", + "rosters": [], + "steps": [ + { + "event": { + "event": "join", + "keyed": true, + "subject": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + } + }, + { + "event": { + "event": "join", + "keyed": true, + "subject": "fc9879c7fcd7555fee8621149499d5375c2e575c5638a9356f21bb301f21da3d" + } + }, + { + "create": { + "application_secret": "9bf97a70aa3414c0c103c8deec70bb4263c0e8f6eba7add50a887e038ade4ca9", + "by": "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff", + "external_senders": [ + "20c7b4cf22772bbc18176c11c946c3a6ce3d7c4ac7fa30bef29e8e65561b4dff" + ], + "group_id": "d5f04dbbee462828b912e47dc488eb11b9cc43f7461776c15e45124d6ab9ddce00000000" + } + } + ], + "suite": 4 + } + ], + "seeds": "every key, application secret and payload is SHA-256 of a fixed label: keys wai:e2ee/corpus/key, secrets wai:e2ee/corpus/secret, bytes wai:e2ee/corpus/bytes, the room id wai:e2ee/corpus/room", + "spec": "wai/extensions/confidentiality.md" +} diff --git a/wai/live-conformance/e2ee_verify.py b/wai/live-conformance/e2ee_verify.py new file mode 100644 index 000000000..c3b6d3e25 --- /dev/null +++ b/wai/live-conformance/e2ee_verify.py @@ -0,0 +1,1064 @@ +#!/usr/bin/env python3 +"""Independent verifier of the end-to-end encrypted rooms corpus (extensions/confidentiality.md). + +Written from the extension's text and the documents it reads. It shares no code with the +reference (joule-moq's secure_objects and joule-moq-mls), which generates the corpus, and +never runs it. Standard library only: + +- AES (FIPS 197), AES-GCM (NIST SP 800-38D) and RFC 9605's AES-CTR with HMAC-SHA-256 are + written here from their specifications, FOR TEST VECTORS ONLY: no constant time, no + speed, no use outside this file; +- HKDF (RFC 5869) and HMAC come from Python's hmac and hashlib; +- BLAKE3 from ../claim-conformance/_blake3.py, and Ed25519 under WAI's strict rule (SPEC §7 + *Signatures*) from ../claim-conformance/_ed25519.py. + +What it checks: + +- every test vector of RFC 9605 Appendix C (rfc9605.json) and of + draft-ietf-moq-secure-objects-01 Appendix B (secure-objects-01.json), the latter exact in + the GCM suites and, in the counter-mode suites, the difference confidentiality §11.2 + states; +- every vector of vectors.json: the confidentiality records (§3.3); the key schedule + (§4.2, §4.5, §5.7); every sealed object, sealed again from its inputs with every derived + value required, then opened through the receiving checks (§6); every room, replayed step + by step through the model of §5.4 to §5.6 (lifecycle, the clock's proposals, the + committer, what a commit holds, each epoch's roster) and every delivery of every sealed + object opened or refused (§6); +- that the corpus raises every reason of §13, and that §13 lists exactly the codes the + corpus lists. + +The boundary: it does not implement MLS. Each epoch's application secret is taken from the +corpus as given (§14), and everything after it is checked. + +Then it mutates copies of the corpus, one defect each, and requires every copy to fail. + + python3 -I e2ee_verify.py + +Exit status 0 only if every vector verifies and every mutated copy fails. Python 3.8 or later. +""" + +import copy +import hashlib +import hmac as _hmac +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) +import _blake3 # noqa: E402 +import _ed25519 as ED # noqa: E402 + + +class Refuse(Exception): + def __init__(self, code): + super().__init__(code) + self.code = code + + +# ================================================================ AES, FOR VECTORS ONLY + +def _xtime(a): + a <<= 1 + return (a ^ 0x11B) & 0xFF if a & 0x100 else a + + +def _gmul(a, b): + r = 0 + while b: + if b & 1: + r ^= a + a = _xtime(a) + b >>= 1 + return r + + +def _make_sbox(): + """FIPS 197 §5.1.1: the inverse in GF(2^8), then the affine transformation.""" + inv = [0] * 256 + for x in range(1, 256): + for y in range(1, 256): + if _gmul(x, y) == 1: + inv[x] = y + break + box = [] + for x in range(256): + b = inv[x] + s = b + for i in range(1, 5): + s ^= ((b << i) | (b >> (8 - i))) & 0xFF + box.append(s ^ 0x63) + return box + + +SBOX = _make_sbox() + + +def key_expansion(key): + """FIPS 197 §5.2: the round keys.""" + nk = len(key) // 4 + if nk not in (4, 6, 8) or len(key) % 4: + raise ValueError("an AES key is 16, 24 or 32 bytes") + nr = nk + 6 + w = [list(key[4 * i:4 * i + 4]) for i in range(nk)] + rcon = 1 + for i in range(nk, 4 * (nr + 1)): + t = list(w[i - 1]) + if i % nk == 0: + t = [SBOX[b] for b in t[1:] + t[:1]] + t[0] ^= rcon + rcon = _xtime(rcon) + elif nk > 6 and i % nk == 4: + t = [SBOX[b] for b in t] + w.append([w[i - nk][j] ^ t[j] for j in range(4)]) + return [sum(w[4 * r:4 * r + 4], []) for r in range(nr + 1)] + + +def encrypt_block(rk, block): + """FIPS 197 §5.1, the state column by column.""" + s = [b ^ k for b, k in zip(block, rk[0])] + nr = len(rk) - 1 + for r in range(1, nr + 1): + s = [SBOX[b] for b in s] + s = [s[4 * ((c + i) % 4) + i] for c in range(4) for i in range(4)] + if r != nr: + m = [] + for c in range(4): + a = s[4 * c:4 * c + 4] + m += [_gmul(a[0], 2) ^ _gmul(a[1], 3) ^ a[2] ^ a[3], + a[0] ^ _gmul(a[1], 2) ^ _gmul(a[2], 3) ^ a[3], + a[0] ^ a[1] ^ _gmul(a[2], 2) ^ _gmul(a[3], 3), + _gmul(a[0], 3) ^ a[1] ^ a[2] ^ _gmul(a[3], 2)] + s = m + s = [b ^ k for b, k in zip(s, rk[r])] + return bytes(s) + + +# ---------------------------------------------------------------- GCM (SP 800-38D) + +_R = 0xE1 << 120 + + +def _gf_mult(x, y): + """SP 800-38D §6.3, Algorithm 1.""" + z, v = 0, y + for i in range(127, -1, -1): + if (x >> i) & 1: + z ^= v + v = (v >> 1) ^ _R if v & 1 else v >> 1 + return z + + +def _ghash(h, data): + y = 0 + for i in range(0, len(data), 16): + y = _gf_mult(y ^ int.from_bytes(data[i:i + 16], "big"), h) + return y + + +def _pad16(b): + return b + bytes(-len(b) % 16) + + +def _inc32(block): + return block[:12] + ((int.from_bytes(block[12:], "big") + 1) % (1 << 32)).to_bytes(4, "big") + + +def _gctr(rk, icb, data): + out, cb = bytearray(), icb + for i in range(0, len(data), 16): + ks = encrypt_block(rk, list(cb)) + out += bytes(a ^ b for a, b in zip(data[i:i + 16], ks)) + cb = _inc32(cb) + return bytes(out) + + +def _gcm_tag(rk, h, j0, aad, c): + s = _ghash(h, _pad16(aad) + _pad16(c) + (8 * len(aad)).to_bytes(8, "big") + (8 * len(c)).to_bytes(8, "big")) + return _gctr(rk, j0, s.to_bytes(16, "big")) + + +def gcm_seal(key, nonce, aad, pt): + """SP 800-38D §7.1 with a 96-bit IV and a 128-bit tag.""" + rk = key_expansion(key) + h = int.from_bytes(encrypt_block(rk, [0] * 16), "big") + j0 = nonce + b"\x00\x00\x00\x01" + c = _gctr(rk, _inc32(j0), pt) + return c + _gcm_tag(rk, h, j0, aad, c) + + +def gcm_open(key, nonce, aad, ct): + """SP 800-38D §7.2: the plaintext, or None.""" + if len(ct) < 16: + return None + c, t = ct[:-16], ct[-16:] + rk = key_expansion(key) + h = int.from_bytes(encrypt_block(rk, [0] * 16), "big") + j0 = nonce + b"\x00\x00\x00\x01" + if not _hmac.compare_digest(_gcm_tag(rk, h, j0, aad, c), t): + return None + return _gctr(rk, _inc32(j0), c) + + +# --------------------------------------------- RFC 9605 §4.5.1: AES-CTR with HMAC-SHA-256 + +def _ctr(enc_key, nonce, data): + rk = key_expansion(enc_key) + out, n = bytearray(), int.from_bytes(nonce + bytes(4), "big") + for i in range(0, len(data), 16): + ks = encrypt_block(rk, list(n.to_bytes(16, "big"))) + out += bytes(a ^ b for a, b in zip(data[i:i + 16], ks)) + n = (n + 1) % (1 << 128) + return bytes(out) + + +def rfc9605_tag(auth_key, nonce, aad, ct, nt): + data = len(aad).to_bytes(8, "big") + len(ct).to_bytes(8, "big") + nt.to_bytes(8, "big") + nonce + aad + ct + return _hmac.new(auth_key, data, hashlib.sha256).digest()[:nt] + + +def ctr_seal(key, nonce, aad, pt, nt): + ct = _ctr(key[:16], nonce, pt) + return ct + rfc9605_tag(key[16:], nonce, aad, ct, nt) + + +def ctr_open(key, nonce, aad, ct, nt): + if len(ct) < nt: + return None + inner, tag = ct[:-nt], ct[-nt:] + if not _hmac.compare_digest(rfc9605_tag(key[16:], nonce, aad, inner, nt), tag): + return None + return _ctr(key[:16], nonce, inner) + + +# The SFrame suites (RFC 9605 §4.5, §8.1), which secure objects §7.2 uses: +# id: (hash, Nk, Nt, counter mode) +SUITES = {1: ("sha256", 48, 10, True), 2: ("sha256", 48, 8, True), 3: ("sha256", 48, 4, True), + 4: ("sha256", 16, 16, False), 5: ("sha512", 32, 16, False)} + + +def seal(suite, key, nonce, aad, pt): + h, nk, nt, ctr = SUITES[suite] + return ctr_seal(key, nonce, aad, pt, nt) if ctr else gcm_seal(key, nonce, aad, pt) + + +def open_(suite, key, nonce, aad, ct): + h, nk, nt, ctr = SUITES[suite] + return ctr_open(key, nonce, aad, ct, nt) if ctr else gcm_open(key, nonce, aad, ct) + + +# --------------------------------------------------------------------- HKDF (RFC 5869) + +def hkdf_extract(hname, salt, ikm): + h = getattr(hashlib, hname) + return _hmac.new(salt or bytes(h().digest_size), ikm, h).digest() + + +def hkdf_expand(hname, prk, info, n): + h = getattr(hashlib, hname) + out, t, i = b"", b"", 1 + while len(out) < n: + t = _hmac.new(prk, t + info + bytes([i]), h).digest() + out += t + i += 1 + return out[:n] + + +def blake3(b): + return _blake3.blake3(b) + + +# ============================================================ MOQT -22 integers and pairs + +def vi(v): + """MOQT §8.1, the shortest encoding.""" + if v < 0 or v >= 1 << 64: + raise ValueError("vi64 range") + for n in range(1, 9): + bits = 7 * n if n < 8 else 56 + if v < (1 << bits): + prefix = (0xFF << (9 - n)) & 0xFF if n > 1 else 0 + return (v | (prefix << (8 * (n - 1)))).to_bytes(n, "big") + return b"\xff" + v.to_bytes(8, "big") + + +def vi_read(b, i): + """Any encoding §8.1 allows; ValueError when the bytes end inside it.""" + if i >= len(b): + raise ValueError("truncated") + first = b[i] + n = 9 + for k in range(8): + if not (first >> (7 - k)) & 1: + n = k + 1 + break + if i + n > len(b): + raise ValueError("truncated") + if n == 9: + return int.from_bytes(b[i + 1:i + 9], "big"), i + 9 + v = first & (0xFF >> n) if n > 1 else first & 0x7F + for x in b[i + 1:i + n]: + v = (v << 8) | x + return v, i + n + + +def pairs_read(b): + """MOQT §8.3: delta-coded types; an even type's value one integer, an odd type's a + length of at most 2^16 - 1 and that many bytes.""" + out, i, prev = [], 0, 0 + while i < len(b): + d, i = vi_read(b, i) + ty = prev + d + if ty >= 1 << 64: + raise ValueError("type overflow") + prev = ty + if ty % 2 == 0: + v, i = vi_read(b, i) + else: + n, i = vi_read(b, i) + if n > 0xFFFF or i + n > len(b): + raise ValueError("value") + v, i = bytes(b[i:i + n]), i + n + out.append((ty, v)) + return out + + +def pairs_write(ps): + """In non-decreasing type order (pairs of one type keep their order), shortest + integers.""" + out, prev = b"", 0 + for ty, v in sorted(ps, key=lambda p: p[0]): + out += vi(ty - prev) + prev = ty + out += vi(v) if ty % 2 == 0 else vi(len(v)) + v + return out + + +# ===================================================================== secure objects + +KEY_ID = 0x3A4A +IMMUTABLE = 0x0B +ENCRYPTED_LIST = 0x000A +PADDING = 0x0032 +INNER_DIGEST = 0x3A4B + + +def ser_namespace(fields): + out = vi(len(fields)) + for f in fields: + out += vi(len(f)) + f + return out + + +def ser_ftn(fields, name): + return ser_namespace(fields) + vi(len(name)) + name + + +def derive_key_salt(suite, kid, base, ftn): + """Secure objects §3.7 (confidentiality §4.5).""" + h, nk, nt, _ = SUITES[suite] + secret = hkdf_extract(h, b"", base) + tail = ftn + suite.to_bytes(2, "big") + kid.to_bytes(8, "big") + return (hkdf_expand(h, secret, b"MOQ 1.0 Secure Objects Secret key " + tail, nk), + hkdf_expand(h, secret, b"MOQ 1.0 Secret salt " + tail, 12)) + + +def ctr_of(group, obj): + if obj > 0xFFFFFFFF: + raise Refuse("object_id_too_large") + return group.to_bytes(8, "big") + obj.to_bytes(4, "big") + + +def aad_of(group, obj, priority, sip): + return ctr_of(group, obj) + bytes([priority]) + sip + + +def xor(a, b): + return bytes(x ^ y for x, y in zip(a, b)) + + +def track_base_key(secret, fields): + """Confidentiality §5.7: HKDF-Expand with SHA-256, info mocha_track and the namespace.""" + return hkdf_expand("sha256", secret, b"mocha_track" + ser_namespace(fields), 32) + + +def kid_of(epoch, leaf): + if epoch >= 1 << 40 or leaf >= 1 << 24: + raise ValueError("KID range") + return (epoch << 24) | leaf + + +def plaintext_write(payload, digest, entries, padding): + pt = vi(len(payload)) + payload + es = b"" + if digest: + es += INNER_DIGEST.to_bytes(2, "big") + vi(32) + blake3(payload) + for ty, v in entries: + es += ty.to_bytes(2, "big") + vi(len(v)) + v + if padding is not None: + es += PADDING.to_bytes(2, "big") + padding.to_bytes(2, "big") + bytes(padding) + if es: + pt += ENCRYPTED_LIST.to_bytes(2, "big") + vi(len(es)) + es + return pt + + +def plaintext_read(b): + """Confidentiality §4.4: (payload, inner digest or None).""" + try: + n, i = vi_read(b, 0) + if i + n > len(b): + raise Refuse("plaintext_malformed") + payload, i = b[i:i + n], i + n + if i == len(b): + return payload, None + if i + 2 > len(b) or int.from_bytes(b[i:i + 2], "big") != ENCRYPTED_LIST: + raise Refuse("plaintext_malformed") + m, i = vi_read(b, i + 2) + if m != len(b) - i: + raise Refuse("plaintext_malformed") + digest = None + while i < len(b): + if i + 2 > len(b): + raise Refuse("plaintext_malformed") + ty = int.from_bytes(b[i:i + 2], "big") + i += 2 + if ty == PADDING: + if i + 2 > len(b): + raise Refuse("plaintext_malformed") + n = int.from_bytes(b[i:i + 2], "big") + if i + 2 + n != len(b): + raise Refuse("plaintext_malformed") + break + n, i = vi_read(b, i) + if i + n > len(b): + raise Refuse("plaintext_malformed") + v, i = b[i:i + n], i + n + if ty == INNER_DIGEST: + if digest is not None or len(v) != 32: + raise Refuse("plaintext_malformed") + digest = v + return payload, digest + except ValueError: + raise Refuse("plaintext_malformed") + + +def key_id_of(props_bytes): + """Confidentiality §6 step 1: (KID, Serialized Immutable Properties).""" + try: + outer = pairs_read(props_bytes) + except ValueError: + raise Refuse("properties_malformed") + imm = [v for t, v in outer if t == IMMUTABLE] + if any(t == KEY_ID for t, _ in outer) or len(imm) > 1: + raise Refuse("properties_malformed") + if not imm: + raise Refuse("key_id_missing") + sip = imm[0] + try: + inner = pairs_read(sip) + except ValueError: + raise Refuse("properties_malformed") + if any(t == IMMUTABLE for t, _ in inner): + raise Refuse("properties_malformed") + kids = [v for t, v in inner if t == KEY_ID] + if not kids: + raise Refuse("key_id_missing") + if len(kids) > 1: + raise Refuse("properties_malformed") + return kids[0], sip + + +# ===================================================================== a receiver (§6) + +def owner_of(fields): + """Live-rooms §6.1: the namespace's second field, when it is a principal's 64 hex digits.""" + if len(fields) < 2: + return None + f = fields[1] + if len(f) == 64 and all(c in b"0123456789abcdef" for c in f): + return bytes.fromhex(f.decode()) + return None + + +class Ring: + """What a member holds: the epochs it entered, their secrets and rosters, the + locations it opened, and its removal.""" + + def __init__(self, suite, me): + self.suite, self.me = suite, me + self.joined = self.removed = None + self.secrets, self.rosters, self.opened = {}, {}, set() + + def enter(self, epoch, secret, roster): + if self.joined is None: + self.joined = epoch + self.secrets[epoch] = secret + self.rosters[epoch] = dict(roster) + + def open(self, o): + fields = [f.encode() for f in o["namespace"]] + name = o["track"].encode() + group, obj, prio = o["group"], o["object"], o["priority"] + kid, sip = key_id_of(bytes.fromhex(o["properties"])) + if obj > 0xFFFFFFFF: + raise Refuse("object_id_too_large") + epoch, leaf = kid >> 24, kid & 0xFFFFFF + if self.removed is not None and epoch >= self.removed: + raise Refuse("removed_before_epoch") + if not self.rosters or self.joined is None or epoch < self.joined: + raise Refuse("epoch_not_joined") + latest = max(self.rosters) + if epoch > latest: + return {"outcome": "pending", "epoch": epoch} + if epoch not in self.secrets or epoch not in self.rosters: + raise Refuse("epoch_not_joined") + sender = self.rosters[epoch].get(leaf) + if sender is None: + raise Refuse("key_id_unknown") + if owner_of(fields) != sender: + raise Refuse("sender_not_owner") + for e in range(epoch, latest + 1): + if e in self.rosters and self.rosters[e].get(leaf) != sender: + raise Refuse("sender_removed") + ftn = ser_ftn(fields, name) + place = (ftn, group, obj) + if place in self.opened: + raise Refuse("replayed") + key, salt = derive_key_salt(self.suite, kid, track_base_key(self.secrets[epoch], fields), ftn) + pt = open_(self.suite, key, xor(salt, ctr_of(group, obj)), aad_of(group, obj, prio, sip), bytes.fromhex(o["payload"])) + if pt is None: + raise Refuse("bad_tag") + payload, digest = plaintext_read(pt) + if digest is not None and digest != blake3(payload): + raise Refuse("inner_digest_mismatch") + self.opened.add(place) + return {"outcome": "opened", "payload_blake3": blake3(payload).hex()} + + +def try_open(ring, o): + try: + return ring.open(o) + except Refuse as e: + return {"outcome": e.code} + + +# ===================================================================== the record (§3) + +def check_record(b, room, authority, mode): + if len(b) < 4: + raise Refuse("truncated") + if b[:4] != b"WRE1": + raise Refuse("malformed") + if len(b) < 5: + raise Refuse("truncated") + if b[4] != 1: + raise Refuse("unknown_version") + if len(b) < 105: + raise Refuse("truncated") + if len(b) > 105: + raise Refuse("malformed") + if b[5:37] != room: + raise Refuse("wrong_room") + suite = int.from_bytes(b[37:39], "little") + if suite not in (4, 5): + raise Refuse("suite_unsupported") + if int.from_bytes(b[39:41], "little") != 1: + raise Refuse("mls_suite_unsupported") + if mode != 0: + raise Refuse("recording_refused") + r = ED.verify(authority, b"wai:live-room-e2ee\x01" + b[:41], b[41:105]) + if r is not None: + raise Refuse(r) + return suite + + +# ============================================================ the group follows the room + +class Model: + """Confidentiality §5.4 to §5.6, as state.""" + + def __init__(self, room, clock): + self.room, self.clock = room, clock + self.generation, self.group = 0, None # group: [epoch, {leaf: principal}] + self.seq, self.keyed, self.gone, self.pending = 0, {}, {}, [] + + def group_id(self): + return self.room + self.generation.to_bytes(4, "big") + + def event(self, ev): + self.seq += 1 + s = bytes.fromhex(ev["subject"]) + kind = ev["event"] + if kind == "join": + now = True if ev["keyed"] else None + elif kind == "leave": + now = False + elif kind == "grant_exchange": + now = ev["keyed_after"] if ev["keyed_before"] != ev["keyed_after"] else None + else: + raise ValueError("an event this text does not define") + if now is True: + self.keyed[s] = self.seq + self.gone.pop(s, None) + elif now is False and self.keyed.pop(s, None) is not None: + self.gone[s] = self.seq + if self.group is not None and not any(p in self.keyed for p in self.group[1].values()): + self.group, self.pending = None, [] + self.generation += 1 + self.gone = {} + + def reflected(self): + if self.group is None: + return set() + r = set(self.group[1].values()) + for p in self.pending: + if p["from"] != "clock": + continue + if "add" in p["change"]: + r.add(bytes.fromhex(p["change"]["add"]["identity"])) + else: + x = self.group[1].get(p["change"]["remove"]["leaf"]) + if x is not None: + r.discard(x) + return r + + def next_owed(self): + """The earliest change, in the order of the events calling for it, that can be + proposed now.""" + if self.group is None: + if not self.keyed: + return None + p = min(self.keyed, key=lambda q: (self.keyed[q], q)) + return ("create", p) + refl = self.reflected() + owed = [] + for p in sorted(self.keyed): + if p not in refl: + owed.append((self.keyed[p], 1, ("add", p))) + for p in sorted(refl): + if p not in self.keyed: + leaf = next((l for l, q in self.group[1].items() if q == p), None) + if leaf is not None: + owed.append((self.gone.get(p, 0), 0, ("remove", p, leaf))) + owed.sort(key=lambda o: (o[0], o[1])) + return owed[0][2] if owed else None + + def create(self, by, gid, ext): + if gid != self.group_id() or ext != [self.clock]: + raise Refuse("group_mismatch") + if self.next_owed() != ("create", by): + raise Refuse("proposal_mismatch") + self.group = [0, {0: by}] + + def propose(self, p): + if self.group is None: + raise Refuse("proposal_mismatch") + if p["from"] == "clock": + if p["epoch"] != self.group[0]: + raise Refuse("proposal_mismatch") + ch, owed = p["change"], self.next_owed() + if "add" in ch: + if ch["add"]["identity"] != ch["add"]["signature_key"]: + raise Refuse("credential_mismatch") + ok = owed is not None and owed[0] == "add" and owed[1] == bytes.fromhex(ch["add"]["identity"]) + else: + ok = owed is not None and owed[0] == "remove" and owed[2] == ch["remove"]["leaf"] + if not ok: + raise Refuse("proposal_mismatch") + self.pending.append(p) + + def commit(self, sender, holds): + """§5.5's checks, in its order, then §5.6: the removed and the added.""" + if self.group is None: + raise Refuse("commit_mismatch") + if any(not isinstance(i, int) or i < 0 or i >= len(self.pending) for i in holds): + raise Refuse("commit_mismatch") + held = [self.pending[i] for i in holds] + removed = {p["change"]["remove"]["leaf"] for p in held if "remove" in p["change"]} + epoch, roster = self.group + committer = next((l for l in sorted(roster) if l not in removed), None) + if committer != sender: + raise Refuse("commit_not_committer") + if any(p["from"] != "clock" for p in held): + raise Refuse("proposal_not_clock") + clock = [i for i, p in enumerate(self.pending) if p["from"] == "clock"] + if sorted(holds) != clock[:len(holds)] or any(l not in roster for l in removed): + raise Refuse("commit_mismatch") + new = {l: p for l, p in roster.items() if l not in removed} + added = [] + for p in held: + if "add" in p["change"]: + q = bytes.fromhex(p["change"]["add"]["identity"]) + leaf = 0 + while leaf in new: + leaf += 1 + new[leaf] = q + added.append(q) + gone = [roster[l] for l in sorted(removed)] + for q in gone: + self.gone.pop(q, None) + self.group, self.pending = [epoch + 1, new], [] + return gone + + +def roster_json(r): + return {str(l): p.hex() for l, p in sorted(r.items())} + + +def replay(v): + suite = v["suite"] + m = Model(bytes.fromhex(v["room"]), bytes.fromhex(v["clock"])) + rings, rosters, deliveries = {}, [], [] + + def enter_all(secret): + epoch, roster = m.group + rosters.append({"generation": m.generation, "epoch": epoch, "roster": roster_json(roster)}) + for p in roster.values(): + rings.setdefault(p, Ring(suite, p)).enter(epoch, secret, roster) + + outcome = "accept" + for i, s in enumerate(v["steps"]): + try: + if "event" in s: + m.event(s["event"]) + elif "create" in s: + c = s["create"] + m.create(bytes.fromhex(c["by"]), bytes.fromhex(c["group_id"]), [bytes.fromhex(x) for x in c["external_senders"]]) + enter_all(bytes.fromhex(c["application_secret"])) + elif "proposal" in s: + m.propose(s["proposal"]) + elif "commit" in s: + c = s["commit"] + gone = m.commit(c["sender"], c["holds"]) + for q in gone: + if q in rings: + rings[q].removed = m.group[0] + enter_all(bytes.fromhex(c["application_secret"])) + elif "object" in s: + out = [] + for d in s["deliveries"]: + who = bytes.fromhex(d["receiver"]) + r = try_open(rings.setdefault(who, Ring(suite, who)), s["object"]) + r["receiver"] = who.hex() + out.append(r) + deliveries.append({"step": i, "deliveries": out}) + else: + raise ValueError("a step this corpus does not define") + except Refuse as e: + outcome = {"refuse": e.code, "at": i} + break + return {"outcome": outcome, "rosters": rosters, "deliveries": deliveries} + + +# =========================================================================== the checks + +def check_object(v): + suite = v["suite"] + if "input" in v: + i = v["input"] + fields = [f.encode() for f in i["namespace"]] + name = i["track"].encode() + payload = bytes.fromhex(i["payload"]) + kid = kid_of(i["epoch"], i["leaf"]) + base = track_base_key(bytes.fromhex(i["application_secret"]), fields) + ftn = ser_ftn(fields, name) + key, salt = derive_key_salt(suite, kid, base, ftn) + imm = [(p["type"], p["varint"] if "varint" in p else bytes.fromhex(p["bytes"])) for p in i["immutable"]] + sip = pairs_write([p for p in imm if p[0] != KEY_ID] + [(KEY_ID, kid)]) + pt = plaintext_write(payload, i["inner_digest"], [(e["type"], bytes.fromhex(e["value"])) for e in i["entries"]], i["padding"]) + aad = aad_of(i["group"], i["object"], i["priority"], sip) + nonce = xor(salt, ctr_of(i["group"], i["object"])) + sealed = seal(suite, key, nonce, aad, pt) + derived = {"track_base_key": base.hex(), "kid": kid, "serialized_full_track_name": ftn.hex(), "moq_key": key.hex(), + "moq_salt": salt.hex(), "immutable_properties": sip.hex(), "plaintext": pt.hex(), "aad": aad.hex(), "nonce": nonce.hex()} + if derived != v["derived"]: + bad = [k for k in derived if derived[k] != v["derived"].get(k)] + raise AssertionError("derived values differ: %s" % bad) + o = v["object"] + if (o["properties"] != pairs_write([(IMMUTABLE, sip)]).hex() or o["payload"] != sealed.hex() + or [f.encode() for f in o["namespace"]] != fields or o["track"] != i["track"] + or (o["group"], o["object"], o["priority"]) != (i["group"], i["object"], i["priority"])): + raise AssertionError("the sealed object is not what its inputs seal") + if v["content_hash"] != blake3(sealed).hex(): + raise AssertionError("the content hash is not BLAKE3 of the sealed payload") + ctx = v["context"] + ring = Ring(suite, bytes.fromhex(ctx["receiver"])) + for e in ctx["epochs"]: + ring.enter(e["epoch"], bytes.fromhex(e["application_secret"]), {int(k): bytes.fromhex(p) for k, p in e["roster"].items()}) + return try_open(ring, v["object"]) + + +def check_published(r, so): + """RFC 9605 Appendix C (`r`) and secure objects' Appendix B (`so`).""" + fails = [] + + def header(kid, ctr): + def compact(x): + return x.to_bytes(max(1, (x.bit_length() + 7) // 8), "big") + b, tail = 0, b"" + if kid < 8: + b |= kid << 4 + else: + k = compact(kid) + b |= 0x80 | ((len(k) - 1) << 4) + tail += k + if ctr < 8: + b |= ctr + else: + c = compact(ctr) + b |= 0x08 | (len(c) - 1) + tail += c + return bytes([b]) + tail + + X = bytes.fromhex + for c in r["header"]: + if header(c["kid"], c["ctr"]).hex() != c["header"]: + fails.append("RFC 9605 header %x/%x" % (c["kid"], c["ctr"])) + for c in r["aead"]: + s = c["cipher_suite"] + if X(c["key"])[:16] != X(c["enc_key"]) or X(c["key"])[16:] != X(c["auth_key"]): + fails.append("RFC 9605 C.2 subkeys, suite %d" % s) + ct = seal(s, X(c["key"]), X(c["nonce"]), X(c["aad"]), X(c["pt"])) + if ct.hex() != c["ct"] or open_(s, X(c["key"]), X(c["nonce"]), X(c["aad"]), ct) != X(c["pt"]): + fails.append("RFC 9605 C.2 suite %d" % s) + for c in r["sframe"]: + s = c["cipher_suite"] + h, nk, nt, _ = SUITES[s] + secret = hkdf_extract(h, b"", X(c["base_key"])) + kl = b"SFrame 1.0 Secret key " + c["kid"].to_bytes(8, "big") + s.to_bytes(2, "big") + sl = b"SFrame 1.0 Secret salt " + c["kid"].to_bytes(8, "big") + s.to_bytes(2, "big") + key, salt = hkdf_expand(h, secret, kl, nk), hkdf_expand(h, secret, sl, 12) + nonce = xor(salt, c["ctr"].to_bytes(12, "big")) + hdr = header(c["kid"], c["ctr"]) + aad = hdr + X(c["metadata"]) + ok = (secret.hex() == c["sframe_secret"] and kl.hex() == c["sframe_key_label"] and sl.hex() == c["sframe_salt_label"] + and key.hex() == c["sframe_key"] and salt.hex() == c["sframe_salt"] and nonce.hex() == c["nonce"] and aad.hex() == c["aad"]) + ct = hdr + seal(s, key, nonce, aad, X(c["pt"])) + if not ok or ct.hex() != c["ct"] or open_(s, key, nonce, aad, ct[len(hdr):]) != X(c["pt"]): + fails.append("RFC 9605 C.3 suite %d" % s) + if not (len(r["header"]) > 100 and len(r["aead"]) == 3 and len(r["sframe"]) == 5): + fails.append("RFC 9605's vectors are not all there") + + for c in so["full_track_name"]: + if ser_ftn([t.encode() for t in c["namespace_tuples"]], c["track_name"].encode()).hex() != c["serialized_full_track_name"]: + fails.append("secure objects B.1 %s" % c["track_name"]) + for c in so["key_derivation"]: + s = c["cipher_suite"] + h, nk, nt, _ = SUITES[s] + ftn = X(c["serialized_full_track_name"]) + tail = ftn + s.to_bytes(2, "big") + c["key_id"].to_bytes(8, "big") + key, salt = derive_key_salt(s, c["key_id"], X(c["track_base_key"]), ftn) + if (hkdf_extract(h, b"", X(c["track_base_key"])).hex() != c["moq_secret"] + or (b"MOQ 1.0 Secure Objects Secret key " + tail).hex() != c["moq_key_label"] + or (b"MOQ 1.0 Secret salt " + tail).hex() != c["moq_salt_label"] + or key.hex() != c["moq_key"] or salt.hex() != c["moq_salt"]): + fails.append("secure objects B.2 suite %d" % s) + for c in so["nonce_aad"]: + ctr = c["group_id"].to_bytes(8, "big") + c["object_id"].to_bytes(4, "big") + # Appendix B's Key ID property: a 16-bit type 0x0002, a length, the value (§11.3). + sip = b"\x00\x02" + vi(len(vi(c["key_id"]))) + vi(c["key_id"]) + X(c["serialized_immutable_properties"]) + if (ctr.hex() != c["ctr"] or xor(X(c["moq_salt"]), ctr).hex() != c["nonce"] + or (ctr + bytes([c["publisher_priority"]]) + sip).hex() != c["aad"]): + fails.append("secure objects B.3 group %d" % c["group_id"]) + gcm = ctr_suites = 0 + for c in so["encryption"]: + s = c["cipher_suite"] + h, nk, nt, is_ctr = SUITES[s] + payload = X(c["original_payload"]) + pt = vi(len(payload)) + payload + X(c["encrypted_properties_list"]) + key, nonce, aad, want = X(c["moq_key"]), X(c["nonce"]), X(c["aad"]), X(c["ciphertext"]) + k2, s2 = derive_key_salt(s, c["key_id"], X(c["track_base_key"]), ser_ftn([t.encode() for t in c["namespace_tuples"]], c["track_name"].encode())) + if pt.hex() != c["plaintext"] or k2 != key or xor(s2, ctr_of(c["group_id"], c["object_id"])) != nonce: + fails.append("secure objects B.4 inputs, suite %d" % s) + continue + got = seal(s, key, nonce, aad, pt) + if not is_ctr: + if got != want or open_(s, key, nonce, aad, want) != pt: + fails.append("secure objects B.4 suite %d" % s) + gcm += 1 + else: + # §11.2: the counter-mode encryption is RFC 9605's; the vector's tag is + # HMAC(auth_key, aad ‖ nonce ‖ ct), and RFC 9605 §4.5.1's is not that. + body = len(want) - nt + alt = _hmac.new(key[16:], aad + nonce + want[:body], hashlib.sha256).digest()[:nt] + if got[:body] != want[:body] or got[body:] == want[body:] or alt != want[body:]: + fails.append("secure objects B.4 suite %d: the difference of §11.2 is not as stated" % s) + ctr_suites += 1 + if (gcm, ctr_suites) != (2, 2): + fails.append("secure objects' encryption vectors are not all there") + return fails + + +def spec_codes(): + """The codes §13 of the extension lists, in order.""" + text = open(os.path.join(HERE, "..", "extensions", "confidentiality.md"), encoding="utf-8").read() + start = text.index("## 13. Reasons") + end = text.find("\n## ", start + 1) + out = [] + for line in text[start:end].splitlines(): + if line.startswith("| `"): + out += line.split("|")[1].split("`")[1::2] + return out + + +def check(doc, quiet=False): + fails, seen = [], set() + + def note(o): + if isinstance(o, str): + seen.add(o) + elif isinstance(o, dict) and "refuse" in o: + seen.add(o["refuse"]) + + def report(kind, name, bad, what): + if not quiet: + print(("PASS" if not bad else "FAIL") + " %s %s: %s" % (kind, name, what)) + fails.extend("%s %s: %s" % (kind, name, b) for b in bad) + + for v in doc["records"]: + try: + got = {"outcome": "accept", "suite": check_record(bytes.fromhex(v["record"]), bytes.fromhex(v["room"]), + bytes.fromhex(v["authority"]), v["recording_mode"])} + except Refuse as e: + got = {"outcome": e.code} + bad = [k for k in ("outcome", "suite") if got.get(k) != v.get(k)] + report("record", v["name"], bad, got["outcome"]) + note(v["outcome"]) + for v in doc["key_schedule"]: + fields = [f.encode() for f in v["namespace"]] + base = track_base_key(bytes.fromhex(v["application_secret"]), fields) + kid = kid_of(v["epoch"], v["leaf"]) + ftn = ser_ftn(fields, v["track"].encode()) + key, salt = derive_key_salt(v["suite"], kid, base, ftn) + got = {"track_base_key": base.hex(), "kid": kid, "serialized_full_track_name": ftn.hex(), "moq_key": key.hex(), "moq_salt": salt.hex()} + report("key schedule", "epoch %d leaf %d" % (v["epoch"], v["leaf"]), [k for k in got if got[k] != v.get(k)], "derived") + for v in doc["objects"]: + try: + got = check_object(v) + bad = [k for k in ("outcome", "payload_blake3") if got.get(k) != v.get(k)] + except (AssertionError, KeyError, ValueError, TypeError) as e: + got, bad = {"outcome": "?"}, ["%s: %s" % (type(e).__name__, e)] + report("object", v["name"], bad, got["outcome"]) + note(v["outcome"]) + for v in doc["rooms"]: + try: + got = replay(v) + bad = [k for k in ("outcome", "rosters", "deliveries") if got[k] != v.get(k)] + except (KeyError, ValueError, TypeError, AttributeError) as e: + got, bad = {"outcome": "?"}, ["%s: %s" % (type(e).__name__, e)] + report("room", v["name"], bad, json.dumps(got["outcome"])) + note(v["outcome"]) + for d in v.get("deliveries", []): + for x in d["deliveries"]: + note(x["outcome"]) + codes = spec_codes() + if doc["codes"] != codes: + fails.append("the corpus's codes are not the ones §13 lists") + for c in codes: + if c not in seen: + fails.append("no vector raises %s" % c) + return fails + + +def flip(h, at): + b = bytearray(bytes.fromhex(h)) + b[at] ^= 0x01 + return b.hex() + + +def mutations(doc): + """Copies of the corpus, each with one defect a conforming verifier must catch.""" + def mutate(label, f): + d = copy.deepcopy(doc) + f(d) + return label, d + + room = doc["rooms"][0] + first_commit = next(i for i, s in enumerate(room["steps"]) if "commit" in s) + first_object = next(i for i, s in enumerate(room["steps"]) if "object" in s) + return [ + mutate("a record's signature byte", lambda d: d["records"][0].update(record=flip(d["records"][0]["record"], 60))), + mutate("a refused record stated accepted", lambda d: d["records"][-1].update(outcome="accept", suite=4)), + mutate("a track base key", lambda d: d["key_schedule"][2].update(track_base_key=flip(d["key_schedule"][2]["track_base_key"], 0))), + mutate("a KID", lambda d: d["key_schedule"][4].update(kid=d["key_schedule"][4]["kid"] + 1)), + mutate("an object's nonce", lambda d: d["objects"][0]["derived"].update(nonce=flip(d["objects"][0]["derived"]["nonce"], 11))), + mutate("an object's AAD", lambda d: d["objects"][3]["derived"].update(aad=flip(d["objects"][3]["derived"]["aad"], 12))), + mutate("a sealed payload's byte", lambda d: d["objects"][1]["object"].update(payload=flip(d["objects"][1]["object"]["payload"], 5))), + mutate("an object's content hash", lambda d: d["objects"][2].update(content_hash=flip(d["objects"][2]["content_hash"], 0))), + mutate("a refused object stated opened", lambda d: d["objects"][-1].update(outcome="opened")), + mutate("an application secret", lambda d: d["rooms"][0]["steps"][first_commit]["commit"].update( + application_secret=flip(d["rooms"][0]["steps"][first_commit]["commit"]["application_secret"], 0))), + mutate("a commit's sender", lambda d: d["rooms"][0]["steps"][first_commit]["commit"].update(sender=1)), + mutate("a delivery's outcome", lambda d: d["rooms"][0]["deliveries"][0]["deliveries"][0].update(outcome="bad_tag")), + mutate("a roster", lambda d: d["rooms"][0]["rosters"][2]["roster"].update({"2": d["rooms"][0]["rosters"][2]["roster"]["0"]})), + mutate("a refused room's reason", lambda d: d["rooms"][1]["outcome"].update(refuse="commit_mismatch")), + mutate("a refused room's step", lambda d: d["rooms"][1]["outcome"].update(at=d["rooms"][1]["outcome"]["at"] - 1)), + mutate("an object's group in the room", lambda d: d["rooms"][0]["steps"][first_object]["object"].update(group=9)), + mutate("a lifecycle event's subject", lambda d: d["rooms"][0]["steps"][0]["event"].update(subject="00" * 32)), + mutate("a code dropped from the list", lambda d: d["codes"].pop()), + ] + + +def published_mutations(r, so): + """Copies of the published vectors, each with one defect.""" + def mutate(label, which, f): + a, b = copy.deepcopy(r), copy.deepcopy(so) + f(a if which == "rfc" else b) + return label, a, b + + return [ + mutate("a byte of an RFC 9605 SFrame ciphertext", "rfc", lambda j: j["sframe"][4].update(ct=flip(j["sframe"][4]["ct"], -1))), + mutate("an RFC 9605 header", "rfc", lambda j: j["header"][100].update(header="00")), + mutate("a byte of an RFC 9605 counter-mode ciphertext", "rfc", lambda j: j["aead"][1].update(ct=flip(j["aead"][1]["ct"], 0))), + mutate("a byte of a secure-objects GCM ciphertext", "so", lambda j: j["encryption"][2].update(ciphertext=flip(j["encryption"][2]["ciphertext"], 0))), + mutate("a byte of a secure-objects counter-mode tag", "so", lambda j: j["encryption"][0].update(ciphertext=flip(j["encryption"][0]["ciphertext"], -1))), + mutate("a secure-objects moq_key", "so", lambda j: j["key_derivation"][3].update(moq_key=flip(j["key_derivation"][3]["moq_key"], 0))), + mutate("a secure-objects AAD", "so", lambda j: j["nonce_aad"][1].update(aad=flip(j["nonce_aad"][1]["aad"], 12))), + ] + + +def main(argv): + if len(argv) != 2: + print("usage: e2ee_verify.py ", file=sys.stderr) + return 2 + d = argv[1] + r = json.load(open(os.path.join(d, "rfc9605.json"))) + so = json.load(open(os.path.join(d, "secure-objects-01.json"))) + fails = check_published(r, so) + print(("PASS" if not fails else "FAIL") + " the published vectors of RFC 9605 Appendix C and secure objects' Appendix B") + pcaught = 0 + pmuts = published_mutations(r, so) + for label, a, b in pmuts: + try: + ok = not check_published(a, b) + except Exception: # a crash on a mutated copy is a failure to read it + ok = False + if ok: + fails.append("a mutated copy of the published vectors verifies: %s" % label) + else: + pcaught += 1 + print("%d of %d mutated copies of the published vectors fail, as they must" % (pcaught, len(pmuts))) + doc = json.load(open(os.path.join(d, "vectors.json"))) + if not (doc["records"] and doc["objects"] and doc["rooms"] and doc["key_schedule"]): + fails.append("an empty corpus") + fails += check(doc) + caught = 0 + muts = mutations(doc) + for label, m in muts: + try: + ok = not check(m, quiet=True) + except Exception: # a crash on a mutated copy is a failure to read it + ok = False + if ok: + fails.append("a mutated copy verifies: %s" % label) + else: + caught += 1 + print("%d of %d mutated copies of the corpus fail, as they must" % (caught, len(muts))) + for f in fails: + print("FAIL " + f) + print("e2ee_verify: %s" % ("every vector verifies" if not fails else "%d failure(s)" % len(fails))) + return 1 if fails else 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/wai/registry/capabilities.json b/wai/registry/capabilities.json index be856f45c..c60416699 100644 --- a/wai/registry/capabilities.json +++ b/wai/registry/capabilities.json @@ -616,6 +616,20 @@ "edition": null, "registered": "2026-10-17" }, + { + "capability": "wai.live.e2ee", + "kind": "record", + "status": "registered", + "media": null, + "role": null, + "tier": null, + "licence": null, + "replicate": null, + "sets": [], + "spec_section": "§5 Interactive, temporal & computed content", + "edition": null, + "registered": "2026-10-19" + }, { "capability": "wai.live.events", "kind": "record", @@ -1850,6 +1864,13 @@ "section": "§10.5.2", "status": "proposed" }, + { + "name": "SECURE_OBJECT_KEY_ID", + "kind": "catalog", + "document": "extensions/confidentiality.md", + "section": "§4.3", + "status": "defined" + }, { "name": "WAI-Room-Challenge", "kind": "http-header", @@ -1878,6 +1899,13 @@ "section": "§7.1", "status": "defined" }, + { + "name": "WAI_INNER_DIGEST", + "kind": "catalog", + "document": "extensions/confidentiality.md", + "section": "§4.4", + "status": "defined" + }, { "name": "WAI_RECEIPT", "kind": "catalog", @@ -1983,6 +2011,13 @@ "section": "§3.1", "status": "defined" }, + { + "name": "WRE1", + "kind": "magic", + "document": "extensions/confidentiality.md", + "section": "§3.1", + "status": "defined" + }, { "name": "WRG1", "kind": "magic", diff --git a/wai/vocab/v2.ttl b/wai/vocab/v2.ttl index c9828a62f..50b8119cd 100644 --- a/wai/vocab/v2.ttl +++ b/wai/vocab/v2.ttl @@ -541,6 +541,13 @@ wai:wai.live.compose a wai:Capability ; wai:specSection "§5 Interactive, temporal & computed content" ; wai:registeredOn "2026-10-17"^^xsd:date . +wai:wai.live.e2ee a wai:Capability ; + rdfs:label "wai.live.e2ee" ; + wai:kind wai:kind-record ; + wai:status wai:status-registered ; + wai:specSection "§5 Interactive, temporal & computed content" ; + wai:registeredOn "2026-10-19"^^xsd:date . + wai:wai.live.events a wai:Capability ; rdfs:label "wai.live.events" ; wai:kind wai:kind-record ; diff --git a/wai/wai-rs/src/codecs/registry.rs b/wai/wai-rs/src/codecs/registry.rs index 70764ee01..0917eeeab 100644 --- a/wai/wai-rs/src/codecs/registry.rs +++ b/wai/wai-rs/src/codecs/registry.rs @@ -218,6 +218,7 @@ pub const CAP_LIVE_METERING: &str = "wai.live.metering"; pub const CAP_LIVE_STEERING: &str = "wai.live.steering"; pub const CAP_TEXT_TIMED: &str = "wai.text.timed"; pub const CAP_LIVE_MODERATION: &str = "wai.live.moderation"; +pub const CAP_LIVE_E2EE: &str = "wai.live.e2ee"; /// One row per string SPEC §5 registers that is a payload, companion or record: /// `(string, SPEC section, edition, registered)`. @@ -304,6 +305,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[ (CAP_LIVE_STEERING, S_INTERACTIVE, None, "2026-10-18"), (CAP_TEXT_TIMED, S_INTERACTIVE, None, "2026-10-17"), (CAP_LIVE_MODERATION, S_INTERACTIVE, None, "2026-10-19"), + (CAP_LIVE_E2EE, S_INTERACTIVE, None, "2026-10-19"), (CAP_AUDIO_IAMF, S_IMMERSIVE, None, "2026-06-17"), (CAP_AUDIO_AC4, S_IMMERSIVE, None, "2026-06-17"), (CAP_AUDIO_IVAS, S_IMMERSIVE, None, "2026-06-17"), @@ -353,7 +355,7 @@ const RECORDS: &[&str] = &[ CAP_ASSET_PROVENANCE, CAP_QUANTUM_CALIBRATION, CAP_QUANTUM_JOB, CAP_VIDEO_MANIFEST, CAP_VIDEO_SSAI, CAP_VIDEO_KEYS, CAP_VIDEO_PACKAGE, CAP_VIDEO_CHANNEL, CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE, CAP_LIVE_COMPOSE, CAP_LIVE_RECORDING, - CAP_LIVE_METERING, CAP_LIVE_STEERING, CAP_TEXT_TIMED, CAP_LIVE_MODERATION, + CAP_LIVE_METERING, CAP_LIVE_STEERING, CAP_TEXT_TIMED, CAP_LIVE_MODERATION, CAP_LIVE_E2EE, ]; /// A named identifier SPEC uses that has the shape of a capability string but is @@ -507,6 +509,7 @@ const TIMED_TEXT: &str = "extensions/timed-text.md"; const LIVE_METERING: &str = "extensions/live-metering.md"; const ENERGY_STEERING: &str = "extensions/energy-steering.md"; const LIVE_MODERATION: &str = "extensions/live-moderation.md"; +const CONFIDENTIALITY: &str = "extensions/confidentiality.md"; const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str, status: WireStatus) -> WireIdentifier { @@ -595,6 +598,9 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[ wire("WMP1", WireKind::Magic, LIVE_MODERATION, "§3.1", WireStatus::Defined), wire("WMS1", WireKind::Magic, LIVE_MODERATION, "§4.2", WireStatus::Defined), wire("WMN1", WireKind::Magic, LIVE_MODERATION, "§5.1", WireStatus::Defined), + wire("WRE1", WireKind::Magic, CONFIDENTIALITY, "§3.1", WireStatus::Defined), + wire("SECURE_OBJECT_KEY_ID", WireKind::Catalog, CONFIDENTIALITY, "§4.3", WireStatus::Defined), + wire("WAI_INNER_DIGEST", WireKind::Catalog, CONFIDENTIALITY, "§4.4", WireStatus::Defined), ]; /// A string with the shape of a capability that the reference implementation uses