Skip to content

Group and channel encryption

Group and channel messages are encrypted with shared symmetric keys that advance through epochs: a custom HKDF-based key ratchet (not MLS) in which each epoch key is distributed to members over the existing pairwise Double Ratchet sessions.

Implementation: zentalk/src/services/e2ee/group-message.ts and channel-message.ts.

  • Each group has one AES-256 message key per epoch; all members of the current epoch share it.
  • The owner is the key authority: only the owner can derive the bootstrap key and initiate rotations.
  • Group key material is never sent to the server — it travels exclusively inside pairwise E2EE messages.
  • Removing a member triggers a rotation to a new epoch the removed member cannot derive.

The first epoch key is derived from the owner’s identity key seed, so a freshly created group needs no external state:

Epoch 1 (bootstrap)
epoch_1 = HKDF-SHA256(owner_identity_key_seed,
salt = "group:{groupId}:messages:v1",
info = "Zentalk Group Messages v1",
length = 32)

Only the owner can compute this; members receive epoch_1 via pairwise E2EE when they are added.

Every rotation mixes the previous epoch key with 32 bytes of fresh CSPRNG randomness:

Epoch N+1 (ratchet step)
epoch_{N+1} = HKDF-SHA256(epoch_N || freshRandom32,
salt = "group:{groupId}:messages:v{N+1}",
info = "zentalk-group-key-ratchet-v1",
length = 32)

The distinct info string domain-separates ratcheted epochs from the bootstrap derivation, so the two can never collide under the same input key material.

The fresh randomness — and the wipe that follows it — is what provides post-compromise security:

  • After rotating, the owner wipes the prior epoch key (and the intermediate HKDF input buffer). The owner’s long-term identity seed alone can no longer reproduce epoch_{N+1}.
  • An attacker who compromises the owner’s identity at time T therefore cannot derive epochs rotated before or after the compromise window without also capturing the pairwise distribution messages.
  • Receivers get the already-ratcheted epoch_{N+1} directly over pairwise E2EE; they do not re-derive it, so freshRandom never needs to survive beyond the rotation call and may be wiped immediately after distribution.

The v1 → v2 transition is the one-time exception: the owner re-derives epoch_1 deterministically as ratchet input, then wipes it, after which the chain is forward-independent of the identity seed.

Distributed key material has this shape:

Field Content
groupId Group identifier
key Base64 32-byte AES-256 epoch key
version Epoch number
createdAt Timestamp
freshRandom Base64 32-byte rotation randomness (v ≥ 2 only, optional)
  • Member added — owner exports the current epoch key and sends it in a pairwise E2EE message; the member stores it in IndexedDB (with a one-time migration path from legacy localStorage storage).
  • Member removed — owner calls the rotation, distributes the new epoch key pairwise to the remaining members, and bumps the stored version. The removed member holds only obsolete epochs.
  • Encrypting clients read the current version first and stamp it into every message, so recipients know which epoch key to use.

Group and channel payloads use AES-256-GCM with a fresh 12-byte IV per message:

Encrypted group/channel message
{
"ciphertext": "<base64>",
"nonce": "<base64, 12 bytes>",
"version": 2,
"encrypted": true
}

version is the epoch of the key used — not a format constant — so a v2 message can never be silently decrypted with a stale v1 key.

Channels reuse the same construction with three differences:

Aspect Groups Channels
Who sends All members Owner/admins only
Key derivation v1 deterministic bootstrap, then ratcheted epochs with fresh randomness Deterministic per version from the owner seed (formula below)
Rotation Ratchet step; owner wipes the prior epoch Version increment with deterministic re-derivation

Channel keys are derived deterministically for every version:

Channel key derivation
channel_key_v = HKDF-SHA256(owner_seed,
salt = "channel:{channelId}:messages:v{version}",
info = "Zentalk Channel Messages v1")

Because channel keys are always re-derivable from the owner’s seed, channels trade the group ratchet’s post-compromise security for operational simplicity — acceptable because only the owner side ever holds derivation capability, and subscribers still receive keys pairwise.

Channels additionally implement key-version mismatch recovery: decryption returns a structured status (no_key, decryption_error, key_version_mismatch) including the local and message key versions, and clients that detect a mismatch (e.g., a subscriber who was offline during a rotation) automatically issue a key request to the owner over pairwise E2EE. Duplicate requests are debounced per channel.

This is not MLS (RFC 9420). There is no TreeKEM: distribution cost is linear in group size (one pairwise message per member per rotation), and epoch confidentiality against removed members depends on the owner performing the rotation. In exchange, the construction is small, auditable, and reuses the pairwise Double Ratchet as its sole distribution channel.

  • Group membership and fanout patterns are visible to relays (see Security overview); sealed envelopes cover group fanout content, not membership.
  • Compromise of any current member reveals current-epoch messages until the next rotation — inherent to shared-key group encryption.