Implement signed API communication to improve security
build / build-linux-amd64 (push) Successful in 1m54s

This commit is contained in:
2026-05-22 12:50:42 +02:00
parent 21b25bcc1b
commit 26908c51bb
11 changed files with 835 additions and 14 deletions
+228
View File
@@ -0,0 +1,228 @@
# Agent API authentication
Reference for the per-device signature gate on the agent-facing HTTP API
(`POST /api/heartbeat`, `POST /api/sysinfo`).
For the operator workflow — turning it on, the dashboard toggle, what
happens when a managed agent is uninstalled — see the matching section
in [CONFIGURATION.md](CONFIGURATION.md).
## Why this exists
Both endpoints originally accepted any caller who supplied an `id` and
`uuid` in the JSON body. Knowing those two values (plaintext on the
device, sent over the rendezvous wire) was enough to inject arbitrary
inventory or heartbeat state for that device — including BIOS serials,
BitLocker recovery keys, the active console user, network interfaces,
and connection lists.
The fix reuses the Ed25519 keypair that the agent **already** generates
on first run and registers with the rendezvous server via `RegisterPk`.
Every signed HTTP request is verified against the public key the
rendezvous handshake stored in `peer.pk`, so the trust root is the same
one the relay encryption already depends on. No new credential to
provision, no new secret to leak.
## Trust root
```
First run Rendezvous (port 21116, TCP/protobuf)
agent generates sk,pk ───── RegisterPk(id, pk) ─────► server stores
in hello-agent.toml peer.pk
Every subsequent request HTTP API (port 21114)
agent signs body server verifies sig
with sk ───── POST /api/heartbeat ─────► against peer.pk
───── POST /api/sysinfo ─────► (when peer.managed=1)
```
The same secret key signs both the rendezvous identity proof and the
HTTP-API payload — there's only one credential per device.
## Per-peer `managed` flag
The gate is per-device, controlled by the `peer.managed` column
(`INTEGER NOT NULL DEFAULT 0`, added by a soft `ALTER` at startup).
| `managed` | Server behaviour |
|-----------|----------------------------------------------------------------------------------|
| `0` | Legacy path. Signed requests are still verified if present, but absence is OK. |
| `1` | Signature required. Any unsigned request claiming this `id` returns 401. |
How the flag transitions:
- **TOFU promote (0 → 1).** The first request that arrives with a valid
signature flips `managed` to 1. Hello-agent signs from boot one, so
the first heartbeat after a hello-agent install transparently locks
the peer down. No admin action required.
- **Admin promote (0 → 1).** `PUT /api/peers/:id/managed {"managed":true}`
or the **Require signed API** action in the dashboard's Devices row
menu. Useful for pre-enrolling a peer record before the agent has
posted anything.
- **Admin downgrade (1 → 0).** Same endpoint, `{"managed":false}`, or
**Allow unsigned API** in the dashboard. Use when the managed agent
has been replaced with stock RustDesk on that device. The dashboard
toggle requires a confirm because the operation reopens the
spoofing surface.
- **Never auto-downgraded.** A failed signature on a `managed=1` peer
is a 401, full stop — there is no "fall back to unsigned" path.
- **Invalid sig on a `managed=0` peer is also 401**, never silently
treated as legacy. This prevents an attacker from probing for the
legacy path by deliberately sending a broken signature.
## Wire format
A signed agent request carries two headers in addition to the JSON body:
```
X-RD-Device-Id: <id>
X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
```
The signed message is the byte concatenation:
```
"rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(BODY)
```
Where:
- `METHOD` is the uppercase HTTP method (`POST`).
- `PATH` is the request path with leading slash and no query string
(`/api/heartbeat`, `/api/sysinfo`).
- `TS` is the same decimal Unix timestamp that appears in the header.
- `sha256(BODY)` is the raw 32-byte SHA-256 of the request body — *not*
hex-encoded, *not* base64-encoded. It is concatenated as binary.
- The signature is detached Ed25519 over that 32-byte-plus-prefix
message, base64-encoded with the standard alphabet and no
URL-safe substitutions.
The `v1.` prefix on the header value reserves a rotation point. The
server rejects any other version string.
### Why this shape
- **Domain separator (`rd-api-v1\n`)** prevents the same `sk` being
tricked into signing data interpretable as another protocol.
- **Method + path** stop a captured `POST /api/sysinfo` signature from
being replayed as some future `POST /api/disconnect`.
- **`sha256(body)`** lets us sign without holding the body twice in
memory on the verify side, and survives any future proxy
re-chunking.
- **Timestamp in both the header and the signed message** makes the
skew check trivial without re-parsing the signature value.
## Server-side verification
The extractor [`api::device_auth::verify`](../src/api/device_auth.rs)
runs before each agent handler:
1. **Parse headers.** Both `X-RD-Device-Id` and `X-RD-Signature` must
be present, or both absent. Mixed states are 401.
2. **Validate the signature envelope.** Version must be `v1`. The
timestamp must be within ±300 seconds of the server's clock. The
base64 decode must succeed.
3. **Replay-check.** A keyed-by-`(id, ts, sig-prefix)` LRU cache (size
16 384, sliding 600-second TTL, sweep-on-insert) rejects exact
replays inside the window. If the cache is full, we accept and skip
the cache — DoS-by-cache-exhaustion is uninteresting compared to
the rest of the surface.
4. **Look up `peer.pk` and `peer.managed`** in one query.
5. **Verify the detached Ed25519 signature** against the canonical
signed-message bytes (see *Wire format* above).
6. **TOFU promote.** A valid signature on a `managed=0` peer flips the
flag to 1 in the same request. The promote is best-effort — if the
DB write fails, the original request is still served, the next
heartbeat will retry.
7. **Bind the trusted id to the body.** After the handler parses JSON,
the body's `id` field must match the header's `X-RD-Device-Id`.
Mismatch is 401 — this is the gate that stops a signed request from
being repurposed to write to a different peer's row.
If no signature headers are present and the peer is `managed=0`, the
verifier returns `LegacyUnsigned`; the handler then calls
`enforce_managed_for_id(body.id)` after parsing the body, which still
rejects unsigned requests for any *other* peer that has since become
managed.
## Agent-side signing
The signer is one small module: [`vendor/rustdesk/src/hbbs_http/sign.rs`](https://example.invalid/sign.rs)
in the hello-agent vendor tree. It reads the existing
`Config::get_key_pair()` (returns `(sk, pk)` from `hello-agent.toml`)
and the existing `Config::get_id()`, builds the canonical message, and
calls `sodiumoxide::crypto::sign::sign_detached`. Returns the two
header lines joined by `\n`, ready for the multi-header parser in
`common.rs::post_request_`.
The agent always tries to sign. If the keypair hasn't been generated
yet (extremely early boot, before rendezvous has run), the signer
returns `None`, the request goes out unsigned, and:
- If `peer.managed=0`: server accepts it (legacy path).
- If `peer.managed=1`: server returns 401, the agent's next heartbeat
retries.
This is the only condition under which a hello-agent build sends an
unsigned request, and it self-resolves on the next sync tick.
## Operational gotchas
- **Stock RustDesk clients keep working** because they post unsigned
and their peer rows stay at `managed=0`. The first time you install
hello-agent on a device, the existing `peer.pk` row gets reused (the
agent re-generated a keypair iff `hello-agent.toml` was wiped). The
first signed heartbeat then promotes the row.
- **`hello-agent --uninstall` preserves the keypair.** A reinstall is
transparent — signing keeps working.
- **Wiping `hello-agent.toml` between sessions** does mean the next
boot generates a new keypair. The rendezvous server will treat that
as a key roll (`register_pk of … due to key not confirmed`) and
store the new `pk`. The signed HTTP API picks up the new key as soon
as that rendezvous step completes — usually within a few seconds.
See [the stale-key recovery note in hello-agent's README](https://example.invalid/README.md)
for the supporter-side symptoms of a key drift.
- **Clock skew over ±5 minutes** will reject signatures. If your
fleet shows scattered 401s on heartbeat, check NTP on the affected
hosts. The server side is the canonical clock.
- **Replay cache survives only inside a single hbbs process.** A
restart clears it. Combined with the 300-second skew window this
means a captured signature is replayable across a restart if and
only if both restarts happen inside that window — an acceptable
trade-off for keeping the cache in-memory.
- **One server, mixed fleet.** Stock clients and hello-agent clients
can target the same hbbs without any flag-level config. The gate is
per-peer.
## Failure modes & log lines
| Symptom | Likely cause |
|-------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------|
| Heartbeats from a known peer suddenly return 401 | Peer was just promoted (TOFU or admin) and the agent build doesn't sign yet → upgrade agent. |
| Heartbeats fail intermittently with 401 | Clock skew > 5 min, or NAT churn replaying a captured request inside the window. |
| `peer X TOFU-promoted to managed=1` in hbbs log | Normal — first valid signature from a previously-unsigned peer. |
| `admin <user> set peer X managed=<bool> via dashboard` | Normal — operator used the Devices toggle. |
| `peer_set_managed(X) failed: …` | DB write failed during TOFU promote. The request was still served; next request will retry. |
| Admin row shows **Unsigned** for a peer running hello-agent | Agent hasn't completed its first signed POST yet (keypair race), or it's running a build |
| | that pre-dates the signing patch — check `vendor/rustdesk/src/hbbs_http/sign.rs` is present. |
## File map
Server:
| Path | Purpose |
|-------------------------------------------|------------------------------------------------------------------|
| `src/api/device_auth.rs` | The verifier (extractor + replay cache + TOFU promote). |
| `src/api/heartbeat.rs`, `src/api/sysinfo.rs` | Wired to call `verify` then `enforce_managed_for_id`. |
| `src/api/peers.rs::set_managed` | `PUT /api/peers/:id/managed` admin endpoint. |
| `src/api/admin/pages/devices.rs::toggle_managed` | Dashboard action handler. |
| `src/database.rs::M2_SOFT_ALTERS` | `ALTER TABLE peer ADD COLUMN managed`. |
| `src/database.rs::peer_get_auth, peer_set_managed` | DB helpers (untyped `sqlx::query` so they survive the no-DB-migrated dev build). |
Agent (hello-agent vendor tree):
| Path | Purpose |
|------------------------------------------------------------|---------------------------------------------------------------|
| `vendor/rustdesk/src/hbbs_http/sign.rs` | The signer. |
| `vendor/rustdesk/src/hbbs_http/sync.rs` (call sites) | Heartbeat + sysinfo POSTs now sign. |
| `vendor/rustdesk/src/common.rs::post_request_, parse_simple_header` | Header parser now accepts `\n`-separated `Name: Value` pairs (backward-compatible). |
+77 -1
View File
@@ -303,6 +303,82 @@ keys and what each one does.
---
## Agent API signing (per-peer)
`POST /api/heartbeat` and `POST /api/sysinfo` are the two endpoints the
agent uses for its management loop. Stock RustDesk and managed builds
(hello-agent) both call them. Each peer row has a `managed` flag that
gates whether the server requires a per-request Ed25519 signature on
those two endpoints; everything else (`/api/peers`, `/api/ab/*`,
audit, recordings, OIDC, etc.) is unaffected.
| `peer.managed` | Heartbeat / sysinfo behaviour |
|----------------|----------------------------------------------------------------------------------------|
| `0` (default) | Unsigned posts accepted (stock-client compatible). Signed posts still verified. |
| `1` | Signature required; unsigned posts return 401. First valid sig auto-promoted to here. |
Default is `0` after the migration, so **stock RustDesk clients are not
affected by the rollout** — they keep posting unsigned, the server keeps
accepting. The first valid signature the server sees from a peer is the
TOFU promote: that peer's `managed` flips to `1` for good, and unsigned
requests claiming that `id` are rejected from then on.
The wire format and verification details live in
[AGENT-API-AUTH.md](AGENT-API-AUTH.md). What you need to know to operate:
### Dashboard
The Devices page has a per-row **Auth** column:
- *Signed* (emerald badge) — `peer.managed = 1`. The peer's heartbeat
and sysinfo posts must carry a valid signature; spoofed unsigned
requests are rejected.
- *Unsigned* (slate badge) — `peer.managed = 0`. Legacy path. Anyone
who knows the id+uuid can post inventory and heartbeats as this
device.
The row's action menu has two new entries (mutually exclusive based on
current state):
- **Require signed API** — flips `managed` to 1 (no confirm — it
strengthens security). Useful for pre-enrolling a peer record
before the agent has booted, or for force-locking a peer if you
want to fail fast when an agent is not signing yet.
- **Allow unsigned API** — flips `managed` to 0 (confirm dialog,
because this reopens the spoofing surface). Use when a managed
agent has been uninstalled and replaced with stock RustDesk on the
same hardware.
### API
`PUT /api/peers/:id/managed` with body `{"managed": true|false}`, gated
on the `is_admin` flag of the calling session, returns
`{"ok":true,"managed":<bool>}`. Same effect as the dashboard toggle —
the dashboard handler just calls this internally after reading the
current value to avoid stale-toggle races.
### Operational notes
- **Mixed fleets are fine.** Stock and hello-agent clients can target
the same hbbs. The gate is per-peer, not per-deployment.
- **Replacing hello-agent with stock RustDesk on a device.** The
device's `peer.managed` is stuck at 1; the stock client doesn't
sign and will start getting 401s. Either re-deploy a signing build
*or* flip the peer back to Unsigned in the dashboard.
- **TLS still recommended.** Signing protects against id+uuid spoof,
not against the unsigned-by-default endpoint surface elsewhere
(`/api/login`, `/api/record`, dashboard) — those still rely on
whatever TLS termination is in front of hbbs. See *TLS deployment*
earlier in this doc.
- **Clock skew tolerance is ±5 minutes.** If a host's clock drifts
past that, heartbeat starts failing 401. Keep NTP healthy on
managed peers; the server's clock is the canonical one.
- **The replay cache lives in-memory only.** A hbbs restart clears
it. The 5-minute timestamp window bounds the worst-case replay
exposure across restarts.
---
## Address books
- **Personal books** are owned per-user and managed from the user's desktop client. The dashboard surfaces them read-only.
@@ -326,7 +402,7 @@ If you set `--ab-legacy-mode=on`, `/api/ab/personal` 404s and clients fall back
| `/admin/oidc/providers` | none | JSON list of enabled providers, used by login.html |
| `/admin/login/oidc/:name` | none | Starts admin OIDC flow (302s to IdP) |
| `/admin/pages/users` | cookie + admin | Users page fragment (incl. inline edit-profile / password-reset / TOTP-disable per row) |
| `/admin/pages/devices` | cookie + admin | Devices (incl. delete) |
| `/admin/pages/devices` | cookie + admin | Devices (incl. delete, force-disconnect, force-sysinfo, toggle managed-auth — see [AGENT-API-AUTH.md](AGENT-API-AUTH.md)) |
| `/admin/pages/groups` | cookie + admin | Device groups |
| `/admin/pages/strategies` | cookie + admin | Strategy management |
| `/admin/pages/address-books` | cookie + admin | Personal + shared books |
+77
View File
@@ -801,6 +801,83 @@ pub fn t(lang: Lang, key: &str) -> &'static str {
"Dispozitivul a fost deja șters.",
"El dispositivo ya se eliminó.",
),
"devices.col_auth" => (
"Auth",
"Auth",
"Auth",
"Auth",
"Auth",
),
"devices.auth_signed" => (
"Signed",
"Signiert",
"Signé",
"Semnat",
"Firmado",
),
"devices.auth_signed_tooltip" => (
"Heartbeat and sysinfo posts must carry a valid Ed25519 signature. Unsigned requests for this peer are rejected.",
"Heartbeat- und Sysinfo-Posts müssen eine gültige Ed25519-Signatur enthalten. Unsignierte Anfragen für dieses Gerät werden abgelehnt.",
"Les requêtes heartbeat et sysinfo doivent porter une signature Ed25519 valide. Les requêtes non signées pour ce pair sont rejetées.",
"Cererile heartbeat și sysinfo trebuie să poarte o semnătură Ed25519 validă. Cererile nesemnate pentru acest peer sunt respinse.",
"Las solicitudes heartbeat y sysinfo deben llevar una firma Ed25519 válida. Las solicitudes sin firma para este par se rechazan.",
),
"devices.auth_unsigned" => (
"Unsigned",
"Unsigniert",
"Non signé",
"Nesemnat",
"Sin firma",
),
"devices.auth_unsigned_tooltip" => (
"Legacy / stock-client path. The server accepts unsigned heartbeat and sysinfo posts identifying as this peer — any caller knowing the id+uuid could inject inventory. The first valid signature flips this to Signed automatically.",
"Legacy-/Stock-Client-Pfad. Der Server akzeptiert unsignierte Heartbeat- und Sysinfo-Posts mit dieser Peer-Identität — jeder Aufrufer, der id+uuid kennt, kann Inventar einschleusen. Die erste gültige Signatur schaltet automatisch auf Signiert.",
"Chemin client hérité / standard. Le serveur accepte des requêtes heartbeat et sysinfo non signées identifiées comme ce pair — tout appelant connaissant id+uuid peut injecter de l'inventaire. La première signature valide bascule automatiquement sur Signé.",
"Calea client legacy / standard. Serverul acceptă cereri heartbeat și sysinfo nesemnate care se identifică drept acest peer — orice apelant care cunoaște id+uuid poate injecta inventar. Prima semnătură validă comută automat la Semnat.",
"Ruta cliente heredada / estándar. El servidor acepta solicitudes heartbeat y sysinfo sin firmar identificadas como este par — cualquier llamador que conozca id+uuid puede inyectar inventario. La primera firma válida cambia automáticamente a Firmado.",
),
"devices.mark_managed" => (
"Require signed API",
"Signierte API erzwingen",
"Exiger l'API signée",
"Cere API semnat",
"Requerir API firmada",
),
"devices.mark_unsigned" => (
"Allow unsigned API",
"Unsignierte API erlauben",
"Autoriser l'API non signée",
"Permite API nesemnat",
"Permitir API sin firma",
),
"devices.confirm_managed_off" => (
"Downgrade {0} to the unsigned API path? Anyone who knows the id+uuid will again be able to post inventory and heartbeats as this device.",
"{0} auf den unsignierten API-Pfad herabstufen? Jeder, der id+uuid kennt, kann dann wieder Inventar und Heartbeats als dieses Gerät posten.",
"Rétrograder {0} vers le chemin API non signé ? Toute personne connaissant id+uuid pourra de nouveau publier de l'inventaire et des heartbeats en tant que cet appareil.",
"Retrogradați {0} la calea API nesemnat? Oricine cunoaște id+uuid va putea din nou să publice inventar și heartbeat-uri ca acest dispozitiv.",
"¿Degradar {0} a la ruta API sin firma? Cualquiera que conozca id+uuid podrá volver a publicar inventario y heartbeats como este dispositivo.",
),
"devices.managed_now_on" => (
"{0} now requires signed API requests.",
"{0} erfordert nun signierte API-Anfragen.",
"{0} exige désormais des requêtes API signées.",
"{0} cere acum cereri API semnate.",
"{0} ahora requiere solicitudes API firmadas.",
),
"devices.managed_now_off" => (
"{0} now accepts unsigned API requests.",
"{0} akzeptiert nun unsignierte API-Anfragen.",
"{0} accepte désormais des requêtes API non signées.",
"{0} acceptă acum cereri API nesemnate.",
"{0} ahora acepta solicitudes API sin firma.",
),
"devices.managed_no_peer" => (
"Peer {0} not found in the rendezvous identity table — cannot set managed flag. The agent must complete a rendezvous handshake first.",
"Peer {0} nicht in der Rendezvous-Identitätstabelle gefunden — Managed-Flag kann nicht gesetzt werden. Der Agent muss zuerst einen Rendezvous-Handshake abschließen.",
"Pair {0} introuvable dans la table d'identité rendezvous — impossible de définir l'indicateur managed. L'agent doit d'abord effectuer un handshake rendezvous.",
"Peer-ul {0} nu a fost găsit în tabelul de identitate rendezvous — nu se poate seta indicatorul managed. Agentul trebuie să finalizeze mai întâi un handshake rendezvous.",
"Par {0} no encontrado en la tabla de identidad rendezvous — no se puede establecer el indicador managed. El agente debe completar primero un apretón de manos rendezvous.",
),
"devices.back" => (
"← Back to devices",
"← Zurück zu Geräten",
+4
View File
@@ -113,6 +113,10 @@ pub fn build(state: Arc<crate::api::state::AppState>) -> Option<Router> {
"/admin/pages/devices/:peer_id/delete",
post(pages::devices::delete),
)
.route(
"/admin/pages/devices/:peer_id/toggle-managed",
post(pages::devices::toggle_managed),
)
// Groups
.route("/admin/pages/groups/create", post(pages::groups::create))
.route("/admin/pages/groups/:id/delete", post(pages::groups::delete))
+109 -1
View File
@@ -109,6 +109,56 @@ pub async fn delete(
notice_then_table(&state, lang, if ok { "ok" } else { "error" }, &msg).await
}
/// Flip `peer.managed` between 0 and 1. Same effect as calling the JSON
/// API `PUT /api/peers/:id/managed`, but rendered as an HTMX action so the
/// table refreshes in place. The handler reads the current value, flips
/// it, and writes back — this avoids a stale-toggle race where the row
/// the admin clicked on showed a stale state (e.g. TOFU just promoted it
/// in the background) and a "set to N" command would no-op silently.
pub async fn toggle_managed(
Extension(state): Extension<Arc<AppState>>,
admin: AuthedUser,
lang: Lang,
Path(peer_id): Path<String>,
) -> Result<Html<String>, ApiError> {
require_admin(&admin)?;
let row = state
.db
.peer_get_auth(&peer_id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let (_pk, was_managed) = match row {
Some(r) => r,
None => {
return notice_then_table(
&state,
lang,
"error",
&tf1(lang, "devices.managed_no_peer", &peer_id),
)
.await;
}
};
let new_value = !was_managed;
state
.db
.peer_set_managed(&peer_id, new_value)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
hbb_common::log::info!(
"admin {} set peer {} managed={} via dashboard",
admin.name,
peer_id,
new_value
);
let key = if new_value {
"devices.managed_now_on"
} else {
"devices.managed_now_off"
};
notice_then_table(&state, lang, "ok", &tf1(lang, key, &peer_id)).await
}
/// Per-device detail page: hardware / OS inventory reported by hello-agent
/// alongside the standard sysinfo (CPU/RAM/OS/hostname). Replaces the
/// devices list in `#devices-region` via HTMX; a "Back to devices" button
@@ -189,6 +239,7 @@ async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiEr
<th class="text-left font-medium px-3 py-2">{c_ver}</th>
<th class="text-left font-medium px-3 py-2">{c_last}</th>
<th class="text-left font-medium px-3 py-2">{c_conns}</th>
<th class="text-left font-medium px-3 py-2">{c_auth}</th>
<th class="text-right font-medium px-3 py-2 w-1">{c_actions}</th>
</tr>
</thead>
@@ -202,12 +253,13 @@ async fn render_table(state: &Arc<AppState>, lang: Lang) -> Result<String, ApiEr
c_ver = t(lang, "devices.col_version"),
c_last = t(lang, "devices.col_last_heartbeat"),
c_conns = t(lang, "devices.col_conns"),
c_auth = t(lang, "devices.col_auth"),
c_actions = t(lang, "common.actions"),
);
if devices.is_empty() {
let _ = write!(
s,
r##"<tr><td colspan="10" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
r##"<tr><td colspan="11" class="px-3 py-4 text-slate-500 text-center text-xs">{}</td></tr>"##,
t(lang, "devices.no_devices"),
);
}
@@ -332,6 +384,57 @@ fn render_device_row(
dot = dot_class,
id = html_escape(&d.id),
);
// Auth badge: `Signed` (emerald) when peer.managed=1 — heartbeat /
// sysinfo posts must carry a valid Ed25519 signature; `—` (slate) when
// managed=0 and the device still posts unsigned bodies. The tooltip
// gives the operator the one-line explanation so they know what
// flipping the flag will do.
let auth_cell = if d.managed {
format!(
r##"<td class="px-3 py-2 whitespace-nowrap">
<span class="inline-flex items-center gap-1 rounded border border-emerald-700/50 bg-emerald-900/30 px-2 py-0.5 text-xs text-emerald-300" title="{tt}">{label}</span>
</td>"##,
tt = html_escape(t(lang, "devices.auth_signed_tooltip")),
label = t(lang, "devices.auth_signed"),
)
} else {
format!(
r##"<td class="px-3 py-2 whitespace-nowrap">
<span class="inline-flex items-center gap-1 rounded border border-slate-700 bg-slate-800/40 px-2 py-0.5 text-xs text-slate-400" title="{tt}">{label}</span>
</td>"##,
tt = html_escape(t(lang, "devices.auth_unsigned_tooltip")),
label = t(lang, "devices.auth_unsigned"),
)
};
// Auth toggle: the menu entry's label flips based on current state,
// and only the off→on transition needs no confirm (it strengthens
// security). on→off removes the signature requirement and reintroduces
// the spoofing surface, so we require a confirm on that direction.
let toggle_managed_item = if d.managed {
format!(
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/toggle-managed"
hx-target="#devices-region" hx-swap="innerHTML"
hx-confirm="{confirm}">
{label}
</button>"##,
id = html_escape(&d.id),
confirm = html_escape(&tf1(lang, "devices.confirm_managed_off", &d.id)),
label = t(lang, "devices.mark_unsigned"),
)
} else {
format!(
r##"<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/toggle-managed"
hx-target="#devices-region" hx-swap="innerHTML">
{label}
</button>"##,
id = html_escape(&d.id),
label = t(lang, "devices.mark_managed"),
)
};
let _ = write!(
s,
r##"<tr class="hover:bg-slate-800/40">
@@ -344,6 +447,7 @@ fn render_device_row(
<td class="px-3 py-2 text-slate-400 whitespace-nowrap">{ver}</td>
<td class="px-3 py-2 text-slate-500 text-xs">{last}</td>
<td class="px-3 py-2 text-slate-400">{n}</td>
{auth_cell}
<td class="px-3 py-2">
<details class="text-right relative">
<summary class="cursor-pointer list-none text-xs text-slate-400 hover:text-slate-200 select-none">···</summary>
@@ -358,6 +462,8 @@ fn render_device_row(
{details}
</button>
<hr class="border-slate-700 my-1" />
{toggle_managed_item}
<hr class="border-slate-700 my-1" />
<button class="w-full text-left px-2 py-1 text-xs hover:bg-slate-800 rounded"
hx-post="/admin/pages/devices/{id}/disconnect"
hx-target="#devices-region" hx-swap="innerHTML"
@@ -394,6 +500,8 @@ fn render_device_row(
ver = html_escape(&version_label),
last = html_escape(&d.last_heartbeat_at),
n = conn_count,
auth_cell = auth_cell,
toggle_managed_item = toggle_managed_item,
connect_web = t(lang, "devices.connect_web"),
details = t(lang, "devices.details"),
confirm_disc = html_escape(&tf1(lang, "devices.confirm_disconnect", &d.id)),
+184
View File
@@ -0,0 +1,184 @@
//! Ed25519-signature gate for the agent-facing HTTP API
//! (`/api/heartbeat`, `/api/sysinfo`).
//!
//! Trust root: the device's Ed25519 public key is already written into
//! `peer.pk` during the rendezvous `RegisterPk` handshake (TCP/protobuf,
//! port 21116). That handshake proves possession of the matching private key
//! to the rendezvous server — so any later HTTP request signed by the same
//! key is provably from the same device.
//!
//! Cutover: per-peer. `peer.managed = 0` (default) keeps stock-client
//! behaviour — no signature required. `managed = 1` requires a valid sig on
//! every request. The flag flips from 0→1 on the first valid signature we
//! observe (TOFU) or via the admin endpoint. It never flips back from a
//! request — only an admin can downgrade.
//!
//! Wire format (both headers required on signed requests):
//! X-RD-Device-Id: <id>
//! X-RD-Signature: v1.<unix_ts>.<base64(ed25519_sig)>
//! where the signed message is:
//! "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::http::HeaderMap;
use lazy_static::lazy_static;
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::Arc;
const SIG_VERSION: &str = "v1";
const HEADER_ID: &str = "x-rd-device-id";
const HEADER_SIG: &str = "x-rd-signature";
const SKEW_TOLERANCE_SECS: i64 = 300;
const REPLAY_WINDOW_SECS: i64 = 600;
const REPLAY_CACHE_MAX: usize = 16_384;
/// Outcome of running the gate. The handler uses this to decide which `id`
/// to trust as the device identity:
/// - `Verified` → caller is cryptographically that device.
/// - `LegacyUnsigned` → managed=0 peer that sent no sig headers; the
/// handler may proceed but the body `id` is trusted only weakly
/// (same risk as today). The handler still calls `get_peer` to confirm
/// the id is known.
pub enum AuthOutcome {
Verified { id: String },
LegacyUnsigned,
}
lazy_static! {
/// Replay cache. Key: "<id>|<ts>|<sig_first32>". Value: expiry unix ts.
/// Small enough that the sweep-on-insert cost is negligible.
static ref REPLAY: Mutex<HashMap<String, i64>> = Mutex::new(HashMap::new());
}
pub async fn verify(
state: &Arc<AppState>,
method: &str,
path: &str,
headers: &HeaderMap,
body: &[u8],
) -> Result<AuthOutcome, ApiError> {
let sig_hdr = headers.get(HEADER_SIG).and_then(|v| v.to_str().ok());
let id_hdr = headers.get(HEADER_ID).and_then(|v| v.to_str().ok());
// No signature headers at all → legacy path. Even then we still need to
// check that the peer (if it claims an id in the body) isn't marked
// `managed=1`. The handler doesn't know the body id yet, so we defer
// the managed-check to a second call (`enforce_managed_for_id`) after
// the handler has parsed the body. Returning LegacyUnsigned here just
// means "no sig present, you must call enforce_managed_for_id next".
let (sig_hdr, id_hdr) = match (sig_hdr, id_hdr) {
(Some(s), Some(i)) if !s.is_empty() && !i.is_empty() => (s, i),
(None, None) => return Ok(AuthOutcome::LegacyUnsigned),
// Partial headers: someone tried to sign but messed up the request.
// Don't fall through to legacy — treat as an outright failure so we
// don't silently downgrade a misconfigured agent.
_ => return Err(ApiError::Unauthorized),
};
// Parse "v1.<ts>.<b64>".
let mut parts = sig_hdr.splitn(3, '.');
let ver = parts.next().unwrap_or("");
let ts_s = parts.next().unwrap_or("");
let sig_b64 = parts.next().unwrap_or("");
if ver != SIG_VERSION || ts_s.is_empty() || sig_b64.is_empty() {
return Err(ApiError::Unauthorized);
}
let ts: i64 = ts_s.parse().map_err(|_| ApiError::Unauthorized)?;
let now = chrono::Utc::now().timestamp();
if (now - ts).abs() > SKEW_TOLERANCE_SECS {
return Err(ApiError::Unauthorized);
}
let sig_bytes = base64::decode(sig_b64).map_err(|_| ApiError::Unauthorized)?;
// Replay check before the expensive crypto. The (id, ts, sig-prefix)
// tuple is unique per request from a non-broken agent.
let replay_key = {
let prefix: String = sig_b64.chars().take(32).collect();
format!("{}|{}|{}", id_hdr, ts, prefix)
};
{
let mut cache = REPLAY.lock().unwrap();
cache.retain(|_, exp| *exp > now);
if cache.contains_key(&replay_key) {
return Err(ApiError::Unauthorized);
}
if cache.len() < REPLAY_CACHE_MAX {
cache.insert(replay_key, now + REPLAY_WINDOW_SECS);
}
// If the cache is full we accept (no DoS via cache exhaustion). The
// 5-min skew window already bounds replay risk.
}
// Look up the peer's pk and managed flag in one query.
let (pk_bytes, managed) = state
.db
.peer_get_auth(id_hdr)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or(ApiError::Unauthorized)?;
if pk_bytes.is_empty() {
// No PK registered — rendezvous hasn't completed. Can't verify.
return Err(ApiError::Unauthorized);
}
// Build the canonical signed message:
// "rd-api-v1\n" || METHOD || "\n" || PATH || "\n" || TS || "\n" || sha256(body)
let body_sha = sodiumoxide::crypto::hash::sha256::hash(body);
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 pk = sodiumoxide::crypto::sign::PublicKey::from_slice(&pk_bytes)
.ok_or(ApiError::Unauthorized)?;
let sig = sodiumoxide::crypto::sign::Signature::from_bytes(&sig_bytes)
.map_err(|_| ApiError::Unauthorized)?;
if !sodiumoxide::crypto::sign::verify_detached(&sig, &msg, &pk) {
return Err(ApiError::Unauthorized);
}
// TOFU promote: first valid sig flips managed=0 → 1. After this, the
// same device can no longer fall back to the legacy unsigned path.
if !managed {
if let Err(e) = state.db.peer_set_managed(id_hdr, true).await {
hbb_common::log::warn!("peer_set_managed({}) failed: {}", id_hdr, e);
// Don't fail the request — the sig was valid, the promote is
// best-effort. Next request will retry the promote.
} else {
hbb_common::log::info!("peer {} TOFU-promoted to managed=1", id_hdr);
}
}
Ok(AuthOutcome::Verified {
id: id_hdr.to_string(),
})
}
/// Called by handlers AFTER they've parsed the body and extracted the
/// device id. Only meaningful when `verify` returned `LegacyUnsigned`.
/// Enforces: if the peer is currently managed=1, an unsigned request for
/// that id must be rejected.
pub async fn enforce_managed_for_id(
state: &Arc<AppState>,
id: &str,
) -> Result<(), ApiError> {
if id.is_empty() {
return Ok(());
}
let row = state
.db
.peer_get_auth(id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
match row {
Some((_, true)) => Err(ApiError::Unauthorized),
_ => Ok(()),
}
}
+22 -2
View File
@@ -5,12 +5,18 @@
//! - `disconnect: [conn_id, ...]` — tell the client to drop those sessions,
//! - `modified_at` + `strategy` — push a config-options merge.
//!
//! Auth: none (the client identifies the device by `(id, uuid)` body fields).
//! Auth: signed agents (peer.managed=1) must carry `X-RD-Device-Id` +
//! `X-RD-Signature` headers — see `device_auth::verify`. Stock clients
//! (peer.managed=0) keep posting unsigned bodies; the first valid sig we
//! see flips the peer to managed=1 (TOFU).
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use crate::api::strategy;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::http::HeaderMap;
use axum::Json;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@@ -45,11 +51,25 @@ pub struct HeartbeatResp {
pub async fn heartbeat(
Extension(state): Extension<Arc<AppState>>,
Json(body): Json<HeartbeatBody>,
headers: HeaderMap,
raw: Bytes,
) -> Result<Json<HeartbeatResp>, ApiError> {
let outcome = device_auth::verify(&state, "POST", "/api/heartbeat", &headers, &raw).await?;
let body: HeartbeatBody = serde_json::from_slice(&raw)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
if body.id.is_empty() || body.uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid required".into()));
}
match outcome {
AuthOutcome::Verified { id: signed_id } => {
if body.id != signed_id {
return Err(ApiError::Unauthorized);
}
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, &body.id).await?;
}
}
let conns_json = serde_json::to_string(&body.conns.unwrap_or_default())
.unwrap_or_else(|_| "[]".into());
+2
View File
@@ -7,6 +7,7 @@ pub mod ab;
pub mod admin;
pub mod audit;
pub mod auth;
pub mod device_auth;
pub mod devices_cli;
pub mod email;
pub mod error;
@@ -80,6 +81,7 @@ pub fn router(state: Arc<AppState>) -> Router {
)
.route("/api/users", get(users::list))
.route("/api/peers", get(peers::list))
.route("/api/peers/:id/managed", put(peers::set_managed))
// M3: audit
.route("/api/audit/conn", post(audit::conn::conn))
.route("/api/audit/file", post(audit::file::file))
+46 -2
View File
@@ -7,9 +7,9 @@ use crate::api::error::ApiError;
use crate::api::middleware::AuthedUser;
use crate::api::pagination::{Page, PageQuery};
use crate::api::state::AppState;
use axum::extract::{Extension, Query};
use axum::extract::{Extension, Path, Query};
use axum::Json;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
@@ -64,3 +64,47 @@ pub async fn list(
.collect();
Ok(Json(Page { total, data }))
}
#[derive(Debug, Deserialize)]
pub struct SetManagedBody {
pub managed: bool,
}
/// `PUT /api/peers/:id/managed` — admin-only toggle for the signed-API gate.
/// Setting `managed=true` is also done TOFU-style by the sig-verify helper
/// on the first valid signature, so this endpoint is mainly useful for:
/// - Pre-enrolling a peer before its agent boots.
/// - Downgrading a peer back to the unsigned path after a managed agent
/// is uninstalled or replaced with stock RustDesk.
pub async fn set_managed(
Extension(state): Extension<Arc<AppState>>,
user: AuthedUser,
Path(id): Path<String>,
Json(body): Json<SetManagedBody>,
) -> Result<Json<Value>, ApiError> {
if !user.is_admin {
return Err(ApiError::Forbidden("admin only".into()));
}
// Confirm the peer exists before flipping, so an admin typo doesn't
// silently create a row-not-found situation.
let row = state
.db
.peer_get_auth(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if row.is_none() {
return Err(ApiError::NotFound);
}
state
.db
.peer_set_managed(&id, body.managed)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
hbb_common::log::info!(
"admin {} set peer {} managed={}",
user.name,
id,
body.managed
);
Ok(Json(json!({ "ok": true, "managed": body.managed })))
}
+33 -6
View File
@@ -1,7 +1,9 @@
use crate::api::device_auth::{self, AuthOutcome};
use crate::api::error::ApiError;
use crate::api::state::AppState;
use axum::body::Bytes;
use axum::extract::Extension;
use axum::Json;
use axum::http::HeaderMap;
use serde_json::Value;
use std::sync::Arc;
@@ -17,9 +19,18 @@ pub async fn sysinfo_ver(Extension(state): Extension<Arc<AppState>>) -> String {
/// `==` comparison on these — do not wrap in JSON.
pub async fn sysinfo(
Extension(state): Extension<Arc<AppState>>,
Json(payload): Json<Value>,
headers: HeaderMap,
body: Bytes,
) -> Result<String, ApiError> {
let id = payload
// Step 1: signature gate. Verified → trust the id from the signed
// header. LegacyUnsigned → fall through but enforce that the body id
// isn't a managed peer (would be downgrade attempt).
let outcome = device_auth::verify(&state, "POST", "/api/sysinfo", &headers, &body).await?;
// Step 2: parse body.
let payload: Value = serde_json::from_slice(&body)
.map_err(|_| ApiError::BadRequest("invalid json".into()))?;
let body_id = payload
.get("id")
.and_then(|v| v.as_str())
.unwrap_or_default();
@@ -27,15 +38,31 @@ pub async fn sysinfo(
.get("uuid")
.and_then(|v| v.as_str())
.unwrap_or_default();
if id.is_empty() || uuid.is_empty() {
if body_id.is_empty() || uuid.is_empty() {
return Err(ApiError::BadRequest("id and uuid required".into()));
}
// Step 3: bind the trusted identity to the body. For signed requests,
// the body id must match the header id — otherwise the agent is trying
// to write inventory for someone else.
let id = match outcome {
AuthOutcome::Verified { id: signed_id } => {
if body_id != signed_id {
return Err(ApiError::Unauthorized);
}
signed_id
}
AuthOutcome::LegacyUnsigned => {
device_auth::enforce_managed_for_id(&state, body_id).await?;
body_id.to_string()
}
};
// Tie sysinfo storage to a real rendezvous-registered peer. Without this
// gate, any caller could populate device_sysinfo for arbitrary IDs.
let peer = state
.db
.get_peer(id)
.get_peer(&id)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if peer.is_none() {
@@ -45,7 +72,7 @@ pub async fn sysinfo(
let version = parse_version_number(payload.get("version").and_then(|v| v.as_str()));
state
.db
.sysinfo_upsert(id, uuid, &payload.to_string(), &state.cfg.sysinfo_ver, version)
.sysinfo_upsert(&id, uuid, &payload.to_string(), &state.cfg.sysinfo_ver, version)
.await?;
Ok("SYSINFO_UPDATED".to_string())
}
+53 -2
View File
@@ -224,6 +224,12 @@ pub struct DashboardDeviceRow {
/// is logged in.
pub unattended_password: String,
pub unattended_password_set_at: String,
/// `peer.managed` from the rendezvous identity table. `true` means the
/// agent-facing HTTP API will reject unsigned heartbeats/sysinfo for
/// this device — see `api::device_auth`. Joined LEFT so devices that
/// reported sysinfo before completing rendezvous render as `false`
/// rather than disappearing from the list.
pub managed: bool,
}
#[derive(Debug, Clone, Default)]
@@ -631,9 +637,11 @@ impl Database {
ds.payload AS payload, \
ds.conns AS conns, \
COALESCE(ds.unattended_password, '') AS u_pw, \
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at \
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at, \
COALESCE(p.managed, 0) AS managed \
FROM device_sysinfo ds \
LEFT JOIN users u ON u.id = ds.user_id \
LEFT JOIN peer p ON p.id = ds.id \
ORDER BY ds.last_heartbeat_at DESC LIMIT ? OFFSET ?",
)
.bind(limit)
@@ -651,6 +659,7 @@ impl Database {
conns_json: r.try_get("conns").unwrap_or_default(),
unattended_password: r.try_get("u_pw").unwrap_or_default(),
unattended_password_set_at: r.try_get("u_pw_at").unwrap_or_default(),
managed: r.try_get::<i64, _>("managed").unwrap_or(0) != 0,
})
.collect();
Ok((total, data))
@@ -672,9 +681,11 @@ impl Database {
ds.payload AS payload, \
ds.conns AS conns, \
COALESCE(ds.unattended_password, '') AS u_pw, \
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at \
COALESCE(ds.unattended_password_set_at, '') AS u_pw_at, \
COALESCE(p.managed, 0) AS managed \
FROM device_sysinfo ds \
LEFT JOIN users u ON u.id = ds.user_id \
LEFT JOIN peer p ON p.id = ds.id \
WHERE ds.id = ? LIMIT 1",
)
.bind(peer_id)
@@ -689,6 +700,7 @@ impl Database {
conns_json: r.try_get("conns").unwrap_or_default(),
unattended_password: r.try_get("u_pw").unwrap_or_default(),
unattended_password_set_at: r.try_get("u_pw_at").unwrap_or_default(),
managed: r.try_get::<i64, _>("managed").unwrap_or(0) != 0,
}))
}
@@ -3047,6 +3059,38 @@ impl Database {
.await?;
Ok(())
}
/// Read just the auth-relevant peer columns for the signed-API gate.
/// Untyped `sqlx::query` (not `query!`) because the `managed` column is
/// added by a soft ALTER at runtime — the compile-time check against the
/// dev DB would fail on fresh checkouts before the migration has run.
/// Returns `(pk, managed)`. `None` means no row for this id.
pub async fn peer_get_auth(&self, id: &str) -> ResultType<Option<(Vec<u8>, bool)>> {
use sqlx::Row;
let row = sqlx::query("select pk, managed from peer where id = ?")
.bind(id)
.fetch_optional(self.pool.get().await?.deref_mut())
.await?;
Ok(row.map(|r| {
let pk: Vec<u8> = r.try_get("pk").unwrap_or_default();
let managed: i64 = r.try_get("managed").unwrap_or(0);
(pk, managed != 0)
}))
}
/// Flip the `managed` flag. Called from two places:
/// 1. The TOFU promote inside the sig-verify helper on first valid sig.
/// 2. The admin endpoint PUT /api/peers/:id/managed.
/// Idempotent — calling with the current value is a no-op update.
pub async fn peer_set_managed(&self, id: &str, managed: bool) -> ResultType<()> {
let v: i64 = if managed { 1 } else { 0 };
sqlx::query("update peer set managed = ? where id = ?")
.bind(v)
.bind(id)
.execute(self.pool.get().await?.deref_mut())
.await?;
Ok(())
}
}
/// Timing-safe equality for hash comparisons. Slightly paranoid given the
@@ -3268,6 +3312,13 @@ const M2_SOFT_ALTERS: &[&str] = &[
// displays it for the operator to read, and it rotates each boot.
"ALTER TABLE device_sysinfo ADD COLUMN unattended_password TEXT",
"ALTER TABLE device_sysinfo ADD COLUMN unattended_password_set_at DATETIME",
// Per-device flag that gates Ed25519 signature enforcement on the agent
// HTTP API (heartbeat, sysinfo). 0 = legacy/stock-rustdesk path, 1 =
// managed peer, signatures required. Set TOFU-style on the first valid
// signature we observe from the peer, or explicitly via the admin
// endpoint PUT /api/peers/:id/managed. Never written from request body
// — only the server flips it.
"ALTER TABLE peer ADD COLUMN managed INTEGER NOT NULL DEFAULT 0",
];
const M3_SCHEMA: &[&str] = &[