12 KiB
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.
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
managedto 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=1peer is a 401, full stop — there is no "fall back to unsigned" path. - Invalid sig on a
managed=0peer 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:
METHODis the uppercase HTTP method (POST).PATHis the request path with leading slash and no query string (/api/heartbeat,/api/sysinfo).TSis 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 sameskbeing tricked into signing data interpretable as another protocol. - Method + path stop a captured
POST /api/sysinfosignature from being replayed as some futurePOST /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
runs before each agent handler:
- Parse headers. Both
X-RD-Device-IdandX-RD-Signaturemust be present, or both absent. Mixed states are 401. - 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. - 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. - Look up
peer.pkandpeer.managedin one query. - Verify the detached Ed25519 signature against the canonical signed-message bytes (see Wire format above).
- TOFU promote. A valid signature on a
managed=0peer 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. - Bind the trusted id to the body. After the handler parses JSON,
the body's
idfield must match the header'sX-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
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 existingpeer.pkrow gets reused (the agent re-generated a keypair iffhello-agent.tomlwas wiped). The first signed heartbeat then promotes the row. hello-agent --uninstallpreserves the keypair. A reinstall is transparent — signing keeps working.- Wiping
hello-agent.tomlbetween 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 newpk. 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 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). |