phone_a_friend
Website: https://martingallagher-code.github.io/phone_a_friend/
Serverless, end-to-end-encrypted chat for people who share access to the same
Linux directory (an NFS mount, a group-writable /srv/chat, a shared home
server, ...). There is no server process: every client reads and writes
plain files in the shared directory and does all encryption, decryption,
sending and receiving itself. A curses TUI runs in any bash terminal.
┌─ INVITES ────────────┬─ chat with bob ────────────────────────────┐
│ ✉ carol (chat) │ 09:12 bob: lunch? │
│ CHATS │ 09:13 alice: sure - where? │
│ bob │ 09:14 bob: the usual │
│ dave ●2 │ │
│ GROUPS │ │
│ #book-club ●1 ├────────────────────────────────────────────┤
│ USERS │ > see you at noo▊ │
│ + erin │ │
└──────────────────────┴────────────────────────────────────────────┘
Features
- Register with a username + passphrase; an X25519 identity keypair is generated for you.
- Invite people to chat — key exchange is push-based: the invite pushes your public key to them; accepting pushes their public key back to you. Without an accepted exchange, messages cannot be sent or decrypted.
- Create groups and invite people — each group has a random symmetric key; inviting someone pushes the group key to them (sealed to their public key). Any member can invite others.
- Unfriend and remove — stop chatting with a contact (
/unfriend); a future invite rebuilds the friendship and the previous chat becomes readable again. Any group member can remove another member (/gremove), mirroring invites; removing yourself leaves the group. Removal is cooperative: the removed client discards its keys when it sees the notice (the group key is not rotated). - See who's around — registered users you have not connected with yet are listed under USERS in the left pane; select one to send a chat invite.
- Send/receive messages to users or groups. The left-hand pane lists
invites, chats and groups; click (mouse supported) or use ↑/↓ + Enter to
open one and read its messages. New messages in the open conversation
appear immediately; elsewhere an unread badge (
●3) lights up next to the chat or group. - Encrypted per-user config — each client maintains its user's config file (private key, contact keys, group keys, read state) in the shared directory, encrypted with a key derived from the passphrase (scrypt + ChaCha20-Poly1305).
Security model
- Access control is layered: the filesystem permissions of the shared directory decide who can see the files at all; encryption decides who can read the messages.
- Direct messages are encrypted with a key derived from the static-static X25519 Diffie-Hellman secret of the two participants — readable only by someone holding one of the two private keys and the peer's public key.
- Group messages are encrypted with the group's symmetric key, held only by members who accepted an invite.
- Invites and invite replies are "sealed" (ephemeral X25519 → HKDF → ChaCha20-Poly1305) to the recipient's public identity key.
- All ciphertexts are authenticated (AEAD); tampered files are ignored.
- Out of scope for v1: forward secrecy/key rotation, sender authentication beyond conversation-key possession, traffic analysis (filenames reveal timing; directory names reveal who talks to whom), and revoking group keys.
Install
Requires Python ≥ 3.9 (Linux). From PyPI:
pip install phoneafriend # installs the `paf` command
From a checkout:
pip install . # or `pip install -e .` for development
# or, without installing:
pip install -r requirements.txt
alias paf='python -m phone_a_friend'
Set up a shared directory
Any directory all participants can read and write works. Typical setup with a shared POSIX group:
sudo mkdir -p /srv/paf
sudo chgrp chatters /srv/paf
sudo chmod 2770 /srv/paf # rwx for the group, setgid, nothing for others
The client creates its own subdirectories (drop-boxes get the sticky bit so users cannot delete each other's files).
Use
paf --dir /srv/paf # launch the TUI (register on first run)
PAF_DIR=/srv/paf paf # same, via environment variable
In the TUI:
| Key | Action |
|---|---|
| ↑ / ↓ / click | select a chat, group, user or invite in the left pane |
| Enter | open selection — or send, if the input line has text |
| F2 or Ctrl-N | invite a user to chat (pushes your public key) |
| F3 or Ctrl-G | create a group |
| F4 or Ctrl-O | invite a user to the open group (pushes the group key) |
| F10 | quit |
| PgUp / PgDn | scroll message history |
| Esc | clear input line / quit |
Selecting an incoming invite prompts you to accept (y) or decline (n);
selecting a name under USERS prompts to send them a chat invite.
Every action is also available as a slash command typed into the input line — these work in any terminal, including ones whose host application intercepts Ctrl or function keys (the VS Code integrated terminal binds Ctrl-N/Ctrl-G/Ctrl-O itself):
/invite USER invite a user to chat
/unfriend USER stop chatting with a user (a new invite can restore it)
/group NAME create a group
/ginvite USER invite a user to the open (or selected) group
/gremove USER remove a user from the open (or selected) group
/quit exit
Scripting / headless use
Every operation is also available as a subcommand, which is handy for
testing and automation (--passphrase / $PAF_PASSPHRASE avoid the prompt):
paf -d /srv/paf -u alice register
paf -d /srv/paf -u alice invite bob # chat invite
paf -d /srv/paf -u bob invites # list incoming
paf -d /srv/paf -u bob accept --from alice
paf -d /srv/paf -u alice send --to bob "hello"
paf -d /srv/paf -u bob read --to alice
paf -d /srv/paf -u alice create-group "book club"
paf -d /srv/paf -u alice invite bob --group "book club"
paf -d /srv/paf -u alice send --group "book club" "welcome"
paf -d /srv/paf -u alice remove bob # unfriend
paf -d /srv/paf -u alice remove bob --group "book club"
paf -d /srv/paf -u alice remove alice --group "book club" # leave
paf -d /srv/paf -u bob status # contacts, groups, unread
Test
python -m unittest discover -s tests -v
Coverage is 100% and enforced in CI (fail_under = 100 in pyproject.toml):
pip install coverage
coverage run -m unittest discover -s tests && coverage report
Releasing to PyPI
The version is single-sourced from __version__ in
phone_a_friend/__init__.py — bump it there (and only there), then publish.
Publishing is automated via .github/workflows/publish.yml using PyPI
trusted publishing — no API
token is stored in the repo. One-time setup on pypi.org: add a GitHub
publisher for MartinGallagher-code/phone_a_friend, workflow
publish.yml, environment pypi. Then creating a GitHub release (e.g.
tag v0.1.0) builds, checks, and uploads the sdist and wheel.
Licensing
Licensed under GPL-3.0-or-later. The repository is compliant with the
REUSE Specification: every file carries SPDX
copyright and license information, and license texts live in LICENSES/.
Compliance is checked in CI with reuse lint.
Shared-directory layout
<shared>/
users/<name>/identity.json public identity (name + public key)
users/<name>/config.enc that user's client config, encrypted
invites/<name>/<id>.json sealed invites pushed TO <name>
replies/<name>/<id>.json sealed invite replies pushed TO <name>
dm/<a>__<b>/<ts>-<rand>.json direct messages, pair-key encrypted
groups/<gid>/meta.json public group metadata
groups/<gid>/msgs/<...>.json group messages, group-key encrypted
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file phoneafriend-0.1.2.tar.gz.
File metadata
- Download URL: phoneafriend-0.1.2.tar.gz
- Upload date:
- Size: 46.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
50d0938855e6792f75c336bca6b98d401813d6f2700148aeb841858831c20361
|
|
| MD5 |
ce011c90777785fe3581c099536621d4
|
|
| BLAKE2b-256 |
181b45761028f95f8daa5d0b8624a0f392b15273b82f856bda4e1ad6545a3fa7
|
Provenance
The following attestation bundles were made for phoneafriend-0.1.2.tar.gz:
Publisher:
publish.yml on MartinGallagher-code/phone_a_friend
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
phoneafriend-0.1.2.tar.gz -
Subject digest:
50d0938855e6792f75c336bca6b98d401813d6f2700148aeb841858831c20361 - Sigstore transparency entry: 2227930614
- Sigstore integration time:
-
Permalink:
MartinGallagher-code/phone_a_friend@7bd10afea4f069a9aa2b5263fa54ffa8c81f0a11 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/MartinGallagher-code
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7bd10afea4f069a9aa2b5263fa54ffa8c81f0a11 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file phoneafriend-0.1.2-py3-none-any.whl.
File metadata
- Download URL: phoneafriend-0.1.2-py3-none-any.whl
- Upload date:
- Size: 34.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
78f0ffe54e2e069c2b4bda68c9fa1e922a4e977a5f6af1dd231df044906f67cd
|
|
| MD5 |
c86d5def07bc3c437cea0ce32fccc95d
|
|
| BLAKE2b-256 |
cdf9f0f10d082153d096efc126c64442609146146b9e1df89c54d54672ea2b71
|
Provenance
The following attestation bundles were made for phoneafriend-0.1.2-py3-none-any.whl:
Publisher:
publish.yml on MartinGallagher-code/phone_a_friend
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
phoneafriend-0.1.2-py3-none-any.whl -
Subject digest:
78f0ffe54e2e069c2b4bda68c9fa1e922a4e977a5f6af1dd231df044906f67cd - Sigstore transparency entry: 2227930973
- Sigstore integration time:
-
Permalink:
MartinGallagher-code/phone_a_friend@7bd10afea4f069a9aa2b5263fa54ffa8c81f0a11 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/MartinGallagher-code
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@7bd10afea4f069a9aa2b5263fa54ffa8c81f0a11 -
Trigger Event:
workflow_dispatch
-
Statement type: