aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-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/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/node-page.js9
-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
24 files changed, 384 insertions, 347 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/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index cbc4798..c650f75 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -977,7 +977,6 @@ export default {
'node.gek_init_hint': 'Noch kein Gruppenschlüssel. Führen Sie GEK init über die CLI aus, um einen einzurichten.',
'node.gek_public_hint': 'Schlüsselrotation ist für öffentliche Gruppen nicht verfügbar.',
'node.gek_rotate': 'Gruppenschlüssel rotieren',
- 'node.gek_rotate_confirm': 'Gruppenschlüssel rotieren? Verbundene Mitglieder erhalten den neuen Schlüssel automatisch. Bereits heruntergeladene Inhalte sind nicht betroffen.',
'node.gek_rotated': 'Gruppenschlüssel rotiert.',
'node.roster': 'Mitgliederliste',
'node.roster_load': 'Mitgliederliste laden',
@@ -1252,7 +1251,6 @@ export default {
'settings.browser_access_off_in_browser': 'Der Browserzugang ist für dieses Konto ausgeschaltet. Er wird in der MeshBay-Desktopanwendung eingeschaltet, die diesen Browser auch für sich selbst freigeben kann.',
'group.browser_access_off': 'Der Browserzugang ist für dieses Konto ausgeschaltet. Schalten Sie ihn in der MeshBay-Desktopanwendung (Profil) ein oder geben Sie diesen Browser dort frei.',
// Worded by the desktop main process for its own dialogs (main.js).
- 'native.attach_confirm': 'Die Gruppe „{name}" auf diesem Computer hosten und den Ordner {path} mit ihren Mitgliedern teilen?',
'native.folder_confirm': 'Den Ordner {path} mit den Mitgliedern einer auf diesem Computer gehosteten Gruppe teilen? Er wurde nicht in der Ordnerauswahl gewählt.',
'native.node_account_confirm': 'Der Node auf diesem Computer ist für {current} eingerichtet. Stattdessen für {next} einrichten? Er stellt dann die Gruppen, die er für {current} hostet, nicht mehr bereit.',
'native.browser_access_confirm': 'Die Anmeldung über einen Browser erlauben? Die Anwendung legt Ihre Identität auf jedem genutzten Node ab, so versiegelt, dass nur Ihre Passphrase zusammen mit Ihrem Hub-Konto sie öffnen kann.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 38616aa..b4e4adf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -1039,7 +1039,6 @@ export default {
'node.not_operator': 'Could not reach your node. Make sure it is running.',
'node.gek': 'Group key',
'node.gek_rotate': 'Rotate group key',
- 'node.gek_rotate_confirm': 'Rotate the group key? Connected members will receive the new key automatically. Content already downloaded is unaffected.',
'node.gek_rotated': 'Group key rotated.',
'node.gek_init_hint': 'No group key yet. Run GEK init from the CLI to set one up.',
'node.gek_public_hint': 'Key rotation is not available for public groups.',
@@ -1233,7 +1232,6 @@ export default {
'settings.browser_access_off_in_browser': 'Browser access is off for this account. It is turned on in the MeshBay desktop application, which can also approve this browser for itself.',
'group.browser_access_off': 'Browser access is off for this account. Turn it on in the MeshBay desktop application (Profile), or approve this browser from it.',
// Worded by the desktop main process for its own dialogs (main.js).
- 'native.attach_confirm': 'Host the group "{name}" on this computer and share the folder {path} with its members?',
'native.folder_confirm': 'Share the folder {path} with the members of a group hosted on this computer? It was not chosen in the folder picker.',
'native.node_account_confirm': 'The node on this computer is set up for {current}. Set it up for {next} instead? It will stop serving the groups it hosts for {current}.',
'native.browser_access_confirm': 'Allow signing in from a browser? The application will leave your identity on every node you use, sealed so that only your passphrase together with your hub account can open it.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index e510d09..7422b13 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -971,7 +971,6 @@ export default {
'node.gek_init_hint': 'Aún no hay clave de grupo. Ejecute GEK init desde la línea de comandos para configurarla.',
'node.gek_public_hint': 'La rotación de clave no está disponible para los grupos públicos.',
'node.gek_rotate': 'Rotar clave de grupo',
- 'node.gek_rotate_confirm': '¿Rotar la clave de grupo? Los miembros conectados recibirán la nueva clave automáticamente. El contenido ya descargado no se ve afectado.',
'node.gek_rotated': 'Clave de grupo rotada.',
'node.roster': 'Registro',
'node.roster_load': 'Cargar registro',
@@ -1246,7 +1245,6 @@ export default {
'settings.browser_access_off_in_browser': 'El acceso desde el navegador está desactivado para esta cuenta. Se activa en la aplicación de escritorio de MeshBay, que también puede aprobar este navegador por sí mismo.',
'group.browser_access_off': 'El acceso desde el navegador está desactivado para esta cuenta. Actívelo en la aplicación de escritorio de MeshBay (Perfil) o apruebe este navegador desde ella.',
// Worded by the desktop main process for its own dialogs (main.js).
- 'native.attach_confirm': '¿Alojar el grupo «{name}» en este ordenador y compartir la carpeta {path} con sus miembros?',
'native.folder_confirm': '¿Compartir la carpeta {path} con los miembros de un grupo alojado en este ordenador? No se eligió en el selector de carpetas.',
'native.node_account_confirm': 'El node de este ordenador está configurado para {current}. ¿Configurarlo para {next} en su lugar? Dejará de servir los grupos que aloja para {current}.',
'native.browser_access_confirm': '¿Permitir el acceso desde un navegador? La aplicación dejará su identidad en cada node que use, sellada de modo que solo su frase de contraseña, junto con su cuenta del hub, pueda abrirla.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index c361fe4..d7d9228 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -983,7 +983,6 @@ export default {
'node.gek_init_hint': 'Aucune clé de groupe pour l\'instant. Lancez GEK init depuis la ligne de commande pour en créer une.',
'node.gek_public_hint': 'La rotation de clé n\'est pas disponible pour les groupes publics.',
'node.gek_rotate': 'Rotation de la clé de groupe',
- 'node.gek_rotate_confirm': 'Effectuer la rotation de la clé de groupe ? Les membres connectés recevront la nouvelle clé automatiquement. Le contenu déjà téléchargé n\'est pas affecté.',
'node.gek_rotated': 'Clé de groupe renouvelée.',
'node.roster': 'Registre',
'node.roster_load': 'Charger le registre',
@@ -1261,7 +1260,6 @@ export default {
'settings.browser_access_off_in_browser': 'L\'accès depuis un navigateur est désactivé pour ce compte. Il s\'active dans l\'application de bureau MeshBay, qui peut aussi approuver ce navigateur pour lui-même.',
'group.browser_access_off': 'L\'accès depuis un navigateur est désactivé pour ce compte. Activez-le dans l\'application de bureau MeshBay (Profil), ou approuvez ce navigateur depuis celle-ci.',
// Worded by the desktop main process for its own dialogs (main.js).
- 'native.attach_confirm': 'Héberger le groupe « {name} » sur cet ordinateur et partager le dossier {path} avec ses membres ?',
'native.folder_confirm': 'Partager le dossier {path} avec les membres d\'un groupe hébergé sur cet ordinateur ? Il n\'a pas été choisi dans le sélecteur de dossier.',
'native.node_account_confirm': 'Le node de cet ordinateur est configuré pour {current}. Le configurer pour {next} à la place ? Il cessera de servir les groupes qu\'il héberge pour {current}.',
'native.browser_access_confirm': 'Autoriser la connexion depuis un navigateur ? L\'application déposera votre identité sur chaque node que vous utilisez, scellée de sorte que seule votre phrase secrète, avec votre compte sur le hub, puisse l\'ouvrir.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 715eb81..5052378 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -979,7 +979,6 @@ export default {
'node.gek_init_hint': 'Nessuna chiave di gruppo ancora. Esegua GEK init dalla riga di comando per configurarla.',
'node.gek_public_hint': 'La rotazione delle chiavi non è disponibile per i gruppi pubblici.',
'node.gek_rotate': 'Ruota chiave di gruppo',
- 'node.gek_rotate_confirm': 'Ruotare la chiave di gruppo? I membri connessi riceveranno la nuova chiave automaticamente. I contenuti già scaricati non saranno interessati.',
'node.gek_rotated': 'Chiave di gruppo ruotata.',
'node.roster': 'Roster',
'node.roster_load': 'Carica roster',
@@ -1260,7 +1259,6 @@ export default {
'settings.browser_access_off_in_browser': 'L\'accesso dal browser è disattivato per questo account. Si attiva nell\'applicazione desktop di MeshBay, che può anche approvare questo browser per sé.',
'group.browser_access_off': 'L\'accesso dal browser è disattivato per questo account. Attivalo nell\'applicazione desktop di MeshBay (Profilo) o approva questo browser da lì.',
// Worded by the desktop main process for its own dialogs (main.js).
- 'native.attach_confirm': 'Ospitare il gruppo «{name}» su questo computer e condividere la cartella {path} con i suoi membri?',
'native.folder_confirm': 'Condividere la cartella {path} con i membri di un gruppo ospitato su questo computer? Non è stata scelta nel selettore di cartelle.',
'native.node_account_confirm': 'Il node di questo computer è configurato per {current}. Configurarlo invece per {next}? Smetterà di servire i gruppi che ospita per {current}.',
'native.browser_access_confirm': 'Consentire l\'accesso da un browser? L\'applicazione lascerà la tua identità su ogni node che usi, sigillata in modo che solo la tua passphrase, insieme al tuo account sull\'hub, possa aprirla.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 7331820..47a968c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -965,7 +965,6 @@ export default {
'node.gek_init_hint': 'グループ鍵が未設定です。CLI から GEK init を実行して設定してください。',
'node.gek_public_hint': '公開グループでは鍵のローテーションは利用できません。',
'node.gek_rotate': 'グループ鍵をローテーション',
- 'node.gek_rotate_confirm': 'グループ鍵をローテーションしますか?接続中のメンバーは新しい鍵を自動的に受け取ります。ダウンロード済みのコンテンツには影響しません。',
'node.gek_rotated': 'グループ鍵をローテーションしました。',
'node.roster': '名簿',
'node.roster_load': '名簿を読み込む',
@@ -1244,7 +1243,6 @@ export default {
'settings.browser_access_off_in_browser': 'このアカウントではブラウザーからのアクセスがオフです。MeshBay デスクトップアプリでオンにできます。アプリからこのブラウザーだけを承認することもできます。',
'group.browser_access_off': 'このアカウントではブラウザーからのアクセスがオフです。MeshBay デスクトップアプリ(プロフィール)でオンにするか、アプリからこのブラウザーを承認してください。',
// Worded by the desktop main process for its own dialogs (main.js).
- 'native.attach_confirm': 'このコンピューターでグループ「{name}」をホストし、フォルダー {path} をメンバーと共有しますか?',
'native.folder_confirm': 'このコンピューターでホストしているグループのメンバーとフォルダー {path} を共有しますか?このフォルダーはフォルダー選択画面で選ばれたものではありません。',
'native.node_account_confirm': 'このコンピューターの node は {current} 用に設定されています。代わりに {next} 用に設定しますか?{current} のためにホストしているグループは提供されなくなります。',
'native.browser_access_confirm': 'ブラウザーからのサインインを許可しますか?アプリは、利用中のすべての node にあなたの ID を残します。これは、パスフレーズと hub のアカウントの両方がそろった場合にのみ開けるよう封印されます。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index d775aae..eaad700 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -981,7 +981,6 @@ export default {
'node.gek_init_hint': 'Nog geen groepssleutel. Voer GEK init uit via de CLI om er een in te stellen.',
'node.gek_public_hint': 'Sleutelrotatie is niet beschikbaar voor openbare groepen.',
'node.gek_rotate': 'Groepssleutel roteren',
- 'node.gek_rotate_confirm': 'De groepssleutel roteren? Verbonden leden ontvangen de nieuwe sleutel automatisch. Reeds gedownloade inhoud wordt niet beïnvloed.',
'node.gek_rotated': 'Groepssleutel geroteerd.',
'node.roster': 'Ledenlijst',
'node.roster_load': 'Ledenlijst laden',
@@ -1262,7 +1261,6 @@ export default {
'settings.browser_access_off_in_browser': 'Browsertoegang staat uit voor dit account. Die wordt aangezet in de MeshBay-desktoptoepassing, die deze browser ook voor zichzelf kan goedkeuren.',
'group.browser_access_off': 'Browsertoegang staat uit voor dit account. Zet die aan in de MeshBay-desktoptoepassing (Profiel), of keur deze browser daar goed.',
// Worded by the desktop main process for its own dialogs (main.js).
- 'native.attach_confirm': 'De groep "{name}" op deze computer hosten en de map {path} met de leden delen?',
'native.folder_confirm': 'De map {path} delen met de leden van een groep die op deze computer wordt gehost? Hij is niet gekozen in de mapkiezer.',
'native.node_account_confirm': 'De node op deze computer is ingesteld voor {current}. In plaats daarvan instellen voor {next}? Hij stopt dan met het aanbieden van de groepen die hij voor {current} host.',
'native.browser_access_confirm': 'Aanmelden vanuit een browser toestaan? De toepassing laat je identiteit achter op elke node die je gebruikt, zo verzegeld dat alleen je wachtzin samen met je hub-account haar kan openen.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 801e008..2635fb0 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -1003,7 +1003,6 @@ export default {
'node.gek_init_hint': 'Nie ma jeszcze klucza grupy. Uruchom GEK init z wiersza poleceń, aby go skonfigurować.',
'node.gek_public_hint': 'Rotacja klucza nie jest dostępna dla grup publicznych.',
'node.gek_rotate': 'Zmień klucz grupy',
- 'node.gek_rotate_confirm': 'Zmienić klucz grupy? Połączeni członkowie otrzymają nowy klucz automatycznie. Wcześniej pobrane treści pozostaną bez zmian.',
'node.gek_rotated': 'Klucz grupy zmieniony.',
'node.roster': 'Rejestr',
'node.roster_load': 'Wczytaj rejestr',
@@ -1288,7 +1287,6 @@ export default {
'settings.browser_access_off_in_browser': 'Dostęp z przeglądarki jest wyłączony dla tego konta. Włącza się go w aplikacji desktopowej MeshBay, która może też zatwierdzić tę przeglądarkę dla niej samej.',
'group.browser_access_off': 'Dostęp z przeglądarki jest wyłączony dla tego konta. Włącz go w aplikacji desktopowej MeshBay (Profil) albo zatwierdź tę przeglądarkę w tej aplikacji.',
// Worded by the desktop main process for its own dialogs (main.js).
- 'native.attach_confirm': 'Hostować grupę „{name}" na tym komputerze i udostępnić jej członkom folder {path}?',
'native.folder_confirm': 'Udostępnić folder {path} członkom grupy hostowanej na tym komputerze? Nie został wybrany w oknie wyboru folderu.',
'native.node_account_confirm': 'Node na tym komputerze jest skonfigurowany dla {current}. Skonfigurować go zamiast tego dla {next}? Przestanie obsługiwać grupy, które hostuje dla {current}.',
'native.browser_access_confirm': 'Zezwolić na logowanie z przeglądarki? Aplikacja pozostawi Twoją tożsamość na każdym używanym node, zapieczętowaną tak, by otworzyć ją mogło tylko Twoje hasło wraz z kontem na hubie.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 81a97d7..207c1b7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -972,7 +972,6 @@ export default {
'node.gek_init_hint': 'Ainda não há chave de grupo. Execute GEK init na CLI para configurá-la.',
'node.gek_public_hint': 'A rotação de chave não está disponível para grupos públicos.',
'node.gek_rotate': 'Rotacionar chave do grupo',
- 'node.gek_rotate_confirm': 'Rotacionar a chave do grupo? Os membros conectados receberão a nova chave automaticamente. O conteúdo já baixado não é afetado.',
'node.gek_rotated': 'Chave do grupo rotacionada.',
'node.roster': 'Cadastro',
'node.roster_load': 'Carregar cadastro',
@@ -1247,7 +1246,6 @@ export default {
'settings.browser_access_off_in_browser': 'O acesso pelo navegador está desativado para esta conta. Ele é ativado no aplicativo de desktop do MeshBay, que também pode aprovar este navegador para si.',
'group.browser_access_off': 'O acesso pelo navegador está desativado para esta conta. Ative-o no aplicativo de desktop do MeshBay (Perfil) ou aprove este navegador por ele.',
// Worded by the desktop main process for its own dialogs (main.js).
- 'native.attach_confirm': 'Hospedar o grupo "{name}" neste computador e compartilhar a pasta {path} com os membros?',
'native.folder_confirm': 'Compartilhar a pasta {path} com os membros de um grupo hospedado neste computador? Ela não foi escolhida no seletor de pastas.',
'native.node_account_confirm': 'O node deste computador está configurado para {current}. Configurá-lo para {next} em vez disso? Ele deixará de servir os grupos que hospeda para {current}.',
'native.browser_access_confirm': 'Permitir o acesso por um navegador? O aplicativo deixará sua identidade em cada node que você usa, selada de forma que apenas sua frase secreta, junto com sua conta no hub, possa abri-la.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 218b5f3..3ea8699 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -953,7 +953,6 @@ export default {
'node.gek_init_hint': '尚无群组密钥。请从命令行运行 GEK 初始化来设置。',
'node.gek_public_hint': '公开群组不支持密钥轮换。',
'node.gek_rotate': '轮换群组密钥',
- 'node.gek_rotate_confirm': '轮换群组密钥?已连接的成员将自动收到新密钥。已下载的内容不受影响。',
'node.gek_rotated': '群组密钥已轮换。',
'node.roster': '花名册',
'node.roster_load': '加载花名册',
@@ -1233,7 +1232,6 @@ export default {
'settings.browser_access_off_in_browser': '此账户已关闭浏览器访问。可在 MeshBay 桌面应用中开启,该应用也可以单独批准此浏览器。',
'group.browser_access_off': '此账户已关闭浏览器访问。请在 MeshBay 桌面应用(个人资料)中开启,或从该应用批准此浏览器。',
// Worded by the desktop main process for its own dialogs (main.js).
- 'native.attach_confirm': '在这台电脑上托管群组“{name}”,并与其成员共享文件夹 {path}?',
'native.folder_confirm': '与这台电脑上托管的群组成员共享文件夹 {path}?该文件夹不是在文件夹选择器中选择的。',
'native.node_account_confirm': '这台电脑上的 node 已为 {current} 设置。改为为 {next} 设置吗?它将不再为 {current} 提供其托管的群组。',
'native.browser_access_confirm': '允许从浏览器登录吗?应用会在您使用的每个 node 上留下您的身份,并加以封存,只有您的密码短语配合您的 hub 账户才能打开。',
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..9d230c1 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';
@@ -338,8 +339,6 @@ export function NodePage({ groups, token, username }) {
}
}, [rosterGroup, loadRoster]);
- // No confirmation drawn here: replacing the key is asked natively by the
- // app itself (node:op), which a script in this page cannot answer.
const rotateGek = useCallback(async (groupId) => {
setBusy(true);
setActionMsg('');
@@ -585,17 +584,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>