summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-07 21:04:56 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-07 21:04:56 +0200
commit3bd31db9d4fa2352f1095dcc630c77915d0774b8 (patch)
tree5674ddabdf4037ee5b7c2d1bc682e941d7a7da01 /packages
parent7fa74d722108ca4338d14db4ecba53800df86fca (diff)
downloadmeshbay-3bd31db9d4fa2352f1095dcc630c77915d0774b8.tar.gz
feat(chat): Tier 2 — a member verifies another member's device itself
Chat messages have been signed by the sending device since MNP 2.0, but a reader had no way to know that the device belonged to the account the node named: the signature proved *a device*, and `sender_id` was still the node's word. This closes that for any account a client has already seen. **What was blocking it was not effort — the evidence was not being kept.** `_do_device_add` verified the countersignature that admits a second device and stored only `added_by_pk`: *which* key approved, never the proof. And `device_add_transcript` binds `nonce_node`, the approving connection's handshake nonce, so even a stored signature was unverifiable by anyone who had not been on that connection. `identities` gains `add_sig`, `add_nonce` and `add_ts`, added before the migration's early return — which fires on every roster widened since 2026-08-18, i.e. all of them, so putting them inside it would have meant they never arrived. `group_roster_req`/`resp` relays, sealed under a new groupbox purpose and answered to **any member of the group**, every live device of every active member with the evidence that admitted it. The node decides nothing: it hands over evidence and the client walks the chain from each account's root outwards (`_verifyRoster`). That is deliberate — the node is the party the property holds against, so it is not asked to assert trust. Two holes the tests caught while this was being built: - "no signature" was being treated as a trust root, so a node that writes the roster could put any key in an account's row and have it laundered straight into the verified set. A root is a device that names **no** countersigner. - pinning only the verified subset at first sight raised "key changed" on legitimate second devices whose countersignature predates this change. First sight pins everything the node says, because that is what trust-on-first-use means and an alarm that fires on normal events stops being read. The property, and it must not be rounded up: **once a client has seen an account, a node that later substitutes a key for it is detected. Nothing is gained at first sight**, where there is nothing to compare against — the same boundary `per-node-identity-v1.md` draws, unmoved. The cost, stated because it is real: the roster is member-visible, so every member learns how many devices the others hold and their public keys. It stays inside the group, the hub is not involved, and it is scoped per group. A member who cannot see the keys cannot check them. User-visible surface: one notice, "this account is using a key you have not seen before", in ten languages. Nothing else. 16 tests — 7 on the node (the evidence is stored, it verifies from the roster alone, a fabricated device carries none, another group's members are not disclosed), 9 running the shipped `_verifyRoster` under node against rosters built by the shipped Python: a chain of three in any order, a signature by the wrong key, one for another node, one for another account, and two fabricated devices signing each other admitting nothing. Tier 3 (operator-signed roster attestation) stays deferred, with nothing depending on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TZZxYjz8YeWRz13xDi8LJr
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-common/src/meshbay_common/groupbox.py5
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py8
-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
-rw-r--r--packages/meshbay-hub/tests/test_account_pinning.py227
-rw-r--r--packages/meshbay-node/src/meshbay_node/roster.py81
-rw-r--r--packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py64
-rw-r--r--packages/meshbay-node/tests/test_group_roster.py242
20 files changed, 850 insertions, 5 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/groupbox.py b/packages/meshbay-common/src/meshbay_common/groupbox.py
index e020ed8..3b45dba 100644
--- a/packages/meshbay-common/src/meshbay_common/groupbox.py
+++ b/packages/meshbay-common/src/meshbay_common/groupbox.py
@@ -54,6 +54,10 @@ PURPOSE_UPLOAD = "upload"
# the chat archive is encrypted under; this is only how they travel, which is
# why rotating the group key costs a re-wrap and not a re-encryption.
PURPOSE_CHAT_KEYS = "chat_keys"
+# The group's roster of members and their device keys, on its way to a member.
+# Sealed for the same reason the index is: it is the group's membership, and a
+# peer that has not completed the handshake has no business reading it.
+PURPOSE_ROSTER = "roster"
# `salt=None` here and `salt: new Uint8Array(0)` in crypto.js agree — RFC 5869
# extracts with a zero key either way. Already proven in production by
@@ -63,6 +67,7 @@ _INFO = {
PURPOSE_ACK: b"meshbay:ack:v1",
PURPOSE_UPLOAD: b"meshbay:upload:v1",
PURPOSE_CHAT_KEYS: b"meshbay:chat_keys:v1",
+ PURPOSE_ROSTER: b"meshbay:roster:v1",
}
# One subkey per purpose, and `seal` draws a fresh 96-bit nonce per message, so
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index cc5324a..e092353 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -241,6 +241,14 @@ class MNP:
ROOT_EJECT_ACK = "root_eject_ack"
ROOT_PLUG = "root_plug" # operator → node: re-enable an ejected root
ROOT_PLUG_ACK = "root_plug_ack"
+ # Member → node: who is in this group and which device keys they hold, with
+ # the countersignature that admitted each one. Distinct from ROSTER_READ
+ # below, which is the operator's view of the whole node: this is scoped to
+ # one group and answers any member of it, because the point is that a member
+ # verifies another member's device *for themselves* rather than trusting the
+ # node's `sender_id` (Tier 2, docs/desktop-client-v1.md §4.8).
+ GROUP_ROSTER_REQ = "group_roster_req"
+ GROUP_ROSTER_RESP = "group_roster_resp"
ROSTER_READ = "roster_read" # operator → node: list pinned identities + members
ROSTER_READ_ACK = "roster_read_ack"
DENYLIST_READ = "denylist_read" # operator → node: show denylist entries
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
diff --git a/packages/meshbay-hub/tests/test_account_pinning.py b/packages/meshbay-hub/tests/test_account_pinning.py
new file mode 100644
index 0000000..192bd25
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_account_pinning.py
@@ -0,0 +1,227 @@
+"""
+Tier 2, the half that decides anything: the client walks the chain.
+
+The node relays evidence and asserts nothing (`test_group_roster.py`). What
+turns that into a property is here — `_verifyRoster` in the shipped
+`transport.js`, run under node against rosters built by the shipped Python, so
+neither side is a model of the other.
+
+The property, stated exactly: **once this client has seen an account, a node
+that later substitutes a key for it is detected.** Nothing is gained at first
+sight, where there is nothing to compare against. A device the node lists but
+cannot evidence never enters `verified`, which is what stops a fabricated key
+being laundered into the set merely by being mentioned.
+"""
+import base64
+import json
+import shutil
+import subprocess
+import tempfile
+import time
+from pathlib import Path
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from meshbay_common.device import device_add_transcript
+
+STATIC = (Path(__file__).resolve().parents[1]
+ / "src" / "meshbay_hub" / "static")
+TRANSPORT = STATIC / "transport.js"
+CRYPTO = STATIC / "crypto.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not TRANSPORT.exists(),
+ reason="node or the SPA sources are not available")
+
+NODE_PK = "Tk9ERVBL"
+NONCE = b"\x11" * 32
+
+_HARNESS = r"""
+const fs = require('fs');
+// The same stub the other transport.js harnesses use (upload_seal_probe.mjs,
+// index_seal_probe.mjs): the module reads `location.hash` at load time for its
+// debug flag, and registers listeners.
+globalThis.window = globalThis;
+globalThis.addEventListener = () => {};
+globalThis.removeEventListener = () => {};
+globalThis.location = { hash: '' };
+globalThis.document = {
+ addEventListener() {}, removeEventListener() {}, visibilityState: 'visible',
+};
+globalThis.localStorage = {
+ _v: {}, getItem(k) { return this._v[k] ?? null; },
+ setItem(k, v) { this._v[k] = String(v); },
+};
+// crypto.js publishes onto window; transport.js reads it from there.
+new Function(fs.readFileSync(process.argv[2], 'utf8'))();
+const T = new Function(
+ fs.readFileSync(process.argv[3], 'utf8') + '\nreturn { _verifyRoster };')();
+
+(async () => {
+ const input = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
+ const out = await T._verifyRoster(input.payload, input.node_pk);
+ const result = {};
+ for (const [user, e] of out.byAccount) {
+ result[user] = { verified: e.verified, unevidenced: e.unevidenced,
+ all: e.all };
+ }
+ process.stdout.write(JSON.stringify(result));
+})().catch(e => { console.error(e); process.exit(1); });
+"""
+
+
+def _device(sk=None):
+ sk = sk or Ed25519PrivateKey.generate()
+ raw = sk.public_key().public_bytes(
+ serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+ return sk, base64.b64encode(raw).decode()
+
+
+def _entry(user, pk_ed, pk_x="cGtY", *, added_by="", sk_signer=None,
+ node_pk=NODE_PK):
+ """One roster row, countersigned for real when a signer is given."""
+ ts = int(time.time())
+ row = {"user_id": user, "username": user, "pk_ed25519": pk_ed,
+ "pk_x25519": pk_x, "added_by_pk": added_by, "add_sig": "",
+ "add_nonce": "", "add_ts": 0, "pinned_at": ""}
+ if sk_signer is not None:
+ transcript = device_add_transcript(
+ node_pk_b64=node_pk, user_id=user, pk_ed25519_b64=pk_ed,
+ pk_x25519_b64=pk_x, nonce_node=NONCE, ts=ts)
+ row["add_sig"] = base64.b64encode(sk_signer.sign(transcript)).decode()
+ row["add_nonce"] = base64.b64encode(NONCE).decode()
+ row["add_ts"] = ts
+ return row
+
+
+def _verify(devices, node_pk=NODE_PK):
+ with tempfile.TemporaryDirectory() as tmp:
+ h = Path(tmp) / "h.js"
+ h.write_text(_HARNESS)
+ payload = Path(tmp) / "in.json"
+ payload.write_text(json.dumps(
+ {"payload": {"devices": devices, "node_pk": node_pk},
+ "node_pk": node_pk}))
+ run = subprocess.run(
+ ["node", str(h), str(CRYPTO), str(TRANSPORT), str(payload)],
+ capture_output=True, timeout=60)
+ assert run.returncode == 0, run.stderr.decode()[-2000:]
+ return json.loads(run.stdout.decode())
+
+
+def test_a_lone_first_device_is_the_trust_root():
+ """No countersignature and none possible — an operator code admitted it."""
+ _sk, pk = _device()
+ out = _verify([_entry("alice", pk)])
+ assert out["alice"]["verified"] == [pk]
+ assert out["alice"]["unevidenced"] == []
+
+
+def test_a_countersigned_second_device_is_reached():
+ """The ordinary case: Alice adds a laptop, and nobody compares digits."""
+ sk_a, pk_a = _device()
+ _sk_b, pk_b = _device()
+ out = _verify([_entry("alice", pk_a),
+ _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_a)])
+ assert sorted(out["alice"]["verified"]) == sorted([pk_a, pk_b])
+ assert out["alice"]["unevidenced"] == []
+
+
+def test_a_chain_of_three_is_walked_in_any_order():
+ """
+ A device may be countersigned by one that is itself countersigned, and the
+ roster arrives in whatever order SQL returned. The walk repeats until it
+ stops making progress rather than assuming an order.
+ """
+ sk_a, pk_a = _device()
+ sk_b, pk_b = _device()
+ _sk_c, pk_c = _device()
+ rows = [_entry("alice", pk_c, added_by=pk_b, sk_signer=sk_b),
+ _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_a),
+ _entry("alice", pk_a)]
+ out = _verify(rows)
+ assert sorted(out["alice"]["verified"]) == sorted([pk_a, pk_b, pk_c])
+
+
+def test_a_fabricated_device_is_not_verified():
+ """
+ The attack. A node writes a device of its own into Alice's row — it writes
+ the roster, so it can. It cannot sign as a key it does not hold, so no
+ chain reaches the key and it stays out of `verified`.
+ """
+ _sk_a, pk_a = _device()
+ _sk_evil, pk_evil = _device()
+ out = _verify([_entry("alice", pk_a),
+ _entry("alice", pk_evil, added_by=pk_a)]) # no signature
+ assert out["alice"]["verified"] == [pk_a]
+ assert out["alice"]["unevidenced"] == [pk_evil]
+
+
+def test_a_signature_by_the_wrong_key_is_not_verified():
+ """A real signature, from a key that is not the one it names."""
+ _sk_a, pk_a = _device()
+ sk_other, _pk_other = _device()
+ _sk_b, pk_b = _device()
+ out = _verify([_entry("alice", pk_a),
+ _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_other)])
+ assert out["alice"]["verified"] == [pk_a]
+ assert out["alice"]["unevidenced"] == [pk_b]
+
+
+def test_a_signature_for_another_node_does_not_transfer():
+ """
+ The transcript binds `node_pk`. A countersignature collected on one node
+ must not admit the same key on another — which is what an operator running
+ two nodes would otherwise be able to do to a member of both.
+ """
+ sk_a, pk_a = _device()
+ _sk_b, pk_b = _device()
+ row = _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_a,
+ node_pk="QU5PVEhFUg==")
+ out = _verify([_entry("alice", pk_a), row])
+ assert out["alice"]["unevidenced"] == [pk_b]
+
+
+def test_a_signature_for_another_account_does_not_transfer():
+ """The transcript binds the account too."""
+ sk_a, pk_a = _device()
+ _sk_b, pk_b = _device()
+ row = _entry("alice", pk_b, added_by=pk_a, sk_signer=sk_a)
+ # Same signature, presented as admitting a device of Bob's.
+ row["user_id"] = "bob"
+ out = _verify([_entry("bob", pk_a), row])
+ assert out["bob"]["unevidenced"] == [pk_b]
+
+
+def test_an_orphan_chain_is_not_admitted_by_itself():
+ """
+ Two fabricated devices signing each other. Neither is reachable from a
+ root, so a cycle admits nothing — the walk starts from what an operator
+ code admitted, not from whatever claims to be signed.
+ """
+ sk_x, pk_x = _device()
+ sk_y, pk_y = _device()
+ _sk_a, pk_a = _device()
+ out = _verify([
+ _entry("alice", pk_a),
+ _entry("alice", pk_x, added_by=pk_y, sk_signer=sk_y),
+ _entry("alice", pk_y, added_by=pk_x, sk_signer=sk_x),
+ ])
+ assert out["alice"]["verified"] == [pk_a]
+ assert sorted(out["alice"]["unevidenced"]) == sorted([pk_x, pk_y])
+
+
+def test_a_device_pinned_before_the_evidence_existed_reads_as_a_root():
+ """
+ Honest about what it is. Such a device has `added_by_pk` but no signature —
+ it was countersigned, the proof was simply not kept. Treating it as
+ verified would mean accepting an unsigned key; treating it as a root is
+ trust-on-first-use, which is what it actually is.
+ """
+ _sk_a, pk_a = _device()
+ _sk_b, pk_b = _device()
+ out = _verify([_entry("alice", pk_a),
+ _entry("alice", pk_b, added_by=pk_a)])
+ assert pk_b in out["alice"]["unevidenced"]
diff --git a/packages/meshbay-node/src/meshbay_node/roster.py b/packages/meshbay-node/src/meshbay_node/roster.py
index af87f92..2288ae4 100644
--- a/packages/meshbay-node/src/meshbay_node/roster.py
+++ b/packages/meshbay-node/src/meshbay_node/roster.py
@@ -75,6 +75,22 @@ CREATE TABLE IF NOT EXISTS identities (
-- Which already-pinned key countersigned this one into existence. Empty for
-- the first device of an account, which an operator code admitted.
added_by_pk TEXT NOT NULL DEFAULT '',
+ -- The countersignature itself, and the two fields needed to rebuild what it
+ -- signed. `added_by_pk` alone says *which* key approved and proves nothing:
+ -- a third party cannot check a signature it does not have. And the
+ -- transcript binds `nonce_node` — the approving connection's handshake
+ -- nonce — so even a stored signature is unverifiable without it.
+ --
+ -- This is what Tier 2 needs (docs/desktop-client-v1.md §4.8): relayed with
+ -- the roster, it lets a member verify for themselves that a second device
+ -- belongs to an account whose first device they have already pinned,
+ -- instead of taking the node's word. Verified and discarded until
+ -- 2026-09-07; a device pinned before that has no evidence and is
+ -- trust-on-first-use only, which the client is told rather than left to
+ -- infer.
+ add_sig TEXT NOT NULL DEFAULT '',
+ add_nonce TEXT NOT NULL DEFAULT '',
+ add_ts INTEGER NOT NULL DEFAULT 0,
revoked_at TEXT,
PRIMARY KEY (user_id, pk_ed25519)
);
@@ -231,6 +247,22 @@ class Roster:
# `pk` is the column's position in the primary key, 0 when not part of it.
key_columns = {r[1] for r in info if r[5]}
+ # The countersignature evidence (Tier 2), added 2026-09-07. Done
+ # **before** the early return below, which fires on any roster already
+ # widened to one row per device — i.e. on every node that has run since
+ # 2026-08-18, which is all of them. Putting these inside that branch
+ # would have meant they never arrived, and the symptom would have been a
+ # roster response whose devices all read as unverifiable.
+ for column in ("add_sig", "add_nonce"):
+ if column not in columns:
+ await self._db.execute(
+ f"ALTER TABLE identities ADD COLUMN {column} "
+ f"TEXT NOT NULL DEFAULT ''")
+ if "add_ts" not in columns:
+ await self._db.execute(
+ "ALTER TABLE identities ADD COLUMN add_ts INTEGER NOT NULL "
+ "DEFAULT 0")
+
if key_columns == {"user_id", "pk_ed25519"} and "revoked_at" in columns:
return
@@ -278,6 +310,9 @@ class Roster:
*,
label: str = "",
added_by_pk: str = "",
+ add_sig: str = "",
+ add_nonce: str = "",
+ add_ts: int = 0,
) -> None:
"""
Record a device for an account.
@@ -291,10 +326,10 @@ class Roster:
await self._db.execute(
"INSERT OR REPLACE INTO identities "
"(user_id, username, pk_ed25519, pk_x25519, pinned_at, pinned_via, "
- " label, added_by_pk, revoked_at) "
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)",
+ " label, added_by_pk, add_sig, add_nonce, add_ts, revoked_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)",
(user_id, username, pk_ed25519, pk_x25519, _now(), via,
- label, added_by_pk),
+ label, added_by_pk, add_sig, add_nonce, add_ts),
)
await self._db.commit()
@@ -367,6 +402,46 @@ class Roster:
await self._db.commit()
return cur.rowcount > 0
+ async def group_devices(self, group_id: str) -> list[dict]:
+ """
+ Every live device of every active member of one group, with the evidence
+ that admitted it.
+
+ For Tier 2 (`docs/desktop-client-v1.md` §4.8), and therefore
+ **member-visible** — unlike `list_identities`, which answers the
+ operator. Two consequences of that, and both are the price of the
+ feature rather than oversights:
+
+ - it tells every member of a group how many devices each other member
+ holds, and their public keys. It stays inside the group, and the hub
+ is not involved;
+ - it is scoped to *this* group. A person in two groups on one node is
+ not disclosed to the second by being in the first.
+
+ `add_sig`/`add_nonce`/`add_ts` are empty for a device pinned before the
+ evidence was kept, and for the first device of any account — which an
+ operator code admitted, not a countersignature. Both read as
+ "trust on first use" to a client, which is what they are; the client
+ must not silently treat an absent signature as a valid one.
+ """
+ assert self._db
+ async with self._db.execute(
+ "SELECT i.user_id, i.username, i.pk_ed25519, i.pk_x25519, "
+ " i.added_by_pk, i.add_sig, i.add_nonce, i.add_ts, i.pinned_at "
+ "FROM identities i "
+ "JOIN members m ON m.user_id = i.user_id "
+ "WHERE m.group_id = ? AND m.status = 'active' "
+ " AND i.revoked_at IS NULL "
+ "ORDER BY i.user_id, i.pinned_at", (group_id,)
+ ) as cur:
+ rows = await cur.fetchall()
+ return [
+ {"user_id": r[0], "username": r[1], "pk_ed25519": r[2],
+ "pk_x25519": r[3], "added_by_pk": r[4], "add_sig": r[5],
+ "add_nonce": r[6], "add_ts": r[7], "pinned_at": r[8]}
+ for r in rows
+ ]
+
async def list_identities(self) -> list[dict]:
assert self._db
async with self._db.execute(
diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
index 4e4a23f..dfabe9b 100644
--- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
+++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py
@@ -99,7 +99,12 @@ from meshbay_common.device import (
device_hello_transcript,
device_request_transcript,
)
-from meshbay_common.groupbox import PURPOSE_ACK, PURPOSE_CHAT_KEYS, seal
+from meshbay_common.groupbox import (
+ PURPOSE_ACK,
+ PURPOSE_CHAT_KEYS,
+ PURPOSE_ROSTER,
+ seal,
+)
from meshbay_common.join import (
JOIN_TTL,
ROLE_MEMBER,
@@ -571,6 +576,8 @@ class WebRTCPeerSession:
self._do_chat_epoch(msg)
elif mtype == MNP.CHAT_KEYS_REQ:
self._spawn(self._do_chat_keys_req(msg))
+ elif mtype == MNP.GROUP_ROSTER_REQ:
+ self._spawn(self._do_group_roster_req(msg))
elif mtype == MNP.MEDIA_META_REQ:
self._spawn(self._do_media_meta_request(msg))
elif mtype == MNP.SEASON_META_REQ:
@@ -1456,10 +1463,23 @@ class WebRTCPeerSession:
"detail": "That request is no longer pending"})
return
+ # The countersignature is **kept**, with the two fields needed to rebuild
+ # what it signed. Until 2026-09-07 it was verified here and thrown away,
+ # leaving only `added_by_pk` — which says *which* key approved and
+ # proves nothing to anyone else. `device_add_transcript` binds
+ # `nonce_node`, this connection's handshake nonce, so a stored signature
+ # without it is still unverifiable; that is why all three go in.
+ #
+ # This is what lets another member check for themselves that this device
+ # belongs to an account whose earlier device they have already pinned,
+ # instead of taking the node's word (Tier 2, desktop-client-v1.md §4.8).
await roster.pin_identity(
user_id=self._user_id, username=self._username or "",
pk_ed25519=pk_ed_b64, pk_x25519=pk_x_b64, via="device",
- label=str(msg.get("label", ""))[:64], added_by_pk=signer)
+ label=str(msg.get("label", ""))[:64], added_by_pk=signer,
+ add_sig=str(msg.get("sig", "")),
+ add_nonce=base64.b64encode(self._nonce_node).decode(),
+ add_ts=ts)
self._audit("device_added", f"{pk_ed_b64[:16]} by {signer[:16]}")
log.info("Device added for %s: %s (approved by %s)",
self._user_id[:8], pk_ed_b64[:16], signer[:16])
@@ -2522,6 +2542,46 @@ class WebRTCPeerSession:
self._broadcast_to_group({"type": MNP.CHAT_EPOCH_ACK,
"v": MNP_VERSION, "epoch": result["epoch"]})
+ async def _do_group_roster_req(self, msg: dict) -> None:
+ """
+ Who is in this group, and which device keys they hold.
+
+ Answers **any member**, not only the operator — that is the whole point.
+ A member verifies for themselves that a message came from a device
+ belonging to the account it claims, instead of taking the node's
+ `sender_id` on trust. What makes that possible is relayed here: each
+ device's key, which already-pinned key countersigned it, and the
+ signature plus the nonce and timestamp needed to rebuild what was
+ signed.
+
+ Sealed under a GEK-derived subkey, for the same reason the index is: it
+ is the group's membership, and a peer that has not completed the
+ handshake has no business reading it.
+
+ What this deliberately does not do is *decide* anything. The node hands
+ over evidence; the client checks the chain and keeps its own pins. A
+ node that lies here is caught by a client that has seen the account
+ before, which is the property Tier 2 buys and the reason the node is not
+ asked to assert trust.
+ """
+ gctx = self._group_ctx()
+ gek = gctx.get("gek")
+ roster = self._ctx.get("roster")
+ if not gek:
+ self._send({"type": "error", "detail": "Group encryption not initialized"})
+ return
+ if roster is None:
+ self._send({"type": "error", "detail": "Roster not available"})
+ return
+
+ devices = await roster.group_devices(self._group_id or "")
+ payload = {"devices": devices,
+ "node_pk": self._node_pk_b64()}
+ sealed = seal(gek, PURPOSE_ROSTER, MNP.GROUP_ROSTER_RESP,
+ self._group_id or "", payload)
+ self._send({"type": MNP.GROUP_ROSTER_RESP, "v": MNP_VERSION,
+ "group_id": self._group_id or "", **sealed})
+
async def _do_chat_keys_req(self, msg: dict) -> None:
"""
Hand this member every chat epoch key the group has, sealed.
diff --git a/packages/meshbay-node/tests/test_group_roster.py b/packages/meshbay-node/tests/test_group_roster.py
new file mode 100644
index 0000000..d7ca7dc
--- /dev/null
+++ b/packages/meshbay-node/tests/test_group_roster.py
@@ -0,0 +1,242 @@
+"""
+Tier 2: a member verifies another member's device for themselves.
+
+`docs/desktop-client-v1.md` §4.8, and `docs/chat-sender-keys.md` §13, which
+recorded why it could not ship with the encryption: **the evidence was not being
+kept.** `_do_device_add` verified the countersignature and stored only
+`added_by_pk` — *which* key approved, never the proof — and the transcript binds
+`nonce_node`, the approving connection's handshake nonce, so even a stored
+signature was unverifiable by anyone who was not on that connection.
+
+So the node half is two things: keep `(sig, nonce, ts)` beside the pin, and
+relay them to any member of the group who asks. The node deliberately decides
+nothing here — it hands over evidence, and the client walks the chain. A node
+that lies is caught by a client that has seen the account before, which is the
+property, and it is why trust is not something the node is asked to assert.
+
+What this does **not** claim, per the convention: nothing is gained at first
+sight. A member who has never seen Alice has nothing to compare against.
+"""
+
+import base64
+import time
+
+import pytest
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+
+from conftest import one_root
+from meshbay_common.crypto import generate_gek, pk_to_b64
+from meshbay_common.device import device_add_transcript
+from meshbay_common.groupbox import PURPOSE_ROSTER, unseal
+from meshbay_common.join import ROLE_MEMBER
+from meshbay_common.protocol import MNP
+from meshbay_node.indexer.group_index import GroupIndex
+from meshbay_node.roster import open_roster
+from meshbay_node.transport.webrtc_server import WebRTCPeerSession
+
+GROUP = "g" * 32
+NONCE = b"\x11" * 32
+
+
+@pytest.fixture
+async def roster(tmp_path):
+ r = await open_roster(tmp_path)
+ yield r
+ await r.close()
+
+
+def _keys():
+ sk_ed = Ed25519PrivateKey.generate()
+ sk_x = Ed25519PrivateKey.generate() # stand-in; only its b64 is used
+ return sk_ed, pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key())
+
+
+def _session(tmp_path, roster, gek, user_id="alice"):
+ shared = tmp_path / "shared"
+ shared.mkdir(exist_ok=True)
+ index = GroupIndex(group_id=GROUP, sk_node=Ed25519PrivateKey.generate())
+ session = WebRTCPeerSession.__new__(WebRTCPeerSession)
+ session._ctx = {
+ "roster": roster, "sk_node": index.sk_node,
+ "groups": {GROUP: {"gek": gek, "index": index,
+ "roots": one_root(shared)}},
+ }
+ session._group_id = GROUP
+ session._user_id = user_id
+ session._username = user_id
+ session._nonce_node = NONCE
+ session._pinned_pk = ""
+ session._device_confirmed = False
+ session.sent = []
+ session._send = session.sent.append
+ session._audit = lambda *a, **k: None
+ return session
+
+
+async def _add_device(session, roster, approver_sk, approver_pk, new_pk, new_px,
+ user_id="alice"):
+ """Run the real device-add path, so the evidence is stored the real way."""
+ ts = int(time.time())
+ transcript = device_add_transcript(
+ node_pk_b64=session._node_pk_b64(), user_id=user_id,
+ pk_ed25519_b64=new_pk, pk_x25519_b64=new_px, nonce_node=NONCE, ts=ts)
+ session._user_id = user_id
+ await session._do_device_add({
+ "pk_ed25519": new_pk, "pk_x25519": new_px, "ts": ts,
+ "sig": base64.b64encode(approver_sk.sign(transcript)).decode(),
+ })
+ return ts
+
+
+# ── the evidence is kept ─────────────────────────────────────────────────────
+
+async def test_the_countersignature_is_stored_not_discarded(tmp_path, roster):
+ """
+ The finding that blocked Tier 2. Before this, `add_sig` did not exist and
+ `added_by_pk` was all that survived — which proves nothing to a third party.
+ """
+ sk_a, pk_a, px_a = _keys()
+ _sk_b, pk_b, px_b = _keys()
+ await roster.pin_identity("alice", "alice", pk_a, px_a, via="code")
+ await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER,
+ status="active", approved_by="op")
+
+ session = _session(tmp_path, roster, generate_gek())
+ ts = await _add_device(session, roster, sk_a, pk_a, pk_b, px_b)
+
+ devices = {d["pk_ed25519"]: d for d in await roster.group_devices(GROUP)}
+ added = devices[pk_b]
+ assert added["added_by_pk"] == pk_a
+ assert added["add_sig"], "the countersignature was thrown away again"
+ assert added["add_ts"] == ts
+ assert base64.b64decode(added["add_nonce"]) == NONCE, (
+ "without the nonce the stored signature is unverifiable — the "
+ "transcript binds it")
+
+
+async def test_the_stored_evidence_actually_verifies(tmp_path, roster):
+ """
+ The point of storing it. A third party rebuilds the transcript from the
+ roster alone and checks the signature — no access to the connection that
+ approved it, which is the whole difficulty.
+ """
+ sk_a, pk_a, px_a = _keys()
+ _sk_b, pk_b, px_b = _keys()
+ await roster.pin_identity("alice", "alice", pk_a, px_a, via="code")
+ await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER,
+ status="active", approved_by="op")
+ session = _session(tmp_path, roster, generate_gek())
+ await _add_device(session, roster, sk_a, pk_a, pk_b, px_b)
+
+ devices = {d["pk_ed25519"]: d for d in await roster.group_devices(GROUP)}
+ d = devices[pk_b]
+ transcript = device_add_transcript(
+ node_pk_b64=session._node_pk_b64(), user_id="alice",
+ pk_ed25519_b64=d["pk_ed25519"], pk_x25519_b64=d["pk_x25519"],
+ nonce_node=base64.b64decode(d["add_nonce"]), ts=d["add_ts"])
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import (
+ Ed25519PublicKey,
+ )
+ pk = Ed25519PublicKey.from_public_bytes(base64.b64decode(d["added_by_pk"]))
+ pk.verify(base64.b64decode(d["add_sig"]), transcript) # raises if wrong
+
+
+async def test_a_first_device_has_no_evidence_and_says_so(tmp_path, roster):
+ """
+ An operator code admitted it; there is no countersignature and there cannot
+ be. It must read as trust-on-first-use rather than as verified — a client
+ that treated an absent signature as a valid one would verify anything.
+ """
+ _sk_a, pk_a, px_a = _keys()
+ await roster.pin_identity("alice", "alice", pk_a, px_a, via="code")
+ await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER,
+ status="active", approved_by="op")
+
+ (d,) = await roster.group_devices(GROUP)
+ assert d["added_by_pk"] == "" and d["add_sig"] == ""
+
+
+# ── the relay ────────────────────────────────────────────────────────────────
+
+async def test_a_member_is_served_the_roster_sealed(tmp_path, roster):
+ """
+ Any member, not only the operator — that is the point. Sealed under a
+ GEK-derived subkey for the same reason the index is.
+ """
+ _sk_a, pk_a, px_a = _keys()
+ await roster.pin_identity("alice", "alice", pk_a, px_a, via="code")
+ await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER,
+ status="active", approved_by="op")
+ await roster.set_member(group_id=GROUP, user_id="bob", role=ROLE_MEMBER,
+ status="active", approved_by="op")
+ _sk_b, pk_b, px_b = _keys()
+ await roster.pin_identity("bob", "bob", pk_b, px_b, via="code")
+
+ gek = generate_gek()
+ session = _session(tmp_path, roster, gek, user_id="bob")
+ await session._do_group_roster_req({})
+
+ resp = session.sent[-1]
+ assert resp["type"] == MNP.GROUP_ROSTER_RESP
+ assert "devices" not in resp, "the roster must not travel in clear"
+ payload = unseal(gek, PURPOSE_ROSTER, MNP.GROUP_ROSTER_RESP, GROUP, resp)
+ assert {d["user_id"] for d in payload["devices"]} == {"alice", "bob"}
+ assert payload["node_pk"], "the transcript needs the node key to rebuild"
+
+
+async def test_a_revoked_device_is_not_relayed(tmp_path, roster):
+ """A retired laptop must stop being offered as one of the account's keys."""
+ sk_a, pk_a, px_a = _keys()
+ _sk_b, pk_b, px_b = _keys()
+ await roster.pin_identity("alice", "alice", pk_a, px_a, via="code")
+ await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER,
+ status="active", approved_by="op")
+ session = _session(tmp_path, roster, generate_gek())
+ await _add_device(session, roster, sk_a, pk_a, pk_b, px_b)
+ await roster.revoke_device("alice", pk_b)
+
+ keys = {d["pk_ed25519"] for d in await roster.group_devices(GROUP)}
+ assert keys == {pk_a}
+
+
+async def test_another_groups_members_are_not_disclosed(tmp_path, roster):
+ """
+ Scoped to this group. A person in two groups on one node is not revealed to
+ the second by being in the first — the roster is member-visible, so its
+ scope *is* the privacy boundary.
+ """
+ _sk_a, pk_a, px_a = _keys()
+ _sk_c, pk_c, px_c = _keys()
+ await roster.pin_identity("alice", "alice", pk_a, px_a, via="code")
+ await roster.pin_identity("carol", "carol", pk_c, px_c, via="code")
+ await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER,
+ status="active", approved_by="op")
+ await roster.set_member(group_id="h" * 32, user_id="carol",
+ role=ROLE_MEMBER, status="active", approved_by="op")
+
+ users = {d["user_id"] for d in await roster.group_devices(GROUP)}
+ assert users == {"alice"}
+
+
+async def test_a_substituted_key_carries_no_evidence(tmp_path, roster):
+ """
+ The attack Tier 2 exists to detect, from the node's side of it.
+
+ A node that invents a device for an account can put it in the roster — it
+ writes the roster. What it cannot do is produce a countersignature from a
+ key it does not hold, so the fabricated device arrives with `add_sig` empty
+ and no chain reaches it. The client is what refuses to walk to it; this
+ asserts the node cannot manufacture the evidence.
+ """
+ _sk_a, pk_a, px_a = _keys()
+ _sk_evil, pk_evil, px_evil = _keys()
+ await roster.pin_identity("alice", "alice", pk_a, px_a, via="code")
+ await roster.set_member(group_id=GROUP, user_id="alice", role=ROLE_MEMBER,
+ status="active", approved_by="op")
+ # The node simply writes a second device for Alice, as a malicious one would.
+ await roster.pin_identity("alice", "alice", pk_evil, px_evil, via="device")
+
+ devices = {d["pk_ed25519"]: d for d in await roster.group_devices(GROUP)}
+ assert devices[pk_evil]["add_sig"] == "", (
+ "a fabricated device cannot come with a countersignature — if this ever "
+ "holds evidence, the node has been handed a way to mint trust")