// 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; } });