live room train D: co-watch rooms, the room's own service under age rules, same-origin room endpoints (#432) #451
62 changed files with 15216 additions and 93 deletions
9
.github/workflows/joule-moq-room.yml
vendored
9
.github/workflows/joule-moq-room.yml
vendored
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
21
.github/workflows/joule-moq-web.yml
vendored
21
.github/workflows/joule-moq-web.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
30
.github/workflows/wai-conformance.yml
vendored
30
.github/workflows/wai-conformance.yml
vendored
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 `["*", "<clock>", "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. |
|
||||
|
|
|
|||
|
|
@ -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<Vec<[u8; 32]>> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -33,6 +33,10 @@ pub struct ClientOptions {
|
|||
pub grants: Vec<(Vec<u8>, 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<ed25519_dalek::SigningKey>,
|
||||
/// 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)?;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Vec<[u8; 32]>>,
|
||||
/// 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<Vec<[u8; 32]>> {
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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<Vec<[u8; 32]>> = 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)),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
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<u8> = 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<u64> {
|
||||
let mut g: Vec<u64> = 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<SigningKey>| 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::<Vec<_>>(), "the session proving the clock's key: every group");
|
||||
assert_eq!(media_groups(&got_authority, s_authority.media_alias), (0..5).collect::<Vec<_>>(), "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;
|
||||
}
|
||||
|
|
@ -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::<Vec<_>>(), "the viewer at 18: everything");
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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": [<hex>]}` | a GroupInfo, or a commit and its Welcome, checked by the clock as a member checks it, then carried: `{"ok", "seq"}`; `409` `{"error": <reason>}` 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": <age-assurance §11's reason>}` 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": <hex>}`: single-use, good for a minute |
|
||||
| `POST /watch/join` `{"room", "grant": <hex>, "proof": <hex>}` | the proof (live-rooms §10.5.1) over a challenge, for `POST //<host><public_prefix>/watch/join`; the clock signs the principal's `join`: `{"ok", "t"}`; `401` `{"error": <reason>}` for a proof that fails |
|
||||
| `POST /watch/post` `{"room", "events": [<hex>]}` (at most 8) | the viewer's events, each checked and carried in order: `{"ok", "carried", "log"}`; `409` `{"error": <reason>, "carried"}` at the first refused |
|
||||
| `GET /watch?room=<id>&from=<n>[&wait_ms=<ms>]` | the watch room's log from entry `n`: `{"room", "entries": [{"seq", "event": <hex>}], "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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<wai::live_room::Role> {
|
||||
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<wai::live_room::Role> {
|
||||
let mut r: Vec<wai::live_room::Role> = 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<wai::live_room::Role> {
|
|||
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<Arc<WatchSource>>,
|
||||
/// 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<RoomMls>,
|
||||
/// A room that admits by age: its policy, its rating, its nonces and its counts.
|
||||
pub age: Option<AgeRoom>,
|
||||
/// 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<WatchRoom>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>()})),
|
||||
// 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]
|
||||
|
|
|
|||
|
|
@ -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": <hex>}`: single-use, good for a minute (live-rooms §10.5.3) |
|
||||
//! | `POST /watch/join` `{"room", "grant": <hex>, "proof": <hex>}` | the grant's principal enters, the proof over a challenge for `POST //<host><prefix>/watch/join`: the clock signs its `join`; `{"ok", "t": <its t>}`; `401` `{"error": <reason>}` for a proof that fails |
|
||||
//! | `POST /watch/post` `{"room", "events": [<hex>]}` (at most 8) | each event checked as co-watch §10 has it and carried, in order: `{"ok", "carried"}`; `409` `{"error": <reason>, "carried"}` at the first refused |
|
||||
//! | `GET /watch?room=<id>&from=<n>[&wait_ms=<ms>]` | the room's log from entry `n` (`{"room", "entries": [{"seq", "event": <hex>}], "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<Service>, listener: tokio::net::TcpListener) {
|
||||
|
|
@ -161,7 +177,51 @@ async fn handle(svc: Arc<Service>, peer: SocketAddr, req: Request<Incoming>) ->
|
|||
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<Service>, 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<Service>) -> Response<Full<Bytes>> {
|
|||
"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<Service>) -> Response<Full<Bytes>> {
|
||||
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<Vec<u8>> {
|
||||
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<Service>, host: &str, body: &[u8]) -> Response<Full<Bytes>> {
|
||||
let Ok(v) = serde_json::from_slice::<Value>(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<Service>, body: &[u8]) -> Response<Full<Bytes>> {
|
||||
let Ok(v) = serde_json::from_slice::<Value>(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<Vec<Vec<u8>>> = 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<Service>, query: &str) -> Response<Full<Bytes>> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Settings, String> {
|
||||
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<co::CoState>,
|
||||
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<Service> {
|
||||
let settings = cfg.settings();
|
||||
Service::try_new(keys, cfg).expect("a configuration the service takes")
|
||||
}
|
||||
|
||||
pub fn try_new(keys: Keys, cfg: &Config) -> Result<Arc<Service>, 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<Self>) {
|
||||
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<Client, String> {
|
||||
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<u64, String> {
|
||||
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<u64, String> {
|
||||
let items: Vec<Item> = 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<Vec<(Vec<u8>, 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<Vec<(Vec<u8>, 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<Self>) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
630
joule-code/joule-code-rs/crates/joule-moq-room/src/watch.rs
Normal file
630
joule-code/joule-code-rs/crates/joule-moq-room/src/watch.rs
Normal file
|
|
@ -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<Vec<u8>>,
|
||||
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<WatchSource, String> {
|
||||
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<u8>,
|
||||
pub digest: [u8; 32],
|
||||
/// Its presence: the `t` of its `join`, while present.
|
||||
pub joined: Option<u64>,
|
||||
/// 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<u64>,
|
||||
}
|
||||
|
||||
/// 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<u8>,
|
||||
pub source: Arc<WatchSource>,
|
||||
/// Every record the room's log carries, in the order taken.
|
||||
pub log: Vec<Vec<u8>>,
|
||||
pub viewers: BTreeMap<[u8; 32], Viewer>,
|
||||
pub controls: Vec<TimedControl>,
|
||||
pub samples: Vec<SampleRec>,
|
||||
pub stretches: Vec<Stretch>,
|
||||
pub grant_changes: Vec<GrantChange>,
|
||||
/// 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<String, u64>,
|
||||
pub skews: Vec<Skew>,
|
||||
}
|
||||
|
||||
impl WatchRoom {
|
||||
pub fn new(room: [u8; 32], t0_us: u64, source: Arc<WatchSource>, 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<u8>, 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<Skew> {
|
||||
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<Value> = (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<u8>]) -> Value {
|
||||
let mut order: Vec<[u8; 32]> = Vec::new();
|
||||
let mut chains: BTreeMap<[u8; 32], Vec<String>> = 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::<Vec<_>>(),
|
||||
"key_log": null,
|
||||
"chains": order.iter().map(|p| chains[p].clone()).collect::<Vec<_>>(),
|
||||
"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::<Vec<_>>()})
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The payload a clock's `meta` record carries: a skew statement.
|
||||
pub fn skew_payload(s: &Skew) -> Vec<u8> {
|
||||
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<u8>) {
|
||||
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<WatchSource> {
|
||||
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<u8>) -> Vec<u8> {
|
||||
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<Vec<Vec<u8>>> = 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u8>) {
|
||||
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<u8> = (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<u8>, 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<u8>| {
|
||||
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<Vec<u8>> { v.as_array().unwrap().iter().map(|h| hex::decode(h.as_str().unwrap()).unwrap()).collect() };
|
||||
let chains: Vec<Vec<Vec<u8>>> = 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <service>/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://<host>`, `wss://<host>` 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 <origin>` the `service` scenario runs against a deployment instead: nothing is
|
||||
started, and the page at `<origin>/room/` is driven the same way (its production test). Its
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
//
|
||||
// node e2e/room.e2e.mjs --relay <joule-moq-relay> --wai <wai_live_web> --web <web dir>
|
||||
// --corpus <live-conformance/compose/vectors.json> [--browser <executable>]
|
||||
// [--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 <s>] [--out <dir>] [--room <joule-moq-room>] [--base <origin>] [--seek on|off]
|
||||
// [--wai-dir <wai>]
|
||||
//
|
||||
|
|
@ -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 <origin>` the service scenario runs against a deployment (the page at
|
||||
// <origin>/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://<its host> 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 <joule-moq-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 <joule-moq-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 <joule-moq-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);
|
||||
|
|
|
|||
|
|
@ -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=()
|
||||
|
|
|
|||
|
|
@ -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://<host>`, or `wss://<host>` 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) => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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://<the page's
|
||||
// host>`, or `wss://<the page's host>` 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";
|
||||
}
|
||||
|
|
@ -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://[email protected]/room-api", "http"],
|
||||
["wss://[email protected]/moq", "ws"],
|
||||
// User information on the page's own host is refused too.
|
||||
["https://user:[email protected]/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,<p>x</p>", "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://[email protected]/", "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);
|
||||
});
|
||||
|
|
@ -73,6 +73,21 @@
|
|||
</div>
|
||||
<canvas id="dvr-canvas" width="640" height="360" hidden style="margin-top: 8px" aria-label="The participant's earlier video, from the relay's window"></canvas>
|
||||
</section>
|
||||
<!-- A watch room (WAI co-watch): the source the room watches, its shared controls and how
|
||||
closely the room keeps together. -->
|
||||
<section id="cowatch" hidden style="grid-column: 1 / -1">
|
||||
<h2>Watching together</h2>
|
||||
<video id="cowatch-media" playsinline style="width: 100%; max-height: 360px; background: #101218; border-radius: 4px"></video>
|
||||
<div class="controls" style="margin: 8px 0 0">
|
||||
<button id="cw-play" type="button">Play</button>
|
||||
<button id="cw-pause" type="button">Pause</button>
|
||||
<button id="cw-back" type="button">Back 10 s</button>
|
||||
<button id="cw-fwd" type="button">Ahead 10 s</button>
|
||||
<label for="cw-rate">Speed</label>
|
||||
<select id="cw-rate"><option value="750000">0.75×</option><option value="1000000" selected>1×</option><option value="1250000">1.25×</option><option value="1500000">1.5×</option></select>
|
||||
<span id="cw-sync" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Events</h2>
|
||||
<ul id="timeline"></ul>
|
||||
|
|
@ -119,6 +134,7 @@
|
|||
<button id="co-prompts" type="button">Ask for prompts</button>
|
||||
<button id="co-fid" type="button">State fidelity</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- With ?debug=binding: the state of the frame each tile shows (WAI browser-sink §10). -->
|
||||
<section id="binding-debug" style="grid-column: 1 / -1" hidden>
|
||||
<h2>Frames on screen</h2>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
// The room page's markup (`node --test web/room/markup.test.mjs`): every element it opens is
|
||||
// closed, in order, so no section nests inside another by accident; and each mode's section
|
||||
// holds its own controls and nothing of another's. A section left open nests everything after it,
|
||||
// so a section the page hides for one mode would hide the Log and the binding table with it.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const html = fs.readFileSync(fileURLToPath(new URL("./index.html", import.meta.url)), "utf8");
|
||||
|
||||
// HTML's void elements, which have no end tag.
|
||||
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
|
||||
|
||||
// The elements of `src` by `id`, each with the ids of the elements it is inside, outermost first;
|
||||
// and every tag that does not balance.
|
||||
function parse(src) {
|
||||
const body = src.replace(/<!--[\s\S]*?-->/g, "").replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, "").replace(/<!doctype[^>]*>/i, "");
|
||||
const stack = [];
|
||||
const ids = new Map();
|
||||
const errors = [];
|
||||
const tag = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/g;
|
||||
let m;
|
||||
while ((m = tag.exec(body))) {
|
||||
const [, close, raw, attrs] = m;
|
||||
const name = raw.toLowerCase();
|
||||
if (close) {
|
||||
const top = stack.pop();
|
||||
if (!top || top.name !== name) errors.push(`</${name}> closes <${top ? top.name : "nothing"}>`);
|
||||
continue;
|
||||
}
|
||||
if (VOID.has(name) || /\/\s*$/.test(attrs)) continue;
|
||||
const id = (attrs.match(/\bid="([^"]*)"/) || [])[1];
|
||||
if (id) ids.set(id, stack.map((e) => e.id).filter(Boolean));
|
||||
stack.push({ name, id });
|
||||
}
|
||||
for (const e of stack) errors.push(`<${e.name}${e.id ? ` id="${e.id}"` : ""}> is never closed`);
|
||||
return { ids, errors };
|
||||
}
|
||||
|
||||
const { ids, errors } = parse(html);
|
||||
|
||||
test("every element the room page opens is closed, in order", () => {
|
||||
assert.deepEqual(errors, []);
|
||||
});
|
||||
|
||||
test("the Log, the binding table and the co-watch section are outside the co-performance section", () => {
|
||||
for (const id of ["log", "binding-debug", "cowatch", "cowatch-media", "cw-sync"]) {
|
||||
assert.ok(ids.has(id), `#${id} is on the page`);
|
||||
assert.ok(!ids.get(id).includes("co"), `#${id} is inside ${ids.get(id).map((x) => `#${x}`).join(" > ")}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("each mode's controls are inside its own section", () => {
|
||||
for (const id of ["co-lead", "co-input", "co-turns", "co-fidelity"]) assert.ok(ids.get(id).includes("co"), `#${id} is inside #co`);
|
||||
for (const id of ["cw-play", "cw-pause", "cw-rate", "cw-sync"]) assert.ok(ids.get(id).includes("cowatch") && !ids.get(id).includes("co"), `#${id} is inside #cowatch alone`);
|
||||
});
|
||||
|
|
@ -422,6 +422,24 @@ pub fn add_grant(o: &mut SetupOptions, record: &[u8], proof: &GrantProof) {
|
|||
o.push(KeyValuePair::bytes(SETUP_OPTION_GRANT_PROOF, proof.to_bytes()));
|
||||
}
|
||||
|
||||
/// The `grant` of a proof of a room key (WAI age-assurance §5.5): no grant, 32 bytes of zero.
|
||||
/// A session that holds a room key proves it with such a proof over the session's challenge,
|
||||
/// and is then the room's own service, which the serving rule of rated tracks does not hold.
|
||||
pub const NO_GRANT: [u8; 32] = [0; 32];
|
||||
|
||||
/// Present a proof of a room key in a client's Setup Options: a GRANT_PROOF whose `grant` is
|
||||
/// [`NO_GRANT`], with no AUTHORIZATION TOKEN beside it.
|
||||
pub fn add_room_key_proof(o: &mut SetupOptions, proof: &GrantProof) {
|
||||
o.push(KeyValuePair::bytes(SETUP_OPTION_GRANT_PROOF, proof.to_bytes()));
|
||||
}
|
||||
|
||||
/// The GRANT_PROOF values among `proofs` that name no grant: proofs of a room key (WAI
|
||||
/// age-assurance §5.5). Read by their `grant` field alone; each is checked by [`check_proof`]
|
||||
/// with [`NO_GRANT`] for the grant and its own key for the principal.
|
||||
pub fn room_key_proofs<'a>(proofs: &[&'a [u8]]) -> Vec<&'a [u8]> {
|
||||
proofs.iter().copied().filter(|p| p.len() >= 37 && p[5..37] == NO_GRANT[..]).collect()
|
||||
}
|
||||
|
||||
/// Every GRANT_PROOF value in a client's Setup Options, in order.
|
||||
pub fn proofs_of(o: &SetupOptions) -> Vec<&[u8]> {
|
||||
o.0.iter().filter(|p| p.ty == SETUP_OPTION_GRANT_PROOF).filter_map(KeyValuePair::as_bytes).collect()
|
||||
|
|
|
|||
|
|
@ -8,11 +8,16 @@
|
|||
//! of the corpus reads, with the age and the signer the corpus states;
|
||||
//! - every request of an accepted `serving` case gets the age the corpus states, from the
|
||||
//! claims the relay holds when it serves it, through [`Claim::covers`], and the track's
|
||||
//! digest is the corpus's.
|
||||
//! digest is the corpus's; a request whose session presented a proof of a room key
|
||||
//! (age-assurance §5.5) is checked as the relay checks it ([`grant_proof::check_proof`] with
|
||||
//! [`grant_proof::NO_GRANT`]), with the room's authority and clock as the relay's room keys,
|
||||
//! and gets the outcome the corpus states: served as the room's own service, served by its
|
||||
//! threshold, or refused with the proof's reason.
|
||||
//!
|
||||
//! The corpus lives beside this crate in the monorepo. If it cannot be read the test fails;
|
||||
//! it never passes on nothing.
|
||||
|
||||
use joule_moq::grant_proof;
|
||||
use joule_moq::rating::{read, track_digest, Claim};
|
||||
use joule_moq::{TrackName, TrackNamespace};
|
||||
use serde_json::Value;
|
||||
|
|
@ -26,6 +31,15 @@ fn corpus() -> Value {
|
|||
serde_json::from_str(&std::fs::read_to_string(CORPUS).expect("the age-assurance corpus")).expect("JSON")
|
||||
}
|
||||
|
||||
fn unhex(s: &str) -> Vec<u8> {
|
||||
assert!(s.len().is_multiple_of(2), "hexadecimal of whole bytes");
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hexadecimal")).collect()
|
||||
}
|
||||
|
||||
fn key32(s: &str) -> [u8; 32] {
|
||||
unhex(s).try_into().expect("32 bytes")
|
||||
}
|
||||
|
||||
fn texts(c: &Value) -> Vec<String> {
|
||||
c["claims"].as_array().map(|a| a.iter().map(|t| t.as_str().expect("a claim's text").to_string()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
|
@ -74,6 +88,9 @@ fn every_claim_reads_as_the_corpus_has_it() {
|
|||
fn every_request_of_a_serving_case_gets_its_age() {
|
||||
let doc = corpus();
|
||||
let mut requests = 0;
|
||||
let mut services = 0;
|
||||
// The relay's room keys: the room's authority and its clock.
|
||||
let room_keys = [key32(doc["principals"]["authority"].as_str().expect("the authority")), key32(doc["principals"]["clock"].as_str().expect("the clock"))];
|
||||
for c in doc["cases"].as_array().expect("cases").iter().filter(|c| c["kind"] == "serving" && c["expect"]["outcome"] == "accept") {
|
||||
let name = c["name"].as_str().expect("a name");
|
||||
let fields: Vec<String> = c["track"]["namespace"].as_array().expect("a namespace").iter().map(|f| f.as_str().expect("a field").to_string()).collect();
|
||||
|
|
@ -88,10 +105,29 @@ fn every_request_of_a_serving_case_gets_its_age() {
|
|||
let held = q["held"].as_array().expect("held").iter().map(|i| &claims[i.as_u64().expect("an index") as usize]);
|
||||
let age = held.filter(|cl| cl.covers(&digest, room.as_ref(), group)).map(Claim::age).max().unwrap_or(0);
|
||||
assert_eq!(age, want["age"].as_u64().expect("an age"), "{name}: a request at group {group}");
|
||||
let served = q["threshold"].as_u64().expect("a threshold") >= age;
|
||||
assert_eq!(served, want["outcome"] == "serve", "{name}: a request at group {group}");
|
||||
// A proof of a room key, checked as the relay checks it in SETUP (age-assurance §5.5).
|
||||
let service = match q.get("service") {
|
||||
None => Ok(false),
|
||||
Some(s) => {
|
||||
services += 1;
|
||||
let proof = unhex(s["proof"].as_str().expect("a proof"));
|
||||
let challenge = key32(s["challenge"].as_str().expect("a challenge"));
|
||||
grant_proof::GrantProof::read(&proof)
|
||||
.and_then(|p| grant_proof::check_proof(&grant_proof::NO_GRANT, &p.key, Some(&proof), &challenge, |_| false, s["context"].as_str().expect("a context")))
|
||||
.map(|p| room_keys.contains(&p.key))
|
||||
}
|
||||
};
|
||||
match service {
|
||||
Err(r) => assert_eq!((want["outcome"].as_str(), want["reason"].as_str()), (Some("refuse"), Some(r.code())), "{name}: a request at group {group}"),
|
||||
Ok(true) => assert_eq!((want["outcome"].as_str(), want["service"].as_bool()), (Some("serve"), Some(true)), "{name}: a request at group {group}"),
|
||||
Ok(false) => {
|
||||
let served = q["threshold"].as_u64().expect("a threshold") >= age;
|
||||
assert_eq!(served, want["outcome"] == "serve", "{name}: a request at group {group}");
|
||||
assert!(want.get("service").is_none(), "{name}: a request at group {group} is no room service");
|
||||
}
|
||||
}
|
||||
requests += 1;
|
||||
}
|
||||
}
|
||||
assert!(requests > 10, "{requests} requests: the corpus was reached");
|
||||
assert!(requests > 10 && services >= 10, "{requests} requests, {services} with a proof of a room key: the corpus was reached");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,76 @@ wire format may change and such changes are recorded here.
|
|||
|
||||
## v0.4.0 — current
|
||||
|
||||
### `wai.age.assurance`: the room's own service under the serving rule
|
||||
|
||||
- **The rule** ([age-assurance](extensions/age-assurance.md) §5.5): a session that proves
|
||||
possession of a room key, the authority's or the clock's, is the room's own service, and a
|
||||
relay serves it every object of the room's tracks whatever their age. A room that admits by
|
||||
age and keeps co-performance needs it: the clock follows and logs every performer's objects
|
||||
over a session of its own (co-performance §8), which holds no grant and no token. The proof
|
||||
is a `GRANT_PROOF` naming no grant (32 bytes of zero) over the session's challenge, checked as
|
||||
live-rooms §10.5.4 checks a proof; one that does not pass refuses the session with that
|
||||
section's reason, and one by a key the relay does not hold as a room key gives nothing. No
|
||||
name, token or flag a client sets makes a session the room's.
|
||||
- **The relay** (`joule-moq-relay`) checks such proofs in SETUP and exempts the session in the
|
||||
rooms of each room key it proved: an authority it honours for the room, or a room key its
|
||||
`[floor]` or `[dvr]` names. Its client presents them (`ClientOptions::room_keys`), and the
|
||||
room service's clock proves both room keys it holds. `tests/age_room_service.rs`: in a room
|
||||
rated 18 with the floor held, sessions proving the clock's key and the authority's receive
|
||||
every group; the clock's token with no proof, and a proof by a key that is no room key, are
|
||||
refused `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`.
|
||||
- **The corpus** gains the serving case `room_service` (the two room keys served at threshold
|
||||
0; sessions with no proof or a non-room key served by their thresholds; a forged signature,
|
||||
another session's challenge, another relay, a named grant and a cut proof refused with
|
||||
live-rooms §10.5.4's reasons at any threshold). `wai-rs` (`age_assurance::room_service`) and
|
||||
`age_assurance_verify.py` agree on every request; the verifier drops each of its three new
|
||||
checks in turn and four new mutated copies of the corpus, all caught.
|
||||
|
||||
### `wai.live.cowatch`: watching together in a room, with the sync measured
|
||||
|
||||
- **The extension** ([co-watch](extensions/co-watch.md)) registers a record for a room that
|
||||
watches one source together. The room authority signs a co-watch policy, `WXP1` (§3): the
|
||||
source (a WAI package by its receipt, a sequence of on-demand objects by a digest of their
|
||||
content hashes and durations, or a recording by its manifest), its duration, and the bounds of
|
||||
every rule below. A policy is replaced by one chained to it; one naming another source starts
|
||||
the controlled position again.
|
||||
- **Controls** (§5): `play`, `pause`, `seek` and `rate`, `WXC1`, as `meta` events, each effective
|
||||
at a room-clock instant ahead of its sending by at least the policy's lead and at most its
|
||||
horizon, so every viewer applies it at that instant whatever its own latency. Who may control
|
||||
is the direct power or the grant attribute `watch_control`, one bit per action, which needs no
|
||||
new power bit. Controls apply in a pinned order, by instant, then by sending, then by digest:
|
||||
of two for one instant that set the same thing, the one sent later prevails, and the other is
|
||||
stated overridden (§5.4). The controls define the controlled position at every instant (§6).
|
||||
- **Presentation statements** (§7): a viewer's player signs, under its own key and in its own
|
||||
chain, the source position it presented at a room-clock instant, its state and its rate, at
|
||||
least once a slot. A statement lifted from another chain, signed by another key, about an
|
||||
instant after its sending, or naming another source is refused.
|
||||
- **Skew** (§8): the room clock's skew statement, `WXK1`, states per window, for each viewer, the
|
||||
slots due while it was present, the samples it has and those counted (a sample within the
|
||||
policy's settle time of a join, a control or a source's start is not), and the 50th
|
||||
percentile, the policy's percentile and the greatest of its absolute errors, with a verdict
|
||||
against the bound; and the same for each pair of viewers. `held` pins the statements the clock
|
||||
had received, so one arriving later changes nothing. A verifier recomputes every entry; a
|
||||
viewer with a due slot of no statement is unknown, never in sync; and a window's statements
|
||||
together list every viewer of it.
|
||||
- **Catch-up** (§9): a pinned correction from the controlled state and the player's position:
|
||||
the controlled rate within the tolerance, a rate nudged toward the room and held within the
|
||||
policy's limits up to the seek threshold, a seek past it, and a seek or a hold while paused.
|
||||
- **Reference**: the `co_watch` module of `wai-rs` (feature `co_watch`); the corpus
|
||||
`live-conformance/cowatch/` (`wai_co_watch_vectors gen`): a watch party with a conflict at one
|
||||
instant and a late joiner, a room whose second policy starts another source, a room with a
|
||||
viewer out of sync, one unknown, one that leaves and a window stated in two statements, a gap,
|
||||
refused vectors for every reason of §12, and catch-up cases; its second implementation
|
||||
`live-conformance/co_watch_verify.py`, which recomputes every entry, requires every mutated
|
||||
copy to fail and catches each of its own checks dropped.
|
||||
- **The room service and the page** (`joule-moq-room`, `joule-moq-web`): a co-watch room serves
|
||||
its source and carries its viewers' controls and statements in a log, the clock signing each
|
||||
viewer's presence and each window's skew statements; the room page has a co-watch mode with
|
||||
shared controls and a sync indicator, and the module `web/room/cowatch.js` gives every
|
||||
catch-up case of the corpus its correction and writes its controls and statements byte for
|
||||
byte.
|
||||
|
||||
### `wai.media.upscale`: super-resolution as an exact derivation, residuals against it, and an account of sink-made pictures
|
||||
|
||||
- **The extension** ([exact-upscale](extensions/exact-upscale.md)) registers the record
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ and a role are fixed once registered.
|
|||
| `wai.live.ads` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.compose` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.coperformance` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.cowatch` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.e2ee` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.events` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.metering` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
|
|
@ -371,6 +372,10 @@ and a role are fixed once registered.
|
|||
| `WWD1` | magic | defined | forensic-watermark §8.1 |
|
||||
| `WWQ1` | magic | defined | forensic-watermark §7.1 |
|
||||
| `WWS1` | magic | defined | forensic-watermark §5.1 |
|
||||
| `WXC1` | magic | defined | co-watch §5.1 |
|
||||
| `WXK1` | magic | defined | co-watch §8.4 |
|
||||
| `WXP1` | magic | defined | co-watch §3.1 |
|
||||
| `WXS1` | magic | defined | co-watch §7.1 |
|
||||
| `X-WAI-EVENT` | playlist-attribute | defined | live-events §8.3 |
|
||||
| `X-WAI-FLOOR` | playlist-attribute | defined | http-delivery §11.1 |
|
||||
| `application/wai` | media-type | proposed | http-delivery §3 |
|
||||
|
|
|
|||
19
wai/SPEC.md
19
wai/SPEC.md
|
|
@ -1139,6 +1139,25 @@ Without an attestation a claim is its key holder's statement, which a modified p
|
|||
make falsely; the attestation bounds that to a device its attester vouched for, one token per
|
||||
impression: [`extensions/ad-presentation.md`](extensions/ad-presentation.md).
|
||||
|
||||
And for **watching together** in a room — `wai.live.cowatch`: people in different places
|
||||
watching one source in sync, with the sync measured. The room authority signs a co-watch
|
||||
policy, `WXP1`, naming the source (a WAI package, a sequence of on-demand objects, or another
|
||||
room's recording) and the bounds. A principal whose grant holds the direct power, or whose
|
||||
grant attribute `watch_control` allows the action, sends controls, `WXC1` (`play`, `pause`,
|
||||
`seek`, `rate`), as `meta` events of `wai.live.events`, each effective at a room-clock instant
|
||||
at least the policy's lead after it is sent and at most its horizon; the controls apply in a
|
||||
pinned order, the one sent later prevailing at one instant, and define the controlled position
|
||||
of the source at every instant. Each viewer's player signs, at least once a slot, a
|
||||
presentation statement, `WXS1`: the source position it presented at a room-clock instant,
|
||||
under its own key, carried in its own chain. The room clock signs a skew statement, `WXK1`,
|
||||
per window: for each viewer and each pair of viewers, the 50th percentile, the policy's
|
||||
percentile and the greatest of the errors from the controlled position, with a verdict against
|
||||
the policy's bound; a verifier recomputes every entry from the statements, a viewer with a slot
|
||||
of no statement is unknown and never in sync, and a window's statements list every viewer. A
|
||||
viewer off the position corrects by a pinned catch-up rule, a rate nudge within limits or a
|
||||
seek past a threshold:
|
||||
[`extensions/co-watch.md`](extensions/co-watch.md).
|
||||
|
||||
And for the **provenance** of a room's CMAF tracks and a device's CMAF take —
|
||||
`wai.live.provenance`: the live video provenance of the C2PA Technical Specification, version
|
||||
2.4, §19, written as it defines it. A room track packaged as CMAF travels under
|
||||
|
|
|
|||
|
|
@ -413,6 +413,30 @@ serves when *T* ≥ *A*, and refuses `below_threshold` otherwise. A session's th
|
|||
is the greatest of its admissions there: a token's (§5.2) and the `age` of each grant of the
|
||||
room it proved (§5.4).
|
||||
|
||||
**The room's own service.** A session that proves possession of a room key (live-rooms §2: the
|
||||
authority's key or the clock's) is the room's own service: its clock, which follows each
|
||||
participant's tracks to sign the lifecycle and, with co-performance, logs every performer's
|
||||
objects and signs its marks (co-performance §8). The rule does not hold such a session in that
|
||||
room: a relay serves it every object of the room's tracks, whatever their age. The proof is the
|
||||
only way a session becomes the room's service; a name, a namespace, a token or a flag the client
|
||||
sets never does.
|
||||
|
||||
1. **The proof.** The session's SETUP carries a `GRANT_PROOF` (live-rooms §10.5.2) whose `grant`
|
||||
is 32 bytes of zero and whose `key` is the room key, over the session's challenge with the
|
||||
verb `SETUP`. A service that holds both room keys may present a proof by each.
|
||||
2. **The check.** The relay checks it as live-rooms §10.5.4 checks a proof, with 32 bytes of
|
||||
zero for the grant's digest and the proof's own `key` for the principal. A proof that does
|
||||
not pass is refused with that section's reason (`pop_malformed`, `pop_wrong_grant`,
|
||||
`pop_bad_signature`, `pop_wrong_challenge`, `pop_wrong_context`), and the session with it,
|
||||
as a grant's proof that does not pass refuses it (`UNAUTHORIZED`, MOQT §12.2).
|
||||
3. **The key.** A proof that passes makes the session the room's service only where its `key`
|
||||
is a room key of the room as the relay holds it: an authority it honours for the room, or a
|
||||
room key it is configured with. A proof that passes by any other key gives the session
|
||||
nothing: it is served by its threshold.
|
||||
|
||||
The exemption is the room's, not a viewer's: a room key is held by the room's service, never
|
||||
issued to a principal, and a session that holds one already signs the room's own records.
|
||||
|
||||
### 5.6 Admission by the room's service (HTTP)
|
||||
|
||||
A room's service that issues the room's grants (live-rooms §4) over HTTP may admit by age
|
||||
|
|
@ -749,7 +773,8 @@ The codes this extension adds, each with where it is raised:
|
|||
|
||||
A verifier under this extension also raises `bad_key`, `bad_signature` and `wrong_room` (live-events
|
||||
§9) for a record's signature and its room, and every code a room verifier raises (live-rooms §11
|
||||
and the live-events codes it lists), at the room. A recording verifier raises capture-binding's
|
||||
and the live-events codes it lists), at the room; and live-rooms §10.5.4's reasons for a session's
|
||||
proof of a room key (§5.5). A recording verifier raises capture-binding's
|
||||
`recording_not_permitted` for a package of a room never recorded (live-recording §4.4 step 5).
|
||||
|
||||
## 12. Conformance
|
||||
|
|
@ -801,7 +826,12 @@ a client of RFC 9578 §6 reproduces. It has:
|
|||
its own track; requests within, before and after the span and the rise, at thresholds 0, 13, 16
|
||||
and 18, before and after the relay holds each claim; and claims the relay cannot attribute to
|
||||
the room, a viewer's about a track of another room and a stranger's about an object it does
|
||||
not carry, which cover nothing. Refused: a claim about the room signed by a
|
||||
not carry, which cover nothing. The room's own service: sessions proving the clock's key and
|
||||
the authority's, served every group at threshold 0; a session with no proof, and one proving
|
||||
the performer's key, served by their thresholds; and proofs refused, at any threshold, with
|
||||
live-rooms §10.5.4's reasons: the clock's key named under the director's signature, a proof
|
||||
over another session's challenge, one for another relay, one naming a grant and one cut short.
|
||||
Refused: a claim about the room signed by a
|
||||
viewer and one signed by the performer, a span of the performer's track rated by a viewer and
|
||||
one rated by the director (`rating_not_authorised`), and a room claim naming another room.
|
||||
- **Predicates**: over a broadcast track rated 13 with a span rated 18, a feature rated 16 and an
|
||||
|
|
|
|||
|
|
@ -586,7 +586,10 @@ cites a selection takes its parties from these fields.
|
|||
forwards. A clock that is a session of its own on the relay takes each performer's objects
|
||||
as the relay forwards them to it: an object's arrival is the instant the clock received it,
|
||||
its due instant the object's timestamp, which a performer states on the room clock (§9.1), and
|
||||
the clock signs each mark as it takes the object. Its log of the objects it took is the
|
||||
the clock signs each mark as it takes the object. In a room that admits by age, such a
|
||||
session proves a room key in its SETUP and is the room's own service, which the serving rule
|
||||
of rated tracks does not hold ([age-assurance](age-assurance.md) §5.5): the relay serves it
|
||||
every performer's objects whatever their rating. Its log of the objects it took is the
|
||||
objects of §9.1 for the room, so a verifier given the room and that log finds every mark
|
||||
founded and no object out of turn or late without its mark. Such a clock's arrival trails the
|
||||
relay's by the hop between them: a performer that sends only while it holds the floor, from a
|
||||
|
|
|
|||
663
wai/extensions/co-watch.md
Normal file
663
wai/extensions/co-watch.md
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
# WAI Extension: Co-watch
|
||||
|
||||
> Status: Draft. Registers the record `wai.live.cowatch`: people in different places watching
|
||||
> one programme together in a room, with shared control and with their sync measured. The room
|
||||
> authority signs a **co-watch policy**, `WXP1`, naming the **source** the room watches (a WAI
|
||||
> package, a sequence of on-demand objects, or another room's recording) and the bounds every
|
||||
> rule below holds to. A principal whose grant allows it sends **controls** (`play`, `pause`,
|
||||
> `seek` and `rate`), each effective at a stated instant of the room clock a little ahead of
|
||||
> when it is sent, so that every viewer applies it at that same instant whatever its own
|
||||
> latency; controls for one instant resolve by a pinned order, and the controls together
|
||||
> define the **controlled position** of the source at every instant. Each viewer's player
|
||||
> signs, periodically, a **presentation statement**, `WXS1`: the source position it was
|
||||
> presenting at a room-clock instant, under the player's key. From those statements, the room
|
||||
> clock signs a **skew statement**, `WXK1`, per window of the room clock: how far each viewer
|
||||
> was from the controlled position, and each pair of viewers from one another, as percentiles,
|
||||
> with a verdict against the policy's bound, which anyone holding the room recomputes; a viewer
|
||||
> whose statements are missing is `unknown`, never in sync. A viewer that drifts past the bound
|
||||
> corrects by a pinned **catch-up rule**: a rate nudge within limits, or a seek past a
|
||||
> threshold. Controls and statements ride in the room's events, as `meta` payloads that their
|
||||
> authors sign. No new transport. The reference implementation is the `co_watch` module of
|
||||
> `wai-rs` (feature `co_watch`); the corpus is
|
||||
> [`live-conformance/cowatch/`](../live-conformance/cowatch/), and its second implementation
|
||||
> is `live-conformance/co_watch_verify.py`.
|
||||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||||
>
|
||||
> Texts read for this revision: the live family this extension builds on, as merged
|
||||
> ([live-events](live-events.md), [live-rooms](live-rooms.md),
|
||||
> [live-recording](live-recording.md), [live-metering](live-metering.md) and
|
||||
> [ad-presentation](ad-presentation.md)), [synthesis](synthesis.md) §10.3 and
|
||||
> [delivery-telemetry](delivery-telemetry.md); RFC 8032 and FIPS 180-4. No outside text is
|
||||
> pinned: the rules of this extension are WAI's own.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
In scope: the co-watch policy, its signature, the policy in effect at each instant and the
|
||||
source it names (§3); how this extension's statements ride in a room's events, who may sign
|
||||
each, and the grant attribute it reads (§4); controls, who may send them, their rules and the
|
||||
order in which they apply (§5); the controlled position (§6); presentation statements (§7);
|
||||
windows, samples, errors, percentiles, verdicts and the skew statement (§8); the catch-up rule
|
||||
(§9); where each rule is enforced (§10); and verification (§11).
|
||||
|
||||
Out of scope: a transport; how a player fetches and decodes the source; how a player estimates
|
||||
the room clock (§7.3 states what it **SHOULD** do, and §14 what a wrong estimate does); how a
|
||||
sink renders a control, an indicator or a verdict; and the room's media and chat, which
|
||||
[live-rooms](live-rooms.md) and [live-events](live-events.md) already carry, so that a co-watch
|
||||
room is also a room whose participants may see and hear one another.
|
||||
|
||||
Relationship to the core spec: `wai.live.cowatch` is a **record** ([SPEC](../SPEC.md) §5):
|
||||
signed records a WAI tool emits and verifies. It names no payload format and is not
|
||||
dispatchable.
|
||||
|
||||
Relationship to [live-events](live-events.md): this extension changes nothing in it. Its
|
||||
statements are payloads of the `meta` kind (§4.2 there), which live-events leaves to
|
||||
applications, so every statement is signed by its author, chained, stamped with the room clock
|
||||
and rate-bounded by its author's policy (live-events §5, §6). A control is not a `control`
|
||||
event (live-events §4.9): that kind's commands are fixed, and a record of another command is
|
||||
refused by every verifier of the current revision.
|
||||
|
||||
Relationship to [live-rooms](live-rooms.md): the descriptor's and the grant's layouts are
|
||||
unchanged. A room states its co-watch policy in a record of its own, `WXP1` (§3), chained like
|
||||
live-moderation's review policy. Who may control is the direct power (live-rooms §3.3, bit 1)
|
||||
or a grant attribute (§4.4), which live-rooms §4.4 signs with the grant and leaves to the
|
||||
extensions that read it: a new power bit would be refused by every room verifier of the current
|
||||
revision (live-rooms §3.4: no power above bit 4).
|
||||
|
||||
Relationship to [live-recording](live-recording.md): a room's recording is a source (§3.5). A
|
||||
viewer that watches it through a relay fetches its tracks below their live edge, from the
|
||||
relay's DVR window or its store, which live-rooms §10.6 grants by the seek power: a co-watch
|
||||
room watching a recording gives its viewers that power.
|
||||
|
||||
Relationship to [ad-presentation](ad-presentation.md) and [synthesis](synthesis.md) §10.3: a
|
||||
presentation statement is a player's signed claim about what it presented, as a presentation
|
||||
claim and a player's log are. It is signed under the player's key as a presentation claim is
|
||||
(ad-presentation §5.3), and chained, as a player's log is, by the event chain it rides in.
|
||||
|
||||
Relationship to [delivery-telemetry](delivery-telemetry.md): a player **MAY** also report its
|
||||
`present` and `stall-start` observations there. Those are unsigned and advisory (§1.1 there); a
|
||||
presentation statement is signed, and skew is computed from statements alone.
|
||||
|
||||
## 2. Terms
|
||||
|
||||
- **Room**, **room clock**, **principal**, **event**, **chain**, **grant**, **grant in effect**,
|
||||
**room key**, **merged order**: as in live-events §2 and live-rooms §2 and §9.2. A room key
|
||||
is the room authority's key or the clock's.
|
||||
- **Statement**: the payload of a `meta` event whose first four bytes are one of the magics of
|
||||
§4.1. It is named by the digest of its event (live-events §3.2).
|
||||
- **Source**: what the room watches (§3.5). A **source position** is an instant of the source's
|
||||
own media time, in microseconds from its start.
|
||||
- **Controlled position**: the source position the controls define for an instant of the room
|
||||
clock (§6).
|
||||
- **Player**: the software that presents the source to a viewer and states what it presented
|
||||
(§7). Its **key** is the key of the principal it presents for.
|
||||
- **Viewer**: a principal, not a room key, present in the room under a grant that may
|
||||
subscribe (live-rooms §3.3).
|
||||
- **Error** of a presentation statement: its position less the controlled position at its
|
||||
instant (§8.3). **Skew** of two viewers: the difference of their errors.
|
||||
- *µs* is microseconds of room clock; a *rate* is in millionths, so 1 000 000 is normal speed.
|
||||
|
||||
Integers are little-endian; a signed integer is two's complement. A digest is SHA-256 (FIPS
|
||||
180-4), 32 bytes. Ed25519 is RFC 8032's, verified strictly ([SPEC](../SPEC.md) §7
|
||||
*Signatures*): a key that fails step 1 or 2 is `bad_key`, and a signature that fails a later
|
||||
step is `bad_signature`.
|
||||
|
||||
## 3. The co-watch policy
|
||||
|
||||
### 3.1 Layout
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `magic` | 4 | `WXP1` (ASCII) |
|
||||
| `version` | 1 | 1 in this revision |
|
||||
| `room` | 32 | the room id (live-rooms §3.2) |
|
||||
| `prev` | 32 | the digest of the policy this one replaces (§3.4); 32 zero bytes for the first |
|
||||
| `effective` | 8 | u64: the room-clock instant from which this policy applies; 0 for the first |
|
||||
| `source_type` | 1 | 1 a WAI package, 2 an object sequence, 3 a recording of a room (§3.5) |
|
||||
| `source` | 32 | the source's digest (§3.5) |
|
||||
| `duration_us` | 8 | u64: the source's duration, above 0 |
|
||||
| `lead_ms` | 4 | u32: the least time from a control's sending to its instant (§5.3), above 0 |
|
||||
| `horizon_ms` | 4 | u32: the most time from a control's sending to its instant, at least `lead_ms` |
|
||||
| `min_rate`, `max_rate` | 4 each | u32: the rates a control and the catch-up rule may set, `min_rate` above 0 and at most 1 000 000, `max_rate` at least 1 000 000 |
|
||||
| `period_ms` | 4 | u32: the slot length: a player states its presentation at least once in each (§7.3, §8.1), above 0 |
|
||||
| `window_ms` | 4 | u32: the length of a skew window (§8.1), a multiple of `period_ms` of 1 to 1000 slots |
|
||||
| `settle_ms` | 4 | u32: how long after a viewer's join, a control's instant or a source's start a sample is settling (§8.3) |
|
||||
| `bound_us` | 4 | u32: the sync bound (§8.5), above 0 |
|
||||
| `quantile` | 1 | the percentile a verdict is taken at (§8.5), 1 to 100 |
|
||||
| `tolerance_us` | 4 | u32: the error the catch-up rule leaves alone (§9), at most `bound_us` |
|
||||
| `nudge` | 4 | u32: the rate the catch-up rule adds or takes away (§9), above 0 |
|
||||
| `seek_us` | 4 | u32: the error past which the catch-up rule seeks (§9), above `bound_us` |
|
||||
| `sig` | 64 | §3.2 |
|
||||
|
||||
### 3.2 Digest and signature
|
||||
|
||||
The **digest** of a policy is SHA-256 over its record from `magic` through `seek_us`. The
|
||||
**signature** is Ed25519 by the room descriptor's `authority` over the 20 bytes
|
||||
`wai:co-watch-policy` (ASCII) and 0x01, followed by the bytes the digest covers.
|
||||
|
||||
### 3.3 Reading and rules
|
||||
|
||||
A reader reads exactly the bytes it is given as one policy, in the order of §3.1, and refuses
|
||||
at the first of these that holds:
|
||||
|
||||
1. Fewer than 4 bytes: `truncated`. A magic other than `WXP1`: `malformed`. No byte after the
|
||||
magic: `truncated`. A version other than 1: `unknown_version`.
|
||||
2. The bytes end before a field the layout requires: `truncated`.
|
||||
3. Bytes after `sig`: `malformed`.
|
||||
|
||||
It then holds the policy to the rules of §3.1's table, and refuses one that breaks any of them
|
||||
as `cw_policy_malformed`: `source_type` is 1, 2 or 3; `duration_us`, `lead_ms`, `period_ms`,
|
||||
`bound_us` and `nudge` are above 0; `horizon_ms` is at least `lead_ms`; `min_rate` is above 0
|
||||
and at most 1 000 000, and `max_rate` at least 1 000 000; `window_ms` is `period_ms` times a
|
||||
whole number from 1 to 1000; `quantile` is 1 to 100; `tolerance_us` is at most `bound_us`; and
|
||||
`seek_us` is above `bound_us`. Then its `room` is the room id (`wrong_room`), its signature
|
||||
verifies (`bad_key`, `bad_signature`), and its link holds (§3.4, `cw_policy_unlinked`).
|
||||
|
||||
### 3.4 The policy in effect
|
||||
|
||||
A verifier is given a room's policies in a list. The first has `prev` zero and `effective` 0.
|
||||
Each later one has as `prev` the digest of the one before it in the list, an `effective` above
|
||||
that one's, and, when it names the source of the one before it (the same `source_type` and
|
||||
`source`), that one's `duration_us`. A policy that breaks this is refused `cw_policy_unlinked`.
|
||||
The **policy in effect** at room-clock instant *t* is the last of the list whose `effective` is
|
||||
at or below *t*.
|
||||
|
||||
A policy is replaced, never amended. A policy that names another source than the one before it
|
||||
**starts** that source at its `effective`: the controlled position begins again (§6.4). One
|
||||
that names the same source changes the bounds alone, and the controlled position runs on.
|
||||
|
||||
### 3.5 The source
|
||||
|
||||
- **A WAI package** (1): `source` is the `receipt_hash` of the package's receipt
|
||||
([packaging](packaging.md) §9). A source position is the package's presentation time.
|
||||
- **An object sequence** (2): on-demand media objects presented in order, each its content hash
|
||||
(SHA-256 of the object's bytes as served) and its presentation duration in milliseconds (a
|
||||
u32, at least 1). `source` is SHA-256 over the 20 bytes `wai:co-watch-source` (ASCII) and
|
||||
0x01, the objects' count (u32), and for each object in order its content hash and its
|
||||
duration; `duration_us` is 1000 times the sum of the durations. The object at index *j*
|
||||
holds the source positions from 1000 times the sum of the durations before it, for 1000
|
||||
times its own. This is the form of ad-presentation §4's creative digest, under a label of
|
||||
its own.
|
||||
- **A recording of a room** (3): `source` is the digest of the recording's manifest
|
||||
([live-recording](live-recording.md) §4.1). A source position is the recorded room's clock
|
||||
less the recording's first instant.
|
||||
|
||||
A verifier given an object sequence's objects for a policy checks them against it: the policy
|
||||
names an object sequence, its `source` is their digest, and its `duration_us` is 1000 times the
|
||||
sum of their durations. Otherwise `cw_source_mismatch`. A player checks the objects it fetches
|
||||
by their content hashes, so it presents what the policy names.
|
||||
|
||||
## 4. Statements
|
||||
|
||||
### 4.1 Carriage
|
||||
|
||||
| magic | statement | section | who signs it |
|
||||
|---|---|---|---|
|
||||
| `WXC1` | control | §5.1 | a room key, or a principal whose grant in effect allows the action (§5.2) |
|
||||
| `WXS1` | presentation statement | §7.1 | a viewer's player, with the viewer's key: any principal that is not a room key |
|
||||
| `WXK1` | skew statement | §8.4 | a room key |
|
||||
|
||||
Each statement is the payload of a `meta` event: its magic, a `version` byte of 1, and the
|
||||
fields its section lists. A `meta` payload whose first four bytes are none of these magics is
|
||||
not this extension's, and a sink that does not implement it shows none of them: they are timed
|
||||
metadata for its application (live-events §4.2). Every statement is bounded by its author's
|
||||
policy (live-events §5, `max_payload`), 1024 bytes for a room key, which bounds the counts §8.4
|
||||
allows.
|
||||
|
||||
### 4.2 Reading
|
||||
|
||||
A statement that ends before a field, that holds bytes after its last field, whose `version` is
|
||||
not 1, or whose field holds a value its section does not allow, is `cw_statement_malformed`.
|
||||
|
||||
### 4.3 Who signs
|
||||
|
||||
A presentation statement by a room key, and a skew statement by any other principal, are
|
||||
`cw_not_permitted`. A control's author is checked by §5.2. A grant's powers and attributes are
|
||||
those of the author's grant in effect at its event's `t` (live-rooms §9.2 step 4); a room key
|
||||
holds every power. Since a statement is a `meta` event, the room verifier has already held it
|
||||
to its author's grant: a principal whose policy does not permit `meta` cannot send one
|
||||
(live-rooms §9.2 step 4, `kind_not_permitted`).
|
||||
|
||||
### 4.4 The control attribute
|
||||
|
||||
This extension reads one grant attribute (live-rooms §4.4):
|
||||
|
||||
| key | value | meaning |
|
||||
|---|---|---|
|
||||
| `watch_control` | a decimal integer from 1 to 15 | the actions the principal may control: bit 0 `play`, bit 1 `pause`, bit 2 `seek`, bit 3 `rate` |
|
||||
|
||||
A **decimal integer** here is 1 or 2 of the digits `0`–`9`, the first not `0`. A grant whose
|
||||
`watch_control` is not of that form, or is above 15, is refused `cw_attr_malformed`. A grant
|
||||
without it allows no action, unless its powers hold the direct power (§5.2). The authority
|
||||
gives a room's hosts every action and its guests some, or every viewer every action, as the
|
||||
room calls for.
|
||||
|
||||
### 4.5 The policy a statement is read under
|
||||
|
||||
A statement is read under the policy in effect at its event's `t`. A statement at an instant
|
||||
with no policy in effect is refused `cw_no_policy`.
|
||||
|
||||
## 5. Controls
|
||||
|
||||
### 5.1 Layout
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `magic`, `version` | 5 | `WXC1`, 1 |
|
||||
| `action` | 1 | 1 `play`, 2 `pause`, 3 `seek`, 4 `rate` |
|
||||
| `at` | 8 | u64: the room-clock instant it takes effect |
|
||||
| `arg` | 8 | u64: for `seek`, the source position to go to; for `rate`, the rate, 1 to 2^32 − 1; for `play` and `pause`, 0 |
|
||||
|
||||
A control whose `action` is outside 1 to 4, or whose `arg` is not as the table allows for its
|
||||
action, is `cw_statement_malformed`.
|
||||
|
||||
### 5.2 Who may control
|
||||
|
||||
A control's author is a room key, or a principal whose grant in effect at the control's `t`
|
||||
holds the direct power (live-rooms §3.3, bit 1), or carries `watch_control` with the action's
|
||||
bit set (§4.4). Otherwise the control is refused `control_not_permitted`.
|
||||
|
||||
### 5.3 Rules
|
||||
|
||||
A control is sent ahead of its instant, so that it reaches every viewer before the instant
|
||||
comes, and not far ahead. With *t* its event's `t`, under the policy in effect at *t*:
|
||||
|
||||
1. `at` is at least *t* + `lead_ms` × 1000: otherwise `control_not_ahead`. A control whose
|
||||
`at` is at or before its own `t`, in the past when it is sent, is so refused.
|
||||
2. `at` is at most *t* + `horizon_ms` × 1000: otherwise `control_beyond_horizon`.
|
||||
|
||||
Then, under the policy in effect at `at`, the instant the control is for:
|
||||
|
||||
3. A `seek`'s `arg` is at most `duration_us`, and a `rate`'s is from `min_rate` to `max_rate`:
|
||||
otherwise `control_out_of_range`.
|
||||
|
||||
A relay or a room service that carries the room's events **MUST NOT** carry a control these
|
||||
rules or §5.2 refuse, and a player **MUST NOT** apply one.
|
||||
|
||||
### 5.4 The order of application
|
||||
|
||||
The accepted controls apply in ascending order of `at`; of two with one `at`, the one whose
|
||||
event has the lower `t` first; of two with one `at` and one `t`, the one whose event digest is
|
||||
the lower first (the digests compared as bytes). So of two controls for one instant, the one
|
||||
sent later prevails, and between two sent at one instant the order is still fixed: every party
|
||||
holding the controls applies them in one order, whatever order it received them in.
|
||||
|
||||
Of the controls at one instant, a later one **overrides** an earlier one that sets the same
|
||||
thing: `play` and `pause` set whether the source plays, `seek` sets the position, and `rate`
|
||||
sets the rate. A control that a later one at its instant overrides is stated **overridden**,
|
||||
and every other control **applied**. Controls at one instant that set different things all
|
||||
apply: a `seek` and a `play` at one instant go to the position and play from it.
|
||||
|
||||
## 6. The controlled position
|
||||
|
||||
### 6.1 The state
|
||||
|
||||
The controlled position is a function of the room clock, kept as a **state**: whether the
|
||||
source plays, its rate, and an **anchor**: an instant and the source position at it.
|
||||
|
||||
### 6.2 The position at an instant
|
||||
|
||||
At instant *x* at or after the anchor's instant *a*, with anchor position *p*, rate *r* and
|
||||
duration *D* (the `duration_us` of the policy in effect at *x*), the controlled position is:
|
||||
|
||||
- while the source plays: *p* + ⌊(*x* − *a*) × *r* / 1 000 000⌋, or *D* if that is above *D*;
|
||||
- while it does not: *p*.
|
||||
|
||||
The product is computed without overflow (at least 128 bits).
|
||||
|
||||
### 6.3 Applying a control
|
||||
|
||||
A control at instant `at` first sets the anchor to (`at`, the controlled position at `at`),
|
||||
then:
|
||||
|
||||
- `play`: the source plays; `pause`: it does not;
|
||||
- `seek`: the anchor's position becomes `arg`;
|
||||
- `rate`: the rate becomes `arg`.
|
||||
|
||||
A source that reaches its duration while it plays stays at *D*: it has ended, and a `seek`
|
||||
before *D* sets it going again from there.
|
||||
|
||||
### 6.4 Where a source starts
|
||||
|
||||
A source starts at the first policy's `effective`, 0, and at the `effective` of each later
|
||||
policy that names another source (§3.4). At a start *s*, the state is: the source does not
|
||||
play, its rate is 1 000 000, and its anchor is (*s*, 0). A control applies to the source in
|
||||
effect at its `at`; a control whose `at` is a start applies after it.
|
||||
|
||||
The controlled position at *x* is then the state reached from the last start at or before *x*
|
||||
by applying, in the order of §5.4, every accepted control whose `at` is from that start to
|
||||
*x*, and §6.2 at *x*.
|
||||
|
||||
## 7. Presentation statements
|
||||
|
||||
### 7.1 Layout
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `magic`, `version` | 5 | `WXS1`, 1 |
|
||||
| `room` | 32 | the room id |
|
||||
| `player` | 32 | the key that signs it: the viewer's |
|
||||
| `source` | 32 | the source it presents (§3.5) |
|
||||
| `at` | 8 | u64: the room-clock instant it describes |
|
||||
| `position` | 8 | u64: the source position it was presenting at `at` |
|
||||
| `state` | 1 | 1 playing, 2 paused, 3 stalled (waiting for data), 4 ended |
|
||||
| `rate` | 4 | u32: the rate it was presenting at, above 0: the controlled rate, or a catch-up rate (§9) |
|
||||
| `sig` | 64 | Ed25519 by `player` over the 26 bytes `wai:co-watch-presentation` (ASCII) and 0x01, followed by the record from `magic` through `rate` |
|
||||
|
||||
A statement whose `state` is outside 1 to 4 or whose `rate` is 0 is `cw_statement_malformed`.
|
||||
Its **digest**, where a party names it apart from its event, is SHA-256 over the record from
|
||||
`magic` through `rate`.
|
||||
|
||||
The statement carries its own signature, beside its event's, so that it stands on its own: a
|
||||
viewer that shows a third party how closely it watched presents its statements and its grant,
|
||||
with no other event of the room.
|
||||
|
||||
### 7.2 Rules
|
||||
|
||||
A presentation statement is checked, in this order:
|
||||
|
||||
1. its `room` is the room id: otherwise `wrong_room`;
|
||||
2. its `player` is its event's principal: otherwise `presentation_wrong_key`. A statement
|
||||
lifted from another viewer's chain, or made by a key the viewer does not hold, is so
|
||||
refused;
|
||||
3. its signature verifies under `player` (§7.1): `bad_key`, `bad_signature`;
|
||||
4. its `at` is at most its event's `t`: a statement describes what was presented, never what
|
||||
will be. Otherwise `presentation_ahead`;
|
||||
5. its `source` is the `source` of the policy in effect at `at`: otherwise
|
||||
`presentation_wrong_source`.
|
||||
|
||||
### 7.3 The player
|
||||
|
||||
A player in a co-watch room **MUST**:
|
||||
|
||||
- apply each control at its `at` on its estimate of the room clock, and one that reaches it
|
||||
after its `at` at once, and present the controlled position by the catch-up rule (§9);
|
||||
- state its presentation at least once in every slot (§8.1) while it is present, and **SHOULD**
|
||||
state it twice in each, so that timer jitter leaves no slot empty;
|
||||
- state as `position` the position of the frame or sample it presented at `at`, as its media
|
||||
pipeline reports it, and as `at` the instant on its estimate of the room clock;
|
||||
- state `stalled` while it waits for data, and `ended` once it has presented the source's end.
|
||||
|
||||
A player **SHOULD** estimate the room clock against a room key's clock, by a request whose
|
||||
answer states the instant it was made at, taking the answer of least round trip of several
|
||||
and the midpoint of that round trip, and **SHOULD** measure again at least each minute. Its
|
||||
statements state its estimate: a player whose clock is off states errors that are off by as
|
||||
much (§14).
|
||||
|
||||
## 8. Skew
|
||||
|
||||
### 8.1 Windows and slots
|
||||
|
||||
A **window** is a stretch of the room clock, from `from` up to, but not including, `to`, over
|
||||
which one policy is in effect and whose length is a whole number of that policy's slots, from 1
|
||||
to 1000. Its **slots** are `from` + *j* × *P* to `from` + (*j* + 1) × *P*, *j* from 0, with *P*
|
||||
the policy's `period_ms` × 1000. A room clock that states windows regularly states them
|
||||
`window_ms` long, starting at multiples of `window_ms` × 1000; §8.4 checks any window a
|
||||
statement names.
|
||||
|
||||
### 8.2 Viewers and due slots
|
||||
|
||||
A principal is **present** from the `t` of a `join` of it to the `t` of the following `leave`
|
||||
of it, if any (live-rooms §7.2). A slot is **due** for a principal that is not a room key when
|
||||
it lies wholly within a stretch of its presence (the `join` at or before the slot's start, and
|
||||
any `leave` at or after its end), and the grant in effect for it at the slot's start may
|
||||
subscribe (live-rooms §3.3). The **viewers** of a window are the principals with a slot of the
|
||||
window due.
|
||||
|
||||
### 8.3 Samples, errors and settling
|
||||
|
||||
A skew statement names, for each viewer it lists, `held`: how many of the viewer's events the
|
||||
room clock held when it made the statement (its next `seq`). A viewer's **sample** in a slot is,
|
||||
of its accepted presentation statements whose event's `seq` is below `held` and whose `at` is
|
||||
within the slot, the first in the merged order. A statement the clock had not received when it
|
||||
made its statement, a later `seq`, is not a sample of that statement, whenever it arrived.
|
||||
|
||||
A sample's **error** is its `position` less the controlled position at its `at` (§6), a signed
|
||||
number of microseconds. A sample is **settling** when its `at` is within `settle_ms` × 1000 µs
|
||||
(of the policy in effect at its `at`) after a `join` of the viewer, the `at` of an accepted
|
||||
control, or a source's start: at or after that instant and before it plus `settle_ms` × 1000. A
|
||||
settling sample fills its slot, and is not **counted**: a viewer seeking to the room on its
|
||||
join, or every viewer applying a control, takes a moment that the bound is not about.
|
||||
|
||||
### 8.4 The skew statement
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `magic`, `version` | 5 | `WXK1`, 1 |
|
||||
| `from`, `to` | 8 each | u64: the window (§8.1) |
|
||||
| `bound_us` | 4 | u32: the bound it judges by, the policy's |
|
||||
| `quantile` | 1 | the percentile it judges at, the policy's |
|
||||
| `viewers` | 1 + 59 × *v* | a u8 count (1 to 8), then each viewer: `principal` (32), `held` (u64), `due`, `samples`, `counted` (u16 each), `p50`, `pq`, `max` (u32 each), `verdict` (u8) |
|
||||
| `pairs` | 1 + 17 × *v*(*v* − 1)/2 | a u8 count, then each pair: `a`, `b` (u8 each, indexes into `viewers`), `n` (u16), `p50`, `pq`, `max` (u32 each), `verdict` (u8) |
|
||||
|
||||
For a viewer: `due` is its due slots, `samples` the due slots it has a sample in, and `counted`
|
||||
those whose sample is counted; `p50`, `pq` and `max` are the 50th and the `quantile`-th
|
||||
percentile and the greatest of the absolute errors of its counted samples, and all three 0 when
|
||||
it has none; `verdict` is §8.5's. For a pair: `n` is the due slots in which both have a counted
|
||||
sample, and `p50`, `pq` and `max` are over the absolute values of the skew in those slots, the
|
||||
first viewer's error less the second's; `verdict` is §8.5's. A value above 2^32 − 1 is
|
||||
written 2^32 − 1.
|
||||
|
||||
A skew statement is `cw_statement_malformed` when: its viewers are not in strictly ascending
|
||||
order of `principal`; a `verdict` is outside 1 to 3; its pairs are not every pair of its
|
||||
viewers exactly once, in ascending order of (`a`, `b`) with `a` below `b`; `from` is not below
|
||||
`to`; `to` is above its event's `t`; the policy in effect at `from` is not in effect up to `to`,
|
||||
or `to` − `from` is not a whole number of its slots from 1 to 1000. Then, under the policy in effect at
|
||||
`from`:
|
||||
|
||||
1. `bound_us` and `quantile` are the policy's: otherwise `skew_mismatch`;
|
||||
2. each viewer it lists is a viewer of the window, its `held` is at most the number of that
|
||||
viewer's events the room holds, and its entry is the one recomputed from the room: otherwise
|
||||
`skew_mismatch`;
|
||||
3. each pair's entry is the one recomputed: otherwise `skew_mismatch`.
|
||||
|
||||
A statement may list a subset of a window's viewers, since one holds at most eight; it states
|
||||
the pairs among those it lists. §8.6 requires every viewer of the window to be listed by one.
|
||||
|
||||
### 8.5 Percentiles and verdicts
|
||||
|
||||
The *q*-th **percentile** of *n* values, *n* above 0, is the value at index ⌈*q* × *n* / 100⌉
|
||||
− 1 of the values in ascending order: the nearest rank. A verdict is 1 **in sync**, 2 **out of
|
||||
sync** or 3 **unknown**:
|
||||
|
||||
- a viewer is **unknown** when it has a due slot with no sample, or no counted sample; it is in
|
||||
sync when its `pq` is at most `bound_us`, and out of sync otherwise;
|
||||
- a pair is unknown when either of its viewers is unknown or its `n` is 0; it is in sync when
|
||||
its `pq` is at most `bound_us`, and out of sync otherwise.
|
||||
|
||||
"In sync" is so a claim with a stated bound, checked against the statements. A viewer whose
|
||||
statements are missing for a slot it was present for is unknown, never in sync: a player that
|
||||
stops stating is not counted as keeping up.
|
||||
|
||||
### 8.6 Completeness
|
||||
|
||||
A room clock that states a window states all of it. Once every record is read, for each window
|
||||
that an accepted skew statement names, every viewer of the window is listed by a skew statement
|
||||
of that window: otherwise `skew_incomplete`, at the window's first skew statement in the merged
|
||||
order.
|
||||
|
||||
## 9. Catch-up
|
||||
|
||||
A player that is off the controlled position corrects by this rule, so that two players given
|
||||
the same facts make the same correction, and a test reproduces it. At instant *x*, under the
|
||||
policy in effect at *x*, with *c* the controlled position, *r* the controlled rate and *e* the
|
||||
player's position less *c*:
|
||||
|
||||
- When the source does not play, or *c* is the duration (it has ended): **seek** to *c* when
|
||||
|*e*| is above `tolerance_us`, and **hold** otherwise.
|
||||
- When it plays:
|
||||
- |*e*| at most `tolerance_us`: **rate** *r*;
|
||||
- |*e*| above `tolerance_us` and at most `seek_us`: **rate** *r* − `nudge` when *e* is above
|
||||
0 (ahead: slower), and *r* + `nudge` when it is below (behind: faster), held within
|
||||
`min_rate` to `max_rate`;
|
||||
- |*e*| above `seek_us`: **seek** to *c*.
|
||||
|
||||
A player plays while the source plays and pauses while it does not; the rule sets its rate and
|
||||
its position. It evaluates the rule each time it states its presentation (§7.3), and keeps the
|
||||
outcome until the next. A rate held at a limit corrects nothing, and the error then grows until
|
||||
the rule seeks. A seek lands where the room is when it is made; what it took to land is left to
|
||||
the next evaluations' rates.
|
||||
|
||||
## 10. Where each rule is enforced
|
||||
|
||||
- **A room service or relay that carries the room's events** carries a control only when §5.2
|
||||
and §5.3 accept it, and a presentation statement only when §7.2 does (§5.3 asks it of
|
||||
controls). It carries each viewer's events in its chain order. A skew statement it signs
|
||||
names, as `held`, what it held when it made it, so a statement that arrives later changes
|
||||
nothing that statement says.
|
||||
- **The room clock** signs the lifecycle (live-rooms §7.2), and so when a viewer is present,
|
||||
and signs skew statements (§8.4): what the room states about how closely it watched
|
||||
together. The service that serves the source states the policy (§3) and serves the objects
|
||||
the policy names.
|
||||
- **The room authority** signs the policy and gives the control attribute (§4.4).
|
||||
- **Players** apply controls at their instants (§7.3), correct by the catch-up rule (§9) and
|
||||
state their presentation (§7). **Sinks** show the verdicts with the bound they are against.
|
||||
|
||||
After the fact, a verifier (§11) checks every one of these against the room: a control a
|
||||
service carried that §5 refuses, a statement signed by a key that is not its viewer's, and a
|
||||
skew statement that does not recompute or leaves a viewer out are each found out.
|
||||
|
||||
## 11. Verification
|
||||
|
||||
### 11.1 Inputs
|
||||
|
||||
A co-watch verifier is given what a room verifier is given (live-rooms §9.1: a descriptor,
|
||||
grants, a key-log head or none, and the chains), and:
|
||||
|
||||
- **policies**: the room's co-watch policies, in a list (§3.4);
|
||||
- **sources**: for any policy, the objects of the object sequence it names (§3.5), or none.
|
||||
|
||||
### 11.2 Steps
|
||||
|
||||
It runs these steps and stops at the first refusal, which it reports with the reason and where:
|
||||
|
||||
1. **The room** (live-rooms §9): a refusal, at `room` with the room verifier's own place. A gap
|
||||
is not a refusal; the steps go on, and the checks of a skew statement that recompute from the
|
||||
room (§8.4 steps 2 and 3, and §8.6) are not made: each depends on events the verifier may
|
||||
not hold.
|
||||
2. **The policies**, in the list's order (§3.3, §3.4), then the sources given (§3.5,
|
||||
`cw_source_mismatch`): at the policy's index.
|
||||
3. **The grants' attributes** (§4.4), in the grants' order: at the grant's index.
|
||||
4. **The statements**, in the merged order: each `meta` event the room accepted whose payload
|
||||
begins with a magic of §4.1: §4.2, then §4.5 (`cw_no_policy`), then §4.3, then the rules of
|
||||
its section, in the order each section gives. A skew statement's checks that recompute (§8.4
|
||||
steps 2 and 3) are made after every statement has been read, in the merged order, since its
|
||||
samples may follow it there. At the record.
|
||||
5. **Completeness** (§8.6): at the record.
|
||||
6. **The outcome** (§11.3).
|
||||
|
||||
### 11.3 Outcomes and what a verification states
|
||||
|
||||
- **refuse**: the reason and where (§11.2).
|
||||
- **gap**: no refusal, and the room has a gap: the gaps, under the code `delivery_gap`, and
|
||||
what **accept** states, computed from what the verifier holds, with each skew statement
|
||||
stated unchecked.
|
||||
- **accept**: no refusal and no gap.
|
||||
|
||||
With accept or gap, the verifier states:
|
||||
|
||||
- the policies' digests, and each start of a source: its instant and the policy's index;
|
||||
- each control, in the merged order: its digest, author, action, `at`, `arg`, and `applied` or
|
||||
`overridden`;
|
||||
- the **timeline**: each instant at which a control or a start changed the state, in order,
|
||||
with the state after it (whether the source plays, the rate, and the anchor's position);
|
||||
- each presentation statement, in the merged order: its digest, player, `at`, `position`,
|
||||
`state`, the controlled position at `at`, its error, and whether it is settling;
|
||||
- each skew statement, in the merged order: its digest, its window, and for each viewer and
|
||||
each pair the entry recomputed, or `unchecked`.
|
||||
|
||||
## 12. Reasons
|
||||
|
||||
The codes this extension adds, each with where it is raised:
|
||||
|
||||
| reason | raised at |
|
||||
|---|---|
|
||||
| `cw_policy_malformed` | §3.3 |
|
||||
| `cw_policy_unlinked` | §3.4 |
|
||||
| `cw_source_mismatch` | §3.5, §11.2 step 2 |
|
||||
| `cw_attr_malformed` | §4.4, §11.2 step 3 |
|
||||
| `cw_statement_malformed` | §4.2, §5.1, §7.1, §8.4 |
|
||||
| `cw_no_policy` | §4.5 |
|
||||
| `cw_not_permitted` | §4.3 |
|
||||
| `control_not_permitted` | §5.2 |
|
||||
| `control_not_ahead` | §5.3 step 1 |
|
||||
| `control_beyond_horizon` | §5.3 step 2 |
|
||||
| `control_out_of_range` | §5.3 step 3 |
|
||||
| `presentation_wrong_key` | §7.2 step 2 |
|
||||
| `presentation_ahead` | §7.2 step 4 |
|
||||
| `presentation_wrong_source` | §7.2 step 5 |
|
||||
| `skew_mismatch` | §8.4 |
|
||||
| `skew_incomplete` | §8.6 |
|
||||
|
||||
A co-watch verifier also raises every code a room verifier raises (live-rooms §11 and the
|
||||
live-events codes it lists), at the room; `truncated`, `malformed` and `unknown_version` for a
|
||||
policy that does not read; `wrong_room`, `bad_key` and `bad_signature` for a policy of another
|
||||
room or whose signature fails, and for a presentation statement (§7.2); and `delivery_gap` for a
|
||||
gap, which is not a refusal.
|
||||
|
||||
## 13. Conformance
|
||||
|
||||
[`live-conformance/cowatch/`](../live-conformance/cowatch/) holds the corpus, in one file,
|
||||
`vectors.json`, written from fixed seeds by `wai_co_watch_vectors gen`. It has rooms, each a
|
||||
room (descriptor, grants, key-log head or none, chains), its policies and sources, and the
|
||||
outcome §11.3 gives them with everything it states; and catch-up cases.
|
||||
|
||||
- **Accepted**: a watch party of three viewers, a host who may control every action, a guest
|
||||
who may play and pause, and a viewer who joins late and may control nothing: the host plays,
|
||||
the guest pauses and plays, the host seeks and sets a rate and sets it back, two controls for
|
||||
one instant resolve by the order of §5.4, the late joiner's first samples settle, and the
|
||||
clock's skew statements state every viewer in sync; a room whose second policy starts a new
|
||||
source; and a room in which one viewer drifts out of sync, one misses a slot and is unknown,
|
||||
one leaves partway, and a window is stated in two statements.
|
||||
- **Gap**: the watch party with one viewer's record not delivered: its skew statements
|
||||
unchecked.
|
||||
- **Refused**: each reason of §12 at least once, each vector a control of one defect: among
|
||||
them a control from a party without the control power, a control effective in the past, a
|
||||
presentation statement signed by another key than its viewer's and one lifted from another
|
||||
viewer's chain, a skew statement that does not recompute, and a viewer whose statements are
|
||||
missing reported in sync; and policies that do not read or are of another room.
|
||||
- **Catch-up cases**: a policy, the controlled state at an instant and a player's position,
|
||||
and the correction §9 gives, for every branch of the rule and both limits of the rate.
|
||||
|
||||
A conforming verifier gives every room the outcome, the reason, the place, and everything else
|
||||
the corpus states, and every catch-up case its correction. `wai_co_watch_vectors verify` and the
|
||||
test `tests/co_watch.rs` check that the file regenerates byte for byte, that `wai-rs` gives
|
||||
every room its outcome, that every reason of §12 appears, and that §12 lists exactly the
|
||||
reference's codes. `live-conformance/co_watch_verify.py` is the second implementation, written
|
||||
from this text: it shares no code with `wai-rs`; it verifies each room with the live-rooms
|
||||
corpus's second implementation (`live_rooms_verify.py`) and every signature with WAI's strict
|
||||
rule (`claim-conformance/_ed25519.py`); and it mutates copies of the corpus and requires every
|
||||
copy to fail. The browser client's co-watch module (`web/room/cowatch.js` of `joule-moq-web`)
|
||||
gives every catch-up case its correction and writes the corpus's controls and presentation
|
||||
statements byte for byte.
|
||||
|
||||
## 14. Security and privacy considerations
|
||||
|
||||
- Every control and every statement is signed by its author and rides in its author's chain,
|
||||
and every policy is signed by the room authority. A service can withhold an event but alter
|
||||
none, and a withheld event is a gap in its author's chain. A withheld control is the room's
|
||||
to notice: its author sees its control missing from what the room carries.
|
||||
- A control's instant is ahead of its sending by at least the policy's lead, so every viewer
|
||||
holds it before it applies, as long as it reaches them within the lead. One that reaches a
|
||||
viewer late is applied at once, and the viewer catches up by §9; the skew it causes settles
|
||||
within the policy's settle time, and a lead too short for the room's delivery shows as
|
||||
viewers out of sync.
|
||||
- A presentation statement is a player's claim: it proves that the holder of the viewer's key
|
||||
stated it, not that it is true. A modified player can state any position, as a player of
|
||||
ad-presentation §13 can. Skew measures how well the room's players say they kept together;
|
||||
a relay's per-hop records ([live-metering](live-metering.md) §3) of when it delivered the
|
||||
source's objects to each session are independent evidence against a statement of a position
|
||||
the session had not received.
|
||||
- A player's `at` is on its own estimate of the room clock (§7.3). An estimate that is off
|
||||
shifts its errors by as much, in both directions alike, so a viewer that keeps its own clock
|
||||
wrong is measured as out of sync, never as in sync on another's behalf.
|
||||
- The skew statement is the room clock's, and is recomputed by anyone holding the room: a clock
|
||||
that states a viewer in sync without the samples, or leaves an out-of-sync viewer out of a
|
||||
window, is refused (§8.4, §8.6). What remains to the clock is to omit a viewer's events from
|
||||
the room, which leaves a gap in the viewer's chain (live-events §6.2), and every skew
|
||||
statement of a room with a gap is unchecked.
|
||||
- A viewer's statements say what it watched and when, which is personal data. A viewer that
|
||||
needs its watching unlinkable across rooms uses a key per room, as live-events §11 has every
|
||||
principal do. Taking part in a room under a grant is taking part in its mechanics, and this
|
||||
extension adds no consent step to them. Where a regime asks the operator for a lawful basis
|
||||
for processing what a room's players state, the operator records it as a `lawful-basis`
|
||||
claim ([jwp-receipts](jwp-receipts.md) §2.4.6).
|
||||
|
|
@ -32,6 +32,7 @@ and the independent verifier of each, beside it.
|
|||
| [`presentation/`](presentation/) | [ad-presentation](../extensions/ad-presentation.md) (`wai.live.ad_presentation`) | `wai_ad_presentation_vectors gen` | `ad_presentation_verify.py` |
|
||||
| [`age/`](age/) | [age-assurance](../extensions/age-assurance.md) (`wai.age.assurance`) | `wai_age_assurance_vectors gen` | `age_assurance_verify.py` |
|
||||
| [`exploration/`](exploration/) | [exploration](../extensions/exploration.md) (`wai.media.exploration`) | `wai_exploration_vectors gen` | `exploration_verify.py` |
|
||||
| [`cowatch/`](cowatch/) | [co-watch](../extensions/co-watch.md) (`wai.live.cowatch`) | `wai_co_watch_vectors gen` | `co_watch_verify.py` |
|
||||
| [`synthesis-mark/`](synthesis-mark/) | [synthesis](../extensions/synthesis.md) §7.4 to §7.8 (the watermark scheme `wai-mark:tent64`) | `wai_synthesis_mark_vectors gen` | `synthesis_mark_verify.py` |
|
||||
| [`upscale/`](upscale/) | [exact-upscale](../extensions/exact-upscale.md) (`wai.media.upscale`) | `wai_upscale_vectors gen` | `upscale_verify.py` |
|
||||
|
||||
|
|
@ -85,4 +86,5 @@ python3 age_assurance_verify.py age
|
|||
python3 influence_verify.py influence
|
||||
python3 direction_verify.py direction
|
||||
python3 exploration_verify.py exploration
|
||||
python3 co_watch_verify.py cowatch
|
||||
```
|
||||
|
|
|
|||
|
|
@ -38,9 +38,11 @@ live-rooms corpus gives them.
|
|||
`["policy"]` or `["token"]`.
|
||||
- `serving`: a room `descriptor`, a `track` of it (`namespace`, a list of its fields, and
|
||||
`name`), `claims`, and `requests`, each a `threshold`, a `group` and `held`, the indexes of
|
||||
the claims the relay holds when it serves the request. `expect`: `accept` with the `track`'s
|
||||
digest and, for each request, the group's `age` and its `outcome`, `serve` or `refuse` with the
|
||||
`reason`; or `refuse` at `["claim", i]`.
|
||||
the claims the relay holds when it serves the request, and, for a session that presented a
|
||||
proof of a room key (§5.5), its `service`: the `proof` (hexadecimal) and the `challenge` and
|
||||
`context` the relay holds for the session. `expect`: `accept` with the `track`'s digest and,
|
||||
for each request, the group's `age` and its `outcome`, `serve` (with `service` `true` for the
|
||||
room's own service) or `refuse` with the `reason`; or `refuse` at `["claim", i]`.
|
||||
- `predicate`: a sink's `anchor` (each key's `issuer`, `threshold` and `key`), its `credentials`
|
||||
(tokens, hexadecimal), the `claims` it holds, and `objects`, each a position: a
|
||||
`content_hash`, a `track` digest and a `group`, any of them `null`. `expect`: `accept` with the
|
||||
|
|
|
|||
|
|
@ -2186,6 +2186,255 @@
|
|||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "room_service",
|
||||
"about": "the room rated 13, groups 40 to 59 at 18: sessions that prove a room key are the room's own service, served every group whatever their threshold, the clock's and the authority's; a session with no proof, and one proving the performer's key, are served by their thresholds; a proof naming the clock's key under the director's signature, one over another session's challenge, one for another relay, one naming a grant and one cut short are refused with live-rooms §10.5.4's reasons, at any threshold (§5.5)",
|
||||
"kind": "serving",
|
||||
"descriptor": "5752443101414a1af5ac939340297ae5cee394395fae97460d5ea9095e2e141af23e7bca22f40fca935082d71402fc38469165cb7f2015daa207409ddf13f1db4a204dcbde0020a16d4e5e060040420f00000000000000100000000400000000005745503101fe1f0000000000000000000000000000000000000000000000000000000000000004f4013200000040420f00000000000100000040420f000000000003086469726563746f7203010000080100000000000000000000000000000000000000000000000000000000000009706572666f726d6572030100001801000000000000000000000000000000000000000000000000000000000000067669657765720001000018000000000000000000000000000000000000000000000000000000000000000002067370656563680101046f707573000001000000000000100000004000000080bb0000010f0a63616d6572612d37323002020d617630312e302e30354d2e30380b617663312e363430303146010002800268011e0096000000f40100000005d0021e00f4010000dc05000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c22453e4161f97321c6f57731881c48dc6171252483fe9b367c52c4226acdc6d0c0a6739f5cb49e6953c79e0886b9bf1e423a70e4b605fd01757f2facb3c3800",
|
||||
"track": {
|
||||
"namespace": [
|
||||
"2fec3b33c4392640b03f65a10749e0e6ec57ee40eab80403c4e196a0cbfd200e",
|
||||
"6a4641d10e608f731fe3e6eb7d0b51cbb7f0ed4cbae9781c4642e606c905f969",
|
||||
"video"
|
||||
],
|
||||
"name": "0"
|
||||
},
|
||||
"claims": [
|
||||
"{\"inputs\":[],\"issued_at\":\"2026-10-21T00:00:00Z\",\"kind\":\"wai-rating\",\"ratings\":[{\"min_age\":13,\"scheme\":\"https://rating-a.example/scheme\",\"value\":\"13\"},{\"min_age\":12,\"scheme\":\"https://rating-b.example/scheme\",\"value\":\"12A\"}],\"record\":null,\"sig\":\"4e4cb1e604d5b923e26257aec976d52b1e73ff9d8ffbfde04f6779846d852192fbe31d5201842d5d32f590046fc2050e19269d5baa02a92b0e7e020ae145fb0d\",\"signer_pubkey\":\"414a1af5ac939340297ae5cee394395fae97460d5ea9095e2e141af23e7bca22\",\"subject\":{\"room\":\"2fec3b33c4392640b03f65a10749e0e6ec57ee40eab80403c4e196a0cbfd200e\",\"type\":\"room\"}}",
|
||||
"{\"inputs\":[],\"issued_at\":\"2026-10-21T00:00:50Z\",\"kind\":\"wai-rating\",\"ratings\":[{\"min_age\":18,\"scheme\":\"https://rating-a.example/scheme\",\"value\":\"18\"}],\"record\":null,\"sig\":\"3b6c660a1ba8951e2b6df177f7cca8c766edd59bdcf9824483f775d0fd2912e44df1ccf5abb73b59113e4cec049c7a559c25d9d4ff7102b48aeae21251668901\",\"signer_pubkey\":\"414a1af5ac939340297ae5cee394395fae97460d5ea9095e2e141af23e7bca22\",\"subject\":{\"from_group\":40,\"to_group\":59,\"track\":\"8b9fdc6da857ef7eceadbfe7dbd89df04ac6c7b729062742e3d600e4492987ac\",\"type\":\"span\"}}"
|
||||
],
|
||||
"requests": [
|
||||
{
|
||||
"threshold": 0,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "57525031010000000000000000000000000000000000000000000000000000000000000000f40fca935082d71402fc38469165cb7f2015daa207409ddf13f1db4a204dcbde5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c19005345545550202f2f72656c61792e6578616d706c652f6d6f7172ccb8b4c6a049eccec38ac8c769f9907e1c5521b9dc9505ef1baf5dbc6dfaddbe46eb15a6b99f1a89a59f45e2d3bcd46aa3a8b22543a290c749b0c39fde0a00",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"threshold": 0,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "57525031010000000000000000000000000000000000000000000000000000000000000000414a1af5ac939340297ae5cee394395fae97460d5ea9095e2e141af23e7bca225c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c19005345545550202f2f72656c61792e6578616d706c652f6d6f713fb3f6c76bae1b4dd0223f374c3d15ea95279595a0460f989d606d0d2efc0f0b6ebe9e292f74b3d5e596fcc9071e1239f333053c96a0cf473ea39814ebe58e0e",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"threshold": 0,
|
||||
"group": 10,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "57525031010000000000000000000000000000000000000000000000000000000000000000f40fca935082d71402fc38469165cb7f2015daa207409ddf13f1db4a204dcbde5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c19005345545550202f2f72656c61792e6578616d706c652f6d6f7172ccb8b4c6a049eccec38ac8c769f9907e1c5521b9dc9505ef1baf5dbc6dfaddbe46eb15a6b99f1a89a59f45e2d3bcd46aa3a8b22543a290c749b0c39fde0a00",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"threshold": 0,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"threshold": 16,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"threshold": 0,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "575250310100000000000000000000000000000000000000000000000000000000000000006a4641d10e608f731fe3e6eb7d0b51cbb7f0ed4cbae9781c4642e606c905f9695c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c19005345545550202f2f72656c61792e6578616d706c652f6d6f711818f22f6429b81a06431a6d95f967526f1b271fef040cfdbedb53d390b0bd58a58ff3b997a8d4ffab3a792b6f2491ece3e69d0030e9638db1a4ac836114f50a",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"threshold": 18,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "575250310100000000000000000000000000000000000000000000000000000000000000006a4641d10e608f731fe3e6eb7d0b51cbb7f0ed4cbae9781c4642e606c905f9695c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c19005345545550202f2f72656c61792e6578616d706c652f6d6f711818f22f6429b81a06431a6d95f967526f1b271fef040cfdbedb53d390b0bd58a58ff3b997a8d4ffab3a792b6f2491ece3e69d0030e9638db1a4ac836114f50a",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"threshold": 0,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "57525031010000000000000000000000000000000000000000000000000000000000000000f40fca935082d71402fc38469165cb7f2015daa207409ddf13f1db4a204dcbde5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c19005345545550202f2f72656c61792e6578616d706c652f6d6f71759b783728fa89d7f41acc9b05329530f580b63335fda731eb89ec0ca2089ebcac3366347d7c7a941c876997d75ea8a8cb3c2a05bc599f97947398b34e634005",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"threshold": 18,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "57525031010000000000000000000000000000000000000000000000000000000000000000f40fca935082d71402fc38469165cb7f2015daa207409ddf13f1db4a204dcbde5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c19005345545550202f2f72656c61792e6578616d706c652f6d6f71759b783728fa89d7f41acc9b05329530f580b63335fda731eb89ec0ca2089ebcac3366347d7c7a941c876997d75ea8a8cb3c2a05bc599f97947398b34e634005",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"threshold": 0,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "57525031010000000000000000000000000000000000000000000000000000000000000000f40fca935082d71402fc38469165cb7f2015daa207409ddf13f1db4a204dcbde5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d5d19005345545550202f2f72656c61792e6578616d706c652f6d6f719a64d3e824f9da819baf4e6f6276ece6aba900e1ba7c077db70ec878d728420fc37e5752f31cd6a0afe6b375826259cf044e1bc0074a1ef2849c52b75aee060b",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"threshold": 0,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "57525031010000000000000000000000000000000000000000000000000000000000000000f40fca935082d71402fc38469165cb7f2015daa207409ddf13f1db4a204dcbde5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c1f005345545550202f2f6f746865722d72656c61792e6578616d706c652f6d6f712e5f3dca7f6a02aa563658bd54f5e7274278bbf9fa778b68e5205d63ba3bd430774b0a11da9365e465085a888dc3a3e6b8ba90b084ec578851b2ace1a7375203",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"threshold": 0,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "575250310112e58ceb354ef6d49a0ebcc3c9ac4934c764570a7f2f6b99d3ba70456298e408f40fca935082d71402fc38469165cb7f2015daa207409ddf13f1db4a204dcbde5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c19005345545550202f2f72656c61792e6578616d706c652f6d6f71233deabfe8e014378e01821d8faa2225d92ad7572d8baaf8343929c56e31430f83a29654657c7378d2f630068a937d9162523813ebf814861f5bc714b99fc504",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
},
|
||||
{
|
||||
"threshold": 0,
|
||||
"group": 45,
|
||||
"held": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"service": {
|
||||
"proof": "57525031010000000000000000000000000000000000000000000000000000000000000000f40fca935082d71402fc38469165cb7f2015daa207409ddf13f1db4a204dcbde5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c19005345545550202f2f72656c61792e6578616d706c652f6d6f7172ccb8b4c6a049eccec38ac8c769f9907e1c5521b9dc9505ef1baf5dbc6dfaddbe46eb15a6b99f1a89a59f45e2d3bcd46aa3a8b22543a290c749b0c39fde0a",
|
||||
"challenge": "5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c5c",
|
||||
"context": "SETUP //relay.example/moq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expect": {
|
||||
"outcome": "accept",
|
||||
"track": "8b9fdc6da857ef7eceadbfe7dbd89df04ac6c7b729062742e3d600e4492987ac",
|
||||
"requests": [
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "serve",
|
||||
"service": true
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "serve",
|
||||
"service": true
|
||||
},
|
||||
{
|
||||
"age": 13,
|
||||
"outcome": "serve",
|
||||
"service": true
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "refuse",
|
||||
"reason": "below_threshold"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "refuse",
|
||||
"reason": "below_threshold"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "refuse",
|
||||
"reason": "below_threshold"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "serve"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "refuse",
|
||||
"reason": "pop_bad_signature"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "refuse",
|
||||
"reason": "pop_bad_signature"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "refuse",
|
||||
"reason": "pop_wrong_challenge"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "refuse",
|
||||
"reason": "pop_wrong_context"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "refuse",
|
||||
"reason": "pop_wrong_grant"
|
||||
},
|
||||
{
|
||||
"age": 18,
|
||||
"outcome": "refuse",
|
||||
"reason": "pop_malformed"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unattributed_claims",
|
||||
"about": "beside the room's rating, a viewer's claim about a track of another room and a stranger's claim about an object the relay does not carry: neither honoured nor refused, they cover nothing served (§3.6)",
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ and never runs it. Everything the extension defines is written here:
|
|||
- the TokenChallenge (RFC 9577 §2.1.1) and the token (RFC 9577 §2.2.1, RFC 9578 §6), the six
|
||||
steps of §5.2 in their order, and RSASSA-PSS-VERIFY (RFC 8017 §8.1.2, EMSA-PSS-VERIFY of
|
||||
§9.1.2 and MGF1 of Appendix B.2.1) with SHA-384 and a 48-byte salt, as RFC 9578 §6.4 has it;
|
||||
- serving a rated track (§5.5), the sink's age state and predicate (§6);
|
||||
- serving a rated track (§5.5), with the room's own service a session's proof of a room key
|
||||
makes (live-rooms §10.5.4, the grant's digest 32 bytes of zero), and the sink's age state and
|
||||
predicate (§6);
|
||||
- the admission receipt (§7): §7.3's reading, `receipt_identifies` first, its checks in their
|
||||
order, and the cells §7.2 writes from the admissions;
|
||||
- the grant attribute `age` (§5.4) and the minors' profile (§8.1, §8.2): the descriptor's rules 1
|
||||
|
|
@ -140,6 +142,9 @@ CHECKS = {
|
|||
"room_claim_room": (True, "a room claim names the room"),
|
||||
"attribution": (True, "a claim the relay cannot attribute is neither honoured nor refused"),
|
||||
"serve_rule": (True, "a request is served only at a threshold at least its group's age"),
|
||||
"service_room_key": (True, "§5.5: a proof of a room key makes the room's own service only by a room key"),
|
||||
"service_proof": (True, "§5.5: a proof of a room key passes live-rooms §10.5.4, or the session is refused"),
|
||||
"service_no_grant": (True, "§5.5: a proof of a room key names no grant"),
|
||||
# §4.1, §4.2
|
||||
"issuers_count": (True, "1 to 16 issuers"),
|
||||
"issuer_name": (False, "an issuer name is 1 to 255 bytes of ! to ~"),
|
||||
|
|
@ -1172,13 +1177,48 @@ def case_serving(case, doc):
|
|||
for q in case["requests"]:
|
||||
held = [claims[k] for k in q["held"] if claims[k]["honoured"]]
|
||||
age = age_under(held, None, track, q["group"], room)
|
||||
if q["threshold"] >= age or not on("serve_rule"):
|
||||
try:
|
||||
own = "service" in q and room_service(d, q["service"])
|
||||
except Refuse as e:
|
||||
out.append({"age": age, "outcome": "refuse", "reason": e.reason})
|
||||
continue
|
||||
if own:
|
||||
out.append({"age": age, "outcome": "serve", "service": True})
|
||||
elif q["threshold"] >= age or not on("serve_rule"):
|
||||
out.append({"age": age, "outcome": "serve"})
|
||||
else:
|
||||
out.append({"age": age, "outcome": "refuse", "reason": "below_threshold"})
|
||||
return {"track": track.hex(), "requests": out}
|
||||
|
||||
|
||||
NO_GRANT = bytes(32)
|
||||
|
||||
|
||||
def room_service(d, s):
|
||||
"""§5.5: the proof of a room key a session presented, at a relay holding the session's
|
||||
`challenge` and receiving `context`. It is checked as live-rooms §10.5.4 checks a proof,
|
||||
the grant's digest 32 bytes of zero and the principal the proof's own key: a proof that
|
||||
does not pass refuses the session with that section's reason. True when it passes by a room
|
||||
key (the authority's or the clock's): the room's own service, which the serving rule does
|
||||
not hold; False for any other key, a session served by its threshold."""
|
||||
proof = bytes.fromhex(s["proof"])
|
||||
challenge, context = bytes.fromhex(s["challenge"]), s["context"]
|
||||
try:
|
||||
p = LRV.read_proof(proof) # step 2
|
||||
except LEV.Refuse as e:
|
||||
raise Refuse(e.reason)
|
||||
if p["grant"] != NO_GRANT and on("service_no_grant"): # step 3
|
||||
raise Refuse("pop_wrong_grant")
|
||||
if on("service_proof"):
|
||||
if ED.verify(p["key"], LRV.PROOF_DOMAIN + p["body"], p["sig"]) is not None: # step 5
|
||||
raise Refuse("pop_bad_signature")
|
||||
if p["challenge"] != challenge: # step 6: none consumed
|
||||
raise Refuse("pop_wrong_challenge")
|
||||
if p["context"] != context: # step 7
|
||||
raise Refuse("pop_wrong_context")
|
||||
return p["key"] in (d["authority"], d["clock"]) or not on("service_room_key")
|
||||
|
||||
|
||||
def case_predicate(case, doc):
|
||||
anchor = [{"issuer": a["issuer"], "threshold": a["threshold"], "key": bytes.fromhex(a["key"])} for a in case["anchor"]]
|
||||
state = 0
|
||||
|
|
@ -1454,6 +1494,14 @@ def mutations(doc):
|
|||
mutate("the minor's join withheld", lambda cs, d: cs["director_records"]["chains"].__setitem__(0, cs["director_records"]["chains"][0][1:])),
|
||||
mutate("the narrowed grant's refusal stated at the minor's join",
|
||||
lambda cs, d: cs["grant_narrowed_to_watch_and_chat"]["expect"].update(at=["record", 0, 0])),
|
||||
mutate("a byte of the signature of the clock's proof of its key",
|
||||
lambda cs, d: cs["room_service"]["requests"][0]["service"].update(proof=flip(cs["room_service"]["requests"][0]["service"]["proof"], 150))),
|
||||
mutate("the session with no proof stated as the room's service",
|
||||
lambda cs, d: cs["room_service"]["expect"]["requests"][3].update(outcome="serve", service=True)),
|
||||
mutate("the performer's proof stated as a room key's",
|
||||
lambda cs, d: cs["room_service"]["expect"]["requests"][5].update(outcome="serve", service=True)),
|
||||
mutate("the forged proof stated as the clock's service",
|
||||
lambda cs, d: cs["room_service"]["expect"]["requests"][7].update(outcome="serve", service=True)),
|
||||
mutate("an unattributed claim stated as covering the request",
|
||||
lambda cs, d: cs["unattributed_claims"]["expect"]["requests"][0].update(age=18, outcome="refuse", reason="below_threshold")),
|
||||
mutate("the anchor's 16 key listed under its own issuer",
|
||||
|
|
|
|||
794
wai/live-conformance/co_watch_verify.py
Normal file
794
wai/live-conformance/co_watch_verify.py
Normal file
|
|
@ -0,0 +1,794 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Independent verifier of the co-watch corpus (extensions/co-watch.md).
|
||||
|
||||
Written from the extension's text. It shares no code with wai-rs, which generates the corpus,
|
||||
and never runs it. It checks Ed25519 signatures with ../claim-conformance/_ed25519.py, WAI's
|
||||
strict rule (SPEC §7 *Signatures*) written from RFC 8032 with the standard library. Each room
|
||||
is verified by the live-rooms corpus's own second implementation, live_rooms_verify.py
|
||||
(live-rooms §9), written from its text alone. Everything else is here:
|
||||
|
||||
- the co-watch policy (§3): its layout, rules, signature and chain, and the object-sequence
|
||||
source digest (§3.5);
|
||||
- the statements (§4): reading, who signs, the control attribute (§4.4);
|
||||
- controls (§5): who may control, the lead and the horizon, the range, and the order of
|
||||
application with the overridden ones;
|
||||
- the controlled position (§6);
|
||||
- presentation statements (§7);
|
||||
- windows, due slots, samples, settling, percentiles, verdicts, the skew statement and
|
||||
completeness (§8);
|
||||
- the catch-up rule (§9).
|
||||
|
||||
For every room it requires the outcome, reason and place, and everything else the corpus
|
||||
states (§11.3); for every catch-up case, its correction; of the corpus, that every reason of
|
||||
§12 appears in it and that no other file is there. Then it mutates copies of the corpus, one
|
||||
defect each, and requires every copy to fail; and it drops each of its own checks in turn and
|
||||
requires the corpus to catch the weakened verifier.
|
||||
|
||||
python3 co_watch_verify.py <live-conformance/cowatch>
|
||||
python3 co_watch_verify.py --room <room.json>
|
||||
|
||||
With `--room`, it verifies one room given in a vector's form (descriptor, grants, chains,
|
||||
policies, sources) and prints what §11.3 states of it, as JSON: a room a deployment kept,
|
||||
checked from the text alone.
|
||||
|
||||
Exit status 0 only if every case verifies, every mutated copy fails and every weakened
|
||||
verifier is caught. Python 3.8 or later; standard library only.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
|
||||
import live_events_verify as LEV # noqa: E402
|
||||
import live_rooms_verify as LRV # noqa: E402
|
||||
import _ed25519 as ED # noqa: E402
|
||||
|
||||
Refuse = LEV.Refuse
|
||||
|
||||
# Ed25519 is a pure function of its inputs: each signature is verified once, and every later
|
||||
# pass over the corpus (its mutated copies, the weakened verifiers) reads the answer back.
|
||||
_verified = {}
|
||||
_verify = ED.verify
|
||||
|
||||
|
||||
def _memo_verify(pk, msg, sig, skip=()):
|
||||
k = (bytes(pk), bytes(msg), bytes(sig), tuple(skip))
|
||||
if k not in _verified:
|
||||
_verified[k] = _verify(pk, msg, sig, skip)
|
||||
return _verified[k]
|
||||
|
||||
|
||||
ED.verify = _memo_verify
|
||||
EXTENSION = os.path.join(HERE, "..", "extensions", "co-watch.md")
|
||||
|
||||
POLICY_MAGIC, CONTROL_MAGIC, PRESENTATION_MAGIC, SKEW_MAGIC = b"WXP1", b"WXC1", b"WXS1", b"WXK1"
|
||||
POLICY_DOMAIN = b"wai:co-watch-policy\x01" # §3.2
|
||||
PRESENTATION_DOMAIN = b"wai:co-watch-presentation\x01" # §7.1
|
||||
SOURCE_DOMAIN = b"wai:co-watch-source\x01" # §3.5
|
||||
ZERO = bytes(32)
|
||||
NORMAL = 1000000
|
||||
META, JOIN, LEAVE, EXCHANGE = 0x01, 0x10, 0x11, 0x14
|
||||
DIRECT = 2 # live-rooms §3.3, bit 1
|
||||
PLAY, PAUSE, SEEK, RATE = 1, 2, 3, 4
|
||||
ACTIONS = {1: "play", 2: "pause", 3: "seek", 4: "rate"}
|
||||
STATES = {1: "playing", 2: "paused", 3: "stalled", 4: "ended"}
|
||||
VERDICTS = {1: "in_sync", 2: "out_of_sync", 3: "unknown"}
|
||||
IN_SYNC, OUT_OF_SYNC, UNKNOWN = 1, 2, 3
|
||||
U32 = (1 << 32) - 1
|
||||
|
||||
# Each check this verifier makes, by name; the self-test drops one at a time (§13).
|
||||
DROP = set()
|
||||
|
||||
|
||||
def on(name):
|
||||
return name not in DROP
|
||||
|
||||
|
||||
class Stop(Exception):
|
||||
def __init__(self, reason, at):
|
||||
super().__init__(reason)
|
||||
self.reason, self.at = reason, at
|
||||
|
||||
|
||||
def sha(*parts):
|
||||
h = hashlib.sha256()
|
||||
for p in parts:
|
||||
h.update(p)
|
||||
return h.digest()
|
||||
|
||||
|
||||
def reasons_of_extension():
|
||||
"""§12's table: the codes this extension adds, in order."""
|
||||
with open(EXTENSION, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
sec = text[text.index("\n## 12. Reasons"):]
|
||||
sec = sec[:sec.index("\n## ", 1)]
|
||||
return re.findall(r"^\| `([a-z_0-9]+)` \|", sec, re.M)
|
||||
|
||||
|
||||
REASONS = reasons_of_extension()
|
||||
|
||||
|
||||
class Rd:
|
||||
def __init__(self, b, p=0):
|
||||
self.b, self.p = b, p
|
||||
|
||||
def take(self, n, why):
|
||||
if self.p + n > len(self.b):
|
||||
raise Refuse(why)
|
||||
s = self.b[self.p:self.p + n]
|
||||
self.p += n
|
||||
return s
|
||||
|
||||
def u8(self, why):
|
||||
return self.take(1, why)[0]
|
||||
|
||||
def u16(self, why):
|
||||
return struct.unpack("<H", self.take(2, why))[0]
|
||||
|
||||
def u32(self, why):
|
||||
return struct.unpack("<I", self.take(4, why))[0]
|
||||
|
||||
def u64(self, why):
|
||||
return struct.unpack("<Q", self.take(8, why))[0]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §3 the policy
|
||||
|
||||
def read_policy(b):
|
||||
"""§3.3: steps 1 to 3, then the rules of §3.1's table."""
|
||||
if len(b) < 4:
|
||||
raise Refuse("truncated")
|
||||
if b[:4] != POLICY_MAGIC:
|
||||
raise Refuse("malformed")
|
||||
if len(b) < 5:
|
||||
raise Refuse("truncated")
|
||||
if b[4] != 1:
|
||||
raise Refuse("unknown_version")
|
||||
r, t = Rd(b, 5), "truncated"
|
||||
p = {"room": r.take(32, t), "prev": r.take(32, t), "effective": r.u64(t), "source_type": r.u8(t),
|
||||
"source": r.take(32, t), "duration_us": r.u64(t)}
|
||||
for k in ("lead_ms", "horizon_ms", "min_rate", "max_rate", "period_ms", "window_ms", "settle_ms", "bound_us"):
|
||||
p[k] = r.u32(t)
|
||||
p["quantile"] = r.u8(t)
|
||||
for k in ("tolerance_us", "nudge", "seek_us"):
|
||||
p[k] = r.u32(t)
|
||||
p["body"] = b[:r.p]
|
||||
p["sig"] = r.take(64, t)
|
||||
if r.p != len(b):
|
||||
raise Refuse("malformed")
|
||||
if on("policy_rules"):
|
||||
ok = (p["source_type"] in (1, 2, 3) and p["duration_us"] > 0 and p["lead_ms"] > 0 and p["period_ms"] > 0
|
||||
and p["bound_us"] > 0 and p["nudge"] > 0 and p["horizon_ms"] >= p["lead_ms"]
|
||||
and 0 < p["min_rate"] <= NORMAL <= p["max_rate"]
|
||||
and p["window_ms"] % p["period_ms"] == 0 and 1 <= p["window_ms"] // p["period_ms"] <= 1000
|
||||
and 1 <= p["quantile"] <= 100 and p["tolerance_us"] <= p["bound_us"] and p["seek_us"] > p["bound_us"])
|
||||
if not ok:
|
||||
raise Refuse("cw_policy_malformed")
|
||||
p["digest"] = sha(p["body"])
|
||||
return p
|
||||
|
||||
|
||||
def source_digest(objects):
|
||||
"""§3.5: an object sequence's digest."""
|
||||
out = SOURCE_DOMAIN + struct.pack("<I", len(objects))
|
||||
for o in objects:
|
||||
out += bytes.fromhex(o["content_hash"]) + struct.pack("<I", o["duration_ms"])
|
||||
return sha(out)
|
||||
|
||||
|
||||
def check_policies(d, blobs, sources):
|
||||
"""§3.3, §3.4, then §3.5 for the sources given: the policies, or Stop at ("policy", i)."""
|
||||
held = []
|
||||
for i, b in enumerate(blobs):
|
||||
try:
|
||||
p = read_policy(b)
|
||||
if p["room"] != d["room"]:
|
||||
raise Refuse("wrong_room")
|
||||
bad = ED.verify(d["authority"], POLICY_DOMAIN + p["body"], p["sig"])
|
||||
if bad:
|
||||
raise Refuse(bad)
|
||||
if on("policy_link"):
|
||||
if not held:
|
||||
linked = p["prev"] == ZERO and p["effective"] == 0
|
||||
else:
|
||||
q = held[-1]
|
||||
same = (p["source_type"], p["source"]) == (q["source_type"], q["source"])
|
||||
linked = p["prev"] == q["digest"] and p["effective"] > q["effective"] and (not same or p["duration_us"] == q["duration_us"])
|
||||
if not linked:
|
||||
raise Refuse("cw_policy_unlinked")
|
||||
objs = sources[i] if i < len(sources) else None
|
||||
if objs is not None and on("sources"):
|
||||
total = sum(o["duration_ms"] for o in objs) * 1000
|
||||
if p["source_type"] != 2 or source_digest(objs) != p["source"] or total != p["duration_us"]:
|
||||
raise Refuse("cw_source_mismatch")
|
||||
except Refuse as e:
|
||||
raise Stop(e.reason, ["policy", i])
|
||||
held.append(p)
|
||||
return held
|
||||
|
||||
|
||||
def policy_at(ps, t):
|
||||
"""§3.4: the index of the last policy whose effective is at or below t, or None."""
|
||||
k = None
|
||||
for i, p in enumerate(ps):
|
||||
if p["effective"] <= t:
|
||||
k = i
|
||||
return k
|
||||
|
||||
|
||||
def starts_of(ps):
|
||||
"""§3.4, §6.4: (instant, policy index) of each source's start."""
|
||||
out = []
|
||||
for i, p in enumerate(ps):
|
||||
if i == 0 or (p["source_type"], p["source"]) != (ps[i - 1]["source_type"], ps[i - 1]["source"]):
|
||||
out.append((p["effective"], i))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §4 statements
|
||||
|
||||
def control_bits(g):
|
||||
"""§4.4: the actions `watch_control` allows, None without it; Refuse when malformed."""
|
||||
for k, v in LRV.read_attrs(g["attrs"]) or []:
|
||||
if k == "watch_control":
|
||||
if not re.fullmatch(r"[1-9][0-9]?", v) or int(v) > 15:
|
||||
raise Refuse("cw_attr_malformed")
|
||||
return int(v)
|
||||
return None
|
||||
|
||||
|
||||
def may_control(g, action):
|
||||
"""§5.2."""
|
||||
if g["powers"] & DIRECT:
|
||||
return True
|
||||
try:
|
||||
bits = control_bits(g)
|
||||
except Refuse:
|
||||
return False
|
||||
return bits is not None and bits >> (action - 1) & 1 == 1
|
||||
|
||||
|
||||
def read_statement(payload):
|
||||
"""§4.1, §4.2: None for a payload not this extension's; else (kind, fields)."""
|
||||
if len(payload) < 4 or payload[:4] not in (CONTROL_MAGIC, PRESENTATION_MAGIC, SKEW_MAGIC):
|
||||
return None
|
||||
bad = "cw_statement_malformed"
|
||||
if len(payload) < 5 or payload[4] != 1:
|
||||
raise Refuse(bad)
|
||||
r = Rd(payload, 5)
|
||||
magic = payload[:4]
|
||||
if magic == CONTROL_MAGIC: # §5.1
|
||||
s = ("control", {"action": r.u8(bad), "at": r.u64(bad), "arg": r.u64(bad)})
|
||||
a, arg = s[1]["action"], s[1]["arg"]
|
||||
if a in (PLAY, PAUSE):
|
||||
ok = arg == 0
|
||||
elif a == SEEK:
|
||||
ok = True
|
||||
elif a == RATE:
|
||||
ok = 1 <= arg <= U32
|
||||
else:
|
||||
ok = False
|
||||
if not ok:
|
||||
raise Refuse(bad)
|
||||
elif magic == PRESENTATION_MAGIC: # §7.1
|
||||
f = {"room": r.take(32, bad), "player": r.take(32, bad), "source": r.take(32, bad), "at": r.u64(bad),
|
||||
"position": r.u64(bad), "state": r.u8(bad), "rate": r.u32(bad)}
|
||||
f["body"] = payload[:r.p]
|
||||
f["sig"] = r.take(64, bad)
|
||||
if f["state"] not in STATES or f["rate"] == 0:
|
||||
raise Refuse(bad)
|
||||
s = ("presentation", f)
|
||||
else: # §8.4
|
||||
f = {"from": r.u64(bad), "to": r.u64(bad), "bound_us": r.u32(bad), "quantile": r.u8(bad)}
|
||||
nv = r.u8(bad)
|
||||
if not 1 <= nv <= 8:
|
||||
raise Refuse(bad)
|
||||
f["viewers"] = []
|
||||
for _ in range(nv):
|
||||
e = {"principal": r.take(32, bad), "held": r.u64(bad), "due": r.u16(bad), "samples": r.u16(bad),
|
||||
"counted": r.u16(bad), "p50": r.u32(bad), "pq": r.u32(bad), "max": r.u32(bad), "verdict": r.u8(bad)}
|
||||
if e["verdict"] not in VERDICTS:
|
||||
raise Refuse(bad)
|
||||
f["viewers"].append(e)
|
||||
keys = [e["principal"] for e in f["viewers"]]
|
||||
if any(keys[i] >= keys[i + 1] for i in range(len(keys) - 1)):
|
||||
raise Refuse(bad)
|
||||
want = [(a, b) for a in range(nv) for b in range(a + 1, nv)]
|
||||
if r.u8(bad) != len(want):
|
||||
raise Refuse(bad)
|
||||
f["pairs"] = []
|
||||
for a, b in want:
|
||||
e = {"a": r.u8(bad), "b": r.u8(bad), "n": r.u16(bad), "p50": r.u32(bad), "pq": r.u32(bad),
|
||||
"max": r.u32(bad), "verdict": r.u8(bad)}
|
||||
if (e["a"], e["b"]) != (a, b) or e["verdict"] not in VERDICTS:
|
||||
raise Refuse(bad)
|
||||
f["pairs"].append(e)
|
||||
s = ("skew", f)
|
||||
if r.p != len(payload):
|
||||
raise Refuse(bad)
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §5.4, §6 the controlled position
|
||||
|
||||
def position(st, x, duration):
|
||||
"""§6.2."""
|
||||
if not st["playing"]:
|
||||
return st["pos"]
|
||||
return min(duration, st["pos"] + (x - st["anchor"]) * st["rate"] // 1000000)
|
||||
|
||||
|
||||
class Timeline:
|
||||
"""§5.4 and §6 over the accepted controls (each with its event's t and digest)."""
|
||||
|
||||
def __init__(self, policies, controls):
|
||||
self.policies = policies
|
||||
self.starts = starts_of(policies)
|
||||
self.controls = sorted(controls, key=lambda c: (c["at"], c["t"], c["digest"]))
|
||||
self.overridden = set()
|
||||
aspect = {PLAY: 0, PAUSE: 0, SEEK: 1, RATE: 2}
|
||||
for i, c in enumerate(self.controls):
|
||||
for d in self.controls[i + 1:]:
|
||||
if d["at"] != c["at"]:
|
||||
break
|
||||
if aspect[d["action"]] == aspect[c["action"]]:
|
||||
self.overridden.add(c["digest"])
|
||||
events = [(s, 0, k, None) for k, (s, _) in enumerate(self.starts)]
|
||||
events += [(c["at"], 1, k, c) for k, c in enumerate(self.controls)]
|
||||
events.sort(key=lambda e: (e[0], e[1], e[2]))
|
||||
self.points, st = [], None
|
||||
for x, what, _, c in events:
|
||||
if what == 0:
|
||||
st = {"playing": False, "rate": NORMAL, "anchor": x, "pos": 0}
|
||||
else:
|
||||
p = position(st, x, self.duration_at(x))
|
||||
st = dict(st, anchor=x, pos=p)
|
||||
if c["action"] == PLAY:
|
||||
st["playing"] = True
|
||||
elif c["action"] == PAUSE:
|
||||
st["playing"] = False
|
||||
elif c["action"] == SEEK:
|
||||
st["pos"] = c["arg"]
|
||||
else:
|
||||
st["rate"] = c["arg"]
|
||||
self.points.append((x, st))
|
||||
|
||||
def duration_at(self, x):
|
||||
k = policy_at(self.policies, x)
|
||||
return 0 if k is None else self.policies[k]["duration_us"]
|
||||
|
||||
def state_at(self, x):
|
||||
st = None
|
||||
for at, s in self.points:
|
||||
if at <= x:
|
||||
st = s
|
||||
return st
|
||||
|
||||
def position_at(self, x):
|
||||
st = self.state_at(x)
|
||||
return 0 if st is None else position(st, x, self.duration_at(x))
|
||||
|
||||
def settle_points(self):
|
||||
return sorted({c["at"] for c in self.controls} | {s for s, _ in self.starts})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §9 catch-up
|
||||
|
||||
def catch_up(p, playing, rate, controlled, duration, presented):
|
||||
"""§9."""
|
||||
e = presented - controlled
|
||||
if not playing or controlled >= duration:
|
||||
return {"action": "seek", "value": controlled} if abs(e) > p["tolerance_us"] else {"action": "hold"}
|
||||
if abs(e) <= p["tolerance_us"]:
|
||||
return {"action": "rate", "value": rate}
|
||||
if abs(e) > p["seek_us"]:
|
||||
return {"action": "seek", "value": controlled}
|
||||
r = rate - p["nudge"] if e > 0 else rate + p["nudge"]
|
||||
return {"action": "rate", "value": max(p["min_rate"], min(p["max_rate"], r))}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §8 skew
|
||||
|
||||
def window_policy(ps, frm, to):
|
||||
"""§8.1: the policy of the window frm–to, or None when it is not one."""
|
||||
if frm >= to:
|
||||
return None
|
||||
k = policy_at(ps, frm)
|
||||
if k is None or policy_at(ps, to - 1) != k:
|
||||
return None
|
||||
per = ps[k]["period_ms"] * 1000
|
||||
n = (to - frm) // per
|
||||
return ps[k] if (to - frm) % per == 0 and 1 <= n <= 1000 else None
|
||||
|
||||
|
||||
def percentile(vals, q):
|
||||
"""§8.5: nearest rank; 0 for none."""
|
||||
if not vals:
|
||||
return 0
|
||||
v = sorted(vals)
|
||||
rank = -(-q * len(v) // 100)
|
||||
return v[max(rank, 1) - 1]
|
||||
|
||||
|
||||
class Facts:
|
||||
"""Presence (§8.2), grants, samples (§8.3) and the timeline."""
|
||||
|
||||
def __init__(self, timeline, stretches, grant_changes, samples):
|
||||
self.tl, self.stretches, self.grants, self.samples = timeline, stretches, grant_changes, samples
|
||||
|
||||
def subscribes_at(self, w, x):
|
||||
last = None
|
||||
for g in self.grants:
|
||||
if g["principal"] == w and g["t"] <= x:
|
||||
last = g
|
||||
return last is not None and last["subscribe"]
|
||||
|
||||
def due(self, p, frm, to, w):
|
||||
per = p["period_ms"] * 1000
|
||||
out = []
|
||||
for j in range((to - frm) // per):
|
||||
s = frm + j * per
|
||||
inside = any(st["principal"] == w and st["join"] <= s and (st["leave"] is None or st["leave"] >= s + per) for st in self.stretches)
|
||||
out.append(inside and self.subscribes_at(w, s))
|
||||
return out
|
||||
|
||||
def viewers(self, frm, to):
|
||||
p = window_policy(self.tl.policies, frm, to)
|
||||
if p is None:
|
||||
return []
|
||||
who = sorted({st["principal"] for st in self.stretches})
|
||||
return [w for w in who if any(self.due(p, frm, to, w))]
|
||||
|
||||
def settling(self, p, w, at):
|
||||
span = p["settle_ms"] * 1000
|
||||
if not on("settle"):
|
||||
return False
|
||||
near = [x for x in self.tl.settle_points()] + [st["join"] for st in self.stretches if st["principal"] == w]
|
||||
return any(x <= at < x + span for x in near)
|
||||
|
||||
def row(self, p, frm, to, w, held):
|
||||
due = self.due(p, frm, to, w)
|
||||
per = p["period_ms"] * 1000
|
||||
sample = [None] * len(due)
|
||||
for s in self.samples:
|
||||
if s["principal"] != w or s["seq"] >= held or not frm <= s["at"] < to:
|
||||
continue
|
||||
j = (s["at"] - frm) // per
|
||||
if not due[j] or sample[j] is not None:
|
||||
continue
|
||||
e = s["position"] - self.tl.position_at(s["at"])
|
||||
sample[j] = (e, not self.settling(p, w, s["at"]))
|
||||
return due, sample
|
||||
|
||||
def entries(self, frm, to, listed):
|
||||
p = window_policy(self.tl.policies, frm, to)
|
||||
rows = [self.row(p, frm, to, w, held) for w, held in listed]
|
||||
bound = p["bound_us"]
|
||||
|
||||
def judge(unknown, pq):
|
||||
return UNKNOWN if unknown else (IN_SYNC if pq <= bound else OUT_OF_SYNC)
|
||||
|
||||
viewers = []
|
||||
for (w, held), (due, sample) in zip(listed, rows):
|
||||
nd = sum(1 for d in due if d)
|
||||
ns = sum(1 for s in sample if s is not None)
|
||||
errs = [abs(s[0]) for s in sample if s is not None and s[1]]
|
||||
p50, pq = percentile(errs, 50), percentile(errs, p["quantile"])
|
||||
viewers.append({"principal": w, "held": held, "due": nd, "samples": ns, "counted": len(errs),
|
||||
"p50": min(p50, U32), "pq": min(pq, U32), "max": min(max(errs, default=0), U32),
|
||||
"verdict": judge(ns < nd or not errs, pq)})
|
||||
pairs = []
|
||||
for a in range(len(listed)):
|
||||
for b in range(a + 1, len(listed)):
|
||||
d = [abs(x[0] - y[0]) for x, y in zip(rows[a][1], rows[b][1]) if x is not None and y is not None and x[1] and y[1]]
|
||||
p50, pq = percentile(d, 50), percentile(d, p["quantile"])
|
||||
unknown = viewers[a]["verdict"] == UNKNOWN or viewers[b]["verdict"] == UNKNOWN or not d
|
||||
pairs.append({"a": a, "b": b, "n": len(d), "p50": min(p50, U32), "pq": min(pq, U32),
|
||||
"max": min(max(d, default=0), U32), "verdict": judge(unknown, pq)})
|
||||
return viewers, pairs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §11 verification
|
||||
|
||||
def viewer_json(v):
|
||||
return {"principal": v["principal"].hex(), "held": v["held"], "due": v["due"], "samples": v["samples"], "counted": v["counted"],
|
||||
"p50": v["p50"], "pq": v["pq"], "max": v["max"], "verdict": VERDICTS[v["verdict"]]}
|
||||
|
||||
|
||||
def pair_json(p):
|
||||
return {"a": p["a"], "b": p["b"], "n": p["n"], "p50": p["p50"], "pq": p["pq"], "max": p["max"], "verdict": VERDICTS[p["verdict"]]}
|
||||
|
||||
|
||||
def verify(descriptor, grants, chains, policy_blobs, sources):
|
||||
"""§11.2: a dict of what the corpus states (§11.3)."""
|
||||
room = LRV.verify_room(descriptor, grants, None, chains) # step 1
|
||||
if room["outcome"] == "refuse":
|
||||
return {"outcome": "refuse", "reason": room["reason"], "at": ["room"] + room["at"]}
|
||||
gap = room["outcome"] == "gap"
|
||||
d = LRV.read_descriptor(descriptor)
|
||||
held_grants, _ = LRV.check_grants(d, grants)
|
||||
by_digest = {g["digest"]: g for g in held_grants}
|
||||
room_keys = (d["authority"], d["clock"])
|
||||
try:
|
||||
policies = check_policies(d, policy_blobs, sources) # step 2
|
||||
for i, g in enumerate(held_grants): # step 3
|
||||
if on("attr"):
|
||||
try:
|
||||
control_bits(g)
|
||||
except Refuse as e:
|
||||
raise Stop(e.reason, ["grant", i])
|
||||
records, seen = [], set()
|
||||
for c, i, under in room["order"]:
|
||||
ev = LEV.read_record(chains[c][i])
|
||||
if ev["digest"] in seen:
|
||||
continue
|
||||
seen.add(ev["digest"])
|
||||
ev["chain"], ev["index"], ev["under"] = c, i, bytes.fromhex(under) if under else None
|
||||
records.append(ev)
|
||||
stretches, grant_changes = [], [] # §8.2
|
||||
for ev in records:
|
||||
if ev["principal"] not in room_keys or ev["kind"] not in (JOIN, LEAVE, EXCHANGE):
|
||||
continue
|
||||
pl = LEV.read_payload(ev["kind"], ev["payload"], 1 << 20)
|
||||
x = LRV.read_detail(ev["kind"], pl["detail"])
|
||||
who = pl["subject"]
|
||||
if ev["kind"] == JOIN:
|
||||
stretches.append({"principal": who, "join": ev["t"], "leave": None})
|
||||
grant_changes.append({"principal": who, "t": ev["t"], "subscribe": by_digest[x["grant"]]["subscribe"] == 1})
|
||||
elif ev["kind"] == LEAVE:
|
||||
for st in reversed(stretches):
|
||||
if st["principal"] == who and st["leave"] is None:
|
||||
st["leave"] = ev["t"]
|
||||
break
|
||||
else:
|
||||
grant_changes.append({"principal": who, "t": ev["t"], "subscribe": by_digest[x["new"]]["subscribe"] == 1})
|
||||
controls, presentations, pending = [], [], []
|
||||
for ev in records: # step 4
|
||||
if ev["kind"] != META:
|
||||
continue
|
||||
at = ["record", ev["chain"], ev["index"]]
|
||||
try:
|
||||
s = read_statement(ev["payload"])
|
||||
if s is None:
|
||||
continue
|
||||
kind, f = s
|
||||
t = ev["t"]
|
||||
k = policy_at(policies, t)
|
||||
if k is None:
|
||||
raise Refuse("cw_no_policy")
|
||||
p = policies[k]
|
||||
is_key = ev["principal"] in room_keys
|
||||
if kind == "control":
|
||||
if on("control_power") and not is_key and not may_control(by_digest[ev["under"]], f["action"]):
|
||||
raise Refuse("control_not_permitted")
|
||||
if on("lead") and f["at"] < t + p["lead_ms"] * 1000:
|
||||
raise Refuse("control_not_ahead")
|
||||
if on("horizon") and f["at"] > t + p["horizon_ms"] * 1000:
|
||||
raise Refuse("control_beyond_horizon")
|
||||
q = policies[policy_at(policies, f["at"])]
|
||||
if on("range") and ((f["action"] == SEEK and f["arg"] > q["duration_us"])
|
||||
or (f["action"] == RATE and not q["min_rate"] <= f["arg"] <= q["max_rate"])):
|
||||
raise Refuse("control_out_of_range")
|
||||
controls.append(dict(f, t=t, digest=ev["digest"], author=ev["principal"]))
|
||||
elif kind == "presentation":
|
||||
if on("who_presents") and is_key:
|
||||
raise Refuse("cw_not_permitted")
|
||||
if f["room"] != d["room"]:
|
||||
raise Refuse("wrong_room")
|
||||
if on("wrong_key") and f["player"] != ev["principal"]:
|
||||
raise Refuse("presentation_wrong_key")
|
||||
bad = ED.verify(f["player"], PRESENTATION_DOMAIN + f["body"], f["sig"]) if on("pres_sig") else None
|
||||
if bad:
|
||||
raise Refuse(bad)
|
||||
if on("ahead") and f["at"] > t:
|
||||
raise Refuse("presentation_ahead")
|
||||
if on("source") and f["source"] != policies[policy_at(policies, f["at"])]["source"]:
|
||||
raise Refuse("presentation_wrong_source")
|
||||
presentations.append(dict(f, digest=ev["digest"], seq=ev["seq"]))
|
||||
else:
|
||||
if on("who_skews") and not is_key:
|
||||
raise Refuse("cw_not_permitted")
|
||||
wp = window_policy(policies, f["from"], f["to"])
|
||||
if wp is None or f["to"] > t:
|
||||
raise Refuse("cw_statement_malformed")
|
||||
if on("skew_bound") and (f["bound_us"], f["quantile"]) != (wp["bound_us"], wp["quantile"]):
|
||||
raise Refuse("skew_mismatch")
|
||||
pending.append((at, ev["digest"], f))
|
||||
except Refuse as e:
|
||||
raise Stop(e.reason, at)
|
||||
tl = Timeline(policies, controls)
|
||||
facts = Facts(tl, stretches, grant_changes,
|
||||
[{"principal": p["player"], "seq": p["seq"], "at": p["at"], "position": p["position"]} for p in presentations])
|
||||
skews = []
|
||||
counts = {}
|
||||
for ev in records:
|
||||
counts[ev["principal"]] = counts.get(ev["principal"], 0) + 1
|
||||
for at, digest, f in pending: # §8.4 steps 2 and 3
|
||||
if gap:
|
||||
skews.append({"digest": digest.hex(), "from": f["from"], "to": f["to"], "viewers": "unchecked", "pairs": "unchecked"})
|
||||
continue
|
||||
vs = set(facts.viewers(f["from"], f["to"]))
|
||||
if on("skew_viewers") and any(v["principal"] not in vs or v["held"] > counts.get(v["principal"], 0) for v in f["viewers"]):
|
||||
raise Stop("skew_mismatch", at)
|
||||
viewers, pairs = facts.entries(f["from"], f["to"], [(v["principal"], v["held"]) for v in f["viewers"]])
|
||||
if on("skew_recompute") and (viewers != f["viewers"] or pairs != f["pairs"]):
|
||||
raise Stop("skew_mismatch", at)
|
||||
skews.append({"digest": digest.hex(), "from": f["from"], "to": f["to"],
|
||||
"viewers": [viewer_json(v) for v in viewers], "pairs": [pair_json(p) for p in pairs]})
|
||||
if not gap and on("complete"): # step 5, §8.6
|
||||
windows = {}
|
||||
for at, _, f in pending:
|
||||
w = windows.setdefault((f["from"], f["to"]), [at, set()])
|
||||
w[1].update(v["principal"] for v in f["viewers"])
|
||||
for (frm, to), (at, listed) in windows.items():
|
||||
if any(w not in listed for w in facts.viewers(frm, to)):
|
||||
raise Stop("skew_incomplete", at)
|
||||
except Stop as s:
|
||||
return {"outcome": "refuse", "reason": s.reason, "at": s.at}
|
||||
out = {"outcome": "gap" if gap else "accept", "room": d["room"].hex(), "gaps": room.get("gaps", []),
|
||||
"policy_digests": [p["digest"].hex() for p in policies],
|
||||
"starts": [{"at": s, "policy": i} for s, i in tl.starts],
|
||||
"controls": [{"digest": c["digest"].hex(), "author": c["author"].hex(), "t": c["t"], "action": ACTIONS[c["action"]],
|
||||
"at": c["at"], "arg": c["arg"], "applied": c["digest"] not in tl.overridden} for c in controls],
|
||||
"timeline": [{"at": x, "playing": s["playing"], "rate": s["rate"], "position": position(s, x, tl.duration_at(x))} for x, s in tl.points],
|
||||
"presentations": [], "skews": skews}
|
||||
for p in presentations:
|
||||
controlled = tl.position_at(p["at"])
|
||||
pol = policies[policy_at(policies, p["at"])]
|
||||
out["presentations"].append({"digest": p["digest"].hex(), "player": p["player"].hex(), "at": p["at"], "position": p["position"],
|
||||
"state": STATES[p["state"]], "controlled": controlled, "error": p["position"] - controlled,
|
||||
"settling": facts.settling(pol, p["player"], p["at"])})
|
||||
return out
|
||||
|
||||
|
||||
def run_vector(v):
|
||||
sources = []
|
||||
for s in v["sources"]:
|
||||
sources.append(None if s is None else s)
|
||||
return verify(bytes.fromhex(v["descriptor"]), [bytes.fromhex(g) for g in v["grants"]],
|
||||
[[bytes.fromhex(r) for r in c] for c in v["chains"]], [bytes.fromhex(p) for p in v["policies"]], sources)
|
||||
|
||||
|
||||
STATED = ("outcome", "reason", "at", "room", "gaps", "policy_digests", "starts", "controls", "timeline", "presentations", "skews")
|
||||
|
||||
|
||||
def check(doc, quiet=False, first=False):
|
||||
"""Every vector and catch-up case of `doc` against this verifier: the failures (with
|
||||
`first`, the first alone)."""
|
||||
fails, seen = [], set()
|
||||
for v in doc["vectors"]:
|
||||
if first and fails:
|
||||
return fails, seen
|
||||
try:
|
||||
got = run_vector(v)
|
||||
except Exception as e: # a copy that does not read is a failure to verify it
|
||||
fails.append("%s: does not verify (%r)" % (v["name"], e))
|
||||
continue
|
||||
bad = [k for k in STATED if (k in got or k in v) and got.get(k) != v.get(k)]
|
||||
if got.get("reason"):
|
||||
seen.add(got["reason"])
|
||||
if not quiet:
|
||||
what = got["outcome"] + ("" if "reason" not in got else " " + got["reason"] + " at %s" % got["at"])
|
||||
print(("PASS" if not bad else "FAIL") + " room %s: %s" % (v["name"], what))
|
||||
for k in bad:
|
||||
fails.append("%s: %s is %s, the corpus states %s" % (v["name"], k, json.dumps(got.get(k))[:200], json.dumps(v.get(k))[:200]))
|
||||
cu = doc["catch_up"]
|
||||
for i, c in enumerate(cu["cases"]):
|
||||
got = catch_up(cu["params"], c["playing"], c["rate"], c["controlled"], c["duration"], c["presented"])
|
||||
ok = got == c["correction"]
|
||||
if not quiet:
|
||||
print(("PASS" if ok else "FAIL") + " catch-up %d: %s -> %s" % (i, c["about"], json.dumps(got)))
|
||||
if not ok:
|
||||
fails.append("catch-up case %d: %s, the corpus states %s" % (i, got, c["correction"]))
|
||||
return fails, seen
|
||||
|
||||
|
||||
def mutations(doc):
|
||||
"""Copies of the corpus, each with one defect a conforming verifier must catch."""
|
||||
vn = {v["name"]: k for k, v in enumerate(doc["vectors"])}
|
||||
party, drift, nxt, gapv = vn["watch_party"], vn["drift"], vn["next_episode"], vn["gap"]
|
||||
|
||||
def mutate(label, f):
|
||||
d = copy.deepcopy(doc)
|
||||
f(d)
|
||||
return label, d
|
||||
|
||||
def vs(d):
|
||||
return d["vectors"]
|
||||
|
||||
def flip(h, i):
|
||||
b = bytearray(bytes.fromhex(h))
|
||||
b[i] ^= 1
|
||||
return b.hex()
|
||||
|
||||
def drop_chain(d, k, principal):
|
||||
chains = vs(d)[k]["chains"]
|
||||
vs(d)[k]["chains"] = [c for c in chains if LEV.read_record(bytes.fromhex(c[0]))["principal"].hex() != principal]
|
||||
|
||||
late = doc["principals"]["late"]
|
||||
return [
|
||||
mutate("a stated 95th percentile", lambda d: vs(d)[party]["skews"][2]["viewers"][0].update(pq=vs(d)[party]["skews"][2]["viewers"][0]["pq"] + 1)),
|
||||
mutate("a stated verdict", lambda d: vs(d)[drift]["skews"][0]["viewers"][2].update(verdict="in_sync")),
|
||||
mutate("a stated pair", lambda d: vs(d)[drift]["skews"][2]["pairs"][0].update(n=0)),
|
||||
mutate("a control stated applied", lambda d: vs(d)[party]["controls"][6].update(applied=True)),
|
||||
mutate("a stated error", lambda d: vs(d)[party]["presentations"][9].update(error=vs(d)[party]["presentations"][9]["error"] + 1)),
|
||||
mutate("a statement stated settling", lambda d: vs(d)[party]["presentations"][7].update(settling=not vs(d)[party]["presentations"][7]["settling"])),
|
||||
mutate("a stated position of the timeline", lambda d: vs(d)[party]["timeline"][3].update(position=1)),
|
||||
mutate("the second source's start unstated", lambda d: vs(d)[nxt]["starts"].pop()),
|
||||
mutate("a byte of a presentation statement's own signature", lambda d: vs(d)[party]["chains"][2].__setitem__(4, flip(vs(d)[party]["chains"][2][4], -70))),
|
||||
mutate("the late joiner's chain withheld", lambda d: drop_chain(d, party, late)),
|
||||
mutate("an object of the second source one millisecond longer", lambda d: vs(d)[nxt]["sources"][1][0].update(duration_ms=vs(d)[nxt]["sources"][1][0]["duration_ms"] + 1)),
|
||||
mutate("the gap's skew statements stated checked", lambda d: vs(d)[gapv]["skews"][0].update(viewers=[])),
|
||||
mutate("a refusal's reason", lambda d: vs(d)[vn["control_short_lead"]].update(reason="control_beyond_horizon")),
|
||||
mutate("a refusal's place", lambda d: vs(d)[vn["skew_leaves_a_viewer_out"]].update(at=["record", 0, 4])),
|
||||
mutate("a catch-up correction", lambda d: d["catch_up"]["cases"][3]["correction"].update(value=1040000)),
|
||||
mutate("the catch-up nudge", lambda d: d["catch_up"]["params"].update(nudge=40000)),
|
||||
]
|
||||
|
||||
|
||||
CHECKS = ("policy_rules", "policy_link", "sources", "attr", "control_power", "lead", "horizon", "range", "who_presents", "wrong_key",
|
||||
"pres_sig", "ahead", "source", "who_skews", "skew_bound", "skew_viewers", "skew_recompute", "complete", "settle")
|
||||
|
||||
|
||||
def main(corpus_dir):
|
||||
fails = []
|
||||
st = ED._self_test()
|
||||
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
|
||||
fails += ["ed25519 self-test"] if st else []
|
||||
ok = len(REASONS) == 16
|
||||
print(("PASS" if ok else "FAIL") + " §12 read from the extension: %d reasons" % len(REASONS))
|
||||
fails += [] if ok else ["§12 does not read"]
|
||||
names = sorted(n for n in os.listdir(corpus_dir) if n != "README.md")
|
||||
ok = names == ["vectors.json"]
|
||||
print(("PASS" if ok else "FAIL") + " the corpus holds vectors.json and README.md alone")
|
||||
fails += [] if ok else ["other files: %s" % names]
|
||||
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
|
||||
doc = json.load(f)
|
||||
f2, seen = check(doc)
|
||||
fails += f2
|
||||
missing = [r for r in REASONS if r not in seen]
|
||||
print(("PASS" if not missing else "FAIL") + " every reason of §12 refuses a vector")
|
||||
fails += ["no vector refused with %s" % ", ".join(missing)] if missing else []
|
||||
for label, d in mutations(doc):
|
||||
caught, _ = check(d, quiet=True, first=True)
|
||||
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:90] if caught else ""))
|
||||
if not caught:
|
||||
fails.append("a mutated copy verifies: %s" % label)
|
||||
for name in CHECKS:
|
||||
DROP.add(name)
|
||||
try:
|
||||
caught, _ = check(doc, quiet=True, first=True)
|
||||
finally:
|
||||
DROP.discard(name)
|
||||
print(("PASS" if caught else "FAIL") + " the corpus catches the check dropped: %s%s" % (name, " (%s)" % caught[0][:90] if caught else ""))
|
||||
if not caught:
|
||||
fails.append("the corpus does not catch the check dropped: %s" % name)
|
||||
print("co_watch_verify: %s" % ("PASS" if not fails else "FAIL (%d)" % len(fails)))
|
||||
for f in fails:
|
||||
print(" " + f)
|
||||
return 0 if not fails else 1
|
||||
|
||||
|
||||
def room_main(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
v = json.load(f)
|
||||
v.setdefault("sources", [])
|
||||
print(json.dumps(run_vector(v)))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) == 3 and sys.argv[1] == "--room":
|
||||
sys.exit(room_main(sys.argv[2]))
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: co_watch_verify.py <live-conformance/cowatch> | --room <room.json>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
sys.exit(main(sys.argv[1]))
|
||||
72
wai/live-conformance/cowatch/README.md
Normal file
72
wai/live-conformance/cowatch/README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# live-conformance/cowatch — co-watch
|
||||
|
||||
The corpus of [`extensions/co-watch.md`](../../extensions/co-watch.md) §13: watch rooms of
|
||||
[live-rooms](../../extensions/live-rooms.md), their co-watch policies (`WXP1`), the controls
|
||||
(`WXC1`), presentation statements (`WXS1`) and skew statements (`WXK1`) their principals sent
|
||||
as `meta` events, what a verifier states of them, and catch-up cases.
|
||||
|
||||
**Co-watch equivalence.** For each vector, a conforming verifier given its `descriptor`,
|
||||
`grants`, `key_log`, `chains`, `policies` and `sources` (§11.1) reaches the `outcome` the
|
||||
vector states, with its `reason` and `at` when it refuses, and, for an accepted room or a room
|
||||
with a gap (§11.3):
|
||||
|
||||
- `room`, `gaps`, `policy_digests`, and `starts` (each source's start: its instant and the
|
||||
policy's index);
|
||||
- `controls`, in the merged order: each one's `digest`, `author`, `t`, `action`, `at`, `arg`
|
||||
and whether it is `applied` or a later control at its instant overrides it;
|
||||
- `timeline`: each change of the controlled state, in the order of §5.4 and §6.4, with whether
|
||||
the source plays, its `rate` and its `position` at that instant;
|
||||
- `presentations`, in the merged order: each statement's `digest`, `player`, `at`,
|
||||
`position`, `state`, the `controlled` position at its `at`, its `error` and whether it is
|
||||
`settling`;
|
||||
- `skews`, in the merged order: each statement's `digest`, `from`, `to`, and its `viewers` and
|
||||
`pairs` as recomputed (§8.4), or `unchecked` in a room with a gap.
|
||||
|
||||
`about` says what each vector is, and is not checked. `catch_up` holds the policy's catch-up
|
||||
`params` and `cases`: a controlled state (`playing`, `rate`, `controlled`, `duration`) and a
|
||||
player's `presented` position, with the `correction` §9 gives. A verifier that reaches another
|
||||
outcome, reason, place, fact or correction does not conform.
|
||||
|
||||
**Provenance.** Generated here: `wai_co_watch_vectors gen` writes `vectors.json` from rooms
|
||||
authored in code, from fixed seeds. Each key is SHA-256 of `wai:co-watch/corpus/key`, a zero
|
||||
byte and the principal's index (`principals` lists their public keys); each source object's
|
||||
content hash is SHA-256 of a fixed label. Each room is written from a script of acts in
|
||||
room-clock order: the clock's lifecycle, controls, and each viewer's statements at the
|
||||
controlled position plus an error written in the generator; the clock's skew statements are
|
||||
made from the room as it stands when the clock makes them, as a room service makes them.
|
||||
Ed25519 is deterministic, so the file is the same bytes on every machine. The generator asserts
|
||||
that each vector reaches the outcome it was written to have, and that the corpus gives every
|
||||
reason of §12, before it writes. A refused vector holds its room up to the record it is refused
|
||||
at. No recorded data.
|
||||
|
||||
## Layout
|
||||
|
||||
`vectors.json`: `principals` (informative), `key_seed`, `vectors` and `catch_up`. The rooms,
|
||||
under one policy unless named otherwise (an hour-long episode; controls 500 ms to 10 s ahead;
|
||||
rates from half to twice normal speed; one-second slots in five-second windows; a 200 ms bound
|
||||
at the 95th percentile; settling 1.5 s; catch-up at 40 ms, 5 % and one second):
|
||||
|
||||
- `watch_party`: the host (the direct power), the guest (`watch_control` 3: play and pause) and
|
||||
the late joiner (no control, joining at 11 s). The host plays, the guest pauses and plays,
|
||||
the host seeks, sets 1.25 and back; at 20 s the guest's pause and the host's play and seek are
|
||||
for one instant, the host's play, sent later, overriding the pause. Each player states twice a
|
||||
slot, with a transient error just after a control, and the late joiner a seek's error on
|
||||
joining; every five-second window to 25 s is stated, every viewer in sync.
|
||||
- `next_episode`: a second policy names another source at 10 s; the controlled position starts
|
||||
again there, paused at 0, and the guest (`watch_control` 15) plays it; both sources' objects
|
||||
are given.
|
||||
- `drift`: four viewers. The fourth states 400 ms ahead (out of sync); the late joiner states
|
||||
nothing for one slot (unknown from 5 s); the guest leaves at 7 s; the window from 5 s is
|
||||
stated in two statements of two viewers each.
|
||||
- `gap`: `watch_party` with one of the guest's statements not delivered.
|
||||
- Refused, each a control of one defect: a control from a party without the power, one past its
|
||||
attribute, one in the past, one under the lead, one past the horizon, a seek past the end, a
|
||||
rate past the limit, an unknown action, a play with an argument, a control before its sender
|
||||
joined (the room refuses it); a statement of another player, one signed by another key, one
|
||||
ahead of its sending, one of another source, one of state 9, one by the clock; a skew
|
||||
statement that does not recompute, one stating a viewer with no statements in sync, one of
|
||||
another bound, one claiming events the room does not hold, one leaving a viewer out, one by a
|
||||
viewer, one made before its window ends; a `watch_control` of 16 and of `03`; no policy;
|
||||
policies malformed, unlinked, of another duration for one source, not from 0, truncated, of
|
||||
version 2, of another room and signed by the clock; and objects that are not the policy's
|
||||
source.
|
||||
7211
wai/live-conformance/cowatch/vectors.json
Normal file
7211
wai/live-conformance/cowatch/vectors.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -742,6 +742,20 @@
|
|||
"edition": null,
|
||||
"registered": "2026-10-21"
|
||||
},
|
||||
{
|
||||
"capability": "wai.live.cowatch",
|
||||
"kind": "record",
|
||||
"status": "registered",
|
||||
"media": null,
|
||||
"role": null,
|
||||
"tier": null,
|
||||
"licence": null,
|
||||
"replicate": null,
|
||||
"sets": [],
|
||||
"spec_section": "§5 Interactive, temporal & computed content",
|
||||
"edition": null,
|
||||
"registered": "2026-10-22"
|
||||
},
|
||||
{
|
||||
"capability": "wai.live.e2ee",
|
||||
"kind": "record",
|
||||
|
|
@ -2501,6 +2515,34 @@
|
|||
"section": "§5.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WXC1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/co-watch.md",
|
||||
"section": "§5.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WXK1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/co-watch.md",
|
||||
"section": "§8.4",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WXP1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/co-watch.md",
|
||||
"section": "§3.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WXS1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/co-watch.md",
|
||||
"section": "§7.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "X-WAI-EVENT",
|
||||
"kind": "playlist-attribute",
|
||||
|
|
|
|||
|
|
@ -604,6 +604,13 @@ wai:wai.live.coperformance a wai:Capability ;
|
|||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-10-21"^^xsd:date .
|
||||
|
||||
wai:wai.live.cowatch a wai:Capability ;
|
||||
rdfs:label "wai.live.cowatch" ;
|
||||
wai:kind wai:kind-record ;
|
||||
wai:status wai:status-registered ;
|
||||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-10-22"^^xsd:date .
|
||||
|
||||
wai:wai.live.e2ee a wai:Capability ;
|
||||
rdfs:label "wai.live.e2ee" ;
|
||||
wai:kind wai:kind-record ;
|
||||
|
|
|
|||
|
|
@ -256,6 +256,15 @@ browser_sink = ["jwp_hash", "ed25519"]
|
|||
# restricting decision as a statement of reasons in the DSA Transparency Database's format,
|
||||
# written by hand. SHA-256, Ed25519 and the key log only; no new dependency.
|
||||
live_moderation = ["live_events", "live_room", "live_record"]
|
||||
# Co-watch (extensions/co-watch): `wai.live.cowatch` — a room watching one source together: the
|
||||
# authority's co-watch policy (WXP1) naming the source and its bounds, controls (WXC1: play, pause,
|
||||
# seek, rate) effective at a stated room-clock instant ahead of their sending and applied in a
|
||||
# pinned order, the controlled position they define, players' signed presentation statements
|
||||
# (WXS1), and the room clock's skew statements (WXK1) per window, recomputed from the statements:
|
||||
# each viewer's error and each pair's skew as percentiles, with verdicts against the bound, a viewer
|
||||
# with missing statements unknown. And the pinned catch-up rule. SHA-256 and Ed25519 only; no new
|
||||
# dependency.
|
||||
co_watch = ["live_events", "live_room"]
|
||||
# Forensic watermark sessions (extensions/forensic-watermark): `wai.live.watermark` — the edge's
|
||||
# signed session record (WWS1: the positions of an ETSI TS 104 002 pattern it embedded, the variant
|
||||
# at each, the token by its digest, the content keys, and a salted commitment to the session's grant
|
||||
|
|
@ -885,6 +894,11 @@ name = "wai_live_moderation_vectors"
|
|||
path = "src/bin/wai_live_moderation_vectors.rs"
|
||||
required-features = ["live_moderation"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_co_watch_vectors"
|
||||
path = "src/bin/wai_co_watch_vectors.rs"
|
||||
required-features = ["co_watch"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_synthesis_vectors"
|
||||
path = "src/bin/wai_synthesis_vectors.rs"
|
||||
|
|
@ -1688,6 +1702,11 @@ name = "live_moderation"
|
|||
path = "tests/live_moderation.rs"
|
||||
required-features = ["live_moderation"]
|
||||
|
||||
[[test]]
|
||||
name = "co_watch"
|
||||
path = "tests/co_watch.rs"
|
||||
required-features = ["co_watch"]
|
||||
|
||||
[[test]]
|
||||
name = "synthesis"
|
||||
path = "tests/synthesis.rs"
|
||||
|
|
|
|||
|
|
@ -611,6 +611,23 @@ pub fn serve(threshold: u64, age: u64) -> Result<(), Reason> {
|
|||
if threshold >= age { Ok(()) } else { Err(Reason::BelowThreshold) }
|
||||
}
|
||||
|
||||
/// The `grant` of a proof of a room key (§5.5): it names no grant, 32 bytes of zero.
|
||||
pub const NO_GRANT: [u8; 32] = [0; 32];
|
||||
|
||||
/// §5.5, the room's own service: the proof of a room key a session presented, `proof`, at a
|
||||
/// relay holding `challenge` for the session and receiving `context`. Checked as live-rooms
|
||||
/// §10.5.4 checks a proof, with [`NO_GRANT`] for the grant's digest and the proof's own key for
|
||||
/// the principal; a proof that does not pass is refused with that section's reason, and the
|
||||
/// session with it. One that passes by a room key of `d` (the authority's or the clock's)
|
||||
/// makes the session the room's own service, which the serving rule does not hold: `true`.
|
||||
/// One that passes by any other key gives the session nothing: `false`, and it is served by
|
||||
/// its threshold.
|
||||
pub fn room_service(d: &Descriptor, proof: &[u8], challenge: &[u8; 32], context: &str) -> Result<bool, Reason> {
|
||||
let p = room::Proof::read(proof).map_err(Reason::Room)?;
|
||||
room::check_proof(&NO_GRANT, &p.key, Some(proof), challenge, &[], context).map_err(Reason::Room)?;
|
||||
Ok(d.is_room_key(&p.key))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------
|
||||
// §4 The age policy
|
||||
// ---------------------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -722,6 +722,52 @@ fn serving_cases() -> Vec<Case> {
|
|||
for (name, about, c, reason) in refused {
|
||||
out.push(case(name, about, k, input(&[&s0, &c], &[(18, 0, &[0, 1])]), refuse(reason, json!(["claim", 1]))));
|
||||
}
|
||||
// The room's own service (§5.5): sessions presenting a proof of a room key, over a relay's
|
||||
// challenge for its endpoint, asking for groups 45 (aged 18 under the span) and 10 (13).
|
||||
let challenge = [0x5c; 32];
|
||||
let context = "SETUP //relay.example/moq";
|
||||
let proof = |who: &str, grant: [u8; 32], ch: [u8; 32], ctx: &str| crate::live_room::Proof::sign(grant, &key(who), ch, ctx).to_bytes();
|
||||
let mut forged = crate::live_room::Proof::sign(NO_GRANT, &key("director"), challenge, context);
|
||||
forged.key = pk("clock");
|
||||
let forged = forged.to_bytes();
|
||||
let mut cut = proof("clock", NO_GRANT, challenge, context);
|
||||
cut.pop();
|
||||
let named = sha256(&[b"a grant of the room".as_slice()]);
|
||||
let service = |p: &[u8]| json!({"proof": hex(p), "challenge": hex(&challenge), "context": context});
|
||||
let q = |t: u64, g: u64, s: Option<Value>| {
|
||||
let mut v = json!({"threshold": t, "group": g, "held": [0, 1]});
|
||||
if let Some(s) = s {
|
||||
v["service"] = s;
|
||||
}
|
||||
v
|
||||
};
|
||||
let requests = vec![
|
||||
q(0, 45, Some(service(&proof("clock", NO_GRANT, challenge, context)))),
|
||||
q(0, 45, Some(service(&proof("authority", NO_GRANT, challenge, context)))),
|
||||
q(0, 10, Some(service(&proof("clock", NO_GRANT, challenge, context)))),
|
||||
q(0, 45, None),
|
||||
q(16, 45, None),
|
||||
q(0, 45, Some(service(&proof("performer", NO_GRANT, challenge, context)))),
|
||||
q(18, 45, Some(service(&proof("performer", NO_GRANT, challenge, context)))),
|
||||
q(0, 45, Some(service(&forged))),
|
||||
q(18, 45, Some(service(&forged))),
|
||||
q(0, 45, Some(service(&proof("clock", NO_GRANT, [0x5d; 32], context)))),
|
||||
q(0, 45, Some(service(&proof("clock", NO_GRANT, challenge, "SETUP //other-relay.example/moq")))),
|
||||
q(0, 45, Some(service(&proof("clock", named, challenge, context)))),
|
||||
q(0, 45, Some(service(&cut))),
|
||||
];
|
||||
out.push(case(
|
||||
"room_service",
|
||||
"the room rated 13, groups 40 to 59 at 18: sessions that prove a room key are the room's own service, served every group whatever their threshold, the clock's and the authority's; a session with no proof, and one proving the performer's key, are served by their thresholds; a proof naming the clock's key under the director's signature, one over another session's challenge, one for another relay, one naming a grant and one cut short are refused with live-rooms §10.5.4's reasons, at any threshold (§5.5)",
|
||||
k,
|
||||
json!({
|
||||
"descriptor": hex(&room.descriptor),
|
||||
"track": {"namespace": track.namespace, "name": track.name},
|
||||
"claims": [s0, s1],
|
||||
"requests": requests,
|
||||
}),
|
||||
Intent::Accept,
|
||||
));
|
||||
let other_track = RoomTrack { namespace: vec![hex(&other_room().room), hex(&pk("director")), "video".into()], name: "0".into() };
|
||||
let (u0, _) = claim("viewer", Subject::Span { track: other_track.digest(), from_group: 0, to_group: 99 }, &[r(SCHEME_A, "18", 18)], &[], None, LATER);
|
||||
let (u1, _) = claim("stranger", Subject::Object(obj("feature")), &[r(SCHEME_A, "18", 18)], &[], None, LATER);
|
||||
|
|
@ -1111,8 +1157,19 @@ pub fn run_case(c: &Value, logs: &Map<String, Value>) -> Option<Value> {
|
|||
for q in c["requests"].as_array()? {
|
||||
let held: Vec<&Claim> = q["held"].as_array()?.iter().map(|i| claims.get(i.as_u64()? as usize)).collect::<Option<_>>()?;
|
||||
let age = age_under(held, &track.position(q["group"].as_u64()?));
|
||||
out.push(match serve(q["threshold"].as_u64()?, age) {
|
||||
Ok(()) => json!({"age": age, "outcome": "serve"}),
|
||||
// A session that presented a proof of a room key (§5.5): the room's own service
|
||||
// where it passes by one, refused where it does not pass.
|
||||
let service = match &q["service"] {
|
||||
Value::Null => Ok(false),
|
||||
s => {
|
||||
let challenge: [u8; 32] = unhex(s["challenge"].as_str()?)?.try_into().ok()?;
|
||||
room_service(&d, &unhex(s["proof"].as_str()?)?, &challenge, s["context"].as_str()?)
|
||||
}
|
||||
};
|
||||
let threshold = q["threshold"].as_u64()?;
|
||||
out.push(match service.and_then(|own| if own { Ok(true) } else { serve(threshold, age).map(|_| false) }) {
|
||||
Ok(true) => json!({"age": age, "outcome": "serve", "service": true}),
|
||||
Ok(false) => json!({"age": age, "outcome": "serve"}),
|
||||
Err(r) => json!({"age": age, "outcome": "refuse", "reason": r.code()}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
74
wai/wai-rs/src/bin/wai_co_watch_vectors.rs
Normal file
74
wai/wai-rs/src/bin/wai_co_watch_vectors.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//! Conformance corpus for co-watch (`live-conformance/cowatch/`, extensions/co-watch.md §13).
|
||||
//!
|
||||
//! wai_co_watch_vectors gen <corpus-dir> write vectors.json, from fixed seeds
|
||||
//! wai_co_watch_vectors verify <corpus-dir> every file regenerates byte for byte, no other
|
||||
//! file but README.md is there, and every case gets
|
||||
//! the outcome it states
|
||||
//!
|
||||
//! The generator and this verifier are one implementation. The corpus's second
|
||||
//! implementation is `live-conformance/co_watch_verify.py`, written from the extension's
|
||||
//! text and sharing no code with this binary.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use wai::co_watch::corpus;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
match (a.get(1).map(String::as_str), a.get(2)) {
|
||||
(Some("gen"), Some(d)) => {
|
||||
if let Err(e) = std::fs::create_dir_all(d) {
|
||||
eprintln!("create {d}: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
for (name, body) in corpus::generate() {
|
||||
let p = Path::new(d).join(&name);
|
||||
if let Err(e) = std::fs::write(&p, body) {
|
||||
eprintln!("write {}: {e}", p.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
println!("wrote {d}");
|
||||
}
|
||||
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
|
||||
_ => {
|
||||
eprintln!("usage: wai_co_watch_vectors gen|verify <corpus-dir>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(dir: &Path) -> i32 {
|
||||
let mut files = BTreeMap::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
eprintln!("read {}: {e}", dir.display());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if name == "README.md" {
|
||||
continue;
|
||||
}
|
||||
match std::fs::read_to_string(e.path()) {
|
||||
Ok(s) => {
|
||||
files.insert(name, s);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("read {name}: {err}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let same = files == generated;
|
||||
println!("{} every file regenerates byte for byte from its seeds, and no other file is there", if same { "PASS" } else { "FAIL" });
|
||||
let fails = corpus::verify(&files);
|
||||
for f in &fails {
|
||||
println!("FAIL {f}");
|
||||
}
|
||||
println!("{} every case gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
|
||||
(!same || !fails.is_empty()) as i32
|
||||
}
|
||||
1640
wai/wai-rs/src/co_watch.rs
Normal file
1640
wai/wai-rs/src/co_watch.rs
Normal file
File diff suppressed because it is too large
Load diff
1042
wai/wai-rs/src/co_watch/corpus.rs
Normal file
1042
wai/wai-rs/src/co_watch/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -222,6 +222,7 @@ pub const CAP_LIVE_E2EE: &str = "wai.live.e2ee";
|
|||
pub const CAP_LIVE_WATERMARK: &str = "wai.live.watermark";
|
||||
pub const CAP_LIVE_ADS: &str = "wai.live.ads";
|
||||
pub const CAP_LIVE_AD_PRESENTATION: &str = "wai.live.ad_presentation";
|
||||
pub const CAP_LIVE_COWATCH: &str = "wai.live.cowatch";
|
||||
pub const CAP_LIVE_PROVENANCE: &str = "wai.live.provenance";
|
||||
pub const CAP_EDGE_DERIVATION: &str = "wai.edge.derivation";
|
||||
pub const CAP_MEDIA_SYNTHESIS: &str = "wai.media.synthesis";
|
||||
|
|
@ -328,6 +329,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||
(CAP_LIVE_WATERMARK, S_INTERACTIVE, None, "2026-10-20"),
|
||||
(CAP_LIVE_ADS, S_INTERACTIVE, None, "2026-10-20"),
|
||||
(CAP_LIVE_AD_PRESENTATION, S_INTERACTIVE, None, "2026-10-21"),
|
||||
(CAP_LIVE_COWATCH, S_INTERACTIVE, None, "2026-10-22"),
|
||||
(CAP_LIVE_PROVENANCE, S_INTERACTIVE, None, "2026-10-21"),
|
||||
(CAP_MEDIA_SYNTHESIS, S_INTERACTIVE, None, "2026-10-21"),
|
||||
(CAP_MEDIA_UPSCALE, S_INTERACTIVE, None, "2026-10-21"),
|
||||
|
|
@ -395,6 +397,7 @@ const RECORDS: &[&str] = &[
|
|||
CAP_LIVE_WATERMARK,
|
||||
CAP_LIVE_ADS,
|
||||
CAP_LIVE_AD_PRESENTATION,
|
||||
CAP_LIVE_COWATCH,
|
||||
CAP_LIVE_PROVENANCE,
|
||||
CAP_EDGE_DERIVATION,
|
||||
CAP_MEDIA_SYNTHESIS,
|
||||
|
|
@ -572,6 +575,7 @@ const AI_USAGE: &str = "extensions/ai-usage.md";
|
|||
const CO_PERFORMANCE: &str = "extensions/co-performance.md";
|
||||
const LIVE_ADS: &str = "extensions/live-ads.md";
|
||||
const AD_PRESENTATION: &str = "extensions/ad-presentation.md";
|
||||
const CO_WATCH: &str = "extensions/co-watch.md";
|
||||
const LIVE_PROVENANCE: &str = "extensions/live-provenance.md";
|
||||
const DERIVING_EDGE: &str = "extensions/deriving-edge.md";
|
||||
const SYNTHESIS: &str = "extensions/synthesis.md";
|
||||
|
|
@ -682,6 +686,10 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
|
|||
wire("WAP1", WireKind::Magic, LIVE_ADS, "§3.1", WireStatus::Defined),
|
||||
wire("WAB1", WireKind::Magic, LIVE_ADS, "§4.2", WireStatus::Defined),
|
||||
wire("WPC1", WireKind::Magic, AD_PRESENTATION, "§5.1", WireStatus::Defined),
|
||||
wire("WXP1", WireKind::Magic, CO_WATCH, "§3.1", WireStatus::Defined),
|
||||
wire("WXC1", WireKind::Magic, CO_WATCH, "§5.1", WireStatus::Defined),
|
||||
wire("WXS1", WireKind::Magic, CO_WATCH, "§7.1", WireStatus::Defined),
|
||||
wire("WXK1", WireKind::Magic, CO_WATCH, "§8.4", WireStatus::Defined),
|
||||
wire("wai_c2pa_track", WireKind::Catalog, LIVE_PROVENANCE, "§10.2", WireStatus::Defined),
|
||||
wire("wai-c2pa", WireKind::Catalog, LIVE_PROVENANCE, "§10.2", WireStatus::Defined),
|
||||
wire("wai-derivation", WireKind::DocumentKind, DERIVING_EDGE, "§5.1", WireStatus::Defined),
|
||||
|
|
|
|||
|
|
@ -474,6 +474,14 @@ pub mod browser_sink;
|
|||
/// cbindgen:ignore
|
||||
#[cfg(feature = "live_moderation")]
|
||||
pub mod live_moderation;
|
||||
// Co-watch (extensions/co-watch.md): `wai.live.cowatch` — a room watching one source together:
|
||||
// the co-watch policy, controls at a room-clock instant ahead of their sending and their pinned
|
||||
// order, the controlled position, players' signed presentation statements, the room clock's
|
||||
// skew statements recomputed from them, and the pinned catch-up rule. Rust-only, like the other
|
||||
// live modules: its constants must not become macros in include/wai.h.
|
||||
/// cbindgen:ignore
|
||||
#[cfg(feature = "co_watch")]
|
||||
pub mod co_watch;
|
||||
// Synthesis provenance (extensions/synthesis.md): `wai.media.synthesis` — the origin claim of an
|
||||
// object, a group or a track and its class (captured, reconstructed, assistive, synthesized),
|
||||
// its IPTC digital source type and C2PA statement, assistive claims proved by recomputing a pinned
|
||||
|
|
|
|||
52
wai/wai-rs/tests/co_watch.rs
Normal file
52
wai/wai-rs/tests/co_watch.rs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
//! `wai.live.cowatch` (extensions/co-watch.md): the committed corpus `live-conformance/cowatch/`
|
||||
//! regenerates byte for byte from its fixed seeds, holds no other file, every case in it gets the
|
||||
//! outcome it states, and the extension's §12 lists exactly the reasons the reference reports.
|
||||
//! The generator itself asserts that the corpus gives every one of them.
|
||||
#![cfg(feature = "co_watch")]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use wai::co_watch::{corpus, Reason};
|
||||
|
||||
fn wai_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
fn committed() -> BTreeMap<String, String> {
|
||||
let dir = wai_dir().join("live-conformance/cowatch");
|
||||
std::fs::read_dir(&dir)
|
||||
.expect("the committed corpus")
|
||||
.flatten()
|
||||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
||||
.filter(|(n, _)| n != "README.md")
|
||||
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_committed_corpus_regenerates_from_its_seeds() {
|
||||
let files = committed();
|
||||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||||
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
|
||||
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
|
||||
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
|
||||
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_co_watch_vectors gen`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_case_gets_its_stated_outcome() {
|
||||
let fails = corpus::verify(&committed());
|
||||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||||
}
|
||||
|
||||
/// §12 of the extension lists exactly the codes `Reason::ALL` has, each once, in order.
|
||||
#[test]
|
||||
fn the_extension_lists_the_reasons() {
|
||||
let text = std::fs::read_to_string(wai_dir().join("extensions/co-watch.md")).expect("the extension");
|
||||
let at = text.find("\n## 12. Reasons").expect("a §12");
|
||||
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
|
||||
let listed: Vec<String> =
|
||||
text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
|
||||
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
|
||||
assert_eq!(listed, want, "§12's table and Reason::ALL differ");
|
||||
}
|
||||
Loading…
Reference in a new issue