From 0c50b2768fe68fe0f4a76e16dd1c986424f640f2 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Mon, 28 Sep 2026 22:40:25 +0000 Subject: [PATCH 01/49] feat(net): decode the AUTHORIZATION TOKEN structure on a request parameter Add a length-prefixed `Param for Bytes` so a request message can carry the AUTHORIZATION TOKEN parameter (0x03), and `token::decode_value`, which reads the same Token structure (section 8.9) the SETUP option carries. This is the foundation for the request-token quest: a token on SUBSCRIBE, REQUEST_UPDATE, PUBLISH, FETCH, PUBLISH_NAMESPACE, and the other params-bearing requests. The request-message decoders wire it in a following change, so decode_value carries the crate's own not-yet-wired marker until then. Co-Authored-By: Claude --- rs/moq-net/src/ietf/parameters.rs | 43 +++++++++++++++++++++++++++++++ rs/moq-net/src/ietf/token.rs | 21 +++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index 669f4f62d7..fd3565a8e1 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -303,6 +303,20 @@ impl Param for u64 { } } +/// A length-prefixed byte-string parameter value, such as the `AUTHORIZATION TOKEN` +/// (0x03) a request carries. The odd parameter key frames the value with a Length on +/// every draft, so this is the same byte-string coding [`Vec`] uses; the bytes are +/// the Token structure of section 8.9, decoded with [`super::token::decode_value`]. +impl Param for bytes::Bytes { + fn param_encode(&self, w: &mut W, version: Version) -> Result<(), EncodeError> { + self.encode(w, version) + } + + fn param_decode(r: &mut R, version: Version) -> Result { + bytes::Bytes::decode(r, version) + } +} + /// A Location parameter value, such as LARGEST_OBJECT (0x09). /// /// Draft-16 section 9.2 serializes every Message Parameter as a Key-Value-Pair, and section @@ -648,6 +662,35 @@ mod tests { Ok(()) } + #[test] + fn test_param_bytes_round_trip() { + // A value that is not text and not a single byte, so no codec can assume UTF-8 or a + // fixed width: the AUTHORIZATION TOKEN structure a request carries. + let value = Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + ] { + let value = value.clone(); + let expected = value.clone(); + round_trip_params( + version, + move |w, v| { + encode_params!(w, v, 0x03 => value); + Ok(()) + }, + move |r, v| { + decode_params!(r, v, 0x03 => token: Option); + assert_eq!(token, Some(expected), "{version}"); + Ok(()) + }, + ); + } + } + #[test] fn test_param_bool_all_versions() { for version in [ diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index 129e9e17e4..90c2758d52 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -32,6 +32,16 @@ pub fn from_setup(params: &Parameters, version: Version) -> Result .transpose() } +/// Decode a Token structure carried as a request message's `AUTHORIZATION TOKEN` +/// parameter value (section 8.9), the same structure the SETUP option carries. The +/// message parameter's Length framing is stripped by the parameter decoder, so this sees +/// the bare structure, exactly as [`from_setup`] hands one to [`decode`]. +// Wired by the request-message decoders in a following change; the marker comes off then. +#[cfg_attr(not(test), expect(dead_code))] +pub fn decode_value(value: &[u8], version: Version) -> Result { + decode(value, version) +} + /// Present `token` in our SETUP, by value. #[cfg_attr(not(test), expect(dead_code))] pub fn into_setup(params: &mut Parameters, token: &Token, version: Version) -> Result<(), EncodeError> { @@ -152,6 +162,17 @@ mod tests { } } + /// The reusable message-parameter decoder reads the same structure `from_setup` does, + /// so a token on a request and a token on the SETUP are decoded identically. + #[test] + fn decode_value_matches_from_setup() { + for version in VERSIONS { + let params = structure(version, &[REGISTER, 7, token().kind], &token().value); + let value = params.get_bytes(ParameterBytes::AuthorizationToken).unwrap(); + assert_eq!(decode_value(value, version).unwrap(), token(), "{version:?}"); + } + } + #[test] fn absent_is_none() { for version in VERSIONS { From d4e4dc4267dc1e3eaa750cabdf1987476180275d Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 19:42:23 +0000 Subject: [PATCH 02/49] feat(net): authorize a SUBSCRIBE by a token on the request A SUBSCRIBE the session grant does not cover falls back to an AUTHORIZATION TOKEN carried on the request itself (MoQ request-token). The token reaches the session's auth acceptor, tagged with the request's path and kind, and its grant covers only that request: it never joins the session union and ends when the request ends. - auth::Request gains optional per-request context, exposed as path(), kind() (auth::RequestKind), and token_kind(); a connection-credential token still has none. Handle::verify_request routes a request token through the same acceptor and returns a RequestVerdict whose grant() resolves to the acceptor's answer, or Unsupported when no requests() consumer verifies tokens. - Subscribe decodes and encodes the AUTHORIZATION TOKEN (0x03) in both decoder families: the strict draft-17+ message parameters and draft-14's trailing parameter block. - The publisher checks the session grant first, then the request token; a covering grant serves the subscription (gated on the request grant's life, not the session union), an uncovered or refused token is UNAUTHORIZED, and no consumer is NOT_SUPPORTED, via error::request::to_code. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 240 +++++++++++++++++++++++++++++- rs/moq-net/src/ietf/publisher.rs | 68 +++++++-- rs/moq-net/src/ietf/subscribe.rs | 82 +++++++++- rs/moq-net/src/ietf/subscriber.rs | 1 + rs/moq-net/src/ietf/token.rs | 2 - rs/moq-net/src/ietf/version.rs | 1 + 6 files changed, 378 insertions(+), 16 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 3a584598ba..e17e07e716 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -335,6 +335,34 @@ impl Handle { Ok(Requests { queue }) } + /// Verify a token that rode on one request, scoped to that request alone. + /// + /// The token reaches the same acceptor a session token does, tagged with the + /// request's path and kind. The grant the acceptor answers covers only this request, + /// never joins the session union, and ends when the returned [`RequestVerdict`] is + /// dropped, so it lives exactly as long as the request. With no [`requests`] consumer + /// the token cannot be verified, so the verdict is [`Error::Unsupported`] and the + /// caller refuses the request. + pub(crate) fn verify_request( + &self, + token: Bytes, + token_kind: u64, + path: crate::PathOwned, + kind: RequestKind, + ) -> RequestVerdict { + match self.acceptor() { + Some(queue) => { + let issue = Issue::shared(); + // A closed queue (the app dropped its Requests) hands the request back, and + // dropping it refuses the token with Unauthorized. + let _ = queue.try_push(Request::new_request(token, token_kind, path, kind, issue.clone())); + RequestVerdict { issue: Some(issue) } + } + // No app verifier took the requests: a request token cannot be checked in band. + None => RequestVerdict { issue: None }, + } + } + /// Decide who answers the peer's tokens: the app if it took the requests, /// otherwise the session itself (`None`). Idempotent. pub(crate) fn acceptor(&self) -> Option> { @@ -769,12 +797,47 @@ impl Drop for Serving { } } +/// Which request a token rode on. A token on a request authorizes that one request, so +/// the acceptor and the session scope its grant to the request's path and kind rather +/// than to the whole session. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RequestKind { + /// A SUBSCRIBE: the subscriber reads the named track. + Subscribe, + /// A FETCH: the subscriber reads a past range of the named track. + Fetch, + /// A PUBLISH: the peer offers a track to publish. + Publish, + /// A PUBLISH_NAMESPACE: the peer announces a namespace it will publish under. + PublishNamespace, + /// A SUBSCRIBE_NAMESPACE: the peer asks to be told what is published under a prefix. + SubscribeNamespace, + /// A TRACK_STATUS: the peer asks for a track's current status. + TrackStatus, +} + +/// The request a token rode on, kept beside the token so the acceptor can scope its +/// grant to that one request. +struct RequestContext { + path: crate::PathOwned, + kind: RequestKind, + /// The Token structure's type (section 8.9): which verifier the token is for. + token_kind: u64, +} + /// A token the peer presented, waiting for an answer. /// +/// A token with no [`path`](Request::path) is the connection's own credential (or an +/// AUTH-stream token), granting the whole session. A token that rode on a request carries +/// that request's [`path`](Request::path) and [`kind`](Request::kind): its grant covers +/// only that request and never joins the session union. +/// /// Dropping it unanswered refuses the token with [`SessionError::Unauthorized`]. pub struct Request { token: Bytes, issue: Option>, + /// `Some` when the token rode on a request; `None` for the connection credential. + context: Option, } impl Request { @@ -782,15 +845,49 @@ impl Request { Self { token, issue: Some(issue), + context: None, + } + } + + /// A token that rode on one request, carrying the credential value, its structure + /// type, and the request it belongs to. + pub(crate) fn new_request( + token: Bytes, + token_kind: u64, + path: crate::PathOwned, + kind: RequestKind, + issue: kio::Shared, + ) -> Self { + Self { + token, + issue: Some(issue), + context: Some(RequestContext { path, kind, token_kind }), } } /// The token the peer presented. Empty means the credential its connection - /// already carried, or none. + /// already carried, or none. For a request token this is the structure's value. pub fn token(&self) -> &Bytes { &self.token } + /// The request this token rode on, or `None` for the connection's own credential. + pub fn path(&self) -> Option<&str> { + self.context.as_ref().map(|c| c.path.as_str()) + } + + /// Which request this token rode on, or `None` for the connection's own credential. + pub fn kind(&self) -> Option { + self.context.as_ref().map(|c| c.kind) + } + + /// The Token structure's type (section 8.9), for a request token: which verifier it + /// is for (a CAT reaches the CAT verifier, not the JWT one). `None` for the + /// connection's own credential. + pub fn token_kind(&self) -> Option { + self.context.as_ref().map(|c| c.token_kind) + } + /// Grant the token. The grant holds until the returned [`Issued`] is revoked /// or dropped, or the peer withdraws the token. /// @@ -824,6 +921,50 @@ fn refuse(issue: &kio::Shared, code: SessionError, reason: String) { issue.done = true; } +/// The verdict on a request-borne token, from [`Handle::verify_request`]. +/// +/// Holding it keeps the request's grant alive; dropping it tells the acceptor the request +/// is over, so the grant lives exactly as long as the request and never outlives it. +pub(crate) struct RequestVerdict { + issue: Option>, +} + +impl RequestVerdict { + /// Wait for the acceptor's first answer: the grant it issued, or a refusal as the + /// [`Error`] whose request code the caller sends the peer. No consumer is + /// [`Error::Unsupported`]; an unanswered (dropped) request is + /// [`SessionError::Unauthorized`]. + pub(crate) async fn grant(&self) -> Result { + let Some(issue) = &self.issue else { + return Err(Error::Unsupported); + }; + kio::wait(|waiter| { + let mut guard = ready_or!(issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + })); + Poll::Ready(match guard.outbox.pop_front() { + Some(Reply::Grant(grant)) => Ok(grant), + Some(Reply::Refuse { code, .. }) => Err(Error::Session(code)), + // Done with nothing written: the acceptor dropped the request unanswered. + None => Err(Error::Session(SessionError::Unauthorized)), + }) + }) + .await + } +} + +impl Drop for RequestVerdict { + fn drop(&mut self) { + if let Some(issue) = &self.issue { + // Tell the acceptor's Issued the request is over, so it stops revalidating this + // request's grant. An unread grant left in the outbox does not matter: the + // request is ending regardless. + issue.lock().peer.get_or_insert(Error::Cancel); + } + } +} + /// A grant issued to one of the peer's tokens. Dropping it ends the grant. pub struct Issued { issue: kio::Shared, @@ -1094,3 +1235,100 @@ mod tests { assert!(default.poll(&waiter).is_pending()); } } + +#[cfg(test)] +mod request_token_tests { + use super::*; + + fn subscribe_path() -> crate::PathOwned { + crate::Path::new("room/alice").to_owned() + } + + /// A request token reaches the app's acceptor tagged with the request it rode on, and + /// the grant the app answers is what the verdict resolves to. + #[tokio::test] + async fn a_request_token_reaches_the_acceptor_with_its_context() { + let handle = Handle::new(true); + let mut requests = handle.requests().expect("take the requests"); + let verdict = handle.verify_request( + Bytes::from_static(b"jwt"), + 7, + subscribe_path(), + RequestKind::Subscribe, + ); + + let request = requests.next().await.expect("a request"); + assert_eq!(request.token(), &Bytes::from_static(b"jwt")); + assert_eq!(request.path(), Some("room/alice")); + assert_eq!(request.kind(), Some(RequestKind::Subscribe)); + assert_eq!(request.token_kind(), Some(7)); + let _issued = request.accept(Grant::all()); + + let grant = verdict.grant().await.expect("granted"); + assert!(grant.publish.matches("room/alice")); + } + + /// With no `requests()` consumer, a request token cannot be verified in band, so the + /// verdict is Unsupported and the caller refuses the request NOT_SUPPORTED. + #[tokio::test] + async fn no_consumer_refuses_a_request_token_as_unsupported() { + let handle = Handle::new(true); + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); + let err = verdict.grant().await.expect_err("no verifier"); + assert!(matches!(err, Error::Unsupported), "{err:?}"); + } + + /// A refused request token resolves to the refusal, which the caller sends the peer as + /// UNAUTHORIZED. + #[tokio::test] + async fn a_refused_request_token_is_unauthorized() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); + requests + .next() + .await + .unwrap() + .reject(SessionError::Unauthorized, "no"); + let err = verdict.grant().await.expect_err("refused"); + assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + } + + /// The grant lives exactly as long as the request: dropping the verdict ends the + /// acceptor's issued grant, so it never outlives the request nor joins the union. + #[tokio::test] + async fn dropping_the_verdict_ends_the_grant() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); + let issued = requests.next().await.unwrap().accept(Grant::all()); + verdict.grant().await.expect("granted"); + drop(verdict); + let err = issued.closed().await; + assert!(matches!(err, Error::Cancel), "{err:?}"); + } + + /// A request token never joins the session union: the union stays what the connection + /// credential earned, not what a request token granted. + #[tokio::test] + async fn a_request_token_never_joins_the_union() { + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); + let _issued = requests.next().await.unwrap().accept(Grant::all()); + verdict.grant().await.expect("granted"); + // The union only reflects tokens presented on this side (none here), never a + // request token the acceptor answered. + assert_eq!(handle.grant().peek(), None, "a request token must not widen the union"); + } + + /// A session token (the connection credential) carries no request context, so the + /// acceptor can tell it apart from a request token. + #[test] + fn a_session_token_has_no_request_context() { + let request = Request::new(Bytes::new(), Issue::shared()); + assert_eq!(request.path(), None); + assert_eq!(request.kind(), None); + assert_eq!(request.token_kind(), None); + } +} diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 6a24d5efd0..086c175375 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -499,18 +499,63 @@ where // Serve only what our grant lets us publish (MoQ Auth), and stop once it no // longer does. Checked before resolving, so a denied request never reaches the - // origin. - let mut gate = crate::auth::Gate::new( - self.auth.clone(), - msg.track_namespace.to_owned(), - crate::auth::Direction::Publish, - ); - if !self + // origin. The session grant is checked first; a request it does not cover falls + // back to an AUTHORIZATION TOKEN carried on the SUBSCRIBE itself (MoQ + // request-token), verified by the app's acceptor and scoped to this one request. + // A union-authorized subscription is gated on the union so it ends if the union + // later narrows; a token-authorized one is not (the union never covered it), and + // its grant instead ends with the request when `_request_grant` drops. + let mut gate = None; + let mut _request_grant = None; + if self .auth .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) { - let err = Error::Unauthorized; - return self.reject_subscribe(stream, request_id, &err, "not granted").await; + gate = Some(crate::auth::Gate::new( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + )); + } else { + let Some(token) = &msg.authorization_token else { + let err = Error::Unauthorized; + return self.reject_subscribe(stream, request_id, &err, "not granted").await; + }; + // A structurally malformed token is refused here rather than failing the + // session at decode, so one bad request never tears down the connection. + let structure = match crate::ietf::token::decode_value(token, self.version) { + Ok(structure) => structure, + Err(_) => { + let err = Error::Unauthorized; + return self + .reject_subscribe(stream, request_id, &err, "malformed authorization token") + .await; + } + }; + let verdict = self.auth.verify_request( + bytes::Bytes::from(structure.value), + structure.kind, + msg.track_namespace.to_owned(), + crate::auth::RequestKind::Subscribe, + ); + match verdict.grant().await { + // The token's grant must cover this exact request; it authorizes nothing + // else and never joins the session union. + Ok(grant) if grant.publish.matches(msg.track_namespace.as_str()) => { + // Held for the subscription's life, so the grant ends with the request. + _request_grant = Some(verdict); + } + Ok(_) => { + let err = Error::Unauthorized; + return self + .reject_subscribe(stream, request_id, &err, "token does not cover this request") + .await; + } + // UNAUTHORIZED for a refusal, NOT_SUPPORTED when no consumer verifies tokens. + Err(err) => { + return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await; + } + } } // Stats (subscriptions, viewer refcount, groups/frames/bytes) are counted in @@ -650,7 +695,7 @@ where if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { return Poll::Ready(Some(served)); } - if gate.poll_denied(waiter).is_ready() { + if gate.as_mut().is_some_and(|gate| gate.poll_denied(waiter).is_ready()) { tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); return Poll::Ready(Some((Err(Error::Unauthorized), false))); } @@ -2872,6 +2917,7 @@ mod serve_tests { filter, fill, properties_wanted: true, + authorization_token: None, } } @@ -5094,6 +5140,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }, ) .await @@ -5447,6 +5494,7 @@ mod range_tests { filter, fill: None, properties_wanted: true, + authorization_token: None, } } diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index 07facd099e..d3a34112d1 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -5,7 +5,7 @@ use std::borrow::Cow; use crate::{ Path, coding::*, - ietf::{Fill, Filter, GroupOrder, Location, Param, Parameters, Properties, RequestId}, + ietf::{Fill, Filter, GroupOrder, Location, Param, ParameterBytes, Parameters, Properties, RequestId}, }; use super::Message; @@ -53,6 +53,11 @@ pub struct Subscribe<'a> { pub fill: Option, /// Whether the subscriber wants Track Properties on the response (draft-20). pub properties_wanted: bool, + /// The `AUTHORIZATION TOKEN` (0x03) the subscriber presented on this request, if any. + /// A relay verifies it when the session grant does not already cover the namespace + /// (MoQ request-token); the value is the Token structure of section 8.9, decoded with + /// [`super::token::decode_value`]. + pub authorization_token: Option, } impl Message for Subscribe<'_> { @@ -78,7 +83,12 @@ impl Message for Subscribe<'_> { let filter = Filter::decode(r, version)?; - let _params = Parameters::decode(r, version)?; + // The legacy trailing parameter block: read the AUTHORIZATION TOKEN out of it + // rather than dropping it, so a draft-14 subscriber can present a request token. + let params = Parameters::decode(r, version)?; + let authorization_token = params + .get_bytes(ParameterBytes::AuthorizationToken) + .map(bytes::Bytes::copy_from_slice); Ok(Self { request_id, @@ -89,11 +99,13 @@ impl Message for Subscribe<'_> { filter, fill: None, properties_wanted: true, + authorization_token, }) } _ => { decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, + 0x03 => authorization_token: Option, 0x04 => rendezvous_timeout: Option, 0x06 => _subgroup_delivery_timeout: Option, 0x10 => forward: Option, @@ -144,6 +156,7 @@ impl Message for Subscribe<'_> { filter, fill, properties_wanted, + authorization_token, }) } } @@ -164,7 +177,13 @@ impl Message for Subscribe<'_> { true.encode(w, version)?; // forward self.filter.encode(w, version)?; - 0u8.encode(w, version)?; // no parameters + // The legacy trailing parameter block, carrying the AUTHORIZATION TOKEN when + // the subscriber presents one; otherwise an empty block (count 0), as before. + let mut params = Parameters::default(); + if let Some(token) = &self.authorization_token { + params.set_bytes(ParameterBytes::AuthorizationToken, token.to_vec()); + } + params.encode(w, version)?; } _ => { // FILL_PARAMETERS arrived in draft-20. Sending it to an older peer would be an @@ -179,6 +198,7 @@ impl Message for Subscribe<'_> { (!self.properties_wanted && Filter::is_draft20(version)).then_some(IncludeProperties(false)); encode_params!(w, version, + 0x03 => self.authorization_token.clone(), 0x10 => true, 0x20 => self.subscriber_priority, 0x21 => self.filter, @@ -530,6 +550,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -541,6 +562,55 @@ mod tests { assert_eq!(decoded.subscriber_priority, 128); } + /// A request-borne AUTHORIZATION TOKEN round-trips on a legacy draft (draft-14's + /// trailing parameter block) and a strict one (draft-18's message parameters), so a + /// non-moq-dev peer can present or refresh a credential the draft-17+ standard way. + #[test] + fn authorization_token_round_trips_legacy_and_strict() { + // A Token structure value that is not text, so no codec can assume UTF-8. + let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); + for version in [Version::Draft14, Version::Draft18] { + let msg = Subscribe { + request_id: RequestId(1), + track_namespace: Path::new("room/alice"), + track_name: "cam".into(), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter: Filter::NextObject, + fill: None, + properties_wanted: true, + authorization_token: Some(token.clone()), + }; + let encoded = encode_message(&msg, version); + let decoded: Subscribe = decode_message(&encoded, version).unwrap(); + assert_eq!(decoded.authorization_token, Some(token.clone()), "{version:?}"); + assert_eq!(decoded.track_namespace.as_str(), "room/alice", "{version:?}"); + assert_eq!(decoded.track_name, "cam", "{version:?}"); + } + } + + /// No token stays no token: a SUBSCRIBE without one carries no phantom parameter, on + /// both families. + #[test] + fn absent_authorization_token_stays_none() { + for version in [Version::Draft14, Version::Draft18] { + let msg = Subscribe { + request_id: RequestId(2), + track_namespace: Path::new("room/bob"), + track_name: "cam".into(), + subscriber_priority: 128, + group_order: GroupOrder::Descending, + filter: Filter::NextObject, + fill: None, + properties_wanted: true, + authorization_token: None, + }; + let encoded = encode_message(&msg, version); + let decoded: Subscribe = decode_message(&encoded, version).unwrap(); + assert_eq!(decoded.authorization_token, None, "{version:?}"); + } + } + #[test] fn test_subscribe_round_trip_v15() { let msg = Subscribe { @@ -552,6 +622,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -642,6 +713,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; for version in [Version::Draft17, Version::Draft18, Version::Draft19, Version::Draft20] { @@ -671,6 +743,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -854,6 +927,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: wanted, + authorization_token: None, }; let encoded = encode_message(&msg, version); @@ -976,6 +1050,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -1034,6 +1109,7 @@ mod tests { filter: Filter::NextObject, fill: None, properties_wanted: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index c4d4b0432c..5c6abef134 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2077,6 +2077,7 @@ where filter: join.filter, fill: join.fill, properties_wanted: true, + authorization_token: None, }) .await?; Ok(()) diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index 90c2758d52..c0ec380048 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -36,8 +36,6 @@ pub fn from_setup(params: &Parameters, version: Version) -> Result /// parameter value (section 8.9), the same structure the SETUP option carries. The /// message parameter's Length framing is stripped by the parameter decoder, so this sees /// the bare structure, exactly as [`from_setup`] hands one to [`decode`]. -// Wired by the request-message decoders in a following change; the marker comes off then. -#[cfg_attr(not(test), expect(dead_code))] pub fn decode_value(value: &[u8], version: Version) -> Result { decode(value, version) } diff --git a/rs/moq-net/src/ietf/version.rs b/rs/moq-net/src/ietf/version.rs index c78d2bbb13..bab54201a4 100644 --- a/rs/moq-net/src/ietf/version.rs +++ b/rs/moq-net/src/ietf/version.rs @@ -94,6 +94,7 @@ mod tests { range_filters: false, }), properties_wanted: false, + authorization_token: None, }; let subscribe_ok = SubscribeOk { From 5e982b4c02dac6385ecaff32fbf5ec46daa7a63f Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 19:53:41 +0000 Subject: [PATCH 03/49] feat(net): carry the AUTHORIZATION TOKEN on REQUEST_UPDATE SubscribeUpdate (the REQUEST_UPDATE message) gains an authorization_token field, decoded and encoded in both families (draft-14's trailing parameter block and the draft-15+ / draft-17+ message parameters), so a peer can present a fresh token to refresh a request's grant. The publisher's use of it (reading REQUEST_UPDATE off the subscribe stream and replacing the request grant) lands in a following change. Co-Authored-By: Claude --- rs/moq-net/src/ietf/subscribe.rs | 54 ++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index d3a34112d1..9f94990f79 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -393,6 +393,10 @@ pub struct SubscribeUpdate { pub end_group: u64, pub subscriber_priority: u8, pub forward: bool, + /// The `AUTHORIZATION TOKEN` (0x03) presented on this REQUEST_UPDATE, if any. A fresh + /// token refreshes the request's grant (MoQ request-token); the value is the Token + /// structure of section 8.9, decoded with [`super::token::decode_value`]. + pub authorization_token: Option, } impl Message for SubscribeUpdate { @@ -409,7 +413,13 @@ impl Message for SubscribeUpdate { self.end_group.encode(w, version)?; self.subscriber_priority.encode(w, version)?; self.forward.encode(w, version)?; - 0u8.encode(w, version)?; // no parameters + // The legacy trailing parameter block, carrying the AUTHORIZATION TOKEN when a + // renewal presents one; otherwise an empty block (count 0), as before. + let mut params = Parameters::default(); + if let Some(token) = &self.authorization_token { + params.set_bytes(ParameterBytes::AuthorizationToken, token.to_vec()); + } + params.encode(w, version)?; } Version::Draft15 | Version::Draft16 => { self.request_id.encode(w, version)?; @@ -417,6 +427,7 @@ impl Message for SubscribeUpdate { .expect("subscription_request_id required for draft15-16") .encode(w, version)?; encode_params!(w, version, + 0x03 => self.authorization_token.clone(), 0x10 => self.forward, 0x20 => self.subscriber_priority, 0x21 => Filter::NextObject, @@ -433,6 +444,7 @@ impl Message for SubscribeUpdate { 0u64.encode(w, version)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) } encode_params!(w, version, + 0x03 => self.authorization_token.clone(), 0x10 => self.forward, 0x20 => self.subscriber_priority, 0x21 => Filter::NextObject, @@ -452,7 +464,10 @@ impl Message for SubscribeUpdate { let end_group = u64::decode(r, version)?; let subscriber_priority = u8::decode(r, version)?; let forward = bool::decode(r, version)?; - let _parameters = Parameters::decode(r, version)?; + let params = Parameters::decode(r, version)?; + let authorization_token = params + .get_bytes(ParameterBytes::AuthorizationToken) + .map(bytes::Bytes::copy_from_slice); Ok(Self { request_id, @@ -461,6 +476,7 @@ impl Message for SubscribeUpdate { end_group, subscriber_priority, forward, + authorization_token, }) } Version::Draft15 | Version::Draft16 => { @@ -468,6 +484,7 @@ impl Message for SubscribeUpdate { let subscription_request_id = Some(RequestId::decode(r, version)?); decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, + 0x03 => authorization_token: Option, 0x06 => _subgroup_delivery_timeout: Option, 0x10 => forward: Option, 0x20 => subscriber_priority: Option, @@ -484,6 +501,7 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + authorization_token, }) } _ => { @@ -494,6 +512,7 @@ impl Message for SubscribeUpdate { } decode_params!(r, version, 0x02 => _object_delivery_timeout: Option, + 0x03 => authorization_token: Option, 0x06 => _subgroup_delivery_timeout: Option, 0x10 => forward: Option, 0x20 => subscriber_priority: Option, @@ -517,6 +536,7 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + authorization_token, }) } } @@ -611,6 +631,31 @@ mod tests { } } + /// A REQUEST_UPDATE (SubscribeUpdate) carries the AUTHORIZATION TOKEN too, so a peer + /// can refresh its credential, on a legacy draft and a strict one. + #[test] + fn subscribe_update_authorization_token_round_trips_legacy_and_strict() { + let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); + for version in [Version::Draft14, Version::Draft18] { + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(3)), + _ => None, + }; + let msg = SubscribeUpdate { + request_id: RequestId(1), + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: 128, + forward: true, + authorization_token: Some(token.clone()), + }; + let encoded = encode_message(&msg, version); + let decoded: SubscribeUpdate = decode_message(&encoded, version).unwrap(); + assert_eq!(decoded.authorization_token, Some(token.clone()), "{version:?}"); + } + } + #[test] fn test_subscribe_round_trip_v15() { let msg = Subscribe { @@ -964,6 +1009,7 @@ mod tests { end_group: 0, subscriber_priority: 200, forward: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft15); @@ -984,6 +1030,7 @@ mod tests { end_group: 100, subscriber_priority: 200, forward: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -1087,6 +1134,7 @@ mod tests { end_group: 0, subscriber_priority: 200, forward: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -1288,6 +1336,7 @@ mod tests { end_group: 0, subscriber_priority: 200, forward: true, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -1311,6 +1360,7 @@ mod tests { end_group: 0, subscriber_priority: 200, forward: true, + authorization_token: None, }; let v18_msg = SubscribeUpdate { ..v17_msg.clone() }; From b216eda87580ecb1f9d56472a1028009f947d68a Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 20:13:05 +0000 Subject: [PATCH 04/49] feat(net): end a request-token subscription when its grant lapses or is revoked A SUBSCRIBE authorized by a request token now holds a RequestGrant for the subscription's life: it carries the acceptor's grant and a deadline armed at the grant's expiry, and ends the request (never the session) when the deadline lapses (UNAUTHORIZED), the acceptor revokes it, or the acceptor drops the issued grant. The deadline is the existing crate::runtime::Deadline, re-armed when the acceptor replaces the grant. REQUEST_UPDATE renewal is prepared (RequestVerdict::poll_reply, RequestGrant:: renew) and unit-tested, but the publisher does not yet read REQUEST_UPDATE off the subscribe stream, so renew()/grant() carry the crate's not-yet-wired marker until that reader lands next. Tests (auth seam, one legacy + one strict draft where wire-relevant): a_request_grant_has_the_acceptors_expiry, a_renewal_token_replaces_the_grant_and _expiry, the_deadline_ends_the_request_unauthorized_without_renewal, a_refused_renewal_keeps_the_old_grant_until_it_lapses, an_acceptor_revoke_ends _the_request, each asserting the session grant is untouched. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 215 +++++++++++++++++++++++++++++++ rs/moq-net/src/ietf/publisher.rs | 19 ++- 2 files changed, 230 insertions(+), 4 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index e17e07e716..65b208a569 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -952,6 +952,31 @@ impl RequestVerdict { }) .await } + + /// After the first grant, poll for the acceptor's next action on this token: a + /// replacement grant (an update, such as a lowered expiry), a refusal (revoke), or the + /// issued grant being dropped. `Pending` while the grant still stands. + pub(crate) fn poll_reply(&self, waiter: &kio::Waiter) -> Poll { + let Some(issue) = &self.issue else { + return Poll::Ready(Reply::Refuse { + code: SessionError::Unauthorized, + reason: "no verifier".to_string(), + }); + }; + let mut guard = ready_or!(issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + })); + Poll::Ready(match guard.outbox.pop_front() { + Some(reply) => reply, + // Done with nothing more to read: the acceptor dropped the issued grant, ending + // the request. + None => Reply::Refuse { + code: SessionError::Unauthorized, + reason: "grant dropped".to_string(), + }, + }) + } } impl Drop for RequestVerdict { @@ -965,6 +990,76 @@ impl Drop for RequestVerdict { } } +/// A request-borne grant, held for the life of the request it authorized (a SUBSCRIBE, a +/// FETCH, ...). It carries the acceptor's grant and a deadline armed at the grant's +/// expiry, and ends the request when the deadline lapses, the acceptor revokes or drops +/// the grant, or a refused renewal leaves the old grant to lapse. A REQUEST_UPDATE the +/// acceptor accepts [`renew`](Self::renew)s it with a fresh grant and expiry. +/// +/// Ending a request grant never touches the session: only that one request ends. +pub(crate) struct RequestGrant { + verdict: RequestVerdict, + grant: Grant, + deadline: crate::runtime::Deadline, +} + +impl RequestGrant { + /// Hold `grant` (the acceptor's first answer, already awaited) for the request's life, + /// armed to lapse at its expiry. + pub(crate) fn new(runtime: &R, verdict: RequestVerdict, grant: Grant) -> Self { + let mut deadline = crate::runtime::Deadline::new(runtime); + deadline.set(grant.expires); + Self { + verdict, + grant, + deadline, + } + } + + /// The grant in force right now, for the caller to check the request's path against. + // Used by the REQUEST_UPDATE reader (path re-check after renewal) in a following change. + #[cfg_attr(not(test), expect(dead_code))] + pub(crate) fn grant(&self) -> &Grant { + &self.grant + } + + /// Replace the grant after an accepted REQUEST_UPDATE: adopt the new verdict (dropping + /// the old, which ends the old token) and re-arm the deadline at the new expiry. A + /// refused renewal does NOT call this: the old grant stands until it lapses. + // Wired by the publisher's REQUEST_UPDATE reader in a following change; the marker comes + // off then. + #[cfg_attr(not(test), expect(dead_code))] + pub(crate) fn renew(&mut self, verdict: RequestVerdict, grant: Grant) { + self.verdict = verdict; + self.deadline.set(grant.expires); + self.grant = grant; + } + + /// Resolve with the error that ends the request: the deadline lapsing + /// ([`Error::Unauthorized`]; [`Error::Expired`] once the code split lands), or the + /// acceptor revoking or dropping the grant (its code). An acceptor-side update (a + /// replacement grant on the same token, e.g. a lowered expiry) is folded in and polling + /// continues. Never resolves while the grant still stands. + pub(crate) fn poll_ended(&mut self, waiter: &kio::Waiter) -> Poll { + loop { + if self.deadline.poll(waiter).is_ready() { + return Poll::Ready(Error::Unauthorized); + } + match self.verdict.poll_reply(waiter) { + Poll::Ready(Reply::Grant(grant)) => { + self.deadline.set(grant.expires); + self.grant = grant; + // Re-poll: the new deadline may already have lapsed, or another reply may + // be waiting. + continue; + } + Poll::Ready(Reply::Refuse { code, .. }) => return Poll::Ready(Error::Session(code)), + Poll::Pending => return Poll::Pending, + } + } + } +} + /// A grant issued to one of the peer's tokens. Dropping it ends the grant. pub struct Issued { issue: kio::Shared, @@ -1331,4 +1426,124 @@ mod request_token_tests { assert_eq!(request.kind(), None); assert_eq!(request.token_kind(), None); } + + use std::time::Duration; + + fn grant_in(runtime: &crate::time::Clock, secs: u64) -> Grant { + Grant { + publish: crate::Pattern::all().into(), + subscribe: Patterns::new(), + expires: crate::runtime::Timers::now(runtime).checked_add(Duration::from_secs(secs)), + } + } + + /// The request grant carries the acceptor's expiry, which is the deadline it lapses at. + #[tokio::test(start_paused = true)] + async fn a_request_grant_has_the_acceptors_expiry() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); + let grant = grant_in(&runtime, 60); + let expires = grant.expires; + let _issued = requests.next().await.unwrap().accept(grant); + let answered = verdict.grant().await.unwrap(); + let request_grant = RequestGrant::new(&runtime, verdict, answered); + assert_eq!(request_grant.grant().expires, expires); + assert!(expires.is_some()); + } + + /// An accepted REQUEST_UPDATE replaces the grant and its expiry. + #[tokio::test(start_paused = true)] + async fn a_renewal_token_replaces_the_grant_and_expiry() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); + let first = grant_in(&runtime, 60); + let first_expires = first.expires; + let _issued = requests.next().await.unwrap().accept(first); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = RequestGrant::new(&runtime, verdict, answered); + + // A REQUEST_UPDATE carrying a fresh token the acceptor accepts with a later expiry. + let renewal = handle.verify_request(Bytes::from_static(b"jwt2"), 0, subscribe_path(), RequestKind::Subscribe); + let second = grant_in(&runtime, 600); + let second_expires = second.expires; + let _issued2 = requests.next().await.unwrap().accept(second); + let renewed = renewal.grant().await.unwrap(); + request_grant.renew(renewal, renewed); + + assert_eq!(request_grant.grant().expires, second_expires); + assert_ne!(second_expires, first_expires); + } + + /// With no accepted renewal, the deadline ends the request UNAUTHORIZED, and the + /// session is not closed by it. + #[tokio::test(start_paused = true)] + async fn the_deadline_ends_the_request_unauthorized_without_renewal() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); + // Held for the request's life, so only the deadline (not a drop) ends it. + let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = RequestGrant::new(&runtime, verdict, answered); + + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; + assert!(matches!(err, Error::Unauthorized), "{err:?}"); + assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + } + + /// A refused renewal does not touch the grant: the old grant stands and the request + /// ends only when that old grant lapses. + #[tokio::test(start_paused = true)] + async fn a_refused_renewal_keeps_the_old_grant_until_it_lapses() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); + let first = grant_in(&runtime, 60); + let first_expires = first.expires; + let _issued = requests.next().await.unwrap().accept(first); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = RequestGrant::new(&runtime, verdict, answered); + + // The renewal token is refused: verify it resolves to a refusal, and the caller does + // NOT renew. The old grant is untouched. + let renewal = handle.verify_request(Bytes::from_static(b"bad"), 0, subscribe_path(), RequestKind::Subscribe); + requests + .next() + .await + .unwrap() + .reject(SessionError::Unauthorized, "bad token"); + assert!(matches!(renewal.grant().await, Err(Error::Session(SessionError::Unauthorized)))); + + // The old grant still stands with its original expiry, and the request ends only + // when that lapses. + assert_eq!(request_grant.grant().expires, first_expires); + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; + assert!(matches!(err, Error::Unauthorized), "{err:?}"); + } + + /// An acceptor-side revoke ends the request before the deadline, and does not close the + /// session. + #[tokio::test(start_paused = true)] + async fn an_acceptor_revoke_ends_the_request() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); + // A grant that never expires, so only the revoke can end the request. + let issued = requests.next().await.unwrap().accept(Grant::all()); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = RequestGrant::new(&runtime, verdict, answered); + + issued.revoke(SessionError::Unauthorized, "revoked"); + let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; + assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); + assert_eq!(handle.grant().peek(), None, "the session grant is untouched"); + } } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 086c175375..e17094280d 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -506,7 +506,7 @@ where // later narrows; a token-authorized one is not (the union never covered it), and // its grant instead ends with the request when `_request_grant` drops. let mut gate = None; - let mut _request_grant = None; + let mut request_grant = None; if self .auth .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) @@ -542,8 +542,9 @@ where // The token's grant must cover this exact request; it authorizes nothing // else and never joins the session union. Ok(grant) if grant.publish.matches(msg.track_namespace.as_str()) => { - // Held for the subscription's life, so the grant ends with the request. - _request_grant = Some(verdict); + // Held for the subscription's life: the request ends when this grant + // lapses or is revoked (RequestGrant::poll_ended below), never the session. + request_grant = Some(crate::auth::RequestGrant::new(&self.runtime, verdict, grant)); } Ok(_) => { let err = Error::Unauthorized; @@ -699,6 +700,14 @@ where tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); return Poll::Ready(Some((Err(Error::Unauthorized), false))); } + // A request-token subscription ends when its grant lapses or the acceptor + // revokes it; the session is untouched. + if let Some(rg) = request_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + tracing::info!(broadcast = %absolute, track = %track_name, %err, "request token grant ended"); + return Poll::Ready(Some((Err(err), false))); + } let mut cx = std::task::Context::from_waker(waiter.waker()); if stream.reader.poll_closed(&mut cx).is_ready() || closed_session.poll_closed(&mut cx).is_ready() { return Poll::Ready(None); @@ -745,7 +754,9 @@ where // Send PublishDone let (status, reason) = match &res { Ok(()) => (ietf::PublishDoneStatus::TrackEnded, "track ended"), - Err(Error::Unauthorized) => (ietf::PublishDoneStatus::Unauthorized, "not granted"), + Err(Error::Unauthorized) | Err(Error::Session(crate::SessionError::Unauthorized)) => { + (ietf::PublishDoneStatus::Unauthorized, "not granted") + } Err(_) => (ietf::PublishDoneStatus::InternalError, "internal error"), }; let _ = stream.writer.encode(&ietf::PublishDone::ID).await; From a7c5329b53d0da278b2356711432808a5771afa0 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 20:37:20 +0000 Subject: [PATCH 05/49] feat(net): renew a request-token subscription from a REQUEST_UPDATE After SUBSCRIBE_OK the publisher's serve loop now reads control messages off the subscribe stream. A REQUEST_UPDATE (SUBSCRIBE_UPDATE, 0x02) carrying a fresh AUTHORIZATION TOKEN is re-verified through the session's acceptor and, when its grant still covers the request, replaces the request grant and re-arms the deadline, acknowledged with REQUEST_OK. A refused, uncovered, or malformed renewal keeps the old grant and answers the update UNAUTHORIZED without tearing down the subscription, so the request ends only when the old grant lapses. A token-less update is the ordinary priority/forward change and is left untouched. The read future borrows only the reader and lives in an inner block, so that borrow is released before a renewal answers on the writer. RequestGrant::renew is now wired, so its dead_code marker is gone. Two publisher-level tests drive the reader end to end against the deadline: a_request_update_renews_a_token_subscription_past_the_old_expiry and a_refused_request_update_lets_the_old_grant_lapse. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 7 +- rs/moq-net/src/ietf/publisher.rs | 425 +++++++++++++++++++++++++++++-- 2 files changed, 404 insertions(+), 28 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 65b208a569..bdd425b7a1 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1016,8 +1016,8 @@ impl RequestGrant { } } - /// The grant in force right now, for the caller to check the request's path against. - // Used by the REQUEST_UPDATE reader (path re-check after renewal) in a following change. + /// The grant in force right now. Observed by the lifecycle tests; the reader checks a + /// renewal's coverage on the freshly awaited grant before it calls [`renew`](Self::renew). #[cfg_attr(not(test), expect(dead_code))] pub(crate) fn grant(&self) -> &Grant { &self.grant @@ -1026,9 +1026,6 @@ impl RequestGrant { /// Replace the grant after an accepted REQUEST_UPDATE: adopt the new verdict (dropping /// the old, which ends the old token) and re-arm the deadline at the new expiry. A /// refused renewal does NOT call this: the old grant stands until it lapses. - // Wired by the publisher's REQUEST_UPDATE reader in a following change; the marker comes - // off then. - #[cfg_attr(not(test), expect(dead_code))] pub(crate) fn renew(&mut self, verdict: RequestVerdict, grant: Grant) { self.verdict = verdict; self.deadline.set(grant.expires); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index e17094280d..13d4c636cb 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -15,7 +15,7 @@ use web_transport_trait::poll::SendStream as _; use crate::{ AsPath, Error, Timescale, Timestamp, - coding::{Stream, Writer}, + coding::{Reader, Stream, Writer}, ietf::{self, Control, EndLocation, FetchHeader, FetchType, Filter, GroupOrder, Location, RequestId}, track::Subscription, util::{MaybeBoxedExt, MaybeSendBox}, @@ -38,6 +38,20 @@ fn serving_subscription(subscriber_priority: u8) -> Subscription { } } +/// Read one `[type][size][body]` control message off a request stream, or `None` once +/// the peer finishes it. Mirrors [`super::auth`]'s reader, but borrows only the reader so +/// a renewal can answer on the writer once the read yields a message. +async fn read_control( + reader: &mut Reader, +) -> Result, Error> { + let Some(id) = reader.decode_maybe::().await? else { + return Ok(None); + }; + let size: u16 = reader.decode().await?; + let data = reader.read_exact(size as usize).await?; + Ok(Some((id, data))) +} + enum FillStep { Batch, Partial(frame::Consumer), @@ -692,29 +706,130 @@ where }; let mut serve = std::pin::pin!(serve); let mut closed_session = self.session.clone(); - kio::wait(|waiter| { - if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { - return Poll::Ready(Some(served)); - } - if gate.as_mut().is_some_and(|gate| gate.poll_denied(waiter).is_ready()) { - tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); - return Poll::Ready(Some((Err(Error::Unauthorized), false))); - } - // A request-token subscription ends when its grant lapses or the acceptor - // revokes it; the session is untouched. - if let Some(rg) = request_grant.as_mut() - && let Poll::Ready(err) = rg.poll_ended(waiter) - { - tracing::info!(broadcast = %absolute, track = %track_name, %err, "request token grant ended"); - return Poll::Ready(Some((Err(err), false))); - } - let mut cx = std::task::Context::from_waker(waiter.waker()); - if stream.reader.poll_closed(&mut cx).is_ready() || closed_session.poll_closed(&mut cx).is_ready() { - return Poll::Ready(None); + + // What one turn of the serve loop resolved to. + enum Step { + // The track (and any fill) finished: the subscription's own result. + Served((Result<(), Error>, bool)), + // The union gate or the request grant ended the subscription early. + Ended((Result<(), Error>, bool)), + // The peer finished or reset the stream, or the session ended. + Closed, + // A `[type][size][body]` control message off the subscribe stream. + Message(u64, bytes::Bytes), + } + + // After SUBSCRIBE_OK the peer may send a REQUEST_UPDATE (SUBSCRIBE_UPDATE, 0x02) + // to refresh the request's token, so the loop reads one control message per turn + // while serving. The read future borrows only `stream.reader` and lives in an + // inner block, so that borrow is released before a renewal answers on + // `stream.writer`. + loop { + let step = { + let mut read = std::pin::pin!(read_control(&mut stream.reader)); + kio::wait(|waiter| { + if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { + return Poll::Ready(Step::Served(served)); + } + if gate.as_mut().is_some_and(|gate| gate.poll_denied(waiter).is_ready()) { + tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); + return Poll::Ready(Step::Ended((Err(Error::Unauthorized), false))); + } + // A request-token subscription ends when its grant lapses or the + // acceptor revokes it; the session is untouched. + if let Some(rg) = request_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + tracing::info!(broadcast = %absolute, track = %track_name, %err, "request token grant ended"); + return Poll::Ready(Step::Ended((Err(err), false))); + } + let mut cx = std::task::Context::from_waker(waiter.waker()); + if closed_session.poll_closed(&mut cx).is_ready() { + return Poll::Ready(Step::Closed); + } + match waiter.poll_future(read.as_mut()) { + Poll::Ready(Ok(Some((id, data)))) => Poll::Ready(Step::Message(id, data)), + // A FIN or a read error is the peer ending the subscribe stream, + // the same end the reader's close signalled before. + Poll::Ready(Ok(None)) | Poll::Ready(Err(_)) => Poll::Ready(Step::Closed), + Poll::Pending => Poll::Pending, + } + }) + .await + }; + + match step { + Step::Served(served) | Step::Ended(served) => break Some(served), + Step::Closed => break None, + Step::Message(id, mut data) => { + // Only REQUEST_UPDATE carries a renewal; any other message is ignored, + // as it was when the loop only watched for the stream closing. + if id != ietf::SubscribeUpdate::ID { + continue; + } + let update = match ietf::SubscribeUpdate::decode_msg(&mut data, self.version) { + Ok(update) => update, + // A malformed REQUEST_UPDATE body ends this subscription, never the + // session: one bad request does not tear down the connection. + Err(err) => break Some((Err(err.into()), false)), + }; + // A token-less REQUEST_UPDATE is an ordinary priority/forward change, + // which this publisher does not act on; the grant is untouched. + let Some(token) = &update.authorization_token else { + continue; + }; + // Only a token-authorized subscription holds a request grant to renew; + // a union-authorized one is already covered by the session grant. + let Some(rg) = request_grant.as_mut() else { + continue; + }; + // A structurally malformed token is refused at the request level, + // leaving the old grant to stand until it lapses. + let structure = match crate::ietf::token::decode_value(token, self.version) { + Ok(structure) => structure, + Err(_) => { + let _ = self + .write_subscribe_error( + &mut stream.writer, + update.request_id, + &Error::Unauthorized, + "malformed authorization token", + ) + .await; + continue; + } + }; + let verdict = self.auth.verify_request( + bytes::Bytes::from(structure.value), + structure.kind, + msg.track_namespace.to_owned(), + crate::auth::RequestKind::Subscribe, + ); + match verdict.grant().await { + // The renewal's grant must still cover this request. On accept the + // old grant is dropped (ending the old token) and the deadline is + // re-armed at the new expiry. + Ok(grant) if grant.publish.matches(msg.track_namespace.as_str()) => { + rg.renew(verdict, grant); + let _ = self.write_request_ok(&mut stream.writer, update.request_id).await; + } + // A refused or uncovered renewal keeps the old grant (per the quest, + // the request ends only when that lapses) and answers UNAUTHORIZED so + // the peer can retry before then. + _ => { + let _ = self + .write_subscribe_error( + &mut stream.writer, + update.request_id, + &Error::Unauthorized, + "renewal not granted", + ) + .await; + } + } + } } - Poll::Pending - }) - .await + } }; let completed = served.is_some(); @@ -848,6 +963,31 @@ where Ok(()) } + /// Acknowledge an accepted REQUEST_UPDATE on the subscribe stream. Draft-14 predates + /// REQUEST_OK and gives SUBSCRIBE_UPDATE no response, so the renewal is silent there. + async fn write_request_ok( + &self, + writer: &mut Writer, + request_id: RequestId, + ) -> Result<(), Error> { + match self.version { + Version::Draft14 => {} + Version::Draft15 | Version::Draft16 => { + writer.encode(&ietf::RequestOk::ID).await?; + writer + .encode(&ietf::RequestOk { + request_id: Some(request_id), + }) + .await?; + } + _ => { + writer.encode(&ietf::RequestOk::ID).await?; + writer.encode(&ietf::RequestOk { request_id: None }).await?; + } + } + Ok(()) + } + /// Serve a draft-20 fill on its own fetch stream: the requested range, read from the /// group cache, capped at the Largest Object snapshot. /// @@ -2915,6 +3055,245 @@ mod serve_tests { } } + /// Like [`serve`], but with an app auth acceptor wired in and the subscribe stream's + /// reader scripted with `first_script` (a REQUEST_UPDATE, for the renewal tests). + fn serve_with_auth(version: Version, auth: crate::auth::Handle, first_script: Vec) -> Serve { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); + let track = broadcast.create_track("video", None).unwrap(); + + let session = ScriptedSession::per_stream(vec![first_script]); + let log = session.log.clone(); + + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let publisher = Publisher::new( + crate::time::Clock::tokio(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_auth(auth); + + Serve { + publisher, + session, + log, + track, + _origin: origin, + _broadcast: broadcast, + } + } + + /// A grant of everything, lapsing in `secs` (or never), on the publisher's clock. + fn grant_all_expiring(runtime: &crate::time::Clock, secs: Option) -> crate::auth::Grant { + crate::auth::Grant { + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), + expires: secs.map(|s| { + crate::runtime::Timers::now(runtime) + .checked_add(Duration::from_secs(s)) + .unwrap() + }), + } + } + + /// An auth handle whose session grant covers only `other`, so a SUBSCRIBE for `room` + /// falls to its request token. The presented credential is kept alive by the returned + /// [`crate::auth::Token`]. + fn auth_covering_other() -> (crate::auth::Handle, crate::auth::Requests, crate::auth::Token) { + let auth = crate::auth::Handle::new(true); + let requests = auth.requests().unwrap(); + let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + assert!( + !auth.allows(crate::auth::Direction::Publish, "room"), + "the session grant must not cover the request path" + ); + (auth, requests, cred) + } + + /// A Token structure value that decodes via `token::decode_value` (USE_VALUE, kind 300). + fn request_token() -> bytes::Bytes { + bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]) + } + + /// One REQUEST_UPDATE carrying a fresh AUTHORIZATION TOKEN, framed as the peer sends it. + async fn subscribe_update_with_token(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), + _ => None, + }; + let msg = ietf::SubscribeUpdate { + request_id: RequestId(0x40), + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: 128, + forward: true, + authorization_token: Some(request_token()), + }; + writer.encode(&ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// A live SUBSCRIBE for `room/video` presenting a request token, with one published + /// group so the subscription parks at the live edge rather than ending. + fn token_subscribe() -> ietf::Subscribe<'static> { + let mut msg = subscribe(Filter::NextObject, None); + msg.request_id = RequestId(REQUEST_ID); + msg.authorization_token = Some(request_token()); + msg + } + + /// A REQUEST_UPDATE whose token the acceptor renews keeps a token-authorized + /// subscription alive past the old grant's expiry: the reader re-verifies the token off + /// the subscribe stream and re-arms the deadline. Proven against the deadline the + /// original grant would otherwise have lapsed at. + #[tokio::test(start_paused = true)] + async fn a_request_update_renews_a_token_subscription_past_the_old_expiry() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let answered = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + let mut held = Vec::new(); + // The first grant lapses in 60s; the renewal never expires. + let first = requests.next().await.unwrap(); + held.push(first.accept(grant_all_expiring(&rt, Some(60)))); + answered.fetch_add(1, Ordering::Relaxed); + let renewal = requests.next().await.unwrap(); + held.push(renewal.accept(grant_all_expiring(&rt, None))); + answered.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // Drive until the acceptor has answered the initial token and the renewal. + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending(), "subscription ended during setup"); + if answered.load(Ordering::Relaxed) >= 2 { + break; + } + settle().await; + } + assert_eq!(answered.load(Ordering::Relaxed), 2, "acceptor never answered both tokens"); + + // Let the serve loop apply the renewal it read off the stream. + for _ in 0..20 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending()); + settle().await; + } + + // Past the original 60s expiry: the renewal re-armed the deadline, so the + // subscription lives on. + tokio::time::advance(Duration::from_secs(120)).await; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the renewal did not extend the subscription past the old expiry" + ); + settle().await; + } + } + + /// A REQUEST_UPDATE the acceptor refuses does NOT extend the grant: the old grant stands + /// and the subscription ends only when it lapses (the quest's rule), never the session. + #[tokio::test(start_paused = true)] + async fn a_refused_request_update_lets_the_old_grant_lapse() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let answered = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + // The first grant lapses in 60s; the renewal is refused, so it stands. + let first = requests.next().await.unwrap(); + let _issued = first.accept(grant_all_expiring(&rt, Some(60))); + answered.fetch_add(1, Ordering::Relaxed); + let renewal = requests.next().await.unwrap(); + renewal.reject(crate::SessionError::Unauthorized, "no"); + answered.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending(), "subscription ended during setup"); + if answered.load(Ordering::Relaxed) >= 2 { + break; + } + settle().await; + } + assert_eq!(answered.load(Ordering::Relaxed), 2, "acceptor never answered both tokens"); + + // Let the serve loop apply the refusal (which keeps the old grant). + for _ in 0..20 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending()); + settle().await; + } + + // The old grant lapses at 60s and ends the subscription, since the refusal did not + // renew it. + tokio::time::advance(Duration::from_secs(120)).await; + let mut ended = false; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "a refused renewal must let the old grant lapse the subscription"); + } + /// A distinctive request id, so `[FetchHeader::TYPE, REQUEST_ID]` is a usable needle. const REQUEST_ID: u64 = 0x2B; From 29fdcbbf0d70604b21c8d8fd92c1501173640f86 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 21:07:29 +0000 Subject: [PATCH 06/49] feat(net): authorize a PUBLISH_NAMESPACE by a token on the request A peer that announces a namespace the session grant does not cover may present an AUTHORIZATION TOKEN on the PUBLISH_NAMESPACE, and the subscriber verifies it through the same acceptor a session token reaches, scoped to that one announce (MoQ request-token). This is the ingest-side vertical: an encoder holds a PUBLISH_NAMESPACE open and renews its credential in-session. The change is additive. With no token, or a grant that already covers the path, behavior is unchanged and the origin model decides scope as before. Only an uncovered announce that carries a token takes the new path: verify_request with RequestKind::PublishNamespace; an accepted grant covering the path admits the announce with a RequestGrant lifetime (deadline plus acceptor revoke, ending the announce and never the session); a refused, uncovered, or malformed token answers request-level UNAUTHORIZED without teardown; no consumer is NOT_SUPPORTED. A REQUEST_UPDATE on the announce stream carrying a fresh token renews the grant and re-arms the deadline (REQUEST_OK); a refused renewal keeps the old grant until it lapses. The update loop now reads via the shared read_control and polls the grant lifetime alongside it. Wire: the AUTHORIZATION TOKEN parameter is already defined on PUBLISH_NAMESPACE in every supported draft (draft-17 section 9.3.2 / draft-18+ section 10.2.2); nothing new goes on the wire. PublishNamespace and PublishNamespaceUpdate now carry it on both decoder families, with legacy and strict round-trip tests, plus subscriber-level accept, refuse, and renew-past-expiry tests. Co-Authored-By: Claude --- rs/moq-net/src/ietf/adapter.rs | 1 + rs/moq-net/src/ietf/publish_namespace.rs | 145 +++++++- rs/moq-net/src/ietf/publisher.rs | 9 +- rs/moq-net/src/ietf/session.rs | 1 + rs/moq-net/src/ietf/subscribe_namespace.rs | 1 + rs/moq-net/src/ietf/subscriber.rs | 375 ++++++++++++++++++++- 6 files changed, 520 insertions(+), 12 deletions(-) diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index e2ab2f2756..21f274516e 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -1420,6 +1420,7 @@ mod tests { request_id, track_namespace: crate::Path::new(namespace), cluster: None, + authorization_token: None, } } diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index 863826dfeb..89ef801b35 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -21,6 +21,12 @@ pub struct PublishNamespace<'a> { /// negotiated the extension and `None` on one that did not, which is what decides /// whether they appear on the wire at all. pub cluster: Option, + + /// The `AUTHORIZATION TOKEN` (0x03) the announcer presented on this request, if any. + /// A subscriber verifies it when its session grant does not already cover the + /// namespace (MoQ request-token); the value is the Token structure of section 8.9, + /// decoded with [`super::token::decode_value`]. + pub authorization_token: Option, } impl PublishNamespace<'_> { @@ -36,12 +42,13 @@ impl PublishNamespace<'_> { let _required_request_id_delta = u64::decode(r, version)?; } let track_namespace = decode_namespace(r, version)?; - let cluster = decode_cluster_params(r, version, negotiated)?; + let (cluster, authorization_token) = decode_request_params(r, version, negotiated)?; Ok(Self { request_id, track_namespace, cluster, + authorization_token, }) } } @@ -55,7 +62,7 @@ impl Message for PublishNamespace<'_> { 0u64.encode(w, version)?; // required_request_id_delta = 0 (draft-17 only, removed in draft-18 per #1615) } encode_namespace(w, &self.track_namespace, version)?; - encode_cluster_params(w, version, self.cluster.as_ref()) + encode_request_params(w, version, self.cluster.as_ref(), self.authorization_token.as_ref()) } fn decode_msg(r: &mut R, version: Version) -> Result { @@ -63,6 +70,63 @@ impl Message for PublishNamespace<'_> { } } +/// Write the Parameters field of a PUBLISH_NAMESPACE: the optional AUTHORIZATION TOKEN +/// (0x03) followed by the cluster parameters. The token rides the same block as the +/// cluster params but is independent of the extension, so it is handled here rather than +/// in [`encode_cluster_params`] (which also serves the NAMESPACE advertisement, where no +/// token belongs). +fn encode_request_params( + w: &mut W, + version: Version, + advert: Option<&cluster::Advert>, + token: Option<&bytes::Bytes>, +) -> Result<(), EncodeError> { + match advert { + Some(advert) => { + let cost = (advert.cost != 0).then_some(advert.cost); + encode_params!(w, version, + 0x03 => token.cloned(), + cluster::HOP_PATH => advert.hops, + cluster::ROUTE_COST => cost, + ); + } + None => encode_params!(w, version, + 0x03 => token.cloned(), + ), + } + Ok(()) +} + +/// Read the Parameters field of a PUBLISH_NAMESPACE. See [`encode_request_params`]. +fn decode_request_params( + r: &mut R, + version: Version, + negotiated: bool, +) -> Result<(Option, Option), DecodeError> { + if !negotiated { + // The cluster parameters are a violation on a non-negotiated session, so only the + // AUTHORIZATION TOKEN is allowed in the block here. + decode_params!(r, version, + 0x03 => authorization_token: Option, + ); + return Ok((None, authorization_token)); + } + + decode_params!(r, version, + 0x03 => authorization_token: Option, + cluster::HOP_PATH => hops: Option, + cluster::ROUTE_COST => cost: Option, + ); + + Ok(( + Some(cluster::Advert { + hops: hops.ok_or(DecodeError::InvalidValue)?, + cost: cost.unwrap_or(0), + }), + authorization_token, + )) +} + /// REQUEST_UPDATE (0x02) on a PUBLISH_NAMESPACE stream: the cluster parameters that /// changed (draft-lcurley-moq-cluster, Updating an Advertisement). /// @@ -77,6 +141,10 @@ pub struct PublishNamespaceUpdate { pub hops: Option, /// ROUTE_COST, when the cost changed. pub cost: Option, + /// The `AUTHORIZATION TOKEN` (0x03) presented on this REQUEST_UPDATE, if any. A fresh + /// token refreshes the announce's request grant (MoQ request-token); the value is the + /// Token structure of section 8.9, decoded with [`super::token::decode_value`]. + pub authorization_token: Option, } impl PublishNamespaceUpdate { @@ -86,6 +154,7 @@ impl PublishNamespaceUpdate { request_id, hops: (held.hops != next.hops).then(|| next.hops.clone()), cost: (held.cost != next.cost).then_some(next.cost), + authorization_token: None, } } @@ -111,6 +180,7 @@ impl Message for PublishNamespaceUpdate { _ => self.request_id.encode(w, version)?, } encode_params!(w, version, + 0x03 => self.authorization_token.clone(), cluster::HOP_PATH => self.hops, cluster::ROUTE_COST => self.cost, ); @@ -128,10 +198,16 @@ impl Message for PublishNamespaceUpdate { _ => RequestId::decode(r, version)?, }; decode_params!(r, version, + 0x03 => authorization_token: Option, cluster::HOP_PATH => hops: Option, cluster::ROUTE_COST => cost: Option, ); - Ok(Self { request_id, hops, cost }) + Ok(Self { + request_id, + hops, + cost, + authorization_token, + }) } } @@ -358,6 +434,7 @@ mod tests { request_id: RequestId(1), track_namespace: Path::new("test/broadcast"), cluster: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft14); @@ -366,6 +443,65 @@ mod tests { assert_eq!(decoded.track_namespace.as_str(), "test/broadcast"); } + /// A request-borne AUTHORIZATION TOKEN round-trips on a legacy draft (draft-14) and a + /// strict one (draft-18), so a non-moq-dev peer can present a credential on a + /// PUBLISH_NAMESPACE the draft-17+ standard way. + #[test] + fn authorization_token_round_trips_legacy_and_strict() { + let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); + for version in [Version::Draft14, Version::Draft18] { + let msg = PublishNamespace { + request_id: RequestId(1), + track_namespace: Path::new("room/alice"), + cluster: None, + authorization_token: Some(token.clone()), + }; + let encoded = encode_message(&msg, version); + let decoded: PublishNamespace = decode_message(&encoded, version).unwrap(); + assert_eq!(decoded.authorization_token, Some(token.clone()), "{version:?}"); + assert_eq!(decoded.track_namespace.as_str(), "room/alice", "{version:?}"); + } + } + + /// No token stays no token: a PUBLISH_NAMESPACE without one carries no phantom + /// parameter, on both families. + #[test] + fn absent_authorization_token_stays_none() { + for version in [Version::Draft14, Version::Draft18] { + let msg = PublishNamespace { + request_id: RequestId(2), + track_namespace: Path::new("room/bob"), + cluster: None, + authorization_token: None, + }; + let encoded = encode_message(&msg, version); + let decoded: PublishNamespace = decode_message(&encoded, version).unwrap(); + assert_eq!(decoded.authorization_token, None, "{version:?}"); + } + } + + /// On a cluster-negotiated session the token rides the same parameter block as + /// HOP_PATH and ROUTE_COST, and both survive the trip. + #[test] + fn authorization_token_rides_alongside_cluster_params() { + let version = Version::Draft19; + let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); + let msg = PublishNamespace { + request_id: RequestId(3), + track_namespace: Path::new("room/alice"), + cluster: Some(cluster::Advert { + hops: hop_path(&[7, 9]), + cost: 5, + }), + authorization_token: Some(token.clone()), + }; + let mut buf = bytes::Bytes::from(encode_message(&msg, version)); + let decoded = PublishNamespace::decode_body(&mut buf, version, true).unwrap(); + assert!(buf.is_empty()); + assert_eq!(decoded.authorization_token, Some(token)); + assert_eq!(decoded.cluster, msg.cluster); + } + #[test] fn test_announce_error() { let msg = PublishNamespaceError { @@ -447,6 +583,7 @@ mod tests { request_id: RequestId(5), track_namespace: Path::new("v17/broadcast"), cluster: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft17); @@ -462,6 +599,7 @@ mod tests { request_id: RequestId(5), track_namespace: Path::new("v18/broadcast"), cluster: None, + authorization_token: None, }; let encoded = encode_message(&msg, Version::Draft18); @@ -554,6 +692,7 @@ mod tests { request_id: RequestId(2), hops: None, cost: Some(0), + authorization_token: None, }; let mut buf = BytesMut::new(); assert!(msg.encode_msg(&mut buf, Version::Draft16).is_err()); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 13d4c636cb..772c68574f 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -40,8 +40,9 @@ fn serving_subscription(subscriber_priority: u8) -> Subscription { /// Read one `[type][size][body]` control message off a request stream, or `None` once /// the peer finishes it. Mirrors [`super::auth`]'s reader, but borrows only the reader so -/// a renewal can answer on the writer once the read yields a message. -async fn read_control( +/// a renewal can answer on the writer once the read yields a message. Shared with the +/// subscriber's announce loop. +pub(super) async fn read_control( reader: &mut Reader, ) -> Result, Error> { let Some(id) = reader.decode_maybe::().await? else { @@ -1731,6 +1732,7 @@ where request_id, track_namespace: path.as_path(), cluster, + authorization_token: None, }) .await?; @@ -5150,6 +5152,7 @@ mod tests { request_id: RequestId(3), hops: None, cost: Some(0), + authorization_token: None, }, ) .await; @@ -5224,6 +5227,7 @@ mod tests { crate::Hops::try_from(vec![crate::Hop::new(8).unwrap(), crate::Hop::new(1).unwrap()]).unwrap(), )), cost: Some(0), + authorization_token: None, }, ) .await; @@ -5302,6 +5306,7 @@ mod tests { request_id: RequestId(3), hops: None, cost: Some(0), + authorization_token: None, }, ) .await; diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index e30f547ef0..35c21c800b 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -1612,6 +1612,7 @@ mod tests { request_id: RequestId(1), track_namespace: crate::Path::new("room/host"), cluster: None, + authorization_token: None, }) .await .unwrap(); diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index bf57ee0bb7..89ed52fc5c 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -419,6 +419,7 @@ mod tests { hops: hop_path(&[7]), cost: 0, }), + authorization_token: None, }; let mut buf = BytesMut::new(); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 5c6abef134..cff919c8e0 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1039,6 +1039,57 @@ where return Ok(()); }; + // A request token on the PUBLISH_NAMESPACE authorizes the announce when the session + // grant does not already cover it (MoQ request-token, draft-17 section 9.3.2 / + // draft-18+ section 10.2.2). Purely additive: with no token, or a grant that covers + // the path, everything below is unchanged and the origin model decides scope as it + // did before. + let mut token_grant = None; + if let Some(token) = &msg.authorization_token + && !self.auth.allows(crate::auth::Direction::Subscribe, path.as_str()) + { + // A structurally malformed token is refused at the request level, never the session. + let structure = match crate::ietf::token::decode_value(token, self.version) { + Ok(structure) => structure, + Err(_) => { + self.write_error(&mut stream, request_id, &Error::Unauthorized, "malformed authorization token") + .await?; + let _ = stream.writer.close().await; + return Ok(()); + } + }; + let verdict = self.auth.verify_request( + bytes::Bytes::from(structure.value), + structure.kind, + path.clone(), + crate::auth::RequestKind::PublishNamespace, + ); + match verdict.grant().await { + // The token's grant must cover this announce; it authorizes nothing else and + // never joins the session union. + Ok(grant) if grant.subscribe.matches(path.as_str()) => { + token_grant = Some(crate::auth::RequestGrant::new(&self.runtime, verdict, grant)); + } + Ok(_) => { + self.write_error( + &mut stream, + request_id, + &Error::Unauthorized, + "token does not cover this request", + ) + .await?; + let _ = stream.writer.close().await; + return Ok(()); + } + // UNAUTHORIZED for a refusal, NOT_SUPPORTED when no consumer verifies tokens. + Err(err) => { + self.write_error(&mut stream, request_id, &err, &err.to_string()).await?; + let _ = stream.writer.close().await; + return Ok(()); + } + } + } + match self.start_announce(path.clone(), advert) { Ok(_) => { if let Err(err) = self.write_ok(&mut stream, request_id).await { @@ -1063,7 +1114,7 @@ where // update) is not released twice here. let mut attached = true; let res = self - .run_publish_namespace_updates(&mut stream, &path, msg.cluster, peer, &mut attached) + .run_publish_namespace_updates(&mut stream, &path, msg.cluster, peer, &mut attached, token_grant) .await; if attached { @@ -1103,11 +1154,39 @@ where mut held: Option, peer: cluster::Peer, attached: &mut bool, + mut token_grant: Option>, ) -> Result<(), Error> { loop { - let type_id: u64 = match stream.reader.decode_maybe().await? { - Some(id) => id, - None => return Ok(()), + // Read one control message, ending the announce (never the session) if the + // request grant lapses or is revoked meanwhile. The read future borrows only the + // reader, in an inner block, so that borrow is gone before a renewal answers on + // the writer. + enum Ctl { + Message(u64, bytes::Bytes), + Closed, + Ended(Error), + } + let ctl = { + let mut read = std::pin::pin!(super::publisher::read_control(&mut stream.reader)); + kio::wait(|waiter| -> Poll> { + if let Some(rg) = token_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + return Poll::Ready(Ok(Ctl::Ended(err))); + } + match waiter.poll_future(read.as_mut()) { + Poll::Ready(Ok(Some((id, data)))) => Poll::Ready(Ok(Ctl::Message(id, data))), + Poll::Ready(Ok(None)) => Poll::Ready(Ok(Ctl::Closed)), + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Pending => Poll::Pending, + } + }) + .await? + }; + let (type_id, mut data) = match ctl { + Ctl::Message(type_id, data) => (type_id, data), + Ctl::Closed => return Ok(()), + Ctl::Ended(err) => return Err(err), }; let terminal = self.terminal_publish_namespace(type_id); if type_id != ietf::PublishNamespaceUpdate::ID && !terminal { @@ -1117,9 +1196,6 @@ where return Err(Error::UnexpectedMessage); } - let size: u16 = stream.reader.decode().await?; - let mut data = stream.reader.read_exact(size as usize).await?; - if terminal { ietf::PublishNamespaceDone::decode_msg(&mut data, self.version)?; if !data.is_empty() { @@ -1136,6 +1212,39 @@ where return Err(Error::WrongSize); } + // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant + // (MoQ request-token), when the announce is token-authorized. On accept the old + // grant is dropped and the deadline re-armed (REQUEST_OK); a refused or uncovered + // renewal keeps the old grant (it stands until it lapses) and answers UNAUTHORIZED + // without tearing down the announce. A cluster reprice never carries a token. + if let (Some(token), Some(rg)) = (&msg.authorization_token, token_grant.as_mut()) { + let structure = match crate::ietf::token::decode_value(token, self.version) { + Ok(structure) => structure, + Err(_) => { + self.write_error(stream, msg.request_id, &Error::Unauthorized, "malformed authorization token") + .await?; + continue; + } + }; + let verdict = self.auth.verify_request( + bytes::Bytes::from(structure.value), + structure.kind, + path.clone(), + crate::auth::RequestKind::PublishNamespace, + ); + match verdict.grant().await { + Ok(grant) if grant.subscribe.matches(path.as_str()) => { + rg.renew(verdict, grant); + self.write_ok(stream, msg.request_id).await?; + } + _ => { + self.write_error(stream, msg.request_id, &Error::Unauthorized, "renewal not granted") + .await?; + } + } + continue; + } + // An omitted parameter keeps its value, so the update lands on what the peer // already advertised. The parameters exist only on a session that negotiated // the extension; anywhere else they are the peer's violation. @@ -4591,6 +4700,247 @@ mod tests { ); } + /// A subscriber whose session grant covers only `other`, with an app auth acceptor + /// wired in, so a PUBLISH_NAMESPACE for `room/alice` falls to its request token. The + /// subscribe stream's reader is scripted with `first_script` (a REQUEST_UPDATE, for the + /// renewal test). Returns the presented credential to keep it alive. + fn auth_announce_harness( + version: Version, + first_script: Vec, + ) -> ( + Subscriber, + crate::auth::Requests, + crate::auth::Token, + origin::Consumer, + crate::lite::test_transport::ScriptedSession, + ) { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![first_script]); + let (tasks, task_set) = crate::util::TaskSet::new(); + std::mem::forget(task_set); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let auth = crate::auth::Handle::new(true); + let requests = auth.requests().unwrap(); + let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree("other").unwrap().into(), + expires: None, + }, + ); + assert!( + !auth.allows(crate::auth::Direction::Subscribe, "room/alice"), + "the session grant must not cover the announced path" + ); + + let subscriber = Subscriber::new( + crate::time::Clock::tokio(), + session.clone(), + origin, + Control::new(None, false), + None, + peer_setup, + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_auth(auth); + + (subscriber, requests, cred, consumer, session) + } + + /// A grant of everything to subscribe, lapsing in `secs` (or never), on the subscriber's + /// clock. + fn subscribe_grant_expiring(runtime: &crate::time::Clock, secs: Option) -> crate::auth::Grant { + crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: secs.map(|s| { + crate::runtime::Timers::now(runtime) + .checked_add(std::time::Duration::from_secs(s)) + .unwrap() + }), + } + } + + /// A Token structure value that decodes via `token::decode_value` (USE_VALUE, kind 300). + fn announce_token() -> bytes::Bytes { + bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]) + } + + fn token_publish_namespace() -> ietf::PublishNamespace<'static> { + ietf::PublishNamespace { + request_id: RequestId(1), + track_namespace: crate::Path::new("room/alice"), + cluster: None, + authorization_token: Some(announce_token()), + } + } + + /// One REQUEST_UPDATE on the announce stream carrying a fresh token, framed as the peer + /// sends it. + async fn publish_namespace_update_with_token(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let msg = ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + hops: None, + cost: None, + authorization_token: Some(announce_token()), + }; + writer.encode(&ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// A request token on a PUBLISH_NAMESPACE the session grant does not cover authorizes + /// the announce: the subscriber verifies it through the acceptor and attaches the route. + #[tokio::test] + async fn a_publish_namespace_token_authorizes_an_uncovered_announce() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(VERSION, Vec::new()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async { + let mut held = Vec::new(); + loop { + let request = requests.next().await.expect("a request"); + held.push(request.accept(crate::auth::Grant::all())); + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut announced = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some() { + announced = true; + break; + } + settle().await; + } + assert!(announced, "a valid request token must authorize the announce"); + } + + /// A refused request token is answered UNAUTHORIZED and the announce is not attached; the + /// session is untouched. + #[tokio::test] + async fn a_refused_publish_namespace_token_is_not_announced() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(VERSION, Vec::new()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async { + let request = requests.next().await.expect("a request"); + request.reject(crate::SessionError::Unauthorized, "no"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "a refused announce ends its own stream"); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "a refused token must not attach the route" + ); + } + + /// A REQUEST_UPDATE the acceptor renews keeps a token-authorized announce alive past the + /// old grant's expiry: the subscriber re-verifies the token off the announce stream and + /// re-arms the deadline. + #[tokio::test(start_paused = true)] + async fn a_publish_namespace_renewal_extends_past_the_old_expiry() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = + auth_announce_harness(VERSION, publish_namespace_update_with_token(VERSION).await); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + let mut held = Vec::new(); + // The first grant lapses in 60s; the renewal never expires. + let first = requests.next().await.expect("a request"); + held.push(first.accept(subscribe_grant_expiring(&rt, Some(60)))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let renewal = requests.next().await.expect("a renewal"); + held.push(renewal.accept(subscribe_grant_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended during setup"); + if answered.load(std::sync::atomic::Ordering::Relaxed) >= 2 { + break; + } + settle().await; + } + assert_eq!( + answered.load(std::sync::atomic::Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); + // Let the loop apply the renewal it read. + for _ in 0..20 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + + // Past the original 60s expiry: the renewal re-armed the deadline, so the announce + // stays attached. + tokio::time::advance(std::time::Duration::from_secs(120)).await; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "renewal did not extend the announce"); + settle().await; + } + assert!( + routed_now(&consumer, "room/alice").is_some(), + "the renewed announce must still be attached" + ); + } + /// NAMESPACE has no REQUEST_UPDATE, so a peer reprices one by re-sending it on the /// SUBSCRIBE_NAMESPACE stream. The repeat is neither a duplicate nor a violation: it /// replaces the advertisement in place, and the route is never retracted for it. @@ -4728,6 +5078,7 @@ mod tests { request_id: RequestId(0), track_namespace: path.borrow(), cluster: None, + authorization_token: None, }; subscriber .run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None) @@ -4784,6 +5135,7 @@ mod tests { request_id: RequestId(0), track_namespace: path.borrow(), cluster: None, + authorization_token: None, }; subscriber .run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None) @@ -5064,6 +5416,7 @@ mod tests { request_id: RequestId(3 + 2 * i as u64), hops: Some(advert.hops.clone()), cost: Some(advert.cost), + authorization_token: None, }) .await .unwrap(); @@ -5201,6 +5554,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..100 { @@ -5247,6 +5601,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); // Both updates apply, then the loop parks on the exhausted script. The @@ -5293,6 +5648,7 @@ mod tests { request_id: RequestId(3), hops: None, cost: Some(0), + authorization_token: None, }) .await .unwrap(); @@ -5314,6 +5670,7 @@ mod tests { Some(held.clone()), peer, &mut attached, + None, )); for _ in 0..20 { assert!(futures::poll!(run.as_mut()).is_pending(), "the stream stays open"); @@ -5362,6 +5719,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..20 { if let std::task::Poll::Ready(res) = futures::poll!(run.as_mut()) { @@ -5408,6 +5766,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); for _ in 0..20 { assert!( @@ -5448,6 +5807,7 @@ mod tests { cost: 0, ..clean.clone() }), + authorization_token: None, }) .await .unwrap(); @@ -5466,6 +5826,7 @@ mod tests { Some(clean.clone()), peer, &mut attached, + None, )); let mut result = None; for _ in 0..20 { From 52673e00de136e90f03cf61ca1bf6182e10f33d2 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 21:14:53 +0000 Subject: [PATCH 07/49] feat(net): accept the AUTHORIZATION TOKEN on FETCH, PUBLISH, TRACK_STATUS, SUBSCRIBE_NAMESPACE A moq-transport peer may carry the AUTHORIZATION TOKEN parameter (0x03) on any of these requests. The strict draft-15+ decoders rejected the whole message on the unknown key, failing the session; the legacy draft-14 block already tolerated it. The strict decoders now consume the parameter so a request-token peer is not decode-killed. The token is dropped rather than acted on: FETCH, TRACK_STATUS, and PUBLISH are refused for other reasons (unsupported or joining-only), and SUBSCRIBE_NAMESPACE scopes through the origin, so none of them authorize by a request token yet. Only SUBSCRIBE and PUBLISH_NAMESPACE do. Nothing new goes on the wire; the parameter is already defined on these messages in every supported draft. A legacy and a strict decode test per message assert the token is accepted. Co-Authored-By: Claude --- rs/moq-net/src/ietf/fetch.rs | 43 ++++++++++++++++++++ rs/moq-net/src/ietf/publish.rs | 46 ++++++++++++++++++++++ rs/moq-net/src/ietf/subscribe_namespace.rs | 37 ++++++++++++++++- rs/moq-net/src/ietf/track.rs | 46 +++++++++++++++++++++- 4 files changed, 169 insertions(+), 3 deletions(-) diff --git a/rs/moq-net/src/ietf/fetch.rs b/rs/moq-net/src/ietf/fetch.rs index 5987d8efbc..14bfb1bd42 100644 --- a/rs/moq-net/src/ietf/fetch.rs +++ b/rs/moq-net/src/ietf/fetch.rs @@ -162,6 +162,7 @@ impl Message for Fetch<'_> { _ => { let fetch_type = FetchType::decode(buf, version)?; decode_params!(buf, version, + 0x03 => _authorization_token: Option, 0x20 => subscriber_priority: Option, 0x22 => group_order: Option, ); @@ -755,6 +756,48 @@ mod tests { ]; assert_eq!(encode_message(&msg, Version::Draft18), expected); } + + /// A request-token peer may present an AUTHORIZATION TOKEN (0x03) on a FETCH. The strict + /// decoder consumes it and the legacy trailing block carries it, so the message is + /// accepted rather than failing the session; the token itself is dropped (a FETCH is not + /// authorized by a request token yet). + #[test] + fn fetch_accepts_a_dropped_authorization_token() -> Result<(), EncodeError> { + let token = &[0x03u8, 0x81, 0x2c, 0x00, 0xff]; + let standalone = || FetchType::Standalone { + namespace: Path::new("room"), + track: "video".into(), + start: Location { group: 0, object: 0 }, + end: Location { group: 1, object: 0 }, + }; + + // Strict (draft-18): the parameter is consumed by decode_params. + let version = Version::Draft18; + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + standalone().encode(&mut body, version).unwrap(); + encode_params!(&mut body, version, 0x03 => Some(bytes::Bytes::from_static(token))); + let mut buf = body.freeze(); + assert_eq!( + Fetch::decode_msg(&mut buf, version).expect("strict token accepted").request_id, + RequestId(1) + ); + assert!(buf.is_empty()); + + // Legacy (draft-14): the token rides the trailing parameter block. + let version = Version::Draft14; + let mut params = crate::ietf::Parameters::default(); + params.set_bytes(crate::ietf::ParameterBytes::AuthorizationToken, token.to_vec()); + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + 128u8.encode(&mut body, version).unwrap(); + GroupOrder::Descending.encode(&mut body, version).unwrap(); + standalone().encode(&mut body, version).unwrap(); + params.encode(&mut body, version).unwrap(); + let mut buf = body.freeze(); + Fetch::decode_msg(&mut buf, version).expect("legacy token accepted"); + Ok(()) + } } /// The Object serialization on a fetch stream (draft-20 section 11.4.4), which a fill's diff --git a/rs/moq-net/src/ietf/publish.rs b/rs/moq-net/src/ietf/publish.rs index cf93727fd1..0c81ae4c2a 100644 --- a/rs/moq-net/src/ietf/publish.rs +++ b/rs/moq-net/src/ietf/publish.rs @@ -334,6 +334,7 @@ impl Message for Publish<'_> { // letting the request reach its NOT_SUPPORTED response. decode_params!(r, version, 0x02 => object_delivery_timeout: Option, + 0x03 => _authorization_token: Option, 0x06 => subgroup_delivery_timeout: Option, 0x08 => _expires: Option, 0x09 => largest_location: Option, @@ -956,4 +957,49 @@ mod tests { assert_eq!(decoded.status_code, UNKNOWN); } + + /// A request-token peer may present an AUTHORIZATION TOKEN (0x03) on a PUBLISH. The + /// strict decoder consumes it and the legacy trailing block carries it, so the message + /// is accepted rather than failing the session; the token is dropped (PUBLISH is refused + /// NOT_SUPPORTED regardless). + #[test] + fn publish_accepts_a_dropped_authorization_token() -> Result<(), EncodeError> { + use super::super::namespace::encode_namespace; + let token = &[0x03u8, 0x81, 0x2c, 0x00, 0xff]; + + // Strict (draft-18): the parameter is consumed alongside the Track Properties block. + let version = Version::Draft18; + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + encode_namespace(&mut body, &Path::new("room"), version).unwrap(); + "video".encode(&mut body, version).unwrap(); + 42u64.encode(&mut body, version).unwrap(); + encode_params!(&mut body, version, 0x03 => Some(bytes::Bytes::from_static(token))); + Properties::default().encode(&mut body, version).unwrap(); + let mut buf = body.freeze(); + assert_eq!( + Publish::decode_msg(&mut buf, version) + .expect("strict token accepted") + .track_alias, + 42 + ); + assert!(buf.is_empty()); + + // Legacy (draft-14): the token rides the trailing parameter block. + let version = Version::Draft14; + let mut params = crate::ietf::Parameters::default(); + params.set_bytes(crate::ietf::ParameterBytes::AuthorizationToken, token.to_vec()); + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + encode_namespace(&mut body, &Path::new("room"), version).unwrap(); + "video".encode(&mut body, version).unwrap(); + 42u64.encode(&mut body, version).unwrap(); + GroupOrder::Descending.encode(&mut body, version).unwrap(); + false.encode(&mut body, version).unwrap(); // content exists + true.encode(&mut body, version).unwrap(); // forward + params.encode(&mut body, version).unwrap(); + let mut buf = body.freeze(); + Publish::decode_msg(&mut buf, version).expect("legacy token accepted"); + Ok(()) + } } diff --git a/rs/moq-net/src/ietf/subscribe_namespace.rs b/rs/moq-net/src/ietf/subscribe_namespace.rs index 89ed52fc5c..b06bfa30ef 100644 --- a/rs/moq-net/src/ietf/subscribe_namespace.rs +++ b/rs/moq-net/src/ietf/subscribe_namespace.rs @@ -76,7 +76,7 @@ impl Message for SubscribeNamespace<'_> { } let request_id = RequestId::decode(r, version)?; let namespace = decode_namespace(r, version)?; - decode_params!(r, version, HIDDEN_PARAM => hidden: Option); + decode_params!(r, version, 0x03 => _authorization_token: Option, HIDDEN_PARAM => hidden: Option); Ok(Self { request_id, @@ -135,7 +135,7 @@ impl Message for SubscribeNamespaceLegacy<'_> { _ => 0x01, }; - decode_params!(r, version, HIDDEN_PARAM => hidden: Option); + decode_params!(r, version, 0x03 => _authorization_token: Option, HIDDEN_PARAM => hidden: Option); Ok(Self { request_id, @@ -537,4 +537,37 @@ mod tests { Err(DecodeError::Version) )); } + + /// A request-token peer may present an AUTHORIZATION TOKEN (0x03) on a SUBSCRIBE_NAMESPACE. + /// Both message shapes consume it, so the message is accepted rather than failing the + /// session; the token is dropped. + #[test] + fn subscribe_namespace_accepts_a_dropped_authorization_token() -> Result<(), EncodeError> { + let token = &[0x03u8, 0x81, 0x2c, 0x00, 0xff]; + + // Modern (draft-18, 0x50). + let version = Version::Draft18; + let mut buf = BytesMut::new(); + RequestId(4).encode(&mut buf, version).unwrap(); + encode_namespace(&mut buf, &Path::new("example"), version).unwrap(); + encode_params!(&mut buf, version, 0x03 => Some(bytes::Bytes::from_static(token))); + let mut bytes = buf.freeze(); + assert_eq!( + SubscribeNamespace::decode_msg(&mut bytes, version) + .expect("modern token accepted") + .request_id, + RequestId(4) + ); + assert!(bytes.is_empty()); + + // Legacy (draft-14, 0x11). + let version = Version::Draft14; + let mut buf = BytesMut::new(); + RequestId(4).encode(&mut buf, version).unwrap(); + encode_namespace(&mut buf, &Path::new("example"), version).unwrap(); + encode_params!(&mut buf, version, 0x03 => Some(bytes::Bytes::from_static(token))); + let mut bytes = buf.freeze(); + SubscribeNamespaceLegacy::decode_msg(&mut bytes, version).expect("legacy token accepted"); + Ok(()) + } } diff --git a/rs/moq-net/src/ietf/track.rs b/rs/moq-net/src/ietf/track.rs index 2c600c331c..6d7e01b8dd 100644 --- a/rs/moq-net/src/ietf/track.rs +++ b/rs/moq-net/src/ietf/track.rs @@ -68,7 +68,9 @@ impl Message for TrackStatus<'_> { let _params = Parameters::decode(r, version)?; } _ => { - decode_params!(r, version,); + decode_params!(r, version, + 0x03 => _authorization_token: Option, + ); } } @@ -150,6 +152,48 @@ mod tests { assert_eq!(decoded.track_name, "video"); } + /// A request-token peer may present an AUTHORIZATION TOKEN (0x03) on a TRACK_STATUS. The + /// strict decoder consumes it and the legacy trailing block carries it, so the message + /// is accepted rather than failing the session; the token is dropped (TRACK_STATUS is + /// refused NOT_SUPPORTED regardless). + #[test] + fn track_status_accepts_a_dropped_authorization_token() -> Result<(), EncodeError> { + let token = &[0x03u8, 0x81, 0x2c, 0x00, 0xff]; + + // Strict (draft-18): the parameter is consumed by decode_params. + let version = Version::Draft18; + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + encode_namespace(&mut body, &Path::new("test/ns"), version).unwrap(); + "video".encode(&mut body, version).unwrap(); + encode_params!(&mut body, version, 0x03 => Some(bytes::Bytes::from_static(token))); + let mut buf = body.freeze(); + assert_eq!( + TrackStatus::decode_msg(&mut buf, version) + .expect("strict token accepted") + .track_name, + "video" + ); + assert!(buf.is_empty()); + + // Legacy (draft-14): the token rides the trailing parameter block. + let version = Version::Draft14; + let mut params = Parameters::default(); + params.set_bytes(crate::ietf::ParameterBytes::AuthorizationToken, token.to_vec()); + let mut body = BytesMut::new(); + RequestId(1).encode(&mut body, version).unwrap(); + encode_namespace(&mut body, &Path::new("test/ns"), version).unwrap(); + "video".encode(&mut body, version).unwrap(); + 0u8.encode(&mut body, version).unwrap(); // subscriber priority + GroupOrder::Descending.encode(&mut body, version).unwrap(); + false.encode(&mut body, version).unwrap(); // forward + Filter::NextObject.encode(&mut body, version).unwrap(); + params.encode(&mut body, version).unwrap(); + let mut buf = body.freeze(); + TrackStatus::decode_msg(&mut buf, version).expect("legacy token accepted"); + Ok(()) + } + #[test] fn test_track_status_v17_round_trip() { let msg = TrackStatus { From f4deb5a5f9d18cf0b9c489193ad81c18d94636a4 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 21:19:57 +0000 Subject: [PATCH 08/49] feat(net): accept the AUTHORIZATION TOKEN message parameter in js/net The strict draft-17+ message-parameter decoder threw on any unknown key, so a moq-transport peer presenting an AUTHORIZATION TOKEN (0x03) on a request would fail the whole message; the legacy count-prefixed form already tolerated it. js/net now recognizes 0x03 as a bytes parameter, so the token is decoded and dropped across SUBSCRIBE, FETCH, PUBLISH, TRACK_STATUS, PUBLISH_NAMESPACE and SUBSCRIBE_NAMESPACE. There is no accept-side consumer API in js/net (scoped out, PR question ii), so the token is not verified: an uncovered request is still refused by the session grant as before. Mirrors rs/moq-net. Co-Authored-By: Claude --- js/net/src/ietf/ietf.test.ts | 21 +++++++++++++++++++++ js/net/src/ietf/parameters.ts | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/js/net/src/ietf/ietf.test.ts b/js/net/src/ietf/ietf.test.ts index ec6d906c20..852e062299 100644 --- a/js/net/src/ietf/ietf.test.ts +++ b/js/net/src/ietf/ietf.test.ts @@ -90,6 +90,27 @@ async function encodeFetchFrameVersioned( return concatChunks(written); } +test("message parameters accept and drop an AUTHORIZATION TOKEN (0x03)", async () => { + // A request-token peer may present the token on SUBSCRIBE, FETCH, PUBLISH, TRACK_STATUS, + // PUBLISH_NAMESPACE or SUBSCRIBE_NAMESPACE. draft-16 tolerates unknown parameters + // generically; a draft-18 strict decoder must recognize 0x03 or it fails the whole + // message. We have no accept-side consumer, so the value is decoded and dropped. + const token = new Uint8Array([0x03, 0x81, 0x2c, 0x00, 0xff]); + for (const version of [Version.DRAFT_16, Version.DRAFT_18]) { + const params = new Parameters(); + params.bytes.set(0x03n, token); + + const { stream, written } = createTestWritableStream(); + const writer = new Writer(stream, version); + await params.encode(writer, version); + writer.close(); + await writer.closed; + + const decoded = await Parameters.decode(new Reader(undefined, concatChunks(written), version), version); + expect(decoded.bytes.get(0x03n)).toEqual(token); + } +}); + test("DEFAULT_PUBLISHER_PRIORITY has exact SUBSCRIBE_OK bytes per draft", async () => { for (const version of [ Version.DRAFT_14, diff --git a/js/net/src/ietf/parameters.ts b/js/net/src/ietf/parameters.ts index 65b1c65047..e662b54ded 100644 --- a/js/net/src/ietf/parameters.ts +++ b/js/net/src/ietf/parameters.ts @@ -211,6 +211,11 @@ const MSG_PARAM_HIDDEN = 0x40b5en; // Bytes parameter IDs (odd) const MSG_PARAM_LARGEST_OBJECT = 0x09n; +/// AUTHORIZATION TOKEN (0x03): a per-request credential (MoQ request-token). This client has +/// no accept-side consumer to verify one, so it is decoded and dropped; an uncovered request +/// is then refused by the session grant as before. Recognizing it keeps a draft-17+ peer that +/// presents a token from failing the whole message on an unknown parameter. +const MSG_PARAM_AUTHORIZATION_TOKEN = 0x03n; const MSG_PARAM_SUBSCRIPTION_FILTER = 0x21n; /// FILL_PARAMETERS, draft-20's request for a backfill. const MSG_PARAM_FILL_PARAMETERS = 0x23n; @@ -245,6 +250,7 @@ function getMessageParamKind(id: bigint): MessageParamKind { case MSG_PARAM_FILL_PARAMETERS: case MSG_PARAM_INCLUDE_PROPERTIES: case MSG_PARAM_HOP_PATH: + case MSG_PARAM_AUTHORIZATION_TOKEN: return "bytes"; default: throw new Error(`unknown message parameter id: ${id.toString()}`); From a26abf633fb2daf4b6eba6e7329c2a4292df67f7 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 21:22:52 +0000 Subject: [PATCH 09/49] docs(relay): note request-borne AUTHORIZATION TOKENs in auth.md Document that the AUTHORIZATION TOKEN may ride an individual request, not only the SETUP: session grant first, then the request's token, else UNAUTHORIZED; the token's grant covers only its request and ends with it; a REQUEST_UPDATE refreshes it in place. The relay refuses a token-bearing request it cannot verify (NOT_SUPPORTED with no acceptor); wiring a per-request lease into the --auth-url server is flagged as a follow-up. Co-Authored-By: Claude --- doc/bin/relay/auth.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 8e84fde839..333ed75156 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -164,6 +164,26 @@ TOKEN` option with Token Type 0; `moq auth serve` verifies it the same way. HMAC can itself be **scoped** at generation (`--root`, `--publish`, `--subscribe`), after which it can never sign a broader token. +### On a request + +The same `AUTHORIZATION TOKEN` may ride an individual request (SUBSCRIBE, +REQUEST_UPDATE, PUBLISH_NAMESPACE, FETCH, PUBLISH, SUBSCRIBE_NAMESPACE, +TRACK_STATUS), not only the SETUP. A request is authorized by the session's +grant first; when that does not cover the request's path, by the token on the +request; with neither it is refused `UNAUTHORIZED`. A request token's grant +covers only the request it rode on, never widens the session, and ends when +the request ends. A REQUEST_UPDATE carrying a fresh token refreshes it, so a +long-lived request (an ingest PUBLISH_NAMESPACE, a subscription) renews its +credential in place without reconnecting; a refused renewal leaves the old +grant standing until it lapses. + +Verifying a request token needs an application acceptor on the session, the +same seam that answers session tokens ([`admissions()`](#in-process) for an +embedded relay). A relay with no acceptor for it decodes the token and refuses +a request the session grant does not otherwise cover, `NOT_SUPPORTED` when +nothing can verify a token at all. Wiring a per-request lease into the +`--auth-url` server is a follow-up. + ### Claims | Claim | Meaning | From e9a82d2e9dbed9d85dc83fd1df755d515515bb4a Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 21:52:11 +0000 Subject: [PATCH 10/49] fix(net): close on an alias request token and race renewal against the deadline Follow-ups on the request-token path. Close the session on an alias request token: a request token whose structure is DELETE/USE_ALIAS decodes to Error::ProtocolViolation (nothing can be registered before SETUP). Every request call site downgraded it to a per-request UNAUTHORIZED, so a connection-level violation left the session alive. It now closes the session exactly as the SETUP path does; a merely-undecodable structure is still refused per request without tearing down the connection. Publisher SUBSCRIBE + renewal and subscriber PUBLISH_NAMESPACE + renewal all propagate it. Test per side: an_alias_token_on_a_request_is_a_protocol_violation. Race renewal against the deadline: the renewal verify was a bare `verdict.grant().await` outside the kio::wait, so a slow acceptor stalled serving and, worse, let a request outlive its grant because the old deadline was not polled. The verify is now a pending verdict polled inside the serve loop alongside serving and the old grant's deadline (RequestVerdict::poll_grant); the loop pauses reading until it resolves, and the deadline still fires if the acceptor hangs. Test: a_slow_renewal_does_not_stall_serving. Propagate publisher renewal write failures: the publisher renewal answers now propagate a write failure with `?`, as the subscriber already did, instead of swallowing it. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 29 ++-- rs/moq-net/src/ietf/publisher.rs | 217 ++++++++++++++++++++++++------ rs/moq-net/src/ietf/subscriber.rs | 106 ++++++++++++--- 3 files changed, 282 insertions(+), 70 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index bdd425b7a1..ae7b93b524 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -935,22 +935,25 @@ impl RequestVerdict { /// [`Error::Unsupported`]; an unanswered (dropped) request is /// [`SessionError::Unauthorized`]. pub(crate) async fn grant(&self) -> Result { + kio::wait(|waiter| self.poll_grant(waiter)).await + } + + /// Poll for the acceptor's first answer, so the caller can race the verify against the + /// live grant's deadline and serving rather than blocking on a bare await. + pub(crate) fn poll_grant(&self, waiter: &kio::Waiter) -> Poll> { let Some(issue) = &self.issue else { - return Err(Error::Unsupported); + return Poll::Ready(Err(Error::Unsupported)); }; - kio::wait(|waiter| { - let mut guard = ready_or!(issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { - true => Poll::Pending, - false => Poll::Ready(()), - })); - Poll::Ready(match guard.outbox.pop_front() { - Some(Reply::Grant(grant)) => Ok(grant), - Some(Reply::Refuse { code, .. }) => Err(Error::Session(code)), - // Done with nothing written: the acceptor dropped the request unanswered. - None => Err(Error::Session(SessionError::Unauthorized)), - }) + let mut guard = ready_or!(issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + })); + Poll::Ready(match guard.outbox.pop_front() { + Some(Reply::Grant(grant)) => Ok(grant), + Some(Reply::Refuse { code, .. }) => Err(Error::Session(code)), + // Done with nothing written: the acceptor dropped the request unanswered. + None => Err(Error::Session(SessionError::Unauthorized)), }) - .await } /// After the first grant, poll for the acceptor's next action on this token: a diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 772c68574f..4bed9b14df 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -536,10 +536,15 @@ where let err = Error::Unauthorized; return self.reject_subscribe(stream, request_id, &err, "not granted").await; }; - // A structurally malformed token is refused here rather than failing the - // session at decode, so one bad request never tears down the connection. + // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol violation + // and closes the session exactly as on the SETUP path; a merely-undecodable + // structure is refused per request without tearing down the connection. let structure = match crate::ietf::token::decode_value(token, self.version) { Ok(structure) => structure, + Err(err @ Error::ProtocolViolation) => { + self.session.clone().close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); + return Err(err); + } Err(_) => { let err = Error::Unauthorized; return self @@ -718,15 +723,47 @@ where Closed, // A `[type][size][body]` control message off the subscribe stream. Message(u64, bytes::Bytes), + // A pending renewal's verdict resolved, for the update with this request id. + Renewal(Result, RequestId), } // After SUBSCRIBE_OK the peer may send a REQUEST_UPDATE (SUBSCRIBE_UPDATE, 0x02) - // to refresh the request's token, so the loop reads one control message per turn - // while serving. The read future borrows only `stream.reader` and lives in an - // inner block, so that borrow is released before a renewal answers on - // `stream.writer`. + // to refresh the request's token. The loop reads one control message per turn + // while serving; a renewal's verify is raced against serving and the old grant's + // deadline (never a bare await), so media keeps flowing and the old deadline can + // still fire when the acceptor is slow. While a renewal is pending the loop stops + // reading until its verdict resolves. The read future borrows only + // `stream.reader` and lives in an inner block, so that borrow is released before a + // renewal answers on `stream.writer`. + let mut pending: Option<(crate::auth::RequestVerdict, RequestId)> = None; loop { - let step = { + let step = if let Some((verdict, rid)) = pending.as_mut() { + let rid = *rid; + kio::wait(|waiter| { + if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { + return Poll::Ready(Step::Served(served)); + } + if gate.as_mut().is_some_and(|gate| gate.poll_denied(waiter).is_ready()) { + tracing::info!(broadcast = %absolute, track = %track_name, "subscription no longer authorized"); + return Poll::Ready(Step::Ended((Err(Error::Unauthorized), false))); + } + if let Some(rg) = request_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + tracing::info!(broadcast = %absolute, track = %track_name, %err, "request token grant ended"); + return Poll::Ready(Step::Ended((Err(err), false))); + } + if let Poll::Ready(res) = verdict.poll_grant(waiter) { + return Poll::Ready(Step::Renewal(res, rid)); + } + let mut cx = std::task::Context::from_waker(waiter.waker()); + if closed_session.poll_closed(&mut cx).is_ready() { + return Poll::Ready(Step::Closed); + } + Poll::Pending + }) + .await + } else { let mut read = std::pin::pin!(read_control(&mut stream.reader)); kio::wait(|waiter| { if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { @@ -762,6 +799,34 @@ where match step { Step::Served(served) | Step::Ended(served) => break Some(served), Step::Closed => break None, + Step::Renewal(res, rid) => { + // The pending verdict resolved: consume it and answer the update. + let (verdict, _) = pending.take().expect("a pending renewal"); + let Some(rg) = request_grant.as_mut() else { + continue; + }; + match res { + // The renewal's grant must still cover this request. On accept the + // old grant is dropped (ending the old token) and the deadline is + // re-armed at the new expiry. + Ok(grant) if grant.publish.matches(msg.track_namespace.as_str()) => { + rg.renew(verdict, grant); + self.write_request_ok(&mut stream.writer, rid).await?; + } + // A refused or uncovered renewal keeps the old grant (per the quest, + // the request ends only when that lapses) and answers UNAUTHORIZED so + // the peer can retry before then. + _ => { + self.write_subscribe_error( + &mut stream.writer, + rid, + &Error::Unauthorized, + "renewal not granted", + ) + .await?; + } + } + } Step::Message(id, mut data) => { // Only REQUEST_UPDATE carries a renewal; any other message is ignored, // as it was when the loop only watched for the stream closing. @@ -781,53 +846,39 @@ where }; // Only a token-authorized subscription holds a request grant to renew; // a union-authorized one is already covered by the session grant. - let Some(rg) = request_grant.as_mut() else { + if request_grant.is_none() { continue; - }; - // A structurally malformed token is refused at the request level, - // leaving the old grant to stand until it lapses. + } + // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol + // violation and closes the session, as on the SETUP path; a + // merely-undecodable structure is refused per request, leaving the old + // grant to stand until it lapses. let structure = match crate::ietf::token::decode_value(token, self.version) { Ok(structure) => structure, + Err(err @ Error::ProtocolViolation) => { + self.session.clone().close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); + return Err(err); + } Err(_) => { - let _ = self - .write_subscribe_error( - &mut stream.writer, - update.request_id, - &Error::Unauthorized, - "malformed authorization token", - ) - .await; + self.write_subscribe_error( + &mut stream.writer, + update.request_id, + &Error::Unauthorized, + "malformed authorization token", + ) + .await?; continue; } }; + // Verify the fresh token, but do not block the serve loop on it: the + // verdict is raced against serving and the old grant's deadline above. let verdict = self.auth.verify_request( bytes::Bytes::from(structure.value), structure.kind, msg.track_namespace.to_owned(), crate::auth::RequestKind::Subscribe, ); - match verdict.grant().await { - // The renewal's grant must still cover this request. On accept the - // old grant is dropped (ending the old token) and the deadline is - // re-armed at the new expiry. - Ok(grant) if grant.publish.matches(msg.track_namespace.as_str()) => { - rg.renew(verdict, grant); - let _ = self.write_request_ok(&mut stream.writer, update.request_id).await; - } - // A refused or uncovered renewal keeps the old grant (per the quest, - // the request ends only when that lapses) and answers UNAUTHORIZED so - // the peer can retry before then. - _ => { - let _ = self - .write_subscribe_error( - &mut stream.writer, - update.request_id, - &Error::Unauthorized, - "renewal not granted", - ) - .await; - } - } + pending = Some((verdict, update.request_id)); } } } @@ -3296,6 +3347,90 @@ mod serve_tests { assert!(ended, "a refused renewal must let the old grant lapse the subscription"); } + /// An alias reference (DELETE/USE_ALIAS) on a request token is a connection-level protocol + /// violation, closing the session exactly as on the SETUP path, not a per-request refusal. + #[tokio::test] + async fn an_alias_token_on_a_request_is_a_protocol_violation() { + const VERSION: Version = Version::Draft18; + let (auth, _requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, Vec::new()); + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let mut msg = subscribe(Filter::NextObject, None); + // USE_ALIAS (0x02): nothing can be registered before SETUP, so an alias reference is a + // PROTOCOL_VIOLATION rather than a token we could verify. + msg.authorization_token = Some(bytes::Bytes::from_static(&[0x02, 0x07])); + + let err = h.publisher.clone().run_subscribe_stream(stream, msg).await.unwrap_err(); + assert!(matches!(err, Error::ProtocolViolation), "{err:?}"); + assert_eq!( + h.log.closes().first().map(|c| c.0), + Some(crate::SessionError::ProtocolViolation.to_code()), + "the session must close with PROTOCOL_VIOLATION" + ); + } + + /// A slow acceptor answering a renewal must not stall serving nor let the request outlive + /// its grant: the renewal verify is raced against the old deadline, which still fires. + #[tokio::test(start_paused = true)] + async fn a_slow_renewal_does_not_stall_serving() { + const VERSION: Version = Version::Draft18; + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let popped = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let popped = popped.clone(); + async move { + let mut held = Vec::new(); + // The first grant lapses in 60s. + let first = requests.next().await.expect("a request"); + held.push(first.accept(grant_all_expiring(&rt, Some(60)))); + popped.fetch_add(1, Ordering::Relaxed); + // The renewal is popped but never answered: a slow or hung acceptor. + let _slow = requests.next().await.expect("a renewal"); + popped.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // Let the loop read the REQUEST_UPDATE and the acceptor pop both requests. + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending(), "the subscription ended during setup"); + if popped.load(Ordering::Relaxed) >= 2 { + break; + } + settle().await; + } + assert_eq!(popped.load(Ordering::Relaxed), 2, "the acceptor never saw the renewal"); + + // The acceptor is stuck. Past the old 60s expiry the deadline must still fire and end + // the request UNAUTHORIZED, rather than the pending renewal stalling serving. + tokio::time::advance(Duration::from_secs(120)).await; + let mut ended = None; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + if let Poll::Ready(res) = futures::poll!(serving.as_mut()) { + ended = Some(res); + break; + } + settle().await; + } + let res = ended.expect("serving must end at the old deadline, not stall on the slow renewal"); + assert!(matches!(res, Err(Error::Unauthorized)), "{res:?}"); + } + /// A distinctive request id, so `[FetchHeader::TYPE, REQUEST_ID]` is a usable needle. const REQUEST_ID: u64 = 0x2B; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index cff919c8e0..522e025d77 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1048,9 +1048,16 @@ where if let Some(token) = &msg.authorization_token && !self.auth.allows(crate::auth::Direction::Subscribe, path.as_str()) { - // A structurally malformed token is refused at the request level, never the session. + // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol violation + // and closes the session exactly as on the SETUP path; a merely-undecodable + // structure is refused per request without tearing down the connection. let structure = match crate::ietf::token::decode_value(token, self.version) { Ok(structure) => structure, + Err(err @ Error::ProtocolViolation) => { + self.session + .close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); + return Err(err); + } Err(_) => { self.write_error(&mut stream, request_id, &Error::Unauthorized, "malformed authorization token") .await?; @@ -1156,7 +1163,50 @@ where attached: &mut bool, mut token_grant: Option>, ) -> Result<(), Error> { + let mut pending: Option<(crate::auth::RequestVerdict, RequestId)> = None; loop { + // A renewal verify in flight is raced against the request grant's deadline (never + // a bare await), so the old deadline can still fire while a slow acceptor decides; + // the loop stops reading until the verdict resolves. + if let Some((verdict, rid)) = pending.as_mut() { + let rid = *rid; + enum Ren { + Renewal(Result), + Ended(Error), + } + let ren = kio::wait(|waiter| { + if let Some(rg) = token_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + return Poll::Ready(Ren::Ended(err)); + } + verdict.poll_grant(waiter).map(Ren::Renewal) + }) + .await; + let res = match ren { + Ren::Ended(err) => return Err(err), + Ren::Renewal(res) => res, + }; + let (verdict, _) = pending.take().expect("a pending renewal"); + let Some(rg) = token_grant.as_mut() else { + continue; + }; + match res { + // On accept the old grant is dropped and the deadline re-armed (REQUEST_OK). + Ok(grant) if grant.subscribe.matches(path.as_str()) => { + rg.renew(verdict, grant); + self.write_ok(stream, rid).await?; + } + // A refused or uncovered renewal keeps the old grant until it lapses and + // answers UNAUTHORIZED without tearing down the announce. + _ => { + self.write_error(stream, rid, &Error::Unauthorized, "renewal not granted") + .await?; + } + } + continue; + } + // Read one control message, ending the announce (never the session) if the // request grant lapses or is revoked meanwhile. The read future borrows only the // reader, in an inner block, so that borrow is gone before a renewal answers on @@ -1213,13 +1263,22 @@ where } // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant - // (MoQ request-token), when the announce is token-authorized. On accept the old - // grant is dropped and the deadline re-armed (REQUEST_OK); a refused or uncovered - // renewal keeps the old grant (it stands until it lapses) and answers UNAUTHORIZED - // without tearing down the announce. A cluster reprice never carries a token. - if let (Some(token), Some(rg)) = (&msg.authorization_token, token_grant.as_mut()) { + // (MoQ request-token), when the announce is token-authorized. The verify is not + // awaited here: it becomes the pending renewal raced against the deadline above. A + // cluster reprice never carries a token. + if let Some(token) = &msg.authorization_token + && token_grant.is_some() + { + // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol violation + // and closes the session, as on the SETUP path; a merely-undecodable structure + // is refused per request, leaving the old grant to stand until it lapses. let structure = match crate::ietf::token::decode_value(token, self.version) { Ok(structure) => structure, + Err(err @ Error::ProtocolViolation) => { + self.session + .close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); + return Err(err); + } Err(_) => { self.write_error(stream, msg.request_id, &Error::Unauthorized, "malformed authorization token") .await?; @@ -1232,16 +1291,7 @@ where path.clone(), crate::auth::RequestKind::PublishNamespace, ); - match verdict.grant().await { - Ok(grant) if grant.subscribe.matches(path.as_str()) => { - rg.renew(verdict, grant); - self.write_ok(stream, msg.request_id).await?; - } - _ => { - self.write_error(stream, msg.request_id, &Error::Unauthorized, "renewal not granted") - .await?; - } - } + pending = Some((verdict, msg.request_id)); continue; } @@ -4941,6 +4991,30 @@ mod tests { ); } + /// An alias reference (DELETE/USE_ALIAS) on a PUBLISH_NAMESPACE token is a connection-level + /// protocol violation, closing the session as on the SETUP path, not a per-request refusal. + #[tokio::test] + async fn an_alias_token_on_a_request_is_a_protocol_violation() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, _requests, _cred, _consumer, session) = auth_announce_harness(VERSION, Vec::new()); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let mut msg = token_publish_namespace(); + // USE_ALIAS (0x02): an alias reference cannot precede SETUP, so it is a PROTOCOL_VIOLATION. + msg.authorization_token = Some(bytes::Bytes::from_static(&[0x02, 0x07])); + + let err = subscriber + .run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None) + .await + .unwrap_err(); + assert!(matches!(err, Error::ProtocolViolation), "{err:?}"); + assert_eq!( + session.log.closes().first().map(|c| c.0), + Some(crate::SessionError::ProtocolViolation.to_code()), + "the session must close with PROTOCOL_VIOLATION" + ); + } + /// NAMESPACE has no REQUEST_UPDATE, so a peer reprices one by re-sending it on the /// SUBSCRIBE_NAMESPACE stream. The repeat is neither a duplicate nor a violation: it /// replaces the advertisement in place, and the route is never retracted for it. From 9a924d6a4472fcbaf66616128bc5181695926380 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 21:52:11 +0000 Subject: [PATCH 11/49] docs(relay): scope the request-token note to the moq-net seam (part B pending) The "On a request" section described relay verification through admissions() that this change does not deliver. Scope it to the moq-net library seam (auth::Handle::requests(), auth::Request::path()/kind()) and state that moq-relay does not yet opt in and refuses a request token NOT_SUPPORTED until the per-request lease (part B) lands. Co-Authored-By: Claude --- doc/bin/relay/auth.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 333ed75156..71b7a64528 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -177,12 +177,13 @@ long-lived request (an ingest PUBLISH_NAMESPACE, a subscription) renews its credential in place without reconnecting; a refused renewal leaves the old grant standing until it lapses. -Verifying a request token needs an application acceptor on the session, the -same seam that answers session tokens ([`admissions()`](#in-process) for an -embedded relay). A relay with no acceptor for it decodes the token and refuses -a request the session grant does not otherwise cover, `NOT_SUPPORTED` when -nothing can verify a token at all. Wiring a per-request lease into the -`--auth-url` server is a follow-up. +Verifying a request token is a moq-net library capability: an application takes +`auth::Handle::requests()` before running the session and answers each token +tagged with the request's path and kind (`auth::Request::path()`, `::kind()`). +moq-relay does not yet opt in on the request path, so as of this release it +refuses a request token with `NOT_SUPPORTED`; wiring a per-request lease into +the `--auth-url` server and the in-process [`admissions()`](#in-process) API is +a follow-up. ### Claims From 695f7e3e0a2f013b55e15b6f0050014b1521c744 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 23:33:07 +0000 Subject: [PATCH 12/49] fix(net): verify a request token without the MoQ Auth extension A quest Goal violation. `Handle::requests()` closed the acceptor queue when the session did not negotiate the moq-dev AUTH extension, and `unsupported()` closed it when the peer turned out not to, so `verify_request` refused every request token on a standard IETF session. A request-borne token rides the request message, not the AUTH stream, so it must be verified without the extension. Both were coupling the standard 0x03 request-token path to a moq-dev extension, refusing exactly the non-moq-dev peer (a standard moq-transport publisher or CDN) the quest exists to serve. The request queue now stays live regardless of `supported`; only session-token presentation (`add`) stays Unsupported without the extension. Test: a_request_token_is_verified_without_the_auth_extension. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index ae7b93b524..0d04f313b2 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -326,11 +326,10 @@ impl Handle { return Err(Error::Duplicate); } let queue = kio::Queue::new(); - // A version without AUTH never receives a token, so its requests end with - // the session like any other. - if !state.supported { - queue.close(); - } + // A request-borne token rides the request message itself, not the AUTH stream, so the + // acceptor answers request tokens even on a session that never negotiated the MoQ Auth + // extension. The queue therefore stays live regardless of `supported`; only session + // token presentation ([`add`](Self::add)) stays [`Error::Unsupported`] without it. state.acceptor = Acceptor::App(queue.clone()); Ok(Requests { queue }) } @@ -548,9 +547,9 @@ impl Handle { } // Nothing will read a withdrawn slot again. state.tokens.retain(|_, slot| !slot.withdrawn); - if let Acceptor::App(queue) = &state.acceptor { - queue.close(); - } + // The request queue stays live: a request-borne token does not ride the AUTH stream, so + // the acceptor keeps answering request tokens without the extension. Only the session + // tokens above end as unsupported. } /// End the session: fail every pending token, end every watch, and close the @@ -1363,8 +1362,28 @@ mod request_token_tests { assert!(grant.publish.matches("room/alice")); } - /// With no `requests()` consumer, a request token cannot be verified in band, so the - /// verdict is Unsupported and the caller refuses the request NOT_SUPPORTED. + /// A request-borne token is verified even without the MoQ Auth extension: it rides the + /// request message, not the AUTH stream, so `requests()` hands a live queue on a session + /// whose `supported` is false and the acceptor admits it. This is the shape a standard + /// moq-transport peer (an encoder or CDN) presents when it does not negotiate the moq-dev + /// AUTH extension. + #[tokio::test] + async fn a_request_token_is_verified_without_the_auth_extension() { + let handle = Handle::new(false); + let mut requests = handle.requests().expect("take the requests"); + for kind in [RequestKind::Subscribe, RequestKind::PublishNamespace] { + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), kind); + let request = requests.next().await.expect("a request reaches the acceptor"); + assert_eq!(request.kind(), Some(kind)); + let _issued = request.accept(Grant::all()); + assert!( + verdict.grant().await.expect("granted").publish.matches("room/alice"), + "{kind:?} admitted without the AUTH extension" + ); + } + } + + /// With no `requests()` consumer, a request token cannot be verified in band, so the /// verdict is Unsupported and the caller refuses the request NOT_SUPPORTED. #[tokio::test] async fn no_consumer_refuses_a_request_token_as_unsupported() { let handle = Handle::new(true); From 337f3e79de779bf9dfe0f9feabb01cf908c9209c Mon Sep 17 00:00:00 2001 From: sletmoe Date: Tue, 29 Sep 2026 23:33:07 +0000 Subject: [PATCH 13/49] feat(net): let a client present a request token on its own requests The send side was hardcoded off: the outgoing PUBLISH_NAMESPACE (advertise) and SUBSCRIBE both set authorization_token: None, so no moq-dev client could exercise the server path and interop rested on an untested assumption. Add a client-side setter following the existing builder style: Client::with_request_token(token) threads an AUTHORIZATION TOKEN through ietf::Config to the Publisher and Subscriber, which attach it to the PUBLISH_NAMESPACE they advertise, its REQUEST_UPDATE (so a reprice also refreshes the credential), and the SUBSCRIBE they send. A server presents none. Additive: with no token set, every request carries authorization_token: None byte-for-byte as before. Test: a_configured_request_token_rides_the_publish_namespace (legacy draft-14 + strict draft-18). The API surface is called out in the PR body for review; JS client-send parity is a listed follow-up (the end-to-end verification is Rust-driven). Co-Authored-By: Claude --- rs/moq-net/src/client.rs | 15 ++++++++ rs/moq-net/src/ietf/publisher.rs | 60 +++++++++++++++++++++++++++++-- rs/moq-net/src/ietf/session.rs | 23 +++++++++--- rs/moq-net/src/ietf/subscriber.rs | 14 +++++++- rs/moq-net/src/server.rs | 2 ++ 5 files changed, 107 insertions(+), 7 deletions(-) diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index b592d135c2..5664bc9722 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -20,6 +20,7 @@ pub struct Client { setup_authority: Option, cost: Option, peer_hop: Option, + request_token: Option, } impl Client { @@ -132,6 +133,18 @@ impl Client { self } + /// Present an `AUTHORIZATION TOKEN` on this client's own requests (SUBSCRIBE, + /// PUBLISH_NAMESPACE, and their REQUEST_UPDATEs), so a request the session grant does not + /// cover is authorized the standard draft-17+ way (MoQ request-token) rather than needing + /// the moq-dev AUTH-stream extension. The value is the section 8.9 Token structure the + /// peer's verifier reads; a relay that takes [`Session::auth`](crate::Session::auth)'s + /// requests answers it. Rides draft-17+ message parameters and draft-14's trailing block; + /// omit to send none. + pub fn with_request_token(mut self, token: impl Into) -> Self { + self.request_token = Some(token.into()); + self + } + /// The origin pair a session attaches, tagged and filtered. /// /// Reads through the publish (egress) consumer and writes through the @@ -282,6 +295,7 @@ impl Client { peer_setup_stream: None, peer_declared: None, auth: auth.clone(), + request_token: self.request_token.clone(), })?; tracing::debug!(version = ?v, "connected"); @@ -438,6 +452,7 @@ impl Client { peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), + request_token: self.request_token.clone(), })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 4bed9b14df..870d25679d 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -277,6 +277,9 @@ pub(super) struct Publisher { // Our grant (MoQ Auth): only what it lets us publish is advertised and served, and a // shrink withdraws what it no longer covers. auth: crate::auth::Handle, + // The AUTHORIZATION TOKEN this side presents on its PUBLISH_NAMESPACE requests and their + // REQUEST_UPDATEs (MoQ request-token), or `None` to send none. A client credential. + request_token: Option, } /// The snapshot a joining FETCH inherits from its subscription. @@ -333,6 +336,7 @@ where joins: Default::default(), version, auth: crate::auth::Handle::new(false), + request_token: None, } } @@ -342,6 +346,14 @@ where self } + /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the + /// PUBLISH_NAMESPACE requests this side sends, and on their REQUEST_UPDATEs, so a client + /// authorizes its announces the standard draft-17+ way (MoQ request-token). + pub fn with_request_token(mut self, token: Option) -> Self { + self.request_token = token; + self + } + /// What the peer declared in its SETUP, or the default (extension off) on a version /// that cannot negotiate it. /// @@ -1783,7 +1795,7 @@ where request_id, track_namespace: path.as_path(), cluster, - authorization_token: None, + authorization_token: self.request_token.clone(), }) .await?; @@ -1860,7 +1872,9 @@ where return Ok(Refused::No); }; let request_id = self.control.next_request_id(&self.runtime).await?; - let update = ietf::PublishNamespaceUpdate::between(request_id, &held, &next); + let mut update = ietf::PublishNamespaceUpdate::between(request_id, &held, &next); + // The credential rides the update too, so a reprice also refreshes the request token. + update.authorization_token = self.request_token.clone(); request.stream.writer.encode(&ietf::PublishNamespaceUpdate::ID).await?; request.stream.writer.encode(&update).await?; @@ -4843,6 +4857,48 @@ mod tests { assert_eq!(log.bi_opens(), 1, "one request stream"); } + /// A client configured with a request token puts it on the PUBLISH_NAMESPACE it sends, on + /// a legacy draft (draft-14 trailing block) and a strict one (draft-18 message + /// parameters). Without `with_request_token` the token bytes never reach the wire. + #[tokio::test] + async fn a_configured_request_token_rides_the_publish_namespace() { + let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xfe, 0xed]); + for version in [Version::Draft14, Version::Draft18] { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; + + let session = + crate::lite::test_transport::ScriptedSession::per_stream(vec![publish_namespace_ok(version).await]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let publisher = Publisher::new( + crate::time::Clock::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + version, + ) + .with_request_token(Some(token.clone())); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + let mut sent = false; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) >= 1 { + sent = true; + break; + } + settle().await; + } + assert!(sent, "{version}: the request token must ride the PUBLISH_NAMESPACE"); + } + } + /// Drive both announce loops at once against a peer that declared `solicit`, /// returning how many times the namespace hit the wire and how many bidi streams /// were opened. One stream means the entry rode the subscription inline; two means diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 35c21c800b..8ac6931839 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -70,6 +70,10 @@ pub struct Config { /// peer's token requests during its handshake. Supports AUTH exactly when the /// version can negotiate it; the peer's SETUP decides whether it does. pub auth: crate::auth::Handle, + + /// The AUTHORIZATION TOKEN a client presents on its own SUBSCRIBE / PUBLISH_NAMESPACE + /// requests (MoQ request-token). `None` for a server, or a client that presents none. + pub request_token: Option, } pub fn start(config: Config) -> Result<(MaybeSendBox<'static, Result<(), Error>>, crate::goaway::Handle), Error> @@ -92,6 +96,7 @@ where peer_setup_stream, peer_declared, auth, + request_token, } = config; // GOAWAY wiring: the public Session holds one half (drain trigger, received @@ -168,7 +173,8 @@ where peer_setup.clone(), version, ) - .with_auth(auth.clone()); + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); let (tasks, mut task_set) = TaskSet::new(); let subscriber = Subscriber::new( runtime.clone(), @@ -183,7 +189,8 @@ where tasks.clone(), goaway.going_away.clone(), ) - .with_auth(auth.clone()); + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); // GOAWAY send task: draft-14-16 carry GOAWAY on the shared control // stream. Parked on the drain trigger; races the transport close so @@ -343,7 +350,8 @@ where peer_setup.clone(), version, ) - .with_auth(auth.clone()); + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); let (tasks, mut task_set) = TaskSet::new(); let subscriber = Subscriber::new( runtime.clone(), @@ -358,7 +366,8 @@ where tasks, goaway.going_away.clone(), ) - .with_auth(auth.clone()); + .with_auth(auth.clone()) + .with_request_token(request_token.clone()); // Our tokens, one Auth request each, once the peer's SETUP negotiates it. let present = auth::run_present( @@ -1128,6 +1137,7 @@ mod tests { ..Default::default() }), auth: crate::auth::Handle::new(false), + request_token: None, }) .expect("start the session"); @@ -1181,6 +1191,7 @@ mod tests { // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), + request_token: None, }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1233,6 +1244,7 @@ mod tests { peer_setup_stream: None, peer_declared, auth: crate::auth::Handle::new(false), + request_token: None, }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1334,6 +1346,7 @@ mod tests { ..Default::default() }), auth: handle.clone(), + request_token: None, }) .expect("start the session"); AuthSession { @@ -1447,6 +1460,7 @@ mod tests { // carry and the dispatch loop actually runs. peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), + request_token: None, }) .expect("start the session"); @@ -1684,6 +1698,7 @@ mod tests { peer_setup_stream: None, peer_declared: None, auth: crate::auth::Handle::new(false), + request_token: None, }) .expect("start the session"); let driver = tokio::spawn(driver); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 522e025d77..91b8a96859 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -440,6 +440,9 @@ pub(super) struct Subscriber { going_away: crate::goaway::GoingAway, // Our grant (MoQ Auth): a subscription it stops covering is cancelled. auth: crate::auth::Handle, + // The AUTHORIZATION TOKEN this side presents on its SUBSCRIBE requests (MoQ + // request-token), or `None` to send none. A client credential. + request_token: Option, } /// Resolve the subscription a data stream belongs to. @@ -507,6 +510,7 @@ where version, going_away, auth: crate::auth::Handle::new(false), + request_token: None, } } @@ -569,6 +573,14 @@ where }); } + /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the SUBSCRIBE + /// requests this side sends, so a client authorizes its subscribes the standard draft-17+ + /// way (MoQ request-token). + pub fn with_request_token(mut self, token: Option) -> Self { + self.request_token = token; + self + } + /// End every active subscription with the error that ended the session. pub fn abort(&self, err: &Error) { self.state.lock().abort(err); @@ -2236,7 +2248,7 @@ where filter: join.filter, fill: join.fill, properties_wanted: true, - authorization_token: None, + authorization_token: self.request_token.clone(), }) .await?; Ok(()) diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 074a2af05a..5497cffdc0 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -565,6 +565,7 @@ where peer_setup_stream: Some(peer_setup.stream), peer_declared: Some(peer_setup.declared), auth: auth.clone(), + request_token: None, })?; tracing::debug!(?version, "connected"); Ok(Session::new( @@ -683,6 +684,7 @@ where peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), + request_token: None, })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } From 39656a151c59de8cf8d0b6d1b101525d1979dac7 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 00:53:05 +0000 Subject: [PATCH 14/49] feat(tokio): expose Client::with_request_token on the moq-tokio wrapper `with_request_token` existed only on `moq_net::Client`, but moq-cli, the test publisher, and every high-level tool go through `moq_tokio::Client`, which wraps `moq_net::Client` and exposed no way to set it. So no real tooling could present a request token and the no-extension peer case could not be exercised end to end. Add `moq_tokio::Client::with_request_token(impl Into)` next to with_peer_hop, delegating to the inner `moq_net::Client`. The wire round-trip is proven at the moq-net layer (a_configured_request_token_rides_the_publish_namespace); moq-tokio has no in-process wire harness and building a Client needs a transport feature, so the moq-tokio test is a compile-level builder-signature check. A moq-tokio connect::Config / moq-cli --request-token knob (parsing bytes-or-file into a Token structure) is more than a few lines and is listed in the PR Follow-ups. Co-Authored-By: Claude --- rs/moq-tokio/src/client.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 686fd6c6df..6ec9a2aa2b 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -223,6 +223,15 @@ impl Client { self } + /// Present an `AUTHORIZATION TOKEN` on this client's own requests (SUBSCRIBE / + /// PUBLISH_NAMESPACE and their REQUEST_UPDATEs), so a request the session grant does not + /// cover is authorized the standard draft-17+ way (MoQ request-token); see + /// [`moq_net::Client::with_request_token`]. + pub fn with_request_token(mut self, token: impl Into) -> Self { + self.moq = self.moq.with_request_token(token); + self + } + /// Override whether this client redials after a session drop. /// /// Defaults to true, unless [`crate::connect::Config::once`] turned it off. @@ -670,6 +679,16 @@ async fn connect_session( mod tests { use super::*; + /// Compile-level check that the moq-tokio client exposes `with_request_token` and chains + /// (returns `Self`), delegating to `moq_net::Client`. The token's wire round-trip is proven + /// at the moq-net layer (`a_configured_request_token_rides_the_publish_namespace`); moq-tokio + /// has no in-process wire harness to re-run it here, and building a `Client` needs a + /// transport feature, so this is a builder-signature check rather than a live send. + #[allow(dead_code)] + fn with_request_token_chains(client: Client) -> Client { + client.with_request_token(bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff])) + } + #[cfg(feature = "noq")] #[tokio::test] async fn fixed_target_preserves_request_and_refuses_redirect() { From 751f93920ee279e1e3a9abe8784e3199461f3e9f Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 01:15:54 +0000 Subject: [PATCH 15/49] fix(net): verify a request token when the union does not cover, not just when it forbids The token gate was `token.is_some() && !allows(dir, path)`, but `allows` returns true on a `None` union (its permissive default). A session that never negotiated the AUTH extension has a `None` union forever, so `!allows` was always false: the 0x03 token was decoded and IGNORED, and the request admitted by the default rather than by the token. That is exactly the non-moq-dev peer (a standard moq-transport encoder or CDN) the quest exists to serve. Add `Handle::covers(dir, path)`, which (unlike `allows`) treats a `None` union as NOT covering, and gate the token path on it: a token-bearing request is verified whenever the union does not positively cover the path, so a no-AUTH session's token is checked, not shadowed. The token-less path keeps `allows` unchanged, so the additive constraint holds. A refused token on a no-AUTH session is now a request-level UNAUTHORIZED, not a default admit. The no-extension acceptor-seam test exercises the acceptor directly; the new tests go through the message gate on a `Handle::new(false)` session: a_request_token_on_a_no_auth_session_is_verified (publisher SUBSCRIBE, subscriber PUBLISH_NAMESPACE; admit on grant, refuse on refusal) and a_token_less_request_on_a_no_auth_session_is_unchanged. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 12 +++ rs/moq-net/src/ietf/publisher.rs | 81 ++++++++++++--- rs/moq-net/src/ietf/subscriber.rs | 161 +++++++++++++++++++++++++++++- 3 files changed, 240 insertions(+), 14 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 0d04f313b2..82e3a1e07e 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -535,6 +535,18 @@ impl Handle { limit.is_none_or(|limit| limit.matches(path)) } + /// Whether the union positively covers `path` right now. Unlike [`allows`](Self::allows), a + /// union that is still `None` (no answer yet, or a version without AUTH) does NOT cover: a + /// request presenting a token is verified by it rather than admitted by the permissive + /// default. Only the token-bearing path uses this; the token-less path keeps `allows`. + pub(crate) fn covers(&self, direction: Direction, path: &str) -> bool { + let state = self.state.read(); + state + .union + .as_ref() + .is_some_and(|union| union.patterns(direction).matches(path)) + } + /// The peer turned out not to negotiate AUTH: fail every token as unsupported and /// close the requests, leaving the union unknown. pub(crate) fn unsupported(&self) { diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 870d25679d..9ae679f064 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -534,20 +534,16 @@ where // its grant instead ends with the request when `_request_grant` drops. let mut gate = None; let mut request_grant = None; - if self - .auth - .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + // A request presenting a token is authorized by it whenever the session union does + // not positively cover the path. `covers` treats a `None` union (no answer yet, or a + // session without the AUTH extension) as NOT covering, so a standard peer's token is + // verified rather than admitted by the permissive default. A token-less request keeps + // the permissive `allows` default unchanged. + if let Some(token) = &msg.authorization_token + && !self + .auth + .covers(crate::auth::Direction::Publish, msg.track_namespace.as_str()) { - gate = Some(crate::auth::Gate::new( - self.auth.clone(), - msg.track_namespace.to_owned(), - crate::auth::Direction::Publish, - )); - } else { - let Some(token) = &msg.authorization_token else { - let err = Error::Unauthorized; - return self.reject_subscribe(stream, request_id, &err, "not granted").await; - }; // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol violation // and closes the session exactly as on the SETUP path; a merely-undecodable // structure is refused per request without tearing down the connection. @@ -589,6 +585,18 @@ where return self.reject_subscribe(stream, request_id, &err, &err.to_string()).await; } } + } else if self + .auth + .allows(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + { + gate = Some(crate::auth::Gate::new( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + )); + } else { + let err = Error::Unauthorized; + return self.reject_subscribe(stream, request_id, &err, "not granted").await; } // Stats (subscriptions, viewer refcount, groups/frames/bytes) are counted in @@ -3445,6 +3453,53 @@ mod serve_tests { assert!(matches!(res, Err(Error::Unauthorized)), "{res:?}"); } + /// Publisher / SUBSCRIBE side: on a session without the AUTH extension the union is + /// `None` forever, so `allows` is permissive; a token-bearing SUBSCRIBE must still be + /// verified via `covers`, not admitted by that default. The acceptor is consulted; with the + /// bug the token path was bypassed and the token silently ignored. + #[tokio::test] + async fn a_request_token_on_a_no_auth_session_is_verified() { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(false); + let mut requests = auth.requests().unwrap(); + assert!(auth.allows(crate::auth::Direction::Publish, "room"), "None union is permissive"); + assert!(!auth.covers(crate::auth::Direction::Publish, "room"), "None union does not cover"); + + let h = serve_with_auth(VERSION, auth, Vec::new()); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let consulted = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let consulted = consulted.clone(); + async move { + let mut held = Vec::new(); + loop { + let request = requests.next().await.expect("a request"); + consulted.fetch_add(1, Ordering::Relaxed); + held.push(request.accept(crate::auth::Grant::all())); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending(), "the subscription ended early"); + if consulted.load(Ordering::Relaxed) >= 1 { + break; + } + settle().await; + } + assert!( + consulted.load(Ordering::Relaxed) >= 1, + "the token must be verified by the acceptor, not admitted by the permissive default" + ); + } + /// A distinctive request id, so `[FetchHeader::TYPE, REQUEST_ID]` is a usable needle. const REQUEST_ID: u64 = 0x2B; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 91b8a96859..963028d143 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1058,7 +1058,7 @@ where // did before. let mut token_grant = None; if let Some(token) = &msg.authorization_token - && !self.auth.allows(crate::auth::Direction::Subscribe, path.as_str()) + && !self.auth.covers(crate::auth::Direction::Subscribe, path.as_str()) { // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol violation // and closes the session exactly as on the SETUP path; a merely-undecodable @@ -5027,6 +5027,165 @@ mod tests { ); } + /// Like [`auth_announce_harness`] but the session never negotiated the MoQ Auth extension, + /// so its union is `None` forever: `allows` is permissive, `covers` is not. No credential + /// is presented; the acceptor answers request tokens regardless. + fn auth_announce_harness_no_ext( + version: Version, + ) -> ( + Subscriber, + crate::auth::Requests, + origin::Consumer, + crate::lite::test_transport::ScriptedSession, + ) { + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let consumer = origin.consume(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let (tasks, task_set) = crate::util::TaskSet::new(); + std::mem::forget(task_set); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let auth = crate::auth::Handle::new(false); + let requests = auth.requests().unwrap(); + assert!( + auth.allows(crate::auth::Direction::Subscribe, "room/alice"), + "a None union is permissive for allows" + ); + assert!( + !auth.covers(crate::auth::Direction::Subscribe, "room/alice"), + "a None union does not cover" + ); + + let subscriber = Subscriber::new( + crate::time::Clock::tokio(), + session.clone(), + origin, + Control::new(None, false), + None, + peer_setup, + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_auth(auth); + + (subscriber, requests, consumer, session) + } + + /// On a session without the AUTH extension the union is `None` forever, so `allows` is + /// permissive; a token-bearing PUBLISH_NAMESPACE must still be verified (`covers`), not + /// admitted by that default. Admitted on a covering grant; refused UNAUTHORIZED (not + /// admitted) on refusal. This is the standard moq-transport peer shape the quest targets. + #[tokio::test] + async fn a_request_token_on_a_no_auth_session_is_verified() { + const VERSION: Version = Version::Draft18; + + // Accepted: the acceptor is consulted (proving the token path, not the permissive + // default, which the origin model would also route) and its grant admits the announce. + { + let (mut subscriber, mut requests, consumer, session) = auth_announce_harness_no_ext(VERSION); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let consulted = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let consulted = consulted.clone(); + async move { + let mut held = Vec::new(); + loop { + let request = requests.next().await.expect("a request reaches the acceptor"); + consulted.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + held.push(request.accept(crate::auth::Grant::all())); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut routed = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended during setup"); + if routed_now(&consumer, "room/alice").is_some() { + routed = true; + break; + } + settle().await; + } + assert!(routed, "a token on a no-auth session must be verified and admitted, not ignored"); + assert!( + consulted.load(std::sync::atomic::Ordering::Relaxed) >= 1, + "the token must reach the acceptor, not be admitted by the permissive default" + ); + } + + // Refused: not admitted by the permissive default. + { + let (mut subscriber, mut requests, consumer, session) = auth_announce_harness_no_ext(VERSION); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let acceptor = async move { + let request = requests.next().await.expect("a request reaches the acceptor"); + request.reject(crate::SessionError::Unauthorized, "no"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "a refused token ends the announce"); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "a refused token must not be admitted by the permissive default" + ); + } + } + + /// The additive constraint: a token-LESS PUBLISH_NAMESPACE on a no-auth session (None union) + /// is admitted by the origin model exactly as before; the token path is never entered. + #[tokio::test] + async fn a_token_less_request_on_a_no_auth_session_is_unchanged() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, _requests, consumer, session) = auth_announce_harness_no_ext(VERSION); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let mut msg = token_publish_namespace(); + msg.authorization_token = None; + + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + msg, + cluster::Peer::default(), + None, + )); + let mut routed = false; + for _ in 0..500 { + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some() { + routed = true; + break; + } + settle().await; + } + assert!(routed, "a token-less announce is admitted by the origin model as before"); + } + /// NAMESPACE has no REQUEST_UPDATE, so a peer reprices one by re-sending it on the /// SUBSCRIBE_NAMESPACE stream. The repeat is neither a duplicate nor a violation: it /// replaces the advertisement in place, and the route is never retracted for it. From 394a2c614805b9fe4ab3f4dafde16d353578d9fa Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 02:17:42 +0000 Subject: [PATCH 16/49] test(net): confirm the request token rides PUBLISH_NAMESPACE at every draft It was unclear whether the AUTHORIZATION TOKEN (0x03) is emitted on PUBLISH_NAMESPACE at draft-17+ or diverts to an AUTH session stream, and whether draft-16 carries it at all. Extend the two existing tests across draft-14..18: the message round-trip (encode then decode preserves the token) and the client emission (a configured token appears on the advertise stream). Both pass at 14, 15, 16, 17, and 18, so the token rides the request param on every supported draft; `with_request_token` sets only the message field and opens no AUTH stream. Co-Authored-By: Claude --- rs/moq-net/src/ietf/publish_namespace.rs | 8 +++++++- rs/moq-net/src/ietf/publisher.rs | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index 89ef801b35..5642c7c1c1 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -449,7 +449,13 @@ mod tests { #[test] fn authorization_token_round_trips_legacy_and_strict() { let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]); - for version in [Version::Draft14, Version::Draft18] { + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + ] { let msg = PublishNamespace { request_id: RequestId(1), track_namespace: Path::new("room/alice"), diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 9ae679f064..ec72e55e7c 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -4918,7 +4918,13 @@ mod tests { #[tokio::test] async fn a_configured_request_token_rides_the_publish_namespace() { let token = bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xfe, 0xed]); - for version in [Version::Draft14, Version::Draft18] { + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + ] { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); settle().await; From 808b601141b1de9232f661c684af3e2862ab8bf2 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 02:17:42 +0000 Subject: [PATCH 17/49] feat(tokio): expose Request::auth() so a QUIC server can answer request tokens moq_tokio::server::Request exposed token() but no auth(), so a QUIC app could only reach the acceptor via Session::auth() AFTER ok(). ok() starts the session driver, whose first poll fixes who answers request tokens, so a consumer installed afterwards races it nondeterministically: the driver wins and a valid token is refused Unsupported, or the app wins and the grant handshake stalls. Admit-on-grant could not be driven deterministically on any moq-tokio QUIC server. Add Request::auth() -> moq_net::auth::Handle, mirroring token() and delegating to the moq-net handshake's auth(), documented to take requests() before ok(). Same completeness argument as exposing with_request_token on the wrapper: the acceptor seam must reach the moq-tokio surface. Co-Authored-By: Claude --- rs/moq-tokio/src/server.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index bb76de1643..0c7d05cc01 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -1509,6 +1509,18 @@ impl Request { request_ref!(self, r => r.token()) } + /// The session's [`auth::Handle`](moq_net::auth::Handle), for an app that verifies the + /// peer's request tokens itself (the `AUTHORIZATION TOKEN` carried on a PUBLISH_NAMESPACE, + /// SUBSCRIBE, or other request). + /// + /// Take [`requests`](moq_net::auth::Handle::requests) here and answer them BEFORE + /// [`ok`](Self::ok): the acceptor is fixed on the session driver's first poll, which `ok` + /// starts, so a consumer installed afterwards races it. Mirrors the moq-net-native accept + /// path. + pub fn auth(&self) -> moq_net::auth::Handle { + request_ref!(self, r => r.auth()) + } + /// The client certificate chain the peer presented, if any, validated /// against a configured [`crate::tls::Listen::root`] during the handshake. /// @@ -1643,6 +1655,15 @@ mod tests { } } + /// `Request::auth` must exist and yield an owned `auth::Handle`, so a QUIC app can take + /// `requests()` on it before `ok()`. Constructing a `Request` needs a live transport + /// handshake (the pre-ok runtime behavior is exercised over real QUIC by the request-token + /// end-to-end verification), so this pins the accessor's shape without binding one. + #[test] + fn request_exposes_an_auth_handle() { + let _signature: fn(&Request) -> moq_net::auth::Handle = Request::auth; + } + /// The handles have to exist before anything binds, and cover the stream /// listeners rather than just the ones an owner happens to construct itself. /// From 6e67893faa66bcc2100a675a8f7364417157722c Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 02:56:45 +0000 Subject: [PATCH 18/49] test(net): a pre-ok requests() consumer governs the session acceptor A QUIC harness could take Request::auth().requests() before ok() yet see the client's token-bearing request refused Unsupported (the default acceptor). Pin the contract through the REAL accept path (accept_request -> ok): install the acceptor before ok(), then verify a request token on the session's own auth() handle (the one the driver polls) and assert the pre-ok consumer receives it and granting admits. Passes at draft-14 (a no-AUTH-extension legacy session) and draft-18 (the modern uni-SETUP path), so the moq-net handle is shared end to end; a harness that sees otherwise differs in when or on which handle it takes the requests. Co-Authored-By: Claude --- rs/moq-net/src/server.rs | 51 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 5497cffdc0..4295fb9b3a 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -1090,6 +1090,57 @@ mod tests { params } + /// An acceptor taken with `Request::auth().requests()` BEFORE `ok()` must govern the + /// session the driver then runs, through the REAL accept path (`accept_request` -> `ok`). + /// The session's own `auth()` (the handle the driver polls) routes a request token to that + /// pre-ok consumer instead of falling to the `Unsupported` default, and granting admits. + /// Both a no-AUTH-extension legacy session (draft-14, `Handle::new(false)`) and the modern + /// uni-SETUP path (draft-18); a QUIC server has no other point to install the acceptor. + #[tokio::test(start_paused = true)] + async fn a_pre_ok_requests_consumer_governs_the_session_acceptor() { + let cases = [ + ( + FakeSession::new(ALPN_14, []) + .with_bi(legacy_setup(ietf::Version::Draft14, ietf::Parameters::default())), + ietf::Version::Draft14, + ), + ( + FakeSession::new(ALPN_18, [ietf_setup(ietf::Version::Draft18, Some("room/alice"))]), + ietf::Version::Draft18, + ), + ]; + + for (session, version) in cases { + let request = Server::new() + .accept_request(tokio::time::Instant::now().into_std(), session) + .await + .unwrap_or_else(|e| panic!("accept at {version:?}: {e}")); + + // Install the acceptor before ok(), as a QUIC server must. + let mut requests = request.auth().requests().expect("requests() pre-ok"); + let (net_session, _driver) = request.ok().await.unwrap_or_else(|e| panic!("ok at {version:?}: {e}")); + + // The session's own handle is the one the driver polls. Verifying a request token on + // it must reach the pre-ok consumer; if the handle were not shared it would fall to + // the Unsupported default and the consumer would never see this request. + let verdict = net_session.auth().verify_request( + Bytes::from_static(b"jwt"), + 1, + crate::PathOwned::from("room/alice".to_string()), + crate::auth::RequestKind::PublishNamespace, + ); + let received = requests.next().await.expect("the pre-ok consumer receives the request"); + assert_eq!(received.path(), Some("room/alice"), "{version:?}"); + assert_eq!( + received.kind(), + Some(crate::auth::RequestKind::PublishNamespace), + "{version:?}" + ); + let _issued = received.accept(crate::auth::Grant::all()); + assert!(verdict.grant().await.is_ok(), "granting admits at {version:?}"); + } + } + #[tokio::test(start_paused = true)] async fn accept_request_exposes_the_setup_token() { let modern = FakeSession::new( From 151c478cc11f7281db2f8c3aeaa39b83aa7a2889 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 02:56:45 +0000 Subject: [PATCH 19/49] fix(net): a client presenting a request token bypasses dialing-side grant enforcement On the dialing side, enforce_grant closes the session when the client announces a broadcast outside its connection grant. A client that also presents a request token authorizes each of its own requests at the server per-request (the covers-gate plus the app's acceptor), so the connection grant does not bound them: the token is precisely how it publishes outside that grant. Enforcing the grant locally would close the client for exactly the announce the token was meant to carry, before the server ever saw it, so a request-borne token could never authorize at draft-17+ when a connection credential was also present. Stand enforcement down when a request token is configured; the server still refuses a bad token per request. Co-Authored-By: Claude --- rs/moq-net/src/ietf/session.rs | 41 +++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 8ac6931839..d8c086d3f0 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -334,9 +334,10 @@ where let auth = auth.clone(); let origin = publish.clone(); let session = session.clone(); + let request_token = request_token.clone(); async move { match client { - true => enforce_grant(auth, origin, session).await, + true => enforce_grant(auth, origin, session, request_token).await, false => std::future::pending().await, } } @@ -1048,7 +1049,16 @@ async fn enforce_grant( auth: crate::auth::Handle, origin: origin::Consumer, mut session: S, + request_token: Option, ) -> Result<(), Error> { + // A client that presents a request token authorizes each of its own requests at the server + // per-request (the covers-gate plus the app's acceptor), so its connection grant does not + // bound them: the token is precisely how it publishes outside that grant. Enforcing the + // grant here would close the client for exactly the announce the token was meant to carry, + // before the server ever saw it. The server refuses a bad token per request instead. + if request_token.is_some() { + return Ok(()); + } let mut announced = origin.announced(); let mut check = crate::auth::Enforce::default(); let Some(path) = kio::wait(|waiter| check.poll(&auth, &mut announced, waiter)).await else { @@ -1214,6 +1224,35 @@ mod tests { /// parks again; a busy machine cannot turn a slow announce into a passing silence. const ANNOUNCE_TURNS: usize = 100; + /// A client that presents a request token authorizes each of its own requests at the + /// server per-request (the covers-gate plus the app's acceptor), so its connection grant + /// does not bound them. Dialing-side grant enforcement must therefore stand down when a + /// request token is set: enforcing it would close the client for announcing outside the + /// connection grant the token was meant to extend, before the server ever saw the token. + /// The connection grant here is irrelevant precisely because the token short-circuits it. + #[tokio::test] + async fn a_client_may_send_a_token_bearing_request_its_connection_grant_does_not_cover() { + let auth = crate::auth::Handle::new(true); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("room/alice", crate::origin::Route::default()).unwrap(); + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let log = session.log.clone(); + + let result = enforce_grant( + auth, + origin.consume(), + session, + Some(bytes::Bytes::from_static(b"jwt")), + ) + .await; + + assert!(result.is_ok(), "a token-bearing client must not be closed by grant enforcement"); + assert!( + log.closes().is_empty(), + "the session must stay open for a token-bearing client" + ); + } + /// Run a publish-only session against a peer that declared `peer_declared`, returning /// how many times the namespace reached the wire. /// From 666293e4bc810415b30f17856cad66bb40ec1040 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 16:52:37 +0000 Subject: [PATCH 20/49] fix(net): wire the session auth handle into the legacy-draft subscriber and publisher ietf::start built the draft 14-16 Publisher and Subscriber without .with_auth(auth), so on a session that never negotiated the MoQ Auth extension the driver consulted a fresh default auth Handle instead of the session handle a requests() acceptor was installed on. A request-borne AUTHORIZATION TOKEN then reached verify_request on a handle whose acceptor was Undecided, which resolves to Default and refuses the request NOT_SUPPORTED, so the announce never reached the origin. The modern (17+) branch already wired .with_auth, so only the legacy no-extension path (the non-moq-dev peer shape the quest targets) was affected, and the existing unit tests missed it by constructing the Subscriber with .with_auth by hand. Add a driver-level integration test that drives a real client and server over the mock transport at draft-14: a request token on a PUBLISH_NAMESPACE must reach the pre-ok requests() acceptor and admit the announce. It fails before this fix and passes after. Co-Authored-By: Claude --- rs/moq-net/tests/auth.rs | 55 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 49ade2886a..1c612dd11f 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -164,6 +164,8 @@ struct Options { server_subscribe: Option, /// Take the server's AUTH requests before its driver runs. server_requests: bool, + /// A request token the client attaches to its outgoing PUBLISH_NAMESPACE / SUBSCRIBE. + client_request_token: Option>, version: Option<&'static str>, } @@ -188,6 +190,9 @@ async fn connect(opts: Options) -> Pair { if let Some(subscribe) = opts.client_subscribe { client = client.with_subscriber(subscribe); } + if let Some(token) = opts.client_request_token { + client = client.with_request_token(token); + } let mut server = Server::new().with_versions(version.into()); if let Some(publish) = &opts.server_publish { @@ -334,6 +339,55 @@ async fn an_out_of_scope_announce_aborts_with_the_path(version: &'static str) { .expect("timed out"); } +/// A request token on a PUBLISH_NAMESPACE authorizes the announce on a session that never +/// negotiated the MoQ Auth extension (draft-14), the standard moq-transport peer shape, when the +/// token reaches the acceptor THROUGH THE DRIVER rather than an inline `verify_request`. +/// +/// This exercises `ietf::start`'s legacy (draft 14-16) branch. That branch built its +/// Subscriber without `.with_auth(auth)`, so the driver's subscriber consulted a fresh +/// default `Handle` instead of the session handle the `requests()` acceptor was installed on: +/// `verify_request` found no `App` acceptor and refused the announce `NOT_SUPPORTED`, and the +/// announce never reached the server origin. The modern (17+) branch already wired +/// `.with_auth`, so only the legacy / no-extension path was affected, and the unit tests +/// missed it by constructing the Subscriber with `.with_auth` by hand. +#[tokio::test] +async fn a_request_token_authorizes_a_legacy_announce_through_the_driver() { + within(async { + let publisher = produce_origin(2); + let relay = produce_origin(1); + let mut pair = connect(Options { + version: Some("moq-transport-14"), + client_publish: Some(publisher.clone()), + // USE_VALUE (0x03), token kind 0, value "ok": a decodable request token the + // acceptor answers unconditionally below. + client_request_token: Some(vec![0x03, 0x00, b'o', b'k']), + server_subscribe: Some(relay.scope("", &patterns(&["room/alice"])).unwrap()), + server_requests: true, + ..Default::default() + }) + .await; + + // The server answers the request token from its acceptor with a grant covering the + // announced path. Held for the test by the returned receiver. + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + + // The client announces under the token. On the legacy path there is no session grant, + // so the token is the only authorization for the announce. + let bc = publisher.create_broadcast("room/alice").unwrap(); + bc.announce(Default::default()).unwrap(); + + // Mechanism: the token reached the acceptor over the driver (not admitted by a + // permissive default, and not refused NOT_SUPPORTED by a disconnected handle). + let (_token, _issued) = answered.recv().await.expect("the token reached the acceptor"); + + // End to end: the verified announce reached the server's subscribe origin. + wait_announced(&relay.consume(), "room/alice", true).await; + }) + .await + .expect("timed out"); +} + /// A broadcast published before the grant arrives is checked at admission too. async fn a_broadcast_published_before_the_grant_is_checked(version: &'static str) { within(async { @@ -697,6 +751,7 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { server_publish: Some(server_origin.clone()), server_subscribe: Some(server_origin.clone()), server_requests: true, + client_request_token: None, }) .await; let mut issued = serve(pair.requests.take().unwrap(), |token| { From 0bc57acbe699f6fcde92f9492c349857b76da92e Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 20:26:38 +0000 Subject: [PATCH 21/49] feat(net): replace a client's request token on a live request A client presenting an AUTHORIZATION TOKEN (MoQ request-token) could set it only at connect time. Add Client::set_request_token so a running session can present a fresh credential, and re-present it on every live request as a REQUEST_UPDATE, keeping a token-authorized request alive past its old grant's expiry without reconnecting. The token becomes a shared, watchable RequestToken cell (kio::Shared) threaded to the publisher and subscriber. On change, the publisher loop re-presents it on each live announce as a token-only PUBLISH_NAMESPACE_UPDATE (draft-17+, where that message exists), and the subscriber loop re-presents it on each live subscription as a token-only SUBSCRIBE_UPDATE (draft-14 on). An unchanged token is a no-op, and a client that presents no token is byte-identical to before. The subscribe reader now consumes the REQUEST_OK / REQUEST_ERROR answer to a renewal it sent, so a renewal does not read as an unexpected message. Mirrored on moq_tokio::Client::set_request_token. Co-Authored-By: Claude --- rs/moq-net/src/client.rs | 71 +++++++++- rs/moq-net/src/ietf/publisher.rs | 207 ++++++++++++++++++++++++++-- rs/moq-net/src/ietf/session.rs | 23 ++-- rs/moq-net/src/ietf/subscriber.rs | 218 ++++++++++++++++++++++++++++-- rs/moq-net/src/server.rs | 4 +- rs/moq-tokio/src/client.rs | 7 + 6 files changed, 491 insertions(+), 39 deletions(-) diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 5664bc9722..b3d23dcbda 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -9,6 +9,59 @@ use crate::{ ietf, lite, setup, stats, }; +/// A client's request-token credential: the `AUTHORIZATION TOKEN` it presents on its own +/// SUBSCRIBE and PUBLISH_NAMESPACE requests (MoQ request-token), shared with the running +/// session so it can be replaced without reconnecting. +/// +/// Cloning shares the cell, so [`Client::set_request_token`] on any handle reaches every +/// session started from that [`Client`]: the session reads the current value when it first +/// sends a request, and re-presents a changed one on each live request as a REQUEST_UPDATE. +/// The default presents no token, which is byte-identical to a client that never sets one. +#[derive(Clone, Default)] +pub(crate) struct RequestToken { + token: kio::Shared>, +} + +impl RequestToken { + /// A credential presenting `token` (or none) until replaced. + #[cfg(test)] + pub(crate) fn new(token: Option) -> Self { + Self { + token: kio::Shared::new(token), + } + } + + /// Replace the token presented on this session's requests. A live request re-presents + /// it as a REQUEST_UPDATE on its next turn; setting the same value again is a no-op. + pub(crate) fn set(&self, token: Option) { + *self.token.lock() = token; + } + + /// The token to present right now, read when a request is first sent. + pub(crate) fn peek(&self) -> Option { + self.token.read().clone() + } + + /// Ready with the current token once it differs from `last`, registering `waiter` + /// otherwise. The send loops park here to re-present a replaced token on their live + /// requests; it reads without advancing `last`, so a poll that loses its turn to + /// another arm is re-offered the change rather than dropping it. + pub(crate) fn poll_changed( + &self, + last: &Option, + waiter: &kio::Waiter, + ) -> std::task::Poll> { + use std::task::Poll; + match self.token.poll(waiter, |cur| match **cur == *last { + true => Poll::Pending, + false => Poll::Ready(()), + }) { + Poll::Ready(guard) => Poll::Ready((*guard).clone()), + Poll::Pending => Poll::Pending, + } + } +} + /// A MoQ client session builder. #[derive(Default, Clone)] pub struct Client { @@ -20,7 +73,7 @@ pub struct Client { setup_authority: Option, cost: Option, peer_hop: Option, - request_token: Option, + request_token: RequestToken, } impl Client { @@ -140,11 +193,23 @@ impl Client { /// peer's verifier reads; a relay that takes [`Session::auth`](crate::Session::auth)'s /// requests answers it. Rides draft-17+ message parameters and draft-14's trailing block; /// omit to send none. - pub fn with_request_token(mut self, token: impl Into) -> Self { - self.request_token = Some(token.into()); + pub fn with_request_token(self, token: impl Into) -> Self { + self.request_token.set(Some(token.into())); self } + /// Replace the `AUTHORIZATION TOKEN` this client presents, for a session already running. + /// + /// The credential is shared with the session, so this re-presents the new token on every + /// live request as a REQUEST_UPDATE (a SUBSCRIBE renewal, or a PUBLISH_NAMESPACE renewal on + /// draft-17+, where that message exists), keeping a token-authorized request alive past its + /// old grant's expiry without reconnecting. Setting the same value again sends nothing. + /// A client that never presented a token (no [`with_request_token`](Self::with_request_token)) + /// begins presenting one from its next request. + pub fn set_request_token(&self, token: impl Into) { + self.request_token.set(Some(token.into())); + } + /// The origin pair a session attaches, tagged and filtered. /// /// Reads through the publish (egress) consumer and writes through the diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index ec72e55e7c..4c8e6047e7 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -250,6 +250,8 @@ enum NamespaceEvent { Retry, /// Our grant (MoQ Auth) or the ceiling changed: re-check every namespace against it. Regrant(crate::auth::Permit), + /// The client replaced its request token: re-present it on every live announce. + TokenRefresh(Option), } #[derive(Clone)] @@ -278,8 +280,9 @@ pub(super) struct Publisher { // shrink withdraws what it no longer covers. auth: crate::auth::Handle, // The AUTHORIZATION TOKEN this side presents on its PUBLISH_NAMESPACE requests and their - // REQUEST_UPDATEs (MoQ request-token), or `None` to send none. A client credential. - request_token: Option, + // REQUEST_UPDATEs (MoQ request-token). A shared handle so a client can replace it while the + // session runs; the default presents none. A client credential. + request_token: crate::RequestToken, } /// The snapshot a joining FETCH inherits from its subscription. @@ -336,7 +339,7 @@ where joins: Default::default(), version, auth: crate::auth::Handle::new(false), - request_token: None, + request_token: crate::RequestToken::default(), } } @@ -348,8 +351,9 @@ where /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the /// PUBLISH_NAMESPACE requests this side sends, and on their REQUEST_UPDATEs, so a client - /// authorizes its announces the standard draft-17+ way (MoQ request-token). - pub fn with_request_token(mut self, token: Option) -> Self { + /// authorizes its announces the standard draft-17+ way (MoQ request-token). A shared handle, + /// so a replaced token is re-presented on each live announce (draft-17+). + pub fn with_request_token(mut self, token: crate::RequestToken) -> Self { self.request_token = token; self } @@ -1803,7 +1807,7 @@ where request_id, track_namespace: path.as_path(), cluster, - authorization_token: self.request_token.clone(), + authorization_token: self.request_token.peek(), }) .await?; @@ -1882,7 +1886,7 @@ where let request_id = self.control.next_request_id(&self.runtime).await?; let mut update = ietf::PublishNamespaceUpdate::between(request_id, &held, &next); // The credential rides the update too, so a reprice also refreshes the request token. - update.authorization_token = self.request_token.clone(); + update.authorization_token = self.request_token.peek(); request.stream.writer.encode(&ietf::PublishNamespaceUpdate::ID).await?; request.stream.writer.encode(&update).await?; @@ -2022,6 +2026,66 @@ where Ok(()) } + /// Re-present the client's request token on every live announce as a token-only + /// REQUEST_UPDATE (MoQ request-token renewal), so a refreshed credential reaches the peer + /// before the old grant lapses. + /// + /// Draft-17+ only: earlier drafts have no PUBLISH_NAMESPACE_UPDATE, so a token set on them + /// rides the initial advertisement and is not renewed in place. Clearing the token (`None`) + /// is not a renewal and sends nothing. A refusal keeps the announce, since the receiver + /// holds the old grant until it lapses; only a dead stream drops it. + async fn refresh_request_token( + &self, + requests: &mut HashMap>, + token: Option, + ) -> Result<(), Error> { + let Some(token) = token else { + return Ok(()); + }; + if matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16) { + return Ok(()); + } + + let suffixes: Vec = requests.keys().cloned().collect(); + for suffix in suffixes { + let request_id = self.control.next_request_id(&self.runtime).await?; + let Some(request) = requests.get_mut(&suffix) else { + continue; + }; + let update = ietf::PublishNamespaceUpdate { + request_id, + hops: None, + cost: None, + authorization_token: Some(token.clone()), + }; + request.stream.writer.encode(&ietf::PublishNamespaceUpdate::ID).await?; + request.stream.writer.encode(&update).await?; + + let absolute = self.origin.absolute(&request.path).to_owned(); + let Some((type_id, mut data)) = self.read_response(&mut request.stream).await? else { + tracing::debug!(broadcast = %absolute, "no answer to the token refresh"); + // The peer never answered: the stream is gone, so drop the advertisement. + requests.remove(&suffix); + continue; + }; + match type_id { + ietf::RequestOk::ID => { + let _ = ietf::RequestOk::decode_msg(&mut data, self.version)?; + tracing::debug!(broadcast = %absolute, "request token refreshed"); + } + ietf::RequestError::ID => { + let msg = ietf::RequestError::decode_msg(&mut data, self.version)?; + // The receiver refuses a renewal it cannot grant but keeps the announce on the + // old grant until it lapses, so the request stays; the announce ends later + // when that grant does and the stream closes. + tracing::warn!(broadcast = %absolute, message = ?msg, "request token refresh refused"); + } + _ => return Err(Error::UnexpectedMessage), + } + } + Ok(()) + } + /// Close out every open PUBLISH_NAMESPACE request. A no-op for a loop whose entries /// ride the SUBSCRIBE_NAMESPACE stream itself, which retracts them by ending. async fn withdraw_requests( @@ -2183,6 +2247,10 @@ where let mut retry_at: Option = None; let mut retry_delay = RETRY_BASE; + // The request token last presented on these announces (their initial value). A client + // replacing it wakes the loop, which re-presents the new one on each live announce. + let mut last_token = self.request_token.peek(); + // Stream updates (origin route (un)announces), bailing if the peer closes // its side first. let res = loop { @@ -2212,6 +2280,11 @@ where if let Poll::Ready(update) = announced.poll_next(waiter) { return Poll::Ready(NamespaceEvent::Update(update)); } + // A replaced request token is re-presented on each live announce, below the + // origin updates so a busy loop still makes progress on both. + if let Poll::Ready(token) = self.request_token.poll_changed(&last_token, waiter) { + return Poll::Ready(NamespaceEvent::TokenRefresh(token)); + } if retry.poll(waiter).is_ready() { return Poll::Ready(NamespaceEvent::Retry); } @@ -2249,6 +2322,10 @@ where self.sync_namespace(&mut ns, &suffix, &path).await?; } } + NamespaceEvent::TokenRefresh(token) => { + last_token = token.clone(); + self.refresh_request_token(&mut ns.requests, token).await?; + } NamespaceEvent::Update(None) => { // The origin is gone: withdraw everything, then finish the // stream and wait for delivery. @@ -4944,7 +5021,7 @@ mod tests { peer_setup, version, ) - .with_request_token(Some(token.clone())); + .with_request_token(crate::RequestToken::new(Some(token.clone()))); let mut run = std::pin::pin!(publisher.run_publish_namespaces()); let mut sent = false; @@ -4960,6 +5037,120 @@ mod tests { } } + /// Replacing the request token re-presents it on a live announce as a token-only + /// PUBLISH_NAMESPACE_UPDATE (MoQ request-token renewal), on the same stream, without a + /// reprice. Draft-17+, since earlier drafts have no PUBLISH_NAMESPACE_UPDATE. + #[tokio::test] + async fn setting_a_new_request_token_re_presents_it_on_a_live_announce() { + const VERSION: Version = Version::Draft18; + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; + + // One stream carries the announce: RequestOk answers the PUBLISH_NAMESPACE, then + // RequestOk answers the token-only REQUEST_UPDATE. + let mut script = publish_namespace_ok(VERSION).await; + script.extend(publish_namespace_ok(VERSION).await); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let token = crate::RequestToken::new(Some(first.clone())); + let publisher = Publisher::new( + crate::time::Clock::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_request_token(token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + // The initial token rides the PUBLISH_NAMESPACE. + let mut initial = false; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &first) >= 1 { + initial = true; + break; + } + settle().await; + } + assert!(initial, "the initial token must ride the PUBLISH_NAMESPACE"); + + // Replacing it re-presents the new token on the live announce. + token.set(Some(second.clone())); + let mut renewed = false; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &second) >= 1 { + renewed = true; + break; + } + settle().await; + } + assert!(renewed, "a replaced token must be re-presented on the live announce"); + } + + /// Setting the same token again is a no-op: no second REQUEST_UPDATE is sent, so the token + /// bytes appear once (the initial PUBLISH_NAMESPACE) and no more. + #[tokio::test] + async fn an_unchanged_token_is_not_re_presented() { + const VERSION: Version = Version::Draft18; + let token_bytes = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + settle().await; + + let mut script = publish_namespace_ok(VERSION).await; + script.extend(publish_namespace_ok(VERSION).await); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![script]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + + let token = crate::RequestToken::new(Some(token_bytes.clone())); + let publisher = Publisher::new( + crate::time::Clock::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_request_token(token.clone()); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token_bytes) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &token_bytes), 1, "the initial token rode the announce once"); + + // Setting the same value again wakes the loop but changes nothing, so no REQUEST_UPDATE. + token.set(Some(token_bytes.clone())); + for _ in 0..50 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!( + occurrences(&log, &token_bytes), + 1, + "an unchanged token must not be re-presented" + ); + } + /// Drive both announce loops at once against a peer that declared `solicit`, /// returning how many times the namespace hit the wire and how many bidi streams /// were opened. One stream means the entry rode the subscription inline; two means diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index d8c086d3f0..c9ad814e37 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -72,8 +72,9 @@ pub struct Config { pub auth: crate::auth::Handle, /// The AUTHORIZATION TOKEN a client presents on its own SUBSCRIBE / PUBLISH_NAMESPACE - /// requests (MoQ request-token). `None` for a server, or a client that presents none. - pub request_token: Option, + /// requests (MoQ request-token), a shared handle so it can be replaced while the session + /// runs. The default presents none, for a server or a client that presents none. + pub request_token: crate::RequestToken, } pub fn start(config: Config) -> Result<(MaybeSendBox<'static, Result<(), Error>>, crate::goaway::Handle), Error> @@ -1049,14 +1050,14 @@ async fn enforce_grant( auth: crate::auth::Handle, origin: origin::Consumer, mut session: S, - request_token: Option, + request_token: crate::RequestToken, ) -> Result<(), Error> { // A client that presents a request token authorizes each of its own requests at the server // per-request (the covers-gate plus the app's acceptor), so its connection grant does not // bound them: the token is precisely how it publishes outside that grant. Enforcing the // grant here would close the client for exactly the announce the token was meant to carry, // before the server ever saw it. The server refuses a bad token per request instead. - if request_token.is_some() { + if request_token.peek().is_some() { return Ok(()); } let mut announced = origin.announced(); @@ -1147,7 +1148,7 @@ mod tests { ..Default::default() }), auth: crate::auth::Handle::new(false), - request_token: None, + request_token: crate::RequestToken::default(), }) .expect("start the session"); @@ -1201,7 +1202,7 @@ mod tests { // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), - request_token: None, + request_token: crate::RequestToken::default(), }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1242,7 +1243,7 @@ mod tests { auth, origin.consume(), session, - Some(bytes::Bytes::from_static(b"jwt")), + crate::RequestToken::new(Some(bytes::Bytes::from_static(b"jwt"))), ) .await; @@ -1283,7 +1284,7 @@ mod tests { peer_setup_stream: None, peer_declared, auth: crate::auth::Handle::new(false), - request_token: None, + request_token: crate::RequestToken::default(), }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1385,7 +1386,7 @@ mod tests { ..Default::default() }), auth: handle.clone(), - request_token: None, + request_token: crate::RequestToken::default(), }) .expect("start the session"); AuthSession { @@ -1499,7 +1500,7 @@ mod tests { // carry and the dispatch loop actually runs. peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), - request_token: None, + request_token: crate::RequestToken::default(), }) .expect("start the session"); @@ -1737,7 +1738,7 @@ mod tests { peer_setup_stream: None, peer_declared: None, auth: crate::auth::Handle::new(false), - request_token: None, + request_token: crate::RequestToken::default(), }) .expect("start the session"); let driver = tokio::spawn(driver); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 963028d143..853efeb596 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -440,9 +440,10 @@ pub(super) struct Subscriber { going_away: crate::goaway::GoingAway, // Our grant (MoQ Auth): a subscription it stops covering is cancelled. auth: crate::auth::Handle, - // The AUTHORIZATION TOKEN this side presents on its SUBSCRIBE requests (MoQ - // request-token), or `None` to send none. A client credential. - request_token: Option, + // The AUTHORIZATION TOKEN this side presents on its SUBSCRIBE requests and their + // REQUEST_UPDATEs (MoQ request-token). A shared handle so a client can replace it while the + // session runs; the default presents none. A client credential. + request_token: crate::RequestToken, } /// Resolve the subscription a data stream belongs to. @@ -510,7 +511,7 @@ where version, going_away, auth: crate::auth::Handle::new(false), - request_token: None, + request_token: crate::RequestToken::default(), } } @@ -575,8 +576,9 @@ where /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the SUBSCRIBE /// requests this side sends, so a client authorizes its subscribes the standard draft-17+ - /// way (MoQ request-token). - pub fn with_request_token(mut self, token: Option) -> Self { + /// way (MoQ request-token). A shared handle, so a replaced token is re-presented on each + /// live subscription as a REQUEST_UPDATE. + pub fn with_request_token(mut self, token: crate::RequestToken) -> Self { self.request_token = token; self } @@ -1828,6 +1830,10 @@ where ); let subscription = request.subscription(); + // The wire priority this subscription was opened at, re-sent unchanged on a + // request-token renewal so the update carries the token without disturbing anything. + let subscriber_priority = + super::priority::to_wire(subscription.as_ref().map(|s| s.priority).unwrap_or(0)); // A live join delivers nothing below the group SUBSCRIBE_OK names as Largest. let live = subscription.as_ref().and_then(|s| s.start).is_none(); let join = match subscribe_join( @@ -2062,10 +2068,14 @@ where enum End { Unused, Revoked, + /// The client replaced its request token: re-present it on this live subscription. + Renew(Option), Done(Result), } let mut fetch_done = fetching.is_none(); + // The request token last presented on this subscription (its initial value). + let mut last_token = self.request_token.peek(); let cancelled = { let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version)); loop { @@ -2082,6 +2092,9 @@ where if track.poll_unused(waiter).is_ready() { return Poll::Ready(End::Unused); } + if let Poll::Ready(token) = self.request_token.poll_changed(&last_token, waiter) { + return Poll::Ready(End::Renew(token)); + } waiter.poll_future(done.as_mut()).map(End::Done) }) .await; @@ -2094,6 +2107,21 @@ where } Err(used) => track = used, }, + // A replaced token is re-presented as a token-only REQUEST_UPDATE; the read + // future above consumes the answer. The subscribe stream's writer is a + // disjoint borrow from its reader, so writing here does not disturb the read. + End::Renew(token) => { + last_token = token.clone(); + if let Some(token) = token + && let Err(err) = self + .send_request_token_update(&mut stream.writer, request_id, subscriber_priority, token) + .await + { + // A failed send does not end the subscription: it continues on the old + // grant until that lapses, and the next change re-presents the token. + tracing::debug!(%err, "failed to re-present the request token"); + } + } End::Revoked => { tracing::info!(broadcast = %self.origin.absolute(&broadcast_path), track = %track_name, "subscription no longer authorized"); let _ = track.abort(Error::Unauthorized); @@ -2160,16 +2188,44 @@ where /// /// The publisher must send it before its FIN (draft-19 section 3.3.2), so a FIN /// without one is a failed request, not a clean end. + /// + /// A request-token renewal we sent (SUBSCRIBE_UPDATE) is answered on this same stream + /// (REQUEST_OK / REQUEST_ERROR on draft-15+, SUBSCRIBE_ERROR on draft-14; draft-14 is + /// silent on an accepted renewal). Those are consumed here and the read continues: a + /// refused renewal leaves the old grant standing until it lapses, so the subscription + /// ends then, with its PUBLISH_DONE, not on the answer. async fn read_publish_done(reader: &mut Reader, version: Version) -> Result { - match reader.decode_maybe::().await? { - Some(ietf::PublishDone::ID) => {} - Some(_) => return Err(Error::UnexpectedMessage), - None => return Err(Error::ProtocolViolation), + loop { + match reader.decode_maybe::().await? { + Some(ietf::PublishDone::ID) => { + let msg: ietf::PublishDone = reader.decode().await?; + tracing::debug!(message = ?msg, "received publish done"); + msg.end(version)?; + return Ok(msg.stream_count); + } + Some(ietf::RequestOk::ID) => { + let msg: ietf::RequestOk = reader.decode().await?; + tracing::debug!(message = ?msg, "request token renewal accepted"); + } + Some(ietf::RequestError::ID) => { + // draft-17+ generalized SUBSCRIBE_ERROR into REQUEST_ERROR at the same id; + // draft-14 still frames it as SUBSCRIBE_ERROR. Either way it refuses the + // renewal, and the old grant stands until it lapses. + match version { + Version::Draft14 => { + let msg: ietf::SubscribeError = reader.decode().await?; + tracing::warn!(message = ?msg, "request token renewal refused"); + } + _ => { + let msg: ietf::RequestError = reader.decode().await?; + tracing::warn!(message = ?msg, "request token renewal refused"); + } + } + } + Some(_) => return Err(Error::UnexpectedMessage), + None => return Err(Error::ProtocolViolation), + } } - let msg: ietf::PublishDone = reader.decode().await?; - tracing::debug!(message = ?msg, "received publish done"); - msg.end(version)?; - Ok(msg.stream_count) } /// Tell the publisher to stop serving a subscription we are walking away from. @@ -2225,6 +2281,42 @@ where Ok(()) } + /// Re-present the client's request token on a live subscription as a token-only + /// REQUEST_UPDATE (SUBSCRIBE_UPDATE), so a refreshed credential reaches the publisher + /// before the old grant lapses (MoQ request-token renewal). + /// + /// Token-only: the range, priority and forward flag are the subscription's own, so a + /// receiver that acts on them (ours does not, for a token update) sees no change. The + /// answer, if the version sends one, is read on the subscription stream by + /// [`read_publish_done`](Self::read_publish_done). + async fn send_request_token_update( + &self, + writer: &mut crate::coding::Writer, + subscription_id: RequestId, + subscriber_priority: u8, + token: bytes::Bytes, + ) -> Result<(), Error> { + let request_id = self.control.next_request_id(&self.runtime).await?; + // Draft-14/15/16 name the subscription being updated; draft-17+ identifies it by stream. + let subscription_request_id = match self.version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(subscription_id), + _ => None, + }; + writer.encode(&ietf::SubscribeUpdate::ID).await?; + writer + .encode(&ietf::SubscribeUpdate { + request_id, + subscription_request_id, + start_location: ietf::Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority, + forward: true, + authorization_token: Some(token), + }) + .await?; + Ok(()) + } + async fn write_subscribe( &self, stream: &mut Stream, @@ -2248,7 +2340,7 @@ where filter: join.filter, fill: join.fill, properties_wanted: true, - authorization_token: self.request_token.clone(), + authorization_token: self.request_token.peek(), }) .await?; Ok(()) @@ -4081,6 +4173,102 @@ mod tests { /// /// Decoding the framing rather than scanning for a byte: a type id is one varint among /// many, and a substring match would happily find one inside a length or a payload. + /// A SUBSCRIBE_OK for the subscribe stream, framed as the peer sends it, with a Largest so + /// the subscription reaches Established. The scripted session parks after it, keeping the + /// subscription live so the steady-state loop runs. + async fn subscribe_ok_bytes(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.encode(&ietf::SubscribeOk::ID).await.unwrap(); + writer + .encode(&ietf::SubscribeOk { + request_id: match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(1)), + _ => None, + }, + track_alias: 7, + largest: Some(ietf::Location { group: 0, object: 0 }), + properties: Default::default(), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Replacing the client's request token re-presents it on a live subscription as a token-only + /// SUBSCRIBE_UPDATE (MoQ request-token renewal), on a legacy draft (draft-14 trailing block) + /// and a strict one (draft-18 message parameters). SUBSCRIBE_UPDATE carries the token from + /// draft-14 on, unlike PUBLISH_NAMESPACE_UPDATE (draft-17+ only). + #[tokio::test(start_paused = true)] + async fn setting_a_new_request_token_re_presents_it_on_a_live_subscription() { + for version in [Version::Draft14, Version::Draft18] { + let first = bytes::Bytes::from_static(&[0x03, 0x00, b'a', b'a']); + let second = bytes::Bytes::from_static(&[0x03, 0x00, b'b', b'b']); + + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(version).await); + let log = session.log.clone(); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let token = crate::RequestToken::new(Some(first.clone())); + let mut subscriber = Subscriber::new( + crate::time::Clock::tokio(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + version, + tasks, + Default::default(), + ) + .with_request_token(token.clone()); + + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + // Held for the test so the track never reads as unused (which would cancel it). + let subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + + let serving = tokio::spawn(async move { + subscriber.run_subscribe(Path::new("broadcast"), dynamic, request).await; + }); + + // The initial token rides the SUBSCRIBE. + let mut initial = false; + for _ in 0..200 { + if occurrences(&log, &first) >= 1 { + initial = true; + break; + } + settle().await; + } + assert!(initial, "{version}: the initial token must ride the SUBSCRIBE"); + + // Replacing it re-presents the new token on the live subscription as a SUBSCRIBE_UPDATE. + token.set(Some(second.clone())); + let mut renewed = false; + for _ in 0..200 { + if occurrences(&log, &second) >= 1 { + renewed = true; + break; + } + settle().await; + } + assert!( + renewed, + "{version}: a replaced token must be re-presented on the live subscription" + ); + + drop(subscription); + drop(track); + drop(consumer); + serving.abort(); + } + } + fn control_message_types(log: &crate::lite::test_transport::Log, version: Version) -> Vec { use crate::coding::Decode; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 4295fb9b3a..816d1e2208 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -565,7 +565,7 @@ where peer_setup_stream: Some(peer_setup.stream), peer_declared: Some(peer_setup.declared), auth: auth.clone(), - request_token: None, + request_token: crate::RequestToken::default(), })?; tracing::debug!(?version, "connected"); Ok(Session::new( @@ -684,7 +684,7 @@ where peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), - request_token: None, + request_token: crate::RequestToken::default(), })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 6ec9a2aa2b..2463ce11bf 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -232,6 +232,13 @@ impl Client { self } + /// Replace the `AUTHORIZATION TOKEN` this client presents, for a session already running, + /// so a refreshed credential is re-presented on every live request as a REQUEST_UPDATE + /// without reconnecting; see [`moq_net::Client::set_request_token`]. + pub fn set_request_token(&self, token: impl Into) { + self.moq.set_request_token(token); + } + /// Override whether this client redials after a session drop. /// /// Defaults to true, unless [`crate::connect::Config::once`] turned it off. From b1e1ba4f5771825b8386e04581587b492c66bbb4 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 20:54:58 +0000 Subject: [PATCH 22/49] fix(net): a token-bearing client authorizes requests outside its connection grant A request token could authorize a request the session grant did not cover only on draft-14, where no connection credential sets a union. At draft-17+ the client filtered its own request against its connection grant before the wire, so the token never got the chance the quest's goal names ("present or refresh a credential on SUBSCRIBE, REQUEST_UPDATE, PUBLISH_NAMESPACE"). A token-bearing client no longer self-censors on its connection grant; the server's covers-gate is the authority and refuses a bad token per request. When a request token is set, the publisher's announce filter (permitted) stands down, and the subscriber's allows() gate and its shrink-revoke Gate stand down. Behavior is unchanged when no token is set. The control-stream adapter now routes a SUBSCRIBE_UPDATE follow-up by the subscription's Request ID (its second field at draft-14/15/16), not the update's own, so a renewal reaches the subscription it renews at draft-14 exactly as at draft-18. Co-Authored-By: Claude --- rs/moq-net/src/ietf/adapter.rs | 26 ++++- rs/moq-net/src/ietf/publisher.rs | 152 +++++++++++++++++++++++++++++- rs/moq-net/src/ietf/subscriber.rs | 127 +++++++++++++++++++++++-- 3 files changed, 295 insertions(+), 10 deletions(-) diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index 21f274516e..a8ca0d2e5b 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -981,9 +981,10 @@ fn classify(type_id: u64, body: &Bytes, version: Version, namespaces: &Namespace _ => Err(Error::UnexpectedMessage), }, - // Follow-up messages: route to existing stream + // Follow-up messages: route to existing stream. A SUBSCRIBE_UPDATE targets the + // subscription by its Subscribe Request ID (the second field), not the update's own. ietf::SubscribeUpdate::ID => { - let id = decode_request_id(body, version)?; + let id = decode_subscribe_update_request_id(body, version)?; Ok(Route::FollowUp(id)) } @@ -1188,6 +1189,19 @@ fn decode_response_request_id(body: &Bytes, version: Version) -> Result Result { + let mut cursor = std::io::Cursor::new(body); + let _update_request_id = RequestId::decode(&mut cursor, version)?; + let subscription_request_id = RequestId::decode(&mut cursor, version)?; + Ok(subscription_request_id) +} + /// Decode the namespace from a PublishNamespace message body (after the request_id). fn decode_publish_namespace_body(body: &Bytes, version: Version) -> Result { let mut cursor = std::io::Cursor::new(body); @@ -1275,7 +1289,13 @@ mod tests { #[test] fn test_classify_subscribe_update_followup() { - let body = make_body_with_request_id(10, Version::Draft15); + use crate::coding::Encode; + // A SUBSCRIBE_UPDATE carries its own Request ID (7) first and the subscription's + // Request ID (10) second; the follow-up must route to the subscription (10). + let mut buf = BytesMut::new(); + RequestId(7).encode(&mut buf, Version::Draft15).unwrap(); + RequestId(10).encode(&mut buf, Version::Draft15).unwrap(); + let body = buf.freeze(); let route = classify_msg(Version::Draft15, ietf::SubscribeUpdate::ID, &body).unwrap(); assert!(matches!(route, Route::FollowUp(RequestId(10)))); } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 4c8e6047e7..49495d4203 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1665,7 +1665,11 @@ where suffix: &crate::PathOwned, path: &crate::PathOwned, ) -> Result<(), Error> { - let permitted = ns.permitted(path); + // A token-bearing client does not self-censor on its connection grant: a request token + // authorizes an announce the connection grant does not cover (MoQ request-token, quest + // Goal), and the server's covers-gate is the authority, refusing a bad token per request. + // Without a token this is unchanged: the connection grant filters as before. + let permitted = self.request_token.peek().is_some() || ns.permitted(path); let Namespaces { peer, target, @@ -3276,6 +3280,88 @@ mod serve_tests { (auth, requests, cred) } + /// A token-bearing client does not self-censor on its connection grant (B1a, quest Goal): the + /// publisher advertises a namespace its connection grant does not cover, carrying the token, + /// and the server's covers-gate is left to be the authority. A token-less client with the same + /// grant filters that announce, as before. + #[tokio::test] + async fn a_token_bearing_client_announces_outside_its_connection_grant() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + // A connection grant of "other", which does not cover "cam". The presented credential and + // its grant are held for the run's lifetime, else the union reverts to permissive. + fn seed_auth() -> (crate::auth::Handle, crate::auth::Token) { + let auth = crate::auth::Handle::new(true); + let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + (auth, cred) + } + + // With a token, "cam" is advertised despite the grant not covering it (the token rides it). + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let (auth, _cred) = seed_auth(); + let publisher = Publisher::new( + crate::time::Clock::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(token.clone()))); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + let mut advertised = false; + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) >= 1 { + advertised = true; + break; + } + settle().await; + } + assert!(advertised, "a token-bearing client must announce outside its connection grant"); + + // Without a token, the same grant filters "cam": nothing is advertised. + let origin2 = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam2 = origin2.announce("cam", crate::origin::Route::default()).unwrap(); + let session2 = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log2 = session2.log.clone(); + let peer_setup2 = peer::PeerSetup::default(); + peer_setup2.set(peer::Peer::default()); + let (auth2, _cred2) = seed_auth(); + let publisher2 = Publisher::new( + crate::time::Clock::tokio(), + session2, + origin2.consume(), + Control::new(None, false), + None, + peer_setup2, + VERSION, + ) + .with_auth(auth2); + let mut run2 = std::pin::pin!(publisher2.run_publish_namespaces()); + for _ in 0..80 { + assert!(futures::poll!(run2.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log2, b"cam"), 0, "a token-less client self-censors on its grant"); + } + /// A Token structure value that decodes via `token::decode_value` (USE_VALUE, kind 300). fn request_token() -> bytes::Bytes { bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]) @@ -3380,6 +3466,70 @@ mod serve_tests { } } + /// The draft-14 mirror of the d18 renewal test (B2): a SUBSCRIBE_UPDATE carrying a fresh token + /// renews a token-authorized subscription past the old grant's expiry at draft-14, where the + /// update rides the control-stream adapter. This exercises `run_subscribe_stream`'s d14 decode + /// and renew; the adapter's follow-up routing to the subscription's stream is proven by + /// `super::super::adapter::tests::test_classify_subscribe_update_followup`. + #[tokio::test(start_paused = true)] + async fn a_subscribe_update_renews_at_draft_14() { + const VERSION: Version = Version::Draft14; + + let (auth, mut requests, _cred) = auth_covering_other(); + let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let answered = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + async move { + let mut held = Vec::new(); + let first = requests.next().await.unwrap(); + held.push(first.accept(grant_all_expiring(&rt, Some(60)))); + answered.fetch_add(1, Ordering::Relaxed); + let renewal = requests.next().await.unwrap(); + held.push(renewal.accept(grant_all_expiring(&rt, None))); + answered.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending(), "subscription ended during setup"); + if answered.load(Ordering::Relaxed) >= 2 { + break; + } + settle().await; + } + assert_eq!(answered.load(Ordering::Relaxed), 2, "acceptor never answered both tokens"); + + for _ in 0..20 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending()); + settle().await; + } + + tokio::time::advance(Duration::from_secs(120)).await; + for _ in 0..50 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the renewal did not extend the subscription past the old expiry" + ); + settle().await; + } + } + /// A REQUEST_UPDATE the acceptor refuses does NOT extend the grant: the old grant stands /// and the subscription ends only when it lapses (the quest's rule), never the session. #[tokio::test(start_paused = true)] diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 853efeb596..87832675ee 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1814,11 +1814,15 @@ where return; } - // Subscribe only to what our grant covers (MoQ Auth), and cancel once it no longer - // does, leaving the rest of the session alone. - if !self - .auth - .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()) + // A token-bearing client does not self-censor on its connection grant: a request token + // authorizes a subscribe the connection grant does not cover (MoQ request-token, quest + // Goal), and the server's covers-gate is the authority. Without a token this is unchanged: + // the connection grant filters, and its shrink revokes, as before. + let token_authorized = self.request_token.peek().is_some(); + if !token_authorized + && !self + .auth + .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()) { request.reject(Error::Unauthorized); return; @@ -2086,7 +2090,7 @@ where { fetch_done = true; } - if gate.poll_denied(waiter).is_ready() { + if !token_authorized && gate.poll_denied(waiter).is_ready() { return Poll::Ready(End::Revoked); } if track.poll_unused(waiter).is_ready() { @@ -4269,6 +4273,117 @@ mod tests { } } + /// A token-bearing client does not self-censor on its connection grant (B1b, quest Goal): the + /// subscriber sends a SUBSCRIBE for a path its connection grant does not cover, carrying the + /// token; a token-less client with the same grant rejects it locally, as before. + #[tokio::test(start_paused = true)] + async fn a_token_bearing_client_subscribes_outside_its_connection_grant() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + // A connection grant of "other", which does not cover "room/x". Held for the run. + fn seed_auth() -> (crate::auth::Handle, crate::auth::Token) { + let auth = crate::auth::Handle::new(true); + let cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::subtree("other").unwrap().into(), + expires: None, + }, + ); + (auth, cred) + } + assert!( + !seed_auth().0.allows(crate::auth::Direction::Subscribe, "room/x"), + "the connection grant must not cover the subscribed path" + ); + + // With a token, the SUBSCRIBE for room/x reaches the wire carrying the token. + let session = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); + let log = session.log.clone(); + let (tasks, _task_set) = crate::util::TaskSet::new(); + let (auth, _cred) = seed_auth(); + let mut subscriber = Subscriber::new( + crate::time::Clock::tokio(), + session, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + VERSION, + tasks, + Default::default(), + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(token.clone()))); + let producer = crate::broadcast::Info::default().produce(); + let mut dynamic = producer.dynamic(); + let consumer = producer.consume(); + let track = consumer.track("video").unwrap(); + let subscription = track.subscribe(None); + let request = dynamic.requested_track().await.expect("no track requested"); + let serving = tokio::spawn(async move { + subscriber.run_subscribe(Path::new("room/x"), dynamic, request).await; + }); + let mut sent = false; + for _ in 0..200 { + if occurrences(&log, &token) >= 1 { + sent = true; + break; + } + settle().await; + } + assert!(sent, "a token-bearing client must subscribe outside its connection grant"); + drop(subscription); + drop(track); + drop(consumer); + serving.abort(); + + // Without a token, the same grant rejects the subscribe locally: no SUBSCRIBE is sent. + let session2 = crate::lite::test_transport::ScriptedSession::new(subscribe_ok_bytes(VERSION).await); + let log2 = session2.log.clone(); + let (tasks2, _task_set2) = crate::util::TaskSet::new(); + let (auth2, _cred2) = seed_auth(); + let mut subscriber2 = Subscriber::new( + crate::time::Clock::tokio(), + session2, + crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(), + Control::new(None, false), + None, + peer::PeerSetup::default(), + crate::Hop::new(1).unwrap(), + None, + VERSION, + tasks2, + Default::default(), + ) + .with_auth(auth2); + let producer2 = crate::broadcast::Info::default().produce(); + let mut dynamic2 = producer2.dynamic(); + let consumer2 = producer2.consume(); + let track2 = consumer2.track("video").unwrap(); + let subscription2 = track2.subscribe(None); + let request2 = dynamic2.requested_track().await.expect("no track requested"); + let serving2 = tokio::spawn(async move { + subscriber2.run_subscribe(Path::new("room/x"), dynamic2, request2).await; + }); + for _ in 0..80 { + settle().await; + } + assert!( + !control_message_types(&log2, VERSION).contains(&ietf::Subscribe::ID), + "a token-less client rejects a subscribe outside its grant locally" + ); + drop(subscription2); + drop(track2); + drop(consumer2); + serving2.abort(); + } + fn control_message_types(log: &crate::lite::test_transport::Log, version: Version) -> Vec { use crate::coding::Decode; From 04d7d586fff92cc1e5cbbbe1a0f9da9c5fc2e94f Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 23:15:55 +0000 Subject: [PATCH 23/49] feat(net): let a client decline the MoQ Auth extension Add Client::without_auth_extension (mirrored on moq-tokio), so a moq-dev client can connect as a peer that does not negotiate the moq-dev AUTH Setup Option, emulating a standard moq-transport peer (an encoder or CDN). When set, the client omits the option from its SETUP and builds its auth handle unsupported, so it presents no connection credential and the server sees declared.auth false. This lets a request-borne AUTHORIZATION TOKEN be exercised as the authorizing artifact at draft-17+: on an ungranted (None-union) session the server's covers-gate does not short-circuit, where a negotiated connection grant would cover the request and skip the token. Additive: the default still declares the extension, and a version that does not negotiate it is unaffected. The driver-level test a_client_may_decline_the_auth_extension proves it at draft-18: the declining client holds no session grant, its token-bearing SUBSCRIBE reaches the acceptor, and a token-less request is admitted by the permissive default. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 8 ++++ rs/moq-net/src/client.rs | 23 +++++++++- rs/moq-net/src/ietf/session.rs | 13 ++++-- rs/moq-net/tests/auth.rs | 83 ++++++++++++++++++++++++++++++++++ rs/moq-tokio/src/client.rs | 7 +++ 5 files changed, 129 insertions(+), 5 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 82e3a1e07e..cab588625b 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -257,6 +257,14 @@ impl Handle { } } + /// Whether this session speaks the AUTH extension. False on a version that cannot + /// negotiate it, and on a handle a caller built declined (see + /// `Client::without_auth_extension`), so the SETUP omits the option and no + /// connection credential is presented. + pub(crate) fn supported(&self) -> bool { + self.state.lock().supported + } + /// The union of every grant this side holds: `None` until the peer first /// answers a token (with a grant or a refusal), and forever on a version /// without AUTH. diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index b3d23dcbda..7d8058d59e 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -74,6 +74,7 @@ pub struct Client { cost: Option, peer_hop: Option, request_token: RequestToken, + decline_auth_extension: bool, } impl Client { @@ -210,6 +211,22 @@ impl Client { self.request_token.set(Some(token.into())); } + /// Do not declare the MoQ Auth extension in this client's SETUP, so it connects as a + /// peer without it (interop testing). + /// + /// A standard moq-transport peer (for example a non-moq-dev encoder or CDN) never + /// negotiates the moq-dev AUTH Setup Option, so its session carries no connection + /// grant and a request-borne `AUTHORIZATION TOKEN` (see + /// [`with_request_token`](Self::with_request_token)) is the authorizing artifact. A + /// moq-dev client normally declares the extension, which fills the session grant and + /// short-circuits the request token; this lets one emulate the peer that does not, so + /// the request-token path can be exercised at draft-17+. Additive: the default still + /// declares the extension. No effect on versions that do not negotiate it. + pub fn without_auth_extension(mut self) -> Self { + self.decline_auth_extension = true; + self + } + /// The origin pair a session attaches, tagged and filtered. /// /// Reads through the publish (egress) consumer and writes through the @@ -342,8 +359,10 @@ impl Client { // Draft-17+: SETUP is exchanged by the connection driver. // We advertise the request path in our SETUP for URL-less transports. - // The peer's SETUP decides whether AUTH is negotiated. - let auth = crate::auth::Handle::new(true); + // The peer's SETUP decides whether AUTH is negotiated. A client that + // declined the extension builds a handle that does not speak it, so its + // SETUP omits the option and no connection credential is presented. + let auth = crate::auth::Handle::new(!self.decline_auth_extension); let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), session: session.clone(), diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index c9ad814e37..d9dc282e81 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -317,9 +317,11 @@ where let runtime = runtime.clone(); let session = session.clone(); let goaway = goaway.clone(); + let declare_auth = auth.supported(); async move { if let Err(err) = - run_setup(runtime, session, version, path, authority, self_origin, cost, goaway).await + run_setup(runtime, session, version, path, authority, self_origin, cost, declare_auth, goaway) + .await { tracing::warn!(%err, "setup send error"); } @@ -624,7 +626,9 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result( runtime: crate::time::Clock, @@ -634,6 +638,7 @@ async fn run_setup( authority: Option, self_origin: Hop, cost: Option, + declare_auth: bool, goaway: crate::goaway::Protocol, ) -> Result<(), Error> { let outer_version = crate::Version::Ietf(version); @@ -652,7 +657,9 @@ async fn run_setup( cluster::peer_into_setup(&mut parameters, self_origin, cost, version); solicit::into_setup(&mut parameters, version); hidden::into_setup(&mut parameters, version); - auth::into_setup(&mut parameters, version); + if declare_auth { + auth::into_setup(&mut parameters, version); + } let parameters = parameters.encode_bytes(version)?; writer.encode(&setup::Setup { parameters }).await?; diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 1c612dd11f..572704c93f 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -20,6 +20,8 @@ const TEST_TIMEOUT: Duration = Duration::from_secs(10); const LITE_06: &str = "moq-lite-06"; /// The first draft that negotiates MoQ Auth, and the newest. const MOQT_17: &str = "moq-transport-17"; +/// A draft at the deployed floor, used for the request-token launch shape. +const MOQT_18: &str = "moq-transport-18"; const MOQT_22: &str = "moq-transport-22"; /// Run each case on every version that exchanges AUTH. @@ -166,6 +168,8 @@ struct Options { server_requests: bool, /// A request token the client attaches to its outgoing PUBLISH_NAMESPACE / SUBSCRIBE. client_request_token: Option>, + /// The client declines the MoQ Auth extension (`Client::without_auth_extension`). + client_decline_auth: bool, version: Option<&'static str>, } @@ -193,6 +197,9 @@ async fn connect(opts: Options) -> Pair { if let Some(token) = opts.client_request_token { client = client.with_request_token(token); } + if opts.client_decline_auth { + client = client.without_auth_extension(); + } let mut server = Server::new().with_versions(version.into()); if let Some(publish) = &opts.server_publish { @@ -388,6 +395,81 @@ async fn a_request_token_authorizes_a_legacy_announce_through_the_driver() { .expect("timed out"); } +/// A client may decline the MoQ Auth extension, connecting at draft-18 as a peer that +/// does not negotiate it (a non-moq-dev encoder or CDN). Its SETUP omits the option, so +/// the server sees `declared.auth == false` and the session carries no connection grant +/// (`None` union). A request-borne `AUTHORIZATION TOKEN` is then the authorizing artifact +/// and reaches the server's acceptor, where a normal draft-18 client's connection grant +/// would cover the request and skip the token. A token-less request on such a +/// session is admitted by the permissive default of the ungranted session. +/// +/// The token is exercised on a SUBSCRIBE, which is always a request and carries the token +/// on every draft. A request token on a PUBLISH_NAMESPACE does NOT reach a moq-net peer at +/// draft-16+ regardless of this option: moq-net declares MoQ Solicit unconditionally, so +/// the announce answers the peer's SUBSCRIBE_NAMESPACE inline via `ietf::Namespace`, which +/// carries no token, and the token-bearing unsolicited PUBLISH_NAMESPACE loop is disabled. +#[tokio::test] +async fn a_client_may_decline_the_auth_extension() { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + let server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", None).unwrap(); + down.announce(Default::default()).unwrap(); + + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_subscribe: Some(received.clone()), + // USE_VALUE (0x03), token kind 0, value "ok". + client_request_token: Some(vec![0x03, 0x00, b'o', b'k']), + client_decline_auth: true, + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // declared.auth == false: the declining client speaks no AUTH, so it holds no + // session grant and cannot present a session token (unlike a normal draft-18 peer). + assert_eq!(pair.client.auth().grant().peek(), None); + assert!(matches!(pair.client.auth().add("x").await, Err(Error::Unsupported))); + + // The client's token-bearing SUBSCRIBE reaches the acceptor: on the `None`-union + // session the covers-gate does not short-circuit, so the token is verified rather + // than admitted by a covering connection grant. + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"down".as_ref()).unwrap(); + + let (_token, _issued) = answered.recv().await.expect("the token reached the acceptor"); + sub.recv_group().await.unwrap().unwrap(); + + // Token-less: a declining client with no token is admitted by the permissive default. + let bare_publisher = produce_origin(4); + let bare_relay = produce_origin(5); + let bare = connect(Options { + version: Some(MOQT_18), + client_publish: Some(bare_publisher.clone()), + client_decline_auth: true, + server_subscribe: Some(bare_relay.clone()), + ..Default::default() + }) + .await; + let bc = bare_publisher.create_broadcast("room/bob").unwrap(); + bc.announce(Default::default()).unwrap(); + wait_announced(&bare_relay.consume(), "room/bob", true).await; + assert_eq!(bare.client_transport.close_reason(), None); + }) + .await + .expect("timed out"); +} + /// A broadcast published before the grant arrives is checked at admission too. async fn a_broadcast_published_before_the_grant_is_checked(version: &'static str) { within(async { @@ -752,6 +834,7 @@ async fn a_revoked_grant_cancels_its_subscriptions(version: &'static str) { server_subscribe: Some(server_origin.clone()), server_requests: true, client_request_token: None, + ..Default::default() }) .await; let mut issued = serve(pair.requests.take().unwrap(), |token| { diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 2463ce11bf..f6828e5ba1 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -239,6 +239,13 @@ impl Client { self.moq.set_request_token(token); } + /// Do not declare the MoQ Auth extension in this client's SETUP, so it connects as a + /// peer without it (interop testing); see [`moq_net::Client::without_auth_extension`]. + pub fn without_auth_extension(mut self) -> Self { + self.moq = self.moq.without_auth_extension(); + self + } + /// Override whether this client redials after a session drop. /// /// Defaults to true, unless [`crate::connect::Config::once`] turned it off. From 95db7401dddf327eedb18d37442325e19060e669 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Wed, 30 Sep 2026 23:47:52 +0000 Subject: [PATCH 24/49] feat(net): let a server decline the MoQ Solicit extension Add Server::without_solicit (mirrored on moq-tokio), so a server can stop declaring the MoQ Solicit Setup Option. A peer that speaks the extension then sends an unsolicited PUBLISH_NAMESPACE (the base moq-transport behavior) instead of answering our SUBSCRIBE_NAMESPACE inline. This is what lets a request-borne AUTHORIZATION TOKEN ride an announce to a moq-net server. Only an unsolicited PUBLISH_NAMESPACE carries the token; the inline Namespace entry has no parameter slot for one. A standard moq-transport peer (a non-moq-dev encoder or CDN) never reads Solicit and always announces unsolicited, so declining it emulates that shape from a moq-dev client for interop testing. The default still declares Solicit. The receive half is gated to match: an unsolicited PUBLISH_NAMESPACE from a solicit-aware peer is a protocol violation only when we actually declared Solicit (Subscriber::with_solicit), so a server that declined it accepts the announce it invited rather than faulting the session. The draft-18 test a_server_that_declines_solicit_gets_a_token_bearing_unsolicited_announce proves it in the launch shape: server without Solicit plus client without the AUTH extension, the client's request token rides an unsolicited PUBLISH_NAMESPACE and reaches the acceptor; with Solicit declared (the default) the client answers inline, carries no token, and the acceptor is never consulted. Co-Authored-By: Claude --- rs/moq-net/src/client.rs | 4 ++ rs/moq-net/src/ietf/session.rs | 46 ++++++++++++++--- rs/moq-net/src/ietf/subscriber.rs | 22 ++++++++ rs/moq-net/src/server.rs | 26 +++++++++- rs/moq-net/tests/auth.rs | 83 +++++++++++++++++++++++++++++++ rs/moq-tokio/src/server.rs | 15 +++++- 6 files changed, 186 insertions(+), 10 deletions(-) diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 7d8058d59e..5a90ecae86 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -380,6 +380,8 @@ impl Client { peer_declared: None, auth: auth.clone(), request_token: self.request_token.clone(), + // A client always declares MoQ Solicit; only a server declines it. + solicit: true, })?; tracing::debug!(version = ?v, "connected"); @@ -537,6 +539,8 @@ impl Client { peer_declared: Some(peer_declared), auth: auth.clone(), request_token: self.request_token.clone(), + // A client always declares MoQ Solicit; only a server declines it. + solicit: true, })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index d9dc282e81..8680af1e31 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -75,6 +75,12 @@ pub struct Config { /// requests (MoQ request-token), a shared handle so it can be replaced while the session /// runs. The default presents none, for a server or a client that presents none. pub request_token: crate::RequestToken, + + /// Whether to declare the MoQ Solicit Setup Option in our SETUP (draft-17+). Default true. + /// A server built with `Server::without_solicit` passes false, so a peer that speaks the + /// extension sends an unsolicited PUBLISH_NAMESPACE (the base moq-transport behavior) + /// instead of answering our SUBSCRIBE_NAMESPACE inline. A client always declares it. + pub solicit: bool, } pub fn start(config: Config) -> Result<(MaybeSendBox<'static, Result<(), Error>>, crate::goaway::Handle), Error> @@ -98,6 +104,7 @@ where peer_declared, auth, request_token, + solicit, } = config; // GOAWAY wiring: the public Session holds one half (drain trigger, received @@ -191,7 +198,8 @@ where goaway.going_away.clone(), ) .with_auth(auth.clone()) - .with_request_token(request_token.clone()); + .with_request_token(request_token.clone()) + .with_solicit(solicit); // GOAWAY send task: draft-14-16 carry GOAWAY on the shared control // stream. Parked on the drain trigger; races the transport close so @@ -318,10 +326,22 @@ where let session = session.clone(); let goaway = goaway.clone(); let declare_auth = auth.supported(); + let declare_solicit = solicit; async move { if let Err(err) = - run_setup(runtime, session, version, path, authority, self_origin, cost, declare_auth, goaway) - .await + run_setup( + runtime, + session, + version, + path, + authority, + self_origin, + cost, + declare_auth, + declare_solicit, + goaway, + ) + .await { tracing::warn!(%err, "setup send error"); } @@ -371,7 +391,8 @@ where goaway.going_away.clone(), ) .with_auth(auth.clone()) - .with_request_token(request_token.clone()); + .with_request_token(request_token.clone()) + .with_solicit(solicit); // Our tokens, one Auth request each, once the peer's SETUP negotiates it. let present = auth::run_present( @@ -625,8 +646,10 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result( self_origin: Hop, cost: Option, declare_auth: bool, + declare_solicit: bool, goaway: crate::goaway::Protocol, ) -> Result<(), Error> { let outer_version = crate::Version::Ietf(version); @@ -655,7 +679,9 @@ async fn run_setup( parameters.set_bytes(ietf::ParameterBytes::Authority, authority.into_bytes()); } cluster::peer_into_setup(&mut parameters, self_origin, cost, version); - solicit::into_setup(&mut parameters, version); + if declare_solicit { + solicit::into_setup(&mut parameters, version); + } hidden::into_setup(&mut parameters, version); if declare_auth { auth::into_setup(&mut parameters, version); @@ -1156,6 +1182,7 @@ mod tests { }), auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), + solicit: true, }) .expect("start the session"); @@ -1210,6 +1237,7 @@ mod tests { peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), + solicit: true, }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1292,6 +1320,7 @@ mod tests { peer_declared, auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), + solicit: true, }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1394,6 +1423,7 @@ mod tests { }), auth: handle.clone(), request_token: crate::RequestToken::default(), + solicit: true, }) .expect("start the session"); AuthSession { @@ -1508,6 +1538,7 @@ mod tests { peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), + solicit: true, }) .expect("start the session"); @@ -1746,6 +1777,7 @@ mod tests { peer_declared: None, auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), + solicit: true, }) .expect("start the session"); let driver = tokio::spawn(driver); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 87832675ee..2c3e98d6c9 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -444,6 +444,11 @@ pub(super) struct Subscriber { // REQUEST_UPDATEs (MoQ request-token). A shared handle so a client can replace it while the // session runs; the default presents none. A client credential. request_token: crate::RequestToken, + // Whether we declared MoQ Solicit in our SETUP (`solicit::into_setup`). True by default; + // a server built with `Server::without_solicit` sets it false. It gates whether an + // unsolicited PUBLISH_NAMESPACE from a solicit-aware peer is a violation: only a peer that + // disregarded a requirement we actually stated is at fault. + declared_solicit: bool, } /// Resolve the subscription a data stream belongs to. @@ -512,6 +517,8 @@ where going_away, auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), + // We declare MoQ Solicit by default; `with_solicit(false)` opts out. + declared_solicit: true, } } @@ -574,6 +581,15 @@ where }); } + /// Whether we declared MoQ Solicit in our SETUP. A server built with + /// [`Server::without_solicit`](crate::Server::without_solicit) passes false, so an + /// unsolicited PUBLISH_NAMESPACE from a solicit-aware peer is expected rather than a + /// violation. + pub fn with_solicit(mut self, declared: bool) -> Self { + self.declared_solicit = declared; + self + } + /// Present this request token (the AUTHORIZATION TOKEN parameter value) on the SUBSCRIBE /// requests this side sends, so a client authorizes its subscribes the standard draft-17+ /// way (MoQ request-token). A shared handle, so a replaced token is re-presented on each @@ -1016,6 +1032,12 @@ where /// request is also how a peer answers our SUBSCRIBE_NAMESPACE there, and the message /// alone does not say which it is. fn unsolicited_is_a_violation(&self, declared: Option) -> bool { + // We only hold a peer to a requirement we actually stated. A server that declined MoQ + // Solicit (`Server::without_solicit`) invited unsolicited advertisements, so one is + // expected even from a solicit-aware peer. + if !self.declared_solicit { + return false; + } match self.version { Version::Draft14 | Version::Draft15 => false, _ => declared.is_some(), diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index 816d1e2208..a0147eea23 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -20,6 +20,7 @@ pub struct Server { subscribe: Option, stats: stats::Session, versions: Versions, + decline_solicit: bool, } impl Server { @@ -65,6 +66,21 @@ impl Server { self } + /// Do not require solicited announcements: omit the MoQ Solicit Setup Option from this + /// server's SETUP. + /// + /// By default a server declares MoQ Solicit, so a peer that speaks the extension answers + /// our SUBSCRIBE_NAMESPACE inline rather than sending an unsolicited PUBLISH_NAMESPACE. + /// Declining it makes such a peer fall back to the base moq-transport behavior and send an + /// unsolicited PUBLISH_NAMESPACE. Use for peers that do not speak the MoQ Solicit extension + /// (a standard moq-transport encoder or CDN), which never solicit and always announce + /// unasked; the server already accepts an unsolicited PUBLISH_NAMESPACE either way. Additive: + /// the default still declares the extension. + pub fn without_solicit(mut self) -> Self { + self.decline_solicit = true; + self + } + /// The configured origin pair, each tagged with the stats context so the /// model attributes reads (egress) and writes (ingress) for this session. /// One shared context across both halves keeps presence and viewer counts @@ -566,6 +582,8 @@ where peer_declared: Some(peer_setup.declared), auth: auth.clone(), request_token: crate::RequestToken::default(), + // Declare MoQ Solicit unless the server declined it. + solicit: !server.decline_solicit, })?; tracing::debug!(?version, "connected"); Ok(Session::new( @@ -628,7 +646,10 @@ where let mut parameters = ietf::Parameters::default(); parameters.set_varint(ietf::ParameterVarInt::MaxRequestId, u32::MAX as u64); parameters.set_bytes(ietf::ParameterBytes::Implementation, b"moq-lite-rs".to_vec()); - ietf::solicit::into_setup(&mut parameters, v); + // Declare MoQ Solicit unless the server declined it. + if !server.decline_solicit { + ietf::solicit::into_setup(&mut parameters, v); + } ietf::hidden::into_setup(&mut parameters, v); parameters.encode_bytes(v)? } @@ -685,6 +706,9 @@ where peer_declared: Some(peer_declared), auth: auth.clone(), request_token: crate::RequestToken::default(), + // The legacy server already declared Solicit (or not) in its SETUP above; + // this arm sends no further SETUP, so the flag is inert here. + solicit: !server.decline_solicit, })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 572704c93f..92f7e29788 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -170,6 +170,8 @@ struct Options { client_request_token: Option>, /// The client declines the MoQ Auth extension (`Client::without_auth_extension`). client_decline_auth: bool, + /// The server declines the MoQ Solicit extension (`Server::without_solicit`). + server_decline_solicit: bool, version: Option<&'static str>, } @@ -202,6 +204,9 @@ async fn connect(opts: Options) -> Pair { } let mut server = Server::new().with_versions(version.into()); + if opts.server_decline_solicit { + server = server.without_solicit(); + } if let Some(publish) = &opts.server_publish { server = server.with_publisher(publish); } @@ -470,6 +475,84 @@ async fn a_client_may_decline_the_auth_extension() { .expect("timed out"); } +/// A server may decline the MoQ Solicit extension, so a peer sends an unsolicited +/// PUBLISH_NAMESPACE (the base moq-transport behavior) instead of answering our +/// SUBSCRIBE_NAMESPACE inline. Only the unsolicited PUBLISH_NAMESPACE carries an +/// `AUTHORIZATION TOKEN`; the inline `Namespace` entry has no parameter slot for one. So a +/// request-borne token on an announce reaches the acceptor exactly when the server does not +/// solicit, which is the shape a standard moq-transport peer (an encoder or CDN) always sends. +/// +/// This runs at draft-18 (the deployed floor) in the launch shape: the client also declines +/// the AUTH extension, so the session's union is `None`, the covers-gate does not short-circuit +/// on a connection grant, and the request token is the authorizing artifact. The +/// control half shows the default: with Solicit declared the client answers inline, no token +/// reaches the acceptor, and the announce is admitted by the permissive default of the +/// ungranted session. +#[tokio::test] +async fn a_server_that_declines_solicit_gets_a_token_bearing_unsolicited_announce() { + within(async { + // USE_VALUE (0x03), token kind 0, value "ok". + let request_token = vec![0x03, 0x00, b'o', b'k']; + + // Server declines Solicit: the client sends an unsolicited PUBLISH_NAMESPACE carrying + // the token, which reaches the acceptor as a PublishNamespace request. + let publisher = produce_origin(1); + let relay = produce_origin(2); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_publish: Some(publisher.clone()), + client_request_token: Some(request_token.clone()), + client_decline_auth: true, + server_subscribe: Some(relay.clone()), + server_decline_solicit: true, + server_requests: true, + ..Default::default() + }) + .await; + let bc = publisher.create_broadcast("room/alice").unwrap(); + bc.announce(Default::default()).unwrap(); + + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/alice"], &["room/alice"]))); + // The announce is admitted once the token is granted. + wait_announced(&relay.consume(), "room/alice", true).await; + let (tok, _issued) = answered + .recv() + .await + .expect("the unsolicited PUBLISH_NAMESPACE carried the token to the acceptor"); + assert_eq!(tok, b"ok", "the acceptor saw the request token's decoded value"); + + // Control: with Solicit declared (the default) the same client answers our + // SUBSCRIBE_NAMESPACE inline with a Namespace, which carries no token, so nothing + // reaches the acceptor. The announce is still admitted by the permissive default. + let publisher = produce_origin(3); + let relay = produce_origin(4); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_publish: Some(publisher.clone()), + client_request_token: Some(request_token.clone()), + client_decline_auth: true, + server_subscribe: Some(relay.clone()), + // server_decline_solicit defaults false: the server declares Solicit. + server_requests: true, + ..Default::default() + }) + .await; + let bc = publisher.create_broadcast("room/carol").unwrap(); + bc.announce(Default::default()).unwrap(); + + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, |_token| Some(grant(&["room/carol"], &[]))); + wait_announced(&relay.consume(), "room/carol", true).await; + assert!( + answered.try_recv().is_err(), + "a solicited (inline) announce carries no token, so the acceptor is never consulted" + ); + }) + .await + .expect("timed out"); +} + /// A broadcast published before the grant arrives is checked at admission too. async fn a_broadcast_published_before_the_grant_is_checked(version: &'static str) { within(async { diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index 0c7d05cc01..f88659aaf6 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -438,8 +438,7 @@ impl Server { /// than the values it returns. /// /// Empty when no TLS-bearing backend is configured (e.g. a stream-only server). - pub fn certificates(&self) -> crate::tls::Certificates { - #[cfg(feature = "noq")] + pub fn certificates(&self) -> crate::tls::Certificates { #[cfg(feature = "noq")] if let Some(noq) = self.noq.as_ref() { return noq.certificates(); } @@ -447,6 +446,18 @@ impl Server { crate::tls::Certificates::empty() } + /// Do not require solicited announcements: omit the MoQ Solicit Setup Option from every + /// session this server accepts; see [`moq_net::Server::without_solicit`]. + /// + /// A peer that speaks the extension then sends an unsolicited PUBLISH_NAMESPACE (the base + /// moq-transport behavior) instead of answering our SUBSCRIBE_NAMESPACE inline. Use for + /// peers that do not speak MoQ Solicit (a standard moq-transport encoder or CDN). Additive: + /// the default still declares the extension. + pub fn without_solicit(mut self) -> Self { + self.moq = self.moq.without_solicit(); + self + } + /// Clone this server's QUIC endpoint, keeping its socket in the reuseport /// group after this server is gone. /// From cb06ed177ec0dfa8584e2e248852684a661199ed Mon Sep 17 00:00:00 2001 From: sletmoe Date: Thu, 1 Oct 2026 00:23:39 +0000 Subject: [PATCH 25/49] style(net): apply rustfmt to the request-token changes cargo fmt --all over the files this branch touched; no code change. Base ea7e98483 is fmt-clean, so these were introduced by the branch. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 38 +++++++-------- rs/moq-net/src/ietf/fetch.rs | 4 +- rs/moq-net/src/ietf/publisher.rs | 78 +++++++++++++++++++++++++------ rs/moq-net/src/ietf/session.rs | 5 +- rs/moq-net/src/ietf/subscriber.rs | 62 ++++++++++++++++-------- rs/moq-tokio/src/server.rs | 3 +- 6 files changed, 133 insertions(+), 57 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index cab588625b..ba140c767d 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -963,10 +963,12 @@ impl RequestVerdict { let Some(issue) = &self.issue else { return Poll::Ready(Err(Error::Unsupported)); }; - let mut guard = ready_or!(issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { - true => Poll::Pending, - false => Poll::Ready(()), - })); + let mut guard = ready_or!( + issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + }) + ); Poll::Ready(match guard.outbox.pop_front() { Some(Reply::Grant(grant)) => Ok(grant), Some(Reply::Refuse { code, .. }) => Err(Error::Session(code)), @@ -985,10 +987,12 @@ impl RequestVerdict { reason: "no verifier".to_string(), }); }; - let mut guard = ready_or!(issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { - true => Poll::Pending, - false => Poll::Ready(()), - })); + let mut guard = ready_or!( + issue.poll(waiter, |issue| match issue.outbox.is_empty() && !issue.done { + true => Poll::Pending, + false => Poll::Ready(()), + }) + ); Poll::Ready(match guard.outbox.pop_front() { Some(reply) => reply, // Done with nothing more to read: the acceptor dropped the issued grant, ending @@ -1364,12 +1368,7 @@ mod request_token_tests { async fn a_request_token_reaches_the_acceptor_with_its_context() { let handle = Handle::new(true); let mut requests = handle.requests().expect("take the requests"); - let verdict = handle.verify_request( - Bytes::from_static(b"jwt"), - 7, - subscribe_path(), - RequestKind::Subscribe, - ); + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 7, subscribe_path(), RequestKind::Subscribe); let request = requests.next().await.expect("a request"); assert_eq!(request.token(), &Bytes::from_static(b"jwt")); @@ -1419,11 +1418,7 @@ mod request_token_tests { let handle = Handle::new(true); let mut requests = handle.requests().unwrap(); let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); - requests - .next() - .await - .unwrap() - .reject(SessionError::Unauthorized, "no"); + requests.next().await.unwrap().reject(SessionError::Unauthorized, "no"); let err = verdict.grant().await.expect_err("refused"); assert!(matches!(err, Error::Session(SessionError::Unauthorized)), "{err:?}"); } @@ -1558,7 +1553,10 @@ mod request_token_tests { .await .unwrap() .reject(SessionError::Unauthorized, "bad token"); - assert!(matches!(renewal.grant().await, Err(Error::Session(SessionError::Unauthorized)))); + assert!(matches!( + renewal.grant().await, + Err(Error::Session(SessionError::Unauthorized)) + )); // The old grant still stands with its original expiry, and the request ends only // when that lapses. diff --git a/rs/moq-net/src/ietf/fetch.rs b/rs/moq-net/src/ietf/fetch.rs index 14bfb1bd42..44a4fde264 100644 --- a/rs/moq-net/src/ietf/fetch.rs +++ b/rs/moq-net/src/ietf/fetch.rs @@ -779,7 +779,9 @@ mod tests { encode_params!(&mut body, version, 0x03 => Some(bytes::Bytes::from_static(token))); let mut buf = body.freeze(); assert_eq!( - Fetch::decode_msg(&mut buf, version).expect("strict token accepted").request_id, + Fetch::decode_msg(&mut buf, version) + .expect("strict token accepted") + .request_id, RequestId(1) ); assert!(buf.is_empty()); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 49495d4203..a6e60cbc76 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -554,7 +554,9 @@ where let structure = match crate::ietf::token::decode_value(token, self.version) { Ok(structure) => structure, Err(err @ Error::ProtocolViolation) => { - self.session.clone().close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); + self.session + .clone() + .close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); return Err(err); } Err(_) => { @@ -880,7 +882,9 @@ where let structure = match crate::ietf::token::decode_value(token, self.version) { Ok(structure) => structure, Err(err @ Error::ProtocolViolation) => { - self.session.clone().close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); + self.session + .clone() + .close(crate::SessionError::ProtocolViolation.to_code(), &err.to_string()); return Err(err); } Err(_) => { @@ -3334,7 +3338,10 @@ mod serve_tests { } settle().await; } - assert!(advertised, "a token-bearing client must announce outside its connection grant"); + assert!( + advertised, + "a token-bearing client must announce outside its connection grant" + ); // Without a token, the same grant filters "cam": nothing is advertised. let origin2 = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); @@ -3359,7 +3366,11 @@ mod serve_tests { assert!(futures::poll!(run2.as_mut()).is_pending()); settle().await; } - assert_eq!(occurrences(&log2, b"cam"), 0, "a token-less client self-censors on its grant"); + assert_eq!( + occurrences(&log2, b"cam"), + 0, + "a token-less client self-censors on its grant" + ); } /// A Token structure value that decodes via `token::decode_value` (USE_VALUE, kind 300). @@ -3438,13 +3449,20 @@ mod serve_tests { // Drive until the acceptor has answered the initial token and the renewal. for _ in 0..500 { let _ = futures::poll!(acceptor.as_mut()); - assert!(futures::poll!(serving.as_mut()).is_pending(), "subscription ended during setup"); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "subscription ended during setup" + ); if answered.load(Ordering::Relaxed) >= 2 { break; } settle().await; } - assert_eq!(answered.load(Ordering::Relaxed), 2, "acceptor never answered both tokens"); + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); // Let the serve loop apply the renewal it read off the stream. for _ in 0..20 { @@ -3505,13 +3523,20 @@ mod serve_tests { for _ in 0..500 { let _ = futures::poll!(acceptor.as_mut()); - assert!(futures::poll!(serving.as_mut()).is_pending(), "subscription ended during setup"); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "subscription ended during setup" + ); if answered.load(Ordering::Relaxed) >= 2 { break; } settle().await; } - assert_eq!(answered.load(Ordering::Relaxed), 2, "acceptor never answered both tokens"); + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); for _ in 0..20 { let _ = futures::poll!(acceptor.as_mut()); @@ -3566,13 +3591,20 @@ mod serve_tests { for _ in 0..500 { let _ = futures::poll!(acceptor.as_mut()); - assert!(futures::poll!(serving.as_mut()).is_pending(), "subscription ended during setup"); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "subscription ended during setup" + ); if answered.load(Ordering::Relaxed) >= 2 { break; } settle().await; } - assert_eq!(answered.load(Ordering::Relaxed), 2, "acceptor never answered both tokens"); + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "acceptor never answered both tokens" + ); // Let the serve loop apply the refusal (which keeps the old grant). for _ in 0..20 { @@ -3656,7 +3688,10 @@ mod serve_tests { // Let the loop read the REQUEST_UPDATE and the acceptor pop both requests. for _ in 0..500 { let _ = futures::poll!(acceptor.as_mut()); - assert!(futures::poll!(serving.as_mut()).is_pending(), "the subscription ended during setup"); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the subscription ended during setup" + ); if popped.load(Ordering::Relaxed) >= 2 { break; } @@ -3689,8 +3724,14 @@ mod serve_tests { const VERSION: Version = Version::Draft18; let auth = crate::auth::Handle::new(false); let mut requests = auth.requests().unwrap(); - assert!(auth.allows(crate::auth::Direction::Publish, "room"), "None union is permissive"); - assert!(!auth.covers(crate::auth::Direction::Publish, "room"), "None union does not cover"); + assert!( + auth.allows(crate::auth::Direction::Publish, "room"), + "None union is permissive" + ); + assert!( + !auth.covers(crate::auth::Direction::Publish, "room"), + "None union does not cover" + ); let h = serve_with_auth(VERSION, auth, Vec::new()); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -3715,7 +3756,10 @@ mod serve_tests { let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); for _ in 0..500 { let _ = futures::poll!(acceptor.as_mut()); - assert!(futures::poll!(serving.as_mut()).is_pending(), "the subscription ended early"); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the subscription ended early" + ); if consulted.load(Ordering::Relaxed) >= 1 { break; } @@ -5286,7 +5330,11 @@ mod tests { } settle().await; } - assert_eq!(occurrences(&log, &token_bytes), 1, "the initial token rode the announce once"); + assert_eq!( + occurrences(&log, &token_bytes), + 1, + "the initial token rode the announce once" + ); // Setting the same value again wakes the loop but changes nothing, so no REQUEST_UPDATE. token.set(Some(token_bytes.clone())); diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 8680af1e31..26bc6b1840 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -1282,7 +1282,10 @@ mod tests { ) .await; - assert!(result.is_ok(), "a token-bearing client must not be closed by grant enforcement"); + assert!( + result.is_ok(), + "a token-bearing client must not be closed by grant enforcement" + ); assert!( log.closes().is_empty(), "the session must stay open for a token-bearing client" diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 2c3e98d6c9..18e3055699 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1095,8 +1095,13 @@ where return Err(err); } Err(_) => { - self.write_error(&mut stream, request_id, &Error::Unauthorized, "malformed authorization token") - .await?; + self.write_error( + &mut stream, + request_id, + &Error::Unauthorized, + "malformed authorization token", + ) + .await?; let _ = stream.writer.close().await; return Ok(()); } @@ -1126,7 +1131,8 @@ where } // UNAUTHORIZED for a refusal, NOT_SUPPORTED when no consumer verifies tokens. Err(err) => { - self.write_error(&mut stream, request_id, &err, &err.to_string()).await?; + self.write_error(&mut stream, request_id, &err, &err.to_string()) + .await?; let _ = stream.writer.close().await; return Ok(()); } @@ -1316,8 +1322,13 @@ where return Err(err); } Err(_) => { - self.write_error(stream, msg.request_id, &Error::Unauthorized, "malformed authorization token") - .await?; + self.write_error( + stream, + msg.request_id, + &Error::Unauthorized, + "malformed authorization token", + ) + .await?; continue; } }; @@ -1858,8 +1869,7 @@ where let subscription = request.subscription(); // The wire priority this subscription was opened at, re-sent unchanged on a // request-token renewal so the update carries the token without disturbing anything. - let subscriber_priority = - super::priority::to_wire(subscription.as_ref().map(|s| s.priority).unwrap_or(0)); + let subscriber_priority = super::priority::to_wire(subscription.as_ref().map(|s| s.priority).unwrap_or(0)); // A live join delivers nothing below the group SUBSCRIBE_OK names as Largest. let live = subscription.as_ref().and_then(|s| s.start).is_none(); let join = match subscribe_join( @@ -4359,7 +4369,10 @@ mod tests { } settle().await; } - assert!(sent, "a token-bearing client must subscribe outside its connection grant"); + assert!( + sent, + "a token-bearing client must subscribe outside its connection grant" + ); drop(subscription); drop(track); drop(consumer); @@ -5296,7 +5309,10 @@ mod tests { for _ in 0..500 { let _ = futures::poll!(acceptor.as_mut()); - assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended during setup"); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the announce ended during setup" + ); if answered.load(std::sync::atomic::Ordering::Relaxed) >= 2 { break; } @@ -5319,7 +5335,10 @@ mod tests { tokio::time::advance(std::time::Duration::from_secs(120)).await; for _ in 0..50 { let _ = futures::poll!(acceptor.as_mut()); - assert!(futures::poll!(run.as_mut()).is_pending(), "renewal did not extend the announce"); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "renewal did not extend the announce" + ); settle().await; } assert!( @@ -5435,14 +5454,20 @@ mod tests { let mut routed = false; for _ in 0..500 { let _ = futures::poll!(acceptor.as_mut()); - assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended during setup"); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the announce ended during setup" + ); if routed_now(&consumer, "room/alice").is_some() { routed = true; break; } settle().await; } - assert!(routed, "a token on a no-auth session must be verified and admitted, not ignored"); + assert!( + routed, + "a token on a no-auth session must be verified and admitted, not ignored" + ); assert!( consulted.load(std::sync::atomic::Ordering::Relaxed) >= 1, "the token must reach the acceptor, not be admitted by the permissive default" @@ -5493,12 +5518,8 @@ mod tests { let mut msg = token_publish_namespace(); msg.authorization_token = None; - let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( - stream, - msg, - cluster::Peer::default(), - None, - )); + let mut run = + std::pin::pin!(subscriber.run_publish_namespace_stream(stream, msg, cluster::Peer::default(), None,)); let mut routed = false; for _ in 0..500 { assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); @@ -5508,7 +5529,10 @@ mod tests { } settle().await; } - assert!(routed, "a token-less announce is admitted by the origin model as before"); + assert!( + routed, + "a token-less announce is admitted by the origin model as before" + ); } /// NAMESPACE has no REQUEST_UPDATE, so a peer reprices one by re-sending it on the diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index f88659aaf6..d5ddebbf87 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -438,7 +438,8 @@ impl Server { /// than the values it returns. /// /// Empty when no TLS-bearing backend is configured (e.g. a stream-only server). - pub fn certificates(&self) -> crate::tls::Certificates { #[cfg(feature = "noq")] + pub fn certificates(&self) -> crate::tls::Certificates { + #[cfg(feature = "noq")] if let Some(noq) = self.noq.as_ref() { return noq.certificates(); } From 0984b239f482329f876e665595c1c9fa84b37a62 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Thu, 1 Oct 2026 20:43:21 +0000 Subject: [PATCH 26/49] fix(net): adapt the request-token changes to the moved auth line Re-threading only, no behavior change, after rebasing onto upstream/quest/m1/auth/README (51c19e29d). Three sites moved under us: - `Issue::shared()` -> `kio::Shared::::default()`: the auth children made `Issue` a plain `#[derive(Default)]` struct with no `shared` ctor; the acceptor seam constructs the shared cell directly, as `Serving::new` does. - `Handle::covers` maps `Direction` to `Grant.publish` / `Grant.subscribe`: the union is a `Grant` with named pattern fields now, not a `patterns(dir)` method, mirroring `State::parts`. - thread `request_token` / `solicit` into the upstream session test's `ietf::session::Config` literal, which gained those required fields. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 12 ++++++------ rs/moq-net/src/ietf/session.rs | 29 +++++++++++++++-------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index ba140c767d..ef270230ea 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -359,7 +359,7 @@ impl Handle { ) -> RequestVerdict { match self.acceptor() { Some(queue) => { - let issue = Issue::shared(); + let issue = kio::Shared::::default(); // A closed queue (the app dropped its Requests) hands the request back, and // dropping it refuses the token with Unauthorized. let _ = queue.try_push(Request::new_request(token, token_kind, path, kind, issue.clone())); @@ -549,10 +549,10 @@ impl Handle { /// default. Only the token-bearing path uses this; the token-less path keeps `allows`. pub(crate) fn covers(&self, direction: Direction, path: &str) -> bool { let state = self.state.read(); - state - .union - .as_ref() - .is_some_and(|union| union.patterns(direction).matches(path)) + state.union.as_ref().is_some_and(|union| match direction { + Direction::Publish => union.publish.matches(path), + Direction::Subscribe => union.subscribe.matches(path), + }) } /// The peer turned out not to negotiate AUTH: fail every token as unsupported and @@ -1455,7 +1455,7 @@ mod request_token_tests { /// acceptor can tell it apart from a request token. #[test] fn a_session_token_has_no_request_context() { - let request = Request::new(Bytes::new(), Issue::shared()); + let request = Request::new(Bytes::new(), kio::Shared::::default()); assert_eq!(request.path(), None); assert_eq!(request.kind(), None); assert_eq!(request.token_kind(), None); diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 26bc6b1840..de19827586 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -328,20 +328,19 @@ where let declare_auth = auth.supported(); let declare_solicit = solicit; async move { - if let Err(err) = - run_setup( - runtime, - session, - version, - path, - authority, - self_origin, - cost, - declare_auth, - declare_solicit, - goaway, - ) - .await + if let Err(err) = run_setup( + runtime, + session, + version, + path, + authority, + self_origin, + cost, + declare_auth, + declare_solicit, + goaway, + ) + .await { tracing::warn!(%err, "setup send error"); } @@ -1581,6 +1580,8 @@ mod tests { peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), + request_token: crate::RequestToken::default(), + solicit: true, }) .expect("start the session"); From aca5238569341d357ba4cc1494937b2523a162aa Mon Sep 17 00:00:00 2001 From: sletmoe Date: Thu, 1 Oct 2026 23:33:05 +0000 Subject: [PATCH 27/49] fix(net): keep the announce grant filter when the token cannot ride A token-bearing client stood down both its announce self-censor and dialing-side enforce_grant whenever a request token was set. That is right only when the announce carries the token, which is when it rides its own PUBLISH_NAMESPACE request. A peer that requires MoQ Solicit gets inline NAMESPACE entries instead, which have no token slot, so a client holding a token for SUBSCRIBE could advertise outside its connection grant with nothing for the server to verify. The announce filter now stands down only for PUBLISH_NAMESPACE targets, and enforce_grant only when announces ride requests: draft-14/15, or a later draft whose peer does not require solicitation. Otherwise the connection grant bounds what is published, as without a token. Tests: a_request_token_does_not_lift_the_grant_on_inline_namespaces (publisher, inline answer to SUBSCRIBE_NAMESPACE) and a_request_token_does_not_lift_the_grant_when_announces_are_inline (enforce_grant against a solicit-requiring peer). Co-Authored-By: Claude --- rs/moq-net/src/ietf/publisher.rs | 77 ++++++++++++++++++++++++++-- rs/moq-net/src/ietf/session.rs | 86 +++++++++++++++++++++++++++----- 2 files changed, 145 insertions(+), 18 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index a6e60cbc76..4ce0414dcd 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1669,11 +1669,12 @@ where suffix: &crate::PathOwned, path: &crate::PathOwned, ) -> Result<(), Error> { - // A token-bearing client does not self-censor on its connection grant: a request token - // authorizes an announce the connection grant does not cover (MoQ request-token, quest - // Goal), and the server's covers-gate is the authority, refusing a bad token per request. - // Without a token this is unchanged: the connection grant filters as before. - let permitted = self.request_token.peek().is_some() || ns.permitted(path); + // A request token authorizes an announce the connection grant does not cover, but only + // when the announce carries it: a PUBLISH_NAMESPACE request does, an inline NAMESPACE + // entry has no slot for one. So the grant still filters inline entries, and stands down + // only for requests, where the server's covers-gate refuses a bad token per request. + let carries_token = matches!(ns.target, Target::Requests(_)) && self.request_token.peek().is_some(); + let permitted = carries_token || ns.permitted(path); let Namespaces { peer, target, @@ -3373,6 +3374,72 @@ mod serve_tests { ); } + /// A peer that requires solicitation gets inline NAMESPACE entries, which have no slot for a + /// request token, so the token cannot authorize them: the connection grant still filters the + /// answer to its SUBSCRIBE_NAMESPACE. + #[tokio::test] + async fn a_request_token_does_not_lift_the_grant_on_inline_namespaces() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let _other = origin.announce("other/mic", crate::origin::Route::default()).unwrap(); + settle().await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(true), + ..Default::default() + }); + let auth = crate::auth::Handle::new(true); + let _cred = auth.present(bytes::Bytes::from_static(b"cred"), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("other").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + let publisher = Publisher::new( + crate::time::Clock::tokio(), + session.clone(), + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(token.clone()))); + + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let msg = ietf::SubscribeNamespace { + request_id: RequestId(1), + namespace: crate::Path::new(""), + hidden: false, + }; + let mut run = std::pin::pin!(publisher.run_subscribe_namespace_stream(stream, msg)); + let mut covered = false; + for _ in 0..200 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, b"mic") >= 1 { + covered = true; + break; + } + settle().await; + } + assert!(covered, "a namespace the grant covers is advertised inline"); + for _ in 0..50 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 0, "the grant still filters inline entries"); + assert_eq!(occurrences(&log, &token), 0, "an inline entry carries no token"); + } + /// A Token structure value that decodes via `token::decode_value` (USE_VALUE, kind 300). fn request_token() -> bytes::Bytes { bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff]) diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index de19827586..74912e2073 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -357,9 +357,10 @@ where let origin = publish.clone(); let session = session.clone(); let request_token = request_token.clone(); + let peer_setup = peer_setup.clone(); async move { match client { - true => enforce_grant(auth, origin, session, request_token).await, + true => enforce_grant(auth, origin, session, request_token, peer_setup, version).await, false => std::future::pending().await, } } @@ -1083,13 +1084,15 @@ async fn enforce_grant( origin: origin::Consumer, mut session: S, request_token: crate::RequestToken, + peer_setup: peer::PeerSetup, + version: Version, ) -> Result<(), Error> { - // A client that presents a request token authorizes each of its own requests at the server - // per-request (the covers-gate plus the app's acceptor), so its connection grant does not - // bound them: the token is precisely how it publishes outside that grant. Enforcing the - // grant here would close the client for exactly the announce the token was meant to carry, - // before the server ever saw it. The server refuses a bad token per request instead. - if request_token.peek().is_some() { + // A request token authorizes an announce outside the connection grant only when the + // announce carries it, which is when it rides its own PUBLISH_NAMESPACE request: always on + // draft-14/15, and on later drafts unless the peer requires solicitation, which turns every + // advertisement into an inline NAMESPACE entry with no token slot. Then the grant still + // bounds what we publish. + if request_token.peek().is_some() && announces_carry_token(&peer_setup, version).await { return Ok(()); } let mut announced = origin.announced(); @@ -1106,6 +1109,16 @@ async fn enforce_grant( Err(err) } +/// Whether our announces will ride their own PUBLISH_NAMESPACE requests, the only form that +/// carries a request token, rather than inline NAMESPACE entries. Draft-14/15 predate +/// NAMESPACE; later drafts send requests unless the peer requires solicitation. +async fn announces_carry_token(peer_setup: &peer::PeerSetup, version: Version) -> bool { + match version { + Version::Draft14 | Version::Draft15 => true, + _ => !peer_setup.get().await.solicit.unwrap_or(false), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1259,12 +1272,10 @@ mod tests { /// parks again; a busy machine cannot turn a slow announce into a passing silence. const ANNOUNCE_TURNS: usize = 100; - /// A client that presents a request token authorizes each of its own requests at the - /// server per-request (the covers-gate plus the app's acceptor), so its connection grant - /// does not bound them. Dialing-side grant enforcement must therefore stand down when a - /// request token is set: enforcing it would close the client for announcing outside the - /// connection grant the token was meant to extend, before the server ever saw the token. - /// The connection grant here is irrelevant precisely because the token short-circuits it. + /// A client that presents a request token authorizes each announce at the server when the + /// announce carries the token, so dialing-side grant enforcement stands down for a peer + /// that takes unsolicited PUBLISH_NAMESPACE requests: enforcing it would close the client + /// for announcing outside the connection grant the token was meant to extend. #[tokio::test] async fn a_client_may_send_a_token_bearing_request_its_connection_grant_does_not_cover() { let auth = crate::auth::Handle::new(true); @@ -1272,12 +1283,19 @@ mod tests { let _cam = origin.announce("room/alice", crate::origin::Route::default()).unwrap(); let session = crate::lite::test_transport::SinkSession::new(Default::default()); let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(false), + ..Default::default() + }); let result = enforce_grant( auth, origin.consume(), session, crate::RequestToken::new(Some(bytes::Bytes::from_static(b"jwt"))), + peer_setup, + Version::Draft18, ) .await; @@ -1291,6 +1309,48 @@ mod tests { ); } + /// When the peer requires solicitation, every advertisement is an inline NAMESPACE entry, + /// which has no slot for a request token. A token set for SUBSCRIBE must not let the client + /// advertise outside its connection grant there: the grant is still enforced. + #[tokio::test] + async fn a_request_token_does_not_lift_the_grant_when_announces_are_inline() { + let auth = crate::auth::Handle::new(true); + let setup = auth.present(bytes::Bytes::new(), true).unwrap(); + auth.granted( + 0, + crate::auth::Grant { + publish: crate::Pattern::subtree("room/alice").unwrap().into(), + subscribe: crate::Patterns::new(), + expires: None, + }, + ); + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin + .announce("room/bob/cam", crate::origin::Route::default()) + .unwrap(); + let session = crate::lite::test_transport::SinkSession::new(Default::default()); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer { + solicit: Some(true), + ..Default::default() + }); + + let result = enforce_grant( + auth, + origin.consume(), + session, + crate::RequestToken::new(Some(bytes::Bytes::from_static(b"jwt"))), + peer_setup, + Version::Draft18, + ) + .await; + + assert!(matches!(result, Err(Error::Unauthorized)), "{result:?}"); + assert_eq!(log.closes().len(), 1, "the session closes on the uncovered announce"); + drop(setup); + } + /// Run a publish-only session against a peer that declared `peer_declared`, returning /// how many times the namespace reached the wire. /// From 742b5f0fc1d0b2b717b68a3ce050dee42369fa06 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Thu, 1 Oct 2026 23:41:29 +0000 Subject: [PATCH 28/49] fix(net): keep request-token bytes out of message Debug output Subscribe, SubscribeUpdate, PublishNamespace and PublishNamespaceUpdate derived Debug over their authorization_token field, and several paths log those messages with `message = ?msg`, so a presented credential could reach the logs verbatim. Their Debug now shows the token's length only, through a shared Redacted wrapper, which covers every existing and future log site of these messages at once. Test: debug_never_shows_the_token_bytes. Co-Authored-By: Claude --- rs/moq-net/src/ietf/publish_namespace.rs | 32 +++++++++++++++++-- rs/moq-net/src/ietf/subscribe.rs | 40 ++++++++++++++++++++++-- rs/moq-net/src/ietf/token.rs | 40 ++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/rs/moq-net/src/ietf/publish_namespace.rs b/rs/moq-net/src/ietf/publish_namespace.rs index 5642c7c1c1..eadb66caf1 100644 --- a/rs/moq-net/src/ietf/publish_namespace.rs +++ b/rs/moq-net/src/ietf/publish_namespace.rs @@ -12,7 +12,7 @@ use super::Version; /// PublishNamespace message (0x06) /// Sent by the publisher to announce the availability of a namespace. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct PublishNamespace<'a> { pub request_id: RequestId, pub track_namespace: Path<'a>, @@ -29,6 +29,20 @@ pub struct PublishNamespace<'a> { pub authorization_token: Option, } +impl std::fmt::Debug for PublishNamespace<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PublishNamespace") + .field("request_id", &self.request_id) + .field("track_namespace", &self.track_namespace) + .field("cluster", &self.cluster) + .field( + "authorization_token", + &super::token::Redacted(&self.authorization_token), + ) + .finish() + } +} + impl PublishNamespace<'_> { /// Decode the message body, expecting the cluster parameters when the session /// negotiated the extension. @@ -133,7 +147,7 @@ fn decode_request_params( /// An omitted parameter keeps its value (moq-transport Section 9.5), so a cost that /// dropped to 0 is sent as an explicit 0, unlike the advertisement itself where absent /// means 0. Draft-17+ only: the extension negotiates on nothing earlier. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub struct PublishNamespaceUpdate { /// The update's own Request ID; every REQUEST_UPDATE consumes one. pub request_id: RequestId, @@ -147,6 +161,20 @@ pub struct PublishNamespaceUpdate { pub authorization_token: Option, } +impl std::fmt::Debug for PublishNamespaceUpdate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PublishNamespaceUpdate") + .field("request_id", &self.request_id) + .field("hops", &self.hops) + .field("cost", &self.cost) + .field( + "authorization_token", + &super::token::Redacted(&self.authorization_token), + ) + .finish() + } +} + impl PublishNamespaceUpdate { /// The update that moves a peer holding `held` to `next`: only what changed. pub fn between(request_id: RequestId, held: &cluster::Advert, next: &cluster::Advert) -> Self { diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index 9f94990f79..0f34627ace 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -40,7 +40,7 @@ impl Param for IncludeProperties { /// Subscribe message (0x03) /// Sent by the subscriber to request all future objects for the given track. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct Subscribe<'a> { pub request_id: RequestId, pub track_namespace: Path<'a>, @@ -60,6 +60,25 @@ pub struct Subscribe<'a> { pub authorization_token: Option, } +impl std::fmt::Debug for Subscribe<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Subscribe") + .field("request_id", &self.request_id) + .field("track_namespace", &self.track_namespace) + .field("track_name", &self.track_name) + .field("subscriber_priority", &self.subscriber_priority) + .field("group_order", &self.group_order) + .field("filter", &self.filter) + .field("fill", &self.fill) + .field("properties_wanted", &self.properties_wanted) + .field( + "authorization_token", + &super::token::Redacted(&self.authorization_token), + ) + .finish() + } +} + impl Message for Subscribe<'_> { const ID: u64 = 0x03; @@ -385,7 +404,7 @@ impl Message for Unsubscribe { } /// SubscribeUpdate message (0x02) -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct SubscribeUpdate { pub request_id: RequestId, pub subscription_request_id: Option, @@ -399,6 +418,23 @@ pub struct SubscribeUpdate { pub authorization_token: Option, } +impl std::fmt::Debug for SubscribeUpdate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubscribeUpdate") + .field("request_id", &self.request_id) + .field("subscription_request_id", &self.subscription_request_id) + .field("start_location", &self.start_location) + .field("end_group", &self.end_group) + .field("subscriber_priority", &self.subscriber_priority) + .field("forward", &self.forward) + .field( + "authorization_token", + &super::token::Redacted(&self.authorization_token), + ) + .finish() + } +} + impl Message for SubscribeUpdate { const ID: u64 = 0x02; diff --git a/rs/moq-net/src/ietf/token.rs b/rs/moq-net/src/ietf/token.rs index c0ec380048..36b997244e 100644 --- a/rs/moq-net/src/ietf/token.rs +++ b/rs/moq-net/src/ietf/token.rs @@ -74,6 +74,46 @@ fn decode(mut buf: &[u8], version: Version) -> Result { }) } +/// Debug for a request-borne `AUTHORIZATION TOKEN` field that shows its length, never its +/// bytes, so a credential cannot reach the logs through a message's `Debug`. +pub(super) struct Redacted<'a>(pub &'a Option); + +impl std::fmt::Debug for Redacted<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0 { + Some(token) => write!(f, "Some(<{} bytes>)", token.len()), + None => f.write_str("None"), + } + } +} + +#[cfg(test)] +mod redacted_tests { + use super::super::{PublishNamespace, PublishNamespaceUpdate, RequestId}; + + /// A request-borne credential shows only its length in a message's `Debug`. + #[test] + fn debug_never_shows_the_token_bytes() { + let secret = bytes::Bytes::from_static(b"s3cr3t-jwt"); + let announce = PublishNamespace { + request_id: RequestId(1), + track_namespace: crate::Path::new("room"), + cluster: None, + authorization_token: Some(secret.clone()), + }; + let update = PublishNamespaceUpdate { + request_id: RequestId(2), + hops: None, + cost: None, + authorization_token: Some(secret), + }; + for debug in [format!("{announce:?}"), format!("{update:?}")] { + assert!(!debug.contains("s3cr3t"), "{debug}"); + assert!(debug.contains("<10 bytes>"), "{debug}"); + } + } +} + #[cfg(test)] mod tests { use super::*; From 15a502ed80591abea595a585fc81d37a7cfb524a Mon Sep 17 00:00:00 2001 From: sletmoe Date: Thu, 1 Oct 2026 23:42:07 +0000 Subject: [PATCH 29/49] fix(net): send a reprice without the unchanged request token A token-bearing client attached its current request token to every cluster reprice (PUBLISH_NAMESPACE_UPDATE). The receiver treats a token-bearing update on a token-authorized announce as a renewal and answers it as one, skipping the cluster apply, so the HOP_PATH and ROUTE_COST riding a reprice were dropped even though the sender got REQUEST_OK. A reprice now carries only what changed; a changed token already goes out on its own token-only update from the refresh loop. The receiver comment states the sender contract it relies on. Test: a_reprice_does_not_carry_the_request_token. Co-Authored-By: Claude --- rs/moq-net/src/ietf/publisher.rs | 67 +++++++++++++++++++++++++++++-- rs/moq-net/src/ietf/subscriber.rs | 4 +- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 4ce0414dcd..decab0c4ec 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1893,9 +1893,7 @@ where return Ok(Refused::No); }; let request_id = self.control.next_request_id(&self.runtime).await?; - let mut update = ietf::PublishNamespaceUpdate::between(request_id, &held, &next); - // The credential rides the update too, so a reprice also refreshes the request token. - update.authorization_token = self.request_token.peek(); + let update = ietf::PublishNamespaceUpdate::between(request_id, &held, &next); request.stream.writer.encode(&ietf::PublishNamespaceUpdate::ID).await?; request.stream.writer.encode(&update).await?; @@ -5877,6 +5875,69 @@ mod tests { assert_eq!(log.bi_opens(), 1, "the update rode the request's own stream"); } + /// A token-bearing client's reprice carries only the cluster change, never the unchanged + /// token: a receiver renewing a token-authorized announce answers a token-bearing update as + /// a renewal, which would drop the HOP_PATH / ROUTE_COST riding it. + #[tokio::test] + async fn a_reprice_does_not_carry_the_request_token() { + const VERSION: Version = Version::Draft19; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cold = origin + .announce("cam", crate::origin::Route::default().with_cost(4)) + .unwrap(); + settle().await; + + let ok = publish_namespace_ok(VERSION).await; + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![[ok.clone(), ok].concat()]); + let log = session.log.clone(); + + let publisher = Publisher::new( + crate::time::Clock::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + clustered(Some(false)), + VERSION, + ) + .with_request_token(crate::RequestToken::new(Some(token.clone()))); + + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &token) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &token), 1, "the token rides the PUBLISH_NAMESPACE"); + + let _warm = origin + .announce("cam", crate::origin::Route::default().with_cost(0)) + .unwrap(); + let expected = request_update( + VERSION, + &ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + hops: None, + cost: Some(0), + authorization_token: None, + }, + ) + .await; + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + if occurrences(&log, &expected) >= 1 { + break; + } + settle().await; + } + assert_eq!(occurrences(&log, &expected), 1, "the reprice is token-free"); + assert_eq!(occurrences(&log, &token), 1, "the token was not re-sent on the reprice"); + } + /// A route from a different original publisher updates the advertisement in place, /// like any other change: withdrawing it would make the namespace briefly vanish /// downstream just because its publisher moved. diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 18e3055699..16a272acb5 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1306,8 +1306,8 @@ where // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant // (MoQ request-token), when the announce is token-authorized. The verify is not - // awaited here: it becomes the pending renewal raced against the deadline above. A - // cluster reprice never carries a token. + // awaited here: it becomes the pending renewal raced against the deadline above. + // Our sender keeps renewals token-only, so a reprice never rides one. if let Some(token) = &msg.authorization_token && token_grant.is_some() { From b757d4ce3534a8abb4590c5fc7788e07f2b21d52 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Thu, 1 Oct 2026 23:42:33 +0000 Subject: [PATCH 30/49] fix(net): decide pre-draft-17 subscribe renewals silently On draft-14/15/16 a SUBSCRIBE_UPDATE shares the control stream, and the publisher answered a renewal with REQUEST_OK / SUBSCRIBE_ERROR / REQUEST_ERROR carrying the update's own Request ID. The peer's control-stream adapter routes those answers by Request ID, and the update's ID was never registered (the update is a follow-up to the subscription), so they reached nothing; on draft-14 a SUBSCRIBE_ERROR would also read as ending the subscription. Draft-14 already sent no accept answer. Renewals before draft-17 are now decided without an answer: an accepted one re-arms the grant, a refused one keeps the old grant until it lapses, as before. Draft-17+ renewals ride their own request stream and are still answered. Test: a_subscribe_update_renews_silently_before_draft_17, at 14, 15 and 16, asserting the renewal extends the subscription and writes nothing. Co-Authored-By: Claude --- rs/moq-net/src/ietf/publisher.rs | 90 ++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 40 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index decab0c4ec..10aedb9f74 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -831,17 +831,26 @@ where let Some(rg) = request_grant.as_mut() else { continue; }; + // Before draft-17 the update shares the control stream, where an + // answer keyed by the update's Request ID reaches nothing the peer + // tracks (and draft-14's SUBSCRIBE_ERROR would read as ending the + // subscription), so the renewal is decided silently there. + let answer = + !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16); match res { // The renewal's grant must still cover this request. On accept the // old grant is dropped (ending the old token) and the deadline is // re-armed at the new expiry. Ok(grant) if grant.publish.matches(msg.track_namespace.as_str()) => { rg.renew(verdict, grant); - self.write_request_ok(&mut stream.writer, rid).await?; + if answer { + self.write_request_ok(&mut stream.writer).await?; + } } // A refused or uncovered renewal keeps the old grant (per the quest, // the request ends only when that lapses) and answers UNAUTHORIZED so // the peer can retry before then. + _ if !answer => {} _ => { self.write_subscribe_error( &mut stream.writer, @@ -1043,28 +1052,10 @@ where Ok(()) } - /// Acknowledge an accepted REQUEST_UPDATE on the subscribe stream. Draft-14 predates - /// REQUEST_OK and gives SUBSCRIBE_UPDATE no response, so the renewal is silent there. - async fn write_request_ok( - &self, - writer: &mut Writer, - request_id: RequestId, - ) -> Result<(), Error> { - match self.version { - Version::Draft14 => {} - Version::Draft15 | Version::Draft16 => { - writer.encode(&ietf::RequestOk::ID).await?; - writer - .encode(&ietf::RequestOk { - request_id: Some(request_id), - }) - .await?; - } - _ => { - writer.encode(&ietf::RequestOk::ID).await?; - writer.encode(&ietf::RequestOk { request_id: None }).await?; - } - } + /// Acknowledge an accepted REQUEST_UPDATE on its draft-17+ subscribe stream. + async fn write_request_ok(&self, writer: &mut Writer) -> Result<(), Error> { + writer.encode(&ietf::RequestOk::ID).await?; + writer.encode(&ietf::RequestOk { request_id: None }).await?; Ok(()) } @@ -3549,17 +3540,22 @@ mod serve_tests { } } - /// The draft-14 mirror of the d18 renewal test (B2): a SUBSCRIBE_UPDATE carrying a fresh token - /// renews a token-authorized subscription past the old grant's expiry at draft-14, where the - /// update rides the control-stream adapter. This exercises `run_subscribe_stream`'s d14 decode - /// and renew; the adapter's follow-up routing to the subscription's stream is proven by + /// The pre-draft-17 mirror of the renewal test: a SUBSCRIBE_UPDATE carrying a fresh token + /// renews a token-authorized subscription past the old grant's expiry at draft-14/15/16, + /// where the update rides the control-stream adapter. No answer is written there: one keyed + /// by the update's Request ID reaches nothing the peer routes. The adapter's follow-up + /// routing to the subscription's stream is proven by /// `super::super::adapter::tests::test_classify_subscribe_update_followup`. #[tokio::test(start_paused = true)] - async fn a_subscribe_update_renews_at_draft_14() { - const VERSION: Version = Version::Draft14; + async fn a_subscribe_update_renews_silently_before_draft_17() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + renews_silently(version).await; + } + } + async fn renews_silently(version: Version) { let (auth, mut requests, _cred) = auth_covering_other(); - let h = serve_with_auth(VERSION, auth, subscribe_update_with_token(VERSION).await); + let h = serve_with_auth(version, auth, subscribe_update_with_token(version).await); let rt = h.publisher.runtime.clone(); let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); @@ -3567,17 +3563,22 @@ mod serve_tests { group.finish().unwrap(); settle().await; - let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + // The renewal is held until the setup writes have settled, so anything written after + // it is released is the renewal's answer. + let release = std::sync::Arc::new(tokio::sync::Notify::new()); let answered = std::sync::Arc::new(AtomicU64::new(0)); let acceptor = { let answered = answered.clone(); + let release = release.clone(); async move { let mut held = Vec::new(); let first = requests.next().await.unwrap(); held.push(first.accept(grant_all_expiring(&rt, Some(60)))); answered.fetch_add(1, Ordering::Relaxed); let renewal = requests.next().await.unwrap(); + release.notified().await; held.push(renewal.accept(grant_all_expiring(&rt, None))); answered.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await @@ -3586,35 +3587,44 @@ mod serve_tests { let mut acceptor = std::pin::pin!(acceptor); let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); - for _ in 0..500 { + for _ in 0..200 { let _ = futures::poll!(acceptor.as_mut()); assert!( futures::poll!(serving.as_mut()).is_pending(), - "subscription ended during setup" + "{version:?}: ended during setup" ); - if answered.load(Ordering::Relaxed) >= 2 { - break; - } settle().await; } assert_eq!( answered.load(Ordering::Relaxed), - 2, - "acceptor never answered both tokens" + 1, + "{version:?}: the first token was answered" ); + let before = h.log.writes.lock().unwrap().len(); - for _ in 0..20 { + release.notify_one(); + for _ in 0..200 { let _ = futures::poll!(acceptor.as_mut()); assert!(futures::poll!(serving.as_mut()).is_pending()); settle().await; } + assert_eq!( + answered.load(Ordering::Relaxed), + 2, + "{version:?}: the renewal was answered" + ); + assert_eq!( + h.log.writes.lock().unwrap().len(), + before, + "{version:?}: a renewal before draft-17 writes no answer" + ); tokio::time::advance(Duration::from_secs(120)).await; for _ in 0..50 { let _ = futures::poll!(acceptor.as_mut()); assert!( futures::poll!(serving.as_mut()).is_pending(), - "the renewal did not extend the subscription past the old expiry" + "{version:?}: the renewal did not extend the subscription past the old expiry" ); settle().await; } From bfa458cfa3402cc3a17e0452c89265045c1924ec Mon Sep 17 00:00:00 2001 From: sletmoe Date: Thu, 1 Oct 2026 23:55:54 +0000 Subject: [PATCH 31/49] fix(net): scope a request grant to the presenter and its request A request token's grant is the presenting peer's permissions: the acceptor's Grant goes back unchanged, so its publish patterns name what that peer may publish and its subscribe patterns what it may read. The SUBSCRIBE path checked grant.publish and the PUBLISH_NAMESPACE path grant.subscribe, inverted on both the initial check and the renewal, so a publish-only credential gained read access and a subscribe-only one was refused. RequestKind::covers now reads the field the request needs, and both verticals use it. The request guard also forgot what it was guarding. An acceptor-side Issued::update replaced the stored grant and deadline without rechecking it, so updating to a grant that no longer covered the request (one with no expiry, say) left it running indefinitely. RequestGrant now keeps the request's path and kind, ends the request when a replacement stops covering it, and answers renewals with the same check. Tests: request_coverage_is_from_the_presenters_side, an_update_that_stops_covering_ends_the_request, a_subscribe_token_needs_a_subscribe_grant and an_announce_token_needs_a_publish_grant (read-only and write-only grants on each side). Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 108 +++++++++++++++++++++++++++--- rs/moq-net/src/ietf/publisher.rs | 91 +++++++++++++++++++++++-- rs/moq-net/src/ietf/subscriber.rs | 77 ++++++++++++++++++--- 3 files changed, 248 insertions(+), 28 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index ef270230ea..204eac2dd1 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -835,6 +835,20 @@ pub enum RequestKind { TrackStatus, } +impl RequestKind { + /// Whether `grant`, issued to the peer that presented the token, covers this request at + /// `path`. A grant names what its holder may do, so a request to read is covered by its + /// `subscribe` patterns and a request to announce or publish by its `publish` patterns. + pub(crate) fn covers(self, grant: &Grant, path: &str) -> bool { + match self { + Self::Subscribe | Self::Fetch | Self::SubscribeNamespace | Self::TrackStatus => { + grant.subscribe.matches(path) + } + Self::Publish | Self::PublishNamespace => grant.publish.matches(path), + } + } +} + /// The request a token rode on, kept beside the token so the acceptor can scope its /// grant to that one request. struct RequestContext { @@ -1027,21 +1041,37 @@ pub(crate) struct RequestGrant { verdict: RequestVerdict, grant: Grant, deadline: crate::runtime::Deadline, + /// The request this grant must keep covering: a replacement that no longer does ends it. + path: crate::PathOwned, + kind: RequestKind, } impl RequestGrant { /// Hold `grant` (the acceptor's first answer, already awaited) for the request's life, /// armed to lapse at its expiry. - pub(crate) fn new(runtime: &R, verdict: RequestVerdict, grant: Grant) -> Self { + pub(crate) fn new( + runtime: &R, + verdict: RequestVerdict, + grant: Grant, + path: crate::PathOwned, + kind: RequestKind, + ) -> Self { let mut deadline = crate::runtime::Deadline::new(runtime); deadline.set(grant.expires); Self { verdict, grant, deadline, + path, + kind, } } + /// Whether `grant` covers the request this guard holds. + pub(crate) fn covers(&self, grant: &Grant) -> bool { + self.kind.covers(grant, self.path.as_str()) + } + /// The grant in force right now. Observed by the lifecycle tests; the reader checks a /// renewal's coverage on the freshly awaited grant before it calls [`renew`](Self::renew). #[cfg_attr(not(test), expect(dead_code))] @@ -1060,9 +1090,10 @@ impl RequestGrant { /// Resolve with the error that ends the request: the deadline lapsing /// ([`Error::Unauthorized`]; [`Error::Expired`] once the code split lands), or the - /// acceptor revoking or dropping the grant (its code). An acceptor-side update (a - /// replacement grant on the same token, e.g. a lowered expiry) is folded in and polling - /// continues. Never resolves while the grant still stands. + /// acceptor revoking or dropping the grant (its code), or an acceptor-side update (a + /// replacement grant on the same token) that no longer covers the request + /// ([`Error::Unauthorized`]). A covering update, such as a lowered expiry, is folded in and + /// polling continues. Never resolves while the grant still stands. pub(crate) fn poll_ended(&mut self, waiter: &kio::Waiter) -> Poll { loop { if self.deadline.poll(waiter).is_ready() { @@ -1070,6 +1101,9 @@ impl RequestGrant { } match self.verdict.poll_reply(waiter) { Poll::Ready(Reply::Grant(grant)) => { + if !self.covers(&grant) { + return Poll::Ready(Error::Unauthorized); + } self.deadline.set(grant.expires); self.grant = grant; // Re-poll: the new deadline may already have lapsed, or another reply may @@ -1465,8 +1499,8 @@ mod request_token_tests { fn grant_in(runtime: &crate::time::Clock, secs: u64) -> Grant { Grant { - publish: crate::Pattern::all().into(), - subscribe: Patterns::new(), + publish: Patterns::new(), + subscribe: crate::Pattern::all().into(), expires: crate::runtime::Timers::now(runtime).checked_add(Duration::from_secs(secs)), } } @@ -1482,7 +1516,7 @@ mod request_token_tests { let expires = grant.expires; let _issued = requests.next().await.unwrap().accept(grant); let answered = verdict.grant().await.unwrap(); - let request_grant = RequestGrant::new(&runtime, verdict, answered); + let request_grant = RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); assert_eq!(request_grant.grant().expires, expires); assert!(expires.is_some()); } @@ -1499,7 +1533,8 @@ mod request_token_tests { let first_expires = first.expires; let _issued = requests.next().await.unwrap().accept(first); let answered = verdict.grant().await.unwrap(); - let mut request_grant = RequestGrant::new(&runtime, verdict, answered); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); // A REQUEST_UPDATE carrying a fresh token the acceptor accepts with a later expiry. let renewal = handle.verify_request(Bytes::from_static(b"jwt2"), 0, subscribe_path(), RequestKind::Subscribe); @@ -1524,7 +1559,8 @@ mod request_token_tests { // Held for the request's life, so only the deadline (not a drop) ends it. let _issued = requests.next().await.unwrap().accept(grant_in(&runtime, 60)); let answered = verdict.grant().await.unwrap(); - let mut request_grant = RequestGrant::new(&runtime, verdict, answered); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; assert!(matches!(err, Error::Unauthorized), "{err:?}"); @@ -1543,7 +1579,8 @@ mod request_token_tests { let first_expires = first.expires; let _issued = requests.next().await.unwrap().accept(first); let answered = verdict.grant().await.unwrap(); - let mut request_grant = RequestGrant::new(&runtime, verdict, answered); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); // The renewal token is refused: verify it resolves to a refusal, and the caller does // NOT renew. The old grant is untouched. @@ -1565,6 +1602,54 @@ mod request_token_tests { assert!(matches!(err, Error::Unauthorized), "{err:?}"); } + /// An acceptor-side update that no longer covers the request ends it, even when the + /// replacement has no expiry to lapse at; one that still covers it is folded in. + #[tokio::test(start_paused = true)] + async fn an_update_that_stops_covering_ends_the_request() { + let runtime = crate::time::Clock::tokio(); + let handle = Handle::new(true); + let mut requests = handle.requests().unwrap(); + let verdict = handle.verify_request(Bytes::from_static(b"jwt"), 0, subscribe_path(), RequestKind::Subscribe); + let issued = requests.next().await.unwrap().accept(Grant::all()); + let answered = verdict.grant().await.unwrap(); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); + + // A covering update (everything, still unexpiring) leaves the request standing. + issued.update(Grant::all()); + let pending = kio::wait(|waiter| Poll::Ready(request_grant.poll_ended(waiter).is_pending())).await; + assert!(pending, "a covering update keeps the request"); + + issued.update(Grant::default()); + let err = tokio::time::timeout( + Duration::from_secs(1), + kio::wait(|waiter| request_grant.poll_ended(waiter)), + ) + .await + .expect("an uncovering update must end the request"); + assert!(matches!(err, Error::Unauthorized), "{err:?}"); + } + + /// The grant is the presenting peer's: a read is covered by `subscribe`, an announce by + /// `publish`, never the other way around. + #[test] + fn request_coverage_is_from_the_presenters_side() { + let read_only = Grant { + publish: Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: None, + }; + let write_only = Grant { + publish: crate::Pattern::all().into(), + subscribe: Patterns::new(), + expires: None, + }; + assert!(RequestKind::Subscribe.covers(&read_only, "room")); + assert!(!RequestKind::Subscribe.covers(&write_only, "room")); + assert!(RequestKind::PublishNamespace.covers(&write_only, "room")); + assert!(!RequestKind::PublishNamespace.covers(&read_only, "room")); + } + /// An acceptor-side revoke ends the request before the deadline, and does not close the /// session. #[tokio::test(start_paused = true)] @@ -1576,7 +1661,8 @@ mod request_token_tests { // A grant that never expires, so only the revoke can end the request. let issued = requests.next().await.unwrap().accept(Grant::all()); let answered = verdict.grant().await.unwrap(); - let mut request_grant = RequestGrant::new(&runtime, verdict, answered); + let mut request_grant = + RequestGrant::new(&runtime, verdict, answered, subscribe_path(), RequestKind::Subscribe); issued.revoke(SessionError::Unauthorized, "revoked"); let err = kio::wait(|waiter| request_grant.poll_ended(waiter)).await; diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 10aedb9f74..6fbf2dd1a2 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -575,10 +575,17 @@ where match verdict.grant().await { // The token's grant must cover this exact request; it authorizes nothing // else and never joins the session union. - Ok(grant) if grant.publish.matches(msg.track_namespace.as_str()) => { + Ok(grant) if crate::auth::RequestKind::Subscribe.covers(&grant, msg.track_namespace.as_str()) => { // Held for the subscription's life: the request ends when this grant - // lapses or is revoked (RequestGrant::poll_ended below), never the session. - request_grant = Some(crate::auth::RequestGrant::new(&self.runtime, verdict, grant)); + // lapses, is revoked, or stops covering it (RequestGrant::poll_ended + // below), never the session. + request_grant = Some(crate::auth::RequestGrant::new( + &self.runtime, + verdict, + grant, + msg.track_namespace.to_owned(), + crate::auth::RequestKind::Subscribe, + )); } Ok(_) => { let err = Error::Unauthorized; @@ -841,7 +848,7 @@ where // The renewal's grant must still cover this request. On accept the // old grant is dropped (ending the old token) and the deadline is // re-armed at the new expiry. - Ok(grant) if grant.publish.matches(msg.track_namespace.as_str()) => { + Ok(grant) if rg.covers(&grant) => { rg.renew(verdict, grant); if answer { self.write_request_ok(&mut stream.writer).await?; @@ -3239,11 +3246,11 @@ mod serve_tests { } } - /// A grant of everything, lapsing in `secs` (or never), on the publisher's clock. + /// A grant to subscribe to everything, lapsing in `secs` (or never), on the publisher's clock. fn grant_all_expiring(runtime: &crate::time::Clock, secs: Option) -> crate::auth::Grant { crate::auth::Grant { - publish: crate::Pattern::all().into(), - subscribe: crate::Patterns::new(), + publish: crate::Patterns::new(), + subscribe: crate::Pattern::all().into(), expires: secs.map(|s| { crate::runtime::Timers::now(runtime) .checked_add(Duration::from_secs(s)) @@ -3846,6 +3853,76 @@ mod serve_tests { ); } + /// A token-bearing SUBSCRIBE being served on a no-AUTH session whose acceptor answers every + /// token with `grant`, after `limit` (if any) narrowed what the peer may do. + type Serving = std::pin::Pin>>>; + + async fn serve_token_subscribe( + grant: crate::auth::Grant, + limit: Option, + ) -> (crate::auth::Handle, Serve, Serving) { + const VERSION: Version = Version::Draft18; + let auth = crate::auth::Handle::new(false); + let mut requests = auth.requests().unwrap(); + if let Some(limit) = &limit { + auth.authorize(limit); + } + let h = serve_with_auth(VERSION, auth.clone(), Vec::new()); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let serving = h.publisher.clone().run_subscribe_stream(stream, token_subscribe()); + let serving: Serving = Box::pin(async move { + let acceptor = async move { + let mut held = Vec::new(); + while let Some(request) = requests.next().await { + held.push(request.accept(grant.clone())); + } + std::future::pending::>().await + }; + tokio::select! { + res = acceptor => res, + res = serving => res, + } + }); + (auth, h, serving) + } + + /// Whether `serving` is still running after the acceptor and the serve loop have had their + /// turns. + async fn still_serving(serving: &mut Serving) -> bool { + for _ in 0..300 { + if futures::poll!(serving.as_mut()).is_ready() { + return false; + } + settle().await; + } + true + } + + /// A subscriber's token grant is checked on its `subscribe` patterns: a write-only grant + /// does not admit a SUBSCRIBE, a read-only one does. + #[tokio::test] + async fn a_subscribe_token_needs_a_subscribe_grant() { + let read_only = crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: None, + }; + let write_only = crate::auth::Grant { + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), + expires: None, + }; + let (_auth, _h, mut serving) = serve_token_subscribe(read_only, None).await; + assert!(still_serving(&mut serving).await, "a read grant admits it"); + let (_auth, _h, mut serving) = serve_token_subscribe(write_only, None).await; + assert!(!still_serving(&mut serving).await, "a write grant refuses it"); + } + /// A distinctive request id, so `[FetchHeader::TYPE, REQUEST_ID]` is a usable needle. const REQUEST_ID: u64 = 0x2B; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 16a272acb5..c076f00fb5 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1115,8 +1115,14 @@ where match verdict.grant().await { // The token's grant must cover this announce; it authorizes nothing else and // never joins the session union. - Ok(grant) if grant.subscribe.matches(path.as_str()) => { - token_grant = Some(crate::auth::RequestGrant::new(&self.runtime, verdict, grant)); + Ok(grant) if crate::auth::RequestKind::PublishNamespace.covers(&grant, path.as_str()) => { + token_grant = Some(crate::auth::RequestGrant::new( + &self.runtime, + verdict, + grant, + path.clone(), + crate::auth::RequestKind::PublishNamespace, + )); } Ok(_) => { self.write_error( @@ -1235,7 +1241,7 @@ where }; match res { // On accept the old grant is dropped and the deadline re-armed (REQUEST_OK). - Ok(grant) if grant.subscribe.matches(path.as_str()) => { + Ok(grant) if rg.covers(&grant) => { rg.renew(verdict, grant); self.write_ok(stream, rid).await?; } @@ -5156,12 +5162,12 @@ mod tests { (subscriber, requests, cred, consumer, session) } - /// A grant of everything to subscribe, lapsing in `secs` (or never), on the subscriber's - /// clock. - fn subscribe_grant_expiring(runtime: &crate::time::Clock, secs: Option) -> crate::auth::Grant { + /// A grant to publish everything, lapsing in `secs` (or never), on the subscriber's clock: + /// what an announcing peer's token must carry. + fn publish_grant_expiring(runtime: &crate::time::Clock, secs: Option) -> crate::auth::Grant { crate::auth::Grant { - publish: crate::Patterns::new(), - subscribe: crate::Pattern::all().into(), + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), expires: secs.map(|s| { crate::runtime::Timers::now(runtime) .checked_add(std::time::Duration::from_secs(s)) @@ -5291,10 +5297,10 @@ mod tests { let mut held = Vec::new(); // The first grant lapses in 60s; the renewal never expires. let first = requests.next().await.expect("a request"); - held.push(first.accept(subscribe_grant_expiring(&rt, Some(60)))); + held.push(first.accept(publish_grant_expiring(&rt, Some(60)))); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let renewal = requests.next().await.expect("a renewal"); - held.push(renewal.accept(subscribe_grant_expiring(&rt, None))); + held.push(renewal.accept(publish_grant_expiring(&rt, None))); answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); std::future::pending::<()>().await } @@ -5507,6 +5513,57 @@ mod tests { } } + /// An announcing peer's token grant is checked on its `publish` patterns: a read-only grant + /// does not admit a PUBLISH_NAMESPACE, a write-only one does. + #[tokio::test] + async fn an_announce_token_needs_a_publish_grant() { + const VERSION: Version = Version::Draft18; + let read_only = crate::auth::Grant { + publish: crate::Patterns::new(), + subscribe: crate::Pattern::all().into(), + expires: None, + }; + let write_only = crate::auth::Grant { + publish: crate::Pattern::all().into(), + subscribe: crate::Patterns::new(), + expires: None, + }; + for (grant, admitted) in [(read_only, false), (write_only, true)] { + let (mut subscriber, mut requests, consumer, session) = auth_announce_harness_no_ext(VERSION); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let acceptor = async move { + let request = requests.next().await.expect("a request reaches the acceptor"); + let _issued = request.accept(grant); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..300 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + if admitted && routed_now(&consumer, "room/alice").is_some() { + break; + } + settle().await; + } + assert_eq!( + routed_now(&consumer, "room/alice").is_some(), + admitted, + "admitted={admitted}" + ); + assert_eq!(ended, !admitted, "a grant that does not cover the announce refuses it"); + } + } + /// The additive constraint: a token-LESS PUBLISH_NAMESPACE on a no-auth session (None union) /// is admitted by the origin model exactly as before; the token path is never entered. #[tokio::test] From 149efd428b19d8dfbae485af9fb5fc1847a2e1ad Mon Sep 17 00:00:00 2001 From: sletmoe Date: Thu, 1 Oct 2026 23:56:12 +0000 Subject: [PATCH 32/49] fix(net): keep the local limit on the request-token path A request token may replace the received union, never the limit this side set on the peer with Handle::authorize; the two are separate (State::parts). The token path bypassed both: with a limit of nothing, a token the acceptor granted everything still served a SUBSCRIBE, left it ungated, and no later narrowing could end it. The outgoing bypasses had the same shape: a token-bearing client's announce filter and its SUBSCRIBE allows() gate skipped the limit along with the grant. The incoming SUBSCRIBE token path now refuses a request outside the limit and holds a limit-only Gate (Gate::limit) for the subscription's life, so a narrowing ends it. A token-bearing client still filters its announces and SUBSCRIBEs on the limit, with the same limit-only gate on the SUBSCRIBE. The incoming PUBLISH_NAMESPACE path already applied the limit independently of the token. Tests: a_request_token_cannot_exceed_the_local_limit (refused up front, ended on narrowing) and a_request_token_does_not_lift_the_local_limit_on_announces. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 18 ++++++- rs/moq-net/src/ietf/publisher.rs | 88 ++++++++++++++++++++++++++++--- rs/moq-net/src/ietf/subscriber.rs | 28 ++++++---- 3 files changed, 117 insertions(+), 17 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 204eac2dd1..5b648a1eb0 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -1176,6 +1176,8 @@ pub(crate) struct Gate { path: crate::PathOwned, direction: Direction, epoch: u64, + /// Watch the limit alone, for a request a token authorized in place of the union. + limit_only: bool, } impl Gate { @@ -1185,6 +1187,16 @@ impl Gate { path, direction, epoch: 0, + limit_only: false, + } + } + + /// A gate on the limit alone: a request token stands in for the union, never for the + /// ceiling this side set on the peer, so a later narrowing still ends the request. + pub(crate) fn limit(handle: Handle, path: crate::PathOwned, direction: Direction) -> Self { + Self { + limit_only: true, + ..Self::new(handle, path, direction) } } @@ -1194,7 +1206,11 @@ impl Gate { pub(crate) fn poll_denied(&mut self, waiter: &kio::Waiter) -> Poll<()> { loop { let permit = ready_or!(self.handle.poll_permit(self.direction, &mut self.epoch, waiter)); - if !permit.matches(self.path.as_str()) { + let allowed = match self.limit_only { + true => permit.within_limit(self.path.as_str()), + false => permit.matches(self.path.as_str()), + }; + if !allowed { return Poll::Ready(()); } } diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 6fbf2dd1a2..e3f36e26eb 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -533,10 +533,10 @@ where // origin. The session grant is checked first; a request it does not cover falls // back to an AUTHORIZATION TOKEN carried on the SUBSCRIBE itself (MoQ // request-token), verified by the app's acceptor and scoped to this one request. - // A union-authorized subscription is gated on the union so it ends if the union - // later narrows; a token-authorized one is not (the union never covered it), and - // its grant instead ends with the request when `_request_grant` drops. - let mut gate = None; + // Every admitted subscription is gated so it ends if the session narrows: a + // union-authorized one on the union and the limit, a token-authorized one on the + // limit alone (the union never covered it; its grant ends with the request). + let mut gate; let mut request_grant = None; // A request presenting a token is authorized by it whenever the session union does // not positively cover the path. `covers` treats a `None` union (no answer yet, or a @@ -548,6 +548,15 @@ where .auth .covers(crate::auth::Direction::Publish, msg.track_namespace.as_str()) { + // The token stands in for the union, never for the limit this side set on the + // peer: a request outside that ceiling is refused whatever the token grants. + if !self + .auth + .within_limit(crate::auth::Direction::Publish, msg.track_namespace.as_str()) + { + let err = Error::Unauthorized; + return self.reject_subscribe(stream, request_id, &err, "not granted").await; + } // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol violation // and closes the session exactly as on the SETUP path; a merely-undecodable // structure is refused per request without tearing down the connection. @@ -578,7 +587,7 @@ where Ok(grant) if crate::auth::RequestKind::Subscribe.covers(&grant, msg.track_namespace.as_str()) => { // Held for the subscription's life: the request ends when this grant // lapses, is revoked, or stops covering it (RequestGrant::poll_ended - // below), never the session. + // below), or when the limit narrows past it (the gate), never the session. request_grant = Some(crate::auth::RequestGrant::new( &self.runtime, verdict, @@ -586,6 +595,11 @@ where msg.track_namespace.to_owned(), crate::auth::RequestKind::Subscribe, )); + gate = Some(crate::auth::Gate::limit( + self.auth.clone(), + msg.track_namespace.to_owned(), + crate::auth::Direction::Publish, + )); } Ok(_) => { let err = Error::Unauthorized; @@ -1671,8 +1685,12 @@ where // when the announce carries it: a PUBLISH_NAMESPACE request does, an inline NAMESPACE // entry has no slot for one. So the grant still filters inline entries, and stands down // only for requests, where the server's covers-gate refuses a bad token per request. + // The token never stands in for the limit this side set on the peer. let carries_token = matches!(ns.target, Target::Requests(_)) && self.request_token.peek().is_some(); - let permitted = carries_token || ns.permitted(path); + let permitted = match carries_token { + true => ns.permit.within_limit(path.as_str()), + false => ns.permitted(path), + }; let Namespaces { peer, target, @@ -3370,6 +3388,40 @@ mod serve_tests { ); } + /// A request token stands in for the connection grant, never for the limit this side set + /// on the peer: a token-bearing client still withholds an announce outside that limit. + #[tokio::test] + async fn a_request_token_does_not_lift_the_local_limit_on_announces() { + const VERSION: Version = Version::Draft18; + let token = bytes::Bytes::from_static(&[0x03, 0x00, b'o', b'k']); + + let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); + let _cam = origin.announce("cam", crate::origin::Route::default()).unwrap(); + let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![Vec::new()]); + let log = session.log.clone(); + let peer_setup = peer::PeerSetup::default(); + peer_setup.set(peer::Peer::default()); + let auth = crate::auth::Handle::new(false); + auth.authorize(&crate::auth::Grant::default()); + let publisher = Publisher::new( + crate::time::Clock::tokio(), + session, + origin.consume(), + Control::new(None, false), + None, + peer_setup, + VERSION, + ) + .with_auth(auth) + .with_request_token(crate::RequestToken::new(Some(token.clone()))); + let mut run = std::pin::pin!(publisher.run_publish_namespaces()); + for _ in 0..100 { + assert!(futures::poll!(run.as_mut()).is_pending()); + settle().await; + } + assert_eq!(occurrences(&log, b"cam"), 0, "the limit still withholds the announce"); + } + /// A peer that requires solicitation gets inline NAMESPACE entries, which have no slot for a /// request token, so the token cannot authorize them: the connection grant still filters the /// answer to its SUBSCRIBE_NAMESPACE. @@ -3923,6 +3975,30 @@ mod serve_tests { assert!(!still_serving(&mut serving).await, "a write grant refuses it"); } + /// A request token stands in for the union, never for the limit this side set on the + /// peer: a SUBSCRIBE outside that ceiling is refused whatever the token grants, and a + /// later narrowing ends one the token admitted. + #[tokio::test] + async fn a_request_token_cannot_exceed_the_local_limit() { + let nothing = Some(crate::auth::Grant::default()); + let (_auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), nothing).await; + assert!( + !still_serving(&mut serving).await, + "a limit of nothing refuses an all-covering token" + ); + + let (auth, _h, mut serving) = serve_token_subscribe(crate::auth::Grant::all(), None).await; + assert!( + still_serving(&mut serving).await, + "an unlimited session admits the token" + ); + auth.authorize(&crate::auth::Grant::default()); + assert!( + !still_serving(&mut serving).await, + "narrowing the limit ends a token-authorized subscription" + ); + } + /// A distinctive request id, so `[FetchHeader::TYPE, REQUEST_ID]` is a usable needle. const REQUEST_ID: u64 = 0x2B; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index c076f00fb5..20698b2e12 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1853,20 +1853,28 @@ where return; } - // A token-bearing client does not self-censor on its connection grant: a request token - // authorizes a subscribe the connection grant does not cover (MoQ request-token, quest - // Goal), and the server's covers-gate is the authority. Without a token this is unchanged: - // the connection grant filters, and its shrink revokes, as before. + // A SUBSCRIBE always carries the token, so a token-bearing client does not self-censor + // on its connection grant: the token authorizes what that grant does not cover, and the + // server's covers-gate is the authority. The token stands in for the grant only, never + // for the limit this side set on the peer, which still filters and, narrowing, revokes. let token_authorized = self.request_token.peek().is_some(); - if !token_authorized - && !self + let allowed = match token_authorized { + true => self .auth - .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()) - { + .within_limit(crate::auth::Direction::Subscribe, broadcast_path.as_str()), + false => self + .auth + .allows(crate::auth::Direction::Subscribe, broadcast_path.as_str()), + }; + if !allowed { request.reject(Error::Unauthorized); return; } - let mut gate = crate::auth::Gate::new( + let gate_for = match token_authorized { + true => crate::auth::Gate::limit, + false => crate::auth::Gate::new, + }; + let mut gate = gate_for( self.auth.clone(), broadcast_path.to_owned(), crate::auth::Direction::Subscribe, @@ -2128,7 +2136,7 @@ where { fetch_done = true; } - if !token_authorized && gate.poll_denied(waiter).is_ready() { + if gate.poll_denied(waiter).is_ready() { return Poll::Ready(End::Revoked); } if track.poll_unused(waiter).is_ready() { From c116287d748cb0fda8d381f7002307efd6107ac4 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 00:03:03 +0000 Subject: [PATCH 33/49] fix(net): keep a subscription's range when renewing its token A request-token renewal sent a SUBSCRIBE_UPDATE that was not token-only. On draft-14 its fixed fields reset the start to {0, 0} and the end to open: a live subscription starting at Largest Object + 1 saw its start decrease, which draft-14 section 9.10 requires a peer to treat as a PROTOCOL_VIOLATION. On draft-15+ SubscribeUpdate::encode_msg always sent Filter::NextObject plus forward and priority, replacing an absolute or ranged filter. SubscribeUpdate's priority, forward flag and filter are now optional, and an omitted one is left off the wire so the update keeps its value. A renewal on draft-15+ carries only the token; on draft-14, which has no way to omit them, it restates the subscription's own start (the object after SUBSCRIBE_OK's Largest Object) and priority. Test: setting_a_new_request_token_re_presents_it_on_a_live_subscription now asserts the exact renewal on draft-14 and draft-18. Co-Authored-By: Claude --- rs/moq-net/src/ietf/publisher.rs | 5 +- rs/moq-net/src/ietf/subscribe.rs | 83 +++++++++++++++++-------------- rs/moq-net/src/ietf/subscriber.rs | 63 ++++++++++++++++++++--- 3 files changed, 105 insertions(+), 46 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index e3f36e26eb..cf62caef97 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -3506,8 +3506,9 @@ mod serve_tests { subscription_request_id, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 128, - forward: true, + subscriber_priority: Some(128), + forward: Some(true), + filter: None, authorization_token: Some(request_token()), }; writer.encode(&ietf::SubscribeUpdate::ID).await.unwrap(); diff --git a/rs/moq-net/src/ietf/subscribe.rs b/rs/moq-net/src/ietf/subscribe.rs index 0f34627ace..31f93af66a 100644 --- a/rs/moq-net/src/ietf/subscribe.rs +++ b/rs/moq-net/src/ietf/subscribe.rs @@ -408,10 +408,17 @@ impl Message for Unsubscribe { pub struct SubscribeUpdate { pub request_id: RequestId, pub subscription_request_id: Option, + /// Draft-14's fixed start, which a peer MUST NOT see decrease; unused on later drafts. pub start_location: Location, + /// Draft-14's fixed end group (0 for open-ended); unused on later drafts. pub end_group: u64, - pub subscriber_priority: u8, - pub forward: bool, + /// `None` leaves the priority as it is. Draft-14 has no way to omit it, so `None` sends + /// the default there. + pub subscriber_priority: Option, + /// `None` leaves forwarding as it is, as for the priority. + pub forward: Option, + /// The new Location Filter (draft-15+); `None` keeps the subscription's own range. + pub filter: Option, /// The `AUTHORIZATION TOKEN` (0x03) presented on this REQUEST_UPDATE, if any. A fresh /// token refreshes the request's grant (MoQ request-token); the value is the Token /// structure of section 8.9, decoded with [`super::token::decode_value`]. @@ -427,6 +434,7 @@ impl std::fmt::Debug for SubscribeUpdate { .field("end_group", &self.end_group) .field("subscriber_priority", &self.subscriber_priority) .field("forward", &self.forward) + .field("filter", &self.filter) .field( "authorization_token", &super::token::Redacted(&self.authorization_token), @@ -447,8 +455,8 @@ impl Message for SubscribeUpdate { .encode(w, version)?; self.start_location.encode(w, version)?; self.end_group.encode(w, version)?; - self.subscriber_priority.encode(w, version)?; - self.forward.encode(w, version)?; + self.subscriber_priority.unwrap_or(128).encode(w, version)?; + self.forward.unwrap_or(true).encode(w, version)?; // The legacy trailing parameter block, carrying the AUTHORIZATION TOKEN when a // renewal presents one; otherwise an empty block (count 0), as before. let mut params = Parameters::default(); @@ -466,7 +474,7 @@ impl Message for SubscribeUpdate { 0x03 => self.authorization_token.clone(), 0x10 => self.forward, 0x20 => self.subscriber_priority, - 0x21 => Filter::NextObject, + 0x21 => self.filter.clone(), ); } _ => { @@ -483,7 +491,7 @@ impl Message for SubscribeUpdate { 0x03 => self.authorization_token.clone(), 0x10 => self.forward, 0x20 => self.subscriber_priority, - 0x21 => Filter::NextObject, + 0x21 => self.filter.clone(), ); } } @@ -498,8 +506,8 @@ impl Message for SubscribeUpdate { let subscription_request_id = Some(RequestId::decode(r, version)?); let start_location = Location::decode(r, version)?; let end_group = u64::decode(r, version)?; - let subscriber_priority = u8::decode(r, version)?; - let forward = bool::decode(r, version)?; + let subscriber_priority = Some(u8::decode(r, version)?); + let forward = Some(bool::decode(r, version)?); let params = Parameters::decode(r, version)?; let authorization_token = params .get_bytes(ParameterBytes::AuthorizationToken) @@ -512,6 +520,7 @@ impl Message for SubscribeUpdate { end_group, subscriber_priority, forward, + filter: None, authorization_token, }) } @@ -524,12 +533,9 @@ impl Message for SubscribeUpdate { 0x06 => _subgroup_delivery_timeout: Option, 0x10 => forward: Option, 0x20 => subscriber_priority: Option, - 0x21 => _filter: Option, + 0x21 => filter: Option, ); - let subscriber_priority = subscriber_priority.unwrap_or(128); - let forward = forward.unwrap_or(true); - Ok(Self { request_id, subscription_request_id, @@ -537,6 +543,7 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + filter, authorization_token, }) } @@ -552,7 +559,7 @@ impl Message for SubscribeUpdate { 0x06 => _subgroup_delivery_timeout: Option, 0x10 => forward: Option, 0x20 => subscriber_priority: Option, - 0x21 => _filter: Option, + 0x21 => filter: Option, 0x23 => fill: Option, ); @@ -562,9 +569,6 @@ impl Message for SubscribeUpdate { return Err(DecodeError::InvalidValue); } - let subscriber_priority = subscriber_priority.unwrap_or(128); - let forward = forward.unwrap_or(true); - Ok(Self { request_id, subscription_request_id: None, @@ -572,6 +576,7 @@ impl Message for SubscribeUpdate { end_group: 0, subscriber_priority, forward, + filter, authorization_token, }) } @@ -682,8 +687,9 @@ mod tests { subscription_request_id, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 128, - forward: true, + subscriber_priority: Some(128), + forward: Some(true), + filter: None, authorization_token: Some(token.clone()), }; let encoded = encode_message(&msg, version); @@ -1043,8 +1049,9 @@ mod tests { subscription_request_id: Some(RequestId(5)), start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: None, authorization_token: None, }; @@ -1053,8 +1060,8 @@ mod tests { assert_eq!(decoded.request_id, RequestId(10)); assert_eq!(decoded.subscription_request_id, Some(RequestId(5))); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } #[test] @@ -1064,8 +1071,9 @@ mod tests { subscription_request_id: Some(RequestId(5)), start_location: Location { group: 1, object: 2 }, end_group: 100, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: None, authorization_token: None, }; @@ -1076,8 +1084,8 @@ mod tests { assert_eq!(decoded.subscription_request_id, Some(RequestId(5))); assert_eq!(decoded.start_location, Location { group: 1, object: 2 }); assert_eq!(decoded.end_group, 100); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } #[test] @@ -1168,8 +1176,9 @@ mod tests { subscription_request_id: None, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: None, authorization_token: None, }; @@ -1178,8 +1187,8 @@ mod tests { assert_eq!(decoded.request_id, RequestId(10)); assert_eq!(decoded.subscription_request_id, None); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } #[test] @@ -1370,8 +1379,9 @@ mod tests { subscription_request_id: None, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: None, authorization_token: None, }; @@ -1380,8 +1390,8 @@ mod tests { assert_eq!(decoded.request_id, RequestId(10)); assert_eq!(decoded.subscription_request_id, None); - assert_eq!(decoded.subscriber_priority, 200); - assert!(decoded.forward); + assert_eq!(decoded.subscriber_priority, Some(200)); + assert_eq!(decoded.forward, Some(true)); } /// Cross-check: draft-17 emits an extra 0-byte (required_request_id_delta) that @@ -1394,8 +1404,9 @@ mod tests { subscription_request_id: None, start_location: Location { group: 0, object: 0 }, end_group: 0, - subscriber_priority: 200, - forward: true, + subscriber_priority: Some(200), + forward: Some(true), + filter: None, authorization_token: None, }; let v18_msg = SubscribeUpdate { ..v17_msg.clone() }; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 20698b2e12..4436e69ded 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2057,6 +2057,12 @@ where priority, largest, } = accepted; + // Where this subscription began: the object after the Largest Object SUBSCRIBE_OK + // named, which every draft before 20 subscribes at. Draft-14's renewal restates it. + let renewal_start = largest.map_or(ietf::Location { group: 0, object: 0 }, |largest| ietf::Location { + group: largest.group, + object: largest.object.saturating_add(1), + }); let info = track::Info::default() .with_timescale(Timescale::MICRO) .with_max_age(self.origin.default_max_age()) @@ -2164,7 +2170,13 @@ where last_token = token.clone(); if let Some(token) = token && let Err(err) = self - .send_request_token_update(&mut stream.writer, request_id, subscriber_priority, token) + .send_request_token_update( + &mut stream.writer, + request_id, + subscriber_priority, + renewal_start, + token, + ) .await { // A failed send does not end the subscription: it continues on the old @@ -2335,15 +2347,17 @@ where /// REQUEST_UPDATE (SUBSCRIBE_UPDATE), so a refreshed credential reaches the publisher /// before the old grant lapses (MoQ request-token renewal). /// - /// Token-only: the range, priority and forward flag are the subscription's own, so a - /// receiver that acts on them (ours does not, for a token update) sees no change. The - /// answer, if the version sends one, is read on the subscription stream by - /// [`read_publish_done`](Self::read_publish_done). + /// Token-only: draft-15+ omits the range, priority and forward flag, which an update keeps + /// as they are. Draft-14's fields are fixed, so it restates the subscription's own: + /// `start` is where it began (the Largest Object after SUBSCRIBE_OK), which the peer + /// MUST NOT see decrease. The answer, if the version sends one, is read on the + /// subscription stream by [`read_publish_done`](Self::read_publish_done). async fn send_request_token_update( &self, writer: &mut crate::coding::Writer, subscription_id: RequestId, subscriber_priority: u8, + start: ietf::Location, token: bytes::Bytes, ) -> Result<(), Error> { let request_id = self.control.next_request_id(&self.runtime).await?; @@ -2352,15 +2366,17 @@ where Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(subscription_id), _ => None, }; + let draft14 = self.version == Version::Draft14; writer.encode(&ietf::SubscribeUpdate::ID).await?; writer .encode(&ietf::SubscribeUpdate { request_id, subscription_request_id, - start_location: ietf::Location { group: 0, object: 0 }, + start_location: start, end_group: 0, - subscriber_priority, - forward: true, + subscriber_priority: draft14.then_some(subscriber_priority), + forward: draft14.then_some(true), + filter: None, authorization_token: Some(token), }) .await?; @@ -4312,6 +4328,37 @@ mod tests { "{version}: a replaced token must be re-presented on the live subscription" ); + // The renewal leaves the subscription's range alone: draft-14 restates where it + // began (the object after SUBSCRIBE_OK's Largest Object, {0, 0} here), and draft-15+ + // omits the filter, priority and forward flag so they keep their values. + let draft14 = version == Version::Draft14; + let mut expected = Vec::new(); + for request_id in 0..16 { + let log = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.encode(&ietf::SubscribeUpdate::ID).await.unwrap(); + writer + .encode(&ietf::SubscribeUpdate { + request_id: RequestId(request_id), + subscription_request_id: draft14.then_some(RequestId(1)), + start_location: ietf::Location { group: 0, object: 1 }, + end_group: 0, + // The SUBSCRIBE went out at the lowest wire priority; the renewal restates it. + subscriber_priority: draft14.then_some(0xff), + forward: draft14.then_some(true), + filter: None, + authorization_token: Some(second.clone()), + }) + .await + .unwrap(); + expected.push(log.writes.lock().unwrap().clone()); + } + assert!( + expected.iter().any(|bytes| occurrences(&log, bytes) == 1), + "{version}: the renewal must keep the subscription's range" + ); + drop(subscription); drop(track); drop(consumer); From e760e7accdf228f5396978b56015eb1cd7386de4 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 00:09:51 +0000 Subject: [PATCH 34/49] fix(net): end a request whose stream closes during a renewal While a request-token renewal was being verified, both serve loops polled only the old grant's deadline and the pending verdict. A peer that then finished or reset the request stream went unnoticed, and with a grant that never expires nothing else could end the request: the route stayed attached and the original Issued::closed never resolved. Both the subscribe loop and the announce update loop now watch the stream as well. A FIN or reset ends the request; a message that arrives meanwhile waits for the verdict, except a PUBLISH_NAMESPACE_DONE, which withdraws the announce at once. read_control now decodes a control message as one value, so a read dropped part-way through when the verdict resolves consumes nothing. Tests: a_closed_subscription_ends_while_a_renewal_is_pending and a_closed_announce_ends_while_a_renewal_is_pending. Co-Authored-By: Claude --- rs/moq-net/src/ietf/publisher.rs | 98 ++++++++++++++++++-- rs/moq-net/src/ietf/subscriber.rs | 144 ++++++++++++++++++++++++++---- 2 files changed, 218 insertions(+), 24 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index cf62caef97..69034a3e1f 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -42,15 +42,31 @@ fn serving_subscription(subscriber_priority: u8) -> Subscription { /// the peer finishes it. Mirrors [`super::auth`]'s reader, but borrows only the reader so /// a renewal can answer on the writer once the read yields a message. Shared with the /// subscriber's announce loop. +/// +/// Cancel-safe: the message decodes as one value, so a read dropped part-way consumes +/// nothing and the next one starts at the same message. pub(super) async fn read_control( reader: &mut Reader, ) -> Result, Error> { - let Some(id) = reader.decode_maybe::().await? else { - return Ok(None); - }; - let size: u16 = reader.decode().await?; - let data = reader.read_exact(size as usize).await?; - Ok(Some((id, data))) + Ok(reader + .decode_maybe::() + .await? + .map(|ControlMessage(id, data)| (id, data))) +} + +/// A whole `[type][size][body]` control message, decoded at once. +#[derive(Debug)] +struct ControlMessage(u64, bytes::Bytes); + +impl crate::coding::Decode for ControlMessage { + fn decode(buf: &mut B, version: Version) -> Result { + let id = u64::decode(buf, version)?; + let size = u16::decode(buf, version)? as usize; + if buf.remaining() < size { + return Err(crate::coding::DecodeError::Short); + } + Ok(Self(id, buf.copy_to_bytes(size))) + } } enum FillStep { @@ -783,9 +799,14 @@ where // `stream.reader` and lives in an inner block, so that borrow is released before a // renewal answers on `stream.writer`. let mut pending: Option<(crate::auth::RequestVerdict, RequestId)> = None; + // A message that arrived while a renewal was pending, handled once it resolves. + let mut stashed: Option<(u64, bytes::Bytes)> = None; loop { let step = if let Some((verdict, rid)) = pending.as_mut() { let rid = *rid; + // The stream is still watched while the verdict is pending: a peer that + // ends the request then must end it here too, whatever the acceptor does. + let mut read = std::pin::pin!(read_control(&mut stream.reader)); kio::wait(|waiter| { if let Poll::Ready(served) = waiter.poll_future(serve.as_mut()) { return Poll::Ready(Step::Served(served)); @@ -807,9 +828,18 @@ where if closed_session.poll_closed(&mut cx).is_ready() { return Poll::Ready(Step::Closed); } + if stashed.is_none() { + match waiter.poll_future(read.as_mut()) { + Poll::Ready(Ok(Some(message))) => stashed = Some(message), + Poll::Ready(Ok(None)) | Poll::Ready(Err(_)) => return Poll::Ready(Step::Closed), + Poll::Pending => {} + } + } Poll::Pending }) .await + } else if let Some((id, data)) = stashed.take() { + Step::Message(id, data) } else { let mut read = std::pin::pin!(read_control(&mut stream.reader)); kio::wait(|waiter| { @@ -3233,11 +3263,16 @@ mod serve_tests { /// Like [`serve`], but with an app auth acceptor wired in and the subscribe stream's /// reader scripted with `first_script` (a REQUEST_UPDATE, for the renewal tests). fn serve_with_auth(version: Version, auth: crate::auth::Handle, first_script: Vec) -> Serve { + serve_on(version, auth, ScriptedSession::per_stream(vec![first_script])) + } + + /// [`serve_with_auth`] over a given session, such as one that finishes the stream after + /// its script. + fn serve_on(version: Version, auth: crate::auth::Handle, session: ScriptedSession) -> Serve { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let broadcast = origin.publish("room", crate::origin::Route::default()).unwrap(); let track = broadcast.create_track("video", None).unwrap(); - let session = ScriptedSession::per_stream(vec![first_script]); let log = session.log.clone(); let peer_setup = peer::PeerSetup::default(); @@ -3690,6 +3725,55 @@ mod serve_tests { } } + /// A peer that ends the subscription while a renewal is still being verified ends it here + /// too: with a grant that never expires and an acceptor that never answers the renewal, + /// only the stream closing can end the request, and it must. + #[tokio::test(start_paused = true)] + async fn a_closed_subscription_ends_while_a_renewal_is_pending() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let session = ScriptedSession::per_stream_eof(vec![subscribe_update_with_token(VERSION).await]); + let h = serve_on(VERSION, auth, session); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + let popped = std::sync::Arc::new(AtomicU64::new(0)); + let acceptor = { + let popped = popped.clone(); + async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + popped.fetch_add(1, Ordering::Relaxed); + let _never_answered = requests.next().await.expect("a renewal"); + popped.fetch_add(1, Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!( + popped.load(Ordering::Relaxed) >= 1, + "the subscription was token-authorized" + ); + assert!( + ended, + "closing the stream ends the subscription despite the pending renewal" + ); + } + /// A REQUEST_UPDATE the acceptor refuses does NOT extend the grant: the old grant stands /// and the subscription ends only when it lapses (the quest's rule), never the session. #[tokio::test(start_paused = true)] diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 4436e69ded..e413e4b9b3 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1190,10 +1190,7 @@ where /// travels the other way, so receiving it on an advertisement *we* were offered is a /// violation, not a withdrawal. fn terminal_publish_namespace(&self, type_id: u64) -> bool { - match self.version { - Version::Draft14 | Version::Draft15 | Version::Draft16 => type_id == ietf::PublishNamespaceDone::ID, - _ => false, - } + terminal_publish_namespace(self.version, type_id) } /// Read advertisement updates off a live PUBLISH_NAMESPACE stream until it closes. @@ -1212,27 +1209,59 @@ where mut token_grant: Option>, ) -> Result<(), Error> { let mut pending: Option<(crate::auth::RequestVerdict, RequestId)> = None; + // A message that arrived while a renewal was pending, handled once it resolves. + let mut stashed: Option<(u64, bytes::Bytes)> = None; loop { // A renewal verify in flight is raced against the request grant's deadline (never - // a bare await), so the old deadline can still fire while a slow acceptor decides; - // the loop stops reading until the verdict resolves. + // a bare await), so the old deadline can still fire while a slow acceptor decides, + // and against the stream, so a peer ending the announce ends it whatever the + // acceptor does. A message that arrives meanwhile waits for the verdict. if let Some((verdict, rid)) = pending.as_mut() { let rid = *rid; enum Ren { Renewal(Result), Ended(Error), + Closed(Result<(), Error>), + // The peer withdrew the announce: the renewal no longer matters. + Withdrawn, } - let ren = kio::wait(|waiter| { - if let Some(rg) = token_grant.as_mut() - && let Poll::Ready(err) = rg.poll_ended(waiter) - { - return Poll::Ready(Ren::Ended(err)); - } - verdict.poll_grant(waiter).map(Ren::Renewal) - }) - .await; + let version = self.version; + let ren = { + let mut read = std::pin::pin!(super::publisher::read_control(&mut stream.reader)); + kio::wait(|waiter| { + if let Some(rg) = token_grant.as_mut() + && let Poll::Ready(err) = rg.poll_ended(waiter) + { + return Poll::Ready(Ren::Ended(err)); + } + if let Poll::Ready(res) = verdict.poll_grant(waiter) { + return Poll::Ready(Ren::Renewal(res)); + } + if stashed.is_none() { + match waiter.poll_future(read.as_mut()) { + Poll::Ready(Ok(Some(message))) => { + let terminal = terminal_publish_namespace(version, message.0); + stashed = Some(message); + if terminal { + return Poll::Ready(Ren::Withdrawn); + } + } + Poll::Ready(Ok(None)) => return Poll::Ready(Ren::Closed(Ok(()))), + Poll::Ready(Err(err)) => return Poll::Ready(Ren::Closed(Err(err))), + Poll::Pending => {} + } + } + Poll::Pending + }) + .await + }; let res = match ren { Ren::Ended(err) => return Err(err), + Ren::Closed(res) => return res, + Ren::Withdrawn => { + pending = None; + continue; + } Ren::Renewal(res) => res, }; let (verdict, _) = pending.take().expect("a pending renewal"); @@ -1264,7 +1293,9 @@ where Closed, Ended(Error), } - let ctl = { + let ctl = if let Some((id, data)) = stashed.take() { + Ctl::Message(id, data) + } else { let mut read = std::pin::pin!(super::publisher::read_control(&mut stream.reader)); kio::wait(|waiter| -> Poll> { if let Some(rg) = token_grant.as_mut() @@ -5174,10 +5205,27 @@ mod tests { crate::auth::Token, origin::Consumer, crate::lite::test_transport::ScriptedSession, + ) { + auth_announce_harness_on( + version, + crate::lite::test_transport::ScriptedSession::per_stream(vec![first_script]), + ) + } + + /// [`auth_announce_harness`] over a given session, such as one that finishes the stream + /// after its script. + fn auth_announce_harness_on( + version: Version, + session: crate::lite::test_transport::ScriptedSession, + ) -> ( + Subscriber, + crate::auth::Requests, + crate::auth::Token, + origin::Consumer, + crate::lite::test_transport::ScriptedSession, ) { let origin = crate::origin::Config::new(crate::Hop::new(1).unwrap()).produce(); let consumer = origin.consume(); - let session = crate::lite::test_transport::ScriptedSession::per_stream(vec![first_script]); let (tasks, task_set) = crate::util::TaskSet::new(); std::mem::forget(task_set); let peer_setup = peer::PeerSetup::default(); @@ -5568,6 +5616,59 @@ mod tests { } } + /// A peer that ends the announce while a renewal is still being verified ends it here too: + /// with a grant that never expires and an acceptor that never answers the renewal, only + /// the stream closing can end the request, and it must. + #[tokio::test] + async fn a_closed_announce_ends_while_a_renewal_is_pending() { + const VERSION: Version = Version::Draft18; + let session = crate::lite::test_transport::ScriptedSession::per_stream_eof(vec![ + publish_namespace_update_with_token(VERSION).await, + ]); + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness_on(VERSION, session); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + let popped = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let popped = popped.clone(); + async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + popped.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let _never_answered = requests.next().await.expect("a renewal"); + popped.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(run.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!( + popped.load(std::sync::atomic::Ordering::Relaxed) >= 1, + "the announce was token-authorized" + ); + assert!( + ended, + "closing the stream ends the announce despite the pending renewal" + ); + assert!( + routed_now(&consumer, "room/alice").is_none(), + "the announce is withdrawn" + ); + } + /// An announcing peer's token grant is checked on its `publish` patterns: a read-only grant /// does not admit a PUBLISH_NAMESPACE, a write-only one does. #[tokio::test] @@ -6674,6 +6775,15 @@ mod tests { } } +/// Whether `type_id` ends a PUBLISH_NAMESPACE stream: PUBLISH_NAMESPACE_DONE before +/// draft-17; later drafts end it by closing the stream. +fn terminal_publish_namespace(version: Version, type_id: u64) -> bool { + match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => type_id == ietf::PublishNamespaceDone::ID, + _ => false, + } +} + /// What a SUBSCRIBE asks for: the range it delivers, and the backfill covering a head that /// range excludes. #[derive(Debug, Default, PartialEq, Eq)] From e7d93b7f3a5e35e9486f34b5616ad494423ea21e Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 00:22:43 +0000 Subject: [PATCH 35/49] feat(net): declare offered extensions with setup::Extensions Replace the two negated opt-out builders, Client::without_auth_extension and Server::without_solicit, with a positive declaration: moq_net::setup::Extensions { auth, solicit }, #[non_exhaustive] and all-on by default, set with Client::with_extensions and Server::with_extensions. Later extensions join the same struct. Both sides now honor both fields. A client can stop offering Solicit, and a server can stop offering Auth, which leaves AUTH un-negotiated whatever the client offered. The private ietf session Config carries the struct in place of its solicit flag, and run_setup takes it in place of its two adjacent bools. moq-tokio mirrors it as an `extensions` field on the dial and listen configs (connect::Config, listen::Config), read from config files and omitted when default. The moq-tokio without_* wrappers are gone. js/net gains an `extensions` option on connect and accept with the same defaults; a session that did not declare Solicit no longer treats an unasked PUBLISH_NAMESPACE as a peer fault. Tests: a_server_may_decline_the_auth_extension (with a control showing the grant does arrive by default), the existing client-auth and server-solicit integration tests ported to Extensions, moq-tokio extensions_default_on_and_parse_off for both configs, and js extensions.test.ts. Co-Authored-By: Claude --- js/net/src/connection/accept.ts | 18 +++++++-- js/net/src/connection/connect.ts | 16 +++++++- js/net/src/connection/extensions.test.ts | 20 ++++++++++ js/net/src/connection/extensions.ts | 27 +++++++++++++ js/net/src/connection/handshake.ts | 18 +++++---- js/net/src/connection/index.ts | 1 + js/net/src/ietf/connection.ts | 21 ++++++---- rs/moq-net/src/auth.rs | 4 +- rs/moq-net/src/client.rs | 36 ++++++----------- rs/moq-net/src/ietf/session.rs | 51 +++++++++++------------- rs/moq-net/src/ietf/subscriber.rs | 10 ++--- rs/moq-net/src/server.rs | 34 ++++++---------- rs/moq-net/src/setup.rs | 22 ++++++++++ rs/moq-net/tests/auth.rs | 48 +++++++++++++++++++--- rs/moq-tokio/src/client.rs | 11 ++--- rs/moq-tokio/src/connect.rs | 25 ++++++++++++ rs/moq-tokio/src/listen.rs | 19 +++++++++ rs/moq-tokio/src/server.rs | 17 ++------ 18 files changed, 273 insertions(+), 125 deletions(-) create mode 100644 js/net/src/connection/extensions.test.ts create mode 100644 js/net/src/connection/extensions.ts diff --git a/js/net/src/connection/accept.ts b/js/net/src/connection/accept.ts index 7772aa29b6..1095863a19 100644 --- a/js/net/src/connection/accept.ts +++ b/js/net/src/connection/accept.ts @@ -3,6 +3,7 @@ import * as Lite from "../lite/index.ts"; import type { Consumer as OriginConsumer, Producer as OriginProducer } from "../origin.ts"; import { Stream } from "../stream.ts"; import type { Established } from "./established.ts"; +import * as Extensions from "./extensions.ts"; import { forwardAnnounced } from "./forward.ts"; import { exchangeSetup } from "./handshake.ts"; @@ -33,6 +34,9 @@ export interface AcceptProps { * nothing. The entries retract when the session dies; see the `consume` connect option. */ consume?: OriginProducer; + + /** The moq-transport extensions to offer; each is on unless set to `false`. */ + extensions?: Extensions.Extensions; } /** The per-session wiring shared by every negotiated protocol path. */ @@ -41,6 +45,8 @@ type SessionProps = { publish?: OriginConsumer; /** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */ client: boolean; + /** The moq-transport extensions this side offers. */ + extensions: Extensions.Offered; }; /** @@ -69,6 +75,7 @@ async function acceptInner( discovery: props.discovery ?? true, publish: props.publish, client: false, + extensions: Extensions.offered(props.extensions), }; if (protocol === Ietf.ALPN.DRAFT_22) { @@ -117,7 +124,12 @@ async function acceptAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, hidden, cluster, auth } = await exchangeSetup(transport, version, "moq-lite-js"); + const { control, solicit, hidden, cluster, auth } = await exchangeSetup( + transport, + version, + "moq-lite-js", + wiring.extensions, + ); return new Ietf.Connection({ ...wiring, @@ -162,7 +174,7 @@ async function acceptSetup( const params = new Ietf.SetupOptions(); params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); - Ietf.solicitIntoSetup(params); + Extensions.intoSetup(params, wiring.extensions, version); Ietf.hiddenIntoSetup(params); const server = new Ietf.ServerSetup({ version, parameters: params }); @@ -222,7 +234,7 @@ async function acceptNegotiated( const params = new Ietf.SetupOptions(); params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); - Ietf.solicitIntoSetup(params); + Extensions.intoSetup(params, wiring.extensions, setupVersion); Ietf.hiddenIntoSetup(params); const server = new Ietf.ServerSetup({ version: selectedVersion, parameters: params }); diff --git a/js/net/src/connection/connect.ts b/js/net/src/connection/connect.ts index 15b2017e4f..e20c818e06 100644 --- a/js/net/src/connection/connect.ts +++ b/js/net/src/connection/connect.ts @@ -9,6 +9,7 @@ import * as Hex from "../util/hex.ts"; import { dev, redact } from "../util/log.ts"; import { isWebTransportSupported } from "./browser.ts"; import type { Established } from "./established.ts"; +import * as Extensions from "./extensions.ts"; import { forwardAnnounced } from "./forward.ts"; import { exchangeSetup } from "./handshake.ts"; @@ -110,6 +111,9 @@ export interface ConnectProps { */ consume?: OriginProducer; + /** The moq-transport extensions to offer; each is on unless set to `false`. */ + extensions?: Extensions.Extensions; + /** * Aborts the connection attempt with the signal's reason. An already-aborted * signal rejects before anything opens, and aborting after the connection is @@ -124,6 +128,8 @@ type SessionProps = { publish?: OriginConsumer; /** Whether this side dialed; only the dialing side aborts on a publication its grant does not cover. */ client: boolean; + /** The moq-transport extensions this side offers. */ + extensions: Extensions.Offered; }; // Save if WebSocket won the last race, so we won't give QUIC a head start next time. @@ -170,6 +176,7 @@ async function connectInner(url: URL, props: Omit, abort: P discovery: props.discovery ?? true, publish: props.publish, client: true, + extensions: Extensions.offered(props.extensions), }; if (props.transport) { @@ -308,7 +315,7 @@ async function negotiate(url: URL, session: WebTransport, wiring: SessionProps): const params = new Ietf.SetupOptions(); params.setVarint(Ietf.SetupOption.MaxRequestId, 42069n); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode("moq-lite-js")); - Ietf.solicitIntoSetup(params); + Extensions.intoSetup(params, wiring.extensions, setupVersion); Ietf.hiddenIntoSetup(params); const client = new Ietf.ClientSetup({ @@ -365,7 +372,12 @@ async function handshakeAlpn( version: Ietf.IetfVersion, wiring: SessionProps, ): Promise { - const { control, solicit, hidden, cluster, auth } = await exchangeSetup(session, version, "moq-lite-js"); + const { control, solicit, hidden, cluster, auth } = await exchangeSetup( + session, + version, + "moq-lite-js", + wiring.extensions, + ); return new Ietf.Connection({ ...wiring, diff --git a/js/net/src/connection/extensions.test.ts b/js/net/src/connection/extensions.test.ts new file mode 100644 index 0000000000..4596235568 --- /dev/null +++ b/js/net/src/connection/extensions.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test"; +import * as Ietf from "../ietf/index.ts"; +import { intoSetup, offered } from "./extensions.ts"; + +test("every extension is offered by default", () => { + expect(offered()).toEqual({ auth: true, solicit: true }); + expect(offered({ solicit: false })).toEqual({ auth: true, solicit: false }); +}); + +test("only the offered extensions reach the SETUP", () => { + const all = new Ietf.SetupOptions(); + intoSetup(all, offered(), Ietf.Version.DRAFT_18); + expect(Ietf.solicitFromSetup(all)).toBe(true); + expect(Ietf.Auth.fromSetup(all, Ietf.Version.DRAFT_18)).toBe(true); + + const none = new Ietf.SetupOptions(); + intoSetup(none, offered({ auth: false, solicit: false }), Ietf.Version.DRAFT_18); + expect(Ietf.solicitFromSetup(none)).toBeUndefined(); + expect(Ietf.Auth.fromSetup(none, Ietf.Version.DRAFT_18)).not.toBe(true); +}); diff --git a/js/net/src/connection/extensions.ts b/js/net/src/connection/extensions.ts new file mode 100644 index 0000000000..279f8712cc --- /dev/null +++ b/js/net/src/connection/extensions.ts @@ -0,0 +1,27 @@ +import * as Ietf from "../ietf/index.ts"; + +/** + * The moq-transport extensions a session offers in its SETUP. Each is on unless set to + * `false`; turning one off connects as a peer that does not speak it. moq-lite carries both + * in its core, so this applies to moq-transport sessions only. + */ +export interface Extensions { + /** The MoQ Auth extension: tokens presented and granted on their own stream. */ + auth?: boolean; + /** The MoQ Solicit extension: the peer answers our SUBSCRIBE_NAMESPACE rather than announcing unasked. */ + solicit?: boolean; +} + +/** Every extension resolved to whether it is offered. */ +export type Offered = Required; + +/** Resolve unset extensions to offered. */ +export function offered(extensions?: Extensions): Offered { + return { auth: extensions?.auth ?? true, solicit: extensions?.solicit ?? true }; +} + +/** Write the options for the offered extensions into a SETUP. */ +export function intoSetup(params: Ietf.SetupOptions, extensions: Offered, version: Ietf.IetfVersion) { + if (extensions.solicit) Ietf.solicitIntoSetup(params); + if (extensions.auth) Ietf.Auth.intoSetup(params, version); +} diff --git a/js/net/src/connection/handshake.ts b/js/net/src/connection/handshake.ts index 9433eafd7e..1a6c4e40e8 100644 --- a/js/net/src/connection/handshake.ts +++ b/js/net/src/connection/handshake.ts @@ -1,6 +1,7 @@ import { type Hop, randomHop } from "../hop.ts"; import * as Ietf from "../ietf/index.ts"; import { Reader, Stream, Writer } from "../stream.ts"; +import * as Extensions from "./extensions.ts"; /** * Draft-17+ SETUP exchange. Each side opens a uni stream, writes its Setup @@ -10,16 +11,17 @@ import { Reader, Stream, Writer } from "../stream.ts"; * * Returns the control stream plus what the peer's SETUP declared: whether it requires * solicitation, which decides whether we announce namespaces unprompted (see the MoQ Solicit - * extension), its Hop ID (see the MoQ Cluster extension), and whether it offered MoQ Auth. - * We declare all three ourselves on - * every session: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited - * advertisement can tell us nothing we won't have asked for, and a peer that knows our Hop - * ID can withhold the advertisements that already flowed through us. + * extension), its Hop ID (see the MoQ Cluster extension), and whether MoQ Auth is negotiated. + * We declare our Hop ID on every session, and Solicit and Auth unless `extensions` turns them + * off: we send SUBSCRIBE_NAMESPACE for each prefix we want, so an unsolicited advertisement can + * tell us nothing we won't have asked for, and a peer that knows our Hop ID can withhold the + * advertisements that already flowed through us. */ export async function exchangeSetup( transport: WebTransport, version: Ietf.IetfVersion, implementation: string, + extensions: Extensions.Offered, ): Promise<{ control: Stream; solicit: boolean | undefined; @@ -30,9 +32,8 @@ export async function exchangeSetup( const encoder = new TextEncoder(); const params = new Ietf.SetupOptions(); params.setBytes(Ietf.SetupOption.Implementation, encoder.encode(implementation)); - Ietf.solicitIntoSetup(params); + Extensions.intoSetup(params, extensions, version); Ietf.hiddenIntoSetup(params); - Ietf.Auth.intoSetup(params, version); // One id per session, like the moq-lite connection: nothing in this process forwards // between sessions, so there is nothing for a shared id to detect. @@ -51,7 +52,8 @@ export async function exchangeSetup( solicit: received.solicit, hidden: received.hidden, cluster: { self, peer: received.cluster }, - auth: received.auth, + // Auth is negotiated only when both sides offer it. + auth: received.auth && extensions.auth, }; } diff --git a/js/net/src/connection/index.ts b/js/net/src/connection/index.ts index 9989292eb6..1cbbb3762c 100644 --- a/js/net/src/connection/index.ts +++ b/js/net/src/connection/index.ts @@ -14,6 +14,7 @@ export { type WebTransportProps, } from "./connect.ts"; export type { Established } from "./established.ts"; +export type { Extensions } from "./extensions.ts"; export { Connection, type ConnectionProps } from "./pool.ts"; export type { Probe, Stats } from "./stats.ts"; export type { Transport } from "./transport.ts"; diff --git a/js/net/src/ietf/connection.ts b/js/net/src/ietf/connection.ts index 3ffea47dc9..625bc613ec 100644 --- a/js/net/src/ietf/connection.ts +++ b/js/net/src/ietf/connection.ts @@ -71,6 +71,8 @@ export class Connection implements Established { // What the peer declared about being solicited; see {@link Ietf.solicitFromSetup}. #solicit: boolean | undefined; + /** Whether our SETUP declared MoQ Solicit, which is what makes an unasked announce a fault. */ + #declaredSolicit: boolean; // The Hop IDs this session declared; see {@link Cluster}. #cluster?: Cluster.Hops; @@ -103,6 +105,7 @@ export class Connection implements Established { hidden = false, cluster, auth = false, + extensions, }: { url: URL; quic: WebTransport; @@ -126,8 +129,10 @@ export class Connection implements Established { * cannot negotiate the extension, as is a `peer` the peer never declared. */ cluster?: Cluster.Hops; - /** Whether the peer's SETUP offered MoQ Auth (draft-17+). */ + /** Whether MoQ Auth is negotiated (draft-17+): offered by both SETUPs. */ auth?: boolean; + /** What our own SETUP offered; every extension when omitted. */ + extensions?: { solicit: boolean }; }) { this.url = url; this.discovery = discovery; @@ -180,6 +185,7 @@ export class Connection implements Established { ready: this.#auth.setupAnswered(), }); this.#solicit = solicit; + this.#declaredSolicit = extensions?.solicit ?? true; this.#cluster = cluster; this.#subscriber = new Subscriber({ session: this.#session, quic, cluster, hidden, grant: this.#auth.grant }); registerWire(this, { consume: (path) => this.#subscriber.consume(path) }); @@ -300,18 +306,19 @@ export class Connection implements Established { Cluster.negotiated(this.#cluster), ); - // We always declare that advertisements to us must be solicited (MoQ - // Solicit), and writing the option at all proves the peer implements the - // extension, whichever value it chose. It also cannot have advertised - // before reading our SETUP, since our SETUP is what says whether + // Unless told otherwise we declare that advertisements to us must be + // solicited (MoQ Solicit), and writing the option at all proves the peer + // implements the extension, whichever value it chose. It also cannot have + // advertised before reading our SETUP, since our SETUP is what says whether // advertising unasked is allowed. So this is a bug in the peer, and a - // silent one on both sides if we tolerate it. + // silent one on both sides if we tolerate it. Without our declaration an + // unasked announce is what we invited. // // Draft-14/15 are exempt: they have no inline NAMESPACE, so a // PUBLISH_NAMESPACE request is also how a peer answers our // SUBSCRIBE_NAMESPACE there, and the message alone does not say which. const legacy = this.#session.version === Version.DRAFT_14 || this.#session.version === Version.DRAFT_15; - if (this.#solicit !== undefined && !legacy) { + if (this.#declaredSolicit && this.#solicit !== undefined && !legacy) { console.error( `unsolicited publish_namespace from a peer that implements MoQ Solicit: broadcast=${msg.trackNamespace}`, ); diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 5b648a1eb0..297b0e7177 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -258,8 +258,8 @@ impl Handle { } /// Whether this session speaks the AUTH extension. False on a version that cannot - /// negotiate it, and on a handle a caller built declined (see - /// `Client::without_auth_extension`), so the SETUP omits the option and no + /// negotiate it, and on a handle whose side does not offer it (`Extensions::auth` off), + /// so the SETUP omits the option and no /// connection credential is presented. pub(crate) fn supported(&self) -> bool { self.state.lock().supported diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 5a90ecae86..201c4416fe 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -74,7 +74,7 @@ pub struct Client { cost: Option, peer_hop: Option, request_token: RequestToken, - decline_auth_extension: bool, + extensions: crate::setup::Extensions, } impl Client { @@ -211,19 +211,9 @@ impl Client { self.request_token.set(Some(token.into())); } - /// Do not declare the MoQ Auth extension in this client's SETUP, so it connects as a - /// peer without it (interop testing). - /// - /// A standard moq-transport peer (for example a non-moq-dev encoder or CDN) never - /// negotiates the moq-dev AUTH Setup Option, so its session carries no connection - /// grant and a request-borne `AUTHORIZATION TOKEN` (see - /// [`with_request_token`](Self::with_request_token)) is the authorizing artifact. A - /// moq-dev client normally declares the extension, which fills the session grant and - /// short-circuits the request token; this lets one emulate the peer that does not, so - /// the request-token path can be exercised at draft-17+. Additive: the default still - /// declares the extension. No effect on versions that do not negotiate it. - pub fn without_auth_extension(mut self) -> Self { - self.decline_auth_extension = true; + /// Choose which moq-transport extensions this client offers in its SETUP. Defaults to all. + pub fn with_extensions(mut self, extensions: crate::setup::Extensions) -> Self { + self.extensions = extensions; self } @@ -359,10 +349,10 @@ impl Client { // Draft-17+: SETUP is exchanged by the connection driver. // We advertise the request path in our SETUP for URL-less transports. - // The peer's SETUP decides whether AUTH is negotiated. A client that - // declined the extension builds a handle that does not speak it, so its - // SETUP omits the option and no connection credential is presented. - let auth = crate::auth::Handle::new(!self.decline_auth_extension); + // The peer's SETUP decides whether AUTH is negotiated. A client that does not + // offer the extension builds a handle that does not speak it, so its SETUP + // omits the option and no connection credential is presented. + let auth = crate::auth::Handle::new(self.extensions.auth); let (protocol, goaway) = ietf::start(ietf::Config { runtime: runtime.clone(), session: session.clone(), @@ -380,8 +370,7 @@ impl Client { peer_declared: None, auth: auth.clone(), request_token: self.request_token.clone(), - // A client always declares MoQ Solicit; only a server declines it. - solicit: true, + extensions: self.extensions, })?; tracing::debug!(version = ?v, "connected"); @@ -459,7 +448,9 @@ impl Client { if let Some(authority) = &self.setup_authority { parameters.set_bytes(ietf::ParameterBytes::Authority, authority.clone().into_bytes()); } - ietf::solicit::into_setup(&mut parameters, ietf_encoding); + if self.extensions.solicit { + ietf::solicit::into_setup(&mut parameters, ietf_encoding); + } ietf::hidden::into_setup(&mut parameters, ietf_encoding); let parameters = parameters.encode_bytes(ietf_encoding)?; @@ -539,8 +530,7 @@ impl Client { peer_declared: Some(peer_declared), auth: auth.clone(), request_token: self.request_token.clone(), - // A client always declares MoQ Solicit; only a server declines it. - solicit: true, + extensions: self.extensions, })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 74912e2073..f09ef8dc0e 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -76,11 +76,11 @@ pub struct Config { /// runs. The default presents none, for a server or a client that presents none. pub request_token: crate::RequestToken, - /// Whether to declare the MoQ Solicit Setup Option in our SETUP (draft-17+). Default true. - /// A server built with `Server::without_solicit` passes false, so a peer that speaks the - /// extension sends an unsolicited PUBLISH_NAMESPACE (the base moq-transport behavior) - /// instead of answering our SUBSCRIBE_NAMESPACE inline. A client always declares it. - pub solicit: bool, + /// The extensions we offer in our SETUP (draft-17+). Without MoQ Solicit a peer that + /// speaks it sends an unsolicited PUBLISH_NAMESPACE (the base moq-transport behavior) + /// instead of answering our SUBSCRIBE_NAMESPACE inline. MoQ Auth is offered only when + /// [`Self::auth`] also supports it. + pub extensions: crate::setup::Extensions, } pub fn start(config: Config) -> Result<(MaybeSendBox<'static, Result<(), Error>>, crate::goaway::Handle), Error> @@ -104,8 +104,9 @@ where peer_declared, auth, request_token, - solicit, + extensions, } = config; + let solicit = extensions.solicit; // GOAWAY wiring: the public Session holds one half (drain trigger, received // signal), the protocol tasks below hold the other. @@ -325,8 +326,10 @@ where let runtime = runtime.clone(); let session = session.clone(); let goaway = goaway.clone(); - let declare_auth = auth.supported(); - let declare_solicit = solicit; + let extensions = crate::setup::Extensions { + auth: auth.supported(), + ..extensions + }; async move { if let Err(err) = run_setup( runtime, @@ -336,8 +339,7 @@ where authority, self_origin, cost, - declare_auth, - declare_solicit, + extensions, goaway, ) .await @@ -646,12 +648,8 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result( runtime: crate::time::Clock, @@ -661,8 +659,7 @@ async fn run_setup( authority: Option, self_origin: Hop, cost: Option, - declare_auth: bool, - declare_solicit: bool, + extensions: crate::setup::Extensions, goaway: crate::goaway::Protocol, ) -> Result<(), Error> { let outer_version = crate::Version::Ietf(version); @@ -679,11 +676,11 @@ async fn run_setup( parameters.set_bytes(ietf::ParameterBytes::Authority, authority.into_bytes()); } cluster::peer_into_setup(&mut parameters, self_origin, cost, version); - if declare_solicit { + if extensions.solicit { solicit::into_setup(&mut parameters, version); } hidden::into_setup(&mut parameters, version); - if declare_auth { + if extensions.auth { auth::into_setup(&mut parameters, version); } let parameters = parameters.encode_bytes(version)?; @@ -1194,7 +1191,7 @@ mod tests { }), auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), - solicit: true, + extensions: Default::default(), }) .expect("start the session"); @@ -1249,7 +1246,7 @@ mod tests { peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), - solicit: true, + extensions: Default::default(), }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1382,7 +1379,7 @@ mod tests { peer_declared, auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), - solicit: true, + extensions: Default::default(), }) .expect("start the session"); let _driver = tokio::spawn(driver); @@ -1485,7 +1482,7 @@ mod tests { }), auth: handle.clone(), request_token: crate::RequestToken::default(), - solicit: true, + extensions: Default::default(), }) .expect("start the session"); AuthSession { @@ -1600,7 +1597,7 @@ mod tests { peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), - solicit: true, + extensions: Default::default(), }) .expect("start the session"); @@ -1641,7 +1638,7 @@ mod tests { peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), - solicit: true, + extensions: Default::default(), }) .expect("start the session"); @@ -1841,7 +1838,7 @@ mod tests { peer_declared: None, auth: crate::auth::Handle::new(false), request_token: crate::RequestToken::default(), - solicit: true, + extensions: Default::default(), }) .expect("start the session"); let driver = tokio::spawn(driver); diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index e413e4b9b3..a40633a95d 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -445,7 +445,7 @@ pub(super) struct Subscriber { // session runs; the default presents none. A client credential. request_token: crate::RequestToken, // Whether we declared MoQ Solicit in our SETUP (`solicit::into_setup`). True by default; - // a server built with `Server::without_solicit` sets it false. It gates whether an + // a side that does not offer it (`Extensions::solicit` off) sets it false. It gates whether an // unsolicited PUBLISH_NAMESPACE from a solicit-aware peer is a violation: only a peer that // disregarded a requirement we actually stated is at fault. declared_solicit: bool, @@ -581,8 +581,8 @@ where }); } - /// Whether we declared MoQ Solicit in our SETUP. A server built with - /// [`Server::without_solicit`](crate::Server::without_solicit) passes false, so an + /// Whether we declared MoQ Solicit in our SETUP. A side that does not offer it + /// ([`Extensions::solicit`](crate::setup::Extensions::solicit) off) passes false, so an /// unsolicited PUBLISH_NAMESPACE from a solicit-aware peer is expected rather than a /// violation. pub fn with_solicit(mut self, declared: bool) -> Self { @@ -1032,8 +1032,8 @@ where /// request is also how a peer answers our SUBSCRIBE_NAMESPACE there, and the message /// alone does not say which it is. fn unsolicited_is_a_violation(&self, declared: Option) -> bool { - // We only hold a peer to a requirement we actually stated. A server that declined MoQ - // Solicit (`Server::without_solicit`) invited unsolicited advertisements, so one is + // We only hold a peer to a requirement we actually stated. A side that did not offer MoQ + // Solicit invited unsolicited advertisements, so one is // expected even from a solicit-aware peer. if !self.declared_solicit { return false; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index a0147eea23..f817879871 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -20,7 +20,7 @@ pub struct Server { subscribe: Option, stats: stats::Session, versions: Versions, - decline_solicit: bool, + extensions: crate::setup::Extensions, } impl Server { @@ -66,18 +66,9 @@ impl Server { self } - /// Do not require solicited announcements: omit the MoQ Solicit Setup Option from this - /// server's SETUP. - /// - /// By default a server declares MoQ Solicit, so a peer that speaks the extension answers - /// our SUBSCRIBE_NAMESPACE inline rather than sending an unsolicited PUBLISH_NAMESPACE. - /// Declining it makes such a peer fall back to the base moq-transport behavior and send an - /// unsolicited PUBLISH_NAMESPACE. Use for peers that do not speak the MoQ Solicit extension - /// (a standard moq-transport encoder or CDN), which never solicit and always announce - /// unasked; the server already accepts an unsolicited PUBLISH_NAMESPACE either way. Additive: - /// the default still declares the extension. - pub fn without_solicit(mut self) -> Self { - self.decline_solicit = true; + /// Choose which moq-transport extensions this server offers in its SETUP. Defaults to all. + pub fn with_extensions(mut self, extensions: crate::setup::Extensions) -> Self { + self.extensions = extensions; self } @@ -442,8 +433,9 @@ impl Server { origin: peer_setup.declared.cluster.hop.filter(|h| *h != crate::Hop::UNKNOWN), token: peer_setup.token.clone(), assigned_hop: crate::Hop::random(), - // The client's SETUP already settled whether MoQ Auth is negotiated. - auth: crate::auth::Handle::new(peer_setup.declared.auth), + // The client's SETUP already settled whether MoQ Auth is negotiated, unless this + // server does not offer it. + auth: crate::auth::Handle::new(peer_setup.declared.auth && self.extensions.auth), inner: Some(RequestInner { server: self.clone(), runtime, @@ -582,8 +574,7 @@ where peer_declared: Some(peer_setup.declared), auth: auth.clone(), request_token: crate::RequestToken::default(), - // Declare MoQ Solicit unless the server declined it. - solicit: !server.decline_solicit, + extensions: server.extensions, })?; tracing::debug!(?version, "connected"); Ok(Session::new( @@ -646,8 +637,7 @@ where let mut parameters = ietf::Parameters::default(); parameters.set_varint(ietf::ParameterVarInt::MaxRequestId, u32::MAX as u64); parameters.set_bytes(ietf::ParameterBytes::Implementation, b"moq-lite-rs".to_vec()); - // Declare MoQ Solicit unless the server declined it. - if !server.decline_solicit { + if server.extensions.solicit { ietf::solicit::into_setup(&mut parameters, v); } ietf::hidden::into_setup(&mut parameters, v); @@ -706,9 +696,9 @@ where peer_declared: Some(peer_declared), auth: auth.clone(), request_token: crate::RequestToken::default(), - // The legacy server already declared Solicit (or not) in its SETUP above; - // this arm sends no further SETUP, so the flag is inert here. - solicit: !server.decline_solicit, + // The legacy server already declared its extensions in its SETUP above; + // this arm sends no further SETUP. + extensions: server.extensions, })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) } diff --git a/rs/moq-net/src/setup.rs b/rs/moq-net/src/setup.rs index 24caaffebb..ae076f55ed 100644 --- a/rs/moq-net/src/setup.rs +++ b/rs/moq-net/src/setup.rs @@ -19,6 +19,28 @@ const SERVER_SETUP: u8 = 0x21; /// Draft-17 unified SETUP message type (varint 0x2F00) pub(crate) const SETUP_V17: u64 = 0x2F00; +/// The moq-transport Setup Options this side offers, each on by default. Turning one off +/// connects as a peer that does not speak that extension; moq-lite carries both in its core. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +#[non_exhaustive] +pub struct Extensions { + /// The MoQ Auth extension: tokens presented and granted on their own stream. + pub auth: bool, + /// The MoQ Solicit extension: the peer answers our SUBSCRIBE_NAMESPACE rather than + /// announcing unasked. + pub solicit: bool, +} + +impl Default for Extensions { + fn default() -> Self { + Self { + auth: true, + solicit: true, + } + } +} + /// A credential a moq-transport peer presented in its SETUP's `AUTHORIZATION TOKEN` option. /// /// The transport never reads the bytes; verifying them is the application's job. diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 92f7e29788..30445a7f9f 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -168,10 +168,12 @@ struct Options { server_requests: bool, /// A request token the client attaches to its outgoing PUBLISH_NAMESPACE / SUBSCRIBE. client_request_token: Option>, - /// The client declines the MoQ Auth extension (`Client::without_auth_extension`). + /// The client does not offer the MoQ Auth extension (`Extensions::auth` off). client_decline_auth: bool, - /// The server declines the MoQ Solicit extension (`Server::without_solicit`). + /// The server does not offer the MoQ Solicit extension (`Extensions::solicit` off). server_decline_solicit: bool, + /// The server does not offer the MoQ Auth extension (`Extensions::auth` off). + server_decline_auth: bool, version: Option<&'static str>, } @@ -200,12 +202,17 @@ async fn connect(opts: Options) -> Pair { client = client.with_request_token(token); } if opts.client_decline_auth { - client = client.without_auth_extension(); + let mut extensions = moq_net::setup::Extensions::default(); + extensions.auth = false; + client = client.with_extensions(extensions); } let mut server = Server::new().with_versions(version.into()); - if opts.server_decline_solicit { - server = server.without_solicit(); + if opts.server_decline_solicit || opts.server_decline_auth { + let mut extensions = moq_net::setup::Extensions::default(); + extensions.solicit = !opts.server_decline_solicit; + extensions.auth = !opts.server_decline_auth; + server = server.with_extensions(extensions); } if let Some(publish) = &opts.server_publish { server = server.with_publisher(publish); @@ -413,6 +420,37 @@ async fn a_request_token_authorizes_a_legacy_announce_through_the_driver() { /// draft-16+ regardless of this option: moq-net declares MoQ Solicit unconditionally, so /// the announce answers the peer's SUBSCRIBE_NAMESPACE inline via `ietf::Namespace`, which /// carries no token, and the token-bearing unsolicited PUBLISH_NAMESPACE loop is disabled. +/// A server that does not offer the MoQ Auth extension leaves it un-negotiated even with a +/// client that offers it: neither side presents a session token. +#[tokio::test] +async fn a_server_may_decline_the_auth_extension() { + within(async { + // Control: with the default extensions the client's connection credential earns a grant. + let offered = connect(Options { + version: Some(MOQT_18), + ..Default::default() + }) + .await; + wait_for(offered.client.auth().grant(), Option::is_some).await.unwrap(); + + let declined = connect(Options { + version: Some(MOQT_18), + server_decline_auth: true, + ..Default::default() + }) + .await; + let granted = tokio::time::timeout( + Duration::from_millis(200), + wait_for(declined.client.auth().grant(), Option::is_some), + ) + .await; + assert!(granted.is_err(), "no grant without the extension: {granted:?}"); + assert!(matches!(declined.client.auth().add("x").await, Err(Error::Unsupported))); + }) + .await + .expect("timed out"); +} + #[tokio::test] async fn a_client_may_decline_the_auth_extension() { within(async { diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index f6828e5ba1..c4d0b7a40e 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -132,7 +132,9 @@ impl Client { let timeout = resolved.timeout; Ok(Self { - moq: moq_net::Client::new().with_versions(versions.clone()), + moq: moq_net::Client::new() + .with_versions(versions.clone()) + .with_extensions(config.extensions), #[cfg(any( feature = "noq", feature = "iroh", @@ -239,13 +241,6 @@ impl Client { self.moq.set_request_token(token); } - /// Do not declare the MoQ Auth extension in this client's SETUP, so it connects as a - /// peer without it (interop testing); see [`moq_net::Client::without_auth_extension`]. - pub fn without_auth_extension(mut self) -> Self { - self.moq = self.moq.without_auth_extension(); - self - } - /// Override whether this client redials after a session drop. /// /// Defaults to true, unless [`crate::connect::Config::once`] turned it off. diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 3dbbe6a889..1b0e518e1a 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -361,6 +361,20 @@ impl ConnectError { mod tests { use super::*; + /// The dial config offers every extension unless told otherwise, and leaves the + /// default out when serialized. + #[test] + fn extensions_default_on_and_parse_off() { + let config: Config = toml::from_str("[extensions]\nsolicit = false\n").expect("parse"); + assert!(!config.extensions.solicit); + assert!(config.extensions.auth); + assert!( + !toml::to_string(&Config::default()) + .expect("serialize") + .contains("extensions") + ); + } + #[test] fn auth_statuses_are_terminal() { assert_eq!(ConnectError::from_status_u16(401), Some(ConnectError::Unauthorized)); @@ -464,6 +478,11 @@ failover_delay = "1s" } } +/// Whether `extensions` is the default, every extension offered; such a config omits it. +pub(crate) fn all_extensions(extensions: &moq_net::setup::Extensions) -> bool { + *extensions == moq_net::setup::Extensions::default() +} + /// The dial side of an endpoint: where to connect and how to get there. /// /// Derives [`usage::Args`], so flatten it into a binary's own parser with @@ -609,6 +628,11 @@ pub struct Config { )] pub version: Vec, + /// The moq-transport extensions to offer in the SETUP, all by default. + #[serde(default, skip_serializing_if = "crate::connect::all_extensions")] + #[usage(skip)] + pub extensions: moq_net::setup::Extensions, + /// TLS trust and client-certificate settings (`--connect-tls-*`). #[usage(flatten)] #[serde(default)] @@ -687,6 +711,7 @@ impl Default for Config { timeout: DEFAULT_TIMEOUT, timeout_arg: None, version: Vec::new(), + extensions: Default::default(), tls: Default::default(), once: None, reconnect: None, diff --git a/rs/moq-tokio/src/listen.rs b/rs/moq-tokio/src/listen.rs index 2d2cea2e92..3ee8cbd4df 100644 --- a/rs/moq-tokio/src/listen.rs +++ b/rs/moq-tokio/src/listen.rs @@ -143,6 +143,11 @@ pub struct Config { )] pub version: Vec, + /// The moq-transport extensions to offer in the SETUP, all by default. + #[serde(default, skip_serializing_if = "crate::connect::all_extensions")] + #[usage(skip)] + pub extensions: moq_net::setup::Extensions, + /// The certificates to serve and the roots that authenticate mTLS clients /// (`--listen-tls-*`). #[usage(flatten)] @@ -392,6 +397,20 @@ impl Config { #[cfg(test)] mod tests { use super::*; + /// The listen config offers every extension unless told otherwise, and leaves the + /// default out when serialized. + #[test] + fn extensions_default_on_and_parse_off() { + let config: Config = toml::from_str("[extensions]\nsolicit = false\n").expect("parse"); + assert!(!config.extensions.solicit); + assert!(config.extensions.auth); + assert!( + !toml::to_string(&Config::default()) + .expect("serialize") + .contains("extensions") + ); + } + /// A parser wrapping the config, since it derives `Args` (see the note in /// [`crate::connect`]). #[derive(usage::Cli)] diff --git a/rs/moq-tokio/src/server.rs b/rs/moq-tokio/src/server.rs index d5ddebbf87..84dfaa19ac 100644 --- a/rs/moq-tokio/src/server.rs +++ b/rs/moq-tokio/src/server.rs @@ -350,7 +350,10 @@ impl Server { iroh::listen(endpoint, &versions)?; } - let mut moq = moq_net::Server::new().with_versions(versions.clone()).with_stats(stats); + let mut moq = moq_net::Server::new() + .with_versions(versions.clone()) + .with_extensions(config.extensions) + .with_stats(stats); if let Some(publisher) = publisher { moq = moq.with_publisher(publisher); } @@ -447,18 +450,6 @@ impl Server { crate::tls::Certificates::empty() } - /// Do not require solicited announcements: omit the MoQ Solicit Setup Option from every - /// session this server accepts; see [`moq_net::Server::without_solicit`]. - /// - /// A peer that speaks the extension then sends an unsolicited PUBLISH_NAMESPACE (the base - /// moq-transport behavior) instead of answering our SUBSCRIBE_NAMESPACE inline. Use for - /// peers that do not speak MoQ Solicit (a standard moq-transport encoder or CDN). Additive: - /// the default still declares the extension. - pub fn without_solicit(mut self) -> Self { - self.moq = self.moq.without_solicit(); - self - } - /// Clone this server's QUIC endpoint, keeping its socket in the reuseport /// group after this server is gone. /// From 22b951f5607150a2fcd22cd9501c9510adf912d4 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 00:33:38 +0000 Subject: [PATCH 36/49] feat(net): present request tokens through the auth handle The request-token credential moves from the Client builder onto the session's auth::Handle, beside session tokens and distinguished by kind: Handle::add presents a connection credential for the whole session, Handle::set_request_token the AUTHORIZATION TOKEN carried on this side's own SUBSCRIBE and PUBLISH_NAMESPACE requests. Client::with_request_token and Client::set_request_token are gone. Set it on Session::auth() before running the driver and the first request carries it; set it again on a live session and every live request re-presents it as a REQUEST_UPDATE, which is client-side renewal. The private ietf session Config no longer carries the token; the session reads it from its handle. moq-tokio seeds it from the dial config: connect::Config::with_request_token applies the token to each session's handle before its driver spawns, so every reconnected session presents it. The seed is skipped by serde and shown by length only in Debug. moq-tokio has no live renewal until Connection::auth() exists; Session::auth() renews on moq-net. Tests: a_request_token_renews_a_subscription_through_the_driver (renewal over the wire through both drivers at draft-18, outliving the first grant's expiry on the same subscription), the integration harness now seeding through Session::auth(), and moq-tokio a_request_token_stays_out_of_debug_and_serde. Co-Authored-By: Claude --- rs/moq-net/src/auth.rs | 70 +++++++++++++++++++++++++++ rs/moq-net/src/client.rs | 80 ------------------------------- rs/moq-net/src/ietf/session.rs | 14 +----- rs/moq-net/src/lib.rs | 1 + rs/moq-net/src/server.rs | 2 - rs/moq-net/tests/auth.rs | 88 ++++++++++++++++++++++++++++++++-- rs/moq-tokio/src/client.rs | 75 +++++++++++++++-------------- rs/moq-tokio/src/connect.rs | 36 ++++++++++++++ 8 files changed, 233 insertions(+), 133 deletions(-) diff --git a/rs/moq-net/src/auth.rs b/rs/moq-net/src/auth.rs index 297b0e7177..1d29422106 100644 --- a/rs/moq-net/src/auth.rs +++ b/rs/moq-net/src/auth.rs @@ -238,12 +238,64 @@ impl Permit { } } +/// The `AUTHORIZATION TOKEN` this side presents on its own SUBSCRIBE and PUBLISH_NAMESPACE +/// requests (MoQ request-token), shared between a session's [`Handle`] and its driver. +/// +/// The session reads the current value when it first sends a request, and re-presents a +/// changed one on each live request as a REQUEST_UPDATE. The default presents no token, +/// which is byte-identical to a session that never sets one. +#[derive(Clone, Default)] +pub(crate) struct RequestToken { + token: kio::Shared>, +} + +impl RequestToken { + /// A credential presenting `token` (or none) until replaced. + #[cfg(test)] + pub(crate) fn new(token: Option) -> Self { + Self { + token: kio::Shared::new(token), + } + } + + /// Replace the token presented on this session's requests. A live request re-presents + /// it as a REQUEST_UPDATE on its next turn; setting the same value again is a no-op. + pub(crate) fn set(&self, token: Option) { + *self.token.lock() = token; + } + + /// The token to present right now, read when a request is first sent. + pub(crate) fn peek(&self) -> Option { + self.token.read().clone() + } + + /// Ready with the current token once it differs from `last`, registering `waiter` + /// otherwise. The send loops park here to re-present a replaced token on their live + /// requests; it reads without advancing `last`, so a poll that loses its turn to + /// another arm is re-offered the change rather than dropping it. + pub(crate) fn poll_changed( + &self, + last: &Option, + waiter: &kio::Waiter, + ) -> std::task::Poll> { + use std::task::Poll; + match self.token.poll(waiter, |cur| match **cur == *last { + true => Poll::Pending, + false => Poll::Ready(()), + }) { + Poll::Ready(guard) => Poll::Ready((*guard).clone()), + Poll::Pending => Poll::Pending, + } + } +} + /// The session's auth handle, returned by [`Session::auth`](crate::Session::auth). /// /// Cheap to clone; every clone shares the session's tokens. #[derive(Clone)] pub struct Handle { state: kio::Shared, + request_token: RequestToken, } impl Handle { @@ -254,9 +306,27 @@ impl Handle { supported, ..Default::default() }), + request_token: RequestToken::default(), } } + /// Present `token` as the `AUTHORIZATION TOKEN` on this side's own requests (MoQ + /// request-token), as distinct from a connection credential, which [`add`](Self::add) + /// presents for the whole session. + /// + /// Set it before running the session's driver to present it on the first request. + /// Replacing it later re-presents the new token on every live request as a + /// REQUEST_UPDATE, renewing a token-authorized request in place; setting the same value + /// again sends nothing. Independent of the MoQ Auth extension. + pub fn set_request_token(&self, token: impl Into) { + self.request_token.set(Some(token.into())); + } + + /// The request-token cell the session's publisher and subscriber present from. + pub(crate) fn request_token(&self) -> RequestToken { + self.request_token.clone() + } + /// Whether this session speaks the AUTH extension. False on a version that cannot /// negotiate it, and on a handle whose side does not offer it (`Extensions::auth` off), /// so the SETUP omits the option and no diff --git a/rs/moq-net/src/client.rs b/rs/moq-net/src/client.rs index 201c4416fe..dc030341f1 100644 --- a/rs/moq-net/src/client.rs +++ b/rs/moq-net/src/client.rs @@ -9,59 +9,6 @@ use crate::{ ietf, lite, setup, stats, }; -/// A client's request-token credential: the `AUTHORIZATION TOKEN` it presents on its own -/// SUBSCRIBE and PUBLISH_NAMESPACE requests (MoQ request-token), shared with the running -/// session so it can be replaced without reconnecting. -/// -/// Cloning shares the cell, so [`Client::set_request_token`] on any handle reaches every -/// session started from that [`Client`]: the session reads the current value when it first -/// sends a request, and re-presents a changed one on each live request as a REQUEST_UPDATE. -/// The default presents no token, which is byte-identical to a client that never sets one. -#[derive(Clone, Default)] -pub(crate) struct RequestToken { - token: kio::Shared>, -} - -impl RequestToken { - /// A credential presenting `token` (or none) until replaced. - #[cfg(test)] - pub(crate) fn new(token: Option) -> Self { - Self { - token: kio::Shared::new(token), - } - } - - /// Replace the token presented on this session's requests. A live request re-presents - /// it as a REQUEST_UPDATE on its next turn; setting the same value again is a no-op. - pub(crate) fn set(&self, token: Option) { - *self.token.lock() = token; - } - - /// The token to present right now, read when a request is first sent. - pub(crate) fn peek(&self) -> Option { - self.token.read().clone() - } - - /// Ready with the current token once it differs from `last`, registering `waiter` - /// otherwise. The send loops park here to re-present a replaced token on their live - /// requests; it reads without advancing `last`, so a poll that loses its turn to - /// another arm is re-offered the change rather than dropping it. - pub(crate) fn poll_changed( - &self, - last: &Option, - waiter: &kio::Waiter, - ) -> std::task::Poll> { - use std::task::Poll; - match self.token.poll(waiter, |cur| match **cur == *last { - true => Poll::Pending, - false => Poll::Ready(()), - }) { - Poll::Ready(guard) => Poll::Ready((*guard).clone()), - Poll::Pending => Poll::Pending, - } - } -} - /// A MoQ client session builder. #[derive(Default, Clone)] pub struct Client { @@ -73,7 +20,6 @@ pub struct Client { setup_authority: Option, cost: Option, peer_hop: Option, - request_token: RequestToken, extensions: crate::setup::Extensions, } @@ -187,30 +133,6 @@ impl Client { self } - /// Present an `AUTHORIZATION TOKEN` on this client's own requests (SUBSCRIBE, - /// PUBLISH_NAMESPACE, and their REQUEST_UPDATEs), so a request the session grant does not - /// cover is authorized the standard draft-17+ way (MoQ request-token) rather than needing - /// the moq-dev AUTH-stream extension. The value is the section 8.9 Token structure the - /// peer's verifier reads; a relay that takes [`Session::auth`](crate::Session::auth)'s - /// requests answers it. Rides draft-17+ message parameters and draft-14's trailing block; - /// omit to send none. - pub fn with_request_token(self, token: impl Into) -> Self { - self.request_token.set(Some(token.into())); - self - } - - /// Replace the `AUTHORIZATION TOKEN` this client presents, for a session already running. - /// - /// The credential is shared with the session, so this re-presents the new token on every - /// live request as a REQUEST_UPDATE (a SUBSCRIBE renewal, or a PUBLISH_NAMESPACE renewal on - /// draft-17+, where that message exists), keeping a token-authorized request alive past its - /// old grant's expiry without reconnecting. Setting the same value again sends nothing. - /// A client that never presented a token (no [`with_request_token`](Self::with_request_token)) - /// begins presenting one from its next request. - pub fn set_request_token(&self, token: impl Into) { - self.request_token.set(Some(token.into())); - } - /// Choose which moq-transport extensions this client offers in its SETUP. Defaults to all. pub fn with_extensions(mut self, extensions: crate::setup::Extensions) -> Self { self.extensions = extensions; @@ -369,7 +291,6 @@ impl Client { peer_setup_stream: None, peer_declared: None, auth: auth.clone(), - request_token: self.request_token.clone(), extensions: self.extensions, })?; @@ -529,7 +450,6 @@ impl Client { peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), - request_token: self.request_token.clone(), extensions: self.extensions, })?; (None, crate::driver::Protocol::Ietf(protocol), goaway, auth) diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index f09ef8dc0e..e9e0338545 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -71,11 +71,6 @@ pub struct Config { /// version can negotiate it; the peer's SETUP decides whether it does. pub auth: crate::auth::Handle, - /// The AUTHORIZATION TOKEN a client presents on its own SUBSCRIBE / PUBLISH_NAMESPACE - /// requests (MoQ request-token), a shared handle so it can be replaced while the session - /// runs. The default presents none, for a server or a client that presents none. - pub request_token: crate::RequestToken, - /// The extensions we offer in our SETUP (draft-17+). Without MoQ Solicit a peer that /// speaks it sends an unsolicited PUBLISH_NAMESPACE (the base moq-transport behavior) /// instead of answering our SUBSCRIBE_NAMESPACE inline. MoQ Auth is offered only when @@ -103,9 +98,9 @@ where peer_setup_stream, peer_declared, auth, - request_token, extensions, } = config; + let request_token = auth.request_token(); let solicit = extensions.solicit; // GOAWAY wiring: the public Session holds one half (drain trigger, received @@ -1190,7 +1185,6 @@ mod tests { ..Default::default() }), auth: crate::auth::Handle::new(false), - request_token: crate::RequestToken::default(), extensions: Default::default(), }) .expect("start the session"); @@ -1245,7 +1239,6 @@ mod tests { // The requests wait on the peer's SETUP (MoQ Hidden). peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), - request_token: crate::RequestToken::default(), extensions: Default::default(), }) .expect("start the session"); @@ -1378,7 +1371,6 @@ mod tests { peer_setup_stream: None, peer_declared, auth: crate::auth::Handle::new(false), - request_token: crate::RequestToken::default(), extensions: Default::default(), }) .expect("start the session"); @@ -1481,7 +1473,6 @@ mod tests { ..Default::default() }), auth: handle.clone(), - request_token: crate::RequestToken::default(), extensions: Default::default(), }) .expect("start the session"); @@ -1596,7 +1587,6 @@ mod tests { // carry and the dispatch loop actually runs. peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), - request_token: crate::RequestToken::default(), extensions: Default::default(), }) .expect("start the session"); @@ -1637,7 +1627,6 @@ mod tests { peer_setup_stream: None, peer_declared: Some(peer::Peer::default()), auth: crate::auth::Handle::new(false), - request_token: crate::RequestToken::default(), extensions: Default::default(), }) .expect("start the session"); @@ -1837,7 +1826,6 @@ mod tests { peer_setup_stream: None, peer_declared: None, auth: crate::auth::Handle::new(false), - request_token: crate::RequestToken::default(), extensions: Default::default(), }) .expect("start the session"); diff --git a/rs/moq-net/src/lib.rs b/rs/moq-net/src/lib.rs index a7f20a366a..5e04e2acca 100644 --- a/rs/moq-net/src/lib.rs +++ b/rs/moq-net/src/lib.rs @@ -100,6 +100,7 @@ pub mod stats; pub mod time; pub mod transport; +pub(crate) use auth::RequestToken; pub use client::*; pub use coding::{BoundsExceeded, DecodeError, EncodeError, VarInt}; pub use driver::Driver; diff --git a/rs/moq-net/src/server.rs b/rs/moq-net/src/server.rs index f817879871..3a34377ba2 100644 --- a/rs/moq-net/src/server.rs +++ b/rs/moq-net/src/server.rs @@ -573,7 +573,6 @@ where peer_setup_stream: Some(peer_setup.stream), peer_declared: Some(peer_setup.declared), auth: auth.clone(), - request_token: crate::RequestToken::default(), extensions: server.extensions, })?; tracing::debug!(?version, "connected"); @@ -695,7 +694,6 @@ where peer_setup_stream: None, peer_declared: Some(peer_declared), auth: auth.clone(), - request_token: crate::RequestToken::default(), // The legacy server already declared its extensions in its SETUP above; // this arm sends no further SETUP. extensions: server.extensions, diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 30445a7f9f..99349326f9 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -198,9 +198,6 @@ async fn connect(opts: Options) -> Pair { if let Some(subscribe) = opts.client_subscribe { client = client.with_subscriber(subscribe); } - if let Some(token) = opts.client_request_token { - client = client.with_request_token(token); - } if opts.client_decline_auth { let mut extensions = moq_net::setup::Extensions::default(); extensions.auth = false; @@ -223,8 +220,13 @@ async fn connect(opts: Options) -> Pair { let observe = client_transport.clone(); let observe_server = server_transport.clone(); + let client_token = opts.client_request_token; let client_fut = async { let (session, driver) = client.connect(now(), client_transport).await.expect("client handshake"); + // Set before the driver runs, so the first request already carries it. + if let Some(token) = client_token { + session.auth().set_request_token(token); + } tokio::spawn(run(driver)); session }; @@ -513,6 +515,86 @@ async fn a_client_may_decline_the_auth_extension() { .expect("timed out"); } +/// A request token renews over the wire: replacing it on the client's `Session::auth()` +/// re-presents it on the live SUBSCRIBE as a REQUEST_UPDATE, the server's driver routes it to +/// the acceptor, and the new grant keeps the subscription alive past the old one's expiry. +/// Runs through both drivers at draft-18, in the shape a base moq-transport peer produces +/// (no MoQ Auth, so the token is what authorizes). +#[tokio::test] +async fn a_request_token_renews_a_subscription_through_the_driver() { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + let server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", None).unwrap(); + down.announce(Default::default()).unwrap(); + + let first = vec![0x03, 0x00, b'a', b'a']; + let second = vec![0x03, 0x00, b'b', b'b']; + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(MOQT_18), + client_subscribe: Some(received.clone()), + client_request_token: Some(first.clone()), + client_decline_auth: true, + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // The first token lapses in a second; the renewal never does. + let expires = Some(now() + Duration::from_secs(1)); + // The acceptor sees the Token structure's value, past its USE_VALUE header. + let renewal = second[2..].to_vec(); + let requests = pair.requests.take().expect("server took its requests pre-ok"); + let mut answered = serve(requests, move |token| { + let mut granted = grant(&[], &["room/alice"]); + if token != renewal.as_slice() { + granted.expires = expires; + } + Some(granted) + }); + + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"one".as_ref()).unwrap(); + group.finish().unwrap(); + let (token, _first_issued) = answered.recv().await.expect("the first token reached the acceptor"); + assert_eq!(token, first[2..]); + sub.recv_group().await.unwrap().unwrap(); + + pair.client.auth().set_request_token(second.clone()); + let (token, _renewed) = answered.recv().await.expect("the renewal reached the acceptor"); + assert_eq!(token, second[2..], "the replaced token rides the REQUEST_UPDATE"); + + // Past the first grant's expiry the subscription still delivers. + tokio::time::sleep(Duration::from_millis(1500)).await; + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(2000), b"two".as_ref()).unwrap(); + group.finish().unwrap(); + sub.recv_group() + .await + .unwrap() + .expect("the renewed subscription is still live"); + assert_eq!( + pair.client_transport.close_reason(), + None, + "renewal never touches the session" + ); + // The same subscription carried on: a lapse would have ended it, and the client's + // re-subscribe would have reached the acceptor as another request. + assert!( + answered.try_recv().is_err(), + "the original subscription was renewed, not replaced" + ); + }) + .await + .expect("timed out"); +} + /// A server may decline the MoQ Solicit extension, so a peer sends an unsolicited /// PUBLISH_NAMESPACE (the base moq-transport behavior) instead of answering our /// SUBSCRIBE_NAMESPACE inline. Only the unsolicited PUBLISH_NAMESPACE carries an diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index c4d0b7a40e..76e157df4a 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -42,6 +42,8 @@ impl Config { #[derive(Clone)] pub struct Client { moq: moq_net::Client, + /// The request token each session presents, from [`crate::connect::Config::with_request_token`]. + request_token: Option, /// The single resolved set of protocol versions, used to advertise moq ALPNs across /// every transport (passed into the QUIC backend's `connect` and used directly for /// raw TCP/UDS qmux and WebSocket). Resolved once in [`Client::new`] so the ALPN list @@ -135,6 +137,7 @@ impl Client { moq: moq_net::Client::new() .with_versions(versions.clone()) .with_extensions(config.extensions), + request_token: config.request_token.clone().map(|token| token.0), #[cfg(any( feature = "noq", feature = "iroh", @@ -225,22 +228,6 @@ impl Client { self } - /// Present an `AUTHORIZATION TOKEN` on this client's own requests (SUBSCRIBE / - /// PUBLISH_NAMESPACE and their REQUEST_UPDATEs), so a request the session grant does not - /// cover is authorized the standard draft-17+ way (MoQ request-token); see - /// [`moq_net::Client::with_request_token`]. - pub fn with_request_token(mut self, token: impl Into) -> Self { - self.moq = self.moq.with_request_token(token); - self - } - - /// Replace the `AUTHORIZATION TOKEN` this client presents, for a session already running, - /// so a refreshed credential is re-presented on every live request as a REQUEST_UPDATE - /// without reconnecting; see [`moq_net::Client::set_request_token`]. - pub fn set_request_token(&self, token: impl Into) { - self.moq.set_request_token(token); - } - /// Override whether this client redials after a session drop. /// /// Defaults to true, unless [`crate::connect::Config::once`] turned it off. @@ -399,7 +386,12 @@ impl Client { if url.scheme() == "tcp" { let session = crate::tcp::connect(url, &self.versions.alpns(), self.failover_delay, self.resolution_delay).await?; - return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); + return Ok(connect_session( + &moq, + self.request_token.as_ref(), + crate::transport::Session::new(session), + ) + .await?); } // Unix domain socket (qmux, no TLS). Same-host only; the server can @@ -407,7 +399,12 @@ impl Client { #[cfg(all(feature = "uds", unix))] if url.scheme() == "unix" { let session = crate::unix::connect(url, &self.versions.alpns()).await?; - return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); + return Ok(connect_session( + &moq, + self.request_token.as_ref(), + crate::transport::Session::new(session), + ) + .await?); } // A WebSocket URL names its transport. No QUIC backend can dial it, so there is @@ -433,7 +430,12 @@ impl Client { crate::iroh::Binding::H3 => self.moq.clone(), }; - return Ok(connect_session(&moq, crate::transport::Session::new(session)).await?); + return Ok(connect_session( + &moq, + self.request_token.as_ref(), + crate::transport::Session::new(session), + ) + .await?); } #[cfg(feature = "noq")] @@ -455,7 +457,7 @@ impl Client { #[cfg(not(feature = "websocket"))] { let session = quic_handle.await?; - return Ok(connect_session(&moq, session).await?); + return Ok(connect_session(&moq, self.request_token.as_ref(), session).await?); } } @@ -473,7 +475,12 @@ impl Client { let alpns = self.versions.alpns(); let session = crate::websocket::connect(&self.websocket, &self.tls, self.tls_host_name.as_deref(), addr, &alpns).await?; - Ok(connect_session(&self.moq, crate::transport::Session::new(session)).await?) + Ok(connect_session( + &self.moq, + self.request_token.as_ref(), + crate::transport::Session::new(session), + ) + .await?) } /// Race the QUIC dial against the WebSocket fallback, handshaking whichever wins. @@ -506,10 +513,13 @@ impl Client { }; match race_transport_connect(quic, websocket).await? { - TransportRace::Quic(quic) => Ok(connect_session(moq, quic).await?), - TransportRace::WebSocket(websocket) => { - Ok(connect_session(&self.moq, crate::transport::Session::new(websocket)).await?) - } + TransportRace::Quic(quic) => Ok(connect_session(moq, self.request_token.as_ref(), quic).await?), + TransportRace::WebSocket(websocket) => Ok(connect_session( + &self.moq, + self.request_token.as_ref(), + crate::transport::Session::new(websocket), + ) + .await?), } } } @@ -674,11 +684,16 @@ where ))] async fn connect_session( client: &moq_net::Client, + request_token: Option<&bytes::Bytes>, transport: S, ) -> Result { let (session, driver) = client .connect(tokio::time::Instant::now().into_std(), transport) .await?; + // Before the driver runs, so the session's first request already carries it. + if let Some(token) = request_token { + session.auth().set_request_token(token.clone()); + } use tracing::Instrument; tokio::spawn(moq_net::time::run(driver).instrument(tracing::Span::current())); Ok(session) @@ -688,16 +703,6 @@ async fn connect_session( mod tests { use super::*; - /// Compile-level check that the moq-tokio client exposes `with_request_token` and chains - /// (returns `Self`), delegating to `moq_net::Client`. The token's wire round-trip is proven - /// at the moq-net layer (`a_configured_request_token_rides_the_publish_namespace`); moq-tokio - /// has no in-process wire harness to re-run it here, and building a `Client` needs a - /// transport feature, so this is a builder-signature check rather than a live send. - #[allow(dead_code)] - fn with_request_token_chains(client: Client) -> Client { - client.with_request_token(bytes::Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff])) - } - #[cfg(feature = "noq")] #[tokio::test] async fn fixed_target_preserves_request_and_refuses_redirect() { diff --git a/rs/moq-tokio/src/connect.rs b/rs/moq-tokio/src/connect.rs index 1b0e518e1a..cf913a730f 100644 --- a/rs/moq-tokio/src/connect.rs +++ b/rs/moq-tokio/src/connect.rs @@ -361,6 +361,17 @@ impl ConnectError { mod tests { use super::*; + /// A request token seeded on the dial config is kept out of its `Debug` and never + /// serialized, since a config is routinely logged and written back. + #[test] + fn a_request_token_stays_out_of_debug_and_serde() { + let config = Config::default().with_request_token(&b"s3cr3t"[..]); + assert!(config.request_token.is_some()); + let debug = format!("{config:?}"); + assert!(!debug.contains("s3cr3t") && debug.contains("<6 bytes>"), "{debug}"); + assert!(!toml::to_string(&config).expect("serialize").contains("request_token")); + } + /// The dial config offers every extension unless told otherwise, and leaves the /// default out when serialized. #[test] @@ -478,6 +489,16 @@ failover_delay = "1s" } } +/// A request token held by a [`Config`], shown by length only so it stays out of logs. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct RequestToken(pub bytes::Bytes); + +impl std::fmt::Debug for RequestToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "<{} bytes>", self.0.len()) + } +} + /// Whether `extensions` is the default, every extension offered; such a config omits it. pub(crate) fn all_extensions(extensions: &moq_net::setup::Extensions) -> bool { *extensions == moq_net::setup::Extensions::default() @@ -633,6 +654,12 @@ pub struct Config { #[usage(skip)] pub extensions: moq_net::setup::Extensions, + /// The `AUTHORIZATION TOKEN` every session presents on its own requests; see + /// [`with_request_token`](Self::with_request_token). + #[serde(skip)] + #[usage(skip)] + pub(crate) request_token: Option, + /// TLS trust and client-certificate settings (`--connect-tls-*`). #[usage(flatten)] #[serde(default)] @@ -712,6 +739,7 @@ impl Default for Config { timeout_arg: None, version: Vec::new(), extensions: Default::default(), + request_token: None, tls: Default::default(), once: None, reconnect: None, @@ -726,6 +754,14 @@ impl Default for Config { } impl Config { + /// Present `token` as the `AUTHORIZATION TOKEN` on every session's own requests (MoQ + /// request-token), set on each session before it sends anything, so it survives + /// reconnects. Renewing it on a live session is [`moq_net::auth::Handle::set_request_token`]. + pub fn with_request_token(mut self, token: impl Into) -> Self { + self.request_token = Some(RequestToken(token.into())); + self + } + /// Every released spelling this config was parsed from, across this section and /// the TLS and WebSocket ones it owns, each paired with what replaced it. /// From 03e26c5746ecf36dfdbc7306b3196a5793f015a4 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 00:33:59 +0000 Subject: [PATCH 37/49] docs(quest): record the request-token review decisions Add the four decisions made in review to the request-token quest Plan: the request token rides the auth handle by kind (seeded from connect::Config on moq-tokio until Connection::auth() exists), the positive setup::Extensions declaration, client-side live renewal staying in this quest, and the EXPIRED / MALFORMED split landing with expired-error. Co-Authored-By: Claude --- quest/m1/auth/request-token.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/quest/m1/auth/request-token.md b/quest/m1/auth/request-token.md index 3a9cef655a..eb4a5ea0bf 100644 --- a/quest/m1/auth/request-token.md +++ b/quest/m1/auth/request-token.md @@ -71,6 +71,23 @@ gives it meaning. Public API: additive on `moq_net::auth::Request` (the request it belongs to) and on `moq_auth::Client` (the per-request lease). Wire: none new; the parameter already exists in every supported draft. +Decided in review: + +- Client credential: the request token rides the auth handle beside + session tokens, distinguished by kind (`auth::Handle::set_request_token`), + with no `Client` methods. `Connection::auth()` is not in the tree yet, so + moq-tokio seeds it from `connect::Config` on every (re)connected session; + live renewal there arrives with `Connection::auth()`, and is available on + moq-net's `Session::auth()` until then. +- Extensions: a positive `#[non_exhaustive] setup::Extensions { auth, + solicit }`, all on by default, on moq-net's client and server, moq-tokio's + dial and listen `Config`, and JS. Later extensions join it. +- Client-side live renewal stays in this quest: setting a new request token + on the handle re-presents it on live requests. +- `EXPIRED_AUTH_TOKEN` / `MALFORMED_AUTH_TOKEN` land with + [expired-error](/quest/m1/auth/expired-error.md); this quest answers + `UNAUTHORIZED` and `NOT_SUPPORTED`. + ## Required - [Relay tokens](/quest/m1/auth/relay-refresh.md) - supplies the lease From c6c4530701fdabacc1854693480099508e842c70 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 00:46:55 +0000 Subject: [PATCH 38/49] test(net): state two request-token invariants explicitly Say why the over-the-driver renewal test runs on real time, and assert that a renewal is only ever acknowledged on draft-17+, where the answer rides the request's own stream. Co-Authored-By: Claude --- rs/moq-net/src/ietf/publisher.rs | 4 ++++ rs/moq-net/tests/auth.rs | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 69034a3e1f..32832a10ff 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -1105,6 +1105,10 @@ where /// Acknowledge an accepted REQUEST_UPDATE on its draft-17+ subscribe stream. async fn write_request_ok(&self, writer: &mut Writer) -> Result<(), Error> { + debug_assert!(!matches!( + self.version, + Version::Draft14 | Version::Draft15 | Version::Draft16 + )); writer.encode(&ietf::RequestOk::ID).await?; writer.encode(&ietf::RequestOk { request_id: None }).await?; Ok(()) diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 99349326f9..856f15f9b8 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -544,7 +544,8 @@ async fn a_request_token_renews_a_subscription_through_the_driver() { }) .await; - // The first token lapses in a second; the renewal never does. + // The first token lapses in a second; the renewal never does. Real time, not a paused + // clock: both drivers and the mock transport run on their own tasks. let expires = Some(now() + Duration::from_secs(1)); // The acceptor sees the Token structure's value, past its USE_VALUE header. let renewal = second[2..].to_vec(); From d802ccca2365afce1ac7217ad1e033d2aacfb770 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 18:18:07 +0000 Subject: [PATCH 39/49] fix(tokio): gate the client request token on a transport feature Only the dial paths in `connect_inner` read `Client::request_token`, and every one of them is behind a transport feature, so a no-transport build left the field written but never read and failed clippy's dead-code lint under `-D warnings`. Gate it on `_transport`, like the sibling `timeout` field, so it is present exactly where a reader is. Co-Authored-By: Claude --- rs/moq-tokio/src/client.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rs/moq-tokio/src/client.rs b/rs/moq-tokio/src/client.rs index 76e157df4a..bc184e07bb 100644 --- a/rs/moq-tokio/src/client.rs +++ b/rs/moq-tokio/src/client.rs @@ -43,6 +43,8 @@ impl Config { pub struct Client { moq: moq_net::Client, /// The request token each session presents, from [`crate::connect::Config::with_request_token`]. + /// Only the dial paths read it, so it is absent without a transport, like [`Self::timeout`]. + #[cfg(feature = "_transport")] request_token: Option, /// The single resolved set of protocol versions, used to advertise moq ALPNs across /// every transport (passed into the QUIC backend's `connect` and used directly for From 45a6385c7b2c21958a9b8d19fa8b42155146eae2 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 18:40:35 +0000 Subject: [PATCH 40/49] fix(net): answer a pre-draft-17 renewal on draft-15 and draft-16 Draft-15 and draft-16 section 9.11 require one REQUEST_OK or REQUEST_ERROR per REQUEST_UPDATE, so a silent renewal left a conformant peer waiting. Restrict the silence to draft-14 (which defines no per-update response and whose control stream reads a 0x05 as ending the subscription), and on draft-15/16 answer keyed to the update's own Request ID, the id the peer matches against the REQUEST_UPDATE it sent. Draft-17+ is unchanged. Tests drive an accepted and a refused renewal at draft-15 and draft-16 and assert exactly one keyed REQUEST_OK or REQUEST_ERROR, and that an accepted renewal keeps the subscription while a refused one lets the old grant lapse it, never the session. Co-Authored-By: Claude --- rs/moq-net/src/ietf/publisher.rs | 144 ++++++++++++++++++++++++------- 1 file changed, 112 insertions(+), 32 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 32832a10ff..b9214ed504 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -882,12 +882,18 @@ where let Some(rg) = request_grant.as_mut() else { continue; }; - // Before draft-17 the update shares the control stream, where an - // answer keyed by the update's Request ID reaches nothing the peer - // tracks (and draft-14's SUBSCRIBE_ERROR would read as ending the - // subscription), so the renewal is decided silently there. - let answer = - !matches!(self.version, Version::Draft14 | Version::Draft15 | Version::Draft16); + // Draft-15 section 9.11 and draft-16 section 9.11 require one + // REQUEST_OK or REQUEST_ERROR per update, keyed to the update's own + // Request ID so the peer matches it to the REQUEST_UPDATE it sent. + // Draft-14 section 9.10 defines no such response, and its control + // stream would read a 0x05 as ending the subscription and a 0x07 as + // PUBLISH_NAMESPACE_OK, so the renewal is decided silently there. + // Draft-17+ answers on the real subscribe stream with the id omitted. + let answer_id = match self.version { + Version::Draft15 | Version::Draft16 => Some(rid), + _ => None, + }; + let answer = !matches!(self.version, Version::Draft14); match res { // The renewal's grant must still cover this request. On accept the // old grant is dropped (ending the old token) and the deadline is @@ -895,7 +901,7 @@ where Ok(grant) if rg.covers(&grant) => { rg.renew(verdict, grant); if answer { - self.write_request_ok(&mut stream.writer).await?; + self.write_request_ok(&mut stream.writer, answer_id).await?; } } // A refused or uncovered renewal keeps the old grant (per the quest, @@ -1103,14 +1109,23 @@ where Ok(()) } - /// Acknowledge an accepted REQUEST_UPDATE on its draft-17+ subscribe stream. - async fn write_request_ok(&self, writer: &mut Writer) -> Result<(), Error> { - debug_assert!(!matches!( - self.version, - Version::Draft14 | Version::Draft15 | Version::Draft16 - )); + /// Acknowledge an accepted REQUEST_UPDATE. + /// + /// Draft-15/16 carry the update's own Request ID so the peer matches the response to + /// the REQUEST_UPDATE it sent; draft-17+ answers on the real subscribe stream with the + /// id omitted. Draft-14 sends no response and never reaches here. + async fn write_request_ok( + &self, + writer: &mut Writer, + request_id: Option, + ) -> Result<(), Error> { + debug_assert_eq!( + request_id.is_some(), + matches!(self.version, Version::Draft15 | Version::Draft16), + "draft-15/16 carry the update request id; draft-17+ omit it", + ); writer.encode(&ietf::RequestOk::ID).await?; - writer.encode(&ietf::RequestOk { request_id: None }).await?; + writer.encode(&ietf::RequestOk { request_id }).await?; Ok(()) } @@ -3555,6 +3570,39 @@ mod serve_tests { log.writes.lock().unwrap().clone() } + /// The wire bytes of one REQUEST_OK keyed to `request_id`, the draft-15/16 accept answer. + async fn request_ok_bytes(version: Version, request_id: RequestId) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.encode(&ietf::RequestOk::ID).await.unwrap(); + writer + .encode(&ietf::RequestOk { + request_id: Some(request_id), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// The wire bytes of one REQUEST_ERROR keyed to `request_id`, the draft-15/16 refuse answer, + /// matching [`Publisher::write_subscribe_error`]'s code and reason for a refused renewal. + async fn request_error_bytes(version: Version, request_id: RequestId) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let error_code = request::to_code(&Error::Unauthorized, request::Kind::Subscribe, version); + writer.encode(&ietf::RequestError::ID).await.unwrap(); + writer + .encode(&ietf::RequestError { + request_id: Some(request_id), + error_code, + reason_phrase: "renewal not granted".into(), + retry_interval: 0, + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + /// A live SUBSCRIBE for `room/video` presenting a request token, with one published /// group so the subscription parks at the live edge rather than ending. fn token_subscribe() -> ietf::Subscribe<'static> { @@ -3639,20 +3687,31 @@ mod serve_tests { } } - /// The pre-draft-17 mirror of the renewal test: a SUBSCRIBE_UPDATE carrying a fresh token - /// renews a token-authorized subscription past the old grant's expiry at draft-14/15/16, - /// where the update rides the control-stream adapter. No answer is written there: one keyed - /// by the update's Request ID reaches nothing the peer routes. The adapter's follow-up - /// routing to the subscription's stream is proven by + /// The pre-draft-17 renewal: a SUBSCRIBE_UPDATE carrying a fresh token renews a + /// token-authorized subscription past the old grant's expiry at draft-14/15/16, where the + /// update rides the control-stream adapter. Draft-14 writes no answer (it defines none, and + /// its control stream would read a 0x05 as ending the subscription); draft-15 and draft-16 + /// (section 9.11) write exactly one REQUEST_OK, keyed to the update's own Request ID so the + /// peer matches it to the REQUEST_UPDATE it sent. The adapter's follow-up routing of the + /// update to the subscription's stream is proven by /// `super::super::adapter::tests::test_classify_subscribe_update_followup`. #[tokio::test(start_paused = true)] - async fn a_subscribe_update_renews_silently_before_draft_17() { + async fn a_subscribe_update_renewal_is_answered_from_draft_15() { for version in [Version::Draft14, Version::Draft15, Version::Draft16] { - renews_silently(version).await; + renews(version, true).await; } } - async fn renews_silently(version: Version) { + /// A refused renewal before draft-17 answers exactly one REQUEST_ERROR on draft-15/16 (none + /// on draft-14) and leaves the old grant to lapse the subscription, never the session. + #[tokio::test(start_paused = true)] + async fn a_refused_subscribe_update_renewal_is_answered_from_draft_15() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + renews(version, false).await; + } + } + + async fn renews(version: Version, accept: bool) { let (auth, mut requests, _cred) = auth_covering_other(); let h = serve_with_auth(version, auth, subscribe_update_with_token(version).await); let rt = h.publisher.runtime.clone(); @@ -3678,7 +3737,11 @@ mod serve_tests { answered.fetch_add(1, Ordering::Relaxed); let renewal = requests.next().await.unwrap(); release.notified().await; - held.push(renewal.accept(grant_all_expiring(&rt, None))); + if accept { + held.push(renewal.accept(grant_all_expiring(&rt, None))); + } else { + renewal.reject(crate::SessionError::Unauthorized, "no"); + } answered.fetch_add(1, Ordering::Relaxed); std::future::pending::<()>().await } @@ -3712,21 +3775,38 @@ mod serve_tests { 2, "{version:?}: the renewal was answered" ); - assert_eq!( - h.log.writes.lock().unwrap().len(), - before, - "{version:?}: a renewal before draft-17 writes no answer" - ); + // Draft-14 writes nothing; draft-15/16 write exactly one keyed answer to the update's + // own Request ID (0x40), the id the renewing peer waits on: REQUEST_OK on accept, + // REQUEST_ERROR on refuse. + let written = h.log.writes.lock().unwrap()[before..].to_vec(); + let expected = match accept { + true => request_ok_bytes(version, RequestId(0x40)).await, + false => request_error_bytes(version, RequestId(0x40)).await, + }; + match version { + Version::Draft14 => assert!(written.is_empty(), "{version:?}: a renewal writes no answer"), + _ => assert_eq!( + written, expected, + "{version:?}: one keyed answer to the update's request id" + ), + } + // An accepted renewal re-armed the deadline, so the subscription lives past the old 60s + // expiry; a refused one leaves the old grant to lapse it there, never the session. tokio::time::advance(Duration::from_secs(120)).await; + let mut ended = false; for _ in 0..50 { let _ = futures::poll!(acceptor.as_mut()); - assert!( - futures::poll!(serving.as_mut()).is_pending(), - "{version:?}: the renewal did not extend the subscription past the old expiry" - ); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } settle().await; } + assert_eq!( + ended, !accept, + "{version:?}: accepted renewal keeps the subscription; a refused one lets the old grant lapse it" + ); } /// A peer that ends the subscription while a renewal is still being verified ends it here From 64e2a8d41633148cfdd8ff939e90606328251eb3 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 18:48:27 +0000 Subject: [PATCH 41/49] fix(net): refuse inbound AUTH when this endpoint declined the extension The dispatch kept the AUTH serve task on the peer's offer alone, so a server that declined MoQ Auth still served a client that advertised it and sent an AUTH stream, granting the empty token or invoking the app verifier on an unsupported handle. The Auth draft's Setup Negotiation requires both offers. Gate serving on the negotiated handle (which carries peer and local offers both) via a small testable predicate, so an AUTH stream a declined endpoint never offered falls through to the UnexpectedStream protocol violation. Co-Authored-By: Claude --- rs/moq-net/src/ietf/session.rs | 34 ++++++++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index e9e0338545..de34ac3639 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -872,6 +872,13 @@ where } } +/// Whether to serve an inbound AUTH stream: only when the peer advertised MoQ Auth AND this +/// endpoint offered it (its handle is supported). The Auth draft's Setup Negotiation requires +/// both offers; without this endpoint's offer, an inbound AUTH stream is a protocol violation. +fn serve_inbound_auth(peer_offered: bool, local_supported: bool) -> bool { + peer_offered && local_supported +} + /// Accept incoming bidi streams and dispatch to the correct handler based on message type. async fn run_dispatch( session: S, @@ -891,12 +898,12 @@ where // costs a handshake round rather than blocking. let peer = subscriber.peer().await; - // An AUTH from a peer that did not negotiate MoQ Auth is an unknown request, which - // falls through to the protocol violation below. - let serve = match peer_setup.get().await.auth { - true => serve, - false => None, - }; + // Serve inbound AUTH only when both endpoints negotiated it. The handle carries + // `peer.auth && local.auth` from the handshake, so a peer that advertised AUTH this + // endpoint never offered leaves no serve task and its AUTH stream falls through to the + // protocol violation below, as the Auth draft's Setup Negotiation requires both offers. + let peer_auth = peer_setup.get().await.auth; + let serve = serve.filter(|serve| serve_inbound_auth(peer_auth, serve.handle.supported())); // From the same slot, so this costs nothing extra: it decides whether an unsolicited // advertisement is the peer ignoring our own SETUP (MoQ Solicit). @@ -1116,6 +1123,21 @@ mod tests { use super::*; use crate::model::ProduceTest; + // An inbound AUTH is served only when both sides offered it. Before the fix the dispatch + // kept the serve task on the peer's offer alone, so a server that declined MoQ Auth still + // served a client that offered it; now a declined local offer drops the serve task and the + // AUTH stream becomes an UnexpectedStream protocol violation. + #[test] + fn inbound_auth_requires_both_offers() { + assert!(serve_inbound_auth(true, true), "both offered: serve it"); + assert!( + !serve_inbound_auth(true, false), + "peer offered, this endpoint declined: an inbound AUTH is a protocol violation" + ); + assert!(!serve_inbound_auth(false, true), "peer did not offer: nothing to serve"); + assert!(!serve_inbound_auth(false, false), "neither offered"); + } + fn occurrences(log: &crate::lite::test_transport::Log, needle: &[u8]) -> usize { let writes = log.writes.lock().unwrap(); writes.windows(needle.len()).filter(|window| *window == needle).count() From b86dd4b39e5ca658649249d2bb67c0e2d6db218f Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 18:57:18 +0000 Subject: [PATCH 42/49] fix(net): keep detecting a cancellation while a renewal is pending Both the subscribe and publish-namespace renewal loops stopped reading the control stream once a message was buffered while a verdict was pending, so a cancellation that arrived afterward was never observed: with a non-expiring grant and an acceptor that never answered the renewal, the request hung. Before draft-17 a cancellation is a message (UNSUBSCRIBE for a subscription, PUBLISH_NAMESPACE_DONE for an announce), not a FIN, so buffering an update first masked it. Keep reading while the verdict is pending: a terminal ends the request now, and a non-terminal update is buffered without halting the read, so a later terminal is never masked. A superseded buffered update is dropped, latest wins. Covers draft-14/15/16 subscription cancellation with a test; the publish-namespace loop keeps its existing withdrawal test. Co-Authored-By: Claude --- rs/moq-net/src/ietf/publisher.rs | 94 +++++++++++++++++++++++++++---- rs/moq-net/src/ietf/subscriber.rs | 34 ++++++----- 2 files changed, 105 insertions(+), 23 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index b9214ed504..bfe8089ef5 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -786,6 +786,9 @@ where Closed, // A `[type][size][body]` control message off the subscribe stream. Message(u64, bytes::Bytes), + // A non-terminal message read while a renewal is pending: held to handle once + // the verdict resolves, so the read keeps watching for a terminal meanwhile. + Buffered(u64, bytes::Bytes), // A pending renewal's verdict resolved, for the update with this request id. Renewal(Result, RequestId), } @@ -794,10 +797,11 @@ where // to refresh the request's token. The loop reads one control message per turn // while serving; a renewal's verify is raced against serving and the old grant's // deadline (never a bare await), so media keeps flowing and the old deadline can - // still fire when the acceptor is slow. While a renewal is pending the loop stops - // reading until its verdict resolves. The read future borrows only - // `stream.reader` and lives in an inner block, so that borrow is released before a - // renewal answers on `stream.writer`. + // still fire when the acceptor is slow. While a renewal is pending the loop keeps + // reading so a cancellation still ends it, buffering a further renewal to handle + // once the verdict resolves. The read future borrows only `stream.reader` and + // lives in an inner block, so that borrow is released before a renewal answers on + // `stream.writer`. let mut pending: Option<(crate::auth::RequestVerdict, RequestId)> = None; // A message that arrived while a renewal was pending, handled once it resolves. let mut stashed: Option<(u64, bytes::Bytes)> = None; @@ -828,14 +832,21 @@ where if closed_session.poll_closed(&mut cx).is_ready() { return Poll::Ready(Step::Closed); } - if stashed.is_none() { - match waiter.poll_future(read.as_mut()) { - Poll::Ready(Ok(Some(message))) => stashed = Some(message), - Poll::Ready(Ok(None)) | Poll::Ready(Err(_)) => return Poll::Ready(Step::Closed), - Poll::Pending => {} + // Keep reading while the verdict is pending so a cancellation still ends + // the request: an acceptor that never answers and a non-expiring grant + // would otherwise hang on it. Draft-14/15/16 cancel with an UNSUBSCRIBE + // message (the adapter delivers it before the FIN), draft-17+ with the + // FIN or a reset. A further renewal is buffered to handle once the + // verdict resolves; breaking the wait starts a fresh read that keeps + // watching for a terminal, so a buffered update never masks one. + match waiter.poll_future(read.as_mut()) { + Poll::Ready(Ok(Some((id, _)))) if id == ietf::Unsubscribe::ID => { + Poll::Ready(Step::Closed) } + Poll::Ready(Ok(Some((id, data)))) => Poll::Ready(Step::Buffered(id, data)), + Poll::Ready(Ok(None)) | Poll::Ready(Err(_)) => Poll::Ready(Step::Closed), + Poll::Pending => Poll::Pending, } - Poll::Pending }) .await } else if let Some((id, data)) = stashed.take() { @@ -876,6 +887,10 @@ where match step { Step::Served(served) | Step::Ended(served) => break Some(served), Step::Closed => break None, + // A message arrived while a renewal was pending: hold the latest to handle + // once the verdict resolves (the loop keeps reading, so a terminal is never + // masked by it). A superseded buffered update is dropped, latest wins. + Step::Buffered(id, data) => stashed = Some((id, data)), Step::Renewal(res, rid) => { // The pending verdict resolved: consume it and answer the update. let (verdict, _) = pending.take().expect("a pending renewal"); @@ -3858,6 +3873,65 @@ mod serve_tests { ); } + /// One UNSUBSCRIBE keyed to the subscription, the draft-14/15/16 cancellation message. + async fn unsubscribe_bytes(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + writer.encode(&ietf::Unsubscribe::ID).await.unwrap(); + writer + .encode(&ietf::Unsubscribe { + request_id: RequestId(REQUEST_ID), + }) + .await + .unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Before draft-17 a cancellation is an UNSUBSCRIBE message, not a FIN. It must end the + /// request even while a renewal verdict is pending: with a non-expiring grant and an + /// acceptor that never answers the renewal, the UNSUBSCRIBE is the only thing that can. + #[tokio::test(start_paused = true)] + async fn a_pending_renewal_ends_on_an_unsubscribe_before_draft_17() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let (auth, mut requests, _cred) = auth_covering_other(); + // The stream carries the renewal then the UNSUBSCRIBE and never FINs, so only the + // message can end the request. + let script = [ + subscribe_update_with_token(version).await, + unsubscribe_bytes(version).await, + ] + .concat(); + let h = serve_with_auth(version, auth, script); + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _issued = first.accept(crate::auth::Grant::all()); + let _never_answered = requests.next().await.expect("a renewal"); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + let mut ended = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!( + ended, + "{version:?}: an UNSUBSCRIBE ends the subscription despite the pending renewal" + ); + } + } + /// A REQUEST_UPDATE the acceptor refuses does NOT extend the grant: the old grant stands /// and the subscription ends only when it lapses (the quest's rule), never the session. #[tokio::test(start_paused = true)] diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index a40633a95d..310521251b 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1224,6 +1224,9 @@ where Closed(Result<(), Error>), // The peer withdrew the announce: the renewal no longer matters. Withdrawn, + // A non-terminal update read while the verdict is pending: buffered to handle + // once it resolves, so the read keeps watching for a terminal meanwhile. + Buffered((u64, bytes::Bytes)), } let version = self.version; let ren = { @@ -1237,21 +1240,20 @@ where if let Poll::Ready(res) = verdict.poll_grant(waiter) { return Poll::Ready(Ren::Renewal(res)); } - if stashed.is_none() { - match waiter.poll_future(read.as_mut()) { - Poll::Ready(Ok(Some(message))) => { - let terminal = terminal_publish_namespace(version, message.0); - stashed = Some(message); - if terminal { - return Poll::Ready(Ren::Withdrawn); - } - } - Poll::Ready(Ok(None)) => return Poll::Ready(Ren::Closed(Ok(()))), - Poll::Ready(Err(err)) => return Poll::Ready(Ren::Closed(Err(err))), - Poll::Pending => {} + // Keep reading while the verdict is pending so a withdrawal still ends the + // announce: a buffered update must not mask a later terminal. A terminal is + // stashed and reported now; a non-terminal update is buffered and the wait + // broken, so the next read starts fresh and keeps watching. + match waiter.poll_future(read.as_mut()) { + Poll::Ready(Ok(Some((id, data)))) if terminal_publish_namespace(version, id) => { + stashed = Some((id, data)); + Poll::Ready(Ren::Withdrawn) } + Poll::Ready(Ok(Some(message))) => Poll::Ready(Ren::Buffered(message)), + Poll::Ready(Ok(None)) => Poll::Ready(Ren::Closed(Ok(()))), + Poll::Ready(Err(err)) => Poll::Ready(Ren::Closed(Err(err))), + Poll::Pending => Poll::Pending, } - Poll::Pending }) .await }; @@ -1262,6 +1264,12 @@ where pending = None; continue; } + Ren::Buffered(message) => { + // Hold the latest update to handle once the verdict resolves; the loop + // keeps reading, so a terminal is never masked by it. + stashed = Some(message); + continue; + } Ren::Renewal(res) => res, }; let (verdict, _) = pending.take().expect("a pending renewal"); From 8c9a8564a92a3e891dd28b04c493d00d4792d75d Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 19:04:35 +0000 Subject: [PATCH 43/49] fix(net): apply cluster params and a token renewal on the same update A PUBLISH_NAMESPACE_UPDATE carrying both a fresh token and HOP_PATH/ ROUTE_COST dropped the cluster parameters: the token branch set the pending renewal and continued before the routing was applied, so the advertisement kept its old route. Apply the routing first, for every update that carries it, then deal with the token; a protocol violation or an unroutable path still tears the announce down regardless of any token. When a token also rides, the routing is already applied and the renewal's answer is the update's single response, so a mixed update still gets exactly one. Covers a mixed update re-routing a token-authorized announce with a test. Co-Authored-By: Claude --- rs/moq-net/src/ietf/subscriber.rs | 176 +++++++++++++++++++++--------- 1 file changed, 127 insertions(+), 49 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 310521251b..f0e545680b 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1349,10 +1349,65 @@ where return Err(Error::WrongSize); } + // Cluster parameters and a token renewal can ride the same update, so apply the + // routing first, for every update that carries it, before dealing with the token. + // A different original publisher applies in place too: the origin drains what the + // old one already serves and never splices the two. The parameters exist only on a + // session that negotiated the extension; anywhere else they are the peer's violation. + let carries_cluster = msg.hops.is_some() || msg.cost.is_some(); + held = match &held { + Some(current) => Some(msg.apply(current)), + None if carries_cluster => { + tracing::warn!(%path, "cluster parameters on a session that negotiated none"); + return Err(Error::ProtocolViolation); + } + None => None, + }; + + // Re-route only when the update actually changes the route (an omitted parameter + // keeps its value, so a token-only update leaves it untouched). A path that now runs + // through us is unusable, so detach rather than keep serving it; reading continues, + // since this stream is the advertisement's only channel and a later clean path + // arrives here or nowhere. Ending the stream is not ours to do: a peer MAY + // legitimately send a path carrying our Hop ID when a redundant sibling shares it. + let applied: Result<(), Error> = if carries_cluster { + match self.route(held.as_ref(), &peer) { + None => { + if std::mem::take(attached) { + tracing::debug!(%path, "publish_namespace now loops back; detaching"); + let _ = self.stop_announce(path.clone()); + } + Ok(()) + } + Some(advert) => { + tracing::debug!(%path, hops = advert.route.hops.len(), cost = ?advert.route.cost, "publish_namespace update"); + match *attached { + true => self.update_announce(path.clone(), advert), + // Re-attach: a clean path replaced the reflected one we detached from. + false => self.start_announce(path.clone(), advert).map(|()| *attached = true), + } + } + } + } else { + Ok(()) + }; + + // An unroutable apply withdraws the announce whether or not a token also rides it. + if let Err(err) = &applied { + tracing::warn!(%path, %err, "publish_namespace update refused"); + self.write_error(stream, msg.request_id, err, &err.to_string()).await?; + // The close is the withdrawal; the caller releases what was attached. + if stream.writer.finish().is_ok() { + let _ = stream.writer.closed().await; + } + return Ok(()); + } + // A REQUEST_UPDATE carrying a fresh token refreshes the announce's request grant // (MoQ request-token), when the announce is token-authorized. The verify is not // awaited here: it becomes the pending renewal raced against the deadline above. - // Our sender keeps renewals token-only, so a reprice never rides one. + // The routing above is already applied, so the renewal's answer (written when its + // verdict resolves) is this update's single response, cluster parameters included. if let Some(token) = &msg.authorization_token && token_grant.is_some() { @@ -1387,54 +1442,8 @@ where continue; } - // An omitted parameter keeps its value, so the update lands on what the peer - // already advertised. The parameters exist only on a session that negotiated - // the extension; anywhere else they are the peer's violation. - // A different original publisher applies in place too: the origin drains what - // the old one already serves and never splices the two. - held = match &held { - Some(current) => Some(msg.apply(current)), - None if msg.hops.is_some() || msg.cost.is_some() => { - tracing::warn!(%path, "cluster parameters on a session that negotiated none"); - return Err(Error::ProtocolViolation); - } - None => None, - }; - - // A path that now runs through us is unusable, so detach rather than keep - // serving it. The update itself is accepted, and reading continues: this - // stream is the only channel the advertisement has, so a later clean path - // arrives here or nowhere. Ending the stream is also not ours to do, since a - // peer MAY legitimately send a path carrying our Hop ID when a redundant - // sibling shares it. - let Some(advert) = self.route(held.as_ref(), &peer) else { - if std::mem::take(attached) { - tracing::debug!(%path, "publish_namespace now loops back; detaching"); - let _ = self.stop_announce(path.clone()); - } - self.write_ok(stream, msg.request_id).await?; - continue; - }; - - tracing::debug!(%path, hops = advert.route.hops.len(), cost = ?advert.route.cost, "publish_namespace update"); - let applied = match *attached { - true => self.update_announce(path.clone(), advert), - // Re-attach: a clean path replaced the reflected one we detached from. - false => self.start_announce(path.clone(), advert).map(|()| *attached = true), - }; - - match applied { - Ok(()) => self.write_ok(stream, msg.request_id).await?, - Err(err) => { - tracing::warn!(%path, %err, "publish_namespace update refused"); - self.write_error(stream, msg.request_id, &err, &err.to_string()).await?; - // The close is the withdrawal; the caller releases what was attached. - if stream.writer.finish().is_ok() { - let _ = stream.writer.closed().await; - } - return Ok(()); - } - } + // No token rides this update: acknowledge it now. + self.write_ok(stream, msg.request_id).await?; } } @@ -5317,6 +5326,22 @@ mod tests { log.writes.lock().unwrap().clone() } + /// One REQUEST_UPDATE on the announce stream carrying both a fresh token and new cluster + /// parameters (HOP_PATH/ROUTE_COST), framed as the peer sends it. + async fn publish_namespace_update_with_token_and_cluster(version: Version) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let msg = ietf::PublishNamespaceUpdate { + request_id: RequestId(3), + hops: Some(hop_path(&[7, 9])), + cost: Some(0), + authorization_token: Some(announce_token()), + }; + writer.encode(&ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + /// A request token on a PUBLISH_NAMESPACE the session grant does not cover authorizes /// the announce: the subscriber verifies it through the acceptor and attaches the route. #[tokio::test] @@ -5463,6 +5488,59 @@ mod tests { "the renewed announce must still be attached" ); } + /// A REQUEST_UPDATE carrying both a fresh token and cluster parameters applies both: the + /// renewal re-arms the grant and the HOP_PATH/ROUTE_COST re-route the advertisement, + /// answered with the renewal's single response. Before the fix the token branch + /// short-circuited the loop and the cluster parameters on the same update were dropped. + #[tokio::test(start_paused = true)] + async fn an_update_applies_both_a_renewal_and_cluster_params() { + const VERSION: Version = Version::Draft18; + let (mut subscriber, mut requests, _cred, consumer, session) = + auth_announce_harness(VERSION, publish_namespace_update_with_token_and_cluster(VERSION).await); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + // A negotiated peer over a free link, so the advertised cost is the route's warm cost. + let peer = cluster::Peer { + hop: Some(crate::Hop::new(9).unwrap()), + cost: Some(0), + }; + // The announce arrives already routed at cost 4; the update re-routes it to cost 0. + let mut initial = token_publish_namespace(); + initial.cluster = Some(cluster::Advert { + hops: hop_path(&[7, 9]), + cost: 4, + }); + + let acceptor = { + let rt = rt.clone(); + async move { + let mut held = Vec::new(); + let first = requests.next().await.expect("a request"); + held.push(first.accept(publish_grant_expiring(&rt, Some(60)))); + let renewal = requests.next().await.expect("a renewal"); + held.push(renewal.accept(publish_grant_expiring(&rt, None))); + std::future::pending::<()>().await + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream(stream, initial, peer, None)); + + let mut rerouted = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if routed_now(&consumer, "room/alice").is_some_and(|route| route.cost.warm == 0) { + rerouted = true; + break; + } + settle().await; + } + assert!( + rerouted, + "the update's cluster parameters must re-route the announce (cost 4 to 0), not be dropped by the renewal" + ); + } /// An alias reference (DELETE/USE_ALIAS) on a PUBLISH_NAMESPACE token is a connection-level /// protocol violation, closing the session as on the SETUP path, not a per-request refusal. From a9883c463de85c5a9e894767e347144a0c6edba9 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 19:56:28 +0000 Subject: [PATCH 44/49] style(docs): format auth.md with the repo markdown formatter Co-Authored-By: Claude --- doc/bin/relay/auth.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/bin/relay/auth.md b/doc/bin/relay/auth.md index 71b7a64528..f4a4148613 100644 --- a/doc/bin/relay/auth.md +++ b/doc/bin/relay/auth.md @@ -167,13 +167,13 @@ after which it can never sign a broader token. ### On a request The same `AUTHORIZATION TOKEN` may ride an individual request (SUBSCRIBE, -REQUEST_UPDATE, PUBLISH_NAMESPACE, FETCH, PUBLISH, SUBSCRIBE_NAMESPACE, -TRACK_STATUS), not only the SETUP. A request is authorized by the session's +REQUEST\_UPDATE, PUBLISH\_NAMESPACE, FETCH, PUBLISH, SUBSCRIBE\_NAMESPACE, +TRACK\_STATUS), not only the SETUP. A request is authorized by the session's grant first; when that does not cover the request's path, by the token on the request; with neither it is refused `UNAUTHORIZED`. A request token's grant covers only the request it rode on, never widens the session, and ends when -the request ends. A REQUEST_UPDATE carrying a fresh token refreshes it, so a -long-lived request (an ingest PUBLISH_NAMESPACE, a subscription) renews its +the request ends. A REQUEST\_UPDATE carrying a fresh token refreshes it, so a +long-lived request (an ingest PUBLISH\_NAMESPACE, a subscription) renews its credential in place without reconnecting; a refused renewal leaves the old grant standing until it lapses. From f7dc815475b366ba2ef9a39f149df85a683f8321 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 21:13:03 +0000 Subject: [PATCH 45/49] fix(net): answer every buffered REQUEST_UPDATE while a verdict is pending Both renewal loops kept a single buffered-update slot while a token verify was pending, so a second update overwrote the first and dropped its cluster delta and its answer. draft-18 section 10.9.1 lets a receiver coalesce the cumulative deltas of several REQUEST_UPDATE messages but still requires one REQUEST_OK or REQUEST_ERROR per successful update. Keep a bounded FIFO queue of the updates buffered while a verdict is pending and drain it in order, so each is verified and answered and each cluster delta is applied, with cancellation still detected throughout. The bound (draft-19 MAX_REQUEST_UPDATES) stops a peer from growing the queue without limit behind a slow verdict; past it the request ends, not the session. On the announce stream a withdrawal read while a verdict is pending goes to its own slot so it ends the announce ahead of any queued update. Co-Authored-By: Claude --- rs/moq-net/src/ietf/publisher.rs | 169 +++++++++++++++++++++++++-- rs/moq-net/src/ietf/subscriber.rs | 186 ++++++++++++++++++++++++++++-- 2 files changed, 341 insertions(+), 14 deletions(-) diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index bfe8089ef5..5854725502 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -803,8 +803,13 @@ where // lives in an inner block, so that borrow is released before a renewal answers on // `stream.writer`. let mut pending: Option<(crate::auth::RequestVerdict, RequestId)> = None; - // A message that arrived while a renewal was pending, handled once it resolves. - let mut stashed: Option<(u64, bytes::Bytes)> = None; + // Updates that arrived while a renewal was pending, handled in order once each + // verdict resolves. A FIFO queue, not a single slot: draft-18 section 10.9.1 + // permits coalescing the cumulative deltas but still requires an answer per + // update, so an earlier buffered renewal must not be dropped by a later one. The + // queue is bounded, so a peer cannot grow it without limit behind a slow verdict. + const MAX_BUFFERED_RENEWALS: usize = 16; + let mut stashed: std::collections::VecDeque<(u64, bytes::Bytes)> = std::collections::VecDeque::new(); loop { let step = if let Some((verdict, rid)) = pending.as_mut() { let rid = *rid; @@ -849,7 +854,7 @@ where } }) .await - } else if let Some((id, data)) = stashed.take() { + } else if let Some((id, data)) = stashed.pop_front() { Step::Message(id, data) } else { let mut read = std::pin::pin!(read_control(&mut stream.reader)); @@ -887,10 +892,20 @@ where match step { Step::Served(served) | Step::Ended(served) => break Some(served), Step::Closed => break None, - // A message arrived while a renewal was pending: hold the latest to handle - // once the verdict resolves (the loop keeps reading, so a terminal is never - // masked by it). A superseded buffered update is dropped, latest wins. - Step::Buffered(id, data) => stashed = Some((id, data)), + // A message arrived while a renewal was pending. Only a REQUEST_UPDATE is + // acted on, and the non-pending path ignores anything else, so drop other + // messages here rather than let a peer grow the queue with ones that will + // never be answered. Each renewal is kept, in order, so none loses its + // verdict or response; too many outstanding behind a slow verdict ends the + // request (not the session), as draft-19 MAX_REQUEST_UPDATES allows. + Step::Buffered(id, data) => { + if id == ietf::SubscribeUpdate::ID { + if stashed.len() >= MAX_BUFFERED_RENEWALS { + break Some((Err(Error::ProtocolViolation), false)); + } + stashed.push_back((id, data)); + } + } Step::Renewal(res, rid) => { // The pending verdict resolved: consume it and answer the update. let (verdict, _) = pending.take().expect("a pending renewal"); @@ -3824,6 +3839,146 @@ mod serve_tests { ); } + /// One REQUEST_UPDATE carrying a fresh token, keyed to its own Request ID so several can be + /// told apart on the wire. + async fn subscribe_update_token_rid(version: Version, rid: u64) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), + _ => None, + }; + let msg = ietf::SubscribeUpdate { + request_id: RequestId(rid), + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: Some(128), + forward: Some(true), + filter: None, + authorization_token: Some(request_token()), + }; + writer.encode(&ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Two token renewals that arrive while a first renewal's verdict is still pending are each + /// verified and answered, not collapsed. The loop keeps per-update state while a verify is + /// pending (draft-18 section 10.9.1 answers each update); the single slot it replaced + /// dropped the middle renewal (0x41) entirely, so its token was never verified. + #[tokio::test(start_paused = true)] + async fn two_renewals_buffered_behind_a_pending_verdict_each_get_a_verdict() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let mut script = subscribe_update_token_rid(VERSION, 0x40).await; + script.extend(subscribe_update_token_rid(VERSION, 0x41).await); + script.extend(subscribe_update_token_rid(VERSION, 0x42).await); + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + // Hold the first renewal (0x40) so the next two (0x41, 0x42) are read and buffered while + // its verdict is pending: the exact window the single slot used to collapse. + let release = std::sync::Arc::new(tokio::sync::Notify::new()); + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let answered = answered.clone(); + let release = release.clone(); + async move { + let first = requests.next().await.unwrap(); + let mut held = vec![first.accept(grant_all_expiring(&rt, None))]; + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let a = requests.next().await.unwrap(); + release.notified().await; + held.push(a.accept(grant_all_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + // 0x41 then 0x42: reached only if neither was dropped from the buffer. + loop { + let renewal = requests.next().await.unwrap(); + held.push(renewal.accept(grant_all_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + // Let 0x41 and 0x42 buffer behind the held 0x40. + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending(), "ended during setup"); + settle().await; + } + release.notify_one(); + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(serving.as_mut()).is_pending()); + if answered.load(std::sync::atomic::Ordering::Relaxed) >= 4 { + break; + } + settle().await; + } + assert_eq!( + answered.load(std::sync::atomic::Ordering::Relaxed), + 4, + "the initial token and all three renewals must each be verified; the single slot dropped 0x41" + ); + } + + /// A peer cannot grow the pending-renewal buffer without limit: once more than + /// MAX_BUFFERED_RENEWALS pile up behind a held verdict the request ends, not the session, as + /// draft-19 MAX_REQUEST_UPDATES allows. Without the bound the loop buffered every renewal and + /// never ended. + #[tokio::test(start_paused = true)] + async fn too_many_renewals_behind_a_held_verdict_end_the_request() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + // Well past the cap: the first renewal is held pending, the rest pile up behind it. + let mut script = Vec::new(); + for rid in 0x40..=0x60u64 { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + // Accept the initial token, then never answer a renewal, so the buffer only grows. + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + let mut ended = false; + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "flooding past the renewal cap must end the request"); + assert!(h.log.closes().is_empty(), "the flood ends the request, not the session"); + } + /// A peer that ends the subscription while a renewal is still being verified ends it here /// too: with a grant that never expires and an acceptor that never answers the renewal, /// only the stream closing can end the request, and it must. diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index f0e545680b..d5a3456ca2 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -1209,8 +1209,16 @@ where mut token_grant: Option>, ) -> Result<(), Error> { let mut pending: Option<(crate::auth::RequestVerdict, RequestId)> = None; - // A message that arrived while a renewal was pending, handled once it resolves. - let mut stashed: Option<(u64, bytes::Bytes)> = None; + // Updates that arrived while a renewal was pending, handled in order once each verdict + // resolves. A FIFO queue, not a single slot: draft-18 section 10.9.1 permits coalescing + // the cumulative deltas but still requires an answer per update, so an earlier buffered + // update must not be dropped by a later one. The queue is bounded, so a peer cannot grow + // it without limit behind a slow verdict. + const MAX_BUFFERED_RENEWALS: usize = 16; + let mut stashed: std::collections::VecDeque<(u64, bytes::Bytes)> = std::collections::VecDeque::new(); + // A withdrawal read while a renewal was pending, handled ahead of any queued update so + // a buffered update never masks it: nothing more is owed once the peer retracts. + let mut terminal_msg: Option<(u64, bytes::Bytes)> = None; loop { // A renewal verify in flight is raced against the request grant's deadline (never // a bare await), so the old deadline can still fire while a slow acceptor decides, @@ -1246,7 +1254,7 @@ where // broken, so the next read starts fresh and keeps watching. match waiter.poll_future(read.as_mut()) { Poll::Ready(Ok(Some((id, data)))) if terminal_publish_namespace(version, id) => { - stashed = Some((id, data)); + terminal_msg = Some((id, data)); Poll::Ready(Ren::Withdrawn) } Poll::Ready(Ok(Some(message))) => Poll::Ready(Ren::Buffered(message)), @@ -1265,9 +1273,19 @@ where continue; } Ren::Buffered(message) => { - // Hold the latest update to handle once the verdict resolves; the loop - // keeps reading, so a terminal is never masked by it. - stashed = Some(message); + // Queue the update to handle once the verdict resolves; the loop keeps + // reading, so a terminal is never masked by it. Each buffered update is + // kept, in order, so none loses its cluster delta or its answer. Too many + // outstanding behind a slow verdict ends this announce, never the session + // (an Err would reach the dispatcher as a protocol violation and close it), + // as draft-19 MAX_REQUEST_UPDATES allows: finish the stream and stop. + if stashed.len() >= MAX_BUFFERED_RENEWALS { + if stream.writer.finish().is_ok() { + let _ = stream.writer.closed().await; + } + return Ok(()); + } + stashed.push_back(message); continue; } Ren::Renewal(res) => res, @@ -1301,7 +1319,11 @@ where Closed, Ended(Error), } - let ctl = if let Some((id, data)) = stashed.take() { + let ctl = if let Some((id, data)) = terminal_msg.take() { + // A withdrawal read while a renewal was pending ends the announce now, ahead of + // any queued update: nothing more is owed once the peer retracts. + Ctl::Message(id, data) + } else if let Some((id, data)) = stashed.pop_front() { Ctl::Message(id, data) } else { let mut read = std::pin::pin!(super::publisher::read_control(&mut stream.reader)); @@ -5542,6 +5564,156 @@ mod tests { ); } + /// One PUBLISH_NAMESPACE REQUEST_UPDATE carrying a fresh token, keyed to its own Request ID, + /// optionally changing the HOP_PATH and/or ROUTE_COST, framed as the peer sends it. + async fn publish_namespace_update_token_rid( + version: Version, + rid: u64, + hops: Option, + cost: Option, + ) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let msg = ietf::PublishNamespaceUpdate { + request_id: RequestId(rid), + hops, + cost, + authorization_token: Some(announce_token()), + }; + writer.encode(&ietf::PublishNamespaceUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + + /// Two announce renewals that arrive while a first renewal's verdict is still pending are + /// each verified, and each one's cluster delta is applied, not collapsed. The middle update + /// (0x04) changes only ROUTE_COST (4 to 1) and the last (0x05) only HOP_PATH, so the final + /// cost shows the middle delta survived; the single slot this replaced dropped 0x04, + /// leaving the cost at the initial 4 and never verifying its token. + #[tokio::test(start_paused = true)] + async fn two_announce_renewals_buffered_behind_a_pending_verdict_each_apply_and_answer() { + const VERSION: Version = Version::Draft18; + + let mut script = publish_namespace_update_token_rid(VERSION, 0x03, None, None).await; + script.extend(publish_namespace_update_token_rid(VERSION, 0x04, None, Some(1)).await); + script.extend(publish_namespace_update_token_rid(VERSION, 0x05, Some(hop_path(&[7, 11])), None).await); + let (mut subscriber, mut requests, _cred, consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + // A negotiated peer over a free link, so the advertised cost is the route's warm cost. + let peer = cluster::Peer { + hop: Some(crate::Hop::new(9).unwrap()), + cost: Some(0), + }; + // The announce arrives routed at cost 4; the buffered updates re-route it. + let mut initial = token_publish_namespace(); + initial.cluster = Some(cluster::Advert { + hops: hop_path(&[7, 9]), + cost: 4, + }); + + // Hold the first renewal (0x03) so the next two (0x04, 0x05) are read and buffered while + // its verdict is pending: the window the single slot used to collapse. + let release = std::sync::Arc::new(tokio::sync::Notify::new()); + let answered = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let acceptor = { + let rt = rt.clone(); + let answered = answered.clone(); + let release = release.clone(); + async move { + let first = requests.next().await.expect("a request"); + let mut held = vec![first.accept(publish_grant_expiring(&rt, None))]; + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let a = requests.next().await.expect("a renewal"); + release.notified().await; + held.push(a.accept(publish_grant_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + loop { + let renewal = requests.next().await.expect("a renewal"); + held.push(renewal.accept(publish_grant_expiring(&rt, None))); + answered.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream(stream, initial, peer, None)); + + for _ in 0..200 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + settle().await; + } + release.notify_one(); + let mut both = false; + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!(futures::poll!(run.as_mut()).is_pending(), "the announce ended early"); + if answered.load(std::sync::atomic::Ordering::Relaxed) >= 4 + && routed_now(&consumer, "room/alice").is_some_and(|route| route.cost.warm == 1) + { + both = true; + break; + } + settle().await; + } + assert!( + both, + "both buffered updates must each be verified (4 total) and each delta applied: the \ + middle update's cost (1), not the single-slot survivor's initial 4" + ); + } + + /// A peer cannot grow the announce renewal buffer without limit: once more than + /// MAX_BUFFERED_RENEWALS pile up behind a held verdict the announce ends and + /// run_publish_namespace_stream returns Ok, so the dispatcher does not treat it as a protocol + /// violation and close the session. Without the bound the loop buffered every update and never + /// ended; returning an error here would have closed the whole session. + #[tokio::test(start_paused = true)] + async fn too_many_announce_renewals_behind_a_held_verdict_end_the_announce_not_the_session() { + const VERSION: Version = Version::Draft18; + + let mut script = Vec::new(); + for rid in 0x40..=0x60u64 { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + // Accept the initial token, then never answer a renewal, so the buffer only grows. + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut result = None; + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + if let std::task::Poll::Ready(r) = futures::poll!(run.as_mut()) { + result = Some(r); + break; + } + settle().await; + } + assert!( + matches!(result, Some(Ok(()))), + "flooding past the cap must end the announce with Ok, not a session-closing error: {result:?}" + ); + assert!( + session.log.closes().is_empty(), + "the flood ends the announce, not the session" + ); + } + /// An alias reference (DELETE/USE_ALIAS) on a PUBLISH_NAMESPACE token is a connection-level /// protocol violation, closing the session as on the SETUP path, not a per-request refusal. #[tokio::test] From c51dd18555356039dd61d98ecd4690636c189e76 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 21:13:03 +0000 Subject: [PATCH 46/49] fix(net): route a pre-draft-17 renewal reply back to its subscription A SUBSCRIBE_UPDATE renewal carries its own Request ID, and on draft-15/16 the receiver keys the REQUEST_OK or REQUEST_ERROR to that update id. The control-stream adapter only registered the subscription under its initial id, so the reply named an id no virtual stream owned: the accept was dropped and the refusal a no-op, and the client's renewal never resolved. Record an outgoing renewal's update id against the subscription it names and route the reply (a REQUEST_OK or REQUEST_ERROR, never another message) to that subscription stream as data, never a close, so the client observes the answer and a refused renewal leaves the subscription open. The recording watches a copy of the outgoing frames and never alters forwarding, so a parse that falls short only leaves a reply unrouted, as before; it is built only for draft-15/16, since draft-14 answers nothing and draft-17+ uses real bidi streams. Forgetting or closing a subscription releases its outstanding renewal mappings. Co-Authored-By: Claude --- rs/moq-net/src/ietf/adapter.rs | 298 ++++++++++++++++++++++++++++++++- 1 file changed, 297 insertions(+), 1 deletion(-) diff --git a/rs/moq-net/src/ietf/adapter.rs b/rs/moq-net/src/ietf/adapter.rs index a8ca0d2e5b..a1bb10faa2 100644 --- a/rs/moq-net/src/ietf/adapter.rs +++ b/rs/moq-net/src/ietf/adapter.rs @@ -258,6 +258,10 @@ pub struct VirtualSendStream { /// Accumulates bytes until the request_id can be parsed, /// then registers the stream and flushes. pending: Option, + /// Watches the outgoing frames of an outgoing request stream for a REQUEST_UPDATE + /// renewal, so its reply (keyed to the update's own Request ID on draft-15/16) routes + /// back to the subscription. `None` for incoming streams, which never send renewals. + sniff: Option, } struct OutgoingRegistration { @@ -325,13 +329,71 @@ impl VirtualSendStream { Self { control_tx, pending: None, + sniff: None, } } fn with_registration(control_tx: Queue, pending: OutgoingRegistration) -> Self { + // Only draft-15/16 key a renewal reply to the update's own Request ID; draft-14 answers + // nothing and draft-17+ uses real bidi streams, not this adapter, so only those sniff. + let sniff = matches!(pending.version, Version::Draft15 | Version::Draft16).then(|| UpdateSniffer { + shared: Arc::clone(&pending.shared), + version: pending.version, + buf: BytesMut::new(), + }); Self { control_tx, pending: Some(pending), + sniff, + } + } +} + +/// Watches the outgoing control frames of an outgoing request stream for a REQUEST_UPDATE +/// renewal (a SUBSCRIBE_UPDATE carrying a fresh token). Draft-14/15/16 give the update its own +/// Request ID, distinct from the subscription's, and the receiver keys its REQUEST_OK / +/// REQUEST_ERROR to that update id, but the adapter only ever registered the subscription under +/// its initial id. Recording the update id against the subscription lets the reply route back +/// to the subscription stream (see [`Shared::route_update_reply`]). +/// +/// It observes a copy of the written bytes and never alters forwarding: a parse that falls +/// short only leaves a reply unrouted, which is the behavior before this existed. +/// +/// Only draft-15/16 are recorded: draft-14 answers nothing (no reply to route) and draft-17+ +/// uses real bidi streams, not this adapter. Only SUBSCRIBE_UPDATE is sniffed; a +/// PUBLISH_NAMESPACE_UPDATE carries no subscription id to route a reply to. +struct UpdateSniffer { + shared: Arc, + version: Version, + buf: BytesMut, +} + +impl UpdateSniffer { + fn observe(&mut self, chunk: &[u8]) { + self.buf.extend_from_slice(chunk); + loop { + let mut cursor = std::io::Cursor::new(&self.buf); + let Ok(type_id) = u64::decode(&mut cursor, self.version) else { + return; + }; + let Ok(size) = u16::decode(&mut cursor, self.version) else { + return; + }; + let header_len = cursor.position() as usize; + let frame_len = header_len + size as usize; + if self.buf.len() < frame_len { + return; + } + if type_id == ietf::SubscribeUpdate::ID { + let body = Bytes::copy_from_slice(&self.buf[header_len..frame_len]); + if let (Ok(update_id), Ok(subscription_id)) = ( + decode_request_id(&body, self.version), + decode_subscribe_update_request_id(&body, self.version), + ) { + self.shared.record_update(update_id, subscription_id); + } + } + let _ = self.buf.split_to(frame_len); } } } @@ -341,6 +403,11 @@ impl VirtualSendStream { /// register and flush; forward directly afterwards. Never blocks, since the /// control queue is unbounded. fn push(&mut self, chunk: Bytes) -> Result<(), crate::Error> { + // Observe the bytes for a renewal update before forwarding; this never alters what is + // forwarded, only what the reply routing later knows. + if let Some(sniff) = &mut self.sniff { + sniff.observe(&chunk); + } if let Some(pending) = &mut self.pending { pending.buf.extend_from_slice(&chunk); @@ -643,6 +710,13 @@ struct Shared { /// Namespace → request_id reverse lookup (for v14/v15 namespace-keyed messages). namespaces: Namespaces, + + /// A renewal update's own Request ID → the subscription it renews, for draft-15/16 where + /// the receiver keys the renewal's REQUEST_OK / REQUEST_ERROR to the update id rather than + /// the subscription id. [`route_update_reply`](Self::route_update_reply) consumes an entry; + /// [`forget`](Self::forget) / [`close`](Self::close) drop any left by a subscription that + /// ended before its reply arrived. + updates: Mutex>, } impl Shared { @@ -693,6 +767,10 @@ impl Shared { fn close(&self, request_id: RequestId, raw: Bytes) { let tx = self.streams.lock().unwrap().remove(&request_id); self.namespaces.forget(request_id); + self.updates + .lock() + .unwrap() + .retain(|_, subscription_id| *subscription_id != request_id); if let Some(tx) = tx { tx.push(raw); } @@ -702,6 +780,40 @@ impl Shared { fn forget(&self, request_id: RequestId) { self.streams.lock().unwrap().remove(&request_id); self.namespaces.forget(request_id); + self.updates + .lock() + .unwrap() + .retain(|_, subscription_id| *subscription_id != request_id); + } + + /// Record a renewal update's own Request ID against the subscription it renews. + fn record_update(&self, update_id: RequestId, subscription_id: RequestId) { + self.updates.lock().unwrap().insert(update_id, subscription_id); + } + + /// Route a reply to a buffered REQUEST_UPDATE back to the subscription it renews. + /// + /// Draft-15/16 key the renewal's REQUEST_OK / REQUEST_ERROR to the update's own Request ID, + /// which the adapter never registered as a stream of its own. Deliver it to the + /// subscription stream as a follow-up (data, never a close), so the client observes the + /// answer and a refused renewal leaves the subscription open, as the keep-old-grant policy + /// and draft-14/15/16 cancellation require. Returns true when it handled the message. + fn route_update_reply(&self, type_id: u64, classified: &Route, raw: &Bytes) -> bool { + // Only a renewal's REQUEST_OK / REQUEST_ERROR is rerouted. A PUBLISH_DONE, UNSUBSCRIBE, + // or FETCH_CANCEL that happens to classify as a response or close for a recorded id keeps + // its normal routing: those are not renewal replies. + if type_id != ietf::RequestOk::ID && type_id != ietf::RequestError::ID { + return false; + } + let reply_id = match classified { + Route::Response(id) | Route::CloseStream(id) => *id, + _ => return false, + }; + let Some(subscription_id) = self.updates.lock().unwrap().remove(&reply_id) else { + return false; + }; + self.push(subscription_id, raw.clone()); + true } } @@ -834,7 +946,13 @@ impl ControlStreamAdapter { let raw = encode_raw(type_id, size, &body, self.version); // Classify and route - match classify(type_id, &body, self.version, &self.shared.namespaces)? { + let classified = classify(type_id, &body, self.version, &self.shared.namespaces)?; + // A reply to a buffered REQUEST_UPDATE renewal is keyed to the update's own Request + // ID, which has no stream of its own; route it to the subscription it renews. + if self.shared.route_update_reply(type_id, &classified, &raw) { + continue; + } + match classified { Route::NewRequest(request_id) => self.shared.open_incoming(request_id, raw)?, Route::Response(request_id) | Route::FollowUp(request_id) => self.shared.push(request_id, raw), Route::CloseStream(request_id) => self.shared.close(request_id, raw), @@ -1514,6 +1632,184 @@ mod tests { (shared, ours, theirs) } + /// One SUBSCRIBE_UPDATE renewal keyed to its own `update_id`, naming the subscription it + /// renews by `subscription_id` in the second field, as a draft-15/16 client frames it. + fn subscribe_update(update_id: RequestId, subscription_id: RequestId) -> ietf::SubscribeUpdate { + ietf::SubscribeUpdate { + request_id: update_id, + subscription_request_id: Some(subscription_id), + start_location: ietf::Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: None, + forward: None, + filter: None, + authorization_token: Some(Bytes::from_static(&[0x03, 0x81, 0x2c, 0x00, 0xff])), + } + } + + /// A renewal's reply is keyed to the update's own Request ID on draft-15/16, which owns no + /// stream of its own. It must route to the subscription the update renews, as data on that + /// stream, so the client observes REQUEST_OK / REQUEST_ERROR and a refused renewal leaves the + /// subscription open. Before the update was recorded against the subscription, the accept + /// (routed as a Response to an unknown id) was dropped and the refuse (a CloseStream to an + /// unknown id) was a no-op, so the client's renewal never resolved. + async fn renewal_reply_reaches_the_subscription(version: Version) { + let shared = Arc::new(Shared::default()); + + // Open the subscription stream on request id 4; the first write registers it. + let (mut send, mut recv) = shared.open_outgoing(version); + send.write_chunk(encode_msg(&subscribe(RequestId(4)), version)) + .await + .unwrap(); + + // The client renews twice: 0x40 to be accepted, 0x41 refused. Each names subscription 4. + send.write_chunk(encode_msg(&subscribe_update(RequestId(0x40), RequestId(4)), version)) + .await + .unwrap(); + send.write_chunk(encode_msg(&subscribe_update(RequestId(0x41), RequestId(4)), version)) + .await + .unwrap(); + + // The peer answers each on the update's own id. Route them the way the read loop does. + let ok_msg = ietf::RequestOk { + request_id: Some(RequestId(0x40)), + }; + let ok = encode_msg(&ok_msg, version); + let ok_route = classify( + ietf::RequestOk::ID, + &encode_body(&ok_msg, version), + version, + &shared.namespaces, + ) + .unwrap(); + assert!( + shared.route_update_reply(ietf::RequestOk::ID, &ok_route, &ok), + "{version:?}: an accepted renewal must route to its subscription" + ); + + let err_msg = ietf::RequestError { + request_id: Some(RequestId(0x41)), + error_code: 0, + reason_phrase: "no".into(), + retry_interval: 0, + }; + let err = encode_msg(&err_msg, version); + let err_route = classify( + ietf::RequestError::ID, + &encode_body(&err_msg, version), + version, + &shared.namespaces, + ) + .unwrap(); + assert!( + shared.route_update_reply(ietf::RequestError::ID, &err_route, &err), + "{version:?}: a refused renewal must route to its subscription" + ); + + // Both answers arrive on the subscription stream, in order, and it is still open: the + // refusal was delivered as data, not a close. + assert_eq!( + recv.read_chunk(usize::MAX).await.unwrap(), + Some(ok), + "{version:?}: the client observes the REQUEST_OK" + ); + assert_eq!( + recv.read_chunk(usize::MAX).await.unwrap(), + Some(err), + "{version:?}: the client observes the REQUEST_ERROR" + ); + // The refusal was delivered as data, not a close: the subscription is still open. + assert!( + recv.read_chunk(usize::MAX).now_or_never().is_none(), + "{version:?}: a refused renewal must not close the subscription stream" + ); + } + + #[tokio::test] + async fn a_renewal_reply_routes_back_to_its_subscription_at_draft_15_and_16() { + renewal_reply_reaches_the_subscription(Version::Draft15).await; + renewal_reply_reaches_the_subscription(Version::Draft16).await; + } + + /// A minimal SUBSCRIBE for `request_id`, enough to open and register a subscription stream. + fn subscribe(request_id: RequestId) -> ietf::Subscribe<'static> { + ietf::Subscribe { + request_id, + track_namespace: crate::Path::new("room/alice"), + track_name: "video".into(), + subscriber_priority: 0, + group_order: ietf::GroupOrder::Descending, + filter: ietf::Filter::NextObject, + fill: None, + properties_wanted: false, + authorization_token: None, + } + } + + /// A renewal is recorded only on a draft that answers one, and the mapping is released when + /// the subscription is forgotten or closed, so an outstanding renewal cannot outlive it. + #[tokio::test] + async fn a_renewal_mapping_is_pre_draft_17_only_and_released_with_its_subscription() { + // Draft-14 answers no renewal, so nothing is recorded. + let shared = Arc::new(Shared::default()); + let (mut send, _recv) = shared.open_outgoing(Version::Draft14); + send.write_chunk(encode_msg(&subscribe(RequestId(4)), Version::Draft14)) + .await + .unwrap(); + send.write_chunk(encode_msg( + &subscribe_update(RequestId(0x40), RequestId(4)), + Version::Draft14, + )) + .await + .unwrap(); + assert!( + shared.updates.lock().unwrap().is_empty(), + "draft-14 records no renewal mapping" + ); + + // Draft-15 records the mapping; forgetting the subscription releases it. + let shared = Arc::new(Shared::default()); + let (mut send, _recv) = shared.open_outgoing(Version::Draft15); + send.write_chunk(encode_msg(&subscribe(RequestId(4)), Version::Draft15)) + .await + .unwrap(); + send.write_chunk(encode_msg( + &subscribe_update(RequestId(0x40), RequestId(4)), + Version::Draft15, + )) + .await + .unwrap(); + assert_eq!( + shared.updates.lock().unwrap().len(), + 1, + "draft-15 records the renewal mapping" + ); + shared.forget(RequestId(4)); + assert!( + shared.updates.lock().unwrap().is_empty(), + "forgetting the subscription releases its renewal mapping" + ); + + // Draft-16 records; closing the subscription releases it. + let shared = Arc::new(Shared::default()); + let (mut send, _recv) = shared.open_outgoing(Version::Draft16); + send.write_chunk(encode_msg(&subscribe(RequestId(4)), Version::Draft16)) + .await + .unwrap(); + send.write_chunk(encode_msg( + &subscribe_update(RequestId(0x41), RequestId(4)), + Version::Draft16, + )) + .await + .unwrap(); + assert_eq!(shared.updates.lock().unwrap().len(), 1); + shared.close(RequestId(4), Bytes::new()); + assert!( + shared.updates.lock().unwrap().is_empty(), + "closing the subscription releases its renewal mapping" + ); + } + /// Read until the stream FINs, returning whether it did so within `limit` reads. async fn drained(stream: &mut VirtualRecvStream, limit: usize) -> bool { for _ in 0..limit { From fe264a0339e334d8a52274267e989f8726efb7a9 Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 22:03:40 +0000 Subject: [PATCH 47/49] feat(net): advertise and enforce MAX_REQUEST_UPDATES The per-request renewal buffer is bounded, but SETUP advertised no MAX_REQUEST_UPDATES, and draft-19 section 10.3.1.7 reads an absent option as unlimited. A conforming peer could send one more REQUEST_UPDATE than the hidden ceiling and lose its subscription or namespace while a token verify was still pending. Advertise a finite credit on the drafts that define the option (19+) and enforce exactly that, counting the one being verified toward it. A peer that exceeds the advertised value broke the negotiated rule, so the draft's own remedy applies: close the session with TOO_MANY_REQUEST_UPDATES. A single constant drives both the advertisement and the check so they cannot drift. Drafts below 19 carry no such option, so a peer there agreed to no ceiling: keep a generous local guard that ends only the one request, never the session, rather than strand a conforming peer for a limit it never saw. Co-Authored-By: Claude --- rs/moq-net/src/error.rs | 8 + rs/moq-net/src/ietf/mod.rs | 1 + rs/moq-net/src/ietf/parameters.rs | 2 + rs/moq-net/src/ietf/publisher.rs | 366 ++++++++++++++++++++++++-- rs/moq-net/src/ietf/request_update.rs | 87 ++++++ rs/moq-net/src/ietf/session.rs | 3 +- rs/moq-net/src/ietf/subscriber.rs | 205 +++++++++++++-- 7 files changed, 634 insertions(+), 38 deletions(-) create mode 100644 rs/moq-net/src/ietf/request_update.rs diff --git a/rs/moq-net/src/error.rs b/rs/moq-net/src/error.rs index b09ef3e5fb..a8c538a4c8 100644 --- a/rs/moq-net/src/error.rs +++ b/rs/moq-net/src/error.rs @@ -28,6 +28,11 @@ pub enum SessionError { #[error("protocol violation")] ProtocolViolation, + /// The peer left more unacknowledged REQUEST_UPDATEs outstanding on one request stream + /// than the MAX_REQUEST_UPDATES it was advertised (draft-19 section 10.3.1.7). + #[error("too many request updates")] + TooManyRequestUpdates, + /// A key-value pair was malformed or repeated more than allowed. #[error("key-value formatting error")] KeyValueFormatting, @@ -65,6 +70,7 @@ impl SessionError { Self::GoawayTimeout => 0x10, Self::Timeout => 0x11, Self::Version => 0x15, + Self::TooManyRequestUpdates => 0x1B, Self::App(app) => *app as u32 + 64, Self::Unknown(code) => *code, } @@ -85,6 +91,7 @@ impl SessionError { 0x10 => Self::GoawayTimeout, 0x11 => Self::Timeout, 0x15 => Self::Version, + 0x1B => Self::TooManyRequestUpdates, code @ 64.. => match u16::try_from(code - 64) { Ok(app) => Self::App(app), Err(_) => Self::Unknown(code), @@ -630,6 +637,7 @@ mod tests { SessionError::Internal, SessionError::Unauthorized, SessionError::ProtocolViolation, + SessionError::TooManyRequestUpdates, SessionError::KeyValueFormatting, SessionError::GoawayTimeout, SessionError::Timeout, diff --git a/rs/moq-net/src/ietf/mod.rs b/rs/moq-net/src/ietf/mod.rs index 39026cef6c..22e317eb2f 100644 --- a/rs/moq-net/src/ietf/mod.rs +++ b/rs/moq-net/src/ietf/mod.rs @@ -26,6 +26,7 @@ mod publish; mod publish_namespace; mod publisher; mod request; +mod request_update; mod session; pub mod solicit; mod subscribe; diff --git a/rs/moq-net/src/ietf/parameters.rs b/rs/moq-net/src/ietf/parameters.rs index fd3565a8e1..4918f7c449 100644 --- a/rs/moq-net/src/ietf/parameters.rs +++ b/rs/moq-net/src/ietf/parameters.rs @@ -20,6 +20,8 @@ pub enum ParameterVarInt { /// Removed in draft-17; only used in draft-14/15/16. MaxRequestId = 2, MaxAuthTokenCacheSize = 4, + /// MAX_REQUEST_UPDATES, added in draft-19. + MaxRequestUpdates = super::request_update::OPTION, /// HOP_ID, from the MoQ Cluster extension. HopId = super::cluster::HOP_ID, /// RELAY_COST, from the MoQ Cluster extension. diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 5854725502..1d56a2684d 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -21,7 +21,7 @@ use crate::{ util::{MaybeBoxedExt, MaybeSendBox}, }; -use super::{Message, Version, cluster, error::request, peer}; +use super::{Message, Version, cluster, error::request, peer, request_update}; /// Largest millisecond duration every implementation can carry losslessly. const MAX_SAFE_AGE_MS: u64 = (1_u64 << 53) - 1; @@ -807,8 +807,8 @@ where // verdict resolves. A FIFO queue, not a single slot: draft-18 section 10.9.1 // permits coalescing the cumulative deltas but still requires an answer per // update, so an earlier buffered renewal must not be dropped by a later one. The - // queue is bounded, so a peer cannot grow it without limit behind a slow verdict. - const MAX_BUFFERED_RENEWALS: usize = 16; + // queue is bounded by MAX_REQUEST_UPDATES, counting the one being verified, so a + // peer cannot grow it without limit behind a slow verdict. let mut stashed: std::collections::VecDeque<(u64, bytes::Bytes)> = std::collections::VecDeque::new(); loop { let step = if let Some((verdict, rid)) = pending.as_mut() { @@ -896,11 +896,32 @@ where // acted on, and the non-pending path ignores anything else, so drop other // messages here rather than let a peer grow the queue with ones that will // never be answered. Each renewal is kept, in order, so none loses its - // verdict or response; too many outstanding behind a slow verdict ends the - // request (not the session), as draft-19 MAX_REQUEST_UPDATES allows. + // verdict or response. The one being verified plus those queued behind it + // are the outstanding REQUEST_UPDATEs; what happens when another would + // exceed the ceiling is version-appropriate. Step::Buffered(id, data) => { if id == ietf::SubscribeUpdate::ID { - if stashed.len() >= MAX_BUFFERED_RENEWALS { + // Outstanding counting the one being verified: 1 + stashed.len(). + let outstanding = stashed.len() as u64 + 1; + if request_update::supported(self.version) { + // Draft-19+: we advertised MAX_REQUEST_UPDATES, so a peer with + // that many already outstanding sending another broke the + // negotiated limit. Draft-19 section 10.3.1.7 answers that with + // a session close, TOO_MANY_REQUEST_UPDATES. A conforming peer + // self-limits and never reaches here. Closing the session makes + // the request's own PUBLISH_DONE moot, so return straight out as + // the malformed-token path does. + if outstanding >= request_update::MAX_REQUEST_UPDATES { + self.session.clone().close( + crate::SessionError::TooManyRequestUpdates.to_code(), + "too many request updates", + ); + return Err(Error::Session(crate::SessionError::TooManyRequestUpdates)); + } + } else if stashed.len() >= request_update::UNNEGOTIATED_GUARD { + // Drafts below 19 negotiate no limit, so a peer agreed to no + // ceiling: this is a local memory guard, not a protocol fault. + // End this request, never the session. break Some((Err(Error::ProtocolViolation), false)); } stashed.push_back((id, data)); @@ -962,13 +983,20 @@ where Err(err) => break Some((Err(err.into()), false)), }; // A token-less REQUEST_UPDATE is an ordinary priority/forward change, - // which this publisher does not act on; the grant is untouched. + // which this publisher does not act on; the grant is untouched. It still + // owes one answer so the peer's MAX_REQUEST_UPDATES credit is restored. let Some(token) = &update.authorization_token else { + self.answer_request_update_ok(&mut stream.writer, update.request_id) + .await?; continue; }; // Only a token-authorized subscription holds a request grant to renew; - // a union-authorized one is already covered by the session grant. + // a union-authorized one is already covered by the session grant. The + // update is accepted all the same, so it is acknowledged and its credit + // restored. if request_grant.is_none() { + self.answer_request_update_ok(&mut stream.writer, update.request_id) + .await?; continue; } // An alias reference (DELETE/USE_ALIAS) is a connection-level protocol @@ -1159,6 +1187,24 @@ where Ok(()) } + /// Acknowledge a REQUEST_UPDATE that this side accepts without a pending verify, keyed as the + /// version requires: draft-14 defines no response (silent), draft-15/16 key it to the update's + /// own request id, draft-17+ answer on the stream with the id omitted. Every REQUEST_UPDATE + /// owes exactly one REQUEST_OK or REQUEST_ERROR (moq-transport section 10.9), and that answer + /// restores one of the peer's MAX_REQUEST_UPDATES credits; without it an advertised limit would + /// strand a peer that only ever sends priority or forward updates. + async fn answer_request_update_ok( + &self, + writer: &mut Writer, + request_id: RequestId, + ) -> Result<(), Error> { + if matches!(self.version, Version::Draft14) { + return Ok(()); + } + let answer_id = matches!(self.version, Version::Draft15 | Version::Draft16).then_some(request_id); + self.write_request_ok(writer, answer_id).await + } + /// Serve a draft-20 fill on its own fetch stream: the requested range, read from the /// group cache, capped at the Largest Object snapshot. /// @@ -3863,6 +3909,31 @@ mod serve_tests { log.writes.lock().unwrap().clone() } + /// One token-less REQUEST_UPDATE (a priority or forward change) keyed to `rid`, framed as the + /// peer sends it. It carries no AUTHORIZATION TOKEN, so it starts no verify and is answered at + /// once rather than held. + async fn subscribe_update_bare_rid(version: Version, rid: u64) -> Vec { + let log = crate::lite::test_transport::Log::default(); + let mut writer = crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), version); + let subscription_request_id = match version { + Version::Draft14 | Version::Draft15 | Version::Draft16 => Some(RequestId(REQUEST_ID)), + _ => None, + }; + let msg = ietf::SubscribeUpdate { + request_id: RequestId(rid), + subscription_request_id, + start_location: Location { group: 0, object: 0 }, + end_group: 0, + subscriber_priority: Some(128), + forward: Some(true), + filter: None, + authorization_token: None, + }; + writer.encode(&ietf::SubscribeUpdate::ID).await.unwrap(); + writer.encode(&msg).await.unwrap(); + log.writes.lock().unwrap().clone() + } + /// Two token renewals that arrive while a first renewal's verdict is still pending are each /// verified and answered, not collapsed. The loop keeps per-update state while a verify is /// pending (draft-18 section 10.9.1 answers each update); the single slot it replaced @@ -3933,18 +4004,19 @@ mod serve_tests { ); } - /// A peer cannot grow the pending-renewal buffer without limit: once more than - /// MAX_BUFFERED_RENEWALS pile up behind a held verdict the request ends, not the session, as - /// draft-19 MAX_REQUEST_UPDATES allows. Without the bound the loop buffered every renewal and - /// never ended. + /// Draft-19 advertises MAX_REQUEST_UPDATES, so a peer that leaves more outstanding than that + /// broke the negotiated limit: draft-19 section 10.3.1.7 closes the session with + /// TOO_MANY_REQUEST_UPDATES. #[tokio::test(start_paused = true)] - async fn too_many_renewals_behind_a_held_verdict_end_the_request() { - const VERSION: Version = Version::Draft18; + async fn renewals_past_the_advertised_limit_close_the_session() { + const VERSION: Version = Version::Draft19; let (auth, mut requests, _cred) = auth_covering_other(); - // Well past the cap: the first renewal is held pending, the rest pile up behind it. + // Exactly one past the limit: the held renewal plus MAX_REQUEST_UPDATES queued behind it + // is the (limit + 1)th outstanding, the one that must close the session. + let last = 0x40 + request_update::MAX_REQUEST_UPDATES; let mut script = Vec::new(); - for rid in 0x40..=0x60u64 { + for rid in 0x40..=last { script.extend(subscribe_update_token_rid(VERSION, rid).await); } let h = serve_with_auth(VERSION, auth, script); @@ -3975,8 +4047,266 @@ mod serve_tests { } settle().await; } - assert!(ended, "flooding past the renewal cap must end the request"); - assert!(h.log.closes().is_empty(), "the flood ends the request, not the session"); + assert!(ended, "flooding past the advertised limit must end the request"); + assert!( + h.log + .closes() + .iter() + .any(|close| close.0 == crate::SessionError::TooManyRequestUpdates.to_code()), + "the flood must close the session with TOO_MANY_REQUEST_UPDATES: {:?}", + h.log.closes() + ); + } + + /// Exactly MAX_REQUEST_UPDATES outstanding (the one being verified plus the queue) is within + /// the advertised limit, so a draft-19 peer holding that many is not faulted: the request + /// keeps running and the session stays up. + #[tokio::test(start_paused = true)] + async fn renewals_at_the_advertised_limit_keep_the_request() { + const VERSION: Version = Version::Draft19; + + let (auth, mut requests, _cred) = auth_covering_other(); + // The held renewal plus MAX_REQUEST_UPDATES - 1 queued behind it is exactly the limit. + let last = 0x40 + request_update::MAX_REQUEST_UPDATES - 1; + let mut script = Vec::new(); + for rid in 0x40..=last { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "the request must stay up at exactly the advertised limit" + ); + settle().await; + } + assert!( + h.log.closes().is_empty(), + "no session close at the limit: {:?}", + h.log.closes() + ); + } + + /// Drafts below 19 carry no MAX_REQUEST_UPDATES option, so a peer there agreed to no ceiling: + /// the same flood that closes a draft-19 session must neither end the request nor close the + /// session on draft-18, because a conforming peer must not be stranded for a limit it never saw. + #[tokio::test(start_paused = true)] + async fn older_drafts_do_not_strand_a_renewal_flood() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let mut script = Vec::new(); + for rid in 0x40..=0x60u64 { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "draft-18 negotiates no limit, so the flood must not strand the request" + ); + settle().await; + } + assert!(h.log.closes().is_empty(), "draft-18 flood must not close the session"); + } + + /// Every REQUEST_UPDATE owes one answer, token or not (moq-transport section 10.9), and that + /// answer restores one MAX_REQUEST_UPDATES credit. A peer that only sends token-less priority + /// updates must keep its credit so it can still renew, so each gets a REQUEST_OK and the + /// session stays up well past the advertised limit. + #[tokio::test(start_paused = true)] + async fn token_less_updates_are_each_acknowledged() { + const VERSION: Version = Version::Draft19; + + let (auth, mut requests, _cred) = auth_covering_other(); + // More than the advertised limit, none carrying a token: none starts a verify, so none is + // ever outstanding. + let count = request_update::MAX_REQUEST_UPDATES + 4; + let mut script = Vec::new(); + for rid in 0x40..0x40 + count { + script.extend(subscribe_update_bare_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "token-less updates must not strand the request" + ); + settle().await; + } + + // Draft-19 answers each with a REQUEST_OK, request id omitted. + let one_ok = { + let log = crate::lite::test_transport::Log::default(); + let mut writer = + crate::coding::Writer::new(crate::lite::test_transport::SinkSend::new(log.clone()), VERSION); + writer.encode(&ietf::RequestOk::ID).await.unwrap(); + writer.encode(&ietf::RequestOk { request_id: None }).await.unwrap(); + log.writes.lock().unwrap().clone() + }; + let written = h.log.writes.lock().unwrap().clone(); + assert!( + written.ends_with(&one_ok.repeat(count as usize)), + "each token-less update must get exactly one REQUEST_OK" + ); + assert!( + h.log.closes().is_empty(), + "token-less updates must not close the session" + ); + } + + /// Before draft-17 a token-less REQUEST_UPDATE is answered keyed to its own request id + /// (draft-15/16), or not at all (draft-14, which defines no response). This pins the keyed and + /// silent branches of the acknowledgement so a wrong key cannot misroute the peer's answer. + #[tokio::test(start_paused = true)] + async fn token_less_updates_are_keyed_before_draft_17() { + for version in [Version::Draft14, Version::Draft15, Version::Draft16] { + let (auth, mut requests, _cred) = auth_covering_other(); + let rids = [0x41u64, 0x42, 0x43]; + let mut script = Vec::new(); + for rid in rids { + script.extend(subscribe_update_bare_rid(version, rid).await); + } + let h = serve_with_auth(version, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), version).await.unwrap(); + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + for _ in 0..500 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(serving.as_mut()).is_pending(), + "{version}: token-less updates must not end the request" + ); + settle().await; + } + + // Draft-14 answers nothing; draft-15/16 answer each update keyed to its own request id. + let mut expected = Vec::new(); + if !matches!(version, Version::Draft14) { + for rid in rids { + expected.extend(request_ok_bytes(version, RequestId(rid)).await); + } + } + let written = h.log.writes.lock().unwrap().clone(); + assert!( + written.ends_with(&expected), + "{version}: each token-less update gets its keyed REQUEST_OK" + ); + assert!( + h.log.closes().is_empty(), + "{version}: token-less updates must not close the session" + ); + } + } + + /// On drafts without the option the guard is a memory backstop, not a protocol limit: a flood + /// past it ends the one request through PUBLISH_DONE, never the session. + #[tokio::test(start_paused = true)] + async fn an_older_draft_flood_past_the_guard_ends_the_request() { + const VERSION: Version = Version::Draft18; + + let (auth, mut requests, _cred) = auth_covering_other(); + let last = 0x40 + request_update::UNNEGOTIATED_GUARD as u64 + 1; + let mut script = Vec::new(); + for rid in 0x40..=last { + script.extend(subscribe_update_token_rid(VERSION, rid).await); + } + let h = serve_with_auth(VERSION, auth, script); + let rt = h.publisher.runtime.clone(); + + let mut group = h.track.create_group(group::Info { sequence: 0 }).unwrap(); + group.write_frame(timestamp(), b"frame".as_slice()).unwrap(); + group.finish().unwrap(); + settle().await; + + let stream = Stream::open(&mut h.session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.unwrap(); + let _held = first.accept(grant_all_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut serving = std::pin::pin!(h.publisher.clone().run_subscribe_stream(stream, token_subscribe())); + + let mut ended = false; + for _ in 0..4000 { + let _ = futures::poll!(acceptor.as_mut()); + if futures::poll!(serving.as_mut()).is_ready() { + ended = true; + break; + } + settle().await; + } + assert!(ended, "a flood past the guard must end the request"); + assert!(h.log.closes().is_empty(), "the guard ends the request, not the session"); } /// A peer that ends the subscription while a renewal is still being verified ends it here diff --git a/rs/moq-net/src/ietf/request_update.rs b/rs/moq-net/src/ietf/request_update.rs new file mode 100644 index 0000000000..44014a9dd2 --- /dev/null +++ b/rs/moq-net/src/ietf/request_update.rs @@ -0,0 +1,87 @@ +//! The MAX_REQUEST_UPDATES Setup Option (draft-ietf-moq-transport-19 section 10.3.1.7). +//! +//! A request stream (SUBSCRIBE, PUBLISH_NAMESPACE, ...) can be refreshed in place with +//! REQUEST_UPDATE messages. Each is outstanding from when the peer sends it until its +//! REQUEST_OK or REQUEST_ERROR, and MAX_REQUEST_UPDATES caps how many a peer may leave +//! outstanding on one stream at once. We verify a request token asynchronously, so a slow +//! acceptor holds one renewal unanswered while more pile up behind it; this bound is what +//! stops that queue from growing without limit. +//! +//! The option is draft-19+ (Option Type 0x08). Advertising it lets a conforming peer +//! self-limit, so a peer that exceeds the advertised value broke a negotiated rule, which +//! the draft answers with a session close ([`SessionError::TooManyRequestUpdates`]). Drafts +//! below 19 carry no such option, so a peer there agreed to no ceiling: the receiver keeps +//! a generous local guard that ends only the one request rather than fault a peer at +//! session scope for a limit it never saw. +//! +//! [`SessionError::TooManyRequestUpdates`]: crate::SessionError::TooManyRequestUpdates + +use super::Version; + +/// MAX_REQUEST_UPDATES Setup Option (Option Type 0x08). Even, so the value is a bare varint. +pub const OPTION: u64 = 0x08; + +/// The credit we advertise and enforce on drafts that define the option: the most +/// unacknowledged REQUEST_UPDATEs we accept on one request stream, counting the one being +/// verified. One constant drives both the advertisement and the enforcement so the value we +/// promise and the value we hold a peer to cannot drift. +pub const MAX_REQUEST_UPDATES: u64 = 16; + +/// Local resource guard on drafts without the option. Generous relative to the negotiated +/// credit, since those drafts negotiate no limit and a conforming peer must never reach it, but +/// still bounded: a control message carries up to a 16-bit length, so this caps the buffered +/// bytes per request stream rather than letting a flood grow without end. Exceeding it ends the +/// one request, never the session. +pub const UNNEGOTIATED_GUARD: usize = 64; + +/// Whether this version defines MAX_REQUEST_UPDATES. Draft-19 section 10.3.1.7 added it; the +/// drafts below carry no such Setup Option. +pub fn supported(version: Version) -> bool { + !matches!( + version, + Version::Draft14 | Version::Draft15 | Version::Draft16 | Version::Draft17 | Version::Draft18 + ) +} + +/// Advertise our MAX_REQUEST_UPDATES credit, on versions that define the option. +pub fn into_setup(params: &mut super::Parameters, version: Version) { + if supported(version) { + params.set_varint(super::ParameterVarInt::MaxRequestUpdates, MAX_REQUEST_UPDATES); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The option rides SETUP only on the drafts that define it; an older peer sees nothing, + /// which is what keeps the bound a negotiated contract rather than a surprise. + #[test] + fn advertised_only_on_draft_19_plus() { + for version in [Version::Draft19, Version::Draft20, Version::Draft21, Version::Draft22] { + let mut params = super::super::Parameters::default(); + into_setup(&mut params, version); + assert_eq!( + params.get_varint(super::super::ParameterVarInt::MaxRequestUpdates), + Some(MAX_REQUEST_UPDATES), + "{version} must advertise MAX_REQUEST_UPDATES" + ); + } + + for version in [ + Version::Draft14, + Version::Draft15, + Version::Draft16, + Version::Draft17, + Version::Draft18, + ] { + let mut params = super::super::Parameters::default(); + into_setup(&mut params, version); + assert_eq!( + params.get_varint(super::super::ParameterVarInt::MaxRequestUpdates), + None, + "{version} has no MAX_REQUEST_UPDATES option to advertise" + ); + } + } +} diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index de34ac3639..62a66660c8 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -9,7 +9,7 @@ use crate::{ use super::{ Control, Message, Publisher, Subscriber, Version, adapter::ControlStreamAdapter, auth, cluster, hidden, peer, - solicit, subscriber::is_protocol_violation, + request_update, solicit, subscriber::is_protocol_violation, }; /// Everything one moq-transport session needs to start. @@ -678,6 +678,7 @@ async fn run_setup( if extensions.auth { auth::into_setup(&mut parameters, version); } + request_update::into_setup(&mut parameters, version); let parameters = parameters.encode_bytes(version)?; writer.encode(&setup::Setup { parameters }).await?; diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index d5a3456ca2..20f942c57a 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -14,7 +14,7 @@ use crate::{ util::{MaybeBoxedExt, MaybeSendBox, TaskSet, Tasks}, }; -use super::{Message, Version, cluster, error::request, peer}; +use super::{Message, Version, cluster, error::request, peer, request_update}; use crate::tail::{Reading, Settle, Tail}; use kio::Lock; @@ -1212,9 +1212,9 @@ where // Updates that arrived while a renewal was pending, handled in order once each verdict // resolves. A FIFO queue, not a single slot: draft-18 section 10.9.1 permits coalescing // the cumulative deltas but still requires an answer per update, so an earlier buffered - // update must not be dropped by a later one. The queue is bounded, so a peer cannot grow - // it without limit behind a slow verdict. - const MAX_BUFFERED_RENEWALS: usize = 16; + // update must not be dropped by a later one. The queue is bounded by MAX_REQUEST_UPDATES, + // counting the one being verified, so a peer cannot grow it without limit behind a slow + // verdict. let mut stashed: std::collections::VecDeque<(u64, bytes::Bytes)> = std::collections::VecDeque::new(); // A withdrawal read while a renewal was pending, handled ahead of any queued update so // a buffered update never masks it: nothing more is owed once the peer retracts. @@ -1275,11 +1275,35 @@ where Ren::Buffered(message) => { // Queue the update to handle once the verdict resolves; the loop keeps // reading, so a terminal is never masked by it. Each buffered update is - // kept, in order, so none loses its cluster delta or its answer. Too many - // outstanding behind a slow verdict ends this announce, never the session - // (an Err would reach the dispatcher as a protocol violation and close it), - // as draft-19 MAX_REQUEST_UPDATES allows: finish the stream and stop. - if stashed.len() >= MAX_BUFFERED_RENEWALS { + // kept, in order, so none loses its cluster delta or its answer. Only a + // REQUEST_UPDATE counts toward the limit (anything else is caught as an + // unexpected message when it drains); the one being verified plus the + // queued updates are the outstanding REQUEST_UPDATEs, and what happens when + // another would exceed the ceiling is version-appropriate. + let is_update = message.0 == ietf::PublishNamespaceUpdate::ID; + let outstanding = stashed + .iter() + .filter(|(id, _)| *id == ietf::PublishNamespaceUpdate::ID) + .count() as u64 + 1; + if request_update::supported(self.version) && is_update { + // Draft-19+: we advertised MAX_REQUEST_UPDATES, so a peer with that many + // already outstanding sending another broke the negotiated limit. + // Draft-19 section 10.3.1.7 answers that with a session close, + // TOO_MANY_REQUEST_UPDATES. Returning the error is not enough here: the + // dispatcher closes only on is_protocol_violation, which excludes + // Error::Session, so close explicitly as the publisher does. A conforming + // peer self-limits and never reaches here. + if outstanding >= request_update::MAX_REQUEST_UPDATES { + self.session.clone().close( + crate::SessionError::TooManyRequestUpdates.to_code(), + "too many request updates", + ); + return Err(Error::Session(crate::SessionError::TooManyRequestUpdates)); + } + } else if stashed.len() >= request_update::UNNEGOTIATED_GUARD { + // Drafts below 19 negotiate no limit, so a peer agreed to no ceiling: + // this is a local memory guard, not a protocol fault. End this announce, + // never the session: finish the stream and stop. if stream.writer.finish().is_ok() { let _ = stream.writer.closed().await; } @@ -5664,17 +5688,18 @@ mod tests { ); } - /// A peer cannot grow the announce renewal buffer without limit: once more than - /// MAX_BUFFERED_RENEWALS pile up behind a held verdict the announce ends and - /// run_publish_namespace_stream returns Ok, so the dispatcher does not treat it as a protocol - /// violation and close the session. Without the bound the loop buffered every update and never - /// ended; returning an error here would have closed the whole session. + /// Draft-19 advertises MAX_REQUEST_UPDATES, so a peer that leaves more outstanding than that on + /// an announce stream broke the negotiated limit: draft-19 section 10.3.1.7 closes the session + /// with TOO_MANY_REQUEST_UPDATES. run_publish_namespace_updates must close explicitly (the + /// dispatcher does not close on an Error::Session), and surface that error too. #[tokio::test(start_paused = true)] - async fn too_many_announce_renewals_behind_a_held_verdict_end_the_announce_not_the_session() { - const VERSION: Version = Version::Draft18; + async fn announce_renewals_past_the_advertised_limit_close_the_session() { + const VERSION: Version = Version::Draft19; + // Exactly one past the limit: the held renewal plus MAX_REQUEST_UPDATES queued behind it. + let last = 0x40 + request_update::MAX_REQUEST_UPDATES; let mut script = Vec::new(); - for rid in 0x40..=0x60u64 { + for rid in 0x40..=last { script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); } let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); @@ -5704,13 +5729,155 @@ mod tests { } settle().await; } + let Some(Err(err)) = result else { + panic!("flooding past the advertised limit must end the announce with an error: {result:?}"); + }; + assert_eq!( + SessionError::from(&err), + SessionError::TooManyRequestUpdates, + "the flood must surface TOO_MANY_REQUEST_UPDATES" + ); + assert!( + session + .log + .closes() + .iter() + .any(|close| close.0 == SessionError::TooManyRequestUpdates.to_code()), + "the flood must close the session with TOO_MANY_REQUEST_UPDATES: {:?}", + session.log.closes() + ); + } + + /// Exactly MAX_REQUEST_UPDATES outstanding (the one being verified plus the queue) is within + /// the advertised limit, so a draft-19 peer holding that many is not faulted: the announce + /// keeps running and the session stays up. + #[tokio::test(start_paused = true)] + async fn announce_renewals_at_the_advertised_limit_keep_the_announce() { + const VERSION: Version = Version::Draft19; + + let last = 0x40 + request_update::MAX_REQUEST_UPDATES - 1; + let mut script = Vec::new(); + for rid in 0x40..=last { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "the announce must stay up at exactly the advertised limit" + ); + settle().await; + } + assert!( + session.log.closes().is_empty(), + "no session close at the limit: {:?}", + session.log.closes() + ); + } + + /// Drafts below 19 carry no MAX_REQUEST_UPDATES option, so a peer there agreed to no ceiling: + /// the same flood that closes a draft-19 session must neither end the announce nor close the + /// session on draft-18, because a conforming peer must not be stranded for a limit it never saw. + #[tokio::test(start_paused = true)] + async fn older_drafts_do_not_strand_an_announce_flood() { + const VERSION: Version = Version::Draft18; + + let mut script = Vec::new(); + for rid in 0x40..=0x60u64 { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + for _ in 0..2000 { + let _ = futures::poll!(acceptor.as_mut()); + assert!( + futures::poll!(run.as_mut()).is_pending(), + "draft-18 negotiates no limit, so the flood must not strand the announce" + ); + settle().await; + } + assert!( + session.log.closes().is_empty(), + "draft-18 flood must not close the session" + ); + } + + /// On drafts without the option the guard is a memory backstop, not a protocol limit: a flood + /// past it ends the one announce by finishing the stream, never the session. + #[tokio::test(start_paused = true)] + async fn an_older_draft_announce_flood_past_the_guard_ends_the_announce() { + const VERSION: Version = Version::Draft18; + + let last = 0x40 + request_update::UNNEGOTIATED_GUARD as u64 + 1; + let mut script = Vec::new(); + for rid in 0x40..=last { + script.extend(publish_namespace_update_token_rid(VERSION, rid, None, None).await); + } + let (mut subscriber, mut requests, _cred, _consumer, session) = auth_announce_harness(VERSION, script); + let rt = subscriber.runtime.clone(); + let stream = Stream::open(&mut session.clone(), VERSION).await.unwrap(); + + let acceptor = async move { + let first = requests.next().await.expect("a request"); + let _held = first.accept(publish_grant_expiring(&rt, None)); + std::future::pending::<()>().await + }; + let mut acceptor = std::pin::pin!(acceptor); + let mut run = std::pin::pin!(subscriber.run_publish_namespace_stream( + stream, + token_publish_namespace(), + cluster::Peer::default(), + None, + )); + + let mut result = None; + for _ in 0..4000 { + let _ = futures::poll!(acceptor.as_mut()); + if let std::task::Poll::Ready(r) = futures::poll!(run.as_mut()) { + result = Some(r); + break; + } + settle().await; + } assert!( matches!(result, Some(Ok(()))), - "flooding past the cap must end the announce with Ok, not a session-closing error: {result:?}" + "a flood past the guard ends the announce with Ok: {result:?}" ); assert!( session.log.closes().is_empty(), - "the flood ends the announce, not the session" + "the guard ends the announce, not the session" ); } From c2b10b2237436f71c81e03f6e07006d02efea27b Mon Sep 17 00:00:00 2001 From: sletmoe Date: Fri, 2 Oct 2026 23:53:01 +0000 Subject: [PATCH 48/49] feat(net): record the peer's advertised MAX_REQUEST_UPDATES Parse the MAX_REQUEST_UPDATES Setup Option a peer advertises (draft-19 section 10.3.1.7) and keep it on the per-session Peer. An absent option and a 0 value both mean no limit, recorded as None. The current sender keeps one renewal in flight per request, so it honors any credit without reading it; recording the value lets a future sender pace to the peer's limit. Co-Authored-By: Claude --- rs/moq-net/src/ietf/peer.rs | 9 +++++ rs/moq-net/src/ietf/publisher.rs | 1 + rs/moq-net/src/ietf/request_update.rs | 53 +++++++++++++++++++++++++++ rs/moq-net/src/ietf/session.rs | 1 + 4 files changed, 64 insertions(+) diff --git a/rs/moq-net/src/ietf/peer.rs b/rs/moq-net/src/ietf/peer.rs index 336ffb755a..6f4d7adbee 100644 --- a/rs/moq-net/src/ietf/peer.rs +++ b/rs/moq-net/src/ietf/peer.rs @@ -22,6 +22,13 @@ pub(crate) struct Peer { /// MoQ Auth: whether both sides negotiated the Auth request streams. pub auth: bool, + + /// MAX_REQUEST_UPDATES: the most unanswered REQUEST_UPDATEs the peer accepts on one + /// request stream, if it set a limit (draft-19 section 10.3.1.7). `None` on a draft + /// without the option, and when the peer advertised none or `0` (both meaning no limit). + /// Recorded for a future sender that paces to this credit; the current sender keeps one + /// renewal in flight per request, which stays within any limit without reading it. + pub max_request_updates: Option, } /// Shared slot for [`Peer`], filled when the peer's SETUP is read. @@ -82,6 +89,7 @@ mod tests { solicit: None, hidden: false, auth: false, + max_request_updates: None, }; let slot = PeerSetup::default(); @@ -94,6 +102,7 @@ mod tests { solicit: Some(true), hidden: true, auth: true, + max_request_updates: Some(16), }); assert_eq!(slot.get().await, first); diff --git a/rs/moq-net/src/ietf/publisher.rs b/rs/moq-net/src/ietf/publisher.rs index 1d56a2684d..164ce3acc1 100644 --- a/rs/moq-net/src/ietf/publisher.rs +++ b/rs/moq-net/src/ietf/publisher.rs @@ -6684,6 +6684,7 @@ mod tests { solicit, hidden: false, auth: false, + max_request_updates: None, }); slot } diff --git a/rs/moq-net/src/ietf/request_update.rs b/rs/moq-net/src/ietf/request_update.rs index 44014a9dd2..fc8d59de0f 100644 --- a/rs/moq-net/src/ietf/request_update.rs +++ b/rs/moq-net/src/ietf/request_update.rs @@ -50,6 +50,21 @@ pub fn into_setup(params: &mut super::Parameters, version: Version) { } } +/// The MAX_REQUEST_UPDATES the peer advertised, if it set a limit. `None` on a draft +/// without the option, on a peer that sent none, and on an explicit `0`: draft-19 section +/// 10.3.1.7 reads both absent and `0` as no limit. Recorded for a future sender that paces +/// to the peer's credit; today the sender keeps one renewal in flight per request, which +/// honors any limit without reading it. +pub fn from_setup(params: &super::Parameters, version: Version) -> Option { + match supported(version) { + // Draft-19 section 10.3.1.7: an absent option and a `0` value both mean no limit. + true => params + .get_varint(super::ParameterVarInt::MaxRequestUpdates) + .filter(|&n| n != 0), + false => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -84,4 +99,42 @@ mod tests { ); } } + + /// What we advertise is what a peer reading our SETUP records, on the drafts that carry + /// the option; an older draft or an absent option records no limit. + #[test] + fn records_the_advertised_limit() { + let mut params = super::super::Parameters::default(); + into_setup(&mut params, Version::Draft19); + assert_eq!( + from_setup(¶ms, Version::Draft19), + Some(MAX_REQUEST_UPDATES), + "a draft-19 peer must record the advertised credit" + ); + + // A draft-19 SETUP carrying no option reads as no limit, not as a default. + assert_eq!( + from_setup(&super::super::Parameters::default(), Version::Draft19), + None, + "an absent option is no limit" + ); + + // An explicit 0 is also no limit (draft-19 section 10.3.1.7), not a zero credit. + let mut zero = super::super::Parameters::default(); + zero.set_varint(super::super::ParameterVarInt::MaxRequestUpdates, 0); + assert_eq!( + from_setup(&zero, Version::Draft19), + None, + "0 means no limit, not zero credit" + ); + + // The option does not exist below draft-19, so even a stray value is ignored. + let mut legacy = super::super::Parameters::default(); + legacy.set_varint(super::super::ParameterVarInt::MaxRequestUpdates, MAX_REQUEST_UPDATES); + assert_eq!( + from_setup(&legacy, Version::Draft18), + None, + "draft-18 defines no MAX_REQUEST_UPDATES option to read" + ); + } } diff --git a/rs/moq-net/src/ietf/session.rs b/rs/moq-net/src/ietf/session.rs index 62a66660c8..8d0d94e31d 100644 --- a/rs/moq-net/src/ietf/session.rs +++ b/rs/moq-net/src/ietf/session.rs @@ -635,6 +635,7 @@ fn peer_from_params(params: &ietf::Parameters, version: Version) -> Result Date: Fri, 2 Oct 2026 23:53:01 +0000 Subject: [PATCH 49/49] fix(net): keep one request-token renewal in flight per subscription The subscriber re-presented a replaced request token as a REQUEST_UPDATE on every change without waiting for a response, so a burst of replacements could leave more renewals outstanding than the serving side's MAX_REQUEST_UPDATES credit (draft-19 section 10.3.1.7). The serving side answers that overflow by closing the session, losing every request on it. Keep at most one renewal unanswered per subscription: a replacement that arrives while one is in flight is coalesced, and the newest is sent once the outstanding one is answered. This stays within any advertised credit without reading it, and matches renewal semantics, where only the newest token matters. Co-Authored-By: Claude --- rs/moq-net/src/ietf/subscriber.rs | 93 +++++++++++++++++--- rs/moq-net/tests/auth.rs | 140 ++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+), 12 deletions(-) diff --git a/rs/moq-net/src/ietf/subscriber.rs b/rs/moq-net/src/ietf/subscriber.rs index 20f942c57a..d696555084 100644 --- a/rs/moq-net/src/ietf/subscriber.rs +++ b/rs/moq-net/src/ietf/subscriber.rs @@ -2220,14 +2220,30 @@ where Revoked, /// The client replaced its request token: re-present it on this live subscription. Renew(Option), + /// The publisher answered the renewal in flight (REQUEST_OK / REQUEST_ERROR), so + /// a replacement coalesced behind it may now be sent. + Answered, Done(Result), } let mut fetch_done = fetching.is_none(); // The request token last presented on this subscription (its initial value). let mut last_token = self.request_token.peek(); + // At most one renewal (SUBSCRIBE_UPDATE) is left unanswered at a time: a replacement + // that arrives while one is in flight is coalesced into `last_token` and sent once + // the outstanding one is answered, so the sender never outruns the receiver's + // MAX_REQUEST_UPDATES credit (draft-19 section 10.3.1.7) whatever its value, without + // reading it. `in_flight` is the token value awaiting an answer, `None` when nothing + // is outstanding. Draft-14 answers no accepted renewal, so it cannot pace on answers + // and re-presents each change directly; it also advertises no credit to exceed. + let throttle = !matches!(self.version, Version::Draft14); + let mut in_flight: Option = None; + // Bumped by `read_publish_done` on each renewal answer; the loop releases the + // coalesced replacement when it advances past `seen_answers`. + let answers = kio::Shared::new(0u64); + let mut seen_answers = 0u64; let cancelled = { - let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version)); + let mut done = std::pin::pin!(Self::read_publish_done(&mut stream.reader, self.version, &answers)); loop { let end = kio::wait(|waiter| { if !fetch_done @@ -2245,6 +2261,16 @@ where if let Poll::Ready(token) = self.request_token.poll_changed(&last_token, waiter) { return Poll::Ready(End::Renew(token)); } + // Only wait on an answer while a renewal is actually outstanding. + if throttle + && in_flight.is_some() + && let Poll::Ready(count) = answers.poll(waiter, |count| match **count != seen_answers { + true => Poll::Ready(()), + false => Poll::Pending, + }) { + seen_answers = *count; + return Poll::Ready(End::Answered); + } waiter.poll_future(done.as_mut()).map(End::Done) }) .await; @@ -2262,20 +2288,55 @@ where // disjoint borrow from its reader, so writing here does not disturb the read. End::Renew(token) => { last_token = token.clone(); - if let Some(token) = token - && let Err(err) = self + // Send only when no renewal is outstanding; otherwise coalesce, leaving the + // newest in `last_token` to go out on End::Answered. A cleared token (`None`) + // is not a renewal and sends nothing. + let send_now = !throttle || in_flight.is_none(); + if send_now && let Some(token) = token { + match self .send_request_token_update( &mut stream.writer, request_id, subscriber_priority, renewal_start, - token, + token.clone(), ) .await + { + Ok(()) if throttle => { + in_flight = Some(token); + // Only an answer that arrives after this send releases the + // coalesced follow-up, so a stray earlier answer cannot. + seen_answers = *answers.lock(); + } + Ok(()) => {} + // A failed send does not end the subscription: it continues on the old + // grant until that lapses, and the next change re-presents the token. + Err(err) => tracing::debug!(%err, "failed to re-present the request token"), + } + } + } + End::Answered => { + // The outstanding renewal was answered. If the credential changed while it + // was in flight, present the newest now (a cleared token sends nothing); + // otherwise the publisher already holds the latest. + let was = in_flight.take(); + if last_token != was + && let Some(token) = last_token.clone() { - // A failed send does not end the subscription: it continues on the old - // grant until that lapses, and the next change re-presents the token. - tracing::debug!(%err, "failed to re-present the request token"); + match self + .send_request_token_update( + &mut stream.writer, + request_id, + subscriber_priority, + renewal_start, + token.clone(), + ) + .await + { + Ok(()) => in_flight = Some(token), + Err(err) => tracing::debug!(%err, "failed to re-present the request token"), + } } } End::Revoked => { @@ -2347,10 +2408,15 @@ where /// /// A request-token renewal we sent (SUBSCRIBE_UPDATE) is answered on this same stream /// (REQUEST_OK / REQUEST_ERROR on draft-15+, SUBSCRIBE_ERROR on draft-14; draft-14 is - /// silent on an accepted renewal). Those are consumed here and the read continues: a - /// refused renewal leaves the old grant standing until it lapses, so the subscription - /// ends then, with its PUBLISH_DONE, not on the answer. - async fn read_publish_done(reader: &mut Reader, version: Version) -> Result { + /// silent on an accepted renewal). Each answer bumps `answers` so the send loop can + /// release the renewal it coalesced behind the one in flight; the read continues + /// regardless, since a refused renewal leaves the old grant standing until it lapses, + /// so the subscription ends then, with its PUBLISH_DONE, not on the answer. + async fn read_publish_done( + reader: &mut Reader, + version: Version, + answers: &kio::Shared, + ) -> Result { loop { match reader.decode_maybe::().await? { Some(ietf::PublishDone::ID) => { @@ -2362,6 +2428,7 @@ where Some(ietf::RequestOk::ID) => { let msg: ietf::RequestOk = reader.decode().await?; tracing::debug!(message = ?msg, "request token renewal accepted"); + *answers.lock() += 1; } Some(ietf::RequestError::ID) => { // draft-17+ generalized SUBSCRIBE_ERROR into REQUEST_ERROR at the same id; @@ -2377,6 +2444,7 @@ where tracing::warn!(message = ?msg, "request token renewal refused"); } } + *answers.lock() += 1; } Some(_) => return Err(Error::UnexpectedMessage), None => return Err(Error::ProtocolViolation), @@ -3605,7 +3673,8 @@ mod tests { let mut session = ScriptedSession::eof(responses); let (_, recv) = session.open_bi().await.unwrap(); let mut reader = Reader::new(recv, Version::Draft19); - let result = Subscriber::::read_publish_done(&mut reader, Version::Draft19).await; + let answers = kio::Shared::new(0u64); + let result = Subscriber::::read_publish_done(&mut reader, Version::Draft19, &answers).await; if clean { assert_eq!(result.unwrap(), 0); } else { diff --git a/rs/moq-net/tests/auth.rs b/rs/moq-net/tests/auth.rs index 856f15f9b8..5f0e9757f3 100644 --- a/rs/moq-net/tests/auth.rs +++ b/rs/moq-net/tests/auth.rs @@ -22,6 +22,7 @@ const LITE_06: &str = "moq-lite-06"; const MOQT_17: &str = "moq-transport-17"; /// A draft at the deployed floor, used for the request-token launch shape. const MOQT_18: &str = "moq-transport-18"; +const MOQT_19: &str = "moq-transport-19"; const MOQT_22: &str = "moq-transport-22"; /// Run each case on every version that exchanges AUTH. @@ -596,6 +597,145 @@ async fn a_request_token_renews_a_subscription_through_the_driver() { .expect("timed out"); } +/// The sender honors the receiver's MAX_REQUEST_UPDATES credit: it keeps at most one +/// renewal in flight per request and coalesces replacements that arrive while one is +/// unanswered, so a burst of token changes never outruns the credit (draft-19 section +/// 10.3.1.7). +/// +/// Two drivers over the in-process transport at draft-19, where the serving side advertises +/// and enforces a 16-update credit with a session close ([`SessionError::TooManyRequestUpdates`]). +/// The acceptor holds the first renewal's verifier, the client replaces its token well past +/// the credit, and the test asserts the connection stays up, only the held renewal reaches the +/// acceptor, and resolving it releases exactly the newest token, not any coalesced between. +/// +/// A fire-and-forget sender would put every renewal outstanding behind the held verifier and +/// the serving side would close the session, losing every request on it. +/// +/// [`SessionError::TooManyRequestUpdates`]: moq_net::SessionError::TooManyRequestUpdates +#[tokio::test] +async fn a_held_renewal_coalesces_a_burst_without_tripping_the_credit() { + within(async { + let ts = |ms| moq_net::Timestamp::from_millis(ms).unwrap(); + let prefs = || moq_net::track::Subscription::default().with_max_age(Duration::from_secs(10)); + let server_origin = produce_origin(1); + let down = server_origin.create_broadcast("room/alice").unwrap(); + let down_track = down.create_track("video", None).unwrap(); + down.announce(Default::default()).unwrap(); + + // USE_VALUE (0x03), token kind 0, then a distinct value per credential. + let token = |n: u8| vec![0x03, 0x00, b't', n]; + let initial = token(0); + let received = produce_origin(3); + let mut pair = connect(Options { + version: Some(MOQT_19), + client_subscribe: Some(received.clone()), + client_request_token: Some(initial.clone()), + client_decline_auth: true, + server_publish: Some(server_origin.clone()), + server_requests: true, + ..Default::default() + }) + .await; + + // The acceptor runs concurrently with the subscribe (the initial token rides the + // SUBSCRIBE, so nothing reaches it until we subscribe). It reports every token it + // verifies and holds the first renewal's verifier until released, the way a relay with + // a slow authorizer would. + let mut requests = pair.requests.take().expect("server took its requests pre-ok"); + let (seen_tx, mut seen) = tokio::sync::mpsc::unbounded_channel::>(); + let release = std::sync::Arc::new(tokio::sync::Notify::new()); + let release_waiter = release.clone(); + let acceptor = tokio::spawn(async move { + let mut issued = Vec::new(); + let mut count = 0u64; + while let Some(request) = requests.next().await { + let token = request.token().to_vec(); + count += 1; + // Request 1 is the initial SUBSCRIBE token; request 2 is the first renewal, + // held until the test releases it. + if count == 2 { + seen_tx.send(token).ok(); + release_waiter.notified().await; + } else { + seen_tx.send(token).ok(); + } + issued.push(request.accept(grant(&[], &["room/alice"]))); + } + }); + + // Establish and deliver one group so the subscription is live. The initial token rides + // the SUBSCRIBE, which the acceptor (above) verifies concurrently. + let remote = received.consume().routed_broadcast("room/alice").await.unwrap(); + let mut sub = remote.track("video").unwrap().subscribe(prefs()).await.unwrap(); + let mut group = down_track.append_group().unwrap(); + group.write_frame(ts(0), b"one".as_ref()).unwrap(); + group.finish().unwrap(); + sub.recv_group().await.unwrap().unwrap(); + assert_eq!( + seen.recv().await.expect("initial token"), + initial[2..], + "the SUBSCRIBE carries the token" + ); + + // Replace the token once and let that one renewal reach the held acceptor, so the held + // renewal is deterministic regardless of scheduling. + pair.client.auth().set_request_token(token(1)); + assert_eq!( + seen.recv().await.expect("first renewal"), + token(1)[2..], + "the first replacement goes out immediately" + ); + + // With that renewal held unanswered, replace the token many more times, spaced so the + // driver observes each: the scenario the credit guards. Far past any plausible credit, + // so the test keeps guarding if MAX_REQUEST_UPDATES grows. A fire-and-forget sender + // would put all of these outstanding behind the held verifier and the serving side + // would close the session with TOO_MANY_REQUEST_UPDATES, losing every request on it; + // the one-in-flight rule coalesces them behind the held one instead. + for n in 2..=64u8 { + pair.client.auth().set_request_token(token(n)); + tokio::time::sleep(Duration::from_millis(5)).await; + } + + assert_eq!( + pair.client_transport.close_reason(), + None, + "the burst never tripped the receiver's credit" + ); + assert_eq!( + pair.server_transport.close_reason(), + None, + "the server never closed the session" + ); + + // Resolve the held verifier. The sender sends the coalesced renewal carrying the newest + // token, not any of the ones replaced between. + release.notify_one(); + assert_eq!( + seen.recv().await.expect("coalesced renewal"), + token(64)[2..], + "the newest token wins; the rest are coalesced away" + ); + + // Only the newest coalesced renewal followed the held one: the burst collapsed to one, + // not a backlog queued behind it. + assert!( + tokio::time::timeout(Duration::from_millis(100), seen.recv()) + .await + .is_err(), + "only the newest coalesced renewal followed, not a backlog" + ); + assert_eq!( + pair.client_transport.close_reason(), + None, + "the session stayed up throughout" + ); + acceptor.abort(); + }) + .await + .expect("timed out"); +} + /// A server may decline the MoQ Solicit extension, so a peer sends an unsolicited /// PUBLISH_NAMESPACE (the base moq-transport behavior) instead of answering our /// SUBSCRIBE_NAMESPACE inline. Only the unsolicited PUBLISH_NAMESPACE carries an