-
Notifications
You must be signed in to change notification settings - Fork 0
Home
meshop is a peer-to-peer encrypted mesh messaging library in Go.
- No server.
- Two devices can talk directly.
- If they are not close, the message hops through other devices.
- Every message is encrypted end-to-end (Noise XX).
Early development. The wire format, the API, and the package layout will all change. Nothing is stable yet.
Done:
- Two parts of one Go program talking
- Two computers on a network talking
- End-to-end encryption
- Three or more devices with relay through neighbors
Next:
- Persistent identity, contacts, history
- Android (Wi-Fi, then Bluetooth)
- iPhone, browser
Every node runs one Router. The router owns links, sessions, a dedup cache, and one inbox.
graph TB
App[App<br/>cmd/demo] -->|Send / Recv| Router
subgraph Node
Router -->|owns| Key[StaticKey<br/>identity]
Router -->|owns| Dedup[dedupCache<br/>seen IDs]
Router -->|owns| Inbox[inbox channel]
Router -->|map by PeerID| L1[Link 1]
Router -->|map by PeerID| L2[Link 2]
Router -->|map by PeerID| S1[Session A]
Router -->|map by PeerID| S2[Session B]
end
L1 -->|TCP| N1[Neighbor 1]
L2 -->|TCP| N2[Neighbor 2]
| Path | Responsibility |
|---|---|
cmd/demo/ |
Demo chat CLI |
pkg/mesh/peer.go |
PeerID, StaticKey, cipher suite |
pkg/mesh/envelope.go |
Envelope type (wire message) |
pkg/mesh/link.go |
One TCP connection, length-prefixed frame I/O |
pkg/mesh/handshake.go |
Noise XX handshake over a Link |
pkg/mesh/session.go |
Encrypt / Decrypt with Noise CipherStates |
pkg/mesh/dedup.go |
Fixed-size FIFO of seen envelope IDs |
pkg/mesh/router.go |
Top-level peer: links, sessions, routing, relay |
| Parameter | Value |
|---|---|
| Handshake pattern | Noise XX |
| DH | Curve25519 |
| AEAD | ChaCha20-Poly1305 |
| Hash | SHA-256 |
| Prologue | meshop/v1 |
| Static key size | 32 bytes (private) + 32 bytes (public) |
| Identity | PeerID = hex(sha256(public_key)) |
| AEAD nonce | 12 bytes (counter from CipherState) |
| Constant | Value | Where | Meaning |
|---|---|---|---|
defaultTTL |
8 | envelope.go |
Hops before drop |
envelopeIDBytes |
16 | envelope.go |
Random ID size |
MaxFrameBytes |
1 MiB | link.go |
Largest single wire frame |
lengthPrefixBytes |
4 | link.go |
Big-endian length prefix |
defaultInboxSize |
64 | router.go |
App receive channel capacity |
defaultDedupSize |
1024 | router.go |
Seen-ID FIFO ring size |
tcpKeepAlivePeriod |
30 s | router.go |
TCP keep-alive |
dialTimeout |
10 s | router.go |
Outbound dial timeout |
handshakeRelayTimeout |
10 s | router.go |
Time to finish a relay handshake |
pendingResponderTTL |
20 s | router.go |
How long a half-finished responder state lives |
Every message on the wire is an Envelope, encoded as JSON:
| Field | Type | In AEAD AAD | Notes |
|---|---|---|---|
id |
hex string (16 bytes) | yes | Used by dedup cache |
from |
PeerID |
yes | Sender's identity |
to |
PeerID |
yes | Recipient's identity |
type |
string | yes | Dispatch tag (e.g. chat, noise/xx/1) |
timestamp |
RFC3339 | yes | Sender's local clock |
nonce |
12 bytes | yes | AEAD nonce derived from CipherState.Nonce()
|
payload |
bytes | no — it is the plaintext | Encrypted for app traffic, plaintext for handshake frames |
ttl |
uint8 | no — it changes at every hop | Decremented by each relay |
Changing any AAD-bound field in flight breaks decryption. ttl and payload stay outside the AAD so relays can decrement TTL without holding any keys.
The link-level handshake runs immediately after TCP connect.
sequenceDiagram
participant I as Initiator
participant R as Responder
Note over I,R: TCP already connected
I->>R: msg1 (e)
R->>I: msg2 (e, ee, s, es)
I->>R: msg3 (s, se)
Note over I,R: Both derive CipherStates
I->>I: verify PeerID == expected
If the dialer set --connect host:port=PEERID, the handshake fails when the remote's static key does not hash to PEERID. This stops impersonation.
Two stages. First, A and C run Noise XX over B's relay. Then they exchange encrypted application messages, still via B. B only forwards bytes; B never learns the session keys.
Alice and Carol are not direct neighbors. They already share a Session (built with Noise XX, whose messages also went through Bob). Bob only forwards bytes; he never learns the session keys.
Alice Bob Carol
| | |
| -- chat (ct) -->| |
| | -- chat (ct) -->|
| | [decrypt with Session(Alice)]
| | |
| |<- chat (ct) --- |
|<- chat (ct) --- | |
[decrypt with Session(Carol)]
At each hop Bob decrements env.TTL and forwards. The payload is opaque to him.
This is what every node does for every envelope received on any link.
Envelope arrives on a Link
|
v
1. Is env.ID in the dedup cache?
yes -> drop
no -> mark env.ID in dedup, continue
|
v
2. Is env.To == localID?
yes -> 3a. Does env.Type start with "noise/xx/"?
yes -> handle Noise handshake step
no -> decrypt env.Payload with Session(env.From)
push plaintext to inbox
no -> 3b. Is env.TTL == 0?
yes -> drop
no -> env.TTL -= 1
forward to all links except the origin link
There is no point-to-point send path. Router.Send always:
- Looks up an existing
Sessionfor the target; if none, runs a handshake over relay. - Builds and encrypts an
Envelope. - Marks the envelope ID in the local dedup cache (so the same packet coming back is dropped).
- Calls
forwardToAllExcept("")— floods to every link.
Direct neighbors and far peers use the same path. The dedup cache plus the TTL drop prevents loops and amplification.
- Mutual authentication. Both sides learn the other's static key, so the dialer can check the PeerID matches what was expected.
- Forward secrecy. Each session has fresh ephemeral keys.
- No PKI / no CAs. Identity is the SHA-256 of the public key. This fits a no-server design.
JSON is easy to read while debugging. The wire format will change anyway, so locking in CBOR or protobuf this early is not worth it.
PeerID = hex(sha256(public_key)). No names to register. Two nodes with the same key file get the same PeerID
TTL changes at every hop. If TTL were inside the AAD, every relay would need to decrypt and re-encrypt, which would require giving relays the keys. That would break end-to-end encryption. Keeping TTL outside the AAD is what makes the flood pattern compatible with end-to-end secrecy.
Flooding means the same envelope reaches a node multiple times. The cache (default 1024 entries) drops repeats. FIFO is simple and enough for now; LRU could be used here also.
Direct neighbors and far peers share the same code path. Router.Send does not check whether the target is a direct neighbor — it encrypts once, then floods.
Direct neighbors get a Session during the link-level handshake (in handshake.go). Far peers get a Session via the handshake-over-relay in router.go. Both end up in router.sessions keyed by PeerID. The rest of the code does not care how a Session was set up.
- Storage of identity, contacts, message history.
- Final wire format (JSON for now).
- Multi-path / retry / acknowledgement policy.
- Smarter routing. Today it is flood with TTL - the simplest thing that works.
- Backpressure when a slow link blocks others.
Things will change. Issues and PRs welcome.