diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 04:10:14 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 04:10:14 +0200 |
| commit | ed9fb22ed703db38f9b07c00d17076f90aa4cbc8 (patch) | |
| tree | 448af8bdc7734798607bb33735c2a8439c362c13 /packages/meshbay-node/tests | |
| parent | ee6573c57f721db8550e34e1c1c79c5922c62a4b (diff) | |
| download | meshbay-ed9fb22ed703db38f9b07c00d17076f90aa4cbc8.tar.gz | |
fix(node)!: remove unauthenticated HTTP file API and TCP transport
Phase 11.5.A — findings C1 and C6 (see second-review.md).
C1: the per-group HTTP file API bound 0.0.0.0 for every configured group,
private ones included, and served two endpoints with no authentication at all:
GET /index (full Mesh Group Index) and GET /file/{id} (raw plaintext file via
FileResponse). Anyone able to reach the port — LAN, forwarded port, permissive
IPv6 — read every private file. This bypassed the entire GEK-proof and node
sovereignty layer. Deleted rather than patched: it duplicated MNP without any
of its controls.
C6: the TCP+TLS chunk server accepted a bare JWT with no GEK proof, leaving a
second non-compliant handshake path. Deleted; QUIC remains and will be brought
to parity with WebRTC by the unified handshake in 11.5.4.
Transport decision recorded in transport/__init__.py: WebRTC/ICE is primary for
browser and native clients (the only NAT traversal validated here — 2 ISPs,
IPv4 STUN + IPv6, 4G CGNAT); QUIC is kept for LAN, port-forwarded and hub-less
group:// access. punch_nat() is a direct-connection helper, not a traversal
stack.
Also removed server_ssl_context()/client_ssl_context() from tls_cert.py (no
remaining callers) and a dead import of the former in quic_server.py.
generate_self_signed_cert() stays: QUIC uses it, and the certificate hash is
the intended channel-binding anchor for 11.5.6, since QUIC has no DTLS
fingerprint to bind the GEK proof to.
BREAKING CHANGE: node.toml keys `port` and `http_port` are gone. Regenerate
config with `meshbay-node init`. Env var MESHBAY_PORT -> MESHBAY_QUIC_PORT.
Tests: 198 passed (209 - 7 test_http_server - 4 test_transport). No other test
changed status. Net -1300 lines.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_daemon.py | 18 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_http_server.py | 227 | ||||
| -rw-r--r-- | packages/meshbay-node/tests/test_transport.py | 222 |
3 files changed, 3 insertions, 464 deletions
diff --git a/packages/meshbay-node/tests/test_daemon.py b/packages/meshbay-node/tests/test_daemon.py index 1c5a07e..e899639 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,19 @@ def _mock_keystore_keys(sk_ed): mock_keys.pk_x25519_b64 = base64.b64encode(pk_x_raw).decode() return mock_keys - @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 +55,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=29010, ui_port=28000), 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.""" @@ -146,7 +137,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 +178,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=29010, ui_port=28000), 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", @@ -237,7 +226,6 @@ async def test_daemon_index_change_pushes_to_peers(tmp_path, shared_dir, gek, hu call_args = daemon._hub.register_swarm.call_args assert len(call_args[0][0]) == indexer.index.count - @pytest.mark.asyncio async def test_daemon_index_change_skips_other_group_peers( tmp_path, shared_dir, gek, hub_pk_pem 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_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() |