Skip to content
Elshad Humbatli edited this page May 12, 2026 · 1 revision

meshop - Project Wiki

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).

Status

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

Table of contents


Architecture

Components

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]
Loading

Package layout

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

Cryptographic parameters

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)

Wire and routing defaults

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

How it works

The Envelope

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.

Noise XX handshake (between two direct neighbors)

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
Loading

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.

Three-node relay: A through B to C (no direct A<->C link)

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.

Three-node relay: A through B to C (no direct A<->C link)

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.

Incoming envelope decision

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

How sending actually works

There is no point-to-point send path. Router.Send always:

  1. Looks up an existing Session for the target; if none, runs a handshake over relay.
  2. Builds and encrypts an Envelope.
  3. Marks the envelope ID in the local dedup cache (so the same packet coming back is dropped).
  4. 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.


Design decisions

Why Noise XX

  • 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.

Why JSON envelopes (for now)

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.

Identity = hash of public key

PeerID = hex(sha256(public_key)). No names to register. Two nodes with the same key file get the same PeerID

TTL is outside the AEAD

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.

Dedup cache is a fixed-size FIFO

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.

One uniform send path

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.

Per-link Session vs per-peer Session

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.

Not decided yet

  • 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.