open-standards/joule-code/joule-code-rs/crates/joule-moq-relay/tests/grant_proof.rs
Transaction Science 74d7cc0a06
Some checks failed
joule-moq-room room service / joule-moq-room · does this change touch it (push) Successful in 16s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Successful in 27s
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Successful in 20s
joule-moq-agent AI participants / joule-moq-agent · does this change touch it (push) Successful in 30s
wai-app and Create / wai-app · does this change touch it (push) Successful in 6m41s
joule-moq-http live HTTP egress / joule-moq-http · does this change touch it (push) Successful in 6m52s
WAI Studio / does this change touch it (push) Successful in 6m15s
wai-fleet service / does this change touch it (push) Successful in 6m35s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Has been cancelled
WAI Studio / Studio in Chromium and Firefox · macos (push) Has been cancelled
WAI Studio / Studio in WebKit · linux (push) Has been cancelled
wai release binaries build on Windows / the seven release binaries · windows (push) Has been cancelled
wai-fleet service / service · linux (push) Has been cancelled
wai-fleet service / service · macos (push) Has been cancelled
WAI examples / WAI examples · does this change touch them (push) Has been cancelled
WAI examples / WAI examples · portable · linux (push) Has been cancelled
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (push) Has been cancelled
WAI examples / WAI examples · android-app · linux-arm64 (push) Has been cancelled
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Has been cancelled
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Has been cancelled
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Has been cancelled
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Has been cancelled
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Has been cancelled
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Has been cancelled
joule-moq-room room service / joule-moq-room, every target (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Has been cancelled
joule-moq-http live HTTP egress / joule-moq-http, every target (push) Has been cancelled
joule-moq-agent AI participants / joule-moq-agent, every target (push) Has been cancelled
joule-code conformance gate / Build + round-trip C1-C4 vectors (push) Has been cancelled
joule-code conformance gate / joule-moq-relay, every target (push) Has been cancelled
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (push) Has been cancelled
joule-code conformance gate / joule-moq-ingest, every target (push) Has been cancelled
joule-moq-relay: WebTransport over HTTP/2 (draft-ietf-webtrans-http2-16), behind the feature webtransport-h2
A TCP listener (webtransport_h2_listen): TLS 1.3 with ALPN h2 and the UDP endpoint's certificate.
The relay's SETTINGS carry SETTINGS_ENABLE_CONNECT_PROTOCOL 1 (RFC 8441), SETTINGS_WT_ENABLED 1
(§3.1) and its initial limits (§4.3.1: its QUIC windows and stream counts). An extended CONNECT with
:protocol webtransport to the relay's path opens a session, its draft chosen from
WT-Available-Protocols and named in WT-Protocol as over HTTP/3, its answer stating
WebTransport-Init (§4.3.2); 405, 501, 400, 404 and 403 refuse the rest, and a refused request's
capsules are never read (§3.2). A connection idle ten seconds, and each at shutdown, gets a GOAWAY.

The session is the CONNECT stream's DATA cut into capsules and multiplexed as the WebSocket
binding is, under -16's own rules (mux Profile::Http2): data flow control for the session and
each stream both ways, credit granted back as data is read, WT_DATA_BLOCKED and
WT_STREAM_DATA_BLOCKED; a limit capsule that does not raise its limit is WT_FLOW_CONTROL_ERROR; a
reset's Reliable Size is the bytes sent and is checked; WT_STOP_SENDING is answered with
WT_RESET_STREAM; a capsule for a stream in no state to take it is WT_STREAM_STATE_ERROR. A session
error resets the CONNECT stream with its HTTP/2 code; -16 registers those as 0xTBD, so they are
placeholders in transport::capsule::h2_error (WAI decision D78, as D8 has it). The binding's own
rules are unchanged.

The HTTP/2 layer is h2 0.4.20 (MIT), vendored (vendor/h2/VENDORED.md) with one change: settings
of identifiers it does not interpret are sent and read, which SETTINGS_WT_ENABLED and the initial
limits need; patched into the joule-code workspace alone. No other workspace enables the feature,
and their locks are unchanged.

tests/relay_webtransport_h2.rs (with the feature, run by the conformance workflow's relay job):
sessions over HTTP/2 beside HTTP/3 with every object verified, each draft chosen alone, a
300,000-byte object past every window, the relay's SETTINGS and answer, its refusals, a client
held to a server's SETTINGS, a session error's reset; and the multiplexer's unit tests of -16's
rules. Fourteen planted faults, each caught.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 06:45:03 -05:00

173 lines
10 KiB
Rust

//! Proof of possession of a room grant's key (WAI live-rooms §10.5.2), over each transport,
//! with the relay reading the client's SETUP before it sends its own
//! (`draft-ietf-moq-privacy-pass-auth-04` §3.5.1):
//!
//! - the relay sends nothing before the client's SETUP;
//! - at -22 over each transport, and at -23, -18 and -16 through the bridge, the same:
//! a client that holds no challenge presents its grant unproved and is refused `pop_missing`,
//! the relay's SETUP carrying `GRANT_REFUSED` and a challenge; its second session proves over
//! that challenge and has the grant's rights; the SETUP that admits it carries the next
//! challenge, which a third session proves over at once;
//! - planted, each refused with its reason and nothing served: the grant as a bare token, a
//! proof by another key, one for another relay, one over a challenge already consumed
//! (`pop_replayed`), one over a challenge the relay never issued, and one over a challenge
//! past its time;
//! - a client that does not know `GRANT_REFUSED` stays, and the relay closes its session
//! UNAUTHORIZED (§12.2) with the reason;
//! - a grant presented later in a request, never proved, is refused that request.
mod common;
use common::*;
use ed25519_dalek::SigningKey;
use joule_moq::draft22::{Parameter, Parameters, RequestErrorCode, SessionError, Token};
use joule_moq::grant_proof;
use joule_moq_relay::client::{Client, ClientOptions};
use joule_moq_relay::config::Grant;
use joule_moq_relay::transport::TransportError;
use joule_moq_relay::RelayServer;
use std::time::Duration;
use tokio::time::timeout;
/// A record shaped as a room grant (`WRG1`, version 1, a room, the principal), which is
/// all the relay reads of it: its rule names the record by its SHA-256.
fn grant_record(room: u8, principal: &SigningKey) -> Vec<u8> {
let mut g = b"WRG1\x01".to_vec();
g.extend_from_slice(&[room; 32]);
g.extend_from_slice(&principal.verifying_key().to_bytes());
g.extend_from_slice(&[0x5a; 160]);
g.extend_from_slice(&[0x77; 64]);
g
}
/// The `//authority/path` a test client addresses over `via`: the URL's over WebTransport
/// and the WebSocket binding; over native QUIC, with no AUTHORITY or PATH sent, `//`.
fn target_of(server: &RelayServer, via: Via) -> String {
let path = server.relay().config().path.clone();
match via {
Via::Quic | Via::QuicDraft(_) => "//".into(),
Via::WebTransport | Via::WtDraft(_) => format!("//127.0.0.1:{}{path}", server.quic_addr.unwrap().port()),
Via::WebSocket | Via::WsDraft(_) => format!("//{}{path}", server.websocket_addr.unwrap()),
#[cfg(feature = "webtransport-h2")]
Via::WtH2 | Via::WtH2Draft(_) => format!("//127.0.0.1:{}{path}", server.webtransport_h2_addr.unwrap().port()),
}
}
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:?}"),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_grant_is_honoured_only_with_its_proof() {
timeout(Duration::from_secs(180), run()).await.expect("the test finishes in time");
}
async fn run() {
let host = SigningKey::from_bytes(&[0x41; 32]);
let stranger = SigningKey::from_bytes(&[0x42; 32]);
let record = grant_record(9, &host);
let hash = hex::encode(joule_moq_relay::auth::sha256(&record));
let rule = |hash: String| Grant { prefix: vec!["room".into()], token_sha256: hash, publish: true, subscribe: true, proof: true, unlimited: false, seek: false };
let server = relay(|c| {
c.auth.required = true;
c.auth.grants = vec![rule(hash.clone())];
})
.await;
let t = Tracks::new(ns(&["room", "host"]), "0", 10, 5, 128);
let with = |key: &SigningKey, target: String| ClientOptions { grants: vec![(record.clone(), key.clone())], target, ..ClientOptions::default() };
let ws = target_of(&server, Via::WebSocket);
// The relay's SETUP never comes before the client's: a session that sends nothing is sent
// nothing.
let (silent, _ep) = dial(&server, Via::Quic, None).await;
assert!(timeout(Duration::from_millis(400), silent.accept_uni()).await.is_err(), "the relay sent its SETUP before it read the client's");
silent.close(0, b"done");
// A client that holds no challenge is refused and given one; its second session proves over
// it; the SETUP that admits it gives the next, and a third session proves over that at once.
// Over each transport at -22, and at each other draft the relay speaks, through its bridge:
// the order, the options and the second session are the same at every one.
use joule_moq::draft::Draft;
let vias = [Via::WebSocket, Via::WebTransport, Via::Quic, Via::QuicDraft(Draft::D23), Via::WsDraft(Draft::D23), Via::QuicDraft(Draft::D18), Via::QuicDraft(Draft::D16)];
let mut challenges = Vec::new();
for via in vias {
let opts = with(&host, target_of(&server, via));
let first = refused_at_setup(&server, via, opts.clone()).await;
assert_eq!(first.reason, "pop_missing", "a grant presented with no proof");
let given = first.challenge.expect("the refusing SETUP carries a challenge");
let (conn, _ep) = dial(&server, via, None).await;
let c = Client::connect(conn, ClientOptions { challenge: Some(given), ..opts.clone() }).await.expect("admitted on its second session");
let _held = publish_tracks(&c, &t, None).await;
let next = c.challenge().expect("the admitting SETUP carries the next challenge");
c.close(0, "done");
let (conn, _ep) = dial(&server, via, None).await;
let c = Client::connect(conn, ClientOptions { challenge: Some(next), ..opts }).await.expect("admitted at once, over the challenge its last SETUP gave");
let _held = publish_tracks(&c, &t, None).await;
challenges.extend([given, next, c.challenge().expect("and gives another")]);
c.close(0, "done");
}
let consumed = challenges[0];
challenges.sort();
challenges.dedup();
assert_eq!(challenges.len(), 3 * vias.len(), "a relay never issues one challenge twice");
// Planted, each refused with its reason.
let mut caught = 0;
let fresh = || async { challenge_from(&server, Via::WebSocket, &with(&host, ws.clone())).await.expect("a challenge") };
let bare = ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: record.clone() }], ..ClientOptions::default() };
assert_eq!(refused_at_setup(&server, Via::WebSocket, bare.clone()).await.reason, "pop_missing", "the grant as a bare token");
caught += 1;
let r = refused_at_setup(&server, Via::WebSocket, ClientOptions { challenge: Some(fresh().await), ..with(&stranger, ws.clone()) }).await;
assert_eq!(r.reason, "pop_wrong_key", "a proof by another key");
caught += 1;
let r = refused_at_setup(&server, Via::WebSocket, ClientOptions { challenge: Some(fresh().await), ..with(&host, "//other-relay.example/moq".into()) }).await;
assert_eq!(r.reason, "pop_wrong_context", "a proof for another relay");
caught += 1;
let r = refused_at_setup(&server, Via::WebSocket, ClientOptions { challenge: Some(consumed), ..with(&host, ws.clone()) }).await;
assert_eq!(r.reason, "pop_replayed", "a proof over a challenge an admitted SETUP consumed");
caught += 1;
let r = refused_at_setup(&server, Via::WebSocket, ClientOptions { challenge: Some([0x5e; 32]), ..with(&host, ws.clone()) }).await;
assert_eq!(r.reason, "pop_wrong_challenge", "a proof over a challenge the relay never issued");
assert!(r.challenge.is_some(), "and a challenge to prove over is given with every refusal");
caught += 1;
// A challenge is good at the relay that issued it: another relay holds none of this one's.
let brief = relay(|c| {
c.auth.required = true;
c.auth.grants = vec![rule(hash.clone())];
c.limits.grant_challenge_ms = 150;
})
.await;
let theirs = target_of(&brief, Via::WebSocket);
let r = refused_at_setup(&brief, Via::WebSocket, ClientOptions { challenge: Some(fresh().await), ..with(&host, theirs.clone()) }).await;
assert_eq!(r.reason, "pop_wrong_challenge", "a challenge of another relay's");
caught += 1;
// And for the time it states: past it, it is no challenge.
let late = challenge_from(&brief, Via::WebSocket, &with(&host, theirs.clone())).await.expect("a challenge");
tokio::time::sleep(Duration::from_millis(400)).await;
let r = refused_at_setup(&brief, Via::WebSocket, ClientOptions { challenge: Some(late), ..with(&host, theirs.clone()) }).await;
assert_eq!(r.reason, "pop_wrong_challenge", "a challenge past its time");
caught += 1;
// Within its time it is good: the same relay, the same grant.
let (conn, _ep) = dial(&brief, Via::WebSocket, None).await;
Client::connect(conn, ClientOptions { challenge: r.challenge, ..with(&host, theirs) }).await.expect("a proof over the challenge the refusal gave").close(0, "done");
brief.shutdown().await;
eprintln!("planted faults caught (grant proofs): {caught} of 7");
// A client that does not know GRANT_REFUSED stays: the relay closes its session itself,
// UNAUTHORIZED, with the reason. Nothing of it was served meanwhile.
let (conn, _ep) = dial(&server, Via::WebSocket, None).await;
let c = Client::connect(conn, ClientOptions { ignore_refusal: true, ..bare }).await.expect("the SETUPs are exchanged");
assert_eq!(grant_proof::refusal_of(&c.peer_setup.options).as_deref(), Some("pop_missing"), "the relay's SETUP says it refuses");
closed_unauthorized(&c, "a grant with no proof").await;
// A grant presented only in a request, never proved in SETUP: refused that request.
let (c, _) = connect(&server, Via::WebSocket, ClientOptions::default()).await;
let late = Parameters::new().with(Parameter::AuthorizationToken(Token::UseValue { token_type: 0, value: record.clone() }));
let refused = c.subscribe(t.ns.clone(), t.media.clone(), late).await.expect_err("a grant not proved");
assert!(refused.contains(&RequestErrorCode::UNAUTHORIZED.to_string()), "{refused}");
c.close(0, "done");
server.shutdown().await;
}