Auth previously used the shared IRIS_TOKEN as the security principal: a leaked token meant access to all devices, and a compromised device could not be isolated. Gateway: - pairing.py: devices.token column (in-place migration) + revoked denylist table; issue_token (idempotent, 64 hex), token_for, reissue_token, revoke/unrevoke/is_revoked/list_revoked. The token never leaks into device dicts (push fan-out / listings). - http_server.py: auth accepts the shared token (bootstrap/legacy) OR the device's own token (both constant-time); a revoked device_id is rejected with 401 before either comparison. On SSE open (pairing) the per-device token is minted and returned in hello.ack. - protocol.py: hello_ack(..., device_token). - adapter.py: setup flow (hermes gateway setup -> Iris) now offers 'Remove a paired device?' on an existing setup: numbered select menu (last option = exit the removal loop), confirmation, back to the menu for further removals. - tools/iris_devices.py: operator CLI (list / revoke / unrevoke / reissue), stdlib only. App: - SecureStore.deviceToken (Android: EncryptedSharedPreferences; Desktop: second keyring slot iris-device-token / device_token.enc). - HelloAckPayload.deviceToken; GatewayClient stores it on hello and presents it instead of the shared token from then on (live provider in HttpGateway); savePairing/clear wipe it for re-pairing. Docs: 09 §9.3 stretch -> implemented (revocation semantics, both control surfaces), 04 hello.ack example, frames.schema.json, M7 row 13. Tests: 8 new Python tests (issuance, acceptance, revocation, isolation, unrevoke, registry unit x2, setup-flow menu) - 94/94 pass; 2 new Kotlin wire tests - green. Live-verified against a running gateway (hello.ack token matches devices.db; revoke -> 401 even with shared token; unrevoke -> 200; setup TUI both paths).
12 KiB
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
- Generate a token.
hermes gateway setup(ourinteractive_setup) either uses an existingIRIS_TOKENor generates a fresh high-entropy token (e.g. 32 bytes → 64 hex chars) and stores it in.env. - 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 whensecure=1). The phone scans it with the app's Scan QR button (or a system scanner →iris://pairdeep link) → pre-fills settings. - Manual: user types the server URL + token in the app's Connect screen.
- QR code: the setup prints a QR encoding
- App connects. First WS frame is
hello {token, device_id, device_name, caps, fcm_token?}. - Server verifies. Constant-time compare of
tokenvsIRIS_TOKEN(hmac.compare_digest). Optionally checkdevice_idagainstIRIS_ALLOWED_USERS(if set) orIRIS_ALLOW_ALL_USERS. - On success: register the device in
devices.db, mint its per-device token (if it has none yet) and return it inhello.ack.device_token. On failure: senderror {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 fromhermes gateway setup. It authorizes pairing — a NEW device (no row indevices.dbyet) presents it to connect, and the gateway mints a per-device token for it (returned inhello.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 thedevicestable ofdevices.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.
- Shared
- 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 therevokeddenylist: 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 viahello.ack). Re-pairing a revoked device also works by giving the app a freshdevice_id(e.g.adb shell pm clear dev.iris.app), which bootstraps with the shared token like any new device.
- Setup flow —
- Allowlist (optional):
IRIS_ALLOWED_USERS(comma-separateddevice_ids) restricts which devices may connect even with a valid token — useful if the shared token is exposed.IRIS_ALLOW_ALL_USERS=truedisables the allowlist (dev only). - Re-pairing / rotation. Rotating
IRIS_TOKENno 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-timeverify_token) + the same device allowlist (X-Iris-Device), the same 64 KiB body cap and per-device rate limit as the WS. Optional TLS viaIRIS_HTTP_CERT/IRIS_HTTP_KEY.GET /v1/healthis 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.pypatterns). device_idis a random UUID (not PII).device_nameis 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_secretpattern (seeplugins/platforms/irc/adapter.py:42) so multiplexed profiles don't leak each other's tokens (fail-closed undergateway.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 <device_id> drops the device + denylists its id (rejected even with the shared token); unrevoke/reissue for re-pairing/rotation. docs/09 §9.3 |