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
@@ -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"],
|
||||
|
||||
Reference in new issue
Block a user