open-standards/joule-code/joule-code-rs/crates/joule-moq-relay/tests/grant_proof.rs
Transaction Science 32cffa4086 joule-moq: drafts chosen by this crate's preference; relay tests and client take the draft
Draft::select takes the most preferred draft a client offers (-22 first),
as the relay's TLS does. The relay client takes extra Setup Options
(AUTHORITY and PATH for a moqt:// URI), and the relay tests can run any
session at -18 or -16.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-21 05:33:13 -04:00

101 lines
5.2 KiB
Rust

//! Proof of possession of a room grant's key (WAI live-rooms §10.5.2), over each transport:
//! the relay sends its SETUP first with a fresh challenge; a client presenting a grant in
//! its SETUP with a proof over that challenge, for the target it addressed, gets the
//! grant's rights; a grant with no proof, a proof by another key, over another session's
//! challenge or for another target closes the session with UNAUTHORIZED (§12.2); and 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 => format!("//{}{path}", server.websocket_addr.unwrap()),
}
}
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 server = relay(|c| {
c.auth.required = true;
c.auth.grants = vec![Grant { prefix: vec!["room".into()], token_sha256: hash, publish: true, subscribe: true, proof: true, unlimited: false, seek: false }];
})
.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() };
// With its proof, over each transport, the grant gives its rights; each session has a
// challenge of its own.
let mut challenges = Vec::new();
for via in [Via::WebSocket, Via::WebTransport, Via::Quic] {
let (c, _ep) = connect(&server, via, with(&host, target_of(&server, via))).await;
challenges.push(grant_proof::challenge_of(&c.peer_setup.options).expect("the relay's SETUP carries a challenge"));
let _held = publish_tracks(&c, &t, None).await;
c.close(0, "done");
}
challenges.sort();
challenges.dedup();
assert_eq!(challenges.len(), 3, "a challenge is drawn for each session");
// The grant as a bare token, a proof by another key, one for another target, and one
// over another session's challenge: each session refused.
let bare = ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: record.clone() }], ..ClientOptions::default() };
let (c, _) = connect(&server, Via::WebSocket, bare).await;
closed_unauthorized(&c, "a grant with no proof").await;
let (c, _) = connect(&server, Via::WebSocket, with(&stranger, target_of(&server, Via::WebSocket))).await;
closed_unauthorized(&c, "a proof by another key").await;
let (c, _) = connect(&server, Via::WebSocket, with(&host, "//other-relay.example/moq".into())).await;
closed_unauthorized(&c, "a proof for another relay").await;
let replayed = ClientOptions { sign_challenge: Some(challenges[0]), ..with(&host, target_of(&server, Via::WebSocket)) };
let (c, _) = connect(&server, Via::WebSocket, replayed).await;
closed_unauthorized(&c, "a proof over an earlier session's challenge").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;
}