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

+261
View File
@@ -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) ─────────