phone_a_friend

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.

PyPI Python versions CI Coverage 100% GPL-3.0-or-later
$ pip install phoneafriend copied ✓
Get started Source ↗
alice@homeserver: ~
📁

Zero infrastructure

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.

🔐

Real cryptography

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.

⌨️

Terminal native

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

The TUI, exactly as it runs

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.

paf --dir /srv/paf
INVITESphone_a_friend
 ✉ carol (chat)          │ ────────────────────────────────────────────────────────────────────────
CHATS                    │
  bob                    │
  dave ●2GROUPS  #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.

paf --dir /srv/paf — chat with bob
INVITESchat with bob
 ✉ carol (chat)          │ ────────────────────────────────────────────────────────────────────────
CHATS  bob                     dave ●2GROUPS  #book-club ●1USERS + carol + erin                  │
                         │
                         │
                         │
                         │
                         │
                         │
                         │
                         │
                         │
                         │
                         │
                         │ 19:16 bob: lunch?19:16 alice: sure - where?19:16 bob: the usual19: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.

paf --dir /srv/paf — #book-club
INVITES#book-club
 ✉ carol (chat)          │ ────────────────────────────────────────────────────────────────────────
CHATS                    │
  bob                    │
  dave ●2GROUPS  #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.

paf --dir /srv/paf — invite from carol
INVITES#book-club
 ✉ carol (chat)          │ ────────────────────────────────────────────────────────────────────────
CHATS                    │
  bob                    │
  dave ●2GROUPS                   │
  #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

Every client is its own server

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.

alice's laptop paf (curses TUI) keys stay client-side bob's SSH session paf (curses TUI) encrypt / decrypt locally carol's cron job paf CLI (headless) scripted send / read /srv/paf (shared directory) users/<name>/identity.json users/<name>/config.enc 🔒 invites/<name>/<id>.json 🔒 replies/<name>/<id>.json 🔒 dm/<a>__<b>/<ts>.json 🔒 groups/<gid>/meta.json groups/<gid>/msgs/… 🔒 🔒 = ciphertext at rest (AEAD) no daemon reads these files — only other clients do read/write read/write

Why this shape?

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

Standard primitives, boring on purpose

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.

Direct messages

X25519 static-static DH → HKDF → ChaCha20-Poly1305

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.

Invites & replies

ephemeral X25519 → HKDF → ChaCha20-Poly1305

"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.

Groups

random 256-bit group key → ChaCha20-Poly1305

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.

Your config

passphrase → scrypt → ChaCha20-Poly1305

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.

⚠ Honest about scope (v1)

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:

  • No forward secrecy or key rotation — a leaked private key exposes past messages.
  • Sender authentication is by conversation-key possession only.
  • Traffic analysis: filenames reveal timing, directory names reveal who talks to whom.
  • Group removal is cooperative — the group key is not rotated when someone is removed.

Quick start

Chatting in three steps

1

Install

$ pip install phoneafriend

Python ≥ 3.9 on Linux. Installs the paf command. Single runtime dependency: cryptography.

2

Share a directory

$ 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.

3

Phone a friend

$ 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.

Keys

KeyAction
↑ ↓ / clickselect chat, group, user or invite
Enteropen selection — or send, if the input has text
F2 / Ctrl-Ninvite a user to chat
F3 / Ctrl-Gcreate a group
F4 / Ctrl-Oinvite a user to the open group
PgUp / PgDnscroll message history
F10quit

Slash commands (work even when your terminal steals Ctrl keys)

CommandAction
/invite USERinvite a user to chat
/unfriend USERstop chatting (a new invite restores it)
/group NAMEcreate a group
/ginvite USERinvite a user to the open group
/gremove USERremove a user from the open group
/quitexit

Scripting & automation

Every operation is a subcommand

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:

headless session — two users, a group, real 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)