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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -68,4 +68,4 @@ optional_env:
|
||||
- name: IRIS_WS_KEY
|
||||
description: "TLS key path for WSS (optional)"
|
||||
prompt: "WSS key"
|
||||
password: false
|
||||
password: false
|
||||
@@ -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
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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) ─────────
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
Reference in new issue
Block a user