summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
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
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')
-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
-rw-r--r--packages/meshbay-common/tests/test_js_python_parity.py65
4 files changed, 140 insertions, 5 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 ───────────────────────────────────────────────────────────────
diff --git a/packages/meshbay-common/tests/test_js_python_parity.py b/packages/meshbay-common/tests/test_js_python_parity.py
index 9adac51..340ea3e 100644
--- a/packages/meshbay-common/tests/test_js_python_parity.py
+++ b/packages/meshbay-common/tests/test_js_python_parity.py
@@ -22,6 +22,7 @@ import pytest
from meshbay_common.adminop import admin_transcript
from meshbay_common.handshake import handshake_transcript, webrtc_binding
+from meshbay_common.join import join_transcript
CRYPTO_JS = (Path(__file__).resolve().parents[2]
/ "meshbay-hub" / "src" / "meshbay_hub" / "static" / "crypto.js")
@@ -52,6 +53,18 @@ ADMIN_VECTORS = [
("file_delete", "Tk9ERVBL", "café", "fichier é.mp4", "04" * 32, 1_700_000_002),
]
+# (node_pk_b64, group_id, user_id, pk_ed b64, pk_x b64, nonce hex, ts)
+JOIN_VECTORS = [
+ # Operator pairing: group_id is empty and must stay distinguishable from a
+ # request that names a group.
+ ("Tk9ERVBL", "", "grenet", "QUFB", "QkJC", "01" * 32, 1_700_000_000),
+ ("Tk9ERVBL", "g" * 32, "grenet", "QUFB", "QkJC", "01" * 32, 1_700_000_000),
+ # Identical to the previous vector except that the two keys are swapped —
+ # they are adjacent fields, so this isolates the ordering.
+ ("Tk9ERVBL", "g" * 32, "grenet", "QkJC", "QUFB", "01" * 32, 1_700_000_000),
+ ("Tk9ERVBL", "café", "utilisateur-é", "QUFB", "QkJC", "03" * 32, 0),
+]
+
_HARNESS = r"""
const fs = require('fs');
@@ -62,7 +75,8 @@ globalThis.crypto = globalThis.crypto || {};
const src = fs.readFileSync(process.argv[2], 'utf8');
const load = new Function(
- src + '\nreturn { handshakeTranscript, adminTranscript, webrtcBinding, b64encode };');
+ src + '\nreturn { handshakeTranscript, adminTranscript, joinTranscript, '
+ + 'webrtcBinding, b64encode };');
const M = load();
const hex = (s) => {
@@ -74,7 +88,7 @@ const toHex = (u8) =>
Array.from(u8).map(b => b.toString(16).padStart(2, '0')).join('');
const input = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
-const out = { handshake: [], admin: [] };
+const out = { handshake: [], admin: [], join: [] };
for (const v of input.handshake) {
const binding = M.webrtcBinding(hex(v.offer_fp), hex(v.answer_fp));
@@ -87,6 +101,11 @@ for (const v of input.admin) {
v.op, v.node_pk, v.group_id, v.subject, M.b64encode(hex(v.nonce)), v.ts)));
}
+for (const v of input.join) {
+ out.join.push(toHex(M.joinTranscript(
+ v.node_pk, v.group_id, v.user_id, v.pk_ed, v.pk_x, hex(v.nonce), v.ts)));
+}
+
process.stdout.write(JSON.stringify(out));
"""
@@ -110,6 +129,11 @@ def js_output(tmp_path_factory):
"subject": s, "nonce": n, "ts": ts}
for op, pk, g, s, n, ts in ADMIN_VECTORS
],
+ "join": [
+ {"node_pk": pk, "group_id": g, "user_id": u,
+ "pk_ed": pe, "pk_x": px, "nonce": n, "ts": ts}
+ for pk, g, u, pe, px, n, ts in JOIN_VECTORS
+ ],
}))
proc = subprocess.run(
@@ -164,6 +188,43 @@ def test_admin_transcript_parity(idx, vector, js_output):
)
+@pytest.mark.parametrize("idx,vector", list(enumerate(JOIN_VECTORS)))
+def test_join_transcript_parity(idx, vector, js_output):
+ """
+ A mismatch here means no browser can pair with a node and no member can be
+ recognised — the node would reject every signature as invalid, and, as with
+ the other two, nothing else in the suite crosses this boundary.
+ """
+ node_pk, group_id, user_id, pk_ed, pk_x, nonce, ts = vector
+
+ expected = join_transcript(
+ node_pk_b64=node_pk,
+ group_id=group_id,
+ user_id=user_id,
+ pk_ed25519_b64=pk_ed,
+ pk_x25519_b64=pk_x,
+ nonce_node=bytes.fromhex(nonce),
+ ts=ts,
+ )
+ assert js_output["join"][idx] == expected.hex(), (
+ f"crypto.js and meshbay_common.join disagree for user={user_id!r} "
+ f"group={group_id!r}"
+ )
+
+
+def test_join_transcript_binds_the_two_keys_in_order(js_output):
+ """
+ The X25519 key is trusted only because the Ed25519 identity signed it, so the
+ two must not be interchangeable: swapping them has to produce different bytes.
+ """
+ assert js_output["join"][1] != js_output["join"][2]
+
+
+def test_operator_pairing_is_distinguishable_from_a_group_join(js_output):
+ """An empty group_id (node-wide operator authority) must not collide."""
+ assert js_output["join"][0] != js_output["join"][1]
+
+
def test_length_prefixing_actually_disambiguates(js_output):
"""
The reason both sides length-prefix: two different field splits must not collide.