aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 11:00:32 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 11:00:32 +0200
commit7c46b4e7dc2893974a37d6a701123a95803fcb95 (patch)
treea919618ce1c105e36c1fc85f7e492fbcb3265779
parent68bfe56a19aeb4c16f8e185fc85d8eee61aef78f (diff)
downloadmeshbay-7c46b4e7dc2893974a37d6a701123a95803fcb95.tar.gz
feat(client): hybrid sign-in — passphrase once, then this device's key
D4. The passphrase stays the account's credential and its only recovery path; what changes is that it is not asked for on every launch. **The renderer never holds the device key.** It is generated, stored and used entirely in the main process, which signs `meshbay:user_auth:<username>:<ts>` on request. Same rule as the save dialog, for the same reason: the renderer is the part of this application that parses decrypted content from nodes, which is attacker-controlled input. And this key is *not* a per-node identity key — those are generated per node and never leave that relationship, so nothing here correlates a person across operators. First run asks which hub, with no default. A client that picks its own hub is a client that can be pointed at one, and the address is the whole of what this application trusts a hub for — the interface comes from the package. Verified against a hub running this code, not against the deployed one: register 201 → passphrase login 200 → device register 201 → **device sign-in 200 with a real session** → `/v1/users/me` 200 → a stranger's key 401. The signature was also checked directly against the hub's own Python verifier before any of that. Inside the running application, over the debugging protocol: the bridge reaches the main process, the renderer calls the hub **through it** (200 — the CORS fix working end to end), and a call to a host that is not the configured hub is refused. **Not verified:** safeStorage persisting the key. This session has no secret service, and standing one up in xvfb did not succeed. The application behaves correctly there — it *refuses* rather than storing unprotected, and now says so in Settings, which is a real case rather than a hypothetical one since it is exactly what a headless or minimal desktop looks like. Worth remembering for next time: meshbay.org runs whatever was last deployed. It answered 405 on the Stage-C endpoints and reported MNP 0.2 while the tree had 0.3, so a local `uvicorn meshbay_hub.app:create_app --factory` on SQLite is what tests hub changes. Nothing was deployed to production for this. 799 tests pass; e2e.py passes end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md13
-rw-r--r--docs/desktop-client-v1.md2
-rw-r--r--packages/meshbay-client/src/main.js72
-rw-r--r--packages/meshbay-client/src/preload.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js139
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js6
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/platform.js38
16 files changed, 330 insertions, 4 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index 880756e..61d8e96 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -369,6 +369,19 @@ anything that assumes one key per person.
directives, not text — the same mistake as reading a CSP out of the comment
above the meta tag
+- **The device's hub key lives in the main process, never in the renderer.**
+ Generated, stored and used there; the interface asks for a signature over
+ `meshbay:user_auth:<username>:<ts>` and is never handed a key. Same rule as
+ the save dialog, for the same reason: the renderer parses decrypted content
+ from nodes, which is attacker-controlled input. It is **not** a per-node
+ identity key — nothing here correlates a person across operators
+
+- **A local hub is the way to test hub changes.** `uvicorn meshbay_hub.app:create_app
+ --factory` with a SQLite URL and a throwaway key runs the current code on
+ loopback in seconds. meshbay.org runs whatever was last deployed — it reported
+ MNP 0.2 and 405 on the Stage-C endpoints while the tree had 0.3 — so testing
+ against it proves what is deployed, not what is written
+
## Two lessons that cost four rounds of live testing
- **`QE/deploy/e2e.py` cannot test `app.js`.** It is a second implementation of the
diff --git a/docs/desktop-client-v1.md b/docs/desktop-client-v1.md
index c4b5380..fedfc8d 100644
--- a/docs/desktop-client-v1.md
+++ b/docs/desktop-client-v1.md
@@ -1058,7 +1058,7 @@ build existing.
| D1 | ✅ **DONE 2026-08-18** — `static/platform.js`; `HUB` is `platform.hubBase()` and the transport is built with the same base. Browser behaviour identical, which was the acceptance criterion | 1 |
| D2 | ✅ **RUNS** (2026-08-18, Electron 42 / Chromium 148 under xvfb). The packaged interface mounts over `app://`, secure context, `crypto.subtle` present, Argon2 WASM loaded, zero console errors. Three things were learned by running it — see §3.1 |
| D3 | ◐ **PARTIAL** — the bridge (`secrets.get/set/clear/backend`) and the honest report of what the OS is actually doing: `unprotected_fallback` when safeStorage finds no keyring, surfaced in Settings rather than swallowed. The native key *lifecycle* belongs with D4 and needs a running application to mean anything | 1 |
-| D4 | Hybrid registration and first sign-in (§5) | 1 |
+| D4 | ✅ **DONE 2026-08-18, verified against a hub running this code** — first-run hub prompt (no default, on purpose), passphrase sign-in registers this device, later launches sign in with the device key and no passphrase. **The renderer never holds that key**: it is generated, stored and used entirely in the main process, which signs on request — the same rule as the save dialog, because the renderer is the part that parses hostile input. Measured: register 201 → passphrase login 200 → device register 201 → **device sign-in 200 with a real session** → a stranger's key 401. **Not verified:** safeStorage actually persisting the key, which needs a desktop with a keyring — this session has none, and the application correctly *refuses* rather than storing unprotected | 1 |
| D5 | Node management panel over the Stage-B ops, root selection included | 2 |
| D6 | First-run wizard — detect, enable the unit, link, group, `gek-init`, pair (§7.4) | 2 |
| D7 | Native save dialog, streamed write from the main process | 2 |
diff --git a/packages/meshbay-client/src/main.js b/packages/meshbay-client/src/main.js
index 867ce27..82dfec9 100644
--- a/packages/meshbay-client/src/main.js
+++ b/packages/meshbay-client/src/main.js
@@ -22,6 +22,7 @@
const { app, BrowserWindow, dialog, ipcMain, protocol, safeStorage, shell } =
require('electron');
+const crypto = require('node:crypto');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const path = require('node:path');
@@ -200,6 +201,52 @@ function secretsBackend() {
return backend === 'basic_text' ? 'unprotected_fallback' : backend;
}
+// ── The device's hub key ────────────────────────────────────────────────────
+//
+// Ed25519, generated here on first sign-in, registered with the hub, and used
+// from then on instead of deriving a key from the passphrase every time. The
+// passphrase remains the account's credential and its only recovery path.
+//
+// **The renderer never holds it.** It parses decrypted content from nodes —
+// video, images, filenames — which is attacker-controlled input, so it asks for
+// a signature rather than being handed a key it could leak. This is the same
+// rule as the save dialog: the renderer asks, this process acts.
+//
+// Note what this key is *not*: it is not a per-node identity key. Those are
+// generated per node, pinned there, and never leave that relationship
+// (docs/per-node-identity-v1.md). Nothing here correlates a person across
+// operators, and nothing wraps a group key for it.
+
+const DEVICE_KEY = 'device_auth_ed25519';
+
+function deviceKey() {
+ const stored = readSecrets()[DEVICE_KEY];
+ if (!stored) return null;
+ return crypto.createPrivateKey({
+ key: Buffer.from(stored, 'base64'), format: 'der', type: 'pkcs8',
+ });
+}
+
+function ensureDeviceKey() {
+ const existing = deviceKey();
+ if (existing) return publicKeyB64(existing);
+ const { privateKey } = crypto.generateKeyPairSync('ed25519');
+ const all = readSecrets();
+ all[DEVICE_KEY] = privateKey.export({ format: 'der', type: 'pkcs8' })
+ .toString('base64');
+ writeSecrets(all);
+ return publicKeyB64(crypto.createPrivateKey({
+ key: Buffer.from(all[DEVICE_KEY], 'base64'), format: 'der', type: 'pkcs8' }));
+}
+
+function publicKeyB64(privateKey) {
+ // Raw 32 bytes, as the hub stores and as `pk_to_b64` produces: the DER
+ // SubjectPublicKeyInfo for Ed25519 is a fixed 12-byte prefix and the key.
+ const der = crypto.createPublicKey(privateKey)
+ .export({ format: 'der', type: 'spki' });
+ return der.subarray(der.length - 32).toString('base64');
+}
+
// ── Window ──────────────────────────────────────────────────────────────────
let mainWindow = null;
@@ -326,6 +373,31 @@ function registerBridge() {
};
});
+ ipcMain.handle('device:ensure', () => ensureDeviceKey());
+ ipcMain.handle('device:public', () => {
+ const key = deviceKey();
+ return key ? publicKeyB64(key) : null;
+ });
+ ipcMain.handle('device:sign', (_e, username) => {
+ const key = deviceKey();
+ if (!key) return null;
+ const timestamp = Math.floor(Date.now() / 1000);
+ // The same bytes `POST /v1/users/auth` verifies. The username is inside the
+ // signature, so one collected for a different account is not usable.
+ const message = Buffer.from(
+ `meshbay:user_auth:${String(username)}:${timestamp}`);
+ return {
+ timestamp,
+ signature: crypto.sign(null, message, key).toString('base64'),
+ };
+ });
+ ipcMain.handle('device:forget', () => {
+ const all = readSecrets();
+ delete all[DEVICE_KEY];
+ writeSecrets(all);
+ return true;
+ });
+
ipcMain.handle('secrets:backend', () => secretsBackend());
ipcMain.handle('secrets:get', (_e, name) => readSecrets()[String(name)] ?? null);
ipcMain.handle('secrets:set', (_e, name, value) => {
diff --git a/packages/meshbay-client/src/preload.js b/packages/meshbay-client/src/preload.js
index e3e240f..b649ae3 100644
--- a/packages/meshbay-client/src/preload.js
+++ b/packages/meshbay-client/src/preload.js
@@ -46,6 +46,16 @@ contextBridge.exposeInMainWorld('meshbay', {
// which CORS refuses and which is not a credential anyway.
fetch: (url, init) => ipcRenderer.invoke('hub:fetch', url, init),
+ // The device's hub key. Generated, held and used entirely in the main
+ // process: the interface asks for a signature and never sees a key, because
+ // it is the part of this application that parses hostile input.
+ device: {
+ ensure: () => ipcRenderer.invoke('device:ensure'),
+ publicKey: () => ipcRenderer.invoke('device:public'),
+ sign: (username) => ipcRenderer.invoke('device:sign', username),
+ forget: () => ipcRenderer.invoke('device:forget'),
+ },
+
secrets: {
get: (name) => ipcRenderer.invoke('secrets:get', name),
set: (name, value) => ipcRenderer.invoke('secrets:set', name, value),
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index 24ef43f..34f146a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -651,6 +651,52 @@ function Sidebar({ groups, presence, route, menuOpen, role }) {
// ── Login Page ───────────────────────────────────────────────────────────────
+/**
+ * Which hub, asked once on a desktop build.
+ *
+ * There is no default. A client that picks its own hub is a client that can be
+ * pointed at one, and the address is the whole of what the application trusts
+ * the hub for — its API, and nothing else: the interface comes from the package.
+ *
+ * Changing it restarts the window, because the address reaches the interface as
+ * a process argument. Reloading in place would leave it talking to the old hub
+ * with nothing on screen to say so.
+ */
+function FirstRunPage({ onSet }) {
+ const [url, setUrl] = useState('');
+ const [error, setError] = useState('');
+ const [busy, setBusy] = useState(false);
+
+ const submit = async (e) => {
+ e.preventDefault();
+ setError('');
+ setBusy(true);
+ try {
+ await window.meshbay.setHubBase(url.trim());
+ onSet();
+ } catch (err) {
+ setError(err.message || String(err));
+ setBusy(false);
+ }
+ };
+
+ return html`
+ <div class="auth-page">
+ <h2>${t('firstrun.title')}</h2>
+ <p class="settings-hint">${t('firstrun.hint')}</p>
+ <form onSubmit=${submit}>
+ <input type="url" placeholder="https://meshbay.org" required
+ value=${url} onInput=${e => setUrl(e.target.value)} />
+ <button type="submit" disabled=${busy}>
+ ${busy ? '…' : t('firstrun.btn')}
+ </button>
+ </form>
+ ${error && html`<p class="error-msg">${error}</p>`}
+ <p class="settings-hint">${t('firstrun.note')}</p>
+ </div>
+ `;
+}
+
function LoginPage() {
const auth = useAuth();
const [username, setUsername] = useState('');
@@ -4069,6 +4115,9 @@ function SettingsPage({ user, theme, onThemeChange, groups }) {
${keyBackend === 'unprotected_fallback' && html`
<p class="error-msg">${t('settings.keys_unprotected')}</p>
`}
+ ${keyBackend === 'unavailable' && html`
+ <p class="error-msg">${t('settings.keys_unavailable')}</p>
+ `}
</div>
`}
@@ -4485,6 +4534,13 @@ function App() {
const route = useRoute();
const [theme, setTheme] = useState(getInitialTheme);
const [user, setUser] = useState(loadAuth);
+ // A desktop build with a remembered device signs in without asking. Null
+ // until it has tried, so nothing renders a sign-in form the user is about to
+ // be taken past.
+ const [deviceTried, setDeviceTried] = useState(!platform.device.available);
+ // Native, and nowhere to talk to yet.
+ const [needsHub, setNeedsHub] = useState(
+ platform.isNative && !platform.hubBase());
const [groups, setGroups] = useState([]);
const [menuOpen, setMenuOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
@@ -4597,12 +4653,79 @@ function App() {
});
}, [user]);
+ // Sign in with this device's key, once, at startup.
+ //
+ // The passphrase stays the account's credential and its recovery path; this
+ // is what saves entering it every launch. A refusal is not an error worth
+ // showing — the key may have been retired from another device, or the hub may
+ // have forgotten it — so it falls through to the ordinary form.
+ useEffect(() => {
+ // Nothing to do when a session was restored from storage, or when this is
+ // a browser. `user` is read once here on purpose: this runs at startup and
+ // must not re-fire when the session it just created lands.
+ if (deviceTried || user) { setDeviceTried(true); return; }
+ let cancelled = false;
+ (async () => {
+ try {
+ // `loadAuth` keeps the username even when the tokens in it are stale,
+ // and `app://meshbay` is a stable origin, so localStorage survives a
+ // relaunch. A fresh install has nothing here and asks for a passphrase,
+ // which is right: the first sign-in is what registers the device.
+ const saved = loadAuth();
+ const username = saved && saved.username;
+ if (!username) return;
+ const signed = await platform.device.sign(username);
+ if (!signed) return;
+ const data = await hubFetch('/v1/users/auth', {
+ method: 'POST',
+ body: { username, timestamp: signed.timestamp,
+ signature: signed.signature },
+ });
+ const me = await hubFetch('/v1/users/me', { token: data.access_token });
+ if (cancelled) return;
+ const u = { username, userId: me.user_id, token: data.access_token,
+ refreshToken: data.refresh_token, role: me.role };
+ setAuth(u);
+ setUser(u);
+ } catch {
+ // Falls through to the sign-in form, which is the honest outcome.
+ } finally {
+ if (!cancelled) setDeviceTried(true);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, []);
+
useEffect(() => { setMenuOpen(false); }, [route]);
const changeTheme = useCallback((val) => {
setTheme(val);
}, []);
+ /**
+ * Register this device's hub key, once, after a passphrase sign-in.
+ *
+ * Deliberately not fatal: a hub that refuses it, or a machine with no key
+ * storage, means the passphrase is asked for again next time — which is
+ * exactly what a browser does, and is a worse experience rather than a
+ * broken one.
+ */
+ const registerThisDevice = useCallback(async (token) => {
+ if (!platform.device.available) return;
+ try {
+ const backend = await platform.secrets.backend();
+ if (backend === 'unavailable') return;
+ const pk = await platform.device.ensure();
+ if (!pk) return;
+ await hubFetch('/v1/users/devices', {
+ method: 'POST', token,
+ body: { pk_auth_ed25519: pk, label: t('device.this_device') },
+ });
+ } catch (err) {
+ console.warn('device not registered:', err.message);
+ }
+ }, []);
+
const authCtx = {
user,
login: async (username, password) => {
@@ -4625,6 +4748,10 @@ function App() {
}
const me = await hubFetch('/v1/users/me', { token });
const u = { username, userId: me.user_id, token, refreshToken, role: me.role };
+ // On a desktop build, remember this device so the next launch does not ask
+ // for the passphrase again. The key is generated and held by the main
+ // process; what travels here is only its public half.
+ await registerThisDevice(token);
// setAuth, not saveAuth: it is the one writer that also updates the copy
// hubFetch renews from. Storing the session without it left the renewal
// path with no refresh token to present.
@@ -4654,7 +4781,17 @@ function App() {
const refreshAuth = useCallback(() => refreshAccessToken(), []);
let page;
- if (route === '/login' || route === '/register') {
+ // A desktop build with no hub configured cannot do anything at all, so it
+ // asks before showing a sign-in form that could not work. Deliberately not
+ // defaulted to meshbay.org: a client that picks its own hub is a client that
+ // can be pointed at one.
+ if (needsHub) {
+ page = html`<${FirstRunPage} onSet=${() => setNeedsHub(false)} />`;
+ } else if (!deviceTried) {
+ // Signing in with this device's key. Showing a form here would be showing
+ // one the user is about to be taken past.
+ page = html`<p class="page-message">${t('status.connecting')}</p>`;
+ } else if (route === '/login' || route === '/register') {
page = route === '/register'
? html`<${RegisterPage} />`
: html`<${LoginPage} />`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index ae356ba..8b652b5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -198,9 +198,15 @@ export default {
'settings.theme_system': 'System',
'settings.language': 'Sprache',
'settings.about': 'Über',
+ 'firstrun.title': 'Which hub?',
+ 'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
+ 'firstrun.btn': 'Continue',
+ 'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
+ 'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
+ 'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
'settings.version': 'Version',
'settings.protocol': 'Protokoll',
'settings.role': 'Rolle',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index fe6bd0f..9018384 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -190,9 +190,15 @@ export default {
'settings.theme_system': 'System',
'settings.language': 'Language',
'settings.about': 'About',
+ 'firstrun.title': 'Which hub?',
+ 'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
+ 'firstrun.btn': 'Continue',
+ 'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
+ 'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
+ 'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
'settings.version': 'Version',
'settings.protocol': 'Protocol',
'settings.role': 'Role',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 3066963..32e5f8a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -194,9 +194,15 @@ export default {
'settings.theme_system': 'Sistema',
'settings.language': 'Idioma',
'settings.about': 'Acerca de',
+ 'firstrun.title': 'Which hub?',
+ 'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
+ 'firstrun.btn': 'Continue',
+ 'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
+ 'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
+ 'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
'settings.version': 'Versión',
'settings.protocol': 'Protocolo',
'settings.role': 'Rol',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index c94089d..6517a7c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -198,9 +198,15 @@ export default {
'settings.theme_system': 'Système',
'settings.language': 'Langue',
'settings.about': 'À propos',
+ 'firstrun.title': 'Quel hub ?',
+ 'firstrun.hint': 'Un hub détient votre compte et vous met en relation avec les nœuds. Il ne voit ni vos fichiers, ni vos messages, ni vos clés.',
+ 'firstrun.btn': 'Continuer',
+ 'firstrun.note': 'Il n’y a volontairement pas de valeur par défaut : cette application ne fait confiance à un hub que pour son API, jamais pour l’interface, qui est livrée avec l’application.',
+ 'device.this_device': 'Cet appareil',
'settings.keys_heading': 'Clés sur cet appareil',
'settings.keys_where': 'Protégées par',
'settings.keys_unprotected': 'Aucun trousseau système ne fonctionne : vos clés sont chiffrées avec une clé qui n’est pas secrète. Quiconque peut lire les fichiers de cette machine peut les lire. Démarrez un trousseau, ou considérez cet appareil comme non fiable.',
+ 'settings.keys_unavailable': 'Ce système n’offre aucun stockage de clés, donc cet appareil ne peut pas être mémorisé — votre phrase secrète vous sera redemandée à chaque fois. C’est le comportement sûr : rien n’a été stocké sans protection.',
'settings.version': 'Version',
'settings.protocol': 'Protocole',
'settings.role': 'Rôle',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 56beb8d..470801d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -197,9 +197,15 @@ export default {
'settings.theme_system': 'Sistema',
'settings.language': 'Lingua',
'settings.about': 'Informazioni',
+ 'firstrun.title': 'Which hub?',
+ 'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
+ 'firstrun.btn': 'Continue',
+ 'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
+ 'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
+ 'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
'settings.version': 'Versione',
'settings.protocol': 'Protocollo',
'settings.role': 'Ruolo',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 5d83bb9..33bdc46 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -192,9 +192,15 @@ export default {
'settings.theme_system': 'システムに合わせる',
'settings.language': '言語',
'settings.about': 'このアプリについて',
+ 'firstrun.title': 'Which hub?',
+ 'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
+ 'firstrun.btn': 'Continue',
+ 'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
+ 'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
+ 'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
'settings.version': 'バージョン',
'settings.protocol': 'プロトコル',
'settings.role': '権限',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 55c7d81..56a2c9f 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -198,9 +198,15 @@ export default {
'settings.theme_system': 'Systeem',
'settings.language': 'Taal',
'settings.about': 'Over',
+ 'firstrun.title': 'Which hub?',
+ 'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
+ 'firstrun.btn': 'Continue',
+ 'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
+ 'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
+ 'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
'settings.version': 'Versie',
'settings.protocol': 'Protocol',
'settings.role': 'Rol',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index 3cf909f..31e5c13 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -204,9 +204,15 @@ export default {
'settings.theme_system': 'Systemowy',
'settings.language': 'Język',
'settings.about': 'O programie',
+ 'firstrun.title': 'Which hub?',
+ 'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
+ 'firstrun.btn': 'Continue',
+ 'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
+ 'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
+ 'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
'settings.version': 'Wersja',
'settings.protocol': 'Protokół',
'settings.role': 'Rola',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 714dff6..d6563ab 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -196,9 +196,15 @@ export default {
'settings.theme_system': 'Sistema',
'settings.language': 'Idioma',
'settings.about': 'Sobre',
+ 'firstrun.title': 'Which hub?',
+ 'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
+ 'firstrun.btn': 'Continue',
+ 'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
+ 'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
+ 'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
'settings.version': 'Versão',
'settings.protocol': 'Protocolo',
'settings.role': 'Função',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index c3d9494..a7bbb0e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -183,9 +183,15 @@ export default {
'settings.theme_system': '跟随系统',
'settings.language': '语言',
'settings.about': '关于',
+ 'firstrun.title': 'Which hub?',
+ 'firstrun.hint': 'A hub holds your account and introduces you to nodes. It never sees your files, your messages or your keys.',
+ 'firstrun.btn': 'Continue',
+ 'firstrun.note': 'There is no default on purpose: this application only trusts a hub for its API, never for the interface, which ships with the application itself.',
+ 'device.this_device': 'This device',
'settings.keys_heading': 'Keys on this device',
'settings.keys_where': 'Protected by',
'settings.keys_unprotected': 'No system keyring is running, so your keys are encrypted with a key that is not a secret. Anyone who can read this machine’s files can read them. Start a keyring, or treat this device as untrusted.',
+ 'settings.keys_unavailable': 'This system offers no key storage at all, so this device cannot be remembered — you will be asked for your passphrase each time. That is the safe outcome: nothing was stored unprotected.',
'settings.version': '版本',
'settings.protocol': '协议',
'settings.role': '角色',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/platform.js b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
index fcc866e..ae04e53 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/platform.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js
@@ -88,6 +88,39 @@ export const secrets = {
};
/**
+ * This device's key for signing in to the hub.
+ *
+ * Ed25519, generated and held by the main process — the interface asks for a
+ * signature and never sees a key. The passphrase is still the account's
+ * credential and its only recovery path; this is what saves deriving a key from
+ * it on every launch.
+ *
+ * Not a per-node identity key. Those are generated per node, pinned there, and
+ * never leave that relationship: nothing here correlates a person across
+ * operators, and nothing wraps a group key for it.
+ *
+ * Absent in a browser, where a passphrase is entered every time and the
+ * keypair bundle on each node is what a second browser recovers — which is
+ * finding C4, and the reason the application exists.
+ */
+export const device = {
+ available: Boolean(bridge && bridge.device),
+ async ensure() {
+ return bridge && bridge.device ? bridge.device.ensure() : null;
+ },
+ async publicKey() {
+ return bridge && bridge.device ? bridge.device.publicKey() : null;
+ },
+ /** `{timestamp, signature}` over `meshbay:user_auth:<username>:<ts>`. */
+ async sign(username) {
+ return bridge && bridge.device ? bridge.device.sign(username) : null;
+ },
+ async forget() {
+ return bridge && bridge.device ? bridge.device.forget() : false;
+ },
+};
+
+/**
* Call the hub.
*
* In a browser this is `fetch`, unchanged — the page came from the hub, so the
@@ -134,7 +167,8 @@ export async function nativeSave(suggestedName, size) {
return bridge.saveFile(suggestedName, size);
}
-export default { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch };
+export default { isNative, hubBase, capabilities, secrets, nativeSave,
+ apiFetch, device };
// Also a global, because `transport.js` is loaded as a classic script — it
// predates the module graph and exposes `MeshBayTransport` the same way. The
@@ -142,5 +176,5 @@ export default { isNative, hubBase, capabilities, secrets, nativeSave, apiFetch
// hub end up disagreeing about how to reach it.
if (typeof window !== 'undefined') {
window.MeshBayPlatform = { isNative, hubBase, capabilities, secrets,
- nativeSave, apiFetch };
+ nativeSave, apiFetch, device };
}