aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_bundle_kdf_parity.py
blob: a4bee434c89d933ce94b25aa4da35723b222894d (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
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
"""
Cross-language parity for the keypair bundle KDF.

The bundle is the one thing a user carries between browsers, and the passphrase
is all that stands between it and whoever holds the disk of a node they joined
(finding C4). It moved from PBKDF2-SHA512 to Argon2id for that reason — PBKDF2 is
compute-only, which is what makes it cheap on a GPU.

Two implementations now have to agree byte for byte: the vendored WebAssembly the
browser runs, and `argon2-cffi` used by the QE harness. A disagreement would not
show up as an error — it would show up as a bundle nobody can open, which is
somebody's account gone.

Skipped when node or argon2-cffi is missing; that is a coverage gap, not a pass.
"""

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"

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 (VENDOR / "argon2.min.js").exists()
    or not HAVE_ARGON2,
    reason="node, the vendored argon2, or argon2-cffi is unavailable",
)

# Parameters must match keyderive.js. If someone tunes them there and not here,
# this test fails — which is the point: changing them silently orphans every
# bundle already written.
MEM_KIB, TIME_COST, LANES = 131072, 3, 1

CASES = ["alice", "grenet", "utilisateur-é", ""]
PASSWORDS = ["correct horse battery staple", "p", "üñïçø∂é ✓ 🔐"]

_HARNESS = r"""
const fs = require('fs'), webcrypto = require('crypto').webcrypto;
global.self = global; global.crypto = webcrypto;
// The browser uses the copy inlined in the bundle; under node the emscripten
// loader looks for a file, so hand it the same bytes explicitly.
global.Module = { wasmBinary: fs.readFileSync(process.argv[2]) };
const argon2 = require(process.argv[3]);

(async () => {
  const input = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
  const out = [];
  for (const v of input) {
    const salt = new Uint8Array(await webcrypto.subtle.digest(
      'SHA-256', new TextEncoder().encode(`meshbay:bundle:v2:${v.username}`)
    )).slice(0, 16);
    const r = await argon2.hash({
      pass: v.password, salt,
      time: v.time, mem: v.mem, parallelism: v.lanes,
      hashLen: 32, type: argon2.ArgonType.Argon2id,
    });
    out.push(Buffer.from(r.hash).toString('hex'));
  }
  process.stdout.write(JSON.stringify(out));
})();
"""


@pytest.fixture(scope="module")
def js_hashes(tmp_path_factory):
    d = tmp_path_factory.mktemp("kdf")
    harness = d / "harness.cjs"
    harness.write_text(_HARNESS, encoding="utf-8")
    vectors = [
        {"username": u, "password": p,
         "mem": MEM_KIB, "time": TIME_COST, "lanes": LANES}
        for u in CASES for p in PASSWORDS
    ]
    payload = d / "vectors.json"
    payload.write_text(json.dumps(vectors), encoding="utf-8")

    proc = subprocess.run(
        ["node", str(harness), str(VENDOR / "argon2.wasm"),
         str(VENDOR / "argon2.min.js"), str(payload)],
        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 vectors, json.loads(proc.stdout)


def _python_hash(username: str, password: str) -> str:
    salt = hashlib.sha256(f"meshbay:bundle:v2:{username}".encode()).digest()[:16]
    return hash_secret_raw(
        password.encode(), salt, time_cost=TIME_COST, memory_cost=MEM_KIB,
        parallelism=LANES, hash_len=32, type=Type.ID,
    ).hex()


def test_bundle_key_matches_across_languages(js_hashes):
    vectors, js = js_hashes
    for i, v in enumerate(vectors):
        assert js[i] == _python_hash(v["username"], v["password"]), (
            f"argon2id disagrees for username={v['username']!r} — a bundle "
            f"written by one implementation would be unreadable by the other"
        )


def test_the_salt_separates_users(js_hashes):
    """Two accounts with the same passphrase must not share a bundle key."""
    assert _python_hash("alice", "same passphrase") != \
        _python_hash("bob", "same passphrase")


def test_parameters_still_match_the_client():
    """
    The numbers live in keyderive.js; this test is the second copy. Tuning one
    without the other orphans every bundle already written, so make it fail.
    """
    source = (STATIC / "keyderive.js").read_text(encoding="utf-8")
    assert f"ARGON2_MEM_KIB = {MEM_KIB}" in source
    assert f"ARGON2_TIME    = {TIME_COST}" in source
    assert f"ARGON2_LANES   = {LANES}" in source
    assert "meshbay:bundle:v2:" in source


# ── The whole chain: A, the pepper, M, the node key, the playlist key ─────────
#
# The real keyderive.js and playlist-crypto.js, over the real WebAssembly
# Argon2, against a reference written from the specification with nothing
# shared: argon2-cffi, `cryptography`'s HKDF and AES-GCM. Down to opening an
# MBK3 bundle, so the format and its associated data agree too.

_CHAIN_HARNESS = r"""
const fs = require('fs'), path = require('path'), url = require('url');
const webcrypto = require('crypto').webcrypto;
global.self = global; global.window = global; global.crypto = webcrypto;
global.Module = { wasmBinary: fs.readFileSync(process.argv[2]) };
global.argon2 = require(process.argv[3]);
eval(fs.readFileSync(process.argv[4], 'utf8'));
const K = window.MeshBayKeys;
const fp = async (key) => Buffer.from(await webcrypto.subtle.encrypt(
  { name: 'AES-GCM', iv: new Uint8Array(12) }, key, new Uint8Array(16))).toString('hex');
(async () => {
  const { derivePlaylistKey } = await import(url.pathToFileURL(process.argv[5]).href);
  const out = [];
  for (const v of JSON.parse(fs.readFileSync(process.argv[6], 'utf8'))) {
    const sk = await K.deriveBundleSessionKey(v.password, v.username, v.user_id, v.pepper, 1);
    const kNode = await K.nodeBundleKey(sk, v.node_pk);
    const bundle = await K.encryptBundle(
      Buffer.from('ed-private'), Buffer.from('x-private'), kNode,
      { userId: v.user_id, nodePk: v.node_pk, pepperVersion: 1 });
    out.push({ node: await fp(kNode), playlists: await fp(await derivePlaylistKey(sk.v3)),
               bundle });
  }
  process.stdout.write(JSON.stringify(out));
})().catch((e) => { console.error(e); process.exit(1); });
"""

CHAIN = [
    {"username": "alice", "password": "correct horse battery staple",
     "user_id": "0b4f6f0e-5d7e-4e8a-9d2b-6a1c1b9e2f11", "node_pk": "Tm9kZUtleUE="},
    {"username": "utilisateur-é", "password": "üñïçø∂é ✓ 🔐",
     "user_id": "7d1e0c2a-3b4c-4d5e-8f60-718293a4b5c6", "node_pk": "Tm9kZUtleUI="},
]


def _hkdf(ikm: bytes, info: str) -> bytes:
    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 _fp(key: bytes) -> str:
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    return AESGCM(key).encrypt(bytes(12), bytes(16), None).hex()


@pytest.fixture(scope="module")
def chain(tmp_path_factory):
    import base64
    d = tmp_path_factory.mktemp("chain")
    vectors = [{**v, "pepper": base64.b64encode(bytes([i + 1]) * 32).decode()}
               for i, v in enumerate(CHAIN)]
    (d / "harness.cjs").write_text(_CHAIN_HARNESS, encoding="utf-8")
    (d / "vectors.json").write_text(json.dumps(vectors), encoding="utf-8")
    proc = subprocess.run(
        ["node", str(d / "harness.cjs"), str(VENDOR / "argon2.wasm"),
         str(VENDOR / "argon2.min.js"), str(STATIC / "keyderive.js"),
         str(STATIC / "playlist-crypto.js"), str(d / "vectors.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 vectors, json.loads(proc.stdout)


def _reference(v: dict) -> tuple[bytes, bytes]:
    import base64
    a = bytes.fromhex(_python_hash(v["username"], v["password"]))
    m = _hkdf(a + base64.b64decode(v["pepper"]), f"meshbay:bundle-master:v3|{v['user_id']}")
    return (_hkdf(m, f"meshbay:bundle:v3|node|{v['node_pk']}"),
            _hkdf(m, "meshbay:playlists:v2"))


def test_the_node_and_playlist_keys_match_across_languages(chain):
    vectors, js = chain
    for v, got in zip(vectors, js):
        k_node, k_pl = _reference(v)
        assert got["node"] == _fp(k_node), f"node key disagrees for {v['username']!r}"
        assert got["playlists"] == _fp(k_pl), f"playlist key disagrees for {v['username']!r}"


def test_an_mbk3_bundle_opens_from_the_specification(chain):
    """Magic, pepper version, nonce, AES-GCM with the account and node as
    associated data — read back by code that shares nothing with the writer."""
    import base64

    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    vectors, js = chain
    for v, got in zip(vectors, js):
        raw = base64.b64decode(got["bundle"])
        assert raw[:4] == b"MBK3" and raw[4] == 1
        aad = f"meshbay:bundle:v3|{v['user_id']}|{v['node_pk']}".encode()
        plain = json.loads(AESGCM(_reference(v)[0]).decrypt(raw[5:17], raw[17:], aad))
        assert base64.b64decode(plain["skEd"]) == b"ed-private"
        assert base64.b64decode(plain["skX"]) == b"x-private"