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" } diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 53fd435..a1d1a03 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -192,7 +192,8 @@ not returned. Upstream failures are redacted to `502` or `503`. ### TURN credential issuer `--turn-issuer --turn-url ` (with `--device-grants`) adds one route for -[direct send](#direct-send-experimental): +[direct send](#direct-send-experimental). Despite the name it hands out an ICE +configuration: TURN credentials, or `stun:` URLs alone: | Endpoint | Authority | Result | | --- | --- | --- | @@ -200,9 +201,15 @@ not returned. Upstream failures are redacted to `502` or `503`. `--turn-url` is given 1 to 4 times, or comma-separated. Each value is a distinct `stun:`, `turn:` or `turns:` URL of at most 256 bytes of printable -ASCII without spaces, quotes or backslashes, and at least one is `turn:` or -`turns:`. The URLs are checked at start-up; a node with a bad one does not -start. +ASCII without spaces, quotes or backslashes. The URLs are checked at +start-up; a node with a bad one does not start. + +The issuer has two modes. If any URL is `turn:` or `turns:`, the secret below +is required and the node mints credentials. If every URL is `stun:`, the +secret must be absent (a `--turn-secret-file` or `WILDBLOOM_TURN_SECRET` that +is given anyway stops the node from starting), nothing is minted, and the +response carries the URLs with an empty `username` and `credential` in `own` +and `peer`. The authority is the pairing issuer's, through the same code: the checkout NIP-98 profile (exact `u` and `method`, a `payload` tag for the empty body, at @@ -223,7 +230,9 @@ The response carries `Cache-Control: no-store` and is exactly: `own` is for the caller and `peer` for the verified person at the other end. Each has exactly the members and limits of an [`ice`](#invitation-and-manifest) -entry, so Drop puts `peer` into `invite` unchanged. `urls` are the configured +entry, so Drop puts `peer` into `invite` unchanged. In the STUN-only mode both +hold `"credential":""` and `"username":""`, for example +`{"expires":1800003600,"own":{"credential":"","urls":["stun:stun.example.net:3478"],"username":""},"peer":{"credential":"","urls":["stun:stun.example.net:3478"],"username":""}}`. `urls` are the configured URLs in their configured order. `expires` is the issue time plus 900 seconds: Drop asks just before it opens a session, which lasts at most 600 seconds, so every allocation and restart falls inside it. Each credential is coturn's TURN REST form (`use-auth-secret` with @@ -260,16 +269,28 @@ Operator notes: - The coturn in front of it needs this secret as an additional `static-auth-secret` line. The line must be distinct from any other - service's secret on that server, such as KithMoot's on - `kithmoot.forgesworn.dev`, so it can be rotated or revoked alone. A local - check with coturn 4.17.2 accepted credentials made from either of two + service's secret on that server, so it can be rotated or revoked alone. A + local check with coturn 4.17.2 accepted credentials made from either of two `static-auth-secret` lines and refused one made from neither. - Nothing works until both are deployed: the node flags and the coturn line. Until then the node issues credentials that the TURN server refuses. +- The sending page accepts only the URLs it was built with. Build it with the + same URLs in `VITE_WILDBLOOM_DIRECT_ICE`, or it refuses them as + `unsupported`. - To rotate, add the new line to coturn, restart the node with the new secret, and remove the old line 15 minutes later, once the last credential signed with it has expired. +Recipes for a self-hoster: + +- Own TURN: run coturn, then + `wildbloomd --device-grants --turn-issuer --turn-url 'turn:turn.example.net:3478?transport=udp' --turn-url stun:turn.example.net:3478 --turn-secret-file /abs/path/0600-file`, + and build the page with `VITE_WILDBLOOM_DIRECT_ICE` listing the same URLs. +- Own STUN only: `wildbloomd --device-grants --turn-issuer --turn-url stun:stun.example.net:3478` + with no secret, and the same page build rule. +- Neither: configure nothing on the node. Drop uses the Cloudflare STUN + default above, and the default page build allows it. + ## Experimental private storage offer An operator may send the following JSON as the plaintext of a NIP-17 private @@ -308,8 +329,8 @@ built. Version 1 covers a page sending a file to Drop (`to-drop`). The node takes no part in the transfer. Its roles are the existing [pairing creation-grant issuer](#pairing-creation-grant-issuer), which lets Drop open the session, and the -[TURN credential issuer](#turn-credential-issuer), which mints the TURN -credentials. `wildbloomd` runs no WebRTC, STUN or TURN code, and Blossom +[TURN credential issuer](#turn-credential-issuer), which hands out the ICE +configuration, if it runs one. `wildbloomd` runs no WebRTC, STUN or TURN code, and Blossom storage and the transports above are unchanged. Nothing about the file rests on any server. @@ -398,7 +419,7 @@ session therefore exposes no address and allocates nothing on a TURN server. | --- | --- | | `dir` | `"to-drop"`: the page sends the file to Drop. `"from-drop"` is reserved for Drop sending to the page; its message order is not specified yet. A page refuses a direction it does not implement with `unsupported`; the `send` page implements only `to-drop` | | `hide` | `true` when Drop uses relay-only ICE and its address is hidden behind TURN. It only tells the page what to say | -| `ice` | 1 to 4 servers, each exactly `{"credential","urls","username"}`. `urls` holds 1 to 4 `stun:`, `turn:` or `turns:` URLs of at most 256 bytes; `username` and `credential` hold 1 to 256 bytes without control characters. At least one URL is `turn:` or `turns:` | +| `ice` | 1 to 4 servers, each exactly `{"credential","urls","username"}`. `urls` holds 1 to 4 `stun:`, `turn:` or `turns:` URLs of at most 256 bytes. An entry with any `turn:` or `turns:` URL has a `username` and a `credential` of 1 to 256 bytes each, without control characters. An entry of `stun:` URLs only carries no credential: its `username` and `credential` are both exactly `""`. Any other combination (a STUN-only entry with a credential, one field empty and one not, a TURN entry with an empty field) is malformed. The list need not hold a TURN URL | | `id` | 16 random bytes in canonical unpadded base64url: the transfer's name in each app's own state. It is never sent to a server, and is neither the relay locator nor a TURN username | | `max` | The largest file, in bytes, Drop will accept: 1 to 250,000,000 in v1 | | `name` | The FSWNENC2 metadata name: a fixed point of the shared name rule (`sanitise_name` in `crates/envelope`) with none of the disguising characters below | @@ -427,31 +448,46 @@ larger than `max`, and Drop refuses one with `unsupported`. ICE server or size) or `failed` (a protocol, connection or verification failure). -### ICE servers and the TURN credential - -Drop obtains two short-lived TURN REST credentials (coturn's `use-auth-secret` -form, each with its own random username label): one for itself and one for the -page. The page's credential travels only inside `invite`, after the check -words, so only the verified peer receives it. It never appears in the link, a -URL, the relay's public fields, a log or browser storage, and the page forgets -it when the direct send ends. Drop obtains both from the node's -[TURN credential issuer](#turn-credential-issuer) before it opens the session, -so a node without the issuer fails before any link is shown, and puts `peer` -into `ice` unchanged. An abandoned session's credentials lapse unused. +### ICE servers and credentials + +Drop obtains its ICE configuration from the node's +[TURN credential issuer](#turn-credential-issuer) if the node runs one: either +two short-lived TURN REST credentials (coturn's `use-auth-secret` form, each +with its own random username label), one for itself and one for the page, or +`stun:` URLs alone, with no credential. If the node answers `404` (it runs no +issuer), Drop uses its built-in default for both ends: the single entry +`{"credential":"","urls":["stun:stun.cloudflare.com:3478"],"username":""}`. +That STUN server is run by Cloudflare, Inc., which learns the public address and +port of each end that does not hide, and when it asked. It carries no file +data. Any other failure of the issuer ends the session before a link is shown. +Drop puts `peer` into `ice` unchanged. + +A page's TURN credential, when there is one, travels only inside `invite`, +after the check words, so only the verified peer receives it. It never appears +in the link, a URL, the relay's public fields, a log or browser storage, and the +page forgets it when the direct send ends. An abandoned session's credentials +lapse unused. A STUN server is never given a username or a credential. Each URL in `ice` must equal, byte for byte, an entry of the page's build-time -allowlist; otherwise the page refuses with `unsupported`. A paired but hostile -inviter therefore cannot point the page at its own TURN server to learn an -address the page asked to hide. The page builds its configuration only from -`ice` and adds no other server; Drop uses its own credential with the same -servers. +allowlist; otherwise the page refuses with `unsupported`. The check comes after +the syntax rules above. A paired but hostile inviter therefore cannot point the +page at its own STUN or TURN server to learn an address the page asked to hide. +The page builds its configuration only from `ice` and adds no other server; +Drop uses its own entry with the same servers. The default page build allows +`stun:stun.cloudflare.com:3478` alone, which cannot relay. Each side decides for itself whether to hide its own address. Hiding means `iceTransportPolicy: "relay"` with STUN URLs removed: the other device then sees only the TURN server's address, while the TURN server still sees the real one. -Drop hides by default; the page connects directly unless the person turns -hiding on. Browsers already hide local addresses behind mDNS names, because -these pages never ask for camera or microphone access. +Hiding therefore needs a TURN URL. An end that wants to hide and has none +refuses: Drop before it shows a link, and the page by not offering the choice. +Drop hides when its setting is on, which is off unless the person turned it on; +the page connects directly unless the person turns hiding on. Browsers already +hide local addresses behind mDNS names, because these pages never ask for +camera or microphone access. + +Without TURN, two ends that are each behind a symmetric NAT (some mobile data +and work networks) cannot connect; each end reports that it could not connect. ### Session descriptions @@ -555,8 +591,10 @@ timed by its own clocks instead. The pairing relay holds the hellos, proofs and a few KiB of encrypted exchange messages until Drop deletes the session or it expires; logical expiry is not -erasure. STUN and TURN servers see both addresses, byte counts and timing. A -TURN server relays DTLS records that carry FSWNENC2 records, and stores nothing. +erasure. A STUN server sees the public address and port of each end that asks +it, and when; with the default configuration that is Cloudflare's server, and +it carries no file data. A TURN server sees both addresses, byte counts and +timing. A TURN server relays DTLS records that carry FSWNENC2 records, and stores nothing. No server sees the words, the check words, the descriptions, or the file's name, size, key or bytes, and TURN usernames share no identifier with the relay session. Without hiding, each side sees the other's public address. diff --git a/vectors/wildbloom-direct-v1.json b/vectors/wildbloom-direct-v1.json index 99ef955..249621f 100644 --- a/vectors/wildbloom-direct-v1.json +++ b/vectors/wildbloom-direct-v1.json @@ -11,7 +11,8 @@ "stun:turn.example.net:3478", "turn:turn.example.net:3478?transport=udp", "turn:turn.example.net:3478?transport=tcp", - "turns:turn.example.net:5349?transport=tcp" + "turns:turn.example.net:5349?transport=tcp", + "stun:stun.example.net:3478" ], "limits": { "messageBytes": 16379, @@ -106,6 +107,20 @@ "text": "{\"dir\":\"to-drop\",\"event\":\"invite\",\"hide\":false,\"ice\":[{\"credential\":\"c3ludGhldGljLWNyZWRlbnRpYWw=\",\"urls\":[\"stun:turn.example.net:3478\",\"turn:turn.example.net:3478?transport=udp\",\"turns:turn.example.net:5349?transport=tcp\"],\"username\":\"1800003600:dGVzdC1sYWJlbA\"},{\"credential\":\"b3RoZXItY3JlZGVudGlhbA==\",\"urls\":[\"turn:turn.example.net:3478?transport=tcp\"],\"username\":\"1800003600:b3RoZXI\"}],\"id\":\"AAECAwQFBgcICQoLDA0ODw\",\"max\":1,\"v\":1}", "sha256": "e898696e4753df739cffbfaa581b5fa07f2dff44be7847e3cb6526042047139f" }, + { + "name": "invite, STUN only", + "from": "drop", + "index": 0, + "text": "{\"dir\":\"to-drop\",\"event\":\"invite\",\"hide\":false,\"ice\":[{\"credential\":\"\",\"urls\":[\"stun:stun.example.net:3478\"],\"username\":\"\"}],\"id\":\"AAECAwQFBgcICQoLDA0ODw\",\"max\":250000000,\"v\":1}", + "sha256": "3a8c5f6c5b933513bf220ea9dc41785eeb284150c2bd72b1da652b5ffcdd409a" + }, + { + "name": "invite, STUN server and TURN server as two entries", + "from": "drop", + "index": 0, + "text": "{\"dir\":\"to-drop\",\"event\":\"invite\",\"hide\":true,\"ice\":[{\"credential\":\"\",\"urls\":[\"stun:stun.example.net:3478\"],\"username\":\"\"},{\"credential\":\"c3ludGhldGljLWNyZWRlbnRpYWw=\",\"urls\":[\"turn:turn.example.net:3478?transport=udp\"],\"username\":\"1800003600:dGVzdC1sYWJlbA\"}],\"id\":\"AAECAwQFBgcICQoLDA0ODw\",\"max\":250000000,\"v\":1}", + "sha256": "b1725b5cf23c88475aa762b4a657303d42314a18b620e194e94ffb713b1a49be" + }, { "name": "empty file", "from": "page", @@ -193,11 +208,11 @@ "stopReason": "unsupported" }, { - "name": "no TURN URL", + "name": "STUN-only server with a credential", "from": "drop", "index": 0, "text": "{\"dir\":\"to-drop\",\"event\":\"invite\",\"hide\":true,\"ice\":[{\"credential\":\"c3ludGhldGljLWNyZWRlbnRpYWw=\",\"urls\":[\"stun:turn.example.net:3478\"],\"username\":\"1800003600:dGVzdC1sYWJlbA\"}],\"id\":\"AAECAwQFBgcICQoLDA0ODw\",\"max\":250000000,\"v\":1}", - "stopReason": "unsupported" + "stopReason": "failed" }, { "name": "no ICE servers", @@ -213,6 +228,27 @@ "text": "{\"dir\":\"to-drop\",\"event\":\"invite\",\"hide\":true,\"ice\":[{\"credential\":\"\",\"urls\":[\"stun:turn.example.net:3478\",\"turn:turn.example.net:3478?transport=udp\",\"turns:turn.example.net:5349?transport=tcp\"],\"username\":\"1800003600:dGVzdC1sYWJlbA\"}],\"id\":\"AAECAwQFBgcICQoLDA0ODw\",\"max\":250000000,\"v\":1}", "stopReason": "failed" }, + { + "name": "STUN-only server with a username and no credential", + "from": "drop", + "index": 0, + "text": "{\"dir\":\"to-drop\",\"event\":\"invite\",\"hide\":true,\"ice\":[{\"credential\":\"\",\"urls\":[\"stun:turn.example.net:3478\"],\"username\":\"1800003600:dGVzdC1sYWJlbA\"}],\"id\":\"AAECAwQFBgcICQoLDA0ODw\",\"max\":250000000,\"v\":1}", + "stopReason": "failed" + }, + { + "name": "TURN server with an empty username", + "from": "drop", + "index": 0, + "text": "{\"dir\":\"to-drop\",\"event\":\"invite\",\"hide\":true,\"ice\":[{\"credential\":\"c3ludGhldGljLWNyZWRlbnRpYWw=\",\"urls\":[\"turn:turn.example.net:3478?transport=udp\"],\"username\":\"\"}],\"id\":\"AAECAwQFBgcICQoLDA0ODw\",\"max\":250000000,\"v\":1}", + "stopReason": "failed" + }, + { + "name": "STUN-only server not on the allowlist", + "from": "drop", + "index": 0, + "text": "{\"dir\":\"to-drop\",\"event\":\"invite\",\"hide\":false,\"ice\":[{\"credential\":\"\",\"urls\":[\"stun:stun.example.org:3478\"],\"username\":\"\"}],\"id\":\"AAECAwQFBgcICQoLDA0ODw\",\"max\":250000000,\"v\":1}", + "stopReason": "unsupported" + }, { "name": "identifier of 15 bytes", "from": "drop",