aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_desktop_keyring.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/tests/test_desktop_keyring.py')
-rw-r--r--packages/meshbay-hub/tests/test_desktop_keyring.py194
1 files changed, 194 insertions, 0 deletions
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")