Per-device tokens with revocation (issue #11)
Auth previously used the shared IRIS_TOKEN as the security principal: a leaked token meant access to all devices, and a compromised device could not be isolated. Gateway: - pairing.py: devices.token column (in-place migration) + revoked denylist table; issue_token (idempotent, 64 hex), token_for, reissue_token, revoke/unrevoke/is_revoked/list_revoked. The token never leaks into device dicts (push fan-out / listings). - http_server.py: auth accepts the shared token (bootstrap/legacy) OR the device's own token (both constant-time); a revoked device_id is rejected with 401 before either comparison. On SSE open (pairing) the per-device token is minted and returned in hello.ack. - protocol.py: hello_ack(..., device_token). - adapter.py: setup flow (hermes gateway setup -> Iris) now offers 'Remove a paired device?' on an existing setup: numbered select menu (last option = exit the removal loop), confirmation, back to the menu for further removals. - tools/iris_devices.py: operator CLI (list / revoke / unrevoke / reissue), stdlib only. App: - SecureStore.deviceToken (Android: EncryptedSharedPreferences; Desktop: second keyring slot iris-device-token / device_token.enc). - HelloAckPayload.deviceToken; GatewayClient stores it on hello and presents it instead of the shared token from then on (live provider in HttpGateway); savePairing/clear wipe it for re-pairing. Docs: 09 §9.3 stretch -> implemented (revocation semantics, both control surfaces), 04 hello.ack example, frames.schema.json, M7 row 13. Tests: 8 new Python tests (issuance, acceptance, revocation, isolation, unrevoke, registry unit x2, setup-flow menu) - 94/94 pass; 2 new Kotlin wire tests - green. Live-verified against a running gateway (hello.ack token matches devices.db; revoke -> 401 even with shared token; unrevoke -> 200; setup TUI both paths).
This commit is contained in:
1 parent
746d809d48
commit
7faaf2aa1c
22 files changed
+832
-61
No files matched your search
@@ -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`) |
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ─────────────────────────────────────────────
|
||||
|
||||
@@ -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 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) {
|
||||
|
||||
+3
-3
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ Pairing succeeded.
|
||||
"search":true,"push":"fcm","pickers":true},
|
||||
"sync_cursor":1042,
|
||||
"last_pushed_cursor":1040,
|
||||
"device_token":"9f2c…(64 hex)",
|
||||
"channels":[{"chat_id":"default","name":"Default","kind":"default","is_default":true}]
|
||||
}}
|
||||
```
|
||||
@@ -52,6 +53,12 @@ device via the push backend (0 = never). The app skips system notifications
|
||||
for sync-replayed frames with `cursor <= last_pushed_cursor` — they already
|
||||
woke the device via push (dedupe, `08-push.md` §8.7).
|
||||
|
||||
`device_token` is the per-device token minted at pairing (docs/09 §9.3):
|
||||
the app stores it and presents it in the `Authorization` header INSTEAD of
|
||||
the shared `IRIS_TOKEN` from then on, so the gateway can revoke one device
|
||||
without affecting the others. Empty when the gateway didn't issue one
|
||||
(legacy).
|
||||
|
||||
### `message`
|
||||
|
||||
A final / standalone message.
|
||||
|
||||
+50
-12
@@ -27,8 +27,10 @@
|
||||
4. **Server verifies.** Constant-time compare of `token` vs `IRIS_TOKEN`
|
||||
(`hmac.compare_digest`). Optionally check `device_id` against
|
||||
`IRIS_ALLOWED_USERS` (if set) or `IRIS_ALLOW_ALL_USERS`.
|
||||
5. **On success:** register the device in `devices.db`, send `hello.ack`.
|
||||
**On failure:** send `error {code:"auth"}` and close.
|
||||
5. **On success:** register the device in `devices.db`, **mint its
|
||||
per-device token** (if it has none yet) and return it in
|
||||
`hello.ack.device_token`. **On failure:** send `error {code:"auth"}`
|
||||
and close.
|
||||
|
||||
`device_id` is a stable, app-generated UUID (persisted in the app's
|
||||
secure storage). It identifies the device for routing + push, **not** as a
|
||||
@@ -36,17 +38,52 @@ security principal (the token is).
|
||||
|
||||
## 9.3 Auth model
|
||||
|
||||
- **Token = the security principal.** Any connection presenting the valid
|
||||
`IRIS_TOKEN` is authorized (it's the user's own token).
|
||||
- **Two tokens, one principal per device.**
|
||||
- **Shared `IRIS_TOKEN` (bootstrap):** the setup token from
|
||||
`hermes gateway setup`. It authorizes *pairing* — a NEW device (no row
|
||||
in `devices.db` yet) presents it to connect, and the gateway mints a
|
||||
per-device token for it (returned in `hello.ack.device_token`). It
|
||||
keeps working for devices that never received a per-device token
|
||||
(legacy apps), so an upgrade never bricks a pairing.
|
||||
- **Per-device token (revocable):** minted once at pairing
|
||||
(`DeviceRegistry.issue_token`, 64 hex chars, stored in the `devices`
|
||||
table of `devices.db`). The app stores it in secure storage and
|
||||
presents it INSTEAD of the shared token from the next request on
|
||||
(`Authorization: Bearer <device-token>`). Both tokens are compared in
|
||||
constant time (`verify_token`); a revoked device is rejected before
|
||||
either comparison runs.
|
||||
- **Per-device revocation.** Two control surfaces (run on the gateway host):
|
||||
- **Setup flow** — `hermes gateway setup` → *Iris*: on an existing setup
|
||||
(devices already paired) it asks **"Remove a paired device?"** (default
|
||||
No). If yes: a numbered select menu (name, device id, last seen) whose
|
||||
LAST option is *Exit* (leaves the removal loop, continues the setup);
|
||||
picking a device asks for confirmation, then returns to the menu so
|
||||
several devices can be removed in a row.
|
||||
- **CLI** — `gateway-plugin/tools/iris_devices.py`:
|
||||
- `list` — paired devices (id, name, token minted?, last seen) + revoked ids.
|
||||
- `revoke <device_id>` — drops the device's row (token, push tokens,
|
||||
cursor) AND adds its id to the `revoked` denylist: the device can no
|
||||
longer connect with its device token **or** the shared token, while
|
||||
every other device is unaffected. This is the isolation primitive a
|
||||
shared token alone can't provide (a compromised device can't be cut
|
||||
off without rotating the token for everyone).
|
||||
- `unrevoke <device_id>` — removes it from the denylist so it can pair
|
||||
again (a fresh token is minted at the next pairing).
|
||||
- `reissue <device_id>` — rotates the device's token (the old one stops
|
||||
working; the app picks up the new one on its next (re)connect via
|
||||
`hello.ack`).
|
||||
Re-pairing a revoked device also works by giving the app a fresh
|
||||
`device_id` (e.g. `adb shell pm clear dev.iris.app`), which bootstraps
|
||||
with the shared token like any new device.
|
||||
- **Allowlist (optional):** `IRIS_ALLOWED_USERS` (comma-separated
|
||||
`device_id`s) restricts which *devices* may connect even with the token —
|
||||
useful if the token is shared. `IRIS_ALLOW_ALL_USERS=true` disables the
|
||||
allowlist (dev only).
|
||||
- **Per-device tokens (stretch):** mint a unique token per device at pairing
|
||||
(revocable) instead of one shared token. v1 uses the shared token + optional
|
||||
device allowlist.
|
||||
- **Re-pairing:** rotating `IRIS_TOKEN` invalidates all devices; they must
|
||||
re-pair. `hermes android pair` (stretch CLI) re-issues + prints a new QR.
|
||||
`device_id`s) restricts which *devices* may connect even with a valid
|
||||
token — useful if the shared token is exposed. `IRIS_ALLOW_ALL_USERS=true`
|
||||
disables the allowlist (dev only).
|
||||
- **Re-pairing / rotation.** Rotating `IRIS_TOKEN` no longer invalidates
|
||||
paired devices: they authenticate with their per-device tokens, which
|
||||
survive the rotation. Only bootstrap of NEW devices needs the new shared
|
||||
token. (Legacy devices without a per-device token still re-pair, as
|
||||
before.)
|
||||
|
||||
## 9.4 Transport security
|
||||
|
||||
@@ -124,3 +161,4 @@ M7 research pass. "verified" = implemented and covered by
|
||||
| 10 | Gap: Android token storage | implemented | `AndroidSecureStore` → `EncryptedSharedPreferences` (MasterKey AES256_GCM) with one-time migration of the plain `iris` prefs (read old key → write encrypted → delete old key); dep in `app/shared/build.gradle.kts` (`app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt`) |
|
||||
| 11 | Gap: guard not committed | implemented | `.pre-commit-config.yaml` (local hook → `scripts/guard_hermes_agent.sh --staged`); a fresh clone gets the guard after `pre-commit install` |
|
||||
| 12 | Gap: in-app QR scanner | implemented | `hermes gateway setup` renders a terminal QR (`gateway-plugin/qr.py`, pure-stdlib encoder) and the app's Connect screen has a **Scan QR** button (CameraX + ML Kit, `QrScanActivity`) plus an `iris://pair` deep link (`PairLink.parse`); `docs/20` |
|
||||
| 13 | Gap: per-device tokens (revocation) | implemented | `DeviceRegistry.issue_token` mints a 64-hex per-device token at pairing (stored in `devices.db`, returned in `hello.ack.device_token`); the app stores it in secure storage and presents it instead of the shared `IRIS_TOKEN` (bootstrap path unchanged). `tools/iris_devices.py revoke <device_id>` drops the device + denylists its id (rejected even with the shared token); `unrevoke`/`reissue` for re-pairing/rotation. `docs/09` §9.3 |
|
||||
@@ -9,6 +9,7 @@ First-time setup on a machine (verified baseline: CachyOS/Arch, `pacman`,
|
||||
pacman -S jdk17-openjdk
|
||||
java -version # expect 17.x
|
||||
```
|
||||
|
||||
(Compose Multiplatform + current AGP are happy on JDK 17. Use 17 to match the
|
||||
Android toolchain; 21 also works but 17 is the safe floor.)
|
||||
|
||||
@@ -28,10 +29,12 @@ export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-
|
||||
sdkmanager --licenses
|
||||
sdkmanager "platform-tools" "platforms;android-34" "build-tools;34.0.0"
|
||||
```
|
||||
|
||||
Persist `ANDROID_HOME`/`PATH` in `~/.bashrc`. ADB is already installed system-wide;
|
||||
`platform-tools` from the SDK is fine too (whichever is first on `PATH`).
|
||||
|
||||
Create `app/local.properties`:
|
||||
|
||||
```
|
||||
sdk.dir=/home/<you>/android-sdk
|
||||
```
|
||||
@@ -39,10 +42,12 @@ sdk.dir=/home/<you>/android-sdk
|
||||
## 12.3 Gradle
|
||||
|
||||
No system install — use the project wrapper:
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./gradlew tasks # first run downloads the wrapper distribution
|
||||
```
|
||||
|
||||
(The wrapper version is pinned in `app/gradle/wrapper/gradle-wrapper.properties`.)
|
||||
|
||||
## 12.4 hermes environment (for the plugin + running the gateway)
|
||||
@@ -53,7 +58,9 @@ uv sync # creates .venv with all core deps (websockets, httpx,
|
||||
source .venv/bin/activate
|
||||
hermes --version # sanity
|
||||
```
|
||||
|
||||
- Run the gateway with the plugin:
|
||||
|
||||
```bash
|
||||
# install the plugin (dev: symlink)
|
||||
mkdir -p ~/.hermes/plugins
|
||||
@@ -61,7 +68,9 @@ hermes --version # sanity
|
||||
hermes gateway status # should list "iris"
|
||||
hermes gateway # run
|
||||
```
|
||||
|
||||
- Tests use hermes's hermetic runner (never bare `pytest`):
|
||||
|
||||
```bash
|
||||
scripts/run_tests.sh tests/gateway/test_android.py
|
||||
```
|
||||
@@ -84,6 +93,7 @@ hermes --version # sanity
|
||||
## 12.6 Environment variables (summary)
|
||||
|
||||
**Secrets (`~/.hermes/.env`):**
|
||||
|
||||
```
|
||||
IRIS_TOKEN=<64-hex>
|
||||
IRIS_PUSH_BACKEND=ntfy # default; fcm = opt-in (metadata via Google)
|
||||
@@ -96,6 +106,7 @@ IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
|
||||
```
|
||||
|
||||
**Behavioral (`~/.hermes/config.yaml`):**
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
platforms:
|
||||
@@ -135,5 +146,6 @@ async def main():
|
||||
asyncio.run(main())
|
||||
PY
|
||||
```
|
||||
|
||||
Expect a `hello.ack`. If you get `error {code:"auth"}`, the token/host/port is
|
||||
wrong.
|
||||
@@ -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" } }
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+11
-23
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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) ─────────
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user