From c1d54d7580d958160402d5ba3aaf6ac4441e0d9e Mon Sep 17 00:00:00 2001 From: Mike Mueller Date: Fri, 22 May 2026 12:49:52 +0200 Subject: [PATCH] Implement signed API communication to improve security --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 35 +++++++++++++ vendor/rustdesk/src/common.rs | 41 ++++++++++----- vendor/rustdesk/src/hbbs_http.rs | 1 + vendor/rustdesk/src/hbbs_http/sign.rs | 72 +++++++++++++++++++++++++++ vendor/rustdesk/src/hbbs_http/sync.rs | 20 +++++++- 7 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 vendor/rustdesk/src/hbbs_http/sign.rs diff --git a/Cargo.lock b/Cargo.lock index d735ef2..a3833a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3197,7 +3197,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hello-agent" -version = "0.1.3" +version = "0.1.4" dependencies = [ "anyhow", "env_logger 0.10.2", diff --git a/Cargo.toml b/Cargo.toml index ba41507..44cab27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hello-agent" -version = "0.1.3" +version = "0.1.4" edition = "2021" rust-version = "1.75" description = "Headless RustDesk-protocol-compatible support agent for Windows" diff --git a/README.md b/README.md index a7a886c..44917f7 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,11 @@ hello-agent.exe --server # user session, SYSTEM token │ └─ stamps `agent_name` / `agent_version` / `inventory` │ into each /api/sysinfo payload (re-uploads when the │ inventory collector below transitions empty → ready) + │ └─ signs every request with the device's Ed25519 sk + │ (same key rendezvous registers via RegisterPk). + │ The server's first valid sig flips that peer to + │ `managed=1` and unsigned posts get 401 from then on. + │ Spec: rustdesk-server/docs/AGENT-API-AUTH.md ├── inventory::collect_inventory (background thread) │ └─ PowerShell + WMI + wlanapi + ipify → `INVENTORY` global │ consumed by hbbs_http::sync above; one-shot, no retry @@ -148,6 +153,36 @@ inventory — keep it in sync when adding new patches. [`src/main.rs`](vendor/rustdesk/src/main.rs) (`.author(...)`). Cosmetic, but they show through in the Windows EXE metadata and in-app error dialogs. +6. **Signed agent API** — every `POST /api/heartbeat` and + `POST /api/sysinfo` carries two extra headers (`X-RD-Device-Id`, + `X-RD-Signature: v1..`) so the server can + bind the request to the device's existing rendezvous keypair + instead of trusting the `id` + `uuid` body fields. Without this + patch, anyone who knows a peer's id and uuid can inject inventory + and heartbeats for it. Three patch sites in the vendor tree: + * New file + [`src/hbbs_http/sign.rs`](vendor/rustdesk/src/hbbs_http/sign.rs) — + the signer (`build_signed_headers`, `path_from_url`). Reads + `Config::get_key_pair()` and `Config::get_id()`; uses the + re-exported `hbb_common::sodiumoxide`. + * [`src/hbbs_http.rs`](vendor/rustdesk/src/hbbs_http.rs) — adds + `pub mod sign;` next to the existing module declarations. + * [`src/common.rs`](vendor/rustdesk/src/common.rs) — the + `post_request_` and `parse_simple_header` header-string parsers + now accept a `\n`-separated list of `Name: Value` lines so we + can pass both signing headers in one call. Old single-pair + callers parse identically — there's no newline to split on. + * [`src/hbbs_http/sync.rs`](vendor/rustdesk/src/hbbs_http/sync.rs) + call sites (the sysinfo POST around the sysinfo-version + comparison block, and the heartbeat POST a few dozen lines + later) — both build a signed-headers string via + `crate::hbbs_http::sign::build_signed_headers("POST", + &path_from_url(&url), body.as_bytes()).unwrap_or_default()` + and pass it to `post_request` instead of `""`. + + Matching server side: see rustdesk-server's + [`docs/AGENT-API-AUTH.md`](https://github.com/cstudio-ch/rustdesk-server/blob/pro-features/docs/AGENT-API-AUTH.md) + for the wire format and verification flow. ## Build diff --git a/vendor/rustdesk/src/common.rs b/vendor/rustdesk/src/common.rs index dc2dff2..1403a12 100644 --- a/vendor/rustdesk/src/common.rs +++ b/vendor/rustdesk/src/common.rs @@ -1331,17 +1331,26 @@ async fn tcp_proxy_request( fn parse_simple_header(header: &str) -> Vec { 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"); diff --git a/vendor/rustdesk/src/hbbs_http.rs b/vendor/rustdesk/src/hbbs_http.rs index 9e45386..80b3856 100644 --- a/vendor/rustdesk/src/hbbs_http.rs +++ b/vendor/rustdesk/src/hbbs_http.rs @@ -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, diff --git a/vendor/rustdesk/src/hbbs_http/sign.rs b/vendor/rustdesk/src/hbbs_http/sign.rs new file mode 100644 index 0000000..5b3e729 --- /dev/null +++ b/vendor/rustdesk/src/hbbs_http/sign.rs @@ -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: +//! X-RD-Signature: v1.. +//! +//! 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 { + 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() +} diff --git a/vendor/rustdesk/src/hbbs_http/sync.rs b/vendor/rustdesk/src/hbbs_http/sync.rs index 8243a76..ab4c467 100644 --- a/vendor/rustdesk/src/hbbs_http/sync.rs +++ b/vendor/rustdesk/src/hbbs_http/sync.rs @@ -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::().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::>(&s) { if rsp.remove("sysinfo").is_some() { info_uploaded.uploaded = false;