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.
1. Overview
Section titled “1. Overview”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.
1.1 Terminology
Section titled “1.1 Terminology”- 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.
2. Identity
Section titled “2. Identity”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]2.1 WispID
Section titled “2.1 WispID”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.
2.2 libp2p PeerId
Section titled “2.2 libp2p PeerId”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.
3. Cryptographic primitives
Section titled “3. Cryptographic primitives”| 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.
4. Wire framing
Section titled “4. Wire framing”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.
5. Presence records
Section titled “5. Presence records”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.
6. Discovery
Section titled “6. Discovery”- Bootstrap. A joining client learns a first peer via, in order: mDNS on the local network, shipped bootstrap addresses, or a manually pasted peer.
- Publish. A reachable client publishes its presence record to the DHT.
- Look up. To reach a WispID, derive
SHA256(pub),GETthe record, verify the signature, and dial the contained addresses. - NAT traversal. AutoNAT determines reachability; DCUtR coordinates hole-punching; circuit-relay-v2 is the fallback when a direct path cannot be formed.
7. Session establishment (pairing)
Section titled “7. Session establishment (pairing)”Pairing requires both peers online once. It runs over the /wisp/session/1 stream.
7.1 Noise handshake
Section titled “7.1 Noise handshake”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).
7.2 Ratchet initialization
Section titled “7.2 Ratchet initialization”- 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.
7.3 Identity binding
Section titled “7.3 Identity binding”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)- Initiator sends its proof; responder verifies the signature against the enclosed key and records that key as the peer’s identity.
- 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.
7.4 Safety number
Section titled “7.4 Safety number”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 digits8. Messaging
Section titled “8. Messaging”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[] ciphertextThe 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.
8.1 Message payloads
Section titled “8.1 Message payloads”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 tagtag = 1 CHAT: MESSAGE_ID || u8 reply_flag || (reply_flag==1: MESSAGE_ID reply_to) || u8[] bodytag = 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[] bodytag = 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.
9. Dead-drops
Section titled “9. Dead-drops”9.1 Derivation
Section titled “9.1 Derivation”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.
9.2 Epochs
Section titled “9.2 Epochs”EPOCH_SECONDS = 3600epoch(now) = now / EPOCH_SECONDSA 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.
9.3 What lands on the relay
Section titled “9.3 What lands on the relay”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.
10. Relay drop protocol
Section titled “10. Relay drop protocol”Runs over the /wisp/drop/1 stream. Each request is answered by one response, both
length-framed. DROP_ID is 32 bytes.
10.1 Requests
Section titled “10.1 Requests”u8 version = 1u8 kindkind = 1 PUT: DROP_ID || u32-be(blob_len) || blobkind = 2 GET: DROP_IDkind = 3 ACK: DROP_ID || u16-be(count) || count * u64-be(entry_id)10.2 Responses
Section titled “10.2 Responses”u8 version = 1u8 kindkind = 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.
10.3 Instant wake (optional)
Section titled “10.3 Instant wake (optional)”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_IDWOKE (relay -> client): u8 version=1 || u8 kind=1 || DROP_IDThe 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.
11. Abuse resistance
Section titled “11. Abuse resistance”- 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.
12. Threat model
Section titled “12. Threat model”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.”
