diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-01 13:25:58 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-01 13:25:58 +0200 |
| commit | b1878ab982ab72571915e7fbe2c1b558ea31f838 (patch) | |
| tree | 2d853b4b28bf3b76d29f860be63cfef6750cf2c3 | |
| parent | 10552e576528e97884b8b7587526e70843a13bb3 (diff) | |
| download | meshbay-b1878ab982ab72571915e7fbe2c1b558ea31f838.tar.gz | |
chore: remove a spike page served in production and an unused derivation
static/webrtc-test.html posted a raw password to /login from the hub's own
origin; meshbay_common/keyderive.py derived keys from a password and nothing
called it (F-32).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| -rw-r--r-- | CLAUDE.md | 2 | ||||
| -rw-r--r-- | packages/meshbay-client/scripts/sync-ui.js | 2 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/keyderive.py | 130 | ||||
| -rw-r--r-- | packages/meshbay-common/tests/test_keyderive.py | 57 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html | 265 |
5 files changed, 2 insertions, 454 deletions
@@ -923,7 +923,7 @@ here are kept only where they are a rule about *editing* the code. | Handshake, version range | `meshbay_common/handshake.py` — `MNP_MIN_SUPPORTED`, `check_version` | read by both servers and both clients | | Wire messages, `req_id`, `IndexEntry` | `meshbay_common/protocol.py` | §5.3 | | Signed admin transcripts | `meshbay_common/adminop.py`, `join.py`, `device.py` | §5.4 | -| Key derivation from a passphrase, bundle format | `static/keyderive.js` (page) + `meshbay-client/src/keyring.js` (desktop main process) | §3.1, §3.7. **Parity-tested — never change the parameters in one place.** `meshbay_common/keyderive.py` is an older, unused derivation, not this one | +| Key derivation from a passphrase, bundle format | `static/keyderive.js` (page) + `meshbay-client/src/keyring.js` (desktop main process) | §3.1, §3.7. **Parity-tested — never change the parameters in one place.** | | Path folding, NFC, long paths, reserved names | `meshbay_common/paths.py` | §10 | | ~~Double Ratchet / Sender Keys~~ | — | **Deleted.** Both were written and never called. Kept code that nothing calls reads as an alternative somebody may reach for, and its green tests read as evidence of a protection that is not in the product. The reasoning that ruled them out is at the top of `chatbox.py` | diff --git a/packages/meshbay-client/scripts/sync-ui.js b/packages/meshbay-client/scripts/sync-ui.js index d0b6eee..4ebdf35 100644 --- a/packages/meshbay-client/scripts/sync-ui.js +++ b/packages/meshbay-client/scripts/sync-ui.js @@ -23,7 +23,7 @@ const DEST = path.resolve(__dirname, '..', 'ui'); const OURS = ['index.html']; // sw.js has to sit at the root of the scope it serves, which it already does. -const SKIP = new Set(['webrtc-test.html']); +const SKIP = new Set(); function copyTree(from, to) { fs.mkdirSync(to, { recursive: true }); diff --git a/packages/meshbay-common/src/meshbay_common/keyderive.py b/packages/meshbay-common/src/meshbay_common/keyderive.py deleted file mode 100644 index 4b90af3..0000000 --- a/packages/meshbay-common/src/meshbay_common/keyderive.py +++ /dev/null @@ -1,130 +0,0 @@ -""" -MeshBay — Key derivation from username + password. - -Allows Ed25519 + X25519 keypairs to be derived deterministically -from credentials. Same inputs → same keys on any device. - -Algorithm: Argon2id (Python CLI / native clients) - salt = SHA-256("meshbay:v1:" + username) - seed = Argon2id(password, salt, length=64, ...) - sk_ed = Ed25519PrivateKey.from_private_bytes(seed[:32]) - sk_x25519 = X25519PrivateKey.from_private_bytes(seed[32:]) - -Browser alternative (keyderive.js): uses PBKDF2-SHA512 because -WebCrypto does not support Argon2. The two algorithms produce -DIFFERENT keys from the same password — a user registered via Python -CLI and via web browser will have different keypairs. - -Resolution: the web client generates RANDOM keypairs on first login -(WebCrypto, stored encrypted in hub), and uses derive_keys_from_password -only to encrypt/decrypt the stored keypair bundle. This avoids the -algorithm mismatch problem entirely. - -See keyderive.js for the browser-side implementation. -""" - -import hashlib - -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey -from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey -from cryptography.hazmat.primitives.kdf.argon2 import Argon2id - -# Argon2id parameters — same as keystore (see crypto.py) -_ITERATIONS = 3 -_MEMORY_COST = 65536 # 64 MB — increase to 262144 for production -_LANES = 4 -_SEED_LENGTH = 64 # 32 bytes Ed25519 + 32 bytes X25519 - - -def _derive_salt(username: str) -> bytes: - """Deterministic salt: SHA-256 of 'meshbay:v1:<username>'.""" - return hashlib.sha256(f"meshbay:v1:{username}".encode()).digest() - - -def derive_keys_from_password( - username: str, - password: str, -) -> tuple[Ed25519PrivateKey, X25519PrivateKey]: - """ - Derive Ed25519 + X25519 keypairs deterministically from username + password. - - Properties: - - Same credentials always produce the same keypairs - - Different usernames produce different keys (even with same password) - - Password cannot be recovered from the public keys - - Changing the password invalidates all GEK bundles stored on the hub - - Use for: - - CLI / native node registration (Argon2id available) - - Recovery of lost keypairs from credentials - - Do NOT use for: - - Web browser registration (use random keypairs + encrypted bundle instead) - """ - salt = _derive_salt(username) - kdf = Argon2id( - salt=salt, length=_SEED_LENGTH, - iterations=_ITERATIONS, lanes=_LANES, memory_cost=_MEMORY_COST, - ) - seed = kdf.derive(password.encode()) - return ( - Ed25519PrivateKey.from_private_bytes(seed[:32]), - X25519PrivateKey.from_private_bytes(seed[32:]), - ) - - -def encrypt_keypair_bundle( - sk_ed: Ed25519PrivateKey, - sk_x: X25519PrivateKey, - password: str, - username: str, -) -> bytes: - """ - Encrypt a keypair bundle with a password-derived key (for hub storage). - Used by web clients: random keypairs encrypted with password, stored on hub. - Returns: AES-256-GCM ciphertext (nonce prepended). - """ - import os - - import msgpack - from cryptography.hazmat.primitives.ciphers.aead import AESGCM - - from meshbay_common.crypto import sk_to_raw - - # Derive an AES key from the password (different info string from key derivation) - salt = hashlib.sha256(f"meshbay:bundle:v1:{username}".encode()).digest() - kdf = Argon2id(salt=salt, length=32, iterations=_ITERATIONS, - lanes=_LANES, memory_cost=_MEMORY_COST) - aes_key = kdf.derive(password.encode()) - - payload = msgpack.packb({ - "sk_ed": sk_to_raw(sk_ed), - "sk_x": sk_to_raw(sk_x), - }, use_bin_type=True) - - nonce = os.urandom(12) - ct = AESGCM(aes_key).encrypt(nonce, payload, None) - return nonce + ct - - -def decrypt_keypair_bundle( - bundle: bytes, - password: str, - username: str, -) -> tuple[Ed25519PrivateKey, X25519PrivateKey]: - """Decrypt a keypair bundle. Raises on wrong password.""" - import msgpack - from cryptography.hazmat.primitives.ciphers.aead import AESGCM - - salt = hashlib.sha256(f"meshbay:bundle:v1:{username}".encode()).digest() - kdf = Argon2id(salt=salt, length=32, iterations=_ITERATIONS, - lanes=_LANES, memory_cost=_MEMORY_COST) - aes_key = kdf.derive(password.encode()) - - nonce, ct = bundle[:12], bundle[12:] - payload = AESGCM(aes_key).decrypt(nonce, ct, None) - data = msgpack.unpackb(payload, raw=False) - return ( - Ed25519PrivateKey.from_private_bytes(data["sk_ed"]), - X25519PrivateKey.from_private_bytes(data["sk_x"]), - ) diff --git a/packages/meshbay-common/tests/test_keyderive.py b/packages/meshbay-common/tests/test_keyderive.py deleted file mode 100644 index 40b3c71..0000000 --- a/packages/meshbay-common/tests/test_keyderive.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Tests for password-based key derivation.""" - -import pytest -from meshbay_common.crypto import pk_to_b64 -from meshbay_common.keyderive import ( - decrypt_keypair_bundle, - derive_keys_from_password, - encrypt_keypair_bundle, -) - - -def test_deterministic(): - """Same credentials → same keys.""" - sk_ed1, sk_x1 = derive_keys_from_password("alice", "correct-horse") - sk_ed2, sk_x2 = derive_keys_from_password("alice", "correct-horse") - assert pk_to_b64(sk_ed1.public_key()) == pk_to_b64(sk_ed2.public_key()) - assert pk_to_b64(sk_x1.public_key()) == pk_to_b64(sk_x2.public_key()) - - -def test_different_users_different_keys(): - sk_ed_a, _ = derive_keys_from_password("alice", "samepassword") - sk_ed_b, _ = derive_keys_from_password("bob", "samepassword") - assert pk_to_b64(sk_ed_a.public_key()) != pk_to_b64(sk_ed_b.public_key()) - - -def test_different_passwords_different_keys(): - sk_ed1, _ = derive_keys_from_password("alice", "password1") - sk_ed2, _ = derive_keys_from_password("alice", "password2") - assert pk_to_b64(sk_ed1.public_key()) != pk_to_b64(sk_ed2.public_key()) - - -def test_ed_and_x_keys_independent(): - sk_ed, sk_x = derive_keys_from_password("user", "pass12345") - from meshbay_common.crypto import sk_to_raw - assert sk_to_raw(sk_ed) != sk_to_raw(sk_x) - - -def test_bundle_encrypt_decrypt(): - sk_ed, sk_x = derive_keys_from_password("alice", "strongpass!") - bundle = encrypt_keypair_bundle(sk_ed, sk_x, "password123", "alice") - sk_ed2, sk_x2 = decrypt_keypair_bundle(bundle, "password123", "alice") - assert pk_to_b64(sk_ed.public_key()) == pk_to_b64(sk_ed2.public_key()) - assert pk_to_b64(sk_x.public_key()) == pk_to_b64(sk_x2.public_key()) - - -def test_bundle_wrong_password_rejected(): - sk_ed, sk_x = derive_keys_from_password("alice", "correctpass") - bundle = encrypt_keypair_bundle(sk_ed, sk_x, "correctpass", "alice") - with pytest.raises(Exception): - decrypt_keypair_bundle(bundle, "wrongpass", "alice") - - -def test_bundle_wrong_username_rejected(): - sk_ed, sk_x = derive_keys_from_password("alice", "pass12345") - bundle = encrypt_keypair_bundle(sk_ed, sk_x, "pass12345", "alice") - with pytest.raises(Exception): - decrypt_keypair_bundle(bundle, "pass12345", "bob") # wrong username salt diff --git a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html b/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html deleted file mode 100644 index 46003a7..0000000 --- a/packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html +++ /dev/null @@ -1,265 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <title>MeshBay — WebRTC Spike Test</title> - <style> - *, *::before, *::after { box-sizing: border-box; } - body { font-family: system-ui, sans-serif; margin: 0; background: #0f172a; color: #e2e8f0; } - .container { max-width: 800px; margin: 32px auto; padding: 0 16px; } - h1 { color: #38bdf8; font-size: 1.4em; } - h2 { color: #94a3b8; font-size: 1.1em; margin-top: 2em; } - .step { background: #1e293b; border: 1px solid #334155; border-radius: 8px; - padding: 16px; margin: 12px 0; } - .step.done { border-color: #22c55e; } - .step.fail { border-color: #ef4444; } - .step.active { border-color: #38bdf8; } - input { padding: 8px 12px; border: 1px solid #475569; border-radius: 6px; - background: #0f172a; color: #e2e8f0; font-size: 0.95em; margin: 4px; width: 240px; } - button { padding: 8px 20px; background: #0ea5e9; color: #fff; border: none; - border-radius: 6px; cursor: pointer; font-size: 0.95em; margin: 4px; } - button:hover { background: #0284c7; } - button:disabled { background: #475569; cursor: not-allowed; } - #log { background: #020617; border: 1px solid #1e293b; border-radius: 8px; - padding: 12px; font-family: monospace; font-size: 0.85em; line-height: 1.6; - max-height: 400px; overflow-y: auto; white-space: pre-wrap; } - .ok { color: #22c55e; } - .err { color: #ef4444; } - .info { color: #38bdf8; } - .warn { color: #f59e0b; } - .dim { color: #64748b; } - .badge { display: inline-block; background: #22c55e; color: #0f172a; padding: 2px 8px; - border-radius: 4px; font-size: 0.8em; font-weight: bold; margin-left: 8px; } - .badge.fail { background: #ef4444; color: #fff; } - </style> -</head> -<body> -<div class="container"> - <h1>MeshBay — WebRTC DataChannel Spike Test</h1> - <p class="dim">Phase 9.5 — E2E browser → NAT → node file transfer via WebRTC</p> - - <div class="step" id="step-login"> - <h2>1. Login to Hub</h2> - <input id="username" placeholder="Username" value="bob"> - <input id="password" placeholder="Password" type="password" value="bob"> - <button id="btn-login" onclick="doLogin()">Login</button> - <span id="login-status"></span> - </div> - - <div class="step" id="step-connect"> - <h2>2. Connect to Node via WebRTC</h2> - <input id="node-id" placeholder="Node ID"> - <input id="group-id" placeholder="Group ID (optional)"> - <button id="btn-connect" onclick="doConnect()" disabled>Connect</button> - <span id="connect-status"></span> - </div> - - <div class="step" id="step-transfer"> - <h2>3. File Transfer Test</h2> - <button id="btn-index" onclick="doFetchIndex()" disabled>Fetch Index</button> - <br> - <input id="file-id" placeholder="File ID (blake3 hex, from node log)"> - <button id="btn-chunk" onclick="doFetchChunk()" disabled>Fetch Chunk</button> - <span id="transfer-status"></span> - </div> - - <h2>Log</h2> - <div id="log"></div> -</div> - -<script src="/transport.js?v=2"></script> -<script> -const HUB_URL = window.location.origin; -const params = new URLSearchParams(window.location.search); -let accessToken = null; -let jwtToken = null; -let transport = null; -let fileIndex = null; -let connecting = false; - -// Pre-fill from URL params -if (params.get('user')) document.getElementById('username').value = params.get('user'); -if (params.get('pass')) document.getElementById('password').value = params.get('pass'); -if (params.get('node')) document.getElementById('node-id').value = params.get('node'); -if (params.get('group')) document.getElementById('group-id').value = params.get('group'); -if (params.get('file')) document.getElementById('file-id').value = params.get('file').replace(/\s+/g, ''); - -// Auto-run if all params provided -if (params.get('auto')) { - setTimeout(async () => { - await doLogin(); - if (accessToken) await doConnect(); - if (transport && transport.connected) { - await doFetchIndex(); - if (document.getElementById('file-id').value) await doFetchChunk(); - } - }, 500); -} - -function logMsg(cls, text) { - const el = document.getElementById('log'); - const line = document.createElement('span'); - line.className = cls; - line.textContent = text + '\n'; - el.appendChild(line); - el.scrollTop = el.scrollHeight; -} - -function setStep(id, state) { - const el = document.getElementById(id); - el.className = 'step ' + state; -} - -async function doLogin() { - const user = document.getElementById('username').value; - const pass = document.getElementById('password').value; - logMsg('info', `Logging in as ${user}...`); - setStep('step-login', 'active'); - - try { - const resp = await fetch(`${HUB_URL}/v1/users/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username: user, password: pass }), - }); - - if (!resp.ok) { - const err = await resp.json(); - throw new Error(err.detail || resp.statusText); - } - - const data = await resp.json(); - accessToken = data.access_token; - jwtToken = data.access_token; - logMsg('ok', `Login OK — token: ${accessToken.substring(0, 20)}...`); - setStep('step-login', 'done'); - document.getElementById('login-status').innerHTML = '<span class="badge">OK</span>'; - document.getElementById('btn-connect').disabled = false; - } catch (e) { - logMsg('err', `Login FAILED: ${e.message}`); - setStep('step-login', 'fail'); - document.getElementById('login-status').innerHTML = '<span class="badge fail">FAIL</span>'; - } -} - -async function doConnect() { - if (connecting) { logMsg('warn', 'Connect already in progress'); return; } - const nodeId = document.getElementById('node-id').value; - const groupId = document.getElementById('group-id').value; - if (!nodeId) { logMsg('warn', 'Enter a node ID'); return; } - - connecting = true; - if (transport) { transport.close(); transport = null; } - - logMsg('info', `Connecting to node ${nodeId.substring(0, 8)}... via WebRTC`); - setStep('step-connect', 'active'); - - try { - transport = new MeshBayTransport(HUB_URL, accessToken); - - logMsg('dim', ' Creating RTCPeerConnection...'); - logMsg('dim', ' Creating DataChannel "mnp"...'); - logMsg('dim', ' Gathering ICE candidates...'); - logMsg('dim', ' Sending SDP offer to hub...'); - - const t0 = performance.now(); - const ack = await transport.connect(nodeId, jwtToken, groupId); - const elapsed = (performance.now() - t0).toFixed(0); - - logMsg('ok', `WebRTC connected in ${elapsed}ms`); - logMsg('ok', ` MNP handshake_ack — node_pk: ${ack.node_pk?.substring(0, 16)}...`); - logMsg('ok', ` DataChannel state: ${transport._channel?.readyState}`); - setStep('step-connect', 'done'); - document.getElementById('connect-status').innerHTML = '<span class="badge">P2P OK</span>'; - document.getElementById('btn-index').disabled = false; - document.getElementById('btn-chunk').disabled = false; - } catch (e) { - logMsg('err', `Connection FAILED: ${e.message}`); - setStep('step-connect', 'fail'); - document.getElementById('connect-status').innerHTML = '<span class="badge fail">FAIL</span>'; - } finally { - connecting = false; - } -} - -async function doFetchIndex() { - logMsg('info', `Fetching Mesh Group Index... (channel: ${transport?._channel?.readyState})`); - try { - const t0 = performance.now(); - const indexBytes = await transport.fetchIndex(); - const elapsed = (performance.now() - t0).toFixed(0); - - logMsg('ok', `Index received: ${indexBytes.byteLength} bytes in ${elapsed}ms`); - - try { - const envelope = msgpack_decode(indexBytes); - logMsg('dim', ` type: ${envelope.type}, encrypted: ${envelope.encrypted}, version: ${envelope.version}`); - logMsg('dim', ` group_id: ${envelope.group_id}`); - - if (envelope.encrypted) { - logMsg('warn', ` Index is GEK-encrypted — browser decryption not implemented in spike`); - logMsg('dim', ` ct_b64 length: ${envelope.ct_b64?.length || 0} chars`); - logMsg('info', ` Spike workaround: enter a file_id manually or use Fetch First Chunk`); - // Store envelope so chunk test can proceed with manual file_id - fileIndex = { entries: [], envelope }; - } else { - // Public group: decompress and parse - logMsg('dim', ` Public index — data_b64 length: ${envelope.data_b64?.length || 0}`); - fileIndex = { entries: [], envelope }; - } - } catch (pe) { - logMsg('warn', ` Could not parse index envelope: ${pe.message}`); - } - } catch (e) { - logMsg('err', `Index fetch FAILED: ${e.message}`); - } -} - -async function doFetchChunk() { - let fileId = document.getElementById('file-id').value.replace(/\s+/g, ''); - - if (!fileId) { - logMsg('warn', 'Enter a file_id (blake3 hex hash from node indexer log)'); - logMsg('dim', ' Look for "Initial scan complete" in the node terminal'); - logMsg('dim', ' Or run: python -c "import blake3; print(blake3.blake3(open(\'QE/demo-v3/shared_media/sample.txt\',\'rb\').read()).hexdigest())"'); - return; - } - - logMsg('info', `Fetching chunk 0 of ${fileId.substring(0, 16)}... (channel: ${transport?._channel?.readyState})`); - - try { - const t0 = performance.now(); - const chunkMsg = await transport.fetchChunk(fileId, 0); - const elapsed = (performance.now() - t0).toFixed(0); - - if (chunkMsg.type === 'error') { - logMsg('err', `Chunk fetch error: ${chunkMsg.detail}`); - return; - } - - logMsg('ok', `Chunk received in ${elapsed}ms:`); - logMsg('ok', ` type: ${chunkMsg.type}`); - logMsg('ok', ` chunk_index: ${chunkMsg.chunk_index}`); - logMsg('ok', ` plaintext_size: ${chunkMsg.plaintext_size} bytes`); - logMsg('ok', ` ct_b64 length: ${chunkMsg.ct_b64?.length || 0} chars`); - logMsg('ok', ` nonce_b64: ${chunkMsg.nonce_b64?.substring(0, 16)}...`); - logMsg('ok', ` sig_b64: ${chunkMsg.sig_b64?.substring(0, 16)}...`); - - logMsg('', ''); - logMsg('ok', '=== SPIKE TEST PASSED ==='); - logMsg('ok', 'Browser connected to node via WebRTC DataChannel.'); - logMsg('ok', 'MNP handshake, index sync, and file chunk transfer all work.'); - logMsg('ok', 'Data flowed P2P — hub was only used for signaling.'); - - setStep('step-transfer', 'done'); - document.getElementById('transfer-status').innerHTML = '<span class="badge">E2E OK</span>'; - } catch (e) { - logMsg('err', `Chunk fetch FAILED: ${e.message}`); - setStep('step-transfer', 'fail'); - document.getElementById('transfer-status').innerHTML = '<span class="badge fail">FAIL</span>'; - } -} -</script> -</body> -</html> |