Per-device tokens with revocation (issue #11)
Auth previously used the shared IRIS_TOKEN as the security principal: a leaked token meant access to all devices, and a compromised device could not be isolated. Gateway: - pairing.py: devices.token column (in-place migration) + revoked denylist table; issue_token (idempotent, 64 hex), token_for, reissue_token, revoke/unrevoke/is_revoked/list_revoked. The token never leaks into device dicts (push fan-out / listings). - http_server.py: auth accepts the shared token (bootstrap/legacy) OR the device's own token (both constant-time); a revoked device_id is rejected with 401 before either comparison. On SSE open (pairing) the per-device token is minted and returned in hello.ack. - protocol.py: hello_ack(..., device_token). - adapter.py: setup flow (hermes gateway setup -> Iris) now offers 'Remove a paired device?' on an existing setup: numbered select menu (last option = exit the removal loop), confirmation, back to the menu for further removals. - tools/iris_devices.py: operator CLI (list / revoke / unrevoke / reissue), stdlib only. App: - SecureStore.deviceToken (Android: EncryptedSharedPreferences; Desktop: second keyring slot iris-device-token / device_token.enc). - HelloAckPayload.deviceToken; GatewayClient stores it on hello and presents it instead of the shared token from then on (live provider in HttpGateway); savePairing/clear wipe it for re-pairing. Docs: 09 §9.3 stretch -> implemented (revocation semantics, both control surfaces), 04 hello.ack example, frames.schema.json, M7 row 13. Tests: 8 new Python tests (issuance, acceptance, revocation, isolation, unrevoke, registry unit x2, setup-flow menu) - 94/94 pass; 2 new Kotlin wire tests - green. Live-verified against a running gateway (hello.ack token matches devices.db; revoke -> 401 even with shared token; unrevoke -> 200; setup TUI both paths).
This commit is contained in:
1 parent
746d809d48
commit
7faaf2aa1c
23 files changed
+837
-66
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user