# 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: X-RD-Signature: v1.. ``` 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 set peer X managed= 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). |