Serverless, end-to-end-encrypted chat over a shared Linux directory.
No server process. No accounts service. No network daemon. Every client reads and writes plain files in a directory you already share — an NFS mount, a group-writable /srv/chat, a home server — and does all of the encryption, decryption, sending and receiving itself. A curses TUI runs in any terminal.
The "network" is a directory. If two users can read and write the same path — NFS, sshfs, a multi-user box — they can chat. Nothing to deploy, monitor, or keep alive. Kill every client and the conversation is still there, as ciphertext on disk.
X25519 key exchange, HKDF, ChaCha20-Poly1305 AEAD, scrypt-encrypted configs — built on the audited pyca/cryptography primitives, not homemade ciphers. Filesystem permissions decide who sees the files; encryption decides who reads the messages.
A fast curses TUI with mouse support, unread badges, invites and groups — plus slash commands for terminals that swallow Ctrl keys, and a full CLI for scripting every operation headlessly. Python ≥ 3.9, one dependency.
Screenshots
These are real captures of paf running against a live shared directory — invites, unread badges, groups and all. Left pane: invites, chats, groups, and registered users you haven't connected with yet. Right pane: the open conversation and input line.
INVITES │ phone_a_friend ✉ carol (chat) │ ──────────────────────────────────────────────────────────────────────── CHATS │ bob │ dave ●2 │ GROUPS │ #book-club ●1 │ Welcome! Select a chat or group on the left. USERS │ + carol │ Registered users you have not connected with + erin │ appear under USERS - select one and press │ Enter to send them a chat invite. │ │ F2 or Ctrl-N invite someone to chat by name │ F3 or Ctrl-G create a group │ F4 or Ctrl-O invite someone to the open group │ F10 quit │ │ Or type a command into the input line: │ /invite USER /group NAME /ginvite USER │ /unfriend USER /gremove USER /quit │ (commands always work, even in terminals that │ swallow Ctrl or function keys, like VS Code) │ │ Invites push public keys: without an accepted │ invite, messages cannot be decrypted. │ ──────────────────────────────────────────────────────────────────────── │ logged in as alice - F2/^N invite F3/^G group F4/^O g-invite F10 quit Enter open/send
First screen after logging in. A pending invite from carol, unread badges on dave ●2 and #book-club ●1, and users you can invite under USERS.
INVITES │ chat with bob ✉ carol (chat) │ ──────────────────────────────────────────────────────────────────────── CHATS │ bob │ dave ●2 │ GROUPS │ #book-club ●1 │ USERS │ + carol │ + erin │ │ │ │ │ │ │ │ │ │ │ │ │ 19:16 bob: lunch? │ 19:16 alice: sure - where? │ 19:16 bob: the usual │ 19:16 alice: see you at noon │ ──────────────────────────────────────────────────────────────────────── │ > see you at the usual place at noon F2/^N invite F3/^G group F4/^O g-invite F10 quit Enter open/send
An open direct-message conversation. Every message in this thread is encrypted with a key only alice and bob can derive. New messages appear in place a few hundred milliseconds after the other side hits Enter.
INVITES │ #book-club ✉ carol (chat) │ ──────────────────────────────────────────────────────────────────────── CHATS │ bob │ dave ●2 │ GROUPS │ #book-club │ USERS │ + carol │ + erin │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ 19:16 bob: chapter 4 tonight? │ ──────────────────────────────────────────────────────────────────────── │ > F2/^N invite F3/^G group F4/^O g-invite F10 quit Enter open/send
A group conversation. Each group has its own random symmetric key, held only by members who accepted an invite. Any member can invite (F4 / /ginvite) or remove (/gremove) others.
INVITES │ #book-club ✉ carol (chat) │ ──────────────────────────────────────────────────────────────────────── CHATS │ bob │ dave ●2 │ GROUPS │ #book-club │ USERS │ + carol │ + erin │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ 19:16 bob: chapter 4 tonight? │ ──────────────────────────────────────────────────────────────────────── │ > chat invite from carol - accept? [y=yes / n=decline / Esc]
Accepting an invite is the key exchange: carol's invite pushed her public key to alice; pressing y pushes alice's public key back. Without an accepted exchange, messages can't be sent or decrypted.
Captured from a genuine session: five users registered in a shared directory, invites exchanged, messages sent — all through the real client.
How it works
There is no privileged process and no coordinator. Each client polls the shared directory a few times per second, decrypts what it can prove it should read, and appends its own ciphertext. The directory is a dead drop, not a broker.
Lots of teams already share a filesystem: research clusters, lab servers, family home servers, retro multi-user boxes. phone_a_friend turns that existing trust boundary into a chat network with nothing new to run or open up.
Access control is layered: filesystem permissions decide who can see the files at all; encryption decides who can read the messages. Drop-boxes get the sticky bit so users can't delete each other's files, and every ciphertext is authenticated — tampered files are simply ignored.
The client polls a few times per second, so the open conversation updates near-instantly and everything else lights up an unread badge (●3).
<shared>/ users/<name>/identity.json # public identity (name + public key) users/<name>/config.enc # client config, passphrase-encrypted invites/<name>/<id>.json # sealed invites pushed TO <name> replies/<name>/<id>.json # sealed invite replies dm/<a>__<b>/<ts>-<rand>.json # DMs, pair-key encrypted groups/<gid>/meta.json # public group metadata groups/<gid>/msgs/<...>.json # group msgs, group-key encrypted
Security model
All cryptography comes from pyca/cryptography. Key exchange is push-based: an invite pushes your public key to the recipient; accepting pushes theirs back. No accepted exchange, no messages — in either direction.
Each pair of users derives a shared key from their long-term X25519 keypairs. Readable only with one of the two private keys and the peer's public key.
"Sealed" to the recipient's public identity key, like a sealed box: only the addressee can open an invite, and each one uses a fresh ephemeral key.
Each group has a random symmetric key. Inviting a member seals the group key to their identity key; any member can invite or remove others.
Private key, contact keys, group keys and read state live in one file in the shared directory — encrypted under a key derived from your passphrase.
Every ciphertext is authenticated (AEAD) and tampered files are ignored, but some things are deliberately out of scope for v1 — read this before you trust it with anything serious:
Quick start
$ pip install phoneafriend
Python ≥ 3.9 on Linux. Installs the paf command. Single runtime dependency: cryptography.
$ sudo mkdir -p /srv/paf $ sudo chgrp chatters /srv/paf $ sudo chmod 2770 /srv/paf
Any directory all participants can read and write works — a POSIX group, an NFS mount, sshfs. The client creates its own subdirectories.
$ paf --dir /srv/paf # or: $ PAF_DIR=/srv/paf paf
Register with a username and passphrase on first run — an X25519 identity keypair is generated for you. Then invite someone with F2.
| Key | Action |
|---|---|
↑ ↓ / click | select chat, group, user or invite |
Enter | open selection — or send, if the input has text |
F2 / Ctrl-N | invite a user to chat |
F3 / Ctrl-G | create a group |
F4 / Ctrl-O | invite a user to the open group |
PgUp / PgDn | scroll message history |
F10 | quit |
| Command | Action |
|---|---|
/invite USER | invite a user to chat |
/unfriend USER | stop chatting (a new invite restores it) |
/group NAME | create a group |
/ginvite USER | invite a user to the open group |
/gremove USER | remove a user from the open group |
/quit | exit |
Scripting & automation
The TUI is a thin layer over a real CLI. Register users, exchange invites, send and read messages from scripts, cron jobs or CI — --passphrase / $PAF_PASSPHRASE avoid the prompt. This is the actual output:
$ paf -d /srv/paf -u alice register registered alice $ paf -d /srv/paf -u alice invite bob chat invite (with your public key) pushed to bob $ paf -d /srv/paf -u bob accept --from alice accepted: chat with alice $ paf -d /srv/paf -u bob send --to alice "lunch?" sent $ paf -d /srv/paf -u alice create-group "book-club" created group 'book-club' (cb9937404fd9ba10) $ paf -d /srv/paf -u alice invite bob --group "book-club" group key for 'book-club' pushed to bob $ paf -d /srv/paf -u alice read --to bob [bob] lunch? $ paf -d /srv/paf -u alice status user: alice contacts: bob (unread: 0) dave (unread: 2) groups: #book-club [cb9937404fd9ba10] (unread: 1)