IRC server and client with AT Protocol (Bluesky) identity authentication, end-to-end encrypted channels, iroh QUIC transport, peer-to-peer DMs, and federated server-to-server clustering.
Users authenticate with their Bluesky identity via a custom SASL mechanism
(ATPROTO-CHALLENGE). Standard IRC clients connect as guests. Authenticated
users get their DID bound to their connection β visible via WHOIS, enforced
for nick ownership, and usable for DID-based bans, invites, and persistent ops.
Try it now: irc.freeq.at
The web client at irc.freeq.at provides:
- AT Protocol OAuth login β sign in with your Bluesky identity
- Channel policy gates β channels can require credential verification to join
- GitHub verification β prove repo collaborator or org membership status
- Bluesky social graph gates β prove you follow someone (no OAuth needed)
- Moderator appointments β ops issue signed credentials for halfop (+h)
- Automatic role escalation β credentials auto-grant IRC modes (op, halfop, voice)
- Shareable invite links β
https://irc.freeq.at/join/#channel - Message editing, deletion, reactions, threads
- End-to-end encrypted channels
| Channel | Policy | What it demonstrates |
|---|---|---|
#demo-follow |
Must follow @chadfowler.com on Bluesky | Social graph verification (zero OAuth) |
#demo-github |
Open join, chad/freeq collaborators get auto-op |
Layered credentials + role escalation |
#demo-moderation |
Open join, moderators appointed via credentials | Credential-based moderation pipeline |
freeq-server/ IRC server with SASL, WebSocket, iroh, S2S federation
freeq-app/ React web client (Vite + Tailwind)
freeq-auth-broker/ AT Protocol OAuth broker (persistent sessions)
freeq-sdk/ Reusable client SDK (connect, auth, events, E2EE, P2P)
freeq-tui/ Terminal UI client built on the SDK
freeq-site/ Marketing site (freeq.at)
The SDK exposes a (ClientHandle, Receiver<Event>) pattern β any UI or bot
can consume events and send commands.
ββββββββββββββββββββββββββββββββββββββββββββ
β IRC Wire Protocol β
ββββββββββββ¬βββββββββββ¬βββββββββββ¬ββββββββββ€
β TCP β TLS βWebSocket β iroh β
β :6667 β :6697 β :8080 β QUIC β
ββββββββββββ΄βββββββββββ΄βββββββββββ΄ββββββββββ
All transports feed into the same handle_generic() handler β the IRC
protocol is transport-agnostic. Each transport is zero-cost when not enabled.
cargo build --release# Minimal: plain TCP only, in-memory
cargo run --release --bin freeq-server
# With persistence
cargo run --release --bin freeq-server -- --db-path data/irc.db
# With TLS
cargo run --release --bin freeq-server -- \
--tls-cert certs/cert.pem --tls-key certs/key.pem
# With WebSocket + REST API
cargo run --release --bin freeq-server -- --web-addr 0.0.0.0:8080
# With iroh transport (QUIC, NAT-traversing)
cargo run --release --bin freeq-server -- --iroh
# Full production setup
cargo run --release --bin freeq-server -- \
--listen-addr 0.0.0.0:6667 \
--tls-listen-addr 0.0.0.0:6697 \
--tls-cert /etc/letsencrypt/live/example.com/fullchain.pem \
--tls-key /etc/letsencrypt/live/example.com/privkey.pem \
--db-path ./irc.db \
--web-addr 0.0.0.0:8080 \
--irohGenerate a self-signed cert for local development:
mkdir -p certs
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
-keyout certs/key.pem -out certs/cert.pem -days 365 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"# Guest (no auth)
cargo run --release --bin freeq-tui -- 127.0.0.1:6667 mynick
# Bluesky OAuth (opens browser)
cargo run --release --bin freeq-tui -- 127.0.0.1:6697 mynick \
--handle alice.bsky.social
# App password fallback
cargo run --release --bin freeq-tui -- 127.0.0.1:6667 mynick \
--handle alice.bsky.social --app-password xxxx-xxxx-xxxx-xxxx
# Auto-join channels
cargo run --release --bin freeq-tui -- 127.0.0.1:6667 mynick \
-c '#general,#random'
# Explicit iroh transport
cargo run --release --bin freeq-tui -- 127.0.0.1:6667 mynick \
--iroh-addr <endpoint-id>
# Vi keybindings
cargo run --release --bin freeq-tui -- 127.0.0.1:6667 mynick --viIroh auto-discovery: When connecting to a server that has --iroh
enabled, the TUI probes CAP LS for the iroh=<endpoint-id> capability
and auto-upgrades to iroh QUIC transport. No manual endpoint ID needed.
OAuth sessions are cached to ~/.config/freeq-tui/<handle>.session.json
so you don't need to re-authenticate on every launch.
Any IRC client works as a guest β irssi, WeeChat, HexChat, LimeChat, etc.
Connect to 127.0.0.1:6667 (plain) or 127.0.0.1:6697 (TLS). No special
configuration needed.
When --web-addr is set, the server accepts WebSocket connections at
ws://<addr>/irc. A test HTML client is included at freeq-server/test-client.html.
The server implements a custom SASL mechanism for AT Protocol identity:
- Client requests
CAP sasl, thenAUTHENTICATE ATPROTO-CHALLENGE - Server sends a challenge:
base64url(json { session_id, nonce, timestamp }) - Client responds with one of:
- Crypto signature (
method: "crypto"): Signs challenge bytes with a private key listed in the DID document - PDS session (
method: "pds-session"): Sends an app-password JWT; server verifies against the PDS - PDS OAuth (
method: "pds-oauth"): Sends a DPoP-bound access token with proof; server verifies against the PDS
- Crypto signature (
- Server verifies, emits
903(success) or904(failure) - Client sends
CAP END, registration completes
- Each challenge contains a cryptographically random nonce
- Challenges are invalidated after use (no replay)
- Challenge validity window: configurable, default 60 seconds
- Private keys never leave the client
- PDS URL is verified against the DID document before accepting session tokens
- Supported key types: secp256k1 (MUST), ed25519 (SHOULD)
- Nick is bound to your DID β no one else can use it
- WHOIS shows your DID and Bluesky handle
- You can be banned or invited by DID (survives reconnect/nick changes)
- Persistent channel ops tied to your DID (survive reconnects and work across federated servers)
- Your identity is cryptographically verifiable
Standard IRC on port 6667 (plain) and 6697 (TLS). TLS auto-detected by port in the client. Always available.
Enabled with --web-addr. Accepts WebSocket IRC at /irc. Uses the same
IRC wire protocol β WebSocket is a transport, not a new protocol. Includes
a read-only REST API at /api/v1/ (channels, members, topics, messages).
Enabled with --iroh. Provides NAT-traversing encrypted QUIC connections
via iroh. The server generates a persistent secret
key (iroh-key.secret) on first run β endpoint ID is stable across restarts.
The server advertises its iroh endpoint ID in CAP LS:
CAP * LS :sasl message-tags iroh=44f1415c9db30989...
Clients auto-discover and upgrade to iroh when available.
Client-side channel encryption using AES-256-GCM with HKDF-SHA256 key derivation from a shared passphrase. The server relays ciphertext unchanged.
/encrypt <passphrase> Enable E2EE for current channel
/decrypt Disable E2EE for current channel
Wire format: ENC1:<nonce-b64>:<ciphertext-b64> β version-tagged, uses the
message body for robustness. All channel members must use the same passphrase.
Direct encrypted messaging between clients via iroh QUIC, bypassing the server entirely.
/p2p start Start your P2P endpoint
/p2p id Show your P2P endpoint ID
/p2p connect <id> Connect to a peer
/p2p msg <id> <message> Send a direct message
P2P conversations appear in dedicated p2p:<short-id> buffers. Wire format
is newline-delimited JSON (not IRC protocol). ALPN: freeq/p2p-dm/1.
P2P endpoint IDs are visible in WHOIS (numeric 672).
Servers cluster over iroh QUIC connections. Each server maintains its own local state and syncs channel membership, messages, topics, and DID-based ops across the federation.
# Server A: just enable iroh (accepts incoming S2S connections)
cargo run --release --bin freeq-server -- --iroh
# Server B: enable iroh + connect to Server A
cargo run --release --bin freeq-server -- --iroh \
--s2s-peers <server-a-endpoint-id>Server A doesn't need --s2s-peers β it accepts incoming S2S connections
automatically when --iroh is enabled.
| Feature | Sync behavior |
|---|---|
| JOIN/PART/QUIT | Membership tracked per origin server |
| PRIVMSG | Channel messages relayed to all peers |
| TOPIC | Topic changes propagate |
| DID-based ops | Persistent ops sync via CRDT |
| Founder | First-write-wins CRDT resolution |
| NAMES | Includes both local and remote members |
| WHOIS | Shows DID, handle, and origin for remote users |
Channel authority (founder, DID-based ops) uses Automerge CRDTs for conflict-free convergence. Presence is NOT in the CRDT β it's S2S event-driven to avoid ghost users when servers crash.
- Founder resolution: Deterministic min-actor-wins β concurrent claims converge deterministically, late entrants cannot overwrite after sync
- DID ops: Union merge β grants propagate, revocations propagate
- Provenance tracking: All CRDT writes carry origin peer + authorizing DID
- Authority boundaries: Soft enforcement validates who can write each key-space
- Event dedup: S2S events carry unique IDs; bounded LRU prevents replay
- Peer identity: CRDT sync keyed by iroh endpoint ID (cryptographic), not server name (untrusted). Hello handshake binds transport to logical identity.
- Compaction: Periodic snapshot + reload bounds doc growth in long-lived deployments
- Async-safe: CRDT uses
tokio::sync::Mutexβ no runtime thread blocking - No timestamps in authority decisions (spoofable by rogue servers)
# Run against two live servers
LOCAL_SERVER=localhost:6667 REMOTE_SERVER=irc.freeq.at:6667 \
cargo test -p freeq-server --test s2s_acceptance -- --nocapture --test-threads=19 tests verify: connectivity, bidirectional message relay, NAMES sync, topic sync, PART/QUIT cleanup, and late-joiner state.
Full compatibility with RFC 1459/2812 basics:
- NICK, USER, JOIN, PART, PRIVMSG, NOTICE, QUIT
- NAMES (query channel membership on demand)
- PING/PONG (client and server keepalive)
- WHOIS (shows DID, handle, iroh ID for authenticated users)
- CTCP ACTION (
/me) - Multiple channels, private messages
| Mode | Description |
|---|---|
+o nick |
Channel operator |
+v nick |
Voice |
+b mask |
Ban (hostmask *!*@host or DID did:plc:xyz) |
+i |
Invite-only |
+t |
Topic lock (ops only) |
+k key |
Channel key (password) |
- DID bans (
MODE #chan +b did:plc:xyz): Bans by identity, not just hostmask. Survives nick changes and reconnects. - DID invites (
INVITE nick #chan): If the user is authenticated, the invite is stored by DID and survives reconnect. - Nick ownership: Once an authenticated user claims a nick, guests and
other DIDs cannot use it. If an unauthenticated user tries to take a
registered nick during SASL negotiation, they're renamed to
GuestXXXXat registration time. - Persistent DID-based ops: When an authenticated user is opped, their DID is recorded. They're auto-opped on rejoin β even on a different server in the federation. Channel founders (first authenticated user to create a channel) can never be de-opped.
The server stores the last 100 messages per channel. When you join, recent history is replayed as standard PRIVMSG β works with any IRC client, no special protocol extension needed.
Rich media is supported through IRCv3 message tags, giving multipart/alternative semantics β the same content in two representations:
- Tags: Structured metadata (content-type, URL, dimensions, alt text)
- Body: Plain text fallback (description + URL)
@content-type=image/jpeg;media-url=https://cdn.bsky.app/img/...;media-alt=Sunset;media-w=1200;media-h=800 :alice!a@host PRIVMSG #photos :Sunset https://cdn.bsky.app/img/...
| Client | What they see |
|---|---|
| irssi, WeeChat | Sunset https://cdn.bsky.app/img/... (clickable link) |
| freeq-tui | πΌ [image/jpeg] Sunset 1200Γ800 https://cdn.bsky.app/img/... |
Media is hosted externally (AT Protocol PDS blob storage). The IRC server never handles media bytes β it just relays tagged messages.
Supported tag keys:
| Tag | Description |
|---|---|
content-type |
MIME type (e.g. image/jpeg, video/mp4) |
media-url |
URL where the media can be fetched |
media-alt |
Alt text / description |
media-w |
Width in pixels |
media-h |
Height in pixels |
media-blurhash |
Blurhash placeholder |
media-size |
File size in bytes |
media-filename |
Original filename |
Token bucket rate limiter (10 commands/second) kicks in after registration. The initial connection burst is not rate-limited, so clients that send many commands on connect (like LimeChat) work correctly.
The status bar shows:
- Transport badge: Colored indicator (red=TCP, green=TLS, cyan=WS, magenta=Iroh)
- Nick: Your current nick
- Auth: Authenticated DID or "guest"
- Uptime: Connection duration
Emacs mode (default):
| Key | Action |
|---|---|
| Ctrl-A / Home | Beginning of line |
| Ctrl-E / End | End of line |
| Ctrl-F / Right | Forward char |
| Ctrl-B / Left | Back char |
| Alt-F | Forward word |
| Alt-B | Back word |
| Ctrl-D | Delete char |
| Ctrl-H / Backspace | Delete back |
| Ctrl-K | Kill to end of line |
| Ctrl-U | Kill to beginning |
| Ctrl-W | Kill word back |
| Alt-D | Kill word forward |
| Ctrl-Y | Yank (paste kill ring) |
| Ctrl-T | Transpose chars |
| Alt-U | Uppercase word |
| Alt-L | Lowercase word |
| Alt-C | Capitalize word |
| Tab | Nick completion |
| Up / Down | Input history |
| Ctrl-N / Alt-N | Next buffer |
| Ctrl-P / Alt-P | Previous buffer |
| BackTab (Shift-Tab) | Previous buffer |
| PageUp / PageDown | Scroll messages |
| Ctrl-C / Ctrl-Q | Quit |
Vi mode (--vi):
Normal mode: h/l move, w/b/e word motion, 0/$ line edges,
i/a/I/A enter insert, x/X/D/C/S/s delete/change, p/P paste,
k/j history, dd clear line. Insert mode: standard typing, Esc to
exit to normal mode.
/join #channel Join a channel
/part [#channel] Leave current or named channel
/msg nick message Private message
/me action CTCP ACTION
/topic [text] View or set channel topic
/mode +o/-o nick Op/deop
/mode +v/-v nick Voice/devoice
/mode +b [mask] Ban (or list bans)
/mode +i/-i Invite-only
/mode +t/-t Topic lock
/mode +k/-k [key] Channel key
/op nick Shortcut for /mode +o
/deop nick Shortcut for /mode -o
/voice nick Shortcut for /mode +v
/kick nick [reason] Kick from channel
/ban mask Ban user
/unban mask Remove ban
/invite nick Invite to current channel
/whois nick Query user info
/names [#channel] List channel members
/raw <line> Send raw IRC line
/encrypt <passphrase> Enable E2EE for current channel
/decrypt Disable E2EE for current channel
/p2p start Start P2P endpoint
/p2p id Show your P2P endpoint ID
/p2p connect <id> Connect to a peer
/p2p msg <id> <text> Send P2P direct message
/net Show/hide network info popup
/debug Toggle raw IRC line display
/quit [message] Disconnect
/help Show commands
Shows: transport type, server address, connection state, uptime, nick,
authenticated DID, iroh endpoint ID, E2EE channels, P2P DM status.
Close with Esc or q.
Toggles raw IRC line display in the status buffer (prefixed with β).
Useful for diagnosing protocol issues.
When --web-addr is set, a read-only REST API is available:
| Endpoint | Description |
|---|---|
GET /api/v1/channels |
List all channels |
GET /api/v1/channels/{name} |
Channel info (topic, modes, member count) |
GET /api/v1/channels/{name}/members |
Channel member list |
GET /api/v1/channels/{name}/topic |
Channel topic |
GET /api/v1/channels/{name}/messages |
Recent messages (with pagination) |
GET /api/v1/stats |
Server stats |
All writes go through IRC β the REST API is strictly read-only.
freeq-server [OPTIONS]
Options:
--listen-addr <ADDR> Plain TCP address [default: 127.0.0.1:6667]
--tls-listen-addr <ADDR> TLS address [default: 127.0.0.1:6697]
--tls-cert <PATH> TLS certificate PEM file
--tls-key <PATH> TLS private key PEM file
--server-name <NAME> Server name [default: freeq]
--challenge-timeout-secs <N> SASL challenge validity [default: 60]
--db-path <PATH> SQLite database path (omit for in-memory)
--web-addr <ADDR> HTTP/WebSocket listener address
--iroh Enable iroh QUIC transport
--iroh-port <PORT> UDP port for iroh (default: random)
--s2s-peers <ID,ID,...> S2S peer iroh endpoint IDs
When --db-path is set, the server persists:
- Message history β all channel messages, queryable with pagination
- Channel state β topics, modes (+t, +i, +k), channel keys
- Bans β hostmask and DID bans survive restarts
- DID-nick bindings β nick ownership persists across server restarts
Without --db-path, the server runs entirely in-memory.
The database uses SQLite with WAL mode for good concurrent read performance.
Persistence failures are logged but do not crash the server.
# Unit + integration tests
cargo test
# S2S federation acceptance tests (9 tests, requires two live servers)
LOCAL_SERVER=localhost:6667 REMOTE_SERVER=irc.freeq.at:6667 \
cargo test -p freeq-server --test s2s_acceptance -- --nocapture --test-threads=1153 tests covering:
- SDK (44): IRC parsing (with tag support), tag escaping roundtrip, DID document parsing, key generation/signing/verification, multibase/multicodec, challenge response encoding, SASL signer variants, media attachment roundtrip, link preview roundtrip, media type detection
- Server unit (33 + 12 CRDT): Message parsing (with tags), tag escaping, SASL challenge store (create, take, replay, expiry, forged nonce), channel state, database roundtrips (channels, bans, messages, identities), CRDT tests (founder deterministic min-actor, founder not overwritten after sync, DID ops sync, topic provenance, authority validation, compaction, metrics, ban provenance)
- Integration (27): Guest connection, secp256k1 auth, ed25519 auth, wrong key rejection, unknown DID rejection, expired challenge rejection, replayed nonce rejection, channel messaging, mixed auth/guest, nick collision, channel topic, topic lock, channel ops/kick, hostmask bans, DID bans, invite-only, message history replay, nick ownership, quit broadcast, channel key (+k), TLS connection, rich media tag passthrough, persistence (messages, topics, bans, nick ownership survive restart)
- S2S acceptance (9): Connectivity, bidirectional message relay, NAMES sync, topic sync, PART/QUIT cleanup, late-joiner state
- Challenge uses JSON encoding (not a binary format) for debuggability
- PDS session verification is an additional auth method beyond the spec's crypto-only approach β it enables OAuth login without requiring users to manage raw signing keys
- History replay uses standard PRIVMSG (no custom extension or batch)
- CAP negotiation follows IRCv3
CAP LS 302/CAP REQ/CAP END - SASL flow follows IRCv3 SASL specification with a custom mechanism name
message-tagscapability follows the IRCv3 message tags specification- Media tags use vendor-prefixed names (
content-type,media-url, etc.) - Server advertises
iroh=<endpoint-id>inCAP LSfor transport discovery ATPROTO-CHALLENGEcould be proposed as an IRCv3 WG mechanism
Freeq supports a plugin system for custom server behavior. Plugins hook into events like authentication, message delivery, and channel joins.
# Load a plugin via CLI
freeq-server --plugin "identity-override:handle=timesync.bsky.social,display_id=3|337"
# Load plugins from a directory of TOML configs
freeq-server --plugin-dir ./examples/plugins/See examples/plugins/ for example configurations and docs/PROTOCOL.md
for the full plugin hook reference.
- Features β Complete feature catalog
- Protocol Notes β SASL mechanism, DID extensions, transport details
- Known Limitations β Explicit list of gaps
- Architecture Decisions β Design rationale
- S2S Audit β Federation protocol analysis
- CRDT Federation Audit β CRDT convergence issues & fix plan
- Future Direction β Roadmap
MIT
