summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 14:24:35 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 14:24:35 +0200
commit2dbff20818d171242a15e3567e14292c0d63aea2 (patch)
treedcfe9607809c95ff15435ec684f0954a2d777798
parent5949532e6450d1aebc681caa8db2192a43b11f12 (diff)
downloadmeshbay-2dbff20818d171242a15e3567e14292c0d63aea2.tar.gz
fix(hub): a phone's poll secret ends with the account's sessions
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 <group>" 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 <noreply@anthropic.com>
-rw-r--r--docs/MESHBAY_DESIGN.md7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/push.py14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py8
-rw-r--r--packages/meshbay-hub/tests/test_push.py79
5 files changed, 107 insertions, 7 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 750484a..c7356c0 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -4085,7 +4085,12 @@ narrow bridge — and Android has all three:
which Android stretches under Doze. The secret is the only credential the
background holds and it reads notification lines and nothing else — not a
session, so nothing renewing in the background can collide with the page's
- rotating refresh token, and a sign-out, which deletes the row, ends it. **When
+ rotating refresh token. It is a credential of its own, so whatever ends the
+ account's sessions ends it too: turning it off, signing out everywhere (what
+ someone who lost a phone presses), a passphrase change or reset, a revocation
+ and an erasure all delete the row, and a revoked or deleted account's poll is
+ refused regardless. A suspended one still reads, the line saying so being one
+ of its notifications. **When
a UnifiedPush distributor is already installed** (ntfy, or an application
carrying one), the row also gets its endpoint and P-256 key and each
notification is sent there at once as one RFC 8291 record, so the push server
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))
diff --git a/packages/meshbay-hub/tests/test_push.py b/packages/meshbay-hub/tests/test_push.py
index c3bca4f..3450aa2 100644
--- a/packages/meshbay-hub/tests/test_push.py
+++ b/packages/meshbay-hub/tests/test_push.py
@@ -453,3 +453,82 @@ async def test_an_endpoint_without_its_keys_is_refused(client):
json={"endpoint": "https://push.example.net/up/k"},
headers={"Authorization": f"Bearer {mine}"})
assert r.status_code == 422
+
+
+# ── A phone's poll secret ends with the account's sessions ───────────────────
+#
+# The secret is a credential of its own, outside any session. A lost phone is
+# exactly what "sign out everywhere" and a passphrase change are pressed for;
+# a revoked account is not told anything more.
+
+
+async def _registered(client, username):
+ token = await _user(client, username)
+ reg = (await client.post("/v1/push/subscriptions", json={},
+ headers={"Authorization": f"Bearer {token}"})).json()
+ await _subscribe(client, token)
+ assert (await _poll(client, reg)).status_code == 200
+ return token, reg
+
+
+async def _rows(db_session, username):
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == username))).scalar_one()
+ db_session.expire_all()
+ return (await db_session.execute(
+ select(PushSubscription).where(PushSubscription.user_id == uid))).scalars().all()
+
+
+@pytest.mark.asyncio
+async def test_signing_out_everywhere_ends_every_phone(client, db_session, unthrottled):
+ token, reg = await _registered(client, "lost_phone")
+ r = await client.post("/v1/users/me/sessions/revoke",
+ headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200, r.text
+ assert (await _poll(client, reg)).status_code == 404
+ assert await _rows(db_session, "lost_phone") == [], "pushed rows go too"
+
+
+@pytest.mark.asyncio
+async def test_a_passphrase_change_ends_every_phone(client, db_session, unthrottled):
+ token, reg = await _registered(client, "new_passphrase")
+ r = await client.post("/v1/users/password", json={
+ "old_auth_key": _auth_key("a-long-enough-passphrase", "new_passphrase"),
+ "new_auth_key": _auth_key("another-long-passphrase", "new_passphrase"),
+ }, headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 200, r.text
+ assert (await _poll(client, reg)).status_code == 404
+ assert await _rows(db_session, "new_passphrase") == []
+
+
+@pytest.mark.asyncio
+async def test_a_revoked_account_is_told_nothing(client, db_session, unthrottled):
+ from meshbay_hub.api.deps import set_admin_usernames
+
+ _, reg = await _registered(client, "revoked_phone")
+ admin = await _user(client, "push_admin")
+ uid = (await db_session.execute(
+ select(User.id).where(User.username == "revoked_phone"))).scalar_one()
+ set_admin_usernames(["push_admin"])
+ r = await client.post("/v1/admin/revoke", json={
+ "target": "user", "target_id": uid, "reason": "test"},
+ headers={"Authorization": f"Bearer {admin}"})
+ assert r.status_code == 200, r.text
+ assert (await _poll(client, reg)).status_code == 404
+ assert await _rows(db_session, "revoked_phone") == []
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(("status", "code"), [("suspended", 200), ("revoked", 404),
+ ("deleted", 404)])
+async def test_polling_follows_the_account_status(client, db_session, unthrottled,
+ status, code):
+ # The gate itself, apart from the paths that delete the row: a status set
+ # some other way still decides. Suspended still reads — the line saying so
+ # is one of the notifications.
+ _, reg = await _registered(client, f"status_{status}")
+ user = (await db_session.execute(
+ select(User).where(User.username == f"status_{status}"))).scalar_one()
+ user.status = status
+ await db_session.commit()
+ assert (await _poll(client, reg)).status_code == code