diff --git a/varde-ctl/src/main.rs b/varde-ctl/src/main.rs index 812c8c3..cbc5f3e 100644 --- a/varde-ctl/src/main.rs +++ b/varde-ctl/src/main.rs @@ -73,7 +73,8 @@ enum Command { /// Peer trust operations #[command(subcommand)] Peer(PeerCommand), - /// Push fully-present content to a trusted peer + /// Push fully-present content to a trusted peer, or one allowed to + /// exchange pushes Push { /// BLAKE3 hash (hex) hash: String, @@ -116,6 +117,14 @@ enum PeerCommand { /// iroh NodeId (z-base-32) node_id: String, }, + /// Allow pushes with a peer for a while, without trusting it + AllowPush { + /// iroh NodeId (z-base-32) + node_id: String, + /// How long, in seconds (at most a day) + #[arg(long, default_value_t = 1800)] + ttl: u64, + }, /// List trusted peers List, } @@ -187,6 +196,9 @@ fn to_request(command: Command) -> Result { }, Command::Peer(PeerCommand::Trust { node_id }) => Request::PeerTrust { node_id }, Command::Peer(PeerCommand::Untrust { node_id }) => Request::PeerUntrust { node_id }, + Command::Peer(PeerCommand::AllowPush { node_id, ttl }) => { + Request::PeerAllowPush { node_id, ttl_secs: ttl } + } Command::Peer(PeerCommand::List) => Request::PeerList {}, Command::Push { hash, node_id } => Request::Push { hash, node_id }, Command::Gc => Request::Gc {}, diff --git a/varde-daemon/src/daemon.rs b/varde-daemon/src/daemon.rs index 7bd0a3e..1fa3217 100644 --- a/varde-daemon/src/daemon.rs +++ b/varde-daemon/src/daemon.rs @@ -566,11 +566,23 @@ impl Daemon { let parsed: iroh::EndpointId = node_id .parse() .map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?; - if !self.meta.untrust_peer(&parsed.to_string())? { + let was_trusted = self.meta.untrust_peer(&parsed.to_string())?; + let was_allowed = self.meta.disallow_push(&parsed.to_string())?; + if !was_trusted && !was_allowed { return Err(OpError::NotFound(format!("{parsed} was not trusted"))); } Ok(ResponseData::Done {}) } + Request::PeerAllowPush { node_id, ttl_secs } => { + let parsed: iroh::EndpointId = node_id + .parse() + .map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?; + let ttl = ttl_secs.min(varde_proto::MAX_PUSH_TTL_SECS); + self.meta + .allow_push(&parsed.to_string(), crate::meta::unix_now() + ttl)?; + debug!(peer = %parsed, ttl, "pushes allowed"); + Ok(ResponseData::Done {}) + } Request::PeerList {} => Ok(ResponseData::Peers { peers: self.present_trusted_peers(), }), @@ -579,11 +591,12 @@ impl Daemon { let peer: iroh::EndpointId = node_id .parse() .map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?; - // Consent, sending side: we only push to peers we trust. - // (The receiver independently requires that it trusts us.) - if !self.meta.is_trusted(&peer.to_string()) { + // Consent, sending side: we only push to peers we trust or + // hold a push allowance for. (The receiver independently + // requires the same of us.) + if !self.meta.may_push(&peer.to_string()) { return Err(OpError::InvalidArgument(format!( - "{peer} is not a trusted peer; trust it before pushing" + "{peer} is neither trusted nor allowed to exchange pushes; trust it or allow pushes first" ))); } let (_, _, complete) = self.store.presence(parsed).await?; diff --git a/varde-daemon/src/meta.rs b/varde-daemon/src/meta.rs index a857b22..2232566 100644 --- a/varde-daemon/src/meta.rs +++ b/varde-daemon/src/meta.rs @@ -40,6 +40,10 @@ struct MetaState { /// Trusted peer NodeIds (z-base-32). #[serde(default)] trusted_peers: BTreeSet, + /// Peers allowed to push to us and be pushed to, until a unix time + /// (`PeerAllowPush`): consent for that one thing, lapsing on its own. + #[serde(default)] + push_peers: BTreeMap, /// Hashes (hex) the user shared via ticket export: standing consent /// to serve them to anyone, like an `open_lan` pin. #[serde(default)] @@ -164,6 +168,31 @@ impl Meta { .clone() } + /// Allow pushes with `node_id` until `until` (unix seconds). Extends, + /// never shortens, an allowance already held; expired ones are + /// dropped on the way. + pub fn allow_push(&self, node_id: &str, until: u64) -> Result<()> { + let now = unix_now(); + self.mutate(|s| { + s.push_peers.retain(|_, u| *u > now); + let entry = s.push_peers.entry(node_id.to_string()).or_insert(0); + *entry = (*entry).max(until); + }) + } + + /// Whether pushes with `node_id` are allowed now: a trusted peer, or + /// one holding an unexpired push allowance. + pub fn may_push(&self, node_id: &str) -> bool { + let state = self.state.lock().expect("meta lock poisoned"); + state.trusted_peers.contains(node_id) + || state.push_peers.get(node_id).is_some_and(|u| *u > unix_now()) + } + + /// End a push allowance. Returns whether there was one. + pub fn disallow_push(&self, node_id: &str) -> Result { + self.mutate(|s| s.push_peers.remove(node_id).is_some()) + } + pub fn is_trusted(&self, node_id: &str) -> bool { self.state .lock() @@ -173,6 +202,14 @@ impl Meta { } } +/// Seconds since the unix epoch. +pub fn unix_now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + #[cfg(test)] mod tests { use super::*; @@ -201,4 +238,27 @@ mod tests { assert!(meta.unpin("h").unwrap()); assert!(!meta.is_pinned("h")); } + + #[test] + fn a_push_allowance_lapses_and_never_shortens() { + let dir = tempfile::tempdir().unwrap(); + let meta = Meta::load(dir.path()).unwrap(); + let now = unix_now(); + assert!(!meta.may_push("p")); + meta.allow_push("p", now + 600).unwrap(); + assert!(meta.may_push("p")); + assert!(!meta.is_trusted("p"), "a push allowance is not trust"); + meta.allow_push("p", now + 10).unwrap(); + assert!(meta.may_push("p"), "a shorter ask does not cut it short"); + meta.allow_push("q", now.saturating_sub(1)).unwrap(); + assert!(!meta.may_push("q"), "expired"); + // Survives a reload. + let again = Meta::load(dir.path()).unwrap(); + assert!(again.may_push("p")); + assert!(again.disallow_push("p").unwrap()); + assert!(!again.may_push("p")); + // Full trust allows pushes too. + again.trust_peer("t").unwrap(); + assert!(again.may_push("t")); + } } diff --git a/varde-daemon/src/shaped.rs b/varde-daemon/src/shaped.rs index 8e75763..7700546 100644 --- a/varde-daemon/src/shaped.rs +++ b/varde-daemon/src/shaped.rs @@ -111,6 +111,8 @@ pub struct Gate { #[derive(Debug, Clone)] struct ConnInfo { trusted: bool, + /// Trusted, or holding a push allowance (`PeerAllowPush`). + may_push: bool, endpoint_id: Option, } @@ -142,6 +144,11 @@ pub fn provider_events(gate: Gate) -> EventSender { .endpoint_id .map(|id| gate.meta.is_trusted(&id.to_string())) .unwrap_or(false); + let may_push = m + .inner + .endpoint_id + .map(|id| gate.meta.may_push(&id.to_string())) + .unwrap_or(false); let ClientConnected { connection_id, endpoint_id, @@ -151,6 +158,7 @@ pub fn provider_events(gate: Gate) -> EventSender { connection_id, ConnInfo { trusted, + may_push, endpoint_id: endpoint_id.map(|id| id.to_string()), }, ); @@ -208,12 +216,13 @@ pub fn provider_events(gate: Gate) -> EventSender { m.tx.send(verdict).await.ok(); } ProviderMessage::PushRequestReceived(m) => { - // Inbound writes: trusted peers only, deferred while - // metered (RateLimited invites a retry later). + // Inbound writes: trusted peers and push allowances + // only, deferred while metered (RateLimited invites a + // retry later). let info = conns.get(&m.inner.connection_id); - let trusted = info.map(|i| i.trusted).unwrap_or(false); - debug!(hash = %m.inner.request.hash.to_hex(), trusted, "push request received"); - let verdict = if !trusted { + let may_push = info.map(|i| i.may_push).unwrap_or(false); + debug!(hash = %m.inner.request.hash.to_hex(), may_push, "push request received"); + let verdict = if !may_push { Err(AbortReason::Permission) } else if gate.metered.is_metered() { Err(AbortReason::RateLimited) diff --git a/varde-daemon/tests/lan_trust.rs b/varde-daemon/tests/lan_trust.rs index 21e092e..b76ab27 100644 --- a/varde-daemon/tests/lan_trust.rs +++ b/varde-daemon/tests/lan_trust.rs @@ -422,3 +422,49 @@ fn push_rejected_without_receiver_trust() { // And the receiver holds none of it. assert!(!is_complete(&b.socket, &hash)); } + +/// A push allowance is enough on both sides: two daemons that only +/// allowed pushes with each other (strangers sharing a place, in redoal) +/// exchange content without trusting each other - and the allowance +/// grants nothing else, so neither lists the other as a trusted peer. +#[test] +fn push_allowance_is_enough_and_is_not_trust() { + let _serial = MDNS_SERIAL.lock().unwrap_or_else(|e| e.into_inner()); + let dir_a = tempfile::tempdir().unwrap(); + let dir_b = tempfile::tempdir().unwrap(); + let a = support::spawn_daemon_with_env(dir_a.path(), &[("VARDE_DISCOVERY", "true")]); + let b = support::spawn_daemon_with_env(dir_b.path(), &[("VARDE_DISCOVERY", "true")]); + let mut client_a = support::Client::connect(&a.socket); + let mut client_b = support::Client::connect(&b.socket); + let id_a = node_id(&mut client_a); + let id_b = node_id(&mut client_b); + + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("voice.bin"); + let payload = vec![7u8; 120_000]; + std::fs::write(&src, &payload).unwrap(); + let hash = add_file(&mut client_a, &src); + pin(&mut client_a, &hash, false); + + assert!(client_a.request(&Request::PeerAllowPush { node_id: id_b.clone(), ttl_secs: 600 }).ok); + assert!(client_b.request(&Request::PeerAllowPush { node_id: id_a, ttl_secs: 600 }).ok); + match client_a.request(&Request::PeerList {}).data { + Some(ResponseData::Peers { peers }) => assert!(peers.is_empty(), "an allowance is not trust: {peers:?}"), + other => panic!("unexpected peer list: {other:?}"), + } + + // The peer's address arrives by mdns; retry until it does. + let deadline = Instant::now() + Duration::from_secs(30); + let reply = loop { + let reply = client_a.request(&Request::Push { hash: hash.clone(), node_id: id_b.clone() }); + if reply.ok || Instant::now() > deadline { + break reply; + } + std::thread::sleep(Duration::from_millis(500)); + }; + if !reply.ok { + eprintln!("SKIP: push never reached the peer (multicast unavailable?): {:?}", reply.error); + return; + } + assert!(wait_complete(&b.socket, &hash, Duration::from_secs(60)), "pushed content not complete on receiver"); +} diff --git a/varde-proto/src/lib.rs b/varde-proto/src/lib.rs index fd5a09e..58e4b47 100644 --- a/varde-proto/src/lib.rs +++ b/varde-proto/src/lib.rs @@ -20,6 +20,9 @@ use serde::{Deserialize, Serialize}; /// Bumped on incompatible changes; reported in [`GlobalStatus`]. pub const PROTOCOL_VERSION: u32 = 2; +/// The longest a [`Request::PeerAllowPush`] allowance may last: a day. +pub const MAX_PUSH_TTL_SECS: u64 = 24 * 60 * 60; + /// The most bytes a [`Request::Put`] or a [`Request::Read`] reply carries /// inline. Larger content goes through `Add`/`Materialize` and a path. pub const MAX_INLINE_BYTES: u64 = 16 * 1024 * 1024; @@ -116,6 +119,19 @@ pub enum Request { /// The peer's iroh NodeId (z-base-32). node_id: String, }, + /// Allow pushes between this daemon and a peer for `ttl_secs` (at most + /// [`MAX_PUSH_TTL_SECS`]): we may push to it, and accept what it pushes + /// to us. Nothing else - unlike [`Request::PeerTrust`] it neither + /// syncs the peer's pins nor serves it our content - so an app can + /// grant it to strangers it shares something with for a while (two + /// phones at the same place) and let it lapse. Asking again extends + /// it; [`Request::PeerUntrust`] ends it early. Added in varde 0.1.5. + PeerAllowPush { + /// The peer's iroh NodeId (z-base-32). + node_id: String, + /// How long the allowance lasts, in seconds. + ttl_secs: u64, + }, /// List trusted peers and their current connectivity. PeerList {}, /// Push fully-present content to a trusted peer, unprompted.