Chat that needs no server.

p2p is a tiny chat framework with zero dependencies and no coordinator server of ours. One 26-character key is your whole contact surface: send it to a friend, they find you anywhere on the internet and message you — direct, end-to-end encrypted, and provably free of a man-in-the-middle from the very first message.

This page explains the whole thing — including the part that sounds impossible: how two computers behind two home routers find each other and talk, with nobody in the middle to introduce them.

01 — identity

The key is the identity

Your machine generates two keypairs — Ed25519 (signing) and X25519 (Diffie–Hellman) — and keeps the private halves on disk (~/.p2p/default.json, mode 0600). Your 26-character key is not random: it is a fingerprint of your public keys, in Crockford base32 (uppercase, no I L O U, so it survives being read aloud or typed).

your public keys edPub (32B) xPub (32B) SHA256("p2p-id-v1" ‖ edPub ‖ xPub) first 110 bits v/flags 110-bit commitment to (edPub, xPub) checksum 5 bits 130 bits total = 26 × 5 15 bits 7ZQ4T8K2M9XJRV0BCDN5FH3PWA Crockford base32 →
The 26-char key, bit by bit. 5 bits version/flags · 110 bits of SHA256("p2p-id-v1" ‖ edPub ‖ xPub) · 15-bit checksum (a typo is caught locally, before any packet leaves). Nobody can hand out a string that points at your identity but is secretly theirs: they would have to find a second preimage of a 110-bit hash — 2110 work. The checksum makes the key safe to dictate over the phone.

02 — the magic

Finding each other with no server

Normally “how does computer A reach computer B?” needs a server both sides already know — a chat server, a signalling server, something with a fixed address. We have none. Three ideas stacked make it work anyway.

Idea 1 — the key doubles as a secret meeting point. Both sides hash the key into a rendezvous id: rid = HKDF(S, "p2p-rv-<channel>-v1", epoch). Anyone holding the key computes the identical rid; anyone who doesn’t, can’t — it’s one-way. The epoch is the UTC day, so the rid rotates daily (readers check ±1 day; listeners pre-announce the next epoch, so there’s no blackout at midnight). No server told the peers where to meet. The math did.

Idea 2 — leave the note on bulletin boards that already exist. We don’t run a server, but the internet is full of free, public, shared notice boards built for other reasons. We ride three, publish to all of them, and race the reads.

Ada (listener) holds the private keys rid=HKDF(S,epoch) Bo (dialer) holds only Ada’s key rid=HKDF(S,epoch) same key ⇒ same secret rid ⇒ same meeting point publish to all three read all three, race ① mDNS — your LAN multicast shout, same Wi-Fi, ~1.5s ② BitTorrent Mainline DHT announce_peer / get_peers on rid ③ Public WebSocket trackers live matchmaker: offer → answer announce lookup — raced all three channels ever see: an opaque hash + an IP. Never who. Never what. we run none of them
Discovery, end to end. Ada announces her rendezvous id on all three channels; Bo derives the same rid from her key and looks it up on all three in parallel (publish-to-N, race-reads) — if one channel is blocked or slow, the others still land. mDNS covers same-network; the DHT and the trackers are two independent internet paths. The DHT gives a public ip:port hint; the tracker is a live matchmaker that carries the full candidate list (IPv6 / LAN / STUN) in the offer blob. Zero trust in all of them: anyone can announce a lie — the commitment gate and the handshake below make lies useless.

03 — the magic, part two

Punching through the routers

Idea 3 — the hole punch. Both sides now know each other’s public address, but home routers (NAT) drop unexpected inbound packets. So each side first asks a free public STUN server “what does my address look like from outside?”, then both fire a UDP packet at the other at the same moment. Your outbound packet teaches your own router “I’m expecting a reply from that address” — which props the pinhole open just long enough for the other side’s packet to arrive. Both firewalls open at once, and a direct path exists.

public STUN server “your address looks like…” ① what’s my public ip:port? ① same question Ada 192.168.1.20 router NAT pinhole Bo 10.0.0.7 router NAT pinhole ② UDP → (opens Ada’s pinhole) ← ② UDP (opens Bo’s pinhole) both fired at the same moment ③ direct encrypted path ChaCha20-Poly1305 · ordered · keepalive 25s · no server in the middle the ladder, raced ICE-style, best path kept: IPv6 direct → LAN → UDP hole punch → TCP simultaneous-open → peer-relay via a mutual friend (v2)
The hole punch. STUN (another borrowed, free service) reveals each side’s public ip:port; the simultaneous send makes each NAT believe the inbound packet is a reply it already expected. Direct connectivity succeeds for roughly 70–90% of real-world pairs. When both peers sit behind symmetric NAT, no direct path can form — the design falls back down the ladder to a relay through a mutual online peer (still end-to-end encrypted, so the relay is blind; v2).

04 — the proof

Why the first ack proves nobody is in the middle

A path exists — but is the machine at the other end really the owner of the key? Every rendezvous channel is treated as hostile: anyone holding the key can announce a bogus address. Two mechanisms make that harmless.

Bo — dialer Ada — listener HELLO { edPub, xPub } — plaintext, replayable, proves NOTHING COMMITMENT GATE SHA256("p2p-id-v1"‖edPub‖xPub)[0..110) == key’s commitment? mismatch → drop. Forging a match = 2¹¹⁰ work. Noise IK msg1: e, es, s, ss (Bo encrypts to Ada’s static key) only the holder of Ada’s X25519 private key can decrypt this Noise IK msg2: e, ee, se ← THE ACK msg2 decrypts ⇒ proof: the other end holds Ada’s private key. A man-in-the-middle who relayed everything still cannot produce it — it is keyed to a secret only Ada has. session: ChaCha20-Poly1305, ordered, exactly-once
Fetch → gate → Noise IK. The HELLO is public and replayable, so it authenticates nothing on its own — it only supplies candidate public keys, which are gated against the 110-bit commitment baked into the key you were given. Then a standard Noise IK handshake (validated byte-exact against the official noise-c test vectors) runs on top. connect() resolves only after msg2 decrypts — that success is the MITM proof. When the CLI prints ✅ secure channel established — verified, no MITM, that line is a mathematical statement, not a hopeful label. Honest limits: forward secrecy is session-granular (no in-session ratchet), and metadata (an IP, an opaque rid) is visible to the rendezvous channels.

05 — behavior

Every path the system takes

First contact, the same-Wi-Fi fast path, the cross-internet path, the fallback ladder, and what happens when a peer’s laptop closes and comes back.

p2p <KEY> checksum checked locally resolve rid mDNS · DHT · trackers (raced) candidate list IPv6 · LAN · STUN-reflexive addresses the ladder — raced, first working rung wins IPv6 direct same-LAN UDP hole punch TCP open relay (v2) ~1.5s on the same Wi-Fi · 70–90% of internet pairs punch through · both sides symmetric-NAT ⇒ relay no rung is trusted: every one of them ends at the same gate commitment gate → Noise IK → first ack decrypts connect() resolves here, and only here — verified, no MITM session — direct, encrypted, ordered exactly-once (seq dedup) · keepalive 25s · connId survives roaming peer drops (laptop closes, Wi-Fi flips) liveness times out → sends buffer in the app-level outbox redial the same rid new socket re-attaches to the SAME peer → buffered messages flush, still exactly-once, still verified
Every path, one flow. Fresh first contact and reconnect share the same machinery: the rid is derived from the key, so a peer that vanishes is simply looked up again. Reliability lives in two layers — wire.js gives ordered, exactly-once delivery within a session; node.js adds an app-level outbox keyed by peer identity, so messages queued while a friend was offline flush on reconnect and are never delivered twice. Honest status: same-LAN is verified end-to-end; cross-internet machinery (live STUN, live DHT lookups, live trackers, punch) is proven piecewise; offline store-and-forward and peer-relay are v2.

06 — the code

The whole stack fits in your head

Node ≥ 22, plain ESM, no build step, zero npm dependencies — it is the only runtime where every primitive is native: X25519, Ed25519, ChaCha20-Poly1305, HKDF, SHA-256 (node:crypto), UDP/TCP (node:dgram/node:net), WebSocket (global).

modulejoblines
src/key.jsIdentity. Keygen, the 26-char encode/decode, checksum, the commitment gate, rid derivation.225
src/noise.jsCrypto. Noise_IK_25519_ChaChaPoly_SHA256, in-house, byte-exact against the official noise-c vectors.381
src/wire.jsReliability. Framing, sliding-window ARQ, cumulative ack, RTT-adaptive resend, keepalive, connId roaming.361
src/transport.jsPipes. Dual-stack UDP, in-house STUN client, ICE-lite hole punch, TCP fallback.555
src/rendezvous/Meeting. mDNS, Mainline DHT (+ bencode), WebSocket trackers, and the publish-fanout / read-race.1159
src/node.jsThe API. identity(), listen(), connect(), peer lifecycle, the reconnect outbox.420

Use it as a library — it is designed to be the messaging backbone of any project:

import { identity, listen } from '@fire17/p2p'

const me = await identity()            // me.S = your 26-char key — share it
const node = await listen(me)
node.on('message', (peer, data) => console.log(data.toString()))

const peer = await node.connect(theirKey)   // resolves only after the MITM-proof handshake
await peer.send('hey')

07 — people

Friends

Everyone you connect with is remembered — their key is saved locally (~/.p2p/<profile>.friends.json), so you never need the long string twice. p2p friends lists them; reconnect by name. Because the rid is derived from the key, a friend is findable again from anywhere, on any network, whenever you’re both online. No account, no directory, no server that could ever be asked who your friends are.

08 — get it

Install

One command

  1. Run the installer. It puts the p2p command on your machine — no account, no signup.
  2. Get your key. p2p key prints your 26 characters. That string is you.
  3. Share it. Send the key — or the link p2p.akeyo.io/init/<your-key>, which installs p2p and dials you in one step.
  4. Chat. They run p2p <key>; you both see ✅ verified, no MITM and you’re talking — directly.

The installer is a plain shell script you can read before you run it: init · init.ps1.