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
@@ -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) ─────────
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user