""" 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. signatures by kind, built here from fields, for the reference to check; // and an X25519 agreement that the other side can check. const ts = Math.floor(Date.now() / 1000); const nonceNode = Buffer.alloc(32, 7).toString('base64'); const other = require('crypto').generateKeyPairSync('ed25519').publicKey .export({ format: 'der', type: 'spki' }).subarray(12).toString('base64'); const F = { join: { nodePk: v.node, groupId: 'grp-1', userId: v.userId, nonceNode, ts }, device_hello: { nodePk: v.node, groupId: 'grp-1', userId: v.userId, nonceNode, ts }, device_request: { nodePk: v.node, userId: v.userId, codeHash: 'ab'.repeat(32), nonceNode, ts }, device_add: { nodePk: v.node, userId: v.userId, pkEd: other, pkX: other, nonceNode, ts }, device_revoke: { nodePk: v.node, userId: v.userId, pkEd: other, nonceNode, ts }, chat: { groupId: 'grp-1', epoch: 3, nonce: Buffer.alloc(12, 1).toString('base64'), ct: Buffer.from('ciphertext').toString('base64') }, admin: { op: 'file_delete', nodePk: v.node, groupId: 'grp-1', subject: 'file-9', nonce: Buffer.alloc(32, 2).toString('base64'), ts }, }; out.fields = F; out.signed = {}; for (const [kind, f] of Object.entries(F)) { out.signed[kind] = ring.signAs(v.userId, v.node, kind, f); } const refusal = async (kind, f) => { try { await ring.signAs(v.userId, v.node, kind, f); return null; } catch (e) { return e.code || e.message; } }; out.refused = { bytes: await refusal('raw', { bytes: 'YQ==' }), other_node: await refusal('join', { ...F.join, nodePk: 'T3RoZXJOb2Rl' }), other_account: await refusal('join', { ...F.join, userId: 'someone-else' }), stale: await refusal('join', { ...F.join, ts: ts - 3600 }), }; 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; } })(); out.access_default = ring.browserAccess('someone-else'); ring.setBrowserAccess(v.userId, false); const refusedSeal = (fn) => { try { fn(); return null; } catch (e) { return e.message; } }; out.sealing_while_access_off = { bundle: refusedSeal(() => ring.sealBundle(v.userId, v.node)), recovery: refusedSeal(() => ring.sealRecovery(v.userId, v.node, 'A'.repeat(56), v.user)), }; ring.forgetSession(v.userId); out.after_sign_out = { session: ring.hasSession(v.userId), identity_kept: !!ring.identity(v.userId, v.node) }; 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 _reference_transcript(kind, f, pub): from meshbay_common.adminop import admin_transcript from meshbay_common.chatbox import signing_transcript from meshbay_common.device import ( device_add_transcript, device_hello_transcript, device_request_transcript, device_revoke_transcript, ) from meshbay_common.join import join_transcript nonce_node = base64.b64decode(f.get("nonceNode", "")) ed, x = pub["pkEdB64"], pub["pkXB64"] return { "join": lambda: join_transcript(f["nodePk"], f["groupId"], f["userId"], ed, x, nonce_node, f["ts"]), "device_hello": lambda: device_hello_transcript(f["nodePk"], f["groupId"], f["userId"], ed, nonce_node, f["ts"]), "device_request": lambda: device_request_transcript( f["nodePk"], f["userId"], ed, x, f["codeHash"], nonce_node, f["ts"]), "device_add": lambda: device_add_transcript(f["nodePk"], f["userId"], f["pkEd"], f["pkX"], nonce_node, f["ts"]), "device_revoke": lambda: device_revoke_transcript(f["nodePk"], f["userId"], f["pkEd"], nonce_node, f["ts"]), "chat": lambda: signing_transcript(f["groupId"], f["epoch"], base64.b64decode(ed), base64.b64decode(f["nonce"]), base64.b64decode(f["ct"])), "admin": lambda: admin_transcript(f["op"], f["nodePk"], f["groupId"], f["subject"], base64.b64decode(f["nonce"]), f["ts"]), }[kind]() def test_every_kind_signs_the_specifications_bytes(out): """ The keyring builds the transcript itself from fields; what it signs must be exactly what meshbay_common builds, or the node refuses every join, chat line and admin operation from the desktop application. """ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey pub = out["minted_pub"] key = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub["pkEdB64"])) assert set(out["signed"]) == {"join", "device_hello", "device_request", "device_add", "device_revoke", "chat", "admin"} for kind, sig in out["signed"].items(): key.verify(base64.b64decode(sig), _reference_transcript(kind, out["fields"][kind], pub)) def test_the_page_names_a_kind_and_never_the_bytes(out): r = out["refused"] assert "nothing is signed as" in r["bytes"] assert "another node" in r["other_node"] assert "another account" in r["other_account"] assert "not now" in r["stale"] def test_nothing_is_sealed_for_a_browser_while_browser_access_is_off(out): for what, err in out["sealing_while_access_off"].items(): assert err and "browser access is off" in err, what def test_agreements_check_out_with_the_public_keys(out): 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")