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..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 98fdd4a8f8eddf3d4f77e78f6d796839cf22ed1f. +// 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 @@ -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..1e5aca8 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": "72f9d2bb90109a9e7a59eafc28d1f236206e5a6b", "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": "0242433956eaec157be394171f5a1a10bd06246ded92619021eb2475a6488d25" }