Skip to content

Architecture

Wisp is a peer-to-peer messenger with no accounts and no trusted server. An identity is a key you hold. Peers find each other through a shared DHT, verify each other with a safety number, and exchange end-to-end encrypted messages — directly when both are online, or through encrypted dead-drops when one is offline. Relays and DHT nodes are treated as adversarial: they carry opaque blobs under unguessable addresses and learn nothing.

The stack is a Rust core for all protocol logic and a Flutter UI (Android, Linux, and Windows), bridged with flutter_rust_bridge. Relays are a standalone Rust binary that reuses the core crates.

┌─────────────────────────────────────────┐
│ Flutter app (Dart) │
│ UI · state · repositories · routing │
│ Android · Linux · Windows │
└───────────────┬─────────────────────────┘
│ flutter_rust_bridge
┌───────────────▼─────────────────────────┐
│ wisp-core (Rust) │
│ engine · sessions · send/poll · store │
└──┬──────────┬──────────┬─────────┬───────┘
┌─────────▼──┐ ┌─────▼────┐ ┌───▼────┐ ┌──▼─────────┐
│ wisp-crypto│ │ wisp-net │ │wisp- │ │wisp-relay- │
│ (pure) │ │ (libp2p) │ │proto │ │store │
└────────────┘ └──────────┘ └────────┘ └────────────┘
Public infrastructure (blind, swappable): wisp-relay-node → wisp-relay binary on a VPS.

Only the client holds plaintext and long-term keys. Everything below the client is treated as hostile.

The client tries the cheapest path that works, in order:

  1. Direct (both online). Look up the recipient’s presence in the DHT, connect (hole-punching a direct path where possible), and stream the message over an encrypted session. Lowest latency; nothing is stored anywhere.
  2. Relayed live (both online, direct path blocked). If two NATs can’t be punched through, the same live session runs through a relay that only sees ciphertext.
  3. Dead-drop (recipient offline). Derive a rotating, unguessable drop address from the shared secret, encrypt and pad the message, and leave it on a relay. The recipient computes the same address later, collects it, decrypts, and acknowledges so the relay frees the slot.

The relay is a fallback for two problems only: NAT (can’t reach a reachable peer) and absence (peer is offline). On a local network, or when a peer is publicly reachable, or once hole-punching succeeds, the relay drops out of the path entirely.

Knowing someone’s address is not the same as being able to reach it: most peers sit behind NATs that drop unsolicited connections, and hole-punching itself needs a third party to coordinate. And no protocol can deliver to a device that is switched off — something has to hold the message. That is the relay’s whole job.

What a relay is not: it holds no keys, reads no messages (end-to-end encrypted), learns no social graph (drop addresses are derived, rotating, and unlinkable), stores no accounts or history, and is fully interchangeable — any relay will do, and clients route around one that’s blocked or gone. It is swappable, blind, and non-identifying. Relay population is meant to come from desktop clients acting as relays, volunteers, and a few defaults shipped with the app.

  • Identity is an Ed25519 keypair; the public key in text is the WispID. A 12-word seed phrase backs it up and restores it on another device.
  • The libp2p PeerId derives from the same key, so reaching a PeerId proves you reached that WispID at the transport layer.
  • Pairing runs a Noise XX handshake, seeds a Double Ratchet session, then exchanges signed identity proofs bound to that specific handshake — so both sides learn each other’s verified WispID, not just a transport key.
  • A safety number derived from both identities lets two humans confirm out-of-band that no one is impersonating them.
Crate Role
wisp-crypto identity, seed phrase, Noise handshake, double ratchet, dead-drop derivation, session serialization, safety numbers
wisp-proto versioned wire formats: presence record, message frame, chat/reply/receipt/delete/edit/pin payloads, message-id, drop protocol, wake protocol, identity proof
wisp-net libp2p: QUIC/TCP + Noise transport, Kademlia DHT, AutoNAT/DCUtR/relay-client, presence publish/lookup
wisp-relay-store blob mailbox: drop_id → blob, TTL, size caps, per-drop quotas (SQLite)
wisp-relay-node the public node: relay server + DHT server + drop store + wake push
wisp-relay thin binary wrapper over wisp-relay-node
wisp-core pairing, sessions, send decision, dead-drop send/collect, relay-wake, in-order receive, send retry, contact store, message search
app/rust the flutter_rust_bridge surface over wisp-core plus the native notifier
wisp-cli headless client for testing the whole protocol

The dependency direction never points back up: crypto and proto are leaves → net depends on protocore depends on all → app/rust, relay, and cli sit on top.

Protected: message content (end-to-end, forward-secret); the social graph (rotating, derived drop addresses); central seizure (no server holds messages or contacts); takedown (clients route around blocked relays and bootstrap nodes).

Not fully solved: a global passive adversary may correlate the timing of a write and a matching read; endpoint compromise defeats any wire encryption; bootstrap dependence is the softest censorship point.

The claim is not “no servers.” It is “no trusted party, and no single point that can read you, link you, or be leaned on to make you stop.” For the full detail, read the protocol specification.