Goal: Everything encrypted by default. This document maps every data path in freeq, what's protected today, and what's not yet.
freeq has encryption at multiple layers — transport, authentication, federation, and (planned) message-level. Some paths are fully encrypted today. Others have gaps. We're transparent about both.
| Data Path | Encrypted? | Mechanism | Notes |
|---|---|---|---|
| Web client ↔ Server | ✅ Yes | TLS 1.3 (HTTPS/WSS) | nginx terminates TLS with Let's Encrypt cert |
| iOS app ↔ Server | ✅ Yes | TLS 1.3 (WSS) | App Transport Security enforces HTTPS |
| IRC client ↔ Server (TLS) | ✅ Yes | TLS 1.3 (port 6697) | rustls with Let's Encrypt cert |
| IRC client ↔ Server (plain) | ❌ No | Plaintext TCP (port 6667) | Legacy IRC compat; should use TLS port |
| Server ↔ Auth Broker | ✅ Yes | HTTPS + HMAC-SHA256 | All broker API calls over TLS; request bodies signed with shared secret |
| Auth Broker ↔ Bluesky PDS | ✅ Yes | HTTPS + OAuth 2.0 + DPoP | Token-bound proof-of-possession; PDS credentials never leave the broker |
| Server ↔ Server (S2S) | ✅ Yes | QUIC (iroh) | iroh uses Noise protocol over QUIC; peer identity = Ed25519 public key |
| Server ↔ SQLite (at rest) | ✅ Yes | AES-256-GCM per message | Key derived from server signing key via HMAC; backward-compatible with legacy plaintext |
| Server ↔ Policy DB (at rest) | ❌ No | Plaintext on disk | Channel policies, credentials |
| Message content (in transit) | 🟡 Transport only | TLS protects the pipe, not the payload | Server sees plaintext; E2E DMs available |
| Message content (at rest) | ✅ Yes | AES-256-GCM (EAR1: prefix) | New messages encrypted; old messages readable as-is |
| Message signatures | ✅ Yes (client + server) | ed25519 via +freeq.at/sig IRCv3 tag |
Client-side signing with session keys; server fallback for legacy clients |
| DM content | 🟡 E2E available | Double Ratchet (X3DH + AES-256-GCM) | E2EE auto-enabled between DID-authenticated users; server sees ciphertext |
| File uploads (in transit) | ✅ Yes | HTTPS to server → HTTPS to PDS | Uploaded via TLS to server, proxied via TLS to AT Protocol PDS |
| File uploads (at rest) | 🟡 PDS-dependent | Stored on user's PDS (Bluesky infra) | Not under freeq's control; PDS may or may not encrypt at rest |
| Authentication challenge | ✅ Yes | Cryptographic challenge-response | Server issues nonce → client signs with DID key → server verifies |
| OAuth tokens | ✅ Yes | In-memory only, TLS transport | Never written to disk; lost on server restart |
| Broker tokens | ✅ Yes | HMAC-signed, TLS transport | Short-lived; broker refreshes PDS tokens on demand |
| Verifier signing key | 🟡 Partial | Persisted to disk as plaintext file | verifier-signing-key.secret; filesystem permissions are the only protection |
| Hostname/IP | ✅ Cloaked | freeq/plc/xxxxxxxx format |
Real IP never exposed to other users |
Every production connection is TLS-encrypted:
wss://irc.freeq.at — nginx terminates TLS 1.3 with a Let's Encrypt certificate, proxies to the local HTTP server.The authentication flow is cryptographically sound:
The auth broker (handles OAuth with Bluesky PDS) is secured at multiple levels:
S2S federation uses iroh, which provides:
User IPs are never visible to other users:
freeq/plc/xxxxxxxx (8-char hash of DID)freeq/guestThis is the biggest gap.
freeq currently operates like Slack, Discord, and every other centralized chat: the server can read all messages. Transport encryption (TLS) protects messages from network observers, but the server itself has full access.
This matters because:
- A compromised server leaks all history
- The server operator can read DMs
- Law enforcement requests to the server operator expose content
Message content is now encrypted at rest using AES-256-GCM. Each message is individually encrypted before SQLite storage, with a key derived from the server's signing key via HMAC-SHA256. Legacy messages (stored before encryption was enabled) remain readable as plaintext.
What's encrypted: Message text in the messages table (PRIVMSG, NOTICE, edits).
What's NOT encrypted: Channel metadata, policies, identities, sender nicks, timestamps. A compromised disk still reveals who talked to whom and when — but not what they said.
Messages are now signed with server-attested ed25519 signatures. Every PRIVMSG/NOTICE from a DID-authenticated user carries a +freeq.at/sig tag containing a base64url-encoded signature over {sender_did}\0{target}\0{text}\0{timestamp}. The server's signing public key is published at /api/v1/signing-key.
What this provides:
- Federated servers can verify message provenance
- Signed messages are distinguishable from unsigned (guest) messages
- Signatures survive S2S relay
What this does NOT provide (yet):
- The server could still theoretically forge signatures (it holds the signing key)
- True end-to-end non-repudiation requires client-side signing (Phase 2)
Uploaded media lives on the user's AT Protocol PDS (typically Bluesky infrastructure). freeq doesn't control PDS encryption policies. The server proxies uploads over TLS, but the PDS storage is opaque to us.
The credential verifier's signing key is stored as a plaintext file on disk. It should be in a hardware security module (HSM) or at minimum an encrypted keystore.
Status: Implemented (client-side + server fallback)
Every message from a DID-authenticated user is cryptographically signed:
@+freeq.at/sig=<base64url-signature> PRIVMSG #channel :Hello world
{target}\0{text}\0{timestamp} (canonical form)Client-side signing (Phase 1.5) is now shipped. Clients (SDK, web, iOS) generate a per-session ed25519 keypair, register the public key with the server via MSGSIG, and sign every outgoing PRIVMSG. The server verifies the client's signature and relays it unchanged — the server cannot forge client-signed messages.
For clients that don't support signing (legacy IRC clients), the server still signs as a fallback, providing message provenance through federation.
Client session signing keys are published at GET /api/v1/signing-keys/{did} so any party can verify signatures independently.
Status: Implemented (web client)
DMs between DID-authenticated users are end-to-end encrypted:
ENC3: prefix) — can't read DM contentRemaining: Multi-device key sync.
Recent improvements:
- Pre-key bundles are now persisted to SQLite (survive server restart)
- SPK signatures are verified using Ed25519 signing keys (prevents MITM)
- Safety number verification UX (Signal-style 60-digit fingerprint)
- DH ratchet step every 10 messages (forward secrecy on key compromise)
- iOS E2EE via Rust FFI (FreeqE2ee manager: generate/restore keys, establish sessions, encrypt/decrypt, safety numbers, session import/export for Keychain persistence)
Status: Future research
Group E2E encryption is hard. Approaches under consideration:
Trade-offs:
- E2E channels can't have server-side search or history for new members
- Moderation becomes harder (server can't inspect content)
- This may be opt-in per channel rather than default
Message text is encrypted with AES-256-GCM before SQLite storage. Key stored in a separate db-encryption-key.secret file, independent of the message signing key. On first run, the key is derived from the signing key for backward compatibility with existing encrypted data, then persisted separately. This ensures a signing key compromise does not also compromise encrypted data.
Remaining: Encrypt channel metadata, policies, and identity tables. Full-database encryption via SQLCipher.
| Feature | freeq (today) | Slack | Discord | Signal | Matrix |
|---|---|---|---|---|---|
| Transport encryption | ✅ | ✅ | ✅ | ✅ | ✅ |
| E2E DMs | ✅ | ❌ | ❌ | ✅ | ✅* |
| E2E group chat | ❌ | ❌ | ❌ | ✅ | ✅* |
| Message signatures | ✅* | ❌ | ❌ | ✅ | ❌ |
| Decentralized identity | ✅ | ❌ | ❌ | ❌ | ✅ |
| Server can read messages | Yes | Yes | Yes | No | Yes* |
| Open protocol | ✅ | ❌ | ❌ | ✅ | ✅ |
| Encrypted at rest | ✅* | Unknown | Unknown | ✅ | Varies |
| IP cloaking | ✅ | N/A | ✅ | ✅ | Varies |
Matrix E2E is opt-in and has had verification UX issues.
Client-side session key signing shipped; server fallback for legacy clients.
Federated peers are now authorization-checked:
A rogue federated peer cannot:
- Grant themselves op status
- Kick users from channels they don't control
- Change topics on locked channels
- Bypass bans by joining from a different server
- Flood the server with events
We believe encryption should be default, not optional. The current gaps exist because we shipped transport security first (the layer that matters most immediately) and are building message-layer security in the open.
We're not going to claim E2E when we don't have it. We're not going to hide the fact that the server can read your messages today. Instead, we're publishing this document, shipping the fixes in order of impact, and inviting scrutiny.
The AT Protocol gives us a unique advantage: every user already has a cryptographic identity (DID) with signing keys. We don't need to invent a key distribution system — it already exists. Message signing and E2E encryption can build on infrastructure that's already deployed to millions of users.
If you find a security issue, please report it to security@freeq.at or open a GitHub issue.