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.
What runs where
Section titled “What runs where” ┌─────────────────────────────────────────┐ │ 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.
How a message is delivered
Section titled “How a message is delivered”The client tries the cheapest path that works, in order:
- 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.
- 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.
- 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.
Why a relay exists at all
Section titled “Why a relay exists at all”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 and verification
Section titled “Identity and verification”- 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.
The crates
Section titled “The crates”| 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 proto → core depends on all → app/rust, relay, and cli sit on
top.
Threat model, briefly
Section titled “Threat model, briefly”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.
