aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/transport-devices.js
blob: 4291cf895a96afd52448318788a59c7bb91379f9 (plain) (blame)
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
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
// An account's devices and what they hold: joining a group, linking a device,
// the identity bundles and the per-account blobs (playlists).
//
// Methods of MeshBayTransport, copied onto its prototype by extendTransport
// (transport.js, which the shell loads first).

extendTransport(class {
  /**
   * Who is in this group and which device keys they hold — verified here, not
   * taken on the node's word.
   *
   * Tier 2 of `docs/MESHBAY_DESIGN.md` §3.3. The node relays, for each device,
   * the already-pinned key that countersigned it and the signature itself; this
   * walks that from each account's first device outwards and keeps only the
   * devices it could actually reach. A device the node asserts but cannot
   * evidence is reported as unverified rather than dropped — the reader is
   * shown a gap, never a silent absence.
   *
   * The property this buys, stated exactly: once a client has seen an account,
   * a node that later substitutes a key for it is **detected**. It buys nothing
   * at first sight, where there is nothing to compare against — that boundary
   * is `docs/MESHBAY_DESIGN.md` §3.2's and does not move.
   */
  async groupRoster() {
    if (this._roster) return this._roster;
    if (this._rosterInFlight) return this._rosterInFlight;

    this._rosterInFlight = (async () => {
      const resp = await this._sendAndWait({
        type: 'group_roster_req', v: '2.0', group_id: this._groupId || '',
      });
      if (resp.type === 'error') throw new Error(resp.detail);
      const payload = msgpack_decode(await window.MeshBayCrypto.openGroup(
        this._gekRaw, 'roster', 'group_roster_resp', this._groupId || '', resp));
      this._roster = await _verifyRoster(payload, this.nodePk);
      return this._roster;
    })();
    try {
      return await this._rosterInFlight;
    } finally {
      this._rosterInFlight = null;
    }
  }

  /**
   * How this client regards `devicePk` as a device of `userId`.
   *
   *   'pinned'    seen before, and the same key — nothing to say
   *   'linked'    new, and countersigned by a key already pinned for it
   *   'first'     first sight of this account: trust on first use
   *   'changed'   a key this account has not shown before and cannot evidence
   *
   * Only `changed` is worth a person's attention, and it is the one notice
   * docs/MESHBAY_DESIGN.md §3.3 budgets for. `first` is not an alarm — every
   * account is new once, and treating that as a warning is how a warning stops
   * being read.
   */
  async accountDeviceStatus(userId, devicePk) {
    let roster;
    try {
      roster = await this.groupRoster();
    } catch {
      return 'unknown';
    }
    const known = await _readPinnedAccount(this.nodePk, userId);
    const entry = roster.byAccount.get(userId);
    if (known && known.includes(devicePk)) return 'pinned';
    if (!known) {
      // First sight, so **everything the node says** is pinned — not only what
      // a chain reaches. There is nothing to compare against yet: that is what
      // trust-on-first-use means, and pinning only the verified subset would
      // raise "key changed" on a legitimate second device whose
      // countersignature simply predates it being kept. What TOFU buys is that
      // a substitution *later* is visible; it cannot buy anything now.
      if (entry) await _writePinnedAccount(this.nodePk, userId, entry.all);
      return entry && entry.all.includes(devicePk) ? 'first' : 'changed';
    }
    if (entry && entry.verified.includes(devicePk)
        && entry.chain.get(devicePk)
        && known.includes(entry.chain.get(devicePk))) {
      // Countersigned by a key we already trust for this account: a second
      // device of someone we know, admitted without anybody comparing digits.
      await _writePinnedAccount(this.nodePk, userId,
                                [...new Set([...known, devicePk])]);
      return 'linked';
    }
    return 'changed';
  }

  /**
   * Ask the node to recognise us and hand over the group key.
   *
   * Sent when we hold no GEK for a group. `code` is needed only the first time
   * this node sees this account (and not at all in an open-join group).
   */
  async joinGroup(userId, groupId, code) {
    if (!this._sessionKeys || !this._sessionKeys.skEdB64 || !this._sessionKeys.skXB64) {
      throw new Error('Identity keys unavailable in this browser — sign in again');
    }
    if (!this._nonceNode || !this.nodePk) {
      throw new Error('Handshake incomplete — reconnect and retry');
    }

    const C = window.MeshBayCrypto;
    const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
    const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);
    const ts = Math.floor(Date.now() / 1000);

    const transcript = C.joinTranscript(
      this.nodePk, groupId || '', userId, pkEdB64, pkXB64, this._nonceNode, ts);
    const sig = await window.MeshBayKeys.signBytes(this._sessionKeys.skEdB64, transcript);

    const resp = await this._sendAndWait({
      type: 'join_request',
      v: '0.1',
      group_id: groupId || '',
      pk_ed25519: pkEdB64,
      pk_x25519: pkXB64,
      code: code || '',
      ts,
      sig,
    });

    if (resp.type === 'error') throw new Error(resp.detail || 'Join refused');
    if ((resp.type !== 'join_result' || !resp.ok) || !resp.gek) {
      const reason = resp.reason || 'unknown';
      const err = new Error(JOIN_REFUSALS[reason] || `Join refused: ${reason}`);
      // The UI reacts to `code_required` by asking for one; everything else is
      // shown as-is.
      err.reason = reason;
      throw err;
    }

    // Unwrap with our own secret key — the node wrapped for the public key we
    // just proved we hold, so nobody else can open this.
    const skXRaw = Uint8Array.from(atob(this._sessionKeys.skXB64), c => c.charCodeAt(0));
    const myPkX = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0));
    const gekRaw = await C.unwrapGEK(resp, skXRaw, myPkX);
    this._gekRaw = gekRaw;
    // What the node's roster says this identity is, which is not what the hub
    // says: `operator` here means this browser's key was paired with the node,
    // not merely that the account owns it.
    this.memberRole = resp.role || '';
    return gekRaw;
  }

  // ── Device linking ─────────────────────────────────────────────────────

  /**
   * Ask to be added, and return the code to show the person.
   *
   * They read it off this screen and type it into a device already paired with
   * this node. The code is hashed together with our own keys, so that other
   * device cannot be handed a substituted key and sign for it by mistake.
   */
  async requestDeviceAdd(userId) {
    if (!this._sessionKeys || !this._sessionKeys.skEdB64) {
      throw new Error('Identity keys unavailable in this browser — sign in again');
    }
    if (!this._nonceNode || !this.nodePk) {
      throw new Error('Handshake incomplete — reconnect and retry');
    }
    const C = window.MeshBayCrypto;
    const pkEdB64 = await _pkEdFromSk(this._sessionKeys.skEdB64);
    const pkXB64 = await _pkFromSk(this._sessionKeys.skXB64);

    // 40 bits from the platform CSPRNG, in the same alphabet as a pairing code
    // so it reads and types the same way.
    const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
    const bytes = crypto.getRandomValues(new Uint8Array(8));
    const raw = Array.from(bytes, b => alphabet[b % alphabet.length]).join('');
    const code = `${raw.slice(0, 4)}-${raw.slice(4)}`;

    const codeHash = await C.deviceCodeHash(
      C.normalizeCode(code), pkEdB64, pkXB64);
    const ts = Math.floor(Date.now() / 1000);
    const transcript = C.deviceRequestTranscript(
      this.nodePk, userId, pkEdB64, pkXB64, codeHash, this._nonceNode, ts);
    const sig = await window.MeshBayKeys.signBytes(
      this._sessionKeys.skEdB64, transcript);

    const resp = await this._sendAndWait({
      type: 'device_add_request', v: '0.1',
      pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig,
    });
    if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
    return { code, expiresAt: resp.expires_at };
  }

  /**
   * Approve a device waiting with this code.
   *
   * The node is a mailbox: it is asked for a request matching
   * sha256(code ‖ keys), and the keys in that hash came from the device that
   * filed it. A node returning something else produces no match, so there is
   * nothing to sign and nothing for a person to misread.
   */
  async approveDevice(userId, code) {
    if (!this._sessionKeys || !this._sessionKeys.skEdB64) {
      throw new Error('Identity keys unavailable in this browser — sign in again');
    }
    if (!this._nonceNode || !this.nodePk) {
      throw new Error('Handshake incomplete — reconnect and retry');
    }
    const C = window.MeshBayCrypto;
    const normalized = C.normalizeCode(code);

    // The code never leaves this browser. The node lists what is pending, each
    // with the hash the requesting device computed over the code and its own
    // keys; we recompute and keep the one that matches. A node offering
    // fabricated keys would have to produce a hash matching sha256(code ‖
    // fabricated) — and it does not know the code.
    const listed = await this._sendAndWait({ type: 'device_lookup', v: '0.1' });
    if (listed.type === 'error') throw new Error(listed.detail || 'Not found');

    let match = null;
    for (const req of listed.requests || []) {
      const expect = await C.deviceCodeHash(
        normalized, req.pk_ed25519, req.pk_x25519);
      if (expect === req.code_hash) { match = req; break; }
    }
    if (!match) {
      throw new Error('No device is waiting with that code');
    }
    return this._countersign(userId, match.code_hash,
                             match.pk_ed25519, match.pk_x25519);
  }

  async _countersign(userId, codeHash, pkEdB64, pkXB64) {
    const C = window.MeshBayCrypto;
    const ts = Math.floor(Date.now() / 1000);
    const transcript = C.deviceAddTranscript(
      this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts);
    const sig = await window.MeshBayKeys.signBytes(
      this._sessionKeys.skEdB64, transcript);
    const resp = await this._sendAndWait({
      type: 'device_add', v: '0.1',
      pk_ed25519: pkEdB64, pk_x25519: pkXB64, code_hash: codeHash, ts, sig,
    });
    if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
    return resp;
  }

  async listDevices() {
    const resp = await this._sendAndWait({ type: 'device_list', v: '0.1' });
    if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
    return { devices: resp.devices || [], pending: resp.pending || 0 };
  }

  /** Retire a device — a lost laptop. Countersigned like an addition. */
  async revokeDevice(userId, pkEdB64, pkXB64) {
    const C = window.MeshBayCrypto;
    const ts = Math.floor(Date.now() / 1000);
    const transcript = C.deviceAddTranscript(
      this.nodePk, userId, pkEdB64, pkXB64, this._nonceNode, ts);
    const sig = await window.MeshBayKeys.signBytes(
      this._sessionKeys.skEdB64, transcript);
    const resp = await this._sendAndWait({
      type: 'device_revoke', v: '0.1', pk_ed25519: pkEdB64, ts, sig,
    });
    if (resp.type === 'error') throw new Error(resp.detail || 'Refused');
    return resp;
  }

  /**
   * Withdraw our key backup from this node.
   *
   * The counterpart of storeKeypairBundle: turning the setting off has to remove
   * what is already stored, not merely stop adding to it — otherwise the blob
   * stays on every node the account has ever joined (C4).
   */
  async deleteKeypairBundle() {
    const msg = await this._sendAndWait({
      type: 'keypair_bundle_delete', v: '0.1',
    });
    if (msg.type === 'error') throw new Error(msg.detail);
    return msg;
  }

  async storeKeypairBundle(bundleEnc, recoveryEnc) {
    const msg = await this._sendAndWait({
      type: 'keypair_bundle_store',
      v: '0.1',
      bundle_enc: bundleEnc,
      // MNP 0.14, optional: the recovery-wrapped copy. Omitted for a plain
      // re-backup; the node keeps any copy it already holds.
      ...(recoveryEnc ? { bundle_enc_recovery: recoveryEnc } : {}),
    });
    if (msg.type === 'error') throw new Error(msg.detail);
    return msg;
  }

  // ── Per-account blobs (playlists) ──────────────────────────────────────

  async storeUserBlob(kind, rev, blobEnc) {
    const msg = await this._sendAndWait({
      type: 'user_blob_store', v: '0.1',
      kind, rev, blob_enc: blobEnc,
    });
    // A refused store is the size cap or the account quota, and the node says
    // which. Thrown rather than swallowed: the caller has to be able to tell
    // the reader that this playlist did not save.
    if (msg.type === 'error') throw new Error(msg.detail);
    return msg;
  }

  /**
   * One blob, or `{ rev: null, blob_enc: null }` when this account has never
   * written that kind here — which is the ordinary state of a node the reader
   * has just joined, and must not read as a failure.
   */
  async fetchUserBlob(kind) {
    const msg = await this._sendAndWait({
      type: 'user_blob_fetch', v: '0.1', kind,
    });
    if (msg.type === 'error') throw new Error(msg.detail);
    return { rev: msg.rev ?? null, blob_enc: msg.blob_enc ?? null };
  }

  /**
   * Which kinds this node holds, and at what revision — never a payload.
   *
   * What a client that has lost its local state needs: playlist ids are
   * client-generated, so there is nothing to fetch by name until this says
   * what the names are.
   */
  async listUserBlobs() {
    const msg = await this._sendAndWait({
      type: 'user_blob_list', v: '0.1',
    });
    if (msg.type === 'error') throw new Error(msg.detail);
    return msg.blobs || [];
  }

  async deleteUserBlob(kind) {
    const msg = await this._sendAndWait({
      type: 'user_blob_delete', v: '0.1', kind,
    });
    if (msg.type === 'error') throw new Error(msg.detail);
    return msg;
  }
});