aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_bundle_key.py
blob: f6c99f8d5c2f5cc3af860d71c820c4302155b7cb (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
"""
The bundle key: one Argon2 run, the hub's pepper, one key per node.

What a node stores — an identity bundle, a playlist blob — is sealed under keys
derived from `M = HKDF(A ‖ pepper, account id)`, where `A` is the passphrase's
Argon2id and the pepper is held by the hub (docs/MESHBAY_DESIGN.md §3.7). Each
of these is quiet when wrong:

  - **One Argon2id run per sign-in.** The budget is the ~650 ms already on that
    path; a second run doubles it and nothing on screen says so.
  - **The pepper and the account are in the key.** Without the pepper, the
    operator holding a bundle can test passphrase guesses again.
  - **One key per node, and a bundle bound to its node and account.** A leaked
    node key, or a bundle copied elsewhere, opens nothing else.
  - **The playlist key is the same on every device of one account**, and is not
    any node's key.
  - **An earlier format is refused, by name** — never opened, never guessed at.

Node's WebCrypto is the real implementation here; only Argon2 is stubbed, and
stubbed precisely so the calls can be counted.
"""

import json
import shutil
import subprocess
from pathlib import Path

import pytest

STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
KEYDERIVE = STATIC / "keyderive.js"
PLAYLIST_CRYPTO = STATIC / "playlist-crypto.js"

pytestmark = pytest.mark.skipif(
    shutil.which("node") is None or not KEYDERIVE.exists(),
    reason="node or the SPA sources are not available")

# keyderive.js assigns `window.MeshBayKeys` and reads `window.argon2`; node has
# neither, and a counted stub is the whole point.
PRELUDE = """
globalThis.window = globalThis;
let argonCalls = 0;
globalThis.argon2 = {
  ArgonType: { Argon2id: 2 },
  async hash(opts) {
    argonCalls++;
    // Deterministic, and a function of what was actually passed, so a changed
    // salt domain or cost parameter shows up as different bytes rather than
    // silently agreeing.
    const seed = new TextEncoder().encode(
      opts.pass + ':' + Array.from(opts.salt).join(',') + ':' + opts.time);
    const digest = new Uint8Array(
      await crypto.subtle.digest('SHA-256', seed));
    return { hash: digest };
  },
};
"""


def _run(tmp_path, body):
    src = KEYDERIVE.read_text(encoding="utf-8")
    script = tmp_path / "case.mjs"
    helpers = (
        "const K = () => window.MeshBayKeys;\n"
        "const PEPPER = btoa(String.fromCharCode(...new Uint8Array(32).fill(7)));\n"
        "const OTHER_PEPPER = btoa(String.fromCharCode(...new Uint8Array(32).fill(9)));\n"
        "// Same key <=> same bytes out of a fixed encryption.\n"
        "const fp = async (key) => btoa(String.fromCharCode(...new Uint8Array(\n"
        "  await crypto.subtle.encrypt({ name: 'AES-GCM', iv: new Uint8Array(12) }, key,\n"
        "                              new Uint8Array(16)))));\n"
        f"const {{ derivePlaylistKey }} = await import('{PLAYLIST_CRYPTO.as_uri()}');\n"
    )
    script.write_text(f"{PRELUDE}\n{src}\n{helpers}\n{body}\n", encoding="utf-8")
    out = subprocess.run(["node", str(script)],
                         capture_output=True, text=True, encoding="utf-8", timeout=60)
    assert out.returncode == 0, out.stderr
    return json.loads(out.stdout)


def test_a_sign_in_runs_argon2_exactly_once(tmp_path):
    out = _run(tmp_path, """
      argonCalls = 0;
      const key = await K().deriveBundleSessionKey('passphrase', 'someone', 'uid-1', PEPPER, 1);
      console.log(JSON.stringify({
        calls: argonCalls, alg: key.v3.algorithm.name,
        extractable: key.v3.extractable, version: key.pepperVersion,
      }));
    """)
    assert out["calls"] == 1, "a second Argon2id run doubles the sign-in cost"
    assert out["alg"] == "HKDF" and out["extractable"] is False
    assert out["version"] == 1


def test_without_the_pepper_there_is_no_key(tmp_path):
    out = _run(tmp_path, """
      let refused = false;
      try { await K().deriveBundleSessionKey('p', 'someone', 'uid-1', null, 1); }
      catch { refused = true; }
      console.log(JSON.stringify({ refused }));
    """)
    assert out["refused"], "a key derived from the passphrase alone is what a node could attack"


def test_the_pepper_and_the_account_are_part_of_the_key(tmp_path):
    out = _run(tmp_path, """
      const node = async (pepper, uid) => fp(await K().nodeBundleKey(
        await K().deriveBundleSessionKey('same passphrase', 'someone', uid, pepper, 1), 'NODE'));
      const base = await node(PEPPER, 'uid-1');
      console.log(JSON.stringify({
        again: base === await node(PEPPER, 'uid-1'),
        other_pepper: base !== await node(OTHER_PEPPER, 'uid-1'),
        other_account: base !== await node(PEPPER, 'uid-2'),
      }));
    """)
    assert out == {"again": True, "other_pepper": True, "other_account": True}


def test_a_bundle_opens_on_its_node_for_its_account_only(tmp_path):
    out = _run(tmp_path, """
      const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1);
      const kA = await K().nodeBundleKey(sk, 'NODE-A');
      const kB = await K().nodeBundleKey(sk, 'NODE-B');
      const sealed = await K().encryptBundle(new Uint8Array([1]), new Uint8Array([2]), kA,
        { userId: 'uid-1', nodePk: 'NODE-A', pepperVersion: 1 });
      const opens = async (key, meta) => {
        try { await K().decryptBundle(sealed, key, meta); return true; } catch { return false; }
      };
      console.log(JSON.stringify({
        format: K().bundleFormat(sealed),
        magic: atob(sealed).slice(0, 4), version: atob(sealed).charCodeAt(4),
        right: await opens(kA, { userId: 'uid-1', nodePk: 'NODE-A' }),
        other_node_key: await opens(kB, { userId: 'uid-1', nodePk: 'NODE-B' }),
        moved_to_other_node: await opens(kA, { userId: 'uid-1', nodePk: 'NODE-B' }),
        served_for_other_account: await opens(kA, { userId: 'uid-2', nodePk: 'NODE-A' }),
      }));
    """)
    assert out["format"] == "current" and out["magic"] == "MBK3" and out["version"] == 1
    assert out["right"] is True
    assert out["other_node_key"] is False
    assert out["moved_to_other_node"] is False
    assert out["served_for_other_account"] is False


def test_an_earlier_format_is_refused_by_name(tmp_path):
    """Sealed under the passphrase alone. Never opened, and the refusal says why
    — the caller must not take it for an absent bundle and mint a new one."""
    out = _run(tmp_path, """
      const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1);
      const k = await K().nodeBundleKey(sk, 'NODE');
      const results = [];
      for (const old of [btoa('MBK2' + 'x'.repeat(40)), btoa('y'.repeat(44))]) {
        let code = null;
        try { await K().decryptBundle(old, k, { userId: 'uid-1', nodePk: 'NODE' }); }
        catch (e) { code = e.code || null; }
        results.push([K().bundleFormat(old), code]);
      }
      console.log(JSON.stringify(results));
    """)
    assert out == [["legacy", "bundle_format_retired"], ["retired", "bundle_format_retired"]]


def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path):
    """The one key an account must hold everywhere: node keys differ per node,
    and a playlist is read from any of them."""
    out = _run(tmp_path, """
      const pl = async (uid) => fp(await derivePlaylistKey((await K().deriveBundleSessionKey(
        'same passphrase', 'someone', uid, PEPPER, 1)).v3));
      const a = await pl('uid-1');
      console.log(JSON.stringify({ same: a === await pl('uid-1'),
                                   other_account: a !== await pl('uid-2') }));
    """)
    assert out == {"same": True, "other_account": True}


def test_the_playlist_key_is_no_node_key(tmp_path):
    out = _run(tmp_path, """
      const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1);
      console.log(JSON.stringify({
        distinct: await fp(await derivePlaylistKey(sk.v3))
          !== await fp(await K().nodeBundleKey(sk, 'NODE')),
      }));
    """)
    assert out["distinct"]


def test_an_mbk2_bundle_is_opened_once_and_sealed_again_as_mbk3(tmp_path):
    """
    TRANSITIONAL. Nodes still hold bundles sealed under the passphrase's Argon2
    key alone. The same Argon2 run that makes `M` makes that key, so the session
    keeps it — decrypt only — and the identity is moved to MBK3 on the account's
    next visit instead of the member being re-invited.
    """
    out = _run(tmp_path, """
      argonCalls = 0;
      const sk = await K().deriveBundleSessionKey('p', 'someone', 'uid-1', PEPPER, 1);
      const calls = argonCalls;
      // An MBK2 bundle as 0.16 wrote it: "MBK2" ‖ nonce ‖ AES-GCM(A), no AAD.
      const a = await crypto.subtle.importKey('raw', await _bundleKeyBytes('p', 'someone'),
                                              { name: 'AES-GCM' }, false, ['encrypt']);
      const nonce = new Uint8Array(12).fill(3);
      const plain = new TextEncoder().encode(JSON.stringify({ skEd: btoa('ED'), skX: btoa('XX') }));
      const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, a, plain));
      const raw = new Uint8Array(4 + 12 + ct.length);
      raw.set(new TextEncoder().encode('MBK2')); raw.set(nonce, 4); raw.set(ct, 16);
      const mbk2 = btoa(String.fromCharCode(...raw));

      const keys = await K().decryptLegacyBundle(mbk2, sk.legacy);
      const resealed = await K().resealLegacyIdentity(keys, sk, null, { userId: 'uid-1', nodePk: 'NODE' });
      const back = await K().decryptBundle(resealed.bundleEnc, await K().nodeBundleKey(sk, 'NODE'),
                                           { userId: 'uid-1', nodePk: 'NODE' });
      let otherPassphrase = 'opened';
      const sk2 = await K().deriveBundleSessionKey('another', 'someone', 'uid-1', PEPPER, 1);
      try { await K().decryptLegacyBundle(mbk2, sk2.legacy); } catch { otherPassphrase = 'refused'; }
      let sealsUnderLegacy = 'yes';
      try { await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce }, sk.legacy, plain); }
      catch { sealsUnderLegacy = 'no'; }
      console.log(JSON.stringify({
        calls, format: K().bundleFormat(mbk2), keys, newFormat: K().bundleFormat(resealed.bundleEnc),
        back, otherPassphrase, sealsUnderLegacy, extractable: sk.legacy.extractable,
      }));
    """)
    assert out["calls"] == 1, "keeping the legacy key must not cost a second Argon2 run"
    assert out["format"] == "legacy"
    assert out["keys"] == {"skEd": "RUQ=", "skX": "WFg="}
    assert out["newFormat"] == "current"
    assert out["back"] == out["keys"]
    assert out["otherPassphrase"] == "refused"
    assert out["sealsUnderLegacy"] == "no", "the legacy key opens; it never seals"
    assert out["extractable"] is False