cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix: bind authenticated media to the advertised authorization revision

Dami committed 9c040f6
An enrolled host mints each viewer's subscription capability at that viewer's
enrollment revision. `AuthorizationState::empty` starts `committed_revision` at
1, so the first enrolled viewer is already revision 2 and every later
enrollment or grant change climbs from there. Portal and sigil-probe both
verified the capability against a hardcoded 1, so on any host running
`AuthorizationPolicy::Required` -- the only configuration the appliance ships
-- authenticated v2 media was rejected with "capability authorization revision
is stale" before a single frame arrived.

Nothing caught it because every automated proof runs under TestPatternProof or
DevelopmentBypass, and those pin `authorization_revision` to 1. The proof
output says so on every run: `revocation_isolation=not-applicable-direct-proof-mode`.

A client cannot know its own enrollment revision, so the host now states it in
the same accepted control-v2 hello that carries the capability, and
`HostHelloV2::validate` refuses a hello whose capability disagrees with the
advertised revision or advertises one without the other. Both clients read the
revision from the hello instead of assuming it.

The regression test composes the real enrollment path with the real client
verification path across two enrollments and a grant change, and pins the
defect: a hardcoded revision must not accept an enrolled viewer's capability.
commit
9c040f61fa919e1b68f26efa87db8fba09b05b9d
parent
dc297376ba062212bfaae208d2711e45473cf225
author
Dami <dami.etoile@gmail.com>
7 files changed, +348 -21
Mcrates/sigil-host/src/authorization.rs173++++++++++++++++++++++++++++++
Mcrates/sigil-host/src/bin/sigil-probe.rs74++++++++++--
Mcrates/sigil-host/src/server/moq.rs3+-
Mcrates/sigil-protocol/src/control_v2.rs111++++++++++++++++++-
Msrc-tauri/src/media/connect.rs1+
Msrc-tauri/src/media/moq_receiver.rs3+-
Msrc-tauri/src/media/transport.rs4+
M crates/sigil-host/src/authorization.rs +173 -0
@@ -1246,6 +1246,179 @@ mod tests {
12461246 );12471247 }12481248 1249+ // Regression: every automated proof runs under TestPatternProof or1250+ // DevelopmentBypass, which pin `authorization_revision` to 1. A real1251+ // `Required` host mints enrollment revisions starting at 2 and rising with1252+ // every grant change, so any client that assumes 1 rejects its own1253+ // authenticated media. Compose the exact host issuance path with the exact1254+ // client verification path across several enrollments and mutations.1255+ #[test]1256+ fn enrolled_viewers_verify_their_capability_at_the_advertised_revision() {1257+ use sigil_protocol::{1258+ Capability, HostHelloV2, SignedSubscriptionCapability, SubscriptionClaims,1259+ SubscriptionTracks,1260+ };1261+1262+ let (_directory, host, first_peer, store) = store();1263+ let second_peer = SecretKey::from_bytes(&[23; 32]);1264+ let generation_id = 11_u64;1265+ let now = 5_000_u64;1266+1267+ let first_token = token(1268+ &store,1269+ &host,1270+ first_peer.public(),1271+ 1_000,1272+ [41; 32],1273+ InvitationGrants::ALL,1274+ );1275+ let second_token = token(1276+ &store,1277+ &host,1278+ second_peer.public(),1279+ 1_002,1280+ [42; 32],1281+ InvitationGrants::ALL,1282+ );1283+ let mut admissions = vec![1284+ store1285+ .authorize_or_redeem_viewer(first_peer.public(), Some(&first_token), 1_001)1286+ .unwrap(),1287+ store1288+ .authorize_or_redeem_viewer(second_peer.public(), Some(&second_token), 1_003)1289+ .unwrap(),1290+ ];1291+ // A grant change bumps the revision again; re-admit to pick it up.1292+ let handle = admissions[0].handle.clone();1293+ store1294+ .replace_viewer_grants(&handle, Some(InvitationGrants::VIEW), 1_004)1295+ .unwrap();1296+ admissions.push(1297+ store1298+ .authorize_or_redeem_viewer(first_peer.public(), None, 1_005)1299+ .unwrap(),1300+ );1301+1302+ // No enrolled viewer lands on revision 1, which is exactly why the1303+ // client cannot assume it.1304+ assert!(1305+ admissions1306+ .iter()1307+ .all(|viewer| viewer.authorization_revision > 1),1308+ "enrolled revisions must not collide with the development-bypass constant"1309+ );1310+1311+ for (index, authorized) in admissions.iter().enumerate() {1312+ let peer = if index == 1 {1313+ second_peer.public()1314+ } else {1315+ first_peer.public()1316+ };1317+1318+ // Host: server/moq.rs mints the capability at the lease revision1319+ // and advertises that revision in the same accepted hello.1320+ let capability = SignedSubscriptionCapability::issue(1321+ SubscriptionClaims::new(1322+ *host.public().as_bytes(),1323+ generation_id,1324+ *peer.as_bytes(),1325+ SubscriptionTracks::VIDEO_H264,1326+ authorized.authorization_revision,1327+ now,1328+ now + 900,1329+ [u8::try_from(index + 1).unwrap(); 32],1330+ 1,1331+ )1332+ .unwrap(),1333+ &host.to_bytes(),1334+ )1335+ .unwrap();1336+ let hello = HostHelloV2::accepted(1337+ 7,1338+ vec![Capability::VideoH264],1339+ control_v2_snapshot(generation_id),1340+ )1341+ .with_media_subscription_capability(1342+ capability.encode(),1343+ authorized.authorization_revision,1344+ );1345+ hello1346+ .validate()1347+ .expect("host hello must bind its capability to the advertised revision");1348+1349+ // Client: Portal and sigil-probe take the revision from the hello.1350+ let advertised = hello1351+ .media_authorization_revision1352+ .expect("accepted hello advertises its authorization revision");1353+ SignedSubscriptionCapability::decode(1354+ hello1355+ .media_subscription_capability1356+ .as_deref()1357+ .expect("accepted hello carries a capability"),1358+ )1359+ .unwrap()1360+ .verify_binding(1361+ *host.public().as_bytes(),1362+ generation_id,1363+ *peer.as_bytes(),1364+ SubscriptionTracks::VIDEO_H264,1365+ advertised,1366+ now + 1,1367+ )1368+ .unwrap_or_else(|error| {1369+ panic!(1370+ "enrolled viewer at revision {} must verify its own capability: {error}",1371+ authorized.authorization_revision1372+ )1373+ });1374+1375+ // The shipped defect, pinned: a client that assumes the1376+ // development-bypass constant rejects a real enrolled viewer.1377+ assert!(1378+ capability1379+ .verify_binding(1380+ *host.public().as_bytes(),1381+ generation_id,1382+ *peer.as_bytes(),1383+ SubscriptionTracks::VIDEO_H264,1384+ 1,1385+ now + 1,1386+ )1387+ .is_err(),1388+ "a hardcoded revision must not accept an enrolled viewer's capability"1389+ );1390+ }1391+ }1392+1393+ fn control_v2_snapshot(generation_id: u64) -> sigil_protocol::SessionSnapshotV2 {1394+ use sigil_protocol::{1395+ ControllerSlot, FocusStateV2, FocusTransitionReasonV2, MediaGenerationDescriptorV2,1396+ SessionSnapshotV2, ViewerPresenceId, ViewerPresenceV2,1397+ };1398+ let presence = ViewerPresenceId::new("viewer-0001").unwrap();1399+ SessionSnapshotV2 {1400+ revision: 1,1401+ self_presence_id: presence.clone(),1402+ viewers: vec![ViewerPresenceV2 {1403+ presence_id: presence,1404+ session_id: 7,1405+ input_capable: true,1406+ you: true,1407+ }],1408+ focus: FocusStateV2::Vacant {1409+ slot: ControllerSlot::ZERO,1410+ },1411+ focus_proposal: None,1412+ self_is_focus_owner: false,1413+ transition_reason: FocusTransitionReasonV2::Initial,1414+ media: MediaGenerationDescriptorV2 {1415+ generation_id,1416+ broadcast_name: sigil_protocol::media_generation_moq_broadcast_name(generation_id)1417+ .unwrap(),1418+ },1419+ }1420+ }1421+12491422 #[test]12501423 fn multiple_peers_enroll_with_unique_stable_handles() {12511424 let (_directory, host, first_peer, store) = store();
M crates/sigil-host/src/bin/sigil-probe.rs +60 -14
@@ -539,6 +539,7 @@ struct MoqProbeReceiver {
539539 struct MoqProbeAuthentication {540540 expected_host: [u8; 32],541541 subscription_capability: String,542+ authorization_revision: u64,542543 now_unix: u64,543544 }544545
@@ -581,7 +582,7 @@ impl MoqProbeReceiver {
581582 generation_id,582583 *endpoint.id().as_bytes(),583584 SubscriptionTracks::VIDEO_H264,584- 1,585+ authentication.authorization_revision,585586 authentication.now_unix,586587 )?;587588 subscribe_authenticated_goq_video_track(
@@ -830,6 +831,9 @@ async fn run_control_v2_smoke(
830831 let subscription_capability = host831832 .media_subscription_capability832833 .context("host omitted control v2 media subscription capability")?;834+ let media_authorization_revision = host835+ .media_authorization_revision836+ .context("host omitted control v2 media authorization revision")?;833837 let initial = host834838 .snapshot835839 .context("host omitted initial control v2 snapshot")?;
@@ -856,6 +860,7 @@ async fn run_control_v2_smoke(
856860 Some(MoqProbeAuthentication {857861 expected_host: *address.id.as_bytes(),858862 subscription_capability,863+ authorization_revision: media_authorization_revision,859864 now_unix: SystemTime::now()860865 .duration_since(UNIX_EPOCH)861866 .context("system clock is before the Unix epoch")?
@@ -871,6 +876,7 @@ async fn run_control_v2_smoke(
871876 media,872877 initial,873878 focus_handoffs,879+ frames,874880 timeout_seconds,875881 )876882 .await;
@@ -954,11 +960,37 @@ async fn run_control_v2_smoke(
954960 }955961 let mut accepted = 0_u32;956962 let mut last_sequence = None;963+ // The roster at hello time is one instantaneous sample: a peer that has964+ // not finished attaching yet, or one that has already left, makes it965+ // disagree with the configured viewer count for reasons that say966+ // nothing about admission. Track the maximum across the snapshot967+ // stream, exactly as the focus participant does, so the cardinality968+ // assertion is about what the host actually admitted.969+ let mut maximum_roster_viewers = initial.viewers.len();970+ let mut roster_revision = initial.revision;957971 while accepted < frames {958- let outcome = tokio::time::timeout(Duration::from_secs(timeout_seconds), media.next())959- .await960- .context("timed out waiting for spectator media")??961- .context("spectator media ended before requested frames")?;972+ let outcome = tokio::select! {973+ envelope = read_server_control_v2(&mut control_recv) => {974+ match envelope?.context("host closed during spectator proof")? {975+ ServerControlEnvelopeV2::Snapshot { snapshot: next } => {976+ ensure!(977+ next.revision >= roster_revision,978+ "spectator snapshot revision regressed"979+ );980+ roster_revision = next.revision;981+ maximum_roster_viewers =982+ maximum_roster_viewers.max(next.viewers.len());983+ continue;984+ }985+ _ => continue,986+ }987+ }988+ outcome = tokio::time::timeout(Duration::from_secs(timeout_seconds), media.next()) => {989+ outcome990+ .context("timed out waiting for spectator media")??991+ .context("spectator media ended before requested frames")?992+ }993+ };962994 let MoqProbeOutcome::Frame { frame, .. } = outcome else {963995 continue;964996 };
@@ -983,11 +1015,11 @@ async fn run_control_v2_smoke(
9831015 println!("control_alpn=sigil/control/2");9841016 println!("media_generation_id={generation_id}");9851017 println!("media_broadcast_name={broadcast_name}");986- println!("roster_viewers={}", initial.viewers.len());1018+ println!("roster_viewers={maximum_roster_viewers}");9871019 println!("frames={accepted}");9881020 println!("spectator=ok");9891021 println!("resource_summary_version=1");990- println!("maximum_roster_viewers={}", initial.viewers.len());1022+ println!("maximum_roster_viewers={maximum_roster_viewers}");9911023 println!("media_progress=monotonic");9921024 println!("probe_media_object_capacity={MEDIA_OBJECT_CAPACITY}");9931025 return Ok(());
@@ -1225,6 +1257,7 @@ fn next_handoff_command(
12251257 }12261258 }12271259 1260+#[allow(clippy::too_many_arguments)]12281261 async fn run_focus_handoff_participant(12291262 control_connection: iroh::endpoint::Connection,12301263 mut control_send: iroh::endpoint::SendStream,
@@ -1232,6 +1265,7 @@ async fn run_focus_handoff_participant(
12321265 mut media: MoqProbeReceiver,12331266 mut snapshot: sigil_protocol::SessionSnapshotV2,12341267 required_handoffs: u32,1268+ frames: u32,12351269 timeout_seconds: u64,12361270 ) -> Result<()> {12371271 let mut request_id = 1_u64;
@@ -1241,15 +1275,24 @@ async fn run_focus_handoff_participant(
12411275 let mut maximum_roster_viewers = snapshot.viewers.len();12421276 let mut last_holder = focus_holder_identity(&snapshot);12431277 let mut next_command_at = tokio::time::Instant::now();1244- let deadline =1245- tokio::time::Instant::now() + Duration::from_secs(timeout_seconds.max(1).saturating_mul(2));1246-1247- while observed_handoffs < required_handoffs {1278+ // Two timeout windows for the handoff exchange, plus the wall time the1279+ // frame budget itself needs at a conservative 30fps floor.1280+ let deadline = tokio::time::Instant::now()1281+ + Duration::from_secs(timeout_seconds.max(1).saturating_mul(2))1282+ + Duration::from_secs(u64::from(frames) / 30 + 1);1283+1284+ // Leaving as soon as the handoffs land would shrink the roster underneath1285+ // any concurrent same-peer replacement cycle, which then fails on viewer1286+ // cardinality for reasons that have nothing to do with admission. Hold the1287+ // session for the full frame budget, exactly like a spectator.1288+ while observed_handoffs < required_handoffs || accepted_frames < frames {12481289 ensure!(12491290 tokio::time::Instant::now() < deadline,1250- "timed out after observing {observed_handoffs} of {required_handoffs} focus handoffs"1291+ "timed out after observing {observed_handoffs} of {required_handoffs} focus handoffs \1292+ and {accepted_frames} of {frames} frames"12511293 );1252- if !command_in_flight1294+ if observed_handoffs < required_handoffs1295+ && !command_in_flight12531296 && tokio::time::Instant::now() >= next_command_at12541297 && let Some(command) = next_handoff_command(&snapshot, request_id)12551298 {
@@ -1303,7 +1346,10 @@ async fn run_focus_handoff_participant(
13031346 }13041347 }13051348 _ = tokio::time::sleep_until(deadline) => {1306- bail!("timed out after observing {observed_handoffs} of {required_handoffs} focus handoffs");1349+ bail!(1350+ "timed out after observing {observed_handoffs} of {required_handoffs} focus \1351+ handoffs and {accepted_frames} of {frames} frames"1352+ );13071353 }13081354 }13091355 }
M crates/sigil-host/src/server/moq.rs +2 -1
M crates/sigil-protocol/src/control_v2.rs +106 -5
@@ -564,6 +564,11 @@ pub struct HostHelloV2 {
564564 pub snapshot: Option<SessionSnapshotV2>,565565 #[serde(default, skip_serializing_if = "Option::is_none")]566566 pub media_subscription_capability: Option<String>,567+ /// Authorization epoch the subscription capability was minted against.568+ /// A client cannot know its own enrollment revision, so the host states it569+ /// here and `validate` refuses a hello whose capability disagrees.570+ #[serde(default, skip_serializing_if = "Option::is_none")]571+ pub media_authorization_revision: Option<u64>,567572 }568573 569574 impl HostHelloV2 {
@@ -581,6 +586,7 @@ impl HostHelloV2 {
581586 pointer_surface_dimensions: None,582587 snapshot: Some(snapshot),583588 media_subscription_capability: None,589+ media_authorization_revision: None,584590 }585591 }586592
@@ -594,6 +600,7 @@ impl HostHelloV2 {
594600 pointer_surface_dimensions: None,595601 snapshot: None,596602 media_subscription_capability: None,603+ media_authorization_revision: None,597604 }598605 }599606
@@ -602,8 +609,13 @@ impl HostHelloV2 {
602609 self603610 }604611 605- pub fn with_media_subscription_capability(mut self, capability: impl Into<String>) -> Self {612+ pub fn with_media_subscription_capability(613+ mut self,614+ capability: impl Into<String>,615+ authorization_revision: u64,616+ ) -> Self {606617 self.media_subscription_capability = Some(capability.into());618+ self.media_authorization_revision = Some(authorization_revision);607619 self608620 }609621
@@ -635,18 +647,39 @@ impl HostHelloV2 {
635647 "snapshot session does not match host hello",636648 );637649 }638- if let Some(capability) = &self.media_subscription_capability {639- let capability = SignedSubscriptionCapability::decode(capability)?;640- if capability.claims.media_generation_id != snapshot.media.generation_id {650+ match (651+ &self.media_subscription_capability,652+ self.media_authorization_revision,653+ ) {654+ (Some(capability), Some(authorization_revision)) => {655+ let capability = SignedSubscriptionCapability::decode(capability)?;656+ if capability.claims.media_generation_id != snapshot.media.generation_id {657+ return invalid(658+ "host hello v2",659+ "subscription capability generation does not match the snapshot",660+ );661+ }662+ if authorization_revision == 0663+ || capability.claims.authorization_revision != authorization_revision664+ {665+ return invalid(666+ "host hello v2",667+ "subscription capability revision does not match the advertised authorization revision",668+ );669+ }670+ }671+ (None, None) => {}672+ _ => {641673 return invalid(642674 "host hello v2",643- "subscription capability generation does not match the snapshot",675+ "a subscription capability and its authorization revision must be advertised together",644676 );645677 }646678 }647679 } else if self.session_id.is_some()648680 || self.snapshot.is_some()649681 || self.media_subscription_capability.is_some()682+ || self.media_authorization_revision.is_some()650683 || self.message.as_ref().is_none_or(|message| {651684 message.is_empty() || message.len() > MAX_REJECTION_MESSAGE_LEN652685 })
@@ -782,6 +815,74 @@ mod tests {
782815 assert!(serde_json::from_str::<ControllerSlot>("1").is_err());783816 }784817 818+ fn test_capability(819+ host: &ed25519_dalek::SigningKey,820+ generation_id: u64,821+ authorization_revision: u64,822+ ) -> String {823+ let subscriber = ed25519_dalek::SigningKey::from_bytes(&[13; 32]);824+ crate::SignedSubscriptionCapability::issue(825+ crate::SubscriptionClaims::new(826+ host.verifying_key().to_bytes(),827+ generation_id,828+ subscriber.verifying_key().to_bytes(),829+ crate::SubscriptionTracks::VIDEO_H264,830+ authorization_revision,831+ 1_000,832+ 1_600,833+ [5; 32],834+ 1,835+ )836+ .unwrap(),837+ &host.to_bytes(),838+ )839+ .unwrap()840+ .encode()841+ }842+843+ // Regression: enrolled hosts mint capabilities at the viewer's enrollment844+ // revision, which is never 1 on a real appliance. The hello must carry that845+ // revision so a client never has to guess it.846+ #[test]847+ fn accepted_hello_binds_the_capability_to_the_advertised_authorization_revision() {848+ let host = ed25519_dalek::SigningKey::from_bytes(&[11; 32]);849+ for revision in [1_u64, 2, 9, u64::MAX] {850+ let token = test_capability(&host, 7, revision);851+ HostHelloV2::accepted(7, vec![Capability::VideoH264], snapshot(1))852+ .with_media_subscription_capability(token.clone(), revision)853+ .validate()854+ .expect("a hello whose revision matches its capability is valid");855+856+ let mismatched = HostHelloV2::accepted(7, vec![Capability::VideoH264], snapshot(1))857+ .with_media_subscription_capability(token, revision.wrapping_add(1).max(1));858+ assert!(859+ mismatched.validate().is_err(),860+ "revision {revision} must not validate against a mismatched advertisement"861+ );862+ }863+ }864+865+ #[test]866+ fn accepted_hello_rejects_a_capability_without_its_authorization_revision() {867+ let host = ed25519_dalek::SigningKey::from_bytes(&[12; 32]);868+ let mut orphaned = HostHelloV2::accepted(7, vec![Capability::VideoH264], snapshot(1));869+ orphaned.media_subscription_capability = Some(test_capability(&host, 7, 4));870+ assert!(orphaned.validate().is_err());871+872+ let mut revision_only = HostHelloV2::accepted(7, vec![Capability::VideoH264], snapshot(1));873+ revision_only.media_authorization_revision = Some(4);874+ assert!(revision_only.validate().is_err());875+876+ let mut zero = HostHelloV2::accepted(7, vec![Capability::VideoH264], snapshot(1))877+ .with_media_subscription_capability(test_capability(&host, 7, 4), 4);878+ zero.media_authorization_revision = Some(0);879+ assert!(zero.validate().is_err());880+881+ let mut rejected = HostHelloV2::rejected("nope");882+ rejected.media_authorization_revision = Some(4);883+ assert!(rejected.validate().is_err());884+ }885+785886 #[test]786887 fn focus_commands_are_revision_and_generation_bound() {787888 let request = FocusCommandV2 {
M src-tauri/src/media/connect.rs +1 -0
M src-tauri/src/media/moq_receiver.rs +2 -1
M src-tauri/src/media/transport.rs +4 -0