diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-18 09:42:34 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-18 09:42:34 +0200 |
| commit | 30e855f55f1d920b25da0bdd8e538c249d3c0c26 (patch) | |
| tree | 587dcad0beb8a120352613be8aa751a97040015c /packages/meshbay-hub | |
| parent | 768e07046368819b8a8f15c8b21e5a8bbfcdf282 (diff) | |
| download | meshbay-30e855f55f1d920b25da0bdd8e538c249d3c0c26.tar.gz | |
feat(client): the platform seam, and an Electron shell that has never been run
Stage D, and the honest half of it.
D1 — the seam (done, and verified)
----------------------------------
`static/platform.js`. `HUB` becomes `platform.hubBase()` and the transport is
built with the same base, so one address has one source. In a browser it returns
'' and every path stays relative to the origin that served the page — the
acceptance criterion for this split was "the browser SPA behaves identically",
and it does. `platform.js` joins `_ASSETS`, or a change to it would not move the
content hash and a cached browser would never ask for it.
D2 — the shell (written, never launched)
-----------------------------------------
**There is no npm on this machine. Electron was never installed and
`packages/meshbay-client/` has not been run once.** That is stated here rather
than discovered later.
What is there: a main process serving the packaged interface over a privileged
`app://` scheme (`secure` and `standard` are not cosmetic — without them the
service worker refuses to register and streamed downloads break silently), a
preload exposing an enumerated bridge that never passes a filesystem path, a
window with `sandbox`, `contextIsolation` and no node integration, navigation
away from the package refused, and a CSP where the hub is reachable over
connect-src and is not a script source. The hub address arrives as a process
argument because `platform.hubBase()` runs before anything can await.
`test_desktop_shell.py` pins each of those by reading the source — the treatment
`test_downloads.py` already gives the three browser save paths. It catches a
property being removed and proves nothing about the application running. Two
were checked by breaking them.
The interface is *copied* into the package by `build/sync-ui.js` from the hub's
static directory, and `ui/` is gitignored: a silent fork is the only real way to
end up maintaining the interface twice.
D3 — partial
------------
The bridge, and the part worth having now: safeStorage's backend is reported
rather than assumed. On Linux it falls back to a fixed key when no keyring is
running, silently — someone who believes the OS is holding their keys is told
when it is not. The native key lifecycle belongs with D4 and needs a running
application to mean anything.
D8 — partial, and a real defect found
--------------------------------------
`meshbay-node.spec` installed the SYSTEM template — the one carrying `User=%i` —
into `%{_userunitdir}`. A user unit already runs as its owner and cannot carry
`User=`; systemd refuses the file, so the packaged unit could never have
started. Nothing noticed because nobody had built and installed the RPM.
Two units now: the template to `%{_unitdir}`, and a new `meshbay-node-user.service`
that a person enables themselves without a password — which is what lets the
desktop client install a node without asking for one. It carries ExecReload, so
`meshbay-node reload` does not have to stop a service somebody is streaming from,
and documents the drop-in for a drive outside the home, RequiresMountsFor
included.
798 tests pass; e2e.py still passes end to end. Nothing here was built or
launched: no npm, no rpmbuild.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
15 files changed, 397 insertions, 5 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py index 392e8d1..7028071 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py @@ -23,8 +23,12 @@ router = APIRouter(tags=["webapp"]) # Assets the shell pulls in, in load order. Everything else is imported by # app.js and rides on the same query string via window.__MB_ASSET_V. +# Every module the page loads. A file missing from here is a file whose change +# does not move the URL, so a browser holding the old one never asks for it — +# which is the failure this list exists to prevent, and it is silent. _ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js", - "i18n.js", "downloads.js", "transfers.js", "zipstream.js") + "i18n.js", "downloads.js", "transfers.js", "zipstream.js", + "platform.js") def _asset_version() -> str: diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js index a48535a..abb3374 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/app.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js @@ -6,10 +6,15 @@ import { t, getLocale, setLocale, initLocale, LOCALES } from './i18n.js'; import { ZipStream, entriesUnder } from './zipstream.js'; import { transfers, formatSpeed } from './transfers.js'; import * as downloads from './downloads.js'; +import * as platform from './platform.js'; // ── Constants ──────────────────────────────────────────────────────────────── -const HUB = ''; +// Where the hub is. Empty in a browser — it served this page, so a relative +// path cannot be pointed at the wrong place. In the installed app the page +// comes from disk and has no origin of its own, so the base is configured. +// See platform.js. +const HUB = platform.hubBase(); const AUTH_KEY = 'mb_auth'; // Renew an access token with this much life left rather than waiting for it to // fail. Generous against a one-hour token: a film is watched without the hub @@ -1323,7 +1328,9 @@ function GroupPage({ groupId, group, token, username, userId, onRefreshAuth, // connection at all. Renewals are shared, so if one is already in // flight this waits for it instead of starting a second. const live = (await ensureFreshToken()) || token; - const transport = new window.MeshBayTransport('', live); + // The same base the API calls use: signaling is a hub endpoint like + // any other, and two sources for one address is how they drift. + const transport = new window.MeshBayTransport(HUB, live); transportRef.current = transport; const ack = await transport.connect( @@ -3941,6 +3948,15 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { // Read from the hub rather than written here: the two constants that used to // sit in this markup said 0.1.0 and MNP 0.1 long after both had moved on. const [hubInfo, setHubInfo] = useState(null); + // On a desktop build, whether the OS is really holding the keys. Electron's + // safeStorage falls back to a fixed key when no keyring is running — a + // headless session, a minimal desktop — and does it silently. Somebody who + // believes the OS is protecting their keys deserves to be told when it is not. + const [keyBackend, setKeyBackend] = useState(''); + useEffect(() => { + if (!platform.secrets.available) return; + platform.secrets.backend().then(setKeyBackend).catch(() => {}); + }, []); useEffect(() => { hubFetch('/v1/hub/version').then(setHubInfo).catch(() => {}); @@ -4043,6 +4059,19 @@ function SettingsPage({ user, theme, onThemeChange, groups }) { </div> `} + ${keyBackend && html` + <div class="settings-section"> + <h3 class="settings-heading">${t('settings.keys_heading')}</h3> + <div class="settings-row"> + <span class="settings-label">${t('settings.keys_where')}</span> + <span class="settings-value">${keyBackend}</span> + </div> + ${keyBackend === 'unprotected_fallback' && html` + <p class="error-msg">${t('settings.keys_unprotected')}</p> + `} + </div> + `} + <div class="settings-section"> <h3 class="settings-heading">${t('settings.about')}</h3> <div class="settings-row"> 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 5a946f7..ae356ba 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js @@ -198,6 +198,9 @@ export default { 'settings.theme_system': 'System', 'settings.language': 'Sprache', 'settings.about': 'Über', + '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.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 fd2d6ec..fe6bd0f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js @@ -190,6 +190,9 @@ export default { 'settings.theme_system': 'System', 'settings.language': 'Language', 'settings.about': 'About', + '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.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 e564164..3066963 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js @@ -194,6 +194,9 @@ export default { 'settings.theme_system': 'Sistema', 'settings.language': 'Idioma', 'settings.about': 'Acerca de', + '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.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 5848f9d..c94089d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js @@ -198,6 +198,9 @@ export default { 'settings.theme_system': 'Système', 'settings.language': 'Langue', 'settings.about': 'À propos', + '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.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 61913e3..56beb8d 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js @@ -197,6 +197,9 @@ export default { 'settings.theme_system': 'Sistema', 'settings.language': 'Lingua', 'settings.about': 'Informazioni', + '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.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 aa656ee..5d83bb9 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js @@ -192,6 +192,9 @@ export default { 'settings.theme_system': 'システムに合わせる', 'settings.language': '言語', 'settings.about': 'このアプリについて', + '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.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 dae903f..55c7d81 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js @@ -198,6 +198,9 @@ export default { 'settings.theme_system': 'Systeem', 'settings.language': 'Taal', 'settings.about': 'Over', + '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.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 65c81ca..3cf909f 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js @@ -204,6 +204,9 @@ export default { 'settings.theme_system': 'Systemowy', 'settings.language': 'Język', 'settings.about': 'O programie', + '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.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 729a7fc..714dff6 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,6 +196,9 @@ export default { 'settings.theme_system': 'Sistema', 'settings.language': 'Idioma', 'settings.about': 'Sobre', + '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.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 0ff5a49..c3d9494 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,6 +183,9 @@ export default { 'settings.theme_system': '跟随系统', 'settings.language': '语言', 'settings.about': '关于', + '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.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 new file mode 100644 index 0000000..0663a42 --- /dev/null +++ b/packages/meshbay-hub/src/meshbay_hub/static/platform.js @@ -0,0 +1,103 @@ +/** + * What differs between running in a browser and running as an installed app. + * + * The interface is the same code either way — that is the whole reason Electron + * was chosen over a shell that replaces the engine (docs/desktop-client-v1.md + * §2). What genuinely differs is small and lives here: + * + * · **where the hub is.** Served from the hub, it is the current origin. Ship + * the interface in a package and it becomes a configured URL, because the + * page is loaded from disk and has no hub origin of its own. + * · **where a downloaded file goes**, and whether a native dialog picks it. + * · **what the app can do at all** — managing a local node, choosing folders + * on this machine. Features gated on these render nowhere in a browser + * rather than failing when clicked. + * + * The browser implementation below is exactly today's behaviour, so nothing + * changes for anyone until an app is installed. That is the acceptance + * criterion for this split: **the browser SPA behaves identically.** + * + * The native side arrives through `window.meshbay`, which the Electron preload + * exposes over a context bridge. Absent, everything falls back to the browser + * path — so this file is safe to load anywhere and there is no build flag. + */ + +const bridge = (typeof window !== 'undefined' && window.meshbay) || null; + +export const isNative = Boolean(bridge); + +/** + * The hub's base URL, prefixed to every API path. + * + * Empty string in a browser: the hub served this page, so a relative path goes + * to the right place and no configuration can be wrong. In the app it is + * whatever the user signed in against, and it is deliberately *not* guessed — + * a client that picks its own hub is a client that can be pointed at one. + */ +export function hubBase() { + return bridge ? (bridge.hubBase() || '') : ''; +} + +/** Native-only capabilities. A browser renders none of what these gate. */ +export const capabilities = { + // Install, configure and drive a node running on this machine. + nodeAdmin: Boolean(bridge && bridge.capabilities && bridge.capabilities.nodeAdmin), + // Choose directories on this machine to share. + localFolders: Boolean(bridge && bridge.capabilities && bridge.capabilities.localFolders), + // A real save dialog and a write that does not pass through the page. + nativeSave: Boolean(bridge && bridge.capabilities && bridge.capabilities.nativeSave), +}; + +/** + * Where the identity keys live. + * + * In a browser: exactly where they live today — IndexedDB and sessionStorage, + * with the keypair bundle on the node as the way a second browser recovers + * them, which is finding C4 and is the reason the app exists. + * + * In the app: the OS keychain, and no bundle is stored anywhere. That is what + * closes C4 for a native device — unconditionally for that device, and for the + * account only once it stops signing in from a browser too. + */ +export const secrets = { + available: Boolean(bridge && bridge.secrets), + async get(name) { + if (!bridge || !bridge.secrets) return null; + return bridge.secrets.get(name); + }, + async set(name, value) { + if (!bridge || !bridge.secrets) return false; + return bridge.secrets.set(name, value); + }, + async clear(name) { + if (!bridge || !bridge.secrets) return false; + return bridge.secrets.clear(name); + }, + /** + * Whether the OS is really protecting them. + * + * Electron's safeStorage falls back to a fixed key when no keyring is + * running — a headless session, a minimal desktop — and silently. A user who + * believes their keys are protected by the OS deserves to be told when they + * are not, so this is surfaced rather than swallowed. + */ + async backend() { + if (!bridge || !bridge.secrets) return 'browser'; + return bridge.secrets.backend(); + }, +}; + +/** + * Save a decrypted file to disk. + * + * Returns null when there is no native path, so the caller keeps today's + * behaviour — File System Access, a service worker stream, or a blob, decided + * in `downloads.js`. Adding a native writer must not remove the three that + * already work. + */ +export async function nativeSave(suggestedName, size) { + if (!bridge || !bridge.saveFile) return null; + return bridge.saveFile(suggestedName, size); +} + +export default { isNative, hubBase, capabilities, secrets, nativeSave }; diff --git a/packages/meshbay-hub/tests/test_desktop_shell.py b/packages/meshbay-hub/tests/test_desktop_shell.py new file mode 100644 index 0000000..13d194e --- /dev/null +++ b/packages/meshbay-hub/tests/test_desktop_shell.py @@ -0,0 +1,218 @@ +""" +The desktop shell's security contract, pinned by reading its source. + +There is no npm on the development machine, so the Electron application cannot +be installed or launched here. That is stated plainly rather than worked around: +**nothing below proves the app runs.** What it does prove is that the properties +the design depends on are present in the source, and it fails if one is removed +— which is the same treatment `test_downloads.py` gives the three +browser-specific save paths, for the same reason. + +Every assertion here corresponds to a sentence in `docs/desktop-client-v1.md` +§3. Weak evidence, and the only evidence available without a packaged build; a +person with an installed client is what confirms the rest. +""" + +from pathlib import Path + +import pytest + +CLIENT = Path(__file__).resolve().parents[2] / "meshbay-client" +MAIN = CLIENT / "src" / "main.js" +PRELOAD = CLIENT / "src" / "preload.js" +INDEX = CLIENT / "build" / "index.html" + +pytestmark = pytest.mark.skipif( + not MAIN.exists(), reason="desktop client sources not present") + + +def _main() -> str: + return MAIN.read_text(encoding="utf-8") + + +def _preload() -> str: + return PRELOAD.read_text(encoding="utf-8") + + +# ── The renderer is confined ──────────────────────────────────────────────── + +@pytest.mark.parametrize("setting", [ + "sandbox: true", + "contextIsolation: true", + "nodeIntegration: false", +]) +def test_the_renderer_keeps_its_sandbox(setting): + """ + Electron with these keeps the Chromium renderer sandbox — the strongest + available, and the reason "native costs the browser sandbox" is false for + this shell. Without contextIsolation the preload's objects are reachable and + mutable from page script, which would make the bridge decorative. + """ + assert setting in _main(), f"{setting} is missing from the window" + + +def test_the_interface_is_never_loaded_from_the_hub(): + """ + The whole reason this application exists. A shell pointing a WebView at the + hub's /app/ is a browser with a different icon and fixes nothing (T3). + """ + source = _main() + assert "loadURL(`${SCHEME}://" in source or "loadURL('app://" in source + assert "loadURL('http" not in source and 'loadURL("http' not in source + assert "loadURL(`http" not in source + + +def test_navigation_away_from_the_package_is_refused(): + source = _main() + assert "will-navigate" in source + assert "setWindowOpenHandler" in source + assert "event.preventDefault()" in source + + +def test_no_permission_is_granted_to_the_page(): + """Nothing here needs a camera, a microphone or a location.""" + assert "setPermissionRequestHandler" in _main() + assert "callback(false)" in _main() + + +# ── The custom scheme ─────────────────────────────────────────────────────── + +@pytest.mark.parametrize("privilege", [ + "standard: true", + "secure: true", + "supportFetchAPI: true", + "stream: true", +]) +def test_the_scheme_is_privileged(privilege): + """ + Without `secure` the scheme is not a secure context, the service worker + silently refuses to register, and streamed downloads break with no error — + the same failure mode as an uncontrolled page, which this codebase has + already learned once. `standard` gives a real origin, so IndexedDB survives + an update instead of being keyed to something that moves. + """ + assert privilege in _main(), f"{privilege} missing from the scheme privileges" + + +def test_the_protocol_handler_cannot_be_walked_out_of(): + """ + The renderer parses decrypted content from nodes, which is + attacker-controlled input. A traversal here would hand it the filesystem. + """ + source = _main() + assert "path.resolve(UI_DIR" in source + assert "startsWith(root + path.sep)" in source + assert "status: 404" in source + + +# ── Content Security Policy ───────────────────────────────────────────────── + +def test_the_policy_keeps_wasm_unsafe_eval(): + """ + The bundle key is Argon2id in WebAssembly. A policy that forbids it does not + degrade anything — it locks every user out of their keys. + """ + assert "'wasm-unsafe-eval'" in _directive("script-src") + + +def _policy() -> str: + """The meta tag's content, not the file — the comment above it names the + same directives and would satisfy a naive search.""" + import re + page = INDEX.read_text(encoding="utf-8") + match = re.search( + r'http-equiv="Content-Security-Policy"\s+content="([^"]*)"', page) + assert match, "no Content-Security-Policy meta tag" + return match.group(1) + + +def _directive(name: str) -> str: + for part in _policy().split(";"): + part = part.strip() + if part.startswith(name + " "): + return part + return "" + + +def test_the_hub_is_reachable_but_never_executable(): + """ + connect-src allows the hub's API and its signaling socket. script-src does + not include it: nothing the hub returns is ever executed. + """ + connect = _directive("connect-src") + assert "https:" in connect and "wss:" in connect + + script = _directive("script-src") + assert script, "no script-src directive" + assert "https:" not in script, "the hub can serve script under this policy" + assert "'unsafe-eval'" not in script.replace("'wasm-unsafe-eval'", "") + assert "default-src 'none'" in _policy() + + +# ── The bridge ────────────────────────────────────────────────────────────── + +def test_the_bridge_is_the_only_way_in(): + source = _preload() + assert "contextBridge.exposeInMainWorld" in source + # Handing the raw ipcRenderer to the page would expose every channel in the + # main process, named or not. + assert "exposeInMainWorld('meshbay', ipcRenderer" not in source + assert "ipcRenderer)" not in source.replace("require('electron');", "") + + +def test_the_renderer_never_names_a_path(): + """ + It asks for a dialog and receives an opaque id; the main process holds the + handle. A channel that took a path from the renderer and wrote to it would + be the whole confinement undone. + """ + source = _preload() + assert "save:begin" in source + assert "handle.id" in source + assert "filePath" not in source, "the preload passes a filesystem path around" + + +def test_the_hub_address_is_not_fetched_synchronously_over_ipc(): + """ + `platform.hubBase()` runs while the module graph is loading, before anything + can await. Synchronous IPC would block the renderer on every call for a + value that cannot change within a run. + """ + source = _preload() + assert "--meshbay-hub=" in source + assert "sendSync" not in source + + +def test_plain_http_is_refused_except_to_loopback(): + """Anywhere else it would put the session token on the wire in clear.""" + source = _main() + assert "must be https" in source + # The guard is a regular expression, so the dot is escaped in the source. + assert "127\\." in source and "localhost" in source + + +# ── One interface, one source ─────────────────────────────────────────────── + +def test_the_interface_is_copied_not_forked(): + """ + §2.7: the hub's static directory is the single source. A silent fork is the + only real way to end up maintaining the interface twice, so the copy is + generated and the generated tree is not committed. + """ + sync = (CLIENT / "build" / "sync-ui.js").read_text(encoding="utf-8") + assert "meshbay-hub" in sync and "static" in sync + assert "rmSync" in sync, "a stale file could survive a rebuild" + + gitignore = (CLIENT.parents[1] / ".gitignore").read_text(encoding="utf-8") + assert "meshbay-client/ui/" in gitignore, ( + "the generated copy is committed, which is how a fork begins") + + +def test_the_packaged_page_loads_the_shared_modules(): + """The app's index.html is its own — the hub's carries a /a/<hash>/ prefix + that would point back at the hub — but it must load the same files.""" + page = INDEX.read_text(encoding="utf-8") + for module in ("keyderive.js", "crypto.js", "transport.js", "app.js", + "style.css", "argon2.min.js"): + assert module in page, f"{module} is not loaded by the packaged page" + assert "/a/" not in page, "the packaged page points at the hub's asset prefix" diff --git a/packages/meshbay-hub/tests/test_session_renewal.py b/packages/meshbay-hub/tests/test_session_renewal.py index 38db67f..6c47f7a 100644 --- a/packages/meshbay-hub/tests/test_session_renewal.py +++ b/packages/meshbay-hub/tests/test_session_renewal.py @@ -28,6 +28,7 @@ broken client. import json import shutil import subprocess +import re from pathlib import Path import pytest @@ -223,5 +224,12 @@ def test_the_connection_signs_its_offer_with_a_live_token(): assert "await ensureFreshToken()" in connect, ( "the offer is signed with whatever token the effect captured, which is " "no longer refreshed by a re-run") - assert "new window.MeshBayTransport('', live)" in connect, ( - "the transport is built with the captured token rather than the live one") + # Asserted on the argument, not on the whole call: the first argument is + # the hub's base URL and became configurable when the interface started + # shipping in a package. Pinning the literal made this fail for a change + # that had nothing to do with tokens. + built = re.search(r"new window\.MeshBayTransport\(([^)]*)\)", connect) + assert built, "the transport is not built in connect()" + args = [a.strip() for a in built.group(1).split(",")] + assert args[-1] == "live", ( + f"the transport is built with {args[-1]!r} rather than the live token") |