A mesh for your team

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

What you're building

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.

before you start

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

Stand up the mesh

1 · Everyone makes a key

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

2 · The roster keeper assembles the roster

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

3 · Verify the fingerprint together

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
roster v3 fingerprint BP9B-M5T4-PDDG

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

4 · Everyone runs a node

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
[postern] ana/ana-laptop listening udp/51820 [postern] roster v3 fingerprint BP9B-M5T4-PDDG (7 peers)

part b

Send basic messages

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.

1 · Add the message handler to src/app.js

Add 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 }
}

2 · Grant message to each device

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

3 · Re-verify and restart

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

4 · Add src/chat.js and send

Ana 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

5 · Add a test

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

Revoking someone

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
the mesh's weak point, stated plainly

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

Reachability

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

Limits, stated plainly

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.