summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests/test_account_keys.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-15 12:32:24 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-15 12:32:24 +0200
commit988a0eecd522431980174910db28e45a63ff2710 (patch)
tree80b16f8cfe907f084fac931fa35326c7f2892a9d /packages/meshbay-cli/tests/test_account_keys.py
parente0a1036e0fee15b3c6c83f3e935d50f472a2e776 (diff)
downloadmeshbay-988a0eecd522431980174910db28e45a63ff2710.tar.gz
feat(cli): account, rm -r and mkdir, Tab completion
account shows the account and does what the sign-up form and the Profile page do: register (the passphrase floor and the hub's proof of work checked here, the recovery key printed once), verify, change the passphrase, reset a forgotten one, change the e-mail address, sign out everywhere, delete. A new passphrase carries the copy of the account's keys each node keeps for browsers first — opened under the old M, the same keys sealed under the new one — and only then tells the hub; a refusal sets the copies back. A reset restores them from the recovery copy. The connections that do it never make keys nor ask for a device approval. This machine keeps the M it changed away from, so a node out of reach is carried at its next connection; a copy under an M it does not know is no longer rewritten, which locked the browsers out after a passphrase changed elsewhere. rm -r removes a folder and all it holds, from the node's operator key: the files, then the folders deepest first, one confirmation that counts them. mkdir makes folders, --parents the missing ones (not -p, the passphrase). completion bash|powershell prints the script that makes Tab complete actions, verbs, options and the paths of the groups this machine is signed in to, cached two minutes; the deb and rpm packages install the bash one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/tests/test_account_keys.py')
-rw-r--r--packages/meshbay-cli/tests/test_account_keys.py135
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