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
@@ -321,16 +321,32 @@ class HttpServer:
|
||||
|
||||
def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None:
|
||||
"""Verify Bearer token + device identity. Returns the device_id, or
|
||||
None after sending a 401."""
|
||||
None after sending a 401.
|
||||
|
||||
Token model (docs/09 §9.3): a REVOKED device_id is rejected no matter
|
||||
which token it presents (per-device isolation). Otherwise the shared
|
||||
``IRIS_TOKEN`` (bootstrap / legacy) or the device's own per-device
|
||||
token (minted at pairing, returned in ``hello.ack.device_token``)
|
||||
both authenticate — each compared in constant time."""
|
||||
auth = handler.headers.get("Authorization") or ""
|
||||
token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None
|
||||
if not verify_token(token, self._adapter.token):
|
||||
_send_json(handler, 401, {"error": "unauthorized"})
|
||||
return None
|
||||
device_id = (handler.headers.get("X-Iris-Device") or "").strip()
|
||||
if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN:
|
||||
_send_json(handler, 401, {"error": "X-Iris-Device header required"})
|
||||
return None
|
||||
with contextlib.suppress(Exception):
|
||||
if self._devices.is_revoked(device_id):
|
||||
logger.warning("iris: http rejected: device %s is revoked", device_id)
|
||||
_send_json(handler, 401, {"error": "device revoked"})
|
||||
return None
|
||||
if not verify_token(token, self._adapter.token):
|
||||
# Not the shared token: try the device's own per-device token.
|
||||
device_token = None
|
||||
with contextlib.suppress(Exception):
|
||||
device_token = self._devices.token_for(device_id)
|
||||
if not (device_token and verify_token(token, device_token)):
|
||||
_send_json(handler, 401, {"error": "unauthorized"})
|
||||
return None
|
||||
if (
|
||||
not self._adapter.allow_all
|
||||
and self._adapter.allowed_users
|
||||
@@ -484,6 +500,14 @@ class HttpServer:
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("iris: device registry upsert failed", exc_info=True)
|
||||
# Per-device token (docs/09 §9.3): minted once at pairing (idempotent
|
||||
# across (re)connects) and returned in the hello below; the app
|
||||
# stores it and presents it instead of the shared token from then on.
|
||||
device_token = ""
|
||||
try:
|
||||
device_token = self._devices.issue_token(device_id)
|
||||
except Exception:
|
||||
logger.warning("iris: device token issuance failed", exc_info=True)
|
||||
sub = _Subscriber(device_id=device_id, kind="sse")
|
||||
# Register BEFORE the replay so a frame appended in between is
|
||||
# fanned out to us (and de-duped by cursor below) instead of lost.
|
||||
@@ -513,6 +537,7 @@ class HttpServer:
|
||||
sync_cursor=self._adapter._outbox.latest_cursor(),
|
||||
channels=self._adapter.channel_list(),
|
||||
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id),
|
||||
device_token=device_token,
|
||||
)
|
||||
self._write_sse(handler, "hello", None, hello.to_json())
|
||||
self._write_sse(
|
||||
|
||||
Reference in new issue
Block a user