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