Per-device tokens with revocation (issue #11)
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).
This commit is contained in:
1 parent
746d809d48
commit
7faaf2aa1c
23 files changed
+837
-66
No files matched your search
+4
-4
@@ -40,7 +40,7 @@ Everything in the feature checklist below.
|
||||
## Feature checklist → where it's handled
|
||||
|
||||
| Requirement | Gateway plugin | App |
|
||||
|---|---|---|
|
||||
| --- | --- | --- |
|
||||
| Input box, auto-grow (max height) | — | Compose `TextField` + bounded `heightIn` |
|
||||
| Menu button → all slash commands | Dispatches `/…`; serves command catalog | Bottom-sheet menu + `/` autocomplete |
|
||||
| Tool output (app decides how much) | Emits **structured** tool events | App setting: everything / truncated / nothing |
|
||||
@@ -55,7 +55,7 @@ Everything in the feature checklist below.
|
||||
## Locked decisions (from planning)
|
||||
|
||||
| Decision | Choice |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
|
||||
| Push backend | **Both** — ntfy default, FCM optional (`IRIS_PUSH_BACKEND`) |
|
||||
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
|
||||
@@ -74,7 +74,7 @@ Everything in the feature checklist below.
|
||||
## Verified environment state (2026-08-19)
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| --- | --- |
|
||||
| OS | CachyOS (Arch-based), `pacman` present |
|
||||
| JDK | **Not installed** → Milestone M0 (`pacman -S jdk17-openjdk`) |
|
||||
| Android SDK | **Not installed** → M0 (cmdline-tools + sdkmanager) |
|
||||
@@ -90,4 +90,4 @@ Everything in the feature checklist below.
|
||||
- Product/effort name: **Iris × Hermes** (folder `iris_x_hermes`).
|
||||
- hermes platform name: **`iris`** (the plugin registers `Platform("iris")`).
|
||||
- WS default port: **8790** (configurable).
|
||||
- Default chat id: **`default`** (the home channel).
|
||||
- Default chat id: **`default`** (the home channel).
|
||||
+4
-1
@@ -50,6 +50,7 @@ iris_x_hermes/
|
||||
## Module responsibilities
|
||||
|
||||
### `gateway-plugin/` (Python)
|
||||
|
||||
- **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`,
|
||||
`requires_env` / `optional_env` (surfaced in `hermes config`/setup).
|
||||
- **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`.
|
||||
@@ -68,6 +69,7 @@ iris_x_hermes/
|
||||
- **`search.py`** — FTS5 query bridge over the hermes session store.
|
||||
|
||||
### `app/shared` (Kotlin KMP)
|
||||
|
||||
- **`commonMain`** — protocol models (kotlinx-serialization), `GatewayClient`
|
||||
(OkHttp WS), repositories (Room), ViewModels (StateFlow), and the Compose UI
|
||||
(design system, screens). ~80% of app code.
|
||||
@@ -77,6 +79,7 @@ iris_x_hermes/
|
||||
window management, `MediaPlayer` actual.
|
||||
|
||||
### `app/androidApp` / `app/desktopApp`
|
||||
|
||||
Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
|
||||
(Desktop). They compose the `shared` UI and inject platform services.
|
||||
|
||||
@@ -127,4 +130,4 @@ keystore.jks
|
||||
- **Plugin:** `~/.hermes/plugins/iris/` ← copy of `gateway-plugin/`
|
||||
(or a symlink for dev). Discovered by hermes's `PluginManager`.
|
||||
- **App (dev):** installed on-device via `./gradlew :androidApp:installDebug`.
|
||||
- **App (desktop, dev):** `./gradlew :desktopApp:run`.
|
||||
- **App (desktop, dev):** `./gradlew :desktopApp:run`.
|
||||
@@ -43,6 +43,7 @@ Pairing succeeded.
|
||||
"search":true,"push":"fcm","pickers":true},
|
||||
"sync_cursor":1042,
|
||||
"last_pushed_cursor":1040,
|
||||
"device_token":"9f2c…(64 hex)",
|
||||
"channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}]
|
||||
}}
|
||||
```
|
||||
@@ -52,6 +53,12 @@ device via the push backend (0 = never). The app skips system notifications
|
||||
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
|
||||
woke the device via push (dedupe, `08-push.md` §8.7).
|
||||
|
||||
`device_token` is the per-device token minted at pairing (docs/09 §9.3):
|
||||
the app stores it and presents it in the `Authorization` header INSTEAD of
|
||||
the shared `IRIS_TOKEN` from then on, so the gateway can revoke one device
|
||||
without affecting the others. Empty when the gateway didn't issue one
|
||||
(legacy).
|
||||
|
||||
### `message`
|
||||
|
||||
A final / standalone message.
|
||||
|
||||
+50
-12
@@ -27,8 +27,10 @@
|
||||
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`, send `hello.ack`.
|
||||
**On failure:** send `error {code:"auth"}` and close.
|
||||
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
|
||||
@@ -36,17 +38,52 @@ security principal (the token is).
|
||||
|
||||
## 9.3 Auth model
|
||||
|
||||
- **Token = the security principal.** Any connection presenting the valid
|
||||
`IRIS_TOKEN` is authorized (it's the user's own token).
|
||||
- **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_id`s) restricts which *devices* may connect even with the token —
|
||||
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 `IRIS_TOKEN` invalidates all devices; they must
|
||||
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
|
||||
`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
|
||||
|
||||
@@ -124,3 +161,4 @@ M7 research pass. "verified" = implemented and covered by
|
||||
| 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 |
|
||||
+13
-1
@@ -9,6 +9,7 @@ First-time setup on a machine (verified baseline: CachyOS/Arch, `pacman`,
|
||||
pacman -S jdk17-openjdk
|
||||
java -version # expect 17.x
|
||||
```
|
||||
|
||||
(Compose Multiplatform + current AGP are happy on JDK 17. Use 17 to match the
|
||||
Android toolchain; 21 also works but 17 is the safe floor.)
|
||||
|
||||
@@ -28,10 +29,12 @@ export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-
|
||||
sdkmanager --licenses
|
||||
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
|
||||
```
|
||||
|
||||
Persist `ANDROID_HOME`/`PATH` in `~/.bashrc`. ADB is already installed system-wide;
|
||||
`platform-tools` from the SDK is fine too (whichever is first on `PATH`).
|
||||
|
||||
Create `app/local.properties`:
|
||||
|
||||
```
|
||||
sdk.dir=/home/<you>/android-sdk
|
||||
```
|
||||
@@ -39,10 +42,12 @@ sdk.dir=/home/<you>/android-sdk
|
||||
## 12.3 Gradle
|
||||
|
||||
No system install — use the project wrapper:
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew tasks # first run downloads the wrapper distribution
|
||||
```
|
||||
|
||||
(The wrapper version is pinned in `app/gradle/wrapper/gradle-wrapper.properties`.)
|
||||
|
||||
## 12.4 hermes environment (for the plugin + running the gateway)
|
||||
@@ -53,7 +58,9 @@ uv sync # creates .venv with all core deps (websockets, httpx,
|
||||
source .venv/bin/activate
|
||||
hermes --version # sanity
|
||||
```
|
||||
|
||||
- Run the gateway with the plugin:
|
||||
|
||||
```bash
|
||||
# install the plugin (dev: symlink)
|
||||
mkdir -p ~/.hermes/plugins
|
||||
@@ -61,7 +68,9 @@ hermes --version # sanity
|
||||
hermes gateway status # should list "iris"
|
||||
hermes gateway # run
|
||||
```
|
||||
|
||||
- Tests use hermes's hermetic runner (never bare `pytest`):
|
||||
|
||||
```bash
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
```
|
||||
@@ -84,6 +93,7 @@ hermes --version # sanity
|
||||
## 12.6 Environment variables (summary)
|
||||
|
||||
**Secrets (`~/.hermes/.env`):**
|
||||
|
||||
```
|
||||
IRIS_TOKEN=<64-hex>
|
||||
IRIS_PUSH_BACKEND=ntfy # default; fcm = opt-in (metadata via Google)
|
||||
@@ -96,6 +106,7 @@ IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
|
||||
```
|
||||
|
||||
**Behavioral (`~/.hermes/config.yaml`):**
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
platforms:
|
||||
@@ -135,5 +146,6 @@ async def main():
|
||||
asyncio.run(main())
|
||||
PY
|
||||
```
|
||||
|
||||
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
|
||||
wrong.
|
||||
wrong.
|
||||
@@ -24,6 +24,7 @@
|
||||
"server_caps": { "type": "object", "properties": { "streaming": {"type":"boolean"}, "reasoning": {"type":"boolean"}, "tools": {"type":"boolean"}, "media": {"type":"boolean"}, "search": {"type":"boolean"}, "push": {"type":"string","enum":["fcm","ntfy","none"]}, "push_ntfy_server": {"type":"string","description":"ntfy server URL for the app's listener; empty string when the backend is not ntfy."}, "pickers": {"type":"boolean"} } },
|
||||
"sync_cursor": { "type": "integer" },
|
||||
"last_pushed_cursor": { "type": "integer", "description": "Highest outbox cursor already delivered to THIS device via the push backend (0 = never). The app skips system notifications for sync-replayed frames at/below it (dedupe, docs/08 §8.7)." },
|
||||
"device_token": { "type": "string", "description": "Per-device token minted at pairing (docs/09 §9.3). The app stores it and presents it INSTEAD of the shared IRIS_TOKEN from then on; the gateway can revoke it per device. Empty when the gateway didn't issue one (legacy)." },
|
||||
"channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
|
||||
}
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user