- docs/install.md: new end-to-end guide for non-technical users (gateway install, app install, LAN/TLS/remote connection, push, options, troubleshooting); docs/setup.md now points to it - README: new 'Install the gateway' section; pairing section updated for HTTP transport (8791, QR scan on Android) - rename IRIS_WS_HOST -> IRIS_HTTP_HOST (clean rename, no compat fallback); drop dead DEFAULT_PORT=8790 - setup.py: advertise https:// in the printed/QR server URL when IRIS_HTTP_CERT is set - ws_probe.py/e2e.py: default --url http://127.0.0.1:8791, env IRIS_WS_URL -> IRIS_HTTP_URL, honor explicit port + https scheme - plugin.yaml: IRIS_HTTP_* env names, description no longer says 'WebSocket server' - docs 03/09/12/19: fix stale WS-era refs (ws_server.py cites, 8790 smoke test, WSS->HTTPS, 'HTTP fallback' reframed as the only transport) - AGENTS.md: symlink name android -> iris (matches actual install) - test: adapter reads IRIS_HTTP_HOST/CERT/KEY from env; legacy IRIS_WS_* names are not consulted (95/95 pass)
174 lines
12 KiB
Markdown
174 lines
12 KiB
Markdown
# 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 `IRIS_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=8791&secure=0&token=<token>` (or a WSS
|
|
URL when `secure=1`). The phone scans it with the app's **Scan QR**
|
|
button (or a system scanner → `iris://pair` deep link) → 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 `IRIS_TOKEN`
|
|
(`hmac.compare_digest`). Optionally check `device_id` against
|
|
`IRIS_ALLOWED_USERS` (if set) or `IRIS_ALLOW_ALL_USERS`.
|
|
5. **On success:** register the device in `devices.db`, **mint its
|
|
per-device token** (if it has none yet) and return it in
|
|
`hello.ack.device_token`. **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
|
|
|
|
- **Two tokens, one principal per device.**
|
|
- **Shared `IRIS_TOKEN` (bootstrap):** the setup token from
|
|
`hermes gateway setup`. It authorizes *pairing* — a NEW device (no row
|
|
in `devices.db` yet) presents it to connect, and the gateway mints a
|
|
per-device token for it (returned in `hello.ack.device_token`). It
|
|
keeps working for devices that never received a per-device token
|
|
(legacy apps), so an upgrade never bricks a pairing.
|
|
- **Per-device token (revocable):** minted once at pairing
|
|
(`DeviceRegistry.issue_token`, 64 hex chars, stored in the `devices`
|
|
table of `devices.db`). The app stores it in secure storage and
|
|
presents it INSTEAD of the shared token from the next request on
|
|
(`Authorization: Bearer <device-token>`). Both tokens are compared in
|
|
constant time (`verify_token`); a revoked device is rejected before
|
|
either comparison runs.
|
|
- **Per-device revocation.** Two control surfaces (run on the gateway host):
|
|
- **Setup flow** — `hermes gateway setup` → *Iris*: on an existing setup
|
|
(devices already paired) it asks **"Remove a paired device?"** (default
|
|
No). If yes: a numbered select menu (name, device id, last seen) whose
|
|
LAST option is *Exit* (leaves the removal loop, continues the setup);
|
|
picking a device asks for confirmation, then returns to the menu so
|
|
several devices can be removed in a row.
|
|
- **CLI** — `gateway-plugin/tools/iris_devices.py`:
|
|
- `list` — paired devices (id, name, token minted?, last seen) + revoked ids.
|
|
- `revoke <device_id>` — drops the device's row (token, push tokens,
|
|
cursor) AND adds its id to the `revoked` denylist: the device can no
|
|
longer connect with its device token **or** the shared token, while
|
|
every other device is unaffected. This is the isolation primitive a
|
|
shared token alone can't provide (a compromised device can't be cut
|
|
off without rotating the token for everyone).
|
|
- `unrevoke <device_id>` — removes it from the denylist so it can pair
|
|
again (a fresh token is minted at the next pairing).
|
|
- `reissue <device_id>` — rotates the device's token (the old one stops
|
|
working; the app picks up the new one on its next (re)connect via
|
|
`hello.ack`).
|
|
Re-pairing a revoked device also works by giving the app a fresh
|
|
`device_id` (e.g. `adb shell pm clear dev.iris.app`), which bootstraps
|
|
with the shared token like any new device.
|
|
- **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
|
|
`device_id`s) restricts which *devices* may connect even with a valid
|
|
token — useful if the shared token is exposed. `IRIS_ALLOW_ALL_USERS=true`
|
|
disables the allowlist (dev only).
|
|
- **Re-pairing / rotation.** Rotating `IRIS_TOKEN` no longer invalidates
|
|
paired devices: they authenticate with their per-device tokens, which
|
|
survive the rotation. Only bootstrap of NEW devices needs the new shared
|
|
token. (Legacy devices without a per-device token still re-pair, as
|
|
before.)
|
|
|
|
## 9.4 Transport security
|
|
|
|
- **Default (LAN/dev):** plain `http://` on the trusted LAN. Fine for a home
|
|
network.
|
|
- **HTTPS (recommended for remote):** set `IRIS_HTTP_CERT` / `IRIS_HTTP_KEY`
|
|
(self-signed or CA-signed). CA-signed certs work out of the box.
|
|
For a **self-signed** cert the app shows its SHA-256 fingerprint on first
|
|
pair (like a SSH host key); once the user confirms it, the fingerprint is
|
|
pinned in secure storage (`SecureStore.pinnedCertFingerprint`) and the
|
|
app's `PinningTrustManager` accepts exactly that certificate from then on
|
|
(hostname verification still applies). A *changed* certificate fails
|
|
again with a fresh confirm request — the user must re-confirm, like a
|
|
changed SSH host key. No system trust-store install needed. Note: the cert
|
|
must carry a **SAN** for the URL host (OkHttp's hostname verifier rejects
|
|
CN-only certs even when pinned) — e.g. `openssl req -x509 ... -addext
|
|
"subjectAltName=DNS:myhost,IP:192.168.1.10"`.
|
|
- **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 to `127.0.0.1:8791`.
|
|
- **Public bind** (`0.0.0.0`) + HTTPS + strong token — last resort.
|
|
- **HTTP transport (docs/19):** the gateway serves the same frames over plain
|
|
HTTP (`IRIS_HTTP_PORT`, default 8791) — the only device-facing transport.
|
|
It shares the same lock as everything else: 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.
|
|
Optional TLS via `IRIS_HTTP_CERT` / `IRIS_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 `IRIS_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()/"iris"` (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 (`http_server.py`, `broadcast`/`send_to`). Inbound: per-device token bucket on JSON frames (20/s, burst 40) → `429` on exceed (`http_server.py`, `_rate_limited`); media uploads bounded by the per-request body cap + per-upload total cap (see gap 1) |
|
|
| 3 | Reject oversized frames / uploads (`max_upload_bytes`) | verified | Per-request body cap in `http_server.py`; 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 | HTTPS + cert pinning for remote | implemented | TLS supported server-side (`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`, `http_server.py`); the app pins self-signed certs via a fingerprint-confirm flow: `PinningTrustManager` wraps the platform default trust manager, a rejected cert is accepted only when its SHA-256 fingerprint matches the user-confirmed pin in `SecureStore.pinnedCertFingerprint`, anything else fails with `TlsFingerprintRequired` → confirm dialog on the Connect screen (`app/shared/src/commonMain/kotlin/iris/net/TlsPinning.kt`, `GatewayClient.State.TlsConfirmRequired`); hostname verification still applies (the cert needs a SAN for the URL host). CA-signed certs work out of the box; LAN `http://` stays the default. Tests: `TlsPinningTest`, `TlsPinningIntegrationTest` |
|
|
| 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 `IRIS_TOKEN`/`IRIS_HTTP_CERT`/`IRIS_HTTP_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
|
|
| 9 | Gap: inbound frame rate limiting | implemented | Closes item 2: per-device token bucket in `http_server.py` (`_rate_limited`). Media uploads are bounded by the per-request body cap + 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 | implemented | `hermes gateway setup` renders a terminal QR (`gateway-plugin/qr.py`, pure-stdlib encoder) and the app's Connect screen has a **Scan QR** button (CameraX + ML Kit, `QrScanActivity`) plus an `iris://pair` deep link (`PairLink.parse`); `docs/20` |
|
|
| 13 | Gap: per-device tokens (revocation) | implemented | `DeviceRegistry.issue_token` mints a 64-hex per-device token at pairing (stored in `devices.db`, returned in `hello.ack.device_token`); the app stores it in secure storage and presents it instead of the shared `IRIS_TOKEN` (bootstrap path unchanged). `tools/iris_devices.py revoke <device_id>` drops the device + denylists its id (rejected even with the shared token); `unrevoke`/`reissue` for re-pairing/rotation. `docs/09` §9.3 |
|