Files
theta-agent/PROTOCOL.md
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

6.9 KiB

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.

{
  "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.