Add QR pairing (terminal QR, in-app scanner, iris://pair deep link)
This commit is contained in:
1 parent
27dc7917f2
commit
7a6d922d12
63 files changed
+2073
-630
No files matched your search
+20
-19
@@ -13,19 +13,20 @@
|
||||
## 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
|
||||
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=8790&token=<token>` (or a WSS URL). The
|
||||
phone scans it with the app's camera (or a system scanner) → pre-fills
|
||||
`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 `ANDROID_TOKEN`
|
||||
4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN`
|
||||
(`hmac.compare_digest`). Optionally check `device_id` against
|
||||
`ANDROID_ALLOWED_USERS` (if set) or `ANDROID_ALLOW_ALL_USERS`.
|
||||
`IRIS_ALLOWED_USERS` (if set) or `IRIS_ALLOW_ALL_USERS`.
|
||||
5. **On success:** register the device in `devices.db`, send `hello.ack`.
|
||||
**On failure:** send `error {code:"auth"}` and close.
|
||||
|
||||
@@ -36,24 +37,24 @@ 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
|
||||
`IRIS_TOKEN` is authorized (it's the user's own token).
|
||||
- **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
|
||||
`device_id`s) restricts which *devices* may connect even with the token —
|
||||
useful if the token is shared. `ANDROID_ALLOW_ALL_USERS=true` disables the
|
||||
useful if the token is shared. `IRIS_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-pairing:** rotating `IRIS_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`
|
||||
- **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).
|
||||
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.
|
||||
@@ -61,11 +62,11 @@ security principal (the token is).
|
||||
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
|
||||
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 `ANDROID_HTTP_CERT` / `ANDROID_HTTP_KEY`.
|
||||
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
|
||||
@@ -73,7 +74,7 @@ security principal (the token is).
|
||||
|
||||
## 9.5 Secret & PII handling
|
||||
|
||||
- **Tokens/keys never logged.** Redact `ANDROID_TOKEN`, FCM tokens/keys, ntfy
|
||||
- **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
|
||||
@@ -84,7 +85,7 @@ security principal (the token is).
|
||||
|
||||
## 9.6 Profile safety
|
||||
|
||||
- All plugin state lives under `get_hermes_home()/"android"` (profile-aware).
|
||||
- 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`).
|
||||
@@ -110,16 +111,16 @@ M7 research pass. "verified" = implemented and covered by
|
||||
"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 |
|
||||
| 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 `ANDROID_TOKEN`/`ANDROID_WS_CERT`/`ANDROID_WS_KEY`/FCM/ntfy secrets; scoped bind lock in `connect()` (`adapter.py:779`) |
|
||||
| 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 | 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 |
|
||||
| 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` |
|
||||
Reference in new issue
Block a user