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
+11
-23
@@ -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)
|
||||
Reference in new issue
Block a user