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
|
/**
* Does the browser open a sealed index — and does it stop when it cannot?
*
* Drives **the real `MeshBayTransport` over the real `crypto.js`**, fed real
* length-prefixed msgpack frames built by Python's `groupbox.seal`. Only the DOM
* and the DataChannel are stand-ins; the framing, the msgpack decode, the
* dispatch, the HKDF and the AES-GCM are all the shipped code.
*
* It exists because the two things worth knowing here are invisible to a
* source-reading test. The first is §3.4: a payload that does not open must
* *raise*, never become an empty index — "this group has no files" is a
* legitimate state, so a silent fallback is indistinguishable from the truth.
* The second is ordering: opening is asynchronous while `_dispatch` is not, so
* two index messages could be applied in whichever order their decrypt promises
* happened to settle, and a delta applied before its base is a silently wrong
* view of the group.
*
* node index_seal_probe.mjs <static-dir> <vectors.json>
*
* Prints JSON: `events` in the order they were delivered, and `fetchIndex`, how
* the outstanding request ended.
*/
import fs from 'fs';
import { delimiter } from 'path';
const STATIC = process.argv[2];
const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
// The transport logs to the console on the paths under test; stdout is this
// probe's JSON result, so everything it says goes to stderr instead.
for (const level of ['log', 'warn', 'error', 'info', 'debug']) {
console[level] = (...args) => process.stderr.write(args.join(' ') + '\n');
}
// Just enough DOM for two classic scripts that expect a page.
globalThis.window = globalThis;
globalThis.addEventListener = () => {};
globalThis.removeEventListener = () => {};
globalThis.location = { hash: '' };
globalThis.document = {
addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
};
new Function(fs.readFileSync(`${STATIC}/crypto.js`, 'utf8'))();
// argv[4]: transport.js and the parts split out of it, joined as one scope.
const transportSrc = process.argv[4].split(delimiter)
.map((p) => fs.readFileSync(p, 'utf8')).join('\n');
new Function(transportSrc)();
const hex = (s) => Uint8Array.from(s.match(/../g).map((b) => parseInt(b, 16)));
const events = [];
const tp = new window.MeshBayTransport('', 'token');
tp._connected = true;
tp._channel = { readyState: 'open', send() {}, close() {} };
tp._pc = { close() {} };
tp._gekRaw = hex(input.gek);
tp._connectArgs = { groupId: input.group_id };
tp.onIndexSync = (msg) => events.push({
event: 'index_sync',
entries: (msg.entries || []).map((e) => e.name),
dirs: msg.dirs || [],
version: msg.version,
// Present on the message a consumer sees? The envelope's own fields should
// be gone, and the payload's should have taken their place.
hasCiphertext: 'ct' in msg || 'nonce' in msg,
});
tp.onIndexDelta = (msg) => events.push({
event: 'index_delta',
additions: (msg.additions || []).map((e) => e.name),
base_version: msg.base_version,
version: msg.version,
});
tp.onSessionFailed = (err) => events.push({ event: 'session_failed', message: err.message });
// One outstanding fetchIndex, so the probe can say what a *waiting caller* is
// told — which is the half of §3.4 a callback cannot show.
const fetchOutcome = { state: 'pending' };
tp._send = () => {};
tp.fetchIndex()
.then((msg) => { fetchOutcome.state = 'resolved';
fetchOutcome.entries = (msg.entries || []).map((e) => e.name); })
.catch((e) => { fetchOutcome.state = 'rejected'; fetchOutcome.message = e.message; });
const closed = { count: 0 };
const realClose = tp.close.bind(tp);
tp.close = () => { closed.count += 1; realClose(); };
(async () => {
// Delivered exactly as the DataChannel delivers them: one call per frame, in
// order, with no await between.
for (const frame of input.frames) tp._onMessage(hex(frame).buffer);
// Let the opening chain drain. Each message costs two WebCrypto promises, so
// a handful of turns is not enough to be sure; a real delay is.
await new Promise((r) => setTimeout(r, 200));
process.stdout.write(JSON.stringify({
events, fetchIndex: fetchOutcome, closed: closed.count,
}));
})();
|