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
|
"""
The playlist key: one Argon2 run, two handles, one subkey.
Identity keys are per node, so a blob encrypted under one is unreadable from
every other node — the precise opposite of what a playlist needs. The only
secret an account holds *everywhere* is the bundle key, so the playlist key is
derived from it with HKDF (docs/playlists.md §3.4).
Three things have to hold, and getting any of them wrong is quiet:
- **One Argon2id run per sign-in.** The budget is the ~650 ms already on that
path. A second call is mathematically pointless and doubles it, and nothing
on screen would say so.
- **The HKDF handle is a second import of the same bytes**, not a derivation
from the AES one — that is imported non-extractably with
`['encrypt','decrypt']`, from which nothing can be derived at all.
- **A purpose-separated subkey**, not the bundle key with a different AAD.
`groupbox.py` writes that rule down for chunk keys; it is the same rule.
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"
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()
script = tmp_path / "case.mjs"
script.write_text(f"{PRELUDE}\n{src}\n{body}\n")
out = subprocess.run(["node", str(script)],
capture_output=True, text=True, timeout=60)
assert out.returncode == 0, out.stderr
return json.loads(out.stdout)
def test_a_sign_in_runs_argon2_exactly_once(tmp_path):
"""The budget is the 650 ms already on the sign-in path. Two handles over
one run is the whole point of `deriveBundleKeys`."""
out = _run(tmp_path, """
argonCalls = 0;
const keys = await deriveBundleKeys('passphrase', 'someone');
console.log(JSON.stringify({
calls: argonCalls,
aes: keys.aes.algorithm.name,
hkdf: keys.hkdf.algorithm.name,
}));
""")
assert out["calls"] == 1, "a second Argon2id run doubles the sign-in cost"
assert out["aes"] == "AES-GCM"
assert out["hkdf"] == "HKDF"
def test_the_bundle_key_fields_a_session_carries_are_built_in_one_run(tmp_path):
"""Both places that build a `session.bundleKey` go through this, so
`v2hkdf` cannot be the field one sign-in path forgot."""
out = _run(tmp_path, """
argonCalls = 0;
const fields = await window.MeshBayKeys.bundleKeyPairFields('p', 'someone');
console.log(JSON.stringify({
calls: argonCalls,
keys: Object.keys(fields).sort(),
v2: fields.v2.algorithm.name,
v2hkdf: fields.v2hkdf.algorithm.name,
}));
""")
assert out["calls"] == 1
assert out["keys"] == ["v2", "v2hkdf"]
assert out["v2"] == "AES-GCM" and out["v2hkdf"] == "HKDF"
def test_the_aes_handle_is_unchanged_by_the_hkdf_one(tmp_path):
"""`deriveEncryptionKey` still returns exactly what it always did — every
identity bundle already written is opened with it."""
out = _run(tmp_path, """
const legacy = await deriveEncryptionKey('p', 'someone');
const paired = (await deriveBundleKeys('p', 'someone')).aes;
const data = new TextEncoder().encode('a keypair bundle');
const iv = new Uint8Array(12);
const ct = await crypto.subtle.encrypt({name:'AES-GCM', iv}, legacy, data);
const back = await crypto.subtle.decrypt({name:'AES-GCM', iv}, paired, ct);
console.log(JSON.stringify({
same: new TextDecoder().decode(back) === 'a keypair bundle',
extractable: legacy.extractable,
}));
""")
assert out["same"], "the paired AES handle is not the same key as before"
assert out["extractable"] is False
def test_the_playlist_key_is_a_subkey_and_not_the_bundle_key(tmp_path):
"""Derived under its own `info`, so what opens a playlist opens nothing
else — and cannot be produced from the AES handle at all."""
out = _run(tmp_path, """
const { aes, hkdf } = await deriveBundleKeys('p', 'someone');
const playlistKey = await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
info: new TextEncoder().encode('meshbay:playlists:v1') },
hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
const iv = new Uint8Array(12);
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, playlistKey, new TextEncoder().encode('tracks'));
// The bundle key must not open what the playlist key sealed.
let bundleOpens = true;
try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aes, ct); }
catch { bundleOpens = false; }
// And nothing can be derived from the AES handle, which is why the HKDF
// one has to be a second import rather than a derivation.
let derivable = true;
try {
await crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
info: new Uint8Array(0) },
aes, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
} catch { derivable = false; }
console.log(JSON.stringify({ bundleOpens, derivable }));
""")
assert out["bundleOpens"] is False, (
"the playlist key is the bundle key — purpose separation is gone")
assert out["derivable"] is False, (
"if the AES handle were derivable the second import would be needless; "
"it is not, which is exactly why deriveBundleKeys imports twice")
def test_a_different_info_gives_a_different_key(tmp_path):
"""What makes it a *purpose*-separated subkey rather than a rename."""
out = _run(tmp_path, """
const { hkdf } = await deriveBundleKeys('p', 'someone');
const mk = (info) => crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
info: new TextEncoder().encode(info) },
hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
const a = await mk('meshbay:playlists:v1');
const b = await mk('meshbay:something-else:v1');
const iv = new Uint8Array(12);
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, a, new TextEncoder().encode('x'));
let opens = true;
try { await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, b, ct); }
catch { opens = false; }
console.log(JSON.stringify({ opens }));
""")
assert out["opens"] is False
def test_two_devices_of_one_account_derive_the_same_playlist_key(tmp_path):
"""The whole point, and the reason the nonce must be random rather than a
counter: two devices derive the *same* key, so a counter would repeat."""
out = _run(tmp_path, """
const mk = async () => {
const { hkdf } = await deriveBundleKeys('same passphrase', 'someone');
return crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
info: new TextEncoder().encode('meshbay:playlists:v1') },
hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
};
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, await mk(), new TextEncoder().encode('Evening'));
const back = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, await mk(), ct);
console.log(JSON.stringify({ text: new TextDecoder().decode(back) }));
""")
assert out["text"] == "Evening"
def test_a_different_account_derives_a_different_key(tmp_path):
"""The salt is domain-separated per user; this is what that buys."""
out = _run(tmp_path, """
const mk = async (user) => {
const { hkdf } = await deriveBundleKeys('p', user);
return crypto.subtle.deriveKey(
{ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0),
info: new TextEncoder().encode('meshbay:playlists:v1') },
hkdf, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
};
const iv = new Uint8Array(12);
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, await mk('alice'), new TextEncoder().encode('x'));
let opens = true;
try { await crypto.subtle.decrypt({ name:'AES-GCM', iv }, await mk('bob'), ct); }
catch { opens = false; }
console.log(JSON.stringify({ opens }));
""")
assert out["opens"] is False
|