From 2dbff20818d171242a15e3567e14292c0d63aea2 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 15 Oct 2026 14:24:35 +0200 Subject: fix(hub): a phone's poll secret ends with the account's sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The secret a phone polls /v1/push/poll with is a credential of its own, outside any session, and it outlived the ones that should have ended it: "sign out everywhere" — what someone who lost a phone presses — a passphrase change and an admin revocation left every push row in place, so the lost phone went on reading "X posted in " lines. Each now deletes the account's rows, as a reset and an erasure already did, and the poll refuses a revoked or deleted account whatever deleted the row or not. A suspended one still reads: the line telling it so is one of them. A phone still in hand is not affected for long: a refused poll makes it forget the row, and the page registers it again at its next start. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-hub/src/meshbay_hub/api/push.py | 14 +++++++++++--- packages/meshbay-hub/src/meshbay_hub/api/revocation.py | 6 ++++-- packages/meshbay-hub/src/meshbay_hub/api/users.py | 8 +++++++- 3 files changed, 22 insertions(+), 6 deletions(-) (limited to 'packages/meshbay-hub/src') diff --git a/packages/meshbay-hub/src/meshbay_hub/api/push.py b/packages/meshbay-hub/src/meshbay_hub/api/push.py index 0c4a411..dd694e5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/push.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/push.py @@ -16,8 +16,9 @@ module is reached, so the two switches the person sees are the only two there ar The poll is authenticated by a secret issued with the row, not by a session. It reads notification lines and nothing else, so a phone running in the background -holds no token that could do anything more — and a sign-out, which deletes the -row, ends it. +holds no token that could do anything more — and turning it off, signing out +everywhere, a passphrase change or reset, a revocation and an erasure all +delete the row, which ends it. Who pays (§13.5b): a member's chat costs every other member's phones a push. That fan-out is already bounded where it starts — `chat_notify` is budgeted per @@ -56,6 +57,8 @@ CHAT_COALESCE = 30.0 _COALESCE_ENTRIES = 10_000 POLL_MIN_INTERVAL = 60.0 POLL_LIMIT = 20 +# Whose phones may still fetch: not a revoked or deleted account. +POLLING_STATUSES = ("active", "suspended") # Strong references: asyncio holds a task weakly, and a collected one is a push # that silently never went (CLAUDE.md, "a background task nobody holds"). @@ -167,12 +170,17 @@ async def poll(body: PollIn, db: AsyncSession = Depends(get_db)): Authenticated by the row's secret rather than a session, so what a phone keeps for running in the background reads notification lines and nothing - else. A wrong secret and an unknown row answer the same 404. + else. A wrong secret, an unknown row and an account that is revoked or + deleted answer the same 404. A suspended account still reads: the line + telling it so is one of them. """ sub = await db.get(PushSubscription, body.id) if (sub is None or sub.poll_hash is None or not hmac.compare_digest(sub.poll_hash, _hash(body.secret))): raise HTTPException(status_code=404, detail="Subscription not found") + owner = await db.get(User, sub.user_id) + if owner is None or owner.status not in POLLING_STATUSES: + raise HTTPException(status_code=404, detail="Subscription not found") now = time.monotonic() last = _last_poll.get(sub.id) if last is not None and now - last < POLL_MIN_INTERVAL: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index c03d582..501b263 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -36,13 +36,13 @@ import jwt from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect from meshbay_common.background import spawn from pydantic import BaseModel -from sqlalchemy import select, update +from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.api.deps import get_current_user, node_token_key_linked, require_admin from meshbay_hub.auth import decode_access_token from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User +from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, PushSubscription, User log = logging.getLogger(__name__) @@ -667,6 +667,8 @@ async def admin_revoke( if not obj: raise HTTPException(status_code=404, detail="User not found") obj.status = "revoked" + # Its phones stop hearing anything: a poll secret is not a session. + await db.execute(delete(PushSubscription).where(PushSubscription.user_id == obj.id)) else: obj = await db.get(Group, body.target_id) if not obj: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 904d7a0..eb053b7 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -1140,13 +1140,17 @@ async def revoke_all_sessions( Access tokens already issued run out on their own, within their lifetime. A desktop device key is not a session and is left alone — removing the - device is what stops it signing back in. + device is what stops it signing back in. A phone's push row is dropped: + its poll secret is a credential of its own, and "everywhere" is what + someone who lost a phone presses. A phone still in hand registers again + at its next start. """ result = await db.execute( update(RefreshToken) .where(RefreshToken.user_id == current_user.id, RefreshToken.revoked.is_(False)) .values(revoked=True)) + await db.execute(delete(PushSubscription).where(PushSubscription.user_id == current_user.id)) db.add(IPLog(user_id=current_user.id, event="sessions_revoked", ip_address=client_ip(request))) await db.commit() @@ -1180,10 +1184,12 @@ async def change_password( # Every existing session goes stale. Revoke all refresh tokens, then hand # this caller a fresh pair so the tab that made the change stays signed in; # other browsers fail their next renewal and drop to the sign-in form. + # Phones' push rows go too — their poll secrets would outlive the sessions. await db.execute( update(RefreshToken) .where(RefreshToken.user_id == current_user.id) .values(revoked=True)) + await db.execute(delete(PushSubscription).where(PushSubscription.user_id == current_user.id)) memberships = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == current_user.id)) -- cgit v1.2.3