aboutsummaryrefslogtreecommitdiffstats
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/MESHBAY_DESIGN.md18
-rw-r--r--docs/MESHBAY_NODE_PROTOCOL.md49
2 files changed, 54 insertions, 13 deletions
diff --git a/docs/MESHBAY_DESIGN.md b/docs/MESHBAY_DESIGN.md
index f42fa69..4b99fb4 100644
--- a/docs/MESHBAY_DESIGN.md
+++ b/docs/MESHBAY_DESIGN.md
@@ -16,7 +16,7 @@
> them — it names the invariant that holds today, not the incident that produced
> it. §13 is the register of those labels.
>
-> Wire versions at the time of writing: **MNP 3.3** (oldest peer accepted 3.0),
+> Wire versions at the time of writing: **MNP 3.4** (oldest peer accepted 3.0),
> **MHP 0.1**, packages **0.15.0**. The normative source for the wire format is
> `MESHBAY_NODE_PROTOCOL.md`; this document states the design the protocol
> serves, not its byte layout.
@@ -858,7 +858,7 @@ implementations of one security check is **C6** waiting to happen.
```
client → node handshake {token, group_id, nonce_c, v, v_min}
node authorize_token() JWT · scope · denylist · group_id · membership · hosting
-node → client handshake_challenge {nonce_s, node_pk}
+node → client handshake_challenge {nonce_s, node_pk, sig} sig: Ed25519 over the challenge (3.4)
── pre-proof window: bundle fetch, join ──
client → node handshake_response {proof}
node verify HMAC(GEK, client transcript)
@@ -876,6 +876,16 @@ client verify HMAC(GEK, node transcript) + Ed25519(node_p
Every field is length-prefixed so the concatenation is unambiguous, and the role
is bound in so a client proof can never be replayed as a node proof (**L4**).
+**Since 3.4 the node signs its challenge**, with the key it announces, over
+`"meshbay:mnp:challenge:v1" ‖ len‖group_id ‖ len‖nonce_c ‖ len‖nonce_s ‖ len‖binding`.
+A join is sent in the pre-proof window, before the ack that proves the node, so
+without it an invitation code went to whichever peer answered signaling. What it
+buys, per the convention at the top: a client that knows which node it means to
+reach can refuse to send a code anywhere else — against a hijacked signaling path
+and against a second host of the same group. It proves *a* key, not the *right*
+one: it helps only a client that already knows which key to expect. A wrong
+signature is refused; an absent one is an older node, discovered from its answer.
+
**Channel binding is mandatory and an absent one is refused** — never degraded to
nonce-only, which would silently drop MitM detection:
@@ -1159,8 +1169,8 @@ it**.
> the branches come back.**
**The floor is not the current version, and MINOR additions are why.** It is
-`MNP_MIN_SUPPORTED` in `handshake.py`, it equals the last MAJOR, and 3.1, 3.2 and
-3.3 have all been added above it without moving it. So a peer can be reachable and
+`MNP_MIN_SUPPORTED` in `handshake.py`, it equals the last MAJOR, and 3.1, 3.2,
+3.3 and 3.4 have all been added above it without moving it. So a peer can be reachable and
still not do something the current version can, and the client has to cope with
that — **by reading the peer's own answer, never by comparing version numbers**.
3.2's audio tracks are the worked example: the node lists them in `stream_init`,
diff --git a/docs/MESHBAY_NODE_PROTOCOL.md b/docs/MESHBAY_NODE_PROTOCOL.md
index b2508c1..ed06edd 100644
--- a/docs/MESHBAY_NODE_PROTOCOL.md
+++ b/docs/MESHBAY_NODE_PROTOCOL.md
@@ -1,6 +1,6 @@
# MeshBay Node Protocol (MNP)
-**Wire version:** `3.3` — `meshbay_common/__init__.py` (`MNP_VERSION`)
+**Wire version:** `3.4` — `meshbay_common/__init__.py` (`MNP_VERSION`)
**Oldest peer accepted:** `3.0` — `handshake.py` (`MNP_MIN_SUPPORTED`)
**Normative implementation:** `meshbay-common` (`protocol.py`, `handshake.py`,
`groupbox.py`, `chatbox.py`, `adminop.py`, `join.py`, `device.py`, `crypto.py`,
@@ -385,7 +385,8 @@ fails if a transport skips a step.
| 4. GEK exists for group |
| |
| 5. handshake_challenge |
- | {v, v_min, nonce: b64(nonce_s), node_pk} |
+ | {v, v_min, nonce: b64(nonce_s), node_pk, |
+ | sig: b64(Ed25519(C))} (3.4, section 6.5) |
|<--------------------------------------------------------------|
| |
| 5b. client checks the node's range the same way |
@@ -508,10 +509,37 @@ This is deliberate and safe:
* a first-time joiner needs it *before* the ack — `join_request` signs a transcript
naming this node (§8.2), and someone who has never held the GEK cannot complete the
handshake that would prove the key;
-* it is **unverified at that point**. The ack proves possession and signs the
- transcript; the client refuses if `ack.node_pk` differs from the announced value;
+* the ack proves possession and signs the transcript; the client refuses if
+ `ack.node_pk` differs from the announced value;
* a wrong value only makes the node's own verification fail.
+**Since 3.4 it is also signed, in the challenge itself:**
+
+```
+C = "meshbay:mnp:challenge:v1" || LP(group_id) || LP(nonce_c) || LP(nonce_s) || LP(binding)
+sig = Ed25519(sk_node, C)
+```
+
+`binding` is the transport's channel binding (§6.4), already known when the challenge
+is sent; `nonce_c` makes the signature fresh. So it cannot be recorded and replayed,
+nor relayed through a peer with different fingerprints. The prefix is its own, so it
+is never interchangeable with the ack's signature over `T("node")`.
+
+Why it exists: a join (§8) is sent **in the pre-proof window**, before the ack — so
+before 3.4 an invitation code went to whichever peer answered signaling, and in a
+group with two hosts, to whichever host answered first. With the signature, a client
+that knows which node key to expect can refuse to send a code anywhere else.
+
+What it does not do: it proves that the peer holds *the key it announces*, not that
+this is the key the client wanted. A client with no expectation learns nothing more
+than before, and the ack remains the proof of GEK possession.
+
+Client rule, from the node's answer and never from its version (§13): a `sig` that
+does not verify is a refusal (`Node challenge signature invalid`); no `sig` is an
+older node, whose key is proved only at the ack. A node signs whenever it has a
+binding, and a node with none sends no signature rather than an unbound one — the
+proof would be refused on that connection anyway.
+
### 6.6 `handshake_ack` fields
Three fields are in clear, and the rest travel **sealed under a group-key-derived
@@ -1899,7 +1927,7 @@ it back (§3.5).
| Type | Dir | Stage | Purpose |
|---|---|---|---|
| `handshake` | C→N | — | open the session; version range, token, group, client nonce |
-| `handshake_challenge` | N→C | — | node nonce, node's version range, announced `node_pk` |
+| `handshake_challenge` | N→C | — | node nonce, node's version range, announced `node_pk`, and since 3.4 `sig` over the challenge transcript |
| `handshake_response` | C→N | — | client's `HMAC(GEK, T("client"))` |
| `handshake_ack` | N→C | — | node proof, node signature, session parameters **sealed** (§11.1a) |
| `keypair_bundle_fetch` / `_resp` | C→N / N→C | pre | the caller's encrypted identity bundle |
@@ -2012,9 +2040,9 @@ message:
## 13. Versioning and compatibility
-MNP versions independently of the package version. Current: **`3.3`**; oldest peer
-accepted: **`3.0`** — 3.1, 3.2 and 3.3 are all additive, so the floor does not move with
-them.
+MNP versions independently of the package version. Current: **`3.4`**; oldest peer
+accepted: **`3.0`** — 3.1, 3.2, 3.3 and 3.4 are all additive, so the floor does not move
+with them. 3.4 adds the challenge signature (section 6.5).
The two numbers are separate on purpose. `MNP_VERSION` says what this build speaks;
`MNP_MIN_SUPPORTED` says what it will talk to, and moving the second is a decision about
@@ -2163,6 +2191,9 @@ walks through the gate meant to stop it.
handshake "meshbay:mnp:handshake:v1" LP(role) LP(group_id) LP(nonce_c) LP(nonce_s) LP(binding)
-> HMAC-SHA256 under the GEK; role in {"client","node"}
+challenge "meshbay:mnp:challenge:v1" LP(group_id) LP(nonce_c) LP(nonce_s) LP(binding)
+ -> Ed25519 by the node key, in handshake_challenge (3.4)
+
admin op "meshbay:admin:v1" LP(op) LP(node_pk) LP(group_id) LP(subject) LP(nonce) LP(ts)
-> Ed25519 by an operator key from the roster
@@ -2212,7 +2243,7 @@ LP(x) = uint32be(len(x)) || x every field, no exceptions
| Constant | Value | Source |
|---|---|---|
-| `MNP_VERSION` | `3.2` | `meshbay_common/__init__.py` |
+| `MNP_VERSION` | `3.4` | `meshbay_common/__init__.py` |
| `MNP_MIN_SUPPORTED` | `3.0` | `handshake.py` |
| `NONCE_LEN` | 32 bytes (both handshake nonces) | `handshake.py` |
| `ADMIN_CHALLENGE_TTL` | 120 s | `adminop.py` |