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

+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))