Skip to main content

Serverless encrypted chat over a shared Linux directory, with a curses TUI

Project description

phone_a_friend

PyPI Python versions CI Coverage REUSE status License: GPL-3.0-or-later

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.
  • 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)
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
/group NAME      create a group
/ginvite USER    invite a user to 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 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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

phoneafriend-0.1.1.tar.gz (44.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

phoneafriend-0.1.1-py3-none-any.whl (33.7 kB view details)

Uploaded Python 3

File details

Details for the file phoneafriend-0.1.1.tar.gz.

File metadata

  • Download URL: phoneafriend-0.1.1.tar.gz
  • Upload date:
  • Size: 44.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for phoneafriend-0.1.1.tar.gz
Algorithm Hash digest
SHA256 e507284985f3f7982c7af0a7dc57adb0bd2e3a9910121b40426a5740606b85fb
MD5 a58839c942d0caadf02038934d96e5a3
BLAKE2b-256 120b9f0aae12222eb82af7595a187393969db9cde8722a6f66dc4996ec25f7c9

See more details on using hashes here.

Provenance

The following attestation bundles were made for phoneafriend-0.1.1.tar.gz:

Publisher: publish.yml on MartinGallagher-code/phone_a_friend

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file phoneafriend-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: phoneafriend-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 33.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for phoneafriend-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 adfd7ff42e0b06533e22099e69035f93b377cddbe41fec26dd15bbb194bbe3c4
MD5 5f2d14fab1906a7a21b184c9299ac45c
BLAKE2b-256 c9ec13d205602a4cbc56b83bcf69ae6c9360d3af3ad36411a4ac2d146a61dbf1

See more details on using hashes here.

Provenance

The following attestation bundles were made for phoneafriend-0.1.1-py3-none-any.whl:

Publisher: publish.yml on MartinGallagher-code/phone_a_friend

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page