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
|
// A passphrase change, carried to every node that holds this account's
// identity bundle.
// ── Passphrase change: re-wrap every reachable identity bundle ───────────────
//
// docs/MESHBAY_DESIGN.md §3.6. The passphrase-derived bundle_key encrypts this
// account's per-node identity on every node it has joined. Changing the
// passphrase changes that key, so each bundle must be read with the old key and
// written back with the new one — on the node, while both keys are in hand.
//
// The reachable set is the online nodes of the account's current groups. A node
// that is offline, or belongs to a group left since, cannot be reached here and
// is reported so the caller can tell the user to ask that group's operator to
// unpin them and issue a fresh code (§3.4).
function _acHubFetch(hubUrl, path, init) {
const p = typeof window !== 'undefined' && window.MeshBayPlatform;
const url = (hubUrl || '') + path;
return (p && p.apiFetch) ? p.apiFetch(url, init) : fetch(url, init);
}
async function _acHubGet(hubUrl, token, path) {
const r = await _acHubFetch(hubUrl, path, {
headers: { Authorization: `Bearer ${token}` },
});
if (!r.ok) throw new Error(`${path} → ${r.status}`);
return r.json();
}
function _acWithTimeout(promise, ms, label) {
let timer;
return Promise.race([
promise.finally(() => clearTimeout(timer)),
new Promise((_, rej) => {
timer = setTimeout(() => rej(new Error(`${label} timed out`)), ms);
}),
]);
}
/**
* @param {object} o
* @param {string} o.hubUrl same base the SPA uses for the hub
* @param {string} o.token a fresh access token
* @param {string} o.username
* @param {string} o.userId
* @param {object} o.bundleKey the session key that opens the bundles as they are
* (keyderive.js `deriveBundleSessionKey`). In Flow B it
* opens nothing and connect falls back to the recovery copy.
* @param {object} [o.newBundleKey] the key to seal them under; defaults to `bundleKey`
* (the Profile backfill: same key, a recovery copy added)
* @param {string} [o.recoveryKey] the recovery mnemonic (Flow B,
* docs/MESHBAY_DESIGN.md §3.6).
* When given, the recovery-wrapped copy is read where the
* passphrase copy cannot be, and a fresh one is written back.
* @param {(p:{done:number,total:number})=>void} [o.onProgress]
* @returns {Promise<{updated:Array,unreachable:Array,failed:Array,newBundleKey:object}>}
*/
async function rewrapAllNodes(o) {
const K = window.MeshBayKeys;
if (!K || !K.encryptBundle) {
throw new Error('key module unavailable');
}
if (!o.bundleKey) throw new Error('no bundle key in this session');
// Keys, never passphrases: each caller has derived them already, with the
// pepper only the hub holds (docs/MESHBAY_DESIGN.md §3.7).
const oldKey = o.bundleKey;
const newKey = o.newBundleKey || o.bundleKey;
const recoveryKey = o.recoveryKey
? await K.deriveRecoveryKey(o.recoveryKey, o.username)
: null;
const mine = await _acHubGet(o.hubUrl, o.token, '/v1/groups/mine');
const groups = mine.groups || (Array.isArray(mine) ? mine : []);
const updated = [], unreachable = [], failed = [];
for (const g of groups) {
const label = g.owner_username ? `${g.name}@${g.owner_username}` : g.name;
let nodes = [];
try {
const nd = await _acHubGet(o.hubUrl, o.token, `/v1/groups/${g.id}/nodes`);
nodes = nd.nodes || [];
} catch (e) {
failed.push({ groupId: g.id, name: label, reason: e.message });
if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
continue;
}
if (nodes.length === 0) {
unreachable.push({ groupId: g.id, name: label, reason: 'node offline' });
if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
continue;
}
let anyOk = false, lastErr = null;
for (const n of nodes) {
const tp = new MeshBayTransport(o.hubUrl, o.token);
// Recover the *existing* identity or report this node — never mint a new
// one just because the stored bundle would not open.
tp._rewrapOnly = true;
try {
await _acWithTimeout(
tp.connect(n.node_id, o.token, g.id, null, null, oldKey,
o.username, o.userId, null, recoveryKey, undefined, n.pk_node),
30000, 'connect');
if (tp.identity && tp.identity.native) {
// The desktop application holds this identity: it seals, and only
// for an account with browser access — without it, nothing of the
// identity is on the node to re-seal.
const P = window.MeshBayPlatform.keys;
if (await P.browserAccess(o.userId)) {
const sealed = await P.sealBundle(o.userId, tp.nodePk,
{ pending: Boolean(o.newBundleKey && o.newBundleKey.pending) });
const rec = o.recoveryKey
? await P.sealRecovery(o.userId, tp.nodePk, o.recoveryKey, o.username) : null;
await tp.storeKeypairBundle(sealed.bundle, rec);
await P.markSealed(o.userId, tp.nodePk, sealed.fingerprint);
}
anyOk = true;
continue;
}
// An identity read from an MBK2 bundle (TRANSITIONAL) is an existing
// one, and is re-sealed below like any other.
if (tp.newNodeBundle && !tp.upgradedLegacy) {
// No identity existed on this node — connect just minted one under
// the old key. Don't persist it: the next time this group is opened
// the normal flow creates one under the current key, and storing it
// here could also walk back a deliberate bundle withdrawal. Nothing
// is stranded, so this node needs no fix.
anyOk = true;
continue;
}
const sk = tp.identity && tp.identity.raw;
if (!sk) { lastErr = new Error('identity not recovered'); continue; }
const skEd = Uint8Array.from(atob(sk.skEdB64), c => c.charCodeAt(0));
const skX = Uint8Array.from(atob(sk.skXB64), c => c.charCodeAt(0));
// Sealed for this account on the node just connected to — the key
// that node proved during the handshake.
const sealedFor = { userId: o.userId, nodePk: tp.nodePk };
const reEnc = await K.encryptBundle(skEd, skX, await K.nodeBundleKey(newKey, tp.nodePk),
{ ...sealedFor, pepperVersion: newKey.pepperVersion });
// In Flow B, refresh the recovery copy too (same R) so the node's
// passphrase copy and recovery copy stay in step.
const reRecovery = recoveryKey
? await K.encryptBundle(skEd, skX, recoveryKey, { ...sealedFor, pepperVersion: 0 })
: null;
await tp.storeKeypairBundle(reEnc, reRecovery);
anyOk = true;
} catch (e) {
lastErr = e;
} finally {
try { tp.close(); } catch { /* already gone */ }
}
}
if (anyOk) updated.push({ groupId: g.id, name: label });
else failed.push({ groupId: g.id, name: label,
reason: (lastErr && lastErr.message) || 'unreachable' });
if (o.onProgress) o.onProgress({ done: updated.length + unreachable.length + failed.length, total: groups.length });
}
return { updated, unreachable, failed, newBundleKey: newKey };
}
MeshBayTransport.rewrapAllNodes = rewrapAllNodes;
|