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

+103
View File
@@ -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"],