aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:21 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 01:27:21 +0200
commitf15efd23f66c521ca9206789482bb38e7326eeb4 (patch)
treef069b741d3fe0114c3b5889c02dc0392c5201f68 /packages/meshbay-common/src
parentaab4bc98a3361d9f23e048a52705baa2f4a4a078 (diff)
downloadmeshbay-f15efd23f66c521ca9206789482bb38e7326eeb4.tar.gz
feat(node)!: the node wraps the group key — closes H3 and M3
The invite flow fetched the invitee's pk_x25519 from the hub and wrapped the GEK for whatever came back (app.js:1466, and gek-init did the same server-side). The hub is the key directory, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. No forgery, no injection, nothing for the client to notice. That was H3. The fix is not safety numbers. Nobody reads the directory any more: - the node holds the GEK and wraps it itself, on every connection, for the X25519 key the joiner signed with their Ed25519 identity in one transcript (meshbay:join:v1), so the identity key vouches for the encryption key; - identities are bound to accounts by a one-time code the hub never sees — 40 bits, single use, one account, bounded per connection AND node-wide; - the node's own roster decides who may receive the key. Hub membership lets someone reach a node; it no longer gets them anything. A hub that invents an account and mints it a token is answered not_authorized_for_group. Safety numbers would have made substitution detectable by a human who checks, at the moment there is nothing to check against — first contact. Removing the lookup makes it impossible, and costs the user one code to pass along. M3 falls out of the same work. The daemon auto-pinned its own keystore key as admin_pk_ed25519 while the browser signs with the user identity key, so every privileged operation failed closed with a signature error that looked like a bug somewhere else; the demo only worked because a deploy script overwrote the value. Authority now comes from the roster, established locally by `operator pair`. Asking the hub for the operator's key — the obvious-looking fix — would have let the hub install itself as node administrator. BREAKING: gek_bundle_store is deleted, not gated. No member hands the node key material at all, so C5b becomes structural rather than an authorization to check. Existing stored bundles are still served, so current deployments keep working. Also: - join_policy (invite|open) is read from node.toml, never from the hub — a hub able to declare a group open would be handed its key. Unknown group ⇒ invite. - admin signatures are verified against the roster on every check, so unpinning takes effect without a restart. admin_pk_ed25519 stays readable as legacy. - two C5b tests were rewritten, deliberately: they asserted that gek_bundle_store demanded an operator signature, and the message is gone. They now assert the stronger property. The file says not to fix these tests, so this is the record of why they changed. - a slice-1 bug found while writing slice 2: connect() never passed skEdB64, so pairing would have failed at runtime with no test able to catch it. Tests: 152 node+common here, including an end-to-end DataChannel run where a member who has never held the group key redeems a code in the pre-proof window and receives the key wrapped for a key only they can open. Design: docs/invite-pairing-v1.md Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-common/src')
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py9
-rw-r--r--packages/meshbay-common/src/meshbay_common/join.py63
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py8
3 files changed, 77 insertions, 3 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
index 71446ca..2d90102 100644
--- a/packages/meshbay-common/src/meshbay_common/adminop.py
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -34,7 +34,12 @@ ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1"
# Operations that require node-operator authority.
OP_FILE_DELETE = "file_delete"
-OP_GEK_BUNDLE_STORE = "gek_bundle_store"
+OP_INVITE_CREATE = "invite_create"
+# OP_GEK_BUNDLE_STORE is gone. Members no longer hand the node key material at
+# all: the node holds the GEK and wraps it itself, for a key the recipient proved
+# they hold (see `join.py` and docs/invite-pairing-v1.md). The operation existed
+# only to make member-supplied bundles safe, and deleting the message is a
+# stronger guarantee than authorizing it.
# A challenge older than this is refused, so a signature captured from a stale
# exchange cannot be replayed later.
@@ -53,7 +58,7 @@ def admin_transcript(
Build the exact byte string signed for an admin operation.
`subject` identifies what is being acted on: a file_id for OP_FILE_DELETE, the
- target user_id for OP_GEK_BUNDLE_STORE.
+ invitee's user_id for OP_INVITE_CREATE.
"""
fields = [
op.encode(),
diff --git a/packages/meshbay-common/src/meshbay_common/join.py b/packages/meshbay-common/src/meshbay_common/join.py
new file mode 100644
index 0000000..6ee543f
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/join.py
@@ -0,0 +1,63 @@
+"""
+Join and pairing transcript (MNP).
+
+A client proves, in one signature, that the X25519 key it wants the group key
+wrapped for belongs to the Ed25519 identity the node pins. Both keys travel inside
+the transcript, so the identity key vouches for the encryption key it is paired
+with — that is what makes "wrap the GEK for the key the peer presented" safe.
+
+Why this exists at all (H3): the invite flow used to fetch the invitee's public key
+from the hub and wrap the group key for whatever came back. The hub is the key
+directory, so a hub answering with its own key was handed the GEK by an honest
+inviter following the protocol exactly. The key now comes from the peer over an
+authenticated channel and is bound to an identity by a one-time pairing code the
+hub never sees. See `docs/invite-pairing-v1.md`.
+
+Fields are length-prefixed and domain-separated, per L4 — the same rule as
+`handshake.py` and `adminop.py`. `nonce_node` is the handshake nonce the node just
+issued, so a signed join cannot be lifted onto another connection.
+"""
+
+from __future__ import annotations
+
+JOIN_PREFIX = b"meshbay:join:v1"
+
+# A join older than this is refused. Same value as the admin challenge: both are
+# interactive exchanges that complete in milliseconds.
+JOIN_TTL = 120 # seconds
+
+ROLE_OPERATOR = "operator"
+ROLE_DELEGATE = "delegate" # reserved; delegation is deferred (§6.2 of the design)
+ROLE_MEMBER = "member"
+
+
+def join_transcript(
+ node_pk_b64: str,
+ group_id: str,
+ user_id: str,
+ pk_ed25519_b64: str,
+ pk_x25519_b64: str,
+ nonce_node: bytes,
+ ts: int,
+) -> bytes:
+ """
+ Bytes signed by a client asking to be pinned by, or recognised on, a node.
+
+ `group_id` is empty for operator pairing, which is node-wide rather than
+ per-group. The node builds this from its own state and the values in the
+ message; nothing signed is ever taken from the wire unverified.
+ """
+ fields = [
+ node_pk_b64.encode(),
+ group_id.encode(),
+ user_id.encode(),
+ pk_ed25519_b64.encode(),
+ pk_x25519_b64.encode(),
+ nonce_node,
+ str(ts).encode(),
+ ]
+ out = bytearray(JOIN_PREFIX)
+ for field in fields:
+ out += len(field).to_bytes(4, "big")
+ out += field
+ return bytes(out)
diff --git a/packages/meshbay-common/src/meshbay_common/protocol.py b/packages/meshbay-common/src/meshbay_common/protocol.py
index d86b4ef..510813a 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -46,12 +46,18 @@ class MNP:
HANDSHAKE_RESPONSE = "handshake_response" # client → node: HMAC(GEK, nonce)
ADMIN_CHALLENGE = "admin_challenge" # node → client: Ed25519 sign challenge
ADMIN_RESPONSE = "admin_response" # client → node: Ed25519 signature
- GEK_BUNDLE_STORE = "gek_bundle_store" # client → node: store wrapped GEK for a user
+ # GEK_BUNDLE_STORE was removed with the invite redesign: the node wraps the GEK
+ # itself, for a key the recipient proved possession of, so no member ever hands
+ # the node key material (C5b, and the H3 substitution it enabled).
GEK_BUNDLE_FETCH = "gek_bundle_fetch" # client → node: request own wrapped GEK
GEK_BUNDLE_RESP = "gek_bundle_resp" # node → client: wrapped GEK bundle
KEYPAIR_BUNDLE_STORE = "keypair_bundle_store" # client → node: store encrypted keypair bundle
KEYPAIR_BUNDLE_FETCH = "keypair_bundle_fetch" # client → node: request own keypair bundle
KEYPAIR_BUNDLE_RESP = "keypair_bundle_resp" # node → client: encrypted keypair bundle
+ JOIN_REQUEST = "join_request" # client → node: pair/recognise this identity
+ JOIN_RESULT = "join_result" # node → client: outcome + wrapped GEK
+ INVITE_CREATE = "invite_create" # operator → node: issue a pairing code
+ INVITE_RESULT = "invite_result" # node → operator: the code, once
# ── Index entry ───────────────────────────────────────────────────────────────