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
+22
View File
@@ -5,6 +5,28 @@ All notable changes to the `theta-agent` daemon will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v1.4.0] - 2026-08-05
Implements **Protocol v1.2.0**. See `PROTOCOL.md` §1.1, §5.15.3.
### Security
- **Fail-closed signature verification.** `verifySignature` returned `true` when no `public_key` was configured, logging "skipping signature verification". Combined with an installer that never wrote a `public_key`, that meant a default install would execute `reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash` and `update_binary` **unverified** from anything that could reach its socket. An agent that cannot verify a high-risk command now refuses it.
- **The token must be issued by the server.** The SSO now rejects tokens it did not mint (close code `4001`). Agents carrying a token generated by the old browser-side installer will not connect until re-enrolled.
### Fixed
- **Canonicalization mismatch broke signatures for most real scripts.** Go's `encoding/json` escapes `<`, `>` and `&` by default; the server's `JSON.stringify` does not. Any payload containing them — an `arbitrary_bash` script using `>` redirection or `&&`, which is most of them — hashed differently on each side and failed verification. `canonicalize()` now uses `json.Encoder` with `SetEscapeHTML(false)` and trims the encoder's trailing newline.
- **Auth failures no longer hot-loop.** A rejected credential was retried every 5 seconds forever, flooding the SSO and its audit log. Close codes `4001`/`4003`/`4004` now back off for 5 minutes and log what to do about it.
- **The auth token no longer appears in logs.** The connect line logged the full URL, including `?token=...`. It now logs only host + path, and the token is URL-escaped.
### Added
- Close-code handling for the SSO's enrollment signals: `4001` unauthorized, `4002` superseded, `4003` revoked, `4004` token rotated.
- `install.sh --public-key <base64>`, written into the generated `agent.yml`. The installer warns loudly when no public key is configured, since such an agent can report telemetry but will refuse every high-risk command.
- Tests: fail-closed with no key, wrong key, payload tampered after signing, shell metacharacters (`>`, `&&`, `<`) round-tripping, and canonical-form equality with the server. `interop_check_test.go` verifies a signature produced by the live SSO against the agent's own verifier (skipped unless `INTEROP_FIXTURE` is set).
### Changed
- Existing tests no longer rely on verification being skipped; high-risk cases now sign with a real test key.
- `agent.yml.example`, `README.md`, `INSTALL.md`: enrollment is a prerequisite, and `public_key` is the base64 of the **raw 32-byte** Ed25519 key — not a PEM body. The previous documented example (`MCowBQYDK2VwAyEA...`) decodes to 44 bytes and would have been rejected.
## [v1.3.0] - 2026-08-04
### Fixed