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
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
|
"""
The desktop keyring (`meshbay-client/src/keyring.js`) against the page and the
specification.
The application keeps `M` and the per-node identities in its main process and
does, with Node's crypto, what `keyderive.js` does with WebCrypto and a
WebAssembly Argon2. Two implementations of one format disagree silently: a
bundle one of them sealed is one the other cannot open, and that is an account
locked out of a node. So the three are held together here — the keyring, the
page, and a reference written from the specification in Python.
"""
import base64
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"
KEYRING = Path(__file__).resolve().parents[2] / "meshbay-client" / "src" / "keyring.js"
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 KEYRING.exists() or not HAVE_ARGON2,
reason="node, the desktop client sources or argon2-cffi is unavailable")
USER, USER_ID, NODE = "keyring-user", "5b0c7c4e-1d2e-4f3a-9b8c-7d6e5f4a3b2c", "Tm9kZUtleUM="
PASSWORD = "a passphrase for the keyring test"
PEPPER = base64.b64encode(bytes([42]) * 32).decode()
_HARNESS = r"""
const fs = require('fs'), url = require('url');
const webcrypto = require('crypto').webcrypto;
const [,, keyringPath, keyderivePath, wasm, argonJs, input] = process.argv;
const { createKeyring } = require(keyringPath);
// What the application injects: Electron's own crypto has no Argon2.
const path = require('path');
const { wasmArgon2 } = require(path.join(path.dirname(keyringPath), 'argon2-wasm.js'));
const argon2 = wasmArgon2(path.dirname(wasm));
const v = JSON.parse(fs.readFileSync(input, 'utf8'));
(async () => {
let store = {};
const ring = createKeyring({ argon2, load: () => JSON.parse(JSON.stringify(store)),
save: (o) => { store = JSON.parse(JSON.stringify(o)); } });
const out = {};
await ring.deriveSession({ password: v.password, username: v.user, userId: v.userId,
pepperB64: v.pepper, pepperVersion: 1 });
out.has_session = ring.hasSession(v.userId);
// 1. minted and sealed here, for the Python reference to open.
const pub = ring.mint(v.userId, v.node);
out.minted_pub = pub;
out.sealed_here = ring.sealBundle(v.userId, v.node).bundle;
out.playlist_key = ring.playlistKey(v.userId);
// 2. signatures by kind, built here from fields, for the reference to check;
// and an X25519 agreement that the other side can check.
const ts = Math.floor(Date.now() / 1000);
const nonceNode = Buffer.alloc(32, 7).toString('base64');
const other = require('crypto').generateKeyPairSync('ed25519').publicKey
.export({ format: 'der', type: 'spki' }).subarray(12).toString('base64');
const F = {
join: { nodePk: v.node, groupId: 'grp-1', userId: v.userId, nonceNode, ts },
device_hello: { nodePk: v.node, groupId: 'grp-1', userId: v.userId, nonceNode, ts },
device_request: { nodePk: v.node, userId: v.userId, codeHash: 'ab'.repeat(32), nonceNode, ts },
device_add: { nodePk: v.node, userId: v.userId, pkEd: other, pkX: other, nonceNode, ts },
device_revoke: { nodePk: v.node, userId: v.userId, pkEd: other, nonceNode, ts },
chat: { groupId: 'grp-1', epoch: 3, nonce: Buffer.alloc(12, 1).toString('base64'),
ct: Buffer.from('ciphertext').toString('base64') },
admin: { op: 'file_delete', nodePk: v.node, groupId: 'grp-1', subject: 'file-9',
nonce: Buffer.alloc(32, 2).toString('base64'), ts },
};
out.fields = F; out.signed = {};
for (const [kind, f] of Object.entries(F)) {
out.signed[kind] = ring.signAs(v.userId, v.node, kind, f);
}
const refusal = async (kind, f) => {
try { await ring.signAs(v.userId, v.node, kind, f); return null; }
catch (e) { return e.code || e.message; }
};
out.refused = {
bytes: await refusal('raw', { bytes: 'YQ==' }),
other_node: await refusal('join', { ...F.join, nodePk: 'T3RoZXJOb2Rl' }),
other_account: await refusal('join', { ...F.join, userId: 'someone-else' }),
stale: await refusal('join', { ...F.join, ts: ts - 3600 }),
};
const eph = require('crypto').generateKeyPairSync('x25519');
const ephPub = eph.publicKey.export({ format: 'der', type: 'spki' }).subarray(12);
out.shared_here = ring.shared(v.userId, v.node, ephPub.toString('base64'));
const minePub = Buffer.concat([Buffer.from('302a300506032b656e032100', 'hex'),
Buffer.from(pub.pkXB64, 'base64')]);
out.shared_there = require('crypto').diffieHellman({ privateKey: eph.privateKey,
publicKey: require('crypto').createPublicKey({ key: minePub, format: 'der', type: 'spki' }),
}).toString('base64');
// 3. sealed by the page (WebCrypto, WebAssembly Argon2), opened here.
global.self = global; global.window = global; global.crypto = webcrypto;
global.Module = { wasmBinary: fs.readFileSync(wasm) };
global.argon2 = require(argonJs);
eval(fs.readFileSync(keyderivePath, 'utf8'));
const K = window.MeshBayKeys;
const sk = await K.deriveBundleSessionKey(v.password, v.user, v.userId, v.pepper, 1);
const pageId = await K.generateNodeIdentity(sk, null, { userId: v.userId, nodePk: 'NODE-P' });
const opened = ring.openBundle(v.userId, 'NODE-P', { bundleEnc: pageId.bundleEnc });
out.page_bundle_opens_here = opened.pkXB64 === pageId.pkXB64;
// ...and what this keyring seals for a node, the page opens.
const back = await K.decryptBundle(ring.sealBundle(v.userId, 'NODE-P').bundle,
await K.nodeBundleKey(sk, 'NODE-P'), { userId: v.userId, nodePk: 'NODE-P' });
out.sealed_here_opens_in_page = back.skX === pageId.skXB64;
// 4. nothing but public keys come out of the keyring's answers.
out.identity_answer = ring.identity(v.userId, v.node);
out.retired = (() => { try { ring.openBundle(v.userId, 'NODE-R',
{ bundleEnc: Buffer.from('MBK2' + 'x'.repeat(40)).toString('base64') }); }
catch (e) { return e.code; } })();
out.access_default = ring.browserAccess('someone-else');
ring.setBrowserAccess(v.userId, false);
const refusedSeal = (fn) => { try { fn(); return null; } catch (e) { return e.message; } };
out.sealing_while_access_off = {
bundle: refusedSeal(() => ring.sealBundle(v.userId, v.node)),
recovery: refusedSeal(() => ring.sealRecovery(v.userId, v.node, 'A'.repeat(56), v.user)),
};
ring.forgetSession(v.userId);
out.after_sign_out = { session: ring.hasSession(v.userId),
identity_kept: !!ring.identity(v.userId, v.node) };
ring.setBrowserAccess(v.userId, false);
out.access_after_off = ring.browserAccess(v.userId);
out.stored_json = JSON.stringify(store);
process.stdout.write(JSON.stringify(out));
})().catch((e) => { console.error(e); process.exit(1); });
"""
def _hkdf(ikm, info):
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 _reference_master():
salt = hashlib.sha256(f"meshbay:bundle:v2:{USER}".encode()).digest()[:16]
a = hash_secret_raw(PASSWORD.encode(), salt, time_cost=3, memory_cost=131072,
parallelism=1, hash_len=32, type=Type.ID)
return _hkdf(a + base64.b64decode(PEPPER), f"meshbay:bundle-master:v3|{USER_ID}")
@pytest.fixture(scope="module")
def out(tmp_path_factory):
d = tmp_path_factory.mktemp("keyring")
(d / "harness.cjs").write_text(_HARNESS, encoding="utf-8")
(d / "input.json").write_text(json.dumps(
{"password": PASSWORD, "user": USER, "userId": USER_ID, "node": NODE,
"pepper": PEPPER}), encoding="utf-8")
proc = subprocess.run(
["node", str(d / "harness.cjs"), str(KEYRING), str(STATIC / "keyderive.js"),
str(VENDOR / "argon2.wasm"), str(VENDOR / "argon2.min.js"), str(d / "input.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 json.loads(proc.stdout)
def test_a_bundle_the_keyring_seals_opens_from_the_specification(out):
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
raw = base64.b64decode(out["sealed_here"])
assert raw[:4] == b"MBK3" and raw[4] == 1
key = _hkdf(_reference_master(), f"meshbay:bundle:v3|node|{NODE}")
plain = json.loads(AESGCM(key).decrypt(
raw[5:17], raw[17:], f"meshbay:bundle:v3|{USER_ID}|{NODE}".encode()))
assert set(plain) == {"skEd", "skX"}
def test_the_page_and_the_keyring_open_each_others_bundles(out):
assert out["page_bundle_opens_here"] is True
assert out["sealed_here_opens_in_page"] is True
def test_the_playlist_key_is_the_pages(out):
assert base64.b64decode(out["playlist_key"]) == _hkdf(
_reference_master(), "meshbay:playlists:v2")
def _reference_transcript(kind, f, pub):
from meshbay_common.adminop import admin_transcript
from meshbay_common.chatbox import signing_transcript
from meshbay_common.device import (
device_add_transcript,
device_hello_transcript,
device_request_transcript,
device_revoke_transcript,
)
from meshbay_common.join import join_transcript
nonce_node = base64.b64decode(f.get("nonceNode", ""))
ed, x = pub["pkEdB64"], pub["pkXB64"]
return {
"join": lambda: join_transcript(f["nodePk"], f["groupId"], f["userId"], ed, x,
nonce_node, f["ts"]),
"device_hello": lambda: device_hello_transcript(f["nodePk"], f["groupId"],
f["userId"], ed, nonce_node, f["ts"]),
"device_request": lambda: device_request_transcript(
f["nodePk"], f["userId"], ed, x, f["codeHash"], nonce_node, f["ts"]),
"device_add": lambda: device_add_transcript(f["nodePk"], f["userId"], f["pkEd"],
f["pkX"], nonce_node, f["ts"]),
"device_revoke": lambda: device_revoke_transcript(f["nodePk"], f["userId"], f["pkEd"],
nonce_node, f["ts"]),
"chat": lambda: signing_transcript(f["groupId"], f["epoch"], base64.b64decode(ed),
base64.b64decode(f["nonce"]),
base64.b64decode(f["ct"])),
"admin": lambda: admin_transcript(f["op"], f["nodePk"], f["groupId"], f["subject"],
base64.b64decode(f["nonce"]), f["ts"]),
}[kind]()
def test_every_kind_signs_the_specifications_bytes(out):
"""
The keyring builds the transcript itself from fields; what it signs must be
exactly what meshbay_common builds, or the node refuses every join, chat
line and admin operation from the desktop application.
"""
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
pub = out["minted_pub"]
key = Ed25519PublicKey.from_public_bytes(base64.b64decode(pub["pkEdB64"]))
assert set(out["signed"]) == {"join", "device_hello", "device_request", "device_add",
"device_revoke", "chat", "admin"}
for kind, sig in out["signed"].items():
key.verify(base64.b64decode(sig), _reference_transcript(kind, out["fields"][kind], pub))
def test_the_page_names_a_kind_and_never_the_bytes(out):
r = out["refused"]
assert "nothing is signed as" in r["bytes"]
assert "another node" in r["other_node"]
assert "another account" in r["other_account"]
assert "not now" in r["stale"]
def test_nothing_is_sealed_for_a_browser_while_browser_access_is_off(out):
for what, err in out["sealing_while_access_off"].items():
assert err and "browser access is off" in err, what
def test_agreements_check_out_with_the_public_keys(out):
assert out["shared_here"] == out["shared_there"]
def test_the_page_is_told_public_keys_and_nothing_else(out):
assert set(out["identity_answer"]) == {"pkEdB64", "pkXB64", "sealedWith"}
assert set(out["minted_pub"]) == {"pkEdB64", "pkXB64"}
assert out["retired"] == "bundle_format_retired"
def test_signing_out_drops_the_key_and_keeps_the_identities(out):
"""The identities are this device's: dropping them would leave every node
pinning a key nobody holds."""
assert out["has_session"] is True
assert out["after_sign_out"] == {"session": False, "identity_kept": True}
def test_browser_access_is_on_unless_this_device_said_otherwise(out):
assert out["access_default"] is True
assert out["access_after_off"] is False
def test_the_store_holds_no_passphrase(out):
assert PASSWORD not in out["stored_json"]
def test_the_application_never_asks_its_own_crypto_for_argon2():
"""Electron's Node is built on BoringSSL: `crypto.argon2` is there and
refuses. Found by signing in to the real application; a plain Node, which
the harness above runs, has it and would never have said so."""
for name in ("keyring.js", "main.js"):
source = (KEYRING.parent / name).read_text(encoding="utf-8")
assert "crypto.argon2" not in source, name
assert "wasmArgon2(" in (KEYRING.parent / "main.js").read_text(encoding="utf-8")
|