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
@@ -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