diff --git a/node/src/mesh.rs b/node/src/mesh.rs index b6734ab..ee59c9b 100644 --- a/node/src/mesh.rs +++ b/node/src/mesh.rs @@ -159,6 +159,17 @@ pub struct Mesh { /// the fleet-conscription takeover (AMS-01). Refreshed on ownership changes /// and on a periodic tick. fleet_authorized: Mutex>, + /// Canonical pubkeys of devices THIS node has sent an ownership `Claim` to + /// and is awaiting a `Claimed` confirmation from. An inbound + /// `OwnershipControl::Claimed` is honoured only when its authenticated + /// sender is in this set — and the entry is consumed on use — so an + /// *unsolicited* `Claimed` from an arbitrary peer can't drive itself into + /// this device's fleet member list and signed roster (which + /// [`Mesh::sender_may_control`] trusts), i.e. can't hand itself control of + /// this machine. The outbound-claim mirror of the per-sender guards the + /// other ownership arms already apply. In-memory only: a claim interrupted + /// by a restart simply needs re-issuing. + pending_claims: Mutex>, /// Latest passive clock-skew sample per peer (ms; positive = the peer's /// wall clock reads ahead of ours) with when it landed, from the /// `sent_at` stamp presence adverts carry. Fed to the network verdict in @@ -1025,6 +1036,7 @@ impl Mesh { }), ownership: Arc::new(Ownership::load()), fleet_authorized: Mutex::new(std::collections::HashSet::new()), + pending_claims: Mutex::new(std::collections::HashSet::new()), peer_clock_skew: Mutex::new(HashMap::new()), clock_skew_warned: std::sync::atomic::AtomicBool::new(false), offer_first_seen: Mutex::new(HashMap::new()), @@ -2899,6 +2911,44 @@ impl Mesh { .await; return; } + // Media-source gate: a Display/Video/Audio offer whose + // source endpoint is a capability on THIS machine makes + // us capture our own screen/camera/microphone and stream + // it to the offerer — every bit as sensitive as letting + // them drive us, but `route_drive_plane` never classified + // it, so the `authorized` computed above is + // unconditionally true for it. Require the same + // owner/fleet-or-explicit-grant authority here. (A known + // CEC technician without live consent was already refused + // just above; `sender_may_source_media` honours a + // technician's live ScreenView grant and person-to-person + // screen/camera/mic shares.) + if hosts_here + && matches!( + route.media, + MediaKind::Display | MediaKind::Video | MediaKind::Audio + ) + && !self.sender_may_source_media(&from, route) + { + tracing::warn!( + "media source offer {} from {} refused: not owner/fleet/share", + route.id, + short_id(&from) + ); + let _ = self + .send_control( + &from, + &ControlMessage::Route(RouteControl::Reject { + route_id: route.id.clone(), + reason: + "not authorized: capturing this device's screen, \ + camera, or microphone needs owner/fleet or a share" + .into(), + }), + ) + .await; + return; + } if let Some(reason) = privileged_offer_refusal(route, hosts_here, authorized) { @@ -5939,6 +5989,27 @@ impl Mesh { } } OwnershipControl::Claimed { owner } => { + // Honour a claim confirmation only from a device THIS node + // actually sent a `Claim` to. Without this guard any + // authenticated peer could send an *unsolicited* `Claimed` and + // drive itself into our fleet member list *and* signed roster — + // both of which `sender_may_control` trusts — i.e. hand itself + // full control of this machine (input, shell, disk, clipboard). + // This is the outbound-claim mirror of the per-sender guards the + // sibling arms already apply (`Release`/`FleetKey` check the + // recorded owner). Consumed on use, so a replayed or duplicate + // confirmation is ignored. + if !self + .pending_claims + .lock() + .remove(pubkey_part(from.as_str())) + { + tracing::warn!( + "ignoring unsolicited claim confirmation from {} — this device never claimed it", + short_id(from.as_str()) + ); + return; + } // The device we claimed (`from`) accepted us as its owner. // Make the claim *do* something durable: mint our fleet key on // the first adoption, record ourselves and the new device in @@ -6526,6 +6597,14 @@ impl Mesh { ); } } + // Record that we're now awaiting this device's `Claimed` confirmation, + // so the inbound handler honours only a confirmation we actually + // solicited (see `pending_claims` / the `Claimed` arm). Recorded before + // the send; if the send fails the peer never answers, so the leftover + // entry is harmless. + self.pending_claims + .lock() + .insert(pubkey_part(&node).to_string()); tracing::info!("claiming {} (sending ownership claim)", short_id(&node)); let msg = ControlMessage::Ownership(OwnershipControl::Claim { owner: me.into() }); self.send_control(&node, &msg).await @@ -11088,6 +11167,49 @@ impl Mesh { .is_allowed(sender, allmystuff_cec_consent::Capability::ScreenView) } + /// Whether `sender` may make THIS machine *source* (capture and stream) the + /// media `route` asks for — its screen, camera, or microphone. This is the + /// capture-side twin of [`Self::sender_may_drive`]: [`route_drive_plane`] + /// only ever classified the drive planes (input/terminal/files/sites/ + /// clipboard), so a `Display`/`Video`/`Audio` *source* offer sailed past the + /// offer gate and reached `start_media` with no authorization at all — the + /// gap that let any authenticated peer pull this device's screen, webcam, or + /// mic. Owner and fleet may source anything; a dialed CEC technician's live + /// `ScreenView` consent covers the screen kinds (`Display`/`Video`; audio is + /// not part of the CEC consent model); otherwise it takes an explicit + /// person-to-person share this device extended that lets that person + /// *consume* this media on this capability (the "Receive your screen" + /// grant). Mirrors the owner/fleet → CEC → share layering of + /// [`Self::may_drive`]; screen viewing stays gated here at the offer (and + /// torn down by [`Self::spawn_cec_consent_sweep`]), never per frame. + fn sender_may_source_media(&self, sender: &str, route: &Route) -> bool { + if self.sender_may_control(sender) { + return true; + } + // A dialed CEC technician holds no fleet membership; their authority to + // view the screen is the customer's live ScreenView consent grant. + if matches!(route.media, MediaKind::Display | MediaKind::Video) + && self + .cec + .is_allowed(sender, allmystuff_cec_consent::Capability::ScreenView) + { + return true; + } + // An explicit share this device extended letting that person consume + // exactly this media on this capability (honours capability pinning and + // the media/role scope via the canonical `Grant::permits`). + let Some(person) = self.shares.person_for_node(pubkey_part(sender)) else { + return false; + }; + self.shares.out_grants_for(&person.id).iter().any(|g| { + g.permits( + route.media, + allmystuff_graph::GrantRole::Consume, + &route.from, + ) + }) + } + /// Media keeps arriving for a route this side doesn't hold live — our /// app restarted (fresh session, old routes gone), or the route tore /// down here while the sender missed it. Tell the sender, rate-limited: