summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js125
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/i18n.js21
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js15
-rw-r--r--packages/meshbay-node/src/meshbay_node/bundle_store.py15
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py24
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")