diff --git a/README.md b/README.md index 0c9f536..81fd72d 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Setup: [`docs/setup.md`](docs/setup.md) §4; details: ### Prerequisites | Where | You need | -|---|---| +| --- | --- | | Gateway host | [hermes-agent](https://github.com/NousResearch/hermes-agent) with its venv (`uv sync`) | | Android build machine | JDK 17, Android SDK (`sdk.dir` in `app/local.properties` or `ANDROID_HOME`), ADB with a connected device | | Desktop build machine | JDK 17 only | @@ -129,7 +129,7 @@ Contributions are welcome! Before you start: 2. **Know the layout.** | Path | What | - |---|---| + | --- | --- | | `gateway-plugin/` | Python hermes platform plugin (`android`); `protocol.py` is the frame source of truth | | `app/shared` | KMP module with most of the client code (shared by Android + Desktop) | | `app/androidApp` | Thin Android shell (package `dev.iris.app`) | @@ -152,4 +152,4 @@ Open an issue first for anything big, then send a pull request. ## License -Apache License 2.0 — see [LICENSE](LICENSE). \ No newline at end of file +Apache License 2.0 — see [LICENSE](LICENSE). diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt index 60ea2d2..216734b 100644 --- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt @@ -76,6 +76,10 @@ class AndroidSecureStore( get() = prefs.getString(KEY_TOKEN, "").orEmpty() set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply() + override var deviceToken: String + get() = prefs.getString(KEY_DEVICE_TOKEN, "").orEmpty() + set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value.trim()).apply() + override val deviceId: String get() { var id = prefs.getString(KEY_DEVICE_ID, null) @@ -176,6 +180,9 @@ class AndroidSecureStore( ) { serverUrl = url this.token = token + // A (re-)pair may target a different gateway: the old per-device + // token is dead there. The next hello.ack re-mints/returns it. + deviceToken = "" } override fun clear() { @@ -187,6 +194,7 @@ class AndroidSecureStore( .edit() .remove(KEY_URL) .remove(KEY_TOKEN) + .remove(KEY_DEVICE_TOKEN) .remove(KEY_DEVICE_ID) .remove(KEY_SYNC_CURSOR) .remove(KEY_FCM_TOKEN) @@ -201,6 +209,7 @@ class AndroidSecureStore( const val SECURE_PREFS_NAME = "iris_secure" const val KEY_URL = "server_url" const val KEY_TOKEN = "token" + const val KEY_DEVICE_TOKEN = "device_token" const val KEY_DEVICE_ID = "device_id" const val KEY_SYNC_CURSOR = "sync_cursor" const val KEY_FCM_TOKEN = "fcm_token" diff --git a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt index b2f71ed..07d2cc1 100644 --- a/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt +++ b/app/shared/src/commonMain/kotlin/iris/data/SecureStore.kt @@ -9,9 +9,14 @@ interface SecureStore { /** http(s)://host:port (legacy ws(s):// URLs are still accepted) */ var serverUrl: String - /** IRIS_TOKEN presented in the auth header. */ + /** IRIS_TOKEN presented in the auth header (bootstrap / fallback). */ var token: String + /** Per-device token minted at pairing (hello.ack ``device_token``, + * docs/09 §9.3). Presented INSTEAD of [token] when non-empty; the + * gateway can revoke it per device. Empty until the first hello.ack. */ + var deviceToken: String + /** Stable app-generated device id (persisted). */ val deviceId: String diff --git a/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt b/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt index 57a09d6..7a2f030 100644 --- a/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt +++ b/app/shared/src/commonMain/kotlin/iris/net/GatewayClient.kt @@ -195,7 +195,9 @@ class GatewayClient( private suspend fun connectLoop() { while (currentCoroutineContext().isActive) { val url = store.serverUrl.trim() - val token = store.token + // Per-device token when the gateway minted one (docs/09 §9.3), + // else the shared IRIS_TOKEN (bootstrap). + val token = store.deviceToken.ifBlank { store.token } if (url.isBlank() || token.isBlank()) { _state.value = State.Disconnected return @@ -278,13 +280,15 @@ class GatewayClient( private fun httpGateway(): HttpGateway? = synchronized(this) { val url = store.serverUrl.trim() - val token = store.token + val token = store.deviceToken.ifBlank { store.token } if (url.isBlank() || token.isBlank()) return@synchronized null http ?: HttpGateway( client, HttpGateway.deriveHttpUrl(url), - token, + // Live provider: a device token minted by the next + // hello.ack is picked up without rebuilding the client. + token = { store.deviceToken.ifBlank { store.token } }, store.deviceId, deviceName = store.deviceName, fcmToken = { store.fcmToken.ifBlank { null } }, @@ -356,6 +360,13 @@ class GatewayClient( /** The SSE `event: hello` (the HTTP hello.ack). */ private fun onHttpHello(ack: HelloAckPayload) { lastAck = ack + // Per-device token (docs/09 §9.3): minted at pairing, stable across + // (re)connects. Store it — from the next request on the app presents + // it instead of the shared IRIS_TOKEN, so the gateway can revoke + // THIS device without touching the others. + if (ack.deviceToken.isNotBlank() && ack.deviceToken != store.deviceToken) { + store.deviceToken = ack.deviceToken + } val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor) _state.value = connected // M5: reconnect catch-up — replay frames parked while offline. @@ -522,7 +533,10 @@ class GatewayClient( HttpGateway( client, HttpGateway.deriveHttpUrl(url), - token, + // An already-paired device presents its per-device token + // (docs/09 §9.3); a fresh pairing falls back to the entered + // shared token (bootstrap). + token = { store.deviceToken.ifBlank { token } }, store.deviceId, deviceName = store.deviceName, fcmToken = { store.fcmToken.ifBlank { null } }, diff --git a/app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt b/app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt index 36a1b0c..20cee21 100644 --- a/app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt +++ b/app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt @@ -38,7 +38,11 @@ import java.util.concurrent.TimeUnit class HttpGateway( private val client: OkHttpClient, private val baseUrl: String, - private val token: String, + /** Live auth-token provider, read per request: the per-device token + * (docs/09 §9.3) when the gateway minted one, else the shared + * IRIS_TOKEN (bootstrap). A lambda so a freshly issued device token is + * picked up without rebuilding the client. */ + private val token: () -> String, private val deviceId: String, /** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway * upserts it into the device registry on every SSE open — the HTTP @@ -123,7 +127,7 @@ class HttpGateway( val b = Headers .Builder() - .add("Authorization", "Bearer $token") + .add("Authorization", "Bearer ${token()}") .add("X-Iris-Device", deviceId) // Device registration (docs/19): the gateway upserts name + push // tokens from these headers on every SSE open (COALESCE — absent diff --git a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt index 2da7245..88bf75f 100644 --- a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt +++ b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt @@ -176,6 +176,11 @@ data class HelloAckPayload( * push backend (0 = never). Sync-replayed frames at/below it must not * re-post system notifications (dedupe, docs/08 §8.7). */ @SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0, + /** 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). */ + @SerialName("device_token") val deviceToken: String = "", ) // ── message (server -> app) ───────────────────────────────────────────── diff --git a/app/shared/src/commonTest/kotlin/iris/protocol/HelloAckWireTest.kt b/app/shared/src/commonTest/kotlin/iris/protocol/HelloAckWireTest.kt new file mode 100644 index 0000000..4dc4c3e --- /dev/null +++ b/app/shared/src/commonTest/kotlin/iris/protocol/HelloAckWireTest.kt @@ -0,0 +1,31 @@ +package iris.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Wire tests for the hello.ack payload (docs/04). */ +class HelloAckWireTest { + @Test + fun helloAckDeserializesDeviceToken() { + val raw = + """ + {"v":1,"type":"hello.ack","payload":{"sync_cursor":5, + "last_pushed_cursor":3,"device_token":"9f2c64hex"}} + """.trimIndent() + val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw) + assertEquals("hello.ack", frame.type) + val p = frame.payloadAs() + assertEquals("9f2c64hex", p?.deviceToken) + assertEquals(5L, p?.syncCursor) + assertEquals(3L, p?.lastPushedCursor) + } + + @Test + fun helloAckDefaultsDeviceTokenToEmpty() { + // Legacy gateways (pre per-device tokens) omit the field entirely. + val raw = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":1}}""" + val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw) + val p = frame.payloadAs() + assertEquals("", p?.deviceToken) + } +} diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt index 21e2dc9..7e71598 100644 --- a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopSecureStore.kt @@ -27,7 +27,24 @@ class DesktopSecureStore : SecureStore { private val baseDir = File(System.getProperty("user.home"), ".iris") private val settingsFile = File(baseDir, "settings.json") private val legacyFile = File(baseDir, "pairing.json") - private val secret = SecretBackend(baseDir) + private val secret = + SecretBackend( + baseDir, + keyringService = "iris-gateway-token", + keyringLabel = "Iris gateway token", + keyringAttr = "iris", + encFileName = "pairing.enc", + ) + + // Per-device token (docs/09 §9.3): a second secret slot, same backends. + private val deviceSecret = + SecretBackend( + baseDir, + keyringService = "iris-device-token", + keyringLabel = "Iris device token", + keyringAttr = "iris-device", + encFileName = "device_token.enc", + ) // M-8: cache the parsed settings so hot-path getters (serverUrl/token per // connect attempt) don't re-read + re-parse the file on every access. @@ -124,6 +141,12 @@ class DesktopSecureStore : SecureStore { if (value.isBlank()) secret.clear() else secret.write(value.trim()) } + override var deviceToken: String + get() = deviceSecret.read().orEmpty() + set(value) { + if (value.isBlank()) deviceSecret.clear() else deviceSecret.write(value.trim()) + } + override val deviceId: String get() { val d = load() @@ -268,6 +291,9 @@ class DesktopSecureStore : SecureStore { val d = load() save(d.copy(serverUrl = url.trim())) if (token.isBlank()) secret.clear() else secret.write(token.trim()) + // A (re-)pair may target a different gateway: the old per-device + // token is dead there. The next hello.ack re-mints/returns it. + deviceSecret.clear() } override fun clear() { @@ -288,6 +314,7 @@ class DesktopSecureStore : SecureStore { ), ) secret.clear() + deviceSecret.clear() } } @@ -306,13 +333,21 @@ private data class PairingData( /** * Token storage: OS keyring when available, else an AES-GCM encrypted file. * All backend failures degrade to the encrypted file (never plaintext). + * + * Parameterized so the shared gateway token and the per-device token + * (docs/09 §9.3) each get their own keyring entry / encrypted file. */ private class SecretBackend( private val baseDir: File, + private val keyringService: String, + private val keyringLabel: String, + private val keyringAttr: String, + encFileName: String, ) { - private val encFile = File(baseDir, "pairing.enc") + private val encFile = File(baseDir, encFileName) private val keyFile = File(baseDir, ".key") - private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available } + private val keyring: KeyringBackend? = + KeyringBackend(keyringService, keyringLabel, keyringAttr).takeIf { it.available } fun read(): String? = keyring?.read() ?: readEncrypted() @@ -388,7 +423,11 @@ private class SecretBackend( } /** OS keyring via the platform CLI (best effort). */ -private class KeyringBackend { +private class KeyringBackend( + private val service: String, + private val label: String, + private val attr: String, +) { private val os = System.getProperty("os.name").lowercase() private val isMac = os.contains("mac") private val isLinux = os.contains("linux") @@ -407,9 +446,9 @@ private class KeyringBackend { fun read(): String? = try { if (isMac) { - out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w")) + out(listOf("security", "find-generic-password", "-a", "iris", "-s", service, "-w")) } else { - out(listOf("secret-tool", "lookup", "app", "iris")) + out(listOf("secret-tool", "lookup", "app", attr)) } } catch (_: Exception) { null @@ -430,11 +469,11 @@ private class KeyringBackend { "-a", "iris", "-s", - "iris-gateway-token", + service, "-w", ) } else { - listOf("secret-tool", "store", "--label=Iris gateway token", "app", "iris") + listOf("secret-tool", "store", "--label=$label", "app", attr) } ProcessBuilder(cmd).start().apply { outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) } @@ -453,14 +492,14 @@ private class KeyringBackend { "-a", "iris", "-s", - "iris-gateway-token", + service, ).inheritIO().start().waitFor() } else { ProcessBuilder( "secret-tool", "clear", "app", - "iris", + attr, ).inheritIO().start().waitFor() } } catch (_: Exception) { diff --git a/docs/00-overview.md b/docs/00-overview.md index a106ae9..c93d248 100644 --- a/docs/00-overview.md +++ b/docs/00-overview.md @@ -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). \ No newline at end of file +- Default chat id: **`default`** (the home channel). diff --git a/docs/02-monorepo.md b/docs/02-monorepo.md index 68dc447..a5dab68 100644 --- a/docs/02-monorepo.md +++ b/docs/02-monorepo.md @@ -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`. \ No newline at end of file +- **App (desktop, dev):** `./gradlew :desktopApp:run`. diff --git a/docs/04-wire-protocol.md b/docs/04-wire-protocol.md index 119650a..cf99c49 100644 --- a/docs/04-wire-protocol.md +++ b/docs/04-wire-protocol.md @@ -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. diff --git a/docs/09-pairing-security.md b/docs/09-pairing-security.md index c9eedee..ee3771c 100644 --- a/docs/09-pairing-security.md +++ b/docs/09-pairing-security.md @@ -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 `). 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 ` — 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 ` — removes it from the denylist so it can pair + again (a fresh token is minted at the next pairing). + - `reissue ` — 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 ` drops the device + denylists its id (rejected even with the shared token); `unrevoke`/`reissue` for re-pairing/rotation. `docs/09` §9.3 | diff --git a/docs/12-toolchain.md b/docs/12-toolchain.md index 110d871..fc0b432 100644 --- a/docs/12-toolchain.md +++ b/docs/12-toolchain.md @@ -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//android-sdk ``` @@ -39,10 +42,12 @@ sdk.dir=/home//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. \ No newline at end of file +wrong. diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index 952d694..8bb636a 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -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" } } } }, diff --git a/gateway-plugin/adapter.py b/gateway-plugin/adapter.py index 721e6d7..87f6571 100644 --- a/gateway-plugin/adapter.py +++ b/gateway-plugin/adapter.py @@ -1162,6 +1162,66 @@ def _ensure_verbose_tool_progress() -> None: # --------------------------------------------------------------------------- +def _offer_device_removal() -> None: + """Setup-flow device management (docs/09 §9.3): if devices are already + paired, offer to revoke one. Revocation is server-side — no access to + the device is needed: its per-device token is deleted and its id is + denylisted, so even the shared token no longer authenticates it. + + Flow: ask (default No) → numbered select menu (last option = exit the + removal loop, NOT the setup) → confirmation → back to the menu, so + several devices can be removed in a row. + """ + try: + from hermes_cli.cli_output import ( + print_info, + print_success, + prompt, + prompt_yes_no, + ) + except Exception: + return + + try: + reg = DeviceRegistry(get_hermes_home() / "iris" / "devices.db") + except Exception: + return + try: + devices = reg.list() + if not devices: + return + if not prompt_yes_no("Remove a paired device?", default=False): + return + while True: + print_info("Paired devices:") + for i, d in enumerate(devices, 1): + last_seen = time.strftime("%Y-%m-%d %H:%M", time.localtime(d["last_seen"])) + print_info(f" {i}. {d['name']} ({d['device_id']}) last seen {last_seen}") + exit_idx = len(devices) + 1 + print_info(f" {exit_idx}. Exit") + # Default = exit: pressing Enter leaves the removal loop (and + # continues the setup) without removing anything. + choice = prompt("Select a device to remove", default=str(exit_idx)) + idx = int(choice) if choice.isdigit() else exit_idx + if idx < 1 or idx >= exit_idx: + return + target = devices[idx - 1] + if not prompt_yes_no( + f"Remove {target['name']} ({target['device_id']})? It will no longer " + "be able to connect (shared token included).", + default=False, + ): + continue # back to the select menu + reg.revoke(target["device_id"]) + devices = [d for d in devices if d["device_id"] != target["device_id"]] + print_success(f"Removed {target['device_id']} \u2014 it can no longer connect.") + if not devices: + print_info("No paired devices left.") + return + finally: + reg.close() + + def interactive_setup() -> None: """Prompt for the pairing token / host / port / push backend. @@ -1190,6 +1250,10 @@ def interactive_setup() -> None: else: print_info("Existing IRIS_TOKEN found (not shown).") + # Device management (docs/09 §9.3): on an existing setup, offer to cut + # off a lost/compromised device before continuing with the config. + _offer_device_removal() + host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST) save_env_value("IRIS_WS_HOST", host or DEFAULT_HOST) # _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the diff --git a/gateway-plugin/http_server.py b/gateway-plugin/http_server.py index e9751fc..6d9d5b5 100644 --- a/gateway-plugin/http_server.py +++ b/gateway-plugin/http_server.py @@ -321,16 +321,32 @@ class HttpServer: def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None: """Verify Bearer token + device identity. Returns the device_id, or - None after sending a 401.""" + None after sending a 401. + + Token model (docs/09 §9.3): a REVOKED device_id is rejected no matter + which token it presents (per-device isolation). Otherwise the shared + ``IRIS_TOKEN`` (bootstrap / legacy) or the device's own per-device + token (minted at pairing, returned in ``hello.ack.device_token``) + both authenticate — each compared in constant time.""" auth = handler.headers.get("Authorization") or "" token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None - if not verify_token(token, self._adapter.token): - _send_json(handler, 401, {"error": "unauthorized"}) - return None device_id = (handler.headers.get("X-Iris-Device") or "").strip() if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN: _send_json(handler, 401, {"error": "X-Iris-Device header required"}) return None + with contextlib.suppress(Exception): + if self._devices.is_revoked(device_id): + logger.warning("iris: http rejected: device %s is revoked", device_id) + _send_json(handler, 401, {"error": "device revoked"}) + return None + if not verify_token(token, self._adapter.token): + # Not the shared token: try the device's own per-device token. + device_token = None + with contextlib.suppress(Exception): + device_token = self._devices.token_for(device_id) + if not (device_token and verify_token(token, device_token)): + _send_json(handler, 401, {"error": "unauthorized"}) + return None if ( not self._adapter.allow_all and self._adapter.allowed_users @@ -484,6 +500,14 @@ class HttpServer: ) except Exception: logger.warning("iris: device registry upsert failed", exc_info=True) + # Per-device token (docs/09 §9.3): minted once at pairing (idempotent + # across (re)connects) and returned in the hello below; the app + # stores it and presents it instead of the shared token from then on. + device_token = "" + try: + device_token = self._devices.issue_token(device_id) + except Exception: + logger.warning("iris: device token issuance failed", exc_info=True) sub = _Subscriber(device_id=device_id, kind="sse") # Register BEFORE the replay so a frame appended in between is # fanned out to us (and de-duped by cursor below) instead of lost. @@ -513,6 +537,7 @@ class HttpServer: sync_cursor=self._adapter._outbox.latest_cursor(), channels=self._adapter.channel_list(), last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id), + device_token=device_token, ) self._write_sse(handler, "hello", None, hello.to_json()) self._write_sse( diff --git a/gateway-plugin/pairing.py b/gateway-plugin/pairing.py index d46badc..2e92670 100644 --- a/gateway-plugin/pairing.py +++ b/gateway-plugin/pairing.py @@ -150,6 +150,21 @@ class DeviceRegistry: self._conn.execute( "ALTER TABLE devices ADD COLUMN last_pushed_cursor INTEGER NOT NULL DEFAULT 0" ) + # Per-device tokens (docs/09 §9.3): a unique, revocable token + # minted at pairing, stored per device. NULL/empty = the device + # still authenticates with the shared IRIS_TOKEN (bootstrap). + if "token" not in cols: + self._conn.execute("ALTER TABLE devices ADD COLUMN token TEXT") + # Revocation denylist: a revoked device_id is rejected even when + # it presents the shared token (isolation, docs/09 §9.3). + self._conn.execute( + """ + CREATE TABLE IF NOT EXISTS revoked ( + device_id TEXT PRIMARY KEY, + revoked_at REAL NOT NULL DEFAULT 0 + ) + """ + ) self._conn.commit() def upsert( @@ -235,6 +250,91 @@ class DeviceRegistry: except (TypeError, ValueError, KeyError, IndexError): return 0 + # ── Per-device tokens (docs/09 §9.3) ───────────────────────────────── + + def issue_token(self, device_id: str) -> str: + """Mint (or return the existing) per-device token for a device. + + Idempotent: a device keeps its token across (re)connects. Creates the + device row on first sight (name defaults to the device_id; the SSE + open's upsert fills in the real name + push tokens).""" + with self._lock: + row = self._conn.execute( + "SELECT token FROM devices WHERE device_id = ?", (device_id,) + ).fetchone() + if row and row["token"]: + return row["token"] + token = generate_token() + now = time.time() + if row: + self._conn.execute( + "UPDATE devices SET token = ? WHERE device_id = ?", + (token, device_id), + ) + else: + self._conn.execute( + "INSERT INTO devices (device_id, name, token, last_seen, created)" + " VALUES (?, ?, ?, ?, ?)", + (device_id, device_id, token, now, now), + ) + self._conn.commit() + return token + + def reissue_token(self, device_id: str) -> str: + """Rotate the device's token (the old one stops working).""" + with self._lock: + token = generate_token() + self._conn.execute( + "UPDATE devices SET token = ? WHERE device_id = ?", + (token, device_id), + ) + self._conn.commit() + return token + + def token_for(self, device_id: str) -> str | None: + """The device's per-device token, or None (shared-token bootstrap).""" + with self._lock: + row = self._conn.execute( + "SELECT token FROM devices WHERE device_id = ?", (device_id,) + ).fetchone() + if row and row["token"]: + return row["token"] + return None + + # ── Revocation (docs/09 §9.3) ──────────────────────────────────────── + + def revoke(self, device_id: str) -> None: + """Revoke a single device: drop its row (token, push tokens, cursor) + and add its id to the denylist, so even the shared token no longer + works for it. Other devices are unaffected.""" + with self._lock: + self._conn.execute("DELETE FROM devices WHERE device_id = ?", (device_id,)) + self._conn.execute( + "INSERT OR REPLACE INTO revoked (device_id, revoked_at) VALUES (?, ?)", + (device_id, time.time()), + ) + self._conn.commit() + + def unrevoke(self, device_id: str) -> None: + """Remove a device from the denylist (operator re-pairing).""" + with self._lock: + self._conn.execute("DELETE FROM revoked WHERE device_id = ?", (device_id,)) + self._conn.commit() + + def is_revoked(self, device_id: str) -> bool: + with self._lock: + row = self._conn.execute( + "SELECT 1 FROM revoked WHERE device_id = ?", (device_id,) + ).fetchone() + return row is not None + + def list_revoked(self) -> list[dict[str, Any]]: + with self._lock: + rows = self._conn.execute( + "SELECT device_id, revoked_at FROM revoked ORDER BY revoked_at DESC" + ).fetchall() + return [{"device_id": r["device_id"], "revoked_at": r["revoked_at"]} for r in rows] + def get(self, device_id: str) -> dict[str, Any] | None: with self._lock: row = self._conn.execute( @@ -260,6 +360,9 @@ def _row_to_device(row: sqlite3.Row) -> dict[str, Any]: caps = {} except (json.JSONDecodeError, TypeError): caps = {} + # NOTE: the per-device ``token`` column is deliberately NOT included — + # device dicts flow into push fan-out and operator listings, and the + # token must never leave the registry (docs/09 §9.5). return { "device_id": row["device_id"], "name": row["name"], diff --git a/gateway-plugin/plugin.yaml b/gateway-plugin/plugin.yaml index fb2d467..21e417f 100644 --- a/gateway-plugin/plugin.yaml +++ b/gateway-plugin/plugin.yaml @@ -68,4 +68,4 @@ optional_env: - name: IRIS_WS_KEY description: "TLS key path for WSS (optional)" prompt: "WSS key" - password: false \ No newline at end of file + password: false diff --git a/gateway-plugin/protocol.py b/gateway-plugin/protocol.py index b038843..ab076a8 100644 --- a/gateway-plugin/protocol.py +++ b/gateway-plugin/protocol.py @@ -233,6 +233,7 @@ def hello_ack( sync_cursor: int = 0, channels: list | None = None, last_pushed_cursor: int = 0, + device_token: str = "", ) -> Frame: return Frame( type=TYPE_HELLO_ACK, @@ -245,6 +246,11 @@ def hello_ack( # notifications for sync-replayed frames at/below it (dedupe, # docs/08 §8.7). "last_pushed_cursor": last_pushed_cursor, + # Per-device token (docs/09 §9.3): minted at pairing, stored in + # devices.db. The app stores it and presents it instead of the + # shared IRIS_TOKEN from then on; empty when the gateway didn't + # issue one (legacy/unknown device). + "device_token": device_token, }, ) diff --git a/gateway-plugin/push.py b/gateway-plugin/push.py index 970f04a..f104903 100644 --- a/gateway-plugin/push.py +++ b/gateway-plugin/push.py @@ -149,9 +149,7 @@ class FcmBackend(PushBackend): } headers = {"kid": sa["private_key_id"]} if sa.get("private_key_id") else None try: - assertion = jwt.encode( - claims, sa["private_key"], algorithm="RS256", headers=headers - ) + assertion = jwt.encode(claims, sa["private_key"], algorithm="RS256", headers=headers) except Exception: logger.warning("iris: FCM JWT mint failed", exc_info=True) return None @@ -170,7 +168,8 @@ class FcmBackend(PushBackend): if resp.status_code != _HTTP_OK: logger.warning( "iris: FCM token exchange HTTP %s: %s", - resp.status_code, resp.text[:200], + resp.status_code, + resp.text[:200], ) return None try: @@ -223,9 +222,7 @@ class FcmBackend(PushBackend): message["notification"] = notification if data: message["data"] = data - message["android"] = { - "priority": "high" if priority == "high" else "normal" - } + message["android"] = {"priority": "high" if priority == "high" else "normal"} payload = {"message": message} auth = await self._authorization(client) if auth is None: @@ -242,9 +239,7 @@ class FcmBackend(PushBackend): return False if resp.status_code >= _HTTP_ERROR_MIN: # 404 NOT_FOUND = stale/invalid registration token. - logger.warning( - "iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200] - ) + logger.warning("iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]) return False return True @@ -269,10 +264,9 @@ class NtfyBackend(PushBackend): auth_token: str | None = None, ): self._topic = (topic or "").strip() or None - self._server = ( - (server_url or _DEFAULT_NTFY_SERVER).strip().rstrip("/") - or _DEFAULT_NTFY_SERVER - ) + self._server = (server_url or _DEFAULT_NTFY_SERVER).strip().rstrip( + "/" + ) or _DEFAULT_NTFY_SERVER self._auth_token = (auth_token or "").strip() or None @property @@ -311,16 +305,12 @@ class NtfyBackend(PushBackend): url = f"{self._server}/{quote(topic, safe='')}" try: async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client: - resp = await client.post( - url, content=text.encode("utf-8"), headers=headers - ) + resp = await client.post(url, content=text.encode("utf-8"), headers=headers) except Exception: logger.warning("iris: ntfy publish failed (network)", exc_info=True) return False if resp.status_code >= _HTTP_ERROR_MIN: - logger.warning( - "iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200] - ) + logger.warning("iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]) return False return True @@ -342,6 +332,4 @@ def build_push_backend( """ if (name or "").strip().lower() == "fcm": return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key) - return NtfyBackend( - topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token - ) + return NtfyBackend(topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token) diff --git a/gateway-plugin/ruff.toml b/gateway-plugin/ruff.toml index 1630ed5..f5e698a 100644 --- a/gateway-plugin/ruff.toml +++ b/gateway-plugin/ruff.toml @@ -43,3 +43,6 @@ max-args = 8 # The e2e / ws_probe drivers are assertion scripts: scenario numbers and # control-flow sprawl are intentional and not worth refactoring. "tests/**" = ["PLR2004", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLW1510"] +# The device-admin CLI is a small operator script: argv length checks are +# its natural shape. +"tools/**" = ["PLR2004"] diff --git a/gateway-plugin/tests/test_android.py b/gateway-plugin/tests/test_android.py index f2b38f0..4208d71 100644 --- a/gateway-plugin/tests/test_android.py +++ b/gateway-plugin/tests/test_android.py @@ -25,6 +25,7 @@ import hashlib import importlib.util import json import os +import sqlite3 import sys import socket import threading @@ -2392,6 +2393,266 @@ async def test_wrong_token_rejected(adapter): await adapter.disconnect() +# ── Per-device tokens + revocation (docs/09 §9.3, issue #11) ───────────── + + +def _sse_status(port: int, token: str, device_id: str) -> int: + """Open the SSE stream and return the HTTP status (200 = auth accepted, + 401 = rejected) without reading the stream body.""" + conn = HTTPConnection("127.0.0.1", port, timeout=5) + conn.request( + "GET", + "/v1/events", + headers={"Authorization": f"Bearer {token}", "X-Iris-Device": device_id}, + ) + resp = conn.getresponse() + status = resp.status + conn.close() + return status + + +@pytest.mark.asyncio +async def test_device_token_issued_in_hello_ack(adapter): + """Pairing mints a per-device token (64 hex) returned in + hello.ack.device_token; it is stable across (re)connects and stored in + the device registry (docs/09 §9.3).""" + await adapter.connect() + try: + port = adapter._http_server.bound_port + ws = HttpTestClient(port) + ack = await ws.start() + token = ack["payload"]["device_token"] + assert len(token) == 64 + int(token, 16) # hex + assert adapter._devices.token_for(DEVICE_ID) == token + await ws.close() + + # Reconnect: the SAME token is returned (idempotent minting). + ws2 = HttpTestClient(port) + ack2 = await ws2.start() + assert ack2["payload"]["device_token"] == token + await ws2.close() + finally: + await adapter.disconnect() + + +@pytest.mark.asyncio +async def test_device_token_accepted_and_shared_token_still_bootstraps(adapter): + """After pairing, the device's own token authenticates (POST /v1/frame + 202), a wrong device token is rejected (401), and the shared token + keeps working (bootstrap / legacy path).""" + await adapter.connect() + try: + port = adapter._http_server.bound_port + ws = HttpTestClient(port) + ack = await ws.start() + device_token = ack["payload"]["device_token"] + await ws.close() + + def _post(token: str) -> int: + conn = HTTPConnection("127.0.0.1", port, timeout=5) + conn.request( + "POST", + "/v1/frame", + body=b'{"type":"channel.list","id":"1"}', + headers={ + "Authorization": f"Bearer {token}", + "X-Iris-Device": DEVICE_ID, + "Content-Type": "application/json", + }, + ) + resp = conn.getresponse() + resp.read() + status = resp.status + conn.close() + return status + + # channel.list is a fast-response frame: 200 with the reply in the + # POST body (202 = plain accept-and-ack). Either proves auth passed. + assert await asyncio.to_thread(_post, device_token) in (200, 202) + assert await asyncio.to_thread(_post, "deadbeef" * 8) == 401 + assert await asyncio.to_thread(_post, TOKEN) in (200, 202) # shared token + finally: + await adapter.disconnect() + + +@pytest.mark.asyncio +async def test_revoked_device_rejected_even_with_shared_token(adapter): + """Revocation isolates ONE device: after revoke, neither its device + token nor the shared token authenticates it (401) — the denylist beats + both (docs/09 §9.3).""" + await adapter.connect() + try: + port = adapter._http_server.bound_port + ws = HttpTestClient(port) + ack = await ws.start() + device_token = ack["payload"]["device_token"] + await ws.close() + + adapter._devices.revoke(DEVICE_ID) + assert adapter._devices.is_revoked(DEVICE_ID) + assert adapter._devices.token_for(DEVICE_ID) is None + + assert await asyncio.to_thread(_sse_status, port, device_token, DEVICE_ID) == 401 + assert await asyncio.to_thread(_sse_status, port, TOKEN, DEVICE_ID) == 401 + finally: + await adapter.disconnect() + + +@pytest.mark.asyncio +async def test_revoke_does_not_affect_other_devices(adapter): + """Revoking device A leaves device B fully functional (the isolation + guarantee of per-device tokens, issue #11).""" + other = "dev_other0000000000001" + await adapter.connect() + try: + port = adapter._http_server.bound_port + ws = HttpTestClient(port) + ack = await ws.start() + device_token = ack["payload"]["device_token"] + await ws.close() + + # Device B pairs with the shared token (bootstrap) and gets its own + # token. + assert await asyncio.to_thread(_sse_status, port, TOKEN, other) == 200 + other_token = adapter._devices.token_for(other) + assert other_token and other_token != device_token + + adapter._devices.revoke(DEVICE_ID) + + # A is dead (both tokens); B is untouched (both tokens). + assert await asyncio.to_thread(_sse_status, port, device_token, DEVICE_ID) == 401 + assert await asyncio.to_thread(_sse_status, port, TOKEN, DEVICE_ID) == 401 + assert await asyncio.to_thread(_sse_status, port, other_token, other) == 200 + assert await asyncio.to_thread(_sse_status, port, TOKEN, other) == 200 + finally: + await adapter.disconnect() + + +@pytest.mark.asyncio +async def test_unrevoke_allows_repair_with_fresh_token(adapter): + """unrevoke lifts the denylist: the device pairs again with the shared + token and receives a FRESH per-device token (the old one is gone).""" + await adapter.connect() + try: + port = adapter._http_server.bound_port + ws = HttpTestClient(port) + ack = await ws.start() + old_token = ack["payload"]["device_token"] + await ws.close() + + adapter._devices.revoke(DEVICE_ID) + adapter._devices.unrevoke(DEVICE_ID) + + ws2 = HttpTestClient(port) + ack2 = await ws2.start() + new_token = ack2["payload"]["device_token"] + assert new_token and new_token != old_token + await ws2.close() + finally: + await adapter.disconnect() + + +# ── DeviceRegistry per-device token unit tests (no server) ──────────────── + + +def test_device_registry_token_migration_and_no_leak(tmp_path): + """A pre-token devices.db (no ``token`` column) migrates in place; + issued tokens are stored but never leak into device dicts (they flow + into push fan-out / operator listings).""" + plugin = _load_plugin() + db = tmp_path / "devices.db" + conn = sqlite3.connect(db) + conn.execute( + "CREATE TABLE devices (device_id TEXT PRIMARY KEY, name TEXT NOT NULL," + " caps TEXT NOT NULL DEFAULT '{}', fcm_token TEXT, ntfy_topic TEXT," + " last_seen REAL NOT NULL DEFAULT 0, created REAL NOT NULL DEFAULT 0)" + ) + conn.execute("INSERT INTO devices (device_id, name) VALUES ('old', 'Old')") + conn.commit() + conn.close() + + reg = plugin.pairing.DeviceRegistry(db) + try: + token = reg.issue_token("old") + assert len(token) == 64 + assert reg.issue_token("old") == token # idempotent + assert reg.token_for("old") == token + assert reg.token_for("unknown") is None + d = reg.get("old") + assert d is not None and "token" not in d + assert "token" not in {k for dev in reg.list() for k in dev} + reg.reissue_token("old") + assert reg.token_for("old") != token + finally: + reg.close() + + +def test_device_registry_revoke_unrevoke(tmp_path): + """revoke drops the device row + denylists the id; unrevoke lifts the + denylist; list_revoked reports the denylist.""" + plugin = _load_plugin() + reg = plugin.pairing.DeviceRegistry(tmp_path / "devices.db") + try: + reg.upsert("a", "A", {}) + reg.upsert("b", "B", {}) + reg.issue_token("a") + reg.revoke("a") + assert reg.is_revoked("a") + assert not reg.is_revoked("b") + assert reg.get("a") is None # row (token, push state) gone + assert reg.get("b") is not None # other device untouched + assert [r["device_id"] for r in reg.list_revoked()] == ["a"] + reg.unrevoke("a") + assert not reg.is_revoked("a") + assert reg.list_revoked() == [] + finally: + reg.close() + + +def test_offer_device_removal_setup_flow(tmp_path, monkeypatch): + """Setup-flow device removal (docs/09 §9.3): ask (default No) → + numbered menu (last option = exit the loop, not the setup) → + confirmation → back to the menu. A declined confirmation loops back; + removing the last device ends the loop.""" + plugin = _load_plugin() + reg = plugin.pairing.DeviceRegistry(tmp_path / "iris" / "devices.db") + reg.upsert("dev_a", "Phone A", {}) + reg.upsert("dev_b", "Phone B", {}) + reg.close() + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + def _run(answers: list[str]) -> None: + answers_iter = iter(answers) + monkeypatch.setattr("builtins.input", lambda *a: next(answers_iter)) # type: ignore[arg-type] + plugin.adapter._offer_device_removal() + + # 1) default No: nothing happens, no menu. + _run(["n"]) + reg = plugin.pairing.DeviceRegistry(tmp_path / "iris" / "devices.db") + assert not reg.is_revoked("dev_a") and not reg.is_revoked("dev_b") + reg.close() + + # 2) yes → menu → pick 1 → decline confirm → back to menu → Enter + # (default = exit): nothing removed. + _run(["y", "1", "n", ""]) + reg = plugin.pairing.DeviceRegistry(tmp_path / "iris" / "devices.db") + assert not reg.is_revoked("dev_a") and not reg.is_revoked("dev_b") + reg.close() + + # 3) yes → pick 1 → confirm → back to menu → pick 1 (the remaining + # device) → confirm → no devices left → loop ends. + _run(["y", "1", "y", "1", "y"]) + reg = plugin.pairing.DeviceRegistry(tmp_path / "iris" / "devices.db") + assert reg.is_revoked("dev_a") + assert reg.is_revoked("dev_b") + assert reg.get("dev_a") is None and reg.get("dev_b") is None + reg.close() + + # 4) no devices left: the question is not asked at all. + _run([]) # any input() call would raise StopIteration → test fails + + # ── M2: tool-detail capture (verbose args + post_tool_call output) ───────── diff --git a/gateway-plugin/tools/iris_devices.py b/gateway-plugin/tools/iris_devices.py new file mode 100644 index 0000000..6de720f --- /dev/null +++ b/gateway-plugin/tools/iris_devices.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Iris device administration (docs/09 §9.3): list / revoke / re-pair devices. + +Per-device tokens are minted automatically at pairing (the gateway returns +them in ``hello.ack.device_token``); this tool is the operator's control +surface for the registry under ``/iris/devices.db``: + + iris_devices.py list show paired devices + revoked ids + iris_devices.py revoke revoke ONE device (its token stops + working AND the shared token no longer + authenticates it; other devices are + unaffected) + iris_devices.py unrevoke allow the device to pair again + iris_devices.py reissue rotate the device's token (the old + one stops working; the app picks up + the new one on its next (re)connect) + +The hermes home is resolved like the gateway: ``HERMES_HOME`` env var, else +``~/.hermes`` (``hermes_constants.get_hermes_home`` when importable, so an +active profile is honored). Run it on the gateway host — the registry is +local state. + +Zero dependencies (stdlib only). +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +_USAGE = """\ +usage: iris_devices.py [device_id] + +commands: + list show paired devices + revoked ids + revoke revoke ONE device (its token stops working AND the + shared token no longer authenticates it; other + devices are unaffected) + unrevoke allow the device to pair again + reissue rotate the device's token (the old one stops working; + the app picks up the new one on its next (re)connect) +""" + + +def _plugin_dir() -> Path: + return Path(__file__).resolve().parents[1] + + +def _hermes_home() -> Path: + import os + + env = os.environ.get("HERMES_HOME", "").strip() + if env: + return Path(env) + try: + from hermes_constants import get_hermes_home + + return Path(get_hermes_home()) + except ImportError: + return Path.home() / ".hermes" + + +def _registry(): + sys.path.insert(0, str(_plugin_dir())) + from pairing import DeviceRegistry + + return DeviceRegistry(_hermes_home() / "iris" / "devices.db") + + +def _fmt_ts(ts: float) -> str: + try: + return time.strftime("%Y-%m-%d %H:%M", time.localtime(float(ts))) + except (TypeError, ValueError, OSError): + return "?" + + +def cmd_list(reg) -> int: + devices = reg.list() + revoked = reg.list_revoked() + if not devices and not revoked: + print("No paired devices.") + return 0 + if devices: + print(f"{'DEVICE ID':<24} {'NAME':<24} {'TOKEN':<6} {'LAST SEEN':<17} CREATED") + for d in devices: + has_token = "yes" if reg.token_for(d["device_id"]) else "no" + print( + f"{d['device_id']:<24} {d['name'][:23]:<24} {has_token:<6} " + f"{_fmt_ts(d['last_seen']):<17} {_fmt_ts(d['created'])}" + ) + if revoked: + print("\nRevoked (rejected even with the shared token):") + for r in revoked: + print(f" {r['device_id']} (revoked {_fmt_ts(r['revoked_at'])})") + return 0 + + +def cmd_revoke(reg, device_id: str) -> int: + if not reg.is_revoked(device_id) and reg.get(device_id) is None: + print(f"unknown device: {device_id}") + return 1 + reg.revoke(device_id) + print(f"revoked {device_id} — it can no longer connect (shared token included).") + print("Re-pairing requires: unrevoke (or the app gets a fresh device id).") + return 0 + + +def cmd_unrevoke(reg, device_id: str) -> int: + if not reg.is_revoked(device_id): + print(f"not revoked: {device_id}") + return 1 + reg.unrevoke(device_id) + print(f"unrevoked {device_id} — it can pair again (a fresh token is minted).") + return 0 + + +def cmd_reissue(reg, device_id: str) -> int: + if reg.get(device_id) is None: + print(f"unknown device: {device_id}") + return 1 + reg.reissue_token(device_id) + print(f"reissued the token for {device_id} — the old one is dead.") + print("The app picks up the new token on its next (re)connect (hello.ack).") + return 0 + + +def main(argv: list[str]) -> int: + args = argv[1:] + if not args or args[0] in ("-h", "--help", "help"): + print(_USAGE.strip()) + return 0 if args else 2 + reg = _registry() + try: + cmd, rest = args[0], args[1:] + if cmd == "list": + return cmd_list(reg) + if cmd in ("revoke", "unrevoke", "reissue"): + if not rest or rest[1:]: + print(f"usage: {Path(sys.argv[0]).name} {cmd} ") + return 2 + return {"revoke": cmd_revoke, "unrevoke": cmd_unrevoke, "reissue": cmd_reissue}[cmd]( + reg, rest[0] + ) + print(f"unknown command: {cmd}") + print(_USAGE.strip()) + return 2 + finally: + reg.close() + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv))