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>
This commit is contained in:
2026-08-05 18:43:26 -04:00
parent 51750d01ec
commit d47d08ecab
9 changed files with 406 additions and 26 deletions
+71 -4
View File
@@ -1,4 +1,4 @@
# Theta Agent Protocol Specification (v1.1.0)
# Theta Agent Protocol Specification (v1.2.0)
This document defines the communication protocol between the `theta-agent` (Client) and the `sso-manager` (Server).
@@ -7,8 +7,34 @@ This document defines the communication protocol between the `theta-agent` (Clie
The agent establishes a persistent outbound WebSocket connection.
- **Endpoint**: `wss://<manager-url>/api/agent/ws`
- **Authentication**: The agent must provide a unique host token as a query parameter:
- `wss://<manager-url>/api/agent/ws?token=<HOST_TOKEN>`
- **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
@@ -97,9 +123,50 @@ These commands **require** an Ed25519 signature in the payload. The agent verifi
To send a high-risk command:
1. Create the payload (e.g., `{"script": "uptime"}`).
2. Canonicalize the JSON (sort keys alphabetically, remove whitespace).
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.