summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-01 13:25:58 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-01 13:25:58 +0200
commitb1878ab982ab72571915e7fbe2c1b558ea31f838 (patch)
tree2d853b4b28bf3b76d29f860be63cfef6750cf2c3
parent10552e576528e97884b8b7587526e70843a13bb3 (diff)
downloadmeshbay-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.md2
-rw-r--r--packages/meshbay-client/scripts/sync-ui.js2
-rw-r--r--packages/meshbay-common/src/meshbay_common/keyderive.py130
-rw-r--r--packages/meshbay-common/tests/test_keyderive.py57
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/webrtc-test.html265
5 files changed, 2 insertions, 454 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 5a5d9c4..1ea823f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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>