fuck-eu-chat-control docs

Security, in plain language

This is the user-facing security page. It says what the app actually protects, what it doesn't, and the one thing you have to do yourself for any of it to mean anything. If you want the formal version with byte layouts and closure decisions, that's the threat model.

The short version

Your messages are encrypted end-to-end with keys that live in your browser. The server that helps you and the other person connect — we call it the broker — cannot read them. It also doesn't keep them: once your direct connection opens, the broker is out of the path entirely. There's no account, no phone number, no message log stored anywhere, and nothing to log into.

That said, this is v1, and v1 is honest about its limits. Three things worth knowing up front:

  • You have to verify the safety number. If you don't, a well-positioned attacker could quietly substitute their own keys during the handshake and neither of you would notice. This is the one residual MITM risk and there is no app magic that fixes it.
  • P2P means your peer sees your IP address. WebRTC peers learn each other's network candidates. The broker doesn't, but the person you're talking to does. If you need to hide your IP from the other party, use a VPN or Tor browser.
  • Symmetric NATs don't connect. If you're behind one of those (roughly 10-20% of home networks, common on carrier-grade NAT), a direct WebRTC channel often won't establish. There is no TURN relay in v1 to fall back on. The connection just fails.

What's protected

  • The content of your messages against the broker. It sees signaling metadata and encrypted handshake bytes over DTLS-SRTP. It cannot read your traffic.
  • Against network observers between peers. They see DTLS-SRTP encrypted data and TLS/WSS encrypted signaling.
  • Against someone who copies your local message database without your browser's key material. The database is wrapped with an auto-generated at-rest key tied to the browser origin. A bare DB file is ciphertext.
  • Forward secrecy across sessions. Every connection — first contact or resumption — uses fresh ephemeral ECDH P-256 keys run through HKDF-SHA256. A key compromise of one session does not retroactively decrypt past sessions.
  • Against future compromise of the broker's logs, simply because there are no application logs to compromise.

What isn't protected

  • An active man-in-the-middle on the broker, if you do not compare the safety number. This is the big one. Without out-of-band verification, the channel is vulnerable and the app cannot detect it. There is no magic expected value to compare against; the only ground truth is what the two of you say to each other through a different channel (in person, a phone call, a different messenger).
  • Your own device being compromised. Malware, a hostile browser extension, a keylogger, anyone who can read your unlocked browser profile — none of this is something in-browser crypto can defend against. A passphrase raises the bar against an offline copy of your profile but does nothing against an online attacker on a live device.
  • Peer IP disclosure. The person you're talking to learns your IP address through WebRTC ICE candidates. The deployment's STUN listener sees your source IP for long enough to return a NAT mapping and discards it; it never sees a room ID or any content.
  • Traffic analysis. A global passive adversary watching the timing and size of packets can learn things. Padding and traffic shaping are future work, not in v1.
  • Availability. Anyone who gets the conversation ID (which is in the invitation link) can occupy the two broker slots, drop messages, or kill the handshake. Verifying the safety number authenticates who you're talking to; it does not guarantee the connection works.
  • A malicious build. If someone serves you a tampered version of this app, no amount of cryptography helps you. Source inspection doesn't prove the deployed bytes match the source. If your threat model includes the operator, self-host or use reproducibly-built artifacts.
  • Losing your at-rest key (e.g. by clearing browser site data). Persisted ciphertext becomes unrecoverable. Export/import is the backup mechanism — use it if you care about the history.

How to actually verify a conversation

After the handshake completes, both peers see a per-conversation safety number: a short string of grouped digits derived from the conversation ID and both identity public keys. Both sides display the same number for the same conversation. Until you mark it compared, the UI labels it “unverified” — loudly, on purpose.

To verify, compare the number through a channel the attacker can't control. Read it out loud in person. Call the person on the phone (a phone call works — an attacker who controls the broker does not automatically control the phone network). Use a different messenger you already trust. Do not paste it back into the same chat — that defeats the entire point.

If the numbers match, you have authenticated the channel against an active MITM. If they don't, stop. Tear the conversation down and start over through a different path. Mismatched safety numbers mean someone is in the middle, or one of you has the wrong identity key — either way, the channel is not private.

No accounts, no server state

There is no signup. No phone number, no email, no username. Your identity is an ECDSA P-256 keypair generated locally and stored in browser origin storage (scoped to this site). It is never sent to the broker or any server.

The broker holds no persistent state, no presence table, and no message log. The two peers connect, exchange handshake bytes, and disconnect. Once the data channel is open, the broker is out of the path entirely. If the broker process is killed mid-conversation, your existing chat keeps working — it just can't help new conversations rendezvous until it's back.

“Never leaves the device” means never sent to a network service. The one intentional exception is export/import: an explicit user action that writes your identity (and optionally history) to a file, encrypted under a passphrase you choose via Argon2id. That file is yours to move between browsers. If you don't export, you can't move the identity.

STUN-only, and the NAT limit

WebRTC needs ICE candidates to set up a direct connection. For peers on loopback or the same LAN, the browser's host candidates are enough and no STUN server is needed at all. For peers across the open internet, the deployment runs a STUN listener on UDP 3478 so each side can learn its own server-reflexive (public) address. That's the only thing STUN does here: tell you what your NAT mapping looks like.

What STUN doesn't do is relay traffic. So if you sit behind a symmetric NAT — where every new destination gets a different source port — the two peers can't learn each other's mapped address in advance, and a direct connection generally fails. Carrier-grade NAT (the kind that ships with LTE/5G home routers and many ISPs) is the common case. Roughly 10-20% of home networks fall into this bucket, and v1 has no answer for them. The connection will not establish and you'll see an error in the UI. A future TURN-bearing release could fix this; v1 does not.

The crypto underneath

If you want to audit the cryptographic choices, they're written down once, in one place. The v1 dependency set is:

  • @noble/curves for P-256 ECDSA identity keys and ephemeral ECDH session keys. Public keys encoded as raw uncompressed SEC1 (65 bytes); signatures as IEEE-P1363r || s (64 bytes, not DER).
  • Native WebCrypto for AES-256-GCM transport AEAD and at-rest encryption, HKDF-SHA256 for the key schedule, SHA-256 for transcript hashing and the safety number.
  • hash-wasm for Argon2id, used only to wrap the at-rest key under an optional passphrase and to wrap the export/import bundle. Memory 64 MiB, 3 iterations, parallelism 1. Runs in a Web Worker so it doesn't block the UI.

PAKE (SPAKE2, RFC 9383) is mandatory when an invitation link contains a ~code; it authenticates the key exchange against a malicious broker. The shared 6-digit code is fed to the RustCrypto spake2 implementation (compiled to WebAssembly) and never crosses the wire — only the derived SPAKE2 shares do, and they are mixed into the session's HKDF chain alongside the ECDH secret. A peer offering safety-number-only against a ~code invitation is rejected at transcript binding. The 6-digit code carries roughly 20 bits of entropy, so it should be delivered out-of-band (read aloud, not in the same channel as the link) and is no substitute for comparing the safety number once connected. The full rationale is in the threat model and the protocol spec.