That didn’t look like a valid p2p key — a key is 26 Crockford-base32 characters. Here’s the normal install instead.
Install p2p and start chatting with them — you’ll become friends and can reconnect anytime.
their key — install below, the key is already baked into the command.
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
Four surfaces. Everything here is shipped and tested; the limits of each are stated where they apply, not buried.
p2p | the TUI: go online, show your key, chat default |
p2p key | print 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 invite | mint a one-time private invite and listen for it — § invites |
p2p friends | everyone you’ve connected with — reconnect by name |
p2p doctor | can the free infra see you? (STUN / DHT / trackers) |
p2p chat [KEY] | line-mode chat — scriptable, pipeable, no full screen |
p2p --selftest | two 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
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.
/app/#<KEY> prefills the dial box; /app/#<group-code> prefills the group Join box. Send one link and your friend is one click from talking to you.S-… share link and it tells you to use the CLI rather than misrouting you. p2p invite is the private path today.A browser needs https:// (or localhost) — WebCrypto and honest security both
want a secure context.
03 — metadata privacy
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 key | one-time invite | |
|---|---|---|
| Where you’re published | HKDF(S, …) — any key-holder can find it | HKDF(K_inv, …) — non-holders can’t even locate it |
| What the record says | your ip:ports, plaintext | AEAD-sealed, padded to a fixed size — an opaque, constant-length ciphertext |
| Who can handshake | anyone 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.
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 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 nKnown 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
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).
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
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.
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
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.
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
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.
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
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.
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
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.
| module | job | lines |
|---|---|---|
| src/key.js | Identity. Keygen, the 26-char encode/decode, checksum, the commitment gate, rid derivation. | 225 |
| src/noise.js | Crypto. Noise_IK (and IKpsk2 for invites), in-house, byte-exact against the official noise-c vectors. | 445 |
| src/wire.js | Reliability. Framing, sliding-window ARQ, cumulative ack, RTT-adaptive resend, keepalive, connId roaming. | 361 |
| src/transport.js | Pipes. Dual-stack UDP, in-house STUN client, ICE-lite hole punch, TCP fallback. | 555 |
| src/transport-wss.js | The 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.js | Metadata privacy. One-time K_inv: the sealed candidate blob, the invite rid, the Noise psk. | 376 |
| src/group.js | Groups. Pairwise fan-out and the sender-key E2E group (signed authorship, blind relay, rotation). | 496 |
| src/node.js | The 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
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
curl -fsSL https://p2p.akeyo.io/init | sh
irm https://p2p.akeyo.io/init.ps1 | iex
Already have p2p? Just run p2p
p2p command on your machine — no account, no signup.p2p key prints your 26 characters. That string is you.p2p.akeyo.io/init/<your-key>, which installs p2p and
dials you in one step.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.