aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common
diff options
context:
space:
mode:
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.