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.

No install, no account, no backend of ours — the browser tab runs the same protocol source the terminal runs, so a tab and a terminal can chat to each other.

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. And how to play with every part of it.

01 — play

Play with everything

Four surfaces. Everything here is shipped and tested; the limits of each are stated where they apply, not buried.

The terminal — every command

p2pthe TUI: go online, show your key, chat default
p2p keyprint your stable key (--new rotates it)
p2p connect <KEY|name>dial a 26-char key, or a saved friend by name
p2p <KEY>same, straight into the TUI
p2p invitemint a one-time private invite and listen for it — § invites
p2p friendseveryone you’ve connected with — reconnect by name
p2p doctorcan the free infra see you? (STUN / DHT / trackers)
p2p chat [KEY]line-mode chat — scriptable, pipeable, no full screen
p2p --selftesttwo in-process nodes end-to-end — proves the plumbing

The 60-second demo: on machine A run p2p and copy the key from the box. On machine B run p2p <that key>. B prints ✅ secure channel established — verified, no MITM — that line is a proof, not a hope. Type; it arrives.

Your identity is stable across restarts (~/.p2p/<profile>.json, mode 0600). --ephemeral gives you a throwaway one; --profile <name> keeps a separate one.

02 — no install

The same peer, in a browser tab

Open it, share your key, chat. It is not a demo or a re-implementation: the browser loads the same key.js / noise.js / wire.js / node.js the terminal runs, over a small node:crypto → WebCrypto shim. Same key format, same commitment gate, same Noise IK handshake, same MITM proof — so a browser tab and a terminal can chat to each other. That pairing is a gate we run, not a claim we make.

A browser needs https:// (or localhost) — WebCrypto and honest security both want a secure context.

03 — metadata privacy

Private one-time invites

Your reusable key is a public contact string: anyone holding it can look up where you are, and the record it points at carries your candidate ip:ports in the clear. A one-time invite removes exactly that exposure.

p2p invite                          #  prints  S-XXXXXXXX…   (your key + a per-invite secret)
p2p connect GK0RN…7NS-YAFPE…KJ29    #  the invitee dials the whole string

p2p invite mints a fresh 128-bit secret K_inv and publishes your presence under it. Where you’re published, what the record says, and who may complete the handshake are then all derived from K_inv:

reusable keyone-time invite
Where you’re publishedHKDF(S, …) — any key-holder can find itHKDF(K_inv, …) — non-holders can’t even locate it
What the record saysyour ip:ports, plaintextAEAD-sealed, padded to a fixed size — an opaque, constant-length ciphertext
Who can handshakeanyone holding the key (Noise IK)only the invitee (Noise IKpsk2)

Your IP is never in the clear on any public channel in invite mode — not to a DHT node, not to a tracker operator, not to a passive observer.

Honest boundaries — what invites do not do (yet)

  • The peer you connect to still sees your IP. Invites hide your address from the infrastructure and from non-invitees — not from the person you’re talking to. Hiding it from the peer needs an onion transport (v3: researched, not built).
  • Burn/rotate is v2, not built. The invite is single-use by convention: nothing yet stops a second connection with the same string, and the secret isn’t retired after first contact.
  • The invite lives only while the process runs. It’s never written to disk — quit and it’s gone. Mint one per person.
  • On the LAN, mDNS still broadcasts candidates in plaintext (under the unlinkable invite id). Anyone on your LAN can see your IP by being on your LAN; the sealing protects the public channels — DHT and trackers.
  • While listening for an invite, ordinary key dials are refused — the node runs IKpsk2 only. That exclusivity is what makes “only the invitee can complete the handshake” true.

The reusable-key path is byte-for-byte unchanged when no invite is in play.

04 — more than two

Groups

Groups are a library feature today (no CLI verb yet). The secure group encrypts once per message under a sender key, fans the ciphertext out, and signs it — so a member cannot forge another member’s authorship. A member who can’t be reached directly is blind-relayed through another member (the relay can’t read it). Removing a member is cryptographic: rotate, and the removed member decrypts nothing afterwards. Join order doesn’t matter. Every one of those properties is a test — and the browser runs this same group code unchanged.

node.group([keyA, keyB, keyC]).send('hi all')   // pairwise fan-out: one authenticated link per member

const g = createSecureGroup(node, me, { secret, create: true })   // sender-key E2E group
await g.join()
await g.send('hi all')                          // ONE ciphertext, fanned to n

Known gap (v0.2): node.group() is reachable from the published package; createSecureGroup is not yet exported from it (usable from a checkout and inside the browser build). Adding that subpath is a one-line change and is queued.

05 — 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.

06 — 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.

07 — 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).

08 — 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.

09 — 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 and cross-process are verified end-to-end; a browser tab and a terminal are a witnessed pairing (over both a WebRTC DataChannel and the WSS relay); invite mode is witnessed over the live public rendezvous (DHT + trackers, mDNS removed). Not yet witnessed: a run across two different networks; offline store-and-forward and peer-relay are v2.

10 — the code

The whole stack fits in your head

Node ≥ 22, plain ESM, no build step, zero npm dependencies — every primitive is native: X25519, Ed25519, ChaCha20-Poly1305, HKDF, SHA-256 (node:crypto), UDP/TCP (node:dgram/node:net), WebSocket (global). The browser runs the same modules through a small WebCrypto shim — one protocol, two runtimes.

modulejoblines
src/key.jsIdentity. Keygen, the 26-char encode/decode, checksum, the commitment gate, rid derivation.225
src/noise.jsCrypto. Noise_IK (and IKpsk2 for invites), in-house, byte-exact against the official noise-c vectors.445
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/transport-wss.jsThe floor. Zero-dependency WebSocket relay — the path that always works, carrying ciphertext only.314
src/rendezvous/Meeting. mDNS, Mainline DHT (+ bencode), WebSocket trackers, and the publish-fanout / read-race.1325
src/invite.jsMetadata privacy. One-time K_inv: the sealed candidate blob, the invite rid, the Noise psk.376
src/group.jsGroups. Pairwise fan-out and the sender-key E2E group (signed authorship, blind relay, rotation).496
src/node.jsThe API. identity(), listen(), connect(), peer lifecycle, the reconnect outbox.552
src/browser/The tab. A node:crypto → WebCrypto shim, WebRTC + WSS transports — and then the same modules above.1502

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')

11 — 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.

12 — 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.