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

+11 -23
View File
@@ -149,9 +149,7 @@ class FcmBackend(PushBackend):
}
headers = {"kid": sa["private_key_id"]} if sa.get("private_key_id") else None
try:
assertion = jwt.encode(
claims, sa["private_key"], algorithm="RS256", headers=headers
)
assertion = jwt.encode(claims, sa["private_key"], algorithm="RS256", headers=headers)
except Exception:
logger.warning("iris: FCM JWT mint failed", exc_info=True)
return None
@@ -170,7 +168,8 @@ class FcmBackend(PushBackend):
if resp.status_code != _HTTP_OK:
logger.warning(
"iris: FCM token exchange HTTP %s: %s",
resp.status_code, resp.text[:200],
resp.status_code,
resp.text[:200],
)
return None
try:
@@ -223,9 +222,7 @@ class FcmBackend(PushBackend):
message["notification"] = notification
if data:
message["data"] = data
message["android"] = {
"priority": "high" if priority == "high" else "normal"
}
message["android"] = {"priority": "high" if priority == "high" else "normal"}
payload = {"message": message}
auth = await self._authorization(client)
if auth is None:
@@ -242,9 +239,7 @@ class FcmBackend(PushBackend):
return False
if resp.status_code >= _HTTP_ERROR_MIN:
# 404 NOT_FOUND = stale/invalid registration token.
logger.warning(
"iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200]
)
logger.warning("iris: FCM send HTTP %s: %s", resp.status_code, resp.text[:200])
return False
return True
@@ -269,10 +264,9 @@ class NtfyBackend(PushBackend):
auth_token: str | None = None,
):
self._topic = (topic or "").strip() or None
self._server = (
(server_url or _DEFAULT_NTFY_SERVER).strip().rstrip("/")
or _DEFAULT_NTFY_SERVER
)
self._server = (server_url or _DEFAULT_NTFY_SERVER).strip().rstrip(
"/"
) or _DEFAULT_NTFY_SERVER
self._auth_token = (auth_token or "").strip() or None
@property
@@ -311,16 +305,12 @@ class NtfyBackend(PushBackend):
url = f"{self._server}/{quote(topic, safe='')}"
try:
async with httpx.AsyncClient(timeout=_HTTP_TIMEOUT_S) as client:
resp = await client.post(
url, content=text.encode("utf-8"), headers=headers
)
resp = await client.post(url, content=text.encode("utf-8"), headers=headers)
except Exception:
logger.warning("iris: ntfy publish failed (network)", exc_info=True)
return False
if resp.status_code >= _HTTP_ERROR_MIN:
logger.warning(
"iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200]
)
logger.warning("iris: ntfy publish HTTP %s: %s", resp.status_code, resp.text[:200])
return False
return True
@@ -342,6 +332,4 @@ def build_push_backend(
"""
if (name or "").strip().lower() == "fcm":
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)
return NtfyBackend(
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
)
return NtfyBackend(topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token)