diff options
Diffstat (limited to 'packages/meshbay-hub')
23 files changed, 741 insertions, 331 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 10049b2..fc117bf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -1,6 +1,7 @@ """Group endpoints — /v1/groups/*""" import re +import unicodedata from datetime import UTC, datetime from fastapi import APIRouter, Depends, HTTPException, Query, Request @@ -347,6 +348,25 @@ async def join_group( "owner_username": owner} +# The column's width. A longer name was a database error on PostgreSQL (a 500) +# and silently truncated on SQLite. +MAX_GROUP_NAME = 128 +# Line breaks and other C0/C1 controls, and the bidirectional overrides that +# make a name display as something other than what it is. Joiners stay: an +# emoji family is a ZWJ sequence. +_BIDI_CONTROLS = frozenset("\u202a\u202b\u202c\u202d\u202e\u2066\u2067\u2068\u2069") + + +def _group_name_problem(name: str) -> str | None: + if not name: + return "A group needs a name." + if len(name) > MAX_GROUP_NAME: + return f"A group name is at most {MAX_GROUP_NAME} characters." + if any(unicodedata.category(c) == "Cc" or c in _BIDI_CONTROLS for c in name): + return "A group name cannot contain control characters." + return None + + class GroupCreateRequest(BaseModel): name: str visibility: str = "private" # public|private @@ -426,8 +446,9 @@ async def create_group( "anyone to be able to join.") name = body.name.strip() - if not name: - raise HTTPException(status_code=422, detail="A group needs a name.") + problem = _group_name_problem(name) + if problem: + raise HTTPException(status_code=422, detail=problem) # One name per owner, case-insensitively. Two *different* owners may each # have a "photos" — that is why the check is scoped to `admin_id` and why # the group's real identity stays its UUID. The DB has a unique index too diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index 6a6baa7..5a33d77 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -87,6 +87,16 @@ NOTIFY_WINDOW_SECONDS = 60 _notify_window: dict[str, tuple[float, int]] = {} # node_id → (window start, count) +# `update_groups` re-reads the node's groups from the database. A node sends one +# when its configuration is reloaded — an operator attaching a group, an owner's +# approval arriving — so ten a minute is far past real use, and the same budget +# rule as chat_notify keeps one node from spending the hub's database for others. +UPDATE_GROUPS_BURST = 10 +_update_window: dict[str, tuple[float, int]] = {} +# The groups one node may claim in one message. An operator with fifty groups +# is a large one. +MAX_CLAIMED_GROUPS = 1000 + def forget_node(node_id: str) -> None: """Drop everything a disconnected node's socket owned. @@ -106,25 +116,35 @@ def forget_node(node_id: str) -> None: _node_users.pop(node_id, None) -def _notify_budget(node_id: str) -> bool: - """True if this node may send one more chat_notify now.""" +def _spend(window: dict[str, tuple[float, int]], node_id: str, burst: int) -> bool: + """True if this node may send one more message of a budgeted kind now.""" now = time.monotonic() - if len(_notify_window) > 1000: + if len(window) > 1000: # Swept here rather than on disconnect, which would let a node refill # its budget by reconnecting — the same token stays valid for an hour. - for nid, (started, _) in list(_notify_window.items()): + for nid, (started, _) in list(window.items()): if now - started >= NOTIFY_WINDOW_SECONDS: - _notify_window.pop(nid, None) - start, count = _notify_window.get(node_id, (now, 0)) + window.pop(nid, None) + start, count = window.get(node_id, (now, 0)) if now - start >= NOTIFY_WINDOW_SECONDS: start, count = now, 0 - if count >= NOTIFY_BURST: - _notify_window[node_id] = (start, count) + if count >= burst: + window[node_id] = (start, count) return False - _notify_window[node_id] = (start, count + 1) + window[node_id] = (start, count + 1) return True +def _notify_budget(node_id: str) -> bool: + """True if this node may send one more chat_notify now.""" + return _spend(_notify_window, node_id, NOTIFY_BURST) + + +def _update_budget(node_id: str) -> bool: + """True if this node may send one more update_groups now.""" + return _spend(_update_window, node_id, UPDATE_GROUPS_BURST) + + async def _mark_hosted(group_ids: list[str]) -> None: """Stamp the first time a node announced it hosts each of these groups. @@ -461,8 +481,8 @@ async def node_websocket(ws: WebSocket): await _reject(ws, "Node already connected", 4009) return - resolved_id, result = await _authorize_node_ws( - msg["token"], claimed_id, msg.get("group_ids")) + claimed = [str(g) for g in (msg.get("group_ids") or [])][:MAX_CLAIMED_GROUPS] + resolved_id, result = await _authorize_node_ws(msg["token"], claimed_id, claimed) if resolved_id is None: await _reject(ws, result, 4003) return @@ -472,7 +492,7 @@ async def node_websocket(ws: WebSocket): node_id = resolved_id _connected_nodes[node_id] = ws _node_groups[node_id] = group_ids - _node_claims[node_id] = list(msg.get("group_ids") or []) + _node_claims[node_id] = claimed _node_users[node_id] = user_id await _mark_hosted(group_ids) log.info("Node WS connected: %s (user=%s, groups=%d)", @@ -493,13 +513,16 @@ async def node_websocket(ws: WebSocket): from meshbay_hub.api.signaling import handle_webrtc_answer handle_webrtc_answer(msg, node_id) elif msg.get("type") == "update_groups": + if not _update_budget(node_id): + log.warning("Node %s exceeded its update_groups rate", node_id[:8]) + continue # Through the same gate as the registration above. This used to # assign the message's list verbatim, so the ceiling that makes # C2 hold at authentication could be stepped over one message # later: a node had only to reload to claim any group on the hub. - _node_claims[node_id] = list(msg.get("group_ids") or []) - new_gids = await resolve_node_groups( - node_id, user_id, msg.get("group_ids")) + claimed = [str(g) for g in (msg.get("group_ids") or [])][:MAX_CLAIMED_GROUPS] + _node_claims[node_id] = claimed + new_gids = await resolve_node_groups(node_id, user_id, claimed) _node_groups[node_id] = new_gids await _mark_hosted(new_gids) log.info("Node %s updated groups: %d", node_id[:8], len(new_gids)) diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py index 6c9699b..56e0e4b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py @@ -20,7 +20,7 @@ import time import uuid from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel +from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -41,9 +41,23 @@ _webrtc_answers: dict[str, asyncio.Future] = {} _answer_owner: dict[str, str] = {} +# A browser offers a handful of candidates — a host and a reflexive one per +# interface — and embeds them in the SDP anyway. The list is relayed to the node +# as it came, so it is bounded like the SDP beside it. +MAX_ICE_CANDIDATES = 64 +MAX_ICE_BYTES = 32 * 1024 + + class WebRTCOfferRequest(BaseModel): sdp: str - ice_candidates: list[dict] = [] + ice_candidates: list[dict] = Field(default_factory=list, max_length=MAX_ICE_CANDIDATES) + + @field_validator("ice_candidates") + @classmethod + def _bounded(cls, v: list[dict]) -> list[dict]: + if len(json.dumps(v)) > MAX_ICE_BYTES: + raise ValueError("ICE candidates too large") + return v class WebRTCOfferResponse(BaseModel): @@ -176,14 +190,6 @@ async def webrtc_offer( if len(body.sdp) > MAX_SDP_BYTES: raise HTTPException(status_code=413, detail="SDP too large") - # Logged here because this is the moment a browser starts a peer connection, - # and the address it starts it from is this one — the hub's own view of the - # TCP connection. Whatever address the peers then discover through STUN is - # theirs to negotiate and is not what a log should record. - db.add(IPLog(user_id=current_user.id, event="webrtc_offer", - ip_address=client_ip(request), detail=node_id[:8])) - await db.commit() - ws = _connected_nodes.get(node_id) if not ws: raise HTTPException(status_code=404, detail="Node not connected") @@ -205,6 +211,14 @@ async def webrtc_offer( raise HTTPException(status_code=429, detail="Too many connections to this node", headers={"Retry-After": str(max(1, math.ceil(wait)))}) + # Logged once the offer is going to a node, not before: the address a peer + # connection starts from is the hub's own view of this TCP connection, and an + # IP log row is kept a year — written before the checks above, any account + # could add rows for any string it named as a node. + db.add(IPLog(user_id=current_user.id, event="webrtc_offer", + ip_address=client_ip(request), detail=node_id[:8])) + await db.commit() + peer_id = str(uuid.uuid4()) answer_future: asyncio.Future = asyncio.get_event_loop().create_future() _webrtc_answers[peer_id] = answer_future diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py index 8e780df..9326bfb 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/users.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py @@ -1,6 +1,7 @@ """User endpoints — /v1/users/*""" import base64 +import hashlib import logging import re import secrets @@ -42,6 +43,7 @@ from meshbay_hub.db.models import ( GroupInviteLink, GroupMember, IPLog, + KnownBrowser, Node, Notification, RefreshToken, @@ -161,6 +163,7 @@ class LoginRequest(BaseModel): username: str password: str | None = None # legacy (raw password) for migration auth_key: str | None = None # PBKDF2-derived auth key (new scheme) + known_browser: str | None = None # from an earlier sign-in on this browser class RefreshRequest(BaseModel): @@ -182,12 +185,18 @@ async def register( ): eh = hash_email_blind(body.email) - existing = await db.execute( - select(User).where(User.username == body.username)) - found = existing.scalar_one_or_none() + # Unique regardless of case: invitations and member management name people + # by username, and "Alice" beside "alice" is one person to whoever reads it. + # Accounts that already differ only by case (made before this) keep their + # names; the exact match is the one a retry means. + same = (await db.execute( + select(User).where(func.lower(User.username) == body.username.lower()) + )).scalars().all() + found = next((u for u in same if u.username == body.username), same[0] if same else None) if found: - if found.status == "pending" and found.email_hash == eh: + if (found.username == body.username and found.status == "pending" + and found.email_hash == eh): # Same person retrying before validation — resend a code. # No captcha: the initial registration already passed it. # @@ -351,6 +360,58 @@ async def _take_login_attempt(db: AsyncSession, username: str) -> None: headers={"Retry-After": str(retry_after)}) +def _session_counter(user: User) -> str: + """The failure counter for a passphrase re-checked inside an open session. + + Its own, not the sign-in one: a stranger who keeps a name locked at sign-in + must not also stop its owner changing their passphrase, deleting their + account or registering a device from a session they already hold. + """ + return f"\x00session:{user.id}" + + +async def _browser_counter(db: AsyncSession, username: str, + token: str | None) -> tuple[str, "KnownBrowser | None"]: + """The failure counter for a sign-in, and the known browser behind it if any. + + A browser that signed in to this account before presents its token and is + counted on its own: the username's counter, which anyone can spend, then + locks only browsers this account has never used. A token for another + account, or none, is the username's counter — the answer is the same either + way, so it says nothing about the account (M1). + """ + if token: + row = (await db.execute( + select(KnownBrowser).join(User, User.id == KnownBrowser.user_id) + .where(KnownBrowser.token_hash == _browser_hash(token), + User.username == username))).scalar_one_or_none() + if row is not None: + return f"{username}\x00browser:{row.id}", row + return username, None + + +def _browser_hash(token: str) -> str: + return hashlib.sha256(f"meshbay:known_browser:{token}".encode()).hexdigest() + + +# How many browsers one account is remembered on. The oldest goes first; a +# browser forgotten here is only an unknown one again. +MAX_KNOWN_BROWSERS = 20 + + +async def _remember_browser(db: AsyncSession, user: User) -> str: + """A new known-browser token for `user`. The caller commits.""" + raw = secrets.token_urlsafe(32) + rows = (await db.execute( + select(KnownBrowser.id).where(KnownBrowser.user_id == user.id) + .order_by(KnownBrowser.last_used_at.desc()))).scalars().all() + stale = rows[MAX_KNOWN_BROWSERS - 1:] + if stale: + await db.execute(delete(KnownBrowser).where(KnownBrowser.id.in_(stale))) + db.add(KnownBrowser(user_id=user.id, token_hash=_browser_hash(raw))) + return raw + + async def _prove_passphrase(db: AsyncSession, user: User, auth_key: str) -> None: """Refuse with 403 unless `auth_key` is this account's, spending an attempt. @@ -358,18 +419,18 @@ async def _prove_passphrase(db: AsyncSession, user: User, auth_key: str) -> None refreshed one, or one lifted from a page, and what it would buy here outlives the session or reopens the offline search the pepper exists to prevent. """ - await _take_login_attempt(db, user.username) + await _take_login_attempt(db, _session_counter(user)) if not await verify_password_off_loop(auth_key, user.pw_hash, user.pw_salt, user.pw_version): raise HTTPException(status_code=403, detail="Passphrase does not match") - await login_throttle.clear(db, user.username) + await login_throttle.clear(db, _session_counter(user)) async def _login_failed(db: AsyncSession, username: str, ip: str, - user_id: str | None = None) -> None: + user_id: str | None = None, counter: str | None = None) -> None: """Record a wrong passphrase and answer 401. Always raises.""" db.add(IPLog(user_id=user_id, event="login_fail", ip_address=ip, detail=username)) - if await login_throttle.is_now_locked(db, username): + if await login_throttle.is_now_locked(db, counter or username): # Once, on the failure that spent the last attempt — so the logs tab # shows when a name was locked, not every refusal after it. db.add(IPLog(user_id=user_id, event="login_locked", ip_address=ip, @@ -431,32 +492,33 @@ async def login( # Before the account is even looked up: an unknown name spends attempts and # locks exactly like a real one, so neither answer tells them apart (M1). - await _take_login_attempt(db, body.username) + counter, browser = await _browser_counter(db, body.username, body.known_browser) + await _take_login_attempt(db, counter) result = await db.execute( select(User).where(User.username == body.username)) user = result.scalar_one_or_none() if not user: - await _login_failed(db, body.username, ip) + await _login_failed(db, body.username, ip, counter=counter) if user.pw_version >= 3: # New scheme: verify auth_key if not body.auth_key or not await verify_password_off_loop( body.auth_key, user.pw_hash, user.pw_salt, version=user.pw_version ): - await _login_failed(db, body.username, ip, user.id) + await _login_failed(db, body.username, ip, user.id, counter) else: # Legacy scheme: need raw password if not body.password: # Nothing was checked, so nothing was guessed. - await login_throttle.release(db, body.username) + await login_throttle.release(db, counter) await db.commit() raise HTTPException(status_code=401, detail="auth_upgrade_required") if not await verify_password_off_loop( body.password, user.pw_hash, user.pw_salt, version=user.pw_version ): - await _login_failed(db, body.username, ip, user.id) + await _login_failed(db, body.username, ip, user.id, counter) # Migrate to new scheme if auth_key provided alongside password if body.auth_key: new_hash, new_salt = await hash_password_off_loop(body.auth_key) @@ -471,6 +533,7 @@ async def login( user.pw_version = 2 # The passphrase was right, whatever the account's status turns out to be. + await login_throttle.clear(db, counter) await login_throttle.clear(db, body.username) if user.status != "active": @@ -501,6 +564,11 @@ async def login( )) db.add(IPLog(user_id=user.id, event="login", ip_address=ip)) pepper = _bundle_pepper(user) + if browser is not None: + browser.last_used_at = datetime.now(UTC) + known = {} + else: + known = {"known_browser": await _remember_browser(db, user)} await db.commit() return { @@ -509,6 +577,7 @@ async def login( "token_type": "bearer", "expires_in": _ttl(), **pepper, + **known, } @@ -787,7 +856,8 @@ async def get_current_user_info( # A passphrase change re-wraps every node's bundle *before* the hub # accepts the new passphrase, and must not start while the hub would # then refuse it. - "passphrase_locked_for": await login_throttle.locked_for(db, current_user.username), + "passphrase_locked_for": await login_throttle.locked_for( + db, _session_counter(current_user)), } @@ -849,13 +919,13 @@ async def update_profile( raise HTTPException( status_code=403, detail="Changing your e-mail requires your passphrase.") - await _take_login_attempt(db, current_user.username) + await _take_login_attempt(db, _session_counter(current_user)) if not await verify_password_off_loop( body.auth_key, current_user.pw_hash, current_user.pw_salt, current_user.pw_version): raise HTTPException(status_code=403, detail="Passphrase does not match") - await login_throttle.clear(db, current_user.username) + await login_throttle.clear(db, _session_counter(current_user)) # How often one account may point the hub at a *different* address. # Long, because this is the only path where a signed-in account chooses @@ -1074,12 +1144,12 @@ async def change_password( current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): - await _take_login_attempt(db, current_user.username) + await _take_login_attempt(db, _session_counter(current_user)) if not await verify_password_off_loop(body.old_auth_key, current_user.pw_hash, current_user.pw_salt, current_user.pw_version): raise HTTPException(status_code=403, detail="Current passphrase does not match") - await login_throttle.clear(db, current_user.username) + await login_throttle.clear(db, _session_counter(current_user)) if body.new_auth_key == body.old_auth_key: raise HTTPException(status_code=400, detail="New passphrase must differ from the current one") @@ -1279,6 +1349,7 @@ async def password_reset( update(RefreshToken).where(RefreshToken.user_id == user.id) .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)) # 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) @@ -1493,6 +1564,7 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus GroupHost.node_id.in_(select(Node.id).where(Node.user_id == user.id)))) 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(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 @@ -1538,11 +1610,11 @@ async def delete_own_account( borrowed laptop or a session left open. Same value as at sign-in, so the hub still never sees the passphrase itself. """ - await _take_login_attempt(db, current_user.username) + await _take_login_attempt(db, _session_counter(current_user)) if not await verify_password_off_loop(body.auth_key, current_user.pw_hash, current_user.pw_salt, current_user.pw_version): raise HTTPException(status_code=403, detail="Passphrase does not match") - await login_throttle.clear(db, current_user.username) + await login_throttle.clear(db, _session_counter(current_user)) return await erase_account(db, current_user) diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8ca_known_browsers.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8ca_known_browsers.py new file mode 100644 index 0000000..aaad5c7 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8ca_known_browsers.py @@ -0,0 +1,32 @@ +"""browsers an account has signed in from, each with its own failure counter + +Revision ID: d4e5f6a7b8ca +Revises: c3d4e5f6a7b9 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = "d4e5f6a7b8ca" +down_revision: str | Sequence[str] | None = "c3d4e5f6a7b9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "known_browsers", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("user_id", sa.String(36), sa.ForeignKey("users.id"), nullable=False), + sa.Column("token_hash", sa.String(64), nullable=False, unique=True), + sa.Column("created_at", sa.DateTime(timezone=True)), + sa.Column("last_used_at", sa.DateTime(timezone=True)), + ) + op.create_index("ix_known_browsers_user_id", "known_browsers", ["user_id"]) + + +def downgrade() -> None: + op.drop_index("ix_known_browsers_user_id", table_name="known_browsers") + op.drop_table("known_browsers") diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py index 1e652a6..dbc0f10 100644 --- a/packages/meshbay-hub/src/meshbay_hub/db/models.py +++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py @@ -429,6 +429,25 @@ class MailQuota(Base): last_sent: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) +class KnownBrowser(Base): + """A browser this account has signed in from, for the sign-in lockout. + + Its own failure counter, which a stranger cannot spend: the lockout keyed by + username alone let anyone who knew a name keep its owner out of every + browser, four requests an hour. Only a hash of the token is kept. It is not + a credential — a sign-in presenting it still needs the passphrase. + """ + + __tablename__ = "known_browsers" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=_uuid) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False, + index=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + last_used_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + class LoginThrottle(Base): """Wrong passphrases per username, for the sign-in lockout (`login_throttle.py`). diff --git a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js index d4c2ab8..ec4a5b6 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js @@ -77,7 +77,7 @@ function CreateGroupFormSimple({ token, onCreated, allowPublicGroups = true }) { <div class="form-field"> <label class="form-label">${t('create_group.name')}</label> <input type="text" placeholder="${t('create_group.name_placeholder')}" - value=${name} onInput=${e => setName(e.target.value)} required autofocus /> + value=${name} onInput=${e => setName(e.target.value)} required autofocus maxlength="128" /> </div> <div class="form-field" style="margin-bottom:0"> @@ -255,6 +255,9 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl name: name.trim(), path: mainRoot.path, writable: mainRoot.writable !== false, + // How people join is set on the node, from this form — the node does + // not take it from the hub. + joinPolicy, }; await platform.node.op('attachGroup', attachBody); await platform.node.op('reload'); @@ -371,7 +374,7 @@ function CreateGroupWizard({ token, username, onCreated, onNodeLinked, allowPubl <div class="form-field"> <label class="form-label">${t('create_group.name')}</label> <input type="text" placeholder="${t('create_group.name_placeholder')}" - value=${name} onInput=${e => setName(e.target.value)} required autofocus /> + value=${name} onInput=${e => setName(e.target.value)} required autofocus maxlength="128" /> </div> <div class="form-field"> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/csv.js b/packages/meshbay-hub/src/meshbay_hub/static/csv.js new file mode 100644 index 0000000..310bdca --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/csv.js @@ -0,0 +1,14 @@ +/** + * One CSV cell, quoted when it has to be, and never a formula. + * + * A spreadsheet runs a cell that starts with `=`, `+`, `-` or `@` (or a tab or a + * carriage return in front of one) as a formula. The audit export carries text + * a member chose — a refused blob's kind, a file name — so such a cell is given + * a leading apostrophe, which spreadsheets read as "this is text", and which is + * what other exports do. + */ +export function csvCell(value) { + let s = value == null ? '' : String(value); + if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`; + return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js index 6bd5896..7bbcac5 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js @@ -161,6 +161,12 @@ async function deriveBundleSessionKey(password, username, userId, pepperB64, pep // HKDF keys are non-extractable by specification. v3: await crypto.subtle.importKey('raw', m, 'HKDF', false, ['deriveKey', 'deriveBits']), pepperVersion: pepperVersion || 1, + // TRANSITIONAL — the key MBK2 bundles were sealed under, which this same + // Argon2 run produces anyway. Kept for the session so a node still holding + // one has it opened and replaced by MBK3 on the account's next visit, + // rather than the member being re-invited. Decrypt only; nothing is sealed + // under it. Remove once no MBK2 bundle is left on any node. + legacy: await crypto.subtle.importKey('raw', a, { name: 'AES-GCM' }, false, ['decrypt']), }; } @@ -321,13 +327,52 @@ async function encryptBundle(skEdRaw, skXRaw, aesKey, { userId, nodePk, pepperVe return btoa(String.fromCharCode(...out)); } -/** 'current', or 'retired' for anything written before MBK3. */ +// TRANSITIONAL — the format before MBK3: "MBK2" ‖ nonce (12) ‖ AES-GCM under +// the passphrase's Argon2 key alone, no associated data. Read once to be +// replaced; never written. +const LEGACY_MAGIC = 'MBK2'; + +/** + * 'current'; 'legacy' for MBK2, opened once with the session's legacy key and + * replaced; 'retired' for anything older, which is not read at all. + */ function bundleFormat(bundleB64) { try { - return atob(bundleB64).startsWith(BUNDLE_MAGIC) ? 'current' : 'retired'; + const head = atob(bundleB64).slice(0, 4); + if (head === BUNDLE_MAGIC) return 'current'; + return head === LEGACY_MAGIC ? 'legacy' : 'retired'; } catch { return 'retired'; } } +/** TRANSITIONAL — open an MBK2 bundle (passphrase or recovery copy). */ +async function decryptLegacyBundle(bundleB64, aesKey) { + if (bundleFormat(bundleB64) !== 'legacy') throw new Error('not an MBK2 bundle'); + const raw = _b64bytes(bundleB64); + const off = LEGACY_MAGIC.length; + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: raw.slice(off, off + 12) }, aesKey, raw.slice(off + 12)); + return JSON.parse(new TextDecoder().decode(plain)); +} + +/** + * TRANSITIONAL — an identity read from an MBK2 bundle, sealed again as MBK3 + * for the same node (and the recovery copy too, when a recovery key is in + * hand), for the caller to store in place of the old one. + */ +async function resealLegacyIdentity(keys, sessionKey, recoveryKey, { userId, nodePk }) { + const skEd = _b64bytes(keys.skEd); + const skX = _b64bytes(keys.skX); + const out = { + bundleEnc: await encryptBundle(skEd, skX, await nodeBundleKey(sessionKey, nodePk), + { userId, nodePk, pepperVersion: sessionKey.pepperVersion }), + }; + if (recoveryKey) { + out.bundleEncRecovery = await encryptBundle(skEd, skX, recoveryKey, + { userId, nodePk, pepperVersion: 0 }); + } + return out; +} + // ── Registration ────────────────────────────────────────────────────────────── /** @@ -426,13 +471,36 @@ async function decryptBundle(bundleB64, aesKey, { userId, nodePk }) { * decrypts it and returns the keys + encrypted bundle for push to node. * Otherwise returns bundleKey so the caller can fetch from node during handshake. */ +// The token the hub gave this browser at an earlier sign-in, per account. It +// is not a credential — the passphrase is still asked — but a sign-in that +// presents it has a failure counter of its own, so a stranger who keeps +// failing on this account's name locks only browsers it has never used. +// Kept across sign-outs on purpose: forgetting it would be the lockout again. +const KNOWN_BROWSERS = 'mb_known_browsers'; + +function _knownBrowser(username) { + try { return (JSON.parse(localStorage.getItem(KNOWN_BROWSERS)) || {})[username] || null; } + catch { return null; } +} + +function _rememberBrowser(username, token) { + if (!token) return; + try { + const all = JSON.parse(localStorage.getItem(KNOWN_BROWSERS)) || {}; + all[username] = token; + localStorage.setItem(KNOWN_BROWSERS, JSON.stringify(all)); + } catch { /* storage refused: this browser stays an unknown one */ } +} + async function loginAndRecover(username, password) { const authKey = await deriveAuthKey(password, username); + const known = _knownBrowser(username); const resp = await hubCall('/v1/users/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, auth_key: authKey }), + body: JSON.stringify({ username, auth_key: authKey, + ...(known ? { known_browser: known } : {}) }), }); if (!resp.ok) { @@ -454,6 +522,7 @@ async function loginAndRecover(username, password) { } const data = await resp.json(); + _rememberBrowser(username, data.known_browser); const result = { accessToken: data.access_token, refreshToken: data.refresh_token, @@ -492,7 +561,7 @@ window.MeshBayKeys = { // The bundle key (docs/MESHBAY_DESIGN.md §3.1, §3.7): one session key per // sign-in, one derived key per node, one format. deriveBundleSessionKey, sessionBundleKey, nodeBundleKey, fetchBundlePepper, - encryptBundle, decryptBundle, bundleFormat, + encryptBundle, decryptBundle, bundleFormat, decryptLegacyBundle, resealLegacyIdentity, // Account recovery key (docs/MESHBAY_DESIGN.md §3.6). generateRecoveryKey, deriveRecoveryKey, }; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js index f940934..41e9567 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/node-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/node-page.js @@ -1,3 +1,4 @@ +import { csvCell } from './csv.js'; import { html, useState, useEffect, useCallback, useRef, } from './vendor/htm-preact.js'; @@ -585,17 +586,13 @@ export function NodePage({ groups, token, username }) { const cols = ['timestamp', 'event', 'user', 'user_id', 'ip', 'group', 'group_id', 'detail']; - const esc = (v) => { - const s = v == null ? '' : String(v); - return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; - }; const lines = [cols.join(',')]; for (const e of rows) { lines.push([ new Date(e.timestamp * 1000).toISOString(), e.event, e.username || '', e.user_id || '', e.ip || '', e.group_name || '', e.group_id || '', e.detail || '', - ].map(esc).join(',')); + ].map(csvCell).join(',')); } const csv = lines.join('\r\n') + '\r\n'; const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-'); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js index bb2438c..34085ae 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/portable-name.js @@ -20,8 +20,13 @@ const WINDOWS_RESERVED = new Set([ ]); const RESERVED_CHARS = new Set('<>:"/\\|?*'); +// The bidirectional controls: "invoice\u202efdp.exe" displays as +// "invoiceexe.pdf", and a saved name must say what the file is. +const BIDI_CONTROLS = new Set('\u061c\u200e\u200f\u202a\u202b\u202c\u202d\u202e' + + '\u2066\u2067\u2068\u2069'); -const reserved = (c) => RESERVED_CHARS.has(c) || c.charCodeAt(0) < 32; +const reserved = (c) => RESERVED_CHARS.has(c) || BIDI_CONTROLS.has(c) + || c.charCodeAt(0) < 32; function isPortable(name) { if (!name || name === '.' || name === '..') return false; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js index 8bf884c..cdf86b0 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js @@ -117,7 +117,9 @@ async function rewrapAllNodes(o) { anyOk = true; continue; } - if (tp.newNodeBundle) { + // An identity read from an MBK2 bundle (TRANSITIONAL) is an existing + // one, and is re-sealed below like any other. + if (tp.newNodeBundle && !tp.upgradedLegacy) { // No identity existed on this node — connect just minted one under // the old key. Don't persist it: the next time this group is opened // the normal flow creates one under the current key, and storing it diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 9b86921..f9e1370 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -684,6 +684,8 @@ class MeshBayTransport { /** Set on a first join: the identity created for this node, still to be left with it. */ get newNodeBundle() { return this._newNodeBundle || null; } + /** TRANSITIONAL — the identity was read from an MBK2 bundle, not created. */ + get upgradedLegacy() { return Boolean(this._upgradedLegacy); } set newNodeBundle(v) { this._newNodeBundle = v; } /** The recovery-wrapped copy of that same first-join identity, when a recovery key was in hand. */ @@ -765,6 +767,7 @@ class MeshBayTransport { this._groupId = groupId || ''; this._newNodeBundle = null; this._newNodeBundleRecovery = null; + this._upgradedLegacy = false; this._joinError = null; // Per connection, for the same reason the chat keys and the roster are // dropped further down: the device the *previous* connection identified @@ -1048,6 +1051,8 @@ class MeshBayTransport { fresh = await this._settleNativeIdentity(kpResp); } else if (this._nodeHasBundle && K.bundleFormat(kpResp.bundle_enc) === 'retired') { throw _retiredBundleError(); + } else if (this._nodeHasBundle && K.bundleFormat(kpResp.bundle_enc) === 'legacy') { + keys = await this._openLegacyBundle(kpResp, sealedFor); } else if (this._nodeHasBundle) { try { keys = await K.decryptBundle(kpResp.bundle_enc, @@ -1298,6 +1303,46 @@ class MeshBayTransport { * hangs, the textbox is dead" report. Every exit below names itself. */ /** + * TRANSITIONAL — an MBK2 bundle, opened with the session's legacy key (or + * the recovery copy with the recovery key) and sealed again as MBK3, left + * for `settleNodeBundle` to store in its place once the connection is made. + * + * A session restored from before the legacy key was kept has none: the + * passphrase is asked for again (`no_keys`) rather than the identity being + * declared lost. A legacy key that does not open it — a bundle sealed under + * an older passphrase — is what a current bundle that does not open is: the + * caller goes on to a first join. + */ + async _openLegacyBundle(kpResp, sealedFor) { + const K = window.MeshBayKeys; + let keys = null; + if (this._bundleKey.legacy) { + try { keys = await K.decryptLegacyBundle(kpResp.bundle_enc, this._bundleKey.legacy); } + catch { /* sealed under another passphrase */ } + } + if (!keys && this._recoveryKey && kpResp.bundle_enc_recovery + && K.bundleFormat(kpResp.bundle_enc_recovery) === 'legacy') { + try { + keys = await K.decryptLegacyBundle(kpResp.bundle_enc_recovery, this._recoveryKey); + this._recoveredFromRecovery = true; + } catch { /* not this recovery key */ } + } + if (!keys) { + if (!this._bundleKey.legacy && !this._recoveryKey) { + const err = new Error('Your passphrase is needed once to update how this node keeps your identity'); + err.reason = 'no_keys'; + throw err; + } + return null; + } + const sealed = await K.resealLegacyIdentity(keys, this._bundleKey, this._recoveryKey, sealedFor); + this._newNodeBundle = sealed.bundleEnc; + this._newNodeBundleRecovery = sealed.bundleEncRecovery || null; + this._upgradedLegacy = true; + return keys; + } + + /** * This node's identity when the desktop application holds the keys. * * Kept by the application once it has it, so a bundle left on the node — @@ -1316,8 +1361,16 @@ class MeshBayTransport { throw _retiredBundleError(); } try { + // An MBK2 bundle too (TRANSITIONAL): the application opens it with + // the legacy key it kept from the passphrase, and `settleNodeBundle` + // then replaces or withdraws it as browser access says. pub = await P.openBundle(uid, pk, { bundleEnc: kpResp.bundle_enc }); } catch (e) { + if (String(e && e.message).includes('no_legacy_key')) { + const err = new Error('Your passphrase is needed once to update how this node keeps your identity'); + err.reason = 'no_keys'; + throw err; + } // Sealed under a passphrase no longer in use: as in a browser, a // passphrase change must report it, and a first join replaces it. if (this._rewrapOnly) throw new Error('could not open the stored identity'); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html deleted file mode 100644 index 46003a7..0000000 --- a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html +++ /dev/null @@ -1,265 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>MeshBay — WebRTC Spike Test</title> - <style> - *, *::before, *::after { box-sizing: border-box; } - body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; } - .container { max-width: 800px; margin: 32px auto; padding: 0 16px; } - h1 { color: #38bdf8; font-size: 1.4em; } - h2 { color: #94a3b8; font-size: 1.1em; margin-top: 2em; } - .step { background: #1e293b; border: 1px solid #334155; border-radius: 8px; - padding: 16px; margin: 12px 0; } - .step.done { border-color: #22c55e; } - .step.fail { border-color: #ef4444; } - .step.active { border-color: #38bdf8; } - input { padding: 8px 12px; border: 1px solid #475569; border-radius: 6px; - background: #0f172a; color: #e2e8f0; font-size: 0.95em; margin: 4px; width: 240px; } - button { padding: 8px 20px; background: #0ea5e9; color: #fff; border: none; - border-radius: 6px; cursor: pointer; font-size: 0.95em; margin: 4px; } - button:hover { background: #0284c7; } - button:disabled { background: #475569; cursor: not-allowed; } - #log { background: #020617; border: 1px solid #1e293b; border-radius: 8px; - padding: 12px; font-family: monospace; font-size: 0.85em; line-height: 1.6; - max-height: 400px; overflow-y: auto; white-space: pre-wrap; } - .ok { color: #22c55e; } - .err { color: #ef4444; } - .info { color: #38bdf8; } - .warn { color: #f59e0b; } - .dim { color: #64748b; } - .badge { display: inline-block; background: #22c55e; color: #0f172a; padding: 2px 8px; - border-radius: 4px; font-size: 0.8em; font-weight: bold; margin-left: 8px; } - .badge.fail { background: #ef4444; color: #fff; } - </style> -</head> -<body> -<div class="container"> - <h1>MeshBay — WebRTC DataChannel Spike Test</h1> - <p class="dim">Phase 9.5 — E2E browser → NAT → node file transfer via WebRTC</p> - - <div class="step" id="step-login"> - <h2>1. Login to Hub</h2> - <input id="username" placeholder="Username" value="bob"> - <input id="password" placeholder="Password" type="password" value="bob"> - <button id="btn-login" onclick="doLogin()">Login</button> - <span id="login-status"></span> - </div> - - <div class="step" id="step-connect"> - <h2>2. Connect to Node via WebRTC</h2> - <input id="node-id" placeholder="Node ID"> - <input id="group-id" placeholder="Group ID (optional)"> - <button id="btn-connect" onclick="doConnect()" disabled>Connect</button> - <span id="connect-status"></span> - </div> - - <div class="step" id="step-transfer"> - <h2>3. File Transfer Test</h2> - <button id="btn-index" onclick="doFetchIndex()" disabled>Fetch Index</button> - <br> - <input id="file-id" placeholder="File ID (blake3 hex, from node log)"> - <button id="btn-chunk" onclick="doFetchChunk()" disabled>Fetch Chunk</button> - <span id="transfer-status"></span> - </div> - - <h2>Log</h2> - <div id="log"></div> -</div> - -<script src="/transport.js?v=2"></script> -<script> -const HUB_URL = window.location.origin; -const params = new URLSearchParams(window.location.search); -let accessToken = null; -let jwtToken = null; -let transport = null; -let fileIndex = null; -let connecting = false; - -// Pre-fill from URL params -if (params.get('user')) document.getElementById('username').value = params.get('user'); -if (params.get('pass')) document.getElementById('password').value = params.get('pass'); -if (params.get('node')) document.getElementById('node-id').value = params.get('node'); -if (params.get('group')) document.getElementById('group-id').value = params.get('group'); -if (params.get('file')) document.getElementById('file-id').value = params.get('file').replace(/\s+/g, ''); - -// Auto-run if all params provided -if (params.get('auto')) { - setTimeout(async () => { - await doLogin(); - if (accessToken) await doConnect(); - if (transport && transport.connected) { - await doFetchIndex(); - if (document.getElementById('file-id').value) await doFetchChunk(); - } - }, 500); -} - -function logMsg(cls, text) { - const el = document.getElementById('log'); - const line = document.createElement('span'); - line.className = cls; - line.textContent = text + '\n'; - el.appendChild(line); - el.scrollTop = el.scrollHeight; -} - -function setStep(id, state) { - const el = document.getElementById(id); - el.className = 'step ' + state; -} - -async function doLogin() { - const user = document.getElementById('username').value; - const pass = document.getElementById('password').value; - logMsg('info', `Logging in as ${user}...`); - setStep('step-login', 'active'); - - try { - const resp = await fetch(`${HUB_URL}/v1/users/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: user, password: pass }), - }); - - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.detail || resp.statusText); - } - - const data = await resp.json(); - accessToken = data.access_token; - jwtToken = data.access_token; - logMsg('ok', `Login OK — token: ${accessToken.substring(0, 20)}...`); - setStep('step-login', 'done'); - document.getElementById('login-status').innerHTML = '<span class="badge">OK</span>'; - document.getElementById('btn-connect').disabled = false; - } catch (e) { - logMsg('err', `Login FAILED: ${e.message}`); - setStep('step-login', 'fail'); - document.getElementById('login-status').innerHTML = '<span class="badge fail">FAIL</span>'; - } -} - -async function doConnect() { - if (connecting) { logMsg('warn', 'Connect already in progress'); return; } - const nodeId = document.getElementById('node-id').value; - const groupId = document.getElementById('group-id').value; - if (!nodeId) { logMsg('warn', 'Enter a node ID'); return; } - - connecting = true; - if (transport) { transport.close(); transport = null; } - - logMsg('info', `Connecting to node ${nodeId.substring(0, 8)}... via WebRTC`); - setStep('step-connect', 'active'); - - try { - transport = new MeshBayTransport(HUB_URL, accessToken); - - logMsg('dim', ' Creating RTCPeerConnection...'); - logMsg('dim', ' Creating DataChannel "mnp"...'); - logMsg('dim', ' Gathering ICE candidates...'); - logMsg('dim', ' Sending SDP offer to hub...'); - - const t0 = performance.now(); - const ack = await transport.connect(nodeId, jwtToken, groupId); - const elapsed = (performance.now() - t0).toFixed(0); - - logMsg('ok', `WebRTC connected in ${elapsed}ms`); - logMsg('ok', ` MNP handshake_ack — node_pk: ${ack.node_pk?.substring(0, 16)}...`); - logMsg('ok', ` DataChannel state: ${transport._channel?.readyState}`); - setStep('step-connect', 'done'); - document.getElementById('connect-status').innerHTML = '<span class="badge">P2P OK</span>'; - document.getElementById('btn-index').disabled = false; - document.getElementById('btn-chunk').disabled = false; - } catch (e) { - logMsg('err', `Connection FAILED: ${e.message}`); - setStep('step-connect', 'fail'); - document.getElementById('connect-status').innerHTML = '<span class="badge fail">FAIL</span>'; - } finally { - connecting = false; - } -} - -async function doFetchIndex() { - logMsg('info', `Fetching Mesh Group Index... (channel: ${transport?._channel?.readyState})`); - try { - const t0 = performance.now(); - const indexBytes = await transport.fetchIndex(); - const elapsed = (performance.now() - t0).toFixed(0); - - logMsg('ok', `Index received: ${indexBytes.byteLength} bytes in ${elapsed}ms`); - - try { - const envelope = msgpack_decode(indexBytes); - logMsg('dim', ` type: ${envelope.type}, encrypted: ${envelope.encrypted}, version: ${envelope.version}`); - logMsg('dim', ` group_id: ${envelope.group_id}`); - - if (envelope.encrypted) { - logMsg('warn', ` Index is GEK-encrypted — browser decryption not implemented in spike`); - logMsg('dim', ` ct_b64 length: ${envelope.ct_b64?.length || 0} chars`); - logMsg('info', ` Spike workaround: enter a file_id manually or use Fetch First Chunk`); - // Store envelope so chunk test can proceed with manual file_id - fileIndex = { entries: [], envelope }; - } else { - // Public group: decompress and parse - logMsg('dim', ` Public index — data_b64 length: ${envelope.data_b64?.length || 0}`); - fileIndex = { entries: [], envelope }; - } - } catch (pe) { - logMsg('warn', ` Could not parse index envelope: ${pe.message}`); - } - } catch (e) { - logMsg('err', `Index fetch FAILED: ${e.message}`); - } -} - -async function doFetchChunk() { - let fileId = document.getElementById('file-id').value.replace(/\s+/g, ''); - - if (!fileId) { - logMsg('warn', 'Enter a file_id (blake3 hex hash from node indexer log)'); - logMsg('dim', ' Look for "Initial scan complete" in the node terminal'); - logMsg('dim', ' Or run: python -c "import blake3; print(blake3.blake3(open(\'QE/demo-v3/shared_media/sample.txt\',\'rb\').read()).hexdigest())"'); - return; - } - - logMsg('info', `Fetching chunk 0 of ${fileId.substring(0, 16)}... (channel: ${transport?._channel?.readyState})`); - - try { - const t0 = performance.now(); - const chunkMsg = await transport.fetchChunk(fileId, 0); - const elapsed = (performance.now() - t0).toFixed(0); - - if (chunkMsg.type === 'error') { - logMsg('err', `Chunk fetch error: ${chunkMsg.detail}`); - return; - } - - logMsg('ok', `Chunk received in ${elapsed}ms:`); - logMsg('ok', ` type: ${chunkMsg.type}`); - logMsg('ok', ` chunk_index: ${chunkMsg.chunk_index}`); - logMsg('ok', ` plaintext_size: ${chunkMsg.plaintext_size} bytes`); - logMsg('ok', ` ct_b64 length: ${chunkMsg.ct_b64?.length || 0} chars`); - logMsg('ok', ` nonce_b64: ${chunkMsg.nonce_b64?.substring(0, 16)}...`); - logMsg('ok', ` sig_b64: ${chunkMsg.sig_b64?.substring(0, 16)}...`); - - logMsg('', ''); - logMsg('ok', '=== SPIKE TEST PASSED ==='); - logMsg('ok', 'Browser connected to node via WebRTC DataChannel.'); - logMsg('ok', 'MNP handshake, index sync, and file chunk transfer all work.'); - logMsg('ok', 'Data flowed P2P — hub was only used for signaling.'); - - setStep('step-transfer', 'done'); - document.getElementById('transfer-status').innerHTML = '<span class="badge">E2E OK</span>'; - } catch (e) { - logMsg('err', `Chunk fetch FAILED: ${e.message}`); - setStep('step-transfer', 'fail'); - document.getElementById('transfer-status').innerHTML = '<span class="badge fail">FAIL</span>'; - } -} -</script> -</body> -</html> diff --git a/packages/meshbay-hub/tests/test_bundle_key.py b/packages/meshbay-hub/tests/test_bundle_key.py index 333f8f8..f6c99f8 100644 --- a/packages/meshbay-hub/tests/test_bundle_key.py +++ b/packages/meshbay-hub/tests/test_bundle_key.py @@ -156,7 +156,7 @@ def test_an_earlier_format_is_refused_by_name(tmp_path): } console.log(JSON.stringify(results)); """) - assert out == [["retired", "bundle_format_retired"], ["retired", "bundle_format_retired"]] + assert out == [["legacy", "bundle_format_retired"], ["retired", "bundle_format_retired"]] def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path): @@ -181,3 +181,49 @@ def test_the_playlist_key_is_no_node_key(tmp_path): })); """) assert out["distinct"] + + +def test_an_mbk2_bundle_is_opened_once_and_sealed_again_as_mbk3(tmp_path): + """ + TRANSITIONAL. Nodes still hold bundles sealed under the passphrase's Argon2 + key alone. The same Argon2 run that makes `M` makes that key, so the session + keeps it — decrypt only — and the identity is moved to MBK3 on the account's + next visit instead of the member being re-invited. + """ + out = _run(tmp_path, """ + argonCalls = 0; + const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1); + const calls = argonCalls; + // An MBK2 bundle as 0.16 wrote it: "MBK2" ‖ nonce ‖ AES-GCM(A), no AAD. + const a = await crypto.subtle.importKey('raw', await _bundleKeyBytes('p', 'someone'), + { name: 'AES-GCM' }, false, ['encrypt']); + const nonce = new Uint8Array(12).fill(3); + const plain = new TextEncoder().encode(JSON.stringify({ skEd: btoa('ED'), skX: btoa('XX') })); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, a, plain)); + const raw = new Uint8Array(4 + 12 + ct.length); + raw.set(new TextEncoder().encode('MBK2')); raw.set(nonce, 4); raw.set(ct, 16); + const mbk2 = btoa(String.fromCharCode(...raw)); + + const keys = await K().decryptLegacyBundle(mbk2, sk.legacy); + const resealed = await K().resealLegacyIdentity(keys, sk, null, { userId: 'uid-1', nodePk: 'NODE' }); + const back = await K().decryptBundle(resealed.bundleEnc, await K().nodeBundleKey(sk, 'NODE'), + { userId: 'uid-1', nodePk: 'NODE' }); + let otherPassphrase = 'opened'; + const sk2 = await K().deriveBundleSessionKey('another', 'someone', 'uid-1', PEPPER, 1); + try { await K().decryptLegacyBundle(mbk2, sk2.legacy); } catch { otherPassphrase = 'refused'; } + let sealsUnderLegacy = 'yes'; + try { await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, sk.legacy, plain); } + catch { sealsUnderLegacy = 'no'; } + console.log(JSON.stringify({ + calls, format: K().bundleFormat(mbk2), keys, newFormat: K().bundleFormat(resealed.bundleEnc), + back, otherPassphrase, sealsUnderLegacy, extractable: sk.legacy.extractable, + })); + """) + assert out["calls"] == 1, "keeping the legacy key must not cost a second Argon2 run" + assert out["format"] == "legacy" + assert out["keys"] == {"skEd": "RUQ=", "skX": "WFg="} + assert out["newFormat"] == "current" + assert out["back"] == out["keys"] + assert out["otherPassphrase"] == "refused" + assert out["sealsUnderLegacy"] == "no", "the legacy key opens; it never seals" + assert out["extractable"] is False diff --git a/packages/meshbay-hub/tests/test_csv_cell.py b/packages/meshbay-hub/tests/test_csv_cell.py new file mode 100644 index 0000000..ca38805 --- /dev/null +++ b/packages/meshbay-hub/tests/test_csv_cell.py @@ -0,0 +1,32 @@ +""" +The audit export never hands a spreadsheet a formula. + +It carries text a member chose — a refused blob's kind, a file name. A cell that +starts with `=`, `+`, `-` or `@` runs as a formula when the operator opens the +file, so it is given a leading apostrophe, which spreadsheets read as text. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +CSV = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" / "csv.js" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node unavailable") + +CASES = ['=HYPERLINK("http://x","y")', "+1+1", "-2+3", "@SUM(A1)", "\t=1", "plain", + 'with "quotes"', "a,b", "", None, 12] + + +def test_a_cell_is_text_and_quoted_where_it_must_be(tmp_path): + script = tmp_path / "case.mjs" + script.write_text( + f"import {{ csvCell }} from '{CSV.as_uri()}';\n" + f"console.log(JSON.stringify({json.dumps(CASES)}.map(csvCell)));\n") + out = json.loads(subprocess.run(["node", str(script)], capture_output=True, + text=True, check=True).stdout) + assert out == ['"\'=HYPERLINK(""http://x"",""y"")"', "'+1+1", "'-2+3", "'@SUM(A1)", + "'\t=1", "plain", '"with ""quotes"""', '"a,b"', "", "", "12"] diff --git a/packages/meshbay-hub/tests/test_desktop_keyring.py b/packages/meshbay-hub/tests/test_desktop_keyring.py index 963ee55..e55e6d0 100644 --- a/packages/meshbay-hub/tests/test_desktop_keyring.py +++ b/packages/meshbay-hub/tests/test_desktop_keyring.py @@ -119,10 +119,33 @@ const v = JSON.parse(fs.readFileSync(input, 'utf8')); await K.nodeBundleKey(sk, 'NODE-P'), { userId: v.userId, nodePk: 'NODE-P' }); out.sealed_here_opens_in_page = back.skX === pageId.skXB64; + // 3b. TRANSITIONAL: an MBK2 bundle, sealed under the Argon2 key alone as + // 0.16 wrote it, is opened with the legacy key kept beside M. + { + const nc = require('crypto'); + const salt = nc.createHash('sha256').update(`meshbay:bundle:v2:${v.user}`).digest().subarray(0, 16); + const a = await argon2(v.password, salt, + { memory: 131072, passes: 3, parallelism: 1, tagLength: 32 }); + const ed = nc.generateKeyPairSync('ed25519').privateKey.export({ format: 'der', type: 'pkcs8' }); + const x = nc.generateKeyPairSync('x25519').privateKey.export({ format: 'der', type: 'pkcs8' }); + const nonce = nc.randomBytes(12); + const c = nc.createCipheriv('aes-256-gcm', Buffer.from(a), nonce); + const body = Buffer.concat([c.update(JSON.stringify({ skEd: ed.toString('base64'), + skX: x.toString('base64') })), c.final()]); + const mbk2 = Buffer.concat([Buffer.from('MBK2'), nonce, body, c.getAuthTag()]).toString('base64'); + out.legacy_open = ring.openBundle(v.userId, 'NODE-L', { bundleEnc: mbk2 }); + out.legacy_kept = ring.identity(v.userId, 'NODE-L'); + const keptLegacy = store.masters[v.userId].legacy; + delete store.masters[v.userId].legacy; + try { ring.openBundle(v.userId, 'NODE-M', { bundleEnc: mbk2 }); out.legacy_missing = 'opened'; } + catch (e) { out.legacy_missing = e.message; } + store.masters[v.userId].legacy = keptLegacy; + } + // 4. nothing but public keys come out of the keyring's answers. out.identity_answer = ring.identity(v.userId, v.node); out.retired = (() => { try { ring.openBundle(v.userId, 'NODE-R', - { bundleEnc: Buffer.from('MBK2' + 'x'.repeat(40)).toString('base64') }); } + { bundleEnc: Buffer.from('y'.repeat(44)).toString('base64') }); } catch (e) { return e.code; } })(); out.access_default = ring.browserAccess('someone-else'); ring.setBrowserAccess(v.userId, false); @@ -284,3 +307,12 @@ def test_the_application_never_asks_its_own_crypto_for_argon2(): source = (KEYRING.parent / name).read_text(encoding="utf-8") assert "crypto.argon2" not in source, name assert "wasmArgon2(" in (KEYRING.parent / "main.js").read_text(encoding="utf-8") + + +def test_an_mbk2_bundle_is_opened_with_the_kept_legacy_key(out): + """TRANSITIONAL. Kept unsealed, so the next settle replaces the node's copy + with MBK3 or withdraws it; without the legacy key the passphrase is asked + for, rather than the identity being given up.""" + assert set(out["legacy_open"]) == {"pkEdB64", "pkXB64"} + assert out["legacy_kept"]["sealedWith"] is None + assert out["legacy_missing"] == "no_legacy_key" diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py index 4426dd7..60aa32f 100644 --- a/packages/meshbay-hub/tests/test_desktop_shell.py +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -701,3 +701,26 @@ def test_an_account_made_in_the_application_starts_without_browser_access(): assert "platform.keys.createdHere(reg.userId)" in register assert "userId" in (STATIC / "keyderive.js").read_text(encoding="utf-8").split( "async function registerUser", 1)[1].split("\n}\n", 1)[0] + + +def _handler(name: str) -> str: + source = _main() + start = source.index(f"handle('{name}'") + return source[start:source.index("\n });", start)] + + +def test_a_finished_download_carries_the_mark_of_the_web(): + """What a browser leaves on every download, so Windows applies SmartScreen + and Protected View. Only checkable here by reading: the stream exists on + NTFS alone, and this suite does not run on Windows.""" + assert "markFromInternet(sink.path)" in _handler("save:end") + source = _main() + mark = source[source.index("function markFromInternet"):] + mark = mark[:mark.index("\n }\n")] + assert "Zone.Identifier" in mark and "ZoneId=3" in mark + assert "process.platform !== 'win32'" in mark + + +def test_a_saved_name_cannot_hide_its_extension(): + begin = _handler("save:begin") + assert "\\u202a-\\u202e" in begin and "\\u2066-\\u2069" in begin diff --git a/packages/meshbay-hub/tests/test_group_name_checked.py b/packages/meshbay-hub/tests/test_group_name_checked.py new file mode 100644 index 0000000..fbc8277 --- /dev/null +++ b/packages/meshbay-hub/tests/test_group_name_checked.py @@ -0,0 +1,33 @@ +""" +A group name is checked where it is created. + +The column is 128 characters wide: a longer name was a database error on +PostgreSQL and a silent truncation on SQLite. And the name is shown to other +people — in their group list, in the invitation mail — so it carries no line +breaks or other control characters, and no bidirectional override that makes it +display as something other than what it is. +""" + +import pytest +from test_bundle_pepper import _login, _register + + +@pytest.mark.asyncio +@pytest.mark.parametrize("name,ok", [ + ("Photos de famille", True), + ("x" * 128, True), + ("👨👩👧 Family", True), # a ZWJ sequence is a name, not a trick + ("x" * 129, False), + ("Films\nClick here", False), + ("tab\there", False), + ("evilgpj.exe", False), + (" ", False), +]) +async def test_a_group_name(client, name, ok): + await _register(client, "group_namer") + token = (await _login(client, "group_namer"))["access_token"] + r = await client.post("/v1/groups", json={"name": name}, + headers={"Authorization": f"Bearer {token}"}) + assert (r.status_code < 300) is ok, (name, r.status_code, r.text) + if not ok: + assert r.status_code == 422 diff --git a/packages/meshbay-hub/tests/test_hub_work_is_bounded.py b/packages/meshbay-hub/tests/test_hub_work_is_bounded.py new file mode 100644 index 0000000..40ea81d --- /dev/null +++ b/packages/meshbay-hub/tests/test_hub_work_is_bounded.py @@ -0,0 +1,51 @@ +""" +What an authenticated caller can make the hub do, bounded where it was not. + +An offer wrote an IP-log row — kept a year — before any check, for whatever +string the caller named as a node, and carried an ICE list of any length to the +node. A node could send `update_groups` as fast as it liked, each one a +database read, with a list of any length. +""" + +import pytest +from meshbay_hub.db.models import IPLog +from sqlalchemy import func, select +from test_availability_between_members import _make_user + + +def _offer(client, user, node_id, candidates): + return client.post(f"/v1/nodes/{node_id}/webrtc/offer", + json={"sdp": "v=0\r\n", "ice_candidates": candidates}, + headers={"Authorization": f"Bearer {user['token']}"}) + + +@pytest.mark.asyncio +async def test_an_offer_that_goes_nowhere_writes_no_log_row(client, db_session): + user = await _make_user(client, "offer_nowhere") + for i in range(5): + r = await _offer(client, user, f"not-a-node-{i}", []) + assert r.status_code == 404 + rows = await db_session.scalar( + select(func.count()).select_from(IPLog).where(IPLog.event == "webrtc_offer")) + assert rows == 0 + + +@pytest.mark.asyncio +async def test_the_ice_list_is_bounded(client): + from meshbay_hub.api.signaling import MAX_ICE_CANDIDATES + user = await _make_user(client, "offer_ice") + one = {"candidate": "candidate:1 1 udp 2122260223 192.0.2.1 50000 typ host", + "sdpMid": "0", "sdpMLineIndex": 0} + assert (await _offer(client, user, "nowhere", [one] * MAX_ICE_CANDIDATES)).status_code == 404 + too_many = [one] * (MAX_ICE_CANDIDATES + 1) + assert (await _offer(client, user, "nowhere", too_many)).status_code == 422 + big = {"candidate": "x" * 40_000} + assert (await _offer(client, user, "nowhere", [big])).status_code == 422 + + +def test_a_node_reloading_is_budgeted(): + from meshbay_hub.api.revocation import UPDATE_GROUPS_BURST, _update_budget, _update_window + _update_window.clear() + assert all(_update_budget("node-a") for _ in range(UPDATE_GROUPS_BURST)) + assert not _update_budget("node-a") + assert _update_budget("node-b"), "one node's budget is not another's" diff --git a/packages/meshbay-hub/tests/test_known_browser.py b/packages/meshbay-hub/tests/test_known_browser.py new file mode 100644 index 0000000..260f08e --- /dev/null +++ b/packages/meshbay-hub/tests/test_known_browser.py @@ -0,0 +1,98 @@ +""" +A stranger who knows your name locks only the browsers you never used. + +The sign-in lockout counts wrong passphrases per username: four an hour from +anyone kept the owner out of every browser for as long as they cared to keep +going. A browser that signed in to the account before presents a token and has +a counter of its own, which nobody else can spend. The token is not a +credential — the passphrase is still asked — and a passphrase re-checked inside +an open session is not the sign-in counter's business at all. +""" + +import pytest +from meshbay_hub.db.models import KnownBrowser, User +from sqlalchemy import func, select +from test_bundle_pepper import KEY, _register + +WRONG = "w" * 44 + + +async def _sign_in(client, username, key=KEY, known=None): + body = {"username": username, "auth_key": key} + if known: + body["known_browser"] = known + return await client.post("/v1/users/login", json=body) + + +async def _lock(client, username): + for _ in range(4): + await _sign_in(client, username, WRONG) + assert (await _sign_in(client, username)).status_code == 429 + + +@pytest.mark.asyncio +async def test_a_known_browser_signs_in_through_a_strangers_lockout(client): + await _register(client, "known_owner") + token = (await _sign_in(client, "known_owner")).json()["known_browser"] + + await _lock(client, "known_owner") # the stranger, without a token + r = await _sign_in(client, "known_owner", known=token) + assert r.status_code == 200, r.text + assert "known_browser" not in r.json(), "a known browser is not given a second token" + + +@pytest.mark.asyncio +async def test_another_accounts_token_is_no_way_round(client): + await _register(client, "known_alice") + await _register(client, "known_bobby") + alices = (await _sign_in(client, "known_alice")).json()["known_browser"] + + await _lock(client, "known_bobby") + assert (await _sign_in(client, "known_bobby", known=alices)).status_code == 429 + + +@pytest.mark.asyncio +async def test_a_known_browser_is_locked_by_its_own_failures(client): + """Whoever holds the token still guesses at the same rate.""" + await _register(client, "known_guess") + token = (await _sign_in(client, "known_guess")).json()["known_browser"] + for _ in range(4): + assert (await _sign_in(client, "known_guess", WRONG, token)).status_code == 401 + assert (await _sign_in(client, "known_guess", known=token)).status_code == 429 + # ...and that spent nothing of a browser that has no token. + assert (await _sign_in(client, "known_guess")).status_code == 200 + + +@pytest.mark.asyncio +async def test_a_locked_name_does_not_stop_its_owner_inside_a_session(client): + await _register(client, "known_inside") + session = (await _sign_in(client, "known_inside")).json()["access_token"] + await _lock(client, "known_inside") + + r = await client.post("/v1/users/me/bundle-pepper", json={"auth_key": KEY}, + headers={"Authorization": f"Bearer {session}"}) + assert r.status_code == 200, r.text + + +@pytest.mark.asyncio +async def test_only_a_hash_is_kept_and_only_twenty(client, db_session): + await _register(client, "known_many") + tokens = [(await _sign_in(client, "known_many")).json()["known_browser"] + for _ in range(25)] + uid = (await db_session.execute( + select(User.id).where(User.username == "known_many"))).scalar_one() + rows = (await db_session.execute( + select(KnownBrowser).where(KnownBrowser.user_id == uid))).scalars().all() + assert len(rows) == 20 + assert not any(t in {r.token_hash for r in rows} for t in tokens) + + +@pytest.mark.asyncio +async def test_erasing_the_account_forgets_its_browsers(client, db_session): + await _register(client, "known_gone") + login = (await _sign_in(client, "known_gone")).json() + r = await client.request("DELETE", "/v1/users/me", json={"auth_key": KEY}, + headers={"Authorization": f"Bearer {login['access_token']}"}) + assert r.status_code == 200, r.text + left = await db_session.scalar(select(func.count()).select_from(KnownBrowser)) + assert left == 0 diff --git a/packages/meshbay-hub/tests/test_login_lockout.py b/packages/meshbay-hub/tests/test_login_lockout.py index 601edbd..8f06d2d 100644 --- a/packages/meshbay-hub/tests/test_login_lockout.py +++ b/packages/meshbay-hub/tests/test_login_lockout.py @@ -131,8 +131,13 @@ async def test_a_burst_of_concurrent_guesses_gets_no_more_than_the_limit(client) @pytest.mark.asyncio -async def test_change_password_counts_on_the_same_row(client): - """It checks the same passphrase, so it is the same oracle.""" +async def test_change_password_counts_on_the_sessions_own_row(client): + """ + It checks the passphrase, so it is counted and locked like a sign-in — on a + row of the session's own. A stranger failing at sign-in must not stop the + owner changing their passphrase from a session they hold, and failures here + must not lock the owner's other browsers out of signing in. + """ await _register(client, "grace_test") token = (await _login(client, "grace_test", RIGHT)).json()["access_token"] auth = {"Authorization": f"Bearer {token}"} @@ -145,7 +150,7 @@ async def test_change_password_counts_on_the_same_row(client): r = await client.post("/v1/users/password", headers=auth, json={ "old_auth_key": RIGHT, "new_auth_key": "n" * 44}) assert r.status_code == 429, r.text - assert (await _login(client, "grace_test", RIGHT)).status_code == 429 + assert (await _login(client, "grace_test", RIGHT)).status_code == 200 @pytest.mark.asyncio @@ -157,10 +162,17 @@ async def test_a_signed_in_session_is_told_its_own_lockout(client): auth = {"Authorization": f"Bearer {token}"} assert (await client.get("/v1/users/me", headers=auth)).json()["passphrase_locked_for"] == 0 - await _fail(client, "olivia_test", 4) + for _ in range(4): + await client.post("/v1/users/password", headers=auth, json={ + "old_auth_key": WRONG, "new_auth_key": "n" * 44}) left = (await client.get("/v1/users/me", headers=auth)).json()["passphrase_locked_for"] assert 3500 <= left <= 3600 + # A stranger failing at sign-in is not this session's lockout. + await _fail(client, "olivia_test", 4) + other = (await _login(client, "olivia_test", RIGHT)) + assert other.status_code == 429 + @pytest.mark.asyncio async def test_an_attempt_that_checks_no_passphrase_is_not_counted(client, db_session): diff --git a/packages/meshbay-hub/tests/test_username_case.py b/packages/meshbay-hub/tests/test_username_case.py new file mode 100644 index 0000000..42f4815 --- /dev/null +++ b/packages/meshbay-hub/tests/test_username_case.py @@ -0,0 +1,24 @@ +""" +A username is unique whatever its case. + +Invitations and member management name people by username, so "Alice" beside +"alice" is one person to whoever reads the list — and a second account under +the other spelling is the way to be mistaken for them. +""" + +import pytest +from test_bundle_pepper import KEY + + +async def _register(client, username, email): + return await client.post("/v1/users/register", json={ + "username": username, "auth_key": KEY, "email": email}) + + +@pytest.mark.asyncio +async def test_a_name_differing_only_by_case_is_taken(client): + assert (await _register(client, "alice_case", "a1@example.invalid")).status_code == 201 + for other in ("Alice_case", "ALICE_CASE", "alice_CASE"): + r = await _register(client, other, "a2@example.invalid") + assert r.status_code == 409, (other, r.text) + |