diff options
6 files changed, 190 insertions, 11 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py index 510813a..50e8663 100644 --- a/packages/meshbay-common/src/meshbay_common/protocol.py +++ b/packages/meshbay-common/src/meshbay_common/protocol.py @@ -54,6 +54,7 @@ class MNP: 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 diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index 0aa74b4..94bf09e 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -113,6 +113,57 @@ function _saveSessionKeys() { 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); +} + +const PASSWORD_MIN_BITS = 60; // refuse below this +const PASSWORD_MIN_LEN = 12; + +// Whether this account backs its encrypted keys up to the nodes it joins. +// +// On: any browser recovers the same identity with the password — the ordinary +// multi-device expectation. Off: the keys exist only where they were generated, +// nothing is left on anyone's disk, and losing this browser's storage loses the +// account's content for good. That is a real choice, so it is the user's. +const KEY_BACKUP_PREFIX = 'mb_key_backup_'; + +function keyBackupEnabled(username) { + try { + return localStorage.getItem(KEY_BACKUP_PREFIX + username) !== '0'; + } catch { return true; } +} + +function setKeyBackupEnabled(username, on) { + try { + localStorage.setItem(KEY_BACKUP_PREFIX + username, on ? '1' : '0'); + } catch {} +} + function _restoreSessionKeys() { try { if (!_sessionKeys) { @@ -432,7 +483,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 { @@ -480,6 +538,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 /> @@ -909,14 +979,26 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth }) { } } - // Push keypair bundle to node (new registration, localStorage → node) - if (_pendingBundlePush && transport.connected) { - try { - await transport.storeKeypairBundle(_pendingBundlePush); - try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} - _pendingBundlePush = null; - } catch (e) { - console.warn('[MeshBay] Bundle push to node deferred:', e.message); + // Back the encrypted keys up to the node, or withdraw them — whichever + // this account asked for. The local copy is only dropped once the node + // holds one, so turning backup off never strands anybody. + if (transport.connected) { + if (keyBackupEnabled(username)) { + if (_pendingBundlePush) { + try { + await transport.storeKeypairBundle(_pendingBundlePush); + try { localStorage.removeItem(`meshbay_kp_${username}`); } catch {} + _pendingBundlePush = null; + } catch (e) { + console.warn('[MeshBay] Bundle push to node deferred:', e.message); + } + } + } else { + try { + await transport.deleteKeypairBundle(); + } catch (e) { + console.warn('[MeshBay] Could not withdraw key backup:', e.message); + } } } @@ -2124,6 +2206,15 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { const [nodeKeyLoading, setNodeKeyLoading] = useState(false); const [pinCount, setPinCount] = useState( () => (window.MeshBayTransport?.pinnedNodeCount?.() ?? 0)); + const [backup, setBackup] = useState(() => keyBackupEnabled(user.username)); + + // Takes effect on the next connection to each node: enabling uploads the + // encrypted bundle, disabling withdraws whatever that node already holds. + const toggleBackup = useCallback(() => { + const next = !backup; + setKeyBackupEnabled(user.username, next); + setBackup(next); + }, [backup, user.username]); // 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). @@ -2222,6 +2313,22 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { </div> <div class="settings-section"> + <h3 class="settings-heading">${t('settings.key_backup')}</h3> + <p class="settings-hint">${t('settings.key_backup_hint')}</p> + <div class="settings-row"> + <span class="settings-label"> + ${backup ? t('settings.key_backup_on') : t('settings.key_backup_off')} + </span> + <button class="btn-secondary" onClick=${toggleBackup}> + ${backup ? t('settings.key_backup_disable') : t('settings.key_backup_enable')} + </button> + </div> + ${!backup && html` + <p class="error-msg" style="margin-top:8px">${t('settings.key_backup_warning')}</p> + `} + </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"> diff --git a/packages/meshbay-hub/src/meshbay_hub/static/i18n.js b/packages/meshbay-hub/src/meshbay_hub/static/i18n.js index c279fd1..702da0b 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,19 @@ const en = { 'settings.coming_soon': 'Coming soon.', 'settings.profile': 'Profile', 'settings.username': 'Username', + 'settings.key_backup': 'Use this account on other devices', + 'settings.key_backup_hint': 'Your keys can be kept — encrypted with your ' + + 'passphrase — on the nodes whose groups you join, so another browser can ' + + 'recover them. Turn this off and your keys stay only where they were ' + + 'created: nothing of yours sits on anyone else\'s disk, and only that ' + + 'browser can open your groups.', + 'settings.key_backup_on': 'Enabled — other browsers can recover your keys', + 'settings.key_backup_off': 'Disabled — keys stay in this browser only', + 'settings.key_backup_enable': 'Enable', + 'settings.key_backup_disable': 'Disable', + 'settings.key_backup_warning': 'With this off, clearing this browser\'s data ' + + 'loses access to everything encrypted for you. There is no recovery — that ' + + 'is the point. Copies already on nodes are withdrawn at the next connection.', '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', diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js index 5ead80e..b1a03ff 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js @@ -574,6 +574,21 @@ class MeshBayTransport { 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; + } + async storeKeypairBundle(bundleEnc) { const msg = await this._sendAndWait({ type: 'keypair_bundle_store', 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/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 46dda64..f659f56 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -307,6 +307,8 @@ class WebRTCPeerSession: 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: @@ -823,6 +825,28 @@ class WebRTCPeerSession: 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") |