Files
theta-agent/PROTOCOL.md
T
wmantly d47d08ecab sec: fail-closed verification + server-issued enrollment (v1.4.0)
Implements protocol v1.2.0.

verifySignature() returned true when no public_key was configured,
logging "skipping signature verification". The SSO installer never wrote
a public_key, so a default install executed reboot, service_restart,
configure_ldap, arbitrary_bash and update_binary UNVERIFIED from anything
that could reach its socket. An agent that cannot verify now refuses.

Canonicalization also disagreed with the server. Go's encoding/json
escapes <, > and & by default; JSON.stringify does not. Any payload
containing them hashed differently on each side and failed verification
-- for arbitrary_bash that is most real scripts (`>` redirection, `&&`).
Now uses json.Encoder with SetEscapeHTML(false), trailing newline
trimmed.

The SSO now rejects tokens it did not issue. Handles its close codes
(4001/4002/4003/4004) and backs off 5 minutes on an enrollment failure
instead of retrying every 5s forever. The connect log no longer prints
the URL, which carried ?token=.

install.sh gains --public-key and warns when none is configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:43:26 -04:00

173 lines
6.9 KiB
Markdown

# Theta Agent Protocol Specification (v1.2.0)
This document defines the communication protocol between the `theta-agent` (Client) and the `sso-manager` (Server).
## 1. Connection Establishment
The agent establishes a persistent outbound WebSocket connection.
- **Endpoint**: `wss://<manager-url>/api/agent/ws`
- **Authentication**: The agent must provide its enrollment token as a query parameter:
- `wss://<manager-url>/api/agent/ws?token=<AGENT_TOKEN>`
### 1.1 Enrollment (changed in v1.2.0)
The token **must be issued by the server**. An administrator enrolls the agent in
the SSO (Directory → Agents, or `POST /api/agent/enroll`), which mints the token,
stores only its SHA-256, and displays the raw value once. That value goes into
`auth_token` in `agent.yml`.
Up to v1.1.0 the token was generated in the browser and never recorded
server-side, so the server accepted *any* string: anyone who could reach
`/api/agent/ws` could register as a node, publish discovery/telemetry, and
receive commands addressed to a token they guessed. Tokens the server did not
issue are now rejected.
The server accepts the WebSocket upgrade before authenticating, so an
authentication failure arrives as a **close frame**, not an HTTP status:
| Code | Meaning | Agent behaviour |
| :--- | :--- | :--- |
| `4001` | Token unknown, or not issued by this server | Back off (5 min); the credential will not fix itself |
| `4002` | Superseded — another connection authenticated as this agent | Normal reconnect |
| `4003` | Enrollment revoked or deleted by an administrator | Back off (5 min) |
| `4004` | Token rotated — `agent.yml` holds the superseded value | Back off (5 min); re-copy the token |
Revocation and rotation both drop any live socket immediately, so they take
effect without waiting for the agent to reconnect.
## 2. Message Format
All messages are exchanged as JSON objects following the `WSMessage` structure.
```json
{
"type": "string",
"payload": {
"key": "value"
}
}
```
## 3. Client $\rightarrow$ Server Messages
### 3.1 Discovery (One-time & On-Change)
Sent immediately upon connection and whenever the agent detects a change in its own network IP addresses.
- **Type**: `discovery`
- **Payload**:
- `hostname`: (string) System hostname.
- `ip_addresses`: (array of strings) List of all non-loopback IPv4 addresses.
- `os`: (string) OS and Platform.
- `kernel`: (string) Kernel version.
- `cpu`: (string) CPU model.
- `ram_total_gb`: (float) Total system RAM in GB.
- `disk_total_gb`: (float) Total root disk capacity in GB.
- `location`: (string) Physical location from config.
### 3.2 Telemetry (Periodic)
Sent every 30 seconds.
- **Type**: `telemetry`
- **Payload**:
- `cpu_usage_percent`: (float) Current CPU load.
- `ram_usage_percent`: (float) Current RAM utilization.
- `disk_usage_percent`: (float) Current root disk utilization.
- `zfs_health`: (string) Primary ZFS pool status (e.g., "ONLINE").
- `gpu_usage_percent`: (float) Average NVIDIA GPU utilization (-1.0 if unavailable).
- `timestamp`: (string) RFC3339 timestamp.
### 3.3 Heartbeat (Periodic)
Sent every 60 seconds to maintain the connection and signal health.
- **Type**: `heartbeat`
- **Payload**:
- `timestamp`: (string) RFC3339 timestamp.
### 3.4 Command Response
Sent in response to any command received from the server.
- **Type**: `response` (Implicitly handled as the answer to a command)
- **Payload**:
- `status`: (string) Either `"ok"` or `"error"`.
- `message`: (string) Human-readable result or error description.
- `output`: (string, optional) Stdout/stderr for execution commands.
---
## 4. Server $\rightarrow$ Client Messages
### 4.1 Standard Commands
These commands are executed if the corresponding capability is enabled in `agent.yml`.
| Command | Payload | Effect |
| :--- | :--- | :--- |
| `reload_config` | `{}` | Agent re-reads `/etc/theta42/agent.yml` from disk. |
| `fetch_logs` | `{}` | Agent returns the last 100 lines of `journalctl -u theta-agent`. |
### 4.2 High-Risk Commands (Signed)
These commands **require** an Ed25519 signature in the payload. The agent verifies the signature against the `public_key` in its config.
**Signature Format**:
- The `signature` field contains the base64-encoded Ed25519 signature of the payload (with the `signature` key removed).
| Command | Payload | Effect |
| :--- | :--- | :--- |
| `reboot` | `{ "signature": "..." }` | Triggers system reboot. |
| `service_restart` | `{ "service": "...", "signature": "..." }` | Restarts specific systemd service. |
| `configure_ldap` | `{ "config": "...", "signature": "..." }` | Writes `/etc/sssd/sssd.conf` and restarts `sssd`. |
| `arbitrary_bash` | `{ "script": "...", "signature": "..." }` | Executes raw bash script. |
| `update_binary` | `{ "url": "...", "sha256": "...", "signature": "..." }` | Downloads, verifies, and replaces the agent binary. |
## 5. Cryptographic Verification Process
To send a high-risk command:
1. Create the payload (e.g., `{"script": "uptime"}`).
2. Canonicalize the JSON (see 5.1).
3. Sign the canonical bytes using the private Ed25519 key.
4. Add the base64 signature to the payload: `{"script": "uptime", "signature": "..."}`.
5. Send as a `WSMessage`.
The agent performs the reverse process to verify authenticity before execution.
### 5.1 Canonical form
Both sides must produce **byte-identical** input to sign/verify:
- keys sorted alphabetically
- no insignificant whitespace
- the `signature` key omitted
- **no HTML escaping** — `<`, `>` and `&` are emitted literally
- no trailing newline
The escaping rule is load-bearing. Go's `encoding/json` escapes those three
characters by default while JavaScript's `JSON.stringify` does not, so a payload
containing any of them hashed differently on each side and verification failed.
For `arbitrary_bash` that is most real scripts (`>` redirection, `&&`). The Go
client uses `json.Encoder` with `SetEscapeHTML(false)`.
Example — payload `{"script": "echo a > b && c", "comment": "x&y"}` canonicalizes to:
```
{"comment":"x&y","script":"echo a > b && c"}
```
### 5.2 The server signing key (changed in v1.2.0)
The server's Ed25519 key pair is **persistent**, stored in OpenBao at
`secret/agent/signing-key`. `public_key` in `agent.yml` is the base64-encoded raw
32-byte public key, available from the enrollment response or
`GET /api/agent/nodes`.
Previously the pair was generated in memory at process start, so it changed on
every restart and no agent could meaningfully pin it. If the server cannot load
or persist a key it now **refuses to send high-risk commands** rather than
signing with a key no agent has seen.
### 5.3 Agent-side verification is fail-closed (changed in v1.2.0)
An agent with no `public_key` configured **rejects** every high-risk command.
Until v1.1.0 it logged "skipping signature verification" and executed them,
which meant an agent installed without a key would run `reboot`,
`configure_ldap` and `arbitrary_bash` from anything that reached its socket.