""" `MeshBayTransport.rewrapAllNodes` — the passphrase-change / recovery fan-out (docs/MESHBAY_DESIGN.md §3.6). The real function is run under node with its two boundaries stubbed: the hub HTTP calls and the per-node `MeshBayTransport` handshake. What is exercised is the orchestration — which groups land in `updated` / `unreachable` / `failed`, which nodes get a `keypair_bundle_store`, and that Flow B also writes a recovery-wrapped copy. The WebRTC handshake itself and `connect()`'s recovery-copy fallback are integration territory with no harness here. """ import json import shutil import subprocess from pathlib import Path import pytest from spa_source import transport_argv STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" TRANSPORT = STATIC / "transport.js" pytestmark = pytest.mark.skipif( shutil.which("node") is None or not TRANSPORT.exists(), reason="node or transport.js is unavailable", ) _HARNESS = r""" const fs = require('fs'); global.self = global; global.window = global; global.location = { hash: '' }; global.addEventListener = () => {}; global.document = { hidden: false, visibilityState: 'visible', addEventListener: () => {}, }; global.localStorage = { getItem: () => null, setItem() {}, removeItem() {}, key: () => null, length: 0, }; // connect() is stubbed on the prototype below, so no WebRTC shim is needed. global.RTCPeerConnection = function () { throw new Error('connect() not stubbed'); }; // transport.js and the parts split out of it, joined as one scope. eval(process.argv[2].split(require('path').delimiter) .map((p) => fs.readFileSync(p, 'utf8')).join('\n')); const T = window.MeshBayTransport; // Keys are opaque tags here; what is checked is which key sealed what, for // which account on which node, under which pepper version. window.MeshBayKeys = { deriveRecoveryKey: async (r) => ({ kind: 'rec', r }), nodeBundleKey: async (key, nodePk) => ({ kind: `${key.kind}@${nodePk}` }), encryptBundle: async (_skEd, _skX, key, m) => `wrapped:${key.kind}|${m.userId}|${m.nodePk}|v${m.pepperVersion}`, }; const b64 = (s) => Buffer.from(s).toString('base64'); const NODES = { 'n-ok': { sessionKeys: { skEdB64: b64('ed'), skXB64: b64('x') } }, 'n-fresh': { newNodeBundle: 'fresh', sessionKeys: { skEdB64: b64('ed'), skXB64: b64('x') } }, 'n-throw': { throws: 'handshake failed' }, 'n-noident': { sessionKeys: null }, }; const stored = []; const rewrapOnlySeen = []; const openedWith = []; T.prototype.connect = async function (nodeId, _t, _g, _gek, _sk, bundleKey) { this._nodeId = nodeId; this.nodePk = 'pk-' + nodeId; // what the handshake proves openedWith.push(bundleKey && bundleKey.kind); rewrapOnlySeen.push(this._rewrapOnly === true); const s = NODES[nodeId] || {}; if (s.throws) throw new Error(s.throws); this._identity = s.sessionKeys ? { raw: s.sessionKeys } : null; this._newNodeBundle = s.newNodeBundle || null; return { ok: true }; }; T.prototype.storeKeypairBundle = async function (enc, rec) { stored.push({ nodeId: this._nodeId, enc, rec: rec || null }); }; T.prototype.close = function () {}; const MINE = { groups: [ { id: 'gA', name: 'a', owner_username: 'ann' }, // normal node { id: 'gB', name: 'b', owner_username: 'ann' }, // no online node { id: 'gC', name: 'c', owner_username: 'ann' }, // /nodes errors { id: 'gD', name: 'd', owner_username: 'ann' }, // connect throws { id: 'gE', name: 'e', owner_username: 'ann' }, // fresh identity, nothing stranded { id: 'gF', name: 'f', owner_username: 'ann' }, // identity not recovered ] }; const NODES_FOR = { gA: { nodes: [{ node_id: 'n-ok' }] }, gB: { nodes: [] }, gC: 'ERR', gD: { nodes: [{ node_id: 'n-throw' }] }, gE: { nodes: [{ node_id: 'n-fresh' }] }, gF: { nodes: [{ node_id: 'n-noident' }] }, }; global.fetch = async (url) => { const path = url.replace(/^.*?(\/v1\/)/, '$1'); if (path === '/v1/groups/mine') return { ok: true, json: async () => MINE }; const m = path.match(/^\/v1\/groups\/([^/]+)\/nodes$/); if (m) { const v = NODES_FOR[m[1]]; if (v === 'ERR') return { ok: false, status: 503 }; return { ok: true, json: async () => v }; } return { ok: false, status: 404 }; }; const names = (a) => a.map((x) => x.name).sort(); (async () => { // Flow A — passphrase change: opened with the old key, sealed with the new. const A = await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid', bundleKey: { kind: 'old', pepperVersion: 1 }, newBundleKey: { kind: 'new', pepperVersion: 1 }, }); const storeA = stored.splice(0); const openedA = openedWith.splice(0); // Flow B — reset: the session key of the new passphrase opens nothing, the // recovery copy does, and both copies are sealed again. const B = await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid', bundleKey: { kind: 'cur', pepperVersion: 1 }, recoveryKey: 'A RECOVERY MNEMONIC', }); const storeB = stored.splice(0); // Flow C — Profile backfill: keep the live key, just add the recovery copy. const C = await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid', bundleKey: { kind: 'bk', pepperVersion: 1 }, recoveryKey: 'A RECOVERY MNEMONIC', }); const storeC = stored.splice(0); let refusedWithoutKey = false; try { await T.rewrapAllNodes({ hubUrl: 'https://h', token: 't', username: 'u', userId: 'uid' }); } catch { refusedWithoutKey = true; } process.stdout.write(JSON.stringify({ a_updated: names(A.updated), a_unreachable: names(A.unreachable), a_failed: names(A.failed), a_stored_nodes: storeA.map((s) => s.nodeId).sort(), a_recovery_always_null: storeA.every((s) => s.rec === null), a_stored: storeA.map((s) => s.enc), a_opened_with: [...new Set(openedA)], b_stored: storeB.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })), c_stored: storeC.map((s) => ({ node: s.nodeId, enc: s.enc, rec: s.rec })), refused_without_key: refusedWithoutKey, // Every transport the fan-out builds is flagged rewrap-only, so a stored // bundle it cannot open is reported, not silently replaced with a new one. all_rewrap_only: rewrapOnlySeen.length > 0 && rewrapOnlySeen.every(Boolean), })); })().catch((e) => { console.error(e); process.exit(1); }); """ @pytest.fixture(scope="module") def result(tmp_path_factory): d = tmp_path_factory.mktemp("rewrap") harness = d / "harness.cjs" harness.write_text(_HARNESS, encoding="utf-8") proc = subprocess.run( ["node", str(harness), transport_argv()], capture_output=True, text=True, encoding="utf-8", timeout=120, ) if proc.returncode != 0: pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}") return json.loads(proc.stdout) def test_a_reachable_node_with_an_identity_is_updated(result): assert "a@ann" in result["a_updated"] assert result["a_stored_nodes"] == ["n-ok"] def test_a_group_with_no_online_node_is_unreachable(result): assert result["a_unreachable"] == ["b@ann"] def test_a_nodes_lookup_error_and_a_failed_handshake_land_in_failed(result): assert "c@ann" in result["a_failed"] # /nodes returned 503 assert "d@ann" in result["a_failed"] # connect() threw def test_a_node_that_never_had_our_identity_is_not_written_but_not_a_failure(result): # gE: connect minted a fresh identity — nothing is stranded, so the group is # "updated", and no keypair_bundle_store is sent for it. assert "e@ann" in result["a_updated"] assert "n-fresh" not in result["a_stored_nodes"] def test_a_node_that_returns_no_identity_is_a_failure(result): assert "f@ann" in result["a_failed"] def test_flow_a_opens_with_the_old_key_and_seals_with_the_new_for_that_node(result): assert result["a_recovery_always_null"] is True assert result["a_opened_with"] == ["old"] assert result["a_stored"] == ["wrapped:new@pk-n-ok|uid|pk-n-ok|v1"] def test_flow_b_writes_both_the_passphrase_and_the_recovery_copy(result): # The recovery copy owes nothing to the pepper: version 0. assert result["b_stored"] == [ {"node": "n-ok", "enc": "wrapped:cur@pk-n-ok|uid|pk-n-ok|v1", "rec": "wrapped:rec|uid|pk-n-ok|v0"}, ] def test_profile_backfill_keeps_the_live_key_and_adds_the_recovery_copy(result): assert result["c_stored"] == [ {"node": "n-ok", "enc": "wrapped:bk@pk-n-ok|uid|pk-n-ok|v1", "rec": "wrapped:rec|uid|pk-n-ok|v0"}, ] def test_there_is_no_passphrase_path_left(result): """Keys only: a caller that has not derived one with the pepper is refused.""" assert result["refused_without_key"] is True def test_every_fanout_transport_is_rewrap_only(result): assert result["all_rewrap_only"] is True