diff options
Diffstat (limited to 'packages/meshbay-hub/src')
12 files changed, 147 insertions, 79 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py index 6205180..403f450 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py @@ -21,10 +21,18 @@ from meshbay_hub.db.models import GroupMember, IPLog, Node, User router = APIRouter(prefix="/v1/nodes", tags=["nodes"]) +class MnpTokenRequest(BaseModel): + # The base64 Ed25519 key of the node this token is for. The token is bound + # to it (E10), so it cannot be replayed to another node. The client knows it + # from `/v1/groups/{id}/nodes` before it connects. + node_pk: str = "" + + @router.post("/mnp-token") @limiter.limit("60/minute") async def mnp_token( request: Request, + body: MnpTokenRequest | None = None, current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): @@ -32,16 +40,22 @@ async def mnp_token( Asked for with the member's own session token (require_user_scope, so a node daemon token cannot mint one). The result carries the member's current group - membership and `aud=MNP_AUD`, so it authorises the member to a node and is - refused by the hub API. Short-lived on purpose; the client refetches it for a - new connection or a reconnect, and it is checked only at the handshake, so a - film already playing is never interrupted by its expiry. + membership, `aud=MNP_AUD` and the target node's key, so it authorises the + member to **that** node only and is refused by the hub API and by any other + node. Short-lived on purpose; the client refetches it for a new connection or + a reconnect, and it is checked only at the handshake, so a film already + playing is never interrupted by its expiry. + + The hub does not verify the node key it is handed — binding the token to it + only *restricts* the token to whatever node holds that key, which is the one + the client is connecting to; a wrong key yields a token no node will accept. """ rows = await db.execute( select(GroupMember.group_id).where(GroupMember.user_id == current_user.id)) group_ids = [gid for (gid,) in rows.all()] return { - "mnp_token": issue_mnp_token(current_user.id, groups=group_ids), + "mnp_token": issue_mnp_token(current_user.id, groups=group_ids, + node_pk=(body.node_pk if body else "")), "expires_in": 900, } diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py index f8cae8a..2c0b8db 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py @@ -441,6 +441,7 @@ async def notify_incoming( body: IncomingRequest, request: Request, current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), ): """ Signal a node that a client wants to connect (NAT punch coordination). @@ -450,8 +451,18 @@ async def notify_incoming( arbitrary node emit UDP packets to an address of their choosing — a small reflection primitive using someone else's machine. The probe target must now be the caller's own source address. + + Like the offer relay, the caller must share an active group with the node — + checked **before** anything reveals whether the node is connected, so this is + not a liveness oracle a stranger can poll, and a stranger cannot make a node + punch on their behalf. """ from meshbay_hub.api.netutil import client_ip + from meshbay_hub.api.signaling import require_shared_active_group + + # First, and before anything reveals whether the node is connected: a + # stranger cannot poll this for a node's liveness, nor make it punch. + await require_shared_active_group(db, node_id, current_user.id) caller_ip = client_ip(request) if body.peer_ip != caller_ip: diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py index 60d5e20..fc40204 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py @@ -102,6 +102,51 @@ def _take_offer(user_id: str, node_id: str, now: float) -> float | None: return None +async def require_shared_active_group(db: AsyncSession, node_id: str, user_id: str) -> None: + """The caller must share an **active** group with this node, or the node must + host an open group while public groups are on (that path *is* what a public + group means, so it follows the instance switch). Raises 403 with a uniform + message otherwise — the same answer whether the node is a member's or a + stranger's, and whether it is connected or not, so it is not a liveness + oracle for a non-member. + + Membership is read from the connected-node registry, so a node hosting no + group shares one with nobody (AV24, AV1): the empty claim is "no groups", not + "all of its owner's". Both the WebRTC offer relay and the NAT-punch signal + call this, so they gate the same way (H6). + """ + from meshbay_hub.api.revocation import _node_groups + node_group_ids = set(_node_groups.get(node_id, [])) + if not node_group_ids: + raise HTTPException(status_code=403, + detail="Not a member of any group on this node") + shared = [gid for (gid,) in (await db.execute( + select(GroupMember.group_id).where( + GroupMember.user_id == user_id, + GroupMember.group_id.in_(node_group_ids), + ))).all()] + if not shared: + has_open = None + if await hub_settings.public_groups_allowed(db): + has_open = (await db.execute( + select(Group.id).where( + Group.id.in_(node_group_ids), + Group.join_policy == "open", + Group.status == "active", + ))).first() + if not has_open: + raise HTTPException(status_code=403, + detail="Not a member of any group on this node") + return + statuses = set((await db.execute( + select(Group.status).where(Group.id.in_(shared)))).scalars().all()) + if "active" not in statuses: + # Report the strongest state present — "revoked" is the signed, + # node-enforced one; "suspended" is the reversible hub flag. + state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended") + raise HTTPException(status_code=403, detail=f"Group is {state}") + + @router.post("/{node_id}/webrtc/offer", response_model=WebRTCOfferResponse) # Per address and per node, and only a coarse guard in front of authentication: # the account's budget above is the limit that means something. 600 because an @@ -143,58 +188,10 @@ async def webrtc_offer( if not ws: raise HTTPException(status_code=404, detail="Node not connected") - # The caller must share at least one active group with the target node, - # OR the node must host at least one open-join group (public groups admit - # anyone — the node's MNP handshake handles authorization). - # - # That second path is exactly what "public groups" means, so it is gated by - # the instance switch: with public groups off, a non-member is not brokered a - # connection to a node just because it happens to host an open group. Members - # of that group are unaffected — they match `shared` below. - node_group_ids = set(_node_groups.get(node_id, [])) - # A node registered for no group shares no group with anybody, which is this - # check's own answer — and `if node_group_ids:` used to skip the whole thing, - # membership, group status and the public-group gate together. Since AV1 made - # an empty claim mean "no groups" rather than "all of my owner's", that is - # the *normal* registration of a node hosting nothing: exactly the - # unconfigured node left running that took a group down on 2026-09-11. So the - # machine least able to defend itself was the one any authenticated account - # could make allocate a peer connection and gather ICE, which is H6 restored - # in the one case AV1 made common. - # - # Nothing legitimate is lost by refusing here: a browser cannot complete a - # handshake with such a node anyway — `group_id` is mandatory (M1) and a node - # holding no group key refuses outright (NS8) — so this only declines work - # the node would decline one step later, at its own expense. - if not node_group_ids: - raise HTTPException(status_code=403, - detail="Not a member of any group on this node") - - result = await db.execute( - select(GroupMember.group_id).where( - GroupMember.user_id == current_user.id, - GroupMember.group_id.in_(node_group_ids), - )) - shared = [gid for (gid,) in result.all()] - if not shared: - has_open = None - if await hub_settings.public_groups_allowed(db): - has_open = (await db.execute( - select(Group.id).where( - Group.id.in_(node_group_ids), - Group.join_policy == "open", - Group.status == "active", - ))).first() - if not has_open: - raise HTTPException(status_code=403, detail="Not a member of any group on this node") - else: - statuses = set((await db.execute( - select(Group.status).where(Group.id.in_(shared)))).scalars().all()) - if "active" not in statuses: - # Report the strongest state present — "revoked" is the signed, - # node-enforced one; "suspended" is the reversible hub flag. - state = "revoked" if "revoked" in statuses else next(iter(statuses), "suspended") - raise HTTPException(status_code=403, detail=f"Group is {state}") + # The caller must share an active group with the node (or the node must host + # an open group when public groups are on). One implementation, shared with + # the NAT-punch signal (`notify_incoming`), so both gate the same way. + await require_shared_active_group(db, node_id, current_user.id) # Both refusals say when to come back, and transport.js does: a 429 here is # the hub being busy, never the node being down. diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py index e182ba0..0d0fd95 100644 --- a/packages/meshbay-hub/src/meshbay_hub/auth.py +++ b/packages/meshbay-hub/src/meshbay_hub/auth.py @@ -241,7 +241,7 @@ def issue_access_token( def issue_mnp_token(user_id: str, groups: list[str] | None = None, - ttl: int = 900) -> str: + node_pk: str | None = None, ttl: int = 900) -> str: """Issue the short-lived token a member presents to a node in the handshake. `aud=MNP_AUD`, so it is accepted by `authorize_token` and refused by the hub @@ -249,6 +249,11 @@ def issue_mnp_token(user_id: str, groups: list[str] | None = None, lifetime does not interrupt a long transfer or a film already playing; only a fresh connection or a reconnect needs a fresh one. It carries the same `sub`/`groups`/`jti` the node authorises and denylists on. + + `node` names the node this token is for (its base64 Ed25519 key), so it + cannot be replayed to another node the member also belongs to — the node + checks it in `authorize_token` (E10). The client knows the target node's key + before it connects and asks for a token bound to it. """ if _hub_sk_pem is None: raise RuntimeError("Hub keypair not loaded") @@ -260,6 +265,7 @@ def issue_mnp_token(user_id: str, groups: list[str] | None = None, "iat": now, "exp": now + ttl, "groups": groups or [], + "node": node_pk or "", "scope": "user", "aud": MNP_AUD, } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js index 2164eae..09c4acf 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/auth-page.js @@ -344,6 +344,23 @@ function WelcomePitch() { `; } +// The sign-in page's dark gradient backdrop and frosted card, without the +// pitch beside it — so Register (and its verify/recovery/done steps) sits on +// the same background and reads in the same dark theme as Login. A lone +// `.welcome-side` is centred by `.welcome`'s `justify-content`. +function AuthShell({ children }) { + return html` + <div class="page-center"> + <div class="welcome-backdrop" aria-hidden="true"></div> + <div class="welcome"> + <div class="welcome-side"> + ${children} + </div> + </div> + </div> + `; +} + export function RegisterPage() { const [username, setUsername] = useState(''); const [email, setEmail] = useState(''); @@ -358,7 +375,9 @@ export function RegisterPage() { const [recoveryMnemonic, setRecoveryMnemonic] = useState(''); const [recoverySaved, setRecoverySaved] = useState(false); const [recoveryCopied, setRecoveryCopied] = useState(false); - const [emailRecovery, setEmailRecovery] = useState(true); + // Off by default: mailing the recovery key is opt-in — the key is shown on + // screen to save, and sending a copy is the user's own choice to make. + const [emailRecovery, setEmailRecovery] = useState(false); const captcha = useCaptcha(); const onSubmit = async (e) => { @@ -457,7 +476,7 @@ export function RegisterPage() { if (phase === 'done') { return html` - <div class="page-center"> + <${AuthShell}> <div class="card login-card"> <h2>${t('register.verified_title')}</h2> <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)"> @@ -467,7 +486,7 @@ export function RegisterPage() { <p style="text-align:center; margin-bottom:16px">${t('invite.after_register')}</p>`} <a href="#/login" style="display:block; text-align:center">${t('register.go_login')}</a> </div> - </div> + </${AuthShell}> `; } @@ -480,7 +499,7 @@ export function RegisterPage() { } catch { /* clipboard blocked — the text is on screen to copy by hand */ } }; return html` - <div class="page-center"> + <${AuthShell}> <div class="card login-card"> <h2>${t('register.recovery_title')}</h2> <p style="margin-bottom:12px; color:var(--text-secondary)"> @@ -509,13 +528,13 @@ export function RegisterPage() { ${t('register.recovery_continue')} </button> </div> - </div> + </${AuthShell}> `; } if (phase === 'verify') { return html` - <div class="page-center"> + <${AuthShell}> <div class="card login-card"> <h2>${t('register.success_title')}</h2> <p style="text-align:center; margin-bottom:16px; color:var(--text-secondary)"> @@ -538,12 +557,12 @@ export function RegisterPage() { ${t('register.resend_sent')}</span>`} </div> </div> - </div> + </${AuthShell}> `; } return html` - <div class="page-center"> + <${AuthShell}> <div class="card login-card"> <h2>${t('register.title')}</h2> <form onSubmit=${onSubmit}> @@ -585,7 +604,7 @@ export function RegisterPage() { ${t('register.has_account')} <a href="#/login">${t('register.login_link')}</a> </div> </div> - </div> + </${AuthShell}> `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js index aac8225..15d352a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js @@ -78,7 +78,7 @@ async function connectToGroup(hubBase, groupId, token, bundleKey, username, user const ack = await Promise.race([ transport.connect( n.node_id, live, groupId, null, null, bundleKey, - username, userId, null), + username, userId, null, undefined, undefined, n.pk_node), new Promise((_, reject) => { const giveUp = () => reject(new Error('Connection timeout')); stallTimer = setTimeout(giveUp, SEARCH_STALL_MS); diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js index 2ee6e05..eb012c3 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-page.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-page.js @@ -433,7 +433,7 @@ function GroupPage({ groupId, group, token, username, userId, userPrefs, try { ack = await transport.connect( n.node_id, live, groupId, null, sessionKeys, session.bundleKey, - username, userId, joinCode, session.recoveryKey, joinNodePk); + username, userId, joinCode, session.recoveryKey, joinNodePk, n.pk_node); break; } catch (e) { lastErr = e; diff --git a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js index a510e63..fba8a0e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/group-settings.js @@ -417,7 +417,8 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, // Whether the hub mails the invitation. Checked, the hub is handed the code // to write it into the mail — so it is the inviter's choice, remembered per // account (docs/MESHBAY_DESIGN.md §3.4). Unchecked, the hub never sees it. - const [inviteByEmail, setInviteByEmail] = useState(true); + // Off by default: mailing the code is opt-in, not something to do unasked. + const [inviteByEmail, setInviteByEmail] = useState(false); const [error, setError] = useState(''); // Node loopback state (Electron-only) @@ -811,7 +812,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, useEffect(() => { hubFetch('/v1/users/me/preferences', { token }) - .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] !== 'false')) + .then(prefs => setInviteByEmail(prefs[INVITE_EMAIL_PREF] === 'true')) .catch(() => {}); }, [token]); @@ -1059,7 +1060,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${inviting ? '...' : t('members.invite_btn')} </button> </div> - <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0; + <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0; font-size:0.88em; color:var(--text-secondary)"> <input type="checkbox" checked=${inviteByEmail} onChange=${e => toggleInviteByEmail(e.target.checked)} /> @@ -1105,7 +1106,7 @@ function GroupSettingsPanel({ groupId, group, token, transportRef, gekRef, ${linking ? '...' : t('members.link_btn')} </button> </div> - <label style="display:flex; gap:8px; align-items:flex-start; margin:6px 0 0; + <label style="display:flex; gap:8px; align-items:center; margin:6px 0 0; font-size:0.88em; color:var(--text-secondary)"> <input type="checkbox" checked=${inviteByEmail} onChange=${e => toggleInviteByEmail(e.target.checked)} /> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 37775d8..8433b1a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -682,7 +682,7 @@ a:hover { text-decoration: underline; } overflow: hidden; pointer-events: none; background: linear-gradient(155deg, - #86a3c4 0%, #6a819b 18%, #3d4d61 42%, #232b36 66%, #0f1113 100%); + #809cbc 0%, #6a819b 18%, #3d4d61 42%, #232b36 66%, #0f1113 100%); } /* The night-blue pool. */ .welcome-backdrop::before { 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 6ae51d1..18ad9d4 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js @@ -110,7 +110,7 @@ async function rewrapAllNodes(o) { try { await _acWithTimeout( tp.connect(n.node_id, o.token, g.id, null, null, oldKey, - o.username, o.userId, null, recoveryKey), + o.username, o.userId, null, recoveryKey, undefined, n.pk_node), 30000, 'connect'); if (tp.newNodeBundle) { // No identity existed on this node — connect just minted one under diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index f3683eb..546d01b 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -663,21 +663,21 @@ class MeshBayTransport { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this._accessToken}`, }, - body: JSON.stringify({}), + body: JSON.stringify({ node_pk: this._nodePkTarget || '' }), }); if (!r.ok) throw new Error(`Could not obtain a node token: ${r.status}`); return (await r.json()).mnp_token; } async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username, - userId, joinCode, recoveryKey, joinNodePk) { + userId, joinCode, recoveryKey, joinNodePk, nodePk) { // Remembered for _reconnectLoop, which calls connect() again with these // same values (plus a freshly-fetched token and the identity connect() // itself settles on below) after the WebRTC connection is declared // "failed" — see the pc.onconnectionstatechange handler further down. this._connectArgs = { nodeId, groupId, gekRaw, bundleKey, username, userId, joinCode, recoveryKey, - joinNodePk, + joinNodePk, nodePk, }; this._lastToken = jwtToken; // The constructor sets this once from whatever token the caller had at @@ -695,6 +695,10 @@ class MeshBayTransport { this._recoveryKey = recoveryKey || null; this._username = username || null; this._userId = userId || null; + // The key of the node we mean to reach, from the hub's node list. The MNP + // token is bound to it (E10) so it cannot be replayed to another node. It is + // the *expected* key; `this.nodePk` below is the one the node then proves. + this._nodePkTarget = nodePk || ''; // The group this connection is for. Kept on the instance because the // handshake is not the only thing that needs it any more: device_hello and // the chat envelope both bind to it, and both run outside connect()'s scope. @@ -1321,7 +1325,7 @@ class MeshBayTransport { ack = await this.connect(args.nodeId, token, args.groupId, args.gekRaw, this._sessionKeys, args.bundleKey, args.username, args.userId, args.joinCode, undefined, - args.joinNodePk); + args.joinNodePk, args.nodePk); } finally { this._inReconnectAttempt = false; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js index 124033e..4c9f90c 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/video-app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app.js @@ -976,6 +976,22 @@ function VideoApp({ useEffect(() => { setMode(loadViewMode()); }, [groupId]); useEffect(() => { setFilter(''); setTypeFilter('all'); }, [groupId]); + // When the operator changes the node's TMDB language, the node drops its + // metadata cache and refetches in the new language, and — until a language + // is set — answers nothing at all rather than querying in English (§9.7). + // Tell every mounted tile to redo its media_meta_req and drop the + // show-level meta already merged here, so the grid switches language (or + // fills in for the first time, right after the operator picks one) without + // a page reload. Skip the initial mount: the language is already right then, + // and bumping would restorm TMDB on every open of the Videos tab. + const tmdbLanguage = tmdbConfig ? (tmdbConfig.language || '') : ''; + const firstLangRef = useRef(true); + useEffect(() => { + if (firstLangRef.current) { firstLangRef.current = false; return; } + setMetaByGroup({}); + bumpMediaMetaGeneration(); + }, [tmdbLanguage]); + const setModeAndSave = (m) => { setMode(m); saveViewMode(m); }; const videoEntries = availableEntries || entries; |