Punchline
Peer-to-peer encrypted messenger in Rust with Noise IK and UDP NAT hole punching
Install / Use
/learn @michal-pielka/PunchlineREADME
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
- Installation
- 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.
<img width="1422" height="920" alt="convo" src="https://github.com/user-attachments/assets/bceda761-5398-42ee-a706-9a7697140336" />
Quick Start
cargo build --release
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):
punchline-stund # STUN server - tells peers their public IP
punchline-signald # Signal server - matches peers who want to talk
<img width="2540" height="1532" alt="servers" src="https://github.com/user-attachments/assets/81745412-6477-4119-a53c-3fc82889e414" />
On each peer's machine:
# Generate your identity (X25519 keypair)
punchline keygen
# Share your public key with your peer
punchline pubkey
# Save their key
punchline peers add alice a1b2c3d4...64_hex_chars
# Connect (both peers run this, targeting each other)
punchline connect alice --stun <server>:3478 --signal <server>:8743
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
<img width="1908" height="1228" alt="dashboard" src="https://github.com/user-attachments/assets/89a84985-c83b-4a72-bfbb-63b6890ff279" />
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 | 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.
<!-- TODO: Diagram -->CLI Reference
punchline
| 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:
stun_server = "203.0.113.10:3478"
signal_server = "203.0.113.10:8743"
Managing Peers
punchline peers # list all
punchline peers add alice a1b2c3d4... # add
punchline peers remove alice # remove
Aliases are stored in ~/.punchline/known_peers.toml. You can also connect with a raw 64-char hex key directly.
Status Check
punchline status
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:
punchline-stund -v --port 3478
punchline-signald -v --port 8743
Theming
Customize the TUI via ~/.config/punchline/style.toml
Styles used in the video:
[colors]
my_text = "#ebdbb2"
peer_text = "#bdae93"
input_text = "#ebdbb2"
border = "#ebdbb2"
sidebar_key = "#ebdbb2"
sidebar_value = "#bdae93"
[padding]
chat_horizontal = 2
chat_vertical = 1
All colors are hex RGB. If the file is absent, the terminal's default colors are used.
Shell Completions
punchline completions bash > ~/.local/share/bash-completion/completions/punchline
punchline completions zsh > ~/.zfunc/_punchline
punchline completions fish > ~/.config/fish/completions/punchline.fish
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)
{ "external_addr": "203.0.113.5:48291", "public_key": "a1b2...", "target_public_key": "d4e5..." }
// PairResponse (server -> client)
{ "target_external_addr": "198.51.100.7:51003", "target_public_key": "d4e5..." }
STUN Protocol
Follows RFC 5389 (simplified): binding request/response with XOR-MAPPED-ADDRESS. IPv4 only.
Project Structure
Cargo workspace with four crate
Related Skills
himalaya
348.0kCLI to manage emails via IMAP/SMTP. Use `himalaya` to list, read, write, reply, forward, search, and organize emails from the terminal. Supports multiple accounts and message composition with MML (MIME Meta Language).
node-connect
348.0kDiagnose OpenClaw node connection and pairing failures for Android, iOS, and macOS companion apps
taskflow
348.0kname: taskflow description: Use when work should span one or more detached tasks but still behave like one job with a single owner context. TaskFlow is the durable flow substrate under authoring layer
frontend-design
108.8kCreate distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
