Skip to content

Protocol Overview

Status: draft, frozen as the v1 baseline. This describes the protocol as implemented in the core/ workspace. Every wire structure carries a version byte so future drafts can evolve without breaking deployed clients.

Compatibility rule. The wire structures below (message frame and payloads, presence, drop and wake protocols) are the frozen v1 format. Two clients on v1 must interoperate. Future changes must be additive — a new payload tag, a new optional trailing field, or a new stream protocol — never a re-layout of an existing structure. A v1 client that meets an unknown payload tag or protocol drops that one item gracefully (it is logged, not fatal) and keeps working, so a newer peer’s extra features degrade instead of breaking the conversation. A breaking change requires a bumped version byte and is a new draft, not an edit to this one.

Wisp is a peer-to-peer messaging protocol with no accounts and no trusted server. An identity is a keypair. Peers discover each other through a Kademlia DHT and exchange end-to-end encrypted messages either directly, through a blind relay while both are online, or by leaving encrypted blobs in rotating dead-drops for a peer who is offline. Relays and DHT nodes are assumed to be adversarial; the protocol is built so that observing them yields no readable content, no social graph, and no stable identifier for a conversation.

  • WispID — a peer’s public identity, the Ed25519 public key in text form.
  • Relay — a public node offering NAT traversal (circuit relay) and a dead-drop store.
  • Drop — a mailbox addressed by an unguessable, rotating 32-byte identifier.
  • Session — the Double Ratchet state shared by two paired peers.
  • Epoch — a one-hour time window used to rotate drop addresses.

Each peer holds two keypairs, generated on first launch and derived from a single 12-word BIP-39 seed phrase, stored encrypted at rest:

  • Ed25519 — long-term signing key. This is the identity.
  • X25519 — static Diffie-Hellman key, used as the Noise static key.

Both are derived from the seed with HKDF-SHA256:

seed = BIP39-seed(mnemonic, passphrase = "")
ed25519_sk = HKDF(seed, info = "wisp-identity-signing")[0..32]
x25519_sk = HKDF(seed, info = "wisp-identity-agreement")[0..32]
WispID = "wisp:" || base32-lower-nopad(ed25519_pub)

The ID is self-certifying: anything signed by the matching private key is provably from that identity. There is no registry.

The transport-layer PeerId is derived from the same Ed25519 key, so a WispID maps deterministically to a PeerId. Reaching a PeerId therefore proves you reached the holder of that WispID at the transport layer.

Purpose Primitive
Identity / signatures Ed25519
Key agreement X25519
Handshake Noise XX (Noise_XX_25519_ChaChaPoly_SHA256)
Message AEAD ChaCha20-Poly1305
KDF / derivation HKDF-SHA256, HMAC-SHA256
Hashing SHA-256

Nothing is invented. The Double Ratchet is the Signal construction with header encryption.

Every message on a libp2p stream is length-prefixed:

frame = u32-be(length) || payload[length]

Maximum payload length is 1 MiB. All multi-byte integers are big-endian.

A presence record announces where a WispID can currently be reached. It is stored in the DHT under SHA256(ed25519_pub) and is self-authenticating.

presence_record =
u8 version = 1
u64 published_at (unix seconds)
u16 address_count (<= 32)
repeated address_count times:
u16 address_length (<= 256)
u8[] multiaddr_bytes
u8[64] signature = Ed25519(all bytes above)

The signing input is every byte preceding the signature. A resolver rejects a record whose signature does not verify against the WispID it looked up. Records carry a short TTL and are republished roughly hourly.

  1. Bootstrap. A joining client learns a first peer via, in order: mDNS on the local network, shipped bootstrap addresses, or a manually pasted peer.
  2. Publish. A reachable client publishes its presence record to the DHT.
  3. Look up. To reach a WispID, derive SHA256(pub), GET the record, verify the signature, and dial the contained addresses.
  4. NAT traversal. AutoNAT determines reachability; DCUtR coordinates hole-punching; circuit-relay-v2 is the fallback when a direct path cannot be formed.

Pairing requires both peers online once. It runs over the /wisp/session/1 stream.

A Noise XX handshake runs over the two static X25519 keys. In message 2 the responder includes its initial ratchet public key (32 bytes) as the Noise payload. After the handshake both sides hold:

  • root — the Noise handshake hash (first 32 bytes), identical on both sides.
  • the responder’s ratchet public key (known to both).
  • Initiator: Session::initiator(root, responder_ratchet_pub).
  • Responder: Session::responder(root, responder_ratchet_secret).

The Double Ratchet is seeded from root. Header keys and the first chain are derived per the Signal header-encryption variant.

The Noise handshake authenticates the static X25519 keys but not the Ed25519 identity. To bind them, both sides derive a token and exchange signed proofs as the first ratchet messages, in this exact order (the responder cannot encrypt until it has decrypted the initiator’s proof):

token = HKDF(root, info = "wisp-identity-binding")[0..32]
identity_proof =
u8 version = 1
u8[32] ed25519_pub
u8[64] Ed25519(sign token)
  1. Initiator sends its proof; responder verifies the signature against the enclosed key and records that key as the peer’s identity.
  2. Responder sends its proof; initiator verifies likewise.

A proof from a different handshake fails because token differs. After binding, each side knows the other’s verified WispID.

Humans confirm no machine-in-the-middle by comparing a safety number, computed identically on both sides (keys are sorted so order does not matter):

sn_input = "wisp-safety-number" || min(keyA,keyB) || max(keyA,keyB)
digest = SHA256(sn_input)
safety_number = six space-separated groups, each (u32-be(digest[4i..4i+4]) mod 100000)
zero-padded to 5 digits

Every message is a Double Ratchet output wrapped in a message frame:

message_frame =
u8 version = 1
u16 encrypted_header_length (<= 1024)
u8[] encrypted_header
u8[] ciphertext

The plaintext is padded to a fixed bucket (256 / 1024 / 4096 bytes; larger rounds up) before ratchet encryption, so ciphertext length reveals only the bucket. Associated data binds the frame to the /wisp/session/1 protocol label. The ratchet provides forward secrecy and post-compromise recovery; header encryption hides counters and key identifiers.

The ratchet plaintext (before padding) is a tagged payload. The first byte is the type tag. MESSAGE_ID is 16 random bytes chosen by the sender (hex-encoded for local storage), so edit / delete / pin can reference a specific message on both devices without relying on a local row id.

u8 tag
tag = 1 CHAT: MESSAGE_ID || u8 reply_flag || (reply_flag==1: MESSAGE_ID reply_to) || u8[] body
tag = 2 RECEIPT: u64-be through (read up to this unix-seconds timestamp)
tag = 3 DELETE: MESSAGE_ID (delete-for-everyone)
tag = 4 EDIT: MESSAGE_ID || u8[] body
tag = 5 PIN: MESSAGE_ID || u8 pinned (1 = pin, 0 = unpin)

body is UTF-8 text. A receiver applies DELETE / EDIT / PIN only to messages the peer actually owns (guarded by the local outgoing flag), so a peer cannot alter messages it did not send. Delete-for-me and pin-for-me are local-only and never put on the wire.

Both peers derive the same rotating drop addresses from a shared drop root, which is derived from root:

drop_root = HKDF(root, info = "wisp-drop-root")[0..32]
drop(direction, epoch) = HKDF(drop_root, info = direction_label || u64-be(epoch))[0..32]

direction_label is "a2b" for initiator→responder and "b2a" for the reverse. The sender uses its outgoing direction; the receiver derives the matching incoming direction. Addresses are 32 bytes and look random to a relay.

EPOCH_SECONDS = 3600
epoch(now) = now / EPOCH_SECONDS

A sender writes to the current epoch’s address. A receiver checks a window of recent epochs (implementation default: current plus the previous 24) to tolerate clock skew and delayed pickup.

The blob stored at a drop address is exactly a message_frame. It carries no sender, no recipient, and no length signal beyond the padding bucket.

Runs over the /wisp/drop/1 stream. Each request is answered by one response, both length-framed. DROP_ID is 32 bytes.

u8 version = 1
u8 kind
kind = 1 PUT: DROP_ID || u32-be(blob_len) || blob
kind = 2 GET: DROP_ID
kind = 3 ACK: DROP_ID || u16-be(count) || count * u64-be(entry_id)
u8 version = 1
u8 kind
kind = 1 STORED: u64-be(entry_id)
kind = 2 BLOBS: u16-be(count) || count * ( u64-be(entry_id) || u32-be(len) || blob )
kind = 3 ACKED: u16-be(removed)
kind = 4 REJECTED: u8 reason (1 blob-too-large, 2 drop-full, 3 malformed, 4 internal)

A relay stores a blob under its address with a TTL, returns all live blobs at an address on GET, and deletes acknowledged entries on ACK. It never learns who wrote or reads a drop.

An optional /wisp/wake/1 stream lets an already-connected client learn the moment a drop it watches is written, instead of waiting for the next poll. The client sends a WATCH listing incoming drop addresses from its current epoch window; the relay pushes a WOKE frame carrying the matching DROP_ID when a PUT lands on one of them.

WATCH (client -> relay): u8 version=1 || u8 kind=1 || u16-be(count) || count * DROP_ID
WOKE (relay -> client): u8 version=1 || u8 kind=1 || DROP_ID

The relay only ever sees the same opaque, hourly-rotating addresses the client already polls, so wake reveals nothing the drop protocol did not, and the sender — not the relay — is what causes the write that triggers it. Correctness never depends on it: a missed wake is caught by the normal poll, and a client whose relay does not speak /wisp/wake/1 simply keeps polling.

  • Size cap. Blobs above the configured maximum (default 4096 bytes) are rejected.
  • Quota. A single drop holds at most a configured number of blobs (default 16), so no address becomes free hosting.
  • TTL. Undelivered blobs expire (default 7 days) and are swept.
  • Future: per-drop write tokens and proof-of-work stamps to raise the cost of flooding.

Protected. Message content (E2E, 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. Availability of a specific drop depends on at least one relay holding it; redundancy is probabilistic, not guaranteed.

Honest framing. Wisp does not remove infrastructure; it relocates it into things that are swappable, blind, and non-identifying. 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.”