diff --git a/.github/workflows/joule-moq-room.yml b/.github/workflows/joule-moq-room.yml index bf2837b75..4690484ec 100644 --- a/.github/workflows/joule-moq-room.yml +++ b/.github/workflows/joule-moq-room.yml @@ -8,10 +8,13 @@ name: joule-moq-room room service # alone opens none, the room verifies (live-rooms §9), a publisher past the room's places # is refused, clients are bounded, and a new room starts when the last publisher has left; # an end-to-end encrypted room (WAI confidentiality), members keying through the -# service's log on a relay; and admission by age (WAI age-assurance §5.6), the service a -# Privacy Pass origin whose tokens' thresholds its grants state and the relay serves by. +# service's log on a relay; admission by age (WAI age-assurance §5.6), the service a +# Privacy Pass origin whose tokens' thresholds its grants state and the relay serves by; and a +# watch room (WAI co-watch): viewers admitted by a proof of their key, their controls and +# statements carried when co-watch accepts them, the clock's skew statements and leaves, and the +# room the log carries verified by wai-rs. # -# It builds on wai-rs's room, event and composition records, on the relay and on +# It builds on wai-rs's room, event, composition and co-watch records, on the relay and on # joule-moq-mls, so a change to any of them runs it. Push filters match the push's delta, so every path the job reads is # listed. diff --git a/.github/workflows/joule-moq-web.yml b/.github/workflows/joule-moq-web.yml index 9ddc713a5..8c8df8ec4 100644 --- a/.github/workflows/joule-moq-web.yml +++ b/.github/workflows/joule-moq-web.yml @@ -40,12 +40,25 @@ name: joule-moq-web browser client # votes and sends prompts from the page, the relay withholding each input the # rules refuse; the clock commits and selects, credited; and the room with the # clock's log of objects verifies the same in the page and on the host. +# ageco a room that admits by age and keeps co-performance: the room's clock, the +# room's own service by its proof of the room's keys (age-assurance §5.5), +# logs both performers' rated tracks and marks an object out of turn, while a +# viewer at 16 is still refused the video rated 18. +# origin the endpoints a link may name: a relay or a room service on another origin +# is refused and nothing connects; a GOAWAY naming another origin is refused +# and the session ends; and every page of the service scenario connects to its +# own origin alone. # binding the browser sink (WAI browser-sink): live-conformance/browser in the page, # a wasm-route decode verified against its receipts with every frame bound, # the binding through clone, transfer, copyTo and re-encode, the user agent's # decoder's frames bound through the side table, no codec string outside the # WebCodecs Codec Registry put to the user agent, a measured figure per decode # beside powerEfficient, and a room whose tiles show verified, bound frames. +# cowatch a watch room (WAI co-watch) kept by a room service: three viewers check the +# source by its content hash and enter by a proof of their keys; a play, a +# pause and a seek applied by every page at their instants, a late joiner +# brought to the room's position, the clock's skew statements in sync within +# the bound, and the room verified by co_watch_verify.py. # # Before the browser, wai-live-web's own tests: the room's records a page reads, co-performance's # among them, each statement read and the corpus verified through the crate. @@ -85,6 +98,10 @@ on: - 'wai/live-conformance/coperformance/**' - 'wai/live-conformance/browser/**' - 'wai/wai-rs/tests/vectors/int_video/**' + # The page's co-watch module is tested against the co-watch corpus, and the cowatch + # scenario verifies its room with the corpus's second implementation. + - 'wai/live-conformance/cowatch/**' + - 'wai/live-conformance/co_watch_verify.py' - '.github/workflows/joule-moq-web.yml' pull_request: paths: @@ -108,6 +125,10 @@ on: - 'wai/live-conformance/coperformance/**' - 'wai/live-conformance/browser/**' - 'wai/wai-rs/tests/vectors/int_video/**' + # The page's co-watch module is tested against the co-watch corpus, and the cowatch + # scenario verifies its room with the corpus's second implementation. + - 'wai/live-conformance/cowatch/**' + - 'wai/live-conformance/co_watch_verify.py' - '.github/workflows/joule-moq-web.yml' jobs: diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index f336990e7..aa18b7300 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -182,6 +182,12 @@ on: # reports and its §3.2 table to name the reference's descriptors, exploration_verify.py # reads §13, and the registry's wire identifiers test reads its sections. - 'wai/extensions/exploration.md' + # tests/co_watch.rs requires this extension's §12 to list the codes the reference reports, + # co_watch_verify.py reads that table, and the registry's wire identifiers test reads its + # sections. + - 'wai/extensions/co-watch.md' + # The co-watch step runs the browser client's co-watch module against the corpus. + - 'joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch*' - 'wai/wai-web/**' # The audio provenance check below reads the corpora above with this tool. - 'wai/tools/wai_audio_provenance_check.py' @@ -1552,6 +1558,30 @@ jobs: (cd wai-rs && cargo test --no-default-features --features exploration --lib exploration::) (cd wai-rs && cargo test --no-default-features --features exploration --test exploration) python3 -I live-conformance/exploration_verify.py live-conformance/exploration + # wai.live.cowatch (extensions/co-watch.md): the reference module's unit tests (the policy + # and its rules, statements read back and refused, the controlled position in the order of + # §5.4, each branch of the catch-up rule, percentiles by nearest rank, the control attribute) + # and tests/co_watch.rs (vectors.json regenerates byte for byte from its fixed seeds and no + # other file is there, wai-rs gives every room and catch-up case its stated outcome, and §12 + # lists exactly the reference's codes, each of which the corpus gives); then the corpus's + # second implementation, co_watch_verify.py, written from the extension's text and sharing + # no code with wai-rs, which verifies each room with live_rooms_verify.py and every + # signature with WAI's strict Ed25519 rule, recomputes the controlled position, every + # sample, percentile and verdict, requires every mutated copy of the corpus to fail and + # drops each of its own checks in turn and requires the corpus to catch the weakened + # verifier; standard library only, run isolated (-I). Last, the browser client's co-watch + # module (joule-moq-web/web/room/cowatch.js), which gives every catch-up case its + # correction, recomputes each accepted room's controlled position, and writes the corpus's + # controls and presentation statements byte for byte. + - name: co-watch · wai-rs, co_watch_verify.py and the page's module + working-directory: wai + shell: bash + run: | + set -euo pipefail + (cd wai-rs && cargo test --no-default-features --features co_watch --lib co_watch::) + (cd wai-rs && cargo test --no-default-features --features co_watch --test co_watch) + python3 -I live-conformance/co_watch_verify.py live-conformance/cowatch + node --test ../joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch.test.mjs # 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, diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md index f55876b9f..20331615d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/README.md @@ -384,6 +384,16 @@ forwards objects, a FETCH, or a fill whose first group is above it is refused, ` later is answered up to the group before it. The counters `age_refused`, `age_withheld` and `ratings_held` count the refusals, the objects not forwarded, and the claims held. +The room's own service is not held to the rule (§5.5): a session whose SETUP carries a +`GRANT_PROOF` naming no grant (32 bytes of zero) by a room key, over its challenge, is served +every object of that room. The relay checks the proof as a grant's proof (live-rooms §10.5.4, +the zero digest for the grant and the proof's key for the principal) and closes the session +`UNAUTHORIZED` for one that does not pass. A key it holds as a room key is an authority's of +`[[auth.authorities]]`, for the rooms that authority is honoured for, or one `[floor] room_keys` +or `[dvr] room_keys` names, for every room; a proof that passes by any other key gives nothing. +The client presents such proofs with `ClientOptions::room_keys`; the room service's clock does, +so it follows and logs a rated room's tracks. + **Patterns.** A field `*` in a rule's prefix, or a door rule's, matches any one field: a room's clock token can be given `["*", "", "events"]`, its events track in every room. A door rule's `trust_field` names the namespace field holding the one key it accepts @@ -650,6 +660,7 @@ All on loopback, every wait bounded: | `request_ids` | INVALID_REQUEST_ID for the wrong parity and for a repeat; requests in flight bounded by the stream limit. | | `dvr` | A long publish crosses the window: the oldest groups go, whole, each media group with its receipts group, within the byte budget; a window by age empties as its groups pass it. FETCH inside the window (ascending and descending), across it (an End of Unknown Range through the last evicted group, then every object, from the store and the cache together) and before it (the marker alone); every object verifies against the fetched receipts. A restart keeps the store, drops and counts a flipped byte and bytes changed with their checks made again (only the receipt finds those), removes a cut-short write, and never serves what it dropped. A room key's redaction erases its target and withholds it, a deny withholds until an allow, a redacted event leaves the object that carries it, a decision by no moderator changes nothing, and the decisions hold across a restart. Without the seek power a FETCH and a fill below the live edge are refused `seek_not_granted`, and a FETCH at the live edge is served; with it, the window. The record of the room is `WMH1` version 2 with the store's figures. A room whose objects are sealed is never kept: its first sealed group is not written, its events and receipts already held and its decisions are deleted, nothing more of it is written, and so after a restart, while a room in the clear beside it keeps its window. A room in which a session proves a minor's publishing grant is never kept from then on, likewise, while a minor's grant that publishes nothing changes nothing. A window past 24 hours is refused. | | `age_serving` | A room rated 13 by its authority, the claim on the publisher's PUBLISH, and a stranger's rating ignored: a session at no threshold is refused the subscription `below_threshold`; the performer rates groups 5 to 7 of its track 18 on group 5's object, after group 4 went out, and from then the viewer at 16 is forwarded none of them while the viewer at 18 is forwarded all; a FETCH across the span is answered up to group 4 and one from inside it is refused; a SUBSCRIBE at a live edge rated 18 is refused. | +| `age_room_service` | A room rated 18 by its authority, with the floor held (`[floor] room_keys` the clock): sessions proving the clock's key and the authority's (a `GRANT_PROOF` naming no grant) are served every group, verified; the clock's token with no proof, and a proof by a key that is no room key, are refused the subscription `below_threshold`; a proof naming the clock's key under another key's signature, and the clock's proof over another session's challenge, close the session `UNAUTHORIZED`. | | `age_rising` | A rating that rises while a subscriber's objects wait at the relay: a viewer at 18 that reads nothing, with small QUIC windows and the relay a small send window, holds groups 0 to 9 queued when the performer rates its whole track 21 on group 10's object; once the relay holds the claim the viewer is given no more of what waited (those streams ended, `CANCELLED`) and nothing of group 10, while a viewer at 21 is given every group. | | `priority_fan_out` | Two publishers, four tracks, three subscribers with their own subscriber priorities and group orders, their transports held shut while the load arrives: each receives every object in §5.1.2's order. | | `drafts` | The two-publisher scenario with every session at -18 (over QUIC and over WebTransport), at -16, and with publishers at -16 and -18 and subscribers at -22, -18 and -16: every object verifies against its publisher's receipts at every subscriber. FETCH from the cache at -18 and -16 (the drafts' exclusive End Locations, -16's absolute fetch IDs). A SUBSCRIBE with a Range Filter, refused at an -18 client by name. An -18 session and a -16 session written from those drafts' own messages, not through this crate's client: SETUP each way, a request answered, a Joining FETCH refused with NOT_SUPPORTED, an unknown Fetch Type closing the session with PROTOCOL_VIOLATION; a -16 Request ID ahead of one in flight on another stream admitted and one seen before closing the session with INVALID_REQUEST_ID. An -18 publisher's FIN on PUBLISH_NAMESPACE withdrawing the namespace, a -22 FIN not; an -18 publisher that closes its control stream before its last object and PUBLISH_DONE losing neither. | diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs index 3069d8a3c..9af43615a 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/auth.rs @@ -273,6 +273,16 @@ impl Authorizer { self.authorities.iter().any(|a| a.key == *signer && (a.rooms.is_empty() || a.rooms.contains(room))) } + /// The rooms `key` is a room key of as a configured authority: `Some` of them (empty for + /// every room) when it is one, `None` when it is not. + pub fn authority_rooms(&self, key: &[u8; 32]) -> Option> { + let rules: Vec<&AuthorityRule> = self.authorities.iter().filter(|a| a.key == *key).collect(); + if rules.is_empty() { + return None; + } + Some(if rules.iter().any(|a| a.rooms.is_empty()) { Vec::new() } else { rules.iter().flat_map(|a| a.rooms.iter().copied()).collect() }) + } + /// The keys of the configured room authorities. pub fn authority_keys(&self) -> Vec<[u8; 32]> { self.authorities.iter().map(|a| a.key).collect() diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/client.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/client.rs index 614311039..435e77fc3 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/client.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/client.rs @@ -33,6 +33,10 @@ pub struct ClientOptions { pub grants: Vec<(Vec, ed25519_dalek::SigningKey)>, /// The `//authority/path` the client addressed, which its proofs sign. pub target: String, + /// A room key to prove possession of (WAI age-assurance §5.5): a GRANT_PROOF naming no + /// grant, signed by each key over the relay's challenge for `target`. A session that + /// proves a key the relay holds as a room key is that room's own service. + pub room_keys: Vec, /// For tests: sign the proofs over this challenge instead of the relay's, as a proof /// made for another session is. pub sign_challenge: Option<[u8; 32]>, @@ -49,7 +53,7 @@ pub struct ClientOptions { impl Default for ClientOptions { fn default() -> Self { - ClientOptions { jwp: true, tokens: Vec::new(), grants: Vec::new(), target: String::new(), sign_challenge: None, max_request_updates: 0, max_object_bytes: 16 << 20, lazy_data: false, extra_setup: Vec::new() } + ClientOptions { jwp: true, tokens: Vec::new(), grants: Vec::new(), target: String::new(), room_keys: Vec::new(), sign_challenge: None, max_request_updates: 0, max_object_bytes: 16 << 20, lazy_data: false, extra_setup: Vec::new() } } } @@ -107,7 +111,7 @@ impl Client { o.push(p.clone()); } let mut control = timeout(STEP, conn.open_uni()).await.map_err(s)?.map_err(s)?; - let early = opts.grants.is_empty(); + let early = opts.grants.is_empty() && opts.room_keys.is_empty(); if early { control.write_all(&ControlMessage::Setup(Setup { options: o.clone() }).encode().map_err(s)?).await.map_err(s)?; } @@ -142,6 +146,10 @@ impl Client { let proof = grant_proof::GrantProof::sign(digest, key, challenge, &context).ok_or("a target a proof cannot carry")?; grant_proof::add_grant(&mut o, record, &proof); } + for key in &opts.room_keys { + let proof = grant_proof::GrantProof::sign(grant_proof::NO_GRANT, key, challenge, &context).ok_or("a target a proof cannot carry")?; + grant_proof::add_room_key_proof(&mut o, &proof); + } control.write_all(&ControlMessage::Setup(Setup { options: o }).encode().map_err(s)?).await.map_err(s)?; } diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/floor.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/floor.rs index 4e661d423..646ff932f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/floor.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/floor.rs @@ -351,6 +351,12 @@ impl Floor { Floor { room_keys, rooms: Mutex::new(HashMap::new()), refused: AtomicU64::new(0), refused_inputs: AtomicU64::new(0) } } + /// Whether the floor holds the selection window `window` (its record's digest) of `room`: + /// one a room key or a director opened, which inputs may now name. + pub fn holds_window(&self, room: &[u8; 32], window: &[u8; 32]) -> bool { + self.rooms.lock().unwrap().get(room).is_some_and(|f| f.windows.contains_key(window)) + } + /// The objects held back so far, out of turn under refuse. pub fn refused(&self) -> u64 { self.refused.load(Ordering::Relaxed) diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs index f7d9e7fe2..6dfc7b5b3 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/relay.rs @@ -51,6 +51,10 @@ pub struct SessionCtx { /// The session's threshold in each room (WAI age-assurance §5.5): the greatest `age` of /// the room's grants its SETUP proved. A room it holds none of admits it at 0. pub thresholds: HashMap<[u8; 32], u64>, + /// The rooms the session is the own service of (WAI age-assurance §5.5): those of each + /// room key its SETUP proved possession of, empty for every room; `None` for a session that + /// proved none. The serving rule does not hold it there. + pub service: Option>, /// MAX_REQUEST_UPDATES the peer declared, and the relay's. pub peer_max_updates: u64, pub my_max_updates: u64, @@ -96,9 +100,16 @@ impl Ingress { } impl SessionCtx { - /// The session's threshold for the tracks under `ns` (age-assurance §5.5). + /// The session's threshold for the tracks under `ns` (age-assurance §5.5): for the room's + /// own service, past every age, as the rule does not hold it. pub fn threshold(&self, ns: &TrackNamespace) -> u64 { - joule_moq::rating::room_of(ns).and_then(|r| self.thresholds.get(&r).copied()).unwrap_or(0) + let room = joule_moq::rating::room_of(ns); + if let (Some(r), Some(rooms)) = (room, &self.service) + && (rooms.is_empty() || rooms.contains(&r)) + { + return u64::MAX; + } + room.and_then(|r| self.thresholds.get(&r).copied()).unwrap_or(0) } /// Account `n` object bytes received against the session's ingress allowance; when it @@ -450,6 +461,19 @@ impl Relay { self.inner.dvr.as_ref() } + /// The rooms `key` is a room key of (WAI live-rooms §2: the authority's key or the clock's), + /// as this relay holds it: a configured authority's, for the rooms it is honoured for; or a + /// room key `floor` or `dvr` names, for every room. Empty for every room; `None` for a key + /// the relay does not hold as a room key. + pub fn room_key_rooms(&self, key: &[u8; 32]) -> Option> { + let configured = |keys: &[String]| keys.iter().any(|k| hex::decode(k).ok().as_deref() == Some(&key[..])); + let cfg = &self.inner.cfg; + if cfg.floor.as_ref().is_some_and(|f| configured(&f.room_keys)) || cfg.dvr.as_ref().is_some_and(|d| configured(&d.room_keys)) { + return Some(Vec::new()); + } + self.inner.auth.authority_rooms(key) + } + /// The rooms' floor, where the configuration holds one. pub fn floor(&self) -> Option<&crate::floor::Floor> { self.inner.floor.as_ref() diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs index 50915d7c5..b6f702665 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/src/session.rs @@ -249,6 +249,35 @@ pub async fn serve(relay: Relay, conn: SharedConnection) { } } } + // A proof of a room key (WAI age-assurance §5.5): a GRANT_PROOF naming no grant, checked as + // live-rooms §10.5.4 checks a proof, with no grant's digest and its own key for the + // principal. One that does not pass refuses the session as a grant's does. One that passes + // by a key this relay holds as a room key makes the session that room's own service, which + // the serving rule of rated tracks does not hold; by any other key, nothing. Only this proof + // makes a session the room's: no name, token or flag the client sets does. + let mut service: Option> = None; + for p in grant_proof::room_key_proofs(&proofs) { + let key = match grant_proof::GrantProof::read(p).and_then(|r| grant_proof::check_proof(&grant_proof::NO_GRANT, &r.key, Some(p), &challenge, |_| false, &context)) { + Ok(r) => r.key, + Err(why) => { + tracing::info!(session = id, remote = %conn.remote(), %context, reason = why.code(), "a proof of a room key that does not pass: the session is refused"); + conn.close(SessionError::UNAUTHORIZED.0, format!("age-assurance §5.5, live-rooms §10.5: {}", why.code()).as_bytes()); + return; + } + }; + if let Some(rooms) = relay.room_key_rooms(&key) { + tracing::info!(session = id, key = %hex::encode(&key[..6]), rooms = rooms.len(), "a room key proved: the session is the room's own service"); + // Empty is every room. + service = Some(match service.take() { + None => rooms, + Some(prev) if prev.is_empty() || rooms.is_empty() => Vec::new(), + Some(mut prev) => { + prev.extend(rooms); + prev + } + }); + } + } // When the session must end: at the relay's session limit, or when the earliest room // grant an authority signed that it proved expires (live-rooms §10.2: with no // re-validation, expiry alone ends a token). @@ -300,6 +329,7 @@ pub async fn serve(relay: Relay, conn: SharedConnection) { setup_tokens, proven, thresholds, + service, peer_max_updates: setup.options.max_request_updates(), my_max_updates: cfg.limits.max_request_updates, control: tokio::sync::Mutex::new(Some(control)), diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_rising.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_rising.rs index 793a8d9d2..a6649531b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_rising.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_rising.rs @@ -92,7 +92,8 @@ async fn run() { let s_slow = subscribe_both(&slow, &t, next()).await; let s_fast = subscribe_both(&fast, &t, next()).await; - let fast_reads = collect_concurrent(&fast, STEP * 2, Duration::from_secs(1), |g| g.iter().any(|x| x.header.track_alias == s_fast.media_alias && x.header.group_id == GROUPS)); + // Until every group has arrived (each on a stream of its own, in any order). + let fast_reads = collect_concurrent(&fast, STEP * 2, Duration::from_secs(1), |g| (0..=GROUPS).all(|n| g.iter().any(|x| x.header.track_alias == s_fast.media_alias && x.header.group_id == n && x.reset.is_none() && !x.objects.is_empty()))); let publish = async { for g in 0..GROUPS { publish_group(&p, &performer, &t, g, &payload("p", g, 24 * 1024)).await; diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_room_service.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_room_service.rs new file mode 100644 index 000000000..7efe41e84 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_room_service.rs @@ -0,0 +1,166 @@ +//! The room's own service under the serving rule (WAI age-assurance §5.5), in a room that +//! admits by age and keeps co-performance: its tracks rated 18 by the room's authority, and +//! the relay holding the room's floor, its clock a room key (`[floor] room_keys`). +//! +//! The room's clock follows and logs every performer's objects over a session of its own. A +//! session that proves a room key in its SETUP (a GRANT_PROOF naming no grant, over the +//! session's challenge) is the room's service, and the relay serves it the rated tracks: +//! +//! - a session proving the clock's key, and one proving the authority's, receive every group; +//! - the same session with the clock's token and no proof, and one proving a key that is no +//! room key, are ordinary sessions at threshold 0: refused the subscription +//! (`below_threshold`); +//! - a proof that names the clock's key under another key's signature, and one by the clock +//! over another session's challenge, close the session with UNAUTHORIZED. +//! +//! Over the WebSocket binding. + +mod common; + +use common::*; +use ed25519_dalek::{Signer, SigningKey}; +use joule_moq::draft22::kvp::KeyValuePair; +use joule_moq::draft22::{Parameters, RequestErrorCode, SessionError, Token}; +use joule_moq::grant_proof::{self, GrantProof, NO_GRANT, SETUP_OPTION_GRANT_PROOF}; +use joule_moq::rating::{self, Rating, Subject}; +use joule_moq_relay::client::{Client, ClientOptions}; +use joule_moq_relay::config::{Authority, FloorConfig, Grant}; +use joule_moq_relay::transport::TransportError; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use tokio::time::timeout; + +const AT: &str = "2026-10-22T00:00:00Z"; + +fn now_s() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() +} + +fn hexkey(k: &SigningKey) -> String { + hex::encode(k.verifying_key().to_bytes()) +} + +/// A grant laid out as live-rooms §4.1 lays it out, with the seek power, `publish` the media +/// bits, and the attributes `attrs` (§4.4), signed by `authority`. +fn grant(authority: &SigningKey, room: [u8; 32], principal: &SigningKey, publish: u8, attrs: &[(&str, &str)]) -> Vec { + let now = now_s(); + let mut g = b"WRG1\x01".to_vec(); + g.extend_from_slice(&room); + g.extend_from_slice(&principal.verifying_key().to_bytes()); + g.extend_from_slice(&[0; 40]); + g.extend_from_slice(&(now - 60).to_le_bytes()); + g.extend_from_slice(&(now + 600).to_le_bytes()); + g.extend_from_slice(&[0, publish, 1, 0, 0x10, 0]); + g.extend_from_slice(&[0; 32]); + g.extend_from_slice(b"WEP1\x01"); + g.extend_from_slice(&[0; 32]); + g.extend_from_slice(&[0; 28]); + g.extend_from_slice(&[0xff; 16]); + g.extend_from_slice(&[0; 3]); + let a: Vec = attrs.iter().flat_map(|(k, v)| [&[k.len() as u8][..], k.as_bytes(), &(v.len() as u16).to_le_bytes(), v.as_bytes()].concat()).collect(); + g.extend_from_slice(&[0, 0]); + g.extend_from_slice(&(a.len() as u16).to_le_bytes()); + g.extend_from_slice(&a); + let mut m = grant_proof::GRANT_DOMAIN.to_vec(); + m.extend_from_slice(&g); + g.extend_from_slice(&authority.sign(&m).to_bytes()); + g +} + +fn media_groups(got: &[Got], alias: u64) -> Vec { + let mut g: Vec = got.iter().filter(|x| x.header.track_alias == alias && x.reset.is_none() && !x.objects.is_empty()).map(|x| x.header.group_id).collect(); + g.sort(); + g.dedup(); + g +} + +async fn closed_unauthorized(c: &Client, what: &str) { + match timeout(STEP, c.conn.closed()).await.expect("closed in time") { + TransportError::Closed { code, .. } => assert_eq!(code, SessionError::UNAUTHORIZED.0, "{what}"), + other => panic!("{what}: {other:?}"), + } +} + +async fn below_threshold(c: &Client, t: &Tracks, what: &str) { + let refused = c.subscribe(t.ns.clone(), t.media.clone(), Parameters::new()).await.expect_err(what); + assert!(refused.contains(&RequestErrorCode::UNAUTHORIZED.to_string()) && refused.contains("below_threshold"), "{what}: {refused}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_rooms_own_service_is_served_its_rated_tracks_and_no_other_session_is() { + timeout(Duration::from_secs(180), run()).await.expect("the test finishes in time"); +} + +async fn run() { + let authority = SigningKey::from_bytes(&[0x81; 32]); + let clock = SigningKey::from_bytes(&[0x82; 32]); + let stranger = SigningKey::from_bytes(&[0x83; 32]); + let performer = SigningKey::from_bytes(&[0x84; 32]); + let room = [0x4d; 32]; + let r = hex::encode(room); + let token = b"the room clock's token".to_vec(); + let server = relay(|c| { + c.auth.required = true; + c.auth.authorities = vec![Authority { key: hexkey(&authority), rooms: vec![] }]; + // The clock's own rule: it subscribes to every track, by its token; exempt from limits. + c.auth.grants = vec![Grant { prefix: vec![], token_sha256: hex::encode(joule_moq_relay::auth::sha256(&token)), publish: false, subscribe: true, proof: false, unlimited: true, seek: false }]; + // Co-performance: the relay holds the room's floor, the clock a room key. + c.floor = Some(FloorConfig { room_keys: vec![hexkey(&clock)] }); + }) + .await; + let target = format!("//{}{}", server.websocket_addr.unwrap(), server.relay().config().path); + let t = Tracks::new(ns(&[&r, &hexkey(&performer), "video"]), "0", 10, 5, 128); + + // The performer, admitted at 18, publishes; its PUBLISH carries the room's rating, 18. + let p_opts = ClientOptions { grants: vec![(grant(&authority, room, &performer, 0b011, &[("age", "18")]), performer.clone())], target: target.clone(), ..ClientOptions::default() }; + let (p, _) = connect(&server, Via::WebSocket, p_opts).await; + let mut props = track_props(t.priority, None); + let rated = vec![Rating { scheme: "https://rating-a.example/scheme".into(), value: "18".into(), min_age: 18 }]; + props.push(rating::property(&rating::write(&authority, &Subject::Room(room), &rated, AT))); + let _media = p.publish(t.ns.clone(), t.media.clone(), t.media_alias, Parameters::new(), props).await.expect("PUBLISH the media track"); + let _receipts = p.publish(t.ns.clone(), t.receipts.clone(), t.receipts_alias, Parameters::new(), track_props(t.priority, None)).await.expect("PUBLISH the receipts track"); + assert_eq!(server.relay().stats().ratings_held, 1, "the authority's rating of the room is held"); + + let session = |room_keys: Vec| ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: token.clone() }], room_keys, target: target.clone(), ..ClientOptions::default() }; + + // The clock's token with no proof: an ordinary session, at 0, refused the room's tracks. + let (ordinary, _) = connect(&server, Via::WebSocket, session(vec![])).await; + below_threshold(&ordinary, &t, "the clock's token alone").await; + // A proof that passes by a key that is no room key: an ordinary session too. + let (other, _) = connect(&server, Via::WebSocket, session(vec![stranger.clone()])).await; + below_threshold(&other, &t, "a proof by a key that is no room key").await; + + // Proofs of the clock's key, and of the authority's: the room's service, served. + let (by_clock, _) = connect(&server, Via::WebSocket, session(vec![clock.clone()])).await; + let (by_authority, _) = connect(&server, Via::WebSocket, session(vec![authority.clone()])).await; + let s_clock = subscribe_both(&by_clock, &t, Parameters::new()).await; + let s_authority = subscribe_both(&by_authority, &t, Parameters::new()).await; + for g in 0..5 { + publish_group(&p, &performer, &t, g, &payload("p", g, 200)).await; + } + let (got_clock, got_authority) = tokio::join!( + // Until every group has arrived, each on a stream of its own and in any order. + collect_concurrent(&by_clock, STEP, Duration::from_millis(700), |g| { let m = media_groups(g, s_clock.media_alias); (0..5).all(|n| m.contains(&n)) }), + collect_concurrent(&by_authority, STEP, Duration::from_millis(700), |g| { let m = media_groups(g, s_authority.media_alias); (0..5).all(|n| m.contains(&n)) }), + ); + assert_eq!(media_groups(&got_clock, s_clock.media_alias), (0..5).collect::>(), "the session proving the clock's key: every group"); + assert_eq!(media_groups(&got_authority, s_authority.media_alias), (0..5).collect::>(), "the session proving the authority's key: every group"); + let (objects, verified, _, _) = verify(&got_clock, &s_clock, &performer); + assert!(objects == 5 && verified >= 5, "{objects} objects, {verified} groups verified"); + + // A forged proof: the clock's key named, another key's signature. The client's SETUP goes + // out at once, so the proof names no challenge; its signature is checked first. + let mut forged = GrantProof::sign(NO_GRANT, &stranger, [0; 32], &format!("{} {target}", grant_proof::VERB_SETUP)).expect("a proof"); + forged.key = clock.verifying_key().to_bytes(); + let forged_opts = ClientOptions { extra_setup: vec![KeyValuePair::bytes(SETUP_OPTION_GRANT_PROOF, forged.to_bytes())], ..session(vec![]) }; + let (f, _) = connect(&server, Via::WebSocket, forged_opts).await; + closed_unauthorized(&f, "a proof naming the clock's key under another's signature").await; + // The clock's proof made over another session's challenge. + let replayed = ClientOptions { sign_challenge: Some([0x5e; 32]), ..session(vec![clock.clone()]) }; + let (rp, _) = connect(&server, Via::WebSocket, replayed).await; + closed_unauthorized(&rp, "the clock's proof over another session's challenge").await; + + for c in [&p, &ordinary, &other, &by_clock, &by_authority] { + c.close(0, "done"); + } + server.shutdown().await; +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_serving.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_serving.rs index e9784c226..a09cc9adf 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_serving.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/age_serving.rs @@ -162,8 +162,10 @@ async fn run() { publish_rated(&p, &performer, &t, g, &payload("p", g, 200), &[]).await; } let (got16, got18) = tokio::join!( - collect_concurrent(&c16, STEP, Duration::from_millis(700), |g| media_groups(g, s16.media_alias).contains(&9)), - collect_concurrent(&c18, STEP, Duration::from_millis(700), |g| media_groups(g, s18.media_alias).contains(&9)), + // Until every group each is to be forwarded has arrived (each rides a stream of its own, + // in any order), and then nothing more for the quiet interval. + collect_concurrent(&c16, STEP, Duration::from_millis(700), |g| { let m = media_groups(g, s16.media_alias); [0, 1, 2, 3, 4, 8, 9].iter().all(|n| m.contains(n)) }), + collect_concurrent(&c18, STEP, Duration::from_millis(700), |g| { let m = media_groups(g, s18.media_alias); (0..10).all(|n| m.contains(&n)) }), ); assert_eq!(media_groups(&got16, s16.media_alias), vec![0, 1, 2, 3, 4, 8, 9], "the viewer at 16: none of the span"); assert_eq!(media_groups(&got18, s18.media_alias), (0..10).collect::>(), "the viewer at 18: everything"); diff --git a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/floor.rs b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/floor.rs index 9de7cec41..e3ca4528e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-relay/tests/floor.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-relay/tests/floor.rs @@ -126,7 +126,12 @@ async fn run() { let _sb = s.subscribe(track(room, &b, "audio"), name("0"), Parameters::new()).await.expect("SUBSCRIBE b's audio"); send(&pa, 10, 1, b"a in its turn").await; send(&pb, 20, 1, b"b out of turn").await; - let got = collect(&s, Duration::from_secs(3), |g| g.len() >= 2).await; + // What reaches the subscriber: the object in turn, waited for; then, once the relay has held + // the other back, anything more for a second. + let has = |g: &[Got], p: &[u8]| g.iter().any(|x| x.objects.iter().any(|o| o.payload == p)); + let mut got = collect(&s, STEP, |g| has(g, b"a in its turn")).await; + assert!(until(STEP, || floor().refused() >= 1).await, "the relay holds back the object out of turn"); + got.extend(collect(&s, Duration::from_secs(1), |_| false).await); let payloads: Vec<&[u8]> = got.iter().flat_map(|g| g.objects.iter().map(|o| o.payload.as_slice())).collect(); assert_eq!(payloads, vec![b"a in its turn".as_slice()], "only the object in turn is forwarded"); assert_eq!(floor().refused(), 1); @@ -138,7 +143,9 @@ async fn run() { assert!(until(STEP, || place(&b) == Some(Place::InTurn)).await, "the relay reads the handover"); send(&pa, 10, 2, b"a after its handover").await; send(&pb, 20, 2, b"b in the turn it was handed").await; - let got = collect(&s, Duration::from_secs(3), |g| g.len() >= 2).await; + let mut got = collect(&s, STEP, |g| has(g, b"b in the turn it was handed")).await; + assert!(until(STEP, || floor().refused() >= 2).await, "the relay holds back a's object after its handover"); + got.extend(collect(&s, Duration::from_secs(1), |_| false).await); let payloads: Vec<&[u8]> = got.iter().flat_map(|g| g.objects.iter().map(|o| o.payload.as_slice())).collect(); assert_eq!(payloads, vec![b"b in the turn it was handed".as_slice()]); assert_eq!(floor().refused(), 2); @@ -259,16 +266,24 @@ async fn inputs() { let _pve = pv.publish(track(room, &viewer, "events"), name("0"), 3, Parameters::new(), track_props(16, None)).await.expect("PUBLISH the viewer's events"); let _sm = s.subscribe(track(room, &member, "events"), name("0"), Parameters::new()).await.expect("SUBSCRIBE the member's events"); let _sv = s.subscribe(track(room, &viewer, "events"), name("0"), Parameters::new()).await.expect("SUBSCRIBE the viewer's events"); - tokio::time::sleep(Duration::from_millis(300)).await; + // The inputs name the window: the floor holds it before the first is sent. + let f = server.relay().floor().unwrap(); + assert!(until(STEP, || f.holds_window(&room, &wd)).await, "the relay holds the clock's window"); + // The member's first vote, forwarded: the watcher's subscription is live. Each group rides a + // stream of its own, which the relay reads in a task of its own, so the second is sent once + // the first has been taken: the limit counts the votes in the order the relay takes them. let first = meta(&member, room, 0, rc(), &vote(1)); send(&pm, 2, 0, &msf(&first)).await; + let mut got = collect(&s, STEP, |g| g.iter().any(|x| x.objects.iter().any(|o| o.payload == msf(&first)))).await; + // Its second, past its power, and the viewer's, with no power: both refused, and nothing + // more reaches the watcher once the relay has refused them. let second = meta_after(&member, room, 1, digest(&first), rc(), &vote(2)); send(&pm, 2, 1, &msf(&second)).await; send(&pv, 3, 0, &msf(&meta(&viewer, room, 0, rc(), &vote(0)))).await; - let got = collect(&s, Duration::from_secs(3), |g| !g.is_empty() && g.len() >= 3).await; + assert!(until(STEP, || f.refused_inputs() >= 2).await, "the relay refuses the second vote and the viewer's"); + got.extend(collect(&s, Duration::from_secs(1), |_| false).await); let payloads: Vec<&[u8]> = got.iter().flat_map(|g| g.objects.iter().map(|o| o.payload.as_slice())).collect(); assert_eq!(payloads, vec![msf(&first).as_slice()], "only the member's first vote is forwarded"); - let f = server.relay().floor().unwrap(); assert_eq!(f.refused_inputs(), 2); for x in [&pm, &pv, &s, &c] { x.close(0, "done"); diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml index 7b9bf100f..ff8bfc561 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-room/Cargo.toml @@ -30,9 +30,10 @@ joule-moq-relay = { path = "../joule-moq-relay" } # End-to-end encrypted rooms: the confidentiality record and the clock as the MLS group's # external sender (WAI confidentiality §3, §5). joule-moq-mls = { path = "../joule-moq-mls" } -# The room's records (live-rooms, live-events, live-compose, co-performance), and admission by -# age (age-assurance: the policy, the rating, the token's checks and the receipts): no codec chain. -wai = { path = "../../../../wai/wai-rs", default-features = false, features = ["live_compose", "timed_text", "age_assurance", "co_performance"] } +# The room's records (live-rooms, live-events, live-compose, co-performance, co-watch), and +# admission by age (age-assurance: the policy, the rating, the token's checks and the +# receipts): no codec chain. +wai = { path = "../../../../wai/wai-rs", default-features = false, features = ["live_compose", "timed_text", "age_assurance", "co_performance", "co_watch"] } # The HTTP service (MIT), the versions joule-moq-ingest pins. hyper = { version = "=1.11.1", features = ["server", "http1"] } hyper-util = { version = "=0.1.20", default-features = false, features = ["tokio"] } diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/README.md b/joule-code/joule-code-rs/crates/joule-moq-room/README.md index 720c6351d..78e60da1c 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-room/README.md @@ -45,8 +45,11 @@ A room service for WAI live rooms (`wai/extensions/live-rooms.md`) over `wai-age-admissions` receipt so far (§7), signed by the clock, every count under 50 suppressed, and with `receipts_out` each window's is appended there as it closes, and the rest of a room's when it ends. The room's rating rides the clock's events track and the grant - answer's room, which a publisher's page carries on its tracks (§5.7). Operating an issuer, or - the attester that establishes an age, is not the service's. + answer's room, which a publisher's page carries on its tracks (§5.7). The clock's own session + proves both room keys in its SETUP (a `GRANT_PROOF` naming no grant, §5.5), so the relay + serves it the room's rated tracks as the room's own service: it follows each publisher and, + with `[co]`, logs every performer's objects. Operating an issuer, or the attester that + establishes an age, is not the service's. - **keeps co-performance** (`[co]`, WAI `extensions/co-performance.md`). With `audience_input` above 0, every viewer's grant carries the audience-input power (the @@ -69,6 +72,21 @@ A room service for WAI live rooms (`wai/extensions/live-rooms.md`) over it may ask. The relay holds the floor and the audience's inputs (its `[floor]`, which `relay-lines` prints commented out). +- **keeps watch rooms** (`[room] watch_source`, WAI `extensions/co-watch.md`). Each room then + watches that file together: the authority signs a co-watch policy (`WXP1`) naming it, an + object sequence of one object, by the SHA-256 of its bytes and its duration, and the service + serves the bytes. A visitor enters the room's co-watch log by a proof of its grant's key over + a challenge the service issued, for the request it makes (live-rooms §10.5.3), and the clock + signs its `join`; every grant carries `watch_control`, the actions its principal may + control. The service is the room's carrier of its viewers' events: one log per room, holding + every record of the clock's chain and every control and presentation statement a viewer + posts, each carried only when a verifier would accept it (its chain, its signature, its + grant's policy and rate, and co-watch §5 and §7.2). Each window, the clock signs the skew + statements of co-watch §8.4 from what the log holds; and a viewer silent for four seconds, or + whose grant has ended, leaves. A watch room is never end-to-end encrypted (its source is + served in the clear), admitted by age (its source is served without a grant) or a room with + co-performance: the service refuses such a configuration. + A room lasts while anyone publishes in it. When the last publisher has left, the next one starts a new room: a new descriptor at a new `t0`, under the same authority and clock, so each room's grants and chain hold one stretch of company. @@ -123,6 +141,13 @@ trust_field = 1 | `POST /mls/post` `{"room", "frames": []}` | a GroupInfo, or a commit and its Welcome, checked by the clock as a member checks it, then carried: `{"ok", "seq"}`; `409` `{"error": }` when the clock refuses it | | `POST /grant` in a room that admits by age | without a token: `401` `{"error": "token_required", "age"}` and a `WWW-Authenticate: PrivateToken challenge=…, token-key=…, max-age=…` for each key; with `Authorization: PrivateToken token="…"`: the grant, its `age` the token's threshold, or `403` `{"error": }` and the challenges again; `503` `busy` when the window's nonces are at their bound. `GET /room`'s `age` is `{"policy", "ratings", "age", "token_window_s"}` | | `GET /age/receipt` | `{"receipt", "policy"}`: the room's admission receipt so far, signed by its clock | +| `GET /watch/time` | a watch room: `{"now_us", "t0_us"}`, the service's clock, for a page's estimate of the room clock | +| `GET /watch/source` | a watch room's source, its bytes whole, of its media type | +| `GET /watch/challenge` | `{"challenge": }`: single-use, good for a minute | +| `POST /watch/join` `{"room", "grant": , "proof": }` | the proof (live-rooms §10.5.1) over a challenge, for `POST ///watch/join`; the clock signs the principal's `join`: `{"ok", "t"}`; `401` `{"error": }` for a proof that fails | +| `POST /watch/post` `{"room", "events": []}` (at most 8) | the viewer's events, each checked and carried in order: `{"ok", "carried", "log"}`; `409` `{"error": , "carried"}` at the first refused | +| `GET /watch?room=&from=[&wait_ms=]` | the watch room's log from entry `n`: `{"room", "entries": [{"seq", "event": }], "next", "end"}`, held up to 8 s while nothing is past `n` | +| `GET /watch/room` | the room as a co-watch verifier is given it (`descriptor`, `grants`, `chains`, `policies`, `sources`), the latest window's skew statements read, and the refusals by reason | Every answer is JSON with `Cache-Control: no-store`. The client's address is `CF-Connecting-IP`, else the first `X-Forwarded-For`, else the connection's: the service @@ -152,7 +177,11 @@ and their grants' `age` read by the relay, one of 13 refused `below_threshold`, refusing it the video the publisher rates 18, and the receipt read and checked by `wai-rs`. A room with co-performance: its policy, the audience-input power and the budgets on its grants, and two viewers' votes in a window the clock opens, commits and selects, verified as co-performance §9 -verifies a room. +verifies a room. A watch room against a relay on loopback: its source served and named by its +policy, viewers admitted by a proof for the request they make (a proof for another target, or +presented again, refused), their controls and statements carried and a control in the past +refused, the clock's skew statements of each window, each viewer's leave once silent, and the +room the log carries verified as co-watch §11 verifies it, every viewer in sync. ## Licence diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml b/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml index 2f8fd8f5d..211fb947e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml +++ b/joule-code/joule-code-rs/crates/joule-moq-room/room.example.toml @@ -5,7 +5,7 @@ # The HTTP service, on loopback behind the front that serves the room's page. listen = "127.0.0.1:9682" # The path the front serves the service under, when it strips it (`/room-api`): a grant's proof -# over HTTP names the path the page addressed. +# over HTTP, and a watch room's join proof, name the path the page addressed. public_prefix = "/room-api" keys = "/path/to/keys.toml" # The relay's WebSocket endpoint, for the clock's own session (loopback). @@ -29,6 +29,15 @@ max_grants = 64 # group whose external sender is the clock, and the room's log of MLS messages. Such a room # is never recorded, and its viewers are not members of its group. e2ee = false +# Every room watches this file together (WAI co-watch): served whole, named by the room's +# co-watch policy, with its duration in milliseconds. Empty for rooms that watch nothing. A +# watch room is not also end-to-end encrypted, admitted by age or a room with co-performance. +watch_source = "" +watch_duration_ms = 0 +# The actions every principal of a watch room may control: 1 play, 2 pause, 4 seek, 8 rate. +watch_control = 15 +# The skew window the clock states, in milliseconds: a whole number of seconds. +watch_window_ms = 5000 [co] # Co-performance (WAI co-performance): either value above 0 gives each room a co-performance diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/age.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/age.rs index 436f2e373..2dad2e573 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/age.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/age.rs @@ -128,7 +128,7 @@ impl AgeConfig { let s = AgeSettings { issuers, ratings, window_s: self.window_s, token_window_s: self.token_window_s, max_nonces: self.max_nonces, origin: self.origin.clone(), receipts_out: self.receipts_out.clone() }; // A room of throwaway keys: what the service will sign reads back. let keys = Keys::generate(); - let d = crate::epoch::descriptor(&keys, crate::epoch::roles(false, 0), 16, 4, 1_000_000); + let d = crate::epoch::descriptor(&keys, crate::epoch::roles(false, 0, false), 16, 4, 1_000_000); AgeRoom::new(&s, &keys, &d, 1).map(|_| s) } } @@ -377,7 +377,7 @@ mod tests { fn tokens_are_admitted_by_window_once_and_at_the_rooms_age() { let s = settings(3); let keys = Keys::generate(); - let d = crate::epoch::descriptor(&keys, crate::epoch::roles(false, 0), 16, 4, 1_800_000_000_000_000); + let d = crate::epoch::descriptor(&keys, crate::epoch::roles(false, 0, false), 16, 4, 1_800_000_000_000_000); let mut a = AgeRoom::new(&s, &keys, &d, 1_800_000_000).unwrap(); assert_eq!((a.age, a.policy.issuers.len()), (16, 2), "issuers sorted by name, the room rated 16"); let key = [9u8; 32]; @@ -425,7 +425,7 @@ mod tests { fn the_receipt_counts_and_suppresses_as_section_7_has_it() { let s = settings(1_000); let keys = Keys::generate(); - let d = crate::epoch::descriptor(&keys, crate::epoch::roles(false, 0), 16, 4, 1_800_000_000_000_000); + let d = crate::epoch::descriptor(&keys, crate::epoch::roles(false, 0, false), 16, 4, 1_800_000_000_000_000); let mut a = AgeRoom::new(&s, &keys, &d, 1_800_000_000).unwrap(); let (key, origin, now) = ([3u8; 32], "room.test", 1_800_000_000u64); let w = now / s.token_window_s; diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs index f2efce944..308c69666 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/e2ee.rs @@ -141,7 +141,7 @@ mod tests { use joule_moq::secure_objects::Suite; fn settings() -> Settings { - Settings { max_publishers: 4, max_present: 16, publisher_grant_s: 600, viewer_grant_s: 900, join_wait_s: 60, max_grants: 8, e2ee: true, age: None, audience_input: 0, latency_budget_ms: 0, lead: crate::epoch::Lead::First, ai: false } + Settings { max_publishers: 4, max_present: 16, publisher_grant_s: 600, viewer_grant_s: 900, join_wait_s: 60, max_grants: 8, e2ee: true, age: None, audience_input: 0, latency_budget_ms: 0, lead: crate::epoch::Lead::First, ai: false, watch: None, watch_control: 0, watch_window_ms: 5_000 } } #[test] diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs index 01ba58f63..e8f29d7f5 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/epoch.rs @@ -10,6 +10,8 @@ use crate::age::{AgeRoom, AgeSettings}; use crate::e2ee::RoomMls; use crate::keys::Keys; +use crate::watch::{WatchRoom, WatchSource}; +use std::sync::Arc; use serde_json::{json, Value}; use std::collections::HashMap; use wai::live_compose::layout::{aspect, fill, kind as lkind, Params}; @@ -30,6 +32,9 @@ pub const PERFORMER_KINDS: [u8; 5] = [kind::META, kind::CHAT, kind::REACTION, ki /// The kinds an audience member may send in a room with co-performance: chats, its inputs /// (`meta`, co-performance §7.3) and its reactions to them (§7.6). pub const AUDIENCE_KINDS: [u8; 3] = [kind::META, kind::CHAT, kind::REACTION]; +/// The kinds a viewer of a watch room may send: its controls and presentation statements +/// (`meta`, WAI co-watch §4.1), and chats. +pub const WATCHER_KINDS: [u8; 2] = [kind::META, kind::CHAT]; /// The co-performance policy every room with co-performance states (co-performance §3.1): /// intents reach at most 8 s ahead, material plays within 120 ms of its planned instant, /// notes within 15 ms and a camera within half a degree, and a schedule may refuse or mark. @@ -67,12 +72,13 @@ pub const GRANT_ENDED: u8 = 3; /// The room's roles: a participant that publishes audio and video and a viewer, both with the /// seek power; in a room with co-performance (`co`), a participant sends its statements too, /// and a lead holds the direct power; a viewer whose grant carries the audience-input power -/// (`audience_input` above 0, WAI co-performance §4.4) sends its inputs and reactions too. -pub fn roles(co: bool, audience_input: u8) -> Vec { - let performer: &[u8] = if co { &PERFORMER_KINDS } else { &PARTICIPANT_KINDS }; +/// (`audience_input` above 0, WAI co-performance §4.4) sends its inputs and reactions too; in +/// a watch room (`watch`), every principal sends its controls and presentation statements, +/// `meta` events (WAI co-watch §4.1). +pub fn roles(co: bool, audience_input: u8, watch: bool) -> Vec { let mut r: Vec = vec![ - Role_ { name: "participant", caps: Caps { publish: 0b011, subscribe: true, max_layer: 0, powers: SEEK }, events: bits(performer) }.into(), - Role_ { name: "viewer", caps: Caps { publish: 0, subscribe: true, max_layer: 0, powers: SEEK }, events: bits(if audience_input > 0 { &AUDIENCE_KINDS } else { &[kind::CHAT] }) }.into(), + Role_ { name: "participant", caps: Caps { publish: 0b011, subscribe: true, max_layer: 0, powers: SEEK }, events: bits(performer_kinds(co || watch)) }.into(), + Role_ { name: "viewer", caps: Caps { publish: 0, subscribe: true, max_layer: 0, powers: SEEK }, events: bits(viewer_kinds(audience_input > 0, watch)) }.into(), ]; if co { r.push(Role_ { name: "lead", caps: Caps { publish: 0b011, subscribe: true, max_layer: 0, powers: SEEK | DIRECT }, events: bits(&PERFORMER_KINDS) }.into()); @@ -80,6 +86,23 @@ pub fn roles(co: bool, audience_input: u8) -> Vec { r } +/// The kinds a participant may send: with `meta`, its co-performance or co-watch statements. +pub fn performer_kinds(meta: bool) -> &'static [u8] { + if meta { &PERFORMER_KINDS } else { &PARTICIPANT_KINDS } +} + +/// The kinds a viewer may send: an audience member's inputs and reactions; a watch room's +/// viewer its `meta` events; and chat. +pub fn viewer_kinds(audience: bool, watch: bool) -> &'static [u8] { + if audience { + &AUDIENCE_KINDS + } else if watch { + &WATCHER_KINDS + } else { + &[kind::CHAT] + } +} + /// The direct power (live-rooms §3.3, bit 1). const DIRECT: u8 = wai::live_room::power::DIRECT; @@ -111,6 +134,14 @@ pub struct Settings { /// Whether a publisher may ask for a grant that marks it an AI participant (live-rooms /// §4.4, `ai`), in a room with co-performance. pub ai: bool, + /// Every room watching this source together (WAI co-watch, [`crate::watch`]); none for + /// rooms that watch nothing. + pub watch: Option>, + /// The `watch_control` every grant of a watch room carries (co-watch §4.4): the actions + /// its principal may control; 0 for none. + pub watch_control: u8, + /// A watch room's skew window, in milliseconds (co-watch §3.1, a whole number of seconds). + pub watch_window_ms: u32, } impl Settings { @@ -278,12 +309,15 @@ pub struct Epoch { pub mls: Option, /// A room that admits by age: its policy, its rating, its nonces and its counts. pub age: Option, + /// A room watching a source together: its policy, its viewers and the log that carries + /// their controls and statements (WAI co-watch). + pub watch: Option, } impl Epoch { /// A new room at `now_us`. pub fn new(keys: &Keys, settings: Settings, generation: u64, now_us: u64) -> Epoch { - let d = descriptor(keys, roles(settings.co(), settings.audience_input), settings.max_present, settings.max_publishers, now_us); + let d = descriptor(keys, roles(settings.co(), settings.audience_input, settings.watch.is_some()), settings.max_present, settings.max_publishers, now_us); let id = d.room_id(); let age = settings.age.as_ref().map(|s| AgeRoom::new(s, keys, &d, now_us / 1_000_000).expect("[age] was checked when the configuration was read")); let compose = Compose { @@ -327,6 +361,7 @@ impl Epoch { joined_ever: false, mls: settings.e2ee.then(|| RoomMls::new(keys, id)), age, + watch: settings.watch.clone().map(|src| WatchRoom::new(id, now_us, src, settings.watch_window_ms, &keys.authority)), settings, } } @@ -359,10 +394,12 @@ impl Epoch { self.members.values().filter(|m| m.state == MemberState::Present).count() } - /// Whether a new room should start before a publisher is let in: nobody holds a place - /// here, and someone has been and gone, or the room has taken its grants. + /// Whether a new room should start before a publisher, or in a watch room a viewer, is let + /// in: nobody holds a place here, nobody watches, and someone has been and gone, or the + /// room has taken its grants. pub fn spent(&self, now_s: u64) -> bool { - self.publishers(now_s) == 0 && (self.joined_ever || self.grants.len() >= self.settings.max_grants) + let watching = self.watch.as_ref().map_or(0, |w| w.present()); + self.publishers(now_s) == 0 && watching == 0 && (self.joined_ever || self.grants.len() >= self.settings.max_grants) } /// A grant for `principal` in `role` at `now_s`, signed by the authority. `age` is the @@ -387,8 +424,9 @@ impl Epoch { // each audience member joining. let places = (self.settings.max_present as usize).saturating_sub(self.settings.max_publishers as usize); let audience = if role == Role::Viewer && self.audience_members(now_s) < places { self.settings.audience_input } else { 0 }; - let viewer_kinds: &[u8] = if audience > 0 { &AUDIENCE_KINDS } else { &[kind::CHAT] }; - let performer: &[u8] = if co { &PERFORMER_KINDS } else { &PARTICIPANT_KINDS }; + let watch = self.watch.is_some(); + let viewer_kinds = viewer_kinds(audience > 0, watch); + let performer = performer_kinds(co || watch); let lead = co && match self.settings.lead { Lead::First => self.next_order == 0, Lead::Publishers => true, @@ -403,7 +441,8 @@ impl Epoch { let order = self.next_order; // Attributes in strictly ascending order of key (live-rooms §4.4): `age`, the // threshold a token admitted the request under (age-assurance §5.4); the co-performance - // ones (§4.4 there) where the room has them; and a publisher's `order` for the layout. + // ones (§4.4 there) where the room has them; a publisher's `order` for the layout; and + // in a watch room the actions every principal may control (co-watch §4.4). let mut attrs: Vec<(String, String)> = age.map(|a| ("age".to_string(), a.to_string())).into_iter().collect(); match role { Role::Publisher => { @@ -418,6 +457,9 @@ impl Epoch { Role::Viewer if audience > 0 => attrs.push((AUDIENCE_ATTR.to_string(), audience.to_string())), Role::Viewer => {} } + if watch && self.settings.watch_control > 0 { + attrs.push((wai::co_watch::CONTROL_ATTR.to_string(), self.settings.watch_control.to_string())); + } // `nbf` a minute back, for a browser whose clock runs behind. let (nbf, exp) = (now_s.saturating_sub(60), now_s + ttl); let g = Grant { @@ -444,6 +486,14 @@ impl Epoch { .sign(&keys.authority); let record = g.to_bytes(); let digest = g.digest(); + if let Some(w) = self.watch.as_mut() { + // In a watch room every grant is the room's: each principal's events are verified + // under it, a viewer's among them. + w.granted(record.clone(), g.clone(), digest); + if role == Role::Viewer { + self.grants.push(record.clone()); + } + } match role { Role::Publisher => { self.next_order += 1; @@ -482,6 +532,8 @@ impl Epoch { // A room with co-performance (WAI co-performance): its policy, and its viewers' // grants, with which a page verifies their inputs beside `grants`. "co": self.co.as_ref().map(|p| json!({"policy": hex::encode(p), "audience": self.audience.iter().map(hex::encode).collect::>()})), + // A watch room (WAI co-watch): its policy, its source and its log's length. + "watch": self.watch.as_ref().map(WatchRoom::view), }) } } @@ -554,7 +606,7 @@ mod tests { use super::*; fn settings() -> Settings { - Settings { max_publishers: 2, max_present: 16, publisher_grant_s: 600, viewer_grant_s: 900, join_wait_s: 60, max_grants: 3, e2ee: false, age: None, audience_input: 0, latency_budget_ms: 0, lead: Lead::First, ai: true } + Settings { max_publishers: 2, max_present: 16, publisher_grant_s: 600, viewer_grant_s: 900, join_wait_s: 60, max_grants: 3, e2ee: false, age: None, audience_input: 0, latency_budget_ms: 0, lead: Lead::First, ai: true, watch: None, watch_control: 0, watch_window_ms: 5_000 } } #[test] diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs index 6d420d16e..c8e3fc34d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/http.rs @@ -46,8 +46,21 @@ //! do not allow the request, `400` for a statement the request does not make, and `404` in a //! room without co-performance. //! -//! Every answer is JSON and `Cache-Control: no-store`. A grant request's body is at most -//! 1 KiB, a log post's 128 KiB, a co-performance request's 4 KiB. +//! A watch room (`[room] watch_source`, [`crate::watch`]) has its source and its log: +//! +//! | Request | Answer | +//! |---|---| +//! | `GET /watch/time` | `{"now_us", "t0_us"}`: the service's clock, for a page's estimate of the room clock | +//! | `GET /watch/source` | the source's bytes, of its media type | +//! | `GET /watch/challenge` | `{"challenge": }`: single-use, good for a minute (live-rooms §10.5.3) | +//! | `POST /watch/join` `{"room", "grant": , "proof": }` | the grant's principal enters, the proof over a challenge for `POST ///watch/join`: the clock signs its `join`; `{"ok", "t": }`; `401` `{"error": }` for a proof that fails | +//! | `POST /watch/post` `{"room", "events": []}` (at most 8) | each event checked as co-watch §10 has it and carried, in order: `{"ok", "carried"}`; `409` `{"error": , "carried"}` at the first refused | +//! | `GET /watch?room=&from=[&wait_ms=]` | the room's log from entry `n` (`{"room", "entries": [{"seq", "event": }], "next", "end"}`), held up to 8 s while it has nothing past `n` | +//! | `GET /watch/room` | the room as a co-watch verifier is given it (co-watch §11.1), with the latest skew statements read | +//! +//! Every answer but the source is JSON and `Cache-Control: no-store`. A grant request's body is +//! at most 1 KiB, a co-performance request's and a watch room's join 4 KiB, a watch post's +//! 16 KiB, a log post's 128 KiB. use crate::co::Signed; use crate::e2ee::Refused; @@ -81,6 +94,9 @@ const CHALLENGE_LIFE: Duration = Duration::from_secs(120); const MAX_CHALLENGES: usize = 4096; /// The objects one `GET /co/objects` answers with. const OBJECTS_PAGE: usize = 5000; +/// A watch room's join and post bodies. +const MAX_JOIN_BODY: usize = 4 * 1024; +const MAX_WATCH_BODY: usize = 16 * 1024; /// Serve `listener` until the task is dropped. pub async fn serve(svc: Arc, listener: tokio::net::TcpListener) { @@ -161,7 +177,51 @@ async fn handle(svc: Arc, peer: SocketAddr, req: Request) -> svc.mls_changed.notify_waiters(); r } - (_, "/") | (_, "/health") | (_, "/room") | (_, "/grant") | (_, "/mls") | (_, "/mls/key-package") | (_, "/mls/post") | (_, "/age/receipt") | (_, "/co/objects") | (_, "/co/state") | (_, "/co/schedule") | (_, "/co/handover") | (_, "/co/window") | (_, "/co/fidelity") => { + (Method::GET, "/watch/time") => { + let t0 = svc.state.lock().unwrap().epoch.t0_us; + reply(StatusCode::OK, json!({"now_us": crate::now_us(), "t0_us": t0})) + } + (Method::GET, "/watch/source") => watch_source(&svc), + (Method::GET, "/watch/challenge") => { + let mut st = svc.state.lock().unwrap(); + match st.epoch.watch.as_mut() { + Some(w) => reply(StatusCode::OK, json!({"challenge": hex::encode(w.challenge(crate::now_us()))})), + None => reply(StatusCode::NOT_FOUND, json!({"error": "not a watch room"})), + } + } + (Method::GET, "/watch") => watch_log(&svc, req.uri().query().unwrap_or("")).await, + (Method::GET, "/watch/room") => { + let st = svc.state.lock().unwrap(); + let ep = &st.epoch; + match ep.watch.as_ref() { + Some(w) => { + let mut v = w.room_json(&ep.descriptor, &ep.grants); + v["room"] = json!(hex::encode(ep.id)); + v["skews"] = w.last_skews(); + v["refused"] = json!(w.refused); + reply(StatusCode::OK, v) + } + None => reply(StatusCode::NOT_FOUND, json!({"error": "not a watch room"})), + } + } + (Method::POST, "/watch/join") => { + let host = req.headers().get("host").and_then(|v| v.to_str().ok()).unwrap_or("").to_string(); + let body = match Limited::new(req.into_body(), MAX_JOIN_BODY).collect().await { + Ok(b) => b.to_bytes(), + Err(_) => return Ok(reply(StatusCode::PAYLOAD_TOO_LARGE, json!({"error": "body"}))), + }; + watch_join(&svc, &host, &body).await + } + (Method::POST, "/watch/post") => { + let body = match Limited::new(req.into_body(), MAX_WATCH_BODY).collect().await { + Ok(b) => b.to_bytes(), + Err(_) => return Ok(reply(StatusCode::PAYLOAD_TOO_LARGE, json!({"error": "body"}))), + }; + let r = watch_post(&svc, &body); + svc.watch_changed.notify_waiters(); + r + } + (_, "/") | (_, "/health") | (_, "/room") | (_, "/grant") | (_, "/mls") | (_, "/mls/key-package") | (_, "/mls/post") | (_, "/age/receipt") | (_, "/co/objects") | (_, "/co/state") | (_, "/co/schedule") | (_, "/co/handover") | (_, "/co/window") | (_, "/co/fidelity") | (_, "/watch") | (_, "/watch/time") | (_, "/watch/source") | (_, "/watch/challenge") | (_, "/watch/join") | (_, "/watch/post") | (_, "/watch/room") => { reply(StatusCode::METHOD_NOT_ALLOWED, json!({"error": "method"})) } _ => reply(StatusCode::NOT_FOUND, json!({"error": "not found"})), @@ -241,7 +301,7 @@ pub fn grant(svc: &Arc, client: &str, host: Option<&str>, authorization if let Err(wait) = st.limiter.take(client) { return reply(StatusCode::TOO_MANY_REQUESTS, json!({"error": "rate", "retry_after_s": wait})); } - if role == Role::Publisher && st.epoch.spent(now) { + if (role == Role::Publisher || st.epoch.watch.is_some()) && st.epoch.spent(now) { svc.new_room(&mut st); } // Admission by age (WAI age-assurance §5.6): every grant request presents a token. @@ -712,3 +772,122 @@ fn co_state(svc: &Arc) -> Response> { "objects": c.objects.len(), "unlogged": c.unlogged, "chains": c.chains.len(), "clock_records": c.clock_chain.len(), })) } + + +/// A watch room's source, whole (WAI co-watch §3.5): a page checks its bytes by their SHA-256, +/// the content hash its policy names, before it presents them. +fn watch_source(svc: &Arc) -> Response> { + let src = match svc.state.lock().unwrap().epoch.watch.as_ref() { + Some(w) => w.source.clone(), + None => return reply(StatusCode::NOT_FOUND, json!({"error": "not a watch room"})), + }; + Response::builder() + .status(StatusCode::OK) + .header("content-type", src.content_type.as_str()) + .header("cache-control", "no-store") + .header("x-content-type-options", "nosniff") + .body(Full::new(Bytes::from((*src.bytes).clone()))) + .expect("a response") +} + +fn hex_field(v: &Value, k: &str) -> Option> { + v[k].as_str().and_then(|h| hex::decode(h).ok()) +} + +/// A viewer enters a watch room by proving it holds its grant's key (live-rooms §10.5.3, +/// §10.5.4); the clock then signs its `join`. +async fn watch_join(svc: &Arc, host: &str, body: &[u8]) -> Response> { + let Ok(v) = serde_json::from_slice::(body) else { return reply(StatusCode::BAD_REQUEST, json!({"error": "not JSON"})) }; + let (Some(room), Some(grant), Some(proof)) = (v["room"].as_str().and_then(room_of), hex_field(&v, "grant"), hex_field(&v, "proof")) else { + return reply(StatusCode::BAD_REQUEST, json!({"error": "room, grant and proof: hexadecimal"})); + }; + let context = wai::live_room::proof_context("POST", host, &format!("{}/watch/join", svc.public_prefix)); + let (generation, principal, digest, joined) = { + let mut st = svc.state.lock().unwrap(); + if st.epoch.id != room { + return reply(StatusCode::GONE, json!({"error": "room", "room": hex::encode(st.epoch.id)})); + } + let generation = st.epoch.generation; + let Some(w) = st.epoch.watch.as_mut() else { return reply(StatusCode::NOT_FOUND, json!({"error": "not a watch room"})) }; + let principal = match w.admit(&grant, &proof, &context, crate::now_us()) { + Ok(p) => p, + Err(why) => { + w.refuse(&why); + return reply(StatusCode::UNAUTHORIZED, json!({"error": why})); + } + }; + let vw = w.viewers.get_mut(&principal).expect("admitted"); + vw.heard_us = crate::now_us(); + (generation, principal, vw.digest, vw.joined.filter(|_| !vw.leaving)) + }; + if let Some(t) = joined { + return reply(StatusCode::OK, json!({"ok": true, "t": t, "again": true})); + } + match svc.emit(generation, principal, &[crate::epoch::Lifecycle::Join { grant: digest }]).await { + Ok(t) => { + tracing::info!(principal = %hex::encode(&principal[..6]), t, "a viewer joins the watch room"); + reply(StatusCode::OK, json!({"ok": true, "t": t})) + } + Err(e) => reply(StatusCode::SERVICE_UNAVAILABLE, json!({"error": e})), + } +} + +/// A viewer's events, each checked and carried in order (WAI co-watch §10). +fn watch_post(svc: &Arc, body: &[u8]) -> Response> { + let Ok(v) = serde_json::from_slice::(body) else { return reply(StatusCode::BAD_REQUEST, json!({"error": "not JSON"})) }; + let Some(room) = v["room"].as_str().and_then(room_of) else { return reply(StatusCode::BAD_REQUEST, json!({"error": "room: 64 hexadecimal digits"})) }; + let events: Option>> = v["events"].as_array().map(|a| a.iter().map(|e| e.as_str().and_then(|h| hex::decode(h).ok())).collect()).unwrap_or(None); + let Some(events) = events.filter(|e| !e.is_empty() && e.len() <= crate::watch::MAX_POST) else { + return reply(StatusCode::BAD_REQUEST, json!({"error": "events: 1 to 8, hexadecimal"})); + }; + let mut st = svc.state.lock().unwrap(); + if st.epoch.id != room { + return reply(StatusCode::GONE, json!({"error": "room", "room": hex::encode(st.epoch.id)})); + } + let t0 = st.epoch.t0_us; + let Some(w) = st.epoch.watch.as_mut() else { return reply(StatusCode::NOT_FOUND, json!({"error": "not a watch room"})) }; + let now = crate::now_us(); + for (k, e) in events.iter().enumerate() { + if let Err(why) = w.post(e, now.saturating_sub(t0), now) { + w.refuse(why); + tracing::info!(reason = why, "a watch post refused"); + return reply(StatusCode::CONFLICT, json!({"error": why, "carried": k})); + } + } + reply(StatusCode::OK, json!({"ok": true, "carried": events.len(), "log": w.log.len()})) +} + +/// A watch room's log from `from`, once it holds anything past it or after [`MLS_WAIT`]. +async fn watch_log(svc: &Arc, query: &str) -> Response> { + let mut room = None; + let mut from = 0usize; + let mut wait = MLS_WAIT; + for (k, v) in query.split('&').filter_map(|kv| kv.split_once('=')) { + match k { + "room" => room = room_of(v), + "from" => from = v.parse().unwrap_or(0), + "wait_ms" => wait = v.parse().map(Duration::from_millis).unwrap_or(MLS_WAIT).min(MLS_WAIT), + _ => {} + } + } + let Some(room) = room else { return reply(StatusCode::BAD_REQUEST, json!({"error": "room: 64 hexadecimal digits"})) }; + let deadline = tokio::time::Instant::now() + wait; + loop { + let notified = svc.watch_changed.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + { + let st = svc.state.lock().unwrap(); + if st.epoch.id != room { + return reply(StatusCode::GONE, json!({"error": "room", "room": hex::encode(st.epoch.id)})); + } + let Some(w) = st.epoch.watch.as_ref() else { return reply(StatusCode::NOT_FOUND, json!({"error": "not a watch room"})) }; + if w.log.len() > from || tokio::time::Instant::now() >= deadline { + let mut v = w.entries(from); + v["room"] = json!(hex::encode(room)); + return reply(StatusCode::OK, v); + } + } + let _ = tokio::time::timeout_at(deadline, notified).await; + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs index f8622c52e..c9b8519da 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/keys.rs @@ -101,7 +101,7 @@ impl Keys { let c = hex::encode(self.clock.verifying_key().to_bytes()); let t = hex::encode(Sha256::digest(self.clock_token)); format!( - "# authority {a}\n# clock {c}\n[[auth.authorities]]\nkey = \"{a}\"\n\n# The room's clock publishes its events track in every room it keeps; its session is\n# exempt from the relay's session and ingress limits.\n[[auth.grants]]\nprefix = [\"*\", \"{c}\", \"events\"]\ntoken_sha256 = \"{t}\"\npublish = true\nunlimited = true\n\n# It watches each publisher's tracks to author the lifecycle.\n[[auth.grants]]\nprefix = []\ntoken_sha256 = \"{t}\"\nsubscribe = true\n\n# With co-performance in the room's configuration (`[co]`): the relay holds the floor and the\n# audience's inputs, honouring the clock's schedules, windows and commitments.\n# [floor]\n# room_keys = [\"{c}\"]\n" + "# authority {a}\n# clock {c}\n[[auth.authorities]]\nkey = \"{a}\"\n\n# The room's clock publishes its events track in every room it keeps; its session is\n# exempt from the relay's session and ingress limits. It proves the room's keys in its SETUP,\n# so a relay serves it the room's rated tracks as the room's own service (WAI age-assurance §5.5).\n[[auth.grants]]\nprefix = [\"*\", \"{c}\", \"events\"]\ntoken_sha256 = \"{t}\"\npublish = true\nunlimited = true\n\n# It watches each publisher's tracks to author the lifecycle.\n[[auth.grants]]\nprefix = []\ntoken_sha256 = \"{t}\"\nsubscribe = true\n\n# With co-performance in the room's configuration (`[co]`): the relay holds the floor and the\n# audience's inputs, honouring the clock's schedules, windows and commitments.\n# [floor]\n# room_keys = [\"{c}\"]\n" ) } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs index e43a2f92d..29a70dd0f 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/lib.rs @@ -21,6 +21,10 @@ //! objects as it takes them, and signs what only it knows: marks, commitments, selections //! and fidelity statements; and, on a request its grants and powers authorise, the turn //! schedules, handovers and windows a page asks for. +//! - **Co-watch** (`[room] watch_source`, WAI co-watch, [`watch`]). The room watches one +//! source together: the service serves it, admits each viewer by a proof of its grant's key, +//! carries the viewers' controls and presentation statements that co-watch accepts on the +//! room's log, and the clock signs each window's skew statements. //! //! Every room the service keeps is an [`epoch::Epoch`]: when everyone has left, the next //! publisher starts a new one. @@ -32,6 +36,7 @@ pub mod epoch; pub mod http; pub mod keys; pub mod limiter; +pub mod watch; use epoch::{Chain, Epoch, Lead, Lifecycle, MemberState, Settings}; use joule_moq::draft22::data::{SubgroupCodec, SubgroupHeader, SubgroupId, SubgroupObject}; @@ -76,7 +81,8 @@ pub struct Config { #[serde(default)] pub co: CoConfig, /// The path the front serves the service under (`/room-api` when it strips that prefix): - /// a grant's proof over HTTP names the path the client addressed (live-rooms §10.5.1). + /// a proof of possession over HTTP names the path the client addressed (live-rooms + /// §10.5.1), a grant's to the relay and a co-watch viewer's at `/watch/join` alike. #[serde(default)] pub public_prefix: String, /// Exit with status 70 past this resident memory, in MiB, for the supervisor to start @@ -96,11 +102,31 @@ pub struct RoomConfig { pub max_grants: usize, /// Every room end-to-end encrypted (WAI confidentiality). pub e2ee: bool, + /// Every room watches this file together (WAI co-watch); empty for none. + pub watch_source: String, + /// The file's duration, in milliseconds. + pub watch_duration_ms: u32, + /// The actions every principal may control (co-watch §4.4): 15 for all of them. + pub watch_control: u8, + /// The skew window, in milliseconds: a whole number of seconds. + pub watch_window_ms: u32, } impl Default for RoomConfig { fn default() -> Self { - RoomConfig { max_publishers: 4, max_present: 16, publisher_grant_s: 1_200, viewer_grant_s: 1_800, join_wait_s: 90, max_grants: 64, e2ee: false } + RoomConfig { + max_publishers: 4, + max_present: 16, + publisher_grant_s: 1_200, + viewer_grant_s: 1_800, + join_wait_s: 90, + max_grants: 64, + e2ee: false, + watch_source: String::new(), + watch_duration_ms: 0, + watch_control: 15, + watch_window_ms: 5_000, + } } } @@ -165,10 +191,28 @@ impl Config { Ok(c) } - pub fn settings(&self) -> Settings { + /// The rooms' settings; a watch room's source read from its file. + pub fn settings(&self) -> Result { let r = &self.room; - let age = self.age.as_ref().map(|a| a.settings().expect("[age] was checked when the configuration was read")); - Settings { + let age = self.age.as_ref().map(|a| a.settings()).transpose()?; + let watch = if r.watch_source.is_empty() { + None + } else { + if r.e2ee { + return Err("a room is not both end-to-end encrypted and a watch room: its source is served in the clear".into()); + } + if self.age.is_some() { + return Err("a room is not both admitted by age and a watch room: its source is served without a grant".into()); + } + if self.co.audience_input > 0 || self.co.latency_budget_ms > 0 { + return Err("a room is not both a room with co-performance and a watch room".into()); + } + if r.watch_control > 15 || r.watch_window_ms == 0 || !r.watch_window_ms.is_multiple_of(watch::PERIOD_MS) || r.watch_window_ms / watch::PERIOD_MS > 1000 { + return Err("watch_control is 0 to 15, and watch_window_ms a whole number of seconds, at most 1000".into()); + } + Some(std::sync::Arc::new(watch::WatchSource::load(&r.watch_source, r.watch_duration_ms)?)) + }; + Ok(Settings { max_publishers: r.max_publishers, max_present: r.max_present, publisher_grant_s: r.publisher_grant_s, @@ -181,10 +225,22 @@ impl Config { latency_budget_ms: self.co.latency_budget_ms.min(wai::co_performance::MAX_BUDGET_MS), lead: self.co.lead, ai: self.co.ai, - } + watch, + watch_control: r.watch_control, + watch_window_ms: r.watch_window_ms, + }) } } +/// The target a proof over a MOQT session names (WAI live-rooms §10.5.1): `//`, the authority +/// and the path of the relay's URL, with no scheme, query or fragment. +pub fn proof_target(url: &str) -> String { + let rest = url.split_once("://").map_or(url, |(_, r)| r); + let (authority, path) = rest.find('/').map_or((rest, ""), |i| rest.split_at(i)); + let path = path.split(['?', '#']).next().unwrap_or(""); + format!("//{authority}{path}") +} + pub fn now_us() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_micros() as u64) } @@ -237,8 +293,11 @@ pub struct Service { /// waiting to be signed. pub co: Mutex, pub co_cfg: CoConfig, + /// The path prefix the front puts before the service's paths ([`Config::public_prefix`]). pub public_prefix: String, pub marks_waiting: Notify, + /// Signalled whenever a watch room's log grows. + pub watch_changed: Notify, } /// One record the clock signs: a lifecycle event for a subject, or a `meta` payload. @@ -249,12 +308,17 @@ pub enum Item { } impl Service { + /// The service; it panics on a configuration [`Service::try_new`] refuses. pub fn new(keys: Keys, cfg: &Config) -> Arc { - let settings = cfg.settings(); + Service::try_new(keys, cfg).expect("a configuration the service takes") + } + + pub fn try_new(keys: Keys, cfg: &Config) -> Result, String> { + let settings = cfg.settings()?; let r = &cfg.rate; let limiter = Limiter::new(Rate { burst: r.client_burst as f64, every_s: r.client_every_s as f64 }, Rate { burst: r.global_burst as f64, every_s: r.global_every_s as f64 }); let epoch = Epoch::new(&keys, settings.clone(), 1, now_us()); - Arc::new(Service { + Ok(Arc::new(Service { keys, settings, relay_url: cfg.relay_url.clone(), @@ -274,7 +338,8 @@ impl Service { co_cfg: cfg.co, public_prefix: cfg.public_prefix.trim_end_matches('/').to_string(), marks_waiting: Notify::new(), - }) + watch_changed: Notify::new(), + })) } /// The clock's session, while it has one. @@ -282,13 +347,17 @@ impl Service { self.clock.lock().await.client.clone() } - /// Start the clock's session and, in a room with co-performance, its duties. + /// Start the clock's session and, in a room with co-performance or a watch room, its + /// duties. pub fn start(self: &Arc) { tokio::spawn(self.clone().run_clock()); if self.co_on() { tokio::spawn(self.clone().sign_marks()); tokio::spawn(self.clone().co_duties()); } + if self.settings.watch.is_some() { + tokio::spawn(self.clone().run_watch()); + } } fn clock_hex(&self) -> String { @@ -347,14 +416,30 @@ impl Service { async fn connect(&self) -> Result { let conn = tokio::time::timeout(Duration::from_secs(10), websocket::connect(&self.relay_url, MuxConfig::default())).await.map_err(|_| "no session in time".to_string())?.map_err(|e| e.to_string())?; - let opts = ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: self.keys.clock_token.to_vec() }], ..ClientOptions::default() }; + // The clock's session is the room's own service: it proves both room keys the service + // holds (WAI age-assurance §5.5), and a relay that holds either as a room key does not + // hold it to the serving rule of rated tracks, which it follows and logs as the room's. + let opts = ClientOptions { + tokens: vec![Token::UseValue { token_type: 0, value: self.keys.clock_token.to_vec() }], + room_keys: vec![self.keys.authority.clone(), self.keys.clock.clone()], + target: proof_target(&self.relay_url), + ..ClientOptions::default() + }; Client::connect(Arc::new(conn), opts).await } /// Sign and send lifecycle events for `subject` in the room of `generation`, in order. - pub async fn emit(&self, generation: u64, subject: [u8; 32], items: &[Lifecycle]) -> Result<(), String> { + /// The `t` of the last record signed. + pub async fn emit(&self, generation: u64, subject: [u8; 32], items: &[Lifecycle]) -> Result { + self.emit_after(generation, subject, items, 0).await + } + + /// [`Service::emit`], each record at room-clock `min_t` or later: a watch room's viewer's + /// `leave` comes after its last event. The `t` of the last record signed. + pub async fn emit_after(&self, generation: u64, subject: [u8; 32], items: &[Lifecycle], min_t: u64) -> Result { let items: Vec = items.iter().map(|w| Item::Life(subject, w.clone())).collect(); - self.emit_items(generation, &items).await.map(|_| ()) + let v = self.emit_items_after(generation, &items, min_t).await?; + Ok(v.last().map_or(0, |(_, t)| *t)) } /// Sign and send a `meta` event of the clock in the room of `generation`: its record and @@ -367,6 +452,11 @@ impl Service { /// Sign and send the clock's records for `items` in the room of `generation`, in order, each /// within the clock's rate (`epoch::CLOCK_MAX_EVENTS`): each record and its `t`. pub async fn emit_items(&self, generation: u64, items: &[Item]) -> Result, u64)>, String> { + self.emit_items_after(generation, items, 0).await + } + + /// [`Service::emit_items`], each record at room-clock `min_t` or later. + pub async fn emit_items_after(&self, generation: u64, items: &[Item], min_t: u64) -> Result, u64)>, String> { let mut clock = self.clock.lock().await; let client = clock.client.clone().ok_or("the clock has no session")?; let (room, t0_us) = { @@ -399,7 +489,7 @@ impl Service { tokio::time::sleep(Duration::from_micros(wait)).await; } let track = clock.track.as_mut().expect("made above"); - let t = now_us().saturating_sub(track.t0_us); + let t = now_us().saturating_sub(track.t0_us).max(min_t); let rec = match what { Item::Life(subject, l) => track.chain.sign(&self.keys, track.room, t, *subject, l), Item::Meta(p) => track.chain.sign_event(&self.keys, track.room, t, wai::live_events::kind::META, p.clone()), @@ -433,6 +523,7 @@ impl Service { } Item::Meta(p) => tracing::info!(room = %hex::encode(room), statement = %String::from_utf8_lossy(&p[..p.len().min(4)]), t, "a statement the clock signs"), } + self.watch_record(generation, &rec, what); out.push((rec, t)); } drop(clock); @@ -440,6 +531,50 @@ impl Service { Ok(out) } + /// In a watch room, every record of the clock's chain is carried on the room's log too, + /// and its viewers' presence followed (WAI co-watch). + fn watch_record(&self, generation: u64, rec: &[u8], what: &Item) { + let mut st = self.state.lock().unwrap(); + if st.epoch.generation != generation { + return; + } + if let Some(w) = st.epoch.watch.as_mut() { + w.clock_record(rec, what); + drop(st); + self.watch_changed.notify_waiters(); + } + } + + /// The clock's part in a watch room: each window's skew statements once the window has + /// ended (co-watch §8.4), and a `leave` for each viewer that has fallen silent or whose + /// grant has ended. Runs for as long as the service does. + pub async fn run_watch(self: Arc) { + loop { + tokio::time::sleep(Duration::from_millis(250)).await; + if !self.connected.load(Ordering::Acquire) { + continue; + } + let (generation, statements, ended) = { + let mut st = self.state.lock().unwrap(); + let generation = st.epoch.generation; + let t0 = st.epoch.t0_us; + let Some(w) = st.epoch.watch.as_mut() else { continue }; + let now = now_us(); + (generation, w.due_statements(now.saturating_sub(t0)), w.ended(now)) + }; + for s in statements { + if let Err(e) = self.emit_meta(generation, watch::skew_payload(&s)).await { + tracing::warn!(error = %e, "a skew statement could not be sent"); + } + } + for (p, why, min_t) in ended { + if let Err(e) = self.emit_after(generation, p, &[Lifecycle::Leave { reason: why }], min_t).await { + tracing::warn!(error = %e, "a viewer's leave could not be sent"); + } + } + } + } + /// In an end-to-end encrypted room, a lifecycle record is carried on the room's log too, /// and its clock proposes what it calls for (WAI confidentiality §5.4). fn mls_lifecycle(&self, generation: u64, rec: &[u8], subject: [u8; 32], what: &Lifecycle) { @@ -644,10 +779,12 @@ mod tests { #[test] fn the_example_configuration_reads() { let c = Config::from_toml(include_str!("../room.example.toml")).expect("the example reads"); - assert!(!c.settings().co(), "co-performance is off by default"); + assert!(!c.settings().unwrap().co(), "co-performance is off by default"); assert_eq!((c.co.lead, c.co.ai, c.public_prefix.as_str()), (Lead::First, true, "/room-api")); let on = Config::from_toml(&format!("{}\n", include_str!("../room.example.toml").replace("audience_input = 0", "audience_input = 3"))).unwrap(); - assert!(on.settings().co()); + assert!(on.settings().unwrap().co()); + assert_eq!(proof_target("ws://127.0.0.1:9681/moq"), "//127.0.0.1:9681/moq"); + assert_eq!(proof_target("wss://relay.example/moq?x=1"), "//relay.example/moq"); assert!(Config::from_toml("listen = \"127.0.0.1:1\"\nkeys = \"k\"\nrelay_url = \"ws://x\"\n[co]\nlead = \"everyone\"\n").is_err(), "a lead the service does not know"); } } diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs index a1451aa03..de0e2df32 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/main.rs @@ -76,7 +76,13 @@ async fn serve(cfg: Config, keys: Keys) -> ExitCode { return ExitCode::FAILURE; } }; - let svc = Service::new(keys, &cfg); + let svc = match Service::try_new(keys, &cfg) { + Ok(s) => s, + Err(e) => { + eprintln!("joule-moq-room: {e}"); + return ExitCode::FAILURE; + } + }; tracing::info!( "room service on http://{}; authority {}, clock {}; relay {}", cfg.listen, diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/src/watch.rs b/joule-code/joule-code-rs/crates/joule-moq-room/src/watch.rs new file mode 100644 index 000000000..8fa722350 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-room/src/watch.rs @@ -0,0 +1,630 @@ +//! Co-watch rooms (WAI `extensions/co-watch.md`): the service's part. +//! +//! A room the service keeps with a `watch_source` watches that source together. The room's +//! authority signs a co-watch policy (`WXP1`, §3) naming the source, an object sequence of one +//! object: the file, by the SHA-256 of its bytes and its duration. The service serves the file, +//! and is the room's carrier of its viewers' events (§10): one log per room, holding every +//! record of the clock's chain (the lifecycle, viewers' presence among it, and the skew +//! statements) and every event a viewer posts, in the order the service took them. A viewer +//! enters by proving it holds its grant's key over a challenge the service issued (live-rooms +//! §10.5.3); the clock then signs its `join`, and its `leave` when it stops stating its +//! presentation or its grant ends. +//! +//! A posted event is carried only when a verifier would accept it: it reads, is the viewer's +//! next in its chain, is signed by it, is a `meta` event within its grant's policy and rate, and +//! is a control that §5 accepts or a presentation statement that §7.2 accepts. Each window, the +//! clock signs the skew statements of §8.4, computed by `wai-rs` from what the log holds, with +//! `held` the events of each viewer it has carried; a statement that arrives later changes +//! nothing it said. + +use ed25519_dalek::SigningKey; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::sync::Arc; +use wai::co_watch::{ + self as cw, may_control, read_statement, source_digest, source_duration_us, GrantChange, SampleRec, Skew, SourceObject, Statement, Stretch, TimedControl, Timeline, WatchFacts, + WatchPolicy, +}; +use wai::live_events::{kind, Event, Payload}; +use wai::live_room::{leave, Detail, Grant}; + +use crate::epoch::Lifecycle; +use crate::Item; + +/// The policy every co-watch room the service keeps states (co-watch §3.1): controls at least +/// 600 ms and at most 10 s ahead, rates from half to twice normal speed, one-second slots, a +/// 250 ms bound at the 95th percentile, samples settling for 1.5 s, and the catch-up rule's +/// 40 ms, 5 % and one second. +pub const LEAD_MS: u32 = 600; +pub const HORIZON_MS: u32 = 10_000; +pub const MIN_RATE: u32 = 500_000; +pub const MAX_RATE: u32 = 2_000_000; +pub const PERIOD_MS: u32 = 1_000; +pub const SETTLE_MS: u32 = 1_500; +pub const BOUND_US: u32 = 250_000; +pub const QUANTILE: u8 = 95; +pub const TOLERANCE_US: u32 = 40_000; +pub const NUDGE: u32 = 50_000; +pub const SEEK_US: u32 = 1_000_000; +/// A window's statements are made this long after its end, for its last statements to arrive. +pub const STATE_AFTER_US: u64 = 2_000_000; +/// A viewer that has posted nothing for this long has left (`leave` reason 4). +pub const SILENT_US: u64 = 4_000_000; +/// An event whose `t` is this far past the service's clock is refused. +pub const AHEAD_US: u64 = 5_000_000; +/// Events one post carries at most. +pub const MAX_POST: usize = 8; +/// Entries one answer of the log carries at most. +pub const PAGE: usize = 512; +/// A challenge is good for this long. +pub const CHALLENGE_US: u64 = 60_000_000; + +/// The source a co-watch room watches: one file, served whole. +pub struct WatchSource { + pub bytes: Arc>, + pub content_type: String, + pub object: SourceObject, +} + +impl std::fmt::Debug for WatchSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "WatchSource({} bytes, {}, {} ms)", self.bytes.len(), self.content_type, self.object.duration_ms) + } +} + +impl WatchSource { + /// The file at `path`, of `duration_ms`, at most 256 MiB. + pub fn load(path: &str, duration_ms: u32) -> Result { + if duration_ms == 0 { + return Err("watch_duration_ms must be above 0".into()); + } + let bytes = std::fs::read(path).map_err(|e| format!("watch_source {path}: {e}"))?; + if bytes.len() > 256 << 20 { + return Err(format!("watch_source {path}: more than 256 MiB")); + } + let content_type = match path.rsplit('.').next().map(str::to_ascii_lowercase).as_deref() { + Some("wav") => "audio/wav", + Some("webm") => "video/webm", + Some("mp4") | Some("m4v") => "video/mp4", + Some("ogg") | Some("oga") => "audio/ogg", + Some("mp3") => "audio/mpeg", + _ => "application/octet-stream", + } + .to_string(); + let object = SourceObject { content_hash: Sha256::digest(&bytes).into(), duration_ms }; + Ok(WatchSource { bytes: Arc::new(bytes), content_type, object }) + } +} + +/// The co-watch policy of the room `room` (co-watch §3), signed by the authority. +pub fn policy(room: [u8; 32], source: &WatchSource, window_ms: u32, authority: &SigningKey) -> WatchPolicy { + let objects = [source.object]; + WatchPolicy { + room, + prev: [0; 32], + effective: 0, + source_type: cw::source_type::OBJECTS, + source: source_digest(&objects), + duration_us: source_duration_us(&objects), + lead_ms: LEAD_MS, + horizon_ms: HORIZON_MS, + min_rate: MIN_RATE, + max_rate: MAX_RATE, + period_ms: PERIOD_MS, + window_ms, + settle_ms: SETTLE_MS, + bound_us: BOUND_US, + quantile: QUANTILE, + tolerance_us: TOLERANCE_US, + nudge: NUDGE, + seek_us: SEEK_US, + sig: [0; 64], + } + .sign(authority) +} + +/// A viewer of the room: its grant, its presence and its chain as the service carried it. +#[derive(Clone, Debug)] +pub struct Viewer { + pub grant: Grant, + pub record: Vec, + pub digest: [u8; 32], + /// Its presence: the `t` of its `join`, while present. + pub joined: Option, + /// A `leave` asked of the clock and not yet signed. + pub leaving: bool, + /// Present by its proof at `/watch/join`, and so gone once silent; a publisher is present + /// while its tracks are. + pub by_proof: bool, + pub digests: Vec<[u8; 32]>, + pub last_t: u64, + /// The service's clock (µs since 1970) at its last post or join. + pub heard_us: u64, + /// The `t` of its recent events, for its policy's rate (live-events §6.3). + recent: VecDeque, +} + +/// Why a post, or a part of one, was not carried. +pub type Refusal = &'static str; + +/// A co-watch room's state at the service. +pub struct WatchRoom { + pub room: [u8; 32], + pub t0_us: u64, + pub policy: WatchPolicy, + pub policy_bytes: Vec, + pub source: Arc, + /// Every record the room's log carries, in the order taken. + pub log: Vec>, + pub viewers: BTreeMap<[u8; 32], Viewer>, + pub controls: Vec, + pub samples: Vec, + pub stretches: Vec, + pub grant_changes: Vec, + /// The start of the next window the clock states. + pub next_from: u64, + /// Challenges issued and not consumed, with when (µs since 1970); and those consumed. + challenges: HashMap<[u8; 32], u64>, + consumed: VecDeque<[u8; 32]>, + pub refused: BTreeMap, + pub skews: Vec, +} + +impl WatchRoom { + pub fn new(room: [u8; 32], t0_us: u64, source: Arc, window_ms: u32, authority: &SigningKey) -> WatchRoom { + let policy = policy(room, &source, window_ms, authority); + WatchRoom { + room, + t0_us, + policy_bytes: policy.to_bytes(), + policy, + source, + log: Vec::new(), + viewers: BTreeMap::new(), + controls: Vec::new(), + samples: Vec::new(), + stretches: Vec::new(), + grant_changes: Vec::new(), + next_from: 0, + challenges: HashMap::new(), + consumed: VecDeque::new(), + refused: BTreeMap::new(), + skews: Vec::new(), + } + } + + pub fn window_us(&self) -> u64 { + u64::from(self.policy.window_ms) * 1000 + } + + /// A viewer's grant the room issued. + pub fn granted(&mut self, record: Vec, grant: Grant, digest: [u8; 32]) { + let p = grant.principal; + self.viewers.insert(p, Viewer { grant, record, digest, joined: None, leaving: false, by_proof: false, digests: Vec::new(), last_t: 0, heard_us: 0, recent: VecDeque::new() }); + } + + pub fn present(&self) -> usize { + self.viewers.values().filter(|v| v.joined.is_some()).count() + } + + /// A record of the clock's chain, as the clock signed it: carried, and a viewer's presence + /// followed. + pub fn clock_record(&mut self, rec: &[u8], what: &Item) { + self.log.push(rec.to_vec()); + let t = Event::parse(rec).map(|e| e.t).unwrap_or(0); + let Item::Life(subject, what) = what else { return }; + let subject = *subject; + match what { + Lifecycle::Join { grant } => { + self.stretches.push(Stretch { principal: subject, join: t, leave: None }); + let subscribe = self.viewers.get(&subject).is_none_or(|v| v.digest != *grant || v.grant.caps.subscribe); + self.grant_changes.push(GrantChange { principal: subject, t, subscribe }); + if let Some(v) = self.viewers.get_mut(&subject) { + v.joined = Some(t); + v.leaving = false; + v.last_t = v.last_t.max(t); + } + } + Lifecycle::Leave { .. } => { + if let Some(s) = self.stretches.iter_mut().rev().find(|s| s.principal == subject && s.leave.is_none()) { + s.leave = Some(t); + } + if let Some(v) = self.viewers.get_mut(&subject) { + v.joined = None; + v.leaving = false; + } + } + Lifecycle::Publish { .. } => {} + } + } + + /// A fresh challenge (live-rooms §10.5.3), from the operating system's generator. + pub fn challenge(&mut self, now_us: u64) -> [u8; 32] { + self.challenges.retain(|_, at| now_us.saturating_sub(*at) < CHALLENGE_US); + if self.challenges.len() >= 1024 { + let oldest = self.challenges.iter().min_by_key(|(_, at)| **at).map(|(c, _)| *c); + if let Some(c) = oldest { + self.challenges.remove(&c); + } + } + let mut c = [0u8; 32]; + rand_core_06::RngCore::fill_bytes(&mut rand_core_06::OsRng, &mut c); + self.challenges.insert(c, now_us); + c + } + + /// The viewer that presents `grant` with `proof` for the request `context` (live-rooms + /// §10.5.4), its challenge consumed: its principal. + pub fn admit(&mut self, grant: &[u8], proof: &[u8], context: &str, now_us: u64) -> Result<[u8; 32], String> { + let digest: [u8; 32] = Sha256::digest(grant.get(..grant.len().saturating_sub(64)).unwrap_or(&[])).into(); + let v = self.viewers.values().find(|v| v.record == grant && v.digest == digest).ok_or("grant_unknown")?; + let p = wai::live_room::Proof::read(proof).map_err(|r| r.code().to_string())?; + let consumed: Vec<[u8; 32]> = self.consumed.iter().copied().collect(); + let fresh = self.challenges.get(&p.challenge).is_some_and(|at| now_us.saturating_sub(*at) < CHALLENGE_US); + let held = if fresh { p.challenge } else { [0; 32] }; + wai::live_room::check_proof(&v.digest, &v.grant.principal, Some(proof), &held, &consumed, context).map_err(|r| r.code().to_string())?; + let principal = v.grant.principal; + self.viewers.get_mut(&principal).expect("found above").by_proof = true; + self.challenges.remove(&p.challenge); + self.consumed.push_back(p.challenge); + if self.consumed.len() > 1024 { + self.consumed.pop_front(); + } + Ok(principal) + } + + /// One posted event, at room-clock `now_t`: carried, or why not. A record carried before + /// is accepted again and not carried twice. + pub fn post(&mut self, rec: &[u8], now_t: u64, now_us: u64) -> Result<(), Refusal> { + let ev = Event::parse(rec).map_err(|r| r.code())?; + if ev.room != self.room { + return Err("wrong_room"); + } + let digest = ev.digest(); + let v = self.viewers.get(&ev.principal).ok_or("not_present")?; + if (ev.seq as usize) < v.digests.len() { + return if v.digests[ev.seq as usize] == digest { Ok(()) } else { Err("seq_replay") }; + } + let joined = v.joined.filter(|_| !v.leaving).ok_or("not_present")?; + ev.verify_signature().map_err(|r| r.code())?; + if ev.seq as usize != v.digests.len() { + return Err("delivery_gap"); + } + if ev.prev != v.digests.last().copied().unwrap_or([0; 32]) { + return Err("chain_broken"); + } + if ev.t < v.last_t || ev.t < joined { + return Err("time_reversed"); + } + if ev.t > now_t + AHEAD_US { + return Err("ahead"); + } + if !v.grant.covers(self.t0_us, ev.t) { + return Err("outside_grant_window"); + } + let pol = v.grant.policy; + if ev.kind != kind::META || !pol.permits(kind::META) { + return Err("kind_not_permitted"); + } + if ev.payload.len() > usize::from(pol.max_payload) { + return Err("payload_too_large"); + } + let in_window = v.recent.iter().filter(|&&x| ev.t - x < pol.window_us).count() as u64; + if in_window + 1 > u64::from(pol.max_events) { + return Err("rate_exceeded"); + } + let p = &self.policy; + match read_statement(&ev.payload) { + None => return Err("not_a_statement"), + Some(Err(r)) => return Err(r.code()), + Some(Ok(Statement::Control(c))) => { + if !may_control(&v.grant, c.action) { + return Err("control_not_permitted"); + } + if c.at < ev.t + u64::from(p.lead_ms) * 1000 { + return Err("control_not_ahead"); + } + if c.at > ev.t + u64::from(p.horizon_ms) * 1000 { + return Err("control_beyond_horizon"); + } + let ok = match c.action { + cw::action::SEEK => c.arg <= p.duration_us, + cw::action::RATE => c.arg >= u64::from(p.min_rate) && c.arg <= u64::from(p.max_rate), + _ => true, + }; + if !ok { + return Err("control_out_of_range"); + } + self.controls.push(TimedControl { control: c, t: ev.t, digest }); + } + Some(Ok(Statement::Presentation(s))) => { + if s.room != self.room { + return Err("wrong_room"); + } + if s.player != ev.principal { + return Err("presentation_wrong_key"); + } + s.verify().map_err(|r| r.code())?; + if s.at > ev.t { + return Err("presentation_ahead"); + } + if s.source != p.source { + return Err("presentation_wrong_source"); + } + self.samples.push(SampleRec { principal: ev.principal, seq: ev.seq, at: s.at, position: s.position }); + } + Some(Ok(Statement::Skew(_))) => return Err("cw_not_permitted"), + } + let v = self.viewers.get_mut(&ev.principal).expect("found above"); + v.digests.push(digest); + v.last_t = ev.t; + v.heard_us = now_us; + v.recent.push_back(ev.t); + while v.recent.front().is_some_and(|&x| ev.t - x >= pol.window_us) { + v.recent.pop_front(); + } + self.log.push(rec.to_vec()); + Ok(()) + } + + /// Count a refusal by its reason. + pub fn refuse(&mut self, why: &str) { + *self.refused.entry(why.to_string()).or_default() += 1; + } + + /// What the skew statements are computed from (co-watch §8). + pub fn facts(&self) -> WatchFacts { + WatchFacts { + timeline: Timeline::build(std::slice::from_ref(&self.policy), &self.controls), + stretches: self.stretches.clone(), + grants: self.grant_changes.clone(), + samples: self.samples.clone(), + } + } + + /// The skew statements of every window that ended at least [`STATE_AFTER_US`] before + /// `now_t` and was not yet stated, in order: each as the clock signs it. + pub fn due_statements(&mut self, now_t: u64) -> Vec { + let w = self.window_us(); + let mut out = Vec::new(); + while self.next_from + w + STATE_AFTER_US <= now_t { + let (from, to) = (self.next_from, self.next_from + w); + self.next_from = to; + if self.present() == 0 && !self.stretches.iter().any(|s| s.leave.is_none_or(|l| l > from) && s.join < to) { + continue; + } + let held: BTreeMap<[u8; 32], u64> = self.viewers.iter().map(|(p, v)| (*p, v.digests.len() as u64)).collect(); + let st = self.facts().statements(from, to, &held); + self.skews.extend(st.iter().cloned()); + out.extend(st); + } + out + } + + /// The viewers whose presence has ended at `now_us` (µs since 1970): silent too long, or + /// their grant over. Each with its `leave` reason and the least `t` the `leave` may have, + /// after the viewer's last event. + pub fn ended(&mut self, now_us: u64) -> Vec<([u8; 32], u8, u64)> { + let now_s = now_us / 1_000_000; + let mut out = Vec::new(); + for (p, v) in self.viewers.iter_mut() { + if v.joined.is_none() || v.leaving || !v.by_proof { + continue; + } + let why = if now_s >= v.grant.exp { + leave::GRANT_ENDED + } else if now_us.saturating_sub(v.heard_us) > SILENT_US { + leave::CONNECTION_LOST + } else { + continue; + }; + v.leaving = true; + out.push((*p, why, v.last_t + 1)); + } + out + } + + /// The log from `from`, at most [`PAGE`] entries. + pub fn entries(&self, from: usize) -> Value { + let end = self.log.len().min(from.saturating_add(PAGE)); + let items: Vec = (from.min(end)..end).map(|i| json!({"seq": i, "event": hex::encode(&self.log[i])})).collect(); + json!({"entries": items, "next": end, "end": self.log.len()}) + } + + /// The room as a co-watch verifier is given it (co-watch §11.1), in the corpus's form: + /// the chains as the log carried them, by principal in the order each first appears. + pub fn room_json(&self, descriptor: &[u8], grants: &[Vec]) -> Value { + let mut order: Vec<[u8; 32]> = Vec::new(); + let mut chains: BTreeMap<[u8; 32], Vec> = BTreeMap::new(); + for r in &self.log { + let Ok(e) = Event::parse(r) else { continue }; + if !chains.contains_key(&e.principal) { + order.push(e.principal); + } + chains.entry(e.principal).or_default().push(hex::encode(r)); + } + json!({ + "descriptor": hex::encode(descriptor), + "grants": grants.iter().map(hex::encode).collect::>(), + "key_log": null, + "chains": order.iter().map(|p| chains[p].clone()).collect::>(), + "policies": [hex::encode(&self.policy_bytes)], + "sources": [[{"content_hash": hex::encode(self.source.object.content_hash), "duration_ms": self.source.object.duration_ms}]], + }) + } + + /// The room's co-watch part, as a page reads it before it enters. + pub fn view(&self) -> Value { + json!({ + "policy": hex::encode(&self.policy_bytes), + "source": {"path": "/watch/source", "content_type": self.source.content_type, "objects": [{"content_hash": hex::encode(self.source.object.content_hash), "duration_ms": self.source.object.duration_ms}]}, + "log": self.log.len(), + "present": self.present(), + }) + } + + /// The latest skew statements, read, for the service's own state. + pub fn last_skews(&self) -> Value { + let last_from = self.skews.last().map(|s| s.from); + Value::Array( + self.skews + .iter() + .filter(|s| Some(s.from) == last_from) + .map(|s| { + json!({"from": s.from, "to": s.to, "viewers": s.viewers.iter().map(|v| json!({"principal": hex::encode(v.principal), "verdict": cw::verdict_name(v.verdict), "pq": v.pq, "counted": v.counted, "due": v.due})).collect::>()}) + }) + .collect(), + ) + } +} + +/// The payload a clock's `meta` record carries: a skew statement. +pub fn skew_payload(s: &Skew) -> Vec { + Statement::Skew(s.clone()).encode() +} + +/// A lifecycle detail's kind and payload for the clock (used by [`crate::epoch::Chain`]). +pub fn lifecycle_payload(subject: [u8; 32], d: &Detail) -> (u8, Vec) { + let k = d.kind(); + (k, Payload::Lifecycle { kind: k, subject, detail: d.encode() }.encode()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::epoch::{Chain, Epoch, Role, Settings}; + use crate::keys::Keys; + use wai::co_watch::{action, state, Control, Presentation}; + + fn source() -> Arc { + let bytes = vec![7u8; 1000]; + Arc::new(WatchSource { object: SourceObject { content_hash: Sha256::digest(&bytes).into(), duration_ms: 60_000 }, bytes: Arc::new(bytes), content_type: "audio/wav".into() }) + } + + fn settings(control: u8) -> Settings { + Settings { + max_publishers: 2, + max_present: 16, + publisher_grant_s: 600, + viewer_grant_s: 900, + join_wait_s: 60, + max_grants: 16, + e2ee: false, + age: None, + audience_input: 0, + latency_budget_ms: 0, + lead: crate::epoch::Lead::First, + ai: false, + watch: Some(source()), + watch_control: control, + watch_window_ms: 5_000, + } + } + + struct Page { + key: SigningKey, + seq: u64, + prev: [u8; 32], + } + + impl Page { + fn event(&mut self, room: [u8; 32], t: u64, payload: Vec) -> Vec { + let e = Event::sign(&self.key, room, self.seq, self.prev, t, None, kind::META, payload); + self.seq += 1; + self.prev = e.digest(); + e.to_bytes() + } + } + + /// A watch room as the service keeps it: two viewers enter by proof, post controls and + /// statements the service checks as §5 and §7.2 do, and the clock's skew statement of a + /// window verifies with every viewer's entry, as co-watch §11 verifies a room. + #[test] + fn a_watch_room_carries_what_verifies_and_states_its_skew() { + let keys = Keys::generate(); + let now_s = 1_800_000_000u64; + let t0 = now_s * 1_000_000; + let mut e = Epoch::new(&keys, settings(3), 1, t0); + let k = |i: u8| SigningKey::from_bytes(&[i; 32]); + let (a, b) = (k(1), k(2)); + e.grant(&keys, a.verifying_key().to_bytes(), Role::Viewer, now_s, None).unwrap(); + e.grant(&keys, b.verifying_key().to_bytes(), Role::Viewer, now_s, None).unwrap(); + let room = e.id; + let mut chain = Chain::default(); + let s = 1_000_000u64; + { + let w = e.watch.as_mut().expect("a watch room"); + // Each viewer proves its key over a challenge for the request it makes. + for v in [&a, &b] { + let rec = w.viewers[&v.verifying_key().to_bytes()].record.clone(); + let c = w.challenge(t0); + let digest: [u8; 32] = Sha256::digest(&rec[..rec.len() - 64]).into(); + let ctx = "POST //room.test/watch/join"; + let proof = wai::live_room::Proof::sign(digest, v, c, ctx).to_bytes(); + assert_eq!(w.admit(&rec, &proof, ctx, t0), Ok(v.verifying_key().to_bytes())); + assert!(w.admit(&rec, &proof, ctx, t0).is_err(), "a proof is good once"); + } + } + for v in [&a, &b] { + let p = v.verifying_key().to_bytes(); + let g = e.watch.as_ref().unwrap().viewers[&p].digest; + let what = Lifecycle::Join { grant: g }; + let rec = chain.sign(&keys, room, s, p, &what); + e.watch.as_mut().unwrap().clock_record(&rec, &Item::Life(p, what)); + } + let w = e.watch.as_mut().unwrap(); + let src = w.policy.source; + let mut pa = Page { key: a.clone(), seq: 0, prev: [0; 32] }; + let mut pb = Page { key: b.clone(), seq: 0, prev: [0; 32] }; + // A control from a viewer whose watch_control (3) allows play: carried; a seek it + // does not allow, and a play in the past, are not. + let play = Control { action: action::PLAY, at: 2 * s, arg: 0 }.encode(); + let r = pa.event(room, 1_200_000, play); + assert_eq!(w.post(&r, 1_200_000, t0), Ok(())); + assert_eq!(w.post(&r, 1_200_000, t0), Ok(()), "a record carried before is not carried twice"); + let mut probe = Page { key: a.clone(), seq: pa.seq, prev: pa.prev }; + assert_eq!(w.post(&probe.event(room, 1_300_000, Control { action: action::SEEK, at: 2 * s, arg: 5 }.encode()), 1_300_000, t0), Err("control_not_permitted")); + let mut probe = Page { key: a.clone(), seq: pa.seq, prev: pa.prev }; + assert_eq!(w.post(&probe.event(room, 1_300_000, Control { action: action::PAUSE, at: s, arg: 0 }.encode()), 1_300_000, t0), Err("control_not_ahead")); + // A statement made by another key is refused. + let mut probe = Page { key: b.clone(), seq: pb.seq, prev: pb.prev }; + let lifted = Presentation::sign(&a, room, src, s, 0, state::PAUSED, 1_000_000).encode(); + assert_eq!(w.post(&probe.event(room, 1_300_000, lifted), 1_300_000, t0), Err("presentation_wrong_key")); + // Both state their presentation twice a slot to 10 s, close to the room, from after + // the control. + let mut x = 1_300_000u64; + while x < 10 * s { + for (page, key, err) in [(&mut pa, &a, 10_000i64), (&mut pb, &b, -15_000)] { + let pos = w.facts().timeline.position_at(x) as i64 + err; + let st = Presentation::sign(key, room, src, x, pos.max(0) as u64, state::PLAYING, 1_000_000).encode(); + let rec = page.event(room, x + 20_000, st); + assert_eq!(w.post(&rec, x + 20_000, t0), Ok(())); + } + x += 500_000; + } + // The clock states the windows that have ended: in sync, both. + let st = w.due_statements(12 * s); + assert_eq!(st.len(), 2, "the windows to 5 s and to 10 s"); + let last = &st[1]; + assert!(last.viewers.iter().all(|v| v.verdict == cw::verdict::IN_SYNC), "{last:?}"); + for sk in &st { + let rec = chain.sign_event(&keys, room, 12 * s, kind::META, skew_payload(sk)); + w.clock_record(&rec, &Item::Meta(skew_payload(sk))); + } + // The room the log carries verifies as co-watch §11 verifies it. + let w = e.watch.as_ref().unwrap(); + let room_json = w.room_json(&e.descriptor, &e.grants); + let chains: Vec>> = room_json["chains"].as_array().unwrap().iter().map(|c| c.as_array().unwrap().iter().map(|h| hex::decode(h.as_str().unwrap()).unwrap()).collect()).collect(); + let run = cw::verify(&cw::Input { descriptor: &e.descriptor, grants: &e.grants, head: None, chains: &chains, policies: std::slice::from_ref(&w.policy_bytes), sources: &[Some(vec![w.source.object])] }); + assert_eq!(run.outcome, cw::Outcome::Accept); + assert_eq!(run.skews.len(), 2); + assert!(run.skews.iter().all(|s| s.recomputed.is_some())); + // A viewer silent for longer than SILENT_US has left. + let mut w = e.watch.take().unwrap(); + let ended = w.ended(t0 + 20 * s); + assert_eq!(ended.len(), 2); + assert!(ended.iter().all(|(_, why, _)| *why == leave::CONNECTION_LOST)); + assert!(w.ended(t0 + 21 * s).is_empty(), "a leave is asked once"); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs index 661ec1fd5..69f0abc42 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs +++ b/joule-code/joule-code-rs/crates/joule-moq-room/tests/room_service.rs @@ -422,3 +422,182 @@ async fn run_e2ee() { } server.shutdown().await; } + +/// A raw GET: the status and the body's bytes. +async fn get_bytes(addr: std::net::SocketAddr, path: &str) -> (u16, Vec) { + let mut s = tokio::net::TcpStream::connect(addr).await.unwrap(); + s.write_all(format!("GET {path} HTTP/1.1\r\nHost: room.test\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap(); + let mut out = Vec::new(); + timeout(STEP, s.read_to_end(&mut out)).await.expect("an answer in time").unwrap(); + let at = out.windows(4).position(|w| w == b"\r\n\r\n").expect("a response") + 4; + let head = String::from_utf8_lossy(&out[..at]).to_string(); + (head.split(' ').nth(1).unwrap().parse().unwrap(), out[at..].to_vec()) +} + +/// A watch room (WAI co-watch) against a relay on loopback: its source served and named by its +/// policy; viewers admitted by a proof of their key for the request they make, a proof over +/// another target or presented again refused; their controls and presentation statements carried +/// when co-watch §5 and §7.2 accept them, and one that does not refused; the clock's skew +/// statements of each window, and a viewer's leave once it falls silent; and the room the log +/// carries verifies as co-watch §11 verifies it. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_watch_room_carries_its_viewers_and_states_their_skew() { + timeout(Duration::from_secs(120), run_watch()).await.expect("the test finishes in time"); +} + +async fn run_watch() { + use wai::co_watch::{self as cw, action, state, Control, Presentation}; + let keys = Keys::generate(); + let server = relay(&keys).await; + let dir = std::env::temp_dir().join(format!("joule-moq-room-watch-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("source.wav"); + let bytes: Vec = (0..4096u32).map(|i| (i % 251) as u8).collect(); + std::fs::write(&file, &bytes).unwrap(); + let cfg = Config::from_toml(&format!( + "listen = \"127.0.0.1:0\"\nkeys = \"/nonexistent\"\nrelay_url = \"ws://{}{}\"\npublic_prefix = \"/room-api\"\n[room]\nwatch_source = \"{}\"\nwatch_duration_ms = 60000\nwatch_window_ms = 2000\n", + server.websocket_addr.unwrap(), + server.relay().config().path, + file.display() + )) + .unwrap(); + let svc = Service::new(Keys::from_toml(&keys.to_toml()).unwrap(), &cfg); + let listener = tokio::net::TcpListener::bind(cfg.listen).await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(svc.clone().run_clock()); + tokio::spawn(svc.clone().run_watch()); + tokio::spawn(http::serve(svc.clone(), listener)); + timeout(STEP, async { + while !svc.connected.load(Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .expect("the clock's session opens"); + + // The room names its source by the policy; the service serves those bytes. + let (_, r) = call(addr, "GET", "/room", None, "10.0.1.9").await; + let policy_bytes = hex::decode(r["watch"]["policy"].as_str().expect("a watch room")).unwrap(); + let policy = cw::WatchPolicy::parse(&policy_bytes).unwrap(); + let (st, served) = get_bytes(addr, "/watch/source").await; + assert_eq!((st, served.len()), (200, bytes.len())); + let object = cw::SourceObject { content_hash: Sha256::digest(&served).into(), duration_ms: 60_000 }; + assert_eq!(policy.source, cw::source_digest(&[object]), "the served bytes are the policy's source"); + + // Two viewers are granted, each with every action (watch_control 15). + let (a, b) = (key(0x51), key(0x52)); + let mut grants = Vec::new(); + for (k, ip) in [(&a, "10.0.1.1"), (&b, "10.0.1.2")] { + let (st, g) = ask(addr, k, "viewer", ip).await; + assert_eq!(st, 200, "{g}"); + grants.push(hex::decode(g["grant"].as_str().unwrap()).unwrap()); + } + let room = r["room"].as_str().unwrap().to_string(); + let room_id: [u8; 32] = hex::decode(&room).unwrap().try_into().unwrap(); + let t0 = r["t0_us"].as_u64().unwrap(); + let digest = |g: &[u8]| -> [u8; 32] { Sha256::digest(&g[..g.len() - 64]).into() }; + let join = |k: SigningKey, g: Vec, target: &'static str| { + let room = room.clone(); + async move { + let (_, c) = call(addr, "GET", "/watch/challenge", None, "10.0.1.9").await; + let c: [u8; 32] = hex::decode(c["challenge"].as_str().unwrap()).unwrap().try_into().unwrap(); + let proof = wai::live_room::Proof::sign(digest(&g), &k, c, &format!("POST {target}")).to_bytes(); + let body = serde_json::json!({"room": room, "grant": hex::encode(&g), "proof": hex::encode(&proof)}); + let first = call(addr, "POST", "/watch/join", Some(&body), "10.0.1.9").await; + let again = call(addr, "POST", "/watch/join", Some(&body), "10.0.1.9").await; + (first, again) + } + }; + // A proof made for another target is refused. + let ((st, e), _) = join(a.clone(), grants[0].clone(), "//room.test/watch/join").await; + assert_eq!((st, e["error"].as_str()), (401, Some("pop_wrong_context")), "the client addressed the service under its prefix"); + let mut joined = Vec::new(); + for (k, g) in [(&a, &grants[0]), (&b, &grants[1])] { + let ((st, j), (st2, j2)) = join((*k).clone(), g.clone(), "//room.test/room-api/watch/join").await; + assert_eq!(st, 200, "{j}"); + assert_eq!((st2, j2["error"].as_str()), (401, Some("pop_replayed")), "a proof is good once"); + joined.push(j["t"].as_u64().unwrap()); + } + + // Each viewer's chain, posted as a page posts it. + struct Page { + k: SigningKey, + seq: u64, + prev: [u8; 32], + } + let now_t = || (std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros() as u64).saturating_sub(t0); + let post = |p: &mut Page, t: u64, payload: Vec| { + let e = wai::live_events::Event::sign(&p.k, room_id, p.seq, p.prev, t, None, wai::live_events::kind::META, payload); + let body = serde_json::json!({"room": room, "events": [hex::encode(e.to_bytes())]}); + (e, body) + }; + let mut pages = [Page { k: a.clone(), seq: 0, prev: [0; 32] }, Page { k: b.clone(), seq: 0, prev: [0; 32] }]; + // A control in the past is refused, and the chain does not move. + let t = now_t().max(joined[0]); + let (_, body) = post(&mut pages[0], t, Control { action: action::PLAY, at: t.saturating_sub(1), arg: 0 }.encode()); + let (st, e) = call(addr, "POST", "/watch/post", Some(&body), "10.0.1.1").await; + assert_eq!((st, e["error"].as_str()), (409, Some("control_not_ahead"))); + // Viewer a plays the source 700 ms ahead. + let play_at = t + 700_000; + let (ev, body) = post(&mut pages[0], t, Control { action: action::PLAY, at: play_at, arg: 0 }.encode()); + let (st, e) = call(addr, "POST", "/watch/post", Some(&body), "10.0.1.1").await; + assert_eq!(st, 200, "{e}"); + pages[0].seq += 1; + pages[0].prev = ev.digest(); + // Both state their presentation every quarter second for five windows, near the room. + let end = now_t() + 10_000_000; + let mut k = 0i64; + while now_t() < end { + for (i, p) in pages.iter_mut().enumerate() { + let x = now_t().max(joined[i]); + let controlled = x.saturating_sub(play_at).min(60_000_000) * u64::from(x >= play_at); + let err = [12_000i64, -9_000, 20_000, -14_000][((k + i as i64) % 4) as usize]; + let pos = (controlled as i64 + err).max(0) as u64; + let s = Presentation::sign(&p.k, room_id, policy.source, x, pos, if x >= play_at { state::PLAYING } else { state::PAUSED }, 1_000_000); + let (ev, body) = post(p, x, s.encode()); + let (st, e) = call(addr, "POST", "/watch/post", Some(&body), "10.0.1.1").await; + assert_eq!(st, 200, "{e}"); + p.seq += 1; + p.prev = ev.digest(); + } + k += 1; + tokio::time::sleep(Duration::from_millis(250)).await; + } + // The clock states each window that ended; then, silent, both viewers leave. + let room_now = timeout(Duration::from_secs(30), async { + loop { + let (_, v) = call(addr, "GET", "/watch/room", None, "10.0.1.9").await; + let leaves = v["chains"][0].as_array().unwrap().iter().filter(|h| wai::live_events::Event::parse(&hex::decode(h.as_str().unwrap()).unwrap()).unwrap().kind == wai::live_events::kind::LEAVE).count(); + if leaves == 2 { + return v; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + }) + .await + .expect("the clock records both leaving"); + let hexes = |v: &Value| -> Vec> { v.as_array().unwrap().iter().map(|h| hex::decode(h.as_str().unwrap()).unwrap()).collect() }; + let chains: Vec>> = room_now["chains"].as_array().unwrap().iter().map(hexes).collect(); + let run = cw::verify(&cw::Input { + descriptor: &hex::decode(room_now["descriptor"].as_str().unwrap()).unwrap(), + grants: &hexes(&room_now["grants"]), + head: None, + chains: &chains, + policies: &hexes(&room_now["policies"]), + sources: &[Some(vec![object])], + }); + assert_eq!(run.outcome, cw::Outcome::Accept); + assert!(run.skews.len() >= 4, "the clock states every window: {}", run.skews.len()); + let checked: Vec<_> = run.skews.iter().filter_map(|s| s.recomputed.as_ref()).collect(); + assert_eq!(checked.len(), run.skews.len()); + // Every window both were present for whole, after the play settled, is stated in sync. + let settled = play_at + u64::from(policy.settle_ms) * 1000; + let full: Vec<_> = run.skews.iter().filter(|s| s.from >= settled && s.to <= end).collect(); + assert!(!full.is_empty(), "a window after the play settled"); + for s in full { + let (v, p) = s.recomputed.as_ref().unwrap(); + assert!(v.len() == 2 && v.iter().all(|x| x.verdict == cw::verdict::IN_SYNC) && p.iter().all(|x| x.verdict == cw::verdict::IN_SYNC), "{s:?}"); + } + assert_eq!(room_now["refused"]["control_not_ahead"].as_u64(), Some(1)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/README.md b/joule-code/joule-code-rs/crates/joule-moq-web/README.md index 1ea9c60d9..eaf27c707 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/README.md +++ b/joule-code/joule-code-rs/crates/joule-moq-web/README.md @@ -56,6 +56,7 @@ signer), which no page of this crate loads yet: the room page plays no ads. | Compose | The room's lifecycle (the clock's `join`, `publish`, `leave` events) replayed by `wai-live-web` gives the participant set; the `WCD1` descriptor and the set give the tiles, drawn on a canvas, re-laid out on each lifecycle event; the voices are mixed by the integer mixer, in blocks of 20 ms, into an AudioWorklet that plays them. | | Events | Every record verified (signature, chain, the grant's policy, through the room verifier); `caption` events shown with their origin (human, machine, machine-reviewed), the other kinds as a timeline. | | Admission by age | When the service's room admits by age (`GET /room`'s `age`, WAI age-assurance §5.6), its grant request is answered `401` with Privacy Pass challenges (RFC 9577): the page picks the least threshold at or above the room's age the challenges offer (or `age_threshold`), checks the challenge (RFC 9577 §2.1.3), obtains a token from the issuer by blind issuance (RFC 9578 §6, `web/room/privacy-pass.js`), at the request URL of the issuer's directory (RFC 9578 §4; `issuer_directory` names where the directories are), and asks again with it. A publisher carries the room's rating on every track it publishes (`WAI_RATING`, §5.7), and with `rate`, `rate_scheme` and `rate_value` its own rating of its video, signed in wasm (`ratingClaim`). A request the relay refuses `below_threshold` is shown as withheld, not as an error. | +| Watching together | When the service's room has a co-watch policy (`GET /room`'s `watch`, WAI `extensions/co-watch.md`), the page fetches the room's source and checks its bytes by the content hash the policy names, estimates the room clock against the service's, and enters the room's co-watch log by a proof of its key over the service's challenge for the request it makes, before it subscribes to the clock (`web/room/cowatch.js`). It follows the log, checks each control as §5 has it before it applies it at its instant, keeps the source at the controlled position by the catch-up rule (§9), signs a presentation statement twice a slot and posts it, and shows its own offset and the verdicts of the clock's last skew statements beside shared controls (play, pause, a seek, a rate). Its events in such a room are its co-watch statements alone. | | End-to-end encryption | When the service's room has a confidentiality record (`GET /room`'s `e2ee`), the page says so before anyone enters, offers no watching and no seeking back, checks the record against the descriptor (its room, authority and recording mode), and loads `joule_moq_web_e2ee`. It leaves a KeyPackage with the service before it publishes, then reads the room's log (`web/room/e2ee.js`): each lifecycle record checked as the clock's, each MLS message checked and applied by the wasm member; it creates the group or commits when the room calls on it, and applies what it made only when the log returns it. Each encoded frame is sealed under the page's epoch before its receipt is signed over the sealed bytes; nothing is sent before the page holds an epoch. Each object received is verified against its receipt, then opened through the receiving checks, and only then decoded; one sealed in an epoch the page has not reached waits, and one that does not open is counted with its reason. The page shows its epoch and the members of the group. Every key is in the wasm member's memory and nowhere else. | | Co-performance | When the service's room has a co-performance policy (`GET /room`'s `co`), the page verifies its audience's grants with its publishers', follows every present principal's events, and shows (`web/room/co.js`): the turn schedule and who holds the floor, the marks the clock signs, each participant's intents as upcoming lines and what became of them, the selection window open now with an input box for a viewer whose grant carries the audience-input power, each selection with its credits, and the clock's fidelity statement. A lead (the direct power) asks the room's clock for turns, a handover, a vote, prompts and a fidelity statement (`POST /co/...`, its grant with a proof over the service's challenge, live-rooms §10.5.3, signed in wasm: `grantProofHttp`); the turn's holder may hand it over. A performer states its media's timestamps on the room clock (an audio object's from the inputs its encoder took) and sends media only while it holds the floor, from a guard after its turn starts to a guard before it ends. `ai=scripted` makes the page a demo AI participant: a grant marking it one, a drawn picture and a tone for its media, and a scripted line announced ahead of each it says as a machine's caption. | @@ -91,6 +92,19 @@ closed. Delivery timeouts still apply at the relay, before a write. challenge, nonce, blind and salt it writes the vector's TokenRequest, and from the issuer's TokenResponse the vector's token; a response changed by a bit is refused; RFC 9577's header fields read and write. +- `node --test web/room/cowatch.test.mjs`: the page's co-watch module (`web/room/cowatch.js`, + WAI `extensions/co-watch.md`) against `wai/live-conformance/cowatch/`: every catch-up case + its correction, each accepted room's controlled position, and its controls and presentation + statements written and signed byte for byte. +- `node --test web/room/endpoint.test.mjs`: the check of the endpoints the room page takes + from its query string and from a relay's GOAWAY (`web/room/endpoint.js`): the page's own + origin alone (`https://`, `wss://` for a relay), and on a loopback page loopback + addresses alone; another origin refused in every form a link can write it (another host or + port, scheme-relative, the page's host as user information, `javascript:`, a plain scheme on + a secure page). +- `node --test web/room/markup.test.mjs`: the room page's markup, every element it opens closed + in order, and each mode's section holding its own controls: the Log, the binding table and + the co-watch section never inside the co-performance section, which a room without it hides. - `node --test web/ads/presentation.test.mjs`: the reference producer of WAI ad-presentation claims (`web/ads/presentation.js`, WAI `extensions/ad-presentation.md` §10): observation sequences recorded into spans, and claims written and signed from them, byte for byte as @@ -111,8 +125,11 @@ closed. Delivery timeouts still apply at the relay, before a write. | `service` | a room service keeps the room, as in production: the relay (WebSocket only, its door holding each publisher to its own key, trusting the service's authority) and the service behind a same-origin proxy; pages enter from their buttons with keys of their own. Two publish and see each other, every object verifies, the layout equals the host's, a machine caption and a typed human caption show their labels, the clock (not a page) authors the lifecycle, a late joiner gets earlier groups by FETCH, a participant seeks back eight seconds into the other's tracks from the relay's DVR window (the relay keeps one and a cache far smaller than the span; the dvr scenario's checks: the control enables, the window's video plays in its own view from its first key frame at the pace it was captured, every object verifies, nothing is unknown, the page returns to live and live video goes on decoding, the relay served it from its window), a viewer watches, a grant with no proof or with a proof by another key is refused the session, a publisher past the room's places is told it is full, and a leave is recorded | | `age` | a room that admits by age, its service configured with the age corpus's test issuers and rating the room 16, and a test issuer on loopback (`e2e/issuer.mjs`, over the corpus's published keys, signing blindly whatever it is sent): each page answers the challenge with a token it obtains by blind issuance and enters at its threshold; a performer at 18 rates its own video 18; a viewer at 16 is served the audio, verified and decoded, and refused the video (`below_threshold`), which the page shows withheld; a viewer at 18 is served both; a viewer with a token of 13 is refused by the service; a token presented a second time is refused (`token_replayed`); the service's admission receipt verifies under the room's clock and suppresses every count under 50 | | `co` | a room with co-performance kept by a room service, behind a relay that holds the floor and the audience's inputs: a lead, an AI participant (the scripted demo) and three audience members; turns started from the lead's controls, every page showing who holds the floor; a performer out of turn marked by the clock, and under a later schedule that refuses held back by the relay; the AI participant's lines shown ahead, one kept and one diverged; a late object marked; a vote and prompts from the page's input box, each input the rules refuse withheld by the relay with its reason and counted in its summaries; each window committed to and selected from, credited; the fidelity statement; and the room with the clock's log of objects verified (co-performance §9) the same in the page and on the host (`wai_live_web co-verify`) | + | `ageco` | a room that admits by age (rated 16, the age scenario's issuers) and keeps co-performance (the relay holding the floor): the lead at 18 rates its video 18, an AI participant at 18; the room's clock proves the room's keys in its SETUP and the relay takes its session for the room's own service (WAI age-assurance §5.5), so the clock logs every track of both performers, the video rated 18 among them, and marks an object played out of turn; a viewer at 16 is still served the audio and refused the video (`below_threshold`) | + | `origin` | the endpoints a link may name (`web/room/endpoint.js`): a link naming a relay on another origin (`?ws=`) is refused when the visitor presses Join, and nothing connects (no WebSocket, no request, no grant, no camera); a link naming a room service on another origin, written scheme-relative, behind the page's host as user information or as `javascript:`, is refused and Join stays off; a relay whose GOAWAY names another origin is refused, the session ends and the page says so. The `service` scenario checks besides that every request and WebSocket of every page stays on the page's own origin, as the production page's `connect-src` requires | | `binding` | the browser sink: `web/binding/` runs `live-conformance/browser`'s routes, constraints, bindings, frame states and side-table sequences through the page's wasm and JavaScript; decodes a `wai.video.int_motion` object on the wasm route after verifying it against its receipts, every frame bound and its SHA-256 the published one, a changed object refused and one with no receipt left unbound; carries the binding through `clone()`, `close()`, `new VideoFrame(frame)`, transfer to a worker and back, `copyTo()` and re-encode; binds the user agent's own VP9 decoder's frames through the side table and refuses a timestamp given twice; reports what the user agent keeps of an unregistered `VideoFrame` metadata member; builds a capability report from the user agent's answers, the interfaces' own record showing no codec string outside the registry put to them; and, where the host has a per-process energy counter (`e2e/energy.py`), reports a figure per decode measured around bursts of decodes beside `powerEfficient`. Then a two-party room with `?debug=binding`: each page's tile of the other shows a frame bound to an object that page verified, under its group receipt, as verified | | `e2ee` | an end-to-end encrypted room kept by a room service (`e2ee = true`), the relay keeping a DVR window: the page says so before anyone enters; two publishers key through the service's log (a GroupInfo, a commit, a Welcome), media flows both ways, every object verified against its receipt over the sealed bytes, opened, then decoded; the page shows its epoch and roster; a viewer, outside the group, receives the bytes exactly as sealed (no envelope, the AEAD's length) and opens nothing; every change a relay could make to an object is refused with the extension's reason (`bad_tag`, `key_id_missing`, `properties_malformed`, `object_id_too_large`, `key_id_unknown`, `sender_not_owner`, `replayed`) and the object opens once; an object changed on the wire is refused `bad_tag`; a late joiner's FETCH of earlier groups opens nothing sealed before its join (`epoch_not_joined`); a member that stops publishing and stays is removed by the next commit and opens nothing of the later epoch it still receives (`removed_before_epoch`), and an object it sealed before is refused `sender_removed`; the relay's DVR store keeps nothing of the room | + | `cowatch` | a watch room (WAI co-watch) kept by a room service (`watch_source`, a two-minute tone), behind the same-origin proxy: three viewers, each page fetching the source and checking it by its content hash and entering the co-watch log by a proof of its key; one plays, one pauses, one seeks, each control applied by every page at its instant; a viewer joins late and is brought to the room's position; in the windows after it, the clock's skew statements state every viewer and pair in sync within the 250 ms bound, as each page shows; no record or event is refused; and the room read back from the service verifies by `co_watch_verify.py`, every skew statement recomputed | With `--base ` the `service` scenario runs against a deployment instead: nothing is started, and the page at `/room/` is driven the same way (its production test). Its diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs index 312d24a5e..29063a5a8 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/lib.mjs @@ -282,3 +282,21 @@ export function serveSameOrigin(root, { service, relayPort }) { }))); }); } + +// A WAV file (PCM, 16-bit, mono) of `seconds` at `rate` Hz: a tone that steps in pitch each +// second, so any stretch of it is told from another by ear. The source a watch room's +// scenario watches. +export function writeWav(file, seconds, rate = 8000) { + const n = seconds * rate; + const b = Buffer.alloc(44 + n * 2); + b.write("RIFF", 0); b.writeUInt32LE(36 + n * 2, 4); b.write("WAVE", 8); + b.write("fmt ", 12); b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22); + b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate * 2, 28); b.writeUInt16LE(2, 32); b.writeUInt16LE(16, 34); + b.write("data", 36); b.writeUInt32LE(n * 2, 40); + for (let i = 0; i < n; i++) { + const f = 220 + 20 * (Math.floor(i / rate) % 12); + b.writeInt16LE(Math.round(6000 * Math.sin((2 * Math.PI * f * i) / rate)), 44 + i * 2); + } + fs.writeFileSync(file, b); + return { bytes: b.length, duration_ms: seconds * 1000 }; +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs index 4957710cd..72cede87b 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/room.e2e.mjs @@ -4,7 +4,9 @@ // // node e2e/room.e2e.mjs --relay --wai --web // --corpus [--browser ] -// [--only compose,webtransport,websocket,goaway,dvr,service,e2ee,age,co,binding] [--runs N] +// [--only compose,webtransport,websocket,goaway,dvr,service,e2ee,age,co,ageco,binding,cowatch, +// origin] +// [--runs N] // [--between ] [--out ] [--room ] [--base ] [--seek on|off] // [--wai-dir ] // @@ -80,6 +82,18 @@ // selection and credits shown; the lead asks for the fidelity statement; and the // room as a page holds it, with the clock's log of objects, verifies (accept) the // same in the page and on the host, every out-of-turn and late object marked. +// ageco a room that admits by age and keeps co-performance: the room's clock proves +// the room's keys and the relay serves its session as the room's own service +// (age-assurance §5.5): it logs both performers' tracks, the lead's video rated +// 18 among them, and marks an object out of turn; a viewer at 16 is still +// refused that video. +// origin the endpoints a link may name: a link naming a relay on another origin is +// refused when the visitor joins, and nothing connects (no WebSocket, no +// request, no grant, no camera); a room service on another origin, written +// scheme-relative, behind the page's host as user information or as javascript:, +// is refused and Join stays off; a relay whose GOAWAY names another origin is +// refused, the session ends and the page says so. And in the service scenario, +// every request and WebSocket of every page stays on the page's own origin. // binding the browser sink (WAI browser-sink): the page web/binding/ runs // live-conformance/browser through the page's wasm and side table, decodes a // wai.video.int_motion object on the wasm route after verifying it against @@ -93,6 +107,16 @@ // figure per decode is reported beside powerEfficient. Then a room with // `debug=binding`: every tile's frame is bound to an object its page // verified, under that object's receipt, and the page shows it verified. +// cowatch a watch room (WAI co-watch) kept by a room service: three viewers, each +// page fetching the source and checking it by the content hash the room's +// policy names, entering the room's co-watch log by a proof of its key; one +// plays, one pauses, one seeks, each control applied by every page at its +// instant; a viewer joins late and is brought to the room's position; every +// page states what it presents twice a slot, and the room clock's skew +// statements measure them: in the last windows every viewer and every pair is +// in sync within the policy's bound, as each page shows; and the room, read +// back from the service, verifies by co_watch_verify.py, the corpus's second +// implementation, every skew statement recomputed. // // With `--base ` the service scenario runs against a deployment (the page at // /room/, its service and relay behind the same origin): nothing is started here, @@ -105,7 +129,7 @@ import fs from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; import { execFileSync } from "node:child_process"; -import { serve, serveSameOrigin, startRelay, roomKeys, startRoomService, makeFixture, hostFrame, hostCoVerify, browserArgs, memguardedBrowser, killStrays, tmpdir, until, sleep, RUN_ID } from "./lib.mjs"; +import { serve, serveSameOrigin, startRelay, roomKeys, startRoomService, makeFixture, hostFrame, hostCoVerify, browserArgs, memguardedBrowser, killStrays, tmpdir, until, sleep, RUN_ID, writeWav } from "./lib.mjs"; import { startIssuer, testKeys } from "./issuer.mjs"; import crypto from "node:crypto"; @@ -115,7 +139,7 @@ const BASE = args.base ? args.base.replace(/\/$/, "") : null; const WAI = need("wai"); const [RELAY, WEB, CORPUS] = BASE ? [null, null, null] : [need("relay"), need("web"), need("corpus")]; const ROOM = args.room ? path.resolve(args.room) : null; -const ONLY = (args.only || (BASE ? "service" : `compose,webtransport,websocket,goaway,dvr${ROOM ? ",service,e2ee,age,co" : ""},binding`)).split(","); +const ONLY = (args.only || (BASE ? "service" : `compose,webtransport,websocket,goaway,dvr${ROOM ? ",service,e2ee,age,co,ageco,cowatch,origin" : ""},binding`)).split(","); // The wai/ directory: the browser sink's corpus and the int_video frame digests it points to. const WAI_DIR = args["wai-dir"] ? path.resolve(args["wai-dir"]) : (CORPUS ? path.resolve(path.dirname(CORPUS), "..", "..") : null); const RUNS = Number(args.runs || 1); @@ -167,6 +191,11 @@ async function page(browser, url, name, dir) { p.on("console", (m) => logs.push(`[${m.type()}] ${m.text()}`)); p.on("pageerror", (e) => logs.push(`[pageerror] ${e.stack || e}`)); p.on("response", (r) => { if (r.status() >= 400) logs.push(`[http ${r.status()}] ${r.url()}`); }); + // Every request and WebSocket the page makes, for the checks of where it connects. + p.requests = []; + p.sockets = []; + p.on("request", (r) => p.requests.push(r.url())); + p.on("websocket", (w) => p.sockets.push(w.url())); await p.goto(url, { waitUntil: "domcontentloaded", timeout: STEP }); p.dump = () => fs.writeFileSync(path.join(dir, `${name}.console.log`), logs.join("\n")); p.snap = () => p.evaluate(() => window.roomTest && window.roomTest.snapshot && window.roomTest.snapshot()); @@ -174,6 +203,14 @@ async function page(browser, url, name, dir) { return p; } +// The requests and WebSockets of `p` that leave `origin`, whose relay is the WebSocket of the +// same host (blob: and data: URLs stay in the page). +function offOrigin(p, origin) { + const own = new URL(origin); + const wsOwn = `${own.protocol === "https:" ? "wss:" : "ws:"}//${own.host}/`; + return [...p.requests, ...p.sockets].filter((u) => !/^(blob|data):/.test(u) && !u.startsWith(`${own.origin}/`) && !u.startsWith(wsOwn)); +} + async function waitPhase(p, phase) { return until(`${p.name} ${phase}`, STEP, async () => { const s = await p.snap(); @@ -461,6 +498,18 @@ async function scenarioGoaway(run) { // --------------------------------------------------------------------------------- service +// Where the page's sections are, as the browser parsed them: the Log, the binding table and the +// co-watch section are never inside the co-performance section, which a room without +// co-performance hides; and the Log shows. +async function markup(p) { + return p.evaluate(() => { + const co = document.getElementById("co"); + const inside = (id) => { const e = document.getElementById(id); return !!(co && e && co.contains(e)); }; + const shows = (id) => { const e = document.getElementById(id); return !!e && (e.checkVisibility ? e.checkVisibility() : e.getClientRects().length > 0); }; + return { co_hidden: !!(co && co.hidden), in_co: ["log", "binding-debug", "cowatch"].filter(inside), log_shows: shows("log"), cowatch_shows: shows("cowatch") }; + }); +} + async function scenarioService(run) { const label = BASE ? "production" : "service"; const dir = path.join(OUT, `run${run}-${label}`); @@ -493,6 +542,8 @@ async function scenarioService(run) { }; try { const A = await enter("alice", "#join", {}, "publishing"); + const m = await markup(A.p); + check(`${label}: in a room without co-performance, the Log, the binding table and the co-watch section are not inside the co-performance section, and the Log shows`, m.co_hidden && m.in_co.length === 0 && m.log_shows, JSON.stringify(m)); const B = await enter("bob", "#join", {}, "publishing"); const [alice, bob] = [A.p, B.p]; check(`${label}: both participants are in one room`, A.room === B.room, `${A.room.slice(0, 16)} / ${B.room.slice(0, 16)}`); @@ -592,6 +643,12 @@ async function scenarioService(run) { return s.timeline.some((e) => e.kind === "leave" && e.who === "room") && s.frame && !s.frame.tiles.some((x) => x.principal === A.principal) && s; }, 500); check(`${label}: a participant leaving is recorded by the room, and the others re-lay out`, true, `${left.frame.tiles.length} tile(s) after the leave`); + // The production page is served with `connect-src 'self' wss:// blob:`: every page + // here connects to its own origin alone. + const pageOrigin = new URL(base).origin; + const away = pages.flatMap((x) => offOrigin(x, pageOrigin)); + const nReq = pages.reduce((n, x) => n + x.requests.length, 0), nWs = pages.reduce((n, x) => n + x.sockets.length, 0); + check(`${label}: every request and WebSocket of every page stays on the page's own origin`, away.length === 0 && nWs > 0, away.length ? away.slice(0, 3).join(" ") : `${nReq} requests, ${nWs} WebSockets, all on ${pageOrigin}`); for (const p of pages) { const s = await p.snap(); @@ -1039,6 +1096,212 @@ async function scenarioAge(run) { } } +// ---------------------------------------------------------------------------------- origin + +// The endpoints a link may name: a relay, a room service or a GOAWAY's new session on another +// origin are refused, and the page connects to nothing there (./web/room/endpoint.js). +async function scenarioOrigin(run) { + const label = "origin"; + const dir = path.join(OUT, `run${run}-origin`); + fs.mkdirSync(dir, { recursive: true }); + if (!ROOM) throw new Error("--room is needed for the origin scenario"); + const k = roomKeys(ROOM, dir); + const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); + const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay"); + const svc = await startRoomService(ROOM, { keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 } }, dir); + const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); + // The fixture room of the GOAWAY step: its relay names a relay on another origin when it stops. + const { fixture: fx, file } = makeFixture(WAI, ["alice"], dir); + const away = "wss://relay.elsewhere.example/moq"; + const moving = await startRelay(RELAY, { auth: true, grants: fx.relay_grants, door: fx.door_rules, summary_out: path.join(dir, "summaries-goaway.jsonl"), goaway_uri: away, goaway_timeout_ms: 10000 }, dir, "relay-goaway"); + const fsrv = await serve(WEB, { "/fixture.json": file }); + const browser = await launch(dir); + const pages = []; + const open = async (url, name) => { + const p = await page(browser, url, name, dir); + pages.push(p); + return p; + }; + const statusOf = (p) => p.evaluate(() => document.getElementById("status").textContent); + try { + // A link that names a relay on another origin: the visitor presses Join, and the page + // refuses the relay before it asks for the camera, a grant or a session. + const foreign = "wss://relay.elsewhere.example/moq"; + const A = await open(`${srv.url}/room/?service=/room-api&ws=${encodeURIComponent(foreign)}`, "foreign-relay"); + await until(`${label} the page is ready`, STEP, () => A.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); + await sleep(1000); + const before = A.requests.length; + await A.click("#join"); + const sa = await until(`${label} the page refuses the link's relay`, STEP, async () => { const x = await A.snap(); return x.phase === "refused" && x; }, 200); + const said = await statusOf(A); + check(`${label}: a link naming a relay on another origin is refused when the visitor joins, and the page says so`, sa.refused.length === 1 && sa.refused[0].name === "ws" && sa.refused[0].value === foreign && /not this page's own/.test(said) && /connects to nothing/.test(said), said); + await sleep(1500); + const asked = A.requests.slice(before); + check(`${label}: nothing connects: no WebSocket, no request after the press, no grant, no session`, A.sockets.length === 0 && asked.length === 0 && !A.requests.some((u) => u.includes("/grant")) && sa.transport == null && sa.grant == null, `${A.sockets.length} WebSockets, ${asked.length} requests after the press${asked.length ? `: ${asked.slice(0, 3).join(" ")}` : ""}`); + const cam = await A.evaluate(() => !!(window.roomTest.state.capture && window.roomTest.state.capture.stream)); + check(`${label}: the camera and microphone are not opened`, !cam, `capture: ${cam}`); + + // A link that names a room service on another origin, in the forms a link can hide it in: + // the page takes none, and offers no Join. + for (const [form, value] of [["scheme-relative", "//service.elsewhere.example/room-api"], ["with the page's host as user information", `http://${new URL(srv.url).host}@service.elsewhere.example/room-api`], ["javascript:", "javascript:alert(document.domain)"]]) { + const B = await open(`${srv.url}/room/?service=${encodeURIComponent(value)}`, `foreign-service-${form.replace(/[^a-z]+/gi, "-").replace(/-$/, "")}`); + const sb = await until(`${label} the page refuses the ${form} service`, STEP, async () => { const x = await B.snap(); return x && x.phase === "refused" && x; }, 200); + const join = await B.evaluate(() => document.getElementById("join").disabled); + await sleep(500); + const off = offOrigin(B, srv.url); + check(`${label}: a link naming a room service on another origin (${form}) is refused, Join stays off, and nothing leaves the page's origin`, sb.refused.length === 1 && sb.refused[0].name === "service" && join && off.length === 0 && B.sockets.length === 0, `${value} — ${sb.refused[0] && sb.refused[0].reason}; ${off.length} requests away`); + } + + // A relay that stops with GOAWAY naming another origin: the page refuses to move there, + // ends its session and says so. + const u = new URL("/room/", fsrv.url); + u.searchParams.set("fixture", "/fixture.json"); + u.searchParams.set("wt", moving.wt); + u.searchParams.set("ws", moving.wsUrl); + u.searchParams.set("hashes", (moving.hashes || [moving.hash]).join(",")); + const uc = new URL(u); + uc.searchParams.set("role", "clock"); + const clock = await open(uc.toString(), "goaway-clock"); + await waitPhase(clock, "ready"); + u.searchParams.set("who", "alice"); + const C = await open(u.toString(), "goaway-elsewhere"); + await waitPhase(C, "publishing"); + moving.proc.kill("SIGINT"); + const sc = await until(`${label} the page refuses the GOAWAY`, STEP, async () => { const x = await C.snap(); return x.goaways.length && x.goaways[0].refused && x.phase === "ended" && x; }, 250); + await until(`${label} the clock's page refuses the GOAWAY too`, STEP, async () => { const x = await clock.snap(); return x.goaways.length && x.goaways[0].refused && x; }, 250); + const saidC = await statusOf(C); + check(`${label}: a GOAWAY naming another origin is refused, the session ends, and the page says so`, sc.goaways[0].uri === away && !sc.goaways[0].moved && /refused/.test(saidC), `${sc.goaways[0].uri}: ${sc.goaways[0].refused}; "${saidC.slice(0, 120)}"`); + check(`${label}: after the refused GOAWAY nothing connects elsewhere`, offOrigin(C, fsrv.url).filter((x) => !x.startsWith("ws://127.0.0.1") && !x.startsWith("https://127.0.0.1")).length === 0 && !C.sockets.some((x) => x.includes("elsewhere")), `${C.sockets.length} WebSockets: ${C.sockets.join(" ")}`); + const stopped = await moving.stop(null, 30000); + const line = moving.lines.find((l) => /stopped; \d+ session\(s\) closed at the GOAWAY timeout/.test(l)) || ""; + check(`${label}: the page closed its session itself, before the GOAWAY timeout`, Number((line.match(/stopped; (\d+)/) || [])[1]) === 0, `${line.trim()} (${JSON.stringify(stopped)})`); + for (const p of pages) { + const x = await p.snap(); + fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(x, null, 1)); + check(`${label}: ${p.name} reports no error`, x.errors.length === 0, x.errors.slice(0, 2).join(" | ")); + } + } finally { + for (const p of pages) { try { p.dump(); } catch { /* closed */ } } + const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]); + await bounded(browser.close()); + await bounded(srv.close()); + await bounded(fsrv.close()); + await bounded(svc.stop()); + await bounded(relay.stop()); + if (moving.proc.exitCode == null) await bounded(moving.stop("SIGKILL")); + killStrays(); + } +} + +// ------------------------------------------------------------ age assurance × co-performance + +// A room that admits by age and keeps co-performance. The room's clock follows and logs the +// performers' rated tracks over a session of its own, which holds no grant and no token: it +// proves the room's keys in its SETUP, and the relay serves it as the room's own service +// (WAI age-assurance §5.5), while a viewer stays held to the serving rule. +async function scenarioAgeCo(run) { + const label = "ageco"; + const dir = path.join(OUT, `run${run}-ageco`); + fs.mkdirSync(dir, { recursive: true }); + if (!ROOM) throw new Error("--room is needed for the ageco scenario"); + const keys = testKeys(path.join(path.dirname(CORPUS), "..", "age", "vectors.json")); + const issuer = await startIssuer(keys); + const k = roomKeys(ROOM, dir); + const door = ["audio", "video"].map((m) => ({ prefix: ["*", "*", m], trust_field: 1, hold_ms: 2000 })); + const relay = await startRelay(RELAY, { no_quic: true, auth: true, door, extra: `${k.lines}\n[floor]\nroom_keys = ["${k.clock}"]\n`, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000, max_ingress_kbps: 3000 } }, dir, "relay"); + const byIssuer = {}; + for (const x of keys) (byIssuer[x.issuer] = byIssuer[x.issuer] || []).push(`{ threshold = ${x.threshold}, spki = "${x.spki}" }`); + let age = "[age]\ntoken_window_s = 600\n"; + for (const [name, ks] of Object.entries(byIssuer)) age += `[[age.issuers]]\nname = "${name}"\nkeys = [${ks.join(", ")}]\n`; + age += `[[age.ratings]]\nscheme = "https://rating-a.example/scheme"\nvalue = "16"\nmin_age = 16\n`; + const svc = await startRoomService(ROOM, { + keys: k.keys, relayWs: relay.wsUrl, room: { max_publishers: 3 }, rate: { client_burst: 40, client_every_s: 1 }, + co: { audience_input: 2, latency_budget_ms: 1500, lead: '"first"', ai: true, fidelity_every_s: 0, commit_after_ms: 1200, lead_ms: 300 }, + top: { public_prefix: '"/room-api"' }, extra: age, + }, dir); + const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); + const base = `${srv.url}/room/?service=/room-api&issuer_directory=${encodeURIComponent(issuer.url)}`; + const url = (extra = {}) => { const u = new URL(base); for (const [kk, v] of Object.entries(extra)) u.searchParams.set(kk, v); return u.toString(); }; + const browser = await launch(dir); + const pages = []; + const enter = async (name, button, extra, phase) => { + const p = await page(browser, url(extra), name, dir); + pages.push(p); + await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); + await p.click(button); + const st = await until(`${name} ${phase}`, STEP, async () => { + const x = await p.snap(); + if (x && x.errors && x.errors.length) throw new Error(`${name}: ${x.errors[0]}`); + return x && x.phase === phase && x; + }, 200); + return { p, principal: st.me && st.me.principal, s: st }; + }; + const co = (p) => p.evaluate(() => window.roomTest.coState()); + try { + // The lead at 18, its own video rated 18; the AI participant at 18. + const A = await enter("lead", "#join", { age_threshold: "18", rate: "18", rate_scheme: "https://rating-a.example/scheme", rate_value: "18" }, "publishing"); + const B = await enter("ai", "#join", { ai: "scripted", script: "off", age_threshold: "18" }, "publishing"); + check(`${label}: both performers enter by a token of 18, in a room with co-performance`, A.s.age && A.s.age.admitted === 18 && B.s.age && B.s.age.admitted === 18 && !!(await co(A.p)), `admitted at ${A.s.age && A.s.age.admitted} and ${B.s.age && B.s.age.admitted}`); + const proved = relay.lines.filter((l) => l.includes("the session is the room's own service")).length; + check(`${label}: the room's clock proves the room's keys, and the relay takes its session for the room's own service`, proved >= 1, `${proved} room key(s) proved`); + // A viewer at 16: held to the serving rule, the video its performer rates 18 withheld. + const V16 = await enter("viewer16", "#watch", { age_threshold: "16" }, "watching"); + const s16 = await until(`${label} the viewer at 16 decodes the audio, the video withheld`, STEP * 2, async () => { + const x = await V16.p.snap(); + if (x.errors.length) throw new Error(x.errors[0]); + const t = x.subs[A.principal]; + return t && t.audio.decoded >= 30 && (t.withheld || []).includes("video") && x; + }, 500); + const t16 = s16.subs[A.principal]; + check(`${label}: a viewer at 16 is still held to the serving rule: the lead's audio served, its video rated 18 withheld`, t16.audio.verified >= 30 && t16.video.received === 0 && s16.age.withheld.some((w) => w.principal === A.principal && w.track === "video"), `${t16.audio.verified} audio verified, ${t16.video.received} video received`); + // The clock logs both performers' objects, the lead's video rated 18 among them. + const logged = await until(`${label} the clock logs every performer's tracks`, STEP * 2, async () => { + const r = await (await fetch(`${srv.url}/room-api/co/objects?from=0`)).json(); + const tracks = {}; + for (const o of r.objects || []) (tracks[o.principal] = tracks[o.principal] || new Set()).add(o.track); + const n = (P) => (tracks[P] ? tracks[P].size : 0); + return n(A.principal) >= 2 && n(B.principal) >= 2 && { objects: (r.objects || []).length, lead: n(A.principal), ai: n(B.principal) }; + }, 500); + check(`${label}: the room's clock logs the objects of both performers' tracks, the lead's video rated 18 among them`, logged.lead >= 2 && logged.ai >= 2, `${logged.objects} objects; tracks: lead ${logged.lead}, AI ${logged.ai}`); + // Turns from the lead's page; the other plays out of turn and the clock marks it, which it + // can only do holding the rated objects. + await A.p.fill("#co-turn-s", "5"); + await A.p.fill("#co-turns-n", "6"); + await A.p.selectOption("#co-mode", "mark"); + await A.p.click("#co-start"); + const sched = await until(`${label} both performers' pages hold the schedule`, STEP, async () => { + const ss = await Promise.all([A, B].map((x) => co(x.p))); + return ss.every((x) => x.schedules.length === 1) && ss[0].schedules[0]; + }, 300); + const holders = sched.segments[0].holders; + const P2 = holders[0] === A.principal ? B : A; + const seg = sched.segments[0]; + const waitRoom = async (p, x) => { for (;;) { const nn = await p.evaluate(() => window.roomTest.now()); if (nn >= x) return nn; await sleep(Math.min(500, (x - nn) / 1000)); } }; + const before = (await co(A.p)).marks.out_of_turn; + await waitRoom(A.p, seg.start + 2_000_000); + await P2.p.evaluate(() => window.roomTest.coPlayOutOfTurn(500)); + const marked = await until(`${label} an object out of turn is marked`, STEP, async () => { const c = await co(A.p); return c.marks.out_of_turn > before && c.marks; }, 300); + check(`${label}: a performer playing out of turn in a rated room is marked by the room's clock`, marked.out_of_turn > 0, `${marked.out_of_turn} out-of-turn mark(s)`); + const below = relay.lines.filter((l) => l.includes("below_threshold")).length; + check(`${label}: the relay refused below the threshold only where the rule holds`, below >= 1, `${below} refusal(s) logged`); + for (const p of pages) { + const x = await p.snap(); + fs.writeFileSync(path.join(dir, `${p.name}.snapshot.json`), JSON.stringify(x, null, 1)); + check(`${label}: ${p.name} reports no error`, x.errors.length === 0, x.errors.slice(0, 2).join(" | ")); + } + } finally { + for (const p of pages) { try { p.dump(); } catch { /* closed */ } } + const bounded = (x) => Promise.race([Promise.resolve(x).catch(() => {}), sleep(15000)]); + for (const p of pages) await bounded(p.evaluate(() => window.roomTest.leave && window.roomTest.leave())); + await bounded(browser.close()); + await bounded(srv.close()); + await bounded(svc.stop()); + await bounded(relay.stop()); + await bounded(issuer.close()); + killStrays(); + } +} + // -------------------------------------------------------------------------- co-performance async function scenarioCo(run) { @@ -1505,6 +1768,118 @@ async function bindingRoom(run, dir) { } } +// ------------------------------------------------------------------------------- co-watch + +// A watch room (WAI co-watch) kept by a room service, three viewers in one browser each. +async function scenarioCowatch(run) { + const label = "cowatch"; + const dir = path.join(OUT, `run${run}-cowatch`); + fs.mkdirSync(dir, { recursive: true }); + if (!ROOM) throw new Error("--room is needed for the cowatch scenario"); + const k = roomKeys(ROOM, dir); + const source = path.join(dir, "programme.wav"); + const wav = writeWav(source, 120); + const relay = await startRelay(RELAY, { no_quic: true, auth: true, door: [], extra: k.lines, summary_out: path.join(dir, "summaries.jsonl"), limits: { max_session_ms: 600000 } }, dir, "relay"); + const svc = await startRoomService(ROOM, { + keys: k.keys, relayWs: relay.wsUrl, + room: { watch_source: JSON.stringify(source), watch_duration_ms: wav.duration_ms, watch_control: 15, watch_window_ms: 5000 }, + top: { public_prefix: JSON.stringify("/room-api") }, + }, dir); + const srv = await serveSameOrigin(WEB, { service: svc.url, relayPort: relay.ws }); + const base = `${srv.url}/room/?service=/room-api`; + const browser = await launch(dir); + const pages = []; + const snap = async (p) => { const s = await p.snap(); if (s.errors.length) throw new Error(`${p.name}: ${s.errors[0]}`); return s; }; + // Each viewer a page of its own: three players, each with its own media element and clock. + const enter = async (name) => { + const p = await page(browser, base, name, dir); + pages.push(p); + await until(`${name}'s page ready`, STEP, () => p.evaluate(() => window.roomTest && window.roomTest.state && window.roomTest.state.phase === "idle"), 200); + await p.click("#watch"); + await waitPhase(p, "watching"); + const s = await until(`${name} joins the watch room`, STEP, async () => { const x = await snap(p); return x.cowatch && x.cowatch.joined != null && x; }, 200); + return { p, s }; + }; + const cws = async () => Promise.all(pages.map(async (p) => [p.name, (await snap(p)).cowatch])); + // Every page holds `n` controls, is past the last one's instant (`after`, µs of room + // clock), and agrees with the room within the bound. + const together = async (what, n, playing, after = 0) => until(what, STEP, async () => { + const all = await cws(); + const ok = all.every(([, c]) => c.controls >= n && c.room_now >= after && c.playing === playing && c.error_us != null && Math.abs(c.error_us) <= BOUND_US && !c.stopped); + return ok && all; + }, 250); + const BOUND_US = 250000; + try { + const A = await enter("ada"); + const m = await markup(A.p); + check(`${label}: the co-watch section shows, outside the co-performance section, and so does the Log`, m.co_hidden && m.in_co.length === 0 && m.cowatch_shows && m.log_shows, JSON.stringify(m)); + const B = await enter("ben"); + check(`${label}: each page fetched the source and checked it by the content hash the room's policy names`, A.s.cowatch.verified_source && B.s.cowatch.verified_source, `${wav.bytes} bytes, ${wav.duration_ms} ms`); + check(`${label}: each page entered the room's co-watch log by a proof of its key, and its clock is estimated against the room's`, A.s.cowatch.joined > 0 && B.s.cowatch.joined > 0, `joined at ${A.s.cowatch.joined} and ${B.s.cowatch.joined} µs of room clock; offsets ${A.s.cowatch.offset_us} and ${B.s.cowatch.offset_us} µs`); + // Ada plays: every page applies it at its instant. + const playAt = await A.p.evaluate(() => window.roomTest.watchControl("play")); + const t1 = await together(`${label}: both play from Ada's control`, 1, true, playAt); + check(`${label}: a play, sent ahead of its instant, starts both players together`, true, t1.map(([n, c]) => `${n} ${(c.error_us / 1000).toFixed(1)} ms`).join(", ") + `; effective at ${playAt}`); + await sleep(4000); + // Ben pauses. + const pauseAt = await B.p.evaluate(() => window.roomTest.watchControl("pause")); + const t2 = await together(`${label}: both pause on Ben's control`, 2, false, pauseAt); + const pos2 = t2.map(([, c]) => c.current_time); + check(`${label}: a pause stops both players at one position`, Math.abs(pos2[0] - pos2[1]) * 1e6 <= BOUND_US, pos2.map((x) => x.toFixed(3)).join(" s, ") + " s"); + await sleep(1500); + const againAt = await A.p.evaluate(() => window.roomTest.watchControl("play")); + await together(`${label}: both play again`, 3, true, againAt); + // Ben seeks to 60 s. + const seekAt = await B.p.evaluate(() => window.roomTest.watchControl("seek", 60e6)); + const t4 = await together(`${label}: both go to 60 s on Ben's seek`, 4, true, seekAt); + check(`${label}: a seek takes every player to the position it names`, t4.every(([, c]) => c.current_time >= 59.5 && c.current_time < 75), t4.map(([n, c]) => `${n} at ${c.current_time.toFixed(2)} s`).join(", ")); + await sleep(3000); + // Cleo joins late, and is brought to where the room is. + const C = await enter("cleo"); + const t5 = await together(`${label}: the late joiner is brought to the room's position`, 4, true); + const late = t5.find(([n]) => n === "cleo")[1]; + check(`${label}: a viewer that joins late reads the room's controls and seeks to the room's position`, late.seeks >= 1 && late.current_time >= 60, `cleo at ${late.current_time.toFixed(2)} s, ${(late.error_us / 1000).toFixed(1)} ms from the room, after ${late.seeks} seek(s)`); + // Two windows with all three present and nothing changing, then their skew statements. + const joinedC = C.s.cowatch.joined; + const target = Math.ceil((joinedC + 2_000_000) / 5_000_000) * 5_000_000 + 10_000_000; + const final = await until(`${label}: the clock states the windows after the late joiner settled`, STEP * 2, async () => { + const r = await fetch(`${srv.url}/room-api/watch/room`).then((x) => x.json()); + const lw = r.skews && r.skews.length && r.skews[0]; + return lw && lw.to >= target && r; + }, 500); + const lastWindow = final.skews; + const vs = lastWindow.flatMap((s) => s.viewers); + check(`${label}: the room clock's last window states all three viewers in sync within the bound`, vs.length === 3 && vs.every((v) => v.verdict === "in_sync" && v.pq <= BOUND_US && v.counted > 0 && v.counted === v.due), + vs.map((v) => `${v.principal.slice(0, 8)} ${v.verdict} p95 ${(v.pq / 1000).toFixed(1)} ms (${v.counted}/${v.due})`).join(", ")); + check(`${label}: the service carried every event the pages posted`, Object.keys(final.refused || {}).length === 0, JSON.stringify(final.refused)); + const shown = await until(`${label}: each page shows the room in sync`, STEP, async () => { + const all = await cws(); + return all.every(([, c]) => c.last_window && c.last_window.to >= target && c.dom && /in sync/.test(c.dom)) && all; + }, 500); + check(`${label}: each page shows its own offset and the room's verdicts`, shown.every(([, c]) => /^You: [+−]\d+ ms from the room · Room: all 3 in sync within 250 ms$/.test(c.dom)), shown.map(([n, c]) => `${n}: "${c.dom}"`).join("; ")); + const all = await cws(); + check(`${label}: no page refused a record of the log or had an event refused`, all.every(([, c]) => Object.keys(c.refused).length === 0 && Object.keys(c.post_refused).length === 0 && !c.stopped), + all.map(([n, c]) => `${n}: ${c.statements} statements, ${c.posted} carried, ${c.nudges} nudged, ${c.seeks} seeks`).join("; ")); + // The room as the service carried it, verified from the extension's text alone. + const roomFile = path.join(dir, "watch-room.json"); + fs.writeFileSync(roomFile, JSON.stringify(final)); + const verifier = path.resolve(WEB, "../../../../../wai/live-conformance/co_watch_verify.py"); + const v = JSON.parse(execFileSync("python3", ["-I", verifier, "--room", roomFile], { maxBuffer: 64 << 20 }).toString()); + const checked = v.skews || []; + const tail = checked.filter((x) => x.from >= target - 10_000_000 && x.viewers !== "unchecked"); + check(`${label}: the room verifies by co_watch_verify.py, every skew statement recomputed, the controls applied in their order`, + v.outcome === "accept" && checked.length > 0 && checked.every((x) => x.viewers !== "unchecked") && v.controls.length === 4 && tail.length > 0 && tail.every((x) => x.viewers.every((y) => y.verdict === "in_sync")), + `${v.outcome}${v.reason ? ` ${v.reason} at ${JSON.stringify(v.at)}` : ""}: ${checked.length} skew statements, ${v.presentations ? v.presentations.length : 0} presentation statements, ${v.controls ? v.controls.length : 0} controls`); + } finally { + for (const p of pages) p.dump(); + await browser.close().catch(() => {}); + await srv.close(); + await svc.stop(); + await relay.stop(); + killStrays(); + } +} + // ------------------------------------------------------------------------------------- main let passedRuns = 0; @@ -1517,7 +1892,7 @@ for (let run = 1; run <= RUNS; run++) { const before = failed; const t = Date.now(); console.log(`== run ${run} of ${RUNS}`); - for (const [name, f] of [["compose", scenarioCompose], ["webtransport", (r) => scenarioRoom(r, "webtransport")], ["websocket", (r) => scenarioRoom(r, "websocket")], ["goaway", scenarioGoaway], ["dvr", scenarioDvr], ["service", scenarioService], ["e2ee", scenarioE2ee], ["age", scenarioAge], ["co", scenarioCo], ["binding", scenarioBinding]]) { + for (const [name, f] of [["compose", scenarioCompose], ["webtransport", (r) => scenarioRoom(r, "webtransport")], ["websocket", (r) => scenarioRoom(r, "websocket")], ["goaway", scenarioGoaway], ["dvr", scenarioDvr], ["service", scenarioService], ["e2ee", scenarioE2ee], ["age", scenarioAge], ["co", scenarioCo], ["ageco", scenarioAgeCo], ["binding", scenarioBinding], ["cowatch", scenarioCowatch], ["origin", scenarioOrigin]]) { if (!ONLY.includes(name)) continue; try { await f(run); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh index 42fb8369b..75c826ba3 100755 --- a/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh +++ b/joule-code/joule-code-rs/crates/joule-moq-web/e2e/run.sh @@ -1,7 +1,7 @@ #!/bin/bash # Build what the browser MoQ client's tests need, and run them. # -# e2e/run.sh [--only compose,webtransport,websocket,goaway,dvr,service,e2ee,age,co,binding] [--runs N] +# e2e/run.sh [--only compose,webtransport,websocket,goaway,dvr,service,e2ee,age,co,ageco,binding,cowatch,origin] [--runs N] # # 1. joule-moq-relay, joule-moq-room (its own workspace) and wai_live_web (native, # release: the test room and the host's composition); @@ -69,7 +69,7 @@ fi BROWSER_BIN=${BROWSER_BIN:-} step "unit tests of the page's modules" -node --test "$CRATE/web/moq/capsule.test.mjs" "$CRATE/web/moq/media.test.mjs" "$CRATE/web/ads/presentation.test.mjs" "$CRATE/web/room/privacy-pass.test.mjs" "$CRATE/web/room/binding.test.mjs" +node --test "$CRATE/web/moq/capsule.test.mjs" "$CRATE/web/moq/media.test.mjs" "$CRATE/web/ads/presentation.test.mjs" "$CRATE/web/room/privacy-pass.test.mjs" "$CRATE/web/room/binding.test.mjs" "$CRATE/web/room/cowatch.test.mjs" "$CRATE/web/room/markup.test.mjs" "$CRATE/web/room/endpoint.test.mjs" step "end-to-end" browser=() diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js index 5d0013e4c..910fb2e52 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/app.js @@ -36,6 +36,14 @@ // debug=binding the state of the frame each tile shows (WAI browser-sink §10): its // binding, the receipts it verified under, its route and energy entry // +// The endpoints among them (`service`, `ws`, `wt`, `fixture`, `issuer_directory`), and the URI +// a relay's GOAWAY moves the session to, are taken only on the page's own origin +// (`https://`, or `wss://` for a relay); a page served from the loopback interface +// (a fixture page, the end-to-end harness) takes loopback addresses, and `hashes` only there +// (./endpoint.js). A link that names another origin is refused and shown, and the page +// connects to nothing: otherwise a visitor who joined from it would send their camera and +// microphone there. +// // A room that admits by age (WAI age-assurance §5.6; `GET /room`'s `age`): the service answers // a viewer's grant request `401` with Privacy Pass challenges (RFC 9577); the page obtains a // token from the issuer by blind issuance (RFC 9578 §6, ./privacy-pass.js) and asks again with @@ -63,11 +71,18 @@ // it an AI participant, publishes a drawn picture and a tone instead of a // camera and a microphone, and announces a line ahead of each it says, as // a caption a machine made; `script=off` leaves the lines to a test +// A watch room (WAI co-watch): the room service names its policy and its source (`GET /room`'s +// `watch`). The page fetches the source, checks its bytes by the content hash the policy +// names, enters the room's co-watch log by a proof of its key over the service's challenge, +// applies each control at its instant, keeps the source at the controlled position by the +// catch-up rule, states what it presents twice a slot, and shows the room's sync (./cowatch.js). +// The page's events in such a room are its co-watch statements: it sends no caption or chat. // // Everything a test reads is on `window.roomTest`. import initWai, * as wai from "../pkg/wai_live_web.js"; import { E2ee } from "./e2ee.js"; +import { CoWatch, ACTION } from "./cowatch.js"; import { connect } from "../moq/transport.js"; import { MoqSession, RequestRefused, withTimeout } from "../moq/session.js"; import * as media from "../moq/media.js"; @@ -75,6 +90,7 @@ import { Compositor } from "./compose.js"; import * as pp from "./privacy-pass.js"; import { Co, wireControls } from "./co.js"; import { SideTable, buildReport, askUserAgent, chainToken, stateOf, renderStates, metadataProbe, installFramePatches } from "./binding.js"; +import { checkEndpoint, isLoopback, kindOf } from "./endpoint.js"; // The codec's wasm: the plain build, or, for an end-to-end encrypted room, the build with // the member of the room's MLS group, loaded for such a room alone. @@ -89,6 +105,22 @@ async function loadMoq(sealed) { const q = new URLSearchParams(location.search); +// What each endpoint the query string may name is, for the page's words. +const ENDPOINT_NAMES = { service: "room service", ws: "relay", wt: "relay", fixture: "test room", issuer_directory: "Privacy Pass issuer directory" }; +// The endpoint the query string names as `name`, held to ./endpoint.js; `fallback` when it +// names none. One it names and the page refuses is recorded in `state.refused` and shown, and +// gives null: the caller then connects to nothing. +function queryEndpoint(name, kind, fallback = null) { + const v = q.get(name); + if (v == null || v === "") return fallback; + const c = checkEndpoint(v, kind, location.href); + if (c.ok) return c.url; + state.refused.push({ name, value: v, reason: c.reason }); + log(`refused the ${name} the link names: ${v} (${c.reason})`); + status(`This link names a ${ENDPOINT_NAMES[name] || name} that is not this page's own (${v}: ${c.reason}). The page does not use it, and connects to nothing.`); + return null; +} + // The browser sink (WAI browser-sink): each subscribed track is routed before it is decoded // (§4), every frame a video decoder outputs is bound to the object it came from in a side // table (§7, §9.2), and with `debug=binding` the page shows the state of the frame each @@ -129,6 +161,8 @@ const MISSING_AFTER_MS = 4000; // receipts with no object after this: accounted const state = { phase: "loading", errors: [], + // Endpoints the query string named and the page refused (./endpoint.js). + refused: [], transport: null, published: { audio: 0, video: 0, events: 0, bytes: 0 }, publishCodec: {}, @@ -243,7 +277,9 @@ class EventsTrack { // --------------------------------------------------------------------------------- room -let fx, me, roomId, t0, room, compositor, session, transportOpts, eventsTrack, service, e2ee, co; +let fx, me, roomId, t0, room, compositor, session, transportOpts, eventsTrack, service, e2ee, co, cowatch; +// Where Privacy Pass issuers' directories are, when the link names it (`issuer_directory`). +let issuerDirectory = null; // The service's clock less this page's, in µs, from `GET /room`'s `now_us`: room-clock instants // this page states are the service's. let skew = 0; @@ -285,15 +321,24 @@ function readGrants() { async function boot() { await loadMoq(false); await initWai(); - fx = await (await fetch(q.get("fixture"))).json(); + const fixture = queryEndpoint("fixture", "http"); + const wt = queryEndpoint("wt", "http"); + const ws = queryEndpoint("ws", "ws"); + if (state.refused.length || !fixture) { + state.phase = "refused"; + if (!fixture && !state.refused.length) status("This page needs a room service or a test room."); + return; + } + fx = await (await fetch(fixture)).json(); const role = q.get("role") || "participant"; me = role === "clock" ? { name: "clock", ...fx.clock } : fx.participants.find((p) => p.name === q.get("who")); if (!me) throw new Error(`no participant "${q.get("who")}" in the fixture`); state.mode = "fixture"; transportOpts = { - webtransport: q.get("wt"), - websocket: q.get("ws"), - hashes: (q.get("hashes") || "").split(",").filter(Boolean).map(unhex), + webtransport: wt, + websocket: ws, + // Certificate hashes let a session trust a development certificate: a loopback page's alone. + hashes: isLoopback(location.hostname) ? (q.get("hashes") || "").split(",").filter(Boolean).map(unhex) : [], force: q.get("transport") || "", }; window.roomTest.role = role; @@ -329,7 +374,7 @@ async function obtainToken(r, age) { if (!usable.length) throw new Error(`the room admits at ${age.age} or above, and none of its ${offered.length} challenges is one this page can answer`); const o = usable[0]; status(`This room is rated ${age.age}. Asking ${o.c.issuerName} for a token of ${o.threshold} or over…`); - const token = await pp.issue({ challenge: o.challenge, tokenKey: o.tokenKey, directory: pp.directoryOf(o.c.issuerName, q.get("issuer_directory")) }); + const token = await pp.issue({ challenge: o.challenge, tokenKey: o.tokenKey, directory: pp.directoryOf(o.c.issuerName, issuerDirectory) }); return { token, threshold: o.threshold, issuer: o.c.issuerName }; } @@ -338,6 +383,14 @@ const ROLE_OF = { publisher: "participant", viewer: "viewer" }; // Enter the room the service keeps: as a publisher (camera and microphone) or a viewer. async function enter(role) { if (state.mode) throw new Error("this page has entered already"); + // The relay and the issuer directory a link may name, held to the page's own origin before + // anything is asked of the camera, the room service or a relay. + const ws = queryEndpoint("ws", "ws", `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/moq`); + issuerDirectory = queryEndpoint("issuer_directory", "http"); + if (state.refused.length) { + state.phase = "refused"; + return state.phase; + } state.mode = "service"; state.phase = "entering"; const asked = Date.now(); @@ -428,11 +481,15 @@ async function enter(role) { status("Leaving a key package with the room…"); if (role === "publisher") await e2ee.keyPackage(); } - const ws = q.get("ws") || `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/moq`; transportOpts = { webtransport: null, websocket: ws, hashes: [], force: "websocket" }; window.roomTest.role = ROLE_OF[role]; + // A watch room's page enters its co-watch log first: the clock's `join` for it is then on the + // clock's events track, which the page subscribes to next, and which a room of viewers alone + // has no other record on yet. + if (v.watch) await startCoWatch(v, r.body, seed, principal); await begin(ROLE_OF[role], stream); pollRoom(); + if (cowatch) runCoWatch(); if (e2ee) { e2ee.follow(() => state.phase !== "left").catch(fail); status(role === "publisher" ? "You are in the room. It is end-to-end encrypted: your media is sealed before it leaves this page, and sent once the room's group has keyed you." : "Watching. This room is end-to-end encrypted, and a viewer is not a member of its group: it opens nothing."); @@ -509,7 +566,8 @@ async function openSession(opts) { // The session ended: by this page, by a move after GOAWAY, or by the relay (its session // limit, the grant's expiry, a refusal). function onClosed(s, info) { - if (s !== session || state.goaways.some((g) => !g.moved)) return; + if (s !== session || state.goaways.some((g) => !g.moved && !g.refused)) return; + if (state.goaways.some((g) => g.refused)) return; // ended by this page, and said so state.ended = { code: info && info.code, reason: info && info.reason }; if (state.phase === "left") return; state.phase = "ended"; @@ -519,6 +577,70 @@ function onClosed(s, info) { // Leave: the session closes, and the relay ends this page's tracks, which the room's clock // records as the publisher leaving. +// ------------------------------------------------------------------------------- co-watch + +// Whether `principal` may control `action` (co-watch §5.2): the direct power, or the action's +// bit of its grant's `watch_control`. +function mayControl(principal, action) { + const g = (state.grants || []).find((x) => x.principal === principal); + if (!g) return false; + if (g.powers & 2) return true; + const a = g.attrs.find((x) => x[0] === "watch_control"); + return !!a && /^[1-9][0-9]?$/.test(a[1]) && Number(a[1]) <= 15 && ((Number(a[1]) >> (action - 1)) & 1) === 1; +} + +async function startCoWatch(v, granted, seed, principal) { + status("Fetching what the room watches…"); + const base = new URL(service, location.href); + cowatch = new CoWatch({ + api, wai, room: v.room, t0: v.t0_us, seed, principal, grant: granted.grant, + media: $("cowatch-media"), policies: [v.watch.policy], objects: v.watch.source.objects, + sourceUrl: `${service}${v.watch.source.path}`, + mayControl: (p, a) => mayControl(p, a), + // The proof names what this page addresses: the service under its prefix (live-rooms §10.5.1). + target: `//${location.host}${base.pathname.replace(/\/$/, "")}/watch/join`, + log, onChange: renderCoWatch, + }); + if ($("cowatch")) $("cowatch").hidden = false; + if ($("caption-form")) $("caption-form").hidden = true; + await cowatch.estimateClock(); + await cowatch.loadSource(); + await cowatch.join(); +} + +function runCoWatch() { + const principal = me.principal; + const alive = () => state.phase !== "left"; + cowatch.follow(alive).catch(fail); + cowatch.run(alive).catch(fail); + for (const [id, a] of [["cw-play", ACTION.play], ["cw-pause", ACTION.pause], ["cw-back", ACTION.seek], ["cw-fwd", ACTION.seek], ["cw-rate", ACTION.rate]]) { + if ($(id)) $(id).disabled = !mayControl(principal, a); + } + status("Watching together. The room keeps every viewer at one position, and measures how close."); +} + +function watchControl(name, arg) { + if (!cowatch) throw new Error("not a watch room"); + return cowatch.control(ACTION[name], arg); +} + +function renderCoWatch() { + const el = $("cw-sync"); + if (!el || !cowatch) return; + const s = cowatch.snapshot(); + const ms = (us) => `${us >= 0 ? "+" : "−"}${Math.abs(us / 1000).toFixed(0)} ms`; + const mine = s.error_us == null ? "" : `You: ${ms(s.error_us)} from the room`; + let room = "Room: no window measured yet"; + if (s.last_window) { + const n = s.last_window.viewers.length; + const unknown = s.last_window.viewers.filter((x) => x.verdict === "unknown").length; + const out = s.last_window.viewers.filter((x) => x.verdict === "out_of_sync").length; + const bound = `${s.last_window.bound_us / 1000} ms`; + room = out ? `Room: ${out} of ${n} out of sync (bound ${bound})` : unknown ? `Room: ${n - unknown} of ${n} in sync within ${bound}, ${unknown} unknown` : `Room: all ${n} in sync within ${bound}`; + } + el.textContent = [mine, room].filter(Boolean).join(" · "); +} + async function leave() { state.phase = "left"; if (state.capture && state.capture.stream) for (const t of state.capture.stream.getTracks()) t.stop(); @@ -1605,8 +1727,20 @@ async function onGoaway(old, g) { try { const opts = { ...transportOpts }; if (g.uri) { - if (g.uri.startsWith("https://")) { opts.webtransport = g.uri; opts.force = "webtransport"; } - else if (g.uri.startsWith("ws")) { opts.websocket = g.uri; opts.force = "websocket"; } + // The session moves only within this page's own origin (or, on a loopback page, to a + // loopback address): a GOAWAY naming another is refused, and the session ends. + const c = checkEndpoint(g.uri, kindOf(g.uri), location.href); + if (!c.ok) { + state.goaways[state.goaways.length - 1].refused = c.reason; + state.ended = { code: 0, reason: "GOAWAY to another origin refused" }; + state.phase = "ended"; + log(`refused to move after GOAWAY to ${g.uri}: ${c.reason}`); + status(`The relay asked this page to move its session to ${g.uri}, which is not this page's own (${c.reason}). The page refused, and its session has ended. Reload the page to enter again.`); + old.close(0, "GOAWAY to another origin refused"); + return; + } + if (c.url.startsWith("https://")) { opts.webtransport = c.url; opts.force = "webtransport"; } + else if (c.url.startsWith("ws")) { opts.websocket = c.url; opts.force = "websocket"; } } transportOpts = opts; const s = await openSession(opts); @@ -1656,18 +1790,21 @@ function renderCaption() { // A participant's own events: captions and chats, signed with its key. window.roomTest.sendCaption = async ({ text, origin = 1, model = null, lang = "en", duration_ms = 3000 }) => { + if (cowatch) throw new Error("a watch room's page sends its co-watch statements alone"); const p = wai.captionPayload(duration_ms, origin, model || undefined, lang, text); const r = await eventsTrack.emit(0x0a, p); await (records = records.then(() => onRecord(r.record))); return r.view; }; window.roomTest.sendChat = async (text) => { + if (cowatch) throw new Error("a watch room's page sends its co-watch statements alone"); const r = await eventsTrack.emit(0x03, wai.chatPayload(text)); await (records = records.then(() => onRecord(r.record))); return r.view; }; window.roomTest.snapshot = () => ({ phase: state.phase, + refused: state.refused, mode: state.mode, me: me && { principal: me.principal, role: me.role || window.roomTest.role }, room: fx && fx.room, @@ -1698,7 +1835,11 @@ window.roomTest.snapshot = () => ({ e2ee: e2ee && { ...state.e2ee, ...e2ee.stats, at: e2ee.at, stopped: e2ee.stopped, member: e2ee.state(), dom: $("e2ee") && !$("e2ee").hidden ? $("e2ee").textContent : null }, age: state.age, co: co && co.snapshot(), + cowatch: cowatch && { ...cowatch.snapshot(), dom: $("cw-sync") ? $("cw-sync").textContent : null }, }); +// A shared control of a watch room (co-watch §5): `play`, `pause`, `seek` (to, µs) or `rate` +// (millionths), at the policy's lead from now. Resolves with the instant it takes effect. +window.roomTest.watchControl = (name, arg = 0) => watchControl(name, arg); // A participant's grant presented as a bare token, with no proof: the relay must refuse // the session (live-rooms §10.5.2, UNAUTHORIZED). Resolves with how the session ended. window.roomTest.tryBareGrant = async () => { @@ -1826,8 +1967,12 @@ async function sendTypedCaption(e) { try { await window.roomTest.sendCaption({ text, origin: 1, lang: document.documentElement.lang || "en" }); } catch (err) { fail(err); } } -service = q.get("service") || ($("room-app") && $("room-app").dataset.service) || ""; -if (service) { +// The room service: the link's, held to the page's own origin, or the page's own markup's. +service = queryEndpoint("service", "http") || (q.get("service") ? "" : ($("room-app") && $("room-app").dataset.service) || ""); +if (state.refused.length) { + state.phase = "refused"; + for (const id of ["join", "watch"]) if ($(id)) $(id).disabled = true; +} else if (service) { window.roomTest.enter = (role) => enter(role); window.roomTest.leave = leave; const buttons = ["join", "watch"].map($).filter(Boolean); @@ -1835,6 +1980,7 @@ if (service) { for (const b of buttons) b.disabled = true; try { const phase = await enter(role); + if (phase === "refused") return; if (phase === "full") for (const b of buttons) b.disabled = b.id === "join"; else if ($("leave")) $("leave").hidden = false; if (phase === "publishing" && $("caption-form")) $("caption-form").hidden = false; @@ -1848,6 +1994,14 @@ if (service) { if ($("watch")) $("watch").addEventListener("click", go("viewer")); if ($("leave")) $("leave").addEventListener("click", () => { $("leave").disabled = true; if ($("caption-form")) $("caption-form").hidden = true; leave(); }); if ($("caption-form")) $("caption-form").addEventListener("submit", sendTypedCaption); + // A watch room's shared controls, each at the room's lead (co-watch §5.3). + const cw = (fn) => () => { try { fn(); } catch (e) { fail(e); } }; + const at = () => (cowatch ? cowatch.timeline.positionAt(cowatch.now()) : 0); + if ($("cw-play")) $("cw-play").addEventListener("click", cw(() => watchControl("play"))); + if ($("cw-pause")) $("cw-pause").addEventListener("click", cw(() => watchControl("pause"))); + if ($("cw-back")) $("cw-back").addEventListener("click", cw(() => watchControl("seek", Math.max(0, at() - 10e6)))); + if ($("cw-fwd")) $("cw-fwd").addEventListener("click", cw(() => watchControl("seek", Math.min(cowatch.policy.duration_us, at() + 10e6)))); + if ($("cw-rate")) $("cw-rate").addEventListener("change", cw(() => watchControl("rate", Number($("cw-rate").value)))); // Before entering: an end-to-end encrypted room says so, and offers no watching, since a // viewer is not a member of its group and opens nothing, and no seeking back. api("/room").then((r) => { diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch.js new file mode 100644 index 000000000..2faffb6bb --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch.js @@ -0,0 +1,513 @@ +// Watching together in a room (WAI extensions/co-watch.md), in a page. +// +// A watch room's service serves the source and carries its viewers' events in a log +// (co-watch §10). This module is the page's part, in two halves: +// +// - the rules, with nothing of the page: the policy read (§3.1), controls and presentation +// statements written byte for byte (§5.1, §7.1), statements read (§4.2), the controlled +// position (§5.4, §6), the checks a control meets before a player applies it (§5.2, §5.3), +// the catch-up rule (§9), and the clock estimate (§7.3). Its tests (cowatch.test.mjs) run +// them against the corpus wai/live-conformance/cowatch/; +// - CoWatch, the player: it enters the room by proving its grant's key over the service's +// challenge (live-rooms §10.5.3), follows the log, applies each control at its instant on +// its estimate of the room clock, keeps a media element at the controlled position by the +// catch-up rule, signs a presentation statement twice a slot under the page's key, and shows +// the room's sync: its own error, and each viewer's verdict from the clock's last skew +// statement. +// +// Everything here is the platform's: WebCrypto's SHA-256 and Ed25519, an HTMLMediaElement. + +const subtle = globalThis.crypto.subtle; +const enc = new TextEncoder(); + +export const NORMAL = 1000000; +export const ACTION = { play: 1, pause: 2, seek: 3, rate: 4 }; +export const ACTION_NAME = { 1: "play", 2: "pause", 3: "seek", 4: "rate" }; +export const STATE = { playing: 1, paused: 2, stalled: 3, ended: 4 }; +export const VERDICT = { 1: "in_sync", 2: "out_of_sync", 3: "unknown" }; +const MAGIC = { policy: "WXP1", control: "WXC1", presentation: "WXS1", skew: "WXK1" }; +const PRESENTATION_DOMAIN = enc.encode("wai:co-watch-presentation\x01"); +const PROOF_DOMAIN = enc.encode("wai:live-room-proof\x01"); +const META = 0x01; + +export const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); +export const unhex = (h) => new Uint8Array((h.match(/../g) || []).map((x) => parseInt(x, 16))); + +export function concat(...parts) { + const n = parts.reduce((a, p) => a + p.length, 0); + const out = new Uint8Array(n); + let o = 0; + for (const p of parts) { out.set(p, o); o += p.length; } + return out; +} + +export async function sha256(...parts) { + return new Uint8Array(await subtle.digest("SHA-256", concat(...parts))); +} + +const le = (n, bytes) => { + const out = new Uint8Array(bytes); + let v = BigInt(n); + for (let i = 0; i < bytes; i++) { out[i] = Number(v & 0xffn); v >>= 8n; } + return out; +}; + +class Rd { + constructor(b, p = 0) { this.b = b; this.p = p; } + take(n) { if (this.p + n > this.b.length) throw new Error("short"); const s = this.b.subarray(this.p, this.p + n); this.p += n; return s; } + u8() { return this.take(1)[0]; } + uint(n) { const s = this.take(n); let v = 0n; for (let i = n - 1; i >= 0; i--) v = (v << 8n) | BigInt(s[i]); return v; } + u16() { return Number(this.uint(2)); } + u32() { return Number(this.uint(4)); } + u64() { return Number(this.uint(8)); } // every instant, position and rate here is below 2^53 + done() { return this.p === this.b.length; } +} + +// ---------------------------------------------------------------- §3 the policy + +/** A co-watch policy's fields (§3.1); its signature is the room's to check. */ +export function readPolicy(b) { + if (new TextDecoder().decode(b.subarray(0, 4)) !== MAGIC.policy || b[4] !== 1) throw new Error("not a WXP1 policy"); + const r = new Rd(b, 5); + const p = { room: hex(r.take(32)), prev: hex(r.take(32)), effective: r.u64(), source_type: r.u8(), source: hex(r.take(32)), duration_us: r.u64() }; + for (const k of ["lead_ms", "horizon_ms", "min_rate", "max_rate", "period_ms", "window_ms", "settle_ms", "bound_us"]) p[k] = r.u32(); + p.quantile = r.u8(); + for (const k of ["tolerance_us", "nudge", "seek_us"]) p[k] = r.u32(); + r.take(64); + if (!r.done()) throw new Error("bytes after the policy"); + return p; +} + +/** The policy in effect at `t` (§3.4): the last whose `effective` is at or below it. */ +export function policyAt(policies, t) { + let k = null; + policies.forEach((p, i) => { if (p.effective <= t) k = i; }); + return k; +} + +/** An object sequence's digest (§3.5). */ +export async function sourceDigest(objects) { + return sha256(enc.encode("wai:co-watch-source\x01"), le(objects.length, 4), ...objects.flatMap((o) => [unhex(o.content_hash), le(o.duration_ms, 4)])); +} + +// ---------------------------------------------------------------- §5, §7 statements + +/** A control (§5.1), 22 bytes. */ +export function controlPayload(c) { + return concat(enc.encode(MAGIC.control), Uint8Array.of(1, c.action), le(c.at, 8), le(c.arg, 8)); +} + +/** A presentation statement's record from `magic` through `rate` (§7.1). */ +export function presentationBody(p) { + return concat(enc.encode(MAGIC.presentation), Uint8Array.of(1), unhex(p.room), unhex(p.player), unhex(p.source), le(p.at, 8), le(p.position, 8), Uint8Array.of(p.state), le(p.rate, 4)); +} + +/** The statement, signed with Ed25519 under the player's key (§7.1): 186 bytes. */ +export async function signPresentation(p, privateKey) { + const body = presentationBody(p); + const sig = new Uint8Array(await subtle.sign({ name: "Ed25519" }, privateKey, concat(PRESENTATION_DOMAIN, body))); + return concat(body, sig); +} + +/** WebCrypto's key for a 32-byte Ed25519 seed (RFC 8410's PKCS #8 form), and its public key. */ +export async function keyFromSeed(seed) { + const pkcs8 = concat(Uint8Array.of(0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20), seed); + const privateKey = await subtle.importKey("pkcs8", pkcs8, { name: "Ed25519" }, true, ["sign"]); + const jwk = await subtle.exportKey("jwk", privateKey); + const publicKey = Uint8Array.from(atob(jwk.x.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)); + return { privateKey, publicKey }; +} + +/** A statement of this extension (§4.1, §4.2) read, or null for another `meta` payload; a + * statement that does not read is `{ kind: "malformed" }`. */ +export function readStatement(payload) { + const magic = new TextDecoder().decode(payload.subarray(0, 4)); + if (![MAGIC.control, MAGIC.presentation, MAGIC.skew].includes(magic)) return null; + try { + if (payload[4] !== 1) throw new Error("version"); + const r = new Rd(payload, 5); + let s; + if (magic === MAGIC.control) { + s = { kind: "control", action: r.u8(), at: r.u64(), arg: r.u64() }; + const ok = s.action === 1 || s.action === 2 ? s.arg === 0 : s.action === 3 ? true : s.action === 4 ? s.arg >= 1 && s.arg <= 0xffffffff : false; + if (!ok) throw new Error("arg"); + } else if (magic === MAGIC.presentation) { + s = { kind: "presentation", room: hex(r.take(32)), player: hex(r.take(32)), source: hex(r.take(32)), at: r.u64(), position: r.u64(), state: r.u8(), rate: r.u32() }; + s.sig = r.take(64); + if (!(s.state in { 1: 1, 2: 1, 3: 1, 4: 1 }) || s.rate === 0) throw new Error("state"); + } else { + s = { kind: "skew", from: r.u64(), to: r.u64(), bound_us: r.u32(), quantile: r.u8(), viewers: [], pairs: [] }; + const nv = r.u8(); + for (let i = 0; i < nv; i++) { + s.viewers.push({ principal: hex(r.take(32)), held: r.u64(), due: r.u16(), samples: r.u16(), counted: r.u16(), p50: r.u32(), pq: r.u32(), max: r.u32(), verdict: VERDICT[r.u8()] }); + } + const np = r.u8(); + for (let i = 0; i < np; i++) s.pairs.push({ a: r.u8(), b: r.u8(), n: r.u16(), p50: r.u32(), pq: r.u32(), max: r.u32(), verdict: VERDICT[r.u8()] }); + } + if (!r.done()) throw new Error("bytes after"); + return s; + } catch { + return { kind: "malformed" }; + } +} + +/** A WEV1 record's fields (live-events §3.1), read as far as this module needs them. */ +export function readEvent(b) { + const r = new Rd(b, 5); + const ev = { room: hex(r.take(32)), principal: hex(r.take(32)), seq: r.u64(), prev: hex(r.take(32)), t: r.u64() }; + if (r.u8() === 1) r.take(48); + ev.kind = r.u8(); + ev.payload = r.take(r.u16()); + ev.body = b.subarray(0, r.p); + ev.sig = r.take(64); + return ev; +} + +// ---------------------------------------------------------------- §5.4, §6 the controlled position + +function positionOf(st, x, duration) { + if (!st.playing) return st.pos; + const p = st.pos + Number((BigInt(Math.max(0, x - st.anchor)) * BigInt(st.rate)) / 1000000n); + return Math.min(duration, p); +} + +/** The controlled position over the room clock (§6), from the policies and the accepted + * controls, each `{ at, t, digest (hex), action, arg }`. */ +export class Timeline { + constructor(policies, controls) { + this.policies = policies; + this.starts = []; + policies.forEach((p, i) => { + if (i === 0 || p.source_type !== policies[i - 1].source_type || p.source !== policies[i - 1].source) this.starts.push(p.effective); + }); + const key = (c) => [c.at, c.t, c.digest]; + this.controls = [...controls].sort((a, b) => { + const [x, y] = [key(a), key(b)]; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1; + return 0; + }); + const aspect = { 1: 0, 2: 0, 3: 1, 4: 2 }; + this.overridden = new Set(); + this.controls.forEach((c, i) => { + for (const d of this.controls.slice(i + 1)) { + if (d.at !== c.at) break; + if (aspect[d.action] === aspect[c.action]) { this.overridden.add(c.digest); break; } + } + }); + const events = [...this.starts.map((s) => ({ x: s, start: true })), ...this.controls.map((c) => ({ x: c.at, c }))]; + events.sort((a, b) => a.x - b.x || (a.start ? 0 : 1) - (b.start ? 0 : 1)); + this.points = []; + let st = null; + for (const e of events) { + if (e.start) st = { playing: false, rate: NORMAL, anchor: e.x, pos: 0 }; + else if (st) { + st = { ...st, pos: positionOf(st, e.x, this.durationAt(e.x)), anchor: e.x }; + if (e.c.action === 1) st.playing = true; + else if (e.c.action === 2) st.playing = false; + else if (e.c.action === 3) st.pos = e.c.arg; + else st.rate = e.c.arg; + } + if (st) this.points.push([e.x, st]); + } + } + + durationAt(x) { + const k = policyAt(this.policies, x); + return k == null ? 0 : this.policies[k].duration_us; + } + + stateAt(x) { + let st = null; + for (const [at, s] of this.points) if (at <= x) st = s; + return st; + } + + positionAt(x) { + const st = this.stateAt(x); + return st ? positionOf(st, x, this.durationAt(x)) : 0; + } +} + +/** Why a control is not applied (§5.2, §5.3), or null: `mayControl(action)` says whether its + * author may send it, under the policy in effect at its `t`. */ +export function controlRefusal(policies, t, c, mayControl) { + const k = policyAt(policies, t); + if (k == null) return "cw_no_policy"; + const p = policies[k]; + if (!mayControl(c.action)) return "control_not_permitted"; + if (c.at < t + p.lead_ms * 1000) return "control_not_ahead"; + if (c.at > t + p.horizon_ms * 1000) return "control_beyond_horizon"; + const q = policies[policyAt(policies, c.at)]; + if ((c.action === 3 && c.arg > q.duration_us) || (c.action === 4 && (c.arg < q.min_rate || c.arg > q.max_rate))) return "control_out_of_range"; + return null; +} + +// ---------------------------------------------------------------- §9 catch-up + +/** The correction the catch-up rule gives (§9). */ +export function catchUp(p, playing, rate, controlled, duration, presented) { + const e = presented - controlled; + const mag = Math.abs(e); + if (!playing || controlled >= duration) return mag > p.tolerance_us ? { action: "seek", value: controlled } : { action: "hold" }; + if (mag <= p.tolerance_us) return { action: "rate", value: rate }; + if (mag > p.seek_us) return { action: "seek", value: controlled }; + const r = e > 0 ? rate - p.nudge : rate + p.nudge; + return { action: "rate", value: Math.max(p.min_rate, Math.min(p.max_rate, r)) }; +} + +// ---------------------------------------------------------------- §7.3 the clock + +/** The offset of the service's clock from this page's, in µs, from round trips each + * `{ sent, got, server }` (this page's ms at sending and receiving, the server's µs): the + * answer of least round trip, at its midpoint (§7.3). */ +export function clockOffset(samples) { + const best = samples.reduce((a, s) => (!a || s.got - s.sent < a.got - a.sent ? s : a), null); + return Math.round(best.server - ((best.sent + best.got) / 2) * 1000); +} + +// ---------------------------------------------------------------- the player + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// A slot is stated twice (co-watch §7.3), so timer jitter leaves none empty. +const STATES_PER_SLOT = 2; +// Statements one post carries at most: a front may hold a request's body to a few KiB. +const POST_EVENTS = 4; + +export class CoWatch { + // o: { api (path, body) -> {status, body}, wai, room, t0, seed (Uint8Array), principal (hex), + // grant (hex), grantDigest (hex), media (HTMLMediaElement), policies (hex), objects, + // sourceUrl, mayControl (principal, action) -> bool, target (the join's proof target, + // "//authority/path"), log, onChange } + constructor(o) { + Object.assign(this, o); + this.policyList = o.policies.map((h) => readPolicy(unhex(h))); + this.policy = this.policyList[0]; + this.offset = 0; + this.at = 0; // the next entry of the log + this.controls = []; + this.timeline = new Timeline(this.policyList, []); + this.seq = 0; + this.prev = "00".repeat(32); + this.lastT = 0; + this.joinT = null; + this.outbox = []; + this.stopped = null; + this.skews = []; + this.others = {}; // principal -> its last statement: at, position, error + this.stats = { log: 0, controls: 0, applied: 0, refused: {}, statements: 0, posted: 0, post_refused: {}, seeks: 0, nudges: 0, verified_source: false, errors: [] }; + this.onChange = o.onChange || (() => {}); + } + + /** The room clock now, on this page's estimate (§7.3). */ + now() { + return Math.max(0, Math.round(Date.now() * 1000 + this.offset - this.t0)); + } + + async estimateClock() { + const samples = []; + for (let i = 0; i < 5; i++) { + const sent = Date.now(); + const r = await this.api("/watch/time"); + const got = Date.now(); + if (r.status === 200) samples.push({ sent, got, server: r.body.now_us }); + } + if (samples.length) this.offset = clockOffset(samples); + this.stats.offset_us = this.offset; + } + + /** The source, fetched whole and checked by its content hash (§3.5), as the media's own. */ + async loadSource() { + const r = await fetch(this.sourceUrl, { cache: "no-store" }); + const bytes = new Uint8Array(await r.arrayBuffer()); + const h = hex(await sha256(bytes)); + const obj = this.objects[0]; + if (h !== obj.content_hash) throw new Error(`the source's bytes are not the object the policy names: ${h.slice(0, 16)}…`); + if (hex(await sourceDigest(this.objects)) !== this.policy.source) throw new Error("the source's objects are not the policy's source"); + this.stats.verified_source = true; + this.media.src = URL.createObjectURL(new Blob([bytes], { type: r.headers.get("content-type") || "application/octet-stream" })); + this.media.preload = "auto"; + await new Promise((ok, bad) => { + if (this.media.readyState >= 1) return ok(); + this.media.addEventListener("loadedmetadata", ok, { once: true }); + this.media.addEventListener("error", () => bad(new Error("the source does not play here")), { once: true }); + }); + } + + /** Enter: a proof of the grant's key over the service's challenge, for the request's own + * method and target (live-rooms §10.5.1, §10.5.3); the clock then signs this page's `join`. */ + async join() { + const c = await this.api("/watch/challenge"); + if (c.status !== 200) throw new Error(`no challenge: ${c.status}`); + const grant = unhex(this.grant); + const digest = await sha256(grant.subarray(0, grant.length - 64)); + const context = enc.encode(`POST ${this.target}`); + const key = await keyFromSeed(this.seed); + const body = concat(enc.encode("WRP1"), Uint8Array.of(1), digest, unhex(this.principal), unhex(c.body.challenge), le(context.length, 2), context); + const sig = new Uint8Array(await subtle.sign({ name: "Ed25519" }, key.privateKey, concat(PROOF_DOMAIN, body))); + const r = await this.api("/watch/join", { room: this.room, grant: this.grant, proof: hex(concat(body, sig)) }); + if (r.status !== 200) throw new Error(`the room refused the join: ${r.status} ${r.body && r.body.error}`); + this.joinT = r.body.t; + this.key = key; + this.log(`co-watch: joined at room clock ${r.body.t}`); + } + + /** A `meta` event of this page's chain, signed (live-events §3), at `t`. */ + event(t, payload) { + t = Math.max(t, this.lastT, this.joinT || 0); + const rec = this.wai.eventSign(this.seed, unhex(this.room), BigInt(this.seq), unhex(this.prev), BigInt(t), META, payload); + const v = JSON.parse(this.wai.eventRead(rec)); + this.seq++; + this.prev = v.digest; + this.lastT = t; + return rec; + } + + /** Ask the room to `action` (ACTION) with `arg`, at the policy's lead from now (§5.3). */ + control(action, arg = 0) { + const t = this.now(); + const at = t + this.policy.lead_ms * 1000 + 50000; + this.outbox.push(this.event(t, controlPayload({ action, at, arg }))); + this.flush(); + return at; + } + + async flush() { + if (this.posting || !this.outbox.length) return; + this.posting = true; + try { + while (this.outbox.length) { + const batch = this.outbox.slice(0, POST_EVENTS); + const r = await this.api("/watch/post", { room: this.room, events: batch.map(hex) }); + if (r.status === 200) { + this.outbox.splice(0, batch.length); + this.stats.posted += batch.length; + this.rejoined = false; + } else if (r.status === 409 && r.body && r.body.error === "not_present" && !this.rejoined) { + // The room took this page for gone (it was silent too long): it enters again, and + // its chain goes on where it was. + this.outbox.splice(0, r.body.carried || 0); + this.rejoined = true; + await this.join(); + } else if (r.status === 409) { + // Those before it were carried; this one never will be, and the chain cannot go + // on past it: the page stops stating rather than leave a gap it cannot fill. + const why = r.body.error; + this.stats.post_refused[why] = (this.stats.post_refused[why] || 0) + 1; + this.outbox.splice(0, (r.body.carried || 0) + 1); + this.stop(`the room refused an event: ${why}`); + break; + } else { + await sleep(500); + } + } + } finally { + this.posting = false; + } + } + + stop(why) { + this.stopped = why; + this.stats.errors.push(why); + this.log(`co-watch: ${why}`); + } + + /** Follow the room's log, each record checked before anything of it is applied. */ + async follow(alive) { + while (alive() && !this.stopped) { + let r; + try { r = await this.api(`/watch?room=${this.room}&from=${this.at}`); } catch { await sleep(1000); continue; } + if (r.status === 410) { this.stop("the room was replaced"); break; } + if (r.status !== 200 || !r.body) { await sleep(1000); continue; } + let changed = false; + for (const e of r.body.entries) { + if (e.seq !== this.at) { this.stop(`the log skipped from ${this.at} to ${e.seq}`); return; } + changed = (await this.onRecord(unhex(e.event))) || changed; + this.at++; + this.stats.log++; + } + if (changed) this.timeline = new Timeline(this.policyList, this.controls); + this.onChange(); + } + } + + refuse(why) { + this.stats.refused[why] = (this.stats.refused[why] || 0) + 1; + } + + async onRecord(bytes) { + const v = JSON.parse(this.wai.eventRead(bytes)); + if (!v.ok) { this.refuse(v.reason); return false; } + if (v.room !== this.room) { this.refuse("wrong_room"); return false; } + if (v.kind !== META) return false; + const s = readStatement(readEvent(bytes).payload); + if (!s) return false; + if (s.kind === "malformed") { this.refuse("cw_statement_malformed"); return false; } + if (s.kind === "control") { + this.stats.controls++; + const why = controlRefusal(this.policyList, v.t, s, (a) => this.mayControl(v.principal, a)); + if (why) { this.refuse(why); return false; } + this.controls.push({ at: s.at, t: v.t, digest: v.digest, action: s.action, arg: s.arg, author: v.principal }); + return true; + } + if (s.kind === "skew") { + this.skews.push(s); + if (this.skews.length > 64) this.skews.shift(); + return false; + } + if (s.player !== v.principal) { this.refuse("presentation_wrong_key"); return false; } + this.others[v.principal] = { at: s.at, position: s.position, error: s.position - this.timeline.positionAt(s.at), state: s.state }; + return false; + } + + /** Keep the media at the controlled position (§9) and state what it presents (§7.3). */ + async run(alive) { + const per = (this.policy.period_ms * 1000) / STATES_PER_SLOT; + let next = 0; + while (alive() && !this.stopped) { + const x = this.now(); + const st = this.timeline.stateAt(x); + const duration = this.timeline.durationAt(x); + const controlled = this.timeline.positionAt(x); + const presented = Math.round(this.media.currentTime * 1e6); + if (st) { + if (st.playing && controlled < duration && this.media.paused) this.media.play().catch(() => {}); + if ((!st.playing || controlled >= duration) && !this.media.paused) this.media.pause(); + const c = catchUp(this.policy, st.playing, st.rate, controlled, duration, presented); + if (c.action === "seek") { this.media.currentTime = c.value / 1e6; this.stats.seeks++; } + else if (c.action === "rate") { + if (c.value !== st.rate) this.stats.nudges++; + if (Math.abs(this.media.playbackRate - c.value / 1e6) > 1e-6) this.media.playbackRate = c.value / 1e6; + } + this.error = presented - controlled; + this.controlled = controlled; + } + if (x >= next && this.key) { + next = x + per; + const ended = this.media.ended || (st && controlled >= duration); + const state = this.media.readyState < 3 && !this.media.paused ? STATE.stalled : ended ? STATE.ended : this.media.paused ? STATE.paused : STATE.playing; + const pos = Math.round(this.media.currentTime * 1e6); + const body = { room: this.room, player: this.principal, source: this.policy.source, at: x, position: pos, state, rate: Math.max(1, Math.round(this.media.playbackRate * 1e6)) }; + this.outbox.push(this.event(x, await signPresentation(body, this.key.privateKey))); + this.stats.statements++; + this.flush(); + } + this.onChange(); + await sleep(50); + } + } + + /** What a test reads, and what the page shows. */ + snapshot() { + const last = this.skews.length ? this.skews[this.skews.length - 1] : null; + const lastWindow = last ? this.skews.filter((s) => s.from === last.from) : []; + return { + joined: this.joinT, at: this.at, offset_us: this.offset, room_now: this.now(), controls: this.controls.length, + overridden: this.controls.filter((c) => this.timeline.overridden.has(c.digest)).length, + controlled: this.controlled ?? null, error_us: this.error ?? null, + playing: !this.media.paused, rate: this.media.playbackRate, current_time: this.media.currentTime, + skews: this.skews.length, last_window: lastWindow.length ? { from: lastWindow[0].from, to: lastWindow[0].to, bound_us: lastWindow[0].bound_us, viewers: lastWindow.flatMap((s) => s.viewers), pairs: lastWindow.flatMap((s) => s.pairs) } : null, + others: this.others, stopped: this.stopped, ...this.stats, + }; + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch.test.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch.test.mjs new file mode 100644 index 000000000..90041a018 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/cowatch.test.mjs @@ -0,0 +1,119 @@ +// The browser client's co-watch module (cowatch.js), against the corpus +// wai/live-conformance/cowatch/ (`node --test web/room/cowatch.test.mjs`): every catch-up case +// its correction (co-watch §9); each accepted room's controlled position recomputed from its +// policies and controls (§5.4, §6), as the corpus states it at every change and at every +// statement; its controls written byte for byte (§5.1); and its presentation statements +// written and signed byte for byte (§7.1), each at the rate the catch-up rule gives (§9). +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import * as cw from "./cowatch.js"; + +const doc = JSON.parse(readFileSync(new URL("../../../../../../wai/live-conformance/cowatch/vectors.json", import.meta.url), "utf8")); +const vector = (name) => doc.vectors.find((v) => v.name === name); +const ACCEPTED = ["watch_party", "next_episode", "drift"]; + +/** The corpus's key `i`: SHA-256 of its label, a zero byte and `i` (its README). */ +const key = async (i) => cw.keyFromSeed(await cw.sha256(new TextEncoder().encode("wai:co-watch/corpus/key\x00"), Uint8Array.of(i))); +const INDEX = { authority: 0, clock: 1, host: 2, guest: 3, late: 4, fourth: 5 }; + +/** Every record of a vector, by its digest. */ +async function records(v) { + const out = new Map(); + for (const chain of v.chains) { + for (const h of chain) { + const b = cw.unhex(h); + const ev = cw.readEvent(b); + out.set(cw.hex(await cw.sha256(ev.body)), ev); + } + } + return out; +} + +const timelineOf = (v) => + new cw.Timeline(v.policies.map((h) => cw.readPolicy(cw.unhex(h))), v.controls.map((c) => ({ at: c.at, t: c.t, digest: c.digest, action: cw.ACTION[c.action], arg: c.arg }))); + +test("every catch-up case gets the corpus's correction", () => { + const { params, cases } = doc.catch_up; + assert.ok(cases.length > 0); + for (const c of cases) assert.deepEqual(cw.catchUp(params, c.playing, c.rate, c.controlled, c.duration, c.presented), c.correction, c.about); +}); + +test("the controlled position of each accepted room is the corpus's", () => { + for (const name of ACCEPTED) { + const v = vector(name); + const tl = timelineOf(v); + assert.deepEqual(tl.points.map(([x, s]) => ({ at: x, playing: s.playing, rate: s.rate, position: s.pos })), v.timeline, name); + for (const c of v.controls) assert.equal(!tl.overridden.has(c.digest), c.applied, `${name}: ${c.digest.slice(0, 8)}`); + for (const p of v.presentations) assert.equal(tl.positionAt(p.at), p.controlled, `${name}: the statement at ${p.at}`); + } +}); + +test("each accepted room's controls are written byte for byte", async () => { + for (const name of ACCEPTED) { + const v = vector(name); + const recs = await records(v); + for (const c of v.controls) { + const ev = recs.get(c.digest); + assert.ok(ev, `${name}: a control's record`); + assert.deepEqual(cw.controlPayload({ action: cw.ACTION[c.action], at: c.at, arg: c.arg }), ev.payload, `${name}: ${c.action} at ${c.at}`); + assert.deepEqual(cw.readStatement(ev.payload), { kind: "control", action: cw.ACTION[c.action], at: c.at, arg: c.arg }); + } + } +}); + +test("each accepted room's presentation statements are written and signed byte for byte, at the catch-up rule's rate", async () => { + const keys = {}; + for (const [n, i] of Object.entries(INDEX)) keys[doc.principals[n]] = await key(i); + for (const name of ACCEPTED) { + const v = vector(name); + const recs = await records(v); + const policies = v.policies.map((h) => cw.readPolicy(cw.unhex(h))); + const tl = timelineOf(v); + for (const p of v.presentations) { + const ev = recs.get(p.digest); + const pol = policies[cw.policyAt(policies, p.at)]; + const st = tl.stateAt(p.at); + const duration = tl.durationAt(p.at); + // The player states its own rate: the controlled one, or the catch-up rule's (§9). + let rate = st.rate; + if (st.playing && p.controlled < duration) { + const c = cw.catchUp(pol, true, st.rate, p.controlled, duration, p.position); + if (c.action === "rate") rate = c.value; + } + const state = !st.playing ? cw.STATE.paused : p.controlled >= duration ? cw.STATE.ended : cw.STATE.playing; + assert.equal(state, cw.STATE[p.state], `${name}: the state at ${p.at}`); + const k = keys[p.player]; + assert.equal(cw.hex(k.publicKey), p.player); + const mine = await cw.signPresentation({ room: v.room, player: p.player, source: pol.source, at: p.at, position: p.position, state, rate }, k.privateKey); + assert.deepEqual(mine, ev.payload, `${name}: the statement of ${p.player.slice(0, 8)} at ${p.at}`); + } + } +}); + +test("skew statements read back with their verdicts", async () => { + const v = vector("drift"); + const recs = await records(v); + for (const s of v.skews) { + const st = cw.readStatement(recs.get(s.digest).payload); + assert.equal(st.kind, "skew"); + assert.deepEqual(st.viewers.map((x) => [x.principal, x.verdict, x.pq]), s.viewers.map((x) => [x.principal, x.verdict, x.pq])); + } +}); + +test("a control the room would refuse is not applied", () => { + const v = vector("watch_party"); + const policies = v.policies.map((h) => cw.readPolicy(cw.unhex(h))); + const yes = () => true; + assert.equal(cw.controlRefusal(policies, 5_000_000, { action: 2, at: 4_000_000, arg: 0 }, yes), "control_not_ahead"); + assert.equal(cw.controlRefusal(policies, 5_000_000, { action: 2, at: 5_100_000, arg: 0 }, yes), "control_not_ahead"); + assert.equal(cw.controlRefusal(policies, 5_000_000, { action: 2, at: 16_000_000, arg: 0 }, yes), "control_beyond_horizon"); + assert.equal(cw.controlRefusal(policies, 5_000_000, { action: 3, at: 6_000_000, arg: policies[0].duration_us + 1 }, yes), "control_out_of_range"); + assert.equal(cw.controlRefusal(policies, 5_000_000, { action: 4, at: 6_000_000, arg: 3_000_000 }, yes), "control_out_of_range"); + assert.equal(cw.controlRefusal(policies, 5_000_000, { action: 2, at: 6_000_000, arg: 0 }, () => false), "control_not_permitted"); + assert.equal(cw.controlRefusal(policies, 5_000_000, { action: 2, at: 6_000_000, arg: 0 }, yes), null); +}); + +test("the clock's offset is taken from the least round trip", () => { + assert.equal(cw.clockOffset([{ sent: 1000, got: 1100, server: 5_000_000 }, { sent: 2000, got: 2010, server: 7_000_000 }]), 7_000_000 - 2_005_000); +}); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/endpoint.js b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/endpoint.js new file mode 100644 index 000000000..014d96f61 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/endpoint.js @@ -0,0 +1,46 @@ +// Which endpoints the room page takes from outside its own code: a relay, a room service, a +// fixture or an issuer directory its query string names, and the URI a relay's GOAWAY moves +// the session to. A page that took any of them as given could be pointed, by a link, at +// another origin, and a visitor who joined from it would send their camera and microphone +// there. So an endpoint is taken only when it is the page's own origin: `https://`, or `wss://` for a relay. A page served from the loopback interface +// (a fixture page, or the end-to-end harness, whose relays and services listen on other +// loopback ports) takes a loopback endpoint, and nothing else. + +/** The host names of the loopback interface, as `URL.hostname` writes them. */ +export const LOOPBACK_HOSTS = ["localhost", "127.0.0.1", "[::1]"]; + +export function isLoopback(hostname) { + return LOOPBACK_HOSTS.includes(hostname); +} + +/** + * Whether the page at `page` (a URL, or `location`) takes `value` as an endpoint of `kind`: + * "http" for a fetch or a WebTransport session, "ws" for a WebSocket relay. A relative value + * is resolved against the page. Returns `{ ok: true, url }` with the endpoint in full, or + * `{ ok: false, reason }`. + */ +export function checkEndpoint(value, kind, page) { + const p = new URL(String(page)); + let u; + try { + u = new URL(String(value), p.href); + } catch { + return { ok: false, reason: "not a URL" }; + } + const schemes = kind === "ws" ? ["wss:", "ws:"] : ["https:", "http:"]; + if (!schemes.includes(u.protocol)) return { ok: false, reason: `the scheme ${u.protocol} is not a ${kind === "ws" ? "WebSocket" : "web"} endpoint's` }; + // User information before a host reads as a host to a person and names none: never taken. + if (u.username || u.password) return { ok: false, reason: "it carries user information" }; + if (isLoopback(p.hostname)) { + return isLoopback(u.hostname) ? { ok: true, url: u.href } : { ok: false, reason: `a loopback page takes only loopback endpoints, not ${u.host}` }; + } + const secure = kind === "ws" ? "wss:" : "https:"; + if (p.protocol !== "https:" || u.protocol !== secure || u.host !== p.host) return { ok: false, reason: `${u.protocol}//${u.host} is not this page's origin` }; + return { ok: true, url: u.href }; +} + +/** The kind of endpoint a URI is by its scheme: "ws" for ws: and wss:, else "http". */ +export function kindOf(value) { + return /^wss?:/i.test(String(value)) ? "ws" : "http"; +} diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/endpoint.test.mjs b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/endpoint.test.mjs new file mode 100644 index 000000000..67383f98c --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/endpoint.test.mjs @@ -0,0 +1,71 @@ +// The room page's check of the endpoints it takes from its query string and from a relay's +// GOAWAY (`node --test web/room/endpoint.test.mjs`): its own origin only, and on a loopback +// page (the end-to-end harness) loopback alone. +import test from "node:test"; +import assert from "node:assert/strict"; +import { checkEndpoint, kindOf } from "./endpoint.js"; + +const PAGE = "https://wai.transaction.science/room/?service=x"; +const taken = (v, kind, page = PAGE) => checkEndpoint(v, kind, page); + +test("the page's own origin is taken, a relative path resolved against it", () => { + assert.deepEqual(taken("/room-api", "http"), { ok: true, url: "https://wai.transaction.science/room-api" }); + assert.deepEqual(taken("https://wai.transaction.science/room-api", "http"), { ok: true, url: "https://wai.transaction.science/room-api" }); + assert.deepEqual(taken("wss://wai.transaction.science/moq", "ws"), { ok: true, url: "wss://wai.transaction.science/moq" }); +}); + +test("another origin is refused, whatever its form", () => { + for (const [v, kind] of [ + ["https://evil.example/room-api", "http"], + ["wss://evil.example/moq", "ws"], + ["https://wai.transaction.science.evil.example/moq", "http"], + ["https://wai.transaction.science:8443/room-api", "http"], + // A scheme-relative URL takes the page's scheme and another host. + ["//evil.example/room-api", "http"], + ["//evil.example/moq", "ws"], + // The page's host as user information: the host is the other one. + ["https://wai.transaction.science@evil.example/room-api", "http"], + ["wss://wai.transaction.science@evil.example/moq", "ws"], + // User information on the page's own host is refused too. + ["https://user:pass@wai.transaction.science/room-api", "http"], + ]) { + const r = taken(v, kind); + assert.equal(r.ok, false, `${v} is refused`); + assert.ok(r.reason, `${v} has a reason`); + } +}); + +test("a scheme that is no endpoint's is refused: javascript:, data:, a plain relay on a secure page", () => { + assert.equal(taken("javascript:alert(document.domain)", "http").ok, false); + assert.equal(taken("javascript:alert(document.domain)", "ws").ok, false); + assert.equal(taken("data:text/html,

x

", "http").ok, false); + assert.equal(taken("ws://wai.transaction.science/moq", "ws").ok, false, "a secure page takes wss: alone"); + assert.equal(taken("http://wai.transaction.science/room-api", "http").ok, false, "a secure page takes https: alone"); + assert.equal(taken("wss://wai.transaction.science/moq", "http").ok, false, "a relay URI is no fetch endpoint"); + assert.equal(taken("https://wai.transaction.science/moq", "ws").ok, false, "a web URI is no WebSocket endpoint"); +}); + +test("a page served over plain HTTP from a public host takes nothing", () => { + assert.equal(taken("/room-api", "http", "http://wai.transaction.science/room/").ok, false); + assert.equal(taken("ws://wai.transaction.science/moq", "ws", "http://wai.transaction.science/room/").ok, false); +}); + +test("a loopback page, as the end-to-end harness serves it, takes loopback endpoints on any port and nothing else", () => { + for (const page of ["http://127.0.0.1:41234/room/", "http://localhost:8080/room/", "http://[::1]:9000/room/"]) { + assert.equal(taken("ws://127.0.0.1:51001/moq", "ws", page).ok, true, page); + assert.equal(taken("https://127.0.0.1:51002/moq", "http", page).ok, true, page); + assert.equal(taken("http://localhost:51003/issuer", "http", page).ok, true, page); + assert.equal(taken("/fixture.json", "http", page).ok, true, page); + assert.equal(taken("wss://evil.example/moq", "ws", page).ok, false, page); + assert.equal(taken("//evil.example/room-api", "http", page).ok, false, page); + assert.equal(taken("http://127.0.0.1@evil.example/", "http", page).ok, false, page); + assert.equal(taken("javascript:alert(1)", "http", page).ok, false, page); + } +}); + +test("a GOAWAY's URI is checked by its own scheme", () => { + assert.equal(kindOf("wss://wai.transaction.science/moq"), "ws"); + assert.equal(kindOf("https://wai.transaction.science/moq"), "http"); + assert.equal(taken("wss://relay.evil.example/moq", kindOf("wss://relay.evil.example/moq")).ok, false); + assert.equal(taken("wss://wai.transaction.science/moq-b", kindOf("wss://wai.transaction.science/moq-b")).ok, true); +}); diff --git a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html index 27ea8c027..07826ca7e 100644 --- a/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html +++ b/joule-code/joule-code-rs/crates/joule-moq-web/web/room/index.html @@ -73,6 +73,21 @@ + +

Events

    @@ -119,6 +134,7 @@ +