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