From f0248975908ad670fa8a820f865bf22ea8d0172d Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Thu, 13 Aug 2026 03:56:30 +0200 Subject: feat: Phase 12 — P2P crypto material, password split, node Ed25519 auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baseline commit capturing in-progress Phase 12 work that was already present in the working tree (uncommitted) before the Phase 11.5 security remediation begins. Committed as-is, without review or modification, so that remediation changes arrive as a separable diff. Contents: BundleStore (P2P GEK + keypair bundles), password split (auth_key / bundle_key), node Ed25519 auth (POST /v1/nodes/auth, node-scoped JWT), GEK-HMAC handshake proof with DTLS channel binding, Ed25519 admin challenge-response, node local admin UI rewrite, browser key persistence. Not authored in this session — captured to establish a baseline. Co-Authored-By: Claude Opus 5 --- packages/meshbay-node/tests/test_hub_client.py | 76 +++++--------------------- 1 file changed, 13 insertions(+), 63 deletions(-) (limited to 'packages/meshbay-node/tests/test_hub_client.py') diff --git a/packages/meshbay-node/tests/test_hub_client.py b/packages/meshbay-node/tests/test_hub_client.py index fe8a2af..2975ee8 100644 --- a/packages/meshbay-node/tests/test_hub_client.py +++ b/packages/meshbay-node/tests/test_hub_client.py @@ -2,7 +2,6 @@ Tests for meshbay_node.hub_client — uses httpx.MockTransport to avoid network. """ -import base64 import json import os import time @@ -16,7 +15,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from cryptography.hazmat.primitives import serialization -from meshbay_common.crypto import generate_gek, pk_to_b64, wrap_gek + from meshbay_node.hub_client import HubClient, HubConfig, HubSession from meshbay_node.keystore import NodeKeys @@ -51,16 +50,15 @@ def hub_config(tmp_path): return HubConfig( hub_url="http://fake-hub", username="testuser", - password="testpass99", cache_dir=tmp_path, ) -def make_token(sk_pem, user_id, pk_user_b64, hub_id="fake-hub", ttl=3600): +def make_node_token(sk_pem, user_id, pk_user_b64, hub_id="fake-hub", ttl=3600): now = int(time.time()) return jwt.encode({ "iss": hub_id, "sub": user_id, "pk_user": pk_user_b64, - "hub_id": hub_id, "jti": "test-jti", + "hub_id": hub_id, "jti": "test-jti", "scope": "node", "iat": now, "exp": now + ttl, }, sk_pem, algorithm="EdDSA") @@ -71,14 +69,14 @@ def make_token(sk_pem, user_id, pk_user_b64, hub_id="fake-hub", ttl=3600): async def test_login_verifies_jwt_offline(hub_keys, node_keys, hub_config): sk_hub, sk_hub_pem, pk_hub_pem = hub_keys user_id = "user-uuid-001" - token = make_token(sk_hub_pem, user_id, node_keys.pk_ed25519_b64) + token = make_node_token(sk_hub_pem, user_id, node_keys.pk_ed25519_b64) def handler(request): if request.url.path == "/v1/hub/pubkey": return httpx.Response(200, json={"pk_hub_pem": pk_hub_pem.decode()}) - if request.url.path == "/v1/users/login": + if request.url.path == "/v1/nodes/auth": return httpx.Response(200, json={ - "access_token": token, "refresh_token": "rt-abc", "expires_in": 3600}) + "access_token": token, "token_type": "bearer", "expires_in": 3600}) return httpx.Response(404) transport = httpx.MockTransport(handler) @@ -96,18 +94,18 @@ async def test_login_verifies_jwt_offline(hub_keys, node_keys, hub_config): @pytest.mark.asyncio async def test_login_rejects_missing_jti(hub_keys, node_keys, hub_config): sk_hub, sk_hub_pem, pk_hub_pem = hub_keys - # Token without jti bad_token = jwt.encode({ "iss": "fake-hub", "sub": "uid", "pk_user": node_keys.pk_ed25519_b64, - "hub_id": "fake-hub", "iat": int(time.time()), "exp": int(time.time()) + 3600, + "hub_id": "fake-hub", "scope": "node", + "iat": int(time.time()), "exp": int(time.time()) + 3600, }, sk_hub_pem, algorithm="EdDSA") def handler(request): if request.url.path == "/v1/hub/pubkey": return httpx.Response(200, json={"pk_hub_pem": pk_hub_pem.decode()}) - if request.url.path == "/v1/users/login": + if request.url.path == "/v1/nodes/auth": return httpx.Response(200, json={ - "access_token": bad_token, "refresh_token": "rt", "expires_in": 3600}) + "access_token": bad_token, "token_type": "bearer", "expires_in": 3600}) return httpx.Response(404) transport = httpx.MockTransport(handler) @@ -118,34 +116,17 @@ async def test_login_rejects_missing_jti(hub_keys, node_keys, hub_config): await client.login() -@pytest.mark.asyncio -async def test_register_idempotent(hub_keys, node_keys, hub_config): - def handler(request): - if request.url.path == "/v1/users/register": - return httpx.Response(409, json={"detail": "Username already taken"}) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - client = HubClient(hub_config, node_keys) - client._http = httpx.AsyncClient(transport=transport, base_url="http://fake-hub") - - # Should not raise on 409 - result = await client.register() - assert result == "" - - @pytest.mark.asyncio async def test_token_needs_refresh(hub_keys, node_keys, hub_config): sk_hub, sk_hub_pem, pk_hub_pem = hub_keys - # Token expiring in 60s (< TOKEN_REFRESH_MARGIN of 300s) - short_token = make_token(sk_hub_pem, "uid", node_keys.pk_ed25519_b64, ttl=60) + short_token = make_node_token(sk_hub_pem, "uid", node_keys.pk_ed25519_b64, ttl=60) def handler(request): if request.url.path == "/v1/hub/pubkey": return httpx.Response(200, json={"pk_hub_pem": pk_hub_pem.decode()}) - if request.url.path == "/v1/users/login": + if request.url.path == "/v1/nodes/auth": return httpx.Response(200, json={ - "access_token": short_token, "refresh_token": "rt", "expires_in": 60}) + "access_token": short_token, "token_type": "bearer", "expires_in": 60}) return httpx.Response(404) transport = httpx.MockTransport(handler) @@ -156,37 +137,6 @@ async def test_token_needs_refresh(hub_keys, node_keys, hub_config): assert session.token_needs_refresh -@pytest.mark.asyncio -async def test_fetch_gek(hub_keys, node_keys, hub_config): - """Admin wraps GEK for this node; client fetches and unwraps.""" - sk_hub, sk_hub_pem, pk_hub_pem = hub_keys - gek = generate_gek() - - # Simulate admin wrapping GEK for this node - pk_x_raw = base64.b64decode(node_keys.pk_x25519_b64) - bundle = wrap_gek(gek, pk_x_raw) - - token = make_token(sk_hub_pem, "uid", node_keys.pk_ed25519_b64) - - def handler(request): - if request.url.path == "/v1/hub/pubkey": - return httpx.Response(200, json={"pk_hub_pem": pk_hub_pem.decode()}) - if request.url.path == "/v1/users/login": - return httpx.Response(200, json={ - "access_token": token, "refresh_token": "rt", "expires_in": 3600}) - if "/v1/groups/" in request.url.path and request.url.path.endswith("/gek"): - return httpx.Response(200, json=bundle) - return httpx.Response(404) - - transport = httpx.MockTransport(handler) - client = HubClient(hub_config, node_keys) - client._http = httpx.AsyncClient(transport=transport, base_url="http://fake-hub") - - await client.login() - recovered = await client.fetch_gek("group-abc") - assert recovered == gek - - @pytest.mark.asyncio async def test_hub_pk_cached(hub_keys, node_keys, hub_config, tmp_path): _, _, pk_hub_pem = hub_keys -- cgit v1.2.3