aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app.js8
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/crypto.js3
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js2
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css9
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js188
14 files changed, 228 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
index a7b8fd9..a793527 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app.js
@@ -605,6 +605,14 @@ function ChatPanel({ transportRef, username, userId, entries, gekRef,
${showSender && html`
<div class="chat-sender">${displayName}</div>
`}
+ ${m.trust === 'changed' && html`
+ ${/* The one notice §4.8 budgets for. Not shown for a first
+ sight, which every account has exactly once — an alarm
+ that fires on normal events stops being read. */ ''}
+ <div class="chat-key-changed" title="${t('chat.key_changed_hint')}">
+ ${t('chat.key_changed')}
+ </div>
+ `}
<div class="chat-bubble ${isOwn ? 'chat-bubble-own' : ''}">
${att ? html`
<div class="chat-attachment" style="cursor:pointer" onClick=${() => {
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
index 24d1399..fd24404 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/crypto.js
@@ -134,6 +134,9 @@ const GROUPBOX_INFO = {
upload: new TextEncoder().encode('meshbay:upload:v1'),
// The group's chat epoch keys, on their way to a member.
chat_keys: new TextEncoder().encode('meshbay:chat_keys:v1'),
+ // The group's members and their device keys, with the evidence that admitted
+ // each one.
+ roster: new TextEncoder().encode('meshbay:roster:v1'),
};
/**
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index 180c762..e5febde 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -659,6 +659,8 @@ export default {
'chat.unreadable_decrypt': 'Diese Nachricht konnte nicht entschlüsselt werden',
'chat.unreadable_envelope': 'Diese Nachricht kam unvollständig an',
'chat.unreadable_format': 'Diese Nachricht erfordert eine neuere Version von MeshBay',
+ 'chat.key_changed': 'Dieses Konto verwendet einen Schlüssel, den Sie noch nie gesehen haben',
+ 'chat.key_changed_hint': 'Der Node sagt, diese Nachricht komme von diesem Konto, aber dessen Geräteschlüssel ist keiner, den Sie akzeptiert haben, und nichts hat ihn signiert. Es kann ein anderswo hinzugefügtes Gerät sein — oder der Node, der die falsche Person nennt.',
'chat.encrypted_needs_newer': 'Diese Unterhaltung ist verschlüsselt und dieser Client kann sie nicht lesen — MeshBay aktualisieren',
'chat.encrypted_cannot_send': 'Diese Unterhaltung ist verschlüsselt und dieses Gerät kann noch nicht darin schreiben',
'group.leave': 'Gruppe verlassen',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 996d5b6..a171304 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -775,6 +775,8 @@ export default {
'chat.unreadable_decrypt': 'This message could not be decrypted',
'chat.unreadable_envelope': 'This message arrived incomplete',
'chat.unreadable_format': 'This message needs a newer version of MeshBay',
+ 'chat.key_changed': 'This account is using a key you have not seen before',
+ 'chat.key_changed_hint': 'The node says this message came from this account, but its device key is not one you have accepted before and nothing signed it into place. It may be a new device added elsewhere — or the node naming the wrong person.',
'chat.encrypted_needs_newer': 'This conversation is encrypted and this client cannot read it — update MeshBay',
'chat.encrypted_cannot_send': 'This conversation is encrypted and this device cannot post to it yet',
'group.leave': 'Leave group',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index 10fac89..3b081cf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -655,6 +655,8 @@ export default {
'chat.unreadable_decrypt': 'No se pudo descifrar este mensaje',
'chat.unreadable_envelope': 'Este mensaje llegó incompleto',
'chat.unreadable_format': 'Este mensaje necesita una versión más reciente de MeshBay',
+ 'chat.key_changed': 'Esta cuenta usa una clave que no has visto antes',
+ 'chat.key_changed_hint': 'El nodo dice que este mensaje viene de esta cuenta, pero su clave de dispositivo no es una que hayas aceptado y nada la firmó. Puede ser un dispositivo nuevo añadido en otro sitio — o el nodo nombrando a la persona equivocada.',
'chat.encrypted_needs_newer': 'Esta conversación está cifrada y este cliente no puede leerla: actualiza MeshBay',
'chat.encrypted_cannot_send': 'Esta conversación está cifrada y este dispositivo aún no puede escribir en ella',
'group.leave': 'Salir del grupo',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index f470bf1..7ecd4a2 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -658,6 +658,8 @@ export default {
'chat.unreadable_decrypt': 'Ce message n\'a pas pu être déchiffré',
'chat.unreadable_envelope': 'Ce message est arrivé incomplet',
'chat.unreadable_format': 'Ce message nécessite une version plus récente de MeshBay',
+ 'chat.key_changed': 'Ce compte utilise une clé que vous n\'avez jamais vue',
+ 'chat.key_changed_hint': 'Le nœud affirme que ce message vient de ce compte, mais sa clé d\'appareil n\'est pas une de celles que vous avez acceptées et rien ne l\'a signée. Ce peut être un nouvel appareil ajouté ailleurs — ou le nœud qui nomme la mauvaise personne.',
'chat.encrypted_needs_newer': 'Cette conversation est chiffrée et ce client ne peut pas la lire — mettez MeshBay à jour',
'chat.encrypted_cannot_send': 'Cette conversation est chiffrée et cet appareil ne peut pas encore y écrire',
'group.leave': 'Quitter le groupe',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index f971320..6ae92cc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -657,6 +657,8 @@ export default {
'chat.unreadable_decrypt': 'Impossibile decifrare questo messaggio',
'chat.unreadable_envelope': 'Questo messaggio è arrivato incompleto',
'chat.unreadable_format': 'Questo messaggio richiede una versione più recente di MeshBay',
+ 'chat.key_changed': 'Questo account usa una chiave che non hai mai visto',
+ 'chat.key_changed_hint': 'Il nodo dice che questo messaggio viene da questo account, ma la sua chiave di dispositivo non è una che hai accettato e nulla l\'ha firmata. Può essere un nuovo dispositivo aggiunto altrove — o il nodo che nomina la persona sbagliata.',
'chat.encrypted_needs_newer': 'Questa conversazione è cifrata e questo client non può leggerla: aggiorna MeshBay',
'chat.encrypted_cannot_send': 'Questa conversazione è cifrata e questo dispositivo non può ancora scriverci',
'group.leave': 'Esci dal gruppo',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index 0817608..e5a4a21 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -645,6 +645,8 @@ export default {
'chat.unreadable_decrypt': 'このメッセージを復号できませんでした',
'chat.unreadable_envelope': 'このメッセージは不完全な状態で届きました',
'chat.unreadable_format': 'このメッセージには新しいバージョンの MeshBay が必要です',
+ 'chat.key_changed': 'このアカウントは見覚えのない鍵を使っています',
+ 'chat.key_changed_hint': 'ノードはこのメッセージがこのアカウントからだと言っていますが、その端末鍵はあなたが受け入れたものではなく、署名もありません。別の場所で追加された新しい端末かもしれませんし、ノードが別人を名乗らせているのかもしれません。',
'chat.encrypted_needs_newer': 'この会話は暗号化されており、このクライアントでは読めません — MeshBay を更新してください',
'chat.encrypted_cannot_send': 'この会話は暗号化されており、この端末はまだ投稿できません',
'group.leave': 'グループを退出',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 25bf89d..7bee011 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -659,6 +659,8 @@ export default {
'chat.unreadable_decrypt': 'Dit bericht kon niet worden ontsleuteld',
'chat.unreadable_envelope': 'Dit bericht kwam onvolledig aan',
'chat.unreadable_format': 'Dit bericht vereist een nieuwere versie van MeshBay',
+ 'chat.key_changed': 'Dit account gebruikt een sleutel die u nog niet eerder zag',
+ 'chat.key_changed_hint': 'De node zegt dat dit bericht van dit account komt, maar de apparaatsleutel is er geen die u hebt geaccepteerd en niets heeft hem ondertekend. Het kan een elders toegevoegd apparaat zijn — of de node die de verkeerde persoon noemt.',
'chat.encrypted_needs_newer': 'Dit gesprek is versleuteld en deze client kan het niet lezen — werk MeshBay bij',
'chat.encrypted_cannot_send': 'Dit gesprek is versleuteld en dit apparaat kan er nog niet in schrijven',
'group.leave': 'Groep verlaten',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index d63d4ae..ddcf9bc 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -677,6 +677,8 @@ export default {
'chat.unreadable_decrypt': 'Nie udało się odszyfrować tej wiadomości',
'chat.unreadable_envelope': 'Ta wiadomość dotarła niekompletna',
'chat.unreadable_format': 'Ta wiadomość wymaga nowszej wersji MeshBay',
+ 'chat.key_changed': 'To konto używa klucza, którego wcześniej nie widziałeś',
+ 'chat.key_changed_hint': 'Węzeł twierdzi, że ta wiadomość pochodzi z tego konta, ale jego klucz urządzenia nie jest jednym z zaakceptowanych i nic go nie podpisało. Może to być nowe urządzenie dodane gdzie indziej — albo węzeł wskazujący niewłaściwą osobę.',
'chat.encrypted_needs_newer': 'Ta rozmowa jest zaszyfrowana i ten klient nie może jej odczytać — zaktualizuj MeshBay',
'chat.encrypted_cannot_send': 'Ta rozmowa jest zaszyfrowana i to urządzenie nie może jeszcze w niej pisać',
'group.leave': 'Opuść grupę',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index f23f237..ce31383 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -656,6 +656,8 @@ export default {
'chat.unreadable_decrypt': 'Não foi possível descriptografar esta mensagem',
'chat.unreadable_envelope': 'Esta mensagem chegou incompleta',
'chat.unreadable_format': 'Esta mensagem exige uma versão mais recente do MeshBay',
+ 'chat.key_changed': 'Esta conta está usando uma chave que você nunca viu',
+ 'chat.key_changed_hint': 'O nó diz que esta mensagem veio desta conta, mas a chave do dispositivo não é uma que você aceitou e nada a assinou. Pode ser um novo dispositivo adicionado em outro lugar — ou o nó apontando a pessoa errada.',
'chat.encrypted_needs_newer': 'Esta conversa é criptografada e este cliente não consegue lê-la — atualize o MeshBay',
'chat.encrypted_cannot_send': 'Esta conversa é criptografada e este dispositivo ainda não pode escrever nela',
'group.leave': 'Sair do grupo',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index cd0332c..260d11c 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -632,6 +632,8 @@ export default {
'chat.unreadable_decrypt': '无法解密此消息',
'chat.unreadable_envelope': '此消息传输不完整',
'chat.unreadable_format': '此消息需要更新版本的 MeshBay',
+ 'chat.key_changed': '此账户使用了你未见过的密钥',
+ 'chat.key_changed_hint': '节点称这条消息来自该账户,但其设备密钥不是你接受过的,也没有任何签名为它背书。可能是在别处新增的设备——也可能是节点指认了错误的人。',
'chat.encrypted_needs_newer': '该对话已加密,此客户端无法读取 — 请更新 MeshBay',
'chat.encrypted_cannot_send': '该对话已加密,此设备尚无法在其中发言',
'group.leave': '退出群组',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index a6a6e7c..a35137b 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -4229,3 +4229,12 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; }
font-style: italic;
opacity: 0.75;
}
+
+/* "This account is using a key you have not seen before." Deliberately quiet:
+ it is a notice, not an error, and it appears on a message that is otherwise
+ perfectly readable. An alarm here would be clicked through. */
+.chat-key-changed {
+ font-size: 0.82em;
+ color: var(--warn);
+ margin-bottom: 2px;
+}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 1f6dd8f..c4a24c5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -897,6 +897,8 @@ class MeshBayTransport {
// and keeping a stale set would silently seal under a retired key.
this._chatKeys = null;
this._chatKeysInFlight = null;
+ this._roster = null;
+ this._rosterInFlight = null;
// Not gated on a version any more: a node that reached this point speaks
// MNP 2.0, where identifying the device is what makes chat possible at
// all. `check_version` refused anything older before we got here.
@@ -1636,6 +1638,87 @@ class MeshBayTransport {
}
/**
+ * Who is in this group and which device keys they hold — verified here, not
+ * taken on the node's word.
+ *
+ * Tier 2 of `desktop-client-v1.md` §4.8. 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 `per-node-identity-v1.md`'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
+ * §4.8 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';
+ }
+
+ /**
* Every chat epoch key for this group, fetched once per connection.
*
* Every epoch, not just the current one — that is what lets a device linked
@@ -1708,6 +1791,11 @@ class MeshBayTransport {
try {
const plain = msgpack_decode(
await C.openChat(epochKey, gid, epoch, deviceB64, nonce, ct));
+ // The signature proves *a device* wrote this. Whether that device belongs
+ // to the account the node named is a separate question, and one this
+ // client answers for itself from the roster (Tier 2) rather than taking
+ // `sender_id` on trust. `changed` is the only value worth a notice.
+ const trust = await this.accountDeviceStatus(base.sender_id, deviceB64);
return {
...base,
payload: String(plain.text || ''),
@@ -1715,6 +1803,7 @@ class MeshBayTransport {
thread_id: plain.thread_id ?? base.thread_id,
device: deviceB64,
verified: true,
+ trust,
};
} catch {
return { ...base, payload: '', unreadable: 'decrypt' };
@@ -3160,6 +3249,105 @@ class MeshBayTransport {
}
/**
+ * Walk each account's devices outwards from the one nobody countersigned.
+ *
+ * A device is *verified* when a chain of real signatures reaches it from that
+ * account's root — the device an operator code admitted, which by definition
+ * has no countersignature and is the trust-on-first-use anchor. Anything the
+ * node lists but cannot evidence stays out of `verified`, so a substituted key
+ * is not laundered into the set merely by being mentioned.
+ *
+ * Devices pinned before the evidence was kept (2026-09-07) carry no signature
+ * and are treated exactly like a root: honest about what they are, rather than
+ * quietly accepted as verified.
+ */
+async function _verifyRoster(payload, nodePk) {
+ const C = window.MeshBayCrypto;
+ const byAccount = new Map();
+ const devices = payload.devices || [];
+
+ const per = new Map();
+ for (const d of devices) {
+ if (!per.has(d.user_id)) per.set(d.user_id, []);
+ per.get(d.user_id).push(d);
+ }
+
+ for (const [userId, list] of per) {
+ // Roots first: no countersigner, or one whose evidence was never stored.
+ const verified = [];
+ const chain = new Map();
+ const pending = [];
+ for (const d of list) {
+ // A root is a device that names **no** countersigner: an operator code
+ // admitted it, and there is nothing to verify.
+ //
+ // Naming one and carrying no proof is *not* a root, and treating it as
+ // one was a hole this file's tests caught: a node that writes the roster
+ // can put any key it likes in an account's row, and if "no signature"
+ // meant "root" it would have been laundered straight into `verified`.
+ // Such a device is unevidenced — which is also the honest reading of one
+ // pinned before the evidence was kept.
+ if (!d.added_by_pk) verified.push(d.pk_ed25519);
+ else pending.push(d);
+ }
+ // Then repeatedly admit anything countersigned by something already in.
+ let progress = true;
+ while (progress && pending.length) {
+ progress = false;
+ for (let i = pending.length - 1; i >= 0; i--) {
+ const d = pending[i];
+ if (!verified.includes(d.added_by_pk)) continue;
+ let ok = false;
+ try {
+ const transcript = C.deviceAddTranscript(
+ payload.node_pk || nodePk, userId, d.pk_ed25519, d.pk_x25519,
+ C.b64decode(d.add_nonce), d.add_ts);
+ ok = await C.verifyNodeSignature(d.added_by_pk, d.add_sig, transcript);
+ } catch { ok = false; }
+ if (ok) {
+ verified.push(d.pk_ed25519);
+ chain.set(d.pk_ed25519, d.added_by_pk);
+ pending.splice(i, 1);
+ progress = true;
+ }
+ }
+ }
+ byAccount.set(userId, {
+ username: (list[0] || {}).username || '',
+ all: list.map(d => d.pk_ed25519),
+ verified,
+ chain,
+ // Listed by the node and not reachable by any chain of signatures.
+ unevidenced: pending.map(d => d.pk_ed25519),
+ });
+ }
+ return { byAccount };
+}
+
+// Which device keys this browser has accepted for each account, per node.
+// localStorage rather than a runtime capability: it is a per-viewer
+// convenience whose loss costs one "first sight" and never a wrong answer —
+// forgetting a pin makes the next key read as `first`, not as verified.
+const _PIN_NS = 'meshbay_account_pins';
+
+function _pinKey(nodePk, userId) {
+ return `${_PIN_NS}:${nodePk || ''}:${userId}`;
+}
+
+async function _readPinnedAccount(nodePk, userId) {
+ try {
+ const raw = localStorage.getItem(_pinKey(nodePk, userId));
+ return raw ? JSON.parse(raw) : null;
+ } catch { return null; }
+}
+
+async function _writePinnedAccount(nodePk, userId, keys) {
+ try {
+ localStorage.setItem(_pinKey(nodePk, userId), JSON.stringify(keys));
+ } catch { /* private window, or storage refused — one more "first sight" */ }
+}
+
+/**
* A wire payload as text.
*
* A plaintext message arrives as a string from the node; msgpack `bin` arrives