Put eight people on a full peer-to-peer mesh and send plain text between them. Every node answers and calls on one UDP port, and each node decides for itself who it will speak to. No hub, no server, no sign‑up
lab/postern-0.0.2-p2p · peer-to-peer
gate: bind udp/51820 · awaiting enrolled peers gate: unknown key → drop (silent) · no error, no banner, no version
the shape
Eight people with one device each is 28 links, and every node holds 7 entries — comfortable at this size. There is no authority: each node keeps its own roster and enforces it alone.
The four reference operations are ping, status,
roster, echo. None of them delivers a message to a person, so
Part B adds one small message operation. To prove the mesh
works first, do Part A and use ping / roster.
Node 18+ on each machine. Everyone clones the repo — node_modules is
committed, so there's nothing to install. One person is the roster keeper who assembles and
distributes the roster file — a convenience, not authority.
part a
On their own machine. The .key.json file holds the private key, is written
0600, and never leaves the machine. Only the 64‑character public key is shared.
node src/enroll.js keygen ana.key.json node src/enroll.js pub ana.key.json # the public key — the only part you send
A peer is a device, not a person, so a stolen laptop is revocable without
touching that person's phone. --ops is what you grant a device when it calls you
— grant the least each needs, there is no default. --addr is where to send a
first packet; omit it for a peer that can only be called (a laptop behind NAT). As long
as one side has an address, the channel opens.
node src/roster-cli.js init roster.json node src/roster-cli.js add-device roster.json ana ana-laptop <anapub> \ --addr 203.0.113.7:51820 --ops ping,status,roster node src/roster-cli.js add-device roster.json ben ben-laptop <benpub> \ --ops ping,status,roster # no --addr: inbound only
Send the roster to everyone, get on a call, and read the fingerprint aloud. If all eight hear their own screen read back, everyone holds the same keys and grants. If one number differs, stop and find out why before anyone runs a node.
node src/roster-cli.js list roster.json
The fingerprint covers keys, grants and revocations — not addresses. A roaming peer's address can be corrected without re‑verifying:
node src/roster-cli.js addr roster.json ben-laptop 198.51.100.4:51820 # routing only — fingerprint unchanged
Each node dials every peer that has an address and answers the rest. The mesh is now up.
POSTERN_KEY=ana.key.json POSTERN_ROSTER=roster.json POSTERN_PORT=51820 node src/peer.js
part b
Add one message operation so a recipient's node prints incoming text,
then a tiny chat.js that sends what you type to every connected peer. Everyone applies
the same two edits and re‑verifies the fingerprint after granting.
message handler to src/app.jsAdd this entry inside the fixed HANDLERS table, alongside ping / echo.
Text is capped at 400 chars — well under the 512‑byte args and 1024‑byte
plaintext limits. The handler never evals, spawns, or builds a path from input.
message (args, ctx) { // validate explicitly; assume args are hostile even from an enrolled peer const text = typeof args.text === 'string' ? args.text.slice(0, 400) : '' if (!text) return { delivered: false } const from = (ctx.roster && ctx.roster.labelOf) ? ctx.roster.labelOf(ctx.peerPub) : ctx.peerPub.slice(0, 16) + '…' console.log(`\n ${from}: ${text}\n`) // the "receive" — prints on the recipient return { delivered: true } }
message to each devicenode src/roster-cli.js grant roster.json ana-laptop message node src/roster-cli.js grant roster.json ben-laptop message # ...one per device (or add it up front: --ops ping,status,roster,message)
Granting changes the fingerprint. Distribute the updated roster.json, read the new
fingerprint aloud again, then each node picks it up without a full restart:
kill -HUP <pid> # re-reads the roster
src/chat.js and sendAna types a line; it is sealed to each connected peer over their encrypted session and prints on
their terminal as ana/ana-laptop: <text>.
'use strict' // Team chat over the mesh. Type a line, enter, it goes to every connected peer. const readline = require('readline') const { startPeer } = require('./peer') startPeer().then((node) => { const rl = readline.createInterface({ input: process.stdin }) console.log('[chat] type a message + enter to send to all connected peers') rl.on('line', (line) => { const text = line.trim() if (!text) return const payload = Buffer.from(JSON.stringify({ op: 'message', id: String(Date.now()), args: { text } })) let n = 0 for (const peerPub of node.byPeer.keys()) { node.send(peerPub, payload); n++ } console.log(`[chat] sent to ${n} peer(s)`) }) })
POSTERN_KEY=ana.key.json POSTERN_ROSTER=roster.json POSTERN_PORT=51820 node src/chat.js
The project's rule for any new op. In test-mesh.js, assert the happy path (a granted
peer's message returns { delivered: true }) and that a peer without
the grant receives forbidden.
npm test # channel suite npm run test:mesh # mesh suite — must pass, including the forbidden case
upkeep
Distribute the new roster, then kill -HUP <pid> on each node. A revoked key is refused
on its next packet and cannot open a new channel.
node src/roster-cli.js revoke roster.json --device ana-laptop # one device node src/roster-cli.js revoke roster.json --person ana # everything of theirs
There is no authority to push a revocation. It takes effect on each node as that node updates; a colleague on a stale roster still trusts the revoked key. The mesh suite asserts this on purpose. So revoke, send the roster, and confirm the new fingerprint from everyone before you consider it done.
the real work
Eight laptops that sleep, roam and sit behind NAT mostly won't reach each other. Three ways through, cheapest first.
Do not put a hub in the middle that terminates every channel. It would see all plaintext and all metadata, and be the single box whose compromise exposes the team.
honesty
Postern is a strong minimal design — not a proven "most resistant" protocol. What the mesh does not change:
Enrolment stays out of band, by a person, through a channel already trusted. Strangers still get silence. There is no sign‑up.