summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py75
-rw-r--r--packages/meshbay-common/src/meshbay_common/crypto.py40
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py220
-rw-r--r--packages/meshbay-common/src/meshbay_common/join.py63
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py15
-rw-r--r--packages/meshbay-common/tests/test_handshake.py225
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py236
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/moderation.py8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/netutil.py34
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py58
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py115
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/signaling.py56
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py104
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py4
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/auth.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py37
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/db/models.py6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js328
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js127
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js28
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/keyderive.js218
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css15
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js411
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md36
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.min.js1
-rwxr-xr-xpackages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.wasmbin0 -> 25725 bytes
-rw-r--r--packages/meshbay-hub/tests/test_bundle_kdf_parity.py131
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py66
-rw-r--r--packages/meshbay-hub/tests/test_node_auth.py3
-rw-r--r--packages/meshbay-hub/tests/test_node_ws_auth.py346
-rw-r--r--packages/meshbay-hub/tests/test_spa_ordering.py103
-rw-r--r--packages/meshbay-node/src/meshbay_node/bundle_store.py15
-rw-r--r--packages/meshbay-node/src/meshbay_node/config.py59
-rw-r--r--packages/meshbay-node/src/meshbay_node/daemon.py502
-rw-r--r--packages/meshbay-node/src/meshbay_node/hub_client.py95
-rw-r--r--packages/meshbay-node/src/meshbay_node/keystore.py25
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py400
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/__init__.py21
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/client.py148
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/http_server.py336
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_client.py96
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/quic_server.py183
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/server.py286
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/tls_cert.py36
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py994
-rw-r--r--packages/meshbay-node/src/meshbay_node/ui/app.py419
-rw-r--r--packages/meshbay-node/tests/test_daemon.py77
-rw-r--r--packages/meshbay-node/tests/test_http_server.py227
-rw-r--r--packages/meshbay-node/tests/test_quic_transport.py13
-rw-r--r--packages/meshbay-node/tests/test_roster_pairing.py744
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py604
-rw-r--r--packages/meshbay-node/tests/test_transport.py222
-rw-r--r--packages/meshbay-node/tests/test_webrtc_transport.py368
55 files changed, 6872 insertions, 2154 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
new file mode 100644
index 0000000..2d90102
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -0,0 +1,75 @@
+"""
+Admin operation challenge transcripts (MNP).
+
+Destructive and privileged node operations are authorized by an Ed25519 signature
+from the node operator, not by a JWT — the hub controls JWT issuance, so a JWT can
+never establish node-level authority (see draft-v4 §4.2.x).
+
+Finding H5: the node used to challenge the client with 32 raw random bytes and the
+client signed them blind. That is an unbound signing oracle — the signed message
+named no operation, no subject, no node and no time, so a signature obtained for one
+purpose was structurally valid for any other, and a malicious node could ask a user
+to sign bytes meaningful in a different protocol.
+
+The transcript below fixes that:
+
+ - a fixed domain-separation prefix, so these signatures can never collide with
+ node_auth, revocation tokens, chunk signatures or anything added later;
+ - the operation and its subject, so the client can display and verify what it is
+ authorizing before signing;
+ - the node's public key, so a signature for node A is not valid on node B;
+ - the group, so authority does not leak across groups on a multi-group node;
+ - a node-chosen nonce, so signatures cannot be replayed;
+ - a timestamp, so stale challenges can be rejected.
+
+Every field is length-prefixed. Plain concatenation would let a crafted subject
+impersonate a following field (finding L4 applies the same rule to the GEK proof).
+
+Both sides MUST build the transcript with this function — the client from the
+fields it received, the node from the state it stored. They are compared by
+producing the same bytes, never by trusting a value off the wire.
+"""
+
+ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1"
+
+# Operations that require node-operator authority.
+OP_FILE_DELETE = "file_delete"
+OP_INVITE_CREATE = "invite_create"
+# OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at
+# all: the node holds the GEK and wraps it itself, for a key the recipient proved
+# they hold (see `join.py` and docs/invite-pairing-v1.md). The operation existed
+# only to make member-supplied bundles safe, and deleting the message is a
+# stronger guarantee than authorizing it.
+
+# A challenge older than this is refused, so a signature captured from a stale
+# exchange cannot be replayed later.
+ADMIN_CHALLENGE_TTL = 120 # seconds
+
+
+def admin_transcript(
+ op: str,
+ node_pk_b64: str,
+ group_id: str,
+ subject: str,
+ nonce: bytes,
+ ts: int,
+) -> bytes:
+ """
+ Build the exact byte string signed for an admin operation.
+
+ `subject` identifies what is being acted on: a file_id for OP_FILE_DELETE, the
+ invitee's user_id for OP_INVITE_CREATE.
+ """
+ fields = [
+ op.encode(),
+ node_pk_b64.encode(),
+ group_id.encode(),
+ subject.encode(),
+ nonce,
+ str(ts).encode(),
+ ]
+ out = bytearray(ADMIN_TRANSCRIPT_PREFIX)
+ for field in fields:
+ out += len(field).to_bytes(4, "big")
+ out += field
+ return bytes(out)
diff --git a/packages/meshbay-common/src/meshbay_common/crypto.py b/packages/meshbay-common/src/meshbay_common/crypto.py
index 682e1c0..b2ff3c0 100644
--- a/packages/meshbay-common/src/meshbay_common/crypto.py
+++ b/packages/meshbay-common/src/meshbay_common/crypto.py
@@ -168,21 +168,45 @@ def unwrap_gek_aes(bundle: dict, sk_recipient: bytes, pk_recipient: bytes) -> by
# ── Keystore (local key storage) ──────────────────────────────────────────────
-# Argon2id parameters — calibrate to ~500ms on target hardware before production.
-# POC measured 78ms with these; increase memory_cost to 262144 (256MB) for prod.
+# Argon2id parameters for the node keystore.
+#
+# Finding M2: these sat at 64 MB long after the hub's password verifier was raised
+# to 256 MB, and the docs recorded the bump as done — true for the hub, false here.
+# The keystore protects the node's Ed25519 and X25519 private keys, so it is the
+# more valuable target of the two.
+#
+# Parameters are recorded in each keystore envelope, so raising them does not
+# invalidate existing files: LEGACY_* is used when an envelope predates the field.
ARGON2_ITERATIONS = 3
-ARGON2_MEMORY_COST = 65536 # 64 MB — increase to 262144 for production
+ARGON2_MEMORY_COST = 262144 # 256 MB
ARGON2_LANES = 4
ARGON2_KEY_LENGTH = 32
-def derive_keystore_key(password: str, salt: bytes) -> bytes:
- """Derive AES-256 key from password using Argon2id."""
+LEGACY_ARGON2_ITERATIONS = 3
+LEGACY_ARGON2_MEMORY_COST = 65536 # 64 MB — keystores written before M2
+LEGACY_ARGON2_LANES = 4
+
+
+def derive_keystore_key(
+ password: str,
+ salt: bytes,
+ *,
+ iterations: int | None = None,
+ memory_cost: int | None = None,
+ lanes: int | None = None,
+) -> bytes:
+ """
+ Derive an AES-256 key from a password using Argon2id.
+
+ Parameters default to the current production values; callers pass the values
+ recorded in an existing envelope when opening an older keystore.
+ """
return Argon2id(
salt=salt,
length=ARGON2_KEY_LENGTH,
- iterations=ARGON2_ITERATIONS,
- lanes=ARGON2_LANES,
- memory_cost=ARGON2_MEMORY_COST,
+ iterations=ARGON2_ITERATIONS if iterations is None else iterations,
+ lanes=ARGON2_LANES if lanes is None else lanes,
+ memory_cost=ARGON2_MEMORY_COST if memory_cost is None else memory_cost,
).derive(password.encode())
def encrypt_keystore(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]:
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
new file mode 100644
index 0000000..fca218f
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -0,0 +1,220 @@
+"""
+Unified MNP handshake — one implementation, every transport.
+
+Finding C6: the handshake existed three times over (WebRTC, QUIC, TCP), and only
+the newest copy enforced the GEK proof. QUIC and TCP accepted a bare JWT, so a
+forged or stolen token reached the node and could inject chat messages without
+ever holding the group key. TCP is gone (11.5.2); QUIC and WebRTC now share this
+module, and a parity test fails if either skips a step.
+
+The sequence:
+
+ client → node handshake {token, group_id, nonce_c}
+ node authorize_token() JWT, scope, denylist, membership, hosting
+ node → client handshake_challenge {nonce_s}
+ client → node handshake_response {proof}
+ node verify client proof HMAC(GEK, client transcript)
+ node → client handshake_ack {proof, sig, node_pk, is_node_admin}
+ client verify node proof HMAC(GEK, node transcript) + Ed25519
+
+Two properties this adds over the previous design:
+
+**Mutual authentication (C3).** Authentication used to run one way: the client
+proved itself, the node proved nothing. `handshake_ack.node_pk` was never verified
+against anything, and per-chunk signatures had been dropped in Phase 9.15, so a
+peer that had hijacked signaling (C2) or been substituted by the hub could accept
+the client's proof, ignore it, and serve a forged index, forged chat history and a
+forged `is_node_admin` flag. The node now proves GEK possession over a
+client-chosen nonce *and* signs the transcript with its long-term key, so the
+client can pin it.
+
+**Unambiguous transcripts (L4).** The old proof was `nonce ‖ offer_fp ‖ answer_fp`
+— bare concatenation, and a missing fingerprint silently degraded it to nonce-only.
+Every field is now length-prefixed and domain-separated, the role is bound so a
+client proof can never be replayed as a node proof, and an empty channel binding is
+refused rather than tolerated.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+from dataclasses import dataclass
+from typing import Any, Protocol
+
+import jwt
+
+HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1"
+
+ROLE_CLIENT = "client"
+ROLE_NODE = "node"
+
+NONCE_LEN = 32
+
+
+class HandshakeError(Exception):
+ """
+ Refusal, with a message safe to hand to the peer.
+
+ `code` is the same refusal in a form a client can act on. The text is for a
+ human and may be reworded; matching on it from the client would be a string
+ comparison that breaks silently the day someone improves the wording.
+ """
+
+ def __init__(self, message: str, code: str = ""):
+ super().__init__(message)
+ self.code = code
+
+
+class DenylistLike(Protocol):
+ def is_denied(self, user_id: str, jti: str, group_id: str = "") -> bool: ...
+
+
+@dataclass
+class AuthorizedPeer:
+ user_id: str
+ group_id: str
+ username: str
+ jti: str
+
+ # No `pk_user`. The hub used to put a user key in the token and the node
+ # recorded it as the uploader's identity, which let whoever issued tokens
+ # decide who could delete a file. Identity keys are pinned by the node
+ # (see roster.py); the hub certifies accounts, not keys.
+
+
+def handshake_transcript(
+ role: str,
+ group_id: str,
+ nonce_client: bytes,
+ nonce_node: bytes,
+ binding: bytes,
+) -> bytes:
+ """
+ Bytes covered by a handshake proof.
+
+ `binding` ties the proof to the concrete connection: the two DTLS fingerprints
+ for WebRTC, the TLS certificate hashes for QUIC. Without it a proof captured on
+ one connection is replayable on another (NS5).
+ """
+ fields = [
+ role.encode(),
+ group_id.encode(),
+ nonce_client,
+ nonce_node,
+ binding,
+ ]
+ out = bytearray(HANDSHAKE_PREFIX)
+ for field in fields:
+ out += len(field).to_bytes(4, "big")
+ out += field
+ return bytes(out)
+
+
+def make_proof(
+ gek: bytes,
+ role: str,
+ group_id: str,
+ nonce_client: bytes,
+ nonce_node: bytes,
+ binding: bytes,
+) -> bytes:
+ if not binding:
+ # An empty binding means the transport could not identify the channel.
+ # Proceeding would silently drop MitM detection (L4).
+ raise HandshakeError("Channel binding unavailable")
+ if not gek:
+ raise HandshakeError("Group encryption not initialized")
+ transcript = handshake_transcript(
+ role, group_id, nonce_client, nonce_node, binding)
+ return hmac.new(gek, transcript, hashlib.sha256).digest()
+
+
+def verify_proof(
+ gek: bytes,
+ proof: bytes,
+ role: str,
+ group_id: str,
+ nonce_client: bytes,
+ nonce_node: bytes,
+ binding: bytes,
+) -> bool:
+ try:
+ expected = make_proof(
+ gek, role, group_id, nonce_client, nonce_node, binding)
+ except HandshakeError:
+ return False
+ return hmac.compare_digest(proof, expected)
+
+
+def authorize_token(
+ token: str,
+ hub_pk_pem: bytes,
+ *,
+ group_id: str,
+ hosted_groups: Any | None = None,
+ denylist: DenylistLike | None = None,
+ require_scope: str | None = "user",
+) -> AuthorizedPeer:
+ """
+ Everything decided from the JWT, before any proof is exchanged.
+
+ 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).
+ """
+ try:
+ decoded = jwt.decode(token, hub_pk_pem, algorithms=["EdDSA"])
+ except Exception as exc:
+ raise HandshakeError(f"Invalid JWT: {exc}") from exc
+
+ # A node-scoped daemon token must not be usable as a client token (M9).
+ if require_scope is not None and decoded.get("scope", "user") != require_scope:
+ raise HandshakeError("Wrong token scope")
+
+ user_id = decoded.get("sub", "")
+ jti = decoded.get("jti", "")
+ if not user_id:
+ raise HandshakeError("Token has no subject")
+
+ if not group_id:
+ raise HandshakeError("group_id is required")
+
+ if denylist is not None and denylist.is_denied(user_id, jti, group_id):
+ raise HandshakeError("Token revoked")
+
+ if group_id not in decoded.get("groups", []):
+ # Almost always a token issued before the person was added to the group:
+ # `groups` is baked in at login and the hub does not push updates. The
+ # client refreshes and retries on this code rather than telling someone
+ # who *is* a member that they are not one.
+ raise HandshakeError("Not a member of this group", code="not_a_member")
+
+ if hosted_groups is not None and group_id not in hosted_groups:
+ raise HandshakeError("Group not hosted on this node")
+
+ return AuthorizedPeer(
+ user_id=user_id,
+ group_id=group_id,
+ username=decoded.get("username", ""),
+ jti=jti,
+ )
+
+
+def webrtc_binding(offer_fp: bytes, answer_fp: bytes) -> bytes:
+ """Channel binding for WebRTC: both DTLS certificate fingerprints."""
+ return (len(offer_fp).to_bytes(4, "big") + offer_fp
+ + len(answer_fp).to_bytes(4, "big") + answer_fp)
+
+
+def quic_binding(server_cert_der: bytes) -> bytes:
+ """
+ Channel binding for QUIC.
+
+ QUIC has no DTLS fingerprint to reuse, so the anchor is a hash of the server's
+ self-signed certificate — the same value a client pins as the node identity.
+ An RFC 5705 exporter would be stronger; aioquic does not currently expose one
+ (11.5.6).
+ """
+ digest = hashlib.sha256(server_cert_der).digest()
+ return len(digest).to_bytes(4, "big") + digest
diff --git a/packages/meshbay-common/src/meshbay_common/join.py b/packages/meshbay-common/src/meshbay_common/join.py
new file mode 100644
index 0000000..6ee543f
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/join.py
@@ -0,0 +1,63 @@
+"""
+Join and pairing transcript (MNP).
+
+A client proves, in one signature, that the X25519 key it wants the group key
+wrapped for belongs to the Ed25519 identity the node pins. Both keys travel inside
+the transcript, so the identity key vouches for the encryption key it is paired
+with — that is what makes "wrap the GEK for the key the peer presented" safe.
+
+Why this exists at all (H3): the invite flow used to fetch the invitee's public key
+from the hub and wrap the group key for whatever came back. The hub is the key
+directory, so a hub answering with its own key was handed the GEK by an honest
+inviter following the protocol exactly. The key now comes from the peer over an
+authenticated channel and is bound to an identity by a one-time pairing code the
+hub never sees. See `docs/invite-pairing-v1.md`.
+
+Fields are length-prefixed and domain-separated, per L4 — the same rule as
+`handshake.py` and `adminop.py`. `nonce_node` is the handshake nonce the node just
+issued, so a signed join cannot be lifted onto another connection.
+"""
+
+from __future__ import annotations
+
+JOIN_PREFIX = b"meshbay:join:v1"
+
+# A join older than this is refused. Same value as the admin challenge: both are
+# interactive exchanges that complete in milliseconds.
+JOIN_TTL = 120 # seconds
+
+ROLE_OPERATOR = "operator"
+ROLE_DELEGATE = "delegate" # reserved; delegation is deferred (§6.2 of the design)
+ROLE_MEMBER = "member"
+
+
+def join_transcript(
+ node_pk_b64: str,
+ group_id: str,
+ user_id: str,
+ pk_ed25519_b64: str,
+ pk_x25519_b64: str,
+ nonce_node: bytes,
+ ts: int,
+) -> bytes:
+ """
+ Bytes signed by a client asking to be pinned by, or recognised on, a node.
+
+ `group_id` is empty for operator pairing, which is node-wide rather than
+ per-group. The node builds this from its own state and the values in the
+ message; nothing signed is ever taken from the wire unverified.
+ """
+ fields = [
+ node_pk_b64.encode(),
+ group_id.encode(),
+ user_id.encode(),
+ pk_ed25519_b64.encode(),
+ pk_x25519_b64.encode(),
+ nonce_node,
+ str(ts).encode(),
+ ]
+ out = bytearray(JOIN_PREFIX)
+ for field in fields:
+ out += len(field).to_bytes(4, "big")
+ out += field
+ return bytes(out)
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index 55dcdde..50e8663 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -29,8 +29,10 @@ class MNP:
CHAT_ATTACHMENT = "chat_attach" # attachment metadata
CHAT_HISTORY = "chat_hist" # request message history
CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages
- GEK_REQUEST = "gek_req" # browser requests group GEK
- GEK_RESPONSE = "gek_resp" # node delivers GEK over secure channel
+ # GEK_REQUEST / GEK_RESPONSE were removed (NS3, and finding L1): the node must
+ # never serve the GEK in plaintext. Members obtain it by unwrapping their own
+ # ECIES bundle. The constants lingered after the handlers were deleted, leaving
+ # the wire contract looking as though the endpoint still existed.
FILE_UPLOAD = "file_upload" # client pushes file chunk to node
FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt
FILE_DELETE = "file_delete" # client requests file deletion
@@ -44,12 +46,19 @@ class MNP:
HANDSHAKE_RESPONSE = "handshake_response" # client → node: HMAC(GEK, nonce)
ADMIN_CHALLENGE = "admin_challenge" # node → client: Ed25519 sign challenge
ADMIN_RESPONSE = "admin_response" # client → node: Ed25519 signature
- GEK_BUNDLE_STORE = "gek_bundle_store" # client → node: store wrapped GEK for a user
+ # GEK_BUNDLE_STORE was removed with the invite redesign: the node wraps the GEK
+ # itself, for a key the recipient proved possession of, so no member ever hands
+ # the node key material (C5b, and the H3 substitution it enabled).
GEK_BUNDLE_FETCH = "gek_bundle_fetch" # client → node: request own wrapped GEK
GEK_BUNDLE_RESP = "gek_bundle_resp" # node → client: wrapped GEK bundle
KEYPAIR_BUNDLE_STORE = "keypair_bundle_store" # client → node: store encrypted keypair bundle
KEYPAIR_BUNDLE_FETCH = "keypair_bundle_fetch" # client → node: request own keypair bundle
KEYPAIR_BUNDLE_RESP = "keypair_bundle_resp" # node → client: encrypted keypair bundle
+ KEYPAIR_BUNDLE_DELETE = "keypair_bundle_delete" # client → node: withdraw own backup
+ JOIN_REQUEST = "join_request" # client → node: pair/recognise this identity
+ JOIN_RESULT = "join_result" # node → client: outcome + wrapped GEK
+ INVITE_CREATE = "invite_create" # operator → node: issue a pairing code
+ INVITE_RESULT = "invite_result" # node → operator: the code, once
# ── Index entry ───────────────────────────────────────────────────────────────
diff --git a/packages/meshbay-common/tests/test_handshake.py b/packages/meshbay-common/tests/test_handshake.py
new file mode 100644
index 0000000..8981db8
--- /dev/null
+++ b/packages/meshbay-common/tests/test_handshake.py
@@ -0,0 +1,225 @@
+"""
+Unified handshake — properties every transport must inherit (11.5.4/5, C6, C3, L4).
+
+These test the shared module rather than any one transport. The point of the module
+is that WebRTC and QUIC cannot drift apart again: the handshake existed three times
+over and only the newest copy enforced the GEK proof.
+"""
+
+import time
+
+import jwt
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.handshake import (
+ HANDSHAKE_PREFIX,
+ NONCE_LEN,
+ ROLE_CLIENT,
+ ROLE_NODE,
+ AuthorizedPeer,
+ HandshakeError,
+ authorize_token,
+ handshake_transcript,
+ make_proof,
+ quic_binding,
+ verify_proof,
+ webrtc_binding,
+)
+
+GEK = b"\x11" * 32
+GROUP = "g" * 32
+NONCE_C = b"\x01" * NONCE_LEN
+NONCE_S = b"\x02" * NONCE_LEN
+BINDING = webrtc_binding(b"\xaa" * 32, b"\xbb" * 32)
+
+
+# ── Token authorization ───────────────────────────────────────────────────────
+
+@pytest.fixture
+def hub_key():
+ sk = Ed25519PrivateKey.generate()
+ pem = sk.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ )
+ pub = sk.public_key().public_bytes(
+ serialization.Encoding.PEM,
+ serialization.PublicFormat.SubjectPublicKeyInfo,
+ )
+ return pem, pub
+
+
+def _token(sk_pem, **over):
+ now = int(time.time())
+ payload = {
+ "iss": "test-hub", "sub": "user-1", "jti": "jti-1",
+ "iat": now, "exp": now + 3600,
+ "groups": [GROUP], "scope": "user", "pk_user": "pk",
+ }
+ payload.update(over)
+ return jwt.encode(payload, sk_pem, algorithm="EdDSA")
+
+
+def test_valid_token_authorizes(hub_key):
+ sk_pem, pk_pem = hub_key
+ peer = authorize_token(_token(sk_pem), pk_pem, group_id=GROUP)
+ assert isinstance(peer, AuthorizedPeer)
+ assert peer.user_id == "user-1"
+
+
+def test_group_id_is_mandatory(hub_key):
+ """
+ M1: group_id used to be optional, and omitting it skipped the membership check
+ entirely while falling back to the node's first group.
+ """
+ sk_pem, pk_pem = hub_key
+ with pytest.raises(HandshakeError, match="group_id"):
+ authorize_token(_token(sk_pem), pk_pem, group_id="")
+
+
+def test_non_member_refused(hub_key):
+ sk_pem, pk_pem = hub_key
+ token = _token(sk_pem, groups=["other-group"])
+ with pytest.raises(HandshakeError, match="Not a member"):
+ authorize_token(token, pk_pem, group_id=GROUP)
+
+
+def test_node_scoped_token_refused_on_client_path(hub_key):
+ """M9: a daemon's node-scoped token must not be usable as a client token."""
+ sk_pem, pk_pem = hub_key
+ token = _token(sk_pem, scope="node")
+ with pytest.raises(HandshakeError, match="scope"):
+ authorize_token(token, pk_pem, group_id=GROUP)
+
+
+def test_unhosted_group_refused(hub_key):
+ sk_pem, pk_pem = hub_key
+ with pytest.raises(HandshakeError, match="not hosted"):
+ authorize_token(_token(sk_pem), pk_pem, group_id=GROUP,
+ hosted_groups={"some-other-group"})
+
+
+def test_denylisted_token_refused(hub_key):
+ sk_pem, pk_pem = hub_key
+
+ class _Deny:
+ def is_denied(self, user_id, jti, group_id=""):
+ return group_id == GROUP
+
+ with pytest.raises(HandshakeError, match="revoked"):
+ authorize_token(_token(sk_pem), pk_pem, group_id=GROUP, denylist=_Deny())
+
+
+def test_forged_token_refused(hub_key):
+ _, pk_pem = hub_key
+ other = Ed25519PrivateKey.generate().private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ )
+ with pytest.raises(HandshakeError, match="Invalid JWT"):
+ authorize_token(_token(other), pk_pem, group_id=GROUP)
+
+
+# ── Proof transcript ──────────────────────────────────────────────────────────
+
+def test_transcript_is_domain_separated():
+ assert handshake_transcript(
+ ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING
+ ).startswith(HANDSHAKE_PREFIX)
+
+
+def test_client_proof_is_not_a_node_proof():
+ """
+ C3: the node proves itself with the same key over the same connection. Without
+ the role bound in, a client's proof would satisfy the node check and vice
+ versa, so an impersonating peer could simply echo it back.
+ """
+ client = make_proof(GEK, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING)
+ assert not verify_proof(GEK, client, ROLE_NODE, GROUP, NONCE_C, NONCE_S, BINDING)
+
+ node = make_proof(GEK, ROLE_NODE, GROUP, NONCE_C, NONCE_S, BINDING)
+ assert not verify_proof(GEK, node, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING)
+ assert client != node
+
+
+@pytest.mark.parametrize("field,value", [
+ ("group_id", "other-group"),
+ ("nonce_client", b"\x09" * NONCE_LEN),
+ ("nonce_node", b"\x09" * NONCE_LEN),
+ ("binding", webrtc_binding(b"\xcc" * 32, b"\xdd" * 32)),
+])
+def test_proof_binds_every_field(field, value):
+ base = dict(role=ROLE_CLIENT, group_id=GROUP, nonce_client=NONCE_C,
+ nonce_node=NONCE_S, binding=BINDING)
+ proof = make_proof(GEK, **base)
+ altered = dict(base, **{field: value})
+ assert not verify_proof(GEK, proof, **altered), (
+ f"proof ignores {field} — replayable across connections")
+
+
+def test_proof_requires_channel_binding():
+ """
+ L4/NS5: the old transcript was nonce ‖ offer_fp ‖ answer_fp, and a missing
+ fingerprint silently degraded it to nonce-only, dropping MitM detection.
+ """
+ with pytest.raises(HandshakeError, match="binding"):
+ make_proof(GEK, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, b"")
+
+ assert not verify_proof(
+ GEK, b"\x00" * 32, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, b"")
+
+
+def test_proof_requires_gek():
+ with pytest.raises(HandshakeError):
+ make_proof(b"", ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING)
+
+
+def test_wrong_gek_fails():
+ proof = make_proof(GEK, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING)
+ assert not verify_proof(
+ b"\x22" * 32, proof, ROLE_CLIENT, GROUP, NONCE_C, NONCE_S, BINDING)
+
+
+def test_transcript_is_unambiguous():
+ """
+ L4: with bare concatenation, a crafted group id could impersonate the
+ following field and two different handshakes would produce identical bytes.
+ """
+ a = handshake_transcript(ROLE_CLIENT, "gg", NONCE_C, NONCE_S, BINDING)
+ b = handshake_transcript(ROLE_CLIENT, "g", b"g" + NONCE_C, NONCE_S, BINDING)
+ assert a != b
+
+
+def test_bindings_differ_by_transport():
+ """A WebRTC proof must not be replayable on a QUIC connection."""
+ assert webrtc_binding(b"\xaa" * 32, b"\xbb" * 32) != quic_binding(b"cert-der")
+
+
+def test_membership_refusal_carries_a_code_a_client_can_act_on():
+ """
+ `groups` is baked into the token at login, so someone added to a group after
+ signing in is refused although they are a member. The client refreshes and
+ retries on this code — it must not have to match on the human wording, which
+ is exactly the kind of coupling that breaks when someone improves a message.
+ """
+ import jwt as _jwt
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+ from cryptography.hazmat.primitives import serialization
+
+ sk = Ed25519PrivateKey.generate()
+ pem_priv = sk.private_bytes(
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption())
+ pem_pub = sk.public_key().public_bytes(
+ serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
+
+ token = _jwt.encode({"sub": "u1", "jti": "j1", "scope": "user", "groups": []},
+ pem_priv, algorithm="EdDSA")
+
+ with pytest.raises(HandshakeError) as excinfo:
+ authorize_token(token, pem_pub, group_id="g" * 32)
+ assert excinfo.value.code == "not_a_member"
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py
new file mode 100644
index 0000000..340ea3e
--- /dev/null
+++ b/packages/meshbay-common/tests/test_js_python_parity.py
@@ -0,0 +1,236 @@
+"""
+Cross-language parity: the browser's transcripts must be byte-identical to Python's.
+
+The handshake proof and the admin signature are computed independently on both sides
+and compared by producing the same bytes. Nothing on the wire carries the transcript,
+which is the point — but it also means a one-byte disagreement between `crypto.js` and
+`meshbay_common` is invisible to every other test and produces a total outage: no
+browser can complete a handshake with any node.
+
+The rest of the suite runs in Python only, so nothing else crosses this boundary. These
+tests drive the real `crypto.js` under node and compare against the real Python.
+
+Skipped when node is unavailable; that is a coverage gap, not a pass.
+"""
+
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from meshbay_common.adminop import admin_transcript
+from meshbay_common.handshake import handshake_transcript, webrtc_binding
+from meshbay_common.join import join_transcript
+
+CRYPTO_JS = (Path(__file__).resolve().parents[2]
+ / "meshbay-hub" / "src" / "meshbay_hub" / "static" / "crypto.js")
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not CRYPTO_JS.exists(),
+ reason="node or crypto.js unavailable — parity cannot be checked",
+)
+
+# (role, group_id, nonce_c hex, nonce_s hex, offer_fp hex, answer_fp hex)
+HANDSHAKE_VECTORS = [
+ ("client", "g" * 32, "01" * 32, "02" * 32, "aa" * 32, "bb" * 32),
+ ("node", "g" * 32, "01" * 32, "02" * 32, "aa" * 32, "bb" * 32),
+ # Short and empty group ids — length prefixing must keep these distinct.
+ ("client", "g", "03" * 32, "04" * 32, "cc" * 32, "dd" * 32),
+ ("client", "", "03" * 32, "04" * 32, "cc" * 32, "dd" * 32),
+ # Non-ASCII: JS TextEncoder and Python .encode() must agree on UTF-8.
+ ("client", "groupe-café-日本", "05" * 32, "06" * 32, "ee" * 32, "ff" * 32),
+ # Fields that could run together under naive concatenation.
+ ("client", "gg", "07" * 32, "08" * 32, "11" * 32, "22" * 32),
+]
+
+# (op, node_pk_b64, group_id, subject, nonce hex, ts)
+ADMIN_VECTORS = [
+ ("file_delete", "Tk9ERVBL", "g" * 32, "file-1", "01" * 32, 1_700_000_000),
+ ("gek_bundle_store", "Tk9ERVBL", "g" * 32, "user-2", "02" * 32, 1_700_000_001),
+ ("file_delete", "Tk9ERVBL", "", "", "03" * 32, 0),
+ ("file_delete", "Tk9ERVBL", "café", "fichier é.mp4", "04" * 32, 1_700_000_002),
+]
+
+# (node_pk_b64, group_id, user_id, pk_ed b64, pk_x b64, nonce hex, ts)
+JOIN_VECTORS = [
+ # Operator pairing: group_id is empty and must stay distinguishable from a
+ # request that names a group.
+ ("Tk9ERVBL", "", "grenet", "QUFB", "QkJC", "01" * 32, 1_700_000_000),
+ ("Tk9ERVBL", "g" * 32, "grenet", "QUFB", "QkJC", "01" * 32, 1_700_000_000),
+ # Identical to the previous vector except that the two keys are swapped —
+ # they are adjacent fields, so this isolates the ordering.
+ ("Tk9ERVBL", "g" * 32, "grenet", "QkJC", "QUFB", "01" * 32, 1_700_000_000),
+ ("Tk9ERVBL", "café", "utilisateur-é", "QUFB", "QkJC", "03" * 32, 0),
+]
+
+_HARNESS = r"""
+const fs = require('fs');
+
+// crypto.js ends with `window.MeshBayCrypto = {...}` and references SubtleCrypto in
+// functions we do not call. A stub is enough to evaluate the module body.
+globalThis.window = {};
+globalThis.crypto = globalThis.crypto || {};
+
+const src = fs.readFileSync(process.argv[2], 'utf8');
+const load = new Function(
+ src + '\nreturn { handshakeTranscript, adminTranscript, joinTranscript, '
+ + 'webrtcBinding, b64encode };');
+const M = load();
+
+const hex = (s) => {
+ const out = new Uint8Array(s.length / 2);
+ for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.substr(i, 2), 16);
+ return out;
+};
+const toHex = (u8) =>
+ Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
+
+const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
+const out = { handshake: [], admin: [], join: [] };
+
+for (const v of input.handshake) {
+ const binding = M.webrtcBinding(hex(v.offer_fp), hex(v.answer_fp));
+ out.handshake.push(toHex(M.handshakeTranscript(
+ v.role, v.group_id, hex(v.nonce_c), hex(v.nonce_s), binding)));
+}
+
+for (const v of input.admin) {
+ out.admin.push(toHex(M.adminTranscript(
+ v.op, v.node_pk, v.group_id, v.subject, M.b64encode(hex(v.nonce)), v.ts)));
+}
+
+for (const v of input.join) {
+ out.join.push(toHex(M.joinTranscript(
+ v.node_pk, v.group_id, v.user_id, v.pk_ed, v.pk_x, hex(v.nonce), v.ts)));
+}
+
+process.stdout.write(JSON.stringify(out));
+"""
+
+
+@pytest.fixture(scope="module")
+def js_output(tmp_path_factory):
+ """Run the real crypto.js under node and return its transcripts as hex."""
+ d = tmp_path_factory.mktemp("parity")
+ harness = d / "harness.js"
+ harness.write_text(_HARNESS)
+
+ payload = d / "vectors.json"
+ payload.write_text(json.dumps({
+ "handshake": [
+ {"role": r, "group_id": g, "nonce_c": nc,
+ "nonce_s": ns, "offer_fp": ofp, "answer_fp": afp}
+ for r, g, nc, ns, ofp, afp in HANDSHAKE_VECTORS
+ ],
+ "admin": [
+ {"op": op, "node_pk": pk, "group_id": g,
+ "subject": s, "nonce": n, "ts": ts}
+ for op, pk, g, s, n, ts in ADMIN_VECTORS
+ ],
+ "join": [
+ {"node_pk": pk, "group_id": g, "user_id": u,
+ "pk_ed": pe, "pk_x": px, "nonce": n, "ts": ts}
+ for pk, g, u, pe, px, n, ts in JOIN_VECTORS
+ ],
+ }))
+
+ proc = subprocess.run(
+ ["node", str(harness), str(CRYPTO_JS), str(payload)],
+ capture_output=True, text=True, timeout=60,
+ )
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr}")
+ return json.loads(proc.stdout)
+
+
+@pytest.mark.parametrize("idx,vector", list(enumerate(HANDSHAKE_VECTORS)))
+def test_handshake_transcript_parity(idx, vector, js_output):
+ """
+ A mismatch here means no browser can complete a handshake with any node —
+ the GEK proof would never verify, and no other test would notice.
+ """
+ role, group_id, nonce_c, nonce_s, offer_fp, answer_fp = vector
+
+ expected = handshake_transcript(
+ role=role,
+ group_id=group_id,
+ nonce_client=bytes.fromhex(nonce_c),
+ nonce_node=bytes.fromhex(nonce_s),
+ binding=webrtc_binding(bytes.fromhex(offer_fp), bytes.fromhex(answer_fp)),
+ )
+ assert js_output["handshake"][idx] == expected.hex(), (
+ f"crypto.js and meshbay_common.handshake disagree for role={role!r} "
+ f"group={group_id!r}"
+ )
+
+
+@pytest.mark.parametrize("idx,vector", list(enumerate(ADMIN_VECTORS)))
+def test_admin_transcript_parity(idx, vector, js_output):
+ """
+ A mismatch here means the browser signs bytes the node did not ask for, so every
+ file deletion and GEK bundle store is rejected.
+ """
+ op, node_pk, group_id, subject, nonce, ts = vector
+
+ expected = admin_transcript(
+ op=op,
+ node_pk_b64=node_pk,
+ group_id=group_id,
+ subject=subject,
+ nonce=bytes.fromhex(nonce),
+ ts=ts,
+ )
+ assert js_output["admin"][idx] == expected.hex(), (
+ f"crypto.js and meshbay_common.adminop disagree for op={op!r} "
+ f"subject={subject!r}"
+ )
+
+
+@pytest.mark.parametrize("idx,vector", list(enumerate(JOIN_VECTORS)))
+def test_join_transcript_parity(idx, vector, js_output):
+ """
+ A mismatch here means no browser can pair with a node and no member can be
+ recognised — the node would reject every signature as invalid, and, as with
+ the other two, nothing else in the suite crosses this boundary.
+ """
+ node_pk, group_id, user_id, pk_ed, pk_x, nonce, ts = vector
+
+ expected = join_transcript(
+ node_pk_b64=node_pk,
+ group_id=group_id,
+ user_id=user_id,
+ pk_ed25519_b64=pk_ed,
+ pk_x25519_b64=pk_x,
+ nonce_node=bytes.fromhex(nonce),
+ ts=ts,
+ )
+ assert js_output["join"][idx] == expected.hex(), (
+ f"crypto.js and meshbay_common.join disagree for user={user_id!r} "
+ f"group={group_id!r}"
+ )
+
+
+def test_join_transcript_binds_the_two_keys_in_order(js_output):
+ """
+ The X25519 key is trusted only because the Ed25519 identity signed it, so the
+ two must not be interchangeable: swapping them has to produce different bytes.
+ """
+ assert js_output["join"][1] != js_output["join"][2]
+
+
+def test_operator_pairing_is_distinguishable_from_a_group_join(js_output):
+ """An empty group_id (node-wide operator authority) must not collide."""
+ assert js_output["join"][0] != js_output["join"][1]
+
+
+def test_length_prefixing_actually_disambiguates(js_output):
+ """
+ The reason both sides length-prefix: two different field splits must not collide.
+ Verified across the language boundary, since a JS implementation that concatenated
+ naively would still agree with itself.
+ """
+ a = js_output["handshake"][2] # group_id "g"
+ b = js_output["handshake"][3] # group_id ""
+ assert a != b, "JS transcripts collide across different group ids"
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 88af764..000f3f7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -6,6 +6,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.api.deps import get_current_user, require_user_scope
+from meshbay_hub.api.netutil import client_ip
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
FederatedGroup, Group, GroupMember,
@@ -14,6 +15,10 @@ from meshbay_hub.db.models import (
router = APIRouter(prefix="/v1/groups", tags=["groups"])
+# Swarm endpoints live at /v1/swarm/*. They were previously declared on the groups
+# router with a full path, which mounted them at /v1/groups/v1/swarm/* (H7).
+swarm_router = APIRouter(prefix="/v1/swarm", tags=["swarm"])
+
@router.get("/mine")
async def my_groups(
@@ -117,13 +122,21 @@ class SwarmRegisterRequest(BaseModel):
endpoint: str # "ip:port"
-@router.post("/v1/swarm/register", status_code=201)
+@swarm_router.post("/register", status_code=201)
async def swarm_register(
body: SwarmRegisterRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
- """Node registers itself as a source for a content hash (public swarm)."""
+ """
+ Node registers itself as a source for a PUBLIC content hash.
+
+ Finding H7: the node registered hashes for every group it hosted, private ones
+ included, and this route was mounted at /v1/groups/v1/swarm/register — so the
+ node's calls 404'd and the leak was masked by a routing bug rather than
+ prevented. Nodes now filter by group visibility before calling, and the path is
+ correct, so the filter has to be right.
+ """
from meshbay_hub.csam import check_content_hash
if check_content_hash(body.content_hash):
raise HTTPException(status_code=451, detail="Content blocked")
@@ -144,12 +157,18 @@ async def swarm_register(
return {"status": "registered", "hash": body.content_hash}
-@router.get("/v1/swarm/{content_hash}")
+@swarm_router.get("/{content_hash}")
async def swarm_sources(
content_hash: str,
+ current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
- """Return list of nodes that can serve a content hash."""
+ """
+ Return nodes that can serve a content hash.
+
+ Authenticated (H7): an open endpoint lets anyone probe whether a given file
+ exists anywhere in the network and which node holds it.
+ """
from datetime import datetime, timezone, timedelta
cutoff = datetime.now(timezone.utc) - timedelta(minutes=30)
result = await db.execute(
@@ -214,7 +233,7 @@ async def join_group(
db.add(GroupMember(group_id=group_id, user_id=current_user.id))
db.add(IPLog(user_id=current_user.id, event="group_join",
- ip_address=_ip(request), detail=group.name))
+ ip_address=client_ip(request), detail=group.name))
await db.commit()
return {"status": "joined", "group_id": group_id, "name": group.name}
@@ -246,7 +265,7 @@ async def create_group(
db.add(GroupMember(group_id=group.id, user_id=current_user.id))
db.add(IPLog(user_id=current_user.id, event="group_create",
- ip_address=_ip(request), detail=body.name))
+ ip_address=client_ip(request), detail=body.name))
await db.commit()
await db.refresh(group)
return {"group_id": group.id, "name": group.name}
@@ -304,13 +323,9 @@ async def delete_group(
from sqlalchemy import delete as sa_delete
await db.execute(sa_delete(GroupMember).where(GroupMember.group_id == group_id))
db.add(IPLog(user_id=current_user.id, event="group_delete",
- ip_address=_ip(request), detail=group.name))
+ ip_address=client_ip(request), detail=group.name))
await db.delete(group)
await db.commit()
return {"status": "deleted", "group_id": group_id}
-def _ip(request: Request) -> str:
- fwd = request.headers.get("X-Forwarded-For")
- return fwd.split(",")[0].strip() if fwd else (
- request.client.host if request.client else "unknown")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
index 6bb007b..853f255 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/moderation.py
@@ -28,6 +28,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.api.deps import get_current_user, require_admin
+from meshbay_hub.api.netutil import client_ip
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import ContentBlocklist, ContentReport, User
@@ -64,7 +65,7 @@ async def report_content(
if len(body.content_hash) != 64 or not all(c in "0123456789abcdef" for c in body.content_hash):
raise HTTPException(status_code=422, detail="content_hash must be 64 hex chars (blake3)")
- ip = _ip(request)
+ ip = client_ip(request)
# Count existing reports for this hash
count_result = await db.execute(
@@ -193,8 +194,3 @@ async def admin_remove_blocklist(
await db.commit()
return {"status": "unblocked", "hash": content_hash}
-
-def _ip(request: Request) -> str:
- fwd = request.headers.get("X-Forwarded-For")
- return fwd.split(",")[0].strip() if fwd else (
- request.client.host if request.client else "unknown")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/netutil.py b/packages/meshbay-hub/src/meshbay_hub/api/netutil.py
new file mode 100644
index 0000000..aa8344a
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/netutil.py
@@ -0,0 +1,34 @@
+"""
+Client address resolution for the audit log and rate limiting.
+
+Finding M7: every call site did
+
+ fwd = request.headers.get("X-Forwarded-For")
+ return fwd.split(",")[0].strip() if fwd else request.client.host
+
+which trusts a header the client controls. Anyone could forge the IP written into
+the compliance log — the log that exists specifically to answer legal requests —
+and sidestep per-IP rate limiting at the same time.
+
+X-Forwarded-For is only consulted when the immediate peer is a trusted proxy, and
+then the *rightmost* entry is used: that is the one our own proxy appended, whereas
+the leftmost is whatever the client sent.
+"""
+
+from fastapi import Request
+
+# Caddy terminates TLS on the same host and proxies to 127.0.0.1:8000.
+TRUSTED_PROXIES = frozenset({"127.0.0.1", "::1", "localhost"})
+
+
+def client_ip(request: Request) -> str:
+ peer = request.client.host if request.client else ""
+
+ if peer in TRUSTED_PROXIES:
+ forwarded = request.headers.get("X-Forwarded-For")
+ if forwarded:
+ hops = [h.strip() for h in forwarded.split(",") if h.strip()]
+ if hops:
+ return hops[-1]
+
+ return peer or "unknown"
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 321e43c..0770148 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.auth import issue_access_token
from meshbay_hub.api.deps import get_current_user
from meshbay_hub.api.middleware import limiter
+from meshbay_hub.api.netutil import client_ip
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, Node, User
@@ -59,7 +60,7 @@ async def node_auth(
sig = base64.b64decode(body.signature)
pk.verify(sig, message)
except (InvalidSignature, Exception):
- db.add(IPLog(event="node_auth_fail", ip_address=_ip(request), detail=body.username))
+ db.add(IPLog(event="node_auth_fail", ip_address=client_ip(request), detail=body.username))
await db.commit()
raise HTTPException(status_code=401, detail="Invalid signature")
@@ -68,9 +69,9 @@ async def node_auth(
group_ids = [gid for (gid,) in memberships.all()]
access_token = issue_access_token(
- user.id, user.pk_node_ed25519, ttl=3600, groups=group_ids, scope="node")
+ user.id, ttl=3600, groups=group_ids, scope="node")
- db.add(IPLog(user_id=user.id, event="node_auth", ip_address=_ip(request)))
+ db.add(IPLog(user_id=user.id, event="node_auth", ip_address=client_ip(request)))
await db.commit()
return {
@@ -83,6 +84,8 @@ async def node_auth(
class NodeAnnounceRequest(BaseModel):
pk_node: str
endpoint_hint: str | None = None
+ timestamp: int | None = None # unix seconds
+ signature: str | None = None # base64 Ed25519 over the announce message
@router.post("/announce", status_code=201)
@@ -92,6 +95,48 @@ async def announce_node(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ """
+ Register a node record.
+
+ Finding M8: this accepted any pk_node with no proof the announcer held the
+ matching private key, so a user could announce a record carrying someone
+ else's node key — useful for muddying node identity, and records accumulated
+ without limit. The announcer must now sign a domain-separated message binding
+ the key to their account, the same pattern already used by /v1/nodes/auth.
+ """
+ if body.timestamp is None or not body.signature:
+ raise HTTPException(
+ status_code=400,
+ detail="announce requires timestamp and signature (proof of possession)")
+
+ now = int(time.time())
+ if abs(now - body.timestamp) > NODE_AUTH_TIMESTAMP_WINDOW:
+ raise HTTPException(status_code=401, detail="Timestamp too old or too far ahead")
+
+ message = (f"meshbay:node_announce:{current_user.id}:"
+ f"{body.pk_node}:{body.timestamp}").encode()
+ try:
+ pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(body.pk_node))
+ pk.verify(base64.b64decode(body.signature), message)
+ except Exception:
+ db.add(IPLog(user_id=current_user.id, event="node_announce_fail",
+ ip_address=client_ip(request), detail=body.pk_node[:16]))
+ await db.commit()
+ raise HTTPException(status_code=401, detail="Invalid node key proof of possession")
+
+ # One active record per key per account — announcing again updates in place
+ # instead of accumulating rows.
+ existing = await db.execute(
+ select(Node).where(Node.user_id == current_user.id,
+ Node.pk_node == body.pk_node))
+ node = existing.scalar_one_or_none()
+ if node is not None:
+ node.endpoint_hint = body.endpoint_hint
+ db.add(IPLog(user_id=current_user.id, event="node_announce",
+ ip_address=client_ip(request), detail=body.endpoint_hint))
+ await db.commit()
+ return {"node_id": node.id}
+
node = Node(
user_id=current_user.id,
pk_node=body.pk_node,
@@ -101,7 +146,7 @@ async def announce_node(
db.add(IPLog(
user_id=current_user.id,
event="node_announce",
- ip_address=_ip(request),
+ ip_address=client_ip(request),
detail=body.endpoint_hint,
))
await db.commit()
@@ -128,8 +173,3 @@ async def get_node(
}
-def _ip(request: Request) -> str:
- fwd = request.headers.get("X-Forwarded-For")
- if fwd:
- return fwd.split(",")[0].strip()
- return request.client.host if request.client else "unknown"
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index c9c59c9..2e3323c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -33,7 +33,7 @@ import time
import uuid
from typing import Any
-from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
+from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -42,7 +42,7 @@ import jwt
from meshbay_hub.auth import hub_public_key_pem, decode_access_token
from meshbay_hub.api.deps import get_current_user, require_admin
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import Group, IPLog, User
+from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User
log = logging.getLogger(__name__)
@@ -128,38 +128,109 @@ async def _handle_chat_notify(group_id: str, sender_name: str, sender_user_id: s
log.warning("Chat notify failed: %s", e)
+async def _reject(ws: WebSocket, detail: str, code: int) -> None:
+ await ws.send_text(json.dumps({"type": "error", "detail": detail}))
+ await ws.close(code=code)
+
+
+async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tuple:
+ """
+ Resolve a node WS registration against the database.
+
+ Returns (node_id, group_ids) on success, or (None, error_detail) on refusal.
+ Uses a short-lived session on purpose: a node WebSocket lives for hours, and a
+ request-scoped dependency would pin a PostgreSQL connection for its whole
+ lifetime, exhausting the pool once a handful of nodes connect.
+ """
+ from meshbay_hub.db.engine import get_session_factory
+
+ try:
+ decoded = decode_access_token(token)
+ except Exception as e:
+ return None, str(e)
+
+ if decoded.get("scope") != "node":
+ return None, "Node-scoped token required"
+
+ user_id = decoded.get("sub", "")
+ if not claimed_id:
+ return None, "node_id required"
+
+ async with get_session_factory()() as db:
+ node = await db.get(Node, claimed_id)
+ if node is None or node.user_id != user_id:
+ log.warning("Rejected WS registration for node %s by user %s",
+ claimed_id[:8], (user_id or "?")[:8])
+ return None, "node_id does not belong to this account"
+
+ user = await db.get(User, user_id)
+ if user is None or user.status != "active":
+ return None, "Account not active"
+
+ # Groups come from the database. The node may narrow the set to what it
+ # actually hosts, but it cannot widen it to groups it is not a member of —
+ # otherwise it could advertise itself as a source for any group on the hub.
+ result = await db.execute(
+ select(GroupMember.group_id).where(GroupMember.user_id == user_id))
+ authorized = {gid for (gid,) in result.all()}
+
+ claimed = set(claimed_groups or authorized)
+ return claimed_id, sorted(authorized & claimed)
+
+
@router.websocket("/v1/nodes/ws")
async def node_websocket(ws: WebSocket):
"""
Persistent WebSocket connection for nodes.
- Nodes authenticate with a JWT bearer in the first message.
- Hub sends revocation tokens as JSON messages.
+
+ Finding C2: this used to take `node_id` and `group_ids` straight from the
+ client's first message, with no check that the authenticated user owned that
+ node. Any registered user could connect with an ordinary browser token, claim a
+ victim node's id, and overwrite its entry in `_connected_nodes`. Every WebRTC
+ offer for that node was then relayed to the attacker, who answered with their
+ own SDP — a full node impersonation, and the DTLS channel binding does not help
+ because the attacker is the endpoint rather than a relay. The attacker received
+ the victim's encrypted keypair bundle, their chat, and their uploads.
+
+ Identity now comes from the token and the database, never from the message.
"""
await ws.accept()
node_id: str | None = None
try:
- # Auth: expect {"type": "auth", "token": "<jwt>"}
+ # Auth: expect {"type": "auth", "token": "<jwt>", "node_id": "..."}
raw = await ws.receive_text()
msg = json.loads(raw)
if msg.get("type") != "auth" or "token" not in msg:
- await ws.send_text(json.dumps({"type": "error", "detail": "Send auth first"}))
- await ws.close(code=4001)
+ await _reject(ws, "Send auth first", 4001)
return
try:
decoded = decode_access_token(msg["token"])
except Exception as e:
- await ws.send_text(json.dumps({"type": "error", "detail": str(e)}))
- await ws.close(code=4001)
+ await _reject(ws, str(e), 4001)
return
- node_id = msg.get("node_id") or decoded.get("sub", "unknown")
+ claimed_id = msg.get("node_id") or ""
+
+ # Refuse to displace a live registration rather than silently overwriting it.
+ if claimed_id and claimed_id in _connected_nodes:
+ await _reject(ws, "Node already connected", 4009)
+ return
+
+ resolved_id, result = await _authorize_node_ws(
+ msg["token"], claimed_id, msg.get("group_ids"))
+ if resolved_id is None:
+ await _reject(ws, result, 4003)
+ return
+ group_ids = result
+
+ user_id = decoded.get("sub", "")
+ node_id = resolved_id
_connected_nodes[node_id] = ws
- group_ids = msg.get("group_ids", [])
- if group_ids:
- _node_groups[node_id] = group_ids
- log.info("Node WS connected: %s (groups=%d)", node_id[:8], len(group_ids))
+ _node_groups[node_id] = group_ids
+ log.info("Node WS connected: %s (user=%s, groups=%d)",
+ node_id[:8], user_id[:8], len(group_ids))
await ws.send_text(json.dumps({"type": "auth_ok", "node_id": node_id}))
# Message loop — handle ping, punch_ready, etc.
@@ -203,12 +274,28 @@ class IncomingRequest(BaseModel):
async def notify_incoming(
node_id: str,
body: IncomingRequest,
+ request: Request,
current_user: User = Depends(get_current_user),
):
"""
Signal a node that a client wants to connect (NAT punch coordination).
Hub forwards the request via WebSocket; node punches NAT and replies punch_ready.
+
+ Finding H6: peer_ip was taken verbatim, so any authenticated user could make an
+ 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.
"""
+ from meshbay_hub.api.netutil import client_ip
+
+ caller_ip = client_ip(request)
+ if body.peer_ip != caller_ip:
+ raise HTTPException(
+ status_code=403,
+ detail="peer_ip must match the requesting address")
+ if not (1 <= body.peer_port <= 65535):
+ raise HTTPException(status_code=422, detail="Invalid peer_port")
+
ws = _connected_nodes.get(node_id)
if not ws:
raise HTTPException(status_code=404, detail="Node not connected")
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
index bd343c9..8f84163 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/signaling.py
@@ -17,11 +17,15 @@ import json
import logging
import uuid
-from fastapi import APIRouter, Depends, HTTPException
+from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
from meshbay_hub.api.deps import get_current_user
-from meshbay_hub.db.models import User
+from meshbay_hub.api.middleware import limiter
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import Group, GroupMember, User
log = logging.getLogger(__name__)
@@ -41,25 +45,66 @@ class WebRTCOfferResponse(BaseModel):
peer_id: str
+MAX_SDP_BYTES = 16 * 1024 # an SDP offer is ~2 KB
+MAX_PENDING_PER_USER = 3 # concurrent in-flight offers per account
+
+_pending_per_user: dict[str, int] = {}
+
+
@router.post("/{node_id}/webrtc/offer", response_model=WebRTCOfferResponse)
+@limiter.limit("30/minute")
async def webrtc_offer(
node_id: str,
body: WebRTCOfferRequest,
+ request: Request,
current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
):
"""
Browser sends WebRTC SDP offer for a node. Hub relays via WebSocket.
Returns the node's SDP answer once received.
+
+ Finding H6: this was reachable by any authenticated user, for any node, with no
+ rate limit and no membership check. Each call makes the node allocate an
+ aiortc RTCPeerConnection and gather ICE, so it was a remote resource-exhaustion
+ primitive against an arbitrary third party's machine.
+
+ Finding H4: it also ignored group status, so "suspend a group" did not stop new
+ connections from being brokered to nodes hosting it.
"""
- from meshbay_hub.api.revocation import _connected_nodes
+ from meshbay_hub.api.revocation import _connected_nodes, _node_groups
+
+ if len(body.sdp) > MAX_SDP_BYTES:
+ raise HTTPException(status_code=413, detail="SDP too large")
ws = _connected_nodes.get(node_id)
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.
+ node_group_ids = set(_node_groups.get(node_id, []))
+ if node_group_ids:
+ 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:
+ raise HTTPException(status_code=403, detail="Not a member of any group on this node")
+
+ active = await db.execute(
+ select(Group.id).where(Group.id.in_(shared), Group.status == "active"))
+ if not active.first():
+ raise HTTPException(status_code=403, detail="Group is not active")
+
+ if _pending_per_user.get(current_user.id, 0) >= MAX_PENDING_PER_USER:
+ raise HTTPException(status_code=429, detail="Too many pending connections")
+
peer_id = str(uuid.uuid4())
answer_future: asyncio.Future = asyncio.get_event_loop().create_future()
_webrtc_answers[peer_id] = answer_future
+ _pending_per_user[current_user.id] = _pending_per_user.get(current_user.id, 0) + 1
try:
await ws.send_text(json.dumps({
@@ -83,6 +128,11 @@ async def webrtc_offer(
)
finally:
_webrtc_answers.pop(peer_id, None)
+ remaining = _pending_per_user.get(current_user.id, 1) - 1
+ if remaining > 0:
+ _pending_per_user[current_user.id] = remaining
+ else:
+ _pending_per_user.pop(current_user.id, None)
def handle_webrtc_answer(msg: dict) -> None:
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 53238de..af9141c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -5,7 +5,7 @@ import uuid
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
-from pydantic import BaseModel, EmailStr, field_validator
+from pydantic import BaseModel, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,6 +22,7 @@ from meshbay_hub.auth import (
verify_password,
)
from meshbay_hub.api.middleware import limiter
+from meshbay_hub.api.netutil import client_ip
from meshbay_hub.config import HubConfig
from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import GroupMember, IPLog, RefreshToken, User
@@ -49,8 +50,6 @@ class RegisterRequest(BaseModel):
email: str
password: str | None = None # deprecated — legacy native clients
auth_key: str | None = None # PBKDF2-derived, new clients
- pk_user_ed25519: str # base64 raw 32B
- pk_user_x25519: str # base64 raw 32B
@field_validator("username")
@classmethod
@@ -62,6 +61,24 @@ class RegisterRequest(BaseModel):
raise ValueError("username: only letters, digits, -, _, .")
return v
+ @field_validator("email")
+ @classmethod
+ def email_valid(cls, v: str) -> str:
+ """
+ Sanity-check the address (L6): the field was plain `str`, so any junk was
+ accepted and stored encrypted forever. Deliberately not RFC 5322 — full
+ validation would pull in the email-validator dependency for little gain,
+ and the address is only ever used for recovery and legal contact.
+ """
+ v = v.strip()
+ local, sep, domain = v.partition("@")
+ if (not sep or not local or not domain
+ or "." not in domain
+ or len(v) > 254
+ or any(c.isspace() or ord(c) < 32 for c in v)):
+ raise ValueError("invalid email address")
+ return v
+
class LoginRequest(BaseModel):
username: str
@@ -101,26 +118,27 @@ async def register(
pw_hash=pw_hash,
pw_salt=pw_salt,
pw_version=pw_ver,
- pk_ed25519=body.pk_user_ed25519,
- pk_x25519=body.pk_user_x25519,
hub_id=hub_id,
)
db.add(user)
+ # flush assigns user.id so the log row can be attributed directly.
+ #
+ # Finding M6: this used to insert the row with a NULL user_id and then run
+ # UPDATE ip_logs SET user_id = <new user> WHERE user_id IS NULL
+ # which claimed *every* unattributed row in the table — failed logins for other
+ # usernames, other registrations racing this one — and stamped them with the
+ # account just created. For logs retained a year to answer legal requests, that
+ # attributed other people's connections to the wrong person.
+ await db.flush()
db.add(IPLog(
+ user_id=user.id,
event="account_create",
- ip_address=_client_ip(request),
+ ip_address=client_ip(request),
detail=body.username,
))
await db.commit()
await db.refresh(user)
- # Set user_id in IPLog after commit
- await db.execute(
- IPLog.__table__.update()
- .where(IPLog.user_id == None) # noqa: E711
- .values(user_id=user.id))
- await db.commit()
-
return {"user_id": user.id}
@@ -135,7 +153,7 @@ async def login(
select(User).where(User.username == body.username))
user = result.scalar_one_or_none()
- ip = _client_ip(request)
+ ip = client_ip(request)
if not body.auth_key and not body.password:
raise HTTPException(status_code=401, detail="No credentials provided")
@@ -189,8 +207,7 @@ async def login(
memberships = await db.execute(
select(GroupMember.group_id).where(GroupMember.user_id == user.id))
group_ids = [gid for (gid,) in memberships.all()]
- access_token = issue_access_token(
- user.id, user.pk_ed25519, ttl=_ttl(), groups=group_ids)
+ access_token = issue_access_token(user.id, ttl=_ttl(), groups=group_ids)
raw_rt, rt_hash = generate_refresh_token()
family_id = str(uuid.uuid4())
@@ -255,8 +272,7 @@ async def token_refresh(
memberships = await db.execute(
select(GroupMember.group_id).where(GroupMember.user_id == user.id))
group_ids = [gid for (gid,) in memberships.all()]
- new_access = issue_access_token(
- user.id, user.pk_ed25519, ttl=_ttl(), groups=group_ids)
+ new_access = issue_access_token(user.id, ttl=_ttl(), groups=group_ids)
await db.commit()
return {
@@ -302,39 +318,10 @@ async def register_node_key(
return {"status": "stored", "pk_node_ed25519": body.pk_node_ed25519}
-class RotateKeysRequest(BaseModel):
- pk_user_ed25519: str # base64 raw 32B
- pk_user_x25519: str # base64 raw 32B
-
-
-@router.put("/me/keys")
-async def rotate_browser_keys(
- body: RotateKeysRequest,
- current_user: User = Depends(require_user_scope),
- db: AsyncSession = Depends(get_db),
-):
- for field, label in [
- (body.pk_user_ed25519, "Ed25519"),
- (body.pk_user_x25519, "X25519"),
- ]:
- try:
- raw = base64.b64decode(field)
- if len(raw) != 32:
- raise ValueError
- except Exception:
- raise HTTPException(
- status_code=400,
- detail=f"Invalid {label} public key (need 32 bytes base64)",
- )
-
- current_user.pk_ed25519 = body.pk_user_ed25519
- current_user.pk_x25519 = body.pk_user_x25519
- await db.commit()
- return {
- "status": "updated",
- "pk_ed25519": body.pk_user_ed25519,
- "pk_x25519": body.pk_user_x25519,
- }
+# Key rotation used to live here (`PUT /me/keys`). Identity keys are per node
+# now, so rotating means `meshbay-node member unpin <user>` and pairing again with
+# a fresh code — an operator decision on the machine that pinned it, not a hub
+# call that silently changes what every node believes about someone.
@router.get("/{username}/pubkeys")
@@ -347,19 +334,16 @@ async def get_user_pubkeys(
target = result.scalar_one_or_none()
if not target:
raise HTTPException(status_code=404, detail="User not found")
+ # Account lookup, not a key directory. `user_id` is how a username is resolved
+ # for an invitation, and `pk_node_ed25519` is a node's own linking key. The
+ # user identity keys this used to return were H3: whoever asked wrapped the
+ # group key for whatever came back.
resp = {
- "user_id": target.id,
- "username": target.username,
- "pk_ed25519": target.pk_ed25519,
- "pk_x25519": target.pk_x25519,
+ "user_id": target.id,
+ "username": target.username,
}
if target.pk_node_ed25519:
resp["pk_node_ed25519"] = target.pk_node_ed25519
return resp
-def _client_ip(request: Request) -> str:
- forwarded = request.headers.get("X-Forwarded-For")
- if forwarded:
- return forwarded.split(",")[0].strip()
- return request.client.host if request.client else "unknown"
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 5ad7329..f804ec5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -47,6 +47,10 @@ _HTML = """\
</head>
<body>
<div id="app"></div>
+ <!-- Argon2id (WebAssembly, inlined) — WebCrypto has no memory-hard KDF, and the
+ keypair bundle needs one: it is protected by the passphrase alone and sits
+ on every node its owner joins (C4). Vendored, see static/vendor/PROVENANCE.md -->
+ <script src="/vendor/argon2.min.js"></script>
<script src="/keyderive.js"></script>
<script src="/crypto.js"></script>
<script src="/transport.js"></script>
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 7011bf0..668ae24 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -25,7 +25,7 @@ from meshbay_hub.api.hub import router as hub_router
from meshbay_hub.api.users import router as users_router, set_config as users_set_config
from meshbay_hub.api.deps import set_admin_usernames
from meshbay_hub.api.nodes import router as nodes_router
-from meshbay_hub.api.groups import router as groups_router
+from meshbay_hub.api.groups import router as groups_router, swarm_router
from meshbay_hub.api.revocation import router as revocation_router
from meshbay_hub.api.moderation import router as moderation_router
from meshbay_hub.api.federation import router as federation_router
@@ -110,6 +110,7 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
app.include_router(users_router)
app.include_router(nodes_router)
app.include_router(groups_router)
+ app.include_router(swarm_router)
app.include_router(revocation_router)
app.include_router(moderation_router)
app.include_router(federation_router)
diff --git a/packages/meshbay-hub/src/meshbay_hub/auth.py b/packages/meshbay-hub/src/meshbay_hub/auth.py
index 563a1eb..28f13a5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/auth.py
+++ b/packages/meshbay-hub/src/meshbay_hub/auth.py
@@ -131,13 +131,17 @@ def current_pw_version() -> int:
def issue_access_token(
user_id: str,
- pk_user: str,
ttl: int = 3600,
groups: list[str] | None = None,
scope: str = "user",
) -> str:
"""
Issue a signed JWT access token.
+
+ Carries no user key. It used to, and the node recorded that key as the
+ uploader's identity — so the party issuing tokens decided who could delete a
+ file. The hub certifies accounts; nodes pin keys.
+
Includes jti (UUID4) — required to prevent replay and enable revocation.
Includes groups — list of group_ids the user is a member of (node-side authz).
scope: "user" (browser, full access) or "node" (daemon, restricted).
@@ -148,7 +152,6 @@ def issue_access_token(
payload = {
"iss": _hub_id,
"sub": user_id,
- "pk_user": pk_user,
"hub_id": _hub_id,
"jti": str(uuid.uuid4()),
"iat": now,
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py
new file mode 100644
index 0000000..c581e55
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/db/migrations/versions/a7c31f9e40b2_drop_user_identity_keys.py
@@ -0,0 +1,37 @@
+"""drop_user_identity_keys
+
+The hub published `users.pk_ed25519` / `users.pk_x25519` as a key directory, and
+the invite flow wrapped the group key for whatever it returned — finding H3. Since
+the node wraps the group key itself, for a key its owner proves possession of,
+nothing reads these columns. Identity keys are generated per node and pinned there
+(`meshbay_node/roster.py`), so there is no hub-side key to publish at all.
+
+Downgrade restores the columns, but not their contents: the keys they held were
+never the hub's to reproduce.
+
+Revision ID: a7c31f9e40b2
+Revises: 2041a4060b3c
+Create Date: 2026-08-14
+
+"""
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = 'a7c31f9e40b2'
+down_revision: Union[str, Sequence[str], None] = '2041a4060b3c'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.drop_column('users', 'pk_ed25519')
+ op.drop_column('users', 'pk_x25519')
+
+
+def downgrade() -> None:
+ # Nullable on the way back: the previous schema required them, and nothing
+ # can invent a key that belonged to a user.
+ op.add_column('users', sa.Column('pk_ed25519', sa.String(64), nullable=True))
+ op.add_column('users', sa.Column('pk_x25519', sa.String(64), nullable=True))
diff --git a/packages/meshbay-hub/src/meshbay_hub/db/models.py b/packages/meshbay-hub/src/meshbay_hub/db/models.py
index cdebd3c..a75217b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/db/models.py
+++ b/packages/meshbay-hub/src/meshbay_hub/db/models.py
@@ -42,8 +42,10 @@ class User(Base):
pw_hash: Mapped[bytes] = mapped_column(nullable=False)
pw_salt: Mapped[bytes] = mapped_column(nullable=False)
pw_version: Mapped[int] = mapped_column(Integer, default=1)
- pk_ed25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B
- pk_x25519: Mapped[str] = mapped_column(String(64), nullable=False) # base64 raw 32B
+ # No user identity keys here. The hub published them and the invite flow
+ # wrapped the group key for whatever it returned, which is finding H3; since
+ # the node does the wrapping, nothing reads a key from this directory. Keys
+ # are generated per node and pinned there (meshbay_node/roster.py).
pk_node_ed25519: Mapped[str | None] = mapped_column(String(64), nullable=True) # node daemon key
hub_id: Mapped[str] = mapped_column(String(128), nullable=False)
role: Mapped[str] = mapped_column(String(16), default="user") # user|moderator|admin
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index ddff928..3087e0e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -65,9 +65,13 @@ async function getAllCachedIndexes() {
// ── Auth persistence ─────────────────────────────────────────────────────────
-let _sessionKeys = null;
+// The key that opens a node's keypair bundle, derived once at sign-in. There is
+// no global identity to keep: identity keys belong to a node and are fetched from
+// it (transport.js), so nothing of that kind lives here.
let _bundleKey = null;
-let _pendingBundlePush = null;
+// A one-time pairing code the user just typed, consumed by the next connection
+// attempt. Deliberately not persisted: it is single-use and short-lived.
+let _pendingJoinCode = null;
function _openKeyDB() {
return new Promise((resolve, reject) => {
@@ -105,18 +109,45 @@ async function _clearKeyDB() {
db.close();
} catch {}
}
-function _saveSessionKeys() {
- try {
- if (_sessionKeys) sessionStorage.setItem('meshbay_sk', JSON.stringify(_sessionKeys));
- } catch {}
+/**
+ * Rough passphrase strength, in bits, and what it is up against.
+ *
+ * This number carries more weight here than in most applications. The encrypted
+ * keypair bundle is protected by PBKDF2-SHA512 (600k) and sits on every node
+ * whose group you join, so the people who host your groups can attack it offline
+ * (finding C4). PBKDF2 is memory-light, which is exactly what GPUs are good at.
+ *
+ * The estimate is deliberately conservative — character classes and length, with
+ * a penalty for repetition and for the handful of patterns everyone tries. It is
+ * a guide, not a guarantee, and it says so in the UI.
+ */
+function passwordBits(pw) {
+ if (!pw) return 0;
+ let pool = 0;
+ if (/[a-z]/.test(pw)) pool += 26;
+ if (/[A-Z]/.test(pw)) pool += 26;
+ if (/[0-9]/.test(pw)) pool += 10;
+ if (/[^A-Za-z0-9]/.test(pw)) pool += 32;
+ let bits = pw.length * Math.log2(pool || 1);
+
+ const unique = new Set(pw).size;
+ if (unique < pw.length / 2) bits *= 0.6; // "aaaaaaaa", "abcabcabc"
+ if (/^[0-9]+$/.test(pw)) bits *= 0.5; // dates, PINs
+ if (/(password|motdepasse|azerty|qwerty|123456|meshbay)/i.test(pw)) bits *= 0.3;
+ return Math.round(bits);
}
-function _restoreSessionKeys() {
- try {
- if (!_sessionKeys) {
- const sk = sessionStorage.getItem('meshbay_sk');
- if (sk) _sessionKeys = JSON.parse(sk);
- }
- } catch {}
+
+const PASSWORD_MIN_BITS = 60; // refuse below this
+const PASSWORD_MIN_LEN = 12;
+
+/** Public X25519 key from our own secret — never read back from the hub. */
+async function _pkXFromSk(skPkcs8B64) {
+ const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0));
+ const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'X25519' }, true, ['deriveBits']);
+ const jwk = await crypto.subtle.exportKey('jwk', sk);
+ const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = b64.length % 4;
+ return pad ? b64 + '='.repeat(4 - pad) : b64;
}
function loadAuth() {
@@ -132,11 +163,8 @@ function saveAuth(auth) {
localStorage.setItem(AUTH_KEY, JSON.stringify(auth));
} else {
localStorage.removeItem(AUTH_KEY);
- _sessionKeys = null;
_bundleKey = null;
- _pendingBundlePush = null;
_clearKeyDB();
- try { sessionStorage.removeItem('meshbay_sk'); } catch {}
}
}
@@ -390,7 +418,14 @@ function RegisterPage() {
const onSubmit = async (e) => {
e.preventDefault();
if (password !== confirm) { setError(t('register.err_mismatch')); return; }
- if (password.length < 8) { setError(t('register.err_min_len')); return; }
+ if (password.length < PASSWORD_MIN_LEN) {
+ setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return;
+ }
+ // The floor can only live here: with the password split (T1) the hub never
+ // sees the password, so it cannot enforce anything about it.
+ if (passwordBits(password) < PASSWORD_MIN_BITS) {
+ setError(t('register.err_too_weak')); return;
+ }
setError('');
setLoading(true);
try {
@@ -438,6 +473,18 @@ function RegisterPage() {
<input type="password" placeholder="${t('register.password')}" value=${password}
onInput=${e => setPassword(e.target.value)}
autocomplete="new-password" required minlength="8" />
+ ${password && html`
+ <div style="margin:-4px 0 10px">
+ <div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden">
+ <div style=${`height:100%;width:${Math.min(100, passwordBits(password) / 100 * 100)}%;
+ background:${passwordBits(password) < PASSWORD_MIN_BITS ? 'var(--error)'
+ : passwordBits(password) < 80 ? 'var(--yellow, #f59e0b)' : 'var(--success)'}`}></div>
+ </div>
+ <p style="font-size:0.8em;color:var(--text-dim);margin-top:4px">
+ ${t('register.strength', { bits: passwordBits(password) })}
+ </p>
+ </div>
+ `}
<input type="password" placeholder="${t('register.confirm')}" value=${confirm}
onInput=${e => setConfirm(e.target.value)}
autocomplete="new-password" required />
@@ -762,7 +809,7 @@ async function pipelinedDownload(transport, gekKey, fileId, totalChunks, onChunk
return results;
}
-function GroupPage({ groupId, group, token, username, userId }) {
+function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) {
const [status, setStatus] = useState('idle');
const [entries, setEntries] = useState([]);
const [cached, setCached] = useState(false);
@@ -778,8 +825,25 @@ function GroupPage({ groupId, group, token, username, userId }) {
const [uploading, setUploading] = useState(false);
const [menuOpen, setMenuOpen] = useState(null);
const [isNodeAdmin, setIsNodeAdmin] = useState(false);
+ const [needsCode, setNeedsCode] = useState(false);
+ const [codeInput, setCodeInput] = useState('');
+ const [retryKey, setRetryKey] = useState(0);
const transportRef = useRef(null);
const gekRef = useRef(null);
+ // One refresh per mount: if a fresh token still says we are not a member, we
+ // really are not, and retrying forever would hide that.
+ const refreshedRef = useRef(false);
+
+ const submitJoinCode = useCallback((e) => {
+ e.preventDefault();
+ const code = codeInput.trim();
+ if (!code) return;
+ _pendingJoinCode = code;
+ setCodeInput('');
+ setNeedsCode(false);
+ setError('');
+ setRetryKey(k => k + 1);
+ }, [codeInput]);
useEffect(() => {
if (menuOpen === null) return;
@@ -803,7 +867,6 @@ function GroupPage({ groupId, group, token, username, userId }) {
setError('');
gekRef.current = null;
if (!_bundleKey) _bundleKey = await _loadBundleKey();
- _restoreSessionKeys();
try {
const nodesData = await hubFetch(`/v1/groups/${groupId}/nodes`, { token });
if (cancelled) return;
@@ -812,11 +875,9 @@ function GroupPage({ groupId, group, token, username, userId }) {
return;
}
- // Session keys for P2P GEK bundle fetch (node delivers wrapped GEK)
- const sessionKeys = _sessionKeys ? {
- skXB64: _sessionKeys.skXB64,
- pkXB64: _sessionKeys.pkXB64,
- } : null;
+ // No keys are carried in: the transport fetches this node's identity
+ // from the node, or creates one there on a first join.
+ const sessionKeys = null;
setStatus('connecting');
const nodeId = nodesData.nodes[0].node_id;
@@ -824,34 +885,21 @@ function GroupPage({ groupId, group, token, username, userId }) {
transportRef.current = transport;
const ack = await transport.connect(
- nodeId, token, groupId, null, sessionKeys, _bundleKey, username);
+ nodeId, token, groupId, null, sessionKeys, _bundleKey, username,
+ userId, _pendingJoinCode);
+ _pendingJoinCode = null;
if (cancelled) return;
setIsNodeAdmin(!!ack.is_node_admin);
- // If transport recovered different session keys from node during handshake
- if (transport.sessionKeys) {
- const recovered = transport.sessionKeys;
- if (!_sessionKeys || recovered.skXB64 !== _sessionKeys.skXB64) {
- _sessionKeys = recovered;
- if (!_sessionKeys.pkXB64) {
- const pubkeys = await hubFetch(
- `/v1/users/${username}/pubkeys`, { token });
- _sessionKeys.pkXB64 = pubkeys.pk_x25519;
- }
- _pendingBundlePush = null;
- try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {}
- _saveSessionKeys();
- }
- }
-
- // Push keypair bundle to node (new registration, localStorage → node)
- if (_pendingBundlePush && transport.connected) {
+ // A first join to this node generated an identity for it; leave it with
+ // the node so any other browser can become the same person here with the
+ // passphrase. It is this node's key and no other's.
+ if (transport.connected && transport.newNodeBundle) {
try {
- await transport.storeKeypairBundle(_pendingBundlePush);
- try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {}
- _pendingBundlePush = null;
+ await transport.storeKeypairBundle(transport.newNodeBundle);
+ transport.newNodeBundle = null;
} catch (e) {
- console.warn('[MeshBay] Bundle push to node deferred:', e.message);
+ console.warn('[MeshBay] could not leave our key with the node:', e.message);
}
}
@@ -880,10 +928,24 @@ function GroupPage({ groupId, group, token, username, userId }) {
cacheGroupIndex(groupId, group ? group.name : groupId, freshEntries);
} catch (err) {
- if (!cancelled) {
- setError(err.message);
- setStatus('error');
+ if (cancelled) return;
+
+ // Our token predates being added to this group. Refresh once and retry
+ // rather than telling someone who was just invited that they are not a
+ // member — which is what the node honestly sees, and is useless to them.
+ if (err.reason === 'not_a_member' && !refreshedRef.current && onRefreshAuth) {
+ refreshedRef.current = true;
+ try {
+ if (await onRefreshAuth()) return; // new token → effect re-runs
+ } catch { /* fall through to the message below */ }
}
+
+ // The node has never seen this browser for this account: it needs a
+ // one-time code from the operator before it will hand over the group
+ // key. Not an error to shout about — a step in joining.
+ if (err.reason === 'code_required') setNeedsCode(true);
+ setError(err.message);
+ setStatus('error');
}
};
@@ -902,7 +964,7 @@ function GroupPage({ groupId, group, token, username, userId }) {
transportRef.current = null;
}
};
- }, [groupId, token]);
+ }, [groupId, token, retryKey]);
const downloadFile = useCallback(async (entry) => {
const transport = transportRef.current;
@@ -979,8 +1041,13 @@ function GroupPage({ groupId, group, token, username, userId }) {
const transport = transportRef.current;
if (!transport || !transport.connected) return;
try {
- const signFn = (_sessionKeys && window.MeshBayKeys)
- ? (challenge) => window.MeshBayKeys.signChallenge(_sessionKeys.skEdB64, challenge)
+ // Signs an explicit transcript built by transport.js, not opaque bytes from
+ // the node — see MeshBayCrypto.adminTranscript and finding H5.
+ // Signed with the identity this node pinned for us — the only one it
+ // will accept, and the only one we hold here.
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
: null;
await transport.deleteFile(entry.id, signFn);
const indexMsg = await transport.fetchIndex();
@@ -1077,6 +1144,17 @@ function GroupPage({ groupId, group, token, username, userId }) {
`}
</div>
${error && html`<div class="error-msg" style="margin-bottom:12px">${error}</div>`}
+ ${needsCode && html`
+ <form class="invite-form" style="margin-bottom:12px" onSubmit=${submitJoinCode}>
+ <h4>${t('group.join_code_title')}</h4>
+ <p class="settings-hint">${t('group.join_code_hint')}</p>
+ <div style="display:flex;gap:8px">
+ <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
+ value=${codeInput} onInput=${e => setCodeInput(e.target.value)} required />
+ <button class="admin-btn" type="submit">${t('group.join_code_btn')}</button>
+ </div>
+ </form>
+ `}
${dlState && html`
<div class="dl-bar">
<span class="dl-name">${dlState.name}</span>
@@ -1222,7 +1300,8 @@ function GroupPage({ groupId, group, token, username, userId }) {
${tab === 'members' && html`
<${MembersPanel} groupId=${groupId} group=${group} token=${token}
- transportRef=${transportRef} gekRef=${gekRef} />
+ transportRef=${transportRef} gekRef=${gekRef}
+ isNodeAdmin=${isNodeAdmin} userId=${userId} />
`}
`}
${status === 'offline' && html`
@@ -1363,13 +1442,41 @@ function _b64ToU8(b64) {
// ── Members Panel ────────────────────────────────────────────────────────
-function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
+function MembersPanel({ groupId, group, token, transportRef, gekRef,
+ isNodeAdmin, userId }) {
const [members, setMembers] = useState([]);
const [adminId, setAdminId] = useState('');
const [loading, setLoading] = useState(true);
const [inviteUser, setInviteUser] = useState('');
const [inviting, setInviting] = useState(false);
const [error, setError] = useState('');
+ const [inviteCode, setInviteCode] = useState(null);
+ const [pairCode, setPairCode] = useState('');
+ const [pairStatus, setPairStatus] = useState('');
+ const [pairing, setPairing] = useState(false);
+
+ // Pairing lives here rather than in Settings because this is where a live
+ // connection to the node exists — and it is offered only when the node itself
+ // says this account is its operator (is_node_admin comes from the authenticated
+ // handshake_ack, not from the hub).
+ const doPair = useCallback(async (e) => {
+ e.preventDefault();
+ const code = pairCode.trim();
+ if (!code) return;
+ setPairing(true);
+ setPairStatus('');
+ try {
+ const transport = transportRef && transportRef.current;
+ if (!transport || !transport.connected) throw new Error('Not connected to the node');
+ await transport.pairOperator(userId, code);
+ setPairCode('');
+ setPairStatus('paired');
+ } catch (err) {
+ setPairStatus(err.message);
+ } finally {
+ setPairing(false);
+ }
+ }, [pairCode, transportRef, userId]);
const loadMembers = useCallback(() => {
setLoading(true);
@@ -1391,29 +1498,37 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
if (!inviteUser.trim()) return;
setInviting(true);
setError('');
+ setInviteCode(null);
try {
const transport = transportRef && transportRef.current;
const username = inviteUser.trim();
-
- // Fetch invitee's public keys (hub = public key directory)
- const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
- const pkXBytes = Uint8Array.from(atob(pubkeys.pk_x25519), c => c.charCodeAt(0));
-
- // Get raw GEK from the active transport connection
- if (!transport || !transport.connected || !transport.gekRaw) {
- throw new Error('Not connected to node or no GEK available');
+ if (!transport || !transport.connected) {
+ throw new Error('Not connected to the node — it must be online to invite');
}
- const gekBytes = transport.gekRaw;
- // Wrap GEK for invitee and store on node via P2P
- const bundle = await window.MeshBayCrypto.wrapGEK(gekBytes, pkXBytes);
- await transport.storeGekBundle(pubkeys.user_id, groupId, bundle);
+ // The hub is asked for the account id, and nothing else. It is no longer
+ // asked for the invitee's public key: the node wraps the group key itself,
+ // for a key the invitee proves possession of when they connect (H3). A hub
+ // that answered with the wrong account here would produce an invite whose
+ // code it never learns — the code goes to a human, out of band.
+ const account = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
- // Add member on hub (membership management only)
+ // Signed with the identity this node pinned for us — the only one it
+ // will accept, and the only one we hold here.
+ const sk = transport.sessionKeys && transport.sessionKeys.skEdB64;
+ const signFn = (sk && window.MeshBayKeys)
+ ? (transcript) => window.MeshBayKeys.signBytes(sk, transcript)
+ : null;
+ const result = await transport.createInvite(
+ account.user_id, groupId, username, signFn);
+
+ // Membership on the hub is what lets them reach the node at all; the code
+ // is what gets them the key.
await hubFetch(`/v1/groups/${groupId}/members/${username}`, {
method: 'POST', token, body: {},
});
+ setInviteCode({ username, code: result.code, expires: result.expires_at });
setInviteUser('');
loadMembers();
} catch (err) {
@@ -1421,7 +1536,7 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
} finally {
setInviting(false);
}
- }, [groupId, token, inviteUser, loadMembers]);
+ }, [groupId, token, inviteUser, loadMembers, transportRef]);
if (loading) return html`<p class="page-message">${t('explore.loading')}</p>`;
@@ -1452,6 +1567,15 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
<form class="invite-form" onSubmit=${doInvite}>
<h4>${t('members.invite_title')}</h4>
${error && html`<p class="error-msg">${error}</p>`}
+ ${inviteCode && html`
+ <div class="success-msg" style="margin-bottom:8px">
+ <p>${t('members.invite_code_ready', { user: inviteCode.username })}</p>
+ <p style="font-family:monospace;font-size:1.4em;letter-spacing:2px;margin:6px 0">
+ ${inviteCode.code}
+ </p>
+ <p>${t('members.invite_code_hint')}</p>
+ </div>
+ `}
<div style="display:flex;gap:8px">
<input type="text" placeholder="${t('members.username_placeholder')}"
value=${inviteUser} onInput=${e => setInviteUser(e.target.value)} required />
@@ -1461,6 +1585,24 @@ function MembersPanel({ groupId, group, token, transportRef, gekRef }) {
</div>
</form>
`}
+ ${isNodeAdmin && html`
+ <form class="invite-form" onSubmit=${doPair}>
+ <h4>${t('members.pair_title')}</h4>
+ <p class="settings-hint">${t('members.pair_hint')}</p>
+ ${pairStatus && html`
+ <p class=${pairStatus === 'paired' ? 'success-msg' : 'error-msg'}>
+ ${pairStatus === 'paired' ? t('members.pair_success') : pairStatus}
+ </p>
+ `}
+ <div style="display:flex;gap:8px">
+ <input type="text" placeholder="XXXX-XXXX" style="font-family:monospace"
+ value=${pairCode} onInput=${e => setPairCode(e.target.value)} required />
+ <button class="admin-btn" type="submit" disabled=${pairing}>
+ ${pairing ? '...' : t('members.pair_btn')}
+ </button>
+ </div>
+ </form>
+ `}
</div>
`;
}
@@ -1969,6 +2111,15 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
const [currentNodeKey, setCurrentNodeKey] = useState(null);
const [nodeKeyStatus, setNodeKeyStatus] = useState('');
const [nodeKeyLoading, setNodeKeyLoading] = useState(false);
+ const [pinCount, setPinCount] = useState(
+ () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0));
+
+ // 11.5.8: node identity pins are refused strictly on change, so users need a
+ // deliberate way to accept a legitimate rotation (operator reinstalled a node).
+ const clearPins = useCallback(() => {
+ window.MeshBayTransport?.clearNodePin?.();
+ setPinCount(window.MeshBayTransport?.pinnedNodeCount?.() ?? 0);
+ }, []);
useEffect(() => {
hubFetch(`/v1/users/${user.username}/pubkeys`, { token: user.token })
@@ -2060,6 +2211,17 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
</div>
<div class="settings-section">
+ <h3 class="settings-heading">${t('settings.node_pins')}</h3>
+ <p class="settings-hint">${t('settings.node_pins_hint')}</p>
+ <div class="settings-row">
+ <span class="settings-label">${t('settings.node_pins_count', { n: pinCount })}</span>
+ <button class="btn-secondary" onClick=${clearPins} disabled=${pinCount === 0}>
+ ${t('settings.node_pins_clear')}
+ </button>
+ </div>
+ </div>
+
+ <div class="settings-section">
<h3 class="settings-heading">${t('settings.appearance')}</h3>
<div class="settings-row">
<span class="settings-label">${t('settings.theme')}</span>
@@ -2501,12 +2663,10 @@ function App() {
const data = await window.MeshBayKeys.loginAndRecover(username, password);
token = data.accessToken;
refreshToken = data.refreshToken;
+ // The only thing sign-in produces: the key that opens a node's bundle.
+ // Which identity we use is decided per node, when we get there.
_bundleKey = data.bundleKey;
await _storeBundleKey(_bundleKey);
- if (data.skXB64) {
- _sessionKeys = { skXB64: data.skXB64, skEdB64: data.skEdB64 };
- _pendingBundlePush = data.keypairBundleEnc;
- }
} else {
const data = await hubFetch('/v1/users/login', {
method: 'POST',
@@ -2516,11 +2676,6 @@ function App() {
refreshToken = data.refresh_token;
}
const me = await hubFetch('/v1/users/me', { token });
- if (_sessionKeys) {
- const pubkeys = await hubFetch(`/v1/users/${username}/pubkeys`, { token });
- _sessionKeys.pkXB64 = pubkeys.pk_x25519;
- _saveSessionKeys();
- }
const u = { username, userId: me.user_id, token, refreshToken, role: me.role };
setUser(u);
saveAuth(u);
@@ -2533,6 +2688,20 @@ function App() {
},
};
+ // Group membership is baked into the access token at login and the hub does not
+ // push updates, so someone invited after they signed in carries a token that
+ // says they are in nothing. Refreshing re-reads membership from the database.
+ const refreshAuth = useCallback(async () => {
+ if (!user || !user.refreshToken) return null;
+ const data = await hubFetch('/v1/users/token/refresh', {
+ method: 'POST', body: { refresh_token: user.refreshToken },
+ });
+ const u = { ...user, token: data.access_token };
+ setUser(u);
+ saveAuth(u);
+ return data.access_token;
+ }, [user]);
+
let page;
if (route === '/login' || route === '/register') {
page = route === '/register'
@@ -2557,7 +2726,8 @@ function App() {
const group = groups.find(g => g.id === groupId);
page = html`<${GroupPage}
groupId=${groupId} group=${group} token=${user.token}
- username=${user.username} userId=${user.userId} />`;
+ username=${user.username} userId=${user.userId}
+ onRefreshAuth=${refreshAuth} />`;
} else if (route === '/admin') {
page = (user.role === 'moderator' || user.role === 'admin')
? html`<${AdminPage} token=${user.token} />`
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 5ebf624..21bf05d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -230,24 +230,133 @@ function b64encode(bytes) {
return btoa(String.fromCharCode(...bytes));
}
+// ── Admin operation transcript ───────────────────────────────────────────────
+// Mirrors meshbay_common/adminop.py::admin_transcript(). Both sides build these
+// bytes independently; they are never taken off the wire.
+//
+// Finding H5: the client used to sign 32 raw random bytes chosen by the node — a
+// blind signing oracle. It now reconstructs a domain-separated, length-prefixed
+// transcript naming the operation, subject, node and group, so the UI can show the
+// user what they are authorizing and a signature cannot be reused elsewhere.
+
+const ADMIN_TRANSCRIPT_PREFIX = new TextEncoder().encode('meshbay:admin:v1');
+
+function adminTranscript(op, nodePkB64, groupId, subject, nonceB64, ts) {
+ const enc = new TextEncoder();
+ const fields = [
+ enc.encode(op),
+ enc.encode(nodePkB64),
+ enc.encode(groupId),
+ enc.encode(subject),
+ b64decode(nonceB64),
+ enc.encode(String(ts)),
+ ];
+ let total = ADMIN_TRANSCRIPT_PREFIX.length;
+ for (const f of fields) total += 4 + f.length;
+
+ const out = new Uint8Array(total);
+ out.set(ADMIN_TRANSCRIPT_PREFIX, 0);
+ let off = ADMIN_TRANSCRIPT_PREFIX.length;
+ for (const f of fields) {
+ new DataView(out.buffer).setUint32(off, f.length, false);
+ off += 4;
+ out.set(f, off);
+ off += f.length;
+ }
+ return out;
+}
+
// ── GEK proof (HMAC-SHA256 for handshake challenge) ─────────────────────────
-async function hmacGEK(gekRaw, nonceB64, offerFp, answerFp) {
- const nonce = b64decode(nonceB64);
- const data = concatBuffers([
- nonce,
- offerFp || new Uint8Array(0),
- answerFp || new Uint8Array(0),
+// Mirrors meshbay_common/handshake.py. Every field length-prefixed and the role
+// bound in, so a client proof can never be replayed as a node proof and a missing
+// fingerprint cannot silently degrade the proof to nonce-only (L4).
+const HANDSHAKE_PREFIX = new TextEncoder().encode('meshbay:mnp:handshake:v1');
+
+function _lenPrefixed(parts) {
+ let total = 0;
+ for (const p of parts) total += 4 + p.length;
+ const out = new Uint8Array(total);
+ const view = new DataView(out.buffer);
+ let off = 0;
+ for (const p of parts) {
+ view.setUint32(off, p.length, false);
+ off += 4;
+ out.set(p, off);
+ off += p.length;
+ }
+ return out;
+}
+
+function webrtcBinding(offerFp, answerFp) {
+ if (!offerFp || !offerFp.length || !answerFp || !answerFp.length) {
+ throw new Error('Channel binding unavailable — refusing to handshake');
+ }
+ return _lenPrefixed([offerFp, answerFp]);
+}
+
+function handshakeTranscript(role, groupId, nonceClient, nonceNode, binding) {
+ const enc = new TextEncoder();
+ const body = _lenPrefixed([
+ enc.encode(role), enc.encode(groupId), nonceClient, nonceNode, binding,
]);
+ const out = new Uint8Array(HANDSHAKE_PREFIX.length + body.length);
+ out.set(HANDSHAKE_PREFIX, 0);
+ out.set(body, HANDSHAKE_PREFIX.length);
+ return out;
+}
+
+async function handshakeProof(gekRaw, role, groupId, nonceClient, nonceNode, binding) {
+ const transcript = handshakeTranscript(role, groupId, nonceClient, nonceNode, binding);
const key = await crypto.subtle.importKey(
'raw', gekRaw, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
- const sig = await crypto.subtle.sign('HMAC', key, data);
- return b64encode(new Uint8Array(sig));
+ const sig = await crypto.subtle.sign('HMAC', key, transcript);
+ return new Uint8Array(sig);
+}
+
+// ── Join / pairing transcript ───────────────────────────────────────────────
+
+// Mirrors meshbay_common/join.py. Signing both of our public keys together binds
+// the X25519 key to the Ed25519 identity the node pins, so the node can safely
+// wrap the group key for a key that came over the wire instead of one fetched
+// from the hub's directory (H3). nonce_node ties it to this connection.
+const JOIN_PREFIX = new TextEncoder().encode('meshbay:join:v1');
+
+function joinTranscript(nodePkB64, groupId, userId, pkEdB64, pkXB64, nonceNode, ts) {
+ const enc = new TextEncoder();
+ const body = _lenPrefixed([
+ enc.encode(nodePkB64),
+ enc.encode(groupId),
+ enc.encode(userId),
+ enc.encode(pkEdB64),
+ enc.encode(pkXB64),
+ nonceNode,
+ enc.encode(String(ts)),
+ ]);
+ const out = new Uint8Array(JOIN_PREFIX.length + body.length);
+ out.set(JOIN_PREFIX, 0);
+ out.set(body, JOIN_PREFIX.length);
+ return out;
+}
+
+function constantTimeEqual(a, b) {
+ if (a.length !== b.length) return false;
+ let diff = 0;
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
+ return diff === 0;
+}
+
+/** Verify the node's Ed25519 signature over the handshake transcript (C3). */
+async function verifyNodeSignature(nodePkB64, sigB64, transcript) {
+ const raw = b64decode(nodePkB64);
+ const key = await crypto.subtle.importKey('raw', raw, { name: 'Ed25519' }, false, ['verify']);
+ return crypto.subtle.verify('Ed25519', key, b64decode(sigB64), transcript);
}
// Export for use in app.js
window.MeshBayCrypto = {
importGEK, deriveChunkKey, decryptChunk, decryptChunkBin, decryptFile,
generateGEK, wrapGEK, unwrapGEK, encryptChunk, b64encode, b64decode,
- hmacGEK,
+ adminTranscript, handshakeTranscript, handshakeProof, webrtcBinding,
+ joinTranscript, verifyNodeSignature, constantTimeEqual,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
index 3450735..f0c5cef 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js
@@ -38,7 +38,7 @@ const en = {
'register.title': 'Register',
'register.username': 'Username',
'register.email': 'Email',
- 'register.password': 'Password (min 8 chars)',
+ 'register.password': 'Passphrase (min 12 chars)',
'register.confirm': 'Confirm password',
'register.submit': 'Register',
'register.loading': 'Creating account...',
@@ -48,7 +48,11 @@ const en = {
'register.success_msg': 'You can now log in with your credentials.',
'register.go_login': 'Go to login',
'register.err_mismatch': 'Passwords do not match',
- 'register.err_min_len': 'Password must be at least 8 characters',
+ 'register.err_min_len': 'Use at least {n} characters',
+ 'register.err_too_weak': 'Too easy to guess. Your passphrase is what protects '
+ + 'your keys where they are stored — a few unrelated words work well.',
+ 'register.strength': 'Strength: about {bits} bits. This protects the copy of '
+ + 'your keys kept on the nodes you join, so it is worth getting right.',
// Home
'home.welcome': 'Welcome to MeshBay',
@@ -120,6 +124,10 @@ const en = {
'settings.coming_soon': 'Coming soon.',
'settings.profile': 'Profile',
'settings.username': 'Username',
+ 'settings.node_pins': 'Node identities',
+ 'settings.node_pins_hint': "Each node's identity key is remembered the first time you connect. If it changes, the connection is refused — that is expected only when an operator reinstalls a node. Verify with them before clearing.",
+ 'settings.node_pins_count': '{n} pinned',
+ 'settings.node_pins_clear': 'Clear pinned identities',
'settings.appearance': 'Appearance',
'settings.theme': 'Theme',
'settings.theme_light': 'Light',
@@ -236,6 +244,22 @@ const en = {
'members.invite_title': 'Invite member',
'members.username_placeholder': 'Username',
'members.invite_btn': 'Invite',
+ 'members.pair_title': 'Pair this browser with your node',
+ 'members.pair_hint': 'Your node only accepts operator actions — invites, file '
+ + 'deletion — from a browser it has been paired with. Run '
+ + '`meshbay-node operator pair` on the node and type the code here. The code '
+ + 'never passes through the hub, which is what stops the hub from claiming to '
+ + 'be you.',
+ 'members.pair_btn': 'Pair',
+ 'members.pair_success': 'This browser is now paired with the node.',
+ 'members.invite_code_ready': 'Invitation code for {user} — send it to them the way '
+ + 'you normally talk. It works once, and it never passes through the hub.',
+ 'members.invite_code_hint': 'They enter it the first time they open this group. '
+ + 'You do not need to be online then.',
+ 'group.join_code_title': 'This node needs to recognise you',
+ 'group.join_code_hint': 'Ask whoever invited you for the one-time code, and enter '
+ + 'it here. After that this browser is recognised and you will not be asked again.',
+ 'group.join_code_btn': 'Join',
'notif.title': 'Notifications',
'notif.empty': 'No notifications',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
index ff3da33..a27522d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/keyderive.js
@@ -67,11 +67,35 @@ async function generateKeypairs() {
// ── Password → AES key ────────────────────────────────────────────────────────
-/**
- * Derive an AES-256 key from password + username using PBKDF2-SHA512.
- * Used for encrypting the keypair bundle.
- */
-async function deriveEncryptionKey(password, username) {
+// Argon2id parameters for the keypair bundle.
+//
+// This is the one KDF in the browser that guards something an adversary can take
+// away and attack at leisure: the bundle is stored on every node whose group its
+// owner joins (finding C4). PBKDF2 was the wrong tool — it is compute-only, which
+// is exactly what a GPU is good at, so 600k iterations bought far less than the
+// wall-clock time suggested.
+//
+// 128 MB / t=3 / p=1 measured at ~640 ms through this WASM build on a desktop.
+// Memory is the lever, not time: each guess must hold 128 MB, so a 24 GB card
+// fits ~187 in parallel and its bandwidth caps it near 2k guesses/s, against no
+// ceiling at all for PBKDF2. 256 MB would double that again at ~1.3 s, which is
+// too much to ask of a phone for something paid at every sign-in.
+const ARGON2_MEM_KIB = 131072; // 128 MB
+const ARGON2_TIME = 3;
+const ARGON2_LANES = 1;
+
+// Bundles written before this carry no marker and are read with the old KDF.
+// They are re-encrypted the first time their owner signs in (see upgradeBundle).
+const BUNDLE_V2_MAGIC = 'MBK2';
+
+function _argon2() {
+ const a = (typeof window !== 'undefined' && window.argon2) || globalThis.argon2;
+ if (!a) throw new Error('Argon2 unavailable — vendor/argon2.min.js did not load');
+ return a;
+}
+
+/** Legacy: PBKDF2-SHA512. Kept to read bundles written before the change. */
+async function deriveEncryptionKeyV1(password, username) {
const enc = new TextEncoder();
const km = await crypto.subtle.importKey(
'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey']);
@@ -86,6 +110,27 @@ async function deriveEncryptionKey(password, username) {
);
}
+/**
+ * Derive the bundle key with Argon2id.
+ *
+ * The salt stays deterministic and domain-separated per user, as before: it is
+ * what lets the key be derived once at sign-in and kept, instead of holding the
+ * passphrase in memory to re-derive it whenever a bundle turns up. It is unique
+ * per account, so it does what a salt is for — no shared precomputation.
+ */
+async function deriveEncryptionKey(password, username) {
+ const enc = new TextEncoder();
+ const salt = new Uint8Array(await crypto.subtle.digest(
+ 'SHA-256', enc.encode(`meshbay:bundle:v2:${username}`))).slice(0, 16);
+ const out = await _argon2().hash({
+ pass: password, salt,
+ time: ARGON2_TIME, mem: ARGON2_MEM_KIB, parallelism: ARGON2_LANES,
+ hashLen: 32, type: _argon2().ArgonType.Argon2id,
+ });
+ return crypto.subtle.importKey(
+ 'raw', out.hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
+}
+
// ── Bundle encryption ─────────────────────────────────────────────────────────
/**
@@ -94,29 +139,42 @@ async function deriveEncryptionKey(password, username) {
*/
async function encryptBundle(skEdRaw, skXRaw, password, username) {
const aesKey = await deriveEncryptionKey(password, username);
+ return encryptBundleWithKey(skEdRaw, skXRaw, aesKey);
+}
+
+/** Same, when the key was already derived at sign-in. Always writes v2. */
+async function encryptBundleWithKey(skEdRaw, skXRaw, aesKey) {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const data = new TextEncoder().encode(JSON.stringify({
skEd: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))),
skX: btoa(String.fromCharCode(...new Uint8Array(skXRaw))),
}));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, aesKey, data);
- // Return base64(nonce || ciphertext)
- const out = new Uint8Array(nonce.length + ct.byteLength);
- out.set(nonce);
- out.set(new Uint8Array(ct), nonce.length);
+ // base64( "MBK2" || nonce || ciphertext ). The marker is what tells a reader
+ // which KDF produced the key, so old bundles stay readable and new ones are
+ // never fed to the old derivation.
+ const magic = new TextEncoder().encode(BUNDLE_V2_MAGIC);
+ const out = new Uint8Array(magic.length + nonce.length + ct.byteLength);
+ out.set(magic);
+ out.set(nonce, magic.length);
+ out.set(new Uint8Array(ct), magic.length + nonce.length);
return btoa(String.fromCharCode(...out));
}
+function bundleVersion(bundleB64) {
+ try {
+ return atob(bundleB64).startsWith(BUNDLE_V2_MAGIC) ? 2 : 1;
+ } catch { return 1; }
+}
+
/**
* Decrypt a keypair bundle. Throws if password is wrong.
*/
async function decryptBundle(bundleB64, password, username) {
- const aesKey = await deriveEncryptionKey(password, username);
- const raw = Uint8Array.from(atob(bundleB64), c => c.charCodeAt(0));
- const nonce = raw.slice(0, 12);
- const ct = raw.slice(12);
- const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ct);
- return JSON.parse(new TextDecoder().decode(plain));
+ const key = bundleVersion(bundleB64) === 2
+ ? await deriveEncryptionKey(password, username)
+ : await deriveEncryptionKeyV1(password, username);
+ return decryptBundleWithKey(bundleB64, key);
}
// ── Registration ──────────────────────────────────────────────────────────────
@@ -131,45 +189,62 @@ async function decryptBundle(bundleB64, password, username) {
* Returns the raw private keys for immediate use after registration.
*/
async function registerUser(username, email, password) {
- const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
-
- const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']);
- const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []);
- const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto));
- const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto));
-
- const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username);
+ // No keypair here any more. Identity keys are per node: one is generated the
+ // first time this account joins a given node, encrypted under the passphrase,
+ // and left with that node. So an operator who cracks what sits on their own
+ // disk holds a key that is worthless anywhere else — and on their own node,
+ // one that unlocks nothing they did not already have.
+ //
+ // It also means the hub stores no user key to publish, which is what H3 read.
const authKey = await deriveAuthKey(password, username);
const resp = await fetch(`${HUB}/v1/users/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- username,
- email,
- auth_key: authKey,
- pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)),
- pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)),
- }),
+ body: JSON.stringify({ username, email, auth_key: authKey }),
});
if (!resp.ok) throw new Error(`Registration failed: ${await resp.text()}`);
+ return { registered: true };
+}
- // Store encrypted bundle locally — will be backed up to node on first group connect
- try { localStorage.setItem(`meshbay_kp_${username}`, encBundle); } catch {}
-
- return { skEdRaw, skXRaw, pkEdBytes, pkXBytes, keypairBundleEnc: encBundle };
+/**
+ * A fresh identity for one node, encrypted under the passphrase-derived key.
+ *
+ * Returns { skEdB64, skXB64, pkXB64, bundleEnc } — the bundle goes to that node
+ * and nowhere else, and is what any other browser fetches to become the same
+ * person there.
+ */
+async function generateNodeIdentity(bundleKey) {
+ const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
+ const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
+ const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, { name: 'X25519' }, true, []);
+ const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto));
+ return {
+ skEdB64: b64(skEdRaw),
+ skXB64: b64(skXRaw),
+ pkXB64: b64(pkXBytes),
+ bundleEnc: await encryptBundleWithKey(skEdRaw, skXRaw, bundleKey.v2 || bundleKey),
+ };
}
/**
* Decrypt a keypair bundle using a pre-derived AES-256 CryptoKey.
* Used when the bundle is fetched from the node (bundleKey was derived at login).
*/
-async function decryptBundleWithKey(bundleB64, aesKey) {
+async function decryptBundleWithKey(bundleB64, aesKeyOrPair) {
+ const v2 = bundleVersion(bundleB64) === 2;
+ // Callers derive both keys at sign-in and pass the pair, because which one a
+ // bundle needs is only known once it has been read — and the passphrase is
+ // deliberately not kept around to derive the other one later.
+ const key = (aesKeyOrPair && aesKeyOrPair.v2)
+ ? (v2 ? aesKeyOrPair.v2 : aesKeyOrPair.v1)
+ : aesKeyOrPair;
const raw = Uint8Array.from(atob(bundleB64), c => c.charCodeAt(0));
- const nonce = raw.slice(0, 12);
- const ct = raw.slice(12);
- const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, aesKey, ct);
+ const off = v2 ? BUNDLE_V2_MAGIC.length : 0;
+ const nonce = raw.slice(off, off + 12);
+ const ct = raw.slice(off + 12);
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce }, key, ct);
return JSON.parse(new TextDecoder().decode(plain));
}
@@ -195,67 +270,34 @@ async function loginAndRecover(username, password) {
const result = {
accessToken: data.access_token,
refreshToken: data.refresh_token,
- bundleKey: await deriveEncryptionKey(password, username),
+ // Both, so a bundle written before the KDF changed can still be opened —
+ // and re-written with the new one on the next backup.
+ bundleKey: {
+ v2: await deriveEncryptionKey(password, username),
+ v1: await deriveEncryptionKeyV1(password, username),
+ },
};
- // localStorage bundle = new registration, not yet pushed to node
- const bundleEnc = (typeof localStorage !== 'undefined'
- && localStorage.getItem(`meshbay_kp_${username}`)) || null;
-
- if (bundleEnc) {
- const keys = await decryptBundle(bundleEnc, password, username);
- result.skEdB64 = keys.skEd;
- result.skXB64 = keys.skX;
- result.keypairBundleEnc = bundleEnc;
- }
-
+ // Nothing else to recover at sign-in. Identity keys belong to a node, so they
+ // are fetched from the node being connected to (or generated there on a first
+ // join) — see transport.js. All that is needed here is the key that opens them.
return result;
}
-async function regenerateKeys(token, username, password) {
- const { skEdRaw, pkEdRaw, skXRaw, pkXRaw } = await generateKeypairs();
-
- const pkEdCrypto = await crypto.subtle.importKey('spki', pkEdRaw, 'Ed25519', true, ['verify']);
- const pkXCrypto = await crypto.subtle.importKey('spki', pkXRaw, 'X25519', true, []);
- const pkEdBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkEdCrypto));
- const pkXBytes = new Uint8Array(await crypto.subtle.exportKey('raw', pkXCrypto));
-
- const resp = await fetch(`${HUB}/v1/users/me/keys`, {
- method: 'PUT',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`,
- },
- body: JSON.stringify({
- pk_user_ed25519: btoa(String.fromCharCode(...pkEdBytes)),
- pk_user_x25519: btoa(String.fromCharCode(...pkXBytes)),
- }),
- });
-
- if (!resp.ok) throw new Error(`Key rotation failed: ${await resp.text()}`);
-
- const encBundle = await encryptBundle(skEdRaw, skXRaw, password, username);
- try { localStorage.setItem(`meshbay_kp_${username}`, encBundle); } catch {}
-
- return {
- skEdB64: btoa(String.fromCharCode(...new Uint8Array(skEdRaw))),
- skXB64: btoa(String.fromCharCode(...new Uint8Array(skXRaw))),
- pkEdB64: btoa(String.fromCharCode(...pkEdBytes)),
- pkXB64: btoa(String.fromCharCode(...pkXBytes)),
- keypairBundleEnc: encBundle,
- };
-}
+// regenerateKeys() removed. Rotating an identity is now per node: the operator
+// runs `meshbay-node member unpin <user>` and issues a fresh code. A hub call
+// that silently changed what every node believed about someone was the wrong
+// shape for this.
-async function signChallenge(skEdPkcs8B64, challengeB64) {
+async function signBytes(skEdPkcs8B64, message) {
const skRaw = Uint8Array.from(atob(skEdPkcs8B64), c => c.charCodeAt(0));
const sk = await crypto.subtle.importKey(
'pkcs8', skRaw, { name: 'Ed25519' }, false, ['sign']);
- const challenge = Uint8Array.from(atob(challengeB64), c => c.charCodeAt(0));
- const sig = await crypto.subtle.sign('Ed25519', sk, challenge);
+ const sig = await crypto.subtle.sign('Ed25519', sk, message);
return btoa(String.fromCharCode(...new Uint8Array(sig)));
}
window.MeshBayKeys = {
- registerUser, loginAndRecover, regenerateKeys, generateKeypairs, signChallenge,
- deriveAuthKey, decryptBundleWithKey,
+ registerUser, loginAndRecover, generateNodeIdentity, generateKeypairs, signBytes,
+ deriveAuthKey, decryptBundleWithKey, encryptBundleWithKey, bundleVersion,
};
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index e430201..81c4130 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -316,6 +316,21 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
font-size: 0.85em;
}
+.success-msg {
+ background: #16a34a20;
+ color: var(--success);
+ border: 1px solid var(--success);
+ border-radius: 6px;
+ padding: 8px 12px;
+ font-size: 0.85em;
+}
+
+.settings-hint {
+ font-size: 0.85em;
+ color: var(--text-dim);
+ margin-bottom: 8px;
+}
+
/* ── Group cards (9.7 prep) ───────────────────────────────────────────────── */
.group-grid {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index ca9c60e..0a8796e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -26,6 +26,31 @@ async function _pkFromSk(skPkcs8B64) {
return pad ? b64 + '='.repeat(4 - pad) : b64;
}
+async function _pkEdFromSk(skPkcs8B64) {
+ const raw = Uint8Array.from(atob(skPkcs8B64), c => c.charCodeAt(0));
+ const sk = await crypto.subtle.importKey('pkcs8', raw, { name: 'Ed25519' }, true, ['sign']);
+ const jwk = await crypto.subtle.exportKey('jwk', sk);
+ const b64 = jwk.x.replace(/-/g, '+').replace(/_/g, '/');
+ const pad = b64.length % 4;
+ return pad ? b64 + '='.repeat(4 - pad) : b64;
+}
+
+const JOIN_REFUSALS = {
+ code_required: 'This node does not know this browser yet. Ask the node operator '
+ + 'for a pairing code (meshbay-node operator pair).',
+ code_invalid: 'That pairing code is not valid — it may be mistyped, expired, '
+ + 'already used, or issued for a different account.',
+ key_changed: 'This account is already paired with a different key on this node. '
+ + 'If you reset your keys, the operator must unpin you before pairing again.',
+ not_authorized_for_group: 'The node does not list you as a member of this group. '
+ + 'Being a member on the hub is not enough — ask the operator for an invite.',
+ no_gek: 'This group has no key yet. The node operator must run '
+ + '`meshbay-node gek-init` for it.',
+ signature_invalid: 'The node rejected the signature over your keys.',
+ stale_request: 'Your clock is too far from the node\'s — check the system time.',
+ group_mismatch: 'The node refused a request naming a different group.',
+};
+
class MeshBayTransport {
constructor(hubUrl, accessToken) {
this._hubUrl = hubUrl;
@@ -53,11 +78,19 @@ class MeshBayTransport {
get sessionKeys() { return this._sessionKeys; }
- async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username) {
+ /** Set on a first join: the identity created for this node, still to be left with it. */
+ get newNodeBundle() { return this._newNodeBundle || null; }
+ set newNodeBundle(v) { this._newNodeBundle = v; }
+
+ async connect(nodeId, jwtToken, groupId, gekRaw, sessionKeys, bundleKey, username,
+ userId, joinCode) {
this._gekRaw = gekRaw || null;
this._sessionKeys = sessionKeys || null;
this._bundleKey = bundleKey || null;
this._username = username || null;
+ this._userId = userId || null;
+ this._newNodeBundle = null;
+ this._joinError = null;
this._pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});
@@ -129,11 +162,16 @@ class MeshBayTransport {
await channelReady;
+ // The client nonce is what makes the NODE's proof fresh (C3) — without it a
+ // recorded handshake_ack could be replayed by an impersonating peer.
+ this._nonceClient = crypto.getRandomValues(new Uint8Array(32));
+
const reply = await this._sendAndWait({
type: 'handshake',
v: '0.1',
token: jwtToken,
group_id: groupId || '',
+ nonce: window.MeshBayCrypto.b64encode(this._nonceClient),
});
if (reply.type === 'handshake_challenge') {
@@ -141,7 +179,23 @@ class MeshBayTransport {
throw new Error('Node requires GEK proof but no crypto available');
}
- // Recover session keys from node if not available locally (P2P keypair bundle)
+ // Recorded the moment the challenge arrives, because everything below may
+ // need them — joining, in particular, happens before the proof and signs a
+ // transcript over both. Reading them further down, next to the proof that
+ // also uses them, meant join_request ran with neither.
+ //
+ // nonce_node ties a join to this connection, so one cannot be lifted onto
+ // another. node_pk is announced here because a first-time member has no
+ // GEK and so cannot complete the handshake that would prove it; it is
+ // unverified at this point and checked against the ack below.
+ this._nonceNode = window.MeshBayCrypto.b64decode(reply.nonce);
+ this.nodePk = reply.node_pk || null;
+
+ // Our identity for THIS node: fetched from it, or created if this is a
+ // first join. Keys are per node, so there is nothing to carry between
+ // them — and an operator who cracks the copy on their own disk gets a key
+ // that opens nothing anywhere else.
+ let fresh = false;
if (!this._sessionKeys && this._bundleKey && window.MeshBayKeys) {
const kpResp = await this._sendAndWait({
type: 'keypair_bundle_fetch', v: '0.1',
@@ -151,11 +205,22 @@ class MeshBayTransport {
kpResp.bundle_enc, this._bundleKey);
const pkXB64 = await _pkFromSk(keys.skX);
this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 };
+ } else {
+ // This node has never seen us. Generate the identity we will use here
+ // and nowhere else; it is stored on this node once the join succeeds,
+ // which is what lets another browser become the same person here.
+ const id = await window.MeshBayKeys.generateNodeIdentity(this._bundleKey);
+ this._sessionKeys = {
+ skEdB64: id.skEdB64, skXB64: id.skXB64, pkXB64: id.pkXB64,
+ };
+ this._newNodeBundle = id.bundleEnc;
+ fresh = true;
}
}
- // Fetch wrapped GEK bundle from node (P2P only — hub never touches crypto)
- if (!gekRaw && this._sessionKeys) {
+ // An identity this node already knows still needs its group key, which the
+ // node wraps on every connection.
+ if (!gekRaw && this._sessionKeys && !fresh) {
const bundleResp = await this._sendAndWait({
type: 'gek_bundle_fetch', v: '0.1',
});
@@ -166,52 +231,159 @@ class MeshBayTransport {
gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw, myPkX);
this._gekRaw = gekRaw;
} catch (e) {
- console.warn('[MeshBay] GEK unwrap failed with local keys, trying node keypair bundle');
- if (this._bundleKey && window.MeshBayKeys) {
- const kpResp = await this._sendAndWait({
- type: 'keypair_bundle_fetch', v: '0.1',
- });
- if (kpResp.type === 'keypair_bundle_resp' && kpResp.found) {
- const keys = await window.MeshBayKeys.decryptBundleWithKey(
- kpResp.bundle_enc, this._bundleKey);
- const pkXB64 = await _pkFromSk(keys.skX);
- this._sessionKeys = { skXB64: keys.skX, skEdB64: keys.skEd, pkXB64 };
- const skXRaw2 = Uint8Array.from(atob(keys.skX), c => c.charCodeAt(0));
- const myPkX2 = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0));
- gekRaw = await window.MeshBayCrypto.unwrapGEK(bundleResp, skXRaw2, myPkX2);
- this._gekRaw = gekRaw;
- }
- }
+ console.warn('[MeshBay] stored GEK bundle did not open; joining instead');
}
}
}
- if (!gekRaw) {
- throw new Error('Node requires GEK proof but no GEK available');
+ // No stored bundle: ask the node to recognise us and wrap the key itself.
+ // This is the normal path for anyone who joined after the invite redesign —
+ // no bundle is pre-stored for members any more. A code is needed only the
+ // first time this node sees this account.
+ if (!gekRaw && this._sessionKeys && userId) {
+ try {
+ gekRaw = await this.joinGroup(userId, groupId, joinCode);
+ } catch (e) {
+ // The UI turns this into "ask the operator for an invite code".
+ this._joinError = e;
+ }
}
- let proof = '';
- if (gekRaw) {
- const offerFp = _extractDtlsFingerprint(this._pc.localDescription.sdp);
- const answerFp = _extractDtlsFingerprint(this._rawAnswerSdp);
- proof = await window.MeshBayCrypto.hmacGEK(gekRaw, reply.nonce, offerFp, answerFp);
+ if (!gekRaw && !this._sessionKeys) {
+ // No identity keys in this browser and none recoverable from the node:
+ // the keypair bundle is created where you register and only reaches a
+ // node after a first successful connection, so a brand-new member opening
+ // a second browser has nothing to sign or unwrap with. Say that, rather
+ // than blaming the GEK — a code prompt here would be useless, since a
+ // code proves who you are and we have no key to bind to.
+ const err = new Error(
+ 'This browser does not hold your keys. Open the group once from the '
+ + 'browser where you registered — after that this one can recover them.');
+ err.reason = 'no_keys';
+ throw err;
+ }
+
+ if (!gekRaw) {
+ throw this._joinError
+ || new Error('Node requires GEK proof but no GEK available');
}
+
+ const C = window.MeshBayCrypto;
+ // Node's answer SDP carries ITS fingerprint; our offer carries ours. Throws
+ // if either is missing rather than proceeding with an unbound proof (L4).
+ const binding = C.webrtcBinding(
+ _extractDtlsFingerprint(this._pc.localDescription.sdp),
+ _extractDtlsFingerprint(this._rawAnswerSdp),
+ );
+ const nonceNode = this._nonceNode; // captured when the challenge arrived
+ const gid = groupId || '';
+
+ const proof = await C.handshakeProof(
+ gekRaw, 'client', gid, this._nonceClient, nonceNode, binding);
+
const ack = await this._sendAndWait({
type: 'handshake_response',
v: '0.1',
- proof,
+ proof: C.b64encode(proof),
});
if (ack.type !== 'handshake_ack') {
throw new Error('GEK proof rejected: ' + (ack.detail || JSON.stringify(ack)));
}
+
+ // Authenticate the NODE before trusting anything it says (C3). Until this
+ // ran, node_pk was decorative: a peer that had hijacked signaling could
+ // accept our proof, ignore it, and serve a forged index, chat history and
+ // is_node_admin flag.
+ const expected = await C.handshakeProof(
+ gekRaw, 'node', gid, this._nonceClient, nonceNode, binding);
+ if (!ack.proof || !C.constantTimeEqual(C.b64decode(ack.proof), expected)) {
+ throw new Error('Node failed to prove GEK possession — refusing connection');
+ }
+ const transcript = C.handshakeTranscript(
+ 'node', gid, this._nonceClient, nonceNode, binding);
+ if (!ack.node_pk || !ack.sig
+ || !await C.verifyNodeSignature(ack.node_pk, ack.sig, transcript)) {
+ throw new Error('Node signature invalid — refusing connection');
+ }
+ // Trust On First Use (11.5.8). With C6 closed, a substituted node already
+ // fails the GEK proof — this covers the case where an attacker HAS the GEK
+ // (an ex-member, or a leaked key) and swaps the node underneath.
+ // Strict refusal: a warning users can click through is decorative.
+ // The key announced in the challenge must be the one that just proved
+ // itself. A peer that changed identity mid-handshake is not one to trust
+ // with anything, including a join we may already have signed for it.
+ if (this.nodePk && this.nodePk !== ack.node_pk) {
+ throw new Error('Node identity changed during the handshake — refusing');
+ }
+ _checkNodePin(nodeId, ack.node_pk);
+ this.nodePk = ack.node_pk;
+
return ack;
}
- if (reply.type !== 'handshake_ack') {
- throw new Error('MNP handshake rejected: ' + (reply.detail || JSON.stringify(reply)));
+ // A node that answers a handshake with anything other than a challenge is not
+ // running the mutual protocol. Accepting a bare handshake_ack here would let a
+ // peer skip proving GEK possession entirely (C3/C6).
+ const rejected = new Error(
+ 'MNP handshake rejected: ' + (reply.detail || `unexpected ${reply.type}`));
+ // `not_a_member` usually means our token predates being added to the group;
+ // the caller refreshes it and tries again rather than showing that to someone
+ // who was invited thirty seconds ago.
+ rejected.reason = reply.code || '';
+ throw rejected;
+ }
+
+ /**
+ * Pair this browser with the node using a one-time code (M3, and the same
+ * substitution as H3).
+ *
+ * The node has no way to know which key belongs to its operator unless someone
+ * tells it locally — asking the hub would let the hub name itself node
+ * administrator. The code comes from `meshbay-node operator pair`, over SSH, and
+ * the hub never sees it.
+ */
+ async pairOperator(userId, code) {
+ if (!this._connected) throw new Error('Not connected to the node');
+ if (!userId) throw new Error('Missing user id');
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
}
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+
+ const C = window.MeshBayCrypto;
+ // Both public keys are derived from OUR OWN secret keys, never read back from
+ // the hub: signing a public key the directory handed us would reintroduce the
+ // substitution this whole mechanism exists to close.
+ const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
+ const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
+ const ts = Math.floor(Date.now() / 1000);
+
+ // group_id is empty: operator authority is node-wide, not per group.
+ const transcript = C.joinTranscript(
+ this.nodePk, '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);
- return reply;
+ const resp = await this._sendAndWait({
+ type: 'join_request',
+ v: '0.1',
+ group_id: '',
+ pk_ed25519: pkEdB64,
+ pk_x25519: pkXB64,
+ code: code || '',
+ ts,
+ sig,
+ });
+
+ if (resp.type === 'error') throw new Error(resp.detail || 'Pairing refused');
+ if (resp.type !== 'join_result' || !resp.ok) {
+ const reason = resp.reason || 'unknown';
+ const err = new Error(JOIN_REFUSALS[reason] || `Pairing refused: ${reason}`);
+ err.reason = reason;
+ throw err;
+ }
+ return resp;
}
async fetchIndex() {
@@ -266,6 +438,39 @@ class MeshBayTransport {
return msg;
}
+ /**
+ * Authorize a privileged node operation with the user's Ed25519 identity key.
+ *
+ * The client rebuilds the signed transcript from the challenge fields and refuses
+ * to sign unless the operation and subject match what the user actually asked for.
+ * Previously the node sent 32 opaque random bytes and the client signed them
+ * blind, which let any peer obtain a signature over content of its choosing
+ * (finding H5).
+ */
+ async _authorizeAdminOp(challenge, expectedOp, expectedSubject, signFn) {
+ if (challenge.op !== expectedOp || challenge.subject !== expectedSubject) {
+ throw new Error(
+ `Refusing to sign: node asked to authorize "${challenge.op}" on ` +
+ `"${challenge.subject}", but the requested action was "${expectedOp}" ` +
+ `on "${expectedSubject}"`);
+ }
+ if (!signFn) throw new Error('Admin challenge received but no signing key available');
+
+ const transcript = window.MeshBayCrypto.adminTranscript(
+ challenge.op, challenge.node_pk, challenge.group_id,
+ challenge.subject, challenge.nonce, challenge.ts);
+
+ const signature = await signFn(transcript);
+ const ack = await this._sendAndWait({
+ type: 'admin_response',
+ v: '0.1',
+ op_id: challenge.op_id,
+ signature,
+ });
+ if (ack.type === 'error') throw new Error(ack.detail);
+ return ack;
+ }
+
async deleteFile(fileId, signFn) {
const msg = await this._sendAndWait({
type: 'file_delete',
@@ -274,16 +479,7 @@ class MeshBayTransport {
});
if (msg.type === 'error') throw new Error(msg.detail);
if (msg.type === 'admin_challenge') {
- if (!signFn) throw new Error('Admin challenge received but no signing key available');
- const signature = await signFn(msg.challenge);
- const ack = await this._sendAndWait({
- type: 'admin_response',
- v: '0.1',
- file_id: fileId,
- signature,
- });
- if (ack.type === 'error') throw new Error(ack.detail);
- return ack;
+ return this._authorizeAdminOp(msg, 'file_delete', fileId, signFn);
}
return msg;
}
@@ -304,15 +500,95 @@ class MeshBayTransport {
return msg;
}
- async storeGekBundle(userId, groupId, bundle) {
+ /**
+ * Ask the node for a one-time pairing code admitting `userId` to this group.
+ *
+ * This replaces wrapping the group key in the browser. We no longer fetch the
+ * invitee's public key from the hub, so the hub can no longer answer with its own
+ * and be handed the group key (H3). The node wraps the key later, itself, for a
+ * key the invitee proves possession of.
+ *
+ * Returns {code, expires_at} — the code is displayed once and passed to the
+ * invitee out of band.
+ */
+ async createInvite(userId, groupId, username, signFn) {
const msg = await this._sendAndWait({
- type: 'gek_bundle_store',
+ type: 'invite_create',
v: '0.1',
user_id: userId,
group_id: groupId,
- pk_eph_b64: bundle.pk_eph_b64,
- nonce_b64: bundle.nonce_b64,
- wrapped_b64: bundle.wrapped_b64,
+ username: username || '',
+ });
+ if (msg.type === 'error') throw new Error(msg.detail);
+ if (msg.type === 'admin_challenge') {
+ return this._authorizeAdminOp(msg, 'invite_create', userId, signFn);
+ }
+ return msg;
+ }
+
+ /**
+ * Ask the node to recognise us and hand over the group key.
+ *
+ * Sent when we hold no GEK for a group. `code` is needed only the first time
+ * this node sees this account (and not at all in an open-join group).
+ */
+ async joinGroup(userId, groupId, code) {
+ if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
+ throw new Error('Identity keys unavailable in this browser — sign in again');
+ }
+ if (!this._nonceNode || !this.nodePk) {
+ throw new Error('Handshake incomplete — reconnect and retry');
+ }
+
+ const C = window.MeshBayCrypto;
+ const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
+ const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
+ const ts = Math.floor(Date.now() / 1000);
+
+ const transcript = C.joinTranscript(
+ this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
+ const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);
+
+ const resp = await this._sendAndWait({
+ type: 'join_request',
+ v: '0.1',
+ group_id: groupId || '',
+ pk_ed25519: pkEdB64,
+ pk_x25519: pkXB64,
+ code: code || '',
+ ts,
+ sig,
+ });
+
+ if (resp.type === 'error') throw new Error(resp.detail || 'Join refused');
+ if ((resp.type !== 'join_result' || !resp.ok) || !resp.gek) {
+ const reason = resp.reason || 'unknown';
+ const err = new Error(JOIN_REFUSALS[reason] || `Join refused: ${reason}`);
+ // The UI reacts to `code_required` by asking for one; everything else is
+ // shown as-is.
+ err.reason = reason;
+ throw err;
+ }
+
+ // Unwrap with our own secret key — the node wrapped for the public key we
+ // just proved we hold, so nobody else can open this.
+ const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0));
+ const myPkX = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0));
+ const gekRaw = await C.unwrapGEK(resp, skXRaw, myPkX);
+ this._gekRaw = gekRaw;
+ return gekRaw;
+ }
+
+ /**
+ * Withdraw our key backup from this node.
+ *
+ * The counterpart of storeKeypairBundle: turning the setting off has to remove
+ * what is already stored, not merely stop adding to it — otherwise the blob
+ * stays on every node the account has ever joined (C4).
+ */
+ async deleteKeypairBundle() {
+ const msg = await this._sendAndWait({
+ type: 'keypair_bundle_delete', v: '0.1',
});
if (msg.type === 'error') throw new Error(msg.detail);
return msg;
@@ -627,5 +903,48 @@ function _extractDtlsFingerprint(sdp) {
return bytes;
}
+// ── Node identity pinning (11.5.8) ───────────────────────────────────────────
+
+const NODE_PIN_PREFIX = 'mb_nodepin_';
+
+function _checkNodePin(nodeId, nodePk) {
+ if (!nodeId || !nodePk) return;
+ const key = NODE_PIN_PREFIX + nodeId;
+
+ let pinned = null;
+ try { pinned = localStorage.getItem(key); } catch { return; }
+
+ if (pinned === null) {
+ try { localStorage.setItem(key, nodePk); } catch {}
+ return;
+ }
+ if (pinned !== nodePk) {
+ throw new Error(
+ 'This node\'s identity key has changed. That is expected only if its ' +
+ 'operator reinstalled the node — otherwise someone may be impersonating ' +
+ 'it. Verify with the operator out of band, then clear the pin in ' +
+ 'Settings to accept the new key.');
+ }
+}
+
+/** Forget a pinned node identity — the deliberate escape hatch for a legitimate rotation. */
+function clearNodePin(nodeId) {
+ try {
+ if (nodeId) localStorage.removeItem(NODE_PIN_PREFIX + nodeId);
+ else {
+ for (const k of Object.keys(localStorage))
+ if (k.startsWith(NODE_PIN_PREFIX)) localStorage.removeItem(k);
+ }
+ } catch {}
+}
+
+function pinnedNodeCount() {
+ try {
+ return Object.keys(localStorage).filter(k => k.startsWith(NODE_PIN_PREFIX)).length;
+ } catch { return 0; }
+}
+
// Export
+MeshBayTransport.clearNodePin = clearNodePin;
+MeshBayTransport.pinnedNodeCount = pinnedNodeCount;
window.MeshBayTransport = MeshBayTransport;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md b/packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md
new file mode 100644
index 0000000..6935e91
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/vendor/PROVENANCE.md
@@ -0,0 +1,36 @@
+# Vendored third-party assets
+
+The SPA is served under a CSP that forbids every external host, so anything it
+uses has to live here. Each entry records exactly what was taken and from where,
+so it can be checked or rebuilt without guesswork.
+
+## argon2.min.js
+
+| | |
+|---|---|
+| Package | `argon2-browser` 1.18.0 (npm) |
+| Source | https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz |
+| Tarball sha256 | `cdb11795a4971bde095fe6b836aa424de50c4558ed4b9505bc74111eee7f6d35` |
+| Tarball sha1 (npm dist.shasum) | `f35820211e0a431aed7f82b9348477234be69bec` |
+| File taken | `package/dist/argon2-bundled.min.js` |
+| File sha256 | `77c64b946baf1a5116dc591f4b9965d636b1b455f75edd2d4a587cb75e01687b` |
+
+The bundled build carries the WebAssembly inline as base64, so there is no second
+request and nothing to locate at runtime.
+
+**Why it is here at all:** WebCrypto has no memory-hard KDF. The encrypted
+keypair bundle is protected by the passphrase alone and rests on every node whose
+group its owner joins (finding C4), so PBKDF2 — compute-only, and therefore cheap
+on a GPU — was the wrong tool for it. Measured through this build on the dev
+machine: Argon2id 128 MB / t=3 / p=1 takes ~640 ms, against ~240 ms for
+PBKDF2-SHA512 at 600k, for a memory cost a GPU cannot ignore.
+
+### argon2.wasm
+
+The same build's standalone WebAssembly, sha256
+`0c2149886c13e4eae4a6ca25ee71d47423c5c8740a874cf04ff816d1b2c901d7`.
+
+The browser never requests it — `argon2.min.js` carries the same bytes inline as
+a data URL. It is kept because the cross-language parity test drives the vendored
+library under node, where the emscripten loader takes its file path instead of the
+inline copy, and a test that cannot run is a test that stops being true.
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.min.js b/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.min.js
new file mode 100644
index 0000000..607e16f
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.min.js
@@ -0,0 +1 @@
+!function(A,I){"object"==typeof exports&&"object"==typeof module?module.exports=I():"function"==typeof define&&define.amd?define([],I):"object"==typeof exports?exports.argon2=I():A.argon2=I()}(this,(function(){return(()=>{var A,I,g={773:(A,I,g)=>{var B,Q="undefined"!=typeof self&&void 0!==self.Module?self.Module:{},C={};for(B in Q)Q.hasOwnProperty(B)&&(C[B]=Q[B]);var E,i,o,D,e=[];E="object"==typeof window,i="function"==typeof importScripts,o="object"==typeof process&&"object"==typeof process.versions&&"string"==typeof process.versions.node,D=!E&&!o&&!i;var n,t,a,r,s,y="";o?(y=i?g(967).dirname(y)+"/":"//",n=function(A,I){return r||(r=g(145)),s||(s=g(967)),A=s.normalize(A),r.readFileSync(A,I?null:"utf8")},a=function(A){var I=n(A,!0);return I.buffer||(I=new Uint8Array(I)),G(I.buffer),I},process.argv.length>1&&process.argv[1].replace(/\\/g,"/"),e=process.argv.slice(2),A.exports=Q,process.on("uncaughtException",(function(A){if(!(A instanceof V))throw A})),process.on("unhandledRejection",u),Q.inspect=function(){return"[Emscripten Module object]"}):D?("undefined"!=typeof read&&(n=function(A){return read(A)}),a=function(A){var I;return"function"==typeof readbuffer?new Uint8Array(readbuffer(A)):(G("object"==typeof(I=read(A,"binary"))),I)},"undefined"!=typeof scriptArgs?e=scriptArgs:void 0!==arguments&&(e=arguments),"undefined"!=typeof print&&("undefined"==typeof console&&(console={}),console.log=print,console.warn=console.error="undefined"!=typeof printErr?printErr:print)):(E||i)&&(i?y=self.location.href:"undefined"!=typeof document&&document.currentScript&&(y=document.currentScript.src),y=0!==y.indexOf("blob:")?y.substr(0,y.lastIndexOf("/")+1):"",n=function(A){var I=new XMLHttpRequest;return I.open("GET",A,!1),I.send(null),I.responseText},i&&(a=function(A){var I=new XMLHttpRequest;return I.open("GET",A,!1),I.responseType="arraybuffer",I.send(null),new Uint8Array(I.response)}),t=function(A,I,g){var B=new XMLHttpRequest;B.open("GET",A,!0),B.responseType="arraybuffer",B.onload=function(){200==B.status||0==B.status&&B.response?I(B.response):g()},B.onerror=g,B.send(null)}),Q.print||console.log.bind(console);var F,c,w=Q.printErr||console.warn.bind(console);for(B in C)C.hasOwnProperty(B)&&(Q[B]=C[B]);C=null,Q.arguments&&(e=Q.arguments),Q.thisProgram&&Q.thisProgram,Q.quit&&Q.quit,Q.wasmBinary&&(F=Q.wasmBinary),Q.noExitRuntime,"object"!=typeof WebAssembly&&u("no native wasm support detected");var h=!1;function G(A,I){A||u("Assertion failed: "+I)}var N,R,f="undefined"!=typeof TextDecoder?new TextDecoder("utf8"):void 0;function U(A){N=A,Q.HEAP8=new Int8Array(A),Q.HEAP16=new Int16Array(A),Q.HEAP32=new Int32Array(A),Q.HEAPU8=R=new Uint8Array(A),Q.HEAPU16=new Uint16Array(A),Q.HEAPU32=new Uint32Array(A),Q.HEAPF32=new Float32Array(A),Q.HEAPF64=new Float64Array(A)}Q.INITIAL_MEMORY;var M,Y=[],S=[],H=[],d=0,k=null,J=null;function u(A){throw Q.onAbort&&Q.onAbort(A),w(A+=""),h=!0,A="abort("+A+"). Build with -s ASSERTIONS=1 for more info.",new WebAssembly.RuntimeError(A)}function p(A){return A.startsWith("data:application/octet-stream;base64,")}function L(A){return A.startsWith("file://")}Q.preloadedImages={},Q.preloadedAudios={};var l,K="argon2.wasm";function q(A){try{if(A==K&&F)return new Uint8Array(F);if(a)return a(A);throw"both async and sync fetching of the wasm failed"}catch(A){u(A)}}function b(A){for(;A.length>0;){var I=A.shift();if("function"!=typeof I){var g=I.func;"number"==typeof g?void 0===I.arg?M.get(g)():M.get(g)(I.arg):g(void 0===I.arg?null:I.arg)}else I(Q)}}function x(A){try{return c.grow(A-N.byteLength+65535>>>16),U(c.buffer),1}catch(A){}}p(K)||(l=K,K=Q.locateFile?Q.locateFile(l,y):y+l);var m,X={a:function(A,I,g){R.copyWithin(A,I,I+g)},b:function(A){var I,g=R.length,B=2147418112;if((A>>>=0)>B)return!1;for(var Q=1;Q<=4;Q*=2){var C=g*(1+.2/Q);if(C=Math.min(C,A+100663296),x(Math.min(B,((I=Math.max(A,C))%65536>0&&(I+=65536-I%65536),I))))return!0}return!1}},W=(function(){var A={a:X};function I(A,I){var g,B=A.exports;Q.asm=B,U((c=Q.asm.c).buffer),M=Q.asm.k,g=Q.asm.d,S.unshift(g),function(A){if(d--,Q.monitorRunDependencies&&Q.monitorRunDependencies(d),0==d&&(null!==k&&(clearInterval(k),k=null),J)){var I=J;J=null,I()}}()}function g(A){I(A.instance)}function B(I){return function(){if(!F&&(E||i)){if("function"==typeof fetch&&!L(K))return fetch(K,{credentials:"same-origin"}).then((function(A){if(!A.ok)throw"failed to load wasm binary file at '"+K+"'";return A.arrayBuffer()})).catch((function(){return q(K)}));if(t)return new Promise((function(A,I){t(K,(function(I){A(new Uint8Array(I))}),I)}))}return Promise.resolve().then((function(){return q(K)}))}().then((function(I){return WebAssembly.instantiate(I,A)})).then(I,(function(A){w("failed to asynchronously prepare wasm: "+A),u(A)}))}if(d++,Q.monitorRunDependencies&&Q.monitorRunDependencies(d),Q.instantiateWasm)try{return Q.instantiateWasm(A,I)}catch(A){return w("Module.instantiateWasm callback failed with error: "+A),!1}F||"function"!=typeof WebAssembly.instantiateStreaming||p(K)||L(K)||"function"!=typeof fetch?B(g):fetch(K,{credentials:"same-origin"}).then((function(I){return WebAssembly.instantiateStreaming(I,A).then(g,(function(A){return w("wasm streaming compile failed: "+A),w("falling back to ArrayBuffer instantiation"),B(g)}))}))}(),Q.___wasm_call_ctors=function(){return(Q.___wasm_call_ctors=Q.asm.d).apply(null,arguments)},Q._argon2_hash=function(){return(Q._argon2_hash=Q.asm.e).apply(null,arguments)},Q._malloc=function(){return(W=Q._malloc=Q.asm.f).apply(null,arguments)}),T=(Q._free=function(){return(Q._free=Q.asm.g).apply(null,arguments)},Q._argon2_verify=function(){return(Q._argon2_verify=Q.asm.h).apply(null,arguments)},Q._argon2_error_message=function(){return(Q._argon2_error_message=Q.asm.i).apply(null,arguments)},Q._argon2_encodedlen=function(){return(Q._argon2_encodedlen=Q.asm.j).apply(null,arguments)},Q._argon2_hash_ext=function(){return(Q._argon2_hash_ext=Q.asm.l).apply(null,arguments)},Q._argon2_verify_ext=function(){return(Q._argon2_verify_ext=Q.asm.m).apply(null,arguments)},Q.stackAlloc=function(){return(T=Q.stackAlloc=Q.asm.n).apply(null,arguments)});function V(A){this.name="ExitStatus",this.message="Program terminated with exit("+A+")",this.status=A}function j(A){function I(){m||(m=!0,Q.calledRun=!0,h||(b(S),Q.onRuntimeInitialized&&Q.onRuntimeInitialized(),function(){if(Q.postRun)for("function"==typeof Q.postRun&&(Q.postRun=[Q.postRun]);Q.postRun.length;)A=Q.postRun.shift(),H.unshift(A);var A;b(H)}()))}A=A||e,d>0||(function(){if(Q.preRun)for("function"==typeof Q.preRun&&(Q.preRun=[Q.preRun]);Q.preRun.length;)A=Q.preRun.shift(),Y.unshift(A);var A;b(Y)}(),d>0||(Q.setStatus?(Q.setStatus("Running..."),setTimeout((function(){setTimeout((function(){Q.setStatus("")}),1),I()}),1)):I()))}if(Q.allocate=function(A,I){var g;return g=1==I?T(A.length):W(A.length),A.subarray||A.slice?R.set(A,g):R.set(new Uint8Array(A),g),g},Q.UTF8ToString=function(A,I){return A?function(A,I,g){for(var B=I+g,Q=I;A[Q]&&!(Q>=B);)++Q;if(Q-I>16&&A.subarray&&f)return f.decode(A.subarray(I,Q));for(var C="";I<Q;){var E=A[I++];if(128&E){var i=63&A[I++];if(192!=(224&E)){var o=63&A[I++];if((E=224==(240&E)?(15&E)<<12|i<<6|o:(7&E)<<18|i<<12|o<<6|63&A[I++])<65536)C+=String.fromCharCode(E);else{var D=E-65536;C+=String.fromCharCode(55296|D>>10,56320|1023&D)}}else C+=String.fromCharCode((31&E)<<6|i)}else C+=String.fromCharCode(E)}return C}(R,A,I):""},Q.ALLOC_NORMAL=0,J=function A(){m||j(),m||(J=A)},Q.run=j,Q.preInit)for("function"==typeof Q.preInit&&(Q.preInit=[Q.preInit]);Q.preInit.length>0;)Q.preInit.pop()();j(),A.exports=Q,Q.unloadRuntime=function(){"undefined"!=typeof self&&delete self.Module,Q=c=M=N=R=void 0,delete A.exports}},631:function(A,I,g){var B,Q;"undefined"!=typeof self&&self,void 0===(Q="function"==typeof(B=function(){const A="undefined"!=typeof self?self:this,I={Argon2d:0,Argon2i:1,Argon2id:2};function B(I){if(B._promise)return B._promise;if(B._module)return Promise.resolve(B._module);let C;return C=A.process&&A.process.versions&&A.process.versions.node?Q().then((A=>new Promise((I=>{A.postRun=()=>I(A)})))):(A.loadArgon2WasmBinary?A.loadArgon2WasmBinary():Promise.resolve(g(721)).then((A=>function(A){const I=atob(A),g=new Uint8Array(new ArrayBuffer(I.length));for(let A=0;A<I.length;A++)g[A]=I.charCodeAt(A);return g}(A)))).then((g=>function(I,g){return new Promise((B=>(A.Module={wasmBinary:I,wasmMemory:g,postRun(){B(Module)}},Q())))}(g,I?function(A){const I=1024,g=64*I,B=(1024*I*1024*2-64*I)/g,Q=Math.min(Math.max(Math.ceil(A*I/g),256)+256,B);return new WebAssembly.Memory({initial:Q,maximum:B})}(I):void 0))),B._promise=C,C.then((A=>(B._module=A,delete B._promise,A)))}function Q(){return A.loadArgon2WasmModule?A.loadArgon2WasmModule():Promise.resolve(g(773))}function C(A,I){return A.allocate(I,"i8",A.ALLOC_NORMAL)}function E(A,I){return C(A,new Uint8Array([...I,0]))}function i(A){if("string"!=typeof A)return A;if("function"==typeof TextEncoder)return(new TextEncoder).encode(A);if("function"==typeof Buffer)return Buffer.from(A);throw new Error("Don't know how to encode UTF8")}return{ArgonType:I,hash:function(A){const g=A.mem||1024;return B(g).then((B=>{const Q=A.time||1,o=A.parallelism||1,D=i(A.pass),e=E(B,D),n=D.length,t=i(A.salt),a=E(B,t),r=t.length,s=A.type||I.Argon2d,y=B.allocate(new Array(A.hashLen||24),"i8",B.ALLOC_NORMAL),F=A.secret?C(B,A.secret):0,c=A.secret?A.secret.byteLength:0,w=A.ad?C(B,A.ad):0,h=A.ad?A.ad.byteLength:0,G=A.hashLen||24,N=B._argon2_encodedlen(Q,g,o,r,G,s),R=B.allocate(new Array(N+1),"i8",B.ALLOC_NORMAL);let f,U,M;try{U=B._argon2_hash_ext(Q,g,o,e,n,a,r,y,G,R,N,s,F,c,w,h,19)}catch(A){f=A}if(0!==U||f){try{f||(f=B.UTF8ToString(B._argon2_error_message(U)))}catch(A){}M={message:f,code:U}}else{let A="";const I=new Uint8Array(G);for(let g=0;g<G;g++){const Q=B.HEAP8[y+g];I[g]=Q,A+=("0"+(255&Q).toString(16)).slice(-2)}M={hash:I,hashHex:A,encoded:B.UTF8ToString(R)}}try{B._free(e),B._free(a),B._free(y),B._free(R),w&&B._free(w),F&&B._free(F)}catch(A){}if(f)throw M;return M}))},verify:function(A){return B().then((g=>{const B=i(A.pass),Q=E(g,B),o=B.length,D=A.secret?C(g,A.secret):0,e=A.secret?A.secret.byteLength:0,n=A.ad?C(g,A.ad):0,t=A.ad?A.ad.byteLength:0,a=E(g,i(A.encoded));let r,s,y,F=A.type;if(void 0===F){let g=A.encoded.split("$")[1];g&&(g=g.replace("a","A"),F=I[g]||I.Argon2d)}try{s=g._argon2_verify_ext(a,Q,o,D,e,n,t,F)}catch(A){r=A}if(s||r){try{r||(r=g.UTF8ToString(g._argon2_error_message(s)))}catch(A){}y={message:r,code:s}}try{g._free(Q),g._free(a)}catch(A){}if(r)throw y;return y}))},unloadRuntime:function(){B._module&&(B._module.unloadRuntime(),delete B._promise,delete B._module)}}})?B.apply(I,[]):B)||(A.exports=Q)},721:function(A,I){A.exports="AGFzbQEAAAABkwESYAN/f38Bf2ABfwF/YAJ/fwBgAn9/AX9gAX8AYAR/f39/AX9gA39/fwBgBH9/f38AYAJ/fgBgAn5/AX5gAn5+AX5gBX9/f39/AGAGf3x/f39/AX9gAABgCH9/f39/f39/AX9gEX9/f39/f39/f39/f39/f39/AX9gBn9/f39/fwF/YA1/f39/f39/f39/f39/AX8CDQIBYQFhAAABYQFiAAEDPDsJCgIAAAIEAQEAAQsGAQAHAAIBAwICAwIIBQECAwEHDQMBBgQGAQEFBQEAAAIEAAAIAQAODwQQAQURAwQFAXABAwMFBwEBgAL//wEGCQF/AUGQo8ACCwcxDAFjAgABZAAhAWUAOwFmAAkBZwAIAWgAOgFpADkBagA4AWsBAAFsADYBbQA1AW4AMwkIAQBBAQsCCzQKwbMBOwgAIAAgAa2KCx4AIAAgAXwgAEIBhkL+////H4MgAUL/////D4N+fAsXAEHwHCgCAEUgAEVyRQRAIAAgARAdCwuDBAEDfyACQYAETwRAIAAgASACEAAaIAAPCyAAIAJqIQMCQCAAIAFzQQNxRQRAAkAgAEEDcUUEQCAAIQIMAQsgAkEBSARAIAAhAgwBCyAAIQIDQCACIAEtAAA6AAAgAUEBaiEBIAJBAWoiAkEDcUUNASACIANJDQALCwJAIANBfHEiBEHAAEkNACACIARBQGoiBUsNAANAIAIgASgCADYCACACIAEoAgQ2AgQgAiABKAIINgIIIAIgASgCDDYCDCACIAEoAhA2AhAgAiABKAIUNgIUIAIgASgCGDYCGCACIAEoAhw2AhwgAiABKAIgNgIgIAIgASgCJDYCJCACIAEoAig2AiggAiABKAIsNgIsIAIgASgCMDYCMCACIAEoAjQ2AjQgAiABKAI4NgI4IAIgASgCPDYCPCABQUBrIQEgAkFAayICIAVNDQALCyACIARPDQEDQCACIAEoAgA2AgAgAUEEaiEBIAJBBGoiAiAESQ0ACwwBCyADQQRJBEAgACECDAELIAAgA0EEayIESwRAIAAhAgwBCyAAIQIDQCACIAEtAAA6AAAgAiABLQABOgABIAIgAS0AAjoAAiACIAEtAAM6AAMgAUEEaiEBIAJBBGoiAiAETQ0ACwsgAiADSQRAA0AgAiABLQAAOgAAIAFBAWohASACQQFqIgIgA0cNAAsLIAALzwEBA38CQCACRQ0AQX8hAyAARSABRXINACAAKQNQQgBSDQACQCAAKALgASIDIAJqQYEBSQ0AIABB4ABqIgUgA2ogAUGAASADayIEEAUaIABCgAEQGiAAIAUQGUEAIQMgAEEANgLgASABIARqIQEgAiAEayICQYEBSQ0AA0AgAEKAARAaIAAgARAZIAFBgAFqIQEgAkGAAWsiAkGAAUsNAAsgACgC4AEhAwsgACADakHgAGogASACEAUaIAAgACgC4AEgAmo2AuABQQAhAwsgAwsJACAAIAE2AAALpwwBB38CQCAARQ0AIABBCGsiAyAAQQRrKAIAIgFBeHEiAGohBQJAIAFBAXENACABQQNxRQ0BIAMgAygCACIBayIDQbAfKAIASQ0BIAAgAWohACADQbQfKAIARwRAIAFB/wFNBEAgAygCCCICIAFBA3YiBEEDdEHIH2pGGiACIAMoAgwiAUYEQEGgH0GgHygCAEF+IAR3cTYCAAwDCyACIAE2AgwgASACNgIIDAILIAMoAhghBgJAIAMgAygCDCIBRwRAIAMoAggiAiABNgIMIAEgAjYCCAwBCwJAIANBFGoiAigCACIEDQAgA0EQaiICKAIAIgQNAEEAIQEMAQsDQCACIQcgBCIBQRRqIgIoAgAiBA0AIAFBEGohAiABKAIQIgQNAAsgB0EANgIACyAGRQ0BAkAgAyADKAIcIgJBAnRB0CFqIgQoAgBGBEAgBCABNgIAIAENAUGkH0GkHygCAEF+IAJ3cTYCAAwDCyAGQRBBFCAGKAIQIANGG2ogATYCACABRQ0CCyABIAY2AhggAygCECICBEAgASACNgIQIAIgATYCGAsgAygCFCICRQ0BIAEgAjYCFCACIAE2AhgMAQsgBSgCBCIBQQNxQQNHDQBBqB8gADYCACAFIAFBfnE2AgQgAyAAQQFyNgIEIAAgA2ogADYCAA8LIAMgBU8NACAFKAIEIgFBAXFFDQACQCABQQJxRQRAIAVBuB8oAgBGBEBBuB8gAzYCAEGsH0GsHygCACAAaiIANgIAIAMgAEEBcjYCBCADQbQfKAIARw0DQagfQQA2AgBBtB9BADYCAA8LIAVBtB8oAgBGBEBBtB8gAzYCAEGoH0GoHygCACAAaiIANgIAIAMgAEEBcjYCBCAAIANqIAA2AgAPCyABQXhxIABqIQACQCABQf8BTQRAIAUoAggiAiABQQN2IgRBA3RByB9qRhogAiAFKAIMIgFGBEBBoB9BoB8oAgBBfiAEd3E2AgAMAgsgAiABNgIMIAEgAjYCCAwBCyAFKAIYIQYCQCAFIAUoAgwiAUcEQCAFKAIIIgJBsB8oAgBJGiACIAE2AgwgASACNgIIDAELAkAgBUEUaiICKAIAIgQNACAFQRBqIgIoAgAiBA0AQQAhAQwBCwNAIAIhByAEIgFBFGoiAigCACIEDQAgAUEQaiECIAEoAhAiBA0ACyAHQQA2AgALIAZFDQACQCAFIAUoAhwiAkECdEHQIWoiBCgCAEYEQCAEIAE2AgAgAQ0BQaQfQaQfKAIAQX4gAndxNgIADAILIAZBEEEUIAYoAhAgBUYbaiABNgIAIAFFDQELIAEgBjYCGCAFKAIQIgIEQCABIAI2AhAgAiABNgIYCyAFKAIUIgJFDQAgASACNgIUIAIgATYCGAsgAyAAQQFyNgIEIAAgA2ogADYCACADQbQfKAIARw0BQagfIAA2AgAPCyAFIAFBfnE2AgQgAyAAQQFyNgIEIAAgA2ogADYCAAsgAEH/AU0EQCAAQQN2IgFBA3RByB9qIQACf0GgHygCACICQQEgAXQiAXFFBEBBoB8gASACcjYCACAADAELIAAoAggLIQIgACADNgIIIAIgAzYCDCADIAA2AgwgAyACNgIIDwtBHyECIANCADcCECAAQf///wdNBEAgAEEIdiIBIAFBgP4/akEQdkEIcSIBdCICIAJBgOAfakEQdkEEcSICdCIEIARBgIAPakEQdkECcSIEdEEPdiABIAJyIARyayIBQQF0IAAgAUEVanZBAXFyQRxqIQILIAMgAjYCHCACQQJ0QdAhaiEBAkACQAJAQaQfKAIAIgRBASACdCIHcUUEQEGkHyAEIAdyNgIAIAEgAzYCACADIAE2AhgMAQsgAEEAQRkgAkEBdmsgAkEfRht0IQIgASgCACEBA0AgASIEKAIEQXhxIABGDQIgAkEddiEBIAJBAXQhAiAEIAFBBHFqIgdBEGooAgAiAQ0ACyAHIAM2AhAgAyAENgIYCyADIAM2AgwgAyADNgIIDAELIAQoAggiACADNgIMIAQgAzYCCCADQQA2AhggAyAENgIMIAMgADYCCAtBwB9BwB8oAgBBAWsiAEF/IAAbNgIACwuULQEMfyMAQRBrIgwkAAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAAQfQBTQRAQaAfKAIAIgVBECAAQQtqQXhxIABBC0kbIghBA3YiAnYiAUEDcQRAIAFBf3NBAXEgAmoiA0EDdCIBQdAfaigCACIEQQhqIQACQCAEKAIIIgIgAUHIH2oiAUYEQEGgHyAFQX4gA3dxNgIADAELIAIgATYCDCABIAI2AggLIAQgA0EDdCIBQQNyNgIEIAEgBGoiASABKAIEQQFyNgIEDA0LIAhBqB8oAgAiCk0NASABBEACQEECIAJ0IgBBACAAa3IgASACdHEiAEEAIABrcUEBayIAIABBDHZBEHEiAnYiAUEFdkEIcSIAIAJyIAEgAHYiAUECdkEEcSIAciABIAB2IgFBAXZBAnEiAHIgASAAdiIBQQF2QQFxIgByIAEgAHZqIgNBA3QiAEHQH2ooAgAiBCgCCCIBIABByB9qIgBGBEBBoB8gBUF+IAN3cSIFNgIADAELIAEgADYCDCAAIAE2AggLIARBCGohACAEIAhBA3I2AgQgBCAIaiICIANBA3QiASAIayIDQQFyNgIEIAEgBGogAzYCACAKBEAgCkEDdiIBQQN0QcgfaiEHQbQfKAIAIQQCfyAFQQEgAXQiAXFFBEBBoB8gASAFcjYCACAHDAELIAcoAggLIQEgByAENgIIIAEgBDYCDCAEIAc2AgwgBCABNgIIC0G0HyACNgIAQagfIAM2AgAMDQtBpB8oAgAiBkUNASAGQQAgBmtxQQFrIgAgAEEMdkEQcSICdiIBQQV2QQhxIgAgAnIgASAAdiIBQQJ2QQRxIgByIAEgAHYiAUEBdkECcSIAciABIAB2IgFBAXZBAXEiAHIgASAAdmpBAnRB0CFqKAIAIgEoAgRBeHEgCGshAyABIQIDQAJAIAIoAhAiAEUEQCACKAIUIgBFDQELIAAoAgRBeHEgCGsiAiADIAIgA0kiAhshAyAAIAEgAhshASAAIQIMAQsLIAEgCGoiCSABTQ0CIAEoAhghCyABIAEoAgwiBEcEQCABKAIIIgBBsB8oAgBJGiAAIAQ2AgwgBCAANgIIDAwLIAFBFGoiAigCACIARQRAIAEoAhAiAEUNBCABQRBqIQILA0AgAiEHIAAiBEEUaiICKAIAIgANACAEQRBqIQIgBCgCECIADQALIAdBADYCAAwLC0F/IQggAEG/f0sNACAAQQtqIgBBeHEhCEGkHygCACIJRQ0AQQAgCGshAwJAAkACQAJ/QQAgCEGAAkkNABpBHyAIQf///wdLDQAaIABBCHYiACAAQYD+P2pBEHZBCHEiAnQiACAAQYDgH2pBEHZBBHEiAXQiACAAQYCAD2pBEHZBAnEiAHRBD3YgASACciAAcmsiAEEBdCAIIABBFWp2QQFxckEcagsiBUECdEHQIWooAgAiAkUEQEEAIQAMAQtBACEAIAhBAEEZIAVBAXZrIAVBH0YbdCEBA0ACQCACKAIEQXhxIAhrIgcgA08NACACIQQgByIDDQBBACEDIAIhAAwDCyAAIAIoAhQiByAHIAIgAUEddkEEcWooAhAiAkYbIAAgBxshACABQQF0IQEgAg0ACwsgACAEckUEQEEAIQRBAiAFdCIAQQAgAGtyIAlxIgBFDQMgAEEAIABrcUEBayIAIABBDHZBEHEiAnYiAUEFdkEIcSIAIAJyIAEgAHYiAUECdkEEcSIAciABIAB2IgFBAXZBAnEiAHIgASAAdiIBQQF2QQFxIgByIAEgAHZqQQJ0QdAhaigCACEACyAARQ0BCwNAIAAoAgRBeHEgCGsiASADSSECIAEgAyACGyEDIAAgBCACGyEEIAAoAhAiAQR/IAEFIAAoAhQLIgANAAsLIARFDQAgA0GoHygCACAIa08NACAEIAhqIgYgBE0NASAEKAIYIQUgBCAEKAIMIgFHBEAgBCgCCCIAQbAfKAIASRogACABNgIMIAEgADYCCAwKCyAEQRRqIgIoAgAiAEUEQCAEKAIQIgBFDQQgBEEQaiECCwNAIAIhByAAIgFBFGoiAigCACIADQAgAUEQaiECIAEoAhAiAA0ACyAHQQA2AgAMCQsgCEGoHygCACICTQRAQbQfKAIAIQMCQCACIAhrIgFBEE8EQEGoHyABNgIAQbQfIAMgCGoiADYCACAAIAFBAXI2AgQgAiADaiABNgIAIAMgCEEDcjYCBAwBC0G0H0EANgIAQagfQQA2AgAgAyACQQNyNgIEIAIgA2oiACAAKAIEQQFyNgIECyADQQhqIQAMCwsgCEGsHygCACIGSQRAQawfIAYgCGsiATYCAEG4H0G4HygCACICIAhqIgA2AgAgACABQQFyNgIEIAIgCEEDcjYCBCACQQhqIQAMCwtBACEAIAhBL2oiCQJ/QfgiKAIABEBBgCMoAgAMAQtBhCNCfzcCAEH8IkKAoICAgIAENwIAQfgiIAxBDGpBcHFB2KrVqgVzNgIAQYwjQQA2AgBB3CJBADYCAEGAIAsiAWoiBUEAIAFrIgdxIgIgCE0NCkHYIigCACIEBEBB0CIoAgAiAyACaiIBIANNIAEgBEtyDQsLQdwiLQAAQQRxDQUCQAJAQbgfKAIAIgMEQEHgIiEAA0AgAyAAKAIAIgFPBEAgASAAKAIEaiADSw0DCyAAKAIIIgANAAsLQQAQDCIBQX9GDQYgAiEFQfwiKAIAIgNBAWsiACABcQRAIAIgAWsgACABakEAIANrcWohBQsgBSAITSAFQf7///8HS3INBkHYIigCACIEBEBB0CIoAgAiAyAFaiIAIANNIAAgBEtyDQcLIAUQDCIAIAFHDQEMCAsgBSAGayAHcSIFQf7///8HSw0FIAUQDCIBIAAoAgAgACgCBGpGDQQgASEACyAAQX9GIAhBMGogBU1yRQRAQYAjKAIAIgEgCSAFa2pBACABa3EiAUH+////B0sEQCAAIQEMCAsgARAMQX9HBEAgASAFaiEFIAAhAQwIC0EAIAVrEAwaDAULIAAiAUF/Rw0GDAQLAAtBACEEDAcLQQAhAQwFCyABQX9HDQILQdwiQdwiKAIAQQRyNgIACyACQf7///8HSw0BIAIQDCIBQX9GQQAQDCIAQX9GciAAIAFNcg0BIAAgAWsiBSAIQShqTQ0BC0HQIkHQIigCACAFaiIANgIAQdQiKAIAIABJBEBB1CIgADYCAAsCQAJAAkBBuB8oAgAiBwRAQeAiIQADQCABIAAoAgAiAyAAKAIEIgJqRg0CIAAoAggiAA0ACwwCC0GwHygCACIAQQAgACABTRtFBEBBsB8gATYCAAtBACEAQeQiIAU2AgBB4CIgATYCAEHAH0F/NgIAQcQfQfgiKAIANgIAQewiQQA2AgADQCAAQQN0IgNB0B9qIANByB9qIgI2AgAgA0HUH2ogAjYCACAAQQFqIgBBIEcNAAtBrB8gBUEoayIDQXggAWtBB3FBACABQQhqQQdxGyIAayICNgIAQbgfIAAgAWoiADYCACAAIAJBAXI2AgQgASADakEoNgIEQbwfQYgjKAIANgIADAILIAAtAAxBCHEgAyAHS3IgASAHTXINACAAIAIgBWo2AgRBuB8gB0F4IAdrQQdxQQAgB0EIakEHcRsiAGoiAjYCAEGsH0GsHygCACAFaiIBIABrIgA2AgAgAiAAQQFyNgIEIAEgB2pBKDYCBEG8H0GIIygCADYCAAwBC0GwHygCACABSwRAQbAfIAE2AgALIAEgBWohAkHgIiEAAkACQAJAAkACQAJAA0AgAiAAKAIARwRAIAAoAggiAA0BDAILCyAALQAMQQhxRQ0BC0HgIiEAA0AgByAAKAIAIgJPBEAgAiAAKAIEaiIEIAdLDQMLIAAoAgghAAwACwALIAAgATYCACAAIAAoAgQgBWo2AgQgAUF4IAFrQQdxQQAgAUEIakEHcRtqIgkgCEEDcjYCBCACQXggAmtBB3FBACACQQhqQQdxG2oiBSAIIAlqIgZrIQIgBSAHRgRAQbgfIAY2AgBBrB9BrB8oAgAgAmoiADYCACAGIABBAXI2AgQMAwsgBUG0HygCAEYEQEG0HyAGNgIAQagfQagfKAIAIAJqIgA2AgAgBiAAQQFyNgIEIAAgBmogADYCAAwDCyAFKAIEIgBBA3FBAUYEQCAAQXhxIQcCQCAAQf8BTQRAIAUoAggiAyAAQQN2IgBBA3RByB9qRhogAyAFKAIMIgFGBEBBoB9BoB8oAgBBfiAAd3E2AgAMAgsgAyABNgIMIAEgAzYCCAwBCyAFKAIYIQgCQCAFIAUoAgwiAUcEQCAFKAIIIgAgATYCDCABIAA2AggMAQsCQCAFQRRqIgAoAgAiAw0AIAVBEGoiACgCACIDDQBBACEBDAELA0AgACEEIAMiAUEUaiIAKAIAIgMNACABQRBqIQAgASgCECIDDQALIARBADYCAAsgCEUNAAJAIAUgBSgCHCIDQQJ0QdAhaiIAKAIARgRAIAAgATYCACABDQFBpB9BpB8oAgBBfiADd3E2AgAMAgsgCEEQQRQgCCgCECAFRhtqIAE2AgAgAUUNAQsgASAINgIYIAUoAhAiAARAIAEgADYCECAAIAE2AhgLIAUoAhQiAEUNACABIAA2AhQgACABNgIYCyAFIAdqIQUgAiAHaiECCyAFIAUoAgRBfnE2AgQgBiACQQFyNgIEIAIgBmogAjYCACACQf8BTQRAIAJBA3YiAEEDdEHIH2ohAgJ/QaAfKAIAIgFBASAAdCIAcUUEQEGgHyAAIAFyNgIAIAIMAQsgAigCCAshACACIAY2AgggACAGNgIMIAYgAjYCDCAGIAA2AggMAwtBHyEAIAJB////B00EQCACQQh2IgAgAEGA/j9qQRB2QQhxIgN0IgAgAEGA4B9qQRB2QQRxIgF0IgAgAEGAgA9qQRB2QQJxIgB0QQ92IAEgA3IgAHJrIgBBAXQgAiAAQRVqdkEBcXJBHGohAAsgBiAANgIcIAZCADcCECAAQQJ0QdAhaiEEAkBBpB8oAgAiA0EBIAB0IgFxRQRAQaQfIAEgA3I2AgAgBCAGNgIAIAYgBDYCGAwBCyACQQBBGSAAQQF2ayAAQR9GG3QhACAEKAIAIQEDQCABIgMoAgRBeHEgAkYNAyAAQR12IQEgAEEBdCEAIAMgAUEEcWoiBCgCECIBDQALIAQgBjYCECAGIAM2AhgLIAYgBjYCDCAGIAY2AggMAgtBrB8gBUEoayIDQXggAWtBB3FBACABQQhqQQdxGyIAayICNgIAQbgfIAAgAWoiADYCACAAIAJBAXI2AgQgASADakEoNgIEQbwfQYgjKAIANgIAIAcgBEEnIARrQQdxQQAgBEEna0EHcRtqQS9rIgAgACAHQRBqSRsiAkEbNgIEIAJB6CIpAgA3AhAgAkHgIikCADcCCEHoIiACQQhqNgIAQeQiIAU2AgBB4CIgATYCAEHsIkEANgIAIAJBGGohAANAIABBBzYCBCAAQQhqIQEgAEEEaiEAIAEgBEkNAAsgAiAHRg0DIAIgAigCBEF+cTYCBCAHIAIgB2siBEEBcjYCBCACIAQ2AgAgBEH/AU0EQCAEQQN2IgBBA3RByB9qIQICf0GgHygCACIBQQEgAHQiAHFFBEBBoB8gACABcjYCACACDAELIAIoAggLIQAgAiAHNgIIIAAgBzYCDCAHIAI2AgwgByAANgIIDAQLQR8hACAHQgA3AhAgBEH///8HTQRAIARBCHYiACAAQYD+P2pBEHZBCHEiAnQiACAAQYDgH2pBEHZBBHEiAXQiACAAQYCAD2pBEHZBAnEiAHRBD3YgASACciAAcmsiAEEBdCAEIABBFWp2QQFxckEcaiEACyAHIAA2AhwgAEECdEHQIWohAwJAQaQfKAIAIgJBASAAdCIBcUUEQEGkHyABIAJyNgIAIAMgBzYCACAHIAM2AhgMAQsgBEEAQRkgAEEBdmsgAEEfRht0IQAgAygCACEBA0AgASICKAIEQXhxIARGDQQgAEEddiEBIABBAXQhACACIAFBBHFqIgMoAhAiAQ0ACyADIAc2AhAgByACNgIYCyAHIAc2AgwgByAHNgIIDAMLIAMoAggiACAGNgIMIAMgBjYCCCAGQQA2AhggBiADNgIMIAYgADYCCAsgCUEIaiEADAULIAIoAggiACAHNgIMIAIgBzYCCCAHQQA2AhggByACNgIMIAcgADYCCAtBrB8oAgAiACAITQ0AQawfIAAgCGsiATYCAEG4H0G4HygCACICIAhqIgA2AgAgACABQQFyNgIEIAIgCEEDcjYCBCACQQhqIQAMAwtB3B5BMDYCAEEAIQAMAgsCQCAFRQ0AAkAgBCgCHCICQQJ0QdAhaiIAKAIAIARGBEAgACABNgIAIAENAUGkHyAJQX4gAndxIgk2AgAMAgsgBUEQQRQgBSgCECAERhtqIAE2AgAgAUUNAQsgASAFNgIYIAQoAhAiAARAIAEgADYCECAAIAE2AhgLIAQoAhQiAEUNACABIAA2AhQgACABNgIYCwJAIANBD00EQCAEIAMgCGoiAEEDcjYCBCAAIARqIgAgACgCBEEBcjYCBAwBCyAEIAhBA3I2AgQgBiADQQFyNgIEIAMgBmogAzYCACADQf8BTQRAIANBA3YiAEEDdEHIH2ohAgJ/QaAfKAIAIgFBASAAdCIAcUUEQEGgHyAAIAFyNgIAIAIMAQsgAigCCAshACACIAY2AgggACAGNgIMIAYgAjYCDCAGIAA2AggMAQtBHyEAIANB////B00EQCADQQh2IgAgAEGA/j9qQRB2QQhxIgJ0IgAgAEGA4B9qQRB2QQRxIgF0IgAgAEGAgA9qQRB2QQJxIgB0QQ92IAEgAnIgAHJrIgBBAXQgAyAAQRVqdkEBcXJBHGohAAsgBiAANgIcIAZCADcCECAAQQJ0QdAhaiECAkACQCAJQQEgAHQiAXFFBEBBpB8gASAJcjYCACACIAY2AgAgBiACNgIYDAELIANBAEEZIABBAXZrIABBH0YbdCEAIAIoAgAhCANAIAgiASgCBEF4cSADRg0CIABBHXYhAiAAQQF0IQAgASACQQRxaiICKAIQIggNAAsgAiAGNgIQIAYgATYCGAsgBiAGNgIMIAYgBjYCCAwBCyABKAIIIgAgBjYCDCABIAY2AgggBkEANgIYIAYgATYCDCAGIAA2AggLIARBCGohAAwBCwJAIAtFDQACQCABKAIcIgJBAnRB0CFqIgAoAgAgAUYEQCAAIAQ2AgAgBA0BQaQfIAZBfiACd3E2AgAMAgsgC0EQQRQgCygCECABRhtqIAQ2AgAgBEUNAQsgBCALNgIYIAEoAhAiAARAIAQgADYCECAAIAQ2AhgLIAEoAhQiAEUNACAEIAA2AhQgACAENgIYCwJAIANBD00EQCABIAMgCGoiAEEDcjYCBCAAIAFqIgAgACgCBEEBcjYCBAwBCyABIAhBA3I2AgQgCSADQQFyNgIEIAMgCWogAzYCACAKBEAgCkEDdiIAQQN0QcgfaiEEQbQfKAIAIQICf0EBIAB0IgAgBXFFBEBBoB8gACAFcjYCACAEDAELIAQoAggLIQAgBCACNgIIIAAgAjYCDCACIAQ2AgwgAiAANgIIC0G0HyAJNgIAQagfIAM2AgALIAFBCGohAAsgDEEQaiQAIAALfwEDfyAAIQECQCAAQQNxBEADQCABLQAARQ0CIAFBAWoiAUEDcQ0ACwsDQCABIgJBBGohASACKAIAIgNBf3MgA0GBgoQIa3FBgIGChHhxRQ0ACyADQf8BcUUEQCACIABrDwsDQCACLQABIQMgAkEBaiIBIQIgAw0ACwsgASAAawvyAgICfwF+AkAgAkUNACAAIAJqIgNBAWsgAToAACAAIAE6AAAgAkEDSQ0AIANBAmsgAToAACAAIAE6AAEgA0EDayABOgAAIAAgAToAAiACQQdJDQAgA0EEayABOgAAIAAgAToAAyACQQlJDQAgAEEAIABrQQNxIgRqIgMgAUH/AXFBgYKECGwiATYCACADIAIgBGtBfHEiBGoiAkEEayABNgIAIARBCUkNACADIAE2AgggAyABNgIEIAJBCGsgATYCACACQQxrIAE2AgAgBEEZSQ0AIAMgATYCGCADIAE2AhQgAyABNgIQIAMgATYCDCACQRBrIAE2AgAgAkEUayABNgIAIAJBGGsgATYCACACQRxrIAE2AgAgBCADQQRxQRhyIgRrIgJBIEkNACABrUKBgICAEH4hBSADIARqIQEDQCABIAU3AxggASAFNwMQIAEgBTcDCCABIAU3AwAgAUEgaiEBIAJBIGsiAkEfSw0ACwsgAAtPAQJ/QdgeKAIAIgEgAEEDakF8cSICaiEAAkAgAkEAIAAgAU0bDQAgAD8AQRB0SwRAIAAQAUUNAQtB2B4gADYCACABDwtB3B5BMDYCAEF/C20BAX8jAEGAAmsiBSQAIARBgMAEcSACIANMckUEQCAFIAFB/wFxIAIgA2siAkGAAiACQYACSSIBGxALGiABRQRAA0AgACAFQYACEA4gAkGAAmsiAkH/AUsNAAsLIAAgBSACEA4LIAVBgAJqJAALnQIBA38gAC0AAEEgcUUEQAJAIAEhBAJAIAIgACIBKAIQIgAEfyAABQJ/IAEiACABLQBKIgNBAWsgA3I6AEogASgCACIDQQhxBEAgACADQSByNgIAQX8MAQsgAEIANwIEIAAgACgCLCIDNgIcIAAgAzYCFCAAIAMgACgCMGo2AhBBAAsNASABKAIQCyABKAIUIgVrSwRAIAEgBCACIAEoAiQRAAAaDAILAn8gASwAS0F/SgRAIAIhAANAIAIgACIDRQ0CGiAEIANBAWsiAGotAABBCkcNAAsgASAEIAMgASgCJBEAACADSQ0CIAMgBGohBCABKAIUIQUgAiADawwBCyACCyEAIAUgBCAAEAUaIAEgASgCFCAAajYCFAsLCwsKACAAQTBrQQpJC2MBAn8gAkUEQEEADwsCfyAALQAAIgMEQANAAkACQCABLQAAIgRFDQAgAkEBayICRQ0AIAMgBEYNAQsgAwwDCyABQQFqIQEgAC0AASEDIABBAWohACADDQALC0EACyABLQAAawucDQIQfhB/IwBBgBBrIhQkACAUQYAIaiABEBcgFEGACGogABAWIBQgFEGACGoQFyADBEAgFCACEBYLQQAhAEEAIQEDQCAUQYAIaiABQQd0IgNBwAByaiIVKQMAIBRBgAhqIANB4AByaiIWKQMAIBRBgAhqIANqIhcpAwAgFEGACGogA0EgcmoiGCkDACIIEAMiBIVBIBACIgUQAyIGIAiFQRgQAiEIIAggBiAFIAQgCBADIgeFQRAQAiIKEAMiEYVBPxACIQggFEGACGogA0HIAHJqIhkpAwAgFEGACGogA0HoAHJqIhopAwAgFEGACGogA0EIcmoiGykDACAUQYAIaiADQShyaiIcKQMAIgQQAyIFhUEgEAIiBhADIgsgBIVBGBACIQQgBCALIAYgBSAEEAMiC4VBEBACIhIQAyIThUE/EAIhBCAUQYAIaiADQdAAcmoiHSkDACAUQYAIaiADQfAAcmoiHikDACAUQYAIaiADQRByaiIfKQMAIBRBgAhqIANBMHJqIiApAwAiBRADIgaFQSAQAiIMEAMiDSAFhUEYEAIhBSAFIA0gDCAGIAUQAyINhUEQEAIiDBADIg6FQT8QAiEFIBRBgAhqIANB2AByaiIhKQMAIBRBgAhqIANB+AByaiIiKQMAIBRBgAhqIANBGHJqIiMpAwAgFEGACGogA0E4cmoiAykDACIGEAMiD4VBIBACIgkQAyIQIAaFQRgQAiEGIAYgECAJIA8gBhADIg+FQRAQAiIJEAMiEIVBPxACIQYgFyAHIAQQAyIHIAQgDiAHIAmFQSAQAiIHEAMiDoVBGBACIgQQAyIJNwMAICIgByAJhUEQEAIiBzcDACAdIA4gBxADIgc3AwAgHCAEIAeFQT8QAjcDACAbIAsgBRADIgQgBSAQIAQgCoVBIBACIgQQAyIHhUEYEAIiBRADIgo3AwAgFiAEIAqFQRAQAiIENwMAICEgByAEEAMiBDcDACAgIAQgBYVBPxACNwMAIB8gDSAGEAMiBCAGIBEgBCAShUEgEAIiBBADIgWFQRgQAiIGEAMiBzcDACAaIAQgB4VBEBACIgQ3AwAgFSAFIAQQAyIENwMAIAMgBCAGhUE/EAI3AwAgIyAPIAgQAyIEIAggEyAEIAyFQSAQAiIEEAMiBYVBGBACIggQAyIGNwMAIB4gBCAGhUEQEAIiBDcDACAZIAUgBBADIgQ3AwAgGCAEIAiFQT8QAjcDACABQQFqIgFBCEcNAAsDQCAAQQR0IgMgFEGACGpqIgEiFUGABGopAwAgASkDgAYgASkDACABKQOAAiIIEAMiBIVBIBACIgUQAyIGIAiFQRgQAiEIIAggBiAFIAQgCBADIgeFQRAQAiIKEAMiEYVBPxACIQggASkDiAQgASkDiAYgFEGACGogA0EIcmoiAykDACABKQOIAiIEEAMiBYVBIBACIgYQAyILIASFQRgQAiEEIAQgCyAGIAUgBBADIguFQRAQAiISEAMiE4VBPxACIQQgASkDgAUgASkDgAcgASkDgAEgASkDgAMiBRADIgaFQSAQAiIMEAMiDSAFhUEYEAIhBSAFIA0gDCAGIAUQAyINhUEQEAIiDBADIg6FQT8QAiEFIAEpA4gFIAEpA4gHIAEpA4gBIAEpA4gDIgYQAyIPhUEgEAIiCRADIhAgBoVBGBACIQYgBiAQIAkgDyAGEAMiD4VBEBACIgkQAyIQhUE/EAIhBiABIAcgBBADIgcgBCAOIAcgCYVBIBACIgcQAyIOhUEYEAIiBBADIgk3AwAgASAHIAmFQRAQAiIHNwOIByABIA4gBxADIgc3A4AFIAEgBCAHhUE/EAI3A4gCIAMgCyAFEAMiBCAFIBAgBCAKhUEgEAIiBBADIgeFQRgQAiIFEAMiCjcDACABIAQgCoVBEBACIgQ3A4AGIAEgByAEEAMiBDcDiAUgASAEIAWFQT8QAjcDgAMgASANIAYQAyIEIAYgESAEIBKFQSAQAiIEEAMiBYVBGBACIgYQAyIHNwOAASABIAQgB4VBEBACIgQ3A4gGIBUgBSAEEAMiBDcDgAQgASAEIAaFQT8QAjcDiAMgASAPIAgQAyIEIAggEyAEIAyFQSAQAiIEEAMiBYVBGBACIggQAyIGNwOIASABIAQgBoVBEBACIgQ3A4AHIAEgBSAEEAMiBDcDiAQgASAEIAiFQT8QAjcDgAIgAEEBaiIAQQhHDQALIAIgFBAXIAIgFEGACGoQFiAUQYAQaiQAC8MBAQN/IwBBQGoiAyQAIANBAEHAABALIQRBfyEDAkAgAEUgAUVyDQAgACgC5AEgAksNACAAKQNQQgBSDQAgACAANQLgARAaIAAQJUEAIQMgAEHgAGoiAiAAKALgASIFakEAQYABIAVrEAsaIAAgAhAZA0AgBCADQQN0IgVqIAAgBWopAwAQMiADQQFqIgNBCEcNAAsgASAEIAAoAuQBEAUaIARBwAAQBCACQYABEAQgAEHAABAEQQAhAwsgBEFAayQAIAML1AMBBn8jAEEQayIEJAAgBCABNgIMIwBBoAFrIgMkACADQQhqQYAYQZABEAUaIAMgADYCNCADIAA2AhwgA0F+IABrIgJB/////wcgAkH/////B0kbIgU2AjggAyAAIAVqIgA2AiQgAyAANgIYIANBCGohACMAQdABayICJAAgAiABNgLMASACQaABakEAQSgQCxogAiACKALMATYCyAECQEEAIAJByAFqIAJB0ABqIAJBoAFqEBtBAEgNACAAKAJMQQBOIQYgACgCACEBIAAsAEpBAEwEQCAAIAFBX3E2AgALIAFBIHEhBwJ/IAAoAjAEQCAAIAJByAFqIAJB0ABqIAJBoAFqEBsMAQsgAEHQADYCMCAAIAJB0ABqNgIQIAAgAjYCHCAAIAI2AhQgACgCLCEBIAAgAjYCLCAAIAJByAFqIAJB0ABqIAJBoAFqEBsgAUUNABogAEEAQQAgACgCJBEAABogAEEANgIwIAAgATYCLCAAQQA2AhwgAEEANgIQIAAoAhQaIABBADYCFEEACxogACAAKAIAIAdyNgIAIAZFDQALIAJB0AFqJAAgBQRAIAMoAhwiACAAIAMoAhhGa0EAOgAACyADQaABaiQAIARBEGokAAs0AQF/QQEhAQJAIABBCkkNAEECIQEDQCAAQeQASQ0BIAFBAWohASAAQQpuIQAMAAsACyABC4UBAQd/AkAgAC0AACIGQTBrQf8BcUEJSw0AIAYhAgNAIAQhByADQZmz5swBSw0BIAJB/wFxQTBrIgIgA0EKbCIEQX9zSw0BIAIgBGohAyAAIAdBAWoiBGoiCC0AACICQTBrQf8BcUEKSQ0ACyAGQTBGQQAgBxsNACABIAM2AgAgCCEFCyAFCzEBA38DQCAAIAJBA3QiA2oiBCAEKQMAIAEgA2opAwCFNwMAIAJBAWoiAkGAAUcNAAsLDAAgACABQYAIEAUaC14BAn8jAEFAaiICJABBfyEDAkAgAEUNACABQQFrQcAATwRAIAAQNwwBCyACQQE6AAMgAkGAAjsAASACIAE6AAAgAkEEckEAQTwQCxogACACEDwhAwsgAkFAayQAIAMLpAoCA38RfiMAQYACayIDJAADQCACQQN0IgQgA0GAAWpqIAEgBGopAAA3AwAgAkEBaiICQRBHDQALIAMgAEHAABAFIQEgACkDWEL5wvibkaOz8NsAhSELIAApA1BC6/qG2r+19sEfhSEMIAApA0hCn9j52cKR2oKbf4UhDSAAKQNAQtGFmu/6z5SH0QCFIQ5C8e30+KWn/aelfyEPQqvw0/Sv7ry3PCESQrvOqqbY0Ouzu38hEEKIkvOd/8z5hOoAIQVBACEDIAEpAzghBiABKQMYIRQgASkDMCEHIAEpAxAhFSABKQMoIQggASkDCCERIAEpAyAhCSABKQMAIQoDQCAJIAUgDiABQYABaiADQQZ0IgJBwAhqKAIAQQN0aikDACAJIAp8fCIKhUEgEAIiDnwiE4VBGBACIQUgBSATIA4gAUGAAWogAkHECGooAgBBA3RqKQMAIAUgCnx8IgqFQRAQAiIOfCIThUE/EAIhCSAIIBAgDSABQYABaiACQcgIaigCAEEDdGopAwAgCCARfHwiEYVBIBACIg18IhCFQRgQAiEFIAUgECANIAFBgAFqIAJBzAhqKAIAQQN0aikDACAFIBF8fCIRhUEQEAIiDXwiEIVBPxACIQUgEiAMIAFBgAFqIAJB0AhqKAIAQQN0aikDACAHIBV8fCIIhUEgEAIiDHwiEiAHhUEYEAIhByAHIBIgDCABQYABaiACQdQIaigCAEEDdGopAwAgByAIfHwiFYVBEBACIgx8IgiFQT8QAiEHIA8gCyABQYABaiACQdgIaigCAEEDdGopAwAgBiAUfHwiEoVBIBACIgt8Ig8gBoVBGBACIQYgBiALIAFBgAFqIAJB3AhqKAIAQQN0aikDACAGIBJ8fCIUhUEQEAIiCyAPfCIPhUE/EAIhBiAFIAggCyABQYABaiACQeAIaigCAEEDdGopAwAgBSAKfHwiCoVBIBACIgt8IgiFQRgQAiEFIAUgCCALIAFBgAFqIAJB5AhqKAIAQQN0aikDACAFIAp8fCIKhUEQEAIiC3wiEoVBPxACIQggByAPIA4gAUGAAWogAkHoCGooAgBBA3RqKQMAIAcgEXx8Ig+FQSAQAiIOfCIRhUEYEAIhBSAFIBEgDiABQYABaiACQewIaigCAEEDdGopAwAgBSAPfHwiEYVBEBACIg58Ig+FQT8QAiEHIAYgDSABQYABaiACQfAIaigCAEEDdGopAwAgBiAVfHwiBYVBIBACIg0gE3wiE4VBGBACIQYgBiATIA0gAUGAAWogAkH0CGooAgBBA3RqKQMAIAUgBnx8IhWFQRAQAiINfCIFhUE/EAIhBiAJIBAgDCABQYABaiACQfgIaigCAEEDdGopAwAgCSAUfHwiEIVBIBACIgx8IhOFQRgQAiEJIAkgEyAMIAFBgAFqIAJB/AhqKAIAQQN0aikDACAJIBB8fCIUhUEQEAIiDHwiEIVBPxACIQkgA0EBaiIDQQxHDQALIAEgDjcDYCABIAk3AyAgASANNwNoIAEgCDcDKCABIBE3AwggASAQNwNIIAEgDDcDcCABIAc3AzAgASAVNwMQIAEgEjcDUCABIAs3A3ggASAGNwM4IAEgFDcDGCABIA83A1ggASAFNwNAIAEgCjcDACAAIAogACkDAIUgBYU3AwBBASECA0AgACACQQN0IgNqIgQgASADaiIDKQMAIAQpAwCFIANBQGspAwCFNwMAIAJBAWoiAkEIRw0ACyABQYACaiQACyYBAX4gACABIAApA0AiAXwiAjcDQCAAIAApA0ggASACVq18NwNIC6AUAhB/An4jAEHQAGsiBiQAIAZByg42AkwgBkE3aiETIAZBOGohEANAAkAgDkEASA0AQf////8HIA5rIARIBEBB3B5BPTYCAEF/IQ4MAQsgBCAOaiEOCyAGKAJMIgchBAJAAkACQAJAAkACQAJAAkAgBgJ/AkAgBy0AACIFBEADQAJAAkAgBUH/AXEiBUUEQCAEIQUMAQsgBUElRw0BIAQhBQNAIAQtAAFBJUcNASAGIARBAmoiCDYCTCAFQQFqIQUgBC0AAiELIAghBCALQSVGDQALCyAFIAdrIQQgAARAIAAgByAEEA4LIAQNDSAGKAJMLAABEA8hBSAGKAJMIQQgBUUNAyAELQACQSRHDQMgBCwAAUEwayEPQQEhESAEQQNqDAQLIAYgBEEBaiIINgJMIAQtAAEhBSAIIQQMAAsACyAOIQwgAA0IIBFFDQJBASEEA0AgAyAEQQJ0aigCACIABEAgAiAEQQN0aiAAIAEQJEEBIQwgBEEBaiIEQQpHDQEMCgsLQQEhDCAEQQpPDQgDQCADIARBAnRqKAIADQggBEEBaiIEQQpHDQALDAgLQX8hDyAEQQFqCyIENgJMQQAhCAJAIAQsAAAiDUEgayIFQR9LDQBBASAFdCIFQYnRBHFFDQADQAJAIAYgBEEBaiIINgJMIAQsAAEiDUEgayIEQSBPDQBBASAEdCIEQYnRBHFFDQAgBCAFciEFIAghBAwBCwsgCCEEIAUhCAsCQCANQSpGBEAgBgJ/AkAgBCwAARAPRQ0AIAYoAkwiBC0AAkEkRw0AIAQsAAFBAnQgA2pBwAFrQQo2AgAgBCwAAUEDdCACakGAA2soAgAhCkEBIREgBEEDagwBCyARDQhBACERQQAhCiAABEAgASABKAIAIgRBBGo2AgAgBCgCACEKCyAGKAJMQQFqCyIENgJMIApBf0oNAUEAIAprIQogCEGAwAByIQgMAQsgBkHMAGoQIyIKQQBIDQYgBigCTCEEC0F/IQkCQCAELQAAQS5HDQAgBC0AAUEqRgRAAkAgBCwAAhAPRQ0AIAYoAkwiBC0AA0EkRw0AIAQsAAJBAnQgA2pBwAFrQQo2AgAgBCwAAkEDdCACakGAA2soAgAhCSAGIARBBGoiBDYCTAwCCyARDQcgAAR/IAEgASgCACIEQQRqNgIAIAQoAgAFQQALIQkgBiAGKAJMQQJqIgQ2AkwMAQsgBiAEQQFqNgJMIAZBzABqECMhCSAGKAJMIQQLQQAhBQNAIAUhEkF/IQwgBCwAAEHBAGtBOUsNByAGIARBAWoiDTYCTCAELAAAIQUgDSEEIAUgEkE6bGpBzxhqLQAAIgVBAWtBCEkNAAsgBUETRg0CIAVFDQYgD0EATgRAIAMgD0ECdGogBTYCACAGIAIgD0EDdGopAwA3A0AMBAsgAA0BC0EAIQwMBQsgBkFAayAFIAEQJCAGKAJMIQ0MAgsgD0F/Sg0DC0EAIQQgAEUNBAsgCEH//3txIgsgCCAIQYDAAHEbIQVBACEMQcAOIQ8gECEIAkACQAJAAn8CQAJAAkACQAJ/AkACQAJAAkACQAJAAkAgDUEBaywAACIEQV9xIAQgBEEPcUEDRhsgBCASGyIEQdgAaw4hBBISEhISEhISDhIPBg4ODhIGEhISEgIFAxISCRIBEhIEAAsCQCAEQcEAaw4HDhILEg4ODgALIARB0wBGDQkMEQsgBikDQCEUQcAODAULQQAhBAJAAkACQAJAAkACQAJAIBJB/wFxDggAAQIDBBcFBhcLIAYoAkAgDjYCAAwWCyAGKAJAIA42AgAMFQsgBigCQCAOrDcDAAwUCyAGKAJAIA47AQAMEwsgBigCQCAOOgAADBILIAYoAkAgDjYCAAwRCyAGKAJAIA6sNwMADBALIAlBCCAJQQhLGyEJIAVBCHIhBUH4ACEECyAQIQcgBEEgcSELIAYpA0AiFFBFBEADQCAHQQFrIgcgFKdBD3FB4BxqLQAAIAtyOgAAIBRCD1YhDSAUQgSIIRQgDQ0ACwsgBUEIcUUgBikDQFByDQMgBEEEdkHADmohD0ECIQwMAwsgECEEIAYpA0AiFFBFBEADQCAEQQFrIgQgFKdBB3FBMHI6AAAgFEIHViEHIBRCA4ghFCAHDQALCyAEIQcgBUEIcUUNAiAJIBAgB2siBEEBaiAEIAlIGyEJDAILIAYpA0AiFEJ/VwRAIAZCACAUfSIUNwNAQQEhDEHADgwBCyAFQYAQcQRAQQEhDEHBDgwBC0HCDkHADiAFQQFxIgwbCyEPIBAhBAJAIBRCgICAgBBUBEAgFCEVDAELA0AgBEEBayIEIBQgFEIKgCIVQgp+fadBMHI6AAAgFEL/////nwFWIQcgFSEUIAcNAAsLIBWnIgcEQANAIARBAWsiBCAHIAdBCm4iC0EKbGtBMHI6AAAgB0EJSyENIAshByANDQALCyAEIQcLIAVB//97cSAFIAlBf0obIQUgBikDQCIUQgBSIAlyRQRAQQAhCSAQIQcMCgsgCSAUUCAQIAdraiIEIAQgCUgbIQkMCQsCfyAJIgRBAEchCAJAAkACQCAGKAJAIgVB4xYgBRsiByIFQQNxRSAERXINAANAIAUtAABFDQIgBEEBayIEQQBHIQggBUEBaiIFQQNxRQ0BIAQNAAsLIAhFDQELAkAgBS0AAEUgBEEESXINAANAIAUoAgAiCEF/cyAIQYGChAhrcUGAgYKEeHENASAFQQRqIQUgBEEEayIEQQNLDQALCyAERQ0AA0AgBSAFLQAARQ0CGiAFQQFqIQUgBEEBayIEDQALC0EACyIEIAcgCWogBBshCCALIQUgBCAHayAJIAQbIQkMCAsgCQRAIAYoAkAMAgtBACEEIABBICAKQQAgBRANDAILIAZBADYCDCAGIAYpA0A+AgggBiAGQQhqNgJAQX8hCSAGQQhqCyEIQQAhBAJAA0AgCCgCACIHRQ0BIAZBBGogBxAiIgdBAEgiCyAHIAkgBGtLckUEQCAIQQRqIQggCSAEIAdqIgRLDQEMAgsLQX8hDCALDQULIABBICAKIAQgBRANIARFBEBBACEEDAELQQAhCCAGKAJAIQ0DQCANKAIAIgdFDQEgBkEEaiAHECIiByAIaiIIIARKDQEgACAGQQRqIAcQDiANQQRqIQ0gBCAISw0ACwsgAEEgIAogBCAFQYDAAHMQDSAKIAQgBCAKSBshBAwFCyAAIAYrA0AgCiAJIAUgBEEAEQwAIQQMBAsgBiAGKQNAPAA3QQEhCSATIQcgCyEFDAILQX8hDAsgBkHQAGokACAMDwsgAEEgIAwgCCAHayILIAkgCSALSBsiCWoiCCAKIAggCkobIgQgCCAFEA0gACAPIAwQDiAAQTAgBCAIIAVBgIAEcxANIABBMCAJIAtBABANIAAgByALEA4gAEEgIAQgCCAFQYDAAHMQDQwACwALkwIBAn8gAEUEQEFnDwsgACgCAEUEQEF/DwsCQAJ/QX4gACgCBEEESQ0AGiAAKAIIRQRAQW4gACgCDA0BGgsgACgCFCEBIAAoAhBFDQFBeiABQQhJDQAaIAAoAhhFBEBBbCAAKAIcDQEaCyAAKAIgRQRAQWsgACgCJA0BGgtBciAAKAIsIgFBCEkNABpBcSABQYCAgAFLDQAaQXIgASAAKAIwIgJBA3RJDQAaIAAoAihFBEBBdA8LIAJFBEBBcA8LQW8gAkH///8HSw0AGiAAKAI0IgFFBEBBZA8LQWMgAUH///8HSw0AGiAAKAJAIQECQCAAKAI8BEAgAQ0BQWkPC0FoIAENARoLQQALDwtBbUF6IAEbCzgBAX8jAEEQayICJAAgAiAANgIMIAIgATYCCCACKAIMQQAgAigCCEH8FygCABEAABogAkEQaiQAC4MSAhN/An4jAEEwayIJJAACQCAAEBwiBA0AQWYhBCABQQJLDQAgACgCLCEDIAAoAjAhBCAAKAI4IQIgCUEANgIAIAkgAjYCBCAAKAIoIQIgCSAENgIYIAkgAjYCCCAJIARBA3QiAiADIAIgA0sbIARBAnQiAm4iAzYCECAJIANBAnQ2AhQgCSACIANsNgIMIAAoAjQhAyAJIAE2AiAgCSADNgIcIAMgBEsEQCAJIAQ2AhwLIwBB0ABrIgskAEFnIQQCQCAJIgFFIAAiA0VyDQAgASADNgIoIAMhBSABKAIMIQZBaiECAkAgASIERQ0AIAatQgqGIhVCIIinDQAgFachAgJAIAUoAjwiBQRAIAQgAiAFEQMAGiAEKAIAIQIMAQsgBCACEAkiAjYCAAtBAEFqIAIbIQILIAIiBA0AIAEoAiAhBSMAQYACayICJAAgA0UgCyIERXJFBEAgAkEQakHAABAYGiACQQxqIAMoAjAQByACQRBqIAJBDGpBBBAGGiACQQxqIAMoAgQQByACQRBqIAJBDGpBBBAGGiACQQxqIAMoAiwQByACQRBqIAJBDGpBBBAGGiACQQxqIAMoAigQByACQRBqIAJBDGpBBBAGGiACQQxqIAMoAjgQByACQRBqIAJBDGpBBBAGGiACQQxqIAUQByACQRBqIAJBDGpBBBAGGiACQQxqIAMoAgwQByACQRBqIAJBDGpBBBAGGgJAIAMoAggiBUUNACACQRBqIAUgAygCDBAGGiADLQBEQQFxRQ0AIAMoAgggAygCDBAdIANBADYCDAsgAkEMaiADKAIUEAcgAkEQaiACQQxqQQQQBhogAygCECIFBEAgAkEQaiAFIAMoAhQQBhoLIAJBDGogAygCHBAHIAJBEGogAkEMakEEEAYaAkAgAygCGCIFRQ0AIAJBEGogBSADKAIcEAYaIAMtAERBAnFFDQAgAygCGCADKAIcEB0gA0EANgIcCyACQQxqIAMoAiQQByACQRBqIAJBDGpBBBAGGiADKAIgIgUEQCACQRBqIAUgAygCJBAGGgsgAkEQaiAEQcAAEBIaCyACQYACaiQAIAtBQGtBCBAEQQAhAiMAQYAIayIDJAAgASgCGARAIARBxABqIQYgBEFAayEFA0AgBUEAEAcgBiACEAcgA0GACCAEQcgAECAgASgCACABKAIUIAJsQQp0aiADEC4gBUEBEAcgA0GACCAEQcgAECAgASgCACABKAIUIAJsQQp0akGACGogAxAuIAJBAWoiAiABKAIYSQ0ACwsgA0GACBAEIANBgAhqJAAgC0HIABAEQQAhBAsgC0HQAGokACAEDQBBZyEEAkAgCUUNACABKAIYRQ0AIwBBIGsiBSQAIAEiCygCCARAIAsoAhghBANAIAQhA0EAIQ8DQEEAIRBBACECIAMEQANAIAUgDzoAGCAFQQA2AhwgBSAFKQMYNwMIIAUgEjYCECAFIBA2AhQgBSAFKQMQNwMAIAUhBEEAIREjAEGAGGsiByQAAkAgCyIDRQ0AAkACQAJAAn8CfwJAAkACQCADKAIgQQFrDgICAQALIAQoAgAhCEEADAMLIAQoAgANA0EAIAQtAAgiDEECSQ0BGiAELQAIIghFQQF0IQwMBQsgBC0ACCEMIAQoAgALIQggBxAvIAdBgAhqEC8gByAIrTcDgAggBDUCBCEVIAcgDK1C/wGDNwOQCCAHIBU3A4gIIAcgAzUCDDcDmAggByADNQIINwOgCCAHIAM1AiA3A6gIQQELIREgCEUNAQsgBC0ACCEIQQAhDAwBCyAELQAIIghFQQF0IQwgCCARRXINACAHQYAQaiAHQYAIaiAHECZBAiEMQQAhCAsgDCADKAIQIgZPDQBBfyADKAIUIgJBAWsgAiAEKAIEbCAMaiAGIAhB/wFxbGoiCCACcBsgCGohBgNAIAhBAWsgBiAIIAJwQQFGGyEOAn8gEQRAIAxB/wBxIgJFBEAgB0GAEGogB0GACGogBxAmCyAHQYAQaiACQQN0agwBCyADKAIAIA5BCnRqCyECIAMoAhghCiACKQMAIRUgBCAMNgIMIAMhBiAVpyEUIBVCIIinIApwrSIVIBUgBDUCBCIVIAQtAAgbIAQoAgAbIhYgFVEhCgJ+IAQiAigCAEUEQCACLQAIIg1FBEAgAigCDEEBayEKQgAMAgsgBigCECANbCENIAIoAgwhAiAKBEAgAiANakEBayEKQgAMAgsgDSACRWshCkIADAELIAYoAhAhDSAGKAIUIRMCfyAKBEAgAigCDCATIA1Bf3NqagwBCyATIA1rIAIoAgxFawshCkIAIAItAAgiAkEDRg0AGiANIAJBAWpsrQshFSAVIApBAWutfCAKrSAUrSIVIBV+QiCIfkIgiH0gBjUCFIKnIQYgAygCACICIAMoAhQgFqdsQQp0aiAGQQp0aiEGIAIgCEEKdGohCgJAIAMoAgRBEEYEQCACIA5BCnRqIAYgCkEAEBEMAQsgAiAOQQp0aiECIAQoAgBFBEAgAiAGIApBABARDAELIAIgBiAKQQEQEQsgDEEBaiIMIAMoAhBPDQEgCEEBaiEIIA5BAWohBiADKAIUIQIMAAsACyAHQYAYaiQAIAsoAhgiBCECIBBBAWoiECAESQ0ACwsgAiEDIA9BAWoiD0EERw0ACyASQQFqIhIgCygCCEkNAAsLIAVBIGokAEEAIQQLIAQNACMAQYAQayIDJAAgAEUgCUVyRQRAIANBgAhqIAEoAgAgASgCFEEKdGpBgAhrEBcgASgCGEECTwRAQQEhBANAIANBgAhqIAEoAgAgASgCFCICIAIgBGxqQQp0akGACGsQFiAEQQFqIgQgASgCGEkNAAsLIAMiAkGACGohC0EAIQQDQCACIARBA3QiBWogBSALaikDABAyIARBAWoiBEGAAUcNAAsgACgCACAAKAIEIANBgAgQICADQYAIakGACBAEIANBgAgQBCABKAIAIgQgASgCDEEKdCIBEAQCQCAAKAJAIgAEQCAEIAEgABECAAwBCyAEEAgLCyADQYAQaiQAQQAhBAsgCUEwaiQAIAQLJwEBfwJAAkACQAJAIAAOAwABAgMLQdATDwtBixEPC0GeEyEBCyABC48DAQF/IwBBgANrIgQkACAEQQA2AowBIARBjAFqIAEQBwJAIAFBwABNBEAgBEGQAWogARAYQQBIDQEgBEGQAWogBEGMAWpBBBAGQQBIDQEgBEGQAWogAiADEAZBAEgNASAEQZABaiAAIAEQEhoMAQsgBEGQAWpBwAAQGEEASA0AIARBkAFqIARBjAFqQQQQBkEASA0AIARBkAFqIAIgAxAGQQBIDQAgBEGQAWogBEFAa0HAABASQQBIDQAgACAEKQNANwAAIAAgBCkDSDcACCAAIAQpA1g3ABggACAEKQNQNwAQIABBIGohACABQSBrIgJBwQBPBEADQCAEIARBQGtBwAAQBSIBQUBrQcAAIAEQMUEASA0CIAAgASkDQDcAACAAIAEpA0g3AAggACAEKQNYNwAYIAAgBCkDUDcAECAAQSBqIQAgAkEgayICQcAASw0ACwsgBCAEQUBrQcAAEAUiAUFAayACIAEQMUEASA0AIAAgAUFAayACEAUaCyAEQZABakHwARAEIARBgANqJAALAwABC5kCACAARQRAQQAPCwJ/AkAgAAR/IAFB/wBNDQECQEGgHigCACgCAEUEQCABQYB/cUGAvwNGDQMMAQsgAUH/D00EQCAAIAFBP3FBgAFyOgABIAAgAUEGdkHAAXI6AABBAgwECyABQYCwA09BACABQYBAcUGAwANHG0UEQCAAIAFBP3FBgAFyOgACIAAgAUEMdkHgAXI6AAAgACABQQZ2QT9xQYABcjoAAUEDDAQLIAFBgIAEa0H//z9NBEAgACABQT9xQYABcjoAAyAAIAFBEnZB8AFyOgAAIAAgAUEGdkE/cUGAAXI6AAIgACABQQx2QT9xQYABcjoAAUEEDAQLC0HcHkEZNgIAQX8FQQELDAELIAAgAToAAEEBCwtQAQN/AkAgACgCACwAABAPRQRADAELA0AgACgCACICLAAAIQMgACACQQFqNgIAIAEgA2pBMGshASACLAABEA9FDQEgAUEKbCEBDAALAAsgAQu7AgACQCABQRRLDQACQAJAAkACQAJAAkACQAJAAkACQCABQQlrDgoAAQIDBAUGBwgJCgsgAiACKAIAIgFBBGo2AgAgACABKAIANgIADwsgAiACKAIAIgFBBGo2AgAgACABNAIANwMADwsgAiACKAIAIgFB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AUHjFEEDEBANACABQQNqIANBDGoQFSIBRQ0AIAAgAygCDDYCKCABQecUQQMQEA0AIAFBA2ogA0EMahAVIgFFDQAgACADKAIMIgQ2AjAgACAENgI0IAEtAABBJEcNACADIAc2AgwgACgCECADQQxqIAFBAWoQLCIBRQ0AIAAgAygCDDYCFCABLQAAQSRHDQAgAyAGNgIMIAAoAgAgA0EMaiABQQFqECwiAUUNACAAIAMoAgw2AgQgAEEANgJEIABCADcCPCAAQgA3AhggAEIANwIgIAAQHCICDQBBYEEAIAEtAAAbIQILIANBEGokACACCykBAn8DQCAAIAJBA3QiA2ogASADaikAADcDACACQQFqIgJBgAFHDQALCwwAIABBAEGACBALGgtlAQJ/IAAgAhAeIgIEfyACBUFdQQACfyAAKAIAIQRBACECIAAoAgQiAAR/A0AgAyACIARqLQAAIAEgAmotAABzciEDIAJBAWoiAiAARw0ACyADQQFrQQh2QQFxQQFrBUEACwsbCwtdAQJ/IwBB8AFrIgMkAEF/IQQCQCACRSAARSABRXJyIAFBwABLcg0AIAMgARAYQQBIDQAgAyACQcAAEAZBAEgNACADIAAgARASIQQLIANB8AEQBCADQfABaiQAIAQLCQAgACABNwAACxAAIwAgAGtBcHEiACQAIAALMwEBfyAAKAIUIgMgASACIAAoAhAgA2siASABIAJLGyIBEAUaIAAgACgCFCABajYCFCACC9oBAQR/IwBB0ABrIggkAAJAIABFBEBBYCEADAELIAggABAKIgk2AgwgCCAJNgIcIAggCRAJIgo2AhggCCAJEAkiCzYCCEEAIQkCQAJAIApFIAtFcg0AIAggAjYCFCAIIAE2AhAgCEEIaiAAIAcQLSIADQEgCCgCCCEJIAggCCgCDBAJIgA2AgggAEUNACAIIAY2AiwgCCAFNgIoIAggBDYCJCAIIAM2AiAgCEEIaiAJIAcQMCEADAELQWohAAsgCCgCGBAIIAgoAggQCCAJEAgLIAhB0ABqJAAgAAuQAgEDfyMAQdAAayIRJABBfiETAkAgCEEESQ0AIAgQCSISRQRAQWohEwwBCyARQQA2AkwgEUIANwJEIBEgAjYCPCARIAI2AjggESABNgI0IBEgADYCMCARIA82AiwgESAONgIoIBEgDTYCJCAR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IGxvbmcgb3IgdG9vIHNob3J0AE1pc3NpbmcgYXJndW1lbnRzAFRvbyBtYW55IGxhbmVzAFRvbyBmZXcgbGFuZXMAVG9vIG1hbnkgdGhyZWFkcwBOb3QgZW5vdWdoIHRocmVhZHMATWVtb3J5IGFsbG9jYXRpb24gZXJyb3IATWVtb3J5IGNvc3QgaXMgdG9vIHNtYWxsAFRpbWUgY29zdCBpcyB0b28gc21hbGwAYXJnb24yaQBBcmdvbjJpAFRoZSBwYXNzd29yZCBkb2VzIG5vdCBtYXRjaCB0aGUgc3VwcGxpZWQgaGFzaABPdXRwdXQgcG9pbnRlciBtaXNtYXRjaABPdXRwdXQgaXMgdG9vIGxvbmcAU2FsdCBpcyB0b28gbG9uZwBTZWNyZXQgaXMgdG9vIGxvbmcAUGFzc3dvcmQgaXMgdG9vIGxvbmcAQXNzb2NpYXRlZCBkYXRhIGlzIHRvbyBsb25nAFRocmVhZGluZyBmYWlsdXJlAE1lbW9yeSBjb3N0IGlzIHRvbyBsYXJnZQBUaW1lIGNvc3QgaXMgdG9vIGxhcmdlAFVua25vd24gZXJyb3IgY29kZQBhcmdvbjJpZABBcmdvbjJpZABFbmNvZGluZyBmYWlsZWQARGVjb2RpbmcgZmFpbGVkAGFyZ29uMmQAQXJnb24yZABBcmdvbjJfQ29udGV4dCBjb250ZXh0IGlzIE5VTEwAT3V0cHV0IHBvaW50ZXIgaXMgTlVMTABUaGUgYWxsb2NhdGUgbWVtb3J5IGNhbGxiYWNrIGlzIE5VTEwAVGhlIGZyZWUgbWVtb3J5IGNhbGxiYWNrIGlzIE5VTEwAT0sAJHY9ACx0PQAscD0AJG09AFRoZXJlIGlzIG5vIHN1Y2ggdmVyc2lvbiBvZiBBcmdvbjIAU2FsdCBwb2ludGVyIGlzIE5VTEwsIGJ1dCBzYWx0IGxlbmd0aCBpcyBub3QgMABTZWNyZXQgcG9pbnRlciBpcyBOVUxMLCBidXQgc2VjcmV0IGxlbmd0aCBpcyBub3QgMABQYXNzd29yZCBwb2ludGVyIGlzIE5VTEwsIGJ1dCBwYXNzd29yZCBsZW5ndGggaXMgbm90IDAAQXNzb2NpYXRlZCBkYXRhIHBvaW50ZXIgaXMgTlVMTCwgYnV0IGFkIGxlbmd0aCBpcyBub3QgMAAobnVsbCkAAACbCAAAuwcAAEkJAADACQAAsAkAAPAHAAAfCAAAMAgAAMkIAABvCgAA4AkAABYKAAA7CgAAQwgAACsLAADBCgAAkgoAAPQKAAACCAAAEQgAAFsJAABbCAAAdAkAAHQIAAAFCQAAdAcAAC0JAACeBwAA9AgAAGIHAAAYCQAAiAcAAOEIAABOBwAA/wkAAFwKAAABAEGkGAsBAgBByxgLBf//////AEGQGQtBEQAKABEREQAAAAAFAAAAAAAACQAAAAALAAAAAAAAAAARAA8KERERAwoHAAEACQsLAAAJBgsAAAsABhEAAAAREREAQeEZCyELAAAAAAAAAAARAAoKERERAAoAAAIACQsAAAAJAAsAAAsAQZsaCwEMAEGnGgsVDAAAAAAMAAAAAAkMAAAAAAAMAAAMAEHVGgsBDgBB4RoLFQ0AAAAEDQAAAAAJDgAAAAAADgAADgBBjxsLARAAQZsbCx4PAAAAAA8AAAAACRAAAAAAABAAABAAABIAAAASEhIAQdIbCw4SAAAAEhISAAAAAAAACQBBgxwLAQsAQY8cCxUKAAAAAAoAAAAACQsAAAAAAAsAAAsAQb0cCwEMAEHJHAsnDAAAAAAMAAAAAAkMAAAAAAAMAAAMAAAwMTIzNDU2Nzg5QUJDREVGAEHwHAsBAQBBoB4LAogPAEHYHgsDkBFQ"},145:()=>{},967:()=>{}},B={};function Q(A){var I=B[A];if(void 0!==I)return I.exports;var C=B[A]={exports:{}};return g[A].call(C.exports,C,C.exports,Q),C.exports}return I=Object.getPrototypeOf?A=>Object.getPrototypeOf(A):A=>A.__proto__,Q.t=function(g,B){if(1&B&&(g=this(g)),8&B)return g;if("object"==typeof g&&g){if(4&B&&g.__esModule)return g;if(16&B&&"function"==typeof g.then)return g}var C=Object.create(null);Q.r(C);var E={};A=A||[null,I({}),I([]),I(I)];for(var i=2&B&&g;"object"==typeof i&&!~A.indexOf(i);i=I(i))Object.getOwnPropertyNames(i).forEach((A=>E[A]=()=>g[A]));return E.default=()=>g,Q.d(C,E),C},Q.d=(A,I)=>{for(var g in I)Q.o(I,g)&&!Q.o(A,g)&&Object.defineProperty(A,g,{enumerable:!0,get:I[g]})},Q.o=(A,I)=>Object.prototype.hasOwnProperty.call(A,I),Q.r=A=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(A,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(A,"__esModule",{value:!0})},Q(631)})()})); \ No newline at end of file
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.wasm b/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.wasm
new file mode 100755
index 0000000..75c3111
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.wasm
Binary files differ
diff --git a/packages/meshbay-hub/tests/test_bundle_kdf_parity.py b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
new file mode 100644
index 0000000..27e10d4
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
@@ -0,0 +1,131 @@
+"""
+Cross-language parity for the keypair bundle KDF.
+
+The bundle is the one thing a user carries between browsers, and the passphrase
+is all that stands between it and whoever holds the disk of a node they joined
+(finding C4). It moved from PBKDF2-SHA512 to Argon2id for that reason — PBKDF2 is
+compute-only, which is what makes it cheap on a GPU.
+
+Two implementations now have to agree byte for byte: the vendored WebAssembly the
+browser runs, and `argon2-cffi` used by the QE harness. A disagreement would not
+show up as an error — it would show up as a bundle nobody can open, which is
+somebody's account gone.
+
+Skipped when node or argon2-cffi is missing; that is a coverage gap, not a pass.
+"""
+
+import hashlib
+import json
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+VENDOR = STATIC / "vendor"
+
+try:
+ from argon2.low_level import Type, hash_secret_raw
+ HAVE_ARGON2 = True
+except ImportError:
+ HAVE_ARGON2 = False
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None
+ or not (VENDOR / "argon2.min.js").exists()
+ or not HAVE_ARGON2,
+ reason="node, the vendored argon2, or argon2-cffi is unavailable",
+)
+
+# Parameters must match keyderive.js. If someone tunes them there and not here,
+# this test fails — which is the point: changing them silently orphans every
+# bundle already written.
+MEM_KIB, TIME_COST, LANES = 131072, 3, 1
+
+CASES = ["alice", "grenet", "utilisateur-é", ""]
+PASSWORDS = ["correct horse battery staple", "p", "üñïçø∂é ✓ 🔐"]
+
+_HARNESS = r"""
+const fs = require('fs'), webcrypto = require('crypto').webcrypto;
+global.self = global; global.crypto = webcrypto;
+// The browser uses the copy inlined in the bundle; under node the emscripten
+// loader looks for a file, so hand it the same bytes explicitly.
+global.Module = { wasmBinary: fs.readFileSync(process.argv[2]) };
+const argon2 = require(process.argv[3]);
+
+(async () => {
+ const input = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
+ const out = [];
+ for (const v of input) {
+ const salt = new Uint8Array(await webcrypto.subtle.digest(
+ 'SHA-256', new TextEncoder().encode(`meshbay:bundle:v2:${v.username}`)
+ )).slice(0, 16);
+ const r = await argon2.hash({
+ pass: v.password, salt,
+ time: v.time, mem: v.mem, parallelism: v.lanes,
+ hashLen: 32, type: argon2.ArgonType.Argon2id,
+ });
+ out.push(Buffer.from(r.hash).toString('hex'));
+ }
+ process.stdout.write(JSON.stringify(out));
+})();
+"""
+
+
+@pytest.fixture(scope="module")
+def js_hashes(tmp_path_factory):
+ d = tmp_path_factory.mktemp("kdf")
+ harness = d / "harness.cjs"
+ harness.write_text(_HARNESS)
+ vectors = [
+ {"username": u, "password": p,
+ "mem": MEM_KIB, "time": TIME_COST, "lanes": LANES}
+ for u in CASES for p in PASSWORDS
+ ]
+ payload = d / "vectors.json"
+ payload.write_text(json.dumps(vectors))
+
+ proc = subprocess.run(
+ ["node", str(harness), str(VENDOR / "argon2.wasm"),
+ str(VENDOR / "argon2.min.js"), str(payload)],
+ capture_output=True, text=True, timeout=300,
+ )
+ if proc.returncode != 0:
+ pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}")
+ return vectors, json.loads(proc.stdout)
+
+
+def _python_hash(username: str, password: str) -> str:
+ salt = hashlib.sha256(f"meshbay:bundle:v2:{username}".encode()).digest()[:16]
+ return hash_secret_raw(
+ password.encode(), salt, time_cost=TIME_COST, memory_cost=MEM_KIB,
+ parallelism=LANES, hash_len=32, type=Type.ID,
+ ).hex()
+
+
+def test_bundle_key_matches_across_languages(js_hashes):
+ vectors, js = js_hashes
+ for i, v in enumerate(vectors):
+ assert js[i] == _python_hash(v["username"], v["password"]), (
+ f"argon2id disagrees for username={v['username']!r} — a bundle "
+ f"written by one implementation would be unreadable by the other"
+ )
+
+
+def test_the_salt_separates_users(js_hashes):
+ """Two accounts with the same passphrase must not share a bundle key."""
+ assert _python_hash("alice", "same passphrase") != \
+ _python_hash("bob", "same passphrase")
+
+
+def test_parameters_still_match_the_client():
+ """
+ The numbers live in keyderive.js; this test is the second copy. Tuning one
+ without the other orphans every bundle already written, so make it fail.
+ """
+ source = (STATIC / "keyderive.js").read_text()
+ assert f"ARGON2_MEM_KIB = {MEM_KIB}" in source
+ assert f"ARGON2_TIME = {TIME_COST}" in source
+ assert f"ARGON2_LANES = {LANES}" in source
+ assert "meshbay:bundle:v2:" in source
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
index a8232c1..5a2cf86 100644
--- a/packages/meshbay-hub/tests/test_hub_api.py
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -24,6 +24,35 @@ def _gen_user_keys():
)
+
+async def _announce_signed(client, token: str) -> tuple[str, str]:
+ """
+ Announce a node with proof of possession (M8).
+
+ The node key is independent of the user's identity key, so this mints a fresh
+ one and signs the domain-separated announce message with it.
+ """
+ import base64 as _b64, time as _t
+
+ me = await client.get("/v1/users/me",
+ headers={"Authorization": f"Bearer {token}"})
+ user_id = me.json()["user_id"]
+
+ sk_node = Ed25519PrivateKey.generate()
+ pk_node = pk_to_b64(sk_node.public_key())
+ ts = _t.time().__trunc__()
+ msg = f"meshbay:node_announce:{user_id}:{pk_node}:{ts}".encode()
+
+ r = await client.post("/v1/nodes/announce", json={
+ "pk_node": pk_node,
+ "endpoint_hint": "1.2.3.4:19000",
+ "timestamp": ts,
+ "signature": _b64.b64encode(sk_node.sign(msg)).decode(),
+ }, headers={"Authorization": f"Bearer {token}"})
+ assert r.status_code == 201, r.text
+ return r.json()["node_id"], pk_node
+
+
# ── Hub info ──────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
@@ -115,8 +144,11 @@ async def test_jwt_offline_verify(client, hub_key_path):
hub_pk_pem = r_pk.json()["pk_hub_pem"].encode()
decoded = pyjwt.decode(token, hub_pk_pem, algorithms=["EdDSA"])
- assert decoded["pk_user"] == pk_ed
assert "jti" in decoded # mandatory
+ # The token carries no user key. It used to, and the node recorded it as the
+ # uploader's identity — so whoever issued tokens decided who could delete a
+ # file. The hub certifies accounts; nodes pin keys.
+ assert "pk_user" not in decoded
@pytest.mark.asyncio
@@ -165,11 +197,17 @@ async def test_refresh_token_rotation_old_rejected(client):
@pytest.mark.asyncio
async def test_get_user_pubkeys(client):
+ """
+ The endpoint resolves an account; it is not a key directory any more.
+
+ Publishing user identity keys is what finding H3 exploited — the invite flow
+ wrapped the group key for whatever came back. Keys are now generated per node
+ and pinned there, so there is nothing here to substitute.
+ """
pk_ed, pk_x, _ = _gen_user_keys()
await client.post("/v1/users/register", json={
"username": "frank", "email": "frank@example.com",
- "password": "frankpass99",
- "pk_user_ed25519": pk_ed, "pk_user_x25519": pk_x})
+ "password": "frankpass99"})
login = await client.post("/v1/users/login", json={
"username": "frank", "password": "frankpass99"})
token = login.json()["access_token"]
@@ -177,8 +215,10 @@ async def test_get_user_pubkeys(client):
r = await client.get("/v1/users/frank/pubkeys",
headers={"Authorization": f"Bearer {token}"})
assert r.status_code == 200
- assert r.json()["pk_ed25519"] == pk_ed
- assert r.json()["pk_x25519"] == pk_x
+ body = r.json()
+ assert body["user_id"] and body["username"] == "frank"
+ assert "pk_ed25519" not in body, "user identity keys must not be published (H3)"
+ assert "pk_x25519" not in body, "user identity keys must not be published (H3)"
# ── Nodes ─────────────────────────────────────────────────────────────────────
@@ -195,15 +235,12 @@ async def test_announce_and_get_node(client):
token = login.json()["access_token"]
hdrs = {"Authorization": f"Bearer {token}"}
- r = await client.post("/v1/nodes/announce",
- json={"pk_node": pk_ed, "endpoint_hint": "1.2.3.4:19000"},
- headers=hdrs)
- assert r.status_code == 201
- node_id = r.json()["node_id"]
+ node_id, pk_node = await _announce_signed(client, token)
r2 = await client.get(f"/v1/nodes/{node_id}", headers=hdrs)
assert r2.status_code == 200
- assert r2.json()["pk_node"] == pk_ed
+ # The node key is independent of the user identity key (M8).
+ assert r2.json()["pk_node"] == pk_node
assert r2.json()["endpoint_hint"] == "1.2.3.4:19000"
@@ -409,10 +446,7 @@ async def test_group_online_nodes(client):
json={"username": "gn_user", "password": "gnpass999"})).json()["access_token"]
# Announce a node
- r = await client.post("/v1/nodes/announce", json={
- "pk_node": pk_ed, "endpoint_hint": "1.2.3.4:19000"},
- headers={"Authorization": f"Bearer {token}"})
- node_id = r.json()["node_id"]
+ node_id, pk_node = await _announce_signed(client, token)
# No nodes online yet
r = await client.get(f"/v1/groups/{group_id}/nodes",
@@ -433,7 +467,7 @@ async def test_group_online_nodes(client):
nodes = r.json()["nodes"]
assert len(nodes) == 1
assert nodes[0]["node_id"] == node_id
- assert nodes[0]["pk_node"] == pk_ed
+ assert nodes[0]["pk_node"] == pk_node
finally:
_connected_nodes.pop(node_id, None)
_node_groups.pop(node_id, None)
diff --git a/packages/meshbay-hub/tests/test_node_auth.py b/packages/meshbay-hub/tests/test_node_auth.py
index e629d20..72ce412 100644
--- a/packages/meshbay-hub/tests/test_node_auth.py
+++ b/packages/meshbay-hub/tests/test_node_auth.py
@@ -214,7 +214,8 @@ async def test_node_scope_allows_pubkey_lookup(client):
r = await client.get("/v1/users/op4/pubkeys",
headers={"Authorization": f"Bearer {node_token}"})
assert r.status_code == 200
- assert "pk_ed25519" in r.json()
+ # An account id and the node's linking key — no user identity keys (H3).
+ assert "pk_ed25519" not in r.json()
assert r.json()["pk_node_ed25519"] is not None
diff --git a/packages/meshbay-hub/tests/test_node_ws_auth.py b/packages/meshbay-hub/tests/test_node_ws_auth.py
new file mode 100644
index 0000000..1391722
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_node_ws_auth.py
@@ -0,0 +1,346 @@
+"""
+Phase 11.5 security regression tests — node WebSocket registration (finding C2).
+
+The hub relays every WebRTC offer for a node to whoever holds that node's entry in
+`_connected_nodes`. That registration used to be established from a client-supplied
+`node_id` with no ownership check, so any registered user could take over a victim
+node's signaling and become the endpoint browsers connect to.
+
+These exercise `_authorize_node_ws` directly rather than through a socket: it is the
+function that makes the authorization decision, and the hub test harness uses
+ASGITransport, which has no WebSocket support.
+"""
+
+import base64
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+
+from meshbay_common.crypto import pk_to_b64
+
+
+async def _make_user(client, username: str) -> dict:
+ """Register + log in a user, returning ids, token and keys."""
+ sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate()
+ pk_ed, pk_x = pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key())
+
+ r = await client.post("/v1/users/register", json={
+ "username": username,
+ "email": f"{username}@example.test",
+ "auth_key": base64.b64encode(b"k" * 32).decode(),
+ "pk_user_ed25519": pk_ed,
+ "pk_user_x25519": pk_x,
+ })
+ assert r.status_code == 201, r.text
+ user_id = r.json()["user_id"]
+
+ r = await client.post("/v1/users/login", json={
+ "username": username,
+ "auth_key": base64.b64encode(b"k" * 32).decode(),
+ })
+ assert r.status_code == 200, r.text
+ return {"user_id": user_id, "token": r.json()["access_token"],
+ "pk_ed": pk_ed, "sk_ed": sk_ed}
+
+
+async def _announce_node(client, user: dict) -> str:
+ # Announce now requires proof of possession of the node key (M8).
+ import time as _t
+ ts = int(_t.time())
+ msg = f"meshbay:node_announce:{user['user_id']}:{user['pk_ed']}:{ts}".encode()
+ r = await client.post(
+ "/v1/nodes/announce",
+ json={
+ "pk_node": user["pk_ed"], "endpoint_hint": "test",
+ "timestamp": ts,
+ "signature": base64.b64encode(user["sk_ed"].sign(msg)).decode(),
+ },
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+ return r.json()["node_id"]
+
+
+def _node_token(user: dict) -> str:
+ from meshbay_hub.auth import issue_access_token
+ return issue_access_token(user["user_id"], scope="node")
+
+
+@pytest.mark.asyncio
+async def test_ws_rejects_user_scoped_token(client):
+ """C2: a browser token must never be able to register as a node."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ victim = await _make_user(client, "victim1")
+ node_id = await _announce_node(client, victim)
+
+ resolved, detail = await _authorize_node_ws(victim["token"], node_id, None)
+ assert resolved is None
+ assert "node-scoped" in detail.lower()
+
+
+@pytest.mark.asyncio
+async def test_ws_rejects_foreign_node_id(client):
+ """
+ C2: the impersonation itself. An attacker with a perfectly valid node-scoped
+ token of their own must not be able to claim someone else's node_id.
+ """
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ victim = await _make_user(client, "victim2")
+ attacker = await _make_user(client, "attacker2")
+ victim_node = await _announce_node(client, victim)
+ await _announce_node(client, attacker)
+
+ resolved, detail = await _authorize_node_ws(
+ _node_token(attacker), victim_node, None)
+ assert resolved is None, "attacker hijacked the victim's node registration (C2)"
+ assert "does not belong" in detail.lower()
+
+
+@pytest.mark.asyncio
+async def test_ws_rejects_unknown_node_id(client):
+ """C2: an invented node_id must not register either."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "user3")
+ resolved, _ = await _authorize_node_ws(_node_token(user), "no-such-node", None)
+ assert resolved is None
+
+
+@pytest.mark.asyncio
+async def test_ws_rejects_missing_node_id(client):
+ """C2: identity may not fall back to the token subject."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "user4")
+ resolved, _ = await _authorize_node_ws(_node_token(user), "", None)
+ assert resolved is None
+
+
+@pytest.mark.asyncio
+async def test_ws_accepts_own_node(client):
+ """The legitimate path still works."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "owner5")
+ node_id = await _announce_node(client, user)
+
+ resolved, groups = await _authorize_node_ws(_node_token(user), node_id, None)
+ assert resolved == node_id
+ assert groups == []
+
+
+@pytest.mark.asyncio
+async def test_ws_group_claims_cannot_widen_beyond_membership(client):
+ """
+ C2: `group_ids` used to be taken verbatim, letting a node advertise itself as
+ an online source for any group on the hub and attract clients to it.
+ """
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "owner6")
+ node_id = await _announce_node(client, user)
+
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "mine", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 201, r.text
+ own_group = r.json()["group_id"]
+
+ resolved, groups = await _authorize_node_ws(
+ _node_token(user), node_id, [own_group, "someone-elses-group"])
+
+ assert resolved == node_id
+ assert groups == [own_group], "node advertised a group it is not a member of"
+
+
+@pytest.mark.asyncio
+async def test_signaling_rejects_non_member(client):
+ """
+ H6/H4: POST /v1/nodes/{id}/webrtc/offer was reachable by any authenticated
+ user for any node, with no membership check and no rate limit. Each call makes
+ the target node allocate an aiortc PeerConnection and gather ICE, so it was a
+ remote resource-exhaustion primitive against a third party's machine.
+ """
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "owner8")
+ outsider = await _make_user(client, "outsider8")
+ node_id = await _announce_node(client, owner)
+
+ r = await client.post(
+ "/v1/groups",
+ json={"name": "private-g", "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {owner['token']}"},
+ )
+ group_id = r.json()["group_id"]
+
+ # Pretend the node is connected and hosting that group.
+ class _FakeWS:
+ async def send_text(self, _):
+ raise AssertionError("offer relayed to node despite non-membership")
+
+ rev._connected_nodes[node_id] = _FakeWS()
+ rev._node_groups[node_id] = [group_id]
+ try:
+ resp = await client.post(
+ f"/v1/nodes/{node_id}/webrtc/offer",
+ json={"sdp": "v=0", "ice_candidates": []},
+ headers={"Authorization": f"Bearer {outsider['token']}"},
+ )
+ assert resp.status_code == 403, resp.text
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+ rev._node_groups.pop(node_id, None)
+
+
+@pytest.mark.asyncio
+async def test_signaling_rejects_oversized_sdp(client):
+ """H6: an SDP offer is ~2 KB; unbounded input is a memory amplifier."""
+ user = await _make_user(client, "user9")
+ resp = await client.post(
+ "/v1/nodes/whatever/webrtc/offer",
+ json={"sdp": "v=0" + ("x" * 200_000), "ice_candidates": []},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert resp.status_code == 413
+
+
+@pytest.mark.asyncio
+async def test_incoming_rejects_foreign_peer_ip(client):
+ """
+ H6: peer_ip was taken verbatim, letting any user make an arbitrary node emit
+ UDP packets to an address of their choosing — reflection via someone else's
+ machine. The probe target must be the caller's own address.
+ """
+ from meshbay_hub.api import revocation as rev
+
+ owner = await _make_user(client, "owner10")
+ node_id = await _announce_node(client, owner)
+
+ class _FakeWS:
+ async def send_text(self, _):
+ raise AssertionError("punch relayed with attacker-chosen peer_ip")
+
+ rev._connected_nodes[node_id] = _FakeWS()
+ try:
+ resp = await client.post(
+ f"/v1/nodes/{node_id}/incoming",
+ json={"peer_ip": "198.51.100.7", "peer_port": 9999},
+ headers={"Authorization": f"Bearer {owner['token']}"},
+ )
+ assert resp.status_code == 403, resp.text
+ finally:
+ rev._connected_nodes.pop(node_id, None)
+
+
+@pytest.mark.asyncio
+async def test_ws_node_may_narrow_its_group_set(client):
+ """A node hosting a subset of the operator's groups may say so."""
+ from meshbay_hub.api.revocation import _authorize_node_ws
+
+ user = await _make_user(client, "owner7")
+ node_id = await _announce_node(client, user)
+
+ created = []
+ for name in ("g-one", "g-two"):
+ r = await client.post(
+ "/v1/groups",
+ json={"name": name, "visibility": "private", "join_policy": "invite"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ created.append(r.json()["group_id"])
+
+ resolved, groups = await _authorize_node_ws(
+ _node_token(user), node_id, [created[0]])
+ assert resolved == node_id
+ assert groups == [created[0]]
+
+
+# ── M8: announce proof of possession ─────────────────────────────────────────
+
+def _announce_payload(user_id: str, sk, pk_b64: str, ts: int | None = None):
+ import time as _t
+ ts = ts if ts is not None else int(_t.time())
+ msg = f"meshbay:node_announce:{user_id}:{pk_b64}:{ts}".encode()
+ return {
+ "pk_node": pk_b64,
+ "endpoint_hint": "test",
+ "timestamp": ts,
+ "signature": base64.b64encode(sk.sign(msg)).decode(),
+ }
+
+
+@pytest.mark.asyncio
+async def test_announce_requires_proof_of_possession(client):
+ """
+ M8: /v1/nodes/announce accepted any pk_node with no proof the announcer held
+ the private key, so a user could announce a record carrying someone else's
+ node key.
+ """
+ user = await _make_user(client, "ann1")
+ r = await client.post(
+ "/v1/nodes/announce",
+ json={"pk_node": user["pk_ed"], "endpoint_hint": "test"},
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 400, r.text
+
+
+@pytest.mark.asyncio
+async def test_announce_rejects_foreign_key(client):
+ """M8: announcing someone else's public key must fail — no matching private key."""
+ user = await _make_user(client, "ann2")
+ victim_sk = Ed25519PrivateKey.generate()
+ victim_pk = pk_to_b64(victim_sk.public_key())
+
+ attacker_sk = Ed25519PrivateKey.generate()
+ payload = _announce_payload(user["user_id"], attacker_sk, victim_pk)
+
+ r = await client.post(
+ "/v1/nodes/announce", json=payload,
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 401, r.text
+
+
+@pytest.mark.asyncio
+async def test_announce_rejects_stale_timestamp(client):
+ """M8: a captured announce must not be replayable later."""
+ import time as _t
+ user = await _make_user(client, "ann3")
+ sk = Ed25519PrivateKey.generate()
+ payload = _announce_payload(
+ user["user_id"], sk, pk_to_b64(sk.public_key()), ts=int(_t.time()) - 3600)
+
+ r = await client.post(
+ "/v1/nodes/announce", json=payload,
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert r.status_code == 401, r.text
+
+
+@pytest.mark.asyncio
+async def test_announce_with_valid_proof_succeeds_and_is_idempotent(client):
+ """The legitimate path works, and re-announcing updates rather than piling up rows."""
+ user = await _make_user(client, "ann4")
+ sk = Ed25519PrivateKey.generate()
+ pk_b64 = pk_to_b64(sk.public_key())
+
+ first = await client.post(
+ "/v1/nodes/announce", json=_announce_payload(user["user_id"], sk, pk_b64),
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert first.status_code == 201, first.text
+
+ second = await client.post(
+ "/v1/nodes/announce", json=_announce_payload(user["user_id"], sk, pk_b64),
+ headers={"Authorization": f"Bearer {user['token']}"},
+ )
+ assert second.status_code == 201, second.text
+ assert second.json()["node_id"] == first.json()["node_id"], (
+ "re-announcing the same key must not create a second node record (M8)")
diff --git a/packages/meshbay-hub/tests/test_spa_ordering.py b/packages/meshbay-hub/tests/test_spa_ordering.py
new file mode 100644
index 0000000..0ef34fc
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_spa_ordering.py
@@ -0,0 +1,103 @@
+"""
+Ordering guards for the SPA's connect() flow.
+
+These are source-level checks, which is not how one would normally test
+behaviour. They exist because a specific class of bug shipped to a live browser
+twice and no other test could see it: `connect()` is a long sequence in which
+later steps read values earlier steps set, and the Python end-to-end client in
+QE/deploy/ cannot catch a mistake there — it is a different implementation,
+written in the right order by construction, so it passes while the browser fails.
+
+Concretely: join_request signs a transcript over the node key and the node nonce,
+and runs *before* the GEK proof, because a first-time member has no GEK to prove.
+Both values were being read further down, next to the proof that also uses them,
+so every invited member hit "Handshake incomplete — reconnect and retry".
+
+If you restructure connect(), these will fail. Check the invariant still holds —
+that nothing reads a value assigned later — and then move the markers.
+"""
+
+from pathlib import Path
+
+import pytest
+
+STATIC = (Path(__file__).resolve().parents[1]
+ / "src" / "meshbay_hub" / "static")
+TRANSPORT = STATIC / "transport.js"
+
+pytestmark = pytest.mark.skipif(
+ not TRANSPORT.exists(), reason="SPA sources not present")
+
+
+def _positions(*needles: str) -> list[int]:
+ source = TRANSPORT.read_text()
+ out = []
+ for needle in needles:
+ idx = source.find(needle)
+ assert idx != -1, f"{needle!r} is gone from transport.js — update this test"
+ out.append(idx)
+ return out
+
+
+def test_challenge_values_are_captured_before_joining():
+ """
+ joinGroup() signs over node_pk and nonce_node, so both must be recorded when
+ the challenge arrives — not later, beside the proof.
+ """
+ # Deliberately loose markers: what matters is where the assignment happens,
+ # not how it is spelled, so a reordering fails on the ordering assertion
+ # below rather than on a missing string.
+ node_pk, nonce_node, join_call = _positions(
+ "this.nodePk = reply.node_pk",
+ "this._nonceNode = ",
+ "await this.joinGroup(",
+ )
+ assert node_pk < join_call, (
+ "node_pk is read from the challenge after joinGroup() runs — the join "
+ "would sign a transcript naming nothing")
+ assert nonce_node < join_call, (
+ "nonce_node is captured after joinGroup() runs — the join would not be "
+ "bound to this connection")
+
+
+def test_join_happens_before_the_gek_proof():
+ """
+ The whole point of joining in the pre-proof window: someone who has never
+ held the group key cannot produce a proof, so the key has to arrive first.
+ """
+ join_call, proof = _positions(
+ "await this.joinGroup(",
+ "await C.handshakeProof(",
+ )
+ assert join_call < proof, (
+ "the join must happen before the GEK proof — a first-time member has no "
+ "key to prove with")
+
+
+def test_keys_are_recovered_before_the_join_is_attempted():
+ """
+ A second browser holds nothing but a password. It recovers its identity keys
+ from the node's encrypted keypair bundle, and only then can it sign a join —
+ so the recovery has to come first. Getting this order wrong is invisible on
+ the browser that registered, and breaks every other one.
+ """
+ recover, join_call = _positions(
+ "type: 'keypair_bundle_fetch'",
+ "await this.joinGroup(",
+ )
+ assert recover < join_call, (
+ "the keypair bundle must be fetched before joinGroup() — otherwise a "
+ "browser that did not register has no key to sign the join with")
+
+
+def test_the_ack_still_verifies_the_announced_node_key():
+ """
+ Taking node_pk from the challenge is only safe because the ack proves it and
+ the client compares the two. Losing that check would leave the announcement
+ trusted on its own.
+ """
+ source = TRANSPORT.read_text()
+ assert "Node identity changed during the handshake" in source, (
+ "the challenge's node_pk must be checked against the ack's")
+ assert "verifyNodeSignature" in source, (
+ "the ack's signature over the handshake transcript must still be verified")
diff --git a/packages/meshbay-node/src/meshbay_node/bundle_store.py b/packages/meshbay-node/src/meshbay_node/bundle_store.py
index e7c6981..4cf3236 100644
--- a/packages/meshbay-node/src/meshbay_node/bundle_store.py
+++ b/packages/meshbay-node/src/meshbay_node/bundle_store.py
@@ -99,6 +99,21 @@ class BundleStore:
row = await cursor.fetchone()
return row[0] if row else None
+ async def delete_keypair(self, user_id: str) -> bool:
+ """
+ Drop someone's keypair bundle at their own request.
+
+ Backing keys up here is what lets a second browser recover them with the
+ password — and it is also what puts a PBKDF2-protected blob on every node
+ whose group they join (finding C4). Someone who does not need the first
+ should be able to withdraw the second, and not merely stop adding to it.
+ """
+ assert self._db
+ cur = await self._db.execute(
+ "DELETE FROM keypair_bundles WHERE user_id = ?", (user_id,))
+ await self._db.commit()
+ return cur.rowcount > 0
+
async def close(self) -> None:
if self._db:
await self._db.close()
diff --git a/packages/meshbay-node/src/meshbay_node/config.py b/packages/meshbay-node/src/meshbay_node/config.py
index a7a0785..f3752ea 100644
--- a/packages/meshbay-node/src/meshbay_node/config.py
+++ b/packages/meshbay-node/src/meshbay_node/config.py
@@ -26,28 +26,33 @@ url = "https://meshbay.org"
username = "myusername"
[node]
-port = 19000 # TCP+TLS (MNP v1)
-quic_port = 19010 # QUIC (MNP v2)
-http_port = 19001 # HTTP file API (public content)
-ui_port = 18000 # local web UI
+quic_port = 19010 # QUIC (MNP) — LAN, port-forwarded, hub-less direct access
+ui_port = 18000 # local admin UI (127.0.0.1 only)
-# Multiple groups — each with its own directory and ports
+# One-time codes. An invitation waits for someone to read their messages; an
+# operator pairing code is typed during the SSH session that printed it.
+invite_ttl_hours = 168 # 7 days
+pair_ttl_hours = 24
+
+# Browser and native clients reach this node over WebRTC DataChannel via hub
+# signaling — no inbound port to open. QUIC is the optional direct path.
+
+# Multiple groups — each with its own directory
[[groups]]
id = "" # set after joining
name = "My Media"
shared_dir = "/home/user/Media"
-port = 19000
quic_port = 19010
-http_port = 19001
[[groups]]
id = ""
name = "Public Archive"
shared_dir = "/home/user/Archive"
-port = 19002
quic_port = 19012
-http_port = 19003
-visibility = "public"
+visibility = "public" # discoverable on the hub
+# join_policy = "open" # anyone the hub says is a member gets the group key,
+ # with no pairing code. Only for groups where that is
+ # genuinely intended: it means the hub can join too.
[keystore]
# unlock_file = "~/.config/meshbay/unlock.key"
@@ -68,10 +73,13 @@ class HubConfig:
@dataclass
class NodeConfig:
- port: int = 19000
quic_port: int = 19010
- http_port: int = 19001
ui_port: int = 18000
+ # How long a one-time code stays usable. Invitations travel through a human
+ # conversation and are answered days later; operator pairing happens during
+ # the SSH session that printed it.
+ invite_ttl_hours: int = 168 # 7 days
+ pair_ttl_hours: int = 24
@dataclass
@@ -79,10 +87,16 @@ class GroupConfig:
id: str = ""
name: str = ""
shared_dir: str = ""
- visibility: str = "private" # public|private
- port: int = 19000 # TCP+TLS MNP port for this group
+ visibility: str = "private" # public|private — discoverability, not admission
+ # Admission. "invite" (default) means a newcomer needs a one-time pairing code
+ # before the node wraps the group key for them; "open" means the node pins
+ # whoever turns up first (TOFU) and serves them.
+ #
+ # Deliberately read from THIS file and never from the hub: a hub that could
+ # declare a group open would walk into any group it liked. Being findable
+ # (`visibility`) and being open (`join_policy`) are different questions.
+ join_policy: str = "invite" # invite|open
quic_port: int = 19010 # QUIC MNP port
- http_port: int = 19001 # HTTP file API port
@dataclass
@@ -121,10 +135,14 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
cfg.hub.username = hub.get("username", cfg.hub.username)
nd = raw.get("node", {})
- cfg.node.port = nd.get("port", cfg.node.port)
+ # `port` (TCP+TLS) and `http_port` no longer exist — both listeners were removed
+ # in Phase 11.5 (findings C1, C6). Regenerate node.toml with `meshbay-node init`.
cfg.node.quic_port = nd.get("quic_port", cfg.node.quic_port)
- cfg.node.http_port = nd.get("http_port", cfg.node.http_port)
cfg.node.ui_port = nd.get("ui_port", cfg.node.ui_port)
+ cfg.node.invite_ttl_hours = int(
+ nd.get("invite_ttl_hours", cfg.node.invite_ttl_hours))
+ cfg.node.pair_ttl_hours = int(
+ nd.get("pair_ttl_hours", cfg.node.pair_ttl_hours))
# Multi-group: [[groups]] array
if "groups" in raw:
@@ -134,9 +152,8 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
name=g.get("name", ""),
shared_dir=g.get("shared_dir", ""),
visibility=g.get("visibility", "private"),
- port=g.get("port", cfg.node.port),
+ join_policy=g.get("join_policy", "invite"),
quic_port=g.get("quic_port", cfg.node.quic_port),
- http_port=g.get("http_port", cfg.node.http_port),
))
# Back-compat: single [group] section
elif "group" in raw:
@@ -163,8 +180,8 @@ def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Config:
cfg.hub.url = url
if user := os.environ.get("MESHBAY_USERNAME"):
cfg.hub.username = user
- if port := os.environ.get("MESHBAY_PORT"):
- cfg.node.port = int(port)
+ if port := os.environ.get("MESHBAY_QUIC_PORT"):
+ cfg.node.quic_port = int(port)
return cfg
diff --git a/packages/meshbay-node/src/meshbay_node/daemon.py b/packages/meshbay-node/src/meshbay_node/daemon.py
index fe12909..58fa99a 100644
--- a/packages/meshbay-node/src/meshbay_node/daemon.py
+++ b/packages/meshbay-node/src/meshbay_node/daemon.py
@@ -8,9 +8,9 @@ Startup sequence:
4. Fetch GEK bundle from hub (if group configured)
5. Start directory indexer (watchdog)
6. Create chat stores (one SQLite DB per group)
- 7. Create WebRTC transport (browser clients via DataChannel)
- 8. Start QUIC+TCP chunk servers (native clients)
- 9. Start HTTP file API (public content)
+ 7. Create WebRTC transport (browser + native clients via DataChannel)
+ 8. Start QUIC chunk server (LAN / port-forwarded / hub-less direct access)
+ 9. (Phase 11.5: the unauthenticated HTTP file API and the TCP+TLS server were removed)
10. Start hub WebSocket (signaling, revocations, WebRTC offers)
11. Start local web UI on node.ui_port (localhost only)
12. Run until SIGINT/SIGTERM
@@ -18,6 +18,9 @@ Startup sequence:
Usage:
meshbay-node # interactive password prompt
meshbay-node --config /path # custom config
+ meshbay-node status # node state + public key (works while stopped)
+ meshbay-node ui # print the local admin UI URL
+ meshbay-node gek-init # initialise the group key (no browser needed)
meshbay-node init # write example config + create keystore
meshbay-node --calibrate-argon2 # benchmark Argon2id, suggest parameters
"""
@@ -26,6 +29,7 @@ import asyncio
import base64
import json
import logging
+import os
import signal
import sys
from pathlib import Path
@@ -41,13 +45,12 @@ from meshbay_node.chat.store import ChatStore
from meshbay_node.config import Config, DEFAULT_CONFIG_PATH, load_config, write_example_config
from meshbay_node.hub_client import HubClient, HubConfig
from meshbay_node.indexer import DirectoryIndexer
-from meshbay_node.keystore import NodeKeys, load_or_create_keystore
+from meshbay_node.keystore import load_or_create_keystore
+from meshbay_node.roster import Roster
from meshbay_node.transport import (
- ChunkServer,
Denylist,
QUIC_AVAILABLE,
WEBRTC_AVAILABLE,
- create_http_app,
)
if QUIC_AVAILABLE:
@@ -103,22 +106,22 @@ class NodeDaemon:
"hub_url": config.hub.url,
"username": config.hub.username,
"groups": [g.name for g in config.groups],
- "node_port": config.node.port,
"quic_port": config.node.quic_port,
"endpoint_hint": None,
"indexes": {},
}
- self._tcp_server: ChunkServer | None = None
self._quic_server = None
self._webrtc = None
- self._denylist = Denylist() if Denylist else None
+ # Persisted so a restart does not silently un-revoke everyone (H4)
+ self._denylist = (
+ Denylist(path=config.data_dir / "denylist.json") if Denylist else None)
self._chat_stores: dict[str, ChatStore] = {}
self._audit_store: AuditStore | None = None
self._bundle_store: BundleStore | None = None
+ self._roster: Roster | None = None
self._indexers: list[DirectoryIndexer] = []
self._tasks: list[asyncio.Task] = []
self._hub: HubClient | None = None
- self._http_servers: list[uvicorn.Server] = []
async def run(self) -> None:
log.info("MeshBay Node starting up")
@@ -133,6 +136,17 @@ class NodeDaemon:
# 2. Start admin UI early (so operator can copy node key before hub login)
self._state["pk_node_ed25519"] = keys.pk_ed25519_b64
self._state["config"] = self._config
+ # Per-run token for the local admin UI (11.5.3). Not a password: it keeps
+ # other local processes and rebound browser pages out of an API that can
+ # re-initialise group keys.
+ ui_token = base64.urlsafe_b64encode(os.urandom(18)).decode().rstrip("=")
+ self._state["ui_token"] = ui_token
+ # Persisted so `meshbay-node ui` can open the browser. Nobody should ever
+ # have to copy a token out of a log or a terminal — that is not a workflow.
+ self._config.data_dir.mkdir(parents=True, exist_ok=True)
+ self._ui_token_file = self._config.data_dir / "ui-token"
+ self._ui_token_file.write_text(ui_token)
+ self._ui_token_file.chmod(0o600)
from meshbay_node.ui import create_ui_app
ui_app = create_ui_app(self._state)
ui_cfg = uvicorn.Config(
@@ -143,7 +157,7 @@ class NodeDaemon:
)
ui_server = uvicorn.Server(ui_cfg)
self._tasks.append(asyncio.create_task(ui_server.serve()))
- log.info("Admin UI at http://localhost:%d", self._config.node.ui_port)
+ log.info("Admin UI ready — open it with: meshbay-node ui")
# 3. Hub connection (Ed25519 auth — retries until node key is linked)
hub_cfg = HubConfig(
@@ -162,6 +176,14 @@ class NodeDaemon:
await self._bundle_store.open()
log.info("Bundle store opened: %s", data_dir / "bundles.db")
+ # 4b. Roster — who this node recognises and which keys are theirs.
+ # Node authority is established here, locally, and never learned from
+ # the hub: a hub that could name the operator's key could install
+ # itself as node administrator.
+ self._roster = Roster(db_path=data_dir / "roster.db")
+ await self._roster.open()
+ await self._roster.purge_expired()
+
# X25519 key material for GEK unwrapping
from cryptography.hazmat.primitives import serialization
sk_x_raw = keys.sk_x25519.private_bytes(
@@ -210,6 +232,10 @@ class NodeDaemon:
"gek": gek,
"shared_root": shared_root,
"index": indexer.index,
+ "visibility": group_cfg.visibility,
+ # Admission policy comes from node.toml, never from the hub:
+ # a hub that could declare a group open would be handed its key.
+ "join_policy": group_cfg.join_policy,
}
if not groups_ctx:
@@ -246,7 +272,11 @@ class NodeDaemon:
groups=groups_ctx,
denylist=denylist,
)
- self._webrtc._ctx["chat_store"] = first.get("chat_store")
+ # No global chat_store here: each group's store lives in
+ # groups_ctx[gid]["chat_store"] and is resolved per session via
+ # _group_ctx(). Assigning the first group's store transport-wide
+ # sent every group's chat to one database and served it back to
+ # members of every other group (finding H1).
self._webrtc._ctx["hub_ws"] = _WsSender(hub)
self._webrtc._ctx["node_user_id"] = session.user_id
self._webrtc._ctx["audit_store"] = self._audit_store
@@ -255,17 +285,27 @@ class NodeDaemon:
self._webrtc._ctx["pk_x25519_raw"] = pk_x_raw
self._webrtc._ctx["pk_x25519_b64"] = keys.pk_x25519_b64
- admin_pk = self._resolve_admin_pk(keys)
+ self._webrtc._ctx["roster"] = self._roster
+ self._webrtc._ctx["invite_ttl"] = (
+ self._config.node.invite_ttl_hours * 3600)
+ admin_pk = self._legacy_admin_pk()
+ paired = await self._roster.has_operator() if self._roster else False
if admin_pk:
self._webrtc._ctx["admin_pk_ed25519"] = admin_pk
- log.info("Admin Ed25519 key pinned for node sovereignty")
+ self._webrtc._ctx["has_admin_authority"] = paired
+ if paired or admin_pk:
+ sources = ([] if not paired else ["paired operator"]) + \
+ ([] if not admin_pk else ["node.toml admin_pk"])
+ log.info("Node authority: %s", " + ".join(sources))
else:
- log.warning("No admin_pk_ed25519 — admin operations disabled")
+ log.warning(
+ "No operator paired — invites and file deletion are "
+ "refused. Run: meshbay-node operator pair")
log.info("WebRTC transport ready")
else:
log.warning("WebRTC not available (aiortc not installed)")
- # 7. QUIC + TCP chunk servers
+ # 7. QUIC chunk server (LAN / port-forwarded / hub-less direct access)
if QUIC_AVAILABLE:
self._quic_server = QuicChunkServer(
sk_node=keys.sk_ed25519,
@@ -282,19 +322,6 @@ class NodeDaemon:
log.info("QUIC server on port %d (%d groups)",
self._config.node.quic_port, len(groups_ctx))
- self._tcp_server = ChunkServer(
- sk_node=keys.sk_ed25519,
- hub_pk_pem=session.hub_pk_pem,
- gek=first["gek"],
- shared_root=first["shared_root"],
- index=first["index"],
- host="0.0.0.0",
- port=self._config.node.port,
- groups=groups_ctx,
- )
- await self._tcp_server.start()
- log.info("TCP+TLS server on port %d", self._config.node.port)
-
# 8. Hub WebSocket (signaling + revocations + WebRTC offers)
async def on_webrtc_offer(sdp, peer_id, ice_candidates):
if not self._webrtc:
@@ -324,8 +351,15 @@ class NodeDaemon:
tid = payload.get("target_id", "")
if target == "user":
denylist.deny_user(tid)
+ elif target == "group":
+ # H4: previously dropped on the floor, so "suspend a
+ # group" was a hub-only gesture that no node enforced.
+ denylist.deny_group(tid)
+ self._drop_group_sessions(tid)
elif target == "jti":
denylist.deny_jti(tid)
+ else:
+ log.warning("Unknown revocation target: %r", target)
except Exception as e:
log.warning("Invalid revocation token: %s", e)
@@ -338,37 +372,17 @@ class NodeDaemon:
self._tasks.append(ws_task)
log.info("Hub WS task started")
- # 9. HTTP file API (one per group)
- for gid, gctx in groups_ctx.items():
- group_cfg = next(
- (g for g in self._config.groups if g.id == gid), None)
- if not group_cfg:
- continue
- http_app = create_http_app(
- sk_node=keys.sk_ed25519,
- hub_pk_pem=session.hub_pk_pem,
- shared_root=gctx["shared_root"],
- index=gctx["index"],
- group_id=gid,
- group_name=group_cfg.name,
- gek=gctx.get("gek"),
- )
- http_cfg = uvicorn.Config(
- http_app,
- host="0.0.0.0",
- port=group_cfg.http_port,
- log_level="warning",
- )
- http_server = uvicorn.Server(http_cfg)
- self._http_servers.append(http_server)
- self._tasks.append(asyncio.create_task(http_server.serve()))
- log.info("HTTP API on port %d for group %s",
- group_cfg.http_port, group_cfg.name)
+ # 9. (removed in Phase 11.5) The per-group HTTP file API used to start here.
+ # It served the Mesh Group Index and raw plaintext files on 0.0.0.0 with no
+ # authentication, for private groups too — finding C1. Every client path now
+ # goes through the MNP handshake (JWT + group claim + GEK proof).
# 10. Update admin UI state (UI already running from step 2)
self._state["groups_ctx"] = groups_ctx
self._state["audit_store"] = self._audit_store
self._state["bundle_store"] = self._bundle_store
+ self._state["roster"] = self._roster
+ self._state["node_user_id"] = session.user_id
self._state["webrtc"] = self._webrtc
self._state["hub"] = hub
self._state["pk_x25519_raw"] = pk_x_raw
@@ -379,9 +393,15 @@ class NodeDaemon:
"yes" if self._webrtc else "no",
"yes" if self._quic_server else "no")
- # 11. Initial swarm registration
+ # 11. Initial swarm registration — PUBLIC groups only.
+ # Finding H7: registering every group's hashes hands the hub a content
+ # fingerprint of every private file on the node, which is exactly the
+ # metadata the "hub stores no content metadata" claim rules out. It also
+ # lets anyone confirm whether a known file exists in the network.
endpoint = f"webrtc:{self._config.node.quic_port}"
for gctx in groups_ctx.values():
+ if gctx.get("visibility") != "public":
+ continue
hashes = [e.id for e in gctx["index"].entries]
if hashes:
asyncio.ensure_future(self._register_swarm(hashes, endpoint))
@@ -403,14 +423,28 @@ class NodeDaemon:
return await hub.startup(endpoint_hint=None)
except _httpx.HTTPStatusError as e:
body = e.response.text if hasattr(e.response, 'text') else ''
- if e.response.status_code == 401 and "No node key" in body:
- self._state["status"] = "waiting_for_node_key"
- log.warning(
- "Node key not linked — open admin UI at "
- "http://localhost:%d, copy the key, and paste it in "
- "Settings > Link Node on the hub. Retrying in 30s...",
- self._config.node.ui_port,
- )
+ # Any 401 here needs a human at a browser, and the operator needs
+ # this daemon alive to read its public key out of the local admin
+ # UI. Exiting would take that UI down and strand them — which is
+ # exactly what happened when a node was started before its owner
+ # had registered.
+ if e.response.status_code == 401:
+ if "No node key" in body:
+ self._state["status"] = "waiting_for_node_key"
+ log.warning(
+ "Node key not linked. Open the admin UI, copy this "
+ "node's key, and paste it in Settings > Link Node on "
+ "%s. Retrying in 30s...",
+ self._config.hub.url,
+ )
+ else:
+ self._state["status"] = "waiting_for_account"
+ log.warning(
+ "Hub rejected the node credentials for user %r. "
+ "Register that account on %s first, then link this "
+ "node's key. Retrying in 30s...",
+ self._config.hub.username, self._config.hub.url,
+ )
await asyncio.sleep(30)
else:
raise
@@ -448,21 +482,25 @@ class NodeDaemon:
log.warning("No unwrappable GEK bundle found for group %s", group_id[:8])
return None
- def _resolve_admin_pk(self, keys: NodeKeys) -> Ed25519PublicKey | None:
- """Resolve the admin Ed25519 public key: config → auto-pin from node keystore."""
- if self._config.admin_pk_ed25519:
- try:
- raw = base64.b64decode(self._config.admin_pk_ed25519)
- return Ed25519PublicKey.from_public_bytes(raw)
- except Exception as e:
- log.error("Invalid admin_pk_ed25519 in config: %s", e)
- return None
+ def _legacy_admin_pk(self) -> Ed25519PublicKey | None:
+ """
+ The pre-roster way of naming the operator: `admin_pk_ed25519` in node.toml.
- pk = keys.sk_ed25519.public_key()
- from meshbay_common.crypto import pk_to_b64
- pk_b64 = pk_to_b64(pk)
- log.info("Auto-pinning admin key from node keystore: %s", pk_b64[:16])
- return pk
+ Still honoured so a deployment configured that way keeps working, but no
+ longer the only path — and the auto-pin that used to stand in for it is
+ gone. It pinned the node's *keystore* key while the browser signed with the
+ user's *identity* key, so admin operations failed closed with a signature
+ error that looked like a bug elsewhere (finding M3). An operator now pairs
+ a browser with `meshbay-node operator pair`.
+ """
+ if not self._config.admin_pk_ed25519:
+ return None
+ try:
+ raw = base64.b64decode(self._config.admin_pk_ed25519)
+ return Ed25519PublicKey.from_public_bytes(raw)
+ except Exception as e:
+ log.error("Invalid admin_pk_ed25519 in config: %s", e)
+ return None
async def _on_index_change(self, indexer: DirectoryIndexer) -> None:
"""Called when a DirectoryIndexer detects file changes."""
@@ -498,13 +536,25 @@ class NodeDaemon:
if pushed:
log.info("Index pushed to %d WebRTC peers", pushed)
- # 11.9 — Register file hashes with hub swarm table
- if self._hub and self._state.get("endpoint_hint"):
+ # 11.9 — Register file hashes with hub swarm table (public groups only, H7)
+ group_cfg = next(
+ (g for g in self._config.groups if g.id == group_id), None)
+ if (self._hub and self._state.get("endpoint_hint")
+ and group_cfg and group_cfg.visibility == "public"):
hashes = [e.id for e in idx.entries]
if hashes:
endpoint = f"webrtc:{self._config.node.quic_port}"
asyncio.ensure_future(self._register_swarm(hashes, endpoint))
+ def _drop_group_sessions(self, group_id: str) -> None:
+ """Close live sessions for a revoked group (H4)."""
+ if not self._webrtc or not group_id:
+ return
+ for session in list(self._webrtc._sessions.values()):
+ if session._group_id == group_id:
+ asyncio.ensure_future(session.close())
+ log.info("Dropped session for revoked group %s", group_id[:8])
+
async def _register_swarm(self, hashes: list[str], endpoint: str) -> None:
try:
n = await self._hub.register_swarm(hashes, endpoint)
@@ -533,6 +583,9 @@ class NodeDaemon:
if self._bundle_store:
await self._bundle_store.close()
+ if self._roster:
+ await self._roster.close()
+
for store in self._chat_stores.values():
await store.close()
@@ -541,15 +594,68 @@ class NodeDaemon:
if self._quic_server:
await self._quic_server.stop()
- if self._tcp_server:
- await self._tcp_server.stop()
- for server in self._http_servers:
- server.should_exit = True
+ token_file = getattr(self, "_ui_token_file", None)
+ if token_file is not None:
+ token_file.unlink(missing_ok=True)
log.info("Node stopped")
+# ── CLI helpers ───────────────────────────────────────────────────────────────
+
+def _daemon_api(cfg: Config, path: str, method: str = "GET",
+ timeout: int = 30) -> dict:
+ """
+ Call the daemon's loopback API.
+
+ The daemon owns the roster, the hub session and the live group contexts, so
+ the CLI asks it to act rather than opening its databases behind its back. It
+ also means every operator action goes through the same authorization as the
+ admin UI (the per-run session token, 11.5.3).
+ """
+ import json as _json
+ import urllib.error
+ import urllib.parse
+ import urllib.request
+
+ token_file = cfg.data_dir / "ui-token"
+ if not token_file.exists():
+ print("Node is not running — start it with: meshbay-node")
+ sys.exit(1)
+
+ sep = "&" if "?" in path else "?"
+ url = (f"http://127.0.0.1:{cfg.node.ui_port}{path}"
+ f"{sep}t={token_file.read_text().strip()}")
+ try:
+ req = urllib.request.Request(url, method=method)
+ with urllib.request.urlopen(req, timeout=timeout) as r:
+ return _json.loads(r.read())
+ except urllib.error.HTTPError as e:
+ body = e.read().decode()[:300]
+ try:
+ detail = _json.loads(body).get("error", body)
+ except Exception:
+ detail = body
+ print(f"failed: {detail}")
+ sys.exit(1)
+ except Exception as e:
+ print(f"failed: {e}")
+ sys.exit(1)
+
+
+def _resolve_group(cfg: Config, group: str | None) -> str:
+ """The group argument, or the only configured one."""
+ if group:
+ return group
+ configured = [g.id for g in cfg.groups if g.id]
+ if len(configured) == 1:
+ return configured[0]
+ print("--group is required (several groups configured)"
+ if configured else "no group configured in node.toml")
+ sys.exit(1)
+
+
# ── Entry point ───────────────────────────────────────────────────────────────
def main() -> None:
@@ -557,16 +663,28 @@ def main() -> None:
parser = argparse.ArgumentParser(description="MeshBay Node daemon")
parser.add_argument("command", nargs="?",
- choices=["init", "calibrate-argon2"],
- help="init: write example config | calibrate-argon2: benchmark")
+ choices=["init", "status", "ui", "gek-init", "operator",
+ "member", "calibrate-argon2"],
+ help="init: write example config | status: node state and keys "
+ "| ui: print the admin UI URL | operator pair: pair a "
+ "browser with this node | member list|invite|revoke|unpin "
+ "| calibrate-argon2: benchmark")
+ parser.add_argument("subcommand", nargs="?",
+ help="'pair' for operator; list|invite|revoke|unpin for member")
+ parser.add_argument("target", nargs="?",
+ help="username, for member invite|revoke|unpin")
parser.add_argument("--config", type=Path, default=None,
help="Config file path")
+ parser.add_argument("--group", default=None,
+ help="group id (optional if only one is configured)")
parser.add_argument("--log-level", default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR"])
args = parser.parse_args()
+ # Query commands print a report; library logging would interleave with it.
+ quiet = args.command in ("status", "ui", "gek-init", "operator", "member")
logging.basicConfig(
- level=getattr(logging, args.log_level),
+ level=logging.ERROR if quiet else getattr(logging, args.log_level),
format="%(asctime)s %(levelname)-8s %(name)s: %(message)s",
)
@@ -579,6 +697,216 @@ def main() -> None:
calibrate_argon2()
return
+ if args.command == "status":
+ import json as _json
+ import urllib.request
+
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ print(f"hub {cfg.hub.url} (user {cfg.hub.username or '—'})")
+
+ # Read straight from the keystore: the operator needs this key to link the
+ # node, and that happens before the daemon can ever stay running.
+ try:
+ keys = load_or_create_keystore(
+ path=cfg.keystore.path, unlock_file=cfg.keystore.unlock_file)
+ print(f"node key {keys.pk_ed25519_b64}")
+ except Exception as e:
+ print(f"node key <keystore locked: {e}>")
+
+ token_file = cfg.data_dir / "ui-token"
+ live = None
+ if token_file.exists():
+ try:
+ url = (f"http://127.0.0.1:{cfg.node.ui_port}"
+ f"/api/status?t={token_file.read_text().strip()}")
+ with urllib.request.urlopen(url, timeout=3) as r:
+ live = _json.loads(r.read())
+ except Exception:
+ live = None
+
+ if live:
+ print(f"daemon running — {live.get('status')}")
+ print(f"node_id {live.get('endpoint_hint') or '—'}")
+ print(f"groups {live.get('group_count', 0)}"
+ f" files {live.get('total_files', 0)}"
+ f" peers {live.get('webrtc_peers', 0)}")
+ print(f"admin UI meshbay-node ui")
+ else:
+ print("daemon not running")
+
+ print(f"config {DEFAULT_CONFIG_PATH}")
+ if not cfg.groups:
+ print("groups none configured — create a group on the hub, then add")
+ print(" a [[groups]] entry with its id and shared_dir")
+ else:
+ for g in cfg.groups:
+ print(f" group {g.name} [{g.visibility}] {g.id or '<no id>'}")
+ print(f" {g.shared_dir or '<no shared_dir>'}")
+ # Node authority: the roster is the source of truth, node.toml the legacy
+ # form. Read the DB directly so this reports correctly while the daemon is
+ # stopped — the state an operator is most often in when checking.
+ import asyncio as _asyncio
+
+ from meshbay_node.roster import Roster as _Roster
+
+ async def _read_roster() -> tuple[list, int]:
+ r = _Roster(db_path=cfg.data_dir / "roster.db")
+ await r.open()
+ try:
+ return (await r.list_members()), len(await r.list_invites())
+ finally:
+ await r.close()
+
+ try:
+ members, pending = _asyncio.run(_read_roster())
+ except Exception as e:
+ members, pending = [], 0
+ print(f"roster <unreadable: {e}>")
+
+ operators = [m for m in members if m["role"] == "operator"
+ and m["status"] == "active"]
+ if operators:
+ for op in operators:
+ print(f"operator {op.get('username') or op['user_id'][:8]}"
+ f" key {(op.get('pk_ed25519') or '')[:16]}…"
+ f" paired {op.get('pinned_at', '?')}")
+ elif cfg.admin_pk_ed25519:
+ print("operator node.toml admin_pk_ed25519 (legacy)")
+ print(" run `meshbay-node operator pair` to replace it")
+ else:
+ print("operator NONE PAIRED — file deletion and member invites are")
+ print(" refused. Run: meshbay-node operator pair")
+ if pending:
+ print(f"invites {pending} pending code(s)")
+ return
+
+ if args.command == "member":
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ sub = args.subcommand or "list"
+
+ if sub == "list":
+ group = args.group or ""
+ out = _daemon_api(
+ cfg, f"/api/roster?group_id={group}" if group else "/api/roster")
+ identities = {i["user_id"]: i for i in out.get("identities", [])}
+
+ members = out.get("members", [])
+ if not members:
+ print("no members admitted yet")
+ print("invite someone: meshbay-node member invite <username>")
+ for m in members:
+ ident = identities.get(m["user_id"], {})
+ scope = m["group_id"][:8] if m["group_id"] else "node-wide"
+ print(f"{(ident.get('username') or m['user_id'])[:20]:20} "
+ f"{m['role']:9} {m['status']:8} {scope:10} "
+ f"pinned {ident.get('pinned_at', '?')} "
+ f"({ident.get('pinned_via', '?')})")
+
+ invites = out.get("invites", [])
+ if invites:
+ print()
+ for i in invites:
+ print(f"pending invite user {i['user_id'][:12]} "
+ f"group {(i['group_id'] or 'node-wide')[:8]} "
+ f"expires {i['expires_at']}")
+ return
+
+ if not args.target:
+ print(f"usage: meshbay-node member {sub} <username>")
+ sys.exit(1)
+
+ if sub == "invite":
+ group_id = _resolve_group(cfg, args.group)
+ out = _daemon_api(
+ cfg, f"/api/groups/{group_id}/invites?username={args.target}",
+ method="POST")
+ from meshbay_node.roster import write_code_file
+ path = write_code_file(cfg.data_dir, out["code"],
+ out.get("expires_at", ""), name="invite-code")
+ print(f"INVITATION CODE {out['code']}")
+ print(f"valid until {out.get('expires_at', '?')}")
+ print()
+ print(f"Send it to {args.target} however you normally talk. It works")
+ print("once, for that account only, and never passes through the hub.")
+ print("They enter it the first time they open the group — you do not")
+ print("need to be online then.")
+ print()
+ print(f"also written to {path}")
+ return
+
+ # revoke and unpin both name a person; the daemon resolves the account.
+ # It tries its own roster first and falls back to the hub, so a node that
+ # pinned someone before invitations carried a name is still manageable.
+ match = _daemon_api(cfg, f"/api/resolve?username={args.target}")
+
+ if sub == "revoke":
+ group_id = _resolve_group(cfg, args.group)
+ out = _daemon_api(
+ cfg, f"/api/members/{match['user_id']}/revoke?group_id={group_id}",
+ method="POST")
+ print(f"{args.target} revoked from {group_id[:8]}")
+ print("They stop receiving the group key on their next connection.")
+ print("They still hold the current one — rotate it:")
+ print(f" meshbay-node gek-init --group {group_id}")
+ return
+
+ if sub == "unpin":
+ _daemon_api(cfg, f"/api/members/{match['user_id']}/unpin", method="POST")
+ print(f"{args.target} unpinned — they can pair again with a new key")
+ print(f"issue a code: meshbay-node member invite {args.target}")
+ return
+
+ print("usage: meshbay-node member list|invite|revoke|unpin")
+ sys.exit(1)
+
+ if args.command == "gek-init":
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ group_id = _resolve_group(cfg, args.group)
+ out = _daemon_api(cfg, f"/api/groups/{group_id}/gek",
+ method="POST", timeout=60)
+
+ print(f"GEK ready for {group_id}")
+ print(f" {out.get('authorized_members', 0)} authorized member(s) — each "
+ f"receives the key on connect")
+ for err in out.get("errors") or []:
+ print(f" ! {err}")
+ return
+
+ if args.command == "operator":
+ if args.subcommand != "pair":
+ print("usage: meshbay-node operator pair")
+ sys.exit(1)
+
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ out = _daemon_api(cfg, "/api/operator/pair", method="POST")
+
+ from meshbay_node.roster import write_code_file
+ path = write_code_file(cfg.data_dir, out["code"], out.get("expires_at", ""))
+
+ print(f"PAIRING CODE {out['code']}")
+ print(f"valid until {out.get('expires_at', '?')}")
+ print()
+ print("Sign in to the web app as this node's operator, open one of your")
+ print("groups, go to the Members tab and enter the code there.")
+ print("It works once, for that account only, and authorizes invites and")
+ print("file deletion from that browser.")
+ print()
+ print(f"also written to {path}")
+ return
+
+ if args.command == "ui":
+ cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
+ token_file = cfg.data_dir / "ui-token"
+ if not token_file.exists():
+ print("Node does not appear to be running — start it with: meshbay-node")
+ sys.exit(1)
+ print(f"http://127.0.0.1:{cfg.node.ui_port}"
+ f"/?t={token_file.read_text().strip()}")
+ print()
+ print("The UI listens on loopback only. From another machine:")
+ print(f" ssh -L {cfg.node.ui_port}:127.0.0.1:{cfg.node.ui_port} <this-host>")
+ return
+
cfg = load_config(args.config or DEFAULT_CONFIG_PATH)
if not cfg.hub.username:
print("Error: hub.username not set in config. Run: meshbay-node init")
diff --git a/packages/meshbay-node/src/meshbay_node/hub_client.py b/packages/meshbay-node/src/meshbay_node/hub_client.py
index 432af0a..d8417e3 100644
--- a/packages/meshbay-node/src/meshbay_node/hub_client.py
+++ b/packages/meshbay-node/src/meshbay_node/hub_client.py
@@ -13,6 +13,7 @@ No auth_key or password is ever stored on or transmitted from the node.
The hub issues a node-scoped JWT that cannot manage group membership.
"""
+import asyncio
import base64
import json
import logging
@@ -127,8 +128,8 @@ class HubClient:
access_token = data["access_token"]
decoded = jwt.decode(access_token, hub_pk_pem, algorithms=["EdDSA"])
- assert decoded["pk_user"] == self._keys.pk_ed25519_b64, \
- "Hub returned token for wrong public key"
+ # No pk_user claim to check any more: tokens carry no key. What binds this
+ # token to this node is the Ed25519 challenge it was issued against.
assert "jti" in decoded, "Hub token missing jti — hub is outdated"
assert decoded.get("scope") == "node", \
"Expected node-scoped token"
@@ -163,9 +164,19 @@ class HubClient:
raise RuntimeError("Not logged in")
await self.ensure_fresh_token()
+ # Proof of possession of the node key (M8) — same domain-separated shape
+ # as node_auth, so a signature for one can never satisfy the other.
+ timestamp = int(time.time())
+ message = (f"meshbay:node_announce:{self._session.user_id}:"
+ f"{self._keys.pk_ed25519_b64}:{timestamp}").encode()
+ signature = base64.b64encode(
+ self._keys.sk_ed25519.sign(message)).decode()
+
r = await self._http.post("/v1/nodes/announce", json={
"pk_node": self._keys.pk_ed25519_b64,
"endpoint_hint": endpoint_hint,
+ "timestamp": timestamp,
+ "signature": signature,
}, headers=self._session.auth_headers)
r.raise_for_status()
node_id = r.json()["node_id"]
@@ -219,9 +230,19 @@ class HubClient:
hub_url = self._session.hub_url.replace("https://", "wss://").replace("http://", "ws://")
ws_url = f"{hub_url}/v1/nodes/ws"
+ # Offers are handled off the read loop (see below), so keep a handle on
+ # the tasks to avoid them being garbage-collected mid-negotiation.
+ pending: set[asyncio.Task] = set()
+
while True:
try:
- async with websockets.connect(ws_url) as ws:
+ # Explicit keepalive: this connection is how a node stays visible
+ # to the hub, and a silently half-open socket looks exactly like a
+ # working one until someone notices the node has vanished.
+ async with websockets.connect(
+ ws_url, ping_interval=20, ping_timeout=20, close_timeout=5,
+ open_timeout=15,
+ ) as ws:
auth_msg = {
"type": "auth",
"token": self._session.access_token,
@@ -230,10 +251,20 @@ class HubClient:
if group_ids:
auth_msg["group_ids"] = group_ids
await ws.send(json.dumps(auth_msg))
- auth_resp = json.loads(await ws.recv())
+ # Bounded: a hub that accepts the socket and then says nothing
+ # — which is what it does for a few seconds while restarting —
+ # would otherwise park this task here forever, with the node
+ # running, silent, and invisible to everyone.
+ auth_resp = json.loads(
+ await asyncio.wait_for(ws.recv(), timeout=15))
if auth_resp.get("type") != "auth_ok":
- log.error("WS auth failed: %s", auth_resp)
- return
+ # Not fatal: the token may simply have expired while we
+ # were disconnected. Refresh on the next pass rather than
+ # ending the task, which used to strand the node for good.
+ log.warning("WS auth refused: %s — retrying in 5s", auth_resp)
+ await asyncio.sleep(5)
+ await self.ensure_fresh_token()
+ continue
self._ws = ws
log.info("Hub WS connected")
@@ -250,28 +281,60 @@ class HubClient:
on_revocation(msg.get("token", ""))
elif mtype == "webrtc_offer" and on_webrtc_offer:
- answer = await on_webrtc_offer(
- msg["sdp"], msg["peer_id"],
- msg.get("ice_candidates", []))
- if answer:
- await ws.send(json.dumps({
- "type": "webrtc_answer",
- "peer_id": msg["peer_id"],
- "sdp": answer[0],
- "ice_candidates": answer[1],
- }))
+ # Answered off the read loop on purpose. Awaiting the
+ # handler here meant one slow negotiation stopped the
+ # node reading this socket at all: no pings answered,
+ # no close frame noticed, no further offers served. A
+ # client that gave up mid-ICE left the node in
+ # CLOSE-WAIT, still running but invisible to the hub
+ # and unreachable by everyone, until it was restarted.
+ task = asyncio.create_task(
+ self._answer_offer(ws, on_webrtc_offer, msg))
+ pending.add(task)
+ task.add_done_callback(pending.discard)
elif mtype == "pong":
pass
except asyncio.CancelledError:
+ for task in pending:
+ task.cancel()
raise
except Exception as e:
log.warning("Hub WS disconnected: %s — reconnecting in 5s", e)
await asyncio.sleep(5)
+ else:
+ # A clean close ends the `async for` without raising. Say so, so a
+ # node that quietly stopped being reachable leaves a trace.
+ log.warning("Hub WS closed by the hub — reconnecting in 5s")
+ await asyncio.sleep(5)
finally:
self._ws = None
+ async def _answer_offer(self, ws, on_webrtc_offer, msg: dict) -> None:
+ """Negotiate one WebRTC offer and return the answer, off the read loop."""
+ try:
+ answer = await on_webrtc_offer(
+ msg["sdp"], msg["peer_id"], msg.get("ice_candidates", []))
+ except Exception as e:
+ log.warning("WebRTC offer from %s failed: %s",
+ str(msg.get("peer_id"))[:8], e)
+ return
+ if not answer:
+ return
+ try:
+ await ws.send(json.dumps({
+ "type": "webrtc_answer",
+ "peer_id": msg["peer_id"],
+ "sdp": answer[0],
+ "ice_candidates": answer[1],
+ }))
+ except Exception as e:
+ # The socket may have gone while we were negotiating; the client will
+ # retry, and the read loop is reconnecting.
+ log.warning("Could not deliver WebRTC answer to %s: %s",
+ str(msg.get("peer_id"))[:8], e)
+
# ── Swarm registration ─────────────────────────────────────────────────
async def register_swarm(self, content_hashes: list[str], endpoint: str) -> int:
diff --git a/packages/meshbay-node/src/meshbay_node/keystore.py b/packages/meshbay-node/src/meshbay_node/keystore.py
index 3777af0..59fc719 100644
--- a/packages/meshbay-node/src/meshbay_node/keystore.py
+++ b/packages/meshbay-node/src/meshbay_node/keystore.py
@@ -39,6 +39,12 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from meshbay_common.crypto import (
+ ARGON2_ITERATIONS,
+ ARGON2_LANES,
+ ARGON2_MEMORY_COST,
+ LEGACY_ARGON2_ITERATIONS,
+ LEGACY_ARGON2_LANES,
+ LEGACY_ARGON2_MEMORY_COST,
decrypt_keystore,
derive_keystore_key,
encrypt_keystore,
@@ -173,7 +179,18 @@ def load_keystore(
tag = base64.b64decode(envelope["tag_b64"])
ct = base64.b64decode(envelope["ciphertext_b64"])
- aes_key = derive_keystore_key(pwd, salt)
+ # Envelopes written before M2 carry no parameters and used the 64 MB profile.
+ params = envelope.get("argon2", {
+ "iterations": LEGACY_ARGON2_ITERATIONS,
+ "memory_cost": LEGACY_ARGON2_MEMORY_COST,
+ "lanes": LEGACY_ARGON2_LANES,
+ })
+ aes_key = derive_keystore_key(
+ pwd, salt,
+ iterations=params.get("iterations"),
+ memory_cost=params.get("memory_cost"),
+ lanes=params.get("lanes"),
+ )
try:
plaintext = decrypt_keystore(iv, ct, tag, aes_key)
except Exception:
@@ -205,6 +222,12 @@ def _write_keystore(path: Path, keys: NodeKeys, password: str) -> None:
envelope = {
"version": KEYSTORE_VERSION,
"argon2_salt_b64": base64.b64encode(salt).decode(),
+ # Recorded so parameters can be raised later without orphaning this file.
+ "argon2": {
+ "iterations": ARGON2_ITERATIONS,
+ "memory_cost": ARGON2_MEMORY_COST,
+ "lanes": ARGON2_LANES,
+ },
"iv_b64": base64.b64encode(iv).decode(),
"tag_b64": base64.b64encode(tag).decode(),
"ciphertext_b64": base64.b64encode(ct).decode(),
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
new file mode 100644
index 0000000..6bda56b
--- /dev/null
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -0,0 +1,400 @@
+"""
+Node roster — who this node recognises, and which keys are theirs.
+
+The node keeps its own answer to "may this person have the group key", derived from
+what the operator authorized locally. It is deliberately NOT derived from the hub:
+the hub decides group membership, and a hub that invents an account and mints a
+token for it would otherwise collect the GEK on connect. Hub membership is an input
+to the decision; it is not the decision.
+
+Three tables:
+
+ identities — one row per person, not per group. Someone paired for one group
+ needs no code for the next one on the same node.
+ members — role and status per (group, user).
+ invites — one-time pairing codes, stored as a hash. The code itself exists
+ only in the operator's hands and the invitee's.
+
+The code is what binds a public key to an account without asking the hub
+(finding H3). See `docs/invite-pairing-v1.md`.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import logging
+import os
+import secrets
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+
+import aiosqlite
+
+log = logging.getLogger(__name__)
+
+# Crockford base32 without I, L, O and U: no character pair a human can confuse
+# when reading a code aloud or typing it from a phone screen.
+_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
+CODE_LEN = 8 # 8 × 5 bits = 40 bits of entropy
+
+# Two different rhythms, so two different lifetimes.
+#
+# An invitation crosses a human conversation: it is sent by mail or message and
+# answered whenever the other person next looks. A day is not enough — the code
+# dies over a weekend and someone has to be at a browser, with the node online, to
+# issue another one.
+#
+# Operator pairing crosses an SSH session: the code is printed and typed minutes
+# later. There is no reason for it to outlive the sitting.
+#
+# The longer window costs little: a code is single use, bound to one account,
+# never seen by the hub, and 40 bits do not fall to guessing in a week against the
+# node-wide lockout.
+DEFAULT_INVITE_TTL = 7 * 24 * 3600 # seconds — member invitations
+DEFAULT_PAIR_TTL = 24 * 3600 # seconds — operator pairing
+
+_SCHEMA = """\
+CREATE TABLE IF NOT EXISTS identities (
+ user_id TEXT PRIMARY KEY,
+ username TEXT NOT NULL,
+ pk_ed25519 TEXT NOT NULL,
+ pk_x25519 TEXT NOT NULL,
+ pinned_at TEXT NOT NULL,
+ pinned_via TEXT NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS members (
+ group_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ role TEXT NOT NULL,
+ status TEXT NOT NULL,
+ approved_by TEXT NOT NULL,
+ approved_at TEXT NOT NULL,
+ PRIMARY KEY (group_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS invites (
+ code_hash TEXT PRIMARY KEY,
+ group_id TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ username TEXT NOT NULL DEFAULT '',
+ role TEXT NOT NULL,
+ created_by TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ used_at TEXT
+);
+"""
+
+
+def generate_code() -> str:
+ """A fresh pairing code, formatted for a human to read out: XXXX-XXXX."""
+ raw = "".join(secrets.choice(_ALPHABET) for _ in range(CODE_LEN))
+ return f"{raw[:4]}-{raw[4:]}"
+
+
+def normalize_code(code: str) -> str:
+ """
+ Fold what a human typed onto what was generated.
+
+ Crockford's rules: case-insensitive, dashes and spaces are decoration, and the
+ excluded letters map onto the digits they resemble. Someone reading a code over
+ the phone should not be able to get it wrong in a way we could have absorbed.
+ """
+ out = []
+ for ch in code.upper():
+ if ch in "- \t":
+ continue
+ if ch in "IL":
+ out.append("1")
+ elif ch == "O":
+ out.append("0")
+ elif ch == "U":
+ out.append("V")
+ else:
+ out.append(ch)
+ return "".join(out)
+
+
+def hash_code(code: str) -> str:
+ """
+ Store codes hashed: a stolen roster DB must not yield usable invitations.
+
+ SHA-256 rather than a password KDF on purpose — the input is 40 bits of
+ uniformly random secret, not a human-chosen string, so there is nothing for a
+ slow hash to defend.
+ """
+ return hashlib.sha256(normalize_code(code).encode()).hexdigest()
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+class Roster:
+ def __init__(self, db_path: Path):
+ self._db_path = db_path
+ self._db: aiosqlite.Connection | None = None
+
+ async def open(self) -> None:
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
+ self._db = await aiosqlite.connect(str(self._db_path))
+ self._db.row_factory = aiosqlite.Row
+ # WAL: the CLI writes invites (`operator pair`) while the daemon reads them.
+ await self._db.execute("PRAGMA journal_mode=WAL")
+ await self._db.executescript(_SCHEMA)
+ # invites.username was added after the first deployments: the name is what
+ # the operator types, and it cannot be recovered from the JWT because the
+ # hub does not put one there. CREATE TABLE IF NOT EXISTS will not add a
+ # column to a table that already exists.
+ async with self._db.execute("PRAGMA table_info(invites)") as cur:
+ columns = {r[1] for r in await cur.fetchall()}
+ if "username" not in columns:
+ await self._db.execute(
+ "ALTER TABLE invites ADD COLUMN username TEXT NOT NULL DEFAULT ''")
+ await self._db.commit()
+
+ async def close(self) -> None:
+ if self._db:
+ await self._db.close()
+ self._db = None
+
+ # ── Identities ───────────────────────────────────────────────────────────
+
+ async def pin_identity(
+ self,
+ user_id: str,
+ username: str,
+ pk_ed25519: str,
+ pk_x25519: str,
+ via: str,
+ ) -> None:
+ assert self._db
+ await self._db.execute(
+ "INSERT OR REPLACE INTO identities "
+ "(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ (user_id, username, pk_ed25519, pk_x25519, _now(), via),
+ )
+ await self._db.commit()
+
+ async def get_identity(self, user_id: str) -> dict | None:
+ assert self._db
+ async with self._db.execute(
+ "SELECT * FROM identities WHERE user_id = ?", (user_id,)
+ ) as cur:
+ row = await cur.fetchone()
+ return dict(row) if row else None
+
+ async def unpin(self, user_id: str) -> bool:
+ assert self._db
+ cur = await self._db.execute(
+ "DELETE FROM identities WHERE user_id = ?", (user_id,))
+ await self._db.commit()
+ return cur.rowcount > 0
+
+ async def list_identities(self) -> list[dict]:
+ assert self._db
+ async with self._db.execute(
+ "SELECT * FROM identities ORDER BY pinned_at"
+ ) as cur:
+ return [dict(r) for r in await cur.fetchall()]
+
+ # ── Authority ────────────────────────────────────────────────────────────
+
+ async def operator_pks(self) -> list[str]:
+ """
+ Base64 Ed25519 keys allowed to authorize admin operations on this node.
+
+ Read fresh on every check rather than cached: an unpin must take effect at
+ once, and this runs only on admin operations, which are rare.
+ """
+ assert self._db
+ async with self._db.execute(
+ "SELECT i.pk_ed25519 FROM identities i "
+ "JOIN members m ON m.user_id = i.user_id "
+ "WHERE m.role = 'operator' AND m.status = 'active'"
+ ) as cur:
+ return [r["pk_ed25519"] for r in await cur.fetchall()]
+
+ async def has_operator(self) -> bool:
+ return bool(await self.operator_pks())
+
+ async def is_authorized(self, group_id: str, user_id: str) -> bool:
+ """
+ May this person be handed the group key?
+
+ The node's own answer, not the hub's. Hub membership is what lets someone
+ reach the node; this is what decides whether the key is wrapped for them —
+ otherwise a hub that invents an account and mints a token for it would be
+ served the GEK on connect.
+
+ An operator is authorized for every group this node hosts: their authority
+ is node-wide and is recorded with an empty group_id.
+ """
+ assert self._db
+ async with self._db.execute(
+ "SELECT 1 FROM members WHERE user_id = ? AND status = 'active' "
+ "AND (group_id = ? OR (group_id = '' AND role = 'operator')) LIMIT 1",
+ (user_id, group_id),
+ ) as cur:
+ return await cur.fetchone() is not None
+
+ # ── Members ──────────────────────────────────────────────────────────────
+
+ async def set_member(
+ self,
+ group_id: str,
+ user_id: str,
+ role: str,
+ status: str,
+ approved_by: str,
+ ) -> None:
+ assert self._db
+ await self._db.execute(
+ "INSERT OR REPLACE INTO members "
+ "(group_id, user_id, role, status, approved_by, approved_at) "
+ "VALUES (?, ?, ?, ?, ?, ?)",
+ (group_id, user_id, role, status, approved_by, _now()),
+ )
+ await self._db.commit()
+
+ async def get_member(self, group_id: str, user_id: str) -> dict | None:
+ assert self._db
+ async with self._db.execute(
+ "SELECT * FROM members WHERE group_id = ? AND user_id = ?",
+ (group_id, user_id),
+ ) as cur:
+ row = await cur.fetchone()
+ return dict(row) if row else None
+
+ async def list_members(self, group_id: str | None = None) -> list[dict]:
+ assert self._db
+ sql = (
+ "SELECT m.*, i.username, i.pk_ed25519, i.pinned_at, i.pinned_via "
+ "FROM members m LEFT JOIN identities i ON i.user_id = m.user_id"
+ )
+ args: tuple = ()
+ if group_id is not None:
+ sql += " WHERE m.group_id = ?"
+ args = (group_id,)
+ async with self._db.execute(sql + " ORDER BY m.approved_at", args) as cur:
+ return [dict(r) for r in await cur.fetchall()]
+
+ async def set_status(self, group_id: str, user_id: str, status: str) -> bool:
+ assert self._db
+ cur = await self._db.execute(
+ "UPDATE members SET status = ? WHERE group_id = ? AND user_id = ?",
+ (status, group_id, user_id),
+ )
+ await self._db.commit()
+ return cur.rowcount > 0
+
+ # ── Invites ──────────────────────────────────────────────────────────────
+
+ async def create_invite(
+ self,
+ group_id: str,
+ user_id: str,
+ role: str,
+ created_by: str,
+ ttl: int = DEFAULT_INVITE_TTL,
+ username: str = "",
+ ) -> str:
+ """
+ Issue a one-time code. Returns it in the clear — this is the only moment it
+ exists outside the operator's hands; only its hash is kept.
+
+ Any earlier unused invite for the same person and group is dropped, so
+ re-inviting supersedes rather than accumulating valid codes.
+ """
+ assert self._db
+ await self._db.execute(
+ "DELETE FROM invites WHERE group_id = ? AND user_id = ? AND used_at IS NULL",
+ (group_id, user_id),
+ )
+ code = generate_code()
+ expires = datetime.now(timezone.utc) + timedelta(seconds=ttl)
+ await self._db.execute(
+ "INSERT INTO invites (code_hash, group_id, user_id, username, role, "
+ "created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+ (hash_code(code), group_id, user_id, username, role, created_by, _now(),
+ expires.isoformat(timespec="seconds")),
+ )
+ await self._db.commit()
+ return code
+
+ async def consume_invite(self, code: str, user_id: str) -> dict | None:
+ """
+ Redeem a code for `user_id`, or return None.
+
+ Single use is enforced by the UPDATE's WHERE clause: two connections racing
+ the same code cannot both see `used_at IS NULL`, so exactly one wins.
+ """
+ assert self._db
+ code_hash = hash_code(code)
+ async with self._db.execute(
+ "SELECT * FROM invites WHERE code_hash = ?", (code_hash,)
+ ) as cur:
+ row = await cur.fetchone()
+ if not row:
+ return None
+
+ invite = dict(row)
+ if invite["used_at"] is not None:
+ return None
+ # A code is valid for exactly one account, so a leaked code cannot be
+ # redeemed by whoever finds it first.
+ if invite["user_id"] != user_id:
+ return None
+ if datetime.fromisoformat(invite["expires_at"]) < datetime.now(timezone.utc):
+ return None
+
+ cur = await self._db.execute(
+ "UPDATE invites SET used_at = ? WHERE code_hash = ? AND used_at IS NULL",
+ (_now(), code_hash),
+ )
+ await self._db.commit()
+ if cur.rowcount == 0:
+ return None
+ return invite
+
+ async def list_invites(self, include_used: bool = False) -> list[dict]:
+ assert self._db
+ sql = "SELECT * FROM invites"
+ if not include_used:
+ sql += " WHERE used_at IS NULL"
+ async with self._db.execute(sql + " ORDER BY created_at") as cur:
+ return [dict(r) for r in await cur.fetchall()]
+
+ async def purge_expired(self) -> int:
+ assert self._db
+ cur = await self._db.execute(
+ "DELETE FROM invites WHERE used_at IS NULL AND expires_at < ?",
+ (_now(),),
+ )
+ await self._db.commit()
+ return cur.rowcount
+
+
+async def open_roster(data_dir: Path) -> Roster:
+ roster = Roster(data_dir / "roster.db")
+ await roster.open()
+ return roster
+
+
+def write_code_file(data_dir: Path, code: str, expires_at: str,
+ name: str = "pair-code") -> Path:
+ """
+ Leave the code in a file as well as on stdout.
+
+ An operator working over SSH may not be able to copy out of their terminal,
+ and a code that can only be read off a scrolled-away screen is a dead end.
+ Pairing and invitation codes go to different files so one does not overwrite
+ the other.
+ """
+ path = data_dir / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(f"{code}\nexpires {expires_at}\n")
+ os.chmod(path, 0o600)
+ return path
diff --git a/packages/meshbay-node/src/meshbay_node/transport/__init__.py b/packages/meshbay-node/src/meshbay_node/transport/__init__.py
index df9c209..e423e35 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/__init__.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/__init__.py
@@ -1,7 +1,17 @@
-"""MeshBay Node transport layer — TCP+TLS (v1), QUIC (v2), WebRTC (browsers)."""
-from .server import ChunkServer
-from .client import ChunkClient
-from .http_server import create_http_app
+"""
+MeshBay Node transport layer — WebRTC DataChannel (primary), QUIC (direct/LAN).
+
+Transport decision (2026-08-13, second security review):
+ - WebRTC/ICE is the primary path for browser AND native clients. ICE/STUN is the
+ only NAT traversal validated on this project (2 ISPs, IPv4 STUN + IPv6, 4G CGNAT).
+ - QUIC is kept at parity for LAN, port-forwarded and hub-less `group://` access.
+ `punch_nat()` is a direct-connection helper, not a traversal stack.
+ - TCP+TLS (`server.py`/`client.py`) and the node HTTP file API (`http_server.py`)
+ were REMOVED in Phase 11.5. The HTTP API served private group indexes and
+ plaintext files with no authentication on 0.0.0.0 (finding C1); the TCP server
+ accepted a bare JWT with no GEK proof (finding C6). Neither is coming back —
+ every client path must go through the unified MNP handshake.
+"""
# QUIC transport (MNP v2) — requires aioquic>=1.0
try:
@@ -14,7 +24,7 @@ except ImportError:
Denylist = None # type: ignore[assignment,misc]
QUIC_AVAILABLE = False
-# WebRTC transport (browsers) — requires aiortc>=1.9
+# WebRTC transport (browsers + native clients) — requires aiortc>=1.9
try:
from .webrtc_server import WebRTCTransport, WebRTCPeerSession
WEBRTC_AVAILABLE = True
@@ -24,7 +34,6 @@ except ImportError:
WEBRTC_AVAILABLE = False
__all__ = [
- "ChunkServer", "ChunkClient", "create_http_app",
"QuicChunkServer", "QuicChunkClient", "Denylist", "QUIC_AVAILABLE",
"WebRTCTransport", "WebRTCPeerSession", "WEBRTC_AVAILABLE",
]
diff --git a/packages/meshbay-node/src/meshbay_node/transport/client.py b/packages/meshbay-node/src/meshbay_node/transport/client.py
deleted file mode 100644
index 63d50af..0000000
--- a/packages/meshbay-node/src/meshbay_node/transport/client.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""
-MeshBay — TCP+TLS chunk client (MNP v1).
-
-Used by the web client (or other nodes) to fetch files from a Mesh Node.
-Verifies Ed25519 chunk signatures using the node's public key from the hub.
-"""
-
-import asyncio
-import base64
-import logging
-import struct
-from pathlib import Path
-
-import blake3
-import msgpack
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
-
-from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import (
- chunk_key as derive_chunk_key,
- decrypt_chunk,
- verify_chunk_signature,
-)
-from meshbay_common.protocol import MNP
-from meshbay_node.transport.tls_cert import client_ssl_context
-
-log = logging.getLogger(__name__)
-
-MAX_MSG = 64 * 1024 * 1024
-
-
-async def _send(writer, obj):
- data = msgpack.packb(obj, use_bin_type=True)
- writer.write(struct.pack(">I", len(data)) + data)
- await writer.drain()
-
-async def _recv(reader):
- header = await reader.readexactly(4)
- length = struct.unpack(">I", header)[0]
- if length > MAX_MSG:
- raise ValueError(f"Message too large: {length}")
- return msgpack.unpackb(await reader.readexactly(length), raw=False)
-
-
-class ChunkClient:
- """
- Async client for fetching encrypted chunks from a ChunkServer.
-
- Usage:
- async with ChunkClient(host, port, jwt_token, gek, pk_node_b64) as client:
- data = await client.fetch_chunk(file_id, chunk_index=0)
- """
-
- def __init__(
- self,
- host: str,
- port: int,
- jwt_token: str,
- gek: bytes,
- pk_node_b64: str, # node's Ed25519 PK from hub — used for sig verification
- group_id: str = "",
- ):
- self._host = host
- self._port = port
- self._jwt_token = jwt_token
- self._gek = gek
- self._group_id = group_id
- self._pk_node = Ed25519PublicKey.from_public_bytes(
- base64.b64decode(pk_node_b64))
- self._reader: asyncio.StreamReader | None = None
- self._writer: asyncio.StreamWriter | None = None
-
- async def __aenter__(self):
- await self.connect()
- return self
-
- async def __aexit__(self, *_):
- await self.close()
-
- async def connect(self) -> None:
- ssl_ctx = client_ssl_context()
- self._reader, self._writer = await asyncio.open_connection(
- self._host, self._port, ssl=ssl_ctx)
-
- handshake_msg = {
- "type": MNP.HANDSHAKE,
- "v": MNP_VERSION,
- "token": self._jwt_token,
- }
- if self._group_id:
- handshake_msg["group_id"] = self._group_id
- await _send(self._writer, handshake_msg)
- ack = await _recv(self._reader)
- if ack.get("type") != MNP.HANDSHAKE_ACK:
- raise ConnectionError(f"Handshake rejected: {ack}")
- log.debug("Connected to node %s:%d", self._host, self._port)
-
- async def close(self) -> None:
- if self._writer:
- self._writer.close()
- await self._writer.wait_closed()
-
- async def fetch_index(self) -> bytes:
- """Request the Mesh Group Index. Returns raw wire bytes (encrypted)."""
- await _send(self._writer, {"type": MNP.INDEX_SYNC, "v": MNP_VERSION})
- msg = await _recv(self._reader)
- return base64.b64decode(msg["index_b64"])
-
- async def fetch_chunk(self, file_id: str, chunk_index: int) -> bytes:
- """
- Fetch, verify, and decrypt one chunk.
- Returns plaintext bytes.
- """
- await _send(self._writer, {
- "type": MNP.FILE_REQUEST,
- "v": MNP_VERSION,
- "file_id": file_id,
- "chunk_index": chunk_index,
- })
- msg = await _recv(self._reader)
-
- if msg.get("type") == "error":
- raise LookupError(msg.get("detail", "Unknown error"))
-
- ct = base64.b64decode(msg["ct_b64"])
- nonce = base64.b64decode(msg["nonce_b64"])
- ct_hash = base64.b64decode(msg["ct_hash_b64"])
- pt_hash = base64.b64decode(msg["pt_hash_b64"])
- sig = base64.b64decode(msg["sig_b64"])
- file_hash = base64.b64decode(msg["file_hash_b64"])
- ci = msg["chunk_index"]
-
- # 1. Verify Ed25519 signature
- verify_chunk_signature(self._pk_node, ci, nonce, ct_hash, sig)
-
- # 2. Verify ciphertext hash
- if blake3.blake3(ct).digest() != ct_hash:
- raise ValueError("Ciphertext hash mismatch")
-
- # 3. Decrypt
- ckey = derive_chunk_key(self._gek, file_hash, ci)
- plaintext = decrypt_chunk(ckey, nonce, ct)
-
- # 4. Verify plaintext hash
- if blake3.blake3(plaintext).digest() != pt_hash:
- raise ValueError("Plaintext hash mismatch after decryption")
-
- return plaintext
diff --git a/packages/meshbay-node/src/meshbay_node/transport/http_server.py b/packages/meshbay-node/src/meshbay_node/transport/http_server.py
deleted file mode 100644
index 151c2e8..0000000
--- a/packages/meshbay-node/src/meshbay_node/transport/http_server.py
+++ /dev/null
@@ -1,336 +0,0 @@
-"""
-MeshBay Node — HTTP file API (port 19001, public content).
-
-Serves public group content over standard HTTP so browsers can
-access files without any special protocol.
-
-Endpoints:
- GET / node info (JSON)
- GET /index public Mesh Group Index (JSON)
- GET /file/{file_id} full file download (streaming)
- GET /file/{file_id}/{chunk} single encrypted chunk (JSON)
- GET /hls/{file_id}/playlist.m3u8 HLS playlist
- GET /hls/{file_id}/{segment}.ts HLS segment (binary TS)
-
-Auth: Bearer JWT in Authorization header (or ?token= query param).
-For public groups: auth optional (anonymous browse allowed).
-For chunk download: auth required (JWT verified offline with hub PK).
-
-Note: this server handles PUBLIC content only (no GEK decryption).
-Private group content requires a client that can do ChaCha20 (Phase 5).
-"""
-
-import asyncio
-import base64
-import json
-import logging
-import os
-import struct
-import subprocess
-import tempfile
-from pathlib import Path
-
-import blake3
-import jwt
-from fastapi import FastAPI, Header, HTTPException, Query, Request
-from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-
-from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import sign_chunk, pk_to_b64
-from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk
-from meshbay_node import __version__
-from meshbay_node.indexer import GroupIndex
-from meshbay_node.indexer.group_index import GroupIndex
-
-log = logging.getLogger(__name__)
-
-CHUNK_SIZE = 1024 * 1024 # 1 MB
-HLS_SEGMENT_DURATION = 4 # seconds per HLS segment
-
-
-def create_http_app(
- sk_node: Ed25519PrivateKey,
- hub_pk_pem: bytes,
- shared_root: Path,
- index: GroupIndex,
- group_id: str,
- group_name: str,
- gek: bytes | None = None, # None for public groups
-) -> FastAPI:
- """
- Create the node's public HTTP API FastAPI app.
- Bind to 0.0.0.0:19001 (or configured port) for external access.
- """
- app = FastAPI(
- title="MeshBay Node HTTP API",
- version=__version__,
- docs_url=None,
- redoc_url=None,
- )
-
- # ── Auth helper ───────────────────────────────────────────────────────────
-
- def _verify_token_optional(
- authorization: str | None,
- token_param: str | None,
- ) -> dict | None:
- """Verify JWT if provided. Returns decoded payload or None."""
- raw = None
- if authorization and authorization.lower().startswith("bearer "):
- raw = authorization[7:]
- elif token_param:
- raw = token_param
- if not raw:
- return None
- try:
- return jwt.decode(raw, hub_pk_pem, algorithms=["EdDSA"])
- except Exception:
- return None
-
- def _require_token(
- authorization: str | None,
- token_param: str | None,
- ) -> dict:
- decoded = _verify_token_optional(authorization, token_param)
- if decoded is None:
- raise HTTPException(status_code=401, detail="Authentication required")
- return decoded
-
- # ── Node info ─────────────────────────────────────────────────────────────
-
- @app.get("/")
- async def node_info():
- return {
- "node_version": __version__,
- "mnp_version": MNP_VERSION,
- "group_id": group_id,
- "group_name": group_name,
- "file_count": index.count,
- "pk_node": pk_to_b64(sk_node.public_key()),
- }
-
- # ── Public index ──────────────────────────────────────────────────────────
-
- @app.get("/index")
- async def get_index(
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Public Mesh Group Index as JSON. No auth required for public groups."""
- entries = [
- {
- "id": e.id,
- "name": e.name,
- "path": e.path,
- "size": e.size,
- "type": e.type,
- "duration": e.duration,
- }
- for e in index.entries
- ]
- return {
- "group_id": group_id,
- "group_name": group_name,
- "version": index.version,
- "entries": entries,
- }
-
- # ── Full file download (streaming) ────────────────────────────────────────
-
- @app.get("/file/{file_id}")
- async def download_file(
- file_id: str,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Stream an entire file. Public groups: no auth needed."""
- entry = index.get_entry(file_id)
- if not entry:
- raise HTTPException(status_code=404, detail="File not found in index")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- return FileResponse(
- path=str(file_path),
- filename=entry.name,
- media_type=_media_type(entry.name),
- )
-
- # ── Chunk endpoint (encrypted, for MNP-aware clients) ────────────────────
-
- @app.get("/file/{file_id}/{chunk_index}")
- async def get_chunk(
- file_id: str,
- chunk_index: int,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """
- Serve one encrypted chunk (JSON). Auth required.
- Clients that understand MNP can decrypt with the GEK they got from the hub.
- """
- _require_token(authorization, token)
-
- entry = index.get_entry(file_id)
- if not entry:
- raise HTTPException(status_code=404, detail="File not found")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- # Read chunk
- with open(file_path, "rb") as f:
- f.seek(chunk_index * CHUNK_SIZE)
- plaintext = f.read(CHUNK_SIZE)
-
- if not plaintext:
- raise HTTPException(status_code=416, detail="Chunk out of range")
-
- file_hash = bytes.fromhex(entry.id)
- pt_hash = blake3.blake3(plaintext).digest()
-
- if gek:
- # Private group: encrypt chunk
- ckey = derive_chunk_key(gek, file_hash, chunk_index)
- nonce, ct = encrypt_chunk(ckey, plaintext)
- ct_hash = blake3.blake3(ct).digest()
- sig = sign_chunk(sk_node, chunk_index, nonce, ct_hash)
- return {
- "chunk_index": chunk_index,
- "plaintext_size": len(plaintext),
- "encrypted": True,
- "nonce_b64": base64.b64encode(nonce).decode(),
- "ct_b64": base64.b64encode(ct).decode(),
- "ct_hash_b64": base64.b64encode(ct_hash).decode(),
- "pt_hash_b64": base64.b64encode(pt_hash).decode(),
- "sig_b64": base64.b64encode(sig).decode(),
- "pk_node_b64": pk_to_b64(sk_node.public_key()),
- "file_hash_b64": base64.b64encode(file_hash).decode(),
- }
- else:
- # Public group: serve plaintext chunk (TLS provides transport encryption)
- pt_hash_b = blake3.blake3(plaintext).digest()
- sig_payload = chunk_index.to_bytes(4, "big") + bytes(12) + pt_hash_b
- sig = sk_node.sign(sig_payload)
- return {
- "chunk_index": chunk_index,
- "plaintext_size": len(plaintext),
- "encrypted": False,
- "data_b64": base64.b64encode(plaintext).decode(),
- "pt_hash_b64": base64.b64encode(pt_hash).decode(),
- "sig_b64": base64.b64encode(sig).decode(),
- "pk_node_b64": pk_to_b64(sk_node.public_key()),
- }
-
- # ── HLS streaming ─────────────────────────────────────────────────────────
-
- @app.get("/hls/{file_id}/playlist.m3u8")
- async def hls_playlist(
- file_id: str,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Generate HLS playlist for a video file."""
- entry = index.get_entry(file_id)
- if not entry or entry.type != "video":
- raise HTTPException(status_code=404, detail="Video file not found")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- duration = entry.duration or _probe_duration(file_path)
- if not duration:
- raise HTTPException(status_code=422, detail="Cannot determine video duration")
-
- n_segments = max(1, int(duration / HLS_SEGMENT_DURATION) + 1)
- token_param = f"?token={token}" if token else ""
-
- lines = [
- "#EXTM3U",
- "#EXT-X-VERSION:3",
- f"#EXT-X-TARGETDURATION:{HLS_SEGMENT_DURATION}",
- "#EXT-X-MEDIA-SEQUENCE:0",
- ]
- for i in range(n_segments):
- seg_dur = min(HLS_SEGMENT_DURATION, duration - i * HLS_SEGMENT_DURATION)
- if seg_dur <= 0:
- break
- lines.append(f"#EXTINF:{seg_dur:.3f},")
- lines.append(f"/hls/{file_id}/{i}.ts{token_param}")
- lines.append("#EXT-X-ENDLIST")
-
- return StreamingResponse(
- iter(["\n".join(lines)]),
- media_type="application/vnd.apple.mpegurl",
- )
-
- @app.get("/hls/{file_id}/{segment_index}.ts")
- async def hls_segment(
- file_id: str,
- segment_index: int,
- authorization: str | None = Header(default=None),
- token: str | None = Query(default=None),
- ):
- """Serve one HLS segment as MPEG-TS via ffmpeg transcoding."""
- entry = index.get_entry(file_id)
- if not entry or entry.type != "video":
- raise HTTPException(status_code=404, detail="Video not found")
-
- file_path = shared_root / entry.path / entry.name
- if not file_path.exists():
- raise HTTPException(status_code=404, detail="File not on disk")
-
- start_time = segment_index * HLS_SEGMENT_DURATION
-
- async def generate():
- proc = await asyncio.create_subprocess_exec(
- "ffmpeg", "-hide_banner", "-loglevel", "error",
- "-ss", str(start_time),
- "-i", str(file_path),
- "-t", str(HLS_SEGMENT_DURATION),
- "-c:v", "copy", "-c:a", "copy",
- "-f", "mpegts", "pipe:1",
- stdout=asyncio.subprocess.PIPE,
- stderr=asyncio.subprocess.DEVNULL,
- )
- assert proc.stdout
- while chunk := await proc.stdout.read(65536):
- yield chunk
- await proc.wait()
-
- return StreamingResponse(generate(), media_type="video/mp2t")
-
- return app
-
-
-# ── Helpers ───────────────────────────────────────────────────────────────────
-
-def _media_type(filename: str) -> str:
- ext = Path(filename).suffix.lower()
- return {
- ".mp4": "video/mp4", ".mkv": "video/x-matroska",
- ".webm": "video/webm", ".avi": "video/x-msvideo",
- ".mp3": "audio/mpeg", ".flac": "audio/flac",
- ".ogg": "audio/ogg", ".opus": "audio/opus",
- ".jpg": "image/jpeg", ".png": "image/png",
- ".pdf": "application/pdf",
- }.get(ext, "application/octet-stream")
-
-
-def _probe_duration(path: Path) -> float | None:
- """Use ffprobe to get video duration in seconds."""
- try:
- result = subprocess.run(
- ["ffprobe", "-v", "quiet", "-print_format", "json",
- "-show_format", str(path)],
- capture_output=True, text=True, timeout=10,
- )
- data = json.loads(result.stdout)
- return float(data["format"]["duration"])
- except Exception:
- return None
diff --git a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
index 288465f..9102085 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_client.py
@@ -11,6 +11,7 @@ TLS cert is self-signed; we use CERT_NONE equivalent in QUIC config.
import asyncio
import base64
import logging
+import os
import struct
from pathlib import Path
@@ -26,6 +27,32 @@ from meshbay_common import MNP_VERSION
from meshbay_common.crypto import verify_chunk_signature
from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, decrypt_chunk_aes as decrypt_chunk
from meshbay_common.protocol import MNP
+from meshbay_common.handshake import (
+ NONCE_LEN,
+ ROLE_CLIENT,
+ ROLE_NODE,
+ handshake_transcript,
+ make_proof,
+ quic_binding,
+ verify_proof,
+)
+
+
+def _peer_cert_der(proto) -> bytes | None:
+ """
+ The server certificate as seen by the client — the channel-binding anchor.
+
+ Spike 11.5.6: aioquic 1.3.0 exposes no RFC 5705 exporter, and the peer
+ certificate only through a private attribute. Returns None when it is absent;
+ callers decide, because absence is not always an error — see below.
+ """
+ from cryptography.hazmat.primitives import serialization
+
+ tls = getattr(getattr(proto, "_quic", None), "tls", None)
+ cert = getattr(tls, "_peer_certificate", None) if tls is not None else None
+ if cert is None:
+ return None
+ return cert.public_bytes(serialization.Encoding.DER)
log = logging.getLogger(__name__)
@@ -99,6 +126,7 @@ class QuicChunkClient:
pk_node_b64: str,
local_port: int = 0, # 0 = OS picks; set for hole punching (Port-Restricted)
group_id: str = "",
+ peer_cert_der: bytes | None = None,
session_ticket: object | None = None,
):
self._host = host
@@ -112,6 +140,9 @@ class QuicChunkClient:
self._proto: _MNPClientProtocol | None = None
self._cm = None
self._ctrl_stream = 0
+ # 11.5.6 binding anchor. Travels with the session ticket: on a resumed
+ # TLS session the server does not re-send its certificate.
+ self._peer_cert_der: bytes | None = peer_cert_der
self._session_ticket = session_ticket
async def __aenter__(self):
@@ -146,19 +177,70 @@ class QuicChunkClient:
)
self._proto = await self._cm.__aenter__()
- handshake_msg = {
- "type": MNP.HANDSHAKE,
+ nonce_c = os.urandom(NONCE_LEN)
+ self._proto._send(self._ctrl_stream, {
+ "type": MNP.HANDSHAKE,
+ "v": MNP_VERSION,
+ "token": self._jwt_token,
+ "group_id": self._group_id,
+ "nonce": base64.b64encode(nonce_c).decode(),
+ })
+
+ reply = await self._proto._recv(self._ctrl_stream)
+ if reply.get("type") != MNP.HANDSHAKE_CHALLENGE:
+ raise ConnectionError(f"QUIC handshake rejected: {reply}")
+
+ nonce_s = base64.b64decode(reply["nonce"])
+
+ # On a RESUMED TLS session the server does not re-send its certificate, so
+ # there is nothing live to bind to. The session ticket is cryptographically
+ # derived from the original handshake, so binding to the certificate seen
+ # then is sound — but only if we actually saw one. We never fall back to an
+ # unbound proof: that would silently drop MitM detection (L4).
+ cert_der = _peer_cert_der(self._proto)
+ if cert_der is not None:
+ self._peer_cert_der = cert_der
+ elif getattr(self, "_peer_cert_der", None) is None:
+ raise ConnectionError(
+ "QUIC peer certificate unavailable and none cached from a prior "
+ "session — refusing to handshake without channel binding")
+ binding = quic_binding(self._peer_cert_der)
+
+ self._proto._send(self._ctrl_stream, {
+ "type": MNP.HANDSHAKE_RESPONSE,
"v": MNP_VERSION,
- "token": self._jwt_token,
- }
- if self._group_id:
- handshake_msg["group_id"] = self._group_id
- self._proto._send(self._ctrl_stream, handshake_msg)
+ "proof": base64.b64encode(make_proof(
+ self._gek, ROLE_CLIENT, self._group_id,
+ nonce_c, nonce_s, binding)).decode(),
+ })
+
ack = await self._proto._recv(self._ctrl_stream)
if ack.get("type") != MNP.HANDSHAKE_ACK:
raise ConnectionError(f"QUIC handshake rejected: {ack}")
+
+ # Authenticate the node before trusting anything it serves (C3).
+ if not verify_proof(
+ self._gek, base64.b64decode(ack.get("proof", "")), ROLE_NODE,
+ self._group_id, nonce_c, nonce_s, binding,
+ ):
+ raise ConnectionError("Node failed to prove GEK possession")
+
+ transcript = handshake_transcript(
+ ROLE_NODE, self._group_id, nonce_c, nonce_s, binding)
+ try:
+ Ed25519PublicKey.from_public_bytes(
+ base64.b64decode(ack["node_pk"])
+ ).verify(base64.b64decode(ack["sig"]), transcript)
+ except Exception as exc:
+ raise ConnectionError(f"Node signature invalid: {exc}") from exc
+
log.debug("QUIC connected to %s:%d", self._host, self._port)
+ @property
+ def peer_cert_der(self) -> bytes | None:
+ """Binding anchor to carry alongside a saved session ticket (11.5.6)."""
+ return self._peer_cert_der
+
async def close(self) -> None:
if self._cm:
await self._cm.__aexit__(None, None, None)
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 f439e62..ed3925d 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/quic_server.py
@@ -20,6 +20,7 @@ The transport is the only change — all crypto, auth, and message types stay th
import asyncio
import base64
import logging
+import os
import struct
import subprocess
from pathlib import Path
@@ -34,6 +35,17 @@ from aioquic.quic.events import QuicEvent, StreamDataReceived, StreamReset
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from meshbay_common import MNP_VERSION
+from meshbay_common.handshake import (
+ NONCE_LEN,
+ ROLE_CLIENT,
+ ROLE_NODE,
+ HandshakeError,
+ authorize_token,
+ handshake_transcript,
+ make_proof,
+ quic_binding,
+ verify_proof,
+)
from meshbay_common.crypto import (
sign_chunk,
pk_to_b64,
@@ -41,7 +53,6 @@ from meshbay_common.crypto import (
from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, encrypt_chunk_aes as encrypt_chunk
from meshbay_common.protocol import MNP
from meshbay_node.indexer import GroupIndex
-from meshbay_node.transport.tls_cert import server_ssl_context
log = logging.getLogger(__name__)
@@ -51,22 +62,69 @@ ALPN = ["meshbay-mnp"]
class Denylist:
- """Shared denylist for revoked users and invalidated JWTs."""
+ """
+ Denylist for revoked users, groups and invalidated JWTs.
- def __init__(self):
+ Finding H4: revocations used to live only in memory, so a node restart silently
+ un-revoked everyone, and group revocations were dropped entirely — the hub
+ signed and broadcast them but the node's handler only understood "user" and
+ "jti". Now persisted to disk and group targets are honoured.
+ """
+
+ def __init__(self, path: Path | None = None):
self.user_ids: set[str] = set()
+ self.group_ids: set[str] = set()
self.jtis: set[str] = set()
+ self._path = path
+ self._load()
- def is_denied(self, user_id: str, jti: str) -> bool:
- return user_id in self.user_ids or jti in self.jtis
+ def is_denied(self, user_id: str, jti: str, group_id: str = "") -> bool:
+ return (user_id in self.user_ids
+ or jti in self.jtis
+ or (bool(group_id) and group_id in self.group_ids))
def deny_user(self, user_id: str) -> None:
self.user_ids.add(user_id)
log.info("Denied user: %s", user_id[:8])
+ self._save()
+
+ def deny_group(self, group_id: str) -> None:
+ self.group_ids.add(group_id)
+ log.info("Denied group: %s", group_id[:8])
+ self._save()
def deny_jti(self, jti: str) -> None:
self.jtis.add(jti)
log.info("Denied jti: %s", jti[:8])
+ self._save()
+
+ def _load(self) -> None:
+ if not self._path or not self._path.exists():
+ return
+ try:
+ import json
+ data = json.loads(self._path.read_text())
+ self.user_ids = set(data.get("users", []))
+ self.group_ids = set(data.get("groups", []))
+ self.jtis = set(data.get("jtis", []))
+ log.info("Denylist loaded: %d users, %d groups, %d jtis",
+ len(self.user_ids), len(self.group_ids), len(self.jtis))
+ except Exception as e:
+ log.warning("Could not load denylist from %s: %s", self._path, e)
+
+ def _save(self) -> None:
+ if not self._path:
+ return
+ try:
+ import json
+ self._path.parent.mkdir(parents=True, exist_ok=True)
+ self._path.write_text(json.dumps({
+ "users": sorted(self.user_ids),
+ "groups": sorted(self.group_ids),
+ "jtis": sorted(self.jtis),
+ }))
+ except Exception as e:
+ log.warning("Could not persist denylist to %s: %s", self._path, e)
# ── Wire helpers ──────────────────────────────────────────────────────────────
@@ -111,6 +169,9 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._user_id: str | None = None
self._group_id: str | None = None
self._buffers: dict[int, _StreamBuffer] = {}
+ self._nonce_client: bytes = b""
+ self._gek_challenge: bytes | None = None
+ self._pending = None
def quic_event_received(self, event: QuicEvent) -> None:
if isinstance(event, StreamDataReceived):
@@ -130,6 +191,8 @@ class _MNPServerProtocol(QuicConnectionProtocol):
try:
if mtype == MNP.HANDSHAKE:
self._do_handshake_sync(stream_id, msg)
+ elif mtype == MNP.HANDSHAKE_RESPONSE:
+ self._do_handshake_response_sync(stream_id, msg)
elif self._user_id is None:
self._send(stream_id, {"type": "error", "detail": "Handshake required"})
elif mtype == MNP.INDEX_SYNC:
@@ -147,44 +210,117 @@ class _MNPServerProtocol(QuicConnectionProtocol):
self._send(stream_id, {"type": "error", "detail": str(e)})
def _do_handshake_sync(self, stream_id: int, msg: dict) -> None:
- token = msg.get("token", "")
- group_id = msg.get("group_id", "")
+ """
+ Authorization half of the unified handshake (11.5.4).
+
+ This used to be a second, weaker copy of the WebRTC logic: group_id was
+ optional (so omitting it skipped the membership check entirely — M1),
+ node-scoped daemon tokens were accepted as client tokens (M9), and the
+ checks could drift from the WebRTC path independently. All of that now
+ comes from meshbay_common.handshake, shared with WebRTC.
+
+ NOT YET DONE — finding C6 remains open on this transport: there is still no
+ GEK proof here, so a forged or stolen token reaches the node and can inject
+ chat without holding the group key. The challenge/response and mutual node
+ proof (quic_binding() is written and unit-tested for exactly this) are the
+ remaining work in 11.5.4/5/6.
+ """
try:
- decoded = jwt.decode(token, self._ctx["hub_pk_pem"], algorithms=["EdDSA"])
- except Exception as e:
- self._send(stream_id, {"type": "error", "detail": f"Invalid JWT: {e}"})
+ peer = authorize_token(
+ msg.get("token", ""),
+ self._ctx["hub_pk_pem"],
+ group_id=msg.get("group_id", ""),
+ hosted_groups=self._ctx.get("groups"),
+ denylist=self._ctx.get("denylist"),
+ )
+ except HandshakeError as refusal:
+ self._send(stream_id, {"type": "error", "detail": str(refusal)})
+ self._quic.close()
+ return
+
+ try:
+ self._nonce_client = base64.b64decode(msg.get("nonce", ""))
+ except Exception:
+ self._nonce_client = b""
+ if len(self._nonce_client) < NONCE_LEN:
+ self._send(stream_id, {"type": "error", "detail": "Client nonce required"})
self._quic.close()
return
- denylist = self._ctx.get("denylist")
- if denylist and denylist.is_denied(decoded.get("sub", ""), decoded.get("jti", "")):
- self._send(stream_id, {"type": "error", "detail": "Token revoked"})
+ gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx
+ if not gctx.get("gek"):
+ self._send(stream_id, {
+ "type": "error",
+ "detail": "Group encryption not initialized — contact node operator",
+ })
+ self._quic.close()
+ return
+
+ # Decoded but NOT authenticated: authentication is the GEK proof below.
+ self._pending = peer
+ self._gek_challenge = os.urandom(NONCE_LEN)
+ self._send(stream_id, {
+ "type": MNP.HANDSHAKE_CHALLENGE,
+ "v": MNP_VERSION,
+ "nonce": base64.b64encode(self._gek_challenge).decode(),
+ })
+
+ def _do_handshake_response_sync(self, stream_id: int, msg: dict) -> None:
+ """Verify the client's GEK proof, then prove the node in return (C6, C3)."""
+ if not self._gek_challenge or self._pending is None:
+ self._send(stream_id, {"type": "error", "detail": "No pending handshake challenge"})
+ return
+
+ peer = self._pending
+ gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx
+ gek = gctx.get("gek")
+ if not gek:
+ self._send(stream_id, {"type": "error", "detail": "Group encryption not initialized"})
self._quic.close()
return
- if group_id and group_id not in decoded.get("groups", []):
- self._send(stream_id, {"type": "error", "detail": "Not a member of this group"})
+ binding = self._ctx.get("server_cert_der")
+ if not binding:
+ # Refuse rather than fall back to an unbound proof (L4).
+ self._send(stream_id, {"type": "error", "detail": "Channel binding unavailable"})
self._quic.close()
return
+ binding = quic_binding(binding)
- if group_id and "groups" in self._ctx and group_id not in self._ctx["groups"]:
- self._send(stream_id, {"type": "error", "detail": "Group not hosted on this node"})
+ try:
+ proof = base64.b64decode(msg.get("proof", ""))
+ except Exception:
+ self._send(stream_id, {"type": "error", "detail": "Invalid proof encoding"})
+ return
+
+ if not verify_proof(gek, proof, ROLE_CLIENT, peer.group_id,
+ self._nonce_client, self._gek_challenge, binding):
+ self._send(stream_id, {"type": "error", "detail": "GEK proof failed"})
self._quic.close()
return
- self._user_id = decoded["sub"]
- self._group_id = group_id
+ self._user_id = peer.user_id
+ self._group_id = peer.group_id
peers = self._ctx.get("_peers")
if peers is not None:
peers[self._user_id] = self
- log.info("QUIC handshake OK — user=%s group=%s", self._user_id[:8], group_id[:8] if group_id else "none")
+ transcript = handshake_transcript(
+ ROLE_NODE, peer.group_id, self._nonce_client, self._gek_challenge, binding)
+ node_proof = make_proof(
+ gek, ROLE_NODE, peer.group_id, self._nonce_client, self._gek_challenge, binding)
+
+ log.info("QUIC handshake OK — user=%s group=%s",
+ self._user_id[:8], self._group_id[:8])
self._send(stream_id, {
"type": MNP.HANDSHAKE_ACK,
"v": MNP_VERSION,
"node_pk": pk_to_b64(self._ctx["sk_node"].public_key()),
+ "proof": base64.b64encode(node_proof).decode(),
+ "sig": base64.b64encode(self._ctx["sk_node"].sign(transcript)).decode(),
})
+ self._gek_challenge = None
def _group_ctx(self) -> dict:
"""Resolve the active group context (multi-group or legacy single-group)."""
@@ -408,6 +544,13 @@ class QuicChunkServer:
generate_self_signed_cert(self._cert_path, self._key_path)
config = QuicConfiguration(is_client=False, alpn_protocols=ALPN)
config.load_cert_chain(str(self._cert_path), str(self._key_path))
+
+ # Channel-binding anchor for the handshake proof (11.5.6). Read from our own
+ # cert file — no aioquic internals needed on this side.
+ from cryptography import x509
+ from cryptography.hazmat.primitives import serialization as _ser
+ self._ctx["server_cert_der"] = x509.load_pem_x509_certificate(
+ self._cert_path.read_bytes()).public_bytes(_ser.Encoding.DER)
return config
def _store_ticket(self, ticket: Any) -> None:
diff --git a/packages/meshbay-node/src/meshbay_node/transport/server.py b/packages/meshbay-node/src/meshbay_node/transport/server.py
deleted file mode 100644
index b77f1f2..0000000
--- a/packages/meshbay-node/src/meshbay_node/transport/server.py
+++ /dev/null
@@ -1,286 +0,0 @@
-"""
-MeshBay Node — TCP+TLS chunk server (MNP v1).
-
-Serves encrypted file chunks to authenticated clients over TLS.
-Each connection:
- 1. Client sends MNP handshake with JWT bearer token
- 2. Server verifies JWT offline (hub PK cached)
- 3. Client sends chunk requests
- 4. Server reads from disk, encrypts on-the-fly, signs, sends
-
-Wire protocol: length-prefixed msgpack (4-byte big-endian length header).
-All messages carry {"type": ..., "v": MNP_VERSION}.
-"""
-
-import asyncio
-import base64
-import logging
-import struct
-import time
-from pathlib import Path
-
-import blake3
-import jwt
-import msgpack
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-
-from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import (
- chunk_key as derive_chunk_key,
- encrypt_chunk,
- sign_chunk,
- pk_to_b64,
-)
-from meshbay_common.protocol import MNP
-from meshbay_node.indexer import GroupIndex
-from meshbay_node.transport.tls_cert import server_ssl_context
-
-log = logging.getLogger(__name__)
-
-CHUNK_SIZE = 1024 * 1024 # 1 MB
-MAX_MSG = 64 * 1024 * 1024 # 64 MB max message size (safety)
-
-
-# ── Wire helpers ──────────────────────────────────────────────────────────────
-
-async def _send(writer: asyncio.StreamWriter, obj: dict) -> None:
- data = msgpack.packb(obj, use_bin_type=True)
- writer.write(struct.pack(">I", len(data)) + data)
- await writer.drain()
-
-async def _recv(reader: asyncio.StreamReader) -> dict:
- header = await reader.readexactly(4)
- length = struct.unpack(">I", header)[0]
- if length > MAX_MSG:
- raise ValueError(f"Message too large: {length}")
- data = await reader.readexactly(length)
- return msgpack.unpackb(data, raw=False)
-
-
-# ── Chunk serving ─────────────────────────────────────────────────────────────
-
-def _serve_chunk(
- sk_node: Ed25519PrivateKey,
- gek: bytes,
- file_path: Path,
- file_hash: bytes,
- chunk_index: int,
-) -> dict:
- """Read, encrypt, sign one chunk. Blocking — run in executor."""
- with open(file_path, "rb") as f:
- f.seek(chunk_index * CHUNK_SIZE)
- plaintext = f.read(CHUNK_SIZE)
-
- pt_hash = blake3.blake3(plaintext).digest()
- ckey = derive_chunk_key(gek, file_hash, chunk_index)
- nonce, ct = encrypt_chunk(ckey, plaintext)
- ct_hash = blake3.blake3(ct).digest()
- sig = sign_chunk(sk_node, chunk_index, nonce, ct_hash)
-
- return {
- "type": MNP.FILE_CHUNK,
- "v": MNP_VERSION,
- "chunk_index": chunk_index,
- "plaintext_size": len(plaintext),
- "nonce_b64": base64.b64encode(nonce).decode(),
- "ct_b64": base64.b64encode(ct).decode(),
- "ct_hash_b64": base64.b64encode(ct_hash).decode(),
- "pt_hash_b64": base64.b64encode(pt_hash).decode(),
- "sig_b64": base64.b64encode(sig).decode(),
- "pk_node_b64": pk_to_b64(sk_node.public_key()),
- "file_hash_b64": base64.b64encode(file_hash).decode(),
- }
-
-
-# ── Connection handler ────────────────────────────────────────────────────────
-
-class _ConnectionHandler:
- def __init__(
- self,
- reader: asyncio.StreamReader,
- writer: asyncio.StreamWriter,
- sk_node: Ed25519PrivateKey,
- hub_pk_pem: bytes,
- gek: bytes,
- shared_root: Path,
- index: GroupIndex,
- groups: dict[str, dict] | None = None,
- ):
- self._reader = reader
- self._writer = writer
- self._sk_node = sk_node
- self._hub_pk_pem = hub_pk_pem
- self._gek = gek
- self._shared_root = shared_root
- self._index = index
- self._groups = groups
- self._peer = writer.get_extra_info("peername")
- self._user_id: str | None = None
- self._group_id: str | None = None
-
- async def handle(self) -> None:
- try:
- await self._handshake()
- await self._serve_loop()
- except asyncio.IncompleteReadError:
- log.debug("[%s] Client disconnected", self._peer)
- except Exception as e:
- log.warning("[%s] Error: %s", self._peer, e)
- await _send(self._writer, {"type": "error", "detail": str(e)})
- finally:
- self._writer.close()
-
- async def _handshake(self) -> None:
- msg = await _recv(self._reader)
- if msg.get("type") != MNP.HANDSHAKE:
- raise ValueError(f"Expected handshake, got {msg.get('type')!r}")
-
- token = msg.get("token", "")
- group_id = msg.get("group_id", "")
- try:
- decoded = jwt.decode(token, self._hub_pk_pem, algorithms=["EdDSA"])
- except Exception as e:
- raise PermissionError(f"Invalid JWT: {e}") from e
-
- if group_id and group_id not in decoded.get("groups", []):
- raise PermissionError("Not a member of this group")
-
- if group_id and self._groups and group_id not in self._groups:
- raise PermissionError("Group not hosted on this node")
-
- self._user_id = decoded["sub"]
- self._group_id = group_id
-
- if group_id and self._groups and group_id in self._groups:
- ctx = self._groups[group_id]
- self._gek = ctx["gek"]
- self._shared_root = ctx["shared_root"]
- self._index = ctx["index"]
-
- log.info("[%s] Handshake OK — user=%s group=%s", self._peer, self._user_id[:8], group_id[:8] if group_id else "none")
-
- await _send(self._writer, {
- "type": MNP.HANDSHAKE_ACK,
- "v": MNP_VERSION,
- "node_pk": pk_to_b64(self._sk_node.public_key()),
- })
-
- async def _serve_loop(self) -> None:
- loop = asyncio.get_event_loop()
- while True:
- msg = await _recv(self._reader)
- mtype = msg.get("type")
-
- if mtype == MNP.INDEX_SYNC:
- wire = self._index.serialize()
- await _send(self._writer, {
- "type": MNP.INDEX_SYNC,
- "v": MNP_VERSION,
- "index_b64": base64.b64encode(wire).decode(),
- })
-
- elif mtype == MNP.FILE_REQUEST:
- file_id = msg["file_id"]
- chunk_index = msg["chunk_index"]
-
- entry = self._index.get_entry(file_id)
- if entry is None:
- await _send(self._writer, {
- "type": "error",
- "detail": f"File not found: {file_id[:8]}",
- })
- continue
-
- file_path = self._shared_root / entry.path / entry.name
- if not file_path.exists():
- await _send(self._writer, {
- "type": "error", "detail": "File not on disk"})
- continue
-
- file_hash = bytes.fromhex(entry.id)
- chunk = await loop.run_in_executor(
- None, _serve_chunk,
- self._sk_node, self._gek, file_path, file_hash, chunk_index)
- await _send(self._writer, chunk)
-
- else:
- log.warning("[%s] Unknown message type: %s", self._peer, mtype)
-
-
-# ── Server ────────────────────────────────────────────────────────────────────
-
-class ChunkServer:
- """
- Async TCP+TLS server that serves encrypted file chunks.
-
- Usage:
- server = ChunkServer(
- host="0.0.0.0", port=19000,
- sk_node=sk, hub_pk_pem=pk_pem,
- gek=gek, shared_root=Path("/data"),
- index=group_index,
- )
- await server.start()
- # ... when shutting down:
- await server.stop()
- """
-
- def __init__(
- self,
- sk_node: Ed25519PrivateKey,
- hub_pk_pem: bytes,
- gek: bytes,
- shared_root: Path,
- index: GroupIndex,
- host: str = "0.0.0.0",
- port: int = 19000,
- cert_path: Path | None = None,
- key_path: Path | None = None,
- groups: dict[str, dict] | None = None,
- ):
- self._sk_node = sk_node
- self._hub_pk_pem = hub_pk_pem
- self._gek = gek
- self._shared_root = shared_root
- self._index = index
- self._host = host
- self._port = port
- self._cert_path = cert_path
- self._key_path = key_path
- self._groups = groups
- self._server: asyncio.Server | None = None
-
- @property
- def port(self) -> int:
- return self._port
-
- async def start(self) -> None:
- ssl_ctx = server_ssl_context(
- cert_path=self._cert_path or Path.home() / ".config/meshbay/node_tls.crt",
- key_path=self._key_path or Path.home() / ".config/meshbay/node_tls.key",
- )
- self._server = await asyncio.start_server(
- self._handle_connection,
- host=self._host,
- port=self._port,
- ssl=ssl_ctx,
- )
- log.info("ChunkServer listening on %s:%d (TLS)", self._host, self._port)
-
- async def stop(self) -> None:
- if self._server:
- self._server.close()
- await self._server.wait_closed()
- self._server = None
- log.info("ChunkServer stopped")
-
- async def _handle_connection(
- self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
- ) -> None:
- handler = _ConnectionHandler(
- reader, writer,
- self._sk_node, self._hub_pk_pem,
- self._gek, self._shared_root, self._index,
- groups=self._groups,
- )
- await handler.handle()
diff --git a/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py b/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py
index 1354ac9..374fd08 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/tls_cert.py
@@ -1,17 +1,18 @@
"""
-Self-signed TLS certificate generation for the node.
+Self-signed TLS certificate generation for the node's QUIC listener.
The cert is used for transport confidentiality only.
Node identity is verified via Ed25519 PK (from hub), not TLS cert chain.
-Clients connect with ssl.CERT_NONE + verify Ed25519 at the MNP handshake layer.
-Certificate is generated once and cached at ~/.config/meshbay/node_tls.pem/.key.
+Phase 11.5 note: the certificate hash is also the intended channel-binding anchor for
+the QUIC handshake proof (11.5.6), since QUIC has no DTLS fingerprint to bind to.
+
+Certificate is generated once and cached at ~/.config/meshbay/node_tls.crt/.key.
"""
import logging
import os
from pathlib import Path
-import ssl
import datetime
import ipaddress
@@ -69,27 +70,6 @@ def generate_self_signed_cert(
return cert_path, key_path
-def server_ssl_context(
- cert_path: Path = DEFAULT_CERT,
- key_path: Path = DEFAULT_KEY,
-) -> ssl.SSLContext:
- """SSL context for the node's TCP server."""
- if not cert_path.exists() or not key_path.exists():
- generate_self_signed_cert(cert_path, key_path)
-
- ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
- ctx.load_cert_chain(certfile=cert_path, keyfile=key_path)
- ctx.minimum_version = ssl.TLSVersion.TLSv1_3
- return ctx
-
-
-def client_ssl_context() -> ssl.SSLContext:
- """
- SSL context for clients connecting to a node.
- CERT_NONE because we verify node identity via Ed25519 PK at the MNP layer.
- """
- ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
- ctx.check_hostname = False
- ctx.verify_mode = ssl.CERT_NONE
- ctx.minimum_version = ssl.TLSVersion.TLSv1_3
- return ctx
+# `server_ssl_context()` / `client_ssl_context()` were removed in Phase 11.5 along with
+# the TCP+TLS transport they served. QUIC builds its own QuicConfiguration and calls
+# generate_self_signed_cert() directly.
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 13e90c8..fe4e3c2 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -28,7 +28,9 @@ import hashlib
import hmac
import logging
import os
+import re
import struct
+import time
from pathlib import Path
from typing import Any
@@ -41,16 +43,68 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
)
from meshbay_common import MNP_VERSION
-from meshbay_common.crypto import pk_to_b64
+from meshbay_common.handshake import (
+ NONCE_LEN,
+ ROLE_CLIENT,
+ ROLE_NODE,
+ HandshakeError,
+ authorize_token,
+ handshake_transcript,
+ make_proof,
+ verify_proof,
+ webrtc_binding,
+)
+from meshbay_common.adminop import (
+ ADMIN_CHALLENGE_TTL,
+ OP_FILE_DELETE,
+ OP_INVITE_CREATE,
+ admin_transcript,
+)
+from meshbay_common.crypto import pk_to_b64, wrap_gek_aes
+from meshbay_common.join import (
+ JOIN_TTL,
+ ROLE_MEMBER,
+ ROLE_OPERATOR,
+ join_transcript,
+)
from meshbay_common.webcrypto import chunk_key_aes, encrypt_chunk_aes
from meshbay_common.protocol import MNP
from meshbay_node.indexer import GroupIndex
+from meshbay_node.roster import DEFAULT_INVITE_TTL
log = logging.getLogger(__name__)
CHUNK_SIZE = 1024 * 1024
MAX_MSG = 64 * 1024 * 1024
+# Upload limits (finding C5a). Uploads used to land directly in the shared root under
+# a name the client chose, overwriting whatever was already there — which both violated
+# node sovereignty and defeated the delete authorization (overwrite a file, become its
+# recorded uploader, then delete it legitimately).
+MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB per file
+
+# Budget for an unauthenticated peer: enough for a handshake and a bundle fetch,
+# nowhere near enough to be a memory-exhaustion primitive (H6).
+PRE_HANDSHAKE_MAX_MSG = 64 * 1024
+# ffmpeg is spawned per stream request; without a cap any member can fork-bomb
+# the node by requesting many streams at once (H6).
+MAX_CONCURRENT_TRANSCODES = 2
+# Bundle fetches are served in the pre-proof window (C4). Bounded and audited
+# until the native client removes remote keypair bundles entirely.
+MAX_PRE_PROOF_FETCHES = 4
+# Pairing codes carry 40 bits and are single-use, but a connection must not be
+# allowed to sit there guessing. Failures are audited, so a grind is visible.
+MAX_JOIN_ATTEMPTS = 5
+# Per-connection limits alone would not bind an attacker who can open connections
+# at will — and the adversary who can mint tokens for any account is the hub. So
+# failed pairings are also counted node-wide over a window.
+MAX_JOIN_FAILURES_WINDOW = 20
+JOIN_FAILURE_WINDOW = 600 # seconds
+UPLOAD_DIR_NAME = ".uploads"
+# Conservative allowlist: also what keeps markup out of filenames, which the node admin
+# UI used to render unescaped (finding H2).
+SAFE_UPLOAD_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._ -]{0,127}$")
+
def _extract_dtls_fingerprint(sdp: str) -> bytes:
"""Extract the DTLS SHA-256 fingerprint from SDP as raw 32 bytes."""
@@ -119,10 +173,18 @@ def _pack(obj: dict) -> bytes:
class _DataChannelBuffer:
- """Accumulate DataChannel messages and extract length-prefixed msgpack."""
+ """
+ Accumulate DataChannel messages and extract length-prefixed msgpack.
+
+ Finding H6: the limit was a flat 64 MB applied even before the handshake, so an
+ unauthenticated peer could announce a 64 MB frame and dribble bytes into it,
+ holding that much memory per connection. Until a peer has proved GEK
+ possession it gets a small budget; the large one is for file uploads.
+ """
- def __init__(self):
+ def __init__(self, max_message: int = MAX_MSG):
self._buf = bytearray()
+ self.max_message = max_message
def feed(self, data: bytes):
self._buf.extend(data)
@@ -130,7 +192,7 @@ class _DataChannelBuffer:
def messages(self):
while len(self._buf) >= 4:
length = struct.unpack(">I", self._buf[:4])[0]
- if length > MAX_MSG:
+ if length > self.max_message:
raise ValueError(f"Message too large: {length}")
if len(self._buf) < 4 + length:
break
@@ -162,15 +224,25 @@ class WebRTCPeerSession:
self._pc = pc
self._ctx = node_ctx
self._channel: RTCDataChannel | None = None
- self._buffer = _DataChannelBuffer()
+ self._buffer = _DataChannelBuffer(max_message=PRE_HANDSHAKE_MAX_MSG)
+ self._pre_proof_fetches = 0
self._user_id: str | None = None
self._group_id: str | None = None
self._peer_id: str = peer_id
self._remote_ip: str = ""
self._username: str = ""
- self._pk_user: str = ""
+ # Set from the roster: the key this node pinned for this account. Never
+ # from the JWT — the hub picks what goes in there.
+ self._pinned_pk: str = ""
self._gek_challenge: bytes | None = None
- self._admin_challenges: dict[str, bytes] = {}
+ # Same value as the GEK challenge, but kept for the life of the connection:
+ # a join_request is signed over it, and it must stay verifiable after the
+ # handshake clears the challenge (an operator pairs while already connected).
+ self._nonce_node: bytes = b""
+ self._join_attempts = 0
+ self._nonce_client: bytes = b""
+ self._admin_ops: dict[str, dict] = {} # op_id → pending admin operation
+ self._uploads: dict[str, dict] = {} # filename → {next_index, bytes}
def _setup_channel(self, channel: RTCDataChannel) -> None:
self._channel = channel
@@ -191,10 +263,30 @@ class WebRTCPeerSession:
self._do_handshake(msg)
elif mtype == MNP.HANDSHAKE_RESPONSE:
self._do_handshake_response(msg)
- elif mtype == MNP.GEK_BUNDLE_FETCH and self._gek_challenge is not None:
- asyncio.ensure_future(self._do_gek_bundle_fetch())
- elif mtype == MNP.KEYPAIR_BUNDLE_FETCH and self._gek_challenge is not None:
- asyncio.ensure_future(self._do_keypair_bundle_fetch())
+ elif mtype in (MNP.GEK_BUNDLE_FETCH, MNP.KEYPAIR_BUNDLE_FETCH) \
+ and self._gek_challenge is not None:
+ # Served before the GEK proof by necessity: the client needs its
+ # wrapped bundle in order to compute the proof. That window is a
+ # disclosure surface (C4) — a hub that forges a JWT reaches it — so
+ # it is bounded and audited here, and closed properly when clients
+ # stop storing keypair bundles on other people's nodes.
+ self._pre_proof_fetches += 1
+ if self._pre_proof_fetches > MAX_PRE_PROOF_FETCHES:
+ self._audit_auth_failed(
+ getattr(self, "_pending_group", ""), "pre-proof fetch flood")
+ self._send({"type": "error", "detail": "Too many requests"})
+ return
+ self._audit_pre_proof_fetch(mtype)
+ if mtype == MNP.GEK_BUNDLE_FETCH:
+ asyncio.ensure_future(self._do_gek_bundle_fetch())
+ else:
+ asyncio.ensure_future(self._do_keypair_bundle_fetch())
+ elif mtype == MNP.JOIN_REQUEST and self._nonce_node:
+ # Valid both before the GEK proof (a new member has no GEK to prove
+ # with) and after it (an operator pairing a browser is already
+ # connected). Authority comes from the pairing code and the
+ # signature, never from the session state.
+ asyncio.ensure_future(self._do_join_request(msg))
elif self._user_id is None:
self._send({"type": "error", "detail": "Handshake required"})
elif mtype == MNP.INDEX_SYNC:
@@ -213,17 +305,21 @@ class WebRTCPeerSession:
self._do_file_delete(msg)
elif mtype == MNP.ADMIN_RESPONSE:
self._do_admin_response(msg)
- elif mtype == MNP.GEK_BUNDLE_STORE:
- asyncio.ensure_future(self._do_gek_bundle_store(msg))
+ elif mtype == MNP.INVITE_CREATE:
+ self._do_invite_create(msg)
elif mtype == MNP.KEYPAIR_BUNDLE_STORE:
asyncio.ensure_future(self._do_keypair_bundle_store(msg))
+ elif mtype == MNP.KEYPAIR_BUNDLE_DELETE:
+ asyncio.ensure_future(self._do_keypair_bundle_delete())
elif mtype == MNP.STREAM_REQUEST:
asyncio.ensure_future(self._stream_video(msg))
else:
log.warning("Unknown MNP message type on DataChannel: %s", mtype)
except Exception as e:
- log.error("Error handling %s on DataChannel: %s", mtype, e)
- self._send({"type": "error", "detail": str(e)})
+ # Log the detail locally; send the peer a generic message. Exception
+ # text here carries filesystem paths and internal state (finding L3).
+ log.error("Error handling %s on DataChannel: %s", mtype, e, exc_info=True)
+ self._send({"type": "error", "detail": "Request failed"})
def _audit(self, event: str, detail: str = "") -> None:
audit = self._ctx.get("audit_store")
@@ -239,57 +335,73 @@ class WebRTCPeerSession:
detail=detail,
))
+ def _channel_binding(self) -> bytes:
+ """Both DTLS fingerprints, so a proof is valid on this connection only."""
+ offer_fp = b""
+ answer_fp = b""
+ if self._pc.remoteDescription:
+ offer_fp = _extract_dtls_fingerprint(self._pc.remoteDescription.sdp)
+ if self._pc.localDescription:
+ answer_fp = _extract_dtls_fingerprint(self._pc.localDescription.sdp)
+ if not offer_fp or not answer_fp:
+ return b""
+ return webrtc_binding(offer_fp, answer_fp)
+
def _do_handshake(self, msg: dict) -> None:
- token = msg.get("token", "")
group_id = msg.get("group_id", "")
try:
- decoded = jwt.decode(token, self._ctx["hub_pk_pem"], algorithms=["EdDSA"])
- except Exception as e:
- self._send({"type": "error", "detail": f"Invalid JWT: {e}"})
- self._audit_auth_failed(group_id, str(e))
- return
-
- denylist = self._ctx.get("denylist")
- if denylist and denylist.is_denied(decoded.get("sub", ""), decoded.get("jti", "")):
- self._send({"type": "error", "detail": "Token revoked"})
- return
-
- if group_id and group_id not in decoded.get("groups", []):
- self._send({"type": "error", "detail": "Not a member of this group"})
+ peer = authorize_token(
+ msg.get("token", ""),
+ self._ctx["hub_pk_pem"],
+ group_id=group_id,
+ hosted_groups=self._ctx.get("groups"),
+ denylist=self._ctx.get("denylist"),
+ )
+ except HandshakeError as refusal:
+ # HandshakeError messages are authored to be peer-safe, unlike arbitrary
+ # exception text (L3) — the client needs to know *why* it was refused.
+ self._send({"type": "error", "detail": str(refusal),
+ "code": getattr(refusal, "code", "")})
+ self._audit_auth_failed(group_id, str(refusal))
return
- if group_id and "groups" in self._ctx and group_id not in self._ctx["groups"]:
- self._send({"type": "error", "detail": "Group not hosted on this node"})
+ try:
+ self._nonce_client = base64.b64decode(msg.get("nonce", ""))
+ except Exception:
+ self._nonce_client = b""
+ if len(self._nonce_client) < NONCE_LEN:
+ # The client nonce is what makes the NODE's proof fresh (C3). Without
+ # it a recorded ack could be replayed by an impersonating peer.
+ self._send({"type": "error", "detail": "Client nonce required"})
return
- # Store decoded JWT data but DO NOT set self._user_id yet —
- # the user is not authenticated until they prove GEK possession.
- self._pending_sub = decoded["sub"]
- self._pending_group = group_id
- self._pending_username = decoded.get("username", "")
- self._pending_pk_user = decoded.get("pk_user", "")
+ # Decoded, but NOT authenticated: that happens on the GEK proof.
+ self._pending_sub = peer.user_id
+ self._pending_group = peer.group_id
+ self._pending_username = peer.username
- ctx = self._ctx
- if "groups" in ctx and group_id:
- gctx = ctx["groups"].get(group_id, ctx)
- else:
- gctx = ctx
- gek = gctx.get("gek")
-
- nonce = os.urandom(32)
- self._gek_challenge = nonce
- challenge = {
- "type": MNP.HANDSHAKE_CHALLENGE,
- "v": MNP_VERSION,
- "nonce": base64.b64encode(nonce).decode(),
- }
- if not gek:
+ gctx = self._ctx["groups"][peer.group_id] if "groups" in self._ctx else self._ctx
+ if not gctx.get("gek"):
self._send({
"type": "error",
"detail": "Group encryption not initialized — contact node operator",
})
return
- self._send(challenge)
+
+ self._gek_challenge = os.urandom(NONCE_LEN)
+ self._nonce_node = self._gek_challenge
+ self._send({
+ "type": MNP.HANDSHAKE_CHALLENGE,
+ "v": MNP_VERSION,
+ "nonce": base64.b64encode(self._gek_challenge).decode(),
+ # Announced here because a first-time joiner needs it *before* the
+ # ack: join_request signs a transcript naming this node, and someone
+ # who has never held the GEK cannot complete the handshake to learn
+ # it. Unverified at this point — the ack proves it, the client checks
+ # the two match, and a wrong value only makes our own verification
+ # fail. It is never a substitute for the ack's proof and signature.
+ "node_pk": self._node_pk_b64(),
+ })
def _do_handshake_response(self, msg: dict) -> None:
if not self._gek_challenge or not hasattr(self, "_pending_sub"):
@@ -297,61 +409,71 @@ class WebRTCPeerSession:
return
group_id = self._pending_group
- ctx = self._ctx
- if "groups" in ctx and group_id:
- gctx = ctx["groups"].get(group_id, ctx)
- else:
- gctx = ctx
+ gctx = self._ctx["groups"][group_id] if "groups" in self._ctx else self._ctx
gek = gctx.get("gek")
-
if not gek:
self._send({"type": "error", "detail": "Group encryption not initialized"})
self._gek_challenge = None
return
- proof = msg.get("proof", "")
try:
- proof_bytes = base64.b64decode(proof)
+ proof_bytes = base64.b64decode(msg.get("proof", ""))
except Exception:
self._send({"type": "error", "detail": "Invalid proof encoding"})
return
- offer_fp = b""
- answer_fp = b""
- if self._pc.remoteDescription:
- offer_fp = _extract_dtls_fingerprint(self._pc.remoteDescription.sdp)
- if self._pc.localDescription:
- answer_fp = _extract_dtls_fingerprint(self._pc.localDescription.sdp)
+ binding = self._channel_binding()
+ if not binding:
+ # Refuse rather than fall back to an unbound proof (L4).
+ self._send({"type": "error", "detail": "Channel binding unavailable"})
+ self._gek_challenge = None
+ self._audit_auth_failed(group_id, "no channel binding")
+ return
- data = self._gek_challenge + offer_fp + answer_fp
- expected = hmac.new(gek, data, hashlib.sha256).digest()
- if not hmac.compare_digest(proof_bytes, expected):
+ if not verify_proof(gek, proof_bytes, ROLE_CLIENT, group_id,
+ self._nonce_client, self._gek_challenge, binding):
self._send({"type": "error", "detail": "GEK proof failed"})
self._gek_challenge = None
self._audit_auth_failed(group_id, "GEK HMAC mismatch")
return
+ self._complete_handshake(gek, binding)
self._gek_challenge = None
- self._complete_handshake()
- def _complete_handshake(self) -> None:
+ def _complete_handshake(self, gek: bytes, binding: bytes) -> None:
+ # Authenticated peers may send large frames (file uploads); unauthenticated
+ # ones may not (H6).
+ self._buffer.max_message = MAX_MSG
self._user_id = self._pending_sub
self._group_id = self._pending_group
self._username = self._pending_username
- self._pk_user = self._pending_pk_user
+ asyncio.ensure_future(self._load_pinned_pk())
- peers = self._ctx.get("_peers")
- if peers is not None:
- peers[self._user_id] = self
+ self._peer_registry()[self._user_id] = self
node_user_id = self._ctx.get("node_user_id")
log.info("WebRTC handshake OK — user=%s group=%s",
self._user_id[:8],
self._group_id[:8] if self._group_id else "none")
+ # The node proves itself too (C3): possession of the GEK over the client's
+ # nonce, plus a signature over the same transcript with its long-term key.
+ # Previously the client received an unverifiable node_pk and trusted
+ # is_node_admin from whoever answered — so a peer that had hijacked
+ # signaling could serve a forged index, chat history and permissions.
+ node_transcript = handshake_transcript(
+ ROLE_NODE, self._group_id or "", self._nonce_client,
+ self._gek_challenge or b"", binding)
+ node_proof = make_proof(
+ gek, ROLE_NODE, self._group_id or "", self._nonce_client,
+ self._gek_challenge or b"", binding)
+
ack = {
"type": MNP.HANDSHAKE_ACK,
"v": MNP_VERSION,
"node_pk": pk_to_b64(self._ctx["sk_node"].public_key()),
+ "proof": base64.b64encode(node_proof).decode(),
+ "sig": base64.b64encode(
+ self._ctx["sk_node"].sign(node_transcript)).decode(),
"is_node_admin": bool(node_user_id and self._user_id == node_user_id),
}
if node_user_id:
@@ -388,65 +510,41 @@ class WebRTCPeerSession:
else:
self._send({"type": MNP.GEK_BUNDLE_RESP, "v": MNP_VERSION, "found": False})
- async def _do_gek_bundle_store(self, msg: dict) -> None:
- """Store a wrapped GEK bundle for a target user (admin operation)."""
- bundle_store = self._ctx.get("bundle_store")
- if not bundle_store:
- self._send({"type": "error", "detail": "Bundle store not available"})
- return
-
- target_user_id = msg.get("user_id", "")
- group_id = msg.get("group_id") or self._group_id
- pk_eph = msg.get("pk_eph_b64", "")
- nonce = msg.get("nonce_b64", "")
- wrapped = msg.get("wrapped_b64", "")
+ def _do_invite_create(self, msg: dict) -> None:
+ """
+ Issue a one-time pairing code for someone the operator wants to admit.
- if not target_user_id or not pk_eph or not nonce or not wrapped or not group_id:
- self._send({"type": "error", "detail": "Missing bundle fields"})
+ Replaces the old invite path, where the inviter fetched the invitee's
+ public key from the hub and wrapped the group key for whatever came back
+ (H3). The node now needs nothing but a name: it will wrap the key itself,
+ later, for a key the invitee proves they hold.
+ """
+ roster = self._ctx.get("roster")
+ if roster is None:
+ self._send({"type": "error", "detail": "Roster not available"})
return
- await bundle_store.store(group_id, target_user_id, pk_eph, nonce, wrapped)
- log.info("GEK bundle stored: group=%s user=%s", group_id[:8], target_user_id[:8])
- self._audit("gek_bundle_store", f"target={target_user_id[:8]}")
-
- self._send({
- "type": "ack", "v": MNP_VERSION,
- "detail": "gek_bundle_stored",
- "user_id": target_user_id,
- })
-
- # Auto-activate GEK if the bundle is for the node operator
- node_user_id = self._ctx.get("node_user_id")
- if node_user_id and target_user_id == node_user_id and group_id:
- await self._try_activate_gek(group_id, target_user_id)
-
- async def _try_activate_gek(self, group_id: str, user_id: str) -> None:
- """Unwrap and activate GEK for the node when the operator's bundle arrives."""
- from meshbay_common.crypto import unwrap_gek_aes
-
- bundle_store = self._ctx.get("bundle_store")
- sk_x_raw = self._ctx.get("sk_x25519_raw")
- pk_x_raw = self._ctx.get("pk_x25519_raw")
- if not bundle_store or not sk_x_raw or not pk_x_raw:
+ invitee_id = msg.get("user_id", "")
+ group_id = msg.get("group_id") or self._group_id
+ if not invitee_id or not group_id:
+ self._send({"type": "error", "detail": "Missing user_id or group_id"})
return
-
- bundle = await bundle_store.fetch(group_id, user_id)
- if not bundle:
+ if group_id != self._group_id:
+ self._send({"type": "error", "detail": "Wrong group for this session"})
return
- try:
- gek = unwrap_gek_aes(bundle, sk_x_raw, pk_x_raw)
- except Exception as e:
- log.warning("Failed to unwrap GEK for auto-activation: %s", e)
+ if not self._has_admin_authority():
+ self._send({
+ "type": "error",
+ "detail": "No operator paired — run `meshbay-node operator pair`",
+ })
return
- groups = self._ctx.get("groups")
- if groups and group_id in groups:
- groups[group_id]["gek"] = gek
- log.info("GEK auto-activated for group %s", group_id[:8])
- elif "gek" in self._ctx:
- self._ctx["gek"] = gek
- log.info("GEK auto-activated (single-group mode)")
+ self._issue_admin_challenge(OP_INVITE_CREATE, invitee_id, {
+ "group_id": group_id,
+ "user_id": invitee_id,
+ "username": str(msg.get("username", ""))[:64],
+ })
async def _do_keypair_bundle_fetch(self) -> None:
"""Serve the caller's encrypted keypair bundle during the handshake window."""
@@ -491,6 +589,287 @@ class WebRTCPeerSession:
"detail": "keypair_bundle_stored",
})
+ # ── Pairing and join (H3, M3) ────────────────────────────────────────────
+
+ def _join_refuse(self, reason: str, audit_detail: str = "") -> None:
+ self._join_attempts += 1
+ # Node-wide window, shared across connections: reconnecting must not reset
+ # the budget.
+ now = time.time()
+ failures = [t for t in self._ctx.get("join_failures", [])
+ if now - t < JOIN_FAILURE_WINDOW]
+ failures.append(now)
+ self._ctx["join_failures"] = failures
+ self._audit_join("join_refused", audit_detail or reason)
+ self._send({
+ "type": MNP.JOIN_RESULT,
+ "v": MNP_VERSION,
+ "ok": False,
+ "reason": reason,
+ })
+
+ def _audit_join(self, event: str, detail: str) -> None:
+ audit = self._ctx.get("audit_store")
+ if not audit:
+ return
+ self._remote_ip = self._remote_ip or _get_remote_ip(self._pc)
+ asyncio.ensure_future(audit.log_event(
+ user_id=self._user_id or getattr(self, "_pending_sub", "unknown"),
+ event=event,
+ ip=self._remote_ip,
+ username=self._username or getattr(self, "_pending_username", ""),
+ group_id=self._group_id or getattr(self, "_pending_group", "") or "",
+ detail=detail,
+ ))
+
+ async def _do_join_request(self, msg: dict) -> None:
+ """
+ Pin an identity, or recognise one already pinned.
+
+ The client signs its own Ed25519 and X25519 keys together with the node's
+ nonce, so the identity key vouches for the encryption key — that is what
+ will make it safe for the node to wrap the GEK for a key that arrived over
+ the wire instead of one fetched from the hub's directory (H3).
+
+ A first pairing needs a one-time code, which the hub never sees. Afterwards
+ the pin is the credential and a changed key is refused outright, the same
+ rule the client applies to `pk_node` (11.5.8).
+ """
+ roster = self._ctx.get("roster")
+ if roster is None:
+ self._send({"type": "error", "detail": "Roster not available"})
+ return
+
+ if self._join_attempts >= MAX_JOIN_ATTEMPTS:
+ self._send({"type": "error", "detail": "Too many attempts"})
+ return
+
+ now = time.time()
+ recent = [t for t in self._ctx.get("join_failures", [])
+ if now - t < JOIN_FAILURE_WINDOW]
+ if len(recent) >= MAX_JOIN_FAILURES_WINDOW:
+ self._audit_join("join_throttled", f"{len(recent)} failures in window")
+ self._send({"type": "error", "detail": "Pairing temporarily locked"})
+ return
+
+ user_id = self._user_id or getattr(self, "_pending_sub", "")
+ username = self._username or getattr(self, "_pending_username", "")
+ if not user_id:
+ self._send({"type": "error", "detail": "Handshake required"})
+ return
+
+ pk_ed_b64 = msg.get("pk_ed25519", "")
+ pk_x_b64 = msg.get("pk_x25519", "")
+ code = msg.get("code", "")
+ ts = msg.get("ts", 0)
+
+ try:
+ pk_ed_raw = base64.b64decode(pk_ed_b64)
+ pk_x_raw = base64.b64decode(pk_x_b64)
+ if len(pk_ed_raw) != 32 or len(pk_x_raw) != 32:
+ raise ValueError
+ pk_ed = Ed25519PublicKey.from_public_bytes(pk_ed_raw)
+ except Exception:
+ self._join_refuse("invalid_keys")
+ return
+
+ if not isinstance(ts, int) or abs(time.time() - ts) > JOIN_TTL:
+ self._join_refuse("stale_request")
+ return
+
+ # An empty group_id means operator pairing, which is node-wide. Anything
+ # else must be the group this connection authenticated to — a signature
+ # obtained for one group must not name another.
+ group_id = msg.get("group_id", "") or ""
+ session_group = self._group_id or getattr(self, "_pending_group", "") or ""
+ if group_id and group_id != session_group:
+ self._join_refuse("group_mismatch")
+ return
+
+ transcript = join_transcript(
+ node_pk_b64=self._node_pk_b64(),
+ group_id=group_id,
+ user_id=user_id,
+ pk_ed25519_b64=pk_ed_b64,
+ pk_x25519_b64=pk_x_b64,
+ nonce_node=self._nonce_node,
+ ts=ts,
+ )
+ try:
+ sig = base64.b64decode(msg.get("sig", ""))
+ except Exception:
+ self._join_refuse("invalid_signature_encoding")
+ return
+ if not self._verify_sig(pk_ed, transcript, sig):
+ self._join_refuse("signature_invalid")
+ return
+
+ known = await roster.get_identity(user_id)
+ if known:
+ if known["pk_ed25519"] != pk_ed_b64 or known["pk_x25519"] != pk_x_b64:
+ # The blocking warning, raised where it matters: whoever this is
+ # holds a different key than the person the operator paired.
+ self._join_refuse(
+ "key_changed",
+ f"pinned={known['pk_ed25519'][:16]} presented={pk_ed_b64[:16]}")
+ return
+ # An operator's row is node-wide (empty group), so a lookup for the
+ # group they happen to be opening finds nothing. Fall back to it, or
+ # the client is told it has no role on a node it administers.
+ member = (await roster.get_member(group_id, user_id)
+ or await roster.get_member("", user_id))
+ await self._join_ok(
+ user_id, pk_x_raw, session_group,
+ role=member["role"] if member else "",
+ recognised=True,
+ )
+ return
+
+ if not code:
+ if self._group_join_policy(session_group) == "open":
+ # An open-join group admits anyone the hub calls a member, so a
+ # code would protect nothing — the hub can walk in through the
+ # front door. Pin what turns up and say so in the audit log.
+ await self._pin_and_admit(
+ roster, user_id, username, pk_ed_b64, pk_x_b64,
+ group_id=session_group, role=ROLE_MEMBER,
+ approved_by="open-join", via="tofu")
+ await self._join_ok(user_id, pk_x_raw, session_group,
+ role=ROLE_MEMBER, recognised=False)
+ return
+ self._join_refuse("code_required")
+ return
+
+ invite = await roster.consume_invite(code, user_id)
+ if not invite:
+ self._join_refuse("code_invalid")
+ return
+
+ await self._pin_and_admit(
+ # The name comes from the invitation, not from the token: the hub does
+ # not put a username claim in a JWT, so pinning from the session alone
+ # left the roster nameless and `member revoke <name>` unable to match.
+ roster, user_id, invite["username"] or username, pk_ed_b64, pk_x_b64,
+ group_id=invite["group_id"], role=invite["role"],
+ approved_by=invite["created_by"], via="code")
+ # The roster row comes from the invitation; the key comes from the
+ # connection. An operator pairing is node-wide (empty group), but they
+ # redeemed the code while opening a group and expect to read it — and
+ # is_authorized() already grants an operator every group on this node.
+ await self._join_ok(user_id, pk_x_raw, session_group or invite["group_id"],
+ role=invite["role"], recognised=False)
+
+ def _group_join_policy(self, group_id: str) -> str:
+ """
+ Admission policy for a group, read from the node's own configuration.
+
+ Never from the hub: a hub that could declare a group open would be handed
+ the key to it (§3.4 of docs/invite-pairing-v1.md).
+ """
+ gctx = (self._ctx.get("groups") or {}).get(group_id) or {}
+ return gctx.get("join_policy", "invite")
+
+ async def _pin_and_admit(
+ self, roster, user_id: str, username: str, pk_ed_b64: str, pk_x_b64: str,
+ *, group_id: str, role: str, approved_by: str, via: str,
+ ) -> None:
+ await roster.pin_identity(
+ user_id=user_id, username=username,
+ pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, via=via,
+ )
+ await roster.set_member(
+ group_id=group_id, user_id=user_id, role=role,
+ status="active", approved_by=approved_by,
+ )
+ if role == ROLE_OPERATOR:
+ self._ctx["has_admin_authority"] = True
+
+ log.info("Identity pinned (%s): user=%s role=%s", via, user_id[:8], role)
+ self._audit_join("join_pinned", f"role={role} via={via}")
+
+ async def _join_ok(
+ self, user_id: str, pk_x_raw: bytes, group_id: str,
+ *, role: str, recognised: bool,
+ ) -> None:
+ """
+ Answer a join, wrapping the group key for the key the caller just proved.
+
+ This is the H3 fix. The inviter used to fetch the invitee's public key from
+ the hub and wrap the GEK for whatever came back, so a hub that answered
+ with its own key was handed the group key by an honest member following the
+ protocol exactly. The node now wraps for a key that arrived from its owner
+ over an authenticated channel, bound to a pinned identity.
+ """
+ reply = {
+ "type": MNP.JOIN_RESULT,
+ "v": MNP_VERSION,
+ "ok": True,
+ "recognised": recognised,
+ "role": role,
+ }
+
+ roster = self._ctx["roster"]
+ if group_id and not await roster.is_authorized(group_id, user_id):
+ # Pinned on this node, but not admitted to this group. Hub membership
+ # alone must not produce a key.
+ reply["gek"] = False
+ reply["reason"] = "not_authorized_for_group"
+ self._send(reply)
+ self._audit_join("join_no_gek", f"group={group_id[:8]} not authorized")
+ return
+
+ gctx = (self._ctx.get("groups") or {}).get(group_id) or {}
+ gek = gctx.get("gek")
+ if not gek:
+ reply["gek"] = False
+ reply["reason"] = "no_gek"
+ self._send(reply)
+ return
+
+ bundle = wrap_gek_aes(gek, pk_x_raw)
+ reply["gek"] = True
+ reply["pk_eph_b64"] = bundle["pk_eph_b64"]
+ reply["nonce_b64"] = bundle["nonce_b64"]
+ reply["wrapped_b64"] = bundle["wrapped_b64"]
+ self._send(reply)
+ self._audit_join("gek_wrapped", f"group={group_id[:8]}")
+
+ async def _do_keypair_bundle_delete(self) -> None:
+ """
+ Withdraw our own key backup from this node.
+
+ Only ever our own: the user_id comes from the authenticated session, never
+ from the message. Someone who does not want a second browser should not be
+ leaving a PBKDF2-protected blob on every node they have ever joined (C4),
+ and turning the setting off has to remove what is already there — not just
+ stop adding to it.
+ """
+ bundle_store = self._ctx.get("bundle_store")
+ if not bundle_store:
+ self._send({"type": "error", "detail": "Bundle store not available"})
+ return
+
+ removed = await bundle_store.delete_keypair(self._user_id)
+ if removed:
+ log.info("Keypair bundle withdrawn by user=%s", self._user_id[:8])
+ self._audit("keypair_bundle_delete")
+ self._send({"type": "ack", "v": MNP_VERSION,
+ "detail": "keypair_bundle_deleted", "removed": removed})
+
+ def _audit_pre_proof_fetch(self, mtype: str) -> None:
+ """Record bundle access made before the GEK proof (C4)."""
+ audit = self._ctx.get("audit_store")
+ if not audit:
+ return
+ self._remote_ip = self._remote_ip or _get_remote_ip(self._pc)
+ asyncio.ensure_future(audit.log_event(
+ user_id=getattr(self, "_pending_sub", "unknown"),
+ event="pre_proof_fetch",
+ ip=self._remote_ip,
+ group_id=getattr(self, "_pending_group", "") or "",
+ detail=mtype,
+ ))
+
def _audit_auth_failed(self, group_id: str, reason: str) -> None:
audit = self._ctx.get("audit_store")
if audit:
@@ -508,6 +887,20 @@ class WebRTCPeerSession:
return self._ctx["groups"][self._group_id]
return self._ctx
+ def _peer_registry(self) -> dict:
+ """
+ Connected peers for THIS group only.
+
+ Finding H1: this used to live on the shared transport context, so a chat
+ message was broadcast to every peer on the node regardless of which group
+ they had authenticated to.
+ """
+ return self._group_ctx().setdefault("_peers", {})
+
+ def _user_names(self) -> dict:
+ """Display-name cache, per group — same leak as _peer_registry (H1)."""
+ return self._group_ctx().setdefault("_user_names", {})
+
def _do_index_sync(self) -> None:
ctx = self._group_ctx()
idx = ctx["index"]
@@ -555,6 +948,17 @@ class WebRTCPeerSession:
self._audit("file_download", entry.name)
def _do_stream_segment(self, msg: dict) -> None:
+ asyncio.ensure_future(self._do_stream_segment_async(msg))
+
+ async def _do_stream_segment_async(self, msg: dict) -> None:
+ """
+ Legacy HLS segment extraction (superseded by stream_req/MSE).
+
+ Finding H6: this ran subprocess.run(..., timeout=30) directly inside the
+ event loop, so a single request stalled the whole daemon — every peer,
+ every group — for up to thirty seconds. Now async and under the same
+ transcode semaphore as _stream_video.
+ """
ctx = self._group_ctx()
file_id = msg["file_id"]
segment_index = msg["segment_index"]
@@ -570,21 +974,34 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "File not on disk"})
return
- import subprocess
+ sem = self._ctx.get("_transcode_sem")
+ if sem is None:
+ sem = asyncio.Semaphore(MAX_CONCURRENT_TRANSCODES)
+ self._ctx["_transcode_sem"] = sem
+
try:
- result = subprocess.run(
- ["ffmpeg", "-hide_banner", "-loglevel", "error",
- "-ss", str(segment_index * segment_duration),
- "-i", str(file_path),
- "-t", str(segment_duration),
- "-c:v", "copy", "-c:a", "copy",
- "-f", "mpegts", "pipe:1"],
- capture_output=True, timeout=30,
- )
- if result.returncode != 0 or not result.stdout:
+ async with sem:
+ proc = await asyncio.create_subprocess_exec(
+ "ffmpeg", "-hide_banner", "-loglevel", "error",
+ "-ss", str(segment_index * segment_duration),
+ "-i", str(file_path),
+ "-t", str(segment_duration),
+ "-c:v", "copy", "-c:a", "copy",
+ "-f", "mpegts", "pipe:1",
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.DEVNULL,
+ )
+ try:
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=30)
+ except asyncio.TimeoutError:
+ proc.kill()
+ await proc.wait()
+ self._send({"type": "error", "detail": "Segment extraction timed out"})
+ return
+ if proc.returncode != 0 or not stdout:
self._send({"type": "error", "detail": "Segment extraction failed"})
return
- segment_data = result.stdout
+ segment_data = stdout
except Exception:
self._send({"type": "error", "detail": "Segment extraction failed"})
return
@@ -599,11 +1016,14 @@ class WebRTCPeerSession:
})
def _do_chat_message(self, msg: dict) -> None:
- chat_store = self._ctx.get("chat_store")
+ # Per-group store — see _peer_registry() and finding H1. Reading chat_store
+ # off the shared transport context sent every group's messages to the first
+ # group's database, and served them back to anyone on the node.
+ chat_store = self._group_ctx().get("chat_store")
payload = msg.get("payload", "")
sender_name = msg.get("sender_name", "")
if sender_name:
- self._ctx.setdefault("_user_names", {})[self._user_id] = sender_name
+ self._user_names()[self._user_id] = sender_name
if chat_store:
raw = payload.encode() if isinstance(payload, str) else payload
asyncio.ensure_future(chat_store.save_message(
@@ -614,7 +1034,7 @@ class WebRTCPeerSession:
sender_name=sender_name,
))
- peers = self._ctx.get("_peers", {})
+ peers = self._peer_registry()
broadcast = {
"type": MNP.CHAT_MESSAGE,
"v": MNP_VERSION,
@@ -647,7 +1067,7 @@ class WebRTCPeerSession:
self._audit("chat_message")
def _do_chat_history(self, msg: dict) -> None:
- chat_store = self._ctx.get("chat_store")
+ chat_store = self._group_ctx().get("chat_store")
if not chat_store:
self._send({
"type": MNP.CHAT_HISTORY_RESPONSE,
@@ -662,7 +1082,7 @@ class WebRTCPeerSession:
async def _send_chat_history(self, chat_store, since: float, limit: int) -> None:
msgs = await chat_store.get_messages(since=since, limit=limit)
- names = self._ctx.get("_user_names", {})
+ names = self._user_names()
self._send({
"type": MNP.CHAT_HISTORY_RESPONSE,
"v": MNP_VERSION,
@@ -691,24 +1111,55 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "Missing filename or data"})
return
+ if not SAFE_UPLOAD_NAME.match(filename):
+ self._send({"type": "error", "detail": "Invalid filename"})
+ return
+
shared_root = ctx.get("shared_root")
if not shared_root:
self._send({"type": "error", "detail": "No shared directory"})
return
- upload_dir = shared_root / ".uploads"
- upload_dir.mkdir(exist_ok=True)
- safe_name = filename.replace("/", "_").replace("\\", "_").replace("..", "_")
- tmp_path = upload_dir / f"{safe_name}.part"
+ # Per-user quarantine: a member can only ever write inside their own directory,
+ # so they cannot overwrite the operator's files or another member's (C5a).
+ rel_dir = f"{UPLOAD_DIR_NAME}/{self._user_id}"
+ user_dir = shared_root / UPLOAD_DIR_NAME / self._user_id
+ user_dir.mkdir(parents=True, exist_ok=True)
+ tmp_path = user_dir / f"{filename}.part"
+ final_path = user_dir / filename
+
+ state = self._uploads.get(filename)
+ if chunk_index == 0:
+ if final_path.exists():
+ self._send({"type": "error", "detail": "File already exists"})
+ return
+ state = {"next_index": 0, "bytes": 0}
+ self._uploads[filename] = state
+ elif state is None:
+ self._send({"type": "error", "detail": "Upload not started"})
+ return
+
+ # Reject out-of-order or replayed chunks — otherwise chunk_index>0 appends
+ # blindly to whatever .part file is already on disk.
+ if chunk_index != state["next_index"]:
+ self._send({"type": "error", "detail": "Unexpected chunk index"})
+ return
if isinstance(data, str):
chunk_bytes = base64.b64decode(data)
else:
chunk_bytes = bytes(data)
- mode = "ab" if chunk_index > 0 else "wb"
- with open(tmp_path, mode) as f:
+ if state["bytes"] + len(chunk_bytes) > MAX_UPLOAD_BYTES:
+ self._uploads.pop(filename, None)
+ tmp_path.unlink(missing_ok=True)
+ self._send({"type": "error", "detail": "Upload exceeds size limit"})
+ return
+
+ with open(tmp_path, "wb" if chunk_index == 0 else "ab") as f:
f.write(chunk_bytes)
+ state["next_index"] = chunk_index + 1
+ state["bytes"] += len(chunk_bytes)
self._send({
"type": MNP.FILE_UPLOAD_ACK,
@@ -718,21 +1169,30 @@ class WebRTCPeerSession:
})
if chunk_index + 1 >= total_chunks:
- final_path = shared_root / safe_name
+ self._uploads.pop(filename, None)
tmp_path.rename(final_path)
- log.info("Upload complete: %s (%d chunks)", safe_name, total_chunks)
- self._audit("file_upload", safe_name)
- self._register_uploader(ctx, safe_name)
+ log.info("Upload complete: %s (%d chunks, %d bytes)",
+ filename, total_chunks, state["bytes"])
+ self._audit("file_upload", f"{rel_dir}/{filename}")
+ self._register_uploader(ctx, rel_dir, filename)
+
+ def _register_uploader(self, ctx: dict, rel_dir: str, filename: str) -> None:
+ """
+ Tag the index entry with the uploader's identity after upload completes.
- def _register_uploader(self, ctx: dict, filename: str) -> None:
- """Tag the index entry with the uploader's user_id after upload completes."""
+ The key recorded here is the one this node pinned, not the one the token
+ carried. `pk_user` was a hub-chosen claim, and it decided who could later
+ delete the file: a hub issuing a token naming its own key could delete
+ anyone's uploads on any node. Deletion is supposed to be authorized by the
+ node, and this closes the last place where it was not.
+ """
idx = ctx.get("index")
if not idx:
return
for entry in idx.entries:
- if entry.name == filename and entry.path == "":
+ if entry.name == filename and entry.path == rel_dir:
entry.uploader_id = self._user_id
- entry.uploader_pk = self._pk_user
+ entry.uploader_pk = self._pinned_pk
return
def _do_file_delete(self, msg: dict) -> None:
@@ -747,28 +1207,115 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "File not found"})
return
- admin_pk = self._ctx.get("admin_pk_ed25519")
has_uploader_pk = bool(entry.uploader_pk)
- if not admin_pk and not has_uploader_pk:
+ if not self._has_admin_authority() and not has_uploader_pk:
self._send({"type": "error", "detail": "No authorized key for deletion"})
return
- challenge = os.urandom(32)
- self._admin_challenges[file_id] = challenge
+ self._issue_admin_challenge(OP_FILE_DELETE, file_id)
+
+ # ── Admin operation challenge/response (finding H5) ──────────────────────
+
+ def _node_pk_b64(self) -> str:
+ return pk_to_b64(self._ctx["sk_node"].public_key())
+
+ def _issue_admin_challenge(
+ self, op: str, subject: str, payload: dict | None = None,
+ ) -> None:
+ """
+ Ask the client to authorize `op` on `subject` with its Ed25519 identity key.
+
+ The client is sent the transcript *fields*, not opaque bytes, so it can
+ rebuild and inspect what it signs. The node keeps the authoritative copy and
+ rebuilds the transcript itself at verification time — nothing signed is ever
+ taken from the response message.
+ """
+ nonce = os.urandom(32)
+ ts = int(time.time())
+ op_id = base64.b64encode(os.urandom(16)).decode()
+ self._admin_ops[op_id] = {
+ "op": op, "subject": subject, "nonce": nonce, "ts": ts,
+ "payload": payload or {},
+ }
self._send({
"type": MNP.ADMIN_CHALLENGE,
"v": MNP_VERSION,
- "challenge": base64.b64encode(challenge).decode(),
- "file_id": file_id,
+ "op_id": op_id,
+ "op": op,
+ "subject": subject,
+ "nonce": base64.b64encode(nonce).decode(),
+ "ts": ts,
+ "node_pk": self._node_pk_b64(),
+ "group_id": self._group_id or "",
})
+ @staticmethod
+ def _verify_sig(pk: Ed25519PublicKey | None, transcript: bytes, sig: bytes) -> bool:
+ if pk is None:
+ return False
+ try:
+ pk.verify(sig, transcript)
+ return True
+ except Exception:
+ return False
+
+ async def _load_pinned_pk(self) -> None:
+ """Remember which key this node pinned for the peer we just authenticated."""
+ roster = self._ctx.get("roster")
+ if roster is None or not self._user_id:
+ return
+ ident = await roster.get_identity(self._user_id)
+ if ident:
+ self._pinned_pk = ident["pk_ed25519"]
+
+ def _has_admin_authority(self) -> bool:
+ """
+ Cheap synchronous pre-check: is there anyone who could authorize this?
+
+ Only decides whether to issue a challenge at all — the gate is
+ `_verify_admin_sig`. The flag is set at startup and refreshed in-process
+ when an operator pairs.
+ """
+ return bool(self._ctx.get("admin_pk_ed25519")
+ or self._ctx.get("has_admin_authority"))
+
+ async def _verify_admin_sig(self, transcript: bytes, sig: bytes) -> bool:
+ """
+ Check a signature against every key holding node-operator authority.
+
+ Read from the roster on each call rather than cached: revoking a paired
+ browser must take effect immediately, and admin operations are rare enough
+ that a SQLite read costs nothing. `admin_pk_ed25519` in node.toml is still
+ honoured so an existing deployment keeps working until its operator pairs
+ (M3) — it is the legacy form of the same statement.
+ """
+ legacy = self._ctx.get("admin_pk_ed25519")
+ if self._verify_sig(legacy, transcript, sig):
+ return True
+
+ roster = self._ctx.get("roster")
+ if roster is None:
+ return False
+ for pk_b64 in await roster.operator_pks():
+ try:
+ pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(pk_b64))
+ except Exception:
+ continue
+ if self._verify_sig(pk, transcript, sig):
+ return True
+ return False
+
def _do_admin_response(self, msg: dict) -> None:
- file_id = msg.get("file_id", "")
+ op_id = msg.get("op_id", "")
sig_b64 = msg.get("signature", "")
- challenge = self._admin_challenges.pop(file_id, None)
- if not challenge:
- self._send({"type": "error", "detail": "No pending admin challenge"})
+ pending = self._admin_ops.pop(op_id, None)
+ if not pending:
+ self._send({"type": "error", "detail": "No pending admin operation"})
+ return
+
+ if time.time() - pending["ts"] > ADMIN_CHALLENGE_TTL:
+ self._send({"type": "error", "detail": "Admin challenge expired"})
return
try:
@@ -777,40 +1324,96 @@ class WebRTCPeerSession:
self._send({"type": "error", "detail": "Invalid signature encoding"})
return
+ transcript = admin_transcript(
+ op=pending["op"],
+ node_pk_b64=self._node_pk_b64(),
+ group_id=self._group_id or "",
+ subject=pending["subject"],
+ nonce=pending["nonce"],
+ ts=pending["ts"],
+ )
+
+ if pending["op"] == OP_FILE_DELETE:
+ asyncio.ensure_future(
+ self._admin_exec_file_delete(pending, transcript, sig_bytes))
+ elif pending["op"] == OP_INVITE_CREATE:
+ asyncio.ensure_future(
+ self._admin_exec_invite_create(pending, transcript, sig_bytes))
+ else:
+ self._send({"type": "error", "detail": "Unknown admin operation"})
+
+ async def _admin_exec_file_delete(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ file_id = pending["subject"]
ctx = self._group_ctx()
entry = ctx["index"].get_entry(file_id)
if not entry:
self._send({"type": "error", "detail": "File not found"})
return
- verified = False
-
- # Try admin key (locally pinned)
- admin_pk = self._ctx.get("admin_pk_ed25519")
- if admin_pk:
- try:
- admin_pk.verify(sig_bytes, challenge)
- verified = True
- except Exception:
- pass
-
- # Try uploader key (stored at upload time)
- if not verified and entry.uploader_pk:
+ uploader_pk = None
+ if entry.uploader_pk:
try:
- uploader_key = Ed25519PublicKey.from_public_bytes(
+ uploader_pk = Ed25519PublicKey.from_public_bytes(
base64.b64decode(entry.uploader_pk))
- uploader_key.verify(sig_bytes, challenge)
- verified = True
except Exception:
- pass
+ uploader_pk = None
- if not verified:
+ # Node operator, or the user who uploaded this file — verified by the key
+ # recorded at upload time, never by a JWT claim (the hub controls those).
+ if not (await self._verify_admin_sig(transcript, sig)
+ or self._verify_sig(uploader_pk, transcript, sig)):
self._send({"type": "error", "detail": "Signature verification failed"})
self._audit("admin_auth_failed", f"file_delete:{file_id[:16]}")
return
self._exec_file_delete(ctx, file_id, entry)
+ async def _admin_exec_invite_create(
+ self, pending: dict, transcript: bytes, sig: bytes,
+ ) -> None:
+ # Node operator only. A group admin who does not run the node has no
+ # authority over who this node admits (deny by default). Delegation is
+ # designed but deferred — see §6.2 of docs/invite-pairing-v1.md.
+ if not await self._verify_admin_sig(transcript, sig):
+ self._send({"type": "error", "detail": "Signature verification failed"})
+ self._audit("admin_auth_failed", f"invite_create:{pending['subject'][:16]}")
+ return
+
+ roster = self._ctx.get("roster")
+ if roster is None:
+ self._send({"type": "error", "detail": "Roster not available"})
+ return
+
+ payload = pending["payload"]
+ code = await roster.create_invite(
+ group_id=payload["group_id"],
+ user_id=payload["user_id"],
+ role=ROLE_MEMBER,
+ created_by=self._user_id or "",
+ ttl=self._ctx.get("invite_ttl", DEFAULT_INVITE_TTL),
+ username=payload.get("username", ""),
+ )
+ invites = await roster.list_invites()
+ expires = next(
+ (i["expires_at"] for i in invites
+ if i["user_id"] == payload["user_id"]
+ and i["group_id"] == payload["group_id"]), "")
+
+ log.info("Invite created: group=%s user=%s",
+ payload["group_id"][:8], payload["user_id"][:8])
+ self._audit("invite_create", f"target={payload['user_id'][:8]}")
+ # The code exists in the clear exactly here and in the operator's hands.
+ self._send({
+ "type": MNP.INVITE_RESULT,
+ "v": MNP_VERSION,
+ "code": code,
+ "expires_at": expires,
+ "user_id": payload["user_id"],
+ "username": payload.get("username", ""),
+ })
+
def _exec_file_delete(self, ctx: dict, file_id: str, entry) -> None:
file_path = ctx["shared_root"] / entry.path / entry.name
if file_path.exists():
@@ -827,6 +1430,20 @@ class WebRTCPeerSession:
async def _stream_video(self, msg: dict) -> None:
"""Stream a video file as fMP4 segments via MSE-compatible output."""
+ # One ffmpeg per request with no cap lets any member exhaust the node's
+ # CPU and process table (H6). The semaphore lives on the transport context
+ # so it is shared across all peers, not per-session.
+ sem = self._ctx.get("_transcode_sem")
+ if sem is None:
+ sem = asyncio.Semaphore(MAX_CONCURRENT_TRANSCODES)
+ self._ctx["_transcode_sem"] = sem
+ if sem.locked() and sem._value <= 0:
+ self._send({"type": "error", "detail": "Server busy, retry shortly"})
+ return
+ async with sem:
+ await self._stream_video_inner(msg)
+
+ async def _stream_video_inner(self, msg: dict) -> None:
ctx = self._group_ctx()
file_id = msg.get("file_id", "")
entry = ctx["index"].get_entry(file_id)
@@ -914,9 +1531,8 @@ class WebRTCPeerSession:
async def close(self) -> None:
self._audit("disconnect")
- peers = self._ctx.get("_peers")
- if peers and self._user_id:
- peers.pop(self._user_id, None)
+ if self._user_id:
+ self._peer_registry().pop(self._user_id, None)
await self._pc.close()
diff --git a/packages/meshbay-node/src/meshbay_node/ui/app.py b/packages/meshbay-node/src/meshbay_node/ui/app.py
index b4885af..28654df 100644
--- a/packages/meshbay-node/src/meshbay_node/ui/app.py
+++ b/packages/meshbay-node/src/meshbay_node/ui/app.py
@@ -9,13 +9,14 @@ FastAPI app providing:
- API endpoints for all data (JSON)
Served only on 127.0.0.1 — not exposed to the network.
-No authentication required (localhost only).
+Gated by a per-run session token (11.5.3) — printed at daemon startup.
"""
import base64
import json
import logging
import time
+from html import escape
from pathlib import Path
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query
@@ -23,6 +24,7 @@ from fastapi.responses import HTMLResponse, JSONResponse
from meshbay_node import __version__
from meshbay_common.crypto import generate_gek, wrap_gek_aes
+from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR
log = logging.getLogger(__name__)
@@ -35,6 +37,55 @@ def create_ui_app(state: dict) -> FastAPI:
redoc_url=None,
)
+ @app.middleware("http")
+ async def _require_session_token(request, call_next):
+ """
+ Gate the admin UI behind a per-run token (11.5.3).
+
+ "localhost only" is weaker than it sounds: any process on the machine can
+ reach it, and a page in the operator's browser can reach it too via DNS
+ rebinding. Since this API can re-initialise a group's GEK and read the
+ audit log, an unauthenticated loopback service is a privilege boundary
+ waiting to be crossed. The token is printed at startup and accepted as
+ ?t= or the X-MeshBay-Token header.
+ """
+ from fastapi.responses import PlainTextResponse
+
+ token = state.get("ui_token")
+ if token:
+ supplied = (request.query_params.get("t")
+ or request.headers.get("X-MeshBay-Token"))
+ if supplied != token:
+ return PlainTextResponse("Forbidden", status_code=403)
+ return await call_next(request)
+
+ @app.middleware("http")
+ async def _security_headers(request, call_next):
+ """
+ Defence in depth behind the escaping fixes for H2. This UI is unauthenticated
+ on loopback, so script execution here equals full control of the node admin API.
+
+ Note what this does and does not do: the page relies on inline <script>, so
+ script-src must allow 'unsafe-inline' and CSP therefore does NOT prevent an
+ injected script from running. Escaping is the actual fix. What CSP buys is
+ containment — connect-src/img-src/form-action 'self'|'none' stop an injected
+ script from exfiltrating the audit log or config to an external host.
+ """
+ response = await call_next(request)
+ response.headers["Content-Security-Policy"] = (
+ "default-src 'none'; "
+ "style-src 'unsafe-inline'; "
+ "script-src 'unsafe-inline'; "
+ "connect-src 'self'; "
+ "img-src 'self' data:; "
+ "form-action 'none'; "
+ "frame-ancestors 'none'; "
+ "base-uri 'none'"
+ )
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["Referrer-Policy"] = "no-referrer"
+ return response
+
# ── JSON API ─────────────────────────────────────────────────────────────
@app.get("/api/status")
@@ -48,7 +99,6 @@ def create_ui_app(state: dict) -> FastAPI:
"status": state.get("status", "starting"),
"hub_url": state.get("hub_url", ""),
"username": state.get("username", ""),
- "node_port": state.get("node_port", 0),
"quic_port": state.get("quic_port", 0),
"endpoint_hint": state.get("endpoint_hint"),
"group_count": len(groups_ctx),
@@ -154,9 +204,7 @@ def create_ui_app(state: dict) -> FastAPI:
return {
"hub_url": config.hub.url,
"username": config.hub.username,
- "node_port": config.node.port,
"quic_port": config.node.quic_port,
- "http_port": config.node.http_port,
"ui_port": config.node.ui_port,
"data_dir": str(config.data_dir),
"groups": [
@@ -170,11 +218,159 @@ def create_ui_app(state: dict) -> FastAPI:
],
}
+ # ── Operator pairing (localhost only) ──────────────────────────────────
+
+ @app.post("/api/operator/pair")
+ async def operator_pair():
+ """
+ Issue a one-time code that pairs a browser as this node's operator.
+
+ The code is the whole point: it binds the operator's browser identity key
+ to their account without asking the hub, which is what stops a hub from
+ naming itself node administrator (M3, and the same substitution as H3).
+ It is returned once and stored only as a hash.
+ """
+ roster = state.get("roster")
+ user_id = state.get("node_user_id")
+ if not roster or not user_id:
+ return JSONResponse({"error": "Node not connected to hub yet"}, 503)
+
+ config = state.get("config")
+ ttl = (config.node.pair_ttl_hours if config else 24) * 3600
+ code = await roster.create_invite(
+ group_id="", # operator authority is node-wide
+ user_id=user_id,
+ role=ROLE_OPERATOR,
+ created_by="local-cli",
+ ttl=ttl,
+ username=(config.hub.username if config else ""),
+ )
+ invites = await roster.list_invites()
+ expires = next((i["expires_at"] for i in invites
+ if i["user_id"] == user_id and i["role"] == ROLE_OPERATOR), "")
+ return {"code": code, "expires_at": expires, "user_id": user_id}
+
+ @app.get("/api/roster")
+ async def api_roster(group_id: str = ""):
+ roster = state.get("roster")
+ if not roster:
+ return {"identities": [], "members": [], "invites": []}
+ return {
+ "identities": await roster.list_identities(),
+ "members": await roster.list_members(group_id or None),
+ "invites": await roster.list_invites(),
+ }
+
+ @app.post("/api/groups/{group_id}/invites")
+ async def create_invite(group_id: str, username: str):
+ """
+ Issue an invitation code from the CLI, without a browser.
+
+ The hub is asked for the account id and nothing else — never for a key.
+ A hub that answered with the wrong account would produce an invite whose
+ code it never learns, since the code goes to a human out of band.
+ """
+ roster = state.get("roster")
+ groups_ctx = state.get("groups_ctx", {})
+ if not roster:
+ return JSONResponse({"error": "Roster not available"}, 503)
+ if group_id not in groups_ctx:
+ return JSONResponse({"error": "Group not hosted on this node"}, 404)
+
+ hub = state.get("hub")
+ if not hub or not hub._session:
+ return JSONResponse({"error": "Hub not connected"}, 503)
+ try:
+ account = await hub.get_user_pubkeys(username)
+ except Exception as e:
+ return JSONResponse({"error": f"Unknown user {username!r}: {e}"}, 404)
+
+ config = state.get("config")
+ ttl = (config.node.invite_ttl_hours if config else 168) * 3600
+ code = await roster.create_invite(
+ group_id=group_id,
+ user_id=account["user_id"],
+ role=ROLE_MEMBER,
+ created_by="local-cli",
+ ttl=ttl,
+ username=username,
+ )
+ invites = await roster.list_invites()
+ expires = next((i["expires_at"] for i in invites
+ if i["user_id"] == account["user_id"]
+ and i["group_id"] == group_id), "")
+ return {"code": code, "expires_at": expires,
+ "username": username, "user_id": account["user_id"]}
+
+ @app.get("/api/resolve")
+ async def resolve_user(username: str):
+ """
+ Map a username to an account id for the CLI.
+
+ The roster answers first — it is the node's own record. The hub is the
+ fallback for identities pinned before invitations carried a name, and for
+ people admitted through an open-join group. Only an account id comes back;
+ no key is ever taken from here.
+ """
+ roster = state.get("roster")
+ if roster:
+ for ident in await roster.list_identities():
+ if ident["username"] == username:
+ return {"user_id": ident["user_id"], "source": "roster"}
+ hub = state.get("hub")
+ if hub and hub._session:
+ try:
+ account = await hub.get_user_pubkeys(username)
+ return {"user_id": account["user_id"], "source": "hub"}
+ except Exception:
+ pass
+ return JSONResponse({"error": f"Unknown user {username!r}"}, 404)
+
+ @app.post("/api/members/{user_id}/revoke")
+ async def revoke_member(user_id: str, group_id: str):
+ """
+ Stop serving the group key to someone.
+
+ Takes effect on their next connection: the key is wrapped on demand, so
+ there is no stored bundle left behind that would outlive this. Rotating
+ the group key is still required — they hold the current one.
+ """
+ roster = state.get("roster")
+ if not roster:
+ return JSONResponse({"error": "Roster not available"}, 503)
+ if not await roster.set_status(group_id, user_id, "revoked"):
+ return JSONResponse({"error": "No such member in that group"}, 404)
+ log.info("Member revoked: user=%s group=%s", user_id[:8], group_id[:8])
+ return {"status": "revoked", "user_id": user_id, "group_id": group_id,
+ "reminder": "rotate the group key: meshbay-node gek-init"}
+
+ @app.post("/api/members/{user_id}/unpin")
+ async def unpin_member(user_id: str):
+ """Forget a pinned identity, so the person can pair again with a new key."""
+ roster = state.get("roster")
+ if not roster:
+ return JSONResponse({"error": "Roster not available"}, 503)
+ if not await roster.unpin(user_id):
+ return JSONResponse({"error": "No such pinned identity"}, 404)
+ log.info("Identity unpinned: user=%s", user_id[:8])
+ return {"status": "unpinned", "user_id": user_id}
+
# ── GEK initialization (operator only, localhost) ──────────────────────
@app.post("/api/groups/{group_id}/gek")
async def init_gek(group_id: str):
- """Generate GEK, wrap for all group members, store, and activate."""
+ """
+ Generate the group key and activate it.
+
+ It used to be wrapped here for every member, using public keys fetched from
+ the hub — which is H3 with the node as the victim instead of the inviter: a
+ hub answering with its own key was handed the group key by the node itself.
+
+ Nothing is pre-wrapped for members now. Each member's copy is produced when
+ they connect, for a key they proved they hold (`join_request`). Only the
+ node's own copy is stored, so the daemon can reload the key across restarts
+ without the operator's browser.
+ """
groups_ctx = state.get("groups_ctx", {})
if group_id not in groups_ctx:
return JSONResponse({"error": "Group not hosted on this node"}, 404)
@@ -187,48 +383,15 @@ def create_ui_app(state: dict) -> FastAPI:
if not bundle_store:
return JSONResponse({"error": "Bundle store not available"}, 503)
- await hub.ensure_fresh_token()
- session = hub._session
- members_resp = await hub._http.get(
- f"/v1/groups/{group_id}/members",
- headers=session.auth_headers,
- )
- if not members_resp.is_success:
- return JSONResponse(
- {"error": f"Failed to fetch members: {members_resp.status_code}"}, 502)
- members = members_resp.json().get("members", [])
- if not members:
- return JSONResponse({"error": "No members in group"}, 400)
-
existing_gek = groups_ctx[group_id].get("gek")
gek = existing_gek or generate_gek()
+ errors: list[str] = []
- wrapped_count = 0
- errors = []
- for member in members:
- username = member["username"]
- user_id = member["user_id"]
- try:
- pk_data = await hub.get_user_pubkeys(username)
- pk_x_raw = base64.b64decode(pk_data["pk_x25519"])
- bundle = wrap_gek_aes(gek, pk_x_raw)
- await bundle_store.store(
- group_id, user_id,
- bundle["pk_eph_b64"], bundle["nonce_b64"], bundle["wrapped_b64"],
- )
- wrapped_count += 1
- log.info("GEK wrapped for %s (%s)", username, user_id[:8])
- except Exception as e:
- errors.append(f"{username}: {e}")
- log.warning("Failed to wrap GEK for %s: %s", username, e)
+ roster = state.get("roster")
+ authorized = len(await roster.list_members(group_id)) if roster else 0
- if wrapped_count == 0:
- return JSONResponse(
- {"error": "Failed to wrap GEK for any member", "details": errors}, 500)
-
- # Also store a copy wrapped for the node keystore X25519 key
- # so the daemon can reload GEK on restart without the operator's browser keys
- config = state.get("config")
+ # Store a copy wrapped for the node keystore X25519 key so the daemon can
+ # reload the GEK on restart without the operator's browser keys.
node_user_id = hub._session.user_id if hub._session else None
pk_x_node_raw = state.get("pk_x25519_raw")
if pk_x_node_raw and node_user_id:
@@ -239,13 +402,14 @@ def create_ui_app(state: dict) -> FastAPI:
node_bundle["pk_eph_b64"], node_bundle["nonce_b64"],
node_bundle["wrapped_b64"],
)
- log.info("GEK also wrapped for node keystore (daemon reload)")
+ log.info("GEK wrapped for node keystore (daemon reload)")
except Exception as e:
+ errors.append(f"node keystore: {e}")
log.warning("Failed to wrap GEK for node keystore: %s", e)
groups_ctx[group_id]["gek"] = gek
- log.info("GEK initialized for group %s — wrapped for %d/%d members",
- group_id[:8], wrapped_count, len(members))
+ log.info("GEK initialized for group %s — %d authorized member(s) will "
+ "receive it on connect", group_id[:8], authorized)
webrtc = state.get("webrtc")
if webrtc and "groups" in webrtc._ctx and group_id in webrtc._ctx["groups"]:
@@ -254,8 +418,7 @@ def create_ui_app(state: dict) -> FastAPI:
return {
"status": "ok",
"group_id": group_id,
- "wrapped_count": wrapped_count,
- "total_members": len(members),
+ "authorized_members": authorized,
"errors": errors,
}
@@ -311,11 +474,21 @@ def create_ui_app(state: dict) -> FastAPI:
@app.get("/", response_class=HTMLResponse)
async def root():
- return _render_page(state)
+ # Roster reads are async and the page renderer is not, so gather here.
+ roster = state.get("roster")
+ roster_view = None
+ if roster:
+ identities = {i["user_id"]: i for i in await roster.list_identities()}
+ roster_view = {
+ "identities": identities,
+ "members": await roster.list_members(),
+ "invites": await roster.list_invites(),
+ }
+ return _render_page(state, roster_view)
@app.get("/audit", response_class=HTMLResponse)
async def audit_page():
- return _render_audit_page()
+ return _render_audit_page(state.get("ui_token", ""))
return app
@@ -330,7 +503,71 @@ def _fmt_size(n: int) -> str:
return f"{n / (1024 * 1024 * 1024):.2f} GB"
-def _render_page(state: dict) -> str:
+def _render_roster(roster_view: dict | None) -> str:
+ """
+ Who this node recognises, and which keys are theirs.
+
+ Every value here is escaped: usernames come from the hub and pass through the
+ roster, so they are attacker-influenced text on the operator's own admin page
+ (the H2 rule applies to them exactly as it does to filenames).
+ """
+ if roster_view is None:
+ return '<p class="muted">Roster unavailable</p>'
+
+ identities = roster_view["identities"]
+ rows = ""
+ for m in roster_view["members"]:
+ ident = identities.get(m["user_id"], {})
+ scope = escape(m["group_id"][:8]) if m["group_id"] else "node-wide"
+ status_color = "#22c55e" if m["status"] == "active" else "#ef4444"
+ rows += (
+ f"<tr><td>{escape(str(ident.get('username') or m['user_id']))}</td>"
+ f"<td>{escape(str(m['role']))}</td>"
+ f"<td><span class='badge' style='background:{status_color}'>"
+ f"{escape(str(m['status']))}</span></td>"
+ f"<td>{scope}</td>"
+ f"<td><code>{escape(str(ident.get('pk_ed25519', ''))[:16])}…</code></td>"
+ f"<td>{escape(str(ident.get('pinned_at', '?')))} "
+ f"({escape(str(ident.get('pinned_via', '?')))})</td></tr>"
+ )
+ if not rows:
+ rows = ('<tr><td colspan="6" class="muted">Nobody admitted yet — '
+ 'run <code>meshbay-node member invite &lt;username&gt;</code></td></tr>')
+
+ invite_rows = ""
+ for i in roster_view["invites"]:
+ invite_rows += (
+ f"<tr><td><code>{escape(str(i['user_id'])[:16])}</code></td>"
+ f"<td>{escape(str(i['group_id'][:8] or 'node-wide'))}</td>"
+ f"<td>{escape(str(i['role']))}</td>"
+ f"<td>{escape(str(i['expires_at']))}</td></tr>"
+ )
+ invites_html = ""
+ if invite_rows:
+ invites_html = f"""
+ <details style="margin-top:10px"><summary>Pending invitations</summary>
+ <table>
+ <thead><tr><th>Account</th><th>Group</th><th>Role</th><th>Expires</th></tr></thead>
+ <tbody>{invite_rows}</tbody>
+ </table>
+ </details>"""
+
+ return f"""
+ <table>
+ <thead><tr><th>User</th><th>Role</th><th>Status</th><th>Scope</th>
+ <th>Identity key</th><th>Pinned</th></tr></thead>
+ <tbody>{rows}</tbody>
+ </table>
+ {invites_html}
+ <p class="muted" style="margin-top:8px">
+ Codes are issued from the CLI: <code>meshbay-node operator pair</code>,
+ <code>meshbay-node member invite &lt;username&gt;</code>. They never pass
+ through the hub.
+ </p>"""
+
+
+def _render_page(state: dict, roster_view: dict | None = None) -> str:
+ token_js = json.dumps(state.get("ui_token", ""))
status = state.get("status", "starting")
indexes = state.get("indexes", {})
groups_ctx = state.get("groups_ctx", {})
@@ -356,12 +593,16 @@ def _render_page(state: dict) -> str:
fcount = idx.count if idx else 0
total_size = sum(e.size for e in idx.entries) if idx else 0
+ # Everything interpolated below is attacker-controlled: filenames come from
+ # uploads by any group member. Rendering them raw was a stored XSS into the
+ # unauthenticated localhost admin UI, i.e. full control of the node admin API
+ # from the operator's browser (finding H2).
file_rows = ""
if idx:
for e in sorted(idx.entries, key=lambda x: x.name):
file_rows += (
- f"<tr><td>{e.name}</td><td>{e.type}</td>"
- f"<td>{_fmt_size(e.size)}</td><td>{e.path or '/'}</td></tr>"
+ f"<tr><td>{escape(e.name)}</td><td>{escape(e.type)}</td>"
+ f"<td>{_fmt_size(e.size)}</td><td>{escape(e.path or '/')}</td></tr>"
)
has_gek = bool(ctx.get("gek"))
@@ -385,14 +626,14 @@ def _render_page(state: dict) -> str:
groups_html += f"""
<div class="card">
- <h3>{name}
- <span class="badge" style="background:#6366f1">{vis}</span>
+ <h3>{escape(str(name))}
+ <span class="badge" style="background:#6366f1">{escape(str(vis))}</span>
{gek_badge}
</h3>
- <p><b>Directory:</b> <code>{shared}</code></p>
+ <p><b>Directory:</b> <code>{escape(str(shared))}</code></p>
<p><b>Files:</b> {fcount} &mdash; <b>Total:</b> {_fmt_size(total_size)}</p>
{gek_action}
- <p class="muted">ID: {gid}</p>
+ <p class="muted">ID: {escape(gid)}</p>
<details><summary>File list</summary>
<table>
<thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Path</th></tr></thead>
@@ -408,10 +649,10 @@ def _render_page(state: dict) -> str:
from meshbay_node.transport.webrtc_server import _get_remote_ip
ip = session._remote_ip or _get_remote_ip(session._pc)
peers_html += (
- f"<tr><td>{session._username or session._user_id or '—'}</td>"
- f"<td>{ip or '—'}</td>"
- f"<td>{session._group_id[:8] if session._group_id else '—'}</td>"
- f"<td>{session._pc.connectionState}</td></tr>"
+ f"<tr><td>{escape(session._username or session._user_id or '—')}</td>"
+ f"<td>{escape(ip or '—')}</td>"
+ f"<td>{escape(session._group_id[:8] if session._group_id else '—')}</td>"
+ f"<td>{escape(session._pc.connectionState)}</td></tr>"
)
if not peers_html:
peers_html = '<tr><td colspan="4" class="muted">No connected peers</td></tr>'
@@ -487,14 +728,16 @@ def _render_page(state: dict) -> str:
<tbody>{peers_html}</tbody>
</table>
+ <h2>Roster</h2>
+ {_render_roster(roster_view)}
+
<h2>Groups</h2>
{groups_html or '<p class="muted">No groups configured</p>'}
<h2>Node Configuration</h2>
<div class="card">
<p><b>Hub:</b> {state.get("hub_url", "—")}</p>
- <p><b>QUIC port:</b> {state.get("quic_port", "—")} &mdash;
- <b>TCP port:</b> {state.get("node_port", "—")}</p>
+ <p><b>QUIC port:</b> {state.get("quic_port", "—")}</p>
<p><b>Node ID:</b> <code>{state.get("endpoint_hint") or "—"}</code></p>
</div>
@@ -524,17 +767,18 @@ def _render_page(state: dict) -> str:
</div>
</div>
<script>
+const TOKEN = {token_js};
async function initGEK(groupId) {{
const btn = document.getElementById('gek-btn-' + groupId.slice(0,8));
const status = document.getElementById('gek-status-' + groupId.slice(0,8));
if (btn) btn.disabled = true;
if (status) status.textContent = 'Initializing...';
try {{
- const resp = await fetch('/api/groups/' + groupId + '/gek', {{ method: 'POST' }});
+ const resp = await fetch('/api/groups/' + groupId + '/gek?t=' + TOKEN, {{ method: 'POST' }});
const data = await resp.json();
if (resp.ok) {{
- if (status) status.textContent = 'GEK initialized — wrapped for '
- + data.wrapped_count + '/' + data.total_members + ' members';
+ if (status) status.textContent = 'GEK initialized — '
+ + data.authorized_members + ' authorized member(s) get it on connect';
if (status) status.style.color = '#22c55e';
setTimeout(() => location.reload(), 2000);
}} else {{
@@ -554,8 +798,11 @@ setTimeout(()=>location.reload(), 10000);
</html>"""
-def _render_audit_page() -> str:
- return """<!DOCTYPE html>
+def _render_audit_page(token: str = "") -> str:
+ return _AUDIT_HTML.replace("__TOKEN__", json.dumps(token))
+
+
+_AUDIT_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
@@ -591,7 +838,7 @@ def _render_audit_page() -> str:
<body>
<div class="container">
<h1>Audit Log</h1>
- <nav><a href="/">Dashboard</a><a href="/audit">Audit Log</a></nav>
+ <nav><a id="navHome" href="/">Dashboard</a><a id="navAudit" href="/audit">Audit Log</a></nav>
<div class="filters">
<select id="eventFilter">
@@ -620,23 +867,39 @@ def _render_audit_page() -> str:
</table>
</div>
<script>
+const TOKEN = __TOKEN__;
async function load() {
const ev = document.getElementById('eventFilter').value;
const limit = document.getElementById('limitSelect').value;
- let url = '/api/audit?limit=' + limit;
+ let url = '/api/audit?limit=' + limit + (TOKEN ? '&t=' + TOKEN : '');
if (ev) url += '&event=' + ev;
const r = await fetch(url);
const data = await r.json();
const tbody = document.getElementById('tbody');
document.getElementById('count').textContent = data.entries.length + ' entries';
- tbody.innerHTML = data.entries.map(e => {
- const t = new Date(e.timestamp * 1000).toLocaleString();
- return '<tr><td>' + t + '</td><td>' + e.event + '</td><td>'
- + (e.username || e.user_id.slice(0,8)) + '</td><td>'
- + (e.ip || '—') + '</td><td>'
- + (e.group_id ? e.group_id.slice(0,8) : '—') + '</td><td>'
- + (e.detail || '') + '</td></tr>';
- }).join('');
+ // textContent, not innerHTML: e.detail carries filenames chosen by group members
+ // (finding H2). Building this row with string concatenation was a stored XSS.
+ tbody.replaceChildren(...data.entries.map(e => {
+ const tr = document.createElement('tr');
+ const cells = [
+ new Date(e.timestamp * 1000).toLocaleString(),
+ e.event,
+ e.username || (e.user_id || '').slice(0, 8),
+ e.ip || '—',
+ e.group_id ? e.group_id.slice(0, 8) : '—',
+ e.detail || '',
+ ];
+ for (const value of cells) {
+ const td = document.createElement('td');
+ td.textContent = value;
+ tr.appendChild(td);
+ }
+ return tr;
+ }));
+}
+for (const [id, href] of [['navHome','/'],['navAudit','/audit']]) {
+ const el = document.getElementById(id);
+ if (el && TOKEN) el.href = href + '?t=' + TOKEN;
}
document.getElementById('eventFilter').onchange = load;
document.getElementById('limitSelect').onchange = load;
diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py
index 1c5a07e..60ef11f 100644
--- a/packages/meshbay-node/tests/test_daemon.py
+++ b/packages/meshbay-node/tests/test_daemon.py
@@ -21,7 +21,6 @@ from meshbay_node.config import Config, HubConfig, NodeConfig, GroupConfig, Keys
from meshbay_node.daemon import NodeDaemon
from meshbay_node.indexer import DirectoryIndexer
-
def _mock_keystore_keys(sk_ed):
"""Create a mock keystore with real Ed25519 + X25519 key material."""
sk_x = X25519PrivateKey.generate()
@@ -35,23 +34,33 @@ def _mock_keystore_keys(sk_ed):
mock_keys.pk_x25519_b64 = base64.b64encode(pk_x_raw).decode()
return mock_keys
+def _free_port() -> int:
+ """
+ A port nobody else in the session is on.
+
+ These tests start the real admin UI server. Hardcoding 28000 made them fail
+ with EADDRINUSE whenever another test file had a node running — which is why
+ the full suite failed while each file passed on its own.
+ """
+ import socket
+ with socket.socket() as s:
+ s.bind(("127.0.0.1", 0))
+ return s.getsockname()[1]
+
@pytest.fixture
def sk_hub():
return Ed25519PrivateKey.generate()
-
@pytest.fixture
def hub_pk_pem(sk_hub):
return sk_hub.public_key().public_bytes(
serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
-
@pytest.fixture
def gek():
return generate_gek()
-
@pytest.fixture
def shared_dir(tmp_path):
d = tmp_path / "shared"
@@ -60,26 +69,22 @@ def shared_dir(tmp_path):
(d / "hello.txt").write_bytes(b"hello daemon test " * 50)
return d
-
@pytest.fixture
def node_config(tmp_path, shared_dir):
return Config(
hub=HubConfig(url="http://localhost:9999", username="testuser"),
- node=NodeConfig(port=29000, quic_port=29010, http_port=29001, ui_port=28000),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
groups=[GroupConfig(
id="g" * 32,
name="test-group",
shared_dir=str(shared_dir),
visibility="private",
- port=29000,
quic_port=29010,
- http_port=29001,
)],
keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
data_dir=tmp_path / "data",
)
-
@pytest.mark.asyncio
async def test_daemon_creates_chat_store(tmp_path, node_config, gek, hub_pk_pem):
"""Daemon creates ChatStore for each group and shuts down cleanly."""
@@ -130,7 +135,14 @@ async def test_daemon_creates_chat_store(tmp_path, node_config, gek, hub_pk_pem)
assert daemon._chat_stores[group_id]._db is not None
if daemon._webrtc:
- assert "chat_store" in daemon._webrtc._ctx
+ # Finding H1: chat_store must live in the per-group context, never on
+ # the shared transport context. Hoisting the first group's store
+ # transport-wide sent every group's chat to one database and served it
+ # back to members of every other group.
+ assert "chat_store" not in daemon._webrtc._ctx
+ groups_ctx = daemon._webrtc._ctx["groups"]
+ assert groups_ctx[group_id]["chat_store"] is daemon._chat_stores[group_id]
+
assert "hub_ws" in daemon._webrtc._ctx
assert "node_user_id" in daemon._webrtc._ctx
assert daemon._webrtc._ctx["node_user_id"] == "user123"
@@ -146,7 +158,6 @@ async def test_daemon_creates_chat_store(tmp_path, node_config, gek, hub_pk_pem)
for store in daemon._chat_stores.values():
assert store._db is None
-
@pytest.mark.asyncio
async def test_daemon_no_groups_exits(tmp_path):
"""Daemon with no valid groups exits cleanly."""
@@ -188,19 +199,18 @@ async def test_daemon_no_groups_exits(tmp_path):
assert len(daemon._chat_stores) == 0
-
@pytest.mark.asyncio
async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hub_pk_pem):
"""Index change callback pushes updated index to WebRTC peers."""
config = Config(
hub=HubConfig(url="http://localhost:9999", username="testuser"),
- node=NodeConfig(port=29000, quic_port=29010, http_port=29001, ui_port=28000),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
groups=[GroupConfig(
id="a" * 32,
name="test-group",
shared_dir=str(shared_dir),
visibility="private",
- port=29000, quic_port=29010, http_port=29001,
+ quic_port=29010,
)],
keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
data_dir=tmp_path / "data",
@@ -232,10 +242,45 @@ async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hu
assert msg["group_id"] == "a" * 32
assert len(msg["entries"]) == indexer.index.count
+ # Finding H7: this group is private, so its content hashes must NOT be
+ # registered with the hub. The test previously asserted the opposite —
+ # publishing a fingerprint of every private file was treated as expected
+ # behaviour. Index push to members is unaffected (asserted above).
+ await asyncio.sleep(0.1)
+ daemon._hub.register_swarm.assert_not_called()
+
+@pytest.mark.asyncio
+async def test_daemon_index_change_registers_swarm_for_public_group(
+ tmp_path, shared_dir, gek, hub_pk_pem):
+ """Public groups still register content hashes with the hub swarm (H7)."""
+ config = Config(
+ hub=HubConfig(url="http://localhost:9999", username="testuser"),
+ node=NodeConfig(quic_port=_free_port(), ui_port=_free_port()),
+ groups=[GroupConfig(
+ id="a" * 32,
+ name="public-group",
+ shared_dir=str(shared_dir),
+ visibility="public",
+ quic_port=29010,
+ )],
+ keystore=KeystoreConfig(path=tmp_path / "keystore.enc"),
+ data_dir=tmp_path / "data",
+ )
+ daemon = NodeDaemon(config)
+ daemon._hub = AsyncMock()
+ daemon._hub.register_swarm = AsyncMock(return_value=2)
+ daemon._state["endpoint_hint"] = "node123"
+
+ indexer = DirectoryIndexer(
+ root=shared_dir, group_id="a" * 32,
+ sk_node=Ed25519PrivateKey.generate(), gek=gek)
+ await indexer.initial_scan()
+
+ await daemon._on_index_change(indexer)
+
await asyncio.sleep(0.1)
daemon._hub.register_swarm.assert_called_once()
- call_args = daemon._hub.register_swarm.call_args
- assert len(call_args[0][0]) == indexer.index.count
+ assert len(daemon._hub.register_swarm.call_args[0][0]) == indexer.index.count
@pytest.mark.asyncio
diff --git a/packages/meshbay-node/tests/test_http_server.py b/packages/meshbay-node/tests/test_http_server.py
deleted file mode 100644
index d4ccc32..0000000
--- a/packages/meshbay-node/tests/test_http_server.py
+++ /dev/null
@@ -1,227 +0,0 @@
-"""Tests for the node HTTP file API."""
-
-import asyncio
-import base64
-import json
-import os
-import time
-import pytest
-import jwt
-import httpx
-from pathlib import Path
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives import serialization
-
-from meshbay_common.crypto import generate_gek, pk_to_b64
-from meshbay_node.indexer import DirectoryIndexer
-from meshbay_node.transport.http_server import create_http_app
-
-
-@pytest.fixture
-def sk_node():
- return Ed25519PrivateKey.generate()
-
-@pytest.fixture
-def sk_hub():
- return Ed25519PrivateKey.generate()
-
-@pytest.fixture
-def hub_pk_pem(sk_hub):
- return sk_hub.public_key().public_bytes(
- serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
-
-@pytest.fixture
-def gek():
- return generate_gek()
-
-@pytest.fixture
-def shared_dir(tmp_path):
- d = tmp_path / "shared"
- d.mkdir()
- (d / "video.mp4").write_bytes(os.urandom(3 * 1024 * 1024)) # 3MB
- (d / "doc.pdf").write_bytes(os.urandom(512 * 1024))
- (d / "song.mp3").write_bytes(os.urandom(256 * 1024))
- return d
-
-def make_token(sk_hub, pk_node_b64, ttl=3600):
- sk_pem = sk_hub.private_bytes(
- serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption())
- now = int(time.time())
- return jwt.encode({
- "iss": "test-hub", "sub": "user-001",
- "pk_user": pk_node_b64, "hub_id": "test-hub",
- "jti": "test-jti", "iat": now, "exp": now + ttl,
- }, sk_pem, algorithm="EdDSA")
-
-
-@pytest.mark.asyncio
-async def test_node_info(sk_node, sk_hub, hub_pk_pem, gek, shared_dir):
- indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
- await indexer.initial_scan()
-
- app = create_http_app(
- sk_node=sk_node, hub_pk_pem=hub_pk_pem,
- shared_root=shared_dir, index=indexer.index,
- group_id="test-group", group_name="Test Group",
- )
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as c:
- r = await c.get("/")
- assert r.status_code == 200
- data = r.json()
- assert data["group_id"] == "test-group"
- assert data["file_count"] == 3
- assert "pk_node" in data
-
-
-@pytest.mark.asyncio
-async def test_public_index(sk_node, sk_hub, hub_pk_pem, shared_dir):
- """Public group: index accessible without auth."""
- indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=None)
- await indexer.initial_scan()
-
- app = create_http_app(
- sk_node=sk_node, hub_pk_pem=hub_pk_pem,
- shared_root=shared_dir, index=indexer.index,
- group_id="pub-group", group_name="Public Group",
- gek=None,
- )
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as c:
- r = await c.get("/index")
- assert r.status_code == 200
- data = r.json()
- assert len(data["entries"]) == 3
- names = {e["name"] for e in data["entries"]}
- assert "video.mp4" in names
- assert "doc.pdf" in names
-
-
-@pytest.mark.asyncio
-async def test_file_download(sk_node, sk_hub, hub_pk_pem, shared_dir):
- """Full file download via HTTP."""
- indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=None)
- await indexer.initial_scan()
-
- app = create_http_app(
- sk_node=sk_node, hub_pk_pem=hub_pk_pem,
- shared_root=shared_dir, index=indexer.index,
- group_id="g", group_name="G",
- )
- entry = next(e for e in indexer.index.entries if e.name == "doc.pdf")
- original = (shared_dir / "doc.pdf").read_bytes()
-
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as c:
- r = await c.get(f"/file/{entry.id}")
- assert r.status_code == 200
- assert r.content == original
-
-
-@pytest.mark.asyncio
-async def test_chunk_public_group(sk_node, sk_hub, hub_pk_pem, shared_dir):
- """Public group chunk: plaintext, signed, auth required."""
- indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=None)
- await indexer.initial_scan()
-
- app = create_http_app(
- sk_node=sk_node, hub_pk_pem=hub_pk_pem,
- shared_root=shared_dir, index=indexer.index,
- group_id="g", group_name="G", gek=None,
- )
- entry = next(e for e in indexer.index.entries if e.name == "video.mp4")
- token = make_token(sk_hub, pk_to_b64(sk_node.public_key()))
-
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as c:
- r = await c.get(f"/file/{entry.id}/0",
- headers={"Authorization": f"Bearer {token}"})
- assert r.status_code == 200
- chunk = r.json()
- assert chunk["encrypted"] is False
- assert chunk["chunk_index"] == 0
- assert "data_b64" in chunk
-
- # Verify the chunk data matches original
- original = (shared_dir / "video.mp4").read_bytes()
- data = base64.b64decode(chunk["data_b64"])
- assert data == original[:len(data)]
-
-
-@pytest.mark.asyncio
-async def test_chunk_private_group(sk_node, sk_hub, hub_pk_pem, gek, shared_dir):
- """Private group chunk: encrypted with GEK."""
- from meshbay_common.webcrypto import chunk_key_aes as derive_chunk_key, decrypt_chunk_aes as decrypt_chunk
- import blake3
-
- indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
- await indexer.initial_scan()
-
- app = create_http_app(
- sk_node=sk_node, hub_pk_pem=hub_pk_pem,
- shared_root=shared_dir, index=indexer.index,
- group_id="g", group_name="G", gek=gek,
- )
- entry = next(e for e in indexer.index.entries if e.name == "doc.pdf")
- token = make_token(sk_hub, pk_to_b64(sk_node.public_key()))
-
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as c:
- r = await c.get(f"/file/{entry.id}/0",
- headers={"Authorization": f"Bearer {token}"})
- assert r.status_code == 200
- chunk = r.json()
- assert chunk["encrypted"] is True
-
- # Decrypt and verify
- file_hash = base64.b64decode(chunk["file_hash_b64"])
- nonce = base64.b64decode(chunk["nonce_b64"])
- ct = base64.b64decode(chunk["ct_b64"])
- ckey = derive_chunk_key(gek, file_hash, 0)
- plaintext = decrypt_chunk(ckey, nonce, ct)
- original = (shared_dir / "doc.pdf").read_bytes()
- assert plaintext == original[:len(plaintext)]
-
-
-@pytest.mark.asyncio
-async def test_chunk_requires_auth(sk_node, hub_pk_pem, shared_dir):
- """Chunk endpoint rejects unauthenticated requests."""
- indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=None)
- await indexer.initial_scan()
- app = create_http_app(
- sk_node=sk_node, hub_pk_pem=hub_pk_pem,
- shared_root=shared_dir, index=indexer.index,
- group_id="g", group_name="G",
- )
- entry = indexer.index.entries[0]
-
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as c:
- r = await c.get(f"/file/{entry.id}/0") # no token
- assert r.status_code == 401
-
-
-@pytest.mark.asyncio
-async def test_unknown_file_404(sk_node, hub_pk_pem, sk_hub, shared_dir):
- indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=None)
- await indexer.initial_scan()
- app = create_http_app(
- sk_node=sk_node, hub_pk_pem=hub_pk_pem,
- shared_root=shared_dir, index=indexer.index,
- group_id="g", group_name="G",
- )
- token = make_token(sk_hub, pk_to_b64(sk_node.public_key()))
-
- async with httpx.AsyncClient(
- transport=httpx.ASGITransport(app=app), base_url="http://test"
- ) as c:
- r = await c.get("/file/nonexistent-hash/0",
- headers={"Authorization": f"Bearer {token}"})
- assert r.status_code == 404
diff --git a/packages/meshbay-node/tests/test_quic_transport.py b/packages/meshbay-node/tests/test_quic_transport.py
index 0c1a1cd..93ab1b0 100644
--- a/packages/meshbay-node/tests/test_quic_transport.py
+++ b/packages/meshbay-node/tests/test_quic_transport.py
@@ -49,7 +49,8 @@ def make_jwt(sk_hub, pk_node_b64, ttl=3600, groups=None):
"iss": "test-hub", "sub": "user-001",
"pk_user": pk_node_b64, "hub_id": "test-hub",
"jti": "test-jti", "iat": now, "exp": now + ttl,
- "groups": groups or [],
+ # group_id is mandatory (M1), so default tokens are members of "g".
+ "groups": groups if groups is not None else ["g"],
}, sk_pem, algorithm="EdDSA")
@@ -80,6 +81,7 @@ async def test_quic_chunk_roundtrip(sk_node, sk_hub, gek, shared_dir, tmp_path):
host="127.0.0.1", port=19100,
jwt_token=token, gek=gek,
pk_node_b64=pk_to_b64(sk_node.public_key()),
+ group_id="g",
) as client:
chunk0 = await client.fetch_chunk(entry.id, chunk_index=0)
chunk1 = await client.fetch_chunk(entry.id, chunk_index=1)
@@ -116,6 +118,7 @@ async def test_quic_fetch_index(sk_node, sk_hub, gek, shared_dir, tmp_path):
host="127.0.0.1", port=19101,
jwt_token=token, gek=gek,
pk_node_b64=pk_to_b64(sk_node.public_key()),
+ group_id="g",
) as client:
wire = await client.fetch_index()
recovered = GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek)
@@ -220,10 +223,12 @@ async def test_quic_session_resumption(sk_node, sk_hub, gek, shared_dir, tmp_pat
async with QuicChunkClient(
host="127.0.0.1", port=19104,
jwt_token=token, gek=gek, pk_node_b64=pk_b64,
+ group_id="g",
) as client:
wire = await client.fetch_index()
assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2
saved_ticket = client.session_ticket
+ saved_cert = client.peer_cert_der
# Allow server to process the close
await asyncio.sleep(0.1)
@@ -233,6 +238,10 @@ async def test_quic_session_resumption(sk_node, sk_hub, gek, shared_dir, tmp_pat
host="127.0.0.1", port=19104,
jwt_token=token, gek=gek, pk_node_b64=pk_b64,
session_ticket=saved_ticket,
+ # A resumed session carries no certificate, so the binding anchor from the
+ # original handshake travels with the ticket (11.5.6).
+ peer_cert_der=saved_cert,
+ group_id="g",
) as client:
wire = await client.fetch_index()
assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2
@@ -269,6 +278,7 @@ async def test_quic_denylist_blocks_user(sk_node, sk_hub, gek, shared_dir, tmp_p
async with QuicChunkClient(
host="127.0.0.1", port=19105,
jwt_token=token, gek=gek, pk_node_b64=pk_b64,
+ group_id="g",
) as client:
wire = await client.fetch_index()
assert GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek).count == 2
@@ -281,6 +291,7 @@ async def test_quic_denylist_blocks_user(sk_node, sk_hub, gek, shared_dir, tmp_p
async with QuicChunkClient(
host="127.0.0.1", port=19105,
jwt_token=token, gek=gek, pk_node_b64=pk_b64,
+ group_id="g",
) as client:
await client.fetch_index()
diff --git a/packages/meshbay-node/tests/test_roster_pairing.py b/packages/meshbay-node/tests/test_roster_pairing.py
new file mode 100644
index 0000000..665c060
--- /dev/null
+++ b/packages/meshbay-node/tests/test_roster_pairing.py
@@ -0,0 +1,744 @@
+"""
+Roster and operator pairing (M3, and the mechanism that will close H3).
+
+Negative assertions, per the posture set in Phase 11.5: each test states an attack
+or a mistake that must not work. The one to keep an eye on is
+`test_daemon_does_not_auto_pin_keystore_key` — the auto-pin is what made node
+sovereignty inert as shipped, and it fails closed, so nothing else in the suite
+notices if it comes back.
+
+See `docs/invite-pairing-v1.md`.
+"""
+
+import base64
+import time
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+
+from meshbay_common.crypto import generate_gek, pk_to_b64, unwrap_gek_aes
+from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR, join_transcript
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import Roster, hash_code, normalize_code
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+
+# ── Fixtures ──────────────────────────────────────────────────────────────────
+
+@pytest.fixture
+async def roster(tmp_path):
+ r = Roster(db_path=tmp_path / "roster.db")
+ await r.open()
+ yield r
+ await r.close()
+
+
+def _keypair_full():
+ """(sk_ed, pk_ed_b64, pk_x_b64, sk_x) — the X25519 secret is needed to unwrap."""
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = X25519PrivateKey.generate()
+ pk_ed_b64 = pk_to_b64(sk_ed.public_key())
+ pk_x_b64 = base64.b64encode(
+ sk_x.public_key().public_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PublicFormat.Raw,
+ )
+ ).decode()
+ return sk_ed, pk_ed_b64, pk_x_b64, sk_x
+
+
+def _keypair():
+ sk_ed, pk_ed_b64, pk_x_b64, _ = _keypair_full()
+ return sk_ed, pk_ed_b64, pk_x_b64
+
+
+def _session(tmp_path: Path, roster, user_id: str = "grenet",
+ group_id: str | None = None, gek: bytes | None = None,
+ join_policy: str = "invite") -> WebRTCPeerSession:
+ """A peer session with the join path wired and sending stubbed out."""
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {
+ "shared_root": shared_root,
+ "index": index,
+ "sk_node": index.sk_node,
+ "roster": roster,
+ }
+ if group_id:
+ session._ctx["groups"] = {
+ group_id: {
+ "gek": gek,
+ "shared_root": shared_root,
+ "index": index,
+ "join_policy": join_policy,
+ },
+ }
+ session._group_id = group_id
+ session._user_id = user_id
+ session._username = user_id
+ session._pk_user = ""
+ session._uploads = {}
+ session._join_attempts = 0
+ session._nonce_node = b"\x11" * 32
+ session._remote_ip = ""
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="", user_id="grenet",
+ group_id="", nonce=None, ts=None):
+ ts = int(time.time()) if ts is None else ts
+ transcript = join_transcript(
+ node_pk_b64=session._node_pk_b64(),
+ group_id=group_id,
+ user_id=user_id,
+ pk_ed25519_b64=pk_ed_b64,
+ pk_x25519_b64=pk_x_b64,
+ nonce_node=nonce if nonce is not None else session._nonce_node,
+ ts=ts,
+ )
+ return {
+ "type": "join_request",
+ "group_id": group_id,
+ "pk_ed25519": pk_ed_b64,
+ "pk_x25519": pk_x_b64,
+ "code": code,
+ "ts": ts,
+ "sig": base64.b64encode(sk_ed.sign(transcript)).decode(),
+ }
+
+
+def _last(session):
+ return session.sent[-1] if session.sent else {}
+
+
+# ── Roster ────────────────────────────────────────────────────────────────────
+
+async def test_invite_is_single_use(roster):
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+ assert await roster.consume_invite(code, "grenet") is not None
+ assert await roster.consume_invite(code, "grenet") is None, (
+ "a pairing code must not be redeemable twice")
+
+
+async def test_invite_is_bound_to_one_account(roster):
+ """A leaked code must be useless to whoever finds it."""
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+ assert await roster.consume_invite(code, "eve") is None
+ assert await roster.consume_invite(code, "grenet") is not None
+
+
+async def test_expired_invite_is_refused(roster):
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli", ttl=-1)
+ assert await roster.consume_invite(code, "grenet") is None
+
+
+async def test_reinvite_supersedes_the_previous_code(roster):
+ first = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+ second = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+ assert await roster.consume_invite(first, "grenet") is None
+ assert await roster.consume_invite(second, "grenet") is not None
+
+
+async def test_codes_are_not_stored_in_the_clear(roster, tmp_path):
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+ rows = await roster.list_invites()
+ assert rows and rows[0]["code_hash"] != normalize_code(code)
+ assert rows[0]["code_hash"] == hash_code(code)
+
+
+def test_code_normalization_absorbs_human_error():
+ """Someone reading a code aloud must not be able to get it wrong."""
+ assert normalize_code("k7m2-qx4p") == normalize_code("K7M2QX4P")
+ assert normalize_code("O1IL") == "0111"
+ assert normalize_code(" k7m2 qx4p ") == "K7M2QX4P"
+
+
+async def test_operator_pks_reflect_unpinning(roster):
+ _, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli")
+ assert await roster.operator_pks() == [pk_ed_b64]
+
+ await roster.unpin("grenet")
+ assert await roster.operator_pks() == [], (
+ "authority must disappear with the pin, without a daemon restart")
+
+
+# ── Join / pairing over MNP ───────────────────────────────────────────────────
+
+async def test_pairing_with_a_valid_code_pins_the_identity(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code))
+
+ assert _last(session).get("ok") is True
+ pinned = await roster.get_identity("grenet")
+ assert pinned["pk_ed25519"] == pk_ed_b64
+ assert await roster.operator_pks() == [pk_ed_b64]
+
+
+async def test_pairing_without_a_code_is_refused(tmp_path, roster):
+ """Fails closed: an unknown identity gets nothing until someone authorizes it."""
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+
+ await session._do_join_request(_join_msg(session, sk_ed, pk_ed_b64, pk_x_b64))
+
+ assert _last(session).get("ok") is False
+ assert _last(session).get("reason") == "code_required"
+ assert await roster.get_identity("grenet") is None
+
+
+async def test_wrong_code_pins_nothing(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="ZZZZ-ZZZZ"))
+
+ assert _last(session).get("reason") == "code_invalid"
+ assert await roster.get_identity("grenet") is None
+
+
+async def test_signature_must_cover_the_presented_keys(tmp_path, roster):
+ """
+ The heart of it: the X25519 key is only trustworthy because the Ed25519
+ identity signed it. Swapping in another encryption key after signing must fail.
+ """
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+
+ msg = _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code)
+ _, _, attacker_pk_x = _keypair()
+ msg["pk_x25519"] = attacker_pk_x
+
+ await session._do_join_request(msg)
+
+ assert _last(session).get("reason") == "signature_invalid"
+ assert await roster.get_identity("grenet") is None
+
+
+async def test_join_cannot_be_replayed_onto_another_connection(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+
+ # Signed against a nonce this connection never issued.
+ msg = _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
+ nonce=b"\x99" * 32)
+ await session._do_join_request(msg)
+
+ assert _last(session).get("reason") == "signature_invalid"
+ assert await roster.get_identity("grenet") is None
+
+
+async def test_pinned_identity_presenting_a_new_key_is_refused(tmp_path, roster):
+ """
+ 11.5.8's rule, applied to people: a changed key is refused outright rather
+ than warned about, and clearing it is a deliberate operator action.
+ """
+ session = _session(tmp_path, roster)
+ _, old_pk_ed, old_pk_x = _keypair()
+ await roster.pin_identity("grenet", "grenet", old_pk_ed, old_pk_x, "code")
+
+ sk_ed2, new_pk_ed, new_pk_x = _keypair()
+ await session._do_join_request(
+ _join_msg(session, sk_ed2, new_pk_ed, new_pk_x, code="ANY-CODE"))
+
+ assert _last(session).get("reason") == "key_changed"
+ assert (await roster.get_identity("grenet"))["pk_ed25519"] == old_pk_ed
+
+
+async def test_attempts_are_bounded(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli")
+
+ for _ in range(6):
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA"))
+
+ assert any(m.get("detail") == "Too many attempts" for m in session.sent), (
+ "a connection must not be able to sit there guessing codes")
+
+
+async def test_failures_are_counted_across_connections(tmp_path, roster):
+ """
+ The adversary who can mint a token for any account is the hub, and it can
+ reconnect at will — so a per-connection budget alone would bound nothing.
+ """
+ shared_ctx = None
+ for _ in range(6):
+ session = _session(tmp_path, roster)
+ if shared_ctx is None:
+ shared_ctx = session._ctx
+ else:
+ session._ctx = shared_ctx # same node, new connection
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ for _ in range(4):
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code="AAAA-AAAA"))
+
+ assert any(m.get("detail") == "Pairing temporarily locked"
+ for m in session.sent), (
+ "reconnecting must not reset the pairing budget")
+
+
+async def test_group_id_cannot_name_another_group(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ session._group_id = "a" * 32
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, group_id="b" * 32))
+
+ assert _last(session).get("reason") == "group_mismatch"
+
+
+# ── H3: the node wraps the group key, and only for people it admitted ─────────
+
+GROUP = "g" * 32
+
+
+async def test_node_wraps_the_gek_for_the_key_the_member_proved(tmp_path, roster):
+ """
+ The H3 fix. Nobody fetches a public key from the hub: the node encrypts the
+ group key for the X25519 key the joiner signed with their pinned identity, so
+ a hub substituting a key of its own has nothing to substitute into.
+ """
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="bob", group_id=GROUP, gek=gek)
+ sk_ed, pk_ed_b64, pk_x_b64, sk_x = _keypair_full()
+
+ code = await roster.create_invite(GROUP, "bob", ROLE_MEMBER, "grenet")
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code,
+ user_id="bob", group_id=GROUP))
+
+ reply = _last(session)
+ assert reply["ok"] is True and reply["gek"] is True
+
+ pk_x_raw = base64.b64decode(pk_x_b64)
+ sk_x_raw = sk_x.private_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PrivateFormat.Raw,
+ encryption_algorithm=serialization.NoEncryption(),
+ )
+ assert unwrap_gek_aes(reply, sk_x_raw, pk_x_raw) == gek
+
+
+async def test_hub_membership_alone_yields_no_key(tmp_path, roster):
+ """
+ A hub can invent an account, add it to a group and mint it a token. What it
+ cannot do is put it on the node's roster — so the key never leaves.
+ """
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="eve", group_id=GROUP, gek=gek)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+
+ # Pinned on this node (say, for another group) but never admitted to this one.
+ await roster.pin_identity("eve", "eve", pk_ed_b64, pk_x_b64, "code")
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64,
+ user_id="eve", group_id=GROUP))
+
+ reply = _last(session)
+ assert reply.get("gek") is False
+ assert reply.get("reason") == "not_authorized_for_group"
+ assert "wrapped_b64" not in reply
+
+
+async def test_open_join_group_admits_without_a_code(tmp_path, roster):
+ """§3.4: where anyone may join, a code protects nothing and is not required."""
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="newcomer", group_id=GROUP,
+ gek=gek, join_policy="open")
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64,
+ user_id="newcomer", group_id=GROUP))
+
+ reply = _last(session)
+ assert reply["ok"] is True and reply["gek"] is True
+ pinned = await roster.get_identity("newcomer")
+ assert pinned["pinned_via"] == "tofu"
+
+
+async def test_invite_only_group_still_demands_a_code(tmp_path, roster):
+ """Being public (discoverable) is not being open (admitting anyone)."""
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="newcomer", group_id=GROUP,
+ gek=gek, join_policy="invite")
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64,
+ user_id="newcomer", group_id=GROUP))
+
+ assert _last(session).get("reason") == "code_required"
+ assert await roster.get_identity("newcomer") is None
+
+
+async def test_unknown_group_is_invite_only(tmp_path, roster):
+ """
+ Fail closed: a group whose policy the node cannot read is treated as
+ invite-only, never as open.
+ """
+ session = _session(tmp_path, roster, user_id="newcomer")
+ session._group_id = "unconfigured-group"
+ assert session._group_join_policy("unconfigured-group") == "invite"
+ assert session._group_join_policy("") == "invite"
+
+
+def test_join_policy_is_carried_from_node_config():
+ """
+ The policy reaches the transport from node.toml. If it ever came from the hub
+ instead, a hub could declare any group open and be handed its key.
+ """
+ daemon_src = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "daemon.py").read_text()
+ assert '"join_policy": group_cfg.join_policy' in daemon_src
+
+ config_src = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "config.py").read_text()
+ assert "join_policy" in config_src, "GroupConfig must carry the admission policy"
+
+
+async def test_revoked_member_stops_receiving_the_key(tmp_path, roster):
+ """
+ Wrapping on demand is what makes revocation work. A stored bundle survived
+ revocation; this does not. (Rotating the GEK is still required — the
+ ex-member has the old one.)
+ """
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="bob", group_id=GROUP, gek=gek)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("bob", "bob", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member(GROUP, "bob", ROLE_MEMBER, "active", "grenet")
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64,
+ user_id="bob", group_id=GROUP))
+ assert _last(session)["gek"] is True
+
+ await roster.set_status(GROUP, "bob", "revoked")
+ session.sent.clear()
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64,
+ user_id="bob", group_id=GROUP))
+ assert _last(session).get("gek") is False
+
+
+# ── What a first-time joiner can know ─────────────────────────────────────────
+
+def test_challenge_carries_node_pk_in_source():
+ """
+ Belt and braces for the above: the field must be in the message the node
+ builds, whatever the surrounding handshake does.
+ """
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "transport" / "webrtc_server.py").read_text()
+ challenge = source[source.find("MNP.HANDSHAKE_CHALLENGE,"):]
+ challenge = challenge[:challenge.find("})")]
+ assert "node_pk" in challenge, (
+ "the challenge must announce the node key — a first-time joiner cannot "
+ "learn it any other way, and join_request signs it")
+
+
+async def test_a_key_pinned_by_one_node_is_worthless_at_another(tmp_path, roster):
+ """
+ The whole point of per-node identity: node A's operator who cracks the bundle
+ on their own disk holds a key node B has never seen. Presenting it there is a
+ first contact like any other — it needs a code from B's operator.
+ """
+ gek = generate_gek()
+ node_b = _session(tmp_path, roster, user_id="bob", group_id=GROUP, gek=gek)
+
+ # The key bob uses at node A. Node B's roster knows nothing about it.
+ sk_ed_a, pk_ed_a, pk_x_a = _keypair()
+
+ await node_b._do_join_request(
+ _join_msg(node_b, sk_ed_a, pk_ed_a, pk_x_a,
+ user_id="bob", group_id=GROUP))
+
+ assert _last(node_b).get("reason") == "code_required"
+ assert await roster.get_identity("bob") is None
+
+
+async def test_the_stolen_key_cannot_be_forced_in_with_someone_elses_code(
+ tmp_path, roster):
+ """And a code issued for another account does not help either."""
+ gek = generate_gek()
+ session = _session(tmp_path, roster, user_id="eve", group_id=GROUP, gek=gek)
+ sk_ed, pk_ed, pk_x = _keypair()
+ code = await roster.create_invite(GROUP, "bob", ROLE_MEMBER, "grenet")
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed, pk_x, code=code,
+ user_id="eve", group_id=GROUP))
+
+ assert _last(session).get("reason") == "code_invalid"
+ assert await roster.get_identity("eve") is None
+
+
+# ── Code lifetimes ────────────────────────────────────────────────────────────
+
+async def test_invitations_outlive_pairing_codes(roster):
+ """
+ An invitation crosses a human conversation; a pairing code crosses an SSH
+ session. A day was long enough for the second and not for the first — a code
+ that dies over a weekend means someone has to be at a browser to reissue it.
+ """
+ from meshbay_node.roster import DEFAULT_INVITE_TTL, DEFAULT_PAIR_TTL
+
+ assert DEFAULT_INVITE_TTL == 7 * 24 * 3600
+ assert DEFAULT_PAIR_TTL == 24 * 3600
+ assert DEFAULT_INVITE_TTL > DEFAULT_PAIR_TTL
+
+
+def test_code_lifetimes_are_configurable(tmp_path):
+ """The operator decides, not the default."""
+ from meshbay_node.config import load_config
+
+ path = tmp_path / "node.toml"
+ path.write_text(
+ '[hub]\nurl = "https://example.org"\nusername = "grenet"\n'
+ "[node]\ninvite_ttl_hours = 72\npair_ttl_hours = 2\n"
+ )
+ cfg = load_config(path)
+ assert cfg.node.invite_ttl_hours == 72
+ assert cfg.node.pair_ttl_hours == 2
+
+ default = load_config(tmp_path / "missing.toml")
+ assert default.node.invite_ttl_hours == 168
+ assert default.node.pair_ttl_hours == 24
+
+
+async def test_expiry_is_enforced_at_redemption(tmp_path, roster):
+ """Purging is housekeeping; the check that matters happens on use."""
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ code = await roster.create_invite("", "grenet", ROLE_OPERATOR, "local-cli", ttl=-1)
+
+ await session._do_join_request(
+ _join_msg(session, sk_ed, pk_ed_b64, pk_x_b64, code=code))
+
+ assert _last(session).get("reason") == "code_invalid"
+ assert await roster.get_identity("grenet") is None
+
+
+# ── M3: where node authority comes from ───────────────────────────────────────
+
+async def test_admin_signature_verified_against_the_paired_key(tmp_path, roster):
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli")
+
+ transcript = b"meshbay:admin:v1 whatever"
+ assert await session._verify_admin_sig(transcript, sk_ed.sign(transcript))
+
+ stranger = Ed25519PrivateKey.generate()
+ assert not await session._verify_admin_sig(
+ transcript, stranger.sign(transcript))
+
+
+async def test_unpinned_operator_loses_authority_immediately(tmp_path, roster):
+ """No caching: revoking a paired browser must not need a daemon restart."""
+ session = _session(tmp_path, roster)
+ sk_ed, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("grenet", "grenet", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member("", "grenet", ROLE_OPERATOR, "active", "local-cli")
+
+ transcript = b"meshbay:admin:v1 whatever"
+ assert await session._verify_admin_sig(transcript, sk_ed.sign(transcript))
+
+ await roster.unpin("grenet")
+ assert not await session._verify_admin_sig(transcript, sk_ed.sign(transcript))
+
+
+# ── Operator surface (slice 3) ────────────────────────────────────────────────
+
+def _ui_client(tmp_path, roster, **extra):
+ from fastapi.testclient import TestClient
+
+ from meshbay_node.config import Config
+ from meshbay_node.ui.app import create_ui_app
+
+ state = {
+ "status": "running", "groups_ctx": {GROUP: {"gek": b"k" * 32}},
+ "indexes": {}, "ui_token": "tok", "roster": roster,
+ "node_user_id": "grenet", "config": Config(),
+ }
+ state.update(extra)
+ return TestClient(create_ui_app(state)), state
+
+
+async def test_revoke_endpoint_stops_authorization(tmp_path, roster):
+ client, _ = _ui_client(tmp_path, roster)
+ _, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("bob", "bob", pk_ed_b64, pk_x_b64, "code")
+ await roster.set_member(GROUP, "bob", ROLE_MEMBER, "active", "grenet")
+ assert await roster.is_authorized(GROUP, "bob")
+
+ resp = client.post(f"/api/members/bob/revoke?group_id={GROUP}&t=tok")
+ assert resp.status_code == 200
+ assert "gek-init" in resp.json()["reminder"], (
+ "revocation must remind the operator to rotate the key they still hold")
+ assert not await roster.is_authorized(GROUP, "bob")
+
+
+async def test_unpin_endpoint_allows_repairing(tmp_path, roster):
+ client, _ = _ui_client(tmp_path, roster)
+ _, pk_ed_b64, pk_x_b64 = _keypair()
+ await roster.pin_identity("bob", "bob", pk_ed_b64, pk_x_b64, "code")
+
+ assert client.post("/api/members/bob/unpin?t=tok").status_code == 200
+ assert await roster.get_identity("bob") is None
+ assert client.post("/api/members/bob/unpin?t=tok").status_code == 404
+
+
+async def test_operator_surface_needs_the_session_token(tmp_path, roster):
+ """11.5.3 applies to every one of these: they change who may hold the key."""
+ client, _ = _ui_client(tmp_path, roster)
+ for path in ("/api/roster",
+ "/api/operator/pair",
+ f"/api/members/bob/revoke?group_id={GROUP}",
+ "/api/members/bob/unpin",
+ f"/api/groups/{GROUP}/invites?username=bob"):
+ method = client.get if path == "/api/roster" else client.post
+ assert method(path).status_code == 403, f"{path} reachable without a token"
+
+
+async def test_cli_invite_asks_the_hub_for_an_account_never_a_key(tmp_path, roster):
+ """
+ The CLI resolves a username to an account id through the hub, and stops there.
+ A key fetched from the hub is what H3 was; an account id is not a secret and
+ a wrong one produces an invite whose code the hub never learns.
+ """
+ class _Hub:
+ _session = object()
+
+ async def get_user_pubkeys(self, username):
+ return {"user_id": f"id-of-{username}",
+ "pk_x25519": "SHOULD-NOT-BE-USED",
+ "pk_ed25519": "SHOULD-NOT-BE-USED"}
+
+ client, _ = _ui_client(tmp_path, roster, hub=_Hub())
+ resp = client.post(f"/api/groups/{GROUP}/invites?username=bob&t=tok")
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["user_id"] == "id-of-bob"
+
+ invites = await roster.list_invites()
+ assert [i["user_id"] for i in invites] == ["id-of-bob"]
+ # Whatever the hub said about keys was never stored anywhere.
+ assert "SHOULD-NOT-BE-USED" not in str(invites)
+ assert await roster.get_identity("id-of-bob") is None
+
+
+def _run_cli(monkeypatch, tmp_path, argv, responses):
+ """Drive the real CLI with the daemon API stubbed, capturing the calls."""
+ import sys as _sys
+
+ from meshbay_node import daemon as _daemon
+
+ calls = []
+
+ def fake_api(cfg, path, method="GET", timeout=30):
+ calls.append((method, path))
+ for key, value in responses.items():
+ if key in path:
+ return value
+ return {}
+
+ monkeypatch.setattr(_daemon, "_daemon_api", fake_api)
+
+ conf = tmp_path / "node.toml"
+ conf.write_text(
+ f'data_dir = "{tmp_path}"\n'
+ '[hub]\nurl = "https://example.org"\nusername = "grenet"\n'
+ f'[[groups]]\nid = "{GROUP}"\nname = "demo"\n'
+ f'shared_dir = "{tmp_path}"\n'
+ )
+ monkeypatch.setattr(_sys, "argv",
+ ["meshbay-node", *argv, "--config", str(conf)])
+ try:
+ _daemon.main()
+ except SystemExit as e:
+ calls.append(("exit", e.code))
+ return calls
+
+
+def test_cli_member_commands_reach_the_right_endpoints(monkeypatch, tmp_path, capsys):
+ resolved = {"user_id": "u-bob", "source": "roster"}
+
+ calls = _run_cli(monkeypatch, tmp_path, ["member", "revoke", "bob"],
+ {"/api/resolve": resolved,
+ "revoke": {"status": "revoked", "reminder": "gek-init"}})
+ assert ("POST", f"/api/members/u-bob/revoke?group_id={GROUP}") in calls
+ # The operator is told the revocation does not take back the key they hold.
+ assert "rotate" in capsys.readouterr().out.lower()
+
+ calls = _run_cli(monkeypatch, tmp_path, ["member", "unpin", "bob"],
+ {"/api/resolve": resolved, "unpin": {"status": "unpinned"}})
+ assert ("POST", "/api/members/u-bob/unpin") in calls
+
+
+def test_cli_resolves_a_name_before_acting(monkeypatch, tmp_path):
+ """
+ The name has to be turned into an account first, and the node's own roster is
+ asked before the hub. A JWT carries no username, so an identity pinned without
+ an invitation has none — the hub fallback is what keeps it manageable.
+ """
+ calls = _run_cli(monkeypatch, tmp_path, ["member", "revoke", "bob"],
+ {"/api/resolve": {"user_id": "u-bob", "source": "hub"},
+ "revoke": {"status": "revoked", "reminder": "gek-init"}})
+
+ assert ("GET", "/api/resolve?username=bob") == calls[0], (
+ "the CLI must resolve the name before acting on anyone")
+ assert ("POST", f"/api/members/u-bob/revoke?group_id={GROUP}") in calls
+
+def test_daemon_does_not_auto_pin_keystore_key():
+ """
+ M3: the daemon used to auto-pin its own keystore key as the admin key, while
+ the browser signs with the user's identity key. Different keys, so every
+ privileged operation failed closed with a signature error that looked like a
+ bug elsewhere — and the demo only worked because a deploy script overwrote it.
+
+ Authority now comes from the roster, or from an explicit node.toml value.
+ """
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "daemon.py").read_text()
+ assert "Auto-pinning admin key" not in source
+ assert "_resolve_admin_pk" not in source, (
+ "the auto-pin resolver is back — node authority must be established "
+ "locally by pairing, never inferred from the node's own keystore (M3)")
+
+
+def test_admin_authority_is_never_fetched_from_the_hub():
+ """
+ The fix M3 invites: ask the hub which key belongs to the operator. That would
+ hand a malicious hub the node — the same substitution as H3, one level deeper.
+ """
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "daemon.py").read_text()
+ admin_region = source[source.find("_legacy_admin_pk"):]
+ assert "pubkeys" not in admin_region.split("def ")[1], (
+ "node authority must never be resolved through a hub lookup")
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
new file mode 100644
index 0000000..dcd9cf6
--- /dev/null
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -0,0 +1,604 @@
+"""
+Phase 11.5 security regression tests.
+
+Each test here encodes a finding from `second-review.md`. They are negative tests:
+they assert that an attack does NOT work. The pre-11.5 code passed 209 feature
+tests while every one of these attacks succeeded — the suite only ever exercised
+happy paths, never an authorization boundary.
+
+If one of these starts failing, a fix has been reverted. Do not "fix" the test.
+"""
+
+import base64
+import struct
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.protocol import IndexEntry
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+
+def _safe_name_re():
+ """
+ Imported lazily so that a missing allowlist fails the two tests that need it,
+ rather than aborting collection of the whole module and hiding every other
+ finding's result.
+ """
+ from meshbay_node.transport.webrtc_server import SAFE_UPLOAD_NAME
+ return SAFE_UPLOAD_NAME
+
+
+# ── C1: the unauthenticated HTTP file API must stay deleted ───────────────────
+
+def test_http_file_api_is_gone():
+ """
+ C1: transport/http_server.py served GET /index and GET /file/{id} on 0.0.0.0
+ with no authentication, for private groups too. It was deleted rather than
+ patched. Re-adding any module that serves file bytes outside the MNP handshake
+ reintroduces a full confidentiality bypass.
+ """
+ with pytest.raises(ImportError):
+ import meshbay_node.transport.http_server # noqa: F401
+
+ import meshbay_node.transport as transport
+ assert not hasattr(transport, "create_http_app")
+
+
+def test_tcp_transport_is_gone():
+ """C6: the TCP+TLS server accepted a bare JWT with no GEK proof."""
+ with pytest.raises(ImportError):
+ import meshbay_node.transport.server # noqa: F401
+
+ import meshbay_node.transport as transport
+ assert not hasattr(transport, "ChunkServer")
+
+
+def test_daemon_exposes_no_plaintext_listener():
+ """
+ C1: the daemon must not bind anything that serves content without a handshake.
+ NodeConfig no longer carries an HTTP port at all.
+ """
+ from meshbay_node.config import NodeConfig, GroupConfig
+
+ assert "http_port" not in NodeConfig.__dataclass_fields__
+ assert "http_port" not in GroupConfig.__dataclass_fields__
+ assert "port" not in NodeConfig.__dataclass_fields__
+
+
+# ── C5a: upload filename allowlist ───────────────────────────────────────────
+
+@pytest.mark.parametrize("name", [
+ "../../etc/passwd",
+ "..\\windows\\system32",
+ "/absolute/path",
+ "<img src=x onerror=alert(1)>", # the H2 stored-XSS vector
+ 'name";DROP TABLE x;--',
+ ".hidden",
+ "",
+ "a" * 200,
+ "file\x00.mp4",
+ "sub/dir/file.mp4",
+])
+def test_upload_rejects_unsafe_filenames(name):
+ """C5a/H2: only a conservative allowlist may reach the filesystem."""
+ assert not _safe_name_re().match(name), f"should be rejected: {name!r}"
+
+
+@pytest.mark.parametrize("name", [
+ "movie.mp4",
+ "My Holiday Video.mkv",
+ "report-2026.pdf",
+ "track_01.flac",
+])
+def test_upload_accepts_ordinary_filenames(name):
+ """The allowlist must not break normal use."""
+ assert _safe_name_re().match(name), f"should be accepted: {name!r}"
+
+
+def _session(tmp_path: Path, user_id: str) -> WebRTCPeerSession:
+ """A peer session wired to a real shared root, with sending stubbed out."""
+ shared_root = tmp_path / "shared"
+ shared_root.mkdir(exist_ok=True)
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ ctx = {"shared_root": shared_root, "index": index, "sk_node": index.sk_node}
+
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = ctx
+ session._group_id = None
+ session._user_id = user_id
+ session._pk_user = ""
+ session._uploads = {}
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+def test_upload_cannot_overwrite_another_members_file(tmp_path):
+ """
+ C5a: uploads used to land in the shared root under a client-chosen name and
+ overwrite whatever was there. That let any member destroy the operator's files,
+ and — by becoming the recorded uploader of the replaced file — delete them
+ through the uploader path, bypassing the Ed25519 admin challenge entirely.
+ """
+ victim = _session(tmp_path, "victim-user")
+ shared_root = victim._ctx["shared_root"]
+
+ original = shared_root / "important.mp4"
+ original.write_bytes(b"operator's original content")
+
+ attacker = _session(tmp_path, "attacker-user")
+ attacker._do_file_upload({
+ "filename": "important.mp4",
+ "chunk_index": 0,
+ "total_chunks": 1,
+ "data": base64.b64encode(b"attacker content").decode(),
+ })
+
+ assert original.read_bytes() == b"operator's original content"
+ uploaded = shared_root / ".uploads" / "attacker-user" / "important.mp4"
+ assert uploaded.exists(), "upload should be quarantined, not dropped"
+ assert uploaded.read_bytes() == b"attacker content"
+
+
+def test_upload_rejects_out_of_order_chunks(tmp_path):
+ """C5a: chunk_index > 0 used to append blindly to any .part file on disk."""
+ session = _session(tmp_path, "user-1")
+ session._do_file_upload({
+ "filename": "movie.mp4", "chunk_index": 3, "total_chunks": 5,
+ "data": base64.b64encode(b"spliced").decode(),
+ })
+ assert any(m.get("type") == "error" for m in session.sent)
+
+
+def test_upload_second_attempt_cannot_replace_own_completed_file(tmp_path):
+ """C5a: even the original uploader goes through a fresh name, not an overwrite."""
+ session = _session(tmp_path, "user-1")
+ payload = {"filename": "movie.mp4", "chunk_index": 0, "total_chunks": 1,
+ "data": base64.b64encode(b"first").decode()}
+ session._do_file_upload(dict(payload))
+ session.sent.clear()
+
+ session._do_file_upload(dict(payload))
+ assert any(m.get("type") == "error" for m in session.sent)
+ stored = session._ctx["shared_root"] / ".uploads" / "user-1" / "movie.mp4"
+ assert stored.read_bytes() == b"first"
+
+
+# ── H1: group isolation ──────────────────────────────────────────────────────
+
+def test_chat_store_and_peers_are_per_group(tmp_path):
+ """
+ H1: chat_store and the peer registry were read from the shared transport
+ context, so on a multi-group node every group's messages went to the first
+ group's database and were served back to members of every other group.
+ """
+ index_a = GroupIndex(group_id="a" * 32, sk_node=Ed25519PrivateKey.generate())
+ index_b = GroupIndex(group_id="b" * 32, sk_node=Ed25519PrivateKey.generate())
+ groups = {
+ "a" * 32: {"chat_store": "STORE_A", "index": index_a, "shared_root": tmp_path},
+ "b" * 32: {"chat_store": "STORE_B", "index": index_b, "shared_root": tmp_path},
+ }
+ ctx = {"groups": groups}
+
+ sess_a = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ sess_a._ctx, sess_a._group_id, sess_a._user_id = ctx, "a" * 32, "alice"
+
+ sess_b = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ sess_b._ctx, sess_b._group_id, sess_b._user_id = ctx, "b" * 32, "bob"
+
+ assert sess_a._group_ctx()["chat_store"] == "STORE_A"
+ assert sess_b._group_ctx()["chat_store"] == "STORE_B"
+
+ sess_a._peer_registry()["alice"] = sess_a
+ sess_b._peer_registry()["bob"] = sess_b
+
+ # Alice's broadcast target set must not contain Bob, who is in another group.
+ assert "bob" not in sess_a._peer_registry()
+ assert "alice" not in sess_b._peer_registry()
+
+ sess_a._user_names()["alice"] = "Alice"
+ assert "alice" not in sess_b._user_names()
+
+
+def test_daemon_sets_no_global_chat_store(tmp_path):
+ """H1: the daemon must not hoist one group's chat store onto the transport."""
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "daemon.py").read_text()
+ assert '_ctx["chat_store"]' not in source, (
+ "daemon must not assign a transport-wide chat_store — it leaks chat "
+ "across groups (H1)"
+ )
+
+
+# ── H2: node admin UI escaping ───────────────────────────────────────────────
+
+def test_no_member_can_hand_the_node_key_material(tmp_path):
+ """
+ C5b, strengthened by the invite redesign (docs/invite-pairing-v1.md).
+
+ This test used to assert that `gek_bundle_store` answered with an admin
+ challenge and stored nothing without an operator signature. The message is now
+ gone entirely: the node holds the GEK and wraps it itself, so no member ever
+ submits key material, authorized or not. Deleting the path is a stronger
+ guarantee than gating it, which is why the assertion changed rather than the
+ behaviour regressing.
+ """
+ from meshbay_common.protocol import MNP as _MNP
+
+ assert not hasattr(_MNP, "GEK_BUNDLE_STORE"), (
+ "the member-supplied bundle message is back — the node must never accept "
+ "key material over MNP (C5b)"
+ )
+
+ source = (Path(__file__).parent.parent
+ / "src" / "meshbay_node" / "transport" / "webrtc_server.py").read_text()
+ assert "_do_gek_bundle_store" not in source
+ assert "_admin_exec_bundle_store" not in source
+
+
+def test_unknown_message_stores_nothing(tmp_path):
+ """A peer sending the retired message must not reach any storage path."""
+ session = _session(tmp_path, "ordinary-member")
+ session._group_id = None
+ session._admin_ops = {}
+
+ stored = []
+
+ class _Store:
+ async def store(self, *args):
+ stored.append(args)
+
+ session._ctx["bundle_store"] = _Store()
+ session._handle_message({
+ "type": "gek_bundle_store",
+ "user_id": "victim", "group_id": "g" * 32,
+ "pk_eph_b64": "AA==", "nonce_b64": "AA==", "wrapped_b64": "AA==",
+ })
+
+ assert stored == [], "a retired message type still reached the bundle store"
+
+
+def test_gek_auto_activation_is_gone():
+ """
+ C5b: the node used to unwrap and adopt any bundle addressed to the operator.
+ Since the operator's X25519 public key is public, any member could hand the
+ node a GEK of their choosing. Nothing arriving over MNP may set a live GEK.
+ """
+ source = (Path(__file__).parent.parent / "src" / "meshbay_node"
+ / "transport" / "webrtc_server.py").read_text()
+ assert "_try_activate_gek" not in source
+ assert 'unwrap_gek_aes' not in source, (
+ "the MNP path must not unwrap a GEK — activation is local-admin only"
+ )
+
+
+# ── H5: admin challenge is bound, not a blind signing oracle ─────────────────
+
+def _transcript(**kw):
+ from meshbay_common.adminop import admin_transcript
+ base = dict(op="file_delete", node_pk_b64="NODEPK", group_id="g" * 32,
+ subject="file-1", nonce=b"\x01" * 32, ts=1_700_000_000)
+ base.update(kw)
+ return admin_transcript(**base)
+
+
+def test_admin_transcript_is_domain_separated():
+ """H5: signatures here can never be valid in another MeshBay protocol."""
+ assert _transcript().startswith(b"meshbay:admin:v1")
+
+
+@pytest.mark.parametrize("field,value", [
+ ("op", "invite_create"),
+ ("subject", "file-2"),
+ ("node_pk_b64", "OTHERNODE"),
+ ("group_id", "h" * 32),
+ ("nonce", b"\x02" * 32),
+ ("ts", 1_700_000_001),
+])
+def test_admin_transcript_binds_every_field(field, value):
+ """
+ H5: a signature must not carry over to another operation, subject, node,
+ group, challenge or moment in time.
+ """
+ assert _transcript() != _transcript(**{field: value}), (
+ f"transcript ignores {field} — signature would be reusable"
+ )
+
+
+def test_admin_transcript_is_unambiguous():
+ """
+ H5/L4: fields are length-prefixed. With plain concatenation a crafted subject
+ could impersonate the following field and two different operations would
+ produce identical signed bytes.
+ """
+ a = _transcript(subject="file-1", group_id="g")
+ b = _transcript(subject="1", group_id="gfile-")
+ assert a != b, "concatenation is ambiguous — length prefixes missing"
+
+
+def test_admin_signature_does_not_transfer_between_operations(tmp_path):
+ """
+ H5: the concrete attack. A signature collected to delete a file must not
+ authorize storing a GEK bundle.
+ """
+ from meshbay_common.adminop import OP_FILE_DELETE, OP_INVITE_CREATE
+
+ sk_admin = Ed25519PrivateKey.generate()
+ delete_transcript = _transcript(op=OP_FILE_DELETE)
+ signature = sk_admin.sign(delete_transcript)
+
+ invite_transcript = _transcript(op=OP_INVITE_CREATE)
+ with pytest.raises(Exception):
+ sk_admin.public_key().verify(signature, invite_transcript)
+
+
+def test_admin_challenge_expires(tmp_path):
+ """H5: a stale challenge must not be usable."""
+ import time as _time
+ from meshbay_common.adminop import ADMIN_CHALLENGE_TTL, OP_FILE_DELETE
+
+ session = _session(tmp_path, "operator")
+ session._group_id = None
+ session._admin_ops = {
+ "op-1": {
+ "op": OP_FILE_DELETE, "subject": "file-1", "nonce": b"\x00" * 32,
+ "ts": int(_time.time()) - ADMIN_CHALLENGE_TTL - 5, "payload": {},
+ }
+ }
+ session._do_admin_response({"op_id": "op-1", "signature": ""})
+ assert any(m.get("type") == "error" and "expired" in m.get("detail", "").lower()
+ for m in session.sent)
+
+
+def test_denylist_persists_and_honours_groups(tmp_path):
+ """
+ H4: revocations lived only in memory, so a node restart silently un-revoked
+ everyone, and 'group' targets were dropped entirely — the hub signed and
+ broadcast them, the node's handler understood only 'user' and 'jti'.
+ """
+ from meshbay_node.transport import Denylist
+
+ path = tmp_path / "denylist.json"
+ first = Denylist(path=path)
+ first.deny_group("g-revoked")
+ first.deny_user("u-revoked")
+ first.deny_jti("j-revoked")
+
+ # A fresh instance stands in for a daemon restart.
+ reloaded = Denylist(path=path)
+ assert reloaded.is_denied("", "", "g-revoked"), "group revocation not honoured"
+ assert reloaded.is_denied("u-revoked", "")
+ assert reloaded.is_denied("", "j-revoked")
+ assert not reloaded.is_denied("someone", "other", "g-allowed")
+
+
+def test_swarm_registration_skips_private_groups():
+ """
+ H7: the daemon registered content hashes for every group, private included,
+ handing the hub a fingerprint of every private file. The bug was masked by a
+ mis-mounted route, so fixing the route without this filter would have turned a
+ dormant leak into a live one.
+ """
+ source = (Path(__file__).parent.parent / "src" / "meshbay_node"
+ / "daemon.py").read_text()
+ assert 'visibility' in source and '_register_swarm' in source
+ # Both registration sites must gate on public visibility.
+ for marker in ['gctx.get("visibility") != "public"',
+ 'group_cfg.visibility == "public"']:
+ assert marker in source, f"swarm registration not gated: {marker}"
+
+
+def test_keystore_argon2_is_production_strength():
+ """M2: the keystore protects the node's private keys and sat at 64 MB."""
+ from meshbay_common.crypto import ARGON2_MEMORY_COST
+ assert ARGON2_MEMORY_COST >= 262144
+
+
+def test_keystore_records_argon2_params_for_migration(tmp_path):
+ """
+ M2: raising the parameters must not orphan existing keystores, so each
+ envelope records the parameters it was written with.
+ """
+ import json
+ from meshbay_node.keystore import create_keystore, load_keystore
+
+ path = tmp_path / "keystore.enc"
+ created = create_keystore(path=path, password="correct horse battery")
+ envelope = json.loads(path.read_text())
+ assert envelope["argon2"]["memory_cost"] >= 262144
+
+ reopened = load_keystore(path=path, password="correct horse battery")
+ assert reopened.pk_ed25519_b64 == created.pk_ed25519_b64
+
+
+def test_legacy_keystore_still_opens(tmp_path):
+ """M2: a keystore written under the 64 MB profile must still unlock."""
+ import base64 as _b64
+ import json
+ import msgpack
+ from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
+ from meshbay_common.crypto import (
+ LEGACY_ARGON2_ITERATIONS, LEGACY_ARGON2_LANES, LEGACY_ARGON2_MEMORY_COST,
+ derive_keystore_key, encrypt_keystore, pk_to_b64, sk_to_b64,
+ )
+ from meshbay_node.keystore import load_keystore
+
+ sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate()
+ payload = msgpack.packb({
+ "sk_ed25519_b64": sk_to_b64(sk_ed),
+ "sk_x25519_b64": sk_to_b64(sk_x),
+ }, use_bin_type=True)
+
+ salt = b"\x01" * 16
+ key = derive_keystore_key(
+ "legacy-pass", salt,
+ iterations=LEGACY_ARGON2_ITERATIONS,
+ memory_cost=LEGACY_ARGON2_MEMORY_COST,
+ lanes=LEGACY_ARGON2_LANES,
+ )
+ iv, ct, tag = encrypt_keystore(payload, key)
+
+ path = tmp_path / "legacy.enc"
+ # No "argon2" key — exactly how pre-M2 envelopes look.
+ path.write_text(json.dumps({
+ "version": 1,
+ "argon2_salt_b64": _b64.b64encode(salt).decode(),
+ "iv_b64": _b64.b64encode(iv).decode(),
+ "tag_b64": _b64.b64encode(tag).decode(),
+ "ciphertext_b64": _b64.b64encode(ct).decode(),
+ }))
+
+ keys = load_keystore(path=path, password="legacy-pass")
+ assert keys.pk_ed25519_b64 == pk_to_b64(sk_ed.public_key())
+ assert keys.pk_x25519_b64 == pk_to_b64(sk_x.public_key())
+
+
+def test_dead_gek_protocol_constants_removed():
+ """L1: the node never serves a GEK; the message types should not suggest it."""
+ from meshbay_common.protocol import MNP
+ assert not hasattr(MNP, "GEK_REQUEST")
+ assert not hasattr(MNP, "GEK_RESPONSE")
+
+
+def test_peer_errors_do_not_leak_internals():
+ """
+ L3: arbitrary exception text carries filesystem paths and internal state, so
+ the catch-all handler must not relay it.
+
+ Deliberately narrow: HandshakeError messages ARE sent to the peer, because a
+ client needs to know why it was refused, and those strings are authored for
+ that purpose. The check targets the generic `except Exception as e` path.
+ """
+ source = (Path(__file__).parent.parent / "src" / "meshbay_node"
+ / "transport" / "webrtc_server.py").read_text()
+ assert '"detail": str(e)' not in source, (
+ "generic exception text relayed to peer — use a fixed message"
+ )
+ # And the catch-all must still exist, sending something opaque.
+ assert '"detail": "Request failed"' in source
+
+
+def test_pre_handshake_message_budget_is_small():
+ """
+ H6: the frame limit was a flat 64 MB applied before authentication, so an
+ unauthenticated peer could announce a huge frame and dribble bytes into it.
+ """
+ from meshbay_node.transport.webrtc_server import (
+ MAX_MSG, PRE_HANDSHAKE_MAX_MSG, _DataChannelBuffer,
+ )
+ assert PRE_HANDSHAKE_MAX_MSG <= 1024 * 1024
+ assert PRE_HANDSHAKE_MAX_MSG < MAX_MSG
+
+ buf = _DataChannelBuffer(max_message=PRE_HANDSHAKE_MAX_MSG)
+ buf.feed(struct.pack(">I", PRE_HANDSHAKE_MAX_MSG + 1) + b"x")
+ with pytest.raises(ValueError):
+ list(buf.messages())
+
+
+def test_stream_segment_is_not_synchronous():
+ """
+ H6: _do_stream_segment ran subprocess.run(timeout=30) inside the event loop,
+ stalling every peer on the node for up to thirty seconds per request.
+
+ Asserts the property (the worker is a coroutine, ffmpeg is spawned through
+ asyncio) rather than grepping for "subprocess.run" — which also matches the
+ comment that documents the old behaviour.
+ """
+ import ast
+ import inspect
+ from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+ assert inspect.iscoroutinefunction(WebRTCPeerSession._do_stream_segment_async)
+
+ source = (Path(__file__).parent.parent / "src" / "meshbay_node"
+ / "transport" / "webrtc_server.py").read_text()
+ tree = ast.parse(source)
+ blocking = [
+ node for node in ast.walk(tree)
+ if isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Attribute)
+ and node.func.attr == "run"
+ and isinstance(node.func.value, ast.Name)
+ and node.func.value.id == "subprocess"
+ ]
+ assert not blocking, "blocking subprocess.run() in the event loop"
+ assert "_transcode_sem" in source, "ffmpeg spawns must be capped"
+
+
+def test_pre_proof_fetches_are_bounded():
+ """C4: the pre-proof bundle window is a disclosure surface; bound it."""
+ from meshbay_node.transport.webrtc_server import MAX_PRE_PROOF_FETCHES
+ assert 0 < MAX_PRE_PROOF_FETCHES <= 10
+
+
+def test_node_admin_ui_requires_token():
+ """
+ 11.5.3: "localhost only" is not authentication. Any local process — or a
+ rebound browser page — could re-initialise a group's GEK and read the audit log.
+ """
+ from fastapi.testclient import TestClient
+ from meshbay_node.ui.app import create_ui_app
+
+ app = create_ui_app({"status": "running", "groups_ctx": {},
+ "indexes": {}, "ui_token": "secret-token"})
+ client = TestClient(app)
+
+ assert client.get("/api/status").status_code == 403
+ assert client.get("/api/status?t=wrong").status_code == 403
+ assert client.get("/api/config?t=wrong").status_code == 403
+ assert client.get("/api/status?t=secret-token").status_code == 200
+ assert client.get(
+ "/api/status", headers={"X-MeshBay-Token": "secret-token"}
+ ).status_code == 200
+
+
+def test_admin_ui_escapes_filenames(tmp_path):
+ """
+ H2: filenames are chosen by any group member and were rendered into the
+ localhost admin UI unescaped, giving script execution against an
+ unauthenticated admin API.
+ """
+ from meshbay_node.ui.app import _render_page
+
+ payload = '<img src=x onerror="fetch(1)">'
+ index = GroupIndex(group_id="g" * 32, sk_node=Ed25519PrivateKey.generate())
+ index.add_entry(IndexEntry(
+ id="0" * 64, name=payload, path="", size=1, type="video", added_at=0,
+ ))
+
+ html = _render_page({
+ "status": "running",
+ "groups_ctx": {"g" * 32: {"index": index, "shared_root": tmp_path}},
+ "indexes": {"g" * 32: index},
+ })
+
+ assert payload not in html, "filename rendered unescaped — stored XSS (H2)"
+ assert "&lt;img" in html, "filename should appear escaped"
+
+
+def test_admin_ui_escapes_roster_usernames(tmp_path):
+ """
+ H2 again, for the roster: usernames originate at the hub and land on the
+ operator's own admin page, which can re-key groups and read the audit log.
+ """
+ from meshbay_node.ui.app import _render_page
+
+ payload = '<img src=x onerror="fetch(1)">'
+ html = _render_page(
+ {"status": "running", "groups_ctx": {}, "indexes": {}},
+ {
+ "identities": {"u1": {"user_id": "u1", "username": payload,
+ "pk_ed25519": "AAA", "pinned_at": "now",
+ "pinned_via": "code"}},
+ "members": [{"group_id": "", "user_id": "u1", "role": "operator",
+ "status": "active"}],
+ "invites": [],
+ },
+ )
+
+ assert payload not in html, "username rendered unescaped — stored XSS (H2)"
+ assert "&lt;img" in html
diff --git a/packages/meshbay-node/tests/test_transport.py b/packages/meshbay-node/tests/test_transport.py
deleted file mode 100644
index 0e70d72..0000000
--- a/packages/meshbay-node/tests/test_transport.py
+++ /dev/null
@@ -1,222 +0,0 @@
-"""
-Integration test: ChunkServer ↔ ChunkClient over TLS.
-
-Starts a real TLS server on localhost, connects a client,
-fetches index and a chunk, verifies signature+hash+decryption.
-"""
-
-import asyncio
-import base64
-import os
-import time
-import jwt
-import pytest
-from pathlib import Path
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
-from cryptography.hazmat.primitives import serialization
-
-from meshbay_common.crypto import generate_gek, pk_to_b64
-from meshbay_node.indexer import DirectoryIndexer, GroupIndex
-from meshbay_node.transport.server import ChunkServer
-from meshbay_node.transport.client import ChunkClient
-
-
-@pytest.fixture
-def sk_node():
- return Ed25519PrivateKey.generate()
-
-@pytest.fixture
-def sk_hub():
- return Ed25519PrivateKey.generate()
-
-@pytest.fixture
-def gek():
- return generate_gek()
-
-@pytest.fixture
-def shared_dir(tmp_path):
- d = tmp_path / "shared"
- d.mkdir()
- (d / "test.mp4").write_bytes(os.urandom(2 * 1024 * 1024)) # 2 MB
- (d / "small.txt").write_bytes(b"hello meshbay " * 100)
- return d
-
-def make_jwt(sk_hub, pk_node_b64, user_id="user-001", ttl=3600, groups=None):
- sk_pem = sk_hub.private_bytes(
- serialization.Encoding.PEM,
- serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption(),
- )
- now = int(time.time())
- return jwt.encode({
- "iss": "test-hub", "sub": user_id,
- "pk_user": pk_node_b64, "hub_id": "test-hub",
- "jti": "test-jti",
- "iat": now, "exp": now + ttl,
- "groups": groups or [],
- }, sk_pem, algorithm="EdDSA")
-
-
-@pytest.mark.asyncio
-async def test_chunk_server_client_roundtrip(
- sk_node, sk_hub, gek, shared_dir, tmp_path):
- """Full integration: server serves a chunk, client verifies and decrypts."""
-
- # Build index
- indexer = DirectoryIndexer(
- root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
- await indexer.initial_scan()
- assert indexer.index.count == 2
-
- # Hub PK for JWT verification
- hub_pk_pem = sk_hub.public_key().public_bytes(
- serialization.Encoding.PEM,
- serialization.PublicFormat.SubjectPublicKeyInfo)
-
- # TLS cert in tmp dir
- cert_path = tmp_path / "node.crt"
- key_path = tmp_path / "node.key"
-
- server = ChunkServer(
- sk_node=sk_node,
- hub_pk_pem=hub_pk_pem,
- gek=gek,
- shared_root=shared_dir,
- index=indexer.index,
- host="127.0.0.1",
- port=0, # OS picks a free port
- cert_path=cert_path,
- key_path=key_path,
- )
- await server.start()
- port = server._server.sockets[0].getsockname()[1]
-
- token = make_jwt(sk_hub, pk_to_b64(sk_node.public_key()))
-
- # Find the large test file in the index
- entry = next(e for e in indexer.index.entries if e.name == "test.mp4")
-
- async with ChunkClient(
- host="127.0.0.1",
- port=port,
- jwt_token=token,
- gek=gek,
- pk_node_b64=pk_to_b64(sk_node.public_key()),
- ) as client:
- # Fetch first chunk
- chunk0 = await client.fetch_chunk(entry.id, chunk_index=0)
- assert len(chunk0) == 1024 * 1024 # first 1MB of 2MB file
-
- # Fetch second chunk
- chunk1 = await client.fetch_chunk(entry.id, chunk_index=1)
- assert len(chunk1) == 1024 * 1024 # second 1MB
-
- # Reassembled file matches original
- original = (shared_dir / "test.mp4").read_bytes()
- assert chunk0 + chunk1 == original
-
- await server.stop()
-
-
-@pytest.mark.asyncio
-async def test_invalid_jwt_rejected(sk_node, sk_hub, gek, shared_dir, tmp_path):
- hub_pk_pem = sk_hub.public_key().public_bytes(
- serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
-
- indexer = DirectoryIndexer(root=shared_dir, group_id="g",
- sk_node=sk_node, gek=gek)
- await indexer.initial_scan()
-
- cert_path = tmp_path / "node.crt"
- key_path = tmp_path / "node.key"
-
- server = ChunkServer(
- sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
- shared_root=shared_dir, index=indexer.index,
- host="127.0.0.1", port=0,
- cert_path=cert_path, key_path=key_path,
- )
- await server.start()
- port = server._server.sockets[0].getsockname()[1]
-
- # Use a different hub key to sign the token
- sk_other_hub = Ed25519PrivateKey.generate()
- bad_token = make_jwt(sk_other_hub, pk_to_b64(sk_node.public_key()))
-
- with pytest.raises(Exception):
- async with ChunkClient(
- host="127.0.0.1", port=port,
- jwt_token=bad_token, gek=gek,
- pk_node_b64=pk_to_b64(sk_node.public_key()),
- ) as client:
- pass
-
- await server.stop()
-
-
-@pytest.mark.asyncio
-async def test_wrong_group_rejected(sk_node, sk_hub, gek, shared_dir, tmp_path):
- """TCP+TLS server rejects a client whose JWT groups don't include the requested group_id."""
- hub_pk_pem = sk_hub.public_key().public_bytes(
- serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
-
- indexer = DirectoryIndexer(root=shared_dir, group_id="g",
- sk_node=sk_node, gek=gek)
- await indexer.initial_scan()
-
- cert_path = tmp_path / "node.crt"
- key_path = tmp_path / "node.key"
-
- server = ChunkServer(
- sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
- shared_root=shared_dir, index=indexer.index,
- host="127.0.0.1", port=0,
- cert_path=cert_path, key_path=key_path,
- )
- await server.start()
- port = server._server.sockets[0].getsockname()[1]
-
- token = make_jwt(sk_hub, pk_to_b64(sk_node.public_key()), groups=["group-a"])
-
- with pytest.raises(ConnectionError, match="rejected"):
- async with ChunkClient(
- host="127.0.0.1", port=port,
- jwt_token=token, gek=gek,
- pk_node_b64=pk_to_b64(sk_node.public_key()),
- group_id="group-b",
- ) as client:
- pass
-
- await server.stop()
-
-
-@pytest.mark.asyncio
-async def test_fetch_index(sk_node, sk_hub, gek, shared_dir, tmp_path):
- hub_pk_pem = sk_hub.public_key().public_bytes(
- serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
- indexer = DirectoryIndexer(root=shared_dir, group_id="g",
- sk_node=sk_node, gek=gek)
- await indexer.initial_scan()
-
- cert_path = tmp_path / "node.crt"
- key_path = tmp_path / "node.key"
-
- server = ChunkServer(
- sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
- shared_root=shared_dir, index=indexer.index,
- host="127.0.0.1", port=0,
- cert_path=cert_path, key_path=key_path,
- )
- await server.start()
- port = server._server.sockets[0].getsockname()[1]
- token = make_jwt(sk_hub, pk_to_b64(sk_node.public_key()))
-
- async with ChunkClient(
- host="127.0.0.1", port=port, jwt_token=token,
- gek=gek, pk_node_b64=pk_to_b64(sk_node.public_key()),
- ) as client:
- wire = await client.fetch_index()
- recovered = GroupIndex.deserialize(wire, sk_node=sk_node, gek=gek)
- assert recovered.count == 2
-
- await server.stop()
diff --git a/packages/meshbay-node/tests/test_webrtc_transport.py b/packages/meshbay-node/tests/test_webrtc_transport.py
index 693a68b..93cd3fd 100644
--- a/packages/meshbay-node/tests/test_webrtc_transport.py
+++ b/packages/meshbay-node/tests/test_webrtc_transport.py
@@ -19,7 +19,9 @@ import jwt
import msgpack
import pytest
from cryptography.hazmat.primitives import serialization
-from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from cryptography.hazmat.primitives.asymmetric.ed25519 import (
+ Ed25519PrivateKey, Ed25519PublicKey,
+)
from aiortc import RTCPeerConnection, RTCSessionDescription
from meshbay_common import MNP_VERSION
@@ -29,10 +31,24 @@ from meshbay_common.crypto import (
wrap_gek,
wrap_gek_aes,
unwrap_gek,
+ unwrap_gek_aes,
)
from meshbay_common.webcrypto import chunk_key_aes, decrypt_chunk_aes
from meshbay_common.protocol import MNP
+TEST_GROUP = "g"
+
+from meshbay_common.handshake import (
+ NONCE_LEN, ROLE_CLIENT, ROLE_NODE, handshake_transcript,
+ make_proof, verify_proof, webrtc_binding,
+)
+from meshbay_common.adminop import (
+ OP_FILE_DELETE,
+ OP_INVITE_CREATE,
+ admin_transcript,
+)
+from meshbay_common.join import ROLE_MEMBER, ROLE_OPERATOR, join_transcript
from meshbay_node.bundle_store import BundleStore
+from meshbay_node.roster import Roster
from meshbay_node.indexer import DirectoryIndexer
from meshbay_node.transport.webrtc_server import WebRTCTransport
@@ -72,6 +88,9 @@ def _hub_pk_pem(sk_hub):
def _make_jwt(sk_hub, groups=None, pk_user="test"):
+ # group_id is mandatory now (M1), so the default token must be a member
+ # of the group the tests connect to. Tests that exercise refusal pass
+ # groups=[...] explicitly.
sk_pem = sk_hub.private_bytes(
serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
@@ -82,10 +101,25 @@ def _make_jwt(sk_hub, groups=None, pk_user="test"):
"iss": "test-hub", "sub": "user-001",
"pk_user": pk_user, "hub_id": "test-hub",
"jti": "test-jti-webrtc", "iat": now, "exp": now + 3600,
- "groups": groups or [],
+ "groups": groups if groups is not None else [TEST_GROUP],
}, sk_pem, algorithm="EdDSA")
+def _transcript_from(challenge_msg: dict) -> bytes:
+ """
+ Rebuild the signed transcript from an admin_challenge, the way a real client
+ does — from the announced fields, never from opaque bytes on the wire (H5).
+ """
+ return admin_transcript(
+ op=challenge_msg["op"],
+ node_pk_b64=challenge_msg["node_pk"],
+ group_id=challenge_msg["group_id"],
+ subject=challenge_msg["subject"],
+ nonce=base64.b64decode(challenge_msg["nonce"]),
+ ts=challenge_msg["ts"],
+ )
+
+
def _pack(obj: dict) -> bytes:
data = msgpack.packb(obj, use_bin_type=True)
return struct.pack(">I", len(data)) + data
@@ -103,36 +137,80 @@ def _extract_dtls_fp(sdp: str) -> bytes:
return b""
-async def _handshake_with_gek_proof(channel, received, sk_hub, gek, groups=None,
- browser_pc=None):
- """Send handshake, handle GEK challenge, return handshake_ack."""
- token = _make_jwt(sk_hub, groups=groups)
+async def _do_mnp_handshake(channel, received, token, gek, pc, group_id):
+ """
+ Client half of the unified handshake (11.5.4): client nonce, length-prefixed
+ role-bound transcript, and verification of the node's own proof + signature.
+ """
+ nonce_c = os.urandom(NONCE_LEN)
channel.send(_pack({
- "type": MNP.HANDSHAKE,
- "v": MNP_VERSION,
- "token": token,
+ "type": MNP.HANDSHAKE, "v": MNP_VERSION,
+ "token": token, "group_id": group_id,
+ "nonce": base64.b64encode(nonce_c).decode(),
}))
msg = await asyncio.wait_for(received.get(), timeout=5.0)
- if msg["type"] == MNP.HANDSHAKE_CHALLENGE:
- nonce = base64.b64decode(msg["nonce"])
- offer_fp = b""
- answer_fp = b""
- if browser_pc:
- offer_fp = _extract_dtls_fp(browser_pc.localDescription.sdp)
- answer_fp = _extract_dtls_fp(browser_pc.remoteDescription.sdp)
- proof = hmac.new(gek, nonce + offer_fp + answer_fp, hashlib.sha256).digest()
- channel.send(_pack({
- "type": MNP.HANDSHAKE_RESPONSE,
- "v": MNP_VERSION,
- "proof": base64.b64encode(proof).decode(),
- }))
- msg = await asyncio.wait_for(received.get(), timeout=5.0)
+ if msg["type"] != MNP.HANDSHAKE_CHALLENGE:
+ return msg
+
+ nonce_s = base64.b64decode(msg["nonce"])
+ binding = webrtc_binding(
+ _extract_dtls_fp(pc.localDescription.sdp),
+ _extract_dtls_fp(pc.remoteDescription.sdp),
+ )
+ proof = make_proof(gek, ROLE_CLIENT, group_id, nonce_c, nonce_s, binding)
+ channel.send(_pack({
+ "type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION,
+ "proof": base64.b64encode(proof).decode(),
+ }))
+ ack = await asyncio.wait_for(received.get(), timeout=5.0)
+
+ if ack.get("type") == MNP.HANDSHAKE_ACK:
+ # The client must authenticate the node too (C3).
+ assert verify_proof(
+ gek, base64.b64decode(ack["proof"]), ROLE_NODE,
+ group_id, nonce_c, nonce_s, binding), "node proof invalid"
+ transcript = handshake_transcript(
+ ROLE_NODE, group_id, nonce_c, nonce_s, binding)
+ Ed25519PublicKey.from_public_bytes(
+ base64.b64decode(ack["node_pk"])
+ ).verify(base64.b64decode(ack["sig"]), transcript)
+ return ack
+
+
+async def _handshake_with_gek_proof(channel, received, sk_hub, gek, groups=None,
+ browser_pc=None, group_id=TEST_GROUP):
+ """Send handshake, handle GEK challenge, return handshake_ack."""
+ token = _make_jwt(sk_hub, groups=groups or [group_id])
+ msg = await _do_mnp_handshake(
+ channel, received, token, gek, browser_pc, group_id)
assert msg["type"] == MNP.HANDSHAKE_ACK
return msg
-async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None):
- """Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue)."""
+def _token(sk_hub, jwt_sub, peer_id, group_id, pk_user="test"):
+ """A hub-issued user token, as the browser would present it."""
+ sk_h_pem = sk_hub.private_bytes(
+ serialization.Encoding.PEM,
+ serialization.PrivateFormat.PKCS8,
+ serialization.NoEncryption(),
+ )
+ now = int(time.time())
+ return jwt.encode({
+ "iss": "test-hub", "sub": jwt_sub,
+ "pk_user": pk_user, "hub_id": "test-hub",
+ "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600,
+ "groups": [group_id], "scope": "user",
+ }, sk_h_pem, algorithm="EdDSA")
+
+
+async def _open_channel(transport, peer_id):
+ """
+ Signaling only: a live DataChannel with no MNP handshake performed.
+
+ Separate from `_setup_peer` because someone joining a group for the first time
+ cannot complete the handshake — they have no GEK to prove — and the join has to
+ happen in that window.
+ """
pc = RTCPeerConnection()
q = asyncio.Queue()
buf = bytearray()
@@ -161,6 +239,13 @@ async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_us
answer_sdp, _ = await transport.handle_offer(pc.localDescription.sdp, peer_id)
await pc.setRemoteDescription(RTCSessionDescription(sdp=answer_sdp, type="answer"))
await asyncio.wait_for(ready.wait(), timeout=5.0)
+ return pc, ch, q
+
+
+async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_user=None,
+ group_id=TEST_GROUP):
+ """Create a peer connection, perform handshake with GEK proof, return (pc, channel, queue)."""
+ pc, ch, q = await _open_channel(transport, peer_id)
pk_user = "test"
if sk_user:
@@ -169,31 +254,9 @@ async def _setup_peer(transport, sk_hub, gek, peer_id, jwt_sub="user-001", sk_us
serialization.Encoding.Raw, serialization.PublicFormat.Raw)
).decode()
- sk_h_pem = sk_hub.private_bytes(
- serialization.Encoding.PEM,
- serialization.PrivateFormat.PKCS8,
- serialization.NoEncryption(),
- )
- now = int(time.time())
- token = jwt.encode({
- "iss": "test-hub", "sub": jwt_sub,
- "pk_user": pk_user, "hub_id": "test-hub",
- "jti": f"jti-{peer_id}", "iat": now, "exp": now + 3600,
- "groups": [],
- }, sk_h_pem, algorithm="EdDSA")
+ token = _token(sk_hub, jwt_sub, peer_id, group_id, pk_user)
- ch.send(_pack({"type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token}))
- msg = await asyncio.wait_for(q.get(), timeout=5.0)
- if msg["type"] == MNP.HANDSHAKE_CHALLENGE:
- nonce = base64.b64decode(msg["nonce"])
- offer_fp = _extract_dtls_fp(pc.localDescription.sdp)
- answer_fp = _extract_dtls_fp(pc.remoteDescription.sdp)
- proof = hmac.new(gek, nonce + offer_fp + answer_fp, hashlib.sha256).digest()
- ch.send(_pack({
- "type": MNP.HANDSHAKE_RESPONSE, "v": MNP_VERSION,
- "proof": base64.b64encode(proof).decode(),
- }))
- msg = await asyncio.wait_for(q.get(), timeout=5.0)
+ msg = await _do_mnp_handshake(ch, q, token, gek, pc, group_id)
assert msg["type"] == MNP.HANDSHAKE_ACK
return pc, ch, q
@@ -676,6 +739,8 @@ async def test_webrtc_wrong_gek_proof_rejected(sk_node, sk_hub, gek, shared_dir)
token = _make_jwt(sk_hub)
channel.send(_pack({
"type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token,
+ "group_id": TEST_GROUP,
+ "nonce": base64.b64encode(os.urandom(NONCE_LEN)).decode(),
}))
challenge = await asyncio.wait_for(received.get(), timeout=5.0)
@@ -734,6 +799,8 @@ async def test_webrtc_dtls_channel_binding_detects_mitm(sk_node, sk_hub, gek, sh
token = _make_jwt(sk_hub)
channel.send(_pack({
"type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token,
+ "group_id": TEST_GROUP,
+ "nonce": base64.b64encode(os.urandom(NONCE_LEN)).decode(),
}))
challenge = await asyncio.wait_for(received.get(), timeout=5.0)
@@ -783,13 +850,13 @@ async def test_webrtc_admin_challenge_response(sk_node, sk_hub, gek, shared_dir)
challenge_msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
- assert challenge_msg["file_id"] == entry.id
+ assert challenge_msg["op"] == OP_FILE_DELETE
+ assert challenge_msg["subject"] == entry.id
- challenge = base64.b64decode(challenge_msg["challenge"])
- signature = sk_admin.sign(challenge)
+ signature = sk_admin.sign(_transcript_from(challenge_msg))
channel.send(_pack({
"type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
- "file_id": entry.id,
+ "op_id": challenge_msg["op_id"],
"signature": base64.b64encode(signature).decode(),
}))
@@ -832,11 +899,10 @@ async def test_webrtc_admin_bad_signature_rejected(sk_node, sk_hub, gek, shared_
challenge_msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
- challenge = base64.b64decode(challenge_msg["challenge"])
- bad_sig = sk_attacker.sign(challenge)
+ bad_sig = sk_attacker.sign(_transcript_from(challenge_msg))
channel.send(_pack({
"type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
- "file_id": entry.id,
+ "op_id": challenge_msg["op_id"],
"signature": base64.b64encode(bad_sig).decode(),
}))
@@ -914,14 +980,14 @@ async def test_webrtc_uploader_delete_requires_challenge(sk_node, sk_hub, gek, s
challenge_msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
- assert challenge_msg["file_id"] == entry.id
+ assert challenge_msg["op"] == OP_FILE_DELETE
+ assert challenge_msg["subject"] == entry.id
# Sign with uploader's Ed25519 key
- challenge = base64.b64decode(challenge_msg["challenge"])
- signature = sk_uploader.sign(challenge)
+ signature = sk_uploader.sign(_transcript_from(challenge_msg))
channel.send(_pack({
"type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
- "file_id": entry.id,
+ "op_id": challenge_msg["op_id"],
"signature": base64.b64encode(signature).decode(),
}))
@@ -979,11 +1045,10 @@ async def test_webrtc_uploader_impersonation_blocked(sk_node, sk_hub, gek, share
assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
# Sign with user B's key (wrong key)
- challenge = base64.b64decode(challenge_msg["challenge"])
- bad_sig = sk_user_b.sign(challenge)
+ bad_sig = sk_user_b.sign(_transcript_from(challenge_msg))
channel.send(_pack({
"type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
- "file_id": entry.id,
+ "op_id": challenge_msg["op_id"],
"signature": base64.b64encode(bad_sig).decode(),
}))
@@ -1014,54 +1079,122 @@ def x25519_keypair():
@pytest.mark.asyncio
-async def test_gek_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir,
- tmp_path, x25519_keypair):
- """GEK bundle stored on node via DataChannel, then fetched during handshake."""
+async def test_invite_then_join_delivers_the_gek(sk_node, sk_hub, gek, shared_dir,
+ tmp_path, x25519_keypair):
+ """
+ The whole invite flow over a real DataChannel, end to end.
+
+ The operator asks for a code; the invitee — who has never held the group key
+ and therefore cannot complete the GEK proof — redeems it in the pre-proof
+ window and the node wraps the key for the X25519 key they just proved they
+ hold. At no point is a public key fetched from the hub, which is the point:
+ that lookup was H3.
+ """
hub_pk_pem = _hub_pk_pem(sk_hub)
indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
await indexer.initial_scan()
- bundle_store = BundleStore(db_path=tmp_path / "bundles.db")
- await bundle_store.open()
+ roster = Roster(db_path=tmp_path / "roster.db")
+ await roster.open()
transport = WebRTCTransport(
sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
shared_root=shared_dir, index=indexer.index,
stun_servers=[],
)
- transport._ctx["bundle_store"] = bundle_store
+ transport._ctx["roster"] = roster
+ transport._ctx["has_admin_authority"] = True
+ transport._ctx["groups"] = {
+ TEST_GROUP: {"gek": gek, "shared_root": shared_dir, "index": indexer.index},
+ }
+
+ # A paired operator, as `meshbay-node operator pair` would have left it.
+ sk_admin = Ed25519PrivateKey.generate()
+ admin_pk_b64 = pk_to_b64(sk_admin.public_key())
+ await roster.pin_identity("user-001", "grenet", admin_pk_b64, "AA==", "code")
+ await roster.set_member("", "user-001", ROLE_OPERATOR, "active", "local-cli")
- # Connect as admin and store a GEK bundle for user-002
pc_admin, ch_admin, q_admin = await _setup_peer(
transport, sk_hub, gek, "peer-admin")
- sk_x_raw, pk_x_raw = x25519_keypair
- bundle = wrap_gek(gek, pk_x_raw)
+ # 1. The operator asks the node for an invitation code.
+ ch_admin.send(_pack({
+ "type": MNP.INVITE_CREATE, "v": MNP_VERSION,
+ "user_id": "user-002", "group_id": TEST_GROUP, "username": "bob",
+ }))
+ challenge_msg = await asyncio.wait_for(q_admin.get(), timeout=5.0)
+ assert challenge_msg["type"] == MNP.ADMIN_CHALLENGE
+ assert challenge_msg["op"] == OP_INVITE_CREATE
+ assert challenge_msg["subject"] == "user-002"
ch_admin.send(_pack({
- "type": MNP.GEK_BUNDLE_STORE,
- "v": MNP_VERSION,
- "user_id": "user-002",
- "group_id": "g",
- "pk_eph_b64": bundle["pk_eph_b64"],
- "nonce_b64": bundle["nonce_b64"],
- "wrapped_b64": bundle["wrapped_b64"],
+ "type": MNP.ADMIN_RESPONSE, "v": MNP_VERSION,
+ "op_id": challenge_msg["op_id"],
+ "signature": base64.b64encode(
+ sk_admin.sign(_transcript_from(challenge_msg))).decode(),
}))
- ack = await asyncio.wait_for(q_admin.get(), timeout=5.0)
- assert ack["type"] == "ack"
- assert ack["detail"] == "gek_bundle_stored"
+ invite = await asyncio.wait_for(q_admin.get(), timeout=5.0)
+ assert invite["type"] == MNP.INVITE_RESULT
+ code = invite["code"]
+ assert code and len(code) == 9 # XXXX-XXXX
- # Verify bundle was persisted
- stored = await bundle_store.fetch("g", "user-002")
- assert stored is not None
- assert stored["pk_eph_b64"] == bundle["pk_eph_b64"]
+ # 2. Bob connects. He cannot prove GEK possession — he has never had it — so
+ # he redeems the code in the pre-proof window instead.
+ sk_x_raw, pk_x_raw = x25519_keypair
+ sk_bob_ed = Ed25519PrivateKey.generate()
+ pc_bob, ch_bob, q_bob = await _open_channel(transport, "peer-bob")
- # Unwrap to verify it's correct
- recovered = unwrap_gek(stored, sk_x_raw, pk_x_raw)
- assert recovered == gek
+ nonce_c = os.urandom(NONCE_LEN)
+ ch_bob.send(_pack({
+ "type": MNP.HANDSHAKE, "v": MNP_VERSION,
+ "token": _token(sk_hub, "user-002", "peer-bob", TEST_GROUP),
+ "group_id": TEST_GROUP,
+ "nonce": base64.b64encode(nonce_c).decode(),
+ }))
+ challenge = await asyncio.wait_for(q_bob.get(), timeout=5.0)
+ assert challenge["type"] == MNP.HANDSHAKE_CHALLENGE
+ nonce_s = base64.b64decode(challenge["nonce"])
- await bundle_store.close()
+ # Bob signs a transcript naming the node, and he cannot complete the handshake
+ # that would prove its key — he has no GEK yet. So he has to be able to learn
+ # it from the challenge; taking it from the test's own knowledge of sk_node
+ # would hide the fact that a real client cannot.
+ assert challenge["node_pk"] == pk_to_b64(sk_node.public_key()), (
+ "the challenge must announce the node key to a first-time joiner")
+ node_pk_b64 = challenge["node_pk"]
+
+ pk_ed_b64 = pk_to_b64(sk_bob_ed.public_key())
+ pk_x_b64 = base64.b64encode(pk_x_raw).decode()
+ ts = int(time.time())
+ transcript = join_transcript(
+ node_pk_b64=node_pk_b64,
+ group_id=TEST_GROUP, user_id="user-002",
+ pk_ed25519_b64=pk_ed_b64, pk_x25519_b64=pk_x_b64,
+ nonce_node=nonce_s, ts=ts,
+ )
+ ch_bob.send(_pack({
+ "type": MNP.JOIN_REQUEST, "v": MNP_VERSION,
+ "group_id": TEST_GROUP,
+ "pk_ed25519": pk_ed_b64, "pk_x25519": pk_x_b64,
+ "code": code, "ts": ts,
+ "sig": base64.b64encode(sk_bob_ed.sign(transcript)).decode(),
+ }))
+
+ result = await asyncio.wait_for(q_bob.get(), timeout=5.0)
+ assert result["type"] == MNP.JOIN_RESULT
+ assert result["ok"] is True
+ assert result["gek"] is True
+ assert result["role"] == ROLE_MEMBER
+
+ # 3. The key really is the group key, and only Bob's secret opens it.
+ assert unwrap_gek_aes(result, sk_x_raw, pk_x_raw) == gek
+
+ # 4. The code is spent.
+ assert await roster.consume_invite(code, "user-002") is None
+
+ await roster.close()
await pc_admin.close()
+ await pc_bob.close()
await transport.close_all()
@@ -1124,8 +1257,10 @@ async def test_gek_bundle_fetch_during_handshake(sk_node, sk_hub, gek, shared_di
# Step 1: Send handshake with group_id so _pending_group is set
token = _make_jwt(sk_hub, groups=["g"])
+ nonce_c = os.urandom(NONCE_LEN)
channel.send(_pack({
"type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g",
+ "nonce": base64.b64encode(nonce_c).decode(),
}))
msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert msg["type"] == MNP.HANDSHAKE_CHALLENGE
@@ -1140,11 +1275,12 @@ async def test_gek_bundle_fetch_during_handshake(sk_node, sk_hub, gek, shared_di
recovered_gek = unwrap_gek(bundle_resp, sk_x_raw, pk_x_raw)
assert recovered_gek == gek
- nonce = base64.b64decode(msg["nonce"])
- offer_fp = _extract_dtls_fp(browser_pc.localDescription.sdp)
- answer_fp = _extract_dtls_fp(browser_pc.remoteDescription.sdp)
- proof = hmac.new(recovered_gek, nonce + offer_fp + answer_fp,
- hashlib.sha256).digest()
+ nonce_s = base64.b64decode(msg["nonce"])
+ binding = webrtc_binding(
+ _extract_dtls_fp(browser_pc.localDescription.sdp),
+ _extract_dtls_fp(browser_pc.remoteDescription.sdp),
+ )
+ proof = make_proof(recovered_gek, ROLE_CLIENT, "g", nonce_c, nonce_s, binding)
# Step 4: Complete handshake
channel.send(_pack({
@@ -1229,8 +1365,10 @@ async def test_keypair_bundle_store_and_fetch(sk_node, sk_hub, gek, shared_dir,
await asyncio.wait_for(ready.wait(), timeout=5.0)
token = _make_jwt(sk_hub, groups=["g"])
+ nonce_c = os.urandom(NONCE_LEN)
channel.send(_pack({
"type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g",
+ "nonce": base64.b64encode(nonce_c).decode(),
}))
msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert msg["type"] == MNP.HANDSHAKE_CHALLENGE
@@ -1296,8 +1434,10 @@ async def test_keypair_bundle_fetch_not_found(sk_node, sk_hub, gek, shared_dir,
await asyncio.wait_for(ready.wait(), timeout=5.0)
token = _make_jwt(sk_hub, groups=["g"])
+ nonce_c = os.urandom(NONCE_LEN)
channel.send(_pack({
"type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g",
+ "nonce": base64.b64encode(nonce_c).decode(),
}))
msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert msg["type"] == MNP.HANDSHAKE_CHALLENGE
@@ -1313,9 +1453,18 @@ async def test_keypair_bundle_fetch_not_found(sk_node, sk_hub, gek, shared_dir,
@pytest.mark.asyncio
-async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shared_dir,
+async def test_gek_not_auto_activated_on_bundle_store(sk_node, sk_hub, gek, shared_dir,
tmp_path, x25519_keypair):
- """Storing the node operator's GEK bundle auto-activates GEK (AES variant)."""
+ """
+ A GEK bundle arriving over MNP must NOT become the node's live key (C5b).
+
+ This test previously asserted the opposite: storing a bundle addressed to the
+ node operator auto-activated it, with no signature required. Because the
+ operator's X25519 public key is public — the node publishes it in handshake_ack
+ — any group member could wrap a key of their own choosing for it and take over
+ the group, locking every legitimate member out. GEK activation now happens only
+ through the node's local admin UI or CLI.
+ """
hub_pk_pem = _hub_pk_pem(sk_hub)
indexer = DirectoryIndexer(root=shared_dir, group_id="g", sk_node=sk_node, gek=gek)
await indexer.initial_scan()
@@ -1324,7 +1473,8 @@ async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shar
bundle_store = BundleStore(db_path=tmp_path / "bundles.db")
await bundle_store.open()
- new_gek = generate_gek()
+ attacker_gek = generate_gek()
+ assert attacker_gek != gek
transport = WebRTCTransport(
sk_node=sk_node, hub_pk_pem=hub_pk_pem, gek=gek,
@@ -1336,14 +1486,18 @@ async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shar
transport._ctx["sk_x25519_raw"] = sk_x_raw
transport._ctx["pk_x25519_raw"] = pk_x_raw
transport._ctx["pk_x25519_b64"] = base64.b64encode(pk_x_raw).decode()
+ transport._ctx["admin_pk_ed25519"] = Ed25519PrivateKey.generate().public_key()
pc_admin, ch_admin, q_admin = await _setup_peer(
transport, sk_hub, gek, "peer-setup-admin")
- # Store GEK bundle wrapped with AES-GCM (browser-compatible)
- node_bundle = wrap_gek_aes(new_gek, pk_x_raw)
+ # An ordinary member wraps a key of their choosing for the operator's public
+ # key and offers it to the node. The message that used to carry this no longer
+ # exists (the node wraps the GEK itself now), so it reaches no handler at all —
+ # a stronger outcome than the admin challenge this test used to assert.
+ node_bundle = wrap_gek_aes(attacker_gek, pk_x_raw)
ch_admin.send(_pack({
- "type": MNP.GEK_BUNDLE_STORE,
+ "type": "gek_bundle_store",
"v": MNP_VERSION,
"user_id": "node-operator",
"group_id": "g",
@@ -1351,12 +1505,12 @@ async def test_gek_auto_activate_on_node_bundle_store(sk_node, sk_hub, gek, shar
"nonce_b64": node_bundle["nonce_b64"],
"wrapped_b64": node_bundle["wrapped_b64"],
}))
- ack = await asyncio.wait_for(q_admin.get(), timeout=5.0)
- assert ack["type"] == "ack"
- await asyncio.sleep(0.2)
+ await asyncio.sleep(0.5)
+ assert q_admin.empty(), "the retired bundle message still gets a response"
- assert transport._ctx.get("gek") == new_gek
+ assert transport._ctx.get("gek") == gek, "group key was seized over MNP (C5b)"
+ assert await bundle_store.fetch("g", "node-operator") is None
await bundle_store.close()
await pc_admin.close()
@@ -1398,6 +1552,8 @@ async def test_webrtc_no_gek_connection_refused(sk_node, sk_hub, shared_dir):
token = _make_jwt(sk_hub)
channel.send(_pack({
"type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token,
+ "group_id": TEST_GROUP,
+ "nonce": base64.b64encode(os.urandom(NONCE_LEN)).decode(),
}))
msg = await asyncio.wait_for(received.get(), timeout=5.0)
@@ -1457,8 +1613,10 @@ async def test_gek_bundle_fetch_not_found(sk_node, sk_hub, gek, shared_dir, tmp_
await asyncio.wait_for(ready.wait(), timeout=5.0)
token = _make_jwt(sk_hub, groups=["g"])
+ nonce_c = os.urandom(NONCE_LEN)
channel.send(_pack({
"type": MNP.HANDSHAKE, "v": MNP_VERSION, "token": token, "group_id": "g",
+ "nonce": base64.b64encode(nonce_c).decode(),
}))
msg = await asyncio.wait_for(received.get(), timeout=5.0)
assert msg["type"] == MNP.HANDSHAKE_CHALLENGE