# Veiled architecture One page. How the pieces fit, why the server is a relay rather than a media server, and where the encrypted state actually lives. ## The shape of it ``` desktop app (Tauri, Rust core) browser client (TypeScript) | | | | wss 8443 | udp 8444 | wss 8443 | control | media | control + media v v v +-----------------------------------------------------------+ | veiled-server: one process, one static binary | | | | control plane media plane | | TLS + WebSocket + HTTP UDP relay (and the same | | CBOR control frames envelope inside the | | SQLite ciphertext store WebSocket for browsers) | | auth, roles, presence header read, bytes moved | +-----------------------------------------------------------+ ^ ^ ^ | | | | wss 8443 | udp 8444 | wss 8443 | control | media | control + media v v v desktop app browser client ``` ## Two planes, deliberately separate **The control plane** is TLS on `8443`: the WebSocket at `/ws`, `/health`, `/api/info` and the web client itself on the same listener. Everything here is small, reliable and ordered: the handshake, presence, channel and role changes, message relay, history requests, presence and WebRTC signalling for screen share. Messages are CBOR maps with integer keys and frames are hand-written from `docs/protocol.md`. **The media plane** is UDP on `8444`, and it is where the latency claim lives. A voice frame is a 28 byte header, an XChaCha20-Poly1305 ciphertext and a 16 byte tag, with ten milliseconds of Opus inside. The server parses the header, checks the binding, applies a per-user token bucket and a 64 entry replay window per sender, and forwards the datagram verbatim to every other bound member of the channel. It never decrypts, never decodes, never mixes, never transcodes and never stores. A browser cannot open a UDP socket, so a browser client sends and receives the identical envelope inside the same WebSocket, prefixed with one byte, `0x01`. The server relays between the two transports transparently. That is the whole reason the desktop app exists. ## Why a relay and not an SFU A selective forwarding unit is what hosted voice products use for group calls: it terminates each participant's media, decodes it, decides who should hear what, and re-encodes. That design buys mixing, server-side recording and per-listener quality decisions. It costs three things this project was not willing to pay: 1. **The server would hold keys.** To decode audio it needs the audio keys, which means the host can listen. The one property this product is built around is that the host cannot. 2. **Every frame gets work done to it.** Decode and re-encode per listener is CPU that scales with participants times listeners, and it is latency: an encode plus a jitter buffer, not just a forward. 3. **The floor moves.** A relay's added delay is a socket read and a socket write. Anything that decodes has a queue to drain and a decision to make, and both show up in the tail. The price is honest and worth stating: bandwidth grows with listeners, because each frame is forwarded once per listener rather than mixed once. Five people in a voice channel costs four upstream copies of roughly 40 kB/s, which is a rounding error on any domestic connection and a real cost on a large call. Server-side recording, transcription, mixing and moving one listener to a different bitrate are all things this design gives up, permanently, not pending. ## Why Rust, and why Tauri **Rust for the server** for two reasons that are both about latency. There is no garbage collector, so there is no pause at an arbitrary moment to add tens of milliseconds to a packet that was otherwise due this instant. And the toolchain produces a single static binary with SQLite compiled in, which is what makes "download it, run the wizard, open one port" true for a stranger with a Windows PC instead of a deployment project. **Tauri v2 for the desktop app** rather than Electron: the same web client in a native shell, about 10 MB of resident overhead instead of about 150 MB, and, critically, native UDP sockets and an Opus encoder without a browser's permission model in the way. The bridge between the two is deliberately tiny and carries bytes, not protocol: `docs/bridge.md` fixes it, and the rule there is that framing, crypto and routing stay in the shared TypeScript so the desktop and browser clients cannot drift apart. **No web framework in the client.** A chat client's DOM work is small, and a framework buys nothing here while costing bundle size and input latency. ## The latency budget From `docs/PLAN.md`, and unchanged since: | Path | Desktop (UDP) | Web (WebSocket media) | | --- | --- | --- | | Text message, LAN | ~2 to 5 ms round trip | ~5 to 10 ms | | Text message, 30 ms RTT internet | ~30 ms | ~35 to 60 ms | | Voice, end to end | RTT + ~25 ms (10 ms frame + 10 to 20 ms jitter buffer) | RTT + ~45 to 70 ms | | Screen share | RTT + 30 to 80 ms (WebRTC, hardware encode) | same | The comparison that matters: Discord voice on a hosted region is RTT + 40 to 90 ms with its SFU and jitter buffer. Veiled's voice path is 10 ms Opus frames forwarded verbatim, so the floor is lower than both when the server is close to the people talking, and worse than both when it is not. That is the trade: self-hosting buys proximity, and proximity is the only lever that beats an optimised codec pipeline. These are targets from the plan, not vendor figures. `tools/bench/` measures the real numbers: control-plane round trip p50/p95/p99, one-way media delay, arrival loss and effective loss after the replay window, each against a loopback echo baseline that runs the same client code with no TLS, no database and no password proof, so the difference between the two rows is the server. ## Where the encrypted state lives | State | Where it lives | Who can read it | | --- | --- | --- | | Identity keypair (Ed25519 + X25519) | The device. IndexedDB in a browser, the OS keyring or a 600-mode `identity.json` on desktop. | The user only | | Channel key, one per epoch | The device, as a 32 byte secret. | Members of the channel | | The wrapped copy of that key | On the server, as an opaque blob sealed to one member's X25519 public key. | Only that member; the server relays it blind | | Message bodies, voice and video payloads | In flight, and for history, on the server as ciphertext. | Holders of the channel key | | Message history as stored | Server: ciphertext plus signature, message id, sender id, channel id and timestamp. Nothing else. | Nobody can read the body. The metadata is visible. | | Decrypted history | The client's local cache. | The user only | | Join and admin passwords | Nowhere. The server keeps Argon2id verifier material it can check and cannot reverse. | Nobody, not even the host | | Server signing key | `identity.json` beside the database. | The host, which is the point: clients pin it | | Addresses, display names, channel names, membership, who spoke when | The server, in memory and in its database. | The host | The last row is the honest limit of the design, and `docs/crypto.md` section 6 and the privacy page say it in the same words: running the server means running the metadata. A low-latency relay has to know where to send packets, so it knows who is talking to whom and when. What it cannot know is what was said. ## What is deliberately absent - No server-side search, preview, notification text or moderation of content. The server cannot read any of it. - No federation between two Veiled servers, no mobile clients, no file attachments, no key backup beyond a manual encrypted export. Phase 2 in `docs/PLAN.md`, and listed rather than implied. - No traffic analysis defence, no cover traffic, no deniability, and no reproducible build verification. A host who edits the web client can serve it to browsers, which is why the desktop app and a locally built client exist. This version does not solve that, it avoids it.