A room with both `[age]` and `[co]` broke: co-performance's clock follows and logs every performer's objects over a relay session of its own, which holds no grant and no token, so age-assurance §5.5 served it nothing rated. - age-assurance §5.5: a session that proves possession of a room key (the authority's or the clock's) is the room's own service, served every object of the room. 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; one that passes by a key that is no room key gives nothing. Co-performance §8 points to it; §11 and §12 name the reasons and the new vectors. - joule-moq: `grant_proof::NO_GRANT`, `add_room_key_proof`, `room_key_proofs`. - joule-moq-relay: SETUP checks room-key proofs; a session proving a key the relay holds as a room key (an authority's, for its rooms; `[floor]` or `[dvr]` room keys, for every room) has `SessionCtx::service`, and its threshold there is past every age. `ClientOptions::room_keys` presents such proofs. tests/age_room_service.rs: a room rated 18 with the floor held; the clock's and the authority's proofs served every group, the clock's token alone and a non-room key refused `below_threshold`, a forged clock proof and one over another challenge closed UNAUTHORIZED. - joule-moq-room: the clock's session proves both room keys it holds. - wai-rs `age_assurance::room_service`, the corpus case `room_service`, and `age_assurance_verify.py` taught the rule, with three new checks and four mutations, all caught. - e2e: scenario `ageco`, a room with both `[age]` and `[co]`. Co-Authored-By: Claude Opus 5.5 <[email protected]>
14 KiB
joule-moq-room
A room service for WAI live rooms (wai/extensions/live-rooms.md) over
joule-moq-relay. It holds one room's authority and clock keys and:
-
issues grants. A visitor's page makes an Ed25519 key and asks for a grant for it (
POST /grant). The service signs a short-livedWRG1grant: a publisher's (its own audio and video, one of the room's places) or a viewer's (the room to watch). A grant is public, so it is useless without its key: the relay, configured with the authority's key ([[auth.authorities]]), honours it only beside a proof of possession over the relay's own challenge (§10.5), which only the page that holds the key can make. Grants are bounded per client address and overall, and publishers per room. Every role, and so every grant, holds the seek power (live-rooms §3.3, bit 4): a visitor may fetch a track below its live edge (§10.6), from the relay's cache or its DVR window, as a late joiner does for the groups before it joined. A relay refuses such a fetch to a grant without it. -
authors the lifecycle (§7.2). The service's clock is a session on the relay. It watches each publisher's video track (a SUBSCRIBE that asks for no objects): when the track appears the clock signs
joinandpublishfor the publisher on its events track (live-events §8.1), and when the relay ends the track (the publisher's session closed) it signsleave. Pages author none of it. -
serves the room.
GET /roomgives the signed descriptor, the composition descriptor and the publishers' grants, which a page verifies the room with (live-rooms §9). -
keeps end-to-end encrypted rooms (
[room] e2ee = true, WAIextensions/confidentiality.md). Each room then has a confidentiality record (WRE1) the authority signs, and one MLS group whose only external sender is the room's clock: the clock proposes the Add of each publisher it records joining and the Remove of each it records leaving, and follows the group from its public messages, holding no key. The service is the room's carrier of MLS messages (§5.1, §15.2): one log per room holding the clock's lifecycle records, its proposals, and every GroupInfo, commit and Welcome a member posts, in the order the clock took them; a post the clock refuses (§5.5) is not carried. -
admits by age (
[age], WAIextensions/age-assurance.md§5.6), as the Privacy Pass origin of RFC 9577. Each room then has an age policy (§4) naming the configured issuers with one key per threshold, and a rating (§3), aroomclaim, both signed by the authority. A grant request without a token is answered401with aPrivateTokenchallenge for each key (WWW-Authenticate, RFC 9577 §2.1.2): its redemption context HMAC-SHA-256 over the room and a window oftoken_window_sunder a key drawn at start (a restart turns outstanding challenges away), itsorigin_infothe service's server name (origin, else the request'sHost). A request withAuthorization: PrivateToken token="…"has the token checked bywai-rs's §5.2 against the room's age, and its grant states the token's threshold inage(§5.4), from which the relay holds every track to the serving rule (§5.5). The nonces redeemed are kept for the current window and the one before, at mostmax_noncesa window (past it,503and nothing spent), and dropped as a window ages out. Each admission is counted by its window, issuer and threshold, and nothing else of it is kept:GET /age/receiptgives the room'swai-age-admissionsreceipt so far (§7), signed by the clock, every count under 50 suppressed, and withreceipts_outeach 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). The clock's own session proves both room keys in its SETUP (aGRANT_PROOFnaming 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], WAIextensions/co-performance.md). Withaudience_inputabove 0, every viewer's grant carries the audience-input power (the attributeaudience_input, the most inputs it may send to one selection window) and may sendmetaevents and reactions, and the viewers' grants are listed with the room (co.audience) so a page verifies their inputs; withlatency_budget_msabove 0, every publisher's grant carries that budget. Either gives the room a co-performance policy (WPP1) the authority signs (co.policy), its publishersmetaevents (their intents), aleadrole with the direct power for the first publisher (lead), and, withai, a grant marked as an AI participant's for a page that asks ("ai": <model digest>). The clock then keeps what only it knows (src/co.rs, co-performance §8): it records each audience member joining when its events track appears, holds the room as a verifier does (every member's chain, by FETCH where it has a gap), logs each publisher's media objects with their arrival and their due instant (GET /co/objects, the objects a verifier is given), and signs a mark (WPM1) for each object out of turn under a schedule that marks or later than its budget, the commitment (WPK1) and selection (WPS1) of each window once it closes, and the periodic fidelity statement (WPF1). A page asks the clock for a turn schedule (WPT1), a handover (WPH1) or a window (WPW1) over HTTP (POST /co/...), with its grant and a proof of possession over a challenge the service issued (live-rooms §10.5.3); its powers decide what it may ask. The relay holds the floor and the audience's inputs (its[floor], whichrelay-linesprints commented out). -
keeps watch rooms (
[room] watch_source, WAIextensions/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 itsjoin; every grant carrieswatch_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.
Running it
joule-moq-room keygen <keys.toml> new keys (mode 0600, never over a file); prints the
relay configuration lines that trust them
joule-moq-room relay-lines <keys.toml> those lines again
joule-moq-room --config <room.toml> serve (room.example.toml shows every field)
The keys file must be readable by its owner alone; the service refuses it otherwise.
Keys has no Debug, so that no log prints a seed.
The relay needs, beside its listener and limits, the lines relay-lines prints:
[[auth.authorities]]
key = "<authority>"
[[auth.grants]] # the clock's events track, in any room
prefix = ["*", "<clock>", "events"]
token_sha256 = "<SHA-256 of the clock's token>"
publish = true
unlimited = true # no session or ingress limit for the clock's session
[[auth.grants]] # the clock watches the publishers
prefix = []
token_sha256 = "<SHA-256 of the clock's token>"
subscribe = true
and a door that holds each publisher's media to its own key:
[[receipts.door]]
prefix = ["*", "*", "video"]
trust_field = 1
HTTP
| Request | Answer |
|---|---|
GET /, GET /health |
{"ok", "relay": the clock's session is open, "room"} |
GET /room |
the room: room, t0_us, descriptor, compose, grants (hex), present, publishers, max_publishers, e2ee ({"record": <hex>, "log"}, or null), and co ({"policy": <hex>, "audience": [<hex>]}, or null) |
POST /grant {"principal": <64 hex>, "role": "publisher" | "viewer"} |
{"grant", "digest", "exp", "role", "room": <as GET /room>}; 409 full or busy; 429 rate with retry_after_s; 503 relay while the clock has no session |
GET /mls?room=<id>&from=<n>[&wait_ms=<ms>] |
an end-to-end encrypted room's log from entry n: {"room", "entries": [{"seq", "event" or "mls": <hex>}], "next", "end"}, held up to 8 s while nothing is past n; 410 for a room since replaced, 404 for a room that is not end-to-end encrypted |
POST /mls/key-package {"room", "key_package": <hex>} |
kept for the principal's Add: {"ok", "principal"}; 403 for a principal the room has not granted |
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
listens on loopback behind the front that sets them.
Tests
cargo test (this crate is a workspace of its own, with its own lock file, since it builds
on wai-rs): the keys file, the grants checked against the descriptor and read by the
relay's reader, the places, the limiter, and tests/room_service.rs, the service against a
relay on loopback: a grant and its proof open a session and the clock announces the
publisher, the grant alone opens none, the room verifies, a third publisher is refused,
clients are bounded, and a new room starts when the last publisher has left; and an end-to-end
encrypted room on a relay, two members keying through the service as pages do (a GroupInfo,
the clock's Add, a commit and its Welcome), the posts the clock refuses not carried, each
opening what the other seals, and a member that leaves removed by the next commit. Admission by
age: unit tests of src/age.rs over published RSA test keys (tests/common/rfc_test_keys.rs:
RFC 7517 A.2's, RFC 9500's testRSA2048 and RFC 9578 A.2's), whose tokens they sign directly,
as the blind protocol's output is the same signature: the challenges by window, a token
admitted once and at the room's age, the window before accepted and the one before that, another
origin's and another service's challenge refused, the nonces bounded and dropped by window, and
the receipt's cells the reference's and its signature the clock's; and tests/age_admission.rs,
the service with [age] and a relay: the 401 and its challenges, tokens at 16 and 18 admitted
and their grants' age read by the relay, one of 13 refused below_threshold, one spent twice
token_replayed, one for another origin token_wrong_challenge, one that does not read
token_malformed, a publisher challenged too, the relay serving the viewer at 16 the audio and
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. 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
Apache-2.0.