From ee690f0b745f0740712d04848ec985ee7ef13d5f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 17:59:39 +0000 Subject: [PATCH] fix(mesh): authorize media-capture offers and guard unsolicited Claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close two authorization gaps in the node engine. Media-source offers were unauthenticated. route_drive_plane() classifies only terminal/files/sites routes, so the offer gate's `authorized = route_drive_plane(route).is_none_or(...)` was unconditionally true for Display/Video/Audio. An offer whose source endpoint is a capability on this machine makes this node capture its own screen/camera/microphone and stream it to the offerer, yet any authenticated peer's offer was auto-accepted — unlike input (gated per frame) or terminal/files/sites (gated at admission). Add sender_may_source_media and screen the offer with it: a media-source offer this node would host is now refused unless the sender is owner/fleet, a CEC technician holding a live ScreenView consent grant (screen kinds only), or a person this device extended an explicit share to that lets them consume that media on that capability — the same owner/fleet -> CEC -> share layering may_drive already uses, reusing the canonical Grant::permits so capability pinning is honoured. Unsolicited OwnershipControl::Claimed took over the fleet. The Claimed arm added its sender to this device's fleet member list and signed roster and handed it the fleet key, without ever checking this node had sent that peer a Claim — while the sibling arms (Release, FleetKey) each guard their sender. Any authenticated peer could send an unsolicited Claimed and gain full control (input, shell, disk, clipboard, sites). Record a pending-claim when claim() sends a Claim, and honour Claimed only from a peer in that set, consuming the entry on use so a replayed or duplicate confirmation is ignored. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J3oMk6B7NDM2SeQZUSz2ku --- node/src/mesh.rs | 122 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) 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: