wai.live.e2ee in the browser: a page member in WebAssembly, frames sealed before publish and opened before decode, a room service carrying the group's messages (L10b) #366
Loading…
Reference in a new issue
No description provided.
Delete branch "joule-moq/e2ee-web"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
L10b: end-to-end encrypted rooms in the browser
A browser page is now a member of an end-to-end encrypted room (WAI
extensions/confidentiality.md). The page holds its own MLS state in WebAssembly. It seals each encoded frame before signing and publishing it, and opens each received object through the §6 checks, in order, before any decoder sees it. A room service (joule-moq-room,[room] e2ee = true) keeps such a room. It holds the room'sWRE1record and its clock, the MLS group's external sender, and it carries the group's messages in a log with one order. The spec gains §15, "A member in a browser", and §1's scope now covers the browser.What is in it
joule-moq-mls: a member behind a carrier that orders the room's messages.stage_createandstage_commithold a member's GroupInfo or commit until the carrier returns it.receivethen applies it, after the same checks every member runs. If another member's commit comes first, or the carrier refuses the post, the staged one is withdrawn.Clock::key_package_ifkeeps a KeyPackage only when it comes from a principal the room admitted.jsbuilds the crate for wasm32.joule-moq: a keyring now holds the locations it has opened per epoch, and drops them together with that epoch's keys. Memory in a long room is bounded by the epochs the page holds.joule-moq-web, featuree2ee, built as a second wasm,joule_moq_web_e2ee, which a page loads only for such a room:E2eeMember: KeyPackage, lifecycle events, log messages in order, staged create and commit, seal and open.e2eeCheckRecord.TrackSigner.signSealed: signs the receipt over the sealed bytes, with noWAI_RECEIPT.UniItem.properties.The room page. It shows that the room is end-to-end encrypted before entering, and offers no Watch and no seeking back. On entering it:
web/room/e2ee.js), checking each lifecycle record as the clock's (signature, room, chain) and passing each MLS message to the member;Sending and receiving:
joule-moq-room:WRE1record and an MLSClockthat follows the lifecycle;GET /mls(long-poll of the room's log),POST /mls/key-package(granted principals only),POST /mls/post(the clock checks each post, and a refused post is not carried);GET /roomreturns the record.wai-live-web:roomDescriptornow returnsrecording_mode, which the page needs to check the record (§3.3 step 5).Spec and docs:
room.example.toml;CI:
cargo test -p joule-moq-web --features e2ee --lib. Those tests run only with the feature.e2e/: a newe2eescenario, built byrun.sh(e2ee wasm under the new[profile.wasm], name section stripped).Design decisions, and why
MLS in wasm: the MLS library and the pure-Rust crypto provider this tree already pins (0.9.1 and 0.6.0), unchanged, through the library's own browser feature. That feature gives the library browser randomness and a browser clock. The crypto provider seeds its ChaCha generator through an older randomness crate, which needs its own browser backend, so
joule-moq-mls's featurejsturns on both. On the host the feature changes nothing. The member builds forwasm32-unknown-unknownwith no threads (the library skips its thread pool on wasm32) and no filesystem, and keys live only in the wasm member's memory.Bundle size. The e2ee build is a separate wasm, so a page in any other room never loads MLS. Measured as
run.shbuilds them:joule_moq_web(unchanged)joule_moq_web_e2eeFor the e2ee build:
[profile.wasm](LTO, one codegen unit,opt-level = "s") plus stripping the name and producers sections brings it to 2.04 MB / 653 KB;Most of the rest is the MLS library and its multi-suite crypto provider. The plain build is built exactly as before.
Carrier: the room service, not MoQ tracks.
events,audioandvideotracks, somls/keypackagetracks would have meant a relay change.joinit answers.Staging is required, not a nicety, and §15.2 makes it a MUST. §5.5 names the committer from the commit's own proposals. So two members with different views (one has read a clock Add, the other also a Remove of the first) can each legitimately commit for the same epoch. If the first applied its commit at once, it would hold an epoch nobody else reaches. Members therefore apply their own post only when the log returns it.
Viewers in an E2EE room are not members. Their grant may subscribe, so it is keyed, but this service records presence from a publisher's tracks. A viewer is never added and opens nothing. The page hides Watch in such a room, and §15.3 says so. The e2e uses a viewer as "a subscriber without keys".
Inner digest: sealed on video, left out on audio (§4.4 SHOULD / MAY).
Group numbering. Frames encoded before the page is keyed are dropped without a group number. A late joiner's FETCH from group 0 then never asks the relay for groups it never had. Before this fix the relay forwarded such a FETCH to the publisher, which refuses requests; this showed up as one flaky e2e run.
Recording: the descriptor's mode is already 0, so §3.3 step 5 holds. The relay's DVR store keeps nothing of such a room, as the e2e checks.
Checks
All builds, tests and browsers ran on the build host through
rb.cargo test --locked -p joule-moq-mlscargo run -q --locked -p joule-moq-mls --bin joule_moq_e2ee_vectors verify ../../wai/live-conformance/e2eecargo test --locked -p joule-moq-web/--features e2eecargo test --locked -p joule-moq --features secure-objects --lib secure_objects,--test secure_objects_vectorscargo test --locked -p joule-moq-relay --test sealed_objectscargo build --locked -p joule-moq-relay -p joule-moq-ingest(dependents of joule-moq)joule-moq-room: cargo test --locked --all-targetsan_end_to_end_encrypted_room_keys_its_members_through_the_servicenew)wai/wai-live-web: cargo test --locked,cargo clippy --locked --all-targets--all-targetson joule-moq-mls, joule-moq-web (with and without e2ee), joule-moq-web e2ee for wasm32, joule-moq-roomwai-rsunpinned_read,joule-moq-room/src/epoch.rs:418run.shcargo metadata --locked --offlineon the joule-code, joule-moq-room and examples/transport-moqt lockswai registry export --checke2e/run.sh --runs 2, whole suite (compose, webtransport, websocket, goaway, dvr, service, e2ee), two separate invocations at the final merged head (after mergingorigin/maind9a614dd)live-conformance/*_verify.pywith its corpus (shared text: SPEC, confidentiality)e2ee_verify.pycheck-tests-in-ci.py,check-workflow-concurrency.sh,check-spec-refs.py,check-wai-section-refs.py,check-no-vanity-metrics.sh,test-public-text-checks.sh,check-comparative-prose.sh,check-orphan-sources.pyWhat the
e2eescenario shows (31 checks per run; run.sh drives the relay over WebSocket with the door on, the room service withe2ee = true, and a DVR window over every track):WAI1envelope. It opens nothing and hands nothing to a decoder.bad_tagbad_tagbad_tagkey_id_missingproperties_malformedobject_id_too_largekey_id_unknownsender_not_ownerreplayedbad_tagand reaches no decoder. This is counted by envelopes that fail to read on the decode path. A decoder may still report the delta frame that follows the dropped one, so decoder errors are not what is counted.epoch_not_joined; the only fetched object that opens is from her own join epoch.removed_before_epoch, while Alice and Bob keep opening each other's. Carol's kept-back object is refusedsender_removed.Falsifications
Each was applied on top of a commit, run, and reverted. F1 and F5 were run again after the final merge, with the same outcome.
withdrawremoved fromreceive)cargo test -p joule-moq-mls --libexit 101:a_member_whose_commit_another_overtakes_drops_its_own_and_applies_the_otherFAILEDa_room_with_a_record_and_a_log_two_members_key_throughFAILED (exit 101); integration testan_end_to_end_encrypted_room_keys_its_members_through_the_serviceFAILED at "nothing refused was carried" (exit 101)--only e2eeexit 1: nothing opens, so nothing decodes ("alice decodes the other's media" never holds)--only e2eeexit 1: the viewer check fails (17 handed to a decoder) and the on-the-wire check fails (decode errors 0 → 2)(F2, making
stage_commitapply at once, broke every commit test. That is too blunt to say anything, so it is not counted.)Not done
Watching without membership. A non-publishing member would need the service to record a viewer's presence and leave. Not built; §15.3.
WHIP/WebRTC ingest into an E2EE room. That would need the W3C encoded-transform interface. Not built; §15.3.
Seeking back in an E2EE room: by design the relay keeps nothing.
Production. The demo room keeps
e2ee = falseuntil the lead deploys. Turning it on needs:e2ee = truein the room service's config;web/room/e2ee.js, the build outputsweb/pkg/joule_moq_web_e2ee{.js,_bg.wasm}, and the rebuiltwai_live_web(roomDescriptornow returnsrecording_mode).Nothing on the relay changes.
No new corpus vectors. The wire, record, reasons and key schedule are unchanged. The browser member runs the reference's code, and the e2e is its test.
🤖 Generated with Claude Code