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

+64
View File
@@ -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
+29 -4
View File
@@ -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(
+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"],
+1 -1
View File
@@ -68,4 +68,4 @@ optional_env:
- name: IRIS_WS_KEY
description: "TLS key path for WSS (optional)"
prompt: "WSS key"
password: false
password: false
+6
View File
@@ -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
View File
@@ -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)
+3
View File
@@ -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"]
+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) ─────────
+153
View File
@@ -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))