summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-common/src
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-14 19:35:37 +0200
commitc83a4f6ab0c8a83e8679e78427ae60dc29bb2c60 (patch)
treedea71c8e115742beaac5952c8c65481bbc130b07 /packages/meshbay-common/src
parentee6573c57f721db8550e34e1c1c79c5922c62a4b (diff)
parentd324792d68503109ab99616af6c85ee37045e169 (diff)
downloadmeshbay-c83a4f6ab0c8a83e8679e78427ae60dc29bb2c60.tar.gz
merge: Phase 11.5 security remediation, invite redesign, per-node identity
Brings in the security remediation branch. Three bodies of work, and what they changed about what this project may claim. Phase 11.5 closed the gap between the documents and the code: the unauthenticated node HTTP API and the TCP transport deleted, one handshake shared by the remaining two transports, mutual authentication, structured admin transcripts, upload confinement, group isolation, revocation that reaches nodes. Six critical and seven high findings closed, bounded, or deferred by decision. The invite redesign closed H3 and M3 — the last open High. The hub was the key directory: an inviter fetched the invitee's key from it and wrapped the group key for whatever came back, so a hub answering with its own key was handed the group key by an honest member following the protocol exactly. That lookup is gone. The node holds the group key and wraps it itself, for a key its recipient proves possession of, bound to an account by a one-time code the hub never sees. M3 fell out of the same work: node authority comes from a local roster, never from the hub. Per-node identity cut what remains of C4 down to one operator. A single keypair used to be copied to every node its owner joined; each node now gets its own, so cracking the bundle on one machine yields a key that is a stranger everywhere else — and on that machine, one that unlocks nothing its holder did not already serve. The bundle KDF moved to Argon2id 128 MB, and the hub stopped storing or publishing user keys at all. What this project may now say: the hub cannot read your content unless it ships you malicious client code. T3 remains, accepted (D1), and is what the native client removes. C4 is reduced, not closed, until 13.3. Chat is still plaintext at rest until Phase 15. Draft-v5 §2 states each claim against the adversary it holds against, which is the convention this branch exists to keep. Four defects were found by deploying it and using a browser, none by the test suite: a node going deaf on its hub socket, a token that predated group membership, a client reading values before they were assigned, and identity keys a browser held but never re-read. The lessons are recorded in CLAUDE.md. Tests: 343 across the three packages, plus QE/deploy/e2e.py — register, pair, invite, join, download, stream, second browser, revoke — run against the live deployment on a wiped hub and node.
Diffstat (limited to 'packages/meshbay-common/src')
-rw-r--r--packages/meshbay-common/src/meshbay_common/adminop.py75
-rw-r--r--packages/meshbay-common/src/meshbay_common/crypto.py40
-rw-r--r--packages/meshbay-common/src/meshbay_common/handshake.py220
-rw-r--r--packages/meshbay-common/src/meshbay_common/join.py63
-rw-r--r--packages/meshbay-common/src/meshbay_common/protocol.py15
5 files changed, 402 insertions, 11 deletions
diff --git a/packages/meshbay-common/src/meshbay_common/adminop.py b/packages/meshbay-common/src/meshbay_common/adminop.py
new file mode 100644
index 0000000..2d90102
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/adminop.py
@@ -0,0 +1,75 @@
+"""
+Admin operation challenge transcripts (MNP).
+
+Destructive and privileged node operations are authorized by an Ed25519 signature
+from the node operator, not by a JWT — the hub controls JWT issuance, so a JWT can
+never establish node-level authority (see draft-v4 §4.2.x).
+
+Finding H5: the node used to challenge the client with 32 raw random bytes and the
+client signed them blind. That is an unbound signing oracle — the signed message
+named no operation, no subject, no node and no time, so a signature obtained for one
+purpose was structurally valid for any other, and a malicious node could ask a user
+to sign bytes meaningful in a different protocol.
+
+The transcript below fixes that:
+
+ - a fixed domain-separation prefix, so these signatures can never collide with
+ node_auth, revocation tokens, chunk signatures or anything added later;
+ - the operation and its subject, so the client can display and verify what it is
+ authorizing before signing;
+ - the node's public key, so a signature for node A is not valid on node B;
+ - the group, so authority does not leak across groups on a multi-group node;
+ - a node-chosen nonce, so signatures cannot be replayed;
+ - a timestamp, so stale challenges can be rejected.
+
+Every field is length-prefixed. Plain concatenation would let a crafted subject
+impersonate a following field (finding L4 applies the same rule to the GEK proof).
+
+Both sides MUST build the transcript with this function — the client from the
+fields it received, the node from the state it stored. They are compared by
+producing the same bytes, never by trusting a value off the wire.
+"""
+
+ADMIN_TRANSCRIPT_PREFIX = b"meshbay:admin:v1"
+
+# Operations that require node-operator authority.
+OP_FILE_DELETE = "file_delete"
+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.
+ADMIN_CHALLENGE_TTL = 120 # seconds
+
+
+def admin_transcript(
+ op: str,
+ node_pk_b64: str,
+ group_id: str,
+ subject: str,
+ nonce: bytes,
+ ts: int,
+) -> bytes:
+ """
+ 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
+ invitee's user_id for OP_INVITE_CREATE.
+ """
+ fields = [
+ op.encode(),
+ node_pk_b64.encode(),
+ group_id.encode(),
+ subject.encode(),
+ nonce,
+ str(ts).encode(),
+ ]
+ out = bytearray(ADMIN_TRANSCRIPT_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/crypto.py b/packages/meshbay-common/src/meshbay_common/crypto.py
index 682e1c0..b2ff3c0 100644
--- a/packages/meshbay-common/src/meshbay_common/crypto.py
+++ b/packages/meshbay-common/src/meshbay_common/crypto.py
@@ -168,21 +168,45 @@ def unwrap_gek_aes(bundle: dict, sk_recipient: bytes, pk_recipient: bytes) -> by
# ── Keystore (local key storage) ──────────────────────────────────────────────
-# Argon2id parameters — calibrate to ~500ms on target hardware before production.
-# POC measured 78ms with these; increase memory_cost to 262144 (256MB) for prod.
+# Argon2id parameters for the node keystore.
+#
+# Finding M2: these sat at 64 MB long after the hub's password verifier was raised
+# to 256 MB, and the docs recorded the bump as done — true for the hub, false here.
+# The keystore protects the node's Ed25519 and X25519 private keys, so it is the
+# more valuable target of the two.
+#
+# Parameters are recorded in each keystore envelope, so raising them does not
+# invalidate existing files: LEGACY_* is used when an envelope predates the field.
ARGON2_ITERATIONS = 3
-ARGON2_MEMORY_COST = 65536 # 64 MB — increase to 262144 for production
+ARGON2_MEMORY_COST = 262144 # 256 MB
ARGON2_LANES = 4
ARGON2_KEY_LENGTH = 32
-def derive_keystore_key(password: str, salt: bytes) -> bytes:
- """Derive AES-256 key from password using Argon2id."""
+LEGACY_ARGON2_ITERATIONS = 3
+LEGACY_ARGON2_MEMORY_COST = 65536 # 64 MB — keystores written before M2
+LEGACY_ARGON2_LANES = 4
+
+
+def derive_keystore_key(
+ password: str,
+ salt: bytes,
+ *,
+ iterations: int | None = None,
+ memory_cost: int | None = None,
+ lanes: int | None = None,
+) -> bytes:
+ """
+ Derive an AES-256 key from a password using Argon2id.
+
+ Parameters default to the current production values; callers pass the values
+ recorded in an existing envelope when opening an older keystore.
+ """
return Argon2id(
salt=salt,
length=ARGON2_KEY_LENGTH,
- iterations=ARGON2_ITERATIONS,
- lanes=ARGON2_LANES,
- memory_cost=ARGON2_MEMORY_COST,
+ iterations=ARGON2_ITERATIONS if iterations is None else iterations,
+ lanes=ARGON2_LANES if lanes is None else lanes,
+ memory_cost=ARGON2_MEMORY_COST if memory_cost is None else memory_cost,
).derive(password.encode())
def encrypt_keystore(plaintext: bytes, key: bytes) -> tuple[bytes, bytes, bytes]:
diff --git a/packages/meshbay-common/src/meshbay_common/handshake.py b/packages/meshbay-common/src/meshbay_common/handshake.py
new file mode 100644
index 0000000..fca218f
--- /dev/null
+++ b/packages/meshbay-common/src/meshbay_common/handshake.py
@@ -0,0 +1,220 @@
+"""
+Unified MNP handshake — one implementation, every transport.
+
+Finding C6: the handshake existed three times over (WebRTC, QUIC, TCP), and only
+the newest copy enforced the GEK proof. QUIC and TCP accepted a bare JWT, so a
+forged or stolen token reached the node and could inject chat messages without
+ever holding the group key. TCP is gone (11.5.2); QUIC and WebRTC now share this
+module, and a parity test fails if either skips a step.
+
+The sequence:
+
+ client → node handshake {token, group_id, nonce_c}
+ node authorize_token() JWT, scope, denylist, membership, hosting
+ node → client handshake_challenge {nonce_s}
+ client → node handshake_response {proof}
+ node verify client proof HMAC(GEK, client transcript)
+ node → client handshake_ack {proof, sig, node_pk, is_node_admin}
+ client verify node proof HMAC(GEK, node transcript) + Ed25519
+
+Two properties this adds over the previous design:
+
+**Mutual authentication (C3).** Authentication used to run one way: the client
+proved itself, the node proved nothing. `handshake_ack.node_pk` was never verified
+against anything, and per-chunk signatures had been dropped in Phase 9.15, so a
+peer that had hijacked signaling (C2) or been substituted by the hub could accept
+the client's proof, ignore it, and serve a forged index, forged chat history and a
+forged `is_node_admin` flag. The node now proves GEK possession over a
+client-chosen nonce *and* signs the transcript with its long-term key, so the
+client can pin it.
+
+**Unambiguous transcripts (L4).** The old proof was `nonce ‖ offer_fp ‖ answer_fp`
+— bare concatenation, and a missing fingerprint silently degraded it to nonce-only.
+Every field is now length-prefixed and domain-separated, the role is bound so a
+client proof can never be replayed as a node proof, and an empty channel binding is
+refused rather than tolerated.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+from dataclasses import dataclass
+from typing import Any, Protocol
+
+import jwt
+
+HANDSHAKE_PREFIX = b"meshbay:mnp:handshake:v1"
+
+ROLE_CLIENT = "client"
+ROLE_NODE = "node"
+
+NONCE_LEN = 32
+
+
+class HandshakeError(Exception):
+ """
+ Refusal, with a message safe to hand to the peer.
+
+ `code` is the same refusal in a form a client can act on. The text is for a
+ human and may be reworded; matching on it from the client would be a string
+ comparison that breaks silently the day someone improves the wording.
+ """
+
+ def __init__(self, message: str, code: str = ""):
+ super().__init__(message)
+ self.code = code
+
+
+class DenylistLike(Protocol):
+ def is_denied(self, user_id: str, jti: str, group_id: str = "") -> bool: ...
+
+
+@dataclass
+class AuthorizedPeer:
+ user_id: str
+ group_id: str
+ username: str
+ jti: str
+
+ # No `pk_user`. The hub used to put a user key in the token and the node
+ # recorded it as the uploader's identity, which let whoever issued tokens
+ # decide who could delete a file. Identity keys are pinned by the node
+ # (see roster.py); the hub certifies accounts, not keys.
+
+
+def handshake_transcript(
+ role: str,
+ group_id: str,
+ nonce_client: bytes,
+ nonce_node: bytes,
+ binding: bytes,
+) -> bytes:
+ """
+ Bytes covered by a handshake proof.
+
+ `binding` ties the proof to the concrete connection: the two DTLS fingerprints
+ for WebRTC, the TLS certificate hashes for QUIC. Without it a proof captured on
+ one connection is replayable on another (NS5).
+ """
+ fields = [
+ role.encode(),
+ group_id.encode(),
+ nonce_client,
+ nonce_node,
+ binding,
+ ]
+ out = bytearray(HANDSHAKE_PREFIX)
+ for field in fields:
+ out += len(field).to_bytes(4, "big")
+ out += field
+ return bytes(out)
+
+
+def make_proof(
+ gek: bytes,
+ role: str,
+ group_id: str,
+ nonce_client: bytes,
+ nonce_node: bytes,
+ binding: bytes,
+) -> bytes:
+ if not binding:
+ # An empty binding means the transport could not identify the channel.
+ # Proceeding would silently drop MitM detection (L4).
+ raise HandshakeError("Channel binding unavailable")
+ if not gek:
+ raise HandshakeError("Group encryption not initialized")
+ transcript = handshake_transcript(
+ role, group_id, nonce_client, nonce_node, binding)
+ return hmac.new(gek, transcript, hashlib.sha256).digest()
+
+
+def verify_proof(
+ gek: bytes,
+ proof: bytes,
+ role: str,
+ group_id: str,
+ nonce_client: bytes,
+ nonce_node: bytes,
+ binding: bytes,
+) -> bool:
+ try:
+ expected = make_proof(
+ gek, role, group_id, nonce_client, nonce_node, binding)
+ except HandshakeError:
+ return False
+ return hmac.compare_digest(proof, expected)
+
+
+def authorize_token(
+ token: str,
+ hub_pk_pem: bytes,
+ *,
+ group_id: str,
+ hosted_groups: Any | None = None,
+ denylist: DenylistLike | None = None,
+ require_scope: str | None = "user",
+) -> AuthorizedPeer:
+ """
+ Everything decided from the JWT, before any proof is exchanged.
+
+ Raises HandshakeError with a peer-safe message. Deliberately strict about
+ `group_id`: it used to be optional, and omitting it skipped the membership
+ check entirely and fell back to the node's first group (M1).
+ """
+ try:
+ decoded = jwt.decode(token, hub_pk_pem, algorithms=["EdDSA"])
+ except Exception as exc:
+ raise HandshakeError(f"Invalid JWT: {exc}") from exc
+
+ # A node-scoped daemon token must not be usable as a client token (M9).
+ if require_scope is not None and decoded.get("scope", "user") != require_scope:
+ raise HandshakeError("Wrong token scope")
+
+ user_id = decoded.get("sub", "")
+ jti = decoded.get("jti", "")
+ if not user_id:
+ raise HandshakeError("Token has no subject")
+
+ if not group_id:
+ raise HandshakeError("group_id is required")
+
+ if denylist is not None and denylist.is_denied(user_id, jti, group_id):
+ raise HandshakeError("Token revoked")
+
+ if group_id not in decoded.get("groups", []):
+ # Almost always a token issued before the person was added to the group:
+ # `groups` is baked in at login and the hub does not push updates. The
+ # client refreshes and retries on this code rather than telling someone
+ # who *is* a member that they are not one.
+ raise HandshakeError("Not a member of this group", code="not_a_member")
+
+ if hosted_groups is not None and group_id not in hosted_groups:
+ raise HandshakeError("Group not hosted on this node")
+
+ return AuthorizedPeer(
+ user_id=user_id,
+ group_id=group_id,
+ username=decoded.get("username", ""),
+ jti=jti,
+ )
+
+
+def webrtc_binding(offer_fp: bytes, answer_fp: bytes) -> bytes:
+ """Channel binding for WebRTC: both DTLS certificate fingerprints."""
+ return (len(offer_fp).to_bytes(4, "big") + offer_fp
+ + len(answer_fp).to_bytes(4, "big") + answer_fp)
+
+
+def quic_binding(server_cert_der: bytes) -> bytes:
+ """
+ Channel binding for QUIC.
+
+ QUIC has no DTLS fingerprint to reuse, so the anchor is a hash of the server's
+ self-signed certificate — the same value a client pins as the node identity.
+ An RFC 5705 exporter would be stronger; aioquic does not currently expose one
+ (11.5.6).
+ """
+ digest = hashlib.sha256(server_cert_der).digest()
+ return len(digest).to_bytes(4, "big") + digest
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 55dcdde..50e8663 100644
--- a/packages/meshbay-common/src/meshbay_common/protocol.py
+++ b/packages/meshbay-common/src/meshbay_common/protocol.py
@@ -29,8 +29,10 @@ class MNP:
CHAT_ATTACHMENT = "chat_attach" # attachment metadata
CHAT_HISTORY = "chat_hist" # request message history
CHAT_HISTORY_RESPONSE = "chat_hist_resp" # history response with messages
- GEK_REQUEST = "gek_req" # browser requests group GEK
- GEK_RESPONSE = "gek_resp" # node delivers GEK over secure channel
+ # GEK_REQUEST / GEK_RESPONSE were removed (NS3, and finding L1): the node must
+ # never serve the GEK in plaintext. Members obtain it by unwrapping their own
+ # ECIES bundle. The constants lingered after the handlers were deleted, leaving
+ # the wire contract looking as though the endpoint still existed.
FILE_UPLOAD = "file_upload" # client pushes file chunk to node
FILE_UPLOAD_ACK = "file_upload_ack" # node acknowledges chunk receipt
FILE_DELETE = "file_delete" # client requests file deletion
@@ -44,12 +46,19 @@ 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
+ KEYPAIR_BUNDLE_DELETE = "keypair_bundle_delete" # client → node: withdraw own backup
+ 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 ───────────────────────────────────────────────────────────────