No description
  • Go 78.4%
  • JavaScript 13.1%
  • Shell 4.2%
  • CSS 3.1%
  • HTML 0.9%
  • Other 0.3%
Find a file
Ruben M. Damião a8af202b2f papo: peer-to-peer chat, its record on the chain, ties by name, hosted
The node and its transports; the record in drawers and the ties by laço
(registo/, a module of its own, where the dependencies live); papo hosted
on the iu (registo/cmd/papo-web); and hosted/, the door by which another
module serves a papo's page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:13:07 -03:00
cmd papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
discovery papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
electron papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
filestore papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
hosted papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
identity papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
internal papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
mailbox papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
node papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
registo papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
relay papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
transport papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
wire papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
.gitignore papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
go.mod papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
Makefile papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00
README.md papo: peer-to-peer chat, its record on the chain, ties by name, hosted 2026-10-10 00:13:07 -03:00

Papo

Peer-to-peer chat on the local network. Peers find each other by multicast, talk over TLS 1.3, and authenticate by Ed25519 key. There is no server, no account, no configuration and no internet.

Two front ends drive the same core: a terminal client, and a desktop window built on Electron.

There is one optional server, papo-relay, and it holds messages for peers who are not on the network. It is opt-in, it is never needed for a conversation between two machines that can see each other, and it cannot read a word of what it carries.

Building and running

make build     # dist/papo, dist/papo-ui and dist/papo-relay
make test      # the node test needs a real network; it skips where there is none
make vet       # go vet, plus gofmt

Terminal. Run it on two machines on the same network, or twice on one machine with different key files:

./dist/papo -nick ana   -key ./ana.key
./dist/papo -nick bento -key ./bento.key

Type text to send it to every connected peer, or use /msg, /ficheiro, /pares, /eu, /ajuda, /sair.

Desktop. make shell builds the core and opens the window. It needs an electron binary, which ELECTRON points at.

To test with two windows on one machine, give each its own key file — not just its own name. Two instances sharing an identity file share a token, and discovery drops its own echo, so they would be invisible to each other:

PAPO_NICK=ana   PAPO_KEY=./ana.key   make shell
PAPO_NICK=bento PAPO_KEY=./bento.key make shell

papo-ui also runs on its own and prints a URL any browser will open:

./dist/papo-ui
PAPO_UI_URL=http://127.0.0.1:39389/?k=81c58be8…
Flag papo papo-ui Meaning
-nick • • display name announced to peers; defaults to the hostname
-key • • identity file; defaults to $XDG_CONFIG_HOME/papo/identity.key
-addr • • TCP address for peer sessions; :0 takes any free port
-files • • where received and attached files are kept
-relay • • relay for peers who are offline; empty for none, PAPO_RELAY by default
-http • address for the front end; loopback only
-watch-parent • exit when standard input closes; the shell passes this
-v • • log what discovery and transport are doing

How it fits together

Package Responsibility
identity the Ed25519 key pair; Token is the address, the fingerprint and the pin
mailbox notes sealed for a token, so they can be left with something untrusted
relay the store-and-forward queue: the server, and the client that drains it
discovery signed multicast announcements, and the table of who is present
transport TLS 1.3 sessions authenticated by pinning the announced key
wire length-prefixed JSON frames in a kind/payload envelope
filestore what peers sent and what is waiting to be sent, on disk
node the three joined into one object that emits events
internal/ui loopback HTTP: the embedded front end, an event stream, two calls
cmd/papo a terminal client, so the core is verifiable without a screen
cmd/papo-ui the core plus the front end, for the desktop shell to spawn
cmd/papo-relay the optional server; the only papo process that is not a peer
electron/ the window: it mints the token, spawns the core, and shows the page

node knows nothing about any interface, and nothing outside it reaches past it. There are no third-party dependencies, in Go or in the front end — the page is hand-written HTML, CSS and JavaScript served out of the binary, with no build step.

The interface receives a stream of server-sent events and sends with ordinary requests. Every event carries the whole peer list, so the page renders what it was last told instead of maintaining a model that could drift from the node's.

Attachments

Files travel over the same session as the chat, in 64 KiB chunks, so a message sent during a transfer arrives without waiting for it. Each transfer is announced with its size, streamed, and closed with a SHA-256 of the whole file; what does not match is discarded rather than stored. The desktop window takes a file from the button, a drag, or a paste — which is how a screenshot gets into a conversation — and shows images, sound and video in place.

Two rules govern what arrives, and both assume the sender may be hostile even though its key is pinned:

  • The name a peer sends never becomes a path. Files are stored under an identifier this node generates; the sender's name is a label to display.
  • The type is derived from the bytes, never believed. What is rendered in place is limited to images, sound, video, plain text and PDF. HTML and SVG are always served as opaque downloads, because either can carry script, and script running in the shell's origin would be able to read the token that guards the whole interface.

Messages for a peer who is not there

Without a relay, a message to a peer who is offline fails and papo says so. With one, it is sealed and queued, and the peer collects it when it next runs.

papo-relay -addr :9494 -dir /var/lib/papo-relay      # anywhere both peers reach
papo -nick ana -relay https://relay.exemplo:9494     # on each peer

A queue is addressed by a recipient token, and a deposit is one POST of opaque bytes. Anyone may write to a box, the way anyone may post a letter; only the holder of the key it is addressed to may read it, and it proves that by signing a challenge. What is collected is acknowledged, and acknowledging is what deletes it.

Route Who may call it What it does
POST /v1/box/{token} anyone leaves one sealed note in that token's queue
POST /v1/session → /claim anyone, then the key signs a challenge for a bearer session
GET /v1/items?wait=30s the box's key lists what is waiting, holding open until something is
GET /v1/items/{id} the box's key the bytes, which stay in the box
DELETE /v1/items/{id} the box's key the acknowledgement, which removes them
GET /v1/info anyone the limits, and how much is held

Every note is sealed for the recipient's token before it leaves: an ephemeral X25519 key agreement against the token itself, AES-256-GCM, and an Ed25519 signature by the sender over the recipient and the body. A fresh key per note means a key stolen tomorrow does not open what was sent today — what it does open is anything still sitting in a relay, which is why notes expire there.

The relay is told three things and cannot learn a fourth: a recipient, a size, and a time. It does not know who wrote a note, what it says, or whether the two peers ever spoke. Traffic analysis — that a token received nine notes on Tuesday — is real, and is the honest price of having a relay at all. Run your own.

The security model, and its limits

A peer announces its public key; a peer that dials requires the far end to complete a TLS handshake with that exact key. That is certificate pinning against a one-entry pin set. TLS 1.3 supplies the ephemeral key agreement, the forward secrecy, the AEAD and the downgrade protection; the certificate itself is ignored as a certificate — no chain, no name, no expiry, no authority.

What this does not give you:

  • No binding between a key and a person. Papo proves the machine on the far end holds the key it announced. Whether that key belongs to the person whose name it displays is something to check out of band, by comparing fingerprints. Nicknames are self-declared and are not evidence.
  • Metadata is visible on the segment. Announcements are in the clear by design: who is present, under what name, at what address. Only the conversation is encrypted.
  • The key file is not encrypted. It holds a raw seed at mode 0600. A passphrase-wrapped keystore is the first thing that must change before anyone relies on this.
  • Presence is replayable. A captured announcement can be replayed within a 60-second window. It buys an attacker a peer entry that then fails to prove itself at the TLS handshake.
  • Anyone you have a session with can fill your disk. Transfers are accepted automatically, bounded per file (256 MiB) and per session (8 at a time), but not in total. An accept prompt is the answer, and it is not written yet.
  • The local port is guarded, not sealed. It binds loopback and every call carries a token minted at startup, which stops a web page and another application. It does not stop a process running as the same user, and no local server can.
  • A relay sees who writes to whom. It cannot read a note, alter one, move one between boxes or replay one, and it is told nothing about the sender. It does see that a token is receiving mail, how much and when. Nothing sealed fixes that; running your own relay is the answer.
  • Anyone can fill a box. Depositing is unauthenticated on purpose — a mailbox that demanded a login would be a relay deciding who may write to you. What bounds it is per-box limits, so flooding costs one conversation and not the disk.
  • Relayed delivery is at least once. A note is acknowledged after it has been shown, so a crash in between shows it twice rather than losing it.

What is not here yet

Live media — camera, microphone, screen sharing — and any link to the rest of the aslivres family. Offline delivery carries text and nothing else: files still need both peers present. There is no address book either, so a message to someone who is away has to name a full token, and the desktop window can only do that for a peer it has already seen. Papo compiles standalone and has no replace directive: identity.Token is byte-identical to brisa's crypto.Token, so joining the family later is a conversion and not a rewrite.