Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.7331.org/llms.txt

Use this file to discover all available pages before exploring further.

Portal chat is E2EE. Messages are encrypted client-side with nacl.secretbox (XSalsa20-Poly1305) and the server stores — and emits — only opaque ciphertext. This page describes the v2 protocol.

Message envelope

Every chat message on the wire is a CiphertextV2 bundle:
{
  "v": 2,
  "epoch": 17,
  "ct": "base64-ciphertext-including-nonce"
}
  • v — protocol version (always 2)
  • epoch — integer rotation counter for the chat
  • ctsecretbox(plaintext, nonce, room_key) with the 24-byte nonce prepended
Servers reject messages whose epoch doesn’t match the chat’s current epoch (ERR_EPOCH_MISMATCH) and messages from senders who never received a chat key for that epoch (ERR_NO_ROOM_KEY).

Chat keys

Each chat has a symmetric chat key — 32 random bytes generated client-side by the first member and fanned out to every joiner. Fan-out uses X25519 public-key encryption: each user registers an ephemeral (pub, priv) keypair at POST /v1/portal/keys on first portal load. When a new user joins a chat, the key distributor encrypts the chat key with the joiner’s X25519 public key. The server stores the resulting blob at:
portal:e2ee:chatkey:{chat_id}:{epoch}:{user_id}:{session_id}
…and nothing else — it never sees the plaintext chat key.

Epoch rotation

The epoch is a Redis-backed counter (INCR per chat) that rotates — invalidating every previous key blob — on four triggers:
TriggerWhy
WipeUser-requested history purge; new conversation must be unreadable to past members
ClearModerator/owner clears chat; same rationale
KickKicked user must not be able to read future messages, even with cached ciphertext
Joiner on history_visible=falseNew member shouldn’t inherit readable history of a closed-history chat
When the epoch rotates, the server:
  1. Deletes all old key blobs for the old epoch
  2. Emits room:epoch_changed to every member
  3. One existing member (the “distributor”) generates a fresh chat key and re-fans-out to everyone at the new epoch
Old ciphertext in Redis (recent history) remains but is now unreadable — no one has the old key anymore.

What the server cannot do

  • Read message plaintext
  • Reconstruct chat keys from key blobs (no private keys stored server-side)
  • Silently add a member — a key distribution event is visible to all clients
  • Replay messages under a different epoch — the version check rejects them

What the server still can do

  • See who sent what, when, and to which chat
  • See message lengths (up to padding)
  • Stop delivery or kick users
  • Freeze history (rotate the epoch without re-fanning)
E2EE is a confidentiality guarantee, not a metadata guarantee.