Files
iris_x_hermes/docs/09-pairing-security.md
T
ARIA 2349a95dd4 HTTP fallback leg (docs/19): e2e scenario 13 + docs
- e2e.py: scenario 13 (http fallback) — drives a full turn over the
  HTTP leg (health + POST /v1/frame + SSE /v1/events, no WS) and
  asserts the user echo lands on the SSE stream in < 1.5 s.
- ws_probe.py --http: prints '== user echo in X.XXs' (the docs/19
  sendable-in-fallback timing assertion) alongside the existing
  health/POST/SSE output; same assertion flags as the WS leg.
- docs: 19 status flipped to implemented; 09-pairing-security §9.4
  cross-reference (second door, same lock: token + device allowlist,
  64 KiB cap, rate limit, optional TLS, unauthenticated /v1/health);
  13-testing manual scenario 15 + automated pointers.
2026-08-22 14:34:14 +02:00

125 lines
8.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 09 — Pairing, Auth & Security
## 9.1 Threat model
- **Trusted domain:** a personal agent on the user's own machine; one owner, a
few of their own devices (phone + desktop).
- **Primary risks:** (a) an unauthorized device connecting to the WS and
reading/driving the agent; (b) eavesdropping on the WS in transit; (c) token
leakage in logs; (d) arbitrary file read via media pull.
- **Not addressing (v1):** multi-tenant isolation, adversarial multi-user abuse,
E2E encryption.
## 9.2 Pairing flow
1. **Generate a token.** `hermes gateway setup` (our `interactive_setup`) either
uses an existing `ANDROID_TOKEN` or generates a fresh high-entropy token
(e.g. 32 bytes → 64 hex chars) and stores it in `.env`.
2. **Present to the app.** Two options:
- **QR code:** the setup prints a QR encoding
`iris://pair?host=<lan-ip>&port=8790&token=<token>` (or a WSS URL). The
phone scans it with the app's camera (or a system scanner) → pre-fills
settings.
- **Manual:** user types the server URL + token in the app's Connect screen.
3. **App connects.** First WS frame is `hello {token, device_id, device_name,
caps, fcm_token?}`.
4. **Server verifies.** Constant-time compare of `token` vs `ANDROID_TOKEN`
(`hmac.compare_digest`). Optionally check `device_id` against
`ANDROID_ALLOWED_USERS` (if set) or `ANDROID_ALLOW_ALL_USERS`.
5. **On success:** register the device in `devices.db`, send `hello.ack`.
**On failure:** send `error {code:"auth"}` and close.
`device_id` is a stable, app-generated UUID (persisted in the app's
secure storage). It identifies the device for routing + push, **not** as a
security principal (the token is).
## 9.3 Auth model
- **Token = the security principal.** Any connection presenting the valid
`ANDROID_TOKEN` is authorized (it's the user's own token).
- **Allowlist (optional):** `ANDROID_ALLOWED_USERS` (comma-separated
`device_id`s) restricts which *devices* may connect even with the token —
useful if the token is shared. `ANDROID_ALLOW_ALL_USERS=true` disables the
allowlist (dev only).
- **Per-device tokens (stretch):** mint a unique token per device at pairing
(revocable) instead of one shared token. v1 uses the shared token + optional
device allowlist.
- **Re-pairing:** rotating `ANDROID_TOKEN` invalidates all devices; they must
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
## 9.4 Transport security
- **Default (LAN/dev):** plain `ws://` on the trusted LAN. Fine for a home
network.
- **WSS (recommended for remote):** set `ANDROID_WS_CERT` / `ANDROID_WS_KEY`
(self-signed or CA-signed). The app pins/accepts the cert (self-signed → user
confirms fingerprint on first pair, like a SSH host key).
- **Remote reachability options** (documented, user's choice):
- **Tailscale / WireGuard** (recommended): gateway gets a stable tailnet IP;
app connects over the private mesh. No public exposure.
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
at the edge, forward WS to `127.0.0.1:8790`.
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
over plain HTTP (`ANDROID_HTTP_PORT`, default 8791) for the app's
fallback transport. It is a *second door with the same lock*: the same
Bearer token (constant-time `verify_token`) + the same device allowlist
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as
the WS. Optional TLS via `ANDROID_HTTP_CERT` / `ANDROID_HTTP_KEY`.
`GET /v1/health` is unauthenticated by design (liveness only — it must
not reflect tokens, device ids, or versions).
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
in secure storage.
## 9.5 Secret & PII handling
- **Tokens/keys never logged.** Redact `ANDROID_TOKEN`, FCM tokens/keys, ntfy
tokens in all log output (hermes PII policy; `agent/redact.py` patterns).
- **`device_id`** is a random UUID (not PII). `device_name` is user-chosen.
- **Media pull** is gated by hermes `validate_media_delivery_path` + delivery
root/recency/denied-path checks (`gateway/platforms/base.py:1684`) — the
plugin can only serve files hermes is allowed to deliver (no arbitrary file
read).
- **Search** is local-only (user's own hermes home); no data leaves the machine.
## 9.6 Profile safety
- All plugin state lives under `get_hermes_home()/"android"` (profile-aware).
- Secrets are read with the scope-aware `_get_scoped_secret` pattern (see
`plugins/platforms/irc/adapter.py:42`) so multiplexed profiles don't leak
each other's tokens (fail-closed under `gateway.multiplex_profiles`).
- The WS bind uses a scoped lock (`gateway.status.acquire_scoped_lock`) so two
profiles can't bind the same port/identity.
## 9.7 Hardening checklist
- [ ] Constant-time token compare.
- [ ] Bounded per-connection send buffer + rate limit on inbound frames.
- [ ] Reject oversized frames / uploads (`max_upload_bytes`).
- [ ] Verify media sha256 + re-sniff MIME (don't trust client).
- [ ] Redact all secrets in logs.
- [ ] WSS + cert pinning for remote.
- [ ] Outbox retention cap + prune.
- [ ] Fail-closed secret reads under multiplexing.
## M7 verification (2026-08-20)
Status of the §9.7 hardening checklist plus the related gaps found in the
M7 research pass. "verified" = implemented and covered by
`hermes-agent/tests/gateway/test_android.py` (35 tests) or the app build;
"gap" = known limitation with the planned mitigation.
| # | Item | Status | Evidence / mitigation |
|---|------|--------|-----------------------|
| 1 | Constant-time token compare | verified | `gateway-plugin/pairing.py:34` (`hmac.compare_digest`); `test_wrong_token_rejected` |
| 2 | Bounded per-connection send buffer + rate limit on inbound frames | verified | Send: `SEND_TIMEOUT_S` bounds every outbound send (`ws_server.py:47`, `broadcast`/`send_to`). Inbound: per-connection token bucket on JSON frames (20/s, burst 40) → `error {code:"rate_limited"}` + close on exceed (`ws_server.py:55`, `_TokenBucket`, `_on_frame`); binary upload chunks exempt (see gap 1) |
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | `serve(max_size=adapter.max_upload_bytes)` (`ws_server.py:139`); per-upload total cap in `media.py` (`create_upload`/`feed`); `test_upload_declared_over_limit_rejected`, `test_upload_midstream_over_limit_rejected` |
| 4 | Verify media sha256 + re-sniff MIME (don't trust client) | verified | `media.py:317` (`complete_upload` digest check), `media.py:147` (`reclassify_kind`); `test_upload_sha256_mismatch_rejected`, `test_reclassify_kind_does_not_trust_client` |
| 5 | Redact all secrets in logs | gap | No mechanical redaction; the token is printed to stdout by design during `hermes gateway setup` (`gateway-plugin/adapter.py:632,650`). Mitigation: stdout is operator-only, not a log file; a redaction pass over gateway logs is planned |
| 6 | WSS + cert pinning for remote | gap (partial) | WSS supported server-side (`ANDROID_WS_CERT`/`ANDROID_WS_KEY`, `ws_server.py:122`); the app builds a default `OkHttpClient` with no `CertificatePinner` (`app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt:87`). Mitigation: remote access requires CA-signed WSS until pinning lands; LAN `ws://` stays the default |
| 7 | Outbox retention cap + prune | verified | `gateway-plugin/outbox.py:48` (`retention_hours` default 72h, `max_rows` cap, `take_overflow_pruned`); `test_outbox_row_cap_prunes_oldest` |
| 8 | Fail-closed secret reads under multiplexing | verified | `_get_scoped_secret` (`gateway-plugin/adapter.py:74`) for `ANDROID_TOKEN`/`ANDROID_WS_CERT`/`ANDROID_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: token bucket in `ws_server.py` (JSON frames only). Binary upload chunks are exempt — a 100 MB upload is 400 × 256 KiB frames in a tight loop and would exhaust any sane bucket; uploads are already bounded by per-frame `max_size` + the per-upload total cap |
| 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) |
| 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` |
| 12 | Gap: in-app QR scanner | gap | Pairing is manual URL+token only; the server prints a QR (`gateway-plugin/adapter.py:648-654`) that any system scanner can read. Plan: in-app camera scan later |