Per-device tokens with revocation (issue #11)
CI / Gateway plugin tests (push) Successful in 5m5s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m50s

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:
ARIA committed 2026-08-24 19:37:44 +02:00
1 parent 746d809d48
commit 7faaf2aa1c
20 files changed
+827 -56

No files matched your search

@@ -76,6 +76,10 @@ class AndroidSecureStore(
get() = prefs.getString(KEY_TOKEN, "").orEmpty() get() = prefs.getString(KEY_TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply() 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 override val deviceId: String
get() { get() {
var id = prefs.getString(KEY_DEVICE_ID, null) var id = prefs.getString(KEY_DEVICE_ID, null)
@@ -176,6 +180,9 @@ class AndroidSecureStore(
) { ) {
serverUrl = url serverUrl = url
this.token = token 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() { override fun clear() {
@@ -187,6 +194,7 @@ class AndroidSecureStore(
.edit() .edit()
.remove(KEY_URL) .remove(KEY_URL)
.remove(KEY_TOKEN) .remove(KEY_TOKEN)
.remove(KEY_DEVICE_TOKEN)
.remove(KEY_DEVICE_ID) .remove(KEY_DEVICE_ID)
.remove(KEY_SYNC_CURSOR) .remove(KEY_SYNC_CURSOR)
.remove(KEY_FCM_TOKEN) .remove(KEY_FCM_TOKEN)
@@ -201,6 +209,7 @@ class AndroidSecureStore(
const val SECURE_PREFS_NAME = "iris_secure" const val SECURE_PREFS_NAME = "iris_secure"
const val KEY_URL = "server_url" const val KEY_URL = "server_url"
const val KEY_TOKEN = "token" const val KEY_TOKEN = "token"
const val KEY_DEVICE_TOKEN = "device_token"
const val KEY_DEVICE_ID = "device_id" const val KEY_DEVICE_ID = "device_id"
const val KEY_SYNC_CURSOR = "sync_cursor" const val KEY_SYNC_CURSOR = "sync_cursor"
const val KEY_FCM_TOKEN = "fcm_token" const val KEY_FCM_TOKEN = "fcm_token"
@@ -9,9 +9,14 @@ interface SecureStore {
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */ /** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
var serverUrl: String var serverUrl: String
/** IRIS_TOKEN presented in the auth header. */ /** IRIS_TOKEN presented in the auth header (bootstrap / fallback). */
var token: String 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). */ /** Stable app-generated device id (persisted). */
val deviceId: String val deviceId: String
@@ -195,7 +195,9 @@ class GatewayClient(
private suspend fun connectLoop() { private suspend fun connectLoop() {
while (currentCoroutineContext().isActive) { while (currentCoroutineContext().isActive) {
val url = store.serverUrl.trim() 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()) { if (url.isBlank() || token.isBlank()) {
_state.value = State.Disconnected _state.value = State.Disconnected
return return
@@ -278,13 +280,15 @@ class GatewayClient(
private fun httpGateway(): HttpGateway? = private fun httpGateway(): HttpGateway? =
synchronized(this) { synchronized(this) {
val url = store.serverUrl.trim() val url = store.serverUrl.trim()
val token = store.token val token = store.deviceToken.ifBlank { store.token }
if (url.isBlank() || token.isBlank()) return@synchronized null if (url.isBlank() || token.isBlank()) return@synchronized null
http http
?: HttpGateway( ?: HttpGateway(
client, client,
HttpGateway.deriveHttpUrl(url), 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, store.deviceId,
deviceName = store.deviceName, deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } }, fcmToken = { store.fcmToken.ifBlank { null } },
@@ -356,6 +360,13 @@ class GatewayClient(
/** The SSE `event: hello` (the HTTP hello.ack). */ /** The SSE `event: hello` (the HTTP hello.ack). */
private fun onHttpHello(ack: HelloAckPayload) { private fun onHttpHello(ack: HelloAckPayload) {
lastAck = ack 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) val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
_state.value = connected _state.value = connected
// M5: reconnect catch-up — replay frames parked while offline. // M5: reconnect catch-up — replay frames parked while offline.
@@ -522,7 +533,10 @@ class GatewayClient(
HttpGateway( HttpGateway(
client, client,
HttpGateway.deriveHttpUrl(url), 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, store.deviceId,
deviceName = store.deviceName, deviceName = store.deviceName,
fcmToken = { store.fcmToken.ifBlank { null } }, fcmToken = { store.fcmToken.ifBlank { null } },
@@ -38,7 +38,11 @@ import java.util.concurrent.TimeUnit
class HttpGateway( class HttpGateway(
private val client: OkHttpClient, private val client: OkHttpClient,
private val baseUrl: String, 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, private val deviceId: String,
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway /** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
* upserts it into the device registry on every SSE open — the HTTP * upserts it into the device registry on every SSE open — the HTTP
@@ -123,7 +127,7 @@ class HttpGateway(
val b = val b =
Headers Headers
.Builder() .Builder()
.add("Authorization", "Bearer $token") .add("Authorization", "Bearer ${token()}")
.add("X-Iris-Device", deviceId) .add("X-Iris-Device", deviceId)
// Device registration (docs/19): the gateway upserts name + push // Device registration (docs/19): the gateway upserts name + push
// tokens from these headers on every SSE open (COALESCE — absent // tokens from these headers on every SSE open (COALESCE — absent
@@ -176,6 +176,11 @@ data class HelloAckPayload(
* push backend (0 = never). Sync-replayed frames at/below it must not * push backend (0 = never). Sync-replayed frames at/below it must not
* re-post system notifications (dedupe, docs/08 §8.7). */ * re-post system notifications (dedupe, docs/08 §8.7). */
@SerialName("last_pushed_cursor") val lastPushedCursor: Long = 0, @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) ───────────────────────────────────────────── // ── message (server -> app) ─────────────────────────────────────────────
@@ -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<HelloAckPayload>()
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<HelloAckPayload>()
assertEquals("", p?.deviceToken)
}
}
@@ -27,7 +27,24 @@ class DesktopSecureStore : SecureStore {
private val baseDir = File(System.getProperty("user.home"), ".iris") private val baseDir = File(System.getProperty("user.home"), ".iris")
private val settingsFile = File(baseDir, "settings.json") private val settingsFile = File(baseDir, "settings.json")
private val legacyFile = File(baseDir, "pairing.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 // 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. // 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()) 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 override val deviceId: String
get() { get() {
val d = load() val d = load()
@@ -268,6 +291,9 @@ class DesktopSecureStore : SecureStore {
val d = load() val d = load()
save(d.copy(serverUrl = url.trim())) save(d.copy(serverUrl = url.trim()))
if (token.isBlank()) secret.clear() else secret.write(token.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() { override fun clear() {
@@ -288,6 +314,7 @@ class DesktopSecureStore : SecureStore {
), ),
) )
secret.clear() secret.clear()
deviceSecret.clear()
} }
} }
@@ -306,13 +333,21 @@ private data class PairingData(
/** /**
* Token storage: OS keyring when available, else an AES-GCM encrypted file. * Token storage: OS keyring when available, else an AES-GCM encrypted file.
* All backend failures degrade to the encrypted file (never plaintext). * 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 class SecretBackend(
private val baseDir: File, 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 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() fun read(): String? = keyring?.read() ?: readEncrypted()
@@ -388,7 +423,11 @@ private class SecretBackend(
} }
/** OS keyring via the platform CLI (best effort). */ /** 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 os = System.getProperty("os.name").lowercase()
private val isMac = os.contains("mac") private val isMac = os.contains("mac")
private val isLinux = os.contains("linux") private val isLinux = os.contains("linux")
@@ -407,9 +446,9 @@ private class KeyringBackend {
fun read(): String? = fun read(): String? =
try { try {
if (isMac) { 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 { } else {
out(listOf("secret-tool", "lookup", "app", "iris")) out(listOf("secret-tool", "lookup", "app", attr))
} }
} catch (_: Exception) { } catch (_: Exception) {
null null
@@ -430,11 +469,11 @@ private class KeyringBackend {
"-a", "-a",
"iris", "iris",
"-s", "-s",
"iris-gateway-token", service,
"-w", "-w",
) )
} else { } else {
listOf("secret-tool", "store", "--label=Iris gateway token", "app", "iris") listOf("secret-tool", "store", "--label=$label", "app", attr)
} }
ProcessBuilder(cmd).start().apply { ProcessBuilder(cmd).start().apply {
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) } outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
@@ -453,14 +492,14 @@ private class KeyringBackend {
"-a", "-a",
"iris", "iris",
"-s", "-s",
"iris-gateway-token", service,
).inheritIO().start().waitFor() ).inheritIO().start().waitFor()
} else { } else {
ProcessBuilder( ProcessBuilder(
"secret-tool", "secret-tool",
"clear", "clear",
"app", "app",
"iris", attr,
).inheritIO().start().waitFor() ).inheritIO().start().waitFor()
} }
} catch (_: Exception) { } catch (_: Exception) {
+3
View File
@@ -50,6 +50,7 @@ iris_x_hermes/
## Module responsibilities ## Module responsibilities
### `gateway-plugin/` (Python) ### `gateway-plugin/` (Python)
- **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`, - **`plugin.yaml`** — manifest: `name: iris-platform`, `kind: platform`,
`requires_env` / `optional_env` (surfaced in `hermes config`/setup). `requires_env` / `optional_env` (surfaced in `hermes config`/setup).
- **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`. - **`adapter.py`** — `IrisAdapter(BasePlatformAdapter)` + `register(ctx)`.
@@ -68,6 +69,7 @@ iris_x_hermes/
- **`search.py`** — FTS5 query bridge over the hermes session store. - **`search.py`** — FTS5 query bridge over the hermes session store.
### `app/shared` (Kotlin KMP) ### `app/shared` (Kotlin KMP)
- **`commonMain`** — protocol models (kotlinx-serialization), `GatewayClient` - **`commonMain`** — protocol models (kotlinx-serialization), `GatewayClient`
(OkHttp WS), repositories (Room), ViewModels (StateFlow), and the Compose UI (OkHttp WS), repositories (Room), ViewModels (StateFlow), and the Compose UI
(design system, screens). ~80% of app code. (design system, screens). ~80% of app code.
@@ -77,6 +79,7 @@ iris_x_hermes/
window management, `MediaPlayer` actual. window management, `MediaPlayer` actual.
### `app/androidApp` / `app/desktopApp` ### `app/androidApp` / `app/desktopApp`
Thin shells: `Application`/`MainActivity` (Android) and `main()`/window Thin shells: `Application`/`MainActivity` (Android) and `main()`/window
(Desktop). They compose the `shared` UI and inject platform services. (Desktop). They compose the `shared` UI and inject platform services.
+7
View File
@@ -43,6 +43,7 @@ Pairing succeeded.
"search":true,"push":"fcm","pickers":true}, "search":true,"push":"fcm","pickers":true},
"sync_cursor":1042, "sync_cursor":1042,
"last_pushed_cursor":1040, "last_pushed_cursor":1040,
"device_token":"9f2c…(64 hex)",
"channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}] "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 for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
woke the device via push (dedupe, `08-push.md` §8.7). 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` ### `message`
A final / standalone message. A final / standalone message.
+50 -12
View File
@@ -27,8 +27,10 @@
4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN` 4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN`
(`hmac.compare_digest`). Optionally check `device_id` against (`hmac.compare_digest`). Optionally check `device_id` against
`IRIS_ALLOWED_USERS` (if set) or `IRIS_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`. 5. **On success:** register the device in `devices.db`, **mint its
**On failure:** send `error {code:"auth"}` and close. 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 `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 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 ## 9.3 Auth model
- **Token = the security principal.** Any connection presenting the valid - **Two tokens, one principal per device.**
`IRIS_TOKEN` is authorized (it's the user's own token). - **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 - **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
`device_id`s) restricts which *devices* may connect even with the token — `device_id`s) restricts which *devices* may connect even with a valid
useful if the token is shared. `IRIS_ALLOW_ALL_USERS=true` disables the token — useful if the shared token is exposed. `IRIS_ALLOW_ALL_USERS=true`
allowlist (dev only). disables the allowlist (dev only).
- **Per-device tokens (stretch):** mint a unique token per device at pairing - **Re-pairing / rotation.** Rotating `IRIS_TOKEN` no longer invalidates
(revocable) instead of one shared token. v1 uses the shared token + optional paired devices: they authenticate with their per-device tokens, which
device allowlist. survive the rotation. Only bootstrap of NEW devices needs the new shared
- **Re-pairing:** rotating `IRIS_TOKEN` invalidates all devices; they must token. (Legacy devices without a per-device token still re-pair, as
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR. before.)
## 9.4 Transport security ## 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`) | | 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` | | 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` | | 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 |
+12
View File
@@ -9,6 +9,7 @@ First-time setup on a machine (verified baseline: CachyOS/Arch, `pacman`,
pacman -S jdk17-openjdk pacman -S jdk17-openjdk
java -version # expect 17.x java -version # expect 17.x
``` ```
(Compose Multiplatform + current AGP are happy on JDK 17. Use 17 to match the (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.) 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 --licenses
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0" sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
``` ```
Persist `ANDROID_HOME`/`PATH` in `~/.bashrc`. ADB is already installed system-wide; 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`). `platform-tools` from the SDK is fine too (whichever is first on `PATH`).
Create `app/local.properties`: Create `app/local.properties`:
``` ```
sdk.dir=/home/<you>/android-sdk sdk.dir=/home/<you>/android-sdk
``` ```
@@ -39,10 +42,12 @@ sdk.dir=/home/<you>/android-sdk
## 12.3 Gradle ## 12.3 Gradle
No system install — use the project wrapper: No system install — use the project wrapper:
```bash ```bash
cd app cd app
./gradlew tasks # first run downloads the wrapper distribution ./gradlew tasks # first run downloads the wrapper distribution
``` ```
(The wrapper version is pinned in `app/gradle/wrapper/gradle-wrapper.properties`.) (The wrapper version is pinned in `app/gradle/wrapper/gradle-wrapper.properties`.)
## 12.4 hermes environment (for the plugin + running the gateway) ## 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 source .venv/bin/activate
hermes --version # sanity hermes --version # sanity
``` ```
- Run the gateway with the plugin: - Run the gateway with the plugin:
```bash ```bash
# install the plugin (dev: symlink) # install the plugin (dev: symlink)
mkdir -p ~/.hermes/plugins mkdir -p ~/.hermes/plugins
@@ -61,7 +68,9 @@ hermes --version # sanity
hermes gateway status # should list "iris" hermes gateway status # should list "iris"
hermes gateway # run hermes gateway # run
``` ```
- Tests use hermes's hermetic runner (never bare `pytest`): - Tests use hermes's hermetic runner (never bare `pytest`):
```bash ```bash
scripts/run_tests.sh tests/gateway/test_android.py scripts/run_tests.sh tests/gateway/test_android.py
``` ```
@@ -84,6 +93,7 @@ hermes --version # sanity
## 12.6 Environment variables (summary) ## 12.6 Environment variables (summary)
**Secrets (`~/.hermes/.env`):** **Secrets (`~/.hermes/.env`):**
``` ```
IRIS_TOKEN=<64-hex> IRIS_TOKEN=<64-hex>
IRIS_PUSH_BACKEND=ntfy # default; fcm = opt-in (metadata via Google) 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`):** **Behavioral (`~/.hermes/config.yaml`):**
```yaml ```yaml
gateway: gateway:
platforms: platforms:
@@ -135,5 +146,6 @@ async def main():
asyncio.run(main()) asyncio.run(main())
PY PY
``` ```
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
wrong. wrong.
+1
View File
@@ -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"} } }, "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" }, "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)." }, "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" } } "channels": { "type": "array", "items": { "$ref": "#/definitions/channel" } }
} }
}, },
+64
View File
@@ -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: def interactive_setup() -> None:
"""Prompt for the pairing token / host / port / push backend. """Prompt for the pairing token / host / port / push backend.
@@ -1190,6 +1250,10 @@ def interactive_setup() -> None:
else: else:
print_info("Existing IRIS_TOKEN found (not shown).") 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) host = prompt("Bind host", default=get_env_value("IRIS_WS_HOST") or DEFAULT_HOST)
save_env_value("IRIS_WS_HOST", 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 # _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
+29 -4
View File
@@ -321,16 +321,32 @@ class HttpServer:
def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None: def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None:
"""Verify Bearer token + device identity. Returns the device_id, or """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 "" auth = handler.headers.get("Authorization") or ""
token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None 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() device_id = (handler.headers.get("X-Iris-Device") or "").strip()
if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN: if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN:
_send_json(handler, 401, {"error": "X-Iris-Device header required"}) _send_json(handler, 401, {"error": "X-Iris-Device header required"})
return None 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 ( if (
not self._adapter.allow_all not self._adapter.allow_all
and self._adapter.allowed_users and self._adapter.allowed_users
@@ -484,6 +500,14 @@ class HttpServer:
) )
except Exception: except Exception:
logger.warning("iris: device registry upsert failed", exc_info=True) 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") sub = _Subscriber(device_id=device_id, kind="sse")
# Register BEFORE the replay so a frame appended in between is # Register BEFORE the replay so a frame appended in between is
# fanned out to us (and de-duped by cursor below) instead of lost. # 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(), sync_cursor=self._adapter._outbox.latest_cursor(),
channels=self._adapter.channel_list(), channels=self._adapter.channel_list(),
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id), 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(handler, "hello", None, hello.to_json())
self._write_sse( self._write_sse(
+103
View File
@@ -150,6 +150,21 @@ class DeviceRegistry:
self._conn.execute( self._conn.execute(
"ALTER TABLE devices ADD COLUMN last_pushed_cursor INTEGER NOT NULL DEFAULT 0" "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() self._conn.commit()
def upsert( def upsert(
@@ -235,6 +250,91 @@ class DeviceRegistry:
except (TypeError, ValueError, KeyError, IndexError): except (TypeError, ValueError, KeyError, IndexError):
return 0 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: def get(self, device_id: str) -> dict[str, Any] | None:
with self._lock: with self._lock:
row = self._conn.execute( row = self._conn.execute(
@@ -260,6 +360,9 @@ def _row_to_device(row: sqlite3.Row) -> dict[str, Any]:
caps = {} caps = {}
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
caps = {} 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 { return {
"device_id": row["device_id"], "device_id": row["device_id"],
"name": row["name"], "name": row["name"],
+6
View File
@@ -233,6 +233,7 @@ def hello_ack(
sync_cursor: int = 0, sync_cursor: int = 0,
channels: list | None = None, channels: list | None = None,
last_pushed_cursor: int = 0, last_pushed_cursor: int = 0,
device_token: str = "",
) -> Frame: ) -> Frame:
return Frame( return Frame(
type=TYPE_HELLO_ACK, type=TYPE_HELLO_ACK,
@@ -245,6 +246,11 @@ def hello_ack(
# notifications for sync-replayed frames at/below it (dedupe, # notifications for sync-replayed frames at/below it (dedupe,
# docs/08 §8.7). # docs/08 §8.7).
"last_pushed_cursor": last_pushed_cursor, "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,
}, },
) )
+11 -23
View File
@@ -149,9 +149,7 @@ class FcmBackend(PushBackend):
} }
headers = {"kid": sa["private_key_id"]} if sa.get("private_key_id") else None headers = {"kid": sa["private_key_id"]} if sa.get("private_key_id") else None
try: try:
assertion = jwt.encode( assertion = jwt.encode(claims, sa["private_key"], algorithm="RS256", headers=headers)
claims, sa["private_key"], algorithm="RS256", headers=headers
)
except Exception: except Exception:
logger.warning("iris: FCM JWT mint failed", exc_info=True) logger.warning("iris: FCM JWT mint failed", exc_info=True)
return None return None
@@ -170,7 +168,8 @@ class FcmBackend(PushBackend):
if resp.status_code != _HTTP_OK: if resp.status_code != _HTTP_OK:
logger.warning( logger.warning(
"iris: FCM token exchange HTTP %s: %s", "iris: FCM token exchange HTTP %s: %s",
resp.status_code, resp.text[:200], resp.status_code,
resp.text[:200],
) )
return None return None
try: try:
@@ -223,9 +222,7 @@ class FcmBackend(PushBackend):
message["notification"] = notification message["notification"] = notification
if data: if data:
message["data"] = data message["data"] = data
message["android"] = { message["android"] = {"priority": "high" if priority == "high" else "normal"}
"priority": "high" if priority == "high" else "normal"
}
payload = {"message": message} payload = {"message": message}
auth = await self._authorization(client) auth = await self._authorization(client)
if auth is None: if auth is None:
@@ -242,9 +239,7 @@ class FcmBackend(PushBackend):
return False return False
if resp.status_code >= _HTTP_ERROR_MIN: if resp.status_code >= _HTTP_ERROR_MIN:
# 404 NOT_FOUND = stale/invalid registration token. # 404 NOT_FOUND = stale/invalid registration token.
logger.warning( logger.warning("iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200])
"iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
)
return False return False
return True return True
@@ -269,10 +264,9 @@ class NtfyBackend(PushBackend):
auth_token: str | None = None, auth_token: str | None = None,
): ):
self._topic = (topic or "").strip() or None self._topic = (topic or "").strip() or None
self._server = ( self._server = (server_url or _DEFAULT_NTFY_SERVER).strip().rstrip(
(server_url or _DEFAULT_NTFY_SERVER).strip().rstrip("/") "/"
or _DEFAULT_NTFY_SERVER ) or _DEFAULT_NTFY_SERVER
)
self._auth_token = (auth_token or "").strip() or None self._auth_token = (auth_token or "").strip() or None
@property @property
@@ -311,16 +305,12 @@ class NtfyBackend(PushBackend):
url = f"{self._server}/{quote(topic, safe='')}" url = f"{self._server}/{quote(topic, safe='')}"
try: try:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client: async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
resp = await client.post( resp = await client.post(url, content=text.encode("utf-8"), headers=headers)
url, content=text.encode("utf-8"), headers=headers
)
except Exception: except Exception:
logger.warning("iris: ntfy publish failed (network)", exc_info=True) logger.warning("iris: ntfy publish failed (network)", exc_info=True)
return False return False
if resp.status_code >= _HTTP_ERROR_MIN: if resp.status_code >= _HTTP_ERROR_MIN:
logger.warning( logger.warning("iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200])
"iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
)
return False return False
return True return True
@@ -342,6 +332,4 @@ def build_push_backend(
""" """
if (name or "").strip().lower() == "fcm": if (name or "").strip().lower() == "fcm":
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key) return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)
return NtfyBackend( return NtfyBackend(topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token)
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
)
+3
View File
@@ -43,3 +43,6 @@ max-args = 8
# The e2e / ws_probe drivers are assertion scripts: scenario numbers and # The e2e / ws_probe drivers are assertion scripts: scenario numbers and
# control-flow sprawl are intentional and not worth refactoring. # control-flow sprawl are intentional and not worth refactoring.
"tests/**" = ["PLR2004", "PLR0911", "PLR0912", "PLR0913", "PLR0915", "PLW1510"] "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"]
+261
View File
@@ -25,6 +25,7 @@ import hashlib
import importlib.util import importlib.util
import json import json
import os import os
import sqlite3
import sys import sys
import socket import socket
import threading import threading
@@ -2392,6 +2393,266 @@ async def test_wrong_token_rejected(adapter):
await adapter.disconnect() 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) ───────── # ── M2: tool-detail capture (verbose args + post_tool_call output) ─────────
+153
View File
@@ -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 ``<hermes-home>/iris/devices.db``:
iris_devices.py list show paired devices + revoked ids
iris_devices.py revoke <device_id> revoke ONE device (its token stops
working AND the shared token no longer
authenticates it; other devices are
unaffected)
iris_devices.py unrevoke <device_id> allow the device to pair again
iris_devices.py reissue <device_id> 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 <command> [device_id]
commands:
list show paired devices + revoked ids
revoke <device_id> revoke ONE device (its token stops working AND the
shared token no longer authenticates it; other
devices are unaffected)
unrevoke <device_id> allow the device to pair again
reissue <device_id> 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 <device_id> (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} <device_id>")
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))