# 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=&port=8791&secure=0&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 `). 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 ` — 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 ` — removes it from the denylist so it can pair again (a fresh token is minted at the next pairing). - `reissue ` — 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 `ws://` on the trusted LAN. Fine for a home network. - **WSS (recommended for remote):** set `IRIS_WS_CERT` / `IRIS_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 (`IRIS_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 `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 (`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 (`IRIS_WS_CERT`/`IRIS_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 `IRIS_TOKEN`/`IRIS_WS_CERT`/`IRIS_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 | 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 ` drops the device + denylists its id (rejected even with the shared token); `unrevoke`/`reissue` for re-pairing/rotation. `docs/09` §9.3 |