aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py25
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py53
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py112
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/d4e5f6a7b8ca_known_browsers.py32
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py19
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/csv.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js77
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/portable-name.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js53
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html265
-rw-r--r--packages/meshbay-hub/tests/test_bundle_key.py48
-rw-r--r--packages/meshbay-hub/tests/test_csv_cell.py32
-rw-r--r--packages/meshbay-hub/tests/test_desktop_keyring.py34
-rw-r--r--packages/meshbay-hub/tests/test_desktop_shell.py23
-rw-r--r--packages/meshbay-hub/tests/test_group_name_checked.py33
-rw-r--r--packages/meshbay-hub/tests/test_hub_work_is_bounded.py51
-rw-r--r--packages/meshbay-hub/tests/test_known_browser.py98
-rw-r--r--packages/meshbay-hub/tests/test_login_lockout.py20
-rw-r--r--packages/meshbay-hub/tests/test_username_case.py24
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),
+ ("evil‮gpj.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)
+