Encrypted messaging you can actually understand.
Communicate with people you trust, away from the eyes of modern surveillance and large corporations.
tlx is the smallest possible end-to-end encrypted messaging stack: a relay that
stores what it can't read, and a daemon, tlxd, that syncs a local SQLite database with it.
The relay is under 150 lines of Python, with a Dockerfile under 25 lines.
Read both to see how it works: OpenSSH authenticates members and runs relay.py to store or fetch encrypted messages.
Optionally, build the image locally from the cloned tlx directory:
docker build -t tlx-relay .
Run this on a server with Docker, using each member's public key from the client setup below:
docker run -d --name tlx -p 2222:22 \
-e TLX_MEMBERS="AAAAC3Nza... AAAAC3Nza..." \
-v tlx-ssh:/etc/ssh -v tlx-home:/home/tlx \
achempion/tlx-relay
TLX_MEMBERS is a space-separated list of public keys. To add a member later, re-create the container
with the longer list; the volumes keep its host key and inboxes.
You need Git, Python 3.9+, OpenSSH 8.2+ and age. Clone the project, install the TUI's dependency and create your identity:
git clone https://github.com/achempion/tlx.git
cd tlx
python3 -m pip install textual
mkdir -m 700 ~/.tlx
ssh-keygen -t ed25519 -N '' -f ~/.tlx/key
cut -d' ' -f2 ~/.tlx/key.pub
Give the printed public key to the relay operator and the people you want to chat with.
Your identity directory, ~/.tlx by default, holds your key, tlxd.db for messages and
tui.db for read markers, aliases and settings.
Replace 203.0.113.10 with your relay's address. Connect once to accept its host key, then start the client:
ssh -i ~/.tlx/key -p 2222 tlx@203.0.113.10 # the relay answers with its usage and hangs up
python3 tui.py --host 203.0.113.10
With --host, the client starts tlxd using ~/.tlx/key, the private key you created above,
and stops it on exit. Pass --dir PATH to choose another identity directory for both the client
and daemon; the daemon uses PATH/key and the TUI reads PATH/key.pub.
Type /new PUBLIC_KEY with another member's key, then send a message. /help lists the commands and keys.
With the client dependencies installed, try two people on one machine talking through a local relay:
run the commands below from the cloned tlx directory in each terminal.
for who in alice bob; do mkdir -m 700 ~/.tlx-$who && ssh-keygen -t ed25519 -N '' -f ~/.tlx-$who/key; done
ALICE=$(cut -d' ' -f2 ~/.tlx-alice/key.pub); BOB=$(cut -d' ' -f2 ~/.tlx-bob/key.pub)
docker run -d --name tlx -p 127.0.0.1:2222:22 -e TLX_MEMBERS="$ALICE $BOB" \
-v tlx-ssh:/etc/ssh -v tlx-home:/home/tlx achempion/tlx-relay
ssh -i ~/.tlx-alice/key -p 2222 tlx@127.0.0.1 # accept the host key; the relay answers with its usage and hangs up
Terminal 1 is Alice. With --host, the client starts its own tlxd:
python3 tui.py --dir ~/.tlx-alice --host 127.0.0.1
Terminal 2 is Bob:
python3 tui.py --dir ~/.tlx-bob --host 127.0.0.1
In Alice's terminal type /new, a space, and Bob's public key (the value of $BOB), then send a message.
It shows as pending until her own copy comes back through the relay, and appears in Bob's terminal a moment later.
Bob just types to reply.
The reference chat client is intentionally split into two components: tlxd handles synchronization
and sending, while the TUI handles the interface. SQLite connects the two, keeping the code that
coordinates encryption and delivery small enough to read, audit and use as a reference for other clients.
OpenSSH authenticates members and runs relay.py for each command. put stores an encrypted blob
once and links it into each recipient's inbox. get returns new blobs from your inbox by sequence,
waiting up to 60 seconds if there are none yet.
The daemon is under 350 lines, including its database schema. It uses OpenSSH and
age to sign and encrypt messages from outbox, send them to the relay, and decrypt and verify incoming
messages before saving them in messages. Both tables live in tlxd.db.
The Textual client shows how to build on that database: read messages, insert into outbox
to send, and update or remove outbox rows to retry or discard. It keeps read markers, aliases and
the chosen theme in its own tui.db, separate from the daemon's schema.
A TUI, GUI or bot can be as elaborate as needed while using this same small database interface.
It delegates cryptography and synchronization to tlxd, so interface features can grow independently
of the code that handles encryption and delivery.
These examples use the identity from Quickstart. Replace 203.0.113.10 with your relay's address.
Start the daemon in one terminal, then the client in another:
python3 tlxd.py --host 203.0.113.10 # --port defaults to 2222, --dir to ~/.tlx
python3 tui.py # uses ~/.tlx and the running daemon
With tlxd running, insert into outbox to send. claimed_at is the send time in Unix nanoseconds;
use a new value for each message. Supply recipients for a new chat; omit them to reply to its known participants.
BOB=AAAAC3Nza... # Bob's public key
CHAT="@$(openssl rand -hex 8)"
# start a new chat with Bob
sqlite3 ~/.tlx/tlxd.db "insert into outbox (claimed_at, chat_id, recipient_public_keys, body)
values ($(python3 -c 'import time; print(time.time_ns())'), '$CHAT', '$BOB', 'hi Bob')"
# once your own copy has arrived, reply to the same chat
sqlite3 ~/.tlx/tlxd.db "insert into outbox (claimed_at, chat_id, body)
values ($(python3 -c 'import time; print(time.time_ns())'), '$CHAT', 'hello again')"
# read
sqlite3 ~/.tlx/tlxd.db "select chat_id, body from messages order by sequence"
sent_at records relay acceptance; permanent failures go in error, while SSH connection failures
are retried. An outbox row is removed when your own copy arrives. Your key is always included in
the recipients, and each message needs at least one other recipient.
The signed message contains the chat ID, claimed time and recipient keys on its first line, followed by the body. The relay accepts encrypted blobs up to 1,000,000 bytes, including all overhead.
Set BOB to Bob's public key. A new chat gets a random ID; a topic adds a prefix, such as
launch@<id>. Replies reuse the full chat ID. Each message needs a fresh CLAIMED_AT in Unix nanoseconds.
ME=$(cut -d' ' -f2 ~/.tlx/key.pub)
BOB=AAAAC3Nza...
CHAT="@$(openssl rand -hex 8)"
CLAIMED_AT=$(python3 -c 'import time; print(time.time_ns())')
printf '%s %s %s %s\nhello\n' "$CHAT" "$CLAIMED_AT" "$ME" "$BOB" > message
To use a file as the body, replace the printf line with this. Leave room under the size limit
for recipient, encryption and signature overhead.
{ echo "$CHAT $CLAIMED_AT $ME $BOB"; cat photo.jpg; } > message
Sign with your private key. This creates message.sig.
ssh-keygen -Y sign -f ~/.tlx/key -n chat message
Encrypt the message and signature for every recipient, then deliver the blob to their inboxes.
cat message message.sig \
| age -r "ssh-ed25519 $ME" -r "ssh-ed25519 $BOB" \
| ssh -i ~/.tlx/key -p 2222 tlx@203.0.113.10 put $ME $BOB
Fetch encrypted blobs after a sequence number. Use 0 to fetch the whole inbox. If there are no
new blobs, the relay waits up to 60 seconds.
ssh -i ~/.tlx/key -p 2222 tlx@203.0.113.10 get 0
The tests use Python's standard unittest runner. The headless TUI cases use temporary SQLite
databases and need only textual:
python3 tests/tui_test.py TuiTests
Integration cases run a Docker relay and real daemons using the shared setup in tests/support.py.
They need Docker, ssh, ssh-keygen, ssh-keyscan and age.
The TUI integration case also needs textual:
python3 tests/e2e.py
python3 tests/tui_test.py RelayTests
Each case starts fresh. To run one case, append its class and method, such as
python3 tests/e2e.py MessagingTests.test_connection_retry_and_offline_catchup.
Tecotype, keyboard-first mail app.
24 commits
Python
99.5%
Encrypted messaging you can actually understand.
Communicate with people you trust, away from the eyes of modern surveillance and large corporations.
tlx is the smallest possible end-to-end encrypted messaging stack: a relay that
stores what it can't read, and a daemon, tlxd, that syncs a local SQLite database with it.
The relay is under 150 lines of Python, with a Dockerfile under 25 lines.
Read both to see how it works: OpenSSH authenticates members and runs relay.py to store or fetch encrypted messages.
Optionally, build the image locally from the cloned tlx directory:
docker build -t tlx-relay .
Run this on a server with Docker, using each member's public key from the client setup below:
docker run -d --name tlx -p 2222:22 \
-e TLX_MEMBERS="AAAAC3Nza... AAAAC3Nza..." \
-v tlx-ssh:/etc/ssh -v tlx-home:/home/tlx \
achempion/tlx-relay
TLX_MEMBERS is a space-separated list of public keys. To add a member later, re-create the container
with the longer list; the volumes keep its host key and inboxes.
You need Git, Python 3.9+, OpenSSH 8.2+ and age. Clone the project, install the TUI's dependency and create your identity:
git clone https://github.com/achempion/tlx.git
cd tlx
python3 -m pip install textual
mkdir -m 700 ~/.tlx
ssh-keygen -t ed25519 -N '' -f ~/.tlx/key
cut -d' ' -f2 ~/.tlx/key.pub
Give the printed public key to the relay operator and the people you want to chat with.
Your identity directory, ~/.tlx by default, holds your key, tlxd.db for messages and
tui.db for read markers, aliases and settings.
Replace 203.0.113.10 with your relay's address. Connect once to accept its host key, then start the client:
ssh -i ~/.tlx/key -p 2222 tlx@203.0.113.10 # the relay answers with its usage and hangs up
python3 tui.py --host 203.0.113.10
With --host, the client starts tlxd using ~/.tlx/key, the private key you created above,
and stops it on exit. Pass --dir PATH to choose another identity directory for both the client
and daemon; the daemon uses PATH/key and the TUI reads PATH/key.pub.
Type /new PUBLIC_KEY with another member's key, then send a message. /help lists the commands and keys.
With the client dependencies installed, try two people on one machine talking through a local relay:
run the commands below from the cloned tlx directory in each terminal.
for who in alice bob; do mkdir -m 700 ~/.tlx-$who && ssh-keygen -t ed25519 -N '' -f ~/.tlx-$who/key; done
ALICE=$(cut -d' ' -f2 ~/.tlx-alice/key.pub); BOB=$(cut -d' ' -f2 ~/.tlx-bob/key.pub)
docker run -d --name tlx -p 127.0.0.1:2222:22 -e TLX_MEMBERS="$ALICE $BOB" \
-v tlx-ssh:/etc/ssh -v tlx-home:/home/tlx achempion/tlx-relay
ssh -i ~/.tlx-alice/key -p 2222 tlx@127.0.0.1 # accept the host key; the relay answers with its usage and hangs up
Terminal 1 is Alice. With --host, the client starts its own tlxd:
python3 tui.py --dir ~/.tlx-alice --host 127.0.0.1
Terminal 2 is Bob:
python3 tui.py --dir ~/.tlx-bob --host 127.0.0.1
In Alice's terminal type /new, a space, and Bob's public key (the value of $BOB), then send a message.
It shows as pending until her own copy comes back through the relay, and appears in Bob's terminal a moment later.
Bob just types to reply.
The reference chat client is intentionally split into two components: tlxd handles synchronization
and sending, while the TUI handles the interface. SQLite connects the two, keeping the code that
coordinates encryption and delivery small enough to read, audit and use as a reference for other clients.
OpenSSH authenticates members and runs relay.py for each command. put stores an encrypted blob
once and links it into each recipient's inbox. get returns new blobs from your inbox by sequence,
waiting up to 60 seconds if there are none yet.
The daemon is under 350 lines, including its database schema. It uses OpenSSH and
age to sign and encrypt messages from outbox, send them to the relay, and decrypt and verify incoming
messages before saving them in messages. Both tables live in tlxd.db.
The Textual client shows how to build on that database: read messages, insert into outbox
to send, and update or remove outbox rows to retry or discard. It keeps read markers, aliases and
the chosen theme in its own tui.db, separate from the daemon's schema.
A TUI, GUI or bot can be as elaborate as needed while using this same small database interface.
It delegates cryptography and synchronization to tlxd, so interface features can grow independently
of the code that handles encryption and delivery.
These examples use the identity from Quickstart. Replace 203.0.113.10 with your relay's address.
Start the daemon in one terminal, then the client in another:
python3 tlxd.py --host 203.0.113.10 # --port defaults to 2222, --dir to ~/.tlx
python3 tui.py # uses ~/.tlx and the running daemon
With tlxd running, insert into outbox to send. claimed_at is the send time in Unix nanoseconds;
use a new value for each message. Supply recipients for a new chat; omit them to reply to its known participants.
BOB=AAAAC3Nza... # Bob's public key
CHAT="@$(openssl rand -hex 8)"
# start a new chat with Bob
sqlite3 ~/.tlx/tlxd.db "insert into outbox (claimed_at, chat_id, recipient_public_keys, body)
values ($(python3 -c 'import time; print(time.time_ns())'), '$CHAT', '$BOB', 'hi Bob')"
# once your own copy has arrived, reply to the same chat
sqlite3 ~/.tlx/tlxd.db "insert into outbox (claimed_at, chat_id, body)
values ($(python3 -c 'import time; print(time.time_ns())'), '$CHAT', 'hello again')"
# read
sqlite3 ~/.tlx/tlxd.db "select chat_id, body from messages order by sequence"
sent_at records relay acceptance; permanent failures go in error, while SSH connection failures
are retried. An outbox row is removed when your own copy arrives. Your key is always included in
the recipients, and each message needs at least one other recipient.
The signed message contains the chat ID, claimed time and recipient keys on its first line, followed by the body. The relay accepts encrypted blobs up to 1,000,000 bytes, including all overhead.
Set BOB to Bob's public key. A new chat gets a random ID; a topic adds a prefix, such as
launch@<id>. Replies reuse the full chat ID. Each message needs a fresh CLAIMED_AT in Unix nanoseconds.
ME=$(cut -d' ' -f2 ~/.tlx/key.pub)
BOB=AAAAC3Nza...
CHAT="@$(openssl rand -hex 8)"
CLAIMED_AT=$(python3 -c 'import time; print(time.time_ns())')
printf '%s %s %s %s\nhello\n' "$CHAT" "$CLAIMED_AT" "$ME" "$BOB" > message
To use a file as the body, replace the printf line with this. Leave room under the size limit
for recipient, encryption and signature overhead.
{ echo "$CHAT $CLAIMED_AT $ME $BOB"; cat photo.jpg; } > message
Sign with your private key. This creates message.sig.
ssh-keygen -Y sign -f ~/.tlx/key -n chat message
Encrypt the message and signature for every recipient, then deliver the blob to their inboxes.
cat message message.sig \
| age -r "ssh-ed25519 $ME" -r "ssh-ed25519 $BOB" \
| ssh -i ~/.tlx/key -p 2222 tlx@203.0.113.10 put $ME $BOB
Fetch encrypted blobs after a sequence number. Use 0 to fetch the whole inbox. If there are no
new blobs, the relay waits up to 60 seconds.
ssh -i ~/.tlx/key -p 2222 tlx@203.0.113.10 get 0
The tests use Python's standard unittest runner. The headless TUI cases use temporary SQLite
databases and need only textual:
python3 tests/tui_test.py TuiTests
Integration cases run a Docker relay and real daemons using the shared setup in tests/support.py.
They need Docker, ssh, ssh-keygen, ssh-keyscan and age.
The TUI integration case also needs textual:
python3 tests/e2e.py
python3 tests/tui_test.py RelayTests
Each case starts fresh. To run one case, append its class and method, such as
python3 tests/e2e.py MessagingTests.test_connection_retry_and_offline_catchup.
Tecotype, keyboard-first mail app.
24 commits
Python
99.5%