Engineering deep dive

Prizrak: a decentralized messenger whose traffic looks like ordinary HTTPS

F Foxeevich · author of Prizrak · ·~26 min read
TL;DR. I built a messenger with no main server, no phone number, end-to-end encryption, and a transport that, on the wire, looks like an ordinary HTTPS visit to a website. When two servers can no longer reach each other, messages travel through intermediate "vaults" that hold no key to what passes through them. There is also a built-in two-hop VPN: the engine is done and tested, the production plumbing is still being finished — the caveats at the end are explicit about that. All of it is open source under AGPL-3.0.
The prizrak.im website
The project site. Clients for Android, Windows, macOS and Linux live there too.

Why another messenger

The usual answer is a slogan. Let me answer as an engineer instead — through what actually breaks in existing solutions.

One: the center. Telegram, WhatsApp and Signal all have an organization that can be served with a demand, and infrastructure that can be blocked as a whole. Even with encrypted content, the fact of "who talked to whom and when" sits in one place, and that place can be switched off. The problem isn't corporate malice — it's that such a point exists at all.

Two: the phone number. A number is an identity document tied to a SIM bought with your passport. A messenger that requires a number cannot be anonymous by construction.

Three: the transport. A perfectly encrypted messenger is useless if DPI can see it on the wire and cut the connection. Here is the detail people miss most often: encryption and invisibility are different problems. You can encrypt everything flawlessly and still be identified by the first eight bytes of a packet.

Prizrak (Russian for "ghost") is an attempt to close all three at once without giving up comfort: chats, groups, channels, calls, voice messages, video notes, reactions, gifts — everything people got used to in Telegram.


What it adds up to

Capability How it works
Identifier name:domain — no phone number, no email
Encryption OpenPGP passport + X3DH + Double Ratchet; MLS (RFC 9420) designed in for large groups
Federation Your own homeserver on your own domain; servers find each other by themselves
Transport Real TLS 1.3 to a real domain, multi-port, decoy page on probing
Censorship resistance A network of "vaults" — relay nodes that cannot read what passes through
Calls Custom native media stack, a custom STUN equivalent, P2P or relay
VPN Built in, two hops, custom protocol on the wire (engine done, plumbing in progress)
Clients Electron (macOS / Windows / Linux) and React Native (Android; iOS in progress)
License AGPL-3.0
Desktop client
The desktop client. On the surface it is an ordinary messenger: chats, groups, channels, calls, voice, delivery and read receipts.

Three pillars

Everything stands on three things. None of them can be switched off on demand — they are built into the architecture.

Overall architecture
Two independent channels: messages through homeservers, calls direct or via relay. Both ride inside the stealth transport.

1. Federation with no center

There is no main server. Anyone runs their own homeserver on their own domain, and their users immediately talk to users of every other server. Exactly like email: many servers, one network.

A user is addressed as alice:a.org — a name plus the domain of their homeserver. When alice:a.org writes to bob:b.org, server a.org looks at the recipient's domain, locates b.org and hands the envelope over.

2. End-to-end encryption

The server is a dumb relay of sealed envelopes. It sees the route (from, to, when) and ciphertext. Nothing else. Neither the server owner, nor the ISP, nor whoever seizes the machine can read the conversation: only the participants hold keys.

This is not a promise, it is a property of the code: the server stores a payload field containing ciphertext and holds no key that opens it.

3. Invisible to DPI

Messenger and call traffic must look like ordinary web browsing. Not "similar to HTTPS" — literally HTTPS: a real TLS 1.3 handshake with a real server certificate. There is nothing to detect, because nothing is being faked; the handshake is genuine.


Federation, in practice

The design follows Matrix, deliberately simplified.

Discovery. The classic route is GET https://domain/.well-known/prizrak/server, which returns a base URL. But I went further: you never have to list other servers in a config file. A server probes its peer's standard ports — 443, 8801, 80, 993, 995, 587, 465, 143, 110, 25 — finds a working entry point and caches the result for five minutes. The resolver config entry survives only as an optional pin for peers on non-standard addresses.

Why this matters: every mandatory config entry is a point of failure and manual labor. A node changes address and you are editing files on every server in the network. Live discovery removes that pain entirely.

Two APIs. Client-to-server and server-to-server:

Level Method Path Purpose
Client POST /_prizrak/client/v1/register publish profile and prekey bundle
Client GET /_prizrak/client/v1/bundle?userId= fetch a peer's keys (including from another domain)
Client POST /_prizrak/client/v1/send send a sealed envelope
Server POST /_prizrak/federation/v1/send accept an envelope for a local user
Server GET /_prizrak/federation/v1/bundle?userId= serve a local user's keys

Where it is simpler than Matrix. Matrix replicates the full room state graph between servers with conflict resolution. That is powerful and heavy. For a Telegram-shaped messenger it is overkill: messages are a stream of sealed events delivered store-and-forward, and group state is resolved inside the E2E group rather than in open server state.

Storage is SQLite in WAL mode with real tables and indexes (it used to be a JSON file; the migration shipped with automatic conversion and 34 store tests).


Encryption: why a "pure PGP messenger" is a bad idea

The initial instinct was "just use PGP" — public key, private key, everyone understands it. That is the right instinct about asymmetric cryptography and the wrong decision for a message stream. Let me be precise about why.

Crypto stack

What is wrong with PGP as a conversation transport:

What PGP does superbly — and why it stayed:

The resulting design:

OpenPGP key (long-term)  ───signs───▶  X25519 identity + prekeys
                                                │
                                    X3DH: the first shared secret
                                                │
                                     Double Ratchet (1:1 stream)
                          forward secrecy + post-compromise security

Under the hood: X25519 for the DH ratchet, HKDF-SHA256 for the root chain, HMAC-SHA256 for the symmetric chains, ChaCha20-Poly1305 as the AEAD. Out-of-order delivery is supported through skipped message keys — without it a mobile network would shred the conversation at the first cell handover.

Pairwise sessions do not scale to large groups: O(n) encryptions per message. That is what MLS (RFC 9420) is for — one encryption per message regardless of group size, with O(log n) work per membership change. MLS assumes only an untrusted delivery service, which maps onto this model neatly. The caveat: MLS is designed in but not yet integrated — today groups run on per-device fan-out (see the caveats at the end).

On key substitution, honestly. The defense is layered: prekeys signed by the passport, fingerprints verified out of band, and — for production — a public key transparency log (append-only, CONIKS-style) so that a swapped key becomes publicly detectable. The KT log is planned, not written. I am not going to pretend otherwise.


Transport: why an ordinary call is caught in one packet

This is the most underestimated part of the problem. People assume that if everything is encrypted, DPI sees nothing. It sees plenty: DPI does not look at content; it looks at shape.

What DPI sees

An ordinary WebRTC call carries two signatures, both in the clear:

The conclusion I had to accept early: you must disguise the entire transport, not "the signalling". Hiding the control channel is pointless when the media stream announces itself in its first bytes.

The fix: don't imitate HTTPS, be it

The best thing that currently works against modern filtering is the Reality/XTLS approach. The idea is counter-intuitive: do not fake TLS, perform a genuine TLS 1.3 handshake.

Multi-port, and "the client will find its way"

The server listens on a list of stable ports at once and silently skips those that are taken or need privileges. The client connects to an address with no port and scans, starting at 443. When a certificate is configured, 443, 993, 995 and 465 come up as genuine HTTPS/WSS.

The logic is simple: the more independent entry points, the more expensive the block. And the fewer questions for a user who just types name:domain and should never have to learn the word "port".

An honest caveat. I deliberately did not build "my own obfuscation on top of TLS" and then claim it is indistinguishable. Real TLS 1.3 on 443 with a real certificate gives a filter far less to match on than any home-made mimicry protocol would. That is not the same as unlinkable — timing and volume are still there. A custom mimicry protocol is a separate large R&D effort, and calling it "invisible" without that work would be dishonest.


Calls: how I lost a week to 2048 bytes

The most instructive engineering story in the project. Keep it as a checklist if you ever build your own media stack.

The initial diagnosis. Calls were slow and cooked the phone. Encryption was not to blame (the AEAD costs a fraction of a percent of CPU). Two other things were:

  1. Every video frame crossed the React Native bridge into JavaScript as base64. Monstrous overhead, per frame.
  2. All media went through the server, always.

The fix: media moves entirely into native code (Camera2 / MediaCodec / AudioRecord / Opus), while JS handles only signalling and UI. Zero base64 per frame.

A STUN of my own. Public STUN is unusable — see the magic cookie above. Instead the server acts as an address mirror itself: the client sends one UDP packet shaped like a QUIC initial (long header, random connection ID), and the server replies with the public ip:port it observed. That is precisely STUN's function with no recognizable byte on the wire. Candidates are then exchanged over the already-encrypted signalling channel, followed by simultaneous hole punching.

And here is where I got burned. Once the direct P2P path went live, audio in video calls was excellent while video fell apart into artifacts and freezes. The difference between audio and video is packet size: an Opus frame is hundreds of bytes, a VP8 frame is tens of kilobytes. The receive UDP buffer was 2048 bytes. Small audio packets fit; large video frames were truncated, the AEAD failed, the frame was lost.

What actually fixed it:

Topology. Direct P2P when NAT allows, relay otherwise. Mobile carriers are almost universally CGNAT, so the relay is the workhorse and the direct channel wins on Wi-Fi. The path indicator (🔗 / 📡) is visible during the call alongside loss and bitrate.

On codecs and iOS, honestly. Codec negotiation in the offer already exists: VP8 by default (for desktop compatibility), H.264 in test mode on Android. What remains is teaching the desktop to receive H.264 (WebCodecs) and porting the native stack to iOS — where there is a hard limit: iOS has no hardware VP8, VideoToolbox does H.264/HEVC. The iOS client itself is in the state "code written, never verified on a device": building it needs a Mac with Xcode, and the audio module was written blind and will almost certainly need a round of fixes.

And honestly about the current mobile state: video calls are switched off again. A later regression took audio down along with video (most likely drawing into a dead EGL context after the SurfaceView was destroyed), so I pulled the button until the video path can be rebuilt with a guarantee that video cannot kill audio. Audio calls work.


The vault network: delivery when the direct path is cut

The situation: servers on domains S1 and S2 cannot see each other — one's IP is banned at the other. But both can see some third node. That is enough.

The vault network

The key decision: delivery by pull. Under filtering it is far more robust when both ends make outbound connections to a shared node. S1 drops the envelope into a vault, S2 fetches it. Neither needs inbound reachability from the other — only outbound reachability to the vault.

The vault knows nothing. The mailbox address is a token — HKDF(recipient homeserver public key, epoch) — not a domain. The node does not know whose mailbox it is; it sees an opaque, rotating identifier and ciphertext. Deduplication is content-addressed (msgId = hash(ciphertext)).

Who actually delivers. The envelope is placed on four nodes at once (RF=4). The recipient takes whichever copy answers first and broadcasts a signed ACK to every replica. A node that receives the ACK deletes the blob. The ACK is the single source of truth about delivery: a node returning from an outage asks the live ones "is there an ACK?" and, if so, simply drops its stale copy.

Self-healing, borrowed from Ceph. Here I took the proven RADOS model and mapped it onto vaults:

Ceph Prizrak vaults
CRUSH — deterministic placement with no central table Rendezvous hashing (HRW): anyone computes where a blob lives
Cluster map + epoch Signed node registry with an epoch, spread by gossip
size / min_size RF=4 / min_size=2
Placement Group A bucket of blobs; replicas compare Merkle roots per bucket
Primary OSD Primary vault — first live node in the deterministic order
Backfill / recovery Topping copies back up to RF when a node leaves
Peering on OSD return Merkle resync: drop what was delivered, shed extras, pull what is missing

An important adjustment for censorship: node-to-node links can be cut too. So the baseline guarantee is that the sender fans out to four nodes immediately (that path is known to work), self-healing is best-effort on top, and the backstop is the sender periodically re-fanning-out anything still unacknowledged.

Immutability makes life much simpler than in Ceph: blobs are write-once and deleted on ACK or TTL (7 days). No mutations means no versions, no strict write ordering, no quorums. Just "copy present / absent / acknowledged" plus anti-entropy.

Hardening. Length padding into buckets, proof-of-work admission against spam, anti-Sybil diversity by a signed operator-group label, epoch rotation, jitter. Plus private bridge nodes that never enter the public registry and are handed out by invitation — the Tor bridge model.

Tested. The vault network and federation over it are covered by 140 automated checks across node and server, including replication, self-healing, a node returning from an outage, and anti-enumeration of listings.


Discovery without "a list in the config"

A separate problem I had to solve: how do servers and nodes find each other when any static list is a gift to a censor? Publish a file listing your nodes and they all get blocked at once.

The principles:

  1. No static lists. The directory is live gossip state, not configuration.
  2. No single point. The directory is a set of signed objects that anyone can serve: another server, a vault, a mirror, a CDN, DNS. Blocking one domain means nothing.
  3. Kerckhoffs's principle. Assume the adversary knows the code and the entire public catalogue. Security comes from signatures (you cannot poison it), a multitude of entry points (you cannot block them all) and the stealth transport (traffic looks like ordinary HTTPS).
  4. One live contact is enough. Reach any node and you unfold the whole current catalogue. After that you never need a config again.

Cold-start channels, any single one of which suffices: live gossip from a known peer, baked-in rotatable seeds, DNS seeds over DoH (which routes around DNS tampering), a signed bootstrap bundle over a large CDN, peer exchange, and private bridges handed out by invitation.

On anti-enumeration: the registry is served in portions with rate limiting (BridgeDB style), and bridges are never gossiped publicly at all.

Honestly: cold start under a total ban is not solvable by magic — you need at least one out-of-band way to receive a bridge, exactly as with Tor. But the layers above mean that in practice very few users ever land in that case.


The built-in VPN: two hops and a protocol of my own

Users install a VPN separately anyway, so it makes sense to build one into the client — especially since all the stealth machinery already exists.

Prizrak VPN

The requirements were strict and, I think, correct:

The layers (each with its own tests):

Layer What it does
Shadow Encryption: Noise NK + X25519, ChaCha20-Poly1305, replay protection
Mask From outside the node is a real website with a door for insiders
Relay chain Two hops with onion-style layered encryption
Breath Traffic shaping: DATA/PAD/KEEP frames, keepalive, automatic profile
Flock Handing out node addresses without a public list
Health Liveness checked from inside the session, no pings; automatic node replacement
Economy Tickets, tariff, revenue sharing with operators

Three decisions I am happy with:

VPN screen in the client
Two switches: "Go invisible" (all device traffic through Prizrak) and "Run a ghost node" (become part of the network and earn). Below that, a list of countries with ratings.

The engine is covered by 182 tests in the VPN package. What remains before field readiness: a real TLS front with Let's Encrypt and a Chrome-shaped ClientHello fingerprint, real sockets between relay and exit instead of in-memory simulation, the tun↔engine packet pump, and testing on live carrier networks. I am not hiding that either: the engine and its layers are done and tested; the production plumbing is not.


Multi-device without one shared key

One account on three devices is a classic trap. The easy answer — "put the same key on every device" — destroys the security model: steal the tablet and the whole account is compromised with nothing to revoke.

Multi-device

How it works: two levels of keys. The account root is the existing OpenPGP key (it already lives in the backup and is restored by the seed phrase). Each device has its own X25519 identity and prekeys, signed by the root and published to a device registry.

The sender fetches the recipient's device list and encrypts the message separately for each device in its own ratchet session. On top of that, self-sync distributes your own outgoing messages and read marks to your other devices.

What it buys, and what it costs:

The limit is 10 devices per account; publishing an eleventh evicts the oldest. The device list and a revoke button live in settings.


Groups, channels and search

The goal here was mundane: parity with Telegram, because people will not use something that lacks what they are used to.

A registry for public group search. A separate service (tech.prizrak.im): servers publish only public rooms to it, records are signed by the homeserver's key, domains are accepted TOFU-style and cross-checked against .well-known, with a 60-requests-per-minute-per-IP rate limit, a 200-groups-per-domain cap and a 7-day TTL. The client queries the registry through its own server, so search works from behind the stealth transport too.


Bot API: like Telegram, only smaller

It installs on every homeserver. A bot is a real Prizrak account whose E2E keys live on the server, while the third-party developer works over plain HTTP with a token (if you expose that endpoint at all, put it behind your own TLS terminator):

POST http://<server>:8840/bot<TOKEN>/sendMessage
{"chat_id": "!roomid:example.org", "text": "Build is green ✅"}

The response envelope matches Telegram's: {"ok": true, "result": …} or {"ok": false, "error_code": …, "description": …}. The v0.1 methods are getMe, sendMessage and getUpdates (long-poll up to 30 seconds, acknowledged by offset).

Bots are created by PrizrakFather, a direct equivalent of BotFather that bootstraps itself on the service's first start. You DM it /newbot, answer two questions, get a token. There are also /mybots, /revoke and /deletebot.

The first target use case was down to earth: posting into channels from external scripts and websites. One end-to-end integration test drives the whole cycle: a real homeserver plus botapi plus a live user, a conversation with PrizrakFather, token revocation, group creation and posting into it through the API.


Economics: ghosts, and why money is centralized

The messenger has an internal currency — ghosts (👻): gifts, donations, reactions, VPN payments, rewards for node operators.

Which raises the question you should ask of any decentralized project with a currency: what stops the admin of some random server from crediting themselves a million? The code is open, after all.

What is decentralized and what is not

The honest, unromantic answer: balances cannot live on self-hosted servers. So the boundary is drawn like this:

Three guarantees:

  1. You cannot mint. The balance exists only in the Bank, which increases it exclusively after a confirmed payment — a webhook with HMAC signature verification, idempotent on the transaction reference. A rogue admin can only buy ghosts with real money, which is perfectly legal.
  2. You cannot steal. Every operation is signed with the user's separate Ed25519 "ghost key" and verified by the Bank. The admin of a homeserver does not have that private key. Replay protection: a ±5-minute timestamp and a single-use nonce.
  3. You cannot hijack a name. The key is bound to user:domain under TOFU: whoever registers first sets the key, and any later change requires a signature from the current one.

A fork can of course issue "its own ghosts" — but that is a different currency of a different bank. Open source does not undermine this: security rests on the Bank's secret payment keys and users' private keys, not on secrecy of the source.

Operator rewards. A vault node earns ghosts for uptime, for deliveries and for gigabyte-hours of storage. Deliveries are currently counted from the node's own report — a cryptographic delivery proof from the recipient is on the roadmap, and until then the network owner adjusts the numbers by hand. The network owner sets the rates in an admin panel, sees the operator table and pays out the accrued amount. Uptime is capped per sample so a disconnect cannot be billed, and payouts are idempotent.


What it is built from

Project map

A monorepo. The JavaScript core is reused by desktop and mobile; native code appears only where nothing else will do (media, the Android VPN service).

Run your own server:

cd packages/server
npm install
./deploy/prizrak-deploy.sh init --domain chat.example.org \
  --admin root --registration on --relay-url stealth://<IP>:8810
./deploy/prizrak-deploy.sh create-admin --password 'PASSWORD'
./deploy/prizrak-deploy.sh start

Run a vault node (any server or home machine with Node.js and a permanent connection will do):

cd packages/deaddrop
npm install
node src/node.js
# status: http://127.0.0.1:8820/status

Default ports: homeserver 8801, call relay 8810, rendezvous 8811/UDP, vault node 8820, group registry 8830, Bot API 8840 — plus 80/443 and the mail ports when TLS masquerading is on. UDP 8811 has to be opened in the firewall explicitly; without it the direct P2P path only forms inside a single Wi-Fi network.

Clients are on prizrak.im: an APK for Android, .exe for Windows, .dmg for macOS, an AppImage for Linux. Registration is a username and a password; the domain is filled in for you.

Download page

Among the small things that cost more than expected but without which a product isn't a product: an account backup file, a 17-word seed phrase with a checksum (a spare way in and a password reset), one-click auto-updates with Ed25519 signature verification, minimize-to-tray, an incoming-message sound (yes, the ICQ one), round video notes up to 60 seconds, voice messages with a waveform, and delivery/read ticks — where for voice and video notes the second tick only appears once the message has actually been played, as in Telegram.


What is missing, and where it is weak

The section that makes any security write-up worth reading.

None of this stops you from using the messenger today, but it is worth knowing.


A legal note

Circumvention tools protect free speech and privacy, but their legality differs by jurisdiction. The project is developed as open source for privacy and freedom of communication; complying with the law that applies to you is your responsibility.


Closing

Prizrak is an attempt at an honest decentralized messenger that refuses to compromise in three places at once: no center, no identity binding, no recognizable trace on the wire. Plus a built-in VPN, because all three problems lean on the same stealth transport and it would be odd not to reuse it.

The project is open; forks and independent servers are welcome — the more independent nodes exist, the more expensive the network is to block.

I would welcome a substantive teardown — especially from people who have worked on DPI, transports and group cryptography. Well-aimed criticism is more useful here than stars.

Try it

Clients for Android, Windows, macOS and Linux are on the download page. Sources are on GitHub under AGPL-3.0.