diff --git a/libs/hbb_common b/libs/hbb_common index c8cbb6be2..8db11c314 160000 --- a/libs/hbb_common +++ b/libs/hbb_common @@ -1 +1 @@ -Subproject commit c8cbb6be283e9215da87625016fe8838dda76c02 +Subproject commit 8db11c314baa7b07c8ae529f4ce7b6f0180616a3 diff --git a/src/client.rs b/src/client.rs index c18312605..f576a9443 100644 --- a/src/client.rs +++ b/src/client.rs @@ -98,6 +98,7 @@ pub const MILLI1: Duration = Duration::from_millis(1); pub const SEC30: Duration = Duration::from_secs(30); pub const VIDEO_QUEUE_SIZE: usize = 120; const MAX_DECODE_FAIL_COUNTER: usize = 3; +const EASY_ACCESS_GRANT_ID_LEN: usize = 32; #[cfg(target_os = "linux")] pub const LOGIN_MSG_DESKTOP_NOT_INITED: &str = "Desktop env is not inited"; @@ -2840,8 +2841,14 @@ impl LoginConfigHandler { } }; let grant_id = match crate::decode64(&response.grant_id) { - Ok(grant_id) if !grant_id.is_empty() => grant_id, - Ok(_) => return None, + Ok(grant_id) if grant_id.len() == EASY_ACCESS_GRANT_ID_LEN => grant_id, + Ok(grant_id) => { + log::warn!( + "Easy access grant id has invalid length: {}", + grant_id.len() + ); + return None; + } Err(err) => { log::warn!("Easy access grant id invalid: {}", err); return None; diff --git a/src/common.rs b/src/common.rs index 69e3ec304..9d0915105 100644 --- a/src/common.rs +++ b/src/common.rs @@ -1124,6 +1124,29 @@ pub fn get_audit_server(api: String, custom: String, typ: String) -> String { format!("{}/api/audit/{}", url, typ) } +fn url_requires_strict_transport(url: &reqwest::Url) -> bool { + url.path().starts_with("/api/easy_access/") +} + +fn validate_strict_transport_url(url: &str) -> ResultType<()> { + let Ok(parsed) = reqwest::Url::parse(url) else { + return Ok(()); + }; + if !url_requires_strict_transport(&parsed) { + return Ok(()); + } + if !cfg!(debug_assertions) && parsed.scheme() != "https" { + bail!("strict HTTPS URL must use https://"); + } + Ok(()) +} + +fn url_allows_danger_accept_invalid_cert(url: &str) -> bool { + reqwest::Url::parse(url) + .map(|url| !url_requires_strict_transport(&url)) + .unwrap_or(true) +} + /// Check if we should use raw TCP proxy for API calls. /// Returns true if USE_RAW_TCP_FOR_API builtin option is "Y", WebSocket is off, /// and the target URL belongs to the configured non-public API host. @@ -1146,9 +1169,16 @@ fn should_use_tcp_proxy_for_api_url(url: &str, api_url: &str) -> bool { return false; } - let target_host = url::Url::parse(url) - .ok() - .and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase())); + let Ok(target_url) = url::Url::parse(url) else { + return false; + }; + if url_requires_strict_transport(&target_url) { + return false; + } + + let target_host = target_url + .host_str() + .map(|host| host.to_ascii_lowercase()); let api_host = url::Url::parse(api_url) .ok() .and_then(|parsed| parsed.host_str().map(|host| host.to_ascii_lowercase())); @@ -1328,10 +1358,16 @@ fn parse_json_header_entries(header: &str) -> ResultType> { /// Returns (status_code, body_text). Separating status so the wrapper can decide on fallback. async fn post_request_http(url: &str, body: &str, header: &str) -> ResultType<(u16, String)> { + validate_strict_transport_url(&url)?; let proxy_conf = Config::get_socks(); let tls_url = get_url_for_tls(url, &proxy_conf); let tls_type = get_cached_tls_type(tls_url); - let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url); + let allow_danger_accept_invalid_cert = url_allows_danger_accept_invalid_cert(&url); + let danger_accept_invalid_cert = if allow_danger_accept_invalid_cert { + get_cached_tls_accept_invalid_cert(tls_url) + } else { + Some(false) + }; let response = post_request_( url, tls_url, @@ -1340,6 +1376,7 @@ async fn post_request_http(url: &str, body: &str, header: &str) -> ResultType<(u tls_type, danger_accept_invalid_cert, danger_accept_invalid_cert, + allow_danger_accept_invalid_cert, ) .await?; let status = response.status().as_u16(); @@ -1415,6 +1452,7 @@ async fn post_request_( tls_type: Option, danger_accept_invalid_cert: Option, original_danger_accept_invalid_cert: Option, + allow_danger_accept_invalid_cert: bool, ) -> ResultType { let mut req = create_http_client_async( tls_type.unwrap_or(TlsType::Rustls), @@ -1454,7 +1492,10 @@ async fn post_request_( Ok(resp) } Err(e) => { - if (tls_type.is_none() || danger_accept_invalid_cert.is_none()) && e.is_request() { + if allow_danger_accept_invalid_cert + && (tls_type.is_none() || danger_accept_invalid_cert.is_none()) + && e.is_request() + { if danger_accept_invalid_cert.is_none() { log::warn!( "HTTP request failed: {:?}, try again, danger accept invalid cert", @@ -1468,6 +1509,7 @@ async fn post_request_( tls_type, Some(true), original_danger_accept_invalid_cert, + allow_danger_accept_invalid_cert, ) .await } else { @@ -1480,6 +1522,7 @@ async fn post_request_( Some(TlsType::NativeTls), original_danger_accept_invalid_cert, original_danger_accept_invalid_cert, + allow_danger_accept_invalid_cert, ) .await } @@ -1506,6 +1549,7 @@ async fn get_http_response_async( tls_type: Option, danger_accept_invalid_cert: Option, original_danger_accept_invalid_cert: Option, + allow_danger_accept_invalid_cert: bool, ) -> ResultType { let http_client = create_http_client_async( tls_type.unwrap_or(TlsType::Rustls), @@ -1561,7 +1605,10 @@ async fn get_http_response_async( Ok(resp) } Err(e) => { - if (tls_type.is_none() || danger_accept_invalid_cert.is_none()) && e.is_request() { + if allow_danger_accept_invalid_cert + && (tls_type.is_none() || danger_accept_invalid_cert.is_none()) + && e.is_request() + { if danger_accept_invalid_cert.is_none() { log::warn!( "HTTP request failed: {:?}, try again, danger accept invalid cert", @@ -1576,6 +1623,7 @@ async fn get_http_response_async( tls_type, Some(true), original_danger_accept_invalid_cert, + allow_danger_accept_invalid_cert, ) .await } else { @@ -1589,6 +1637,7 @@ async fn get_http_response_async( Some(TlsType::NativeTls), original_danger_accept_invalid_cert, original_danger_accept_invalid_cert, + allow_danger_accept_invalid_cert, ) .await } @@ -1608,10 +1657,16 @@ async fn http_request_http( body: Option, header: &str, ) -> ResultType<(u16, String)> { + validate_strict_transport_url(&url)?; let proxy_conf = Config::get_socks(); let tls_url = get_url_for_tls(url, &proxy_conf); let tls_type = get_cached_tls_type(tls_url); - let danger_accept_invalid_cert = get_cached_tls_accept_invalid_cert(tls_url); + let allow_danger_accept_invalid_cert = url_allows_danger_accept_invalid_cert(&url); + let danger_accept_invalid_cert = if allow_danger_accept_invalid_cert { + get_cached_tls_accept_invalid_cert(tls_url) + } else { + Some(false) + }; let response = get_http_response_async( url, tls_url, @@ -1621,6 +1676,7 @@ async fn http_request_http( tls_type, danger_accept_invalid_cert, danger_accept_invalid_cert, + allow_danger_accept_invalid_cert, ) .await?; // Serialize response headers @@ -2812,6 +2868,14 @@ mod tests { "not a url", "https://admin.example.com" )); + assert!(!should_use_tcp_proxy_for_api_url( + "https://admin.example.com/api/easy_access/grant", + "https://admin.example.com" + )); + assert!(!should_use_tcp_proxy_for_api_url( + "http://admin.example.com/api/easy_access/grant", + "http://admin.example.com" + )); } #[test] diff --git a/src/flutter_ffi.rs b/src/flutter_ffi.rs index 1278fac72..f695b09a0 100644 --- a/src/flutter_ffi.rs +++ b/src/flutter_ffi.rs @@ -1345,6 +1345,9 @@ pub fn main_get_uuid() -> String { } pub fn main_get_easy_access_device_auth() -> String { + const EASY_ACCESS_MANAGERS_DEVICE_AUTH_DOMAIN: &str = "easy_access.managers"; + const EASY_ACCESS_MANAGERS_DEVICE_AUTH_VERSION: u32 = 1; + let id = get_id(); let uuid = hbb_common::get_uuid(); if id.is_empty() || uuid.is_empty() { @@ -1368,14 +1371,20 @@ pub fn main_get_easy_access_device_auth() -> String { let Ok(device_box_sk) = ed25519::to_curve25519_sk(&device_sign_sk) else { return String::new(); }; + let plaintext = serde_json::json!({ + "domain": EASY_ACCESS_MANAGERS_DEVICE_AUTH_DOMAIN, + "v": EASY_ACCESS_MANAGERS_DEVICE_AUTH_VERSION, + "uuid": BASE64_STANDARD.encode(&uuid), + }) + .to_string(); let nonce = box_::gen_nonce(); - let ciphertext = box_::seal(&uuid, &nonce, &server_box_pk, &device_box_sk); + let ciphertext = box_::seal(plaintext.as_bytes(), &nonce, &server_box_pk, &device_box_sk); let mut proof = Vec::with_capacity(box_::NONCEBYTES + ciphertext.len()); proof.extend_from_slice(nonce.as_ref()); proof.extend_from_slice(&ciphertext); serde_json::json!({ "id": id, - "ciphertext": BASE64_STANDARD.encode(proof), + "proof": BASE64_STANDARD.encode(proof), }) .to_string() } diff --git a/src/server/connection.rs b/src/server/connection.rs index b03647077..4cb99f1d2 100644 --- a/src/server/connection.rs +++ b/src/server/connection.rs @@ -361,6 +361,7 @@ pub struct Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] terminal_user_token: Option, terminal_generic_service: Option>, + easy_access_verified: bool, } impl ConnInner { @@ -405,6 +406,8 @@ const SEND_TIMEOUT_VIDEO: u64 = 12_000; const SEND_TIMEOUT_OTHER: u64 = SEND_TIMEOUT_VIDEO * 10; const SESSION_TIMEOUT: Duration = Duration::from_secs(30); const EASY_ACCESS_CHALLENGE_LEN: usize = 32; +const EASY_ACCESS_MANAGER_ID_LEN: usize = 16; +const EASY_ACCESS_GRANT_ID_LEN: usize = 32; impl Connection { pub async fn start( @@ -548,6 +551,7 @@ impl Connection { #[cfg(not(any(target_os = "android", target_os = "ios")))] terminal_user_token: None, terminal_generic_service: None, + easy_access_verified: false, }; let addr = hbb_common::try_into_v4(addr); if !conn.on_open(addr).await { @@ -2294,7 +2298,14 @@ impl Connection { Self::is_permission_enabled_locally(enable_prefix_option) } - async fn verify_easy_access(&self) -> bool { + async fn verify_easy_access(&mut self) -> bool { + // If already verified in this connection, return cached result. + // This prevents re-consuming the one-time grant when client retries + // LoginRequest (e.g., desktop not ready on first attempt). + if self.easy_access_verified { + return true; + } + const EASY_ACCESS_GRANT_VERSION: u32 = 1; fn open_easy_access_device_bound_proof( @@ -2303,8 +2314,7 @@ impl Connection { server_pk: &sign::PublicKey, target_sk: &[u8], ) -> Option> { - const EASY_ACCESS_DEVICE_PROOF_NONCE_DOMAIN: &[u8] = - b"easy-access-device-proof-nonce/v1"; + const EASY_ACCESS_DEVICE_PROOF_NONCE_DOMAIN: &[u8] = b"EAD1"; fn derive_easy_access_device_bound_nonce( server_approval_signature: &[u8], @@ -2328,7 +2338,7 @@ impl Connection { device_nonce: &[u8], approved: bool, ) -> Vec { - const EASY_ACCESS_CONSUME_DECISION_DOMAIN: &[u8] = b"easy-access-consume-decision/v1"; + const EASY_ACCESS_CONSUME_DECISION_DOMAIN: &[u8] = b"EAC1"; let mut bytes = Vec::new(); bytes.extend_from_slice(EASY_ACCESS_CONSUME_DECISION_DOMAIN); @@ -2340,6 +2350,32 @@ impl Connection { bytes } + fn serialize_easy_access_manager_approval_signature_payload( + target_binding_bytes: &[u8], + ) -> Vec { + const EASY_ACCESS_MANAGER_APPROVAL_SIGNATURE_DOMAIN: &[u8] = b"EAM1"; + + let mut bytes = Vec::with_capacity( + EASY_ACCESS_MANAGER_APPROVAL_SIGNATURE_DOMAIN.len() + target_binding_bytes.len(), + ); + bytes.extend_from_slice(EASY_ACCESS_MANAGER_APPROVAL_SIGNATURE_DOMAIN); + bytes.extend_from_slice(target_binding_bytes); + bytes + } + + fn serialize_easy_access_server_approval_signature_payload( + manager_approval_bytes: &[u8], + ) -> Vec { + const EASY_ACCESS_SERVER_APPROVAL_SIGNATURE_DOMAIN: &[u8] = b"EAS1"; + + let mut bytes = Vec::with_capacity( + EASY_ACCESS_SERVER_APPROVAL_SIGNATURE_DOMAIN.len() + manager_approval_bytes.len(), + ); + bytes.extend_from_slice(EASY_ACCESS_SERVER_APPROVAL_SIGNATURE_DOMAIN); + bytes.extend_from_slice(manager_approval_bytes); + bytes + } + #[derive(Serialize)] struct EasyAccessGrantConsumeDeviceAuthPayload { uuid: String, @@ -2350,7 +2386,7 @@ impl Connection { #[derive(Serialize)] struct EasyAccessGrantConsumeRequest { id: String, - ciphertext: String, + proof: String, } #[derive(Deserialize)] @@ -2407,7 +2443,7 @@ impl Connection { proof.extend_from_slice(&ciphertext); let body = match serde_json::to_string(&EasyAccessGrantConsumeRequest { id: Config::get_id(), - ciphertext: crate::encode64(proof), + proof: crate::encode64(proof), }) { Ok(body) => body, Err(err) => { @@ -2503,12 +2539,12 @@ impl Connection { } let grant_id = self.lr.easy_access_grant_id.clone(); - if grant_id.is_empty() { + if grant_id.len() != EASY_ACCESS_GRANT_ID_LEN { return false; }; let target_challenge = self.hash.easy_access_challenge.clone(); - if target_challenge.is_empty() { - log::warn!("Easy access target challenge missing"); + if target_challenge.len() != EASY_ACCESS_CHALLENGE_LEN { + log::warn!("Easy access target challenge invalid"); return false; } let target_uuid = hbb_common::get_uuid(); @@ -2517,12 +2553,12 @@ impl Connection { return false; } let (target_sk, target_pk) = Config::get_key_pair(); - if target_sk.is_empty() { - log::warn!("Easy access target private key missing"); + if target_sk.len() != sign::SECRETKEYBYTES { + log::warn!("Easy access target private key invalid"); return false; } - if target_pk.is_empty() { - log::warn!("Easy access target public key missing"); + if target_pk.len() != sign::PUBLICKEYBYTES { + log::warn!("Easy access target public key invalid"); return false; } let server_key = crate::common::get_key(true).await; @@ -2537,6 +2573,7 @@ impl Connection { consume_easy_access_grant(&grant_id, target_uuid.as_slice(), &target_sk, &server_pk) .await else { + log::warn!("Easy access consume_easy_access_grant returned None"); return false; }; if ticket.version != EASY_ACCESS_GRANT_VERSION { @@ -2573,7 +2610,7 @@ impl Connection { }; if !sign::verify_detached( &server_approval_signature, - &manager_approval_bytes, + &serialize_easy_access_server_approval_signature_payload(&manager_approval_bytes), &server_pk, ) { log::warn!("Easy access server approval signature verify failed"); @@ -2599,8 +2636,8 @@ impl Connection { log::warn!("Easy access manager approval signature missing"); return false; } - if manager_approval.manager_id.is_empty() { - log::warn!("Easy access manager id missing"); + if manager_approval.manager_id.len() != EASY_ACCESS_MANAGER_ID_LEN { + log::warn!("Easy access manager id invalid"); return false; } if target_binding.challenge.as_ref() != target_challenge.as_ref() { @@ -2615,8 +2652,8 @@ impl Connection { log::warn!("Easy access target public key mismatch"); return false; } - if target_binding.grant_id.is_empty() { - log::warn!("Easy access grant id missing"); + if target_binding.grant_id.len() != EASY_ACCESS_GRANT_ID_LEN { + log::warn!("Easy access grant id invalid"); return false; } let manager_pk = match sign::PublicKey::from_slice(&manager_approval.manager_pk) { @@ -2643,7 +2680,7 @@ impl Connection { }; if !sign::verify_detached( &manager_approval_signature, - &target_binding_bytes, + &serialize_easy_access_manager_approval_signature_payload(&target_binding_bytes), &manager_pk, ) { log::warn!("Easy access manager approval signature verify failed"); @@ -2654,6 +2691,7 @@ impl Connection { return false; } log::info!("Easy access grant verified"); + self.easy_access_verified = true; true }