Files
rustdesk-server/docs/AGENT-API-AUTH.md
T
mike 26908c51bb
build / build-linux-amd64 (push) Successful in 1m54s
Implement signed API communication to improve security
2026-05-22 12:50:42 +02:00

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 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 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 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 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).