From d752823b8c2e0f5153df18de604911234eb39fc8 Mon Sep 17 00:00:00 2001 From: RustDesk <71636191+rustdesk@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:08:59 +0800 Subject: [PATCH] swtich_code for hbbs (#15615) * swtich_code for hbbs to bypass ACL * improve register_switch_grant: skip public server, log at error level Also document why registration is fire-and-forget with no retry: the peer connects within seconds, so a late retry would land after its punch request was already rejected; a failed switch is recovered by the user triggering it again, which registers a fresh grant. Co-Authored-By: Claude Fable 5 * add timestamp Signed-off-by: 21pages * fix(switch-sides): handle grant registration clock skew - retry registration once with the server-provided timestamp - require an explicit accepted response from hbbs - report malformed or incomplete responses Signed-off-by: 21pages * fix(switch-sides): register grants with code verifiers - send a derived verifier instead of the raw switch code - use detached signatures for grant registration - add verifier and signed-message tests Signed-off-by: 21pages --------- Signed-off-by: 21pages Co-authored-by: 21pages Co-authored-by: Claude Fable 5 --- src/client.rs | 22 +++++-- src/hbbs_http/sync.rs | 132 ++++++++++++++++++++++++++++++++++++++++++ src/ipc.rs | 1 + 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/src/client.rs b/src/client.rs index f711c227c..6f2347868 100644 --- a/src/client.rs +++ b/src/client.rs @@ -426,8 +426,8 @@ impl Client { NatType::from_i32(my_nat_type).unwrap_or(NatType::UNKNOWN_NAT) }; - if !key.is_empty() && !token.is_empty() { - // mainly for the security of token + let switch_code = interface.get_switch_code(); + if !key.is_empty() && (!token.is_empty() || !switch_code.is_empty()) { secure_tcp(&mut socket, &key) .await .map_err(|e| anyhow!("Failed to secure tcp: {}", e))?; @@ -469,6 +469,7 @@ impl Client { udp_port: udp_nat_port as _, force_relay: interface.is_force_relay(), socket_addr_v6: ipv6.1.unwrap_or_default(), + switch_code, ..Default::default() }); for i in 1..=3 { @@ -716,6 +717,7 @@ impl Client { let mut direct = !conn.is_err(); if interface.is_force_relay() || conn.is_err() { if !relay_server.is_empty() { + let switch_code = interface.get_switch_code(); conn = Self::request_relay( peer_id, relay_server.to_owned(), @@ -724,6 +726,7 @@ impl Client { key, token, conn_type, + &switch_code, ) .await; if let Err(e) = conn { @@ -844,6 +847,7 @@ impl Client { key: &str, token: &str, conn_type: ConnType, + switch_code: &str, ) -> ResultType { let mut succeed = false; let mut uuid = "".to_owned(); @@ -855,8 +859,7 @@ impl Client { .await .with_context(|| "Failed to connect to rendezvous server")?; - if !key.is_empty() && !token.is_empty() { - // mainly for the security of token + if !key.is_empty() && (!token.is_empty() || !switch_code.is_empty()) { secure_tcp(&mut socket, key).await?; } @@ -877,6 +880,7 @@ impl Client { uuid: uuid.clone(), relay_server: relay_server.clone(), secure, + switch_code: switch_code.to_owned(), ..Default::default() }); socket.send(&msg_out).await?; @@ -3754,6 +3758,16 @@ pub trait Interface: Send + Clone + 'static + Sized { self.get_lch().read().unwrap().force_relay } + fn get_switch_code(&self) -> String { + match self.get_lch().read().unwrap().switch_uuid.clone() { + Some(u) if !u.is_empty() => { + use hbb_common::sodiumoxide::crypto::hash::sha256; + crate::encode64(sha256::hash(u.as_bytes()).0) + } + _ => String::new(), + } + } + fn swap_modifier_mouse(&self, _msg: &mut hbb_common::protos::message::MouseEvent) {} fn update_direct(&self, direct: Option) { diff --git a/src/hbbs_http/sync.rs b/src/hbbs_http/sync.rs index 1bb61943f..d78f90194 100644 --- a/src/hbbs_http/sync.rs +++ b/src/hbbs_http/sync.rs @@ -308,3 +308,135 @@ fn handle_config_options(config_options: HashMap) { pub fn is_pro() -> bool { PRO.lock().unwrap().clone() } + +// Fire-and-forget by design: the switch flow must not block on this POST. +// If the device clock is outside the server's accepted window, the server +// returns its current Unix time and this task re-signs and retries once. +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub fn register_switch_grant(switch_uuid: String) { + tokio::spawn(async move { + let api_server = crate::ui_interface::get_api_server(); + if api_server.is_empty() || crate::is_public(&api_server) { + return; + } + use hbb_common::sodiumoxide::crypto::{hash::sha256, sign}; + let switch_code = crate::encode64(sha256::hash(switch_uuid.as_bytes()).0); + let switch_code_verifier = switch_code_verifier(&switch_code); + let timestamp = (hbb_common::get_time() / 1000).to_string(); + let id = Config::get_id(); + let kp = Config::get_key_pair(); + let Some(sk) = sign::SecretKey::from_slice(&kp.0) else { + log::error!("Failed to register switch grant: no device key"); + return; + }; + let url = format!("{}/api/switch-grant", api_server); + let mut timestamp = timestamp; + for attempt in 0..2 { + let signature = sign::sign_detached( + &switch_grant_signed_msg(&id, &switch_code_verifier, ×tamp), + &sk, + ); + let body = json!({ + "id": &id, + "switch_code_verifier": &switch_code_verifier, + "timestamp": ×tamp, + "signature": crate::encode64(signature.to_bytes()), + }) + .to_string(); + let response = match crate::post_request(url.clone(), body, "").await { + Ok(response) => response, + Err(e) => { + log::error!("Failed to register switch grant: {}", e); + return; + } + }; + let response = match serde_json::from_str::(&response) { + Ok(response) => response, + Err(e) => { + log::error!("Failed to register switch grant: invalid response: {}", e); + return; + } + }; + match response.get("accepted").and_then(Value::as_bool) { + Some(true) => return, + Some(false) => {} + None => { + log::error!("Failed to register switch grant: missing accepted response"); + return; + } + } + let Some(server_time) = response["server_time"].as_i64() else { + log::error!("Failed to register switch grant: rejected by server"); + return; + }; + if attempt == 0 { + log::warn!("Switch grant timestamp rejected, retrying with server time"); + timestamp = server_time.to_string(); + } else { + log::error!("Failed to register switch grant after retrying with server time"); + } + } + }); +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn switch_code_verifier(switch_code: &str) -> String { + use hbb_common::sodiumoxide::crypto::hash::sha256; + + let prefix = b"switch-grant-verifier\0"; + let mut msg = Vec::with_capacity(prefix.len() + switch_code.len()); + msg.extend_from_slice(prefix); + msg.extend_from_slice(switch_code.as_bytes()); + crate::encode64(sha256::hash(&msg).0) +} + +#[cfg(feature = "flutter")] +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn switch_grant_signed_msg(id: &str, switch_code_verifier: &str, timestamp: &str) -> Vec { + let mut msg = + Vec::with_capacity(13 + id.len() + 1 + switch_code_verifier.len() + 1 + timestamp.len()); + msg.extend_from_slice(b"switch-grant\0"); + msg.extend_from_slice(id.as_bytes()); + msg.push(0); + msg.extend_from_slice(switch_code_verifier.as_bytes()); + msg.push(0); + msg.extend_from_slice(timestamp.as_bytes()); + msg +} + +#[cfg(all( + test, + feature = "flutter", + not(any(target_os = "android", target_os = "ios")) +))] +mod tests { + use super::{switch_code_verifier, switch_grant_signed_msg}; + + #[test] + fn test_switch_code_verifier_is_not_raw_switch_code() { + let switch_code = "code-abc"; + let verifier = switch_code_verifier(switch_code); + assert_ne!(verifier, switch_code); + assert_eq!(verifier, switch_code_verifier(switch_code)); + assert_eq!( + verifier, + "dMIn3uiPe77XodFB5IKi7PrKJ7l7+zVquNn0ObSaHQc=" + ); + } + + #[test] + fn test_switch_grant_signed_msg_layout() { + let expected: Vec = [ + &b"switch-grant\0"[..], + b"id1", + b"\0", + b"c1", + b"\0", + b"1700000000", + ] + .concat(); + assert_eq!(switch_grant_signed_msg("id1", "c1", "1700000000"), expected); + } +} diff --git a/src/ipc.rs b/src/ipc.rs index 4498ceb5f..188c2e467 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -978,6 +978,7 @@ async fn handle(data: Data, stream: &mut Connection) { Data::SwitchSidesRequest(id) => { let uuid = uuid::Uuid::new_v4(); crate::server::insert_switch_sides_uuid(id, uuid.clone()); + crate::hbbs_http::sync::register_switch_grant(uuid.to_string()); allow_err!( stream .send(&Data::SwitchSidesRequest(uuid.to_string()))