aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 12:06:31 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 12:06:31 +0200
commit2caa93dbc06161b5d3f776a204ac8d921df92126 (patch)
tree460ef9fe22a685ec83b76c8e95b104df05858ab7 /packages/meshbay-hub/src
parent6309894d019421f54cff311e630f3897f7eba93e (diff)
downloadmeshbay-2caa93dbc06161b5d3f776a204ac8d921df92126.tar.gz
feat(client): make the key backup a choice, and raise the passphrase floor
Two things the multi-browser story made obvious. **The backup is now opt-out.** Keys are kept, encrypted with the passphrase, on every node whose group you join — that is what lets a second browser recover them, and it is finding C4: a PBKDF2-protected blob on other people's disks, attackable offline at the speed of PBKDF2, which is memory-light and therefore cheap on a GPU. Until now everybody paid that cost, including people who will only ever use one browser and get nothing back for it. Settings → "Use this account on other devices". Turning it off does not merely stop future uploads: the next connection to each node withdraws what that node already holds (new keypair_bundle_delete, which only ever deletes the caller's own, taken from the authenticated session and never from the message). The warning says plainly what it costs — clearing the browser then loses everything encrypted for that account, with no recovery, which is the point of choosing it. Default is on. Silent, unrecoverable key loss is worse for an ordinary user than an exposure the roadmap already tracks, but that is a judgement call and it is now visible and reversible instead of implicit. **Passphrase floor 8 → 12 characters, plus a strength estimate** shown while typing, with a refusal below ~60 bits. This number matters more here than in most applications: it is what stands between a node operator and your identity keys. It has to live in the client — with the password split (T1) the hub never sees a password and cannot enforce anything about one — so the UI says why it is asking, rather than nagging. The estimator is deliberately conservative and dependency-free: character classes and length, penalised for repetition and for the handful of patterns everyone tries. Verified against the live deployment: withdrawing the backup leaves a second browser unable to recover anything, which is exactly what it promises, and re-enabling restores it. Tests: 338. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
-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
3 files changed, 150 insertions, 11 deletions
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',