From d126fac33000db1e3885e51c1214371ae092fd95 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Fri, 23 Oct 2026 20:40:29 +0100 Subject: [PATCH 1/2] feat(drop): receive over STUN alone when the node runs no TURN server Drop accepts an ICE entry of STUN URLs with empty credentials, and when the node answers 404 for the issuer it uses a built-in stun:stun.cloudflare.com:3478 for both ends instead of failing with no_turn. Hiding needs a TURN URL, so a receive with hiding on refuses with no_relay before any link is shown, and the encoder never sends a hidden invitation without one. The hide setting now defaults to off for installs that never set it; a stored on or off is kept. A connection that fails with no relay on offer reports connect_direct. The vendored direct-send bundle is rebuilt from the page change. --- desktop/src-tauri/src/drop/client.rs | 44 ++++-- desktop/src-tauri/src/drop/direct/messages.rs | 105 ++++++++++++-- desktop/src-tauri/src/drop/direct/receive.rs | 133 ++++++++++++++---- desktop/src-tauri/src/drop/direct/session.rs | 30 +++- .../src/drop/direct/session/tests.rs | 57 +++++++- desktop/src-tauri/src/drop/e2e.rs | 2 +- desktop/src-tauri/src/drop/status.rs | 2 +- desktop/tests/direct-window.mjs | 22 ++- desktop/tests/drop-harness.html | 2 +- desktop/tests/drop-stub.js | 2 +- desktop/tests/drop-ui.mjs | 20 +-- desktop/ui/DROP-IPC.md | 10 +- desktop/ui/app.js | 4 +- desktop/ui/drop.js | 2 +- desktop/ui/receive.js | 4 +- desktop/ui/vendor/wildbloom-direct.js | 4 +- .../vendor/wildbloom-direct.provenance.json | 8 +- 17 files changed, 357 insertions(+), 94 deletions(-) diff --git a/desktop/src-tauri/src/drop/client.rs b/desktop/src-tauri/src/drop/client.rs index 2e5af36..d9deb6b 100644 --- a/desktop/src-tauri/src/drop/client.rs +++ b/desktop/src-tauri/src/drop/client.rs @@ -741,8 +741,9 @@ pub async fn live_grant( Ok(grant) } -/// Two TURN REST credentials on the same servers from the node's issuer: -/// `own` for this computer and `peer` for the verified person at the other +/// The ICE configuration from the node's issuer, on the same servers for +/// both ends: TURN REST credentials, or `stun:` URLs with no credential. +/// `own` is for this computer and `peer` for the verified person at the other /// end of a direct send. `Debug` hides the usernames and credentials. #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] #[serde(deny_unknown_fields)] @@ -752,14 +753,15 @@ pub struct TurnCredentials { pub peer: super::direct::messages::IceServer, } -/// Why the node gave no TURN credentials. No variant carries a header or +/// Why the node gave no ICE configuration. No variant carries a header or /// body. #[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)] pub enum TurnError { #[error("the node did not answer")] Unreachable, - /// 404: the node does not run the TURN credential issuer. - #[error("the node does not issue TURN credentials")] + /// 404: the node does not run the issuer, so Drop uses its built-in STUN + /// default. + #[error("the node does not issue an ICE configuration")] NotOffered, /// 429: this computer has asked too often in the last hour. #[error("too many requests for TURN credentials this hour")] @@ -772,8 +774,8 @@ pub enum TurnError { } /// Reads the issuer's reply: exactly `{"expires","own","peer"}`, each entry -/// exactly an `ice` server that passes the `ice` rules and names a `turn:` or -/// `turns:` URL. +/// exactly an `ice` server that passes the `ice` rules: TURN credentials, or +/// `stun:` URLs with an empty username and credential. pub fn parse_turn_credentials(bytes: &[u8]) -> Result { // Objects only: a derived struct would also take the array form. let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|_| TurnError::Invalid)?; @@ -788,9 +790,8 @@ pub fn parse_turn_credentials(bytes: &[u8]) -> Result NodeEndpoint { server(move |mut socket| async move { diff --git a/desktop/src-tauri/src/drop/direct/messages.rs b/desktop/src-tauri/src/drop/direct/messages.rs index 7a3a49c..3911501 100644 --- a/desktop/src-tauri/src/drop/direct/messages.rs +++ b/desktop/src-tauri/src/drop/direct/messages.rs @@ -72,8 +72,10 @@ impl StopReason { } /// One ICE server: exactly `{"credential","urls","username"}`. The same -/// shape is a TURN credential from the node's issuer and an `ice` entry. -/// `Debug` hides the username and credential. +/// shape is the ICE configuration from the node's issuer and an `ice` entry. +/// An entry with any `turn:` or `turns:` URL carries a username and a +/// credential; an entry of `stun:` URLs only has both empty. `Debug` hides +/// the username and credential. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct IceServer { @@ -132,17 +134,39 @@ pub fn is_turn_url(url: &str) -> bool { url.starts_with("turn:") || url.starts_with("turns:") } +/// The STUN server used when the node runs no ICE issuer: Cloudflare's free +/// public one, as KithMoot uses. No ForgeSworn server is a default. The +/// page's build allowlist holds the same string. +pub const DEFAULT_STUN_URL: &str = "stun:stun.cloudflare.com:3478"; + +/// The built-in ICE entry for Drop and for the page: the default STUN URL and +/// no credential. It is not secret. +pub fn default_stun() -> IceServer { + IceServer { + credential: String::new(), + urls: vec![DEFAULT_STUN_URL.to_owned()], + username: String::new(), + } +} + impl IceServer { - /// The syntax rules of one `ice` entry: 1 to 4 URLs, and a username and - /// credential of 1 to 256 bytes without control characters. + /// The syntax rules of one `ice` entry: 1 to 4 URLs; then, if any is a + /// `turn:` or `turns:` URL, a username and credential of 1 to 256 bytes + /// without control characters, and otherwise both exactly empty, so a + /// credential never goes to a STUN server. pub fn well_formed(&self) -> bool { + let secrets = [&self.username, &self.credential]; (1..=URLS_PER_SERVER).contains(&self.urls.len()) && self.urls.iter().all(|url| valid_ice_url(url)) - && [&self.username, &self.credential].iter().all(|secret| { - !secret.is_empty() - && secret.len() <= CREDENTIAL_BYTES - && !secret.chars().any(is_control) - }) + && if self.has_turn() { + secrets.iter().all(|secret| { + !secret.is_empty() + && secret.len() <= CREDENTIAL_BYTES + && !secret.chars().any(is_control) + }) + } else { + secrets.iter().all(|secret| secret.is_empty()) + } } pub fn has_turn(&self) -> bool { @@ -314,9 +338,11 @@ pub fn encode(message: &Message) -> Result, EncodeError> { let string = |text: &str| Value::String(text.to_owned()); match message { Message::Invite { hide, ice, id, max } => { + // A hidden address needs a TURN URL, so Drop never sends a hidden + // invitation it cannot honour. Readers do not check `hide`. let ice_ok = (1..=ICE_SERVERS).contains(&ice.len()) && ice.iter().all(IceServer::well_formed) - && ice.iter().any(IceServer::has_turn); + && (!*hide || ice.iter().any(IceServer::has_turn)); if !ice_ok || !canonical_id(id) || !(1..=MAX_FILE_BYTES).contains(max) { return Err(EncodeError::Invalid); } @@ -538,9 +564,6 @@ fn read_ice(value: &Value, allowlist: &[&str]) -> Result, Refusal { return Err(unsupported("an ice URL is not on the build-time allowlist")); } - if !servers.iter().any(IceServer::has_turn) { - return Err(unsupported("no TURN URL")); - } Ok(servers) } @@ -1002,6 +1025,48 @@ mod tests { "v=0\r\no=- 1 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\na=fingerprint:sha-256 AA:BB\r\n".into() } + /// The built-in default is Cloudflare's STUN server, spelt exactly as the + /// page's build allowlist spells it, with no credential. + #[test] + fn the_built_in_default_is_cloudflare_stun_alone() { + assert_eq!(DEFAULT_STUN_URL, "stun:stun.cloudflare.com:3478"); + let server = default_stun(); + assert_eq!( + server.urls, + vec!["stun:stun.cloudflare.com:3478".to_owned()] + ); + assert!(server.username.is_empty() && server.credential.is_empty()); + assert!(server.well_formed() && !server.has_turn()); + assert!(!DEFAULT_STUN_URL.contains("forgesworn")); + } + + #[test] + fn an_ice_entry_is_well_formed_only_in_its_two_forms() { + let entry = |urls: &[&str], username: &str, credential: &str| IceServer { + credential: credential.into(), + urls: urls.iter().map(|url| (*url).to_owned()).collect(), + username: username.into(), + }; + let stun = "stun:s.example:3478"; + let turn = "turn:t.example:3478?transport=udp"; + // STUN alone: both empty. + assert!(entry(&[stun], "", "").well_formed()); + assert!(!entry(&[stun], "u", "c").well_formed()); + assert!(!entry(&[stun], "u", "").well_formed()); + assert!(!entry(&[stun], "", "c").well_formed()); + // Any TURN URL: both present, at most 256 bytes, no control characters. + assert!(entry(&[turn], "u", "c").well_formed()); + assert!(entry(&[stun, turn], "u", "c").well_formed()); + assert!(!entry(&[turn], "", "c").well_formed()); + assert!(!entry(&[turn], "u", "").well_formed()); + assert!(!entry(&[stun, turn], "", "").well_formed()); + assert!(!entry(&[turn], &"u".repeat(257), "c").well_formed()); + assert!(!entry(&[turn], "u\n", "c").well_formed()); + // The URL rules are unchanged. + assert!(!entry(&[], "", "").well_formed()); + assert!(!entry(&["stun:bad host"], "", "").well_formed()); + } + fn ice() -> IceServer { IceServer { credential: "c3ludGhldGljLWNyZWRlbnRpYWw=".into(), @@ -1033,12 +1098,26 @@ mod tests { encode(&invite(vec![ice()], "AAECAwQFBgcICQoLDA0ODx", 1)), Err(EncodeError::Invalid) ); + // A STUN-only entry with a credential is malformed. let mut stun_only = ice(); stun_only.urls = vec!["stun:turn.example.net:3478".into()]; assert_eq!( encode(&invite(vec![stun_only], &id, 1)), Err(EncodeError::Invalid) ); + // STUN alone can carry an invitation that does not hide, never one that does. + let hidden = |hide, ice: Vec| Message::Invite { + hide, + ice, + id: id.clone(), + max: 1, + }; + assert!(encode(&hidden(false, vec![default_stun()])).is_ok()); + assert_eq!( + encode(&hidden(true, vec![default_stun()])), + Err(EncodeError::Invalid) + ); + assert!(encode(&hidden(true, vec![default_stun(), ice()])).is_ok()); let mut empty = ice(); empty.credential = String::new(); assert_eq!( diff --git a/desktop/src-tauri/src/drop/direct/receive.rs b/desktop/src-tauri/src/drop/direct/receive.rs index 320ac02..48b9597 100644 --- a/desktop/src-tauri/src/drop/direct/receive.rs +++ b/desktop/src-tauri/src/drop/direct/receive.rs @@ -9,7 +9,7 @@ use super::session::{self, Controls, DirectHost, Dirs, Opened, Timing}; use super::sink::SinkError; use super::{ AddedToWildbloom, PURPOSE, PeerReport, PeerRequest, ReceiveState, ReceivingSettings, - messages::MAX_FILE_BYTES, + messages::{MAX_FILE_BYTES, default_stun}, }; use crate::drop::client::{self, NodeEndpoint, TurnError}; use crate::drop::engine::{Engine, internal}; @@ -19,8 +19,35 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -/// The receiving setting in the index: `off` turns hiding off; anything else -/// (and nothing) is the default, on. +/// The ICE configuration for one receive from the issuer's answer. A node +/// with no issuer (404) gives the built-in STUN entry for both ends. +/// `expires` is not read in a direct send, which lasts at most the session's +/// own ten minutes. Hiding means relay-only ICE, which needs a TURN URL, so +/// it is refused here, before any pairing link exists. +fn ice_for_receive( + answer: Result, + hide: bool, +) -> Result { + let turn = match answer { + Ok(turn) => turn, + Err(TurnError::NotOffered) => client::TurnCredentials { + expires: 0, + own: default_stun(), + peer: default_stun(), + }, + Err(TurnError::Unreachable) => return Err("node_unreachable"), + Err(TurnError::Limited) => return Err("turn_limit"), + Err(TurnError::Refused(_) | TurnError::Invalid) => return Err("turn_refused"), + }; + if hide && !turn.own.has_turn() { + return Err("no_relay"); + } + Ok(turn) +} + +/// The receiving setting in the index: `on` turns hiding on; anything else +/// (and nothing) is the default, off. Hiding needs a TURN server, which a +/// default install has none of. pub(crate) const HIDE_ADDRESS: &str = "receive_hide_address"; struct Running { @@ -111,9 +138,9 @@ pub(crate) fn display_path(path: &Path, home: Option<&Path>) -> String { } impl Engine { - /// Hiding Drop's address (relay-only ICE) is on unless turned off. + /// Hiding Drop's address (relay-only ICE) is off unless turned on. pub fn receive_hide_address(&self) -> bool { - self.index.get(HIDE_ADDRESS).ok().flatten().as_deref() != Some("off") + self.index.get(HIDE_ADDRESS).ok().flatten().as_deref() == Some("on") } pub fn receiving_settings(&self) -> ReceivingSettings { @@ -238,23 +265,20 @@ impl Engine { } } - /// Drop's and the page's TURN credentials, then a live session for - /// purpose `send` under the send page's link. The credentials come - /// first, so a node without the issuer fails before any link is shown. + /// Drop's and the page's ICE configuration, then a live session for + /// purpose `send` under the send page's link. The configuration comes + /// first, so a node that refuses it, or a person who hides their address + /// where there is no TURN server to hide behind, fails before any link + /// is shown. A node with no issuer (404) gives the built-in STUN default. async fn open_receive( &self, node: &NodeEndpoint, slot: &RelaySlot, ) -> Result, &'static str> { let identity = self.identity().map_err(|_| "node_unreachable")?; - let turn = client::turn_credentials(&self.http, node, identity.device.keys()) - .await - .map_err(|error| match error { - TurnError::Unreachable => "node_unreachable", - TurnError::NotOffered => "no_turn", - TurnError::Limited => "turn_limit", - TurnError::Refused(_) | TurnError::Invalid => "turn_refused", - })?; + let hide = self.receive_hide_address(); + let answer = client::turn_credentials(&self.http, node, identity.device.keys()).await; + let turn = ice_for_receive(answer, hide)?; let (live, session, secret) = self .open_pairing( node, @@ -273,7 +297,7 @@ impl Engine { session, secret, turn, - hide: self.receive_hide_address(), + hide, }) } @@ -483,6 +507,7 @@ impl Engine { #[cfg(test)] mod tests { use super::*; + use crate::drop::direct::messages::IceServer; use crate::drop::engine::{EngineConfig, Host, LocalNode, ONLINE_BLOB_LIMIT, kv}; use crate::drop::keychain::{MemoryStore, SecretStore}; use crate::drop::status::StatusLine; @@ -565,21 +590,72 @@ mod tests { (engine, host) } + #[test] + fn a_node_without_an_issuer_gives_the_built_in_stun_entry() { + let turn = ice_for_receive(Err(TurnError::NotOffered), false).unwrap(); + assert_eq!(turn.own, default_stun()); + assert_eq!(turn.peer, default_stun()); + assert_eq!(turn.own.urls, vec!["stun:stun.cloudflare.com:3478"]); + assert!(!turn.own.has_turn()); + } + + /// Hiding with nothing to hide behind ends before a pairing link is made: + /// `open_receive` returns this error before it reaches `open_pairing`. + #[test] + fn hiding_without_a_turn_url_is_refused_before_any_link() { + assert_eq!( + ice_for_receive(Err(TurnError::NotOffered), true).unwrap_err(), + "no_relay" + ); + let stun_only = client::TurnCredentials { + expires: 1_800_003_600, + own: default_stun(), + peer: default_stun(), + }; + assert_eq!( + ice_for_receive(Ok(stun_only.clone()), true).unwrap_err(), + "no_relay" + ); + assert!(ice_for_receive(Ok(stun_only), false).is_ok()); + let relay = |label: &str| IceServer { + credential: "Y3JlZA".into(), + urls: vec!["turn:turn.example.net:3478?transport=udp".into()], + username: format!("1800003600:{label}"), + }; + let with_turn = client::TurnCredentials { + expires: 1_800_003_600, + own: relay("b3du"), + peer: relay("cGVlcg"), + }; + assert!(ice_for_receive(Ok(with_turn), true).is_ok()); + } + + #[test] + fn the_other_issuer_failures_keep_their_codes() { + for (error, code) in [ + (TurnError::Unreachable, "node_unreachable"), + (TurnError::Limited, "turn_limit"), + (TurnError::Refused(403), "turn_refused"), + (TurnError::Invalid, "turn_refused"), + ] { + assert_eq!(ice_for_receive(Err(error), false).unwrap_err(), code); + } + } + #[tokio::test] - async fn hiding_defaults_on_persists_and_shows_in_the_status() { + async fn hiding_defaults_off_persists_and_shows_in_the_status() { let dir = tempfile::tempdir().unwrap(); let (engine, host) = engine(dir.path()); - assert!(engine.receive_hide_address()); + assert!(!engine.receive_hide_address()); assert_eq!( serde_json::to_value(engine.status()).unwrap()["receiving"], - serde_json::json!({ "hideAddress": true, "folder": "~/Downloads/Wildbloom Received", + serde_json::json!({ "hideAddress": false, "folder": "~/Downloads/Wildbloom Received", "maxBytes": 250_000_000 }) ); - engine.set_receiving(false).unwrap(); - assert!(!engine.receive_hide_address()); + engine.set_receiving(true).unwrap(); + assert!(engine.receive_hide_address()); assert!( - !host - .statuses + host.statuses .lock() .unwrap() .last() @@ -587,11 +663,14 @@ mod tests { .receiving .hide_address ); - engine.set_receiving(true).unwrap(); - assert!(engine.status().receiving.hide_address); + engine.set_receiving(false).unwrap(); + assert!(!engine.status().receiving.hide_address); // An unknown stored value reads as the default. engine.index.set(HIDE_ADDRESS, "maybe").unwrap(); - assert!(engine.receive_hide_address()); + assert!(!engine.receive_hide_address()); + // A stored `off` from an earlier version stays off. + engine.index.set(HIDE_ADDRESS, "off").unwrap(); + assert!(!engine.receive_hide_address()); } #[tokio::test] diff --git a/desktop/src-tauri/src/drop/direct/session.rs b/desktop/src-tauri/src/drop/direct/session.rs index 5b9f585..cc06d9f 100644 --- a/desktop/src-tauri/src/drop/direct/session.rs +++ b/desktop/src-tauri/src/drop/direct/session.rs @@ -5,12 +5,13 @@ //! 1. Show the link and words; run the PAKE; show the three check words and //! wait for the owner's yes or no. //! 2. Only on yes, switch the confirmed channel to `foregather-exchange/1` -//! and send `invite` (`dir:"to-drop"`, `hide`, `ice` = the page's own TURN -//! credential, a fresh 16-byte `id`, `max` 250,000,000). +//! and send `invite` (`dir:"to-drop"`, `hide`, `ice` = the page's ICE entry +//! from the node, or the built-in STUN default, a fresh 16-byte `id`, `max` +//! 250,000,000). //! 3. Read `manifest` and ask the owner. `decline` ends it there: no //! connection was made and nothing was allocated on a TURN server. //! 4. On accept, open the hidden window, make the connection from Drop's own -//! credential (relay-only when hiding), send `accept` with the offer, read +//! ICE entry (relay-only when hiding), send `accept` with the offer, read //! `answer`, keep its key here and hand only the SDP to the window. //! 5. While the data flows, answer an ICE failure (or a long disconnection) //! with `restart`, at most five times and only before the deadline, each @@ -575,6 +576,9 @@ struct Run { deadline: Instant, expires_at: u64, hide: bool, + /// The ICE configuration holds a TURN URL. Without one the two ends can + /// only meet directly, and a failure to connect says so. + relay_offered: bool, id: String, exchange: Option, /// The page can still be read: the relay has not failed. @@ -642,6 +646,7 @@ pub async fn run( deadline: pairing::deadline_at(expires_at), expires_at, hide, + relay_offered: false, id: String::new(), exchange: None, exchange_alive: true, @@ -753,8 +758,9 @@ impl Run { }; self.exchange = Some(ExchangeChannel::new(channel).map_err(|_| End::quiet("protocol"))?); - // 2. The invitation, with the page's own TURN credential. + // 2. The invitation, with the page's own ICE entry. let TurnCredentials { own, peer, .. } = turn; + self.relay_offered = own.has_turn(); self.id = fresh_id().ok_or(End::quiet("protocol"))?; if let Ok(mut transfer) = self.controls.transfer.lock() { *transfer = Some(self.id.clone()); @@ -812,7 +818,7 @@ impl Run { Err(_) => return Err(End::Cancelled), } - // 4. The connection: Drop's own credential, relay-only when hiding. + // 4. The connection: Drop's own ICE entry, relay-only when hiding. self.awake = Some(self.host.keep_awake()); self.emit_progress(); if !self.host.open_peer() { @@ -1122,11 +1128,21 @@ impl Run { } } + /// Why a connection that never came up ended: `connect_direct` when no + /// relay was on offer, because some networks do not allow a direct one. + fn connect_reason(&self) -> &'static str { + if self.relay_offered { + "connect" + } else { + "connect_direct" + } + } + fn give_up(&self) -> End { End::failed(if self.ever_connected { "lost" } else { - "connect" + self.connect_reason() }) } @@ -1478,7 +1494,7 @@ impl Run { return Some(End::Kept(kept.clone())); } if self.connect_by.is_some_and(|by| at >= by) { - return Some(End::failed("connect")); + return Some(End::failed(self.connect_reason())); } if self.stall_at().is_some_and(|by| at >= by) { return Some(End::failed("lost")); diff --git a/desktop/src-tauri/src/drop/direct/session/tests.rs b/desktop/src-tauri/src/drop/direct/session/tests.rs index 7b6229b..a915524 100644 --- a/desktop/src-tauri/src/drop/direct/session/tests.rs +++ b/desktop/src-tauri/src/drop/direct/session/tests.rs @@ -5,7 +5,7 @@ //! servers and credentials are synthetic. use super::*; -use crate::drop::direct::messages::IceServer; +use crate::drop::direct::messages::{IceServer, default_stun}; use crate::drop::direct::sink::tests::{sealed, sha}; use crate::drop::relay; use foregather_kit::Namespace; @@ -364,6 +364,9 @@ struct Harness { task: tokio::task::JoinHandle, temp: PathBuf, received: PathBuf, + /// The ICE configuration and hiding choice the session was opened with. + ice: TurnCredentials, + hide: bool, _dir: tempfile::TempDir, } @@ -385,6 +388,18 @@ fn start_full( can_open: bool, timing: Timing, space: fn(&std::path::Path) -> Option, +) -> Harness { + start_custom(lifetime, can_open, timing, space, turn(), true) +} + +/// The same, with the ICE configuration and the hiding choice given. +fn start_custom( + lifetime: u64, + can_open: bool, + timing: Timing, + space: fn(&std::path::Path) -> Option, + turn: TurnCredentials, + hide: bool, ) -> Harness { let dir = tempfile::tempdir().unwrap(); let temp = dir.path().join("drop-temp"); @@ -418,9 +433,10 @@ fn start_full( link: "https://wildbloom.example.invalid/send/#synthetic".into(), words: words.split(' ').map(str::to_owned).collect(), expires_at: session.expires, - turn: turn(), - hide: true, + turn: turn.clone(), + hide, }; + let ice = turn.clone(); let dirs = Dirs { temp: temp.clone(), received: received.clone(), @@ -434,7 +450,7 @@ fn start_full( dirs, timing, )); - let allowlist = turn().peer.urls.clone(); + let allowlist = turn.peer.urls.clone(); Harness { relay: relay.clone(), host, @@ -453,6 +469,8 @@ fn start_full( task, temp, received, + ice, + hide, _dir: dir, } } @@ -535,8 +553,8 @@ impl Harness { assert_eq!( create.op, PeerOp::Create { - ice_servers: vec![turn().own], - relay_only: true, + ice_servers: vec![self.ice.own.clone()], + relay_only: self.hide, envelope_bytes: file.envelope.len() as u64, size: file.manifest.size, } @@ -1180,6 +1198,33 @@ async fn a_connection_that_never_comes_up_is_given_up() { assert_eq!(h.end().await.reason, Some("connect")); } +/// The configuration a node without an issuer gives: STUN alone for both +/// ends. +fn stun_only() -> TurnCredentials { + TurnCredentials { + expires: 0, + own: default_stun(), + peer: default_stun(), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_stun_only_session_gives_up_as_a_direct_connection_failure() { + let mut h = start_custom(600, true, TIMING, plenty, stun_only(), false); + let f = file("a.bin", 1000); + h.accepted(&f).await; + h.phone.send(&Message::Answer { + key: f.key.clone(), + sdp: PAGE_SDP.into(), + }); + let answer = h.web.next().await; + h.web.done(&answer); + assert_eq!(h.closed().await, Some(2)); + assert_eq!(h.phone.read().await, stop(StopReason::Failed)); + // No relay was on offer, so the person is told a direct connection failed. + assert_eq!(h.end().await.reason, Some("connect_direct")); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_tampered_envelope_is_refused_and_deleted() { let mut h = start(600); diff --git a/desktop/src-tauri/src/drop/e2e.rs b/desktop/src-tauri/src/drop/e2e.rs index cbda9d7..90c352f 100644 --- a/desktop/src-tauri/src/drop/e2e.rs +++ b/desktop/src-tauri/src/drop/e2e.rs @@ -2629,7 +2629,7 @@ async fn direct_receive_services() { return; }; // The node issues credentials before Drop asks: a quick check that the - // issuer is on, so a daemon without it fails here, not as `no_turn`. + // issuer is on, so a daemon without it fails here with a clear message. let probe = svc .http .post(svc.online.url("turn/v1/credentials")) diff --git a/desktop/src-tauri/src/drop/status.rs b/desktop/src-tauri/src/drop/status.rs index 4ee46a5..e109dc6 100644 --- a/desktop/src-tauri/src/drop/status.rs +++ b/desktop/src-tauri/src/drop/status.rs @@ -220,7 +220,7 @@ mod tests { expiry_seconds: 86_400, }, receiving: crate::drop::direct::ReceivingSettings { - hide_address: true, + hide_address: false, folder: "~/Downloads/Wildbloom Received".into(), max_bytes: 250_000_000, }, diff --git a/desktop/tests/direct-window.mjs b/desktop/tests/direct-window.mjs index 4145a0f..5faf682 100644 --- a/desktop/tests/direct-window.mjs +++ b/desktop/tests/direct-window.mjs @@ -331,7 +331,7 @@ try { }); await journey('hiding without a TURN URL is refused as no-relay; requests for another transfer are not served', async (w) => { - await emit(w, { transfer: 'T1', seq: 1, op: 'create', iceServers: [{ urls: ['stun:127.0.0.1:9'], username: 'synthetic-user', credential: SECRET }], relayOnly: true, envelopeBytes: ENVELOPE, size: SIZE }); + await emit(w, { transfer: 'T1', seq: 1, op: 'create', iceServers: [{ urls: ['stun:127.0.0.1:9'], username: '', credential: '' }], relayOnly: true, envelopeBytes: ENVELOPE, size: SIZE }); await reported(w, { kind: 'error', seq: 1, reason: 'no-relay' }, 'no-relay'); await emit(w, { transfer: 'T1', seq: 2, op: 'create', iceServers: [{ urls: ['turn:127.0.0.1:9?transport=udp'], username: 'synthetic-user', credential: SECRET }], relayOnly: true, envelopeBytes: ENVELOPE, size: SIZE }); await reported(w, { kind: 'done', seq: 2 }, 'a relay-only create'); @@ -344,6 +344,26 @@ try { assert.ok(!JSON.stringify(await reports(w)).includes(SECRET), 'Reports never carry the credential.'); }); + await journey('a STUN-only server reaches the peer without a username or credential member', async (w) => { + await w.page.evaluate(() => { + const Original = window.RTCPeerConnection; + window.__configs = []; + window.RTCPeerConnection = class extends Original { + constructor(configuration, ...rest) { + super(configuration, ...rest); + window.__configs.push(JSON.parse(JSON.stringify(configuration))); + } + }; + }); + await emit(w, { transfer: 'T1', seq: 1, op: 'create', iceServers: [{ urls: ['stun:127.0.0.1:9'], username: '', credential: '' }], relayOnly: false, envelopeBytes: ENVELOPE, size: SIZE }); + await reported(w, { kind: 'done', seq: 1 }, 'a create with STUN alone'); + const [configuration] = await w.page.evaluate(() => window.__configs); + assert.deepEqual(configuration.iceServers, [{ urls: ['stun:127.0.0.1:9'] }], 'No credential member for a STUN server.'); + assert.equal(configuration.iceTransportPolicy, 'all'); + await emit(w, { transfer: 'T1', seq: 2, op: 'close', stop: null }); + await reported(w, { kind: 'done', seq: 2 }, 'close'); + }); + console.log(`direct window: ${passed} passed`); } finally { await browser.close(); diff --git a/desktop/tests/drop-harness.html b/desktop/tests/drop-harness.html index 108d675..31258c5 100644 --- a/desktop/tests/drop-harness.html +++ b/desktop/tests/drop-harness.html @@ -60,7 +60,7 @@ r12: { state: 'cancelled', by: 'you' }, r12b: { state: 'cancelled', by: 'them' }, }; - for (const reason of ['node_unreachable', 'no_turn', 'turn_refused', 'turn_limit', 'relay_unreachable', 'expired', 'wrong_words', 'mismatch', 'too_big', 'unsupported', 'protocol', 'peer_failed', 'peer_storage', 'connect', 'lost', 'verification', 'storage', 'no_relay', 'webview', 'something_new']) receives[`f-${reason}`] = { state: 'failed', reason }; + for (const reason of ['node_unreachable', 'turn_refused', 'turn_limit', 'relay_unreachable', 'expired', 'wrong_words', 'mismatch', 'too_big', 'unsupported', 'protocol', 'peer_failed', 'peer_storage', 'connect', 'connect_direct', 'lost', 'verification', 'storage', 'no_relay', 'webview', 'something_new']) receives[`f-${reason}`] = { state: 'failed', reason }; const receiveKey = name.startsWith('receive-') ? name.slice(8) : null; if (receiveKey) stub.receive = receives[receiveKey] || { state: 'idle' }; const waitFor = async (test) => { for (let i = 0; i < 200; i++) { const v = test(); if (v) return v; await new Promise((r) => setTimeout(r, 25)); } return null; }; diff --git a/desktop/tests/drop-stub.js b/desktop/tests/drop-stub.js index 6d2f076..4051f55 100644 --- a/desktop/tests/drop-stub.js +++ b/desktop/tests/drop-stub.js @@ -68,7 +68,7 @@ limits: { dropMaxBytes: 1_000_000_000, shareMaxBytes: 250_000_000 }, browserApproval: false, sharing: { afterDownload: 'delete', expirySeconds: 86400 }, - receiving: { hideAddress: true, folder: '~/Downloads/Wildbloom Received', maxBytes: 250_000_000 }, + receiving: { hideAddress: false, folder: '~/Downloads/Wildbloom Received', maxBytes: 250_000_000 }, }); const name = params.get('state') || 'list'; diff --git a/desktop/tests/drop-ui.mjs b/desktop/tests/drop-ui.mjs index a9d47ad..cf616cc 100644 --- a/desktop/tests/drop-ui.mjs +++ b/desktop/tests/drop-ui.mjs @@ -1445,7 +1445,7 @@ const STATES = [ 'share-s5&os=windows', 'share-s5&os=linux&desktop=kde', 'receive-idle', 'receive-r1', 'receive-r2', 'receive-r3', 'receive-r4', 'receive-r5', 'receive-r6', 'receive-r6b', 'receive-r6c', 'receive-r7', 'receive-r7b', 'receive-r7c', 'receive-r8', 'receive-r8b', 'receive-r9', 'receive-r10', 'receive-r10b', 'receive-r11', 'receive-r12', 'receive-r12b', - 'receive-f-no_turn', 'receive-f-wrong_words', 'receive-f-unsupported', 'receive-f-no_relay', 'receive-f-verification', + 'receive-f-connect_direct', 'receive-f-wrong_words', 'receive-f-unsupported', 'receive-f-no_relay', 'receive-f-verification', 'receive-r4&os=windows', 'receive-r6&os=linux&desktop=kde', ]; const SIZES = [[400, 600], [340, 420], [760, 600]]; @@ -1955,6 +1955,8 @@ scenario('receive: the whole happy path, answers sent once, nothing about time o assert.match(await sheetText(page), /Nothing has been sent yet\. Only accept a file you expect\./); assert.equal(await page.evaluate(() => document.activeElement.textContent), 'Receive a file', 'focus is not on Accept'); assert.deepEqual(await buttonsOf(page), ['Decline', 'Accept']); + // Hiding is off by default; this owner has turned it on. + await page.evaluate(() => { window.__stub.state.receiving.hideAddress = true; }); await sheet.getByRole('button', { name: 'Accept', exact: true }).click(); assert.deepEqual((await calls(page, 'receive_consent')).map((c) => c.args), [{ accept: true }]); await page.waitForFunction(() => document.querySelector('dialog.receive .share-slot').textContent.includes('Connecting')); @@ -2130,7 +2132,7 @@ scenario('receive: every failure has plain words, no jargon, and Start again', a const page = await drop('list'); await ready(page); await openReceive(page); - const reasons = ['node_unreachable', 'no_turn', 'turn_refused', 'turn_limit', 'relay_unreachable', 'expired', 'wrong_words', 'mismatch', 'too_big', 'unsupported', 'protocol', 'peer_failed', 'peer_storage', 'connect', 'lost', 'verification', 'storage', 'no_relay', 'webview', 'a_reason_from_the_future']; + const reasons = ['node_unreachable', 'turn_refused', 'turn_limit', 'relay_unreachable', 'expired', 'wrong_words', 'mismatch', 'too_big', 'unsupported', 'protocol', 'peer_failed', 'peer_storage', 'connect', 'connect_direct', 'lost', 'verification', 'storage', 'no_relay', 'webview', 'a_reason_from_the_future']; const seen = new Set(); for (const reason of reasons) { await setReceive(page, { state: 'failed', reason }); @@ -2257,7 +2259,7 @@ scenario('settings: hide my address when receiving', async () => { const page = await settingsPage({ scheme: 'dark' }); const box_ = page.getByRole('checkbox', { name: 'Hide my address when receiving' }); assert.equal(await box_.count(), 1); - assert.equal(await box_.isChecked(), true, 'on by default'); + assert.equal(await box_.isChecked(), false, 'off by default'); assert.equal(await text(page.locator('#drop-receiving-hint')), "The other person sees the relay's address instead of yours, and sends may be slower."); assert.equal(await box_.getAttribute('aria-describedby'), 'drop-receiving-hint'); assert.equal(await text(page.locator('#drop-receiving-folder')), 'Received files go to ~/Downloads/Wildbloom Received.'); @@ -2280,17 +2282,17 @@ scenario('settings: hide my address when receiving', async () => { assert.equal(await show.isVisible(), false); await page.evaluate(() => { window.__stub.state.receiving.folder = '~/Downloads/Wildbloom Received'; window.__stub.flush(); }); await page.waitForFunction(() => !document.querySelector('#drop-receiving-row').hidden); - await box_.uncheck(); + await box_.check(); await page.waitForFunction(() => window.__stub.calls.some((c) => c.command === 'set_receiving')); - assert.deepEqual((await calls(page, 'set_receiving')).map((c) => c.args), [{ hideAddress: false }]); - assert.equal(await box_.isChecked(), false); - await page.evaluate(() => { window.__stub.state.receiving.hideAddress = true; window.__stub.flush(); }); - await page.waitForFunction(() => document.querySelector('#drop-hide-address').checked); + assert.deepEqual((await calls(page, 'set_receiving')).map((c) => c.args), [{ hideAddress: true }]); + assert.equal(await box_.isChecked(), true); + await page.evaluate(() => { window.__stub.state.receiving.hideAddress = false; window.__stub.flush(); }); + await page.waitForFunction(() => !document.querySelector('#drop-hide-address').checked); await page.evaluate(() => { window.__stub.handlers.set_receiving = () => { throw { code: 'unknown' }; }; }); await box_.click(); await page.locator('#drop-settings-error:not([hidden])').waitFor(); assert.equal(await text(page.locator('#drop-settings-error')), "Couldn't change that setting."); - assert.equal(await box_.isChecked(), true, 'the box goes back'); + assert.equal(await box_.isChecked(), false, 'the box goes back'); const result = await new AxeBuilder({ page }).include('#drop-settings').withTags(AXE_TAGS).analyze(); assert.deepEqual(result.violations.map((v) => ({ id: v.id, nodes: v.nodes.map((n) => n.target) })), []); for (const width of [820, 420, 320]) { diff --git a/desktop/ui/DROP-IPC.md b/desktop/ui/DROP-IPC.md index 297c8bc..6b6cbc6 100644 --- a/desktop/ui/DROP-IPC.md +++ b/desktop/ui/DROP-IPC.md @@ -84,7 +84,7 @@ Conventions how long a sent copy stays otherwise: an hour, a day (the default) or a week. Both are read when a file is sent; a share already sent keeps what it was sent with. A changed setting is sent as `drop://status`. -- **Added.** `receiving` is "Receive a file". `hideAddress` (default true) is the node +- **Added.** `receiving` is "Receive a file". `hideAddress` (default false) is the node window's "Hide my address when receiving" setting (`set_receiving`): Drop then connects only through the relay, so the sender sees the relay's address, not this computer's. It is read when a receive starts. `folder` is where received files land, outside the Wildbloom folder @@ -346,8 +346,7 @@ time. | `reason` | Meaning | |---|---| | `node_unreachable` | The online node did not answer, or this computer is not approved there. | -| `no_turn` | The online node does not issue relay (TURN) credentials yet, so it cannot receive files directly. | -| `turn_refused`, `turn_limit` | The node refused relay credentials (`turn_limit`: too many tries this hour; try later). | +| `turn_refused`, `turn_limit` | The node refused to hand out its ICE configuration (`turn_limit`: too many tries this hour; try later). A node with no issuer is not a failure: Drop uses its built-in STUN server. | | `relay_unreachable` | The pairing relay failed. | | `expired` | The 10-minute window ended before the file started to arrive. | | `wrong_words` | Someone opened the link with the wrong words. The link can't be used again. | @@ -356,11 +355,12 @@ time. | `unsupported` | Their page can't use what this computer offered (for example its relay). | | `protocol` | Something arrived that the protocol does not allow. | | `peer_failed`, `peer_storage` | Their side failed. | -| `connect` | The two devices never connected. | +| `connect` | The two devices never connected, with a relay on offer. | +| `connect_direct` | The two devices never connected and no relay was on offer, so only a direct connection was possible. Some networks, like mobile data or work Wi-Fi, do not allow one. | | `lost` | The connection was lost and could not be restored, or no new byte arrived for 90 seconds while connected. | | `verification` | The file did not check out (wrong size, damaged or not what they offered). Nothing was kept. | | `storage` | There is no room on this computer. Nothing was kept. Also sent instead of `consent` when the temp and Received folders' volumes lack twice the envelope and 64 MiB free. | -| `no_relay` | Hiding the address needs a relay and none was offered. | +| `no_relay` | Hiding the address needs a TURN relay and the online node offers none. Sent before any link is shown when the setting is on. | | `webview` | The connection could not be set up in this app. | An unknown `reason` falls back to a generic "Something went wrong. Nothing was kept." diff --git a/desktop/ui/app.js b/desktop/ui/app.js index 50badbf..d3e3780 100644 --- a/desktop/ui/app.js +++ b/desktop/ui/app.js @@ -177,8 +177,8 @@ elements.checkUpdate.addEventListener("click", async () => { // A backend that doesn't report sharing yet gets today's behaviour. const currentSharing = () => ({ afterDownload: "delete", expirySeconds: 86400, ...(snapshot?.sharing || {}) }); - // A backend that doesn't report receiving yet hides the address, as Drop does by default. - const currentReceiving = () => ({ hideAddress: true, folder: "", ...(snapshot?.receiving || {}) }); + // A backend that doesn't report receiving yet leaves the address showing, as Drop does by default. + const currentReceiving = () => ({ hideAddress: false, folder: "", ...(snapshot?.receiving || {}) }); function render() { const online = snapshot?.online; diff --git a/desktop/ui/drop.js b/desktop/ui/drop.js index de5f270..0405fc7 100644 --- a/desktop/ui/drop.js +++ b/desktop/ui/drop.js @@ -833,7 +833,7 @@ online: { configured: false, url: '', reachable: true, failedChecks: 0, usedBytes: 0, limitBytes: 0, full: false }, approval: { state: 'none' }, recovery: { confirmed: true }, watchFolder: { enabled: false, path: '' }, limits: { dropMaxBytes: 1e9, shareMaxBytes: 250e6 }, browserApproval: false, - receiving: { hideAddress: true, folder: '', maxBytes: 250e6 }, + receiving: { hideAddress: false, folder: '', maxBytes: 250e6 }, }; const rows = new Map(); const rowEls = new Map(); diff --git a/desktop/ui/receive.js b/desktop/ui/receive.js index 6599b1d..da39db2 100644 --- a/desktop/ui/receive.js +++ b/desktop/ui/receive.js @@ -27,7 +27,6 @@ const limit = formatBytes(context?.receiving?.maxBytes || 250e6); switch (reason) { case 'node_unreachable': return "Can't reach your online node, or this computer isn't approved there. Nothing was received."; - case 'no_turn': return "Your online node can't help with direct transfers yet, so it can't receive files this way."; case 'turn_refused': return 'Your online node refused to set up the transfer. Nothing was received.'; case 'turn_limit': return 'Too many tries this hour. Try again later.'; case 'relay_unreachable': return "Can't reach the sharing service. Check your internet connection."; @@ -40,10 +39,11 @@ case 'peer_failed': return 'Their side ran into a problem. Nothing was kept.'; case 'peer_storage': return "Their device ran out of room, so the transfer stopped. Nothing was kept."; case 'connect': return "This computer and theirs couldn't connect. Nothing was kept."; + case 'connect_direct': return "This computer and theirs couldn't connect directly. Some networks, like mobile data or work Wi-Fi, don't allow it. Nothing was kept."; case 'lost': return "The connection was lost and couldn't be restored. Nothing was kept."; case 'verification': return "The file didn't check out, so Drop threw it away. Nothing was kept."; case 'storage': return "There isn't enough room on this computer. Nothing was kept."; - case 'no_relay': return "Hiding your address needs your online node, and it wasn't available. You can turn this off in Settings."; + case 'no_relay': return "Hiding your address needs a relay server, and your online node doesn't offer one. Turn hiding off in Settings to receive directly."; case 'webview': return "Drop couldn't set up the connection on this computer. Nothing was kept."; default: return 'Something went wrong. Nothing was kept.'; } diff --git a/desktop/ui/vendor/wildbloom-direct.js b/desktop/ui/vendor/wildbloom-direct.js index dbcc46b..5ebcf27 100644 --- a/desktop/ui/vendor/wildbloom-direct.js +++ b/desktop/ui/vendor/wildbloom-direct.js @@ -1,4 +1,4 @@ -// Generated by scripts/vendor-direct.mjs from https://github.com/forgesworn/wildbloom at 98fdd4a8f8eddf3d4f77e78f6d796839cf22ed1f. +// Generated by scripts/vendor-direct.mjs from https://github.com/forgesworn/wildbloom at e04bdefe29c0319c1384d24c0e33a9b55dbea3c7. // Do not edit: rebuild it. Provenance: wildbloom-direct.provenance.json. // commit:src/direct/config.ts @@ -38,7 +38,7 @@ function buildRtcConfiguration(servers, hide) { for (const server of servers) { const urls = hide ? server.urls.filter(isRelayUrl) : [...server.urls]; if (urls.length === 0) continue; - iceServers.push({ urls, username: server.username, credential: server.credential }); + iceServers.push(server.username === "" ? { urls } : { urls, username: server.username, credential: server.credential }); } if (hide && iceServers.length === 0) throw new NoRelayError(); return { diff --git a/desktop/ui/vendor/wildbloom-direct.provenance.json b/desktop/ui/vendor/wildbloom-direct.provenance.json index e0ac97d..2045645 100644 --- a/desktop/ui/vendor/wildbloom-direct.provenance.json +++ b/desktop/ui/vendor/wildbloom-direct.provenance.json @@ -2,16 +2,16 @@ "schema": "wildbloom-node.vendor.v1", "bundle": "desktop/ui/vendor/wildbloom-direct.js", "repository": "https://github.com/forgesworn/wildbloom", - "commit": "98fdd4a8f8eddf3d4f77e78f6d796839cf22ed1f", + "commit": "e04bdefe29c0319c1384d24c0e33a9b55dbea3c7", "sources": { "src/direct/config.ts": { - "sha256": "4dff027bb3695b9abb68da700d271f2f667be0f17854b5b30f9b7e43dff2beb8" + "sha256": "5e58c618efbe81b122ae8d3de5c3e91efa3c85868457892ac9ac75e6a898dbdb" }, "src/direct/frames.ts": { "sha256": "33a7f9af96392cfb6be3eb191673f83d6026fb5ee142445d1781b5d3144dfecb" }, "src/direct/peer.ts": { - "sha256": "b438c81071d75706810156a8c9b4fe855d4b5aba6c2c9a26a7e321d464c10b31" + "sha256": "ef4a8c6530d1452231e1ccf173e53bae14cd54218774237e68c53b5252564fda" }, "src/direct/transfer.ts": { "sha256": "40769e01a4392fcdbaf1ffad7569aab62343fc73b026f96c9889827a8725a780" @@ -38,5 +38,5 @@ ] } }, - "sha256": "c54dff57953101bb341faa6a8b99647df9fa5894483bbbfd2778e1a7bc1c2023" + "sha256": "05087fab6a1866abb3e21cea496422073ded697dab345e57cfe152893e6015e2" } From b9aa792604564c45e7d41fa13f53154adbeede12 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Fri, 23 Oct 2026 21:48:31 +0100 Subject: [PATCH 2/2] chore(drop): vendor the direct-send bundle from the merged page commit --- desktop/ui/vendor/wildbloom-direct.js | 2 +- desktop/ui/vendor/wildbloom-direct.provenance.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/desktop/ui/vendor/wildbloom-direct.js b/desktop/ui/vendor/wildbloom-direct.js index 5ebcf27..c0bacbf 100644 --- a/desktop/ui/vendor/wildbloom-direct.js +++ b/desktop/ui/vendor/wildbloom-direct.js @@ -1,4 +1,4 @@ -// Generated by scripts/vendor-direct.mjs from https://github.com/forgesworn/wildbloom at e04bdefe29c0319c1384d24c0e33a9b55dbea3c7. +// Generated by scripts/vendor-direct.mjs from https://github.com/forgesworn/wildbloom at 72f9d2bb90109a9e7a59eafc28d1f236206e5a6b. // Do not edit: rebuild it. Provenance: wildbloom-direct.provenance.json. // commit:src/direct/config.ts diff --git a/desktop/ui/vendor/wildbloom-direct.provenance.json b/desktop/ui/vendor/wildbloom-direct.provenance.json index 2045645..1e5aca8 100644 --- a/desktop/ui/vendor/wildbloom-direct.provenance.json +++ b/desktop/ui/vendor/wildbloom-direct.provenance.json @@ -2,7 +2,7 @@ "schema": "wildbloom-node.vendor.v1", "bundle": "desktop/ui/vendor/wildbloom-direct.js", "repository": "https://github.com/forgesworn/wildbloom", - "commit": "e04bdefe29c0319c1384d24c0e33a9b55dbea3c7", + "commit": "72f9d2bb90109a9e7a59eafc28d1f236206e5a6b", "sources": { "src/direct/config.ts": { "sha256": "5e58c618efbe81b122ae8d3de5c3e91efa3c85868457892ac9ac75e6a898dbdb" @@ -38,5 +38,5 @@ ] } }, - "sha256": "05087fab6a1866abb3e21cea496422073ded697dab345e57cfe152893e6015e2" + "sha256": "0242433956eaec157be394171f5a1a10bd06246ded92619021eb2475a6488d25" }