// 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._identity) { 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, pkXB64 } = this._identity; const ts = Math.floor(Date.now() / 1000); const sig = await this._identity.signAs('join', { nodePk: this.nodePk, groupId: groupId || '', userId, nonceNode: C.b64encode(this._nonceNode), ts, }); 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 myPkX = Uint8Array.from(atob(pkXB64), c => c.charCodeAt(0)); const gekRaw = await C.unwrapGEK(resp, (pk) => this._identity.shared(pk), 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._identity) { 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, pkXB64 } = this._identity; // 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 sig = await this._identity.signAs('device_request', { nodePk: this.nodePk, userId, codeHash, nonceNode: C.b64encode(this._nonceNode), ts, }); 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._identity) { 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 sig = await this._identity.signAs('device_add', { nodePk: this.nodePk, userId, pkEd: pkEdB64, pkX: pkXB64, nonceNode: C.b64encode(this._nonceNode), ts, }); 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 sig = await this._identity.signAs('device_revoke', { nodePk: this.nodePk, userId, pkEd: pkEdB64, nonceNode: C.b64encode(this._nonceNode), ts, }); 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 browser access 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). Called by * `settleNodeBundle` only, for an account whose identities the desktop * application holds. */ 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; } /** * Leave on this node what should be there, once the connection is made. * * In a browser: the bundle of an identity just created, as always — it is * how the next browser becomes the same person here. In the desktop * application, which keeps the identity itself: nothing when the account * has no browser access (and whatever was left before is withdrawn), or the * identity sealed under the current key when it has — sealed again whenever * the key changed since, which is what carries a passphrase change to nodes * that were offline when it happened. */ async settleNodeBundle() { const id = this._identity; if (!id) return; if (!id.native) { if (this._newNodeBundle) { await this.storeKeypairBundle(this._newNodeBundle, this._newNodeBundleRecovery); this._newNodeBundle = null; this._newNodeBundleRecovery = null; } return; } const P = window.MeshBayPlatform.keys; const uid = this._userId; if (!await P.browserAccess(uid)) { if (this._nodeHasBundle) { await this.deleteKeypairBundle(); this._nodeHasBundle = false; } return; } if (this._nodeHasBundle && id.sealedWith && id.sealedWith === await P.fingerprint(uid)) return; const sealed = await P.sealBundle(uid, this.nodePk); await this.storeKeypairBundle(sealed.bundle, null); await P.markSealed(uid, this.nodePk, sealed.fingerprint); id.sealedWith = sealed.fingerprint; this._nodeHasBundle = true; } 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; } });