Implement signed API communication to improve security
build-windows / build-hello-agent-x64 (push) Successful in 4m52s
build-windows / sign-hello-agent-x64 (push) Successful in 5s
build-windows / validate-hello-agent-x64 (push) Successful in 6s

This commit is contained in:
2026-05-22 12:49:52 +02:00
parent fb00ac1101
commit 6807fe2bc0
9 changed files with 184 additions and 18 deletions
+29 -12
View File
@@ -1331,17 +1331,26 @@ async fn tcp_proxy_request(
fn parse_simple_header(header: &str) -> Vec<HeaderEntry> {
let mut entries = Vec::new();
let mut has_content_type = false;
// Accept a `\n`-separated list of `Name: Value` pairs. Single-pair input
// (the historical shape) still parses correctly because there's no
// newline to split on.
if !header.is_empty() {
let tmp: Vec<&str> = header.splitn(2, ": ").collect();
if tmp.len() == 2 {
if tmp[0].eq_ignore_ascii_case("Content-Type") {
has_content_type = true;
for line in header.split('\n') {
let line = line.trim();
if line.is_empty() {
continue;
}
let tmp: Vec<&str> = line.splitn(2, ": ").collect();
if tmp.len() == 2 {
if tmp[0].eq_ignore_ascii_case("Content-Type") {
has_content_type = true;
}
entries.push(HeaderEntry {
name: tmp[0].into(),
value: tmp[1].into(),
..Default::default()
});
}
entries.push(HeaderEntry {
name: tmp[0].into(),
value: tmp[1].into(),
..Default::default()
});
}
}
if !has_content_type {
@@ -1499,10 +1508,18 @@ async fn post_request_(
danger_accept_invalid_cert.unwrap_or(false),
)
.post(url);
// `header` is a `\n`-separated list of `Name: Value` pairs. Single-pair
// callers (the original shape) work unchanged. The signed-API path uses
// this to pass both `X-RD-Device-Id` and `X-RD-Signature` at once.
if !header.is_empty() {
let tmp: Vec<&str> = header.split(": ").collect();
if tmp.len() == 2 {
req = req.header(tmp[0], tmp[1]);
for line in header.split('\n') {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some((name, value)) = line.split_once(": ") {
req = req.header(name, value);
}
}
}
req = req.header("Content-Type", "application/json");
+1
View File
@@ -7,6 +7,7 @@ pub mod account;
pub mod downloader;
mod http_client;
pub mod record_upload;
pub mod sign;
pub mod sync;
pub use http_client::{
create_http_client_async, create_http_client_async_with_url, create_http_client_with_url,
+72
View File
@@ -0,0 +1,72 @@
//! Sign agent → server HTTP requests with the device's existing Ed25519
//! keypair (the same one rendezvous uses for `RegisterPk`). Producing the
//! header pair below for any signed call:
//!
//! X-RD-Device-Id: <id>
//! X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
//!
//! Server verifier: `/Users/sn0/Desktop/rustdesk-server/src/api/device_auth.rs`.
//!
//! Signed message format (must match the server byte-for-byte):
//! "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
use hbb_common::config::Config;
use hbb_common::sodiumoxide::crypto::{hash::sha256, sign};
/// Returns the two HTTP header lines joined by `\n`, ready to hand to
/// `post_request`'s extended `header` parser. Returns `None` if the local
/// keypair hasn't been generated yet (very early boot, before rendezvous) —
/// the caller should fall back to an unsigned request in that case; the
/// server's TOFU promote will still flip `managed=1` on the next signed
/// request and any unsigned attempts after that flip will be rejected.
pub fn build_signed_headers(method: &str, path: &str, body: &[u8]) -> Option<String> {
let (sk_bytes, _pk_bytes) = Config::get_key_pair();
if sk_bytes.is_empty() {
return None;
}
let sk = sign::SecretKey::from_slice(&sk_bytes)?;
let id = Config::get_id();
if id.is_empty() {
return None;
}
let ts = chrono::Utc::now().timestamp();
let body_sha = sha256::hash(body);
let ts_s = ts.to_string();
let mut msg = Vec::with_capacity(64 + method.len() + path.len());
msg.extend_from_slice(b"rd-api-v1\n");
msg.extend_from_slice(method.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(path.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(ts_s.as_bytes());
msg.push(b'\n');
msg.extend_from_slice(body_sha.as_ref());
let sig = sign::sign_detached(&msg, &sk);
let sig_b64 = crate::encode64(sig.as_ref());
Some(format!(
"X-RD-Device-Id: {}\nX-RD-Signature: v1.{}.{}",
id, ts, sig_b64
))
}
/// Extract the `/path` portion of a full URL. Used to derive the signed
/// path from sync.rs's `url` variable, which is always something like
/// `https://server.example.com:21114/api/heartbeat`. Falls back to "/" if
/// the URL doesn't parse — server-side verification will then fail, which
/// is the right outcome (a malformed agent URL is a misconfiguration the
/// operator should see).
pub fn path_from_url(url: &str) -> String {
// Manual parse to avoid pulling in the `url` crate for one call. The
// structure is always scheme://host[:port]/path[?query]. Strip scheme,
// then take from the first '/' onward, then drop any '?query'.
let no_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
let path_and_q = no_scheme.find('/').map(|i| &no_scheme[i..]).unwrap_or("/");
let path = path_and_q
.split_once('?')
.map(|(p, _)| p)
.unwrap_or(path_and_q);
path.to_string()
}
+18 -2
View File
@@ -258,7 +258,15 @@ async fn start_hbbs_sync_async() {
}
}
}
match crate::post_request(url.replace("heartbeat", "sysinfo"), v, "").await {
let sysinfo_url = url.replace("heartbeat", "sysinfo");
let sysinfo_path = crate::hbbs_http::sign::path_from_url(&sysinfo_url);
let sysinfo_headers = crate::hbbs_http::sign::build_signed_headers(
"POST",
&sysinfo_path,
v.as_bytes(),
)
.unwrap_or_default();
match crate::post_request(sysinfo_url, v, &sysinfo_headers).await {
Ok(x) => {
if x == "SYSINFO_UPDATED" {
info_uploaded = InfoUploaded::uploaded(url.clone(), id.clone(), sys_username, had_inventory);
@@ -292,7 +300,15 @@ async fn start_hbbs_sync_async() {
}
let modified_at = LocalConfig::get_option("strategy_timestamp").parse::<i64>().unwrap_or(0);
v["modified_at"] = json!(modified_at);
if let Ok(s) = crate::post_request(url.clone(), v.to_string(), "").await {
let hb_body = v.to_string();
let hb_path = crate::hbbs_http::sign::path_from_url(&url);
let hb_headers = crate::hbbs_http::sign::build_signed_headers(
"POST",
&hb_path,
hb_body.as_bytes(),
)
.unwrap_or_default();
if let Ok(s) = crate::post_request(url.clone(), hb_body, &hb_headers).await {
if let Ok(mut rsp) = serde_json::from_str::<HashMap::<&str, Value>>(&s) {
if rsp.remove("sysinfo").is_some() {
info_uploaded.uploaded = false;
+1 -1
View File
@@ -72,7 +72,7 @@ pub mod ui_cm_interface;
mod ui_interface;
mod ui_session_interface;
mod hbbs_http;
pub mod hbbs_http;
#[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))]
pub mod clipboard_file;