aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/MESHBAY_DESIGN.md20
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md1
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py19
-rw-r--r--packages/meshbay-common/tests/test_handshake.py21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py24
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py11
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py101
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/auth-page.js37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/connection-pool.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js14
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-page.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/group-settings.js9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport-rewrap.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js12
-rw-r--r--packages/meshbay-hub/tests/test_incoming_membership.py76
-rw-r--r--packages/meshbay-hub/tests/test_invite_email_choice.py5
-rw-r--r--packages/meshbay-hub/tests/test_mnp_token.py21
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py17
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py1
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py1
-rw-r--r--packages/meshbay-node/tests/test_login_retry_is_resilient.py109
23 files changed, 426 insertions, 89 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index 394dfb5..4daa094 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -1006,6 +1006,17 @@ a film already playing — a reconnect fetches a fresh one. `meshbay_common/toke
holds the two audience strings, shared by the hub that issues and the node that
verifies.
+**The MNP token names the node it is for** (`node` claim = that node's Ed25519
+key), and a node refuses a token that names a different key. The client knows
+the target node's key before it connects (from `/v1/groups/{id}/nodes`) and asks
+for a token bound to it. So the credential a member hands one operator is of no
+use at any other node the member belongs to: not for a session there — the
+group-key proof already refuses one to whoever does not hold the key — nor for
+the **pre-proof window**, where a token accepted at face value would otherwise
+fetch the member's *encrypted* keypair bundle for that node. A token that names
+no node is accepted, since the hub mints one only for the authenticated
+requester and it therefore grants nothing across accounts.
+
**Authorization rules:**
- `group_id` is **mandatory**. Omitting it once skipped the membership check and
@@ -1808,7 +1819,10 @@ groups from a client (**NS7**).
Signaling is rate-limited, SDP-size bounded, capped per user, and **the caller must
share an active group with the target node**. Otherwise any authenticated user
could make a third party's machine allocate peer connections on demand (**H6**).
-The address in a NAT-punch request must match the caller's source address.
+The NAT-punch signal shares the offer relay's gate — the caller must share an
+active group with the node, checked before anything reveals whether the node is
+connected, so it is no liveness oracle — and the address it names must match the
+caller's own source address.
**The offer ceilings bound a member and never an ordinary one** (**AV30**). They
are sized against an account with twenty groups on three devices, because a
@@ -3237,7 +3251,7 @@ had already been asked.
| **E7** | **Several named roots** per group, forming one virtual root (§6.2) |
| **E8** | **exFAT/NTFS and Windows are the common case.** Linux ships first; that is build order, not population (§10) |
| **E9** | **Group-related server state lives on the node. Always** (§1.3) |
-| **E10** | **The credential a member presents to a node is not the one the hub API accepts.** A member hands its handshake token to the node operator, who is in the threat model, so it is a short-lived node-audience token (`aud = MNP_AUD`, §5.2), useless at the hub API; the session token (`aud` = hub API) is never disclosed to a node. Before this the member presented its hub session token, so an operator held a live hub credential for the member — enough to enumerate their other groups and act as them at the hub. Closed by the audience split and the MNP 4.0 flag day (§5.6) |
+| **E10** | **The credential a member presents to a node opens nothing else.** A member hands its handshake token to the node operator (in the threat model), so it is a short-lived node-audience token (`aud = MNP_AUD`, §5.2): the session token that opens the hub API is never disclosed to a node, and the MNP token names the one node it is for (`node` claim) so it cannot be presented to another node the member uses. Established by the MNP 4.0 flag day (§5.6) |
| **O1** | Initial key setup in the pre-proof window — deferred; that window is where C4 and C5b came from |
| **O2** | A LAN enrolment door — one endpoint, bounded window, one-time code, closing permanently on success |
| **O3** | `device_policy {allow_bundle: false}`, signed by a pinned key — **the mechanism that actually closes C4** (§3.7) |
@@ -3279,7 +3293,7 @@ had already been asked.
20. **Private keys never leave the device on native clients.** Qualified: a browser has no durable storage of its own and still needs a bundle on each node, so C4 closes for an *account* only when it opts out of browser use.
21. **Hub minimisation is enforced by an acceptance test, not by policy.** The hub must be *unable* to see keys, content or file listings.
22. **No new code exchanges between people.** Safety numbers are refused for identity verification, permanently. The device-linking code is between a person's own devices and is unaffected. The total user-visible cost of the whole authorship story is **one notice**: *"this account's key changed"*.
-23. **A member's credential to a node is audience-separated from its hub credential** (**E10**, §5.2). The handshake carries a short-lived `aud = MNP_AUD` token, never the hub session token; the hub API accepts only its own audience. A node operator therefore holds nothing that acts at the hub.
+23. **A member's credential to a node is separate from its hub credential and bound to that node** (**E10**, §5.2). The handshake carries a short-lived `aud = MNP_AUD` token that names the node it is for, never the hub session token; the hub API accepts only its own audience, and each node accepts only a token that names it. A node operator therefore holds nothing that acts at the hub or at another node.
### 14.2 Client architecture
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index b3cd6eb..8de731e 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -463,6 +463,7 @@ absent. `verify_proof` compares with `hmac.compare_digest`.
|---|---|---|
| JWT verifies under the hub's Ed25519 public key (`EdDSA`) | `Invalid JWT: ...` | |
| `aud == MNP_AUD`, and `exp`/`sub`/`scope` present (MNP 4.0) | `Invalid JWT: ...` | the member presents a short-lived **node-audience** token (`POST /v1/nodes/mnp-token`), not its hub session token — the operator holds whatever is presented, and the session token opens the hub API. The two audience strings are in `meshbay_common/tokens.py` |
+| `node` claim, when set, equals this node's key (MNP 4.0) | `Token is not for this node`, code `wrong_node` | the token names the node it was minted for, so one captured by node A's operator cannot be replayed to node B (E10). A token naming no node is accepted — the hub mints an unbound one only for the requester |
| `scope == "user"` | `Wrong token scope` | a node-scoped daemon token must not be usable as a client token |
| `sub` non-empty | `Token has no subject` | |
| `group_id` non-empty | `group_id is required` | an absent group means no membership check to make; there is no default group, and a node's first group is not one |
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
index dedfeb1..992fe8a 100644
--- a/packages/meshbay-common/src/meshbay_common/handshake.py
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -269,6 +269,7 @@ def authorize_token(
hosted_groups: Any | None = None,
denylist: DenylistLike | None = None,
require_scope: str | None = "user",
+ node_pk_b64: str | None = None,
) -> AuthorizedPeer:
"""
Everything decided from the JWT, before any proof is exchanged.
@@ -276,6 +277,13 @@ def authorize_token(
Raises HandshakeError with a peer-safe message. Deliberately strict about
`group_id`: it used to be optional, and omitting it skipped the membership
check entirely and fell back to the node's first group (M1).
+
+ `node_pk_b64` binds the token to **this** node (E10). The MNP token names
+ the node it was minted for (`node` claim), so a member's token captured by
+ the operator of one node cannot be replayed to another node the member also
+ belongs to — not even to reach the pre-proof window there. A node always
+ passes its own key; a caller that passes `None` (a unit test not exercising
+ this) skips the check.
"""
try:
decoded = jwt.decode(token, hub_pk_pem, algorithms=["EdDSA"],
@@ -294,6 +302,17 @@ def authorize_token(
if require_scope is not None and decoded.get("scope", "user") != require_scope:
raise HandshakeError("Wrong token scope")
+ # The token names the node it was minted for (`node` claim). A member's real
+ # client always binds it to the node it is reaching, so a token captured by
+ # that node's operator and replayed to a *second* node the member also uses
+ # is refused here — before the pre-proof window can serve anything (E10).
+ # A token that names no node is not refused: the hub only ever mints one for
+ # the authenticated requester, so an unbound token grants nothing across
+ # accounts, and this keeps older or non-binding callers working.
+ node_claim = decoded.get("node", "")
+ if node_pk_b64 is not None and node_claim and node_claim != node_pk_b64:
+ raise HandshakeError("Token is not for this node", code="wrong_node")
+
user_id = decoded.get("sub", "")
jti = decoded.get("jti", "")
if not user_id:
diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py
index 8f385bb..f42d597 100644
--- a/packages/meshbay-common/tests/test_handshake.py
+++ b/packages/meshbay-common/tests/test_handshake.py
@@ -120,6 +120,27 @@ def test_a_token_with_no_audience_is_refused(hub_key):
authorize_token(no_aud, pk_pem, group_id=GROUP)
+def test_a_token_bound_to_another_node_is_refused(hub_key):
+ """E10: a token names the node it is for. A member's token captured by one
+ node's operator cannot be replayed to a second node the member also uses."""
+ sk_pem, pk_pem = hub_key
+ token_for_A = _token(sk_pem, node="node-A-pk")
+ # Node B (its own key is 'node-B-pk') refuses it.
+ with pytest.raises(HandshakeError, match="this node"):
+ authorize_token(token_for_A, pk_pem, group_id=GROUP, node_pk_b64="node-B-pk")
+ # Node A accepts it.
+ peer = authorize_token(token_for_A, pk_pem, group_id=GROUP, node_pk_b64="node-A-pk")
+ assert peer.user_id == "user-1"
+
+
+def test_a_token_naming_no_node_is_still_accepted(hub_key):
+ """Lenient by design: the hub mints an unbound token only for the requester,
+ so it grants nothing across accounts, and older callers keep working."""
+ sk_pem, pk_pem = hub_key
+ peer = authorize_token(_token(sk_pem), pk_pem, group_id=GROUP, node_pk_b64="node-B-pk")
+ assert peer.group_id == GROUP
+
+
def test_unhosted_group_refused(hub_key):
sk_pem, pk_pem = hub_key
with pytest.raises(HandshakeError, match="not hosted"):
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/create-group-page.js b/packages/meshbay-hub/src/meshbay_hub/static/create-group-page.js
index f81247d..85fe53c 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
@@ -142,11 +142,15 @@ function CreateGroupWizard({ token, username, onCreated, allowPublicGroups = tru
const linkNodeKey = useCallback(async (pk) => {
if (!pk) return;
- try {
- await hubFetch('/v1/users/me/node_key', {
- method: 'PUT', token, body: { pk_node_ed25519: pk },
- });
- } catch { /* already linked or same key */ }
+ // `PUT /me/node_key` is idempotent — linking the same key again returns 200,
+ // so there is no "already linked" case to swallow here. A failure means the
+ // hub did not record this node's key (a rejected session, a malformed key),
+ // and the node then fails to authenticate and never comes up. It must
+ // surface — `detectNode`'s catch shows it — rather than let the wizard
+ // proceed against a node that looks linked but is not.
+ await hubFetch('/v1/users/me/node_key', {
+ method: 'PUT', token, body: { pk_node_ed25519: pk },
+ });
}, [token]);
const detectNode = useCallback(async () => {
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/tests/test_incoming_membership.py b/packages/meshbay-hub/tests/test_incoming_membership.py
new file mode 100644
index 0000000..708e327
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_incoming_membership.py
@@ -0,0 +1,76 @@
+"""The NAT-punch signal is not a liveness oracle, and only a member reaches it.
+
+`POST /v1/nodes/{id}/incoming` used to check nothing but the caller's own
+address, then reveal whether the node was connected (404 vs 504) and, with QUIC
+on, make it punch. Any authenticated account could poll it for a node's liveness
+and make a stranger's node emit a UDP probe. It now requires a shared active
+group with the node first — the same gate the offer relay uses — checked before
+anything depends on the node's connection state, so a non-member gets one uniform
+403 whether the node is connected or not.
+"""
+
+import pytest
+from test_availability_between_members import (
+ _add_member,
+ _announce_node,
+ _make_group,
+ _make_user,
+)
+
+
+def _incoming(client, node_id, user, peer_ip="1.2.3.4", peer_port=5000):
+ return client.post(f"/v1/nodes/{node_id}/incoming",
+ json={"peer_ip": peer_ip, "peer_port": peer_port},
+ headers={"Authorization": f"Bearer {user['token']}"})
+
+
+@pytest.mark.asyncio
+async def test_a_non_member_is_refused_whether_the_node_is_connected_or_not(client):
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "inc_owner")
+ stranger = await _make_user(client, "inc_stranger")
+ group_id = await _make_group(client, owner, "inc-group")
+ node_id = await _announce_node(client, owner)
+
+ # Node NOT in the connected registry — the stranger gets the membership 403
+ # (not the connection 404), so the answer says nothing about whether the node
+ # is up. The detail is what distinguishes it from the peer_ip refusal that a
+ # request without the gate would give.
+ r_off = await _incoming(client, node_id, stranger)
+ assert r_off.status_code == 403
+ assert "member" in r_off.json()["detail"]
+
+ # Node connected and serving the group — the stranger, not a member, still gets
+ # the membership 403, and never reaches the punch or the connection-state answer.
+ rev._connected_nodes[node_id] = object()
+ rev._node_groups[node_id] = [group_id]
+ try:
+ r_on = await _incoming(client, node_id, stranger)
+ assert r_on.status_code == 403
+ assert "member" in r_on.json()["detail"]
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+ rev._node_groups.pop(node_id, None)
+
+
+@pytest.mark.asyncio
+async def test_a_member_passes_the_membership_gate(client):
+ """A member is not turned away by the gate. (It then reaches the connection
+ check — 404 here, since no real node socket is registered — never 403.)"""
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "inc2_owner")
+ member = await _make_user(client, "inc2_member")
+ group_id = await _make_group(client, owner, "inc2-group")
+ await _add_member(client, owner, group_id, member)
+ node_id = await _announce_node(client, owner)
+
+ rev._node_groups[node_id] = [group_id] # registered/hosted, but no live socket
+ try:
+ r = await _incoming(client, node_id, member)
+ # Past the membership gate: the refusal, if any, is about the connection
+ # or the peer address, never "not a member of any group on this node".
+ assert r.status_code != 403 or "member" not in r.json().get("detail", "")
+ finally:
+ rev._node_groups.pop(node_id, None)
diff --git a/packages/meshbay-hub/tests/test_invite_email_choice.py b/packages/meshbay-hub/tests/test_invite_email_choice.py
index e04c31f..de9410d 100644
--- a/packages/meshbay-hub/tests/test_invite_email_choice.py
+++ b/packages/meshbay-hub/tests/test_invite_email_choice.py
@@ -3,8 +3,9 @@ Whether the hub mails an invitation is the inviter's choice, and it is remembere
Mailing it hands the hub the code — `invite-notify` writes it into the message —
which is exactly what §3.4 says the code is for not doing. So the Members tab
-offers it as a box, checked by default, and an unchecked box must mean the hub
-is never asked. The choice lives in an account preference; a key the hub does
+offers it as a box, unchecked by default (mailing the code is opt-in), and an
+unchecked box must mean the hub is never asked. The choice lives in an account
+preference, remembered once set; a key the hub does
not list is refused, and the box would snap back on every click with nothing on
screen to say why.
"""
diff --git a/packages/meshbay-hub/tests/test_mnp_token.py b/packages/meshbay-hub/tests/test_mnp_token.py
index 7b483ff..3aa3115 100644
--- a/packages/meshbay-hub/tests/test_mnp_token.py
+++ b/packages/meshbay-hub/tests/test_mnp_token.py
@@ -69,3 +69,24 @@ async def test_a_session_token_is_refused_by_a_node_but_the_mnp_token_is_not(cli
# The MNP token authorises the member to the node.
peer = authorize_token(mnp, pk, group_id=gid)
assert peer.group_id == gid
+
+
+@pytest.mark.asyncio
+async def test_the_mnp_token_is_bound_to_the_node_it_names(client):
+ """E10: a token minted for node A is refused by node B, so an operator who
+ captures a member's token cannot replay it to another of the member's nodes."""
+ from meshbay_hub.auth import hub_public_key_pem
+
+ tok = await _session_token(client, "mnp_bind_test")
+ H = {"Authorization": f"Bearer {tok}"}
+ gid = (await client.post("/v1/groups", headers=H, json={
+ "name": "g", "visibility": "private", "join_policy": "invite"})).json()["group_id"]
+ # A token bound to node A's key.
+ mnp = (await client.post("/v1/nodes/mnp-token", headers=H,
+ json={"node_pk": "node-A-pk"})).json()["mnp_token"]
+ pk = hub_public_key_pem()
+ # Node B refuses it; node A accepts it.
+ with pytest.raises(HandshakeError, match="this node"):
+ authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-B-pk")
+ peer = authorize_token(mnp, pk, group_id=gid, node_pk_b64="node-A-pk")
+ assert peer.group_id == gid
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index 4800dac..dc5df81 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -983,6 +983,23 @@ class NodeDaemon(EnrichmentMixin):
self._config.hub.username, self._config.hub.url,
)
await asyncio.sleep(5)
+ elif e.response.status_code in (429, 500, 502, 503, 504):
+ # Transient: the hub is busy (429 — often this daemon's own
+ # retry storm against the sign-in rate limit), restarting
+ # (502/503) or erroring (500/504). None of these is a reason
+ # to exit: the daemon exiting here crash-loops under systemd
+ # and strands the operator, who needs it alive to read the
+ # node key (`meshbay-node status`, the desktop client) so
+ # they can link it. Back off — respecting Retry-After when
+ # the hub sends one — and try again, rather than dying.
+ self._state["status"] = "waiting_for_hub"
+ delay = 10
+ ra = (e.response.headers or {}).get("Retry-After")
+ if ra and str(ra).isdigit():
+ delay = min(max(delay, int(ra)), 300)
+ log.warning("Hub returned %s on login — retrying in %ds",
+ e.response.status_code, delay)
+ await asyncio.sleep(delay)
else:
raise
except Exception as e:
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
index 96cd752..715448f 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -286,6 +286,7 @@ class _MNPServerProtocol(QuicConnectionProtocol):
group_id=msg.get("group_id", ""),
hosted_groups=self._ctx.get("groups"),
denylist=self._ctx.get("denylist"),
+ node_pk_b64=pk_to_b64(self._ctx["sk_node"].public_key()),
)
except HandshakeError as refusal:
self._send(stream_id, {"type": "error", "detail": str(refusal),
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py
index 87394d1..eceb2b4 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc/handshake.py
@@ -65,6 +65,7 @@ class HandshakeMixin:
group_id=group_id,
hosted_groups=self._ctx.get("groups"),
denylist=self._ctx.get("denylist"),
+ node_pk_b64=self._node_pk_b64(),
)
except HandshakeError as refusal:
# HandshakeError messages are authored to be peer-safe, unlike arbitrary
diff --git a/packages/meshbay-node/tests/test_login_retry_is_resilient.py b/packages/meshbay-node/tests/test_login_retry_is_resilient.py
new file mode 100644
index 0000000..e37a413
--- /dev/null
+++ b/packages/meshbay-node/tests/test_login_retry_is_resilient.py
@@ -0,0 +1,109 @@
+"""A transient hub state on node sign-in must not crash the daemon.
+
+`_login_with_retry` retries a 401 (the node key is not linked yet — a human has
+to link it, and the daemon must stay alive so its key can be read). It used to
+`raise` on every other status, so a **429** (the daemon's own retries hitting
+the sign-in rate limit) or a **502/503** (the hub restarting during a deploy)
+killed the process — systemd then crash-looped it, which is what "impossible de
+démarrer le node" looked like after a reset left the node with a fresh, unlinked
+key. Those transient statuses are now retried with a back-off that respects
+`Retry-After`.
+"""
+
+import asyncio
+
+import httpx
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from meshbay_node.config import Config, GroupConfig, HubConfig, KeystoreConfig, NodeConfig
+from meshbay_node.daemon import NodeDaemon
+
+
+def _daemon(tmp_path):
+ cfg = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=29011, ui_port=29012),
+ groups=[],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ return NodeDaemon(cfg)
+
+
+def _http_error(status: int, headers: dict | None = None) -> httpx.HTTPStatusError:
+ req = httpx.Request("POST", "http://localhost:9999/v1/nodes/auth")
+ resp = httpx.Response(status, headers=headers or {}, request=req)
+ return httpx.HTTPStatusError(f"{status}", request=req, response=resp)
+
+
+class _Hub:
+ """A hub whose `startup` raises the given sequence, then returns a session."""
+ def __init__(self, seq):
+ self._seq = list(seq)
+ self.calls = 0
+
+ async def startup(self, endpoint_hint=None):
+ self.calls += 1
+ item = self._seq.pop(0)
+ if isinstance(item, Exception):
+ raise item
+ return item
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("status", [429, 500, 502, 503, 504])
+async def test_a_transient_status_is_retried_not_fatal(tmp_path, status, monkeypatch):
+ slept = []
+
+ async def _sleep(d):
+ slept.append(d)
+ monkeypatch.setattr(asyncio, "sleep", _sleep)
+
+ daemon = _daemon(tmp_path)
+ session = object()
+ hub = _Hub([_http_error(status), session]) # transient, then success
+ got = await daemon._login_with_retry(hub)
+ assert got is session # it recovered instead of crashing
+ assert hub.calls == 2 # retried once
+ assert slept # it backed off
+
+
+@pytest.mark.asyncio
+async def test_retry_after_is_respected(tmp_path, monkeypatch):
+ slept = []
+
+ async def _sleep(d):
+ slept.append(d)
+ monkeypatch.setattr(asyncio, "sleep", _sleep)
+
+ daemon = _daemon(tmp_path)
+ hub = _Hub([_http_error(429, {"Retry-After": "42"}), object()])
+ await daemon._login_with_retry(hub)
+ assert 42 in slept
+
+
+@pytest.mark.asyncio
+async def test_a_401_still_retries_and_stays_alive(tmp_path, monkeypatch):
+ async def _sleep(d):
+ pass
+ monkeypatch.setattr(asyncio, "sleep", _sleep)
+
+ daemon = _daemon(tmp_path)
+ session = object()
+ hub = _Hub([_http_error(401), session])
+ got = await daemon._login_with_retry(hub)
+ assert got is session
+ assert daemon._state.get("status") in ("waiting_for_node_key", "waiting_for_account")
+
+
+@pytest.mark.asyncio
+async def test_a_genuine_client_error_still_raises(tmp_path, monkeypatch):
+ """A 400/422 is a bug, not a transient state — it must not be swallowed."""
+ async def _sleep(d):
+ pass
+ monkeypatch.setattr(asyncio, "sleep", _sleep)
+
+ daemon = _daemon(tmp_path)
+ hub = _Hub([_http_error(400), object()])
+ with pytest.raises(httpx.HTTPStatusError):
+ await daemon._login_with_retry(hub)