punchline
End-to-end encrypted peer-to-peer chat over UDP. No accounts, no central server relaying/storing messages, no middleman. Just two peers, a direct connection, and Noise protocol encryption.
https://github.com/user-attachments/assets/939e96d3-45e3-4484-9a27-28c3a0457b05
Table of Contents
- What It Does
- Quick Start
- How It Works
- CLI Reference
- Usage
- Cryptography
- Wire Protocol
- Project Structure
- Building from Source
- Running Tests
- Tech Stack
- License
What It Does
Two people run punchline connect <peer> on their machines. Punchline punches through their NATs, performs an encrypted handshake, and drops them into a private chat - all in a few milliseconds. The included STUN and signal servers handle discovery, then get out of the way.
Quick Start
Start the servers (on a machine both peers can reach), or use my public ones hosted at 64.225.107.28 (STUN: port 3478, signaling: port 8743):
On each peer's machine:
# Generate your identity (X25519 keypair)
# Share your public key with your peer
# Save their key
# Connect (both peers run this, targeting each other)
The TUI launches with a live connection progress view:
-
STUN discovery - resolving your external address via
punchline-stund -
Signal server - connecting to
punchline-signald -
Waiting for peer - signal server matches both peers
-
Hole punch - establishing the direct UDP path
-
Noise handshake - encrypted key exchange
Once complete, you're in the chat. Type and press Enter. Press Esc to quit.
How It Works
The entire system consists of three binaries, all included in this repo:
| Binary | Role | When used |
|---|---|---|
punchline-stund |
STUN server (UDP) - responds with the client's external IP:port | During setup only |
punchline-signald |
Signal server (WebSocket) - matches peers and exchanges addresses | During setup only |
punchline-client |
The messenger itself - CLI, TUI, crypto, hole punching | Always |
After the initial setup, the STUN and signal servers are no longer contacted. Everything flows directly peer-to-peer.
CLI Reference
punchline-client
| Command | Description |
|---|---|
keygen [--force] [-i path] |
Generate a new X25519 identity keypair. Use --force to overwrite without prompting. Use -i to specify output path. |
pubkey [-i path] |
Print your public key (64 hex characters). Use -i to derive from a specific key file. |
connect <peer> [-i path] [--stun addr] [--signal addr] |
Connect to a peer by alias or raw hex key. Use -i to specify identity key. Launches the TUI. |
peers |
List all known peers. |
peers add <name> <key> |
Save a peer's public key under a nickname. |
peers remove <name> |
Remove a peer by nickname. |
config path |
Print the config file path. |
config show |
Show current configuration values. |
status |
Show identity, config, server reachability, and peer count. |
completions <shell> |
Generate shell completions (bash, zsh, or fish). |
Global flags:
| Flag | Description |
|---|---|
-v |
Increase log verbosity (-v = debug, -vv = trace). |
-q, --quiet |
Suppress all log output. |
punchline-stund
| Flag | Description |
|---|---|
--address <addr> |
Bind address (default: 0.0.0.0). |
--port <port> |
Bind port (default: 3478). |
-v / -vv |
Debug / trace logging. |
-q |
Quiet mode. |
punchline-signald
| Flag | Description |
|---|---|
--address <addr> |
Bind address (default: 0.0.0.0). |
--port <port> |
Bind port (default: 8743). |
-v / -vv |
Debug / trace logging. |
-q |
Quiet mode. |
Usage
Configuration
Instead of passing --stun and --signal every time, create ~/.config/punchline/config.toml:
= "203.0.113.10:3478"
= "203.0.113.10:8743"
Managing Peers
Aliases are stored in ~/.punchline/known_peers.toml. You can also connect with a raw 64-char hex key directly.
Status Check
Shows your identity, config, server reachability (sends a real STUN probe and TCP connect), and peer count.
Server Options
Both servers support -v (debug), -vv (trace), -q (quiet), --address, and --port:
Theming
Customize the TUI via ~/.config/punchline/style.toml
Styles used in the video:
[]
= "#ebdbb2"
= "#bdae93"
= "#ebdbb2"
= "#ebdbb2"
= "#ebdbb2"
= "#bdae93"
[]
= 2
= 1
All colors are hex RGB. If the file is absent, the terminal's default colors are used.
Shell Completions
Cryptography
Full protocol name: Noise_IK_25519_ChaChaPoly_SHA256
| Component | Role |
|---|---|
| Noise IK | Handshake pattern - initiator knows responder's public key. Completes in 2 messages. |
| X25519 | Elliptic-curve Diffie-Hellman key exchange (RFC 7748). 128-bit security, constant-time. |
| ChaCha20-Poly1305 | AEAD cipher for message encryption (RFC 8439). Same cipher used in TLS 1.3 and WireGuard. |
| SHA-256 | Used internally by Noise for key derivation and handshake hashing. |
IK Handshake
The IK pattern means the initiator knows the responder's static public key before the handshake begins. Both peers already have each other's keys (exchanged out-of-band or via the peer registry), so no trust-on-first-use is required.
- Initiator -> Responder: Sends an encrypted message containing its static public key, encrypted under the responder's known key. Provides identity hiding against passive observers.
- Responder -> Initiator: Decrypts, verifies, and replies. Both sides transition to transport mode with shared session keys.
Initiator Determination
Punchline deterministically selects the initiator by comparing the first 8 bytes of each peer's public key as a big-endian u64. The peer with the smaller value becomes the initiator. Both sides compute this independently.
Key Storage
The identity is a 32-byte X25519 secret key at ~/.punchline/id_x25519 with Unix permissions 0600. The public key is derived on load. Key generation uses x25519-dalek with OsRng.
Wire Protocol
The first byte of each UDP packet identifies its type:
| Prefix | Type | Phase | Description |
|---|---|---|---|
0x00 |
PROBE | Hole punch | Sent every 200ms to open NAT pinhole |
0x01 |
ACK | Hole punch | Confirms receipt of a PROBE |
| (none) | Handshake | Handshake | Raw Noise-encrypted handshake payload |
0x02 |
Message | Transport | Encrypted chat message |
0x03 |
Keepalive | Transport | Encrypted empty payload (heartbeat) |
Hole Punch Protocol
Both peers execute the same algorithm simultaneously:
- Send
PROBE(0x00) every 200ms to the peer's external address. - On receiving a
PROBE, switch to sendingACK(0x01). - On receiving an
ACK, send one finalACKand declare success. - Safety timeout: 2 seconds of sending ACKs without reply assumes the peer finished.
Transport Protocol
Messages (0x02) carry Noise-encrypted UTF-8 payloads. Keepalives (0x03) are encrypted empty payloads sent every 10 seconds to maintain cipher nonce synchronization. 30 seconds without any packet triggers disconnect.
Signal Protocol
JSON over WebSocket:
// PairRequest (client -> server)
// PairResponse (server -> client)
STUN Protocol
Follows RFC 5389 (simplified): binding request/response with XOR-MAPPED-ADDRESS. IPv4 only.
Project Structure
Cargo workspace with four crates:
crates/
├── proto/ # Shared library: crypto, STUN, signal types, transport trait
├── client/ # P2P client: CLI, TUI, connection logic, peer management
├── signald/ # Signal server: WebSocket peer matching
└── stund/ # STUN server: external address discovery
Building from Source
Prerequisites: Rust 2024 edition (rustc 1.85+)
Binaries are placed in target/release/:
punchline-clientpunchline-signaldpunchline-stund
Running Tests
Tests cover cryptographic operations, STUN encoding/decoding, signal protocol serialization, config parsing, peer management, style theming, and the Noise IK handshake.
Tech Stack
| Crate | Purpose |
|---|---|
snow |
Noise protocol framework (handshake + transport encryption) |
x25519-dalek |
X25519 key generation and derivation |
ratatui |
Terminal UI framework |
crossterm |
Terminal event handling |
clap |
CLI argument parsing + shell completions |
tungstenite |
WebSocket client/server |
tracing |
Structured logging |
License
MIT - see LICENSE.