diff options
Diffstat (limited to 'packages/meshbay-common/src/meshbay_common')
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/adminop.py | 9 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/join.py | 63 | ||||
| -rw-r--r-- | packages/meshbay-common/src/meshbay_common/protocol.py | 8 |
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 ─────────────────────────────────────────────────────────────── |