diff options
Diffstat (limited to 'packages/meshbay-hub')
24 files changed, 1267 insertions, 5 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py index e4bac2e..128c0d1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/notifications.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/notifications.py @@ -26,8 +26,9 @@ from sqlalchemy import delete, func, select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.api.deps import get_current_user +from meshbay_hub.api.push import push_notification from meshbay_hub.db.engine import get_db -from meshbay_hub.db.models import GroupMember, Notification, User +from meshbay_hub.db.models import GroupMember, Notification, User, UserPreference router = APIRouter(prefix="/v1/notifications", tags=["notifications"]) @@ -157,9 +158,23 @@ async def create_notification( conversation is a single line saying when it last spoke rather than forty saying that it spoke. - Returns None when the person muted this group: the point of muting is that - nothing is created, not that something is created and hidden. + Returns None when the person muted this group, or turned every notification + off: the point of muting is that nothing is created, not that something is + created and hidden — and nothing created is nothing pushed to a phone. + + The account-wide switch used to be read by the interface alone, which hid + the list while rows went on accumulating; with a phone that is told about + each row, a switch only the interface honours is a switch that does nothing. """ + disabled = await db.execute( + select(UserPreference.value).where( + UserPreference.user_id == user_id, + UserPreference.key == "notifications_disabled", + ) + ) + if disabled.scalar() == "true": + return None + if group_id is not None: muted = await db.execute( select(GroupMember.muted).where( @@ -185,6 +200,7 @@ async def create_notification( existing.read = False existing.created_at = datetime.now(UTC) await db.flush() + await push_notification(db, existing) return existing notif = Notification( @@ -193,4 +209,5 @@ async def create_notification( ) db.add(notif) await db.flush() + await push_notification(db, notif) return notif diff --git a/packages/meshbay-hub/src/meshbay_hub/api/push.py b/packages/meshbay-hub/src/meshbay_hub/api/push.py new file mode 100644 index 0000000..0c4a411 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/api/push.py @@ -0,0 +1,277 @@ +""" +Notifications on a phone — /v1/push/*: pushed when it can be, fetched when not. + +A phone registers once and gets a row here. **With a UnifiedPush distributor** +it gives an endpoint and a P-256 key, and every notification +`create_notification` lets through is sent there, encrypted to the phone +(`webpush.py`). **Without one** — nothing to install is the default — the row has +no endpoint, and the phone fetches what is new with `POST /v1/push/poll` every +quarter of an hour or so. Both are the same rows and the same payload, so a phone +can move between them (a distributor installed, removed, refusing) without the +hub caring which. + +**Nothing reaches a phone that was not created**: a muted group and an account +with every notification turned off stop at `create_notification`, before this +module is reached, so the two switches the person sees are the only two there are. + +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. + +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 +node — and here a conversation reaches each phone at most once per +`CHAT_COALESCE` seconds: the phone shows one line per group, so the pushes in +between would only have replaced it. An account holds `MAX_SUBSCRIPTIONS` rows +at most, because each is one outbound request per notification; a row is polled +at most once per `POLL_MIN_INTERVAL`. +""" + +import asyncio +import hashlib +import hmac +import logging +import math +import secrets +import time +from datetime import UTC, datetime + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from meshbay_hub import webpush +from meshbay_hub.api.deps import require_user_scope +from meshbay_hub.db.engine import get_db +from meshbay_hub.db.models import Notification, PushSubscription, User + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/v1/push", tags=["push"]) + +MAX_SUBSCRIPTIONS = 10 +CHAT_COALESCE = 30.0 +_COALESCE_ENTRIES = 10_000 +POLL_MIN_INTERVAL = 60.0 +POLL_LIMIT = 20 + +# 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"). +_tasks: set[asyncio.Task] = set() +_last_chat: dict[tuple[str, str], float] = {} +_last_poll: dict[str, float] = {} +# Replaced by the tests; the real one never raises. +_send = webpush.send + + +class SubscriptionIn(BaseModel): + # The row this phone already has, to update rather than add one: a phone + # with no endpoint has nothing else to be recognised by. + id: str | None = Field(default=None, max_length=36) + endpoint: str | None = Field(default=None, max_length=webpush.MAX_ENDPOINT) + # Lengths bounded before decoding: base64 decoding skips characters outside + # its alphabet, so an unbounded string could still decode to 65 bytes. + p256dh: str | None = Field(default=None, max_length=128) + auth: str | None = Field(default=None, max_length=32) + + +def _hash(secret: str) -> str: + return hashlib.sha256(secret.encode()).hexdigest() + + +def _payload(notif: Notification) -> dict: + """What a phone is told, pushed or fetched: the hub's own line, never a message.""" + return { + "v": 1, + "id": notif.id, + "kind": notif.kind, + "title": notif.title, + "link": notif.link, + "group_id": notif.group_id, + "created_at": _iso(notif.created_at), + } + + +def _iso(at: datetime) -> str: + # SQLite hands back naive datetimes; every one stored here is UTC. + return (at if at.tzinfo else at.replace(tzinfo=UTC)).isoformat() + + +@router.post("/subscriptions") +async def subscribe( + body: SubscriptionIn, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """ + Register this phone, or update its row: with an endpoint and keys when it + has a push distributor, without them when it will fetch instead. + + Answers the row's id, a fresh secret for `POST /v1/push/poll` (the previous + one stops working) and the hub's time, from which the phone counts what is + new — what was there before it registered is not news. + """ + if body.endpoint is not None: + if body.p256dh is None or body.auth is None: + raise HTTPException(status_code=422, detail="an endpoint needs its keys") + try: + webpush.check_endpoint(body.endpoint) + webpush.check_keys(body.p256dh, body.auth) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) from e + + sub = None + if body.id is not None: + sub = await db.get(PushSubscription, body.id) + if sub is not None and sub.user_id != current_user.id: + sub = None + if body.endpoint is not None: + same = (await db.execute( + select(PushSubscription).where( + PushSubscription.user_id == current_user.id, + PushSubscription.endpoint == body.endpoint))).scalar_one_or_none() + if sub is None: + sub = same + elif same is not None and same.id != sub.id: + # The endpoint moved to this row; the old one would only repeat it. + await db.delete(same) + await db.flush() + if sub is None: + held = (await db.execute( + select(func.count()).select_from(PushSubscription) + .where(PushSubscription.user_id == current_user.id))).scalar() or 0 + if held >= MAX_SUBSCRIPTIONS: + raise HTTPException(status_code=429, detail="Too many push subscriptions") + sub = PushSubscription(user_id=current_user.id) + db.add(sub) + secret = secrets.token_urlsafe(32) + sub.endpoint, sub.p256dh, sub.auth = body.endpoint, body.p256dh, body.auth + sub.poll_hash = _hash(secret) + await db.commit() + return {"id": sub.id, "poll_secret": secret, "now": datetime.now(UTC).isoformat()} + + +class PollIn(BaseModel): + id: str = Field(max_length=36) + secret: str = Field(max_length=64) + since: datetime + + +@router.post("/poll") +async def poll(body: PollIn, db: AsyncSession = Depends(get_db)): + """ + What is new for this phone since `since`: the same payloads a push carries, + oldest first, at most twenty. + + 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. + """ + 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") + now = time.monotonic() + last = _last_poll.get(sub.id) + if last is not None and now - last < POLL_MIN_INTERVAL: + raise HTTPException(status_code=429, detail="Polled too often", + headers={"Retry-After": str(int(POLL_MIN_INTERVAL - (now - last)) + 1)}) + if len(_last_poll) >= _COALESCE_ENTRIES: + for k in [k for k, at in _last_poll.items() if now - at >= POLL_MIN_INTERVAL]: + del _last_poll[k] + _last_poll[sub.id] = now + + since = body.since if body.since.tzinfo else body.since.replace(tzinfo=UTC) + rows = (await db.execute( + select(Notification).where( + Notification.user_id == sub.user_id, + Notification.created_at > since.astimezone(UTC), + ).order_by(Notification.created_at.desc()).limit(POLL_LIMIT) + )).scalars().all() + return {"notifications": [_payload(n) for n in reversed(rows)]} + + +@router.delete("/subscriptions/{subscription_id}") +async def unsubscribe( + subscription_id: str, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Stop telling one phone anything: turned off there, or signed out of.""" + sub = await db.get(PushSubscription, subscription_id) + if sub is None or sub.user_id != current_user.id: + raise HTTPException(status_code=404, detail="Subscription not found") + await db.delete(sub) + await db.commit() + return {"status": "ok"} + + +def _coalesced(sub_id: str, group_id: str, now: float) -> bool: + key = (sub_id, group_id) + if now - _last_chat.get(key, -math.inf) < CHAT_COALESCE: + return True + if len(_last_chat) >= _COALESCE_ENTRIES: + for k in [k for k, at in _last_chat.items() if now - at >= CHAT_COALESCE]: + del _last_chat[k] + _last_chat[key] = now + return False + + +async def push_notification(db: AsyncSession, notif: Notification) -> None: + """ + Send `notif` to the person's phones, off the caller's path. + + Called by `create_notification` once the row exists, inside the caller's + transaction: the subscriptions are read there, the requests leave in a task + of their own, so a slow push server delays nobody's request. + """ + subs = (await db.execute( + select(PushSubscription).where(PushSubscription.user_id == notif.user_id, + PushSubscription.endpoint.is_not(None)) + )).scalars().all() + if not subs: + return + payload = _payload(notif) + now = time.monotonic() + targets = [ + (s.id, webpush.Target(s.endpoint, s.p256dh, s.auth)) for s in subs + if not (notif.kind == "chat_message" and notif.group_id + and _coalesced(s.id, notif.group_id, now)) + ] + if not targets: + return + ttl = webpush.TTL_CHAT if notif.kind == "chat_message" else webpush.TTL_OTHER + task = asyncio.get_running_loop().create_task(_deliver(targets, payload, ttl)) + _tasks.add(task) + task.add_done_callback(_tasks.discard) + + +async def _deliver(targets: list[tuple[str, webpush.Target]], payload: dict, + ttl: int) -> None: + results = await asyncio.gather(*(_send(t, payload, ttl=ttl) for _, t in targets), + return_exceptions=True) + gone = [sid for (sid, _), r in zip(targets, results, strict=True) if r == webpush.GONE] + if not gone: + return + # The distributor dropped the registration: pushing there would cost a + # request per notification for ever and reach nothing. The row stays, without + # its endpoint — the phone still fetches, and registers again when it can. + try: + from meshbay_hub.db.engine import get_session_factory + async with get_session_factory()() as db: + await db.execute( + update(PushSubscription).where(PushSubscription.id.in_(gone)) + .values(endpoint=None, p256dh=None, auth=None)) + await db.commit() + except Exception as e: + log.warning("Could not drop %d gone push subscription(s): %s", len(gone), e) + + +async def drain() -> None: + """Wait for every push in flight — for the tests, and for a clean shutdown.""" + # Done tasks leave the set from a callback the loop has not run yet, and + # awaiting a finished gather never yields to it: wait on the unfinished only. + while pending := [t for t in _tasks if not t.done()]: + await asyncio.gather(*pending, return_exceptions=True) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 795a902..130240e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -46,6 +46,7 @@ from meshbay_hub.db.models import ( KnownBrowser, Node, Notification, + PushSubscription, RefreshToken, User, UserDevice, @@ -1361,6 +1362,7 @@ async def password_reset( .values(revoked=True)) await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id)) await db.execute(delete(KnownBrowser).where(KnownBrowser.user_id == user.id)) + await db.execute(delete(PushSubscription).where(PushSubscription.user_id == user.id)) # A code sent to the address on file is a stronger proof than a passphrase, # and it is the way out of a lockout somebody else caused. await login_throttle.clear(db, user.username) @@ -1579,6 +1581,7 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus await db.execute(delete(Node).where(Node.user_id == user.id)) await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id)) await db.execute(delete(KnownBrowser).where(KnownBrowser.user_id == user.id)) + await db.execute(delete(PushSubscription).where(PushSubscription.user_id == user.id)) await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id)) # Links this account issued for a group it no longer owns; the ones for its # own groups went with them above. A used link keeps pointing at the diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index ca05fd8..2ad71bd 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -30,6 +30,7 @@ from meshbay_hub.api.middleware import limiter from meshbay_hub.api.moderation import router as moderation_router from meshbay_hub.api.nodes import router as nodes_router from meshbay_hub.api.notifications import router as notifications_router +from meshbay_hub.api.push import router as push_router from meshbay_hub.api.revocation import router as revocation_router from meshbay_hub.api.signaling import router as signaling_router from meshbay_hub.api.users import router as users_router @@ -233,6 +234,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(signaling_router) app.include_router(admin_router) app.include_router(notifications_router) + app.include_router(push_router) app.include_router(webapp_router) from starlette.staticfiles import StaticFiles diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py new file mode 100644 index 0000000..4311448 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/e7f8a9b0c1d2_push_subscriptions.py @@ -0,0 +1,35 @@ +"""phones told about notifications: a Web Push endpoint, or a poll secret + +Revision ID: e7f8a9b0c1d2 +Revises: d4e5f6a7b8ca +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "e7f8a9b0c1d2" +down_revision: str | Sequence[str] | None = "d4e5f6a7b8ca" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "push_subscriptions", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False), + sa.Column("endpoint", sa.String(1024), nullable=True), + sa.Column("p256dh", sa.String(128), nullable=True), + sa.Column("auth", sa.String(32), nullable=True), + sa.Column("poll_hash", sa.String(64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True)), + ) + op.create_index("ix_push_subscriptions_user_endpoint", "push_subscriptions", + ["user_id", "endpoint"], unique=True) + + +def downgrade() -> None: + op.drop_index("ix_push_subscriptions_user_endpoint", table_name="push_subscriptions") + op.drop_table("push_subscriptions") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index dbc0f10..7291485 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -398,6 +398,34 @@ class ContentReview(Base): decided_by: Mapped[str | None] = mapped_column(String(64)) +class PushSubscription(Base): + """A phone told about this account's notifications (§11.3): pushed to its + Web Push endpoint when it has one, fetched with its poll secret when not. + + The endpoint is a capability — whoever holds the URL can wake the phone — + and the keys are what the hub encrypts to, so the push server relays bytes + it cannot read. One account holds a handful at most + (`api/push.MAX_SUBSCRIPTIONS`): the rows are shared, and every notification + costs one outbound request per row. + """ + + __tablename__ = "push_subscriptions" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) + # All three empty for a phone with no push distributor, which fetches instead. + endpoint: Mapped[str | None] = mapped_column(String(1024), nullable=True) + p256dh: Mapped[str | None] = mapped_column(String(128), nullable=True) + auth: Mapped[str | None] = mapped_column(String(32), nullable=True) + # sha256 of the secret `POST /v1/push/poll` is answered for; never the secret. + poll_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + __table_args__ = ( + Index("ix_push_subscriptions_user_endpoint", "user_id", "endpoint", unique=True), + ) + + class UserPreference(Base): __tablename__ = "user_preferences" diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index b9466d0..2254a11 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -31,6 +31,7 @@ import { } from './playlists.js'; import { IndexingDock } from './index-dock.js'; import { SettingsPage } from './settings-page.js'; +import { syncPush, disablePush } from './push.js'; import { ProfilePage } from './profile-page.js'; import { ExplorePage } from './explore-page.js'; import { GroupName } from './group-name.js'; @@ -1042,6 +1043,7 @@ function App() { .catch(() => {}); refreshNodeKey(); fetchNotifications(); + syncPush(user).catch(() => {}); }, [user]); // The node this application ships, set up, started and linked for whoever @@ -1233,6 +1235,13 @@ function App() { // Navigating away leaves transfers running; signing out does not. They // are moving data on tokens that are about to stop being ours. transfers.reset(); + // This phone stops being told about the account it is leaving. Its + // access token is still good for that request; the refresh token's + // revocation below does not touch it. + // The stored session, read now: React's copy can be a renewal behind, + // and once the session is cleared nothing could renew it. + disablePush(user && { ...user, token: (loadAuth() || {}).token || user.token }) + .catch(() => {}); // Revoked on the hub too, so a copy of the refresh token is worth // nothing. Read before the next line clears it. logoutOnHub(); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js index 4dff605..d8b7b5c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -436,6 +436,15 @@ export default { 'settings.email_verified': 'E-Mail-Adresse erfolgreich geändert.', 'settings.notif_global_disable': 'Disable all notifications', 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.', + 'settings.push_label': 'Benachrichtigungen auf diesem Telefon', + 'settings.push_off': 'Aus. Einschalten, um bei geschlossenem MeshBay über neue Nachrichten und Einladungen informiert zu werden.', + 'settings.push_pending': 'Warte auf die Antwort des Verteilers…', + 'settings.push_blocked': 'Android blockiert die Benachrichtigungen von MeshBay: Erlauben Sie sie in den Systemeinstellungen.', + 'settings.push_ready': 'An. Neue Benachrichtigungen erreichen dieses Telefon über den Verteiler, verschlüsselt, sodass er sie nicht lesen kann.', + 'settings.push_poll': 'An. Dieses Telefon sieht etwa alle fünfzehn Minuten nach neuen Benachrichtigungen. Damit sie sofort ankommen, können Sie eine UnifiedPush-Verteiler-App wie ntfy installieren (optional).', + 'settings.push_global_off': 'Solange alle Benachrichtigungen ausgeschaltet sind, wird nichts gesendet.', + 'push.channel_chat': 'Nachrichten', + 'push.channel_other': 'Einladungen und Konto', 'settings.defaults': 'Standardwerte', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js index 90c043e..a4a8215 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -433,6 +433,15 @@ export default { 'settings.email_verified': 'Email address changed successfully.', 'settings.notif_global_disable': 'Disable all notifications', 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.', + 'settings.push_label': 'Notifications on this phone', + 'settings.push_off': 'Off. Turn on to be told about new messages and invitations while MeshBay is closed.', + 'settings.push_pending': 'Waiting for the distributor’s answer…', + 'settings.push_blocked': 'Android is blocking MeshBay’s notifications: allow them in the system settings.', + 'settings.push_ready': 'On. New notifications reach this phone through the distributor, encrypted so it cannot read them.', + 'settings.push_poll': 'On. This phone checks for new notifications about every fifteen minutes. For them to arrive at once, you can install a UnifiedPush distributor app such as ntfy (optional).', + 'settings.push_global_off': 'Nothing is sent while every notification is turned off.', + 'push.channel_chat': 'Messages', + 'push.channel_other': 'Invitations and account', 'settings.defaults': 'Defaults', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js index 7b46b17..78160d4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -434,6 +434,15 @@ export default { 'settings.email_verified': 'Dirección de correo cambiada con éxito.', 'settings.notif_global_disable': 'Disable all notifications', 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.', + 'settings.push_label': 'Notificaciones en este teléfono', + 'settings.push_off': 'Desactivadas. Actívalas para enterarte de nuevos mensajes e invitaciones con MeshBay cerrado.', + 'settings.push_pending': 'Esperando la respuesta del distribuidor…', + 'settings.push_blocked': 'Android bloquea las notificaciones de MeshBay: permítelas en los ajustes del sistema.', + 'settings.push_ready': 'Activadas. Las nuevas notificaciones llegan a este teléfono a través del distribuidor, cifradas para que no pueda leerlas.', + 'settings.push_poll': 'Activadas. Este teléfono busca notificaciones nuevas cada quince minutos aproximadamente. Para recibirlas al instante, puedes instalar una app distribuidora de UnifiedPush como ntfy (opcional).', + 'settings.push_global_off': 'No se envía nada mientras todas las notificaciones estén desactivadas.', + 'push.channel_chat': 'Mensajes', + 'push.channel_other': 'Invitaciones y cuenta', 'settings.defaults': 'Valores predeterminados', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js index d3f9200..1fbb6cf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -435,6 +435,15 @@ export default { 'settings.email_verified': 'Adresse e-mail modifiée avec succès.', 'settings.notif_global_disable': 'Désactiver toutes les notifications', 'settings.notif_global_hint': 'Quand activé, aucune notification n\x27est créée pour aucun groupe.', + 'settings.push_label': 'Notifications sur ce téléphone', + 'settings.push_off': 'Désactivées. Activez-les pour être prévenu des nouveaux messages et invitations quand MeshBay est fermé.', + 'settings.push_pending': 'En attente de la réponse du distributeur…', + 'settings.push_blocked': 'Android bloque les notifications de MeshBay : autorisez-les dans les paramètres du système.', + 'settings.push_ready': 'Activées. Les nouvelles notifications arrivent sur ce téléphone par le distributeur, chiffrées pour qu’il ne puisse pas les lire.', + 'settings.push_poll': 'Activées. Ce téléphone vérifie les nouvelles notifications environ toutes les quinze minutes. Pour les recevoir immédiatement, vous pouvez installer une application de distribution UnifiedPush comme ntfy (facultatif).', + 'settings.push_global_off': 'Rien n’est envoyé tant que toutes les notifications sont désactivées.', + 'push.channel_chat': 'Messages', + 'push.channel_other': 'Invitations et compte', 'settings.defaults': 'Valeurs par défaut', 'settings.default_tab': 'Onglet par défaut', 'settings.default_tab_hint': 'L\'onglet qui s\'ouvre en premier quand vous entrez dans un groupe.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js index 715738e..250e769 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -435,6 +435,15 @@ export default { 'settings.email_verified': 'Indirizzo e-mail modificato con successo.', 'settings.notif_global_disable': 'Disable all notifications', 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.', + 'settings.push_label': 'Notifiche su questo telefono', + 'settings.push_off': 'Disattivate. Attivale per essere avvisato di nuovi messaggi e inviti quando MeshBay è chiuso.', + 'settings.push_pending': 'In attesa della risposta del distributore…', + 'settings.push_blocked': 'Android blocca le notifiche di MeshBay: consentile nelle impostazioni di sistema.', + 'settings.push_ready': 'Attivate. Le nuove notifiche arrivano su questo telefono tramite il distributore, cifrate in modo che non possa leggerle.', + 'settings.push_poll': 'Attivate. Questo telefono controlla le nuove notifiche circa ogni quindici minuti. Per riceverle subito, puoi installare un’app distributore UnifiedPush come ntfy (facoltativo).', + 'settings.push_global_off': 'Non viene inviato nulla finché tutte le notifiche sono disattivate.', + 'push.channel_chat': 'Messaggi', + 'push.channel_other': 'Inviti e account', 'settings.defaults': 'Valori predefiniti', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js index 23358c2..ad4ba25 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -432,6 +432,15 @@ export default { 'settings.email_verified': 'メールアドレスが正常に変更されました。', 'settings.notif_global_disable': 'Disable all notifications', 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.', + 'settings.push_label': 'この端末の通知', + 'settings.push_off': 'オフ。オンにすると、MeshBay を閉じていても新着メッセージや招待が通知されます。', + 'settings.push_pending': 'ディストリビューターの応答を待っています…', + 'settings.push_blocked': 'Android が MeshBay の通知をブロックしています。システム設定で許可してください。', + 'settings.push_ready': 'オン。新しい通知はディストリビューター経由でこの端末に届きます。暗号化されているため、ディストリビューターは内容を読めません。', + 'settings.push_poll': 'オン。この端末は約15分ごとに新しい通知を確認します。すぐに受け取りたい場合は、ntfy などの UnifiedPush ディストリビューターアプリをインストールできます(任意)。', + 'settings.push_global_off': 'すべての通知がオフの間は何も送信されません。', + 'push.channel_chat': 'メッセージ', + 'push.channel_other': '招待とアカウント', 'settings.defaults': 'デフォルト', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js index 5e53084..78bada9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -436,6 +436,15 @@ export default { 'settings.email_verified': 'E-mailadres succesvol gewijzigd.', 'settings.notif_global_disable': 'Alle meldingen uitschakelen', 'settings.notif_global_hint': 'When enabled, no notifications are created for any group.', + 'settings.push_label': 'Meldingen op deze telefoon', + 'settings.push_off': 'Uit. Zet aan om over nieuwe berichten en uitnodigingen te horen terwijl MeshBay gesloten is.', + 'settings.push_pending': 'Wachten op het antwoord van de distributor…', + 'settings.push_blocked': 'Android blokkeert de meldingen van MeshBay: sta ze toe in de systeeminstellingen.', + 'settings.push_ready': 'Aan. Nieuwe meldingen bereiken deze telefoon via de distributor, versleuteld zodat die ze niet kan lezen.', + 'settings.push_poll': 'Aan. Deze telefoon kijkt ongeveer elk kwartier of er nieuwe meldingen zijn. Wil je ze meteen ontvangen, installeer dan een UnifiedPush-distributor-app zoals ntfy (optioneel).', + 'settings.push_global_off': 'Er wordt niets verzonden zolang alle meldingen uit staan.', + 'push.channel_chat': 'Berichten', + 'push.channel_other': 'Uitnodigingen en account', 'settings.defaults': 'Standaardwaarden', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js index 5d6d852..fd26974 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -447,6 +447,15 @@ export default { 'settings.email_verified': 'Adres e-mail został pomyślnie zmieniony.', 'settings.notif_global_disable': 'Wyłącz wszystkie powiadomienia', 'settings.notif_global_hint': 'Po włączeniu nie będą tworzone żadne powiadomienia dla żadnej grupy.', + 'settings.push_label': 'Powiadomienia na tym telefonie', + 'settings.push_off': 'Wyłączone. Włącz, aby dowiadywać się o nowych wiadomościach i zaproszeniach, gdy MeshBay jest zamknięty.', + 'settings.push_pending': 'Oczekiwanie na odpowiedź dystrybutora…', + 'settings.push_blocked': 'Android blokuje powiadomienia MeshBay: zezwól na nie w ustawieniach systemu.', + 'settings.push_ready': 'Włączone. Nowe powiadomienia docierają na ten telefon przez dystrybutora, zaszyfrowane tak, że nie może ich odczytać.', + 'settings.push_poll': 'Włączone. Ten telefon sprawdza nowe powiadomienia mniej więcej co piętnaście minut. Aby otrzymywać je od razu, możesz zainstalować aplikację dystrybutora UnifiedPush, np. ntfy (opcjonalnie).', + 'settings.push_global_off': 'Nic nie jest wysyłane, dopóki wszystkie powiadomienia są wyłączone.', + 'push.channel_chat': 'Wiadomości', + 'push.channel_other': 'Zaproszenia i konto', 'settings.defaults': 'Wartości domyślne', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js index 6fe50f6..e91ab10 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js @@ -436,6 +436,15 @@ export default { 'settings.email_verified': 'Endereço de e-mail alterado com sucesso.', 'settings.notif_global_disable': 'Desativar todas as notificações', 'settings.notif_global_hint': 'Quando ativado, nenhuma notificação é criada para nenhum grupo.', + 'settings.push_label': 'Notificações neste telefone', + 'settings.push_off': 'Desativadas. Ative para saber de novas mensagens e convites com o MeshBay fechado.', + 'settings.push_pending': 'Aguardando a resposta do distribuidor…', + 'settings.push_blocked': 'O Android está bloqueando as notificações do MeshBay: permita-as nas configurações do sistema.', + 'settings.push_ready': 'Ativadas. Novas notificações chegam a este telefone pelo distribuidor, criptografadas para que ele não possa lê-las.', + 'settings.push_poll': 'Ativadas. Este telefone verifica novas notificações a cada quinze minutos, aproximadamente. Para recebê-las na hora, você pode instalar um app distribuidor UnifiedPush como o ntfy (opcional).', + 'settings.push_global_off': 'Nada é enviado enquanto todas as notificações estiverem desativadas.', + 'push.channel_chat': 'Mensagens', + 'push.channel_other': 'Convites e conta', 'settings.defaults': 'Padrões', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js index b3886ff..c030523 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js @@ -428,6 +428,15 @@ export default { 'settings.email_verified': '邮箱地址修改成功。', 'settings.notif_global_disable': '关闭所有通知', 'settings.notif_global_hint': '启用后,所有群组都不会创建通知。', + 'settings.push_label': '此手机上的通知', + 'settings.push_off': '已关闭。开启后,即使 MeshBay 已关闭,也会收到新消息和邀请的通知。', + 'settings.push_pending': '正在等待分发应用的响应…', + 'settings.push_blocked': 'Android 正在阻止 MeshBay 的通知:请在系统设置中允许。', + 'settings.push_ready': '已开启。新通知通过分发应用送达此手机,并经过加密,分发应用无法读取。', + 'settings.push_poll': '已开启。此手机大约每十五分钟检查一次新通知。如需即时收到,可以安装 UnifiedPush 分发应用,例如 ntfy(可选)。', + 'settings.push_global_off': '所有通知关闭期间不会发送任何内容。', + 'push.channel_chat': '消息', + 'push.channel_other': '邀请与账户', 'settings.defaults': '默认值', 'settings.default_tab': 'Default tab', 'settings.default_tab_hint': 'Which tab opens first when you enter a group.', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js index e3b3d2d..c70b03a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -214,6 +214,22 @@ export const playback = { }, }; +/** + * Notifications on this device while the application is closed — fetched, or + * pushed through a UnifiedPush distributor when the phone has one. Only the + * Android application does this; `available` is false in a browser and on a + * desktop, and nothing here is then called. See push.js. + */ +export const push = { + available: Boolean(bridge && bridge.push), + status() { return bridge.push.status(); }, + enable() { return bridge.push.enable(); }, + disable() { return bridge.push.disable(); }, + remember(subscription, account, secret, since) { + return bridge.push.remember(subscription, account, secret, since); + }, +}; + /** Pick a directory to add as a group root. Returns { path, name } or null. */ export const rootPicker = { available: Boolean(bridge && bridge.rootPicker), diff --git a/packages/meshbay-hub/src/meshbay_hub/static/push.js b/packages/meshbay-hub/src/meshbay_hub/static/push.js new file mode 100644 index 0000000..a075aa4 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/push.js @@ -0,0 +1,68 @@ +/** + * Notifications on this phone: the page's half. + * + * Nothing to install: the shell fetches what is new on its own every quarter + * of an hour, and when the phone already has a UnifiedPush distributor it is + * used too, so they arrive at once. The page gives the hub whatever the shell + * ends up with, because the page holds the session. Whether a notification is + * wanted — every one turned off, or a group muted — is decided on the hub, + * which then creates nothing (docs/MESHBAY_DESIGN.md §11.3). + */ +import * as platform from './platform.js'; +import { hubFetch } from './hub-client.js'; + +// How long turning it on waits for a distributor before registering without +// one; when the endpoint comes later, the next sync hands it over. +const WAIT_MS = 10_000; +const POLL_MS = 500; + +/** + * Make the hub's row match what this phone has: at every start and sign-in, + * and after turning it on. A distributor may hand out a new endpoint, or go + * away, at any time, page running or not; this is where the hub hears it. + */ +export async function syncPush(user) { + if (!platform.push.available || !user) return null; + const s = await platform.push.status(); + if (!s.enabled) return s; + const mine = s.subscription && s.account === user.userId; + if (mine && s.registered === s.endpoint) return s; + const body = s.endpoint + ? { endpoint: s.endpoint, p256dh: s.p256dh, auth: s.auth } + : {}; + if (mine) body.id = s.subscription; + const r = await hubFetch('/v1/push/subscriptions', { + method: 'POST', token: user.token, body, + }); + return platform.push.remember(r.id, user.userId, r.poll_secret, r.now); +} + +/** Turn it on: a moment for a distributor to answer, then register either way. */ +export async function enablePush(user, onProgress) { + let s = await platform.push.enable(); + const until = Date.now() + WAIT_MS; + while (s.state === 'pending' && Date.now() < until) { + if (onProgress) onProgress(s); + await new Promise((resolve) => setTimeout(resolve, POLL_MS)); + s = await platform.push.status(); + } + return syncPush(user).then((synced) => synced || s); +} + +/** + * Turn it off: the hub forgets this phone first, then the phone stops. The hub + * half is the one that can fail; the phone half happens regardless, so a + * refused request never leaves it on here. + */ +export async function disablePush(user) { + if (!platform.push.available) return null; + const s = await platform.push.status(); + if (s.subscription && user && s.account === user.userId) { + try { + await hubFetch(`/v1/push/subscriptions/${s.subscription}`, { + method: 'DELETE', token: user.token, + }); + } catch (e) { /* already gone, or unreachable: the phone half still happens */ } + } + return platform.push.disable(); +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js index 8755556..985b219 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/settings-page.js @@ -5,6 +5,7 @@ import { t, getLocale, setLocale, LOCALES } from './i18n.js'; import * as downloads from './downloads.js'; import * as platform from './platform.js'; import { hubFetch } from './hub-client.js'; +import { syncPush, enablePush, disablePush } from './push.js'; import { APPS } from './apps.js'; import { PAGE_SIZE_PREF, PAGE_SIZE_DEFAULT, PAGE_SIZE_STEP, PAGE_SIZE_MAX, pageSizeFrom, @@ -81,6 +82,35 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange } }, [globalMute, user.token, onPrefsChange]); + // Notifications on this phone (Android only: `platform.push.available`). + const [push, setPush] = useState(null); + const [pushBusy, setPushBusy] = useState(false); + useEffect(() => { + if (!platform.push.available) return; + syncPush(user).catch(() => platform.push.status()).then(setPush).catch(() => {}); + }, [user]); + + const togglePush = useCallback(async () => { + if (pushBusy) return; + setPushBusy(true); + try { + setPush(push && push.enabled ? await disablePush(user) : await enablePush(user, setPush)); + } catch (err) { + setPush(await platform.push.status().catch(() => null)); + } finally { + setPushBusy(false); + } + }, [push, pushBusy, user]); + + const pushHint = () => { + if (!push) return null; + if (globalMute) return t('settings.push_global_off'); + if (!push.enabled) return t('settings.push_off'); + if (!push.permitted) return t('settings.push_blocked'); + if (push.state === 'pending') return t('settings.push_pending'); + return push.endpoint ? t('settings.push_ready') : t('settings.push_poll'); + }; + const toggleMute = useCallback(async (gid) => { const next = !muted[gid]; setMuted(prev => ({ ...prev, [gid]: next })); @@ -240,6 +270,17 @@ export function SettingsPage({ user, theme, onThemeChange, groups, onPrefsChange <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> </label> </div> + ${push && html` + <div class="settings-row"> + <span class="settings-label">${t('settings.push_label')}</span> + <label class="toggle-switch"> + <input type="checkbox" checked=${!!push.enabled} disabled=${pushBusy} + onChange=${togglePush} /> + <span class="toggle-switch-track"><span class="toggle-switch-thumb"></span></span> + </label> + </div> + <p class="settings-hint">${pushHint()}</p> + `} ${!globalMute && html` <p class="settings-hint" style="margin-bottom:8px">${t('settings.notif_global_hint')}</p> ${groups.map(g => html` diff --git a/packages/meshbay-hub/src/meshbay_hub/webpush.py b/packages/meshbay-hub/src/meshbay_hub/webpush.py new file mode 100644 index 0000000..301e191 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/webpush.py @@ -0,0 +1,194 @@ +""" +Web Push to a phone: RFC 8291 encryption and the one outbound request. + +The Android application registers with a UnifiedPush distributor (ntfy, or any +other) and hands the hub an endpoint URL and a P-256 key; the hub POSTs each +notification there, encrypted to that key (`docs/MESHBAY_DESIGN.md` §7.6). The +push server relays bytes it cannot read; what it does learn is *when* this +person is notified, which is the same metadata the hub already holds. + +**The endpoint is a URL a member supplied, and the hub fetches it.** That is +the shape of an SSRF, so a send resolves the host itself, refuses unless every +address is public, and connects to the address it checked — the hostname rides +only as the TLS server name and the Host header, so a second resolution cannot +point the request somewhere else. No redirect is followed. +""" + +import asyncio +import base64 +import ipaddress +import json +import logging +import os +import socket +from dataclasses import dataclass +from urllib.parse import urlsplit, urlunsplit + +import httpx +from cryptography.hazmat.primitives import hashes, hmac, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +log = logging.getLogger(__name__) + +RECORD_SIZE = 4096 +SEND_TIMEOUT = 5.0 +MAX_ENDPOINT = 1024 +# RFC 8030 §5.2: a push service may keep a message this long for a phone that is +# off. A chat line an hour old is still worth seeing; one a day old is not. +TTL_CHAT = 3600 +TTL_OTHER = 86400 + + +class EndpointRefused(ValueError): + """The endpoint is not one the hub will send to.""" + + +def b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _hmac(key: bytes, data: bytes) -> bytes: + h = hmac.HMAC(key, hashes.SHA256()) + h.update(data) + return h.finalize() + + +def check_keys(p256dh: str, auth: str) -> tuple[bytes, bytes]: + """Decode and validate a subscription's keys; ValueError when they are not.""" + try: + ua_public = b64url_decode(p256dh) + auth_secret = b64url_decode(auth) + except (ValueError, TypeError) as e: + raise ValueError("keys are not base64url") from e + if len(ua_public) != 65 or ua_public[0] != 4: + raise ValueError("p256dh is not an uncompressed P-256 point") + if len(auth_secret) != 16: + raise ValueError("auth is not 16 bytes") + # Raises ValueError for a point that is not on the curve. + ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ua_public) + return ua_public, auth_secret + + +def encrypt(plaintext: bytes, ua_public: bytes, auth_secret: bytes, *, + as_private: ec.EllipticCurvePrivateKey | None = None, + salt: bytes | None = None) -> bytes: + """One aes128gcm record (RFC 8188) keyed as RFC 8291 §3.4 says. + + `as_private` and `salt` are parameters only so the RFC's own example can be + replayed; a send always draws both fresh. + """ + if len(plaintext) > RECORD_SIZE - 16 - 1 - 86: + raise ValueError("push payload too large for one record") + as_private = as_private or ec.generate_private_key(ec.SECP256R1()) + salt = salt or os.urandom(16) + as_public = as_private.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + ua_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ua_public) + ecdh_secret = as_private.exchange(ec.ECDH(), ua_key) + + prk_key = _hmac(auth_secret, ecdh_secret) + key_info = b"WebPush: info\x00" + ua_public + as_public + ikm = _hmac(prk_key, key_info + b"\x01") + prk = _hmac(salt, ikm) + cek = _hmac(prk, b"Content-Encoding: aes128gcm\x00\x01")[:16] + nonce = _hmac(prk, b"Content-Encoding: nonce\x00\x01")[:12] + + header = salt + RECORD_SIZE.to_bytes(4, "big") + bytes([len(as_public)]) + as_public + return header + AESGCM(cek).encrypt(nonce, plaintext + b"\x02", None) + + +def check_endpoint(url: str) -> tuple[str, int]: + """The endpoint's shape, checked when it is registered: https, a host, no + credentials, not an address that is private on its face. Where its name + resolves is checked at every send, because that can change.""" + if len(url) > MAX_ENDPOINT: + raise EndpointRefused("endpoint too long") + parts = urlsplit(url) + if parts.scheme != "https" or not parts.hostname: + raise EndpointRefused("endpoint must be an https URL") + if parts.username or parts.password: + raise EndpointRefused("endpoint must not carry credentials") + try: + port = parts.port or 443 + except ValueError as e: + raise EndpointRefused("endpoint port is not a number") from e + host = parts.hostname + try: + literal = ipaddress.ip_address(host) + except ValueError: + literal = None + if literal is not None and not literal.is_global: + raise EndpointRefused("endpoint is not a public address") + return host, port + + +async def _resolve_public(host: str, port: int) -> str: + infos = await asyncio.get_running_loop().getaddrinfo( + host, port, type=socket.SOCK_STREAM) + addresses = {info[4][0] for info in infos} + if not addresses: + raise EndpointRefused("endpoint does not resolve") + for a in addresses: + if not ipaddress.ip_address(a.split("%", 1)[0]).is_global: + raise EndpointRefused("endpoint resolves to a non-public address") + # IPv4 first: a hub with an AAAA answer and no IPv6 route is common. + return sorted(addresses, key=lambda a: (":" in a, a))[0] + + +@dataclass +class Target: + endpoint: str + p256dh: str + auth: str + + +# Outcomes of one send, for the caller to act on. +DELIVERED = "delivered" +GONE = "gone" # 404/410: the registration no longer exists (RFC 8030 §7.3) +FAILED = "failed" + + +async def send(target: Target, payload: dict, *, ttl: int, + client: httpx.AsyncClient | None = None) -> str: + """Encrypt `payload` for one subscription and POST it. Never raises.""" + try: + host, port = check_endpoint(target.endpoint) + ua_public, auth_secret = check_keys(target.p256dh, target.auth) + body = encrypt(json.dumps(payload, separators=(",", ":")).encode(), + ua_public, auth_secret) + address = await _resolve_public(host, port) + except (EndpointRefused, ValueError, OSError) as e: + log.info("push refused before sending: %s", e) + return FAILED + + parts = urlsplit(target.endpoint) + netloc = f"[{address}]" if ":" in address else address + if parts.port: + netloc += f":{parts.port}" + pinned = urlunsplit((parts.scheme, netloc, parts.path or "/", parts.query, "")) + headers = { + "Host": parts.netloc, + "Content-Encoding": "aes128gcm", + "Content-Type": "application/octet-stream", + "TTL": str(ttl), + "Urgency": "normal", + } + own = client is None + client = client or httpx.AsyncClient(timeout=SEND_TIMEOUT, follow_redirects=False) + try: + resp = await client.post(pinned, content=body, headers=headers, + extensions={"sni_hostname": host}) + except httpx.HTTPError as e: + # The URL path is a bearer capability for this phone: never logged. + log.info("push to %s failed: %s", host, type(e).__name__) + return FAILED + finally: + if own: + await client.aclose() + if resp.status_code in (404, 410): + return GONE + if 200 <= resp.status_code < 300: + return DELIVERED + log.info("push to %s answered %d", host, resp.status_code) + return FAILED diff --git a/packages/meshbay-hub/tests/test_android_shell.py b/packages/meshbay-hub/tests/test_android_shell.py index 71832f2..129732c 100644 --- a/packages/meshbay-hub/tests/test_android_shell.py +++ b/packages/meshbay-hub/tests/test_android_shell.py @@ -135,11 +135,15 @@ def test_the_shim_offers_desktop_channels_and_native_answers_each(): preload = set(re.findall(r"ipcRenderer\.invoke\('([\w:-]+)'", preload_js)) native = set() for path in (SRC / "bridge" / "Channels.kt", SRC / "bridge" / "KeyChannels.kt", - SRC / "cast" / "CastChannels.kt"): + SRC / "cast" / "CastChannels.kt", SRC / "notify" / "PushChannels.kt"): native |= set(re.findall(r'^\s*"([\w:-]+)" ->', _read(path), flags=re.M)) shim = _shim_channels() assert shim, "no channel found in the shim" - assert shim <= preload, f"channels the desktop does not have: {shim - preload}" + # A phone has a push distributor to talk to and a desktop does not; that + # family is the one the desktop lacks rather than the one it shares. + phone_only = {c for c in shim if c.startswith("push:")} + assert phone_only, "the push channels are gone from the shim" + assert shim - phone_only <= preload, f"channels the desktop does not have: {shim - preload}" assert shim == native, f"shim and native disagree: {shim ^ native}" @@ -196,6 +200,28 @@ def test_nothing_is_granted_and_video_may_go_fullscreen(): assert "override fun onHideCustomView" in activity +def test_a_notification_is_drawn_only_when_it_opened_and_leads_inside_the_page(): + """The distributor is another application: its receiver must not be ours + to call, a message nobody encrypted to this phone is not drawn, and the + link a notification carries is a route in the page, never a URL.""" + manifest = _read(APP / "src" / "main" / "AndroidManifest.xml") + service = manifest.split('android:name=".notify.PushReceiver"', 1)[1].split("</service>", 1)[0] + assert 'android:exported="false"' in service + job = manifest.split('android:name=".notify.PollJob"', 1)[1].split("/>", 1)[0] + assert 'android:exported="false"' in job and "BIND_JOB_SERVICE" in job + # The background fetch carries the poll secret, never a session token. + poll = _read(SRC / "notify" / "PollJob.kt") + assert "/v1/push/poll" in poll and "Authorization" not in poll + receiver = _read(SRC / "notify" / "PushReceiver.kt") + assert "!message.decrypted" in receiver + notifier = _read(SRC / "notify" / "Notifier.kt") + assert 'Regex("^#/[A-Za-z0-9/_-]{0,200}$")' in notifier + assert "FLAG_IMMUTABLE" in notifier + activity = _read(SRC / "MainActivity.kt") + assert activity.count("Notifier.linkOf(intent)") == 2 + assert 'location.hash = ${JSONObject.quote(link)}' in activity + + def test_no_backup_carries_the_keys_away(): manifest = _read(APP / "src" / "main" / "AndroidManifest.xml") assert 'android:allowBackup="false"' in manifest diff --git a/packages/meshbay-hub/tests/test_push.py b/packages/meshbay-hub/tests/test_push.py new file mode 100644 index 0000000..c3bca4f --- /dev/null +++ b/packages/meshbay-hub/tests/test_push.py @@ -0,0 +1,455 @@ +""" +Push to a phone: the encryption, the endpoint a member supplies, and — the +point of the whole feature — that the two switches a person sees are honoured. + +Every flow test is two accounts, the one causing a notification and the one +receiving it, because a one-member test proves a one-member property. +""" + +import base64 +import hashlib +import json + +import httpx +import pytest +from cryptography.hazmat.primitives import hashes, hmac, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from membership import add_member +from meshbay_hub import webpush +from meshbay_hub.api import push +from meshbay_hub.db.models import Notification, PushSubscription, User +from sqlalchemy import select + + +def _b64(s: str) -> bytes: + s = "".join(s.split()) + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def _b64e(b: bytes) -> str: + return base64.urlsafe_b64encode(b).rstrip(b"=").decode() + + +def _auth_key(password: str, username: str) -> str: + salt = hashlib.sha256(f"meshbay:auth:v1:{username}".encode()).digest() + return base64.b64encode( + hashlib.pbkdf2_hmac("sha512", password.encode(), salt, 600_000, 32)).decode() + + +async def _user(client, username, password="a-long-enough-passphrase"): + await client.post("/v1/users/register", json={ + "username": username, "email": f"{username}@example.com", + "auth_key": _auth_key(password, username)}) + r = await client.post("/v1/users/login", json={ + "username": username, "auth_key": _auth_key(password, username)}) + return r.json()["access_token"] + + +def _phone(): + """A user agent's keys, as a distributor's connector would generate them.""" + sk = ec.generate_private_key(ec.SECP256R1()) + pk = sk.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + auth = b"0123456789abcdef" + return sk, _b64e(pk), _b64e(auth) + + +def _hm(key, data): + h = hmac.HMAC(key, hashes.SHA256()) + h.update(data) + return h.finalize() + + +def _decrypt(body: bytes, ua_private, auth_secret: bytes) -> bytes: + """RFC 8291 from the receiving side, written from the RFC and not from webpush.py.""" + salt, idlen = body[:16], body[20] + as_public = body[21:21 + idlen] + ua_public = ua_private.public_key().public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + ecdh = ua_private.exchange( + ec.ECDH(), ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), as_public)) + ikm = _hm(_hm(auth_secret, ecdh), b"WebPush: info\x00" + ua_public + as_public + b"\x01") + prk = _hm(salt, ikm) + cek = _hm(prk, b"Content-Encoding: aes128gcm\x00\x01")[:16] + nonce = _hm(prk, b"Content-Encoding: nonce\x00\x01")[:12] + plain = AESGCM(cek).decrypt(nonce, body[21 + idlen:], None) + assert plain.endswith(b"\x02") + return plain[:-1] + + +@pytest.fixture +def sent(monkeypatch): + """Every push the hub would have sent, instead of sending it.""" + calls = [] + + async def fake_send(target, payload, *, ttl, client=None): + calls.append((target, payload, ttl)) + return webpush.DELIVERED + + monkeypatch.setattr(push, "_send", fake_send) + push._last_chat.clear() + return calls + + +async def _subscribe(client, token, endpoint="https://push.example.net/up/abc"): + _, p256dh, auth = _phone() + r = await client.post("/v1/push/subscriptions", + json={"endpoint": endpoint, "p256dh": p256dh, "auth": auth}, + headers={"Authorization": f"Bearer {token}"}) + return r + + +async def _two_in_a_group(client, db_session, name): + member = await _user(client, f"{name}_member") + owner = await _user(client, f"{name}_owner") + g = await client.post("/v1/groups", json={"name": name}, + headers={"Authorization": f"Bearer {owner}"}) + gid = g.json()["group_id"] + await add_member(client, gid, f"{name}_member", {"Authorization": f"Bearer {owner}"}) + uid = (await db_session.execute( + select(User.id).where(User.username == f"{name}_member"))).scalar_one() + return member, uid, gid + + +# ── Encryption ─────────────────────────────────────────────────────────────── + +def test_encryption_reproduces_the_rfc_8291_example(): + as_private = ec.derive_private_key( + int.from_bytes(_b64("yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"), "big"), + ec.SECP256R1()) + out = webpush.encrypt( + _b64("V2hlbiBJIGdyb3cgdXAsIEkgd2FudCB0byBiZSBhIHdhdGVybWVsb24"), + _b64("BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcx" + "aOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"), + _b64("BTBZMqHH6r4Tts7J_aSIgg"), + as_private=as_private, salt=_b64("DGv6ra1nlYgDCS1FRnbzlw")) + header = _b64("DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml" + "mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8") + ciphertext = _b64("8pfeW0KbunFT06SuDKoJH9Ql87S1QUrdirN6GcG7sFz1y1sqLgVi1VhjVkHsUoEs" + "bI_0LpXMuGvnzQ") + assert out == header + ciphertext + + +def test_a_fresh_encryption_opens_on_the_phone(): + sk, p256dh, auth = _phone() + body = webpush.encrypt(b'{"kind":"x"}', _b64(p256dh), _b64(auth)) + assert _decrypt(body, sk, _b64(auth)) == b'{"kind":"x"}' + + +# ── The endpoint is a URL a member chose, and the hub fetches it ───────────── + +@pytest.mark.parametrize("endpoint", [ + "http://push.example.net/up/abc", + "https://127.0.0.1/up", + "https://10.1.2.3/up", + "https://[::1]/up", + "https://169.254.169.254/latest", + "https://user:pw@push.example.net/up", + "ftp://push.example.net/up", +]) +@pytest.mark.asyncio +async def test_an_endpoint_the_hub_should_not_fetch_is_refused(client, endpoint): + token = await _user(client, "ssrf_shape") + r = await _subscribe(client, token, endpoint) + assert r.status_code == 422, (endpoint, r.text) + + +@pytest.mark.asyncio +async def test_a_name_resolving_to_a_private_address_is_never_sent_to(): + _, p256dh, auth = _phone() + seen = [] + mock = httpx.AsyncClient(transport=httpx.MockTransport( + lambda req: seen.append(req) or httpx.Response(201))) + result = await webpush.send(webpush.Target("https://localhost/up", p256dh, auth), + {"v": 1}, ttl=60, client=mock) + assert result == webpush.FAILED and seen == [] + + +@pytest.mark.asyncio +async def test_the_request_goes_to_the_address_that_was_checked(monkeypatch): + """The hostname is the TLS name and the Host header only: a second lookup + returning something else would never be made.""" + sk, p256dh, auth = _phone() + + async def resolved(host, port): + assert host == "push.example.net" + return "93.184.215.14" + + monkeypatch.setattr(webpush, "_resolve_public", resolved) + seen = [] + mock = httpx.AsyncClient(transport=httpx.MockTransport( + lambda req: seen.append(req) or httpx.Response(201))) + result = await webpush.send( + webpush.Target("https://push.example.net/up/abc?up=1", p256dh, auth), + {"title": "hello"}, ttl=60, client=mock) + assert result == webpush.DELIVERED + (req,) = seen + assert req.url.host == "93.184.215.14" and req.url.path == "/up/abc" + assert req.headers["host"] == "push.example.net" + assert req.extensions["sni_hostname"] == "push.example.net" + assert req.headers["content-encoding"] == "aes128gcm" and req.headers["ttl"] == "60" + assert json.loads(_decrypt(req.content, sk, _b64(auth))) == {"title": "hello"} + + +@pytest.mark.asyncio +async def test_bad_keys_are_refused(client): + token = await _user(client, "bad_keys_user") + r = await client.post("/v1/push/subscriptions", json={ + "endpoint": "https://push.example.net/up/abc", + "p256dh": _b64e(b"\x04" + b"\x01" * 64), "auth": _b64e(b"0" * 16)}, + headers={"Authorization": f"Bearer {token}"}) + assert r.status_code == 422 + + +# ── Rows ───────────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_registering_again_updates_the_same_row(client): + token = await _user(client, "registers_again") + first = (await _subscribe(client, token)).json()["id"] + second = (await _subscribe(client, token)).json()["id"] + assert first == second + + +@pytest.mark.asyncio +async def test_an_account_holds_a_bounded_number_of_subscriptions(client): + token = await _user(client, "many_phones") + for i in range(push.MAX_SUBSCRIPTIONS): + r = await _subscribe(client, token, f"https://push.example.net/up/{i}") + assert r.status_code == 200, r.text + r = await _subscribe(client, token, "https://push.example.net/up/one-more") + assert r.status_code == 429 + + +@pytest.mark.asyncio +async def test_only_the_owner_can_remove_a_subscription(client): + mine = await _user(client, "sub_owner") + other = await _user(client, "sub_other") + sid = (await _subscribe(client, mine)).json()["id"] + r = await client.delete(f"/v1/push/subscriptions/{sid}", + headers={"Authorization": f"Bearer {other}"}) + assert r.status_code == 404 + r = await client.delete(f"/v1/push/subscriptions/{sid}", + headers={"Authorization": f"Bearer {mine}"}) + assert r.status_code == 200 + + +# ── What is pushed, and what is not ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_a_notification_reaches_the_phone(client, db_session, sent): + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "pushed") + assert (await _subscribe(client, member)).status_code == 200 + await create_notification(db_session, uid, "chat_message", "owner posted in pushed", + link=f"#/group/{gid}", group_id=gid, aggregate=True) + await db_session.commit() + await push.drain() + (target, payload, ttl) = sent[0] + assert payload["kind"] == "chat_message" and payload["group_id"] == gid + assert payload["title"] == "owner posted in pushed" and ttl == webpush.TTL_CHAT + + +@pytest.mark.asyncio +async def test_a_muted_group_pushes_nothing(client, db_session, sent): + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "hushed") + await _subscribe(client, member) + r = await client.post(f"/v1/groups/{gid}/mute", json={"muted": True}, + headers={"Authorization": f"Bearer {member}"}) + assert r.status_code == 200 + await create_notification(db_session, uid, "chat_message", "owner posted in hushed", + group_id=gid, aggregate=True) + await db_session.commit() + await push.drain() + assert sent == [] + + +@pytest.mark.asyncio +async def test_every_notification_turned_off_pushes_nothing_and_stores_nothing( + client, db_session, sent): + """The account-wide switch was read by the interface only: rows went on + being created and hidden. With a phone told about each row, that would have + been a switch that did nothing.""" + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "silenced") + await _subscribe(client, member) + r = await client.put("/v1/users/me/preferences/notifications_disabled", + json={"value": "true"}, + headers={"Authorization": f"Bearer {member}"}) + assert r.status_code == 200 + for kind, group in (("chat_message", gid), ("group_invite", None)): + made = await create_notification(db_session, uid, kind, "something", + group_id=group, aggregate=group is not None) + assert made is None, kind + await db_session.commit() + await push.drain() + assert sent == [] + rows = (await db_session.execute( + select(Notification).where(Notification.user_id == uid, + Notification.title == "something"))).scalars().all() + assert rows == [] + + await client.put("/v1/users/me/preferences/notifications_disabled", + json={"value": "false"}, + headers={"Authorization": f"Bearer {member}"}) + await create_notification(db_session, uid, "group_invite", "back on") + await db_session.commit() + await push.drain() + assert [p["title"] for _, p, _ in sent] == ["back on"] + + +@pytest.mark.asyncio +async def test_a_busy_conversation_reaches_a_phone_once_per_window(client, db_session, sent): + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "chatty") + await _subscribe(client, member) + for i in range(5): + await create_notification(db_session, uid, "chat_message", f"line {i}", + group_id=gid, aggregate=True) + await create_notification(db_session, uid, "group_invite", "not chat") + await db_session.commit() + await push.drain() + assert [p["title"] for _, p, _ in sent] == ["line 0", "not chat"] + + +@pytest.mark.asyncio +async def test_a_registration_the_push_server_dropped_falls_back_to_fetching( + client, db_session, monkeypatch): + """The row loses its endpoint, not its existence: the phone keeps fetching.""" + from meshbay_hub.api.notifications import create_notification + + async def gone(target, payload, *, ttl, client=None): + return webpush.GONE + + monkeypatch.setattr(push, "_send", gone) + push._last_chat.clear() + member, uid, gid = await _two_in_a_group(client, db_session, "dropped") + await _subscribe(client, member) + await create_notification(db_session, uid, "group_invite", "anyone there") + await db_session.commit() + await push.drain() + db_session.expire_all() + (row,) = (await db_session.execute( + select(PushSubscription).where(PushSubscription.user_id == uid))).scalars().all() + assert row.endpoint is None and row.p256dh is None and row.poll_hash is not None + + +# ── Fetching, for a phone with no distributor ─────────────────────────────── + +async def _poll(client, reg, since=None): + return await client.post("/v1/push/poll", json={ + "id": reg["id"], "secret": reg["poll_secret"], "since": since or reg["now"]}) + + +@pytest.fixture +def unthrottled(monkeypatch): + monkeypatch.setattr(push, "POLL_MIN_INTERVAL", 0.0) + push._last_poll.clear() + + +@pytest.mark.asyncio +async def test_a_phone_without_a_distributor_fetches_what_is_new( + client, db_session, sent, unthrottled): + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "fetched") + r = await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {member}"}) + assert r.status_code == 200, r.text + reg = r.json() + # The invitation that made them a member predates the registration: not news. + assert (await _poll(client, reg)).json()["notifications"] == [] + + await create_notification(db_session, uid, "chat_message", "owner posted in fetched", + link=f"#/group/{gid}", group_id=gid, aggregate=True) + await db_session.commit() + await push.drain() + assert sent == [], "a row with no endpoint is never pushed to" + (got,) = (await _poll(client, reg)).json()["notifications"] + assert got["kind"] == "chat_message" and got["group_id"] == gid + assert got["title"] == "owner posted in fetched" + assert (await _poll(client, reg, got["created_at"])).json()["notifications"] == [] + + +@pytest.mark.asyncio +async def test_fetching_honours_both_switches(client, db_session, unthrottled): + from meshbay_hub.api.notifications import create_notification + + member, uid, gid = await _two_in_a_group(client, db_session, "fetchmute") + reg = (await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {member}"})).json() + await client.post(f"/v1/groups/{gid}/mute", json={"muted": True}, + headers={"Authorization": f"Bearer {member}"}) + await create_notification(db_session, uid, "chat_message", "muted line", + group_id=gid, aggregate=True) + await client.put("/v1/users/me/preferences/notifications_disabled", + json={"value": "true"}, headers={"Authorization": f"Bearer {member}"}) + await create_notification(db_session, uid, "group_invite", "all off") + await db_session.commit() + assert (await _poll(client, reg)).json()["notifications"] == [] + + +@pytest.mark.asyncio +async def test_the_poll_secret_is_the_only_way_in(client, unthrottled): + mine = await _user(client, "poll_owner") + reg = (await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {mine}"})).json() + assert (await _poll(client, {**reg, "poll_secret": "x" * 43})).status_code == 404 + assert (await _poll(client, {**reg, "id": "0" * 36})).status_code == 404 + again = (await client.post("/v1/push/subscriptions", json={"id": reg["id"]}, + headers={"Authorization": f"Bearer {mine}"})).json() + assert again["id"] == reg["id"] + assert (await _poll(client, reg)).status_code == 404, "a new secret retires the old" + assert (await _poll(client, again)).status_code == 200 + await client.delete(f"/v1/push/subscriptions/{reg['id']}", + headers={"Authorization": f"Bearer {mine}"}) + assert (await _poll(client, again)).status_code == 404, "signed out: the row is gone" + + +@pytest.mark.asyncio +async def test_another_account_cannot_take_over_a_row(client): + mine = await _user(client, "row_owner") + other = await _user(client, "row_taker") + reg = (await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {mine}"})).json() + theirs = (await client.post("/v1/push/subscriptions", json={"id": reg["id"]}, + headers={"Authorization": f"Bearer {other}"})).json() + assert theirs["id"] != reg["id"] + + +@pytest.mark.asyncio +async def test_a_phone_cannot_poll_faster_than_the_floor(client): + push._last_poll.clear() + mine = await _user(client, "eager_poller") + reg = (await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {mine}"})).json() + assert (await _poll(client, reg)).status_code == 200 + r = await _poll(client, reg) + assert r.status_code == 429 and int(r.headers["retry-after"]) > 0 + + +@pytest.mark.asyncio +async def test_a_phone_gaining_a_distributor_keeps_its_row(client): + mine = await _user(client, "upgrading_phone") + reg = (await client.post("/v1/push/subscriptions", json={}, + headers={"Authorization": f"Bearer {mine}"})).json() + _, p256dh, auth = _phone() + r = await client.post("/v1/push/subscriptions", json={ + "id": reg["id"], "endpoint": "https://push.example.net/up/new", + "p256dh": p256dh, "auth": auth}, headers={"Authorization": f"Bearer {mine}"}) + assert r.json()["id"] == reg["id"] + + +@pytest.mark.asyncio +async def test_an_endpoint_without_its_keys_is_refused(client): + mine = await _user(client, "keyless_phone") + r = await client.post("/v1/push/subscriptions", + json={"endpoint": "https://push.example.net/up/k"}, + headers={"Authorization": f"Bearer {mine}"}) + assert r.status_code == 422 diff --git a/packages/meshbay-hub/tests/test_unauthenticated_surface.py b/packages/meshbay-hub/tests/test_unauthenticated_surface.py index 563dfa8..aabcf45 100644 --- a/packages/meshbay-hub/tests/test_unauthenticated_surface.py +++ b/packages/meshbay-hub/tests/test_unauthenticated_surface.py @@ -39,6 +39,7 @@ PUBLIC = { ("POST", "/v1/users/verify-email"): "the e-mailed code is the credential, attempts capped", ("POST", "/v1/users/password/reset-request"): "captcha, per-IP and per-account limits", ("POST", "/v1/users/password/reset"): "the e-mailed code is the credential, attempts capped", + ("POST", "/v1/push/poll"): "a phone's own poll secret; reads notification lines, one a minute", ("POST", "/v1/nodes/auth"): "node sign-in — Ed25519 signature over a fresh timestamp", ("WS", "/v1/nodes/ws"): "a node-scoped JWT in the first message, within a timeout", ("GET", "/v1/groups"): "the public directory — empty when public groups are off", |