aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src/meshbay_common/groupbox.py
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/meshbay-common/src/meshbay_common/groupbox.py
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/meshbay-common/src/meshbay_common/groupbox.py')
-rw-r--r--packages/meshbay-common/src/meshbay_common/groupbox.py5
1 files changed, 5 insertions, 0 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