aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/tests/test_account_keys.py
blob: e14f6319e021480473e5fe7db68aa4d075ec8ee7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
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