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
23 files changed
+837 -66

No files matched your search

@@ -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) {