You have seen what happened to Session. And do not get us started on Telegram, we would be here all day.
Session shut down. Telegram became a running joke for privacy: encryption you have to manually turn on (seriously?), metadata leaking out of every seam, and a CEO who cannot decide if he is running a messenger or a crypto casino. Every single platform that promised you anonymity is either dead, compromised, or slowly selling you out to advertisers.
Nothing reliable came along. Nothing that actually respected the people using it. So we stopped waiting and built it ourselves.
No logs. No backdoors. No excuses.
QxChat is zero-logs by architectural design. E2EE is not buried behind a hidden toggle in settings that you will never find. It is on by default, handled entirely on your device. Not on "our" servers, not in "the cloud". On your hardware.
And because the entire project is open source (not "open core", not "source available", genuinely open source), you can read every single line of code. Audit it, fork it, host it, break it. That is the kind of transparency Discord and Telegram will never give you.
✕What the others do
Store your metadata on servers you cannot inspect: Yes
Make encryption opt-in so 99% of people never use it: Yes
Keep the backend code closed because "trust us bro": Yes
Change their privacy policy whenever advertisers ask: Absolutely
Ship a 200 MB Electron app that eats a gigabyte of RAM: Every single time
✓What QxChat does
Store metadata: No. Zero. None.
Telemetry or trackers: No, never.
Client-side E2EE on by default: Yes, always.
Fully open source (Rust + Vue 3): Every single line.
You host it, you own everything: Yes, that is the entire point.
QxChat is built around a simple idea: you host the server, you own the keys.
Every room you create generates its own encryption key directly on your device. Messages are encrypted before they leave your client and only decrypted by the recipient. The server? It just routes ciphertext packets. It does not read your chats because mathematically, it literally cannot.
Voice and video calls stream through your own TURN server. Not Google infrastructure, not AWS relays, yours. Your profiles, your room memberships, your message history: it all stays on your machine and your server. There is no third party standing in the middle.
No analytics. No telemetry. No "anonymous diagnostic telemetry" that turns out to be uniquely identifying. Just a clean WebSocket connection between you and a server you control.
1
Ephemeral Key Generation
Clients generate cryptographic identity and room keys locally via browser SubtleCrypto and Rust crypto primitives.
2
Zero-Knowledge Relay
The Rust server acts purely as a WebSocket routing hub, forwarding encrypted payloads without inspectable headers.
3
Direct P2P WebRTC Media
Voice, video, and screen sharing stream directly between participants with zero intermediary recording.
QxChat is built on lqxp, an open Vue-based framework engineered for real-time messaging. No magic tricks, no proprietary nonsense, just clean and solid tech:
Frontend
Vue 3 & TypeScript
One codebase for web, desktop, and mobile. Not three separate apps stitched together with duct tape, but one clean, responsive interface everywhere.
Desktop
Tauri v2
Native binaries for Windows, macOS, and Linux. No Electron, no bundled Chromium, no 600 MB RAM usage for a chat window. Just the system WebView doing what it was built to do.
Backend
Rust WebSocket Core
Async, memory-safe, and effortlessly handles thousands of concurrent rooms on a $5 VPS. The kind of resource efficiency Electron developers can only dream of.
Media
WebRTC P2P
Voice, video, and screen sharing go directly between participants. No intermediary server recording anything. No "pay $9.99/month for 1080p stream quality".
Every single piece is self-hostable. The API, the WebSocket layer, the TURN server. You decide where it runs. There is no vendor lock-in, no SaaS dependency, and no paywalled features.
Let us be clear: QxChat is not "open core" where all the useful features are locked behind an enterprise paywall. It is not "source available" with a license written by corporate lawyers to prevent you from doing anything real. It is fully, genuinely open source (client, server, everything) under MIT.
Why does this matter? Because trust is not built on slick landing pages. It is built on code you can inspect. Every single claim we make can be verified by anyone with a text editor in five minutes. No NDAs, no sealed binaries, no hand-waving.
"If we disappear tomorrow, the community forks the repo and keeps going. Your conversations do not die with a company. That is the entire point."
QxChat is not just another chat app. It is a bet that communication tools can be built without treating users as data to be mined. Privacy is not a checkbox you find in sub-settings. Self-hosting is not an afterthought. You are in control.
Whether you want to set up a private server for your team, contribute code, report edge-case bugs, or just use a messenger that does not spy on you: you are welcome here.
No invite codes. No waitlist. Just download and run.
QxChat is a centralized, single-server real-time messaging platform with two cooperating subsystems: QxChat core (rooms, real-time messages, presence, profiles, calls, and moderation — the existing chat infrastructure) and QXP-PHANTOM (an add-only friend request protocol that runs on top of the core without ever materializing the social graph on the server).
The server is a single Rust binary (qxprotocol) exposing an HTTP API and a WebSocket endpoint. The reference client is a Vue application that runs in the browser.
Authenticated WebSocket at /ws for chat, rooms, presence, calls, and the authenticated QXP-PHANTOM operations (prekey publish/fetch and block updates).
Anonymous HTTP for the QXP-PHANTOM dead-drop operations (deposit and poll) and for the anti-abuse challenge endpoints. These requests never carry an authorization header, so the server cannot bind a deposit or a poll to an account or to the same session that performed it.
Why anonymous HTTP for dead-drops
Splitting dead-drop traffic onto anonymous HTTP is a deliberate privacy requirement: it prevents the server from observing the account that deposits into a slot and the account that later reads the same slot — which would reveal the social edge.
State model
The server keeps live state in core/presence.rs::AppState.
Field
Type
Lifetime
players
HashMap<session_id, PlayerSession>
RAM, per connection.
room_messages
HashMap<room_id, Vec<ChatMessageRecord>>
RAM, capped at 150 messages per room, lost on restart.
Persisted users, sessions, prekeys, blocks, default room, social blobs.
rate_limits
HashMap<key, RateLimitBucket>
RAM, fixed windows.
public_profile_cache
HashMap<key, CachedPublicProfile>
RAM, 5 minute TTL.
call_access_overrides
HashSet<String>
RAM.
PlayerSession tracks the authenticated identity, joined rooms, presence, call state, and the outbound channel for a single WebSocket connection.
Storage model
RAM only, never persisted:
Room messages. Capped at 150 per room (MAX_ROOM_MESSAGES), destroyed on restart.
The QXP-PHANTOM dead-drop. Capped at 100 000 envelopes, 24 hour TTL, destroyed on restart.
Rate limit buckets, the public profile cache, and all ephemeral crypto key registries (VDF consumed set, PQC ephemeral keys, quota nullifiers, and CAPTCHA tokens).
Persisted in SQLite or PostgreSQL through sqlx:
users (account, password hash, recovery hash, profile, social blob).
sessions (hashed session tokens).
feature_flags.
prekeys (public prekey bundles).
blocks (opaque block tags).
default_room (the single default room record).
Room metadata in the RoomDatabase backend.
The choice between SQLite and PostgreSQL is driven by database.kind and database.url in the configuration file.
Configuration
Configuration is read from files/config.{dev,prod}.toml, or files/config.custom.toml when it exists. The PRODUCTION environment variable selects the prod profile. Configuration covers the API address and port, the network paths, TURN servers, the database backend, and security flags such as the admin id list and whether registration is enabled.
Logging is initialized by core/config.rs::init_tracing with tracing_subscriber in compact form, filtered by RUST_LOG. No IP address or User-Agent extraction is configured in the tracing layer.
Authentication flow at a glance
Account authentication uses a password for the account and a separate BIP39 recovery phrase for password reset. The client additionally maintains a cryptographic identity that the server does not store: a device signing key, an ML-DSA-65 key, an ML-KEM-768 key, and a master secret derived from the recovery words.
Verifies against a fixed dummy hash when no real hash exists, then returns the same generic failure. Lifts the timing signal that would otherwise reveal whether a username exists.
token_hash
SHA256(session token) as lowercase hex.
generate_session_token
64 alphanumeric characters from the OS CSPRNG.
generate_recovery_words
A BIP39 mnemonic from 16 bytes of entropy, which produces 12 words.
Username validation normalizes to lowercase and trimmed form, enforces a length of 2 to 24 characters at registration, rejects a reserved system name, and allows only a fixed whitelist of characters. Passwords must be 8 to 128 characters.
Registration
The POST /api/auth/register endpoint and the services/auth.rs::register function create an account.
Steps:
Verify that registration is enabled in the feature flags.
Reject usernames that hit the operator blocklist.
Validate the username and password.
If the username is taken, burn a constant-time dummy hash and return a generic error.
The recovery words are returned exactly once, at registration. They are also available for download from the client after a successful registration.
Login
The POST /api/auth/login endpoint and services/auth.rs::login function authenticate a password.
Steps:
Normalize the username.
Look up the user.
Verify the password with Argon2id.
Reject banned or disabled accounts.
On a missing user, burn the constant-time dummy hash.
Create a session token.
The response is { "ok": true, "token": "...", "user": { ... } }.
Session tokens
A session token is a 64 character alphanumeric string. The server stores only SHA256(token) in the sessions table.
Column
Meaning
token_hash
Primary key, the SHA-256 hex of the token.
user_id
The owning account.
created_at
Creation time in milliseconds.
expires_at
Expiry time in milliseconds.
The session TTL is 7 days (SESSION_TTL_MS). authenticate_token resolves a token to the user, rejecting expired tokens and disabled or banned accounts. touch_session extends the TTL. logout deletes the session row. Recovery and password changes invalidate all sessions for the account.
Recovery
The POST /api/auth/recover endpoint resets a password using the recovery phrase.
Steps:
Normalize and validate the new password.
Look up the user.
Verify the normalized recovery phrase against recovery_hash.
Reject banned accounts.
Hash the new password and write it.
Delete all existing sessions for the account.
Create a fresh session token.
The recovery phrase is normalized the same way as at creation: split on whitespace, lowercased, joined with single spaces.
WebSocket identification
The WebSocket protocol identifies a connection with opcode 2. The client sends a token in either the token or authToken field. The server calls authenticate_token and, on success, binds the session to a player record with the account username, id, admin flag, badges, profile, and presence status.
The identification response includes the optional default room record:
Authenticated HTTP endpoints use the Authorization: Bearer <token> header. authenticated_user resolves the token through authenticate_token. The endpoints that require a bearer token include the social blob, profile image upload, username change, and the admin endpoints.
Account deletion
Deletion requires the current password. On success the server removes:
All sessions for the account.
The prekey bundle.
The block tags.
The user row.
It then disconnects all live sessions for the account, invalidates the public profile cache, and removes any uploaded avatar and banner files.
This layer proves to other clients, in a cryptographically verifiable way, that a payload was produced by the holder of a specific device key and recovery-word derived identity. The server relays these signatures and public keys but never possesses the private key material.
Identity key material
The client maintains the following key material:
Key
Algorithm
Purpose
Where it appears
Device signing key
ECDSA P-256
Signs each end-to-end encrypted message.
Public key attached to every message envelope.
ML-DSA-65 key
ML-DSA-65 (FIPS 204)
Post-quantum component of hybrid signatures.
Public key in the prekey bundle.
ML-KEM-768 key
ML-KEM-768 (FIPS 203)
Encapsulation key for sealing friend envelopes and the anti-abuse challenge.
Public key in the prekey bundle.
Master secret
32 bytes, derived
Root for contextual signing keys and the roster key.
Never leaves the client.
Contextual keypair
ECDSA P-256, derived per room
Signs the inner friend envelope in a room-specific domain.
Public key appears only inside sealed envelopes.
The device signing key and ML-DSA-65 key are generated by the client and exported as JWK (for P-256) and hex (for ML-DSA). The ML-KEM-768 key is generated by the audited ml-kem / @noble/post-quantum library.
Master secret derivation
The master secret is derived from the 12 recovery words. It is never stored; it is recomputed on demand whenever a friend envelope must be sealed or the roster must be decrypted.
phrase = join(recovery_words, " ")
seed = PBKDF2-SHA256(password = phrase,
salt = "qxphantom:master",
iterations = 100000,
dkLen = 256 bits)
masterSecret = HKDF-SHA256(ikm = seed,
salt = empty,
info = "qxp-master",
len = 32 bytes)
If the recovery words are not present in the client state, the master secret cannot be derived and the QXP-PHANTOM signing and roster operations are unavailable.
Contextual keypair derivation
A room-scoped ECDSA P-256 keypair is derived from the master secret and a room id. This separates signing identities across rooms so that a public key used in one room cannot be linked to the public key used in another room.
salt = SHA256(UTF-8(roomId))
seed = HKDF-SHA256(ikm = masterSecret,
salt = salt,
info = "qxphantom:ctx:v1",
len = 32 bytes)
d = (bigint(seed) mod (order(P-256) - 1)) + 1
publicKey = P-256 point from scalar d
The scalar d and the public point are used to build a P-256 JWK. Only the public key is ever serialized into an envelope.
Hybrid signatures
QXP-PHANTOM requires both signatures to validate. An adversary must break both schemes to forge a payload. The two schemes are:
ECDSA P-256 over the canonical byte string, using either the device key (prekey bundle) or a contextual key (inner envelope).
ML-DSA-65 over the same canonical byte string, using the device key.
The ECDSA signature is the raw r || s form (64 bytes, IEEE P1363) encoded as b64url. The ML-DSA-65 signature is 3309 bytes encoded as hex.
Prekey bundle signature
The canonical bytes are the sorted, compact JSON of the bundle with the two signature fields removed. The ECDSA component is produced by the device ECDSA P-256 key, and the ML-DSA component by the device ML-DSA-65 key.
Inner envelope signature
The canonical bytes are the sorted, compact JSON of the inner envelope with the hybridSig field removed. The ECDSA component is produced by the contextual key derived for the room, and the ML-DSA component by the device ML-DSA-65 key.
Canonical serialization
All signed payloads use the same canonicalization contract, implemented identically in services/phantom_crypto.rs (Rust) and crypto/phantom.ts (TypeScript):
Object keys are sorted recursively.
Arrays keep their order.
Scalars are serialized with JSON rules.
No whitespace is inserted.
The canonicalDeviceSigningKey helper in crypto/e2ee.ts is a separate, narrower canonicalization used only to compare the public key inside an encrypted message envelope.
Trust model
The master secret, the ML-DSA secret key, the ML-KEM secret key, and the contextual private keys never leave the client.
The device ECDSA public key, the ML-DSA public key, and the ML-KEM public key are shared through the published prekey bundle.
The contextual public key is revealed only inside an envelope that has already been sealed to the recipient's ML-KEM key. The server cannot read it.
The server relays and validates the hybrid signatures on the prekey bundle during publication, but it does not possess the private keys and cannot forge an envelope.
The endpoint device is the security boundary. If the device is compromised, all of this key material is exposed.
Messages on the WebSocket are JSON objects with an op field and a d data field. The client sends operations; the server responds with the same opcode where a reply is expected, or pushes events asynchronously.
The dispatch table from websocket/protocol.rs::process_message is:
Opcode
Name
Direction
0
Ping or error
Both
1
Heartbeat
Both
2
Identify (authenticate session)
Client to server
3
Join room
Both
4
Leave room
Client to server
5
Report kill
Client to server
6
Version query
Client to server
7
Send chat message
Both
8
Update client settings
Client to server
18
Request room history
Client to server
19
Toggle message reaction
Client to server
21
Delete message
Client to server
28
Request link preview
Client to server
29
Edit message
Client to server
31
Update typing state
Client to server
32
Upload room icon
Client to server
33
Update room title
Client to server
35
Request public profiles
Client to server
36
Publish prekey (PHANTOM)
Client to server
37
Fetch prekeys (PHANTOM)
Client to server
39
Update block list (PHANTOM)
Client to server
40
Create room
Client to server
41
Update room description
Client to server
42
Set member role
Client to server
43
Ban member
Client to server
44
Unban member
Client to server
45
Kick member
Client to server
46
Timeout member
Client to server
47
Transfer ownership
Client to server
48
Set chat lock
Client to server
49
Set moderator permissions
Client to server
50
Set calls enabled
Client to server
51
Set call access
Client to server
52
Unmute member
Client to server
98
Update voice chat state
Both
100
Update mute state
Client to server
101
Admin status
Client to server
104
Admin broadcast
Client to server
105
Stats query
Client to server
110
Update call media state
Both
111
Relay call signal
Both
112
Update call deafened state
Both
23
Link preview patch
Server to client
999
Session eviction
Server to client
Room identity and access token
A room has two identifiers:
Field
Bytes
Encoding
roomId
16
lowercase hex, 32 characters
roomKey
32
lowercase hex, 64 characters
A room access token is the concatenation roomId || roomKey, 96 hex characters. The roomId is a routing label visible to the server; the roomKey is the symmetric encryption secret shared by room members. The server stores room metadata keyed by roomId but does not require the roomKey to route or store messages.
Room lifecycle
Joining is opcode 3. The server:
Rate limits the join.
Validates the room id length and character set.
Requires an identified session.
Enforces a maximum of 100 rooms per player.
Rejects joins to a community room if the user is banned.
Persists the room record if it is new.
Broadcasts the updated roster, unless the join is silent or the user is invisible.
Returns the roster, profiles, presence, role, icon, and room record to the joining client.
Dispatches the buffered room history.
Room membership lives in PlayerSession.rooms. Room metadata such as title, icon, description, roles, bans, and timeouts is persisted in the RoomDatabase backend. Leaving is opcode 4; the server can also evict a session with opcode 999 when an account is deleted or disabled.
Message sending
Messages are sent with opcode 7. A message is either plaintext or an encrypted envelope, never both.
Validation performed by the server:
The room id must be valid.
If an encrypted envelope is present, its roomId must match the target room.
An encrypted message must not include a plaintext text or attachment.
The message must be non-empty.
The sender must be identified and a member of the room.
A minimum 400 millisecond interval applies between messages per session.
A community room can restrict who may speak.
Plaintext is truncated to 2000 characters.
The server constructs a ChatMessageRecord, stores it in the RAM message store, and broadcasts it to all room members. The sender receives a separate acknowledgment with the assigned message id.
Message storage
Room messages live in AppState.room_messages, a HashMap<room_id, Vec<ChatMessageRecord>>. The store is capped at 150 messages per room; the oldest are dropped when the cap is exceeded. The store is RAM only and is lost on restart. Room history is served on request with opcode 18.
End-to-end encrypted message envelope
Encrypted messages use a shared room key and a per-message derived AES-256-GCM key. The format is version 2 with algorithm identifier QXDR-A256GCM-HKDFSHA256.
The plaintext JSON is encrypted with AES-256-GCM using the derived key, the random 12 byte IV, and the AAD above.
Authentication
The envelope is signed with the sender device ECDSA P-256 key. The signed bytes are the compact JSON of:
{ v, alg, roomId, n, salt, iv, ciphertext, senderDeviceId, senderSigningKey }
The signature is the raw r || s form encoded as b64url. On receipt, the client verifies the signature, optionally checks that the sender key matches a trusted key, then derives the message key and decrypts. The server parses and bounds-checks the envelope but cannot decrypt it because it does not possess the room key.
QXP-PHANTOM is an add-only friend request protocol. Its central property is that the social graph never exists on the server. A request is delivered only into a blind mailbox slot derived from a secret shared by the two parties, and there is no negative response: ignoring a request simply lets it expire.
Privacy goals
The server cannot reconstruct a social edge from its own state.
A deposit and a poll are not bound to an account or a session.
The server cannot compute any slot because it does not know room keys or prekeys until they are published.
A one-sided deposit has no effect and produces no signal to the recipient.
Envelopes are opaque to the server and expire after 24 hours.
Prekey bundle
Each user publishes a public prekey bundle that lets others seal envelopes to them. It is stored server side and served by username.
The bundle is signed with both device keys over its canonical form, excluding the two signature fields. Publication is idempotent; the server verifies both signatures before storing. Rotation republishes the bundle with a new version and new keys.
The fingerprint used throughout the protocol is:
fp(pk) = SHA256(raw bytes of the ML-KEM-768 public key), lowercase hex 64 chars
Slots
A slot is a 32 byte opaque label computed only by clients. The epoch day binds a slot to a 24 hour window.
The contextual slot proves the depositor knows both the target prekey fingerprint and the shared room key. The global slot proves only knowledge of the prekey fingerprint. The server cannot compute either because it does not know room keys and treats slots as opaque labels.
Envelope
An envelope has an outer layer visible to the server and an inner payload sealed to the recipient.
The server validates that pv is 1, that the three identifiers are exactly 64 hex characters, that the bucket is one of 4096, 16384, 65536, and that ct is non-empty and at most 96 KiB.
Inner layer
The ct field is b64url(ML-KEM ciphertext || IV || AES-GCM ciphertext). After decapsulation the recipient recovers this signed JSON:
innerPadded = pad(JSON(inner), bucket) // 4 byte BE length prefix + JSON
sharedSecret, cipherText = ML-KEM-768.encapsulate(recipientMlkemPk)
aesKey = HKDF-SHA256(sharedSecret, empty, "qxphantom:v1") -> AES-256-GCM
iv = random 12 bytes
aead = AES-256-GCM(aesKey, iv, innerPadded)
ct = b64url(cipherText || iv || aead)
The inner JSON is signed with the hybrid signature before sealing. The padding makes the outer size constant per bucket.
Opening
The recipient decapsulates with its ML-KEM secret key, derives the AES key, decrypts, verifies the hybrid signature, verifies epochBucket against the current day, and checks the sender against the local block list. Any failure is silent.
Deposit and poll
Deposits and polls use anonymous HTTP and never carry an authorization header.
Deposit
POST /api/phantom/deposit accepts an envelope and a gate. The gate is:
The mode-specific gate: cap verifies a CAPTCHA token, or pass consumes a Privacy Pass deposit token.
On success the envelope is stored in the RAM dead-drop. On any failure the response is the same generic { "ok": false, "reason": "gate" }.
Poll
POST /api/phantom/poll accepts up to 64 slots and a want value capped at 8. The server claims at most one envelope per slot, FIFO, and pads the result to want frames. Null frames are returned for missing envelopes.
Dead-drop store
The store is a RAM HashMap<slotId, VecDeque<envelope>> with these bounds:
Limit
Value
Envelopes per slot
16
Total envelopes
100 000
Envelope TTL
24 hours
Sweep interval
60 seconds
The store is never written to disk and is lost on restart.
Blocking
Blocking is two layered barriers.
Server barrier (cost): the server stores opaque tags only. For a block update it computes SHA256(fp(owner prekey) || hint); at deposit time it computes SHA256(recipientFp || senderHint) and rejects if the tag exists. The server learns that a pair is blocked but not which account blocks which. The blocks.user_id column is used only for the per-account quota of 512 and is never joined or exposed.
Client barrier (guarantee): the client keeps a local block list of sender fingerprints and silently destroys any incoming envelope whose sender is in the list, before any UI rendering.
Encrypted roster
The friend list is synchronized across devices through an opaque encrypted blob. The blob key is:
The blob is b64(iv || ciphertext), at most 64 KiB. PUT /api/social/blob enforces strict last-write-wins: the version must be strictly greater than the current version, otherwise the server returns 409 with the current version.
Shared-room path
A1 Alice computes slotContextual(Bob) using the shared room key
A2 Alice seals an intro and deposits it (cap gate)
A3 Alice polls her own slots on a jittered schedule
A4 Bob polls his rooms and global slots
A5 Bob opens the intro and decides
ignore -> silence, envelope expires in 24 hours
accept -> create room, seal a welcome, deposit it in Alice's global slot
A6 Alice receives the welcome and joins the room
A7 both store the friend locally and sync the roster
Username path
B1 Alice fetches Bob's prekey bundle
B2 Alice seals an intro and deposits it in slotGlobal(Bob)
B3 Bob, if acceptUnknown is enabled, opens and decides, then proceeds as A4 onward
Reciprocity is required for completion. There is no friendship without a second deposit, the welcome, from the recipient.
This contract is implemented in services/phantom_crypto.rs::canonical_json and crypto/phantom.ts::canonicalJson. The two implementations are verified against each other with a cross-language test vector.
Argon2id
Passwords and recovery phrases are hashed with Argon2id using the default argon2 crate parameters. Verification uses the same parameters. A fixed dummy hash is used for constant-time failure when a username or recovery phrase does not exist.
BIP39 recovery words
Recovery words are generated with bip39, 16 bytes of entropy, English wordlist, which yields 12 words. The phrase is normalized by splitting on whitespace, lowercasing, and rejoining with single spaces.
HMAC-SHA256
Several subsystems use HMAC-SHA256 with a per-subsystem 32 byte secret generated once per process by the OS CSPRNG. The HMAC output is lowercase hex. These secrets are never persisted, so signed tickets and challenges become invalid after a restart.
The subsystems with their own secret are:
Subsystem
Secret holder
Anonymous quota (rln)
core/rln.rs
VDF challenge
core/vdf.rs
CAPTCHA (cap)
core/cap.rs
Privacy Pass deposit tokens
services/privacy_pass.rs
ECDSA P-256
ECDSA P-256 is used for:
Device message signing (Web Crypto).
Contextual friend signing (Web Crypto).
The ECDSA half of the hybrid signatures.
The signature is the raw r || s form (64 bytes, IEEE P1363), not DER. It is encoded as b64url. Verification parses the two 32 byte scalars and verifies over SHA-256.
ML-DSA-65
ML-DSA-65 (FIPS 204) is the post-quantum half of the hybrid signatures. The Rust ml-dsa crate and the @noble/post-quantumml-dsa.js library implement it.
Size
Bytes
Public key
1952
Signature
3309
ML-KEM-768
ML-KEM-768 (FIPS 203) is used for envelope sealing and the anti-abuse PQC challenge. The Rust ml-kem crate and the @noble/post-quantumml-kem.js library implement it.
Size
Bytes
Encapsulation key
1184
Decapsulation key
2400
Ciphertext
1088
Shared secret
32
AES-256-GCM
AES-256-GCM is used for message payloads, the inner envelope, and the roster blob. The IV is 12 random bytes and the authentication tag is 16 bytes.
Anonymous quota tokens
The rln module implements an anonymous, HMAC-based quota token rather than a zero-knowledge rate-limiting nullifier. The token is:
The server verifies the signature, checks the epoch is within a small window, compares the nullifier in constant time, and stores it with a 3 minute TTL so the same ticket cannot be spent twice for the same action.
Wesolowski Verifiable Delay Function
The VDF is a sequential squaring proof over a fixed 1024 bit RSA modulus, a hardcoded composite of two 512 bit primes. The default iteration count is 30 000 and the challenge TTL is 3 minutes.
The challenge signature covers issuedAt:t:targetHash:salt:x:expiresAt. The target hash is the first 16 hex characters of SHA256(target). The proof is verified with the Wesolowski relation:
l = HashToPrime(x, y) // 128 bit Fiat-Shamir prime
r = 2^t mod l
check: (pi^l * x^r) mod N == y mod N
Post-quantum challenge
The anti-abuse challenge also includes an ML-KEM-768 encapsulation step. The server generates an ephemeral keypair, returns the encapsulation key, and later decapsulates the client ciphertext. The shared secret is discarded after decapsulation; the step currently acts as a capability gate rather than a key agreement.
The anti-abuse layer combines fixed-window rate limits, an anonymous quota token, a Verifiable Delay Function, an ML-KEM post-quantum challenge, and an optional CAPTCHA token. Its purpose is to make mass account creation and mass deposits expensive without linking the work to a user account.
Rate limiting
core/security.rs::rate_limit_hit implements a keyed fixed-window counter. A bucket holds a window start, a window length, and a count. Buckets are evicted when the store exceeds 5000 entries.
Representative limits:
Key
Limit
Window
phantom:deposit:global
20
1 second
phantom:poll:global
20
1 second
phantom:prekey:global
60
60 seconds
prekey-fetch:user:<id>
4
15 seconds
register:global
10
15 seconds
register:user:<name>
3
30 seconds
recover:user:<name>
3
30 seconds
auth:cap:challenge:global
60
10 seconds
auth:challenge:global
60
10 seconds
pass:redeem:global
60
60 seconds
chat:session:<id>
30
60 seconds
In addition, chat messages enforce a 400 millisecond minimum interval per session.
Anonymous quota token
Before any gated action, the client requests an anonymous quota token from GET /api/auth/challenge. The token is an HMAC-signed epoch ticket. The client computes an action-scoped nullifier and the server consumes it, preventing reuse of the same ticket for the same action. The nullifier store is RAM-only, capped at 10 000 entries, with a 3 minute TTL, and the comparison is constant time.
Verifiable Delay Function
The VDF forces a sequential squaring computation before a gated action. The client must compute y = x^(2^t) mod N and produce the Wesolowski proof pi. The server verifies the proof in logarithmic time. The default iteration count is 30 000 and the challenge expires after 3 minutes.
Post-quantum challenge
The challenge also carries an ephemeral ML-KEM-768 encapsulation key. The client encapsulates and returns the ciphertext; the server decapsulates to confirm the client performed the KEM step. The key is one-time and expires after 3 minutes.
CAPTCHA flow
The full CAPTCHA flow returns a one-time cap token that can gate a registration, login, recovery, or deposit.
The redeem handler verifies the challenge signature, consumes the quota nullifier, verifies the VDF proof, and decapsulates the ML-KEM ciphertext. It also checks optional instrumentation: an automated browser flag and a minimum interaction time of 250 milliseconds. The cap token is HMAC-signed, expires after 5 minutes, and is consumed once.
Privacy Pass redemption
POST /api/pass/redeem is wired and implements the nonce store with a reserve, verify, commit, release cycle. The store is capped at 100 000 entries with FIFO eviction. The deposit token issuance and consumption are implemented.
Not yet wired
The VOPRF verification of the AmortizedBatchTokenResponse against a public issuer keyset is not yet wired. The verify_amortized_batch_response function currently returns an error unconditionally. As a result, the pass deposit gate cannot currently be satisfied. The cap gate is the working path.
Storage bounds and TTLs
The ephemeral stores are bounded to prevent unbounded growth.
Three authenticated WebSocket operations were added for QXP-PHANTOM:
Opcode
Name
Request
Response
36
Publish prekey
a prekey bundle
{ "ok": true, "version": 1 }
37
Fetch prekeys
{ "usernames": ["..."] }
{ "bundles": { "user": bundle } }
39
Update blocks
{ "add": ["<hex64>"], "remove": ["<hex64>"] }
{ "filter": ["<hex64>"] }
All three require an identified session and follow the existing respond_error(state, sid, op, message, request_id) error pattern with static messages.
Opcode renumbering versus the specification
The design specification assigned PHANTOM the opcodes 36, 37, and 45. In the shipped code, opcode 45 was already occupied by moderation (kick), so block updates use 39. Prekey publish and fetch keep their specification opcodes.
Concept
Specification opcode
Shipped opcode
Publish prekey
36
36
Fetch prekeys
37
37
Update blocks
45
39
Default room
A single default room record was added so the server can offer an onboarding or announcement room. The record is stored in the default_room table with a fixed row id of 1 and contains roomId, roomKey, and title.
Admin endpoint: POST /api/admin/default-room with { roomId, roomKey, title } or { clear: true }.
The identification response (opcode 2) includes the record as defaultRoom.
The client persists an allowServerDefaultRoom toggle and a per-room defaultRoomLeavedRoomId flag. The client auto-joins on identification unless the user disabled the behavior or already left that specific room.
Social blob
The encrypted roster sync was added as GET /api/social/blob and PUT /api/social/blob. The blob is an AES-256-GCM ciphertext produced by the client; the server only enforces the last-write-wins version and the 64 KiB size limit.
Post-quantum challenge migration
The anti-abuse PQC challenge was migrated from a hand implemented polynomial KEM to the audited ml-kem (RustCrypto) and @noble/post-quantum libraries. The wire fields changed from { tHex, rhoHex } / { uHex, vHex } to { ekHex } / { ctHex }. The server and client were updated together.
Gap report
The following table records where the threat model or the design document describes a mechanism that differs from what is shipped. These are intentional accuracy notes, not an indication that the implementation is silently weaker without review.
Area
Design or threat model claim
Shipped implementation
Impact
Message encryption
MLS with X-Wing hybrid post-quantum (E8)
Room-key AES-256-GCM with ECDSA P-256 signatures (QXDR-A256GCM-HKDFSHA256), no MLS, no X-Wing
E2EE but not post-quantum and not MLS.
Rate-limiting nullifier
Zero-knowledge RLN (E5)
HMAC-signed anonymous quota tokens and SHA-256 nullifiers
Anonymous and replay-resistant, but not zero-knowledge in the SNARK sense.
Privacy Pass redemption
Implemented (S1, E7)
Route and nonce store wired, but VOPRF verification is a stub that always fails
The pass deposit gate is currently unusable.
Contextual pseudonym
Ed25519 derived per room (P3)
ECDSA P-256 derived per room
Same domain separation property, different curve.
Inner envelope sealing
MLS / X-Wing inner layer (P4)
ML-KEM-768 plus AES-256-GCM, no second MLS layer
Envelopes are sealed to the recipient and opaque to the server.
Deposit gate
Pass, cap, or ghost token
Anonymous quota nullifier plus one of pass or cap
One additional anonymous gate is always required. The ghost gate was removed.
Audit items integrated
Several audit items are present in the implementation:
The VDF challenge signature covers expiresAt in addition to the other fields (S2).
VDF parameters are length-bounded before parsing into large integers (S3).
Nullifier and target-hash comparisons use constant-time checks (S4).
Device and contextual signatures use ECDSA P-256 rather than Ed25519 (C3).