diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_desktop_shell.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_desktop_shell.py | 218 |
1 files changed, 218 insertions, 0 deletions
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" |