diff options
Diffstat (limited to 'packages/meshbay-cli/tests/test_account_keys.py')
| -rw-r--r-- | packages/meshbay-cli/tests/test_account_keys.py | 135 |
1 files changed, 135 insertions, 0 deletions
diff --git a/packages/meshbay-cli/tests/test_account_keys.py b/packages/meshbay-cli/tests/test_account_keys.py new file mode 100644 index 0000000..e14f631 --- /dev/null +++ b/packages/meshbay-cli/tests/test_account_keys.py @@ -0,0 +1,135 @@ +""" +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 |