diff options
Diffstat (limited to 'packages')
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="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DwtBixEPC0GeEyEBCyABC48DAQF/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 Binary files differnew file mode 100755 index 0000000..75c3111 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/vendor/argon2.wasm 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 <username></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 <username></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} — <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", "—")} — - <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 "<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 "<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 |