- Go 78.4%
- JavaScript 13.1%
- Shell 4.2%
- CSS 3.1%
- HTML 0.9%
- Other 0.3%
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> |
||
|---|---|---|
| cmd | ||
| discovery | ||
| electron | ||
| filestore | ||
| hosted | ||
| identity | ||
| internal | ||
| mailbox | ||
| node | ||
| registo | ||
| relay | ||
| transport | ||
| wire | ||
| .gitignore | ||
| go.mod | ||
| Makefile | ||
| README.md | ||
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.