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
|
// 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 {string} [o.oldPassphrase] omit in Flow B — connect falls back to the recovery copy
* @param {string} o.newPassphrase
* @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.deriveEncryptionKey) {
throw new Error('key module unavailable');
}
let oldKey, newKey;
if (o.bundleKey) {
// "Keep the current passphrase key, just add / refresh the recovery copy"
// — the Profile backfill (docs/MESHBAY_DESIGN.md §3.6). `o.bundleKey` is the
// live {v2,v1} session key, so no passphrase is needed.
oldKey = newKey = o.bundleKey;
} else {
// Flow B has no old passphrase; connect will fail the passphrase decrypt and
// fall back to the recovery copy, so a placeholder key is fine for `oldKey`.
const oldPass = o.oldPassphrase || o.newPassphrase;
oldKey = {
v2: await K.deriveEncryptionKey(oldPass, o.username),
v1: await K.deriveEncryptionKeyV1(oldPass, o.username),
};
newKey = {
v2: await K.deriveEncryptionKey(o.newPassphrase, o.username),
v1: await K.deriveEncryptionKeyV1(o.newPassphrase, o.username),
};
}
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),
30000, 'connect');
if (tp.newNodeBundle) {
// 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.sessionKeys;
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));
const reEnc = await K.encryptBundleWithKey(skEd, skX, newKey.v2);
// 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.encryptBundleWithKey(skEd, skX, recoveryKey)
: 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;
|