Files
iris_x_hermes/docs/09-pairing-security.md
T
ARIA b1c9bac7d8
CI / Gateway plugin tests (push) Successful in 5m19s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m3s
docs+plugin: HTTP-only transport cleanup, install guide, review fixes
- 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)
2026-08-24 22:22:02 +02:00

12 KiB

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_ids) 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