Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions desktop/src-tauri/src/drop/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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")]
Expand All @@ -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<TurnCredentials, TurnError> {
// 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)?;
Expand All @@ -788,9 +790,8 @@ pub fn parse_turn_credentials(bytes: &[u8]) -> Result<TurnCredentials, TurnError
// Read from the bytes, not the value, so a duplicate member is refused.
let credentials: TurnCredentials =
serde_json::from_slice(bytes).map_err(|_| TurnError::Invalid)?;
let sound =
|server: &super::direct::messages::IceServer| server.well_formed() && server.has_turn();
if credentials.expires == 0 || !sound(&credentials.own) || !sound(&credentials.peer) {
if credentials.expires == 0 || !credentials.own.well_formed() || !credentials.peer.well_formed()
{
return Err(TurnError::Invalid);
}
Ok(credentials)
Expand Down Expand Up @@ -1136,9 +1137,16 @@ mod tests {
v.as_object_mut().unwrap().remove("peer");
}),
("extra entry member", |v| v["own"]["ttl"] = 1.into()),
("no TURN URL", |v| {
("STUN-only server with a credential", |v| {
v["peer"]["urls"] = serde_json::json!(["stun:turn.example.net:3478"])
}),
("STUN-only server with a username only", |v| {
v["own"]["urls"] = serde_json::json!(["stun:turn.example.net:3478"]);
v["own"]["credential"] = "".into();
}),
("TURN server with an empty username", |v| {
v["own"]["username"] = "".into()
}),
("five URLs", |v| {
v["own"]["urls"] =
serde_json::json!(["turn:a", "turn:b", "turn:c", "turn:d", "turn:e"])
Expand Down Expand Up @@ -1179,6 +1187,20 @@ mod tests {
assert_eq!(parse_turn_credentials(b""), Err(TurnError::Invalid));
}

/// A STUN-only reply, as a node with only `--turn-url stun:...` sends.
#[test]
fn a_stun_only_reply_parses_when_it_carries_no_credential() {
let entry = serde_json::json!({
"credential": "",
"urls": ["stun:stun.example.net:3478"],
"username": "",
});
let reply = serde_json::json!({"expires": 1_800_003_600u64, "own": entry, "peer": entry});
let parsed = parse_turn_credentials(reply.to_string().as_bytes()).unwrap();
assert!(!parsed.own.has_turn() && !parsed.peer.has_turn());
assert!(parsed.own.username.is_empty() && parsed.own.credential.is_empty());
}

/// A server that answers one request with `status` and `body`.
async fn answering(status: &'static str, body: String) -> NodeEndpoint {
server(move |mut socket| async move {
Expand Down
105 changes: 92 additions & 13 deletions desktop/src-tauri/src/drop/direct/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -314,9 +338,11 @@ pub fn encode(message: &Message) -> Result<Vec<u8>, 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);
}
Expand Down Expand Up @@ -538,9 +564,6 @@ fn read_ice(value: &Value, allowlist: &[&str]) -> Result<Vec<IceServer>, 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)
}

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<IceServer>| 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!(
Expand Down
Loading
Loading