Require strict transport for Easy Access API calls, validate grant field lengths, and verify domain-separated server and manager signatures.
Signed-off-by: 21pages <sunboeasy@gmail.com>
This commit is contained in:
parent
04554d9129
commit
3d0305a797
5 changed files with 149 additions and 31 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit c8cbb6be283e9215da87625016fe8838dda76c02
|
||||
Subproject commit 8db11c314baa7b07c8ae529f4ce7b6f0180616a3
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<Vec<HeaderEntry>> {
|
|||
|
||||
/// 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<TlsType>,
|
||||
danger_accept_invalid_cert: Option<bool>,
|
||||
original_danger_accept_invalid_cert: Option<bool>,
|
||||
allow_danger_accept_invalid_cert: bool,
|
||||
) -> ResultType<reqwest::Response> {
|
||||
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<TlsType>,
|
||||
danger_accept_invalid_cert: Option<bool>,
|
||||
original_danger_accept_invalid_cert: Option<bool>,
|
||||
allow_danger_accept_invalid_cert: bool,
|
||||
) -> ResultType<reqwest::Response> {
|
||||
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<String>,
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -361,6 +361,7 @@ pub struct Connection {
|
|||
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||
terminal_user_token: Option<TerminalUserToken>,
|
||||
terminal_generic_service: Option<Box<GenericService>>,
|
||||
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<Vec<u8>> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue