diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-30 16:58:31 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-30 16:58:31 +0200 |
| commit | 6d167392f6f8ede37e2794a68a3738f8ba03131d (patch) | |
| tree | 9caacef15dd034c6425f4bb623e0cd50a28ec52a /packages/meshbay-hub/tests | |
| parent | 8926f163dad9d32dc06c3a142658a4e11d9c12c1 (diff) | |
| download | meshbay-6d167392f6f8ede37e2794a68a3738f8ba03131d.tar.gz | |
feat(client): the desktop application keeps M and every node identity in its main process
keyring.js derives, opens, mints, seals, signs and agrees there; the page gets
public keys and a handle. Argon2 comes from the page's own WebAssembly build
(Electron's crypto has none). Without OS key storage the page keeps its keys as
a browser does. A node's bundle is settled after connecting, re-sealed when the
key changed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests')
| -rw-r--r-- | packages/meshbay-hub/tests/harness/group_tab_probe.py | 2 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_desktop_keyring.py | 194 |
2 files changed, 196 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/harness/group_tab_probe.py b/packages/meshbay-hub/tests/harness/group_tab_probe.py index 730ba99..e23a946 100644 --- a/packages/meshbay-hub/tests/harness/group_tab_probe.py +++ b/packages/meshbay-hub/tests/harness/group_tab_probe.py @@ -73,6 +73,8 @@ window.MeshBayTransport = class { // unmount; a stub without this throws inside connect() and the page // renders its error state instead of a tab bar. addReconnectListener() { return () => {}; } + // What the node should hold of our identity, settled after connecting. + async settleNodeBundle() {} close() {} }; </script> diff --git a/packages/meshbay-hub/tests/test_desktop_keyring.py b/packages/meshbay-hub/tests/test_desktop_keyring.py new file mode 100644 index 0000000..673a00e --- /dev/null +++ b/packages/meshbay-hub/tests/test_desktop_keyring.py @@ -0,0 +1,194 @@ +""" +The desktop keyring (`meshbay-client/src/keyring.js`) against the page and the +specification. + +The application keeps `M` and the per-node identities in its main process and +does, with Node's crypto, what `keyderive.js` does with WebCrypto and a +WebAssembly Argon2. Two implementations of one format disagree silently: a +bundle one of them sealed is one the other cannot open, and that is an account +locked out of a node. So the three are held together here — the keyring, the +page, and a reference written from the specification in Python. +""" + +import base64 +import hashlib +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static" +VENDOR = STATIC / "vendor" +KEYRING = Path(__file__).resolve().parents[2] / "meshbay-client" / "src" / "keyring.js" + +try: + from argon2.low_level import Type, hash_secret_raw + HAVE_ARGON2 = True +except ImportError: + HAVE_ARGON2 = False + +pytestmark = pytest.mark.skipif( + shutil.which("node") is None or not KEYRING.exists() or not HAVE_ARGON2, + reason="node, the desktop client sources or argon2-cffi is unavailable") + +USER, USER_ID, NODE = "keyring-user", "5b0c7c4e-1d2e-4f3a-9b8c-7d6e5f4a3b2c", "Tm9kZUtleUM=" +PASSWORD = "a passphrase for the keyring test" +PEPPER = base64.b64encode(bytes([42]) * 32).decode() + +_HARNESS = r""" +const fs = require('fs'), url = require('url'); +const webcrypto = require('crypto').webcrypto; +const [,, keyringPath, keyderivePath, wasm, argonJs, input] = process.argv; +const { createKeyring } = require(keyringPath); +// What the application injects: Electron's own crypto has no Argon2. +const path = require('path'); +const { wasmArgon2 } = require(path.join(path.dirname(keyringPath), 'argon2-wasm.js')); +const argon2 = wasmArgon2(path.dirname(wasm)); +const v = JSON.parse(fs.readFileSync(input, 'utf8')); +(async () => { + let store = {}; + const ring = createKeyring({ argon2, load: () => JSON.parse(JSON.stringify(store)), + save: (o) => { store = JSON.parse(JSON.stringify(o)); } }); + const out = {}; + await ring.deriveSession({ password: v.password, username: v.user, userId: v.userId, + pepperB64: v.pepper, pepperVersion: 1 }); + out.has_session = ring.hasSession(v.userId); + + // 1. minted and sealed here, for the Python reference to open. + const pub = ring.mint(v.userId, v.node); + out.minted_pub = pub; + out.sealed_here = ring.sealBundle(v.userId, v.node).bundle; + out.playlist_key = ring.playlistKey(v.userId); + + // 2. a signature and an X25519 agreement that the other side can check. + const msg = Buffer.from('a transcript'); + out.sig = ring.sign(v.userId, v.node, msg.toString('base64')); + const eph = require('crypto').generateKeyPairSync('x25519'); + const ephPub = eph.publicKey.export({ format: 'der', type: 'spki' }).subarray(12); + out.shared_here = ring.shared(v.userId, v.node, ephPub.toString('base64')); + const minePub = Buffer.concat([Buffer.from('302a300506032b656e032100', 'hex'), + Buffer.from(pub.pkXB64, 'base64')]); + out.shared_there = require('crypto').diffieHellman({ privateKey: eph.privateKey, + publicKey: require('crypto').createPublicKey({ key: minePub, format: 'der', type: 'spki' }), + }).toString('base64'); + + // 3. sealed by the page (WebCrypto, WebAssembly Argon2), opened here. + global.self = global; global.window = global; global.crypto = webcrypto; + global.Module = { wasmBinary: fs.readFileSync(wasm) }; + global.argon2 = require(argonJs); + eval(fs.readFileSync(keyderivePath, 'utf8')); + const K = window.MeshBayKeys; + const sk = await K.deriveBundleSessionKey(v.password, v.user, v.userId, v.pepper, 1); + const pageId = await K.generateNodeIdentity(sk, null, { userId: v.userId, nodePk: 'NODE-P' }); + const opened = ring.openBundle(v.userId, 'NODE-P', { bundleEnc: pageId.bundleEnc }); + out.page_bundle_opens_here = opened.pkXB64 === pageId.pkXB64; + // ...and what this keyring seals for a node, the page opens. + const back = await K.decryptBundle(ring.sealBundle(v.userId, 'NODE-P').bundle, + await K.nodeBundleKey(sk, 'NODE-P'), { userId: v.userId, nodePk: 'NODE-P' }); + out.sealed_here_opens_in_page = back.skX === pageId.skXB64; + + // 4. nothing but public keys come out of the keyring's answers. + out.identity_answer = ring.identity(v.userId, v.node); + out.retired = (() => { try { ring.openBundle(v.userId, 'NODE-R', + { bundleEnc: Buffer.from('MBK2' + 'x'.repeat(40)).toString('base64') }); } + catch (e) { return e.code; } })(); + ring.forgetSession(v.userId); + out.after_sign_out = { session: ring.hasSession(v.userId), + identity_kept: !!ring.identity(v.userId, v.node) }; + out.access_default = ring.browserAccess('someone-else'); + ring.setBrowserAccess(v.userId, false); + out.access_after_off = ring.browserAccess(v.userId); + out.stored_json = JSON.stringify(store); + process.stdout.write(JSON.stringify(out)); +})().catch((e) => { console.error(e); process.exit(1); }); +""" + + +def _hkdf(ikm, info): + from cryptography.hazmat.primitives.hashes import SHA256 + from cryptography.hazmat.primitives.kdf.hkdf import HKDF + return HKDF(algorithm=SHA256(), length=32, salt=None, info=info.encode()).derive(ikm) + + +def _reference_master(): + salt = hashlib.sha256(f"meshbay:bundle:v2:{USER}".encode()).digest()[:16] + a = hash_secret_raw(PASSWORD.encode(), salt, time_cost=3, memory_cost=131072, + parallelism=1, hash_len=32, type=Type.ID) + return _hkdf(a + base64.b64decode(PEPPER), f"meshbay:bundle-master:v3|{USER_ID}") + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + d = tmp_path_factory.mktemp("keyring") + (d / "harness.cjs").write_text(_HARNESS, encoding="utf-8") + (d / "input.json").write_text(json.dumps( + {"password": PASSWORD, "user": USER, "userId": USER_ID, "node": NODE, + "pepper": PEPPER}), encoding="utf-8") + proc = subprocess.run( + ["node", str(d / "harness.cjs"), str(KEYRING), str(STATIC / "keyderive.js"), + str(VENDOR / "argon2.wasm"), str(VENDOR / "argon2.min.js"), str(d / "input.json")], + capture_output=True, text=True, encoding="utf-8", timeout=300) + if proc.returncode != 0: + pytest.fail(f"node harness failed:\n{proc.stderr[-2000:]}") + return json.loads(proc.stdout) + + +def test_a_bundle_the_keyring_seals_opens_from_the_specification(out): + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + raw = base64.b64decode(out["sealed_here"]) + assert raw[:4] == b"MBK3" and raw[4] == 1 + key = _hkdf(_reference_master(), f"meshbay:bundle:v3|node|{NODE}") + plain = json.loads(AESGCM(key).decrypt( + raw[5:17], raw[17:], f"meshbay:bundle:v3|{USER_ID}|{NODE}".encode())) + assert set(plain) == {"skEd", "skX"} + + +def test_the_page_and_the_keyring_open_each_others_bundles(out): + assert out["page_bundle_opens_here"] is True + assert out["sealed_here_opens_in_page"] is True + + +def test_the_playlist_key_is_the_pages(out): + assert base64.b64decode(out["playlist_key"]) == _hkdf( + _reference_master(), "meshbay:playlists:v2") + + +def test_signatures_and_agreements_check_out_with_the_public_keys(out): + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + Ed25519PublicKey.from_public_bytes(base64.b64decode(out["minted_pub"]["pkEdB64"])).verify( + base64.b64decode(out["sig"]), b"a transcript") + assert out["shared_here"] == out["shared_there"] + + +def test_the_page_is_told_public_keys_and_nothing_else(out): + assert set(out["identity_answer"]) == {"pkEdB64", "pkXB64", "sealedWith"} + assert set(out["minted_pub"]) == {"pkEdB64", "pkXB64"} + assert out["retired"] == "bundle_format_retired" + + +def test_signing_out_drops_the_key_and_keeps_the_identities(out): + """The identities are this device's: dropping them would leave every node + pinning a key nobody holds.""" + assert out["has_session"] is True + assert out["after_sign_out"] == {"session": False, "identity_kept": True} + + +def test_browser_access_is_on_unless_this_device_said_otherwise(out): + assert out["access_default"] is True + assert out["access_after_off"] is False + + +def test_the_store_holds_no_passphrase(out): + assert PASSWORD not in out["stored_json"] + + +def test_the_application_never_asks_its_own_crypto_for_argon2(): + """Electron's Node is built on BoringSSL: `crypto.argon2` is there and + refuses. Found by signing in to the real application; a plain Node, which + the harness above runs, has it and would never have said so.""" + for name in ("keyring.js", "main.js"): + source = (KEYRING.parent / name).read_text(encoding="utf-8") + assert "crypto.argon2" not in source, name + assert "wasmArgon2(" in (KEYRING.parent / "main.js").read_text(encoding="utf-8") |