""" What `account` computes on this side, held to what the page computes. The recovery key and the passphrase floor are run against the page's own `keyderive.js` and `auth-page.js` under node: a recovery copy sealed here must open in a browser and the other way round, or a reset would restore nothing; and a passphrase the page refuses must be refused here. The proof of work is solved against the hub's own issuer. """ import base64 import json import re import shutil import subprocess from pathlib import Path import pytest from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey from meshbay_cli.client.keys import ( Identity, generate_recovery, open_recovery, recovery_bytes, recovery_key, recovery_mnemonic, seal_recovery, ) from meshbay_cli.client.passphrase import ( passphrase_bits, passphrase_problem, solve_captcha, ) STATIC = (Path(__file__).resolve().parents[2] / "meshbay-hub" / "src" / "meshbay_hub" / "static") needs_node = pytest.mark.skipif(shutil.which("node") is None or not STATIC.exists(), reason="node or the page's scripts are unavailable") USER, UID, NODE = "alice-test", "u-1", base64.b64encode(b"\x05" * 32).decode() PASSPHRASES = ["", "short", "correct horse battery staple", "aaaaaaaaaaaaaaaa", "123456789012345678", "MyPassword is long enough!", "Zé ünïcødé ✓ 🔐 2026", "azerty-azerty-azerty", "Tr0ub4dor&3xyzzy", "x" * 40] _HARNESS = r""" const fs = require('fs'), webcrypto = require('crypto').webcrypto; global.self = global; global.window = global; global.crypto = webcrypto; eval(fs.readFileSync(process.argv[2], 'utf8')); const K = window.MeshBayKeys; const page = fs.readFileSync(process.argv[3], 'utf8'); eval(page.match(/function passwordBits[\s\S]*?\n}\n/)[0]); const input = JSON.parse(fs.readFileSync(process.argv[4], 'utf8')); const b = (s) => Uint8Array.from(Buffer.from(s, 'base64')); (async () => { const out = {bits: input.passphrases.map(passwordBits)}; const key = await K.deriveRecoveryKey(input.mnemonic, input.user); out.opened = await K.decryptBundle(input.sealed, key, {userId: input.uid, nodePk: input.node}); const fresh = K.generateRecoveryKey(); const fkey = await K.deriveRecoveryKey(fresh.mnemonic, input.user); out.js_mnemonic = fresh.mnemonic; out.js_raw = fresh.rawB64; out.js_sealed = await K.encryptBundle(b(input.keys.skEd), b(input.keys.skX), fkey, {userId: input.uid, nodePk: input.node, pepperVersion: 0}); process.stdout.write(JSON.stringify(out)); })(); """ @pytest.fixture(scope="module") def page(tmp_path_factory): d = tmp_path_factory.mktemp("parity") keys = Identity(Ed25519PrivateKey.generate(), X25519PrivateKey.generate()).to_pkcs8() raw, mnemonic = generate_recovery() sealed = seal_recovery(keys, rkey=recovery_key(raw, USER), user_id=UID, node_pk=NODE) (d / "h.cjs").write_text(_HARNESS, encoding="utf-8") (d / "in.json").write_text(json.dumps({ "passphrases": PASSPHRASES, "mnemonic": mnemonic, "user": USER, "uid": UID, "node": NODE, "sealed": sealed, "keys": keys}), encoding="utf-8") r = subprocess.run(["node", str(d / "h.cjs"), str(STATIC / "keyderive.js"), str(STATIC / "auth-page.js"), str(d / "in.json")], capture_output=True, text=True, timeout=60) assert r.returncode == 0, r.stderr return {"keys": keys, **json.loads(r.stdout)} @needs_node def test_a_recovery_copy_sealed_here_opens_in_a_browser(page): assert page["opened"] == page["keys"] @needs_node def test_a_recovery_copy_sealed_in_a_browser_opens_here(page): raw = recovery_bytes(page["js_mnemonic"]) assert raw == base64.b64decode(page["js_raw"]) assert recovery_mnemonic(raw) == page["js_mnemonic"] assert open_recovery(page["js_sealed"], rkey=recovery_key(raw, USER), user_id=UID, node_pk=NODE) == page["keys"] @needs_node def test_the_passphrase_estimate_is_the_pages(page): assert [passphrase_bits(p) for p in PASSPHRASES] == page["bits"] def test_the_floor_is_twelve_characters_and_sixty_bits(): assert "12 characters" in passphrase_problem("Short1!") assert "too easy" in passphrase_problem("aaaaaaaaaaaaaaaa") assert "too easy" in passphrase_problem("password-password") assert passphrase_problem("correct horse battery staple") is None def test_a_recovery_key_is_read_however_it_is_typed(): raw, mnemonic = generate_recovery() assert re.fullmatch(r"(?:[A-Z2-7]{4} ){12}[A-Z2-7]{4}", mnemonic) assert recovery_bytes(mnemonic.lower().replace(" ", "")) == raw assert recovery_bytes(" " + mnemonic.replace(" ", "-") + "\n") == raw with pytest.raises(ValueError): recovery_bytes(mnemonic[:20]) with pytest.raises(ValueError): recovery_bytes(mnemonic.replace(mnemonic[0], "1", 1)) assert recovery_key(raw, "alice") != recovery_key(raw, "bob") def test_the_proof_of_work_is_solved_as_the_hub_checks_it(monkeypatch): captcha = pytest.importorskip("meshbay_hub.captcha") from cryptography.hazmat.primitives import serialization from meshbay_hub import auth monkeypatch.setattr(auth, "_hub_sk_pem", Ed25519PrivateKey.generate().private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) challenge = captcha.issue(5000) token = solve_captcha(challenge) assert token and captcha.verify(token) assert solve_captcha({**challenge, "challenge": "00" * 32}) is None