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

8.7 KiB
Raw Blame History

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