Skip to content

Latest commit

 

History

History
597 lines (490 loc) · 31.2 KB

File metadata and controls

597 lines (490 loc) · 31.2 KB
  ▄██▀ ▀█  ▄██▀ █▄  ▀██  ██▀  ▄██▀ ▀█  ▄██▀ █▄   █▄   ▄█
 ▐▒▒▒     ▐▒▒▒  ▒▒▌  ▒▒  ▒▒  ▐▒▒▒     ▐▒▒▒  ▒▒▌  ▒▒▒▄▒▒▒
 ▐▒▒▒     ▐▒▒▒  ▒▒▌  ▒▒▌ ▒▒  ▐▒▒▒     ▐▒▒▒  ▒▒▌  ▒▒ ▀ ▒▒
  ▀██▄ ▄█  ▀██▄ █▀    ▀█▄▀    ▀██▄ ▄█  ▀██▄ █▀  ▄██▄ ▄██▄

XChaCha20 · ML-KEM-768 · Ed25519 · BLAKE3 · SPQR · E2EE · ephemeral · N-party

COVCOM Protocol Spec

Note

A technical overview of COVCOM: how the encryption works, how the server stays ignorant, and where the design makes deliberate tradeoffs.

Table of Contents


versioning

Important

This document describes the COVCOM wire protocol at version 0x03. Each protocol bump updates this callout to the version it documents. To read about an earlier version, read this file's history in git.

COVCOM carries its own wire-contract version, PROTOCOL_VERSION, a hand-bumped integer defined in lib/src/protocol.ts. That file is the single source of truth: clients and the server read the value from it instead of copying literals, so the version cannot drift between artifacts. It is deliberately separate from the leviathan-crypto version, because the covcom wire contract can break for reasons unrelated to the cipher, and the cipher can change without breaking covcom.

Alongside the integer, the v3 wire format is version-locked to leviathan-crypto's v3 signing API surface. All covcom ctx strings carry -v3 suffixes (covcom-identity-claim-v3, covcom-message-sig-v3). Future breaking changes in either project bump both in lockstep.

The version is negotiated at join. The client sends protocolVersion on create and join, and the server stamps serverVersion on room_created and joined. Either side rejects a missing or mismatched version with a version_mismatch error and returns to the start screen, so a skew is caught at join with a clear message rather than as a cryptic failure deeper in the handshake. The check is bidirectional: the newer side recognizes the skew, since an older peer either omits the field or sends a value it cannot match.

The version travels in plaintext as a compatibility gate. A hostile server can lie about it, which is exactly why the signed identity claims, not the version, carry the security guarantees.


the cipher

COVCOM encrypts every message with XChaCha20-Poly1305. That is the foundation. The post-quantum KEM, the double ratchet, and the epoch machinery are all built around a single purpose: getting a fresh, unique XChaCha20 key to the right people at the right time.

XChaCha20 is fast and audited. Every message is sealed under a fresh single-use key from the send chain, and the STREAM construction salts each seal with a random 128-bit nonce, so accidental key or nonce reuse cannot happen regardless of message volume. Poly1305 authenticates every ciphertext, so a garbled or tampered message fails authentication before any plaintext is produced. Each message key is used exactly once, then wiped.

File attachments stream. The file key comes from the same chain as regular message keys (one sealFileKey() step), and the file is encrypted with SealStream, leviathan's incremental STREAM construction, one bounded chunk at a time on the main thread. Each chunk is sent as its own broadcast frame: a file-begin frame carries the stream preamble and the filename/size/mime, then one file-chunk frame per chunk. The sender reads the file in slices and the receiver decrypts and reassembles chunk by chunk, so neither side ever holds the whole file encoded in memory and no frame approaches the relay's message-size limit. The STREAM construction binds chunk order and detects truncation; every frame carries its own detached signature. The file key never travels. The receiver re-derives it from the shared chain using the counter and epoch carried in file-begin, the same way it derives any message key. Only the preamble and that locator ride in the frame, never the key itself.

The transfer is flow controlled. The receiver acknowledges consumed chunks over the peer-to-peer relay channel, and the sender keeps only a bounded window of chunks in flight ahead of the slowest recipient. The relay forwards frames with no backpressure of its own, so this window is what keeps its per-connection buffer from overflowing and dropping a chunk. In a group the sender paces to the slowest receiver, so every participant reassembles the file intact.


the chain

Each participant owns one send chain. It is stateful and forward-secret, implemented as a KDFChain from the leviathan-crypto ratchet module.

Every call to step() runs HKDF-SHA-256 over the current chain key with a big-endian uint64 counter in the info bytes, producing a new chain key and a fresh 32-byte message key. The old chain key is wiped before the new one is stored. Calling step() on an already-disposed chain throws; the stateful design makes reuse structurally impossible.

This is the symmetric-key ratchet from the Double Ratchet spec (§2.2). Because message keys are not used to derive anything else, you can delete a key the moment you finish with it without affecting any other message's security. A device compromised after a conversation ends cannot reconstruct the XChaCha20 keys that protected it.

Out-of-order delivery is handled by SkippedKeyStore, which advances the chain past gaps while caching keys for messages that may still arrive. A ceiling on cached keys prevents a malicious sender from forcing unbounded storage growth (§2.6).


the ratchet

A chain that never refreshes only solves half the problem. If someone compromises a chain key today, they can decrypt every future message in that chain until someone catches on. The ratchet limits that window by periodically generating a new chain seed from fresh cryptographic material.

The Double Ratchet algorithm was originally published as the Axolotl Ratchet in 2013, named for the Mexican salamander famous for regenerating lost limbs. The name fits: compromise a key, the next ratchet step heals it. The rename to Double Ratchet happened before the first public spec release, but the regeneration metaphor stuck.

Classic Signal uses Diffie-Hellman for the ratchet step. Each party publishes a new ephemeral public key with every message, and the DH output mixes into the root key, refreshing the chain. Both parties can compute the shared output simultaneously, so classic DR ratchets on every exchange.

COVCOM uses ML-KEM-768 (Kyber, FIPS 203) instead. ML-KEM is a Key Encapsulation Mechanism: the encapsulator generates a fresh ciphertext and shared secret in one operation; the decapsulator recovers the same secret from the ciphertext. That shared secret feeds into root key derivation via HKDF-SHA-256 (KDF_SCKA_RK, §7.2), expanding to 96 bytes of output: a new root key, a new send chain key, and a new receive chain key, all derived in one pass.

The reason for 96 bytes rather than the classic 64 is that one KEM epoch spawns both the send chain and the receive chain simultaneously. In classic DR, a DH step spawns one new chain at a time because it requires two DH exchanges in alternation. ML-KEM is non-interactive on the encapsulator's side, so both chains can emerge from a single operation.

The asymmetry of ML-KEM changes how the ratchet behaves. In classic DH, both parties can advance independently from their own computation. With ML-KEM, the encapsulator goes first; the decapsulator cannot advance until the KEM ciphertext arrives in a message header. This is why the protocol is a Sparse Post-Quantum Ratchet (§5): you cannot ratchet on every single message because the KEM ciphertext for ML-KEM-768 is 1088 bytes, and that overhead on every message would be impractical.

Three events trigger a ratchet: a new participant joining the room, a user pressing the rotate button manually, and an automatic trigger after every 25 messages sent. At 25 messages the per-sender post-compromise security window is short at normal chat pace, and the cost is roughly one ML-KEM encap per peer (about 0.13 ms each, benchmarked in the sandbox). The overhead is invisible to the user.

When a ratchet fires, the ratchet_step wire message carries both the key material and the first encrypted message of the new epoch. Each peer gets their own kemCt, a shared encSeed encrypted with the KEM-derived symmetric key, and the sender's new ratchet public key. After a receiver decapsulates, their own ratchet keypair rotates and they broadcast the replacement via ek_update. The keypair that just decapsulated is gone.


group messaging

Per-pair ratchet channels require O(N²) state in a group. A 10-person room would mean 90 distinct channel states, each consuming memory and requiring coordination. The state grows quadratically with participants.

COVCOM uses the Sender Keys model instead (WhatsApp Security Whitepaper). Each participant maintains exactly one send chain. Everyone with access to your chain seed independently derives the same XChaCha20 message keys from the same chain state. The state is O(N): one chain per sender, not per pair.

Balbás et al. note (§V-C-3) that vanilla Sender Keys alone is not sufficient for post-compromise security: PCS only fires on member removal and re-add, leaving a passive adversary who compromised any member's state free to eavesdrop indefinitely until that event occurs. The KEM ratchet in COVCOM closes this gap directly. Join-triggered, manual, and auto-every-25 ratchets are all PCS boundaries that Sender Keys by itself does not provide.

Distributing the chain seed to N people securely is where the KEM work happens. When you ratchet, you generate one 32-byte shared seed, then KEM-encrypt it separately for each peer. Each peer gets a distinct kemCt that only they can decapsulate, but the same seed once they do. They call ratchetInit on that seed (KDF_SCKA_INIT, §5.4), which derives an initial root key, send chain key, and receive chain key in a single 96-byte HKDF expansion. From there, your send chain and their receive chain of you are synchronized without any further communication.

The room ID bytes travel as a context value through every HKDF call. Two sessions sharing the same chain seed in different rooms produce completely independent key material.

Epochs are per-sender and independent. Alice can be at epoch 3 while Bob is still at epoch 1. Neither triggers the other's ratchet. When a receiver processes an incoming ratchet step, they prune sender state older than three epochs back, keeping the current epoch plus two previous. This is one wider than the Signal spec's ClearOldEpochs (§5.7), which keeps only the current and previous; the extra window improves tolerance for messages that arrive slightly out of epoch order.


joining a room

A room is identified by a 16-byte ID, rendered as a 32-character hex string, and gated by a 16-byte roomSecret. The server generates both at creation time. Both fields are encoded in the invite: a binary blob serialized as a version byte, the room ID in its 32-byte hex form, the 16-byte room secret, and an optional DNS field, totalling 49 bytes minimum. The invite is armored into a PEM-like block for copy-paste sharing or saved as a .room file.

When you join, your client creates a fresh Session with a new keypair generated at that moment. You send identify, and every existing member immediately relays their current chain seed to you: a KEM-encrypted blob addressed to your new public key, carrying their current epoch number and the 32-byte seed for that epoch. You decrypt each blob, call ratchetInit on the seed, and set up a receive chain for each sender at whatever epoch they are actually at. If Alice is at epoch 3 and Bob is at epoch 1, you enter at exactly those positions, not at epoch 0.

Once you have received a seed from every current member, you fire the welcome ratchet. The joiner always initiates this step, not the existing members. The joiner is the only principal guaranteed to be present at join time, so no host election is needed and the protocol stays symmetric. Your epoch advances, and you broadcast your fresh chain credentials to the group. Existing members set up receive chains for you and can now decrypt your messages. You are in.

Messages sent while you were joining are not recoverable. You were not there; those XChaCha20 keys are gone. This is correct forward secrecy behavior.


session lifecycle

A client moves through four phases during its time in a room: landing, joining, waiting, and ready.

Landing. The entry screen where you create a room or paste an invite. No WebSocket exists yet.

Joining. The brief window between sending a create or join and receiving joined from the server. The WebSocket is open but the session has not been initialized; nothing arrives in this state except the handshake response or an error.

Waiting. The lobby. The session is initialized but no peers share the room with you. This happens in three situations: you just created the room, you joined a room that exists but has no active members, or every other participant has left. The lobby displays the invite code so others can join.

Ready. Active chat. At least one peer is in the room with you and the welcome ratchet has fired.

The novel transition in this lifecycle is ready → waiting. When the last peer leaves, you stay in the room and your chat history stays on screen. The client disposes the session, wiping every key, and generates a fresh keypair. It then sends rekey to the server, carrying the new encryption and ratchet public keys. The server confirms via rekeyed, silently updating its connection record. No peer_joined broadcast fires; from any other observer's perspective nothing happened. When the next peer arrives, the normal handshake runs against the fresh keypair.

The rekey message is permitted only on a connection that has already identified. Authentication is implicit. The server checks that the connection has a username on file. An attacker cannot rekey as Alice without controlling Alice's open TCP connection, and once Alice disconnects, the connection is closed before any rekey could arrive.

A real disconnect is different. Connection drops, sleep events, and network switches close the WebSocket entirely. The server's handleClose runs and tears down the connection record. The client polls /health_check until the server responds 200, opens a new WebSocket, and sends join with the saved roomId and roomSecret. Identify runs with a fresh keypair. The welcome ratchet fires. Chat history already rendered remains on screen across the reconnect; nothing was persisted, so closing the tab still erases everything. Messages sent while you were offline are unrecoverable, which is correct forward secrecy behavior. If your username was claimed by someone else while you were offline, identify returns username_taken and you choose a new name.

Simultaneous joins are sequenced by the server. WebSocket over TCP guarantees every existing member sees peer_joined events in the same order, and the ratchet path on every client is synchronous, with no await between receiving peer_joined and finishing the corresponding ratchet step. A second join cannot interleave with the first, which keeps epoch state consistent regardless of how close two joins land in time.

The room creator is not a privileged principal. After receiving room_created, the creator sends join exactly like any other client and walks the same handshake. From the moment they identify, they appear in joined.members for the next joiner. There is no host, no session owner, and no special-case wire flow for creators.


the server

The server is a WebSocket message relay built with Bun. It is intentionally as ignorant as possible.

It knows: room IDs, usernames (first-come-first-served per room), public encapsulation keys and ratchet public keys per connection, and which connections are in which rooms. It sees encrypted blobs. It routes them. That is all it does.

Usernames are otherwise unconstrained in character set, with one hardening exception. On identify the server rejects (closes) any username containing C0/C1 control characters, DEL, or the bidirectional and zero-width format characters that enable display-name spoofing (the Arabic letter mark, LRM, and RLM, the LRE/RLE/PDF/LRO/RLO and isolate controls, zero-width space, word joiner, and the BOM). Control bytes are never legitimate in a display name and would be an escape-injection vector in the CLI's terminal renderer; the format characters reorder text or build homoglyph handles. The server rejects rather than sanitizes, because the username is bound by the signed identity claim, and silently altering it would break peer verification. ZWNJ, ZWJ, and variation selectors stay allowed because they are legitimate in emoji and in Persian, Arabic, and Indic text.

The server does not decrypt anything, store messages, or participate in key exchange. When it receives a relay message addressed to a peer, it forwards the ciphertext to that peer and nothing else. When it receives a broadcast, it fans the ciphertext out to every other connection in the room. It cannot read either. A relay payload carries a one-byte client tag that distinguishes a chain seed from a file-transfer acknowledgement; the server forwards the payload opaquely and never inspects the tag.

Rooms persist in memory until the TTL cron runs, defaulting to 24 hours of inactivity. This is not the server keeping a record of conversations; it is the server staying alive long enough for participants to reconnect after a dropped connection. When the last connection drops and the idle clock runs out, the room is deleted.

Room creation is optionally gated by an adminToken set via environment variable. This is a server-side secret that never appears in the invite or the client bundle. It controls who can create rooms; joining is controlled entirely by the roomSecret embedded in the invite, which the server validates on every join message.

When a client disconnects, the server broadcasts peer_left and removes the connection. The remaining members remove that sender's state. The client side of the reconnect flow is covered in session lifecycle.


the clients

COVCOM ships two clients and a Docker image.

The web client is a Vite-built TypeScript application with no UI framework. State lives in a single in-memory store keyed by screen (landing, joining, waiting, ready) plus chat-history and sidebar UI flags. A shell module mounts the matching view and latches an "ever ready" flag so the chat view stays mounted across the ready and post-handshake lobby states; the input bar swaps to an armored-invite block when the room is back in the lobby, and the chat history stays on screen across the transition.

The header bar holds three buttons. The fingerprint badge is tinted with the local ambient color and opens a sidebar panel listing the swatch row and hex for you and every peer side by side. The log button opens a per-session wire-event sidebar that captures every inbound and outbound WebSocket frame with redacted payloads and expandable detail rows. The visibility toggle hides peer joins, peer departures, and dropped-message notices when the chat history gets noisy. Key rotation rows are always visible, regardless of who triggered them.

The waiting view shows the armored invite as text, a QR code rendering of the same bytes, copy and download buttons, and a small table summarizing the active cipher suite. File attachments arrive via a drop overlay that covers the entire page during an active drag.

The CLI is a compiled Bun binary with a custom terminal UI built from scratch using only process.stdin, process.stdout, and ANSI escape sequences. No external dependencies. It targets the ANSI 16-color palette to work correctly across tmux sessions, SSH connections, and common terminal emulators without palette remapping artifacts. Per-slot color overrides are available in ~/.config/covcom/config.json, supporting ANSI16, 256-color indices, or truecolor hex values.

The lobby screen in the CLI shows a table of the active cipher suite: cipher, KEM, and format version. This is the only place in the entire codebase that uses box-drawing characters.

The Docker image is two stages: a Bun build stage for the web client and server, then Caddy 2 Alpine as a reverse proxy with the Bun server behind it. There are no build arguments. All configuration is runtime environment variables.


identity claims

Every session mints a fresh Ed25519 signing keypair on construction. The public key publishes once in the initial identify, wrapped in a signed claim that binds it to the username and the session's ML-KEM ratchet ek. Every later structural event (ratchet step, ek update) ships a signed continuation claim; every broadcast carries a detached signature over its ciphertext.

The signing keypair is ephemeral. It lives in the same memory as the chain seed and is wiped in dispose(). Nothing reaches disk. Compromise across sessions is impossible because there is nothing to persist.

Claim payload. The signed bytes bind the session public key, the current ratchet ek, the username, a 16-byte session id derived from the room id, the local epoch, a monotonic sequence number, a millisecond timestamp, and the BLAKE3 hash of the previous claim's payload (or 32 zeros for the first claim). The wire envelope follows leviathan-crypto's v3 attached form, signed with Ed25519PreHashSuite (formatEnum 0x11) under the ctx string covcom-identity-claim-v3. See CRYPTOGRAPHY.md for the byte layout.

Verification. The first claim a receiver sees from a peer self-attests. The receiver extracts the session pk from the payload and uses it to verify the signature, then records the pk as that peer's identity for the rest of the session, anchoring continuity at whatever sequence number that first claim carried. Anchoring trust-on-first-sight matters because a late joiner reaches an established room after the existing members have already ratcheted; their current claim is well past sequence zero. Every later claim from the same peer must carry the same session pk, must increment the sequence number by one from the observed baseline, and must reference the previous claim's BLAKE3 hash as prevLogRoot. A break in either chain rejects the claim and surfaces a system message to the user.

Per-message signatures. Every broadcast carries a detached signature over counter || epoch || sender || ts || ciphertext under the ctx covcom-message-sig-v3. The receiver verifies before decrypting. A tampered or reattributed message fails signature check before any AEAD work runs.

Per-sender Merkle log. Each client maintains a SHA-256 Merkle tree per other sender for the duration of the session. Leaves are the canonical claim-payload bytes. The tree provides inclusion proofs for specific events and exercises the leviathan-crypto v3 Merkle substrate. Per-message signatures stay outside the log, which keeps log growth bounded by structural-event count rather than message count.

Session fingerprint. The session signing public key drives two surfaces. Both derive deterministically from BLAKE3.hash(sessionPk, 16). The full 16 bytes split into eight chunks of two bytes; each chunk maps through an OKLCh perceptual remap to a sRGB hex color. The row of eight colors is the out-of-band verification surface, with a 128-bit second-preimage budget. The first two bytes form a single OKLCh color rendered as the user's ambient badge. The web client tints the fingerprint button in the header with the ambient color; clicking it opens a Verify sidebar panel that lists the swatch row and hex for you and every peer side by side. The CLI opens an equivalent Verify pane in its sidebar with the keys-display (Esc then V) or /verify. Users compare their colors with peers out-of-band. A mismatch means one of you is looking at a different session than the other thinks.

What this layer does not defend against. The server cannot inject a forged peer_joined for an existing peer mid-session, because every later claim would fail the chain continuity check. The server can, in principle, swap the very first identify claim seen by a fresh joiner, because the joiner has no prior session pk for that peer. Out-of-band fingerprint comparison is the only mitigation against first-contact substitution, which is why the color row exists.


security properties

Forward secrecy. Every message uses a unique XChaCha20 key derived by stepping the send chain forward. The chain key is overwritten immediately; the message key is wiped after use. A device compromised after a conversation cannot reconstruct the keys that protected it.

Post-compromise security. PCS recovers at ratchet boundaries. Between ratchets, a compromised device can decrypt messages from the current epoch until the next ratchet fires. The auto-ratchet interval bounds this window to roughly 25 messages per sender under normal conditions. Sender Keys alone does not provide this property (Balbás et al., §V-C-3); the KEM ratchet is what supplies it. The Signal spec notes (§8.9) that dropped messages can slow ratchet progress in sparse PQ ratcheting, because KEM advancement is causal; this is an accepted tradeoff of the SPQR design.

Harvest-now, decrypt-later resistance. Classical DH ratchet healing cannot protect recorded ciphertexts against a future quantum computer. ML-KEM-768 is a FIPS 203 lattice-based KEM standardized for post-quantum resistance. Ciphertexts captured today remain opaque to a quantum adversary (§8.11).

Enumeration resistance. roomSecret is 16 server-generated random bytes, a 2^128 space. You cannot guess a room to join; you need an invite.

Zero persistent identity. No accounts, no registration, no retained history. Usernames are per-room and first-come-first-served. The session signing key, the ML-KEM keypair, and every chain key all live in memory for the lifetime of one session and are wiped on disposal. The server never sees anything that persists across sessions. Signal's X3DH handshake requires long-term identity keys that the server observes during initial key agreement (Johansen et al.); COVCOM has no equivalent. There is nothing to correlate across sessions.

Within-session identity binding. Identity claims sign the session's public state (ratchet ek, username) with the session signing key, so the server cannot reattribute messages or substitute keys mid-session without breaking the chain. See identity claims.

Per-message provenance. Every broadcast carries a detached signature under the session signing key, verified before decryption. Forged or reattributed messages fail signature check before any AEAD work runs.

Tamper-evident transcript. Each client builds a SHA-256 Merkle log of the structural events it observes from every other sender. Split views, where the server feeds different participants different orderings, are detectable by comparing fingerprints out-of-band.


honest limitations

No header encryption. Ratchet public keys and epoch numbers in ratchet_step and ek_update messages are visible to the server. An observer can track when participants rotate and at what epoch, even without reading message content. §4 of the Double Ratchet spec describes the header encryption variant that conceals this metadata; it is not implemented here.

First-contact MITM is undefendable in-band. A malicious server can swap the very first identify claim a fresh joiner sees for a given peer. The joiner has no prior session pk to verify against, so the substitute pk verifies its own claim. Every subsequent claim must chain off this forged baseline, so mid-session substitution still fails, but the initial impression is the attacker's. Out-of-band fingerprint comparison is the only mitigation; the color row exists for this purpose.

Username squatting on reconnect. The session model has no persistent identity. If you disconnect and someone else claims your username before you reconnect, you rejoin as a stranger with a new name. This is a known limitation of the first-come-first-served model and is acceptable for ephemeral use.



Cross Reference

Document Description
index Project Documentation index
USAGE Client and server applications development and runtime help
CRYPTOGRAPHY Primitives, KDF chains, wire format, invite encoding
THREAT-MODEL Principals, adversary tiers, guarantees, non-goals
LIB-SPEC Shared library API, session and identity surface, invites, file transfer, and protocol manifest
SERVER-SPEC Server wire contract, message handlers, room lifecycle, and configuration
WEB-SPEC Web client architecture, state and session model, views, rendering, and the single-file build
CLI-SPEC CLI architecture, rendering, input, widgets, views, and color system
TESTING Test layers, unit and end-to-end suites, cross-client interop, and CI