E2E Group Calls

This article describes the end-to-end encryption used for Telegram group voice and video calls, incorporating a blockchain for state management and enhanced security.

Related Articles


Overview

Telegram end-to-end encrypted group calls generally rely on 3 components to manage communication securely among multiple participants:

  1. Blockchain: A decentralized ledger shared among all participants. It acts as the source of truth for the call's state, including participant lists, permissions, and shared encryption keys. Its hash is needed to generate verification codes.
  2. Encryption Protocol: A protocol optimized for real-time communication, encrypting audio and video data at the frame level. It includes mechanisms for packet signing to verify authorship and secure distribution of shared keys.
  3. Emoji Verification Protocol: A two-phase commit-reveal scheme used to generate verification emojis based on the blockchain state combined with participant-generated randomness. This prevents manipulation by any single participant, including block creators, ensuring trustworthy visual key verification.

This document details the technical implementation of these components.

High-Level Workflow

Below follows a high-level workflow for working with group calls. As mentioned, the blockchain underpins the core operations of joining, leaving, and maintaining a consistent state within a group call.

Joining a Call

  1. Fetch State: A user wishing to join requests the latest blockchain block (representing the current call state) from the server.
  2. Create Join Block: The user constructs a new block proposal. This block:
    • References the previous block's hash.
    • Includes a ChangeSetGroupState change adding the user to the participant list.
    • Includes a ChangeSetSharedKey change establishing a new shared key, encrypted for all participants (including the joining user). The joining user must be listed as a participant in the group state change within the same block to be able to create the shared key.
  3. Submit Block: The user sends this proposed block to the server.
  4. Server Validation & Broadcast: The server validates the block (ensuring it only adds the joining user, follows sequence rules, etc.).
    • If the block is valid and no conflicting block for the same height has already been accepted, the server applies it and broadcasts the new block to all current participants.
    • If the block is invalid or a conflict exists (e.g., another user joined simultaneously, resulting in a block at the same height), the operation fails, and the user may need to retry starting from the latest block.
  5. Client Update: Eventually, all participants receive the new block from the server and start using the new shared key.

Removing a Participant

  1. Initiation: Any active participant with the necessary permissions (remove_users flag) can initiate the removal of another (e.g., inactive) participant.
  2. Create Removal Block: The initiating participant creates a block proposal containing:
    • A ChangeSetGroupState change removing the target participant.
    • A subsequent ChangeSetSharedKey change establishing a new key encrypted only for the remaining participants.
  3. Submit & Broadcast: Similar to joining, the block is submitted to the server, validated, and broadcast to the remaining participants upon success.

Note: Self-removal is not supported via this mechanism, as a participant cannot create a block that removes themselves while simultaneously generating a new key for the others.

Security Considerations

  • Clients must only apply blocks received from the server, even those they proposed themselves. The server enforces block ordering and prevents forks.
  • All participants must verify that they see the same verification emojis, which are derived from the blockchain state using the commit-reveal protocol detailed later.
  • If the server were to deliver different valid blocks to different participants (a fork), their blockchain hashes, and consequently their verification emojis, would permanently diverge. The reliance on the server prevents this under normal operation.

Blockchain State Management

A dedicated blockchain provides a distributed, verifiable, and synchronized history of the group call's state.

Block Structure

Blocks form the chain, linking sequentially to maintain history. The structure is defined as follows (based on e2e_api.tl):

e2e.chain.block flags:# signature:int512 prev_block_hash:int256 changes:vector<e2e.chain.Change> height:int state_proof:e2e.chain.StateProof signature_public_key:flags.0?int256 = e2e.chain.Block;

Namely:

  • signature: A cryptographic signature verifying the block's authenticity.
  • prev_block_hash: The SHA256 hash of the preceding block, forming the chain link.
  • changes: A list of state modifications applied by this block.
  • height: The sequential number of the block in the chain.
  • state_proof: Cryptographic proof (including group state hash, shared key info hash) representing the blockchain state after this block is applied.
  • signature_public_key: The public key of the participant who created and signed the block.

Blockchain State

e2e.chain.stateProof flags:# kv_hash:int256 group_state:flags.0?e2e.chain.GroupState shared_key:flags.1?e2e.chain.SharedKey = e2e.chain.StateProof;

Blockchain states consist of:

  1. Group State: List of group participants and their permissions.
  2. Shared Key: Shared group key encrypted for each group participant.
  3. Key Value Storage: This is out of scope of the current document.

Signature and Hash Generation

  • Signature: Calculated over the TL-serialized block with the signature field itself zeroed out. The specific serialization format follows standard TL rules.
  • Block Hash: The SHA256 hash of the complete TL-serialized block.

Change Types for Group Calls

Blocks contain changes that modify the blockchain state. The types used in group calls are:

  1. ChangeSetGroupState: Modifies the list of participants and their permissions. This action clears the current shared key, requiring a subsequent ChangeSetSharedKey in a later block if encryption is needed.
    e2e.chain.groupParticipant user_id:long public_key:int256 flags:# add_users:flags.0?true remove_users:flags.1?true version:int = e2e.chain.GroupParticipant;
    e2e.chain.groupState participants:vector<e2e.chain.GroupParticipant> = e2e.chain.GroupState;
    e2e.chain.changeSetGroupState group_state:e2e.chain.GroupState = e2e.chain.Change;
  2. ChangeSetSharedKey: Establishes a new shared encryption key, encrypted individually for each listed participant.
    e2e.chain.sharedKey ek:int256 encrypted_shared_key:string dest_user_id:vector<long> dest_header:vector<bytes> = e2e.chain.SharedKey;
    e2e.chain.changeSetSharedKey shared_key:e2e.chain.SharedKey = e2e.chain.Change;
  3. ChangeNoop: A no-operation change, potentially used for hash randomization. Must be present in the initial “zero block”.
    e2e.chain.changeNoop random:int256 = e2e.chain.Change;

Participants and Permissions

Participants are defined by their user_id, public_key, and associated permissions within the GroupState:

e2e.chain.groupParticipant user_id:long public_key:int256 flags:# add_users:flags.0?true remove_users:flags.1?true version:int = e2e.chain.GroupParticipant;
  • add_users: Permission to add new participants.
  • remove_users: Permission to remove existing participants.

Note: For improved user experience, any person can currently join a call with server permission, without requiring explicit confirmation from existing participants. While the blockchain supports an explicit confirmation mode, we currently use external_permissions in the blockchain state to allow self-addition to groups.

Block Application Process

Blocks must be applied atomically (all changes succeed or none do) and sequentially. The validation process is as follows:

  1. Height Check: The block's height must be exactly current_height + 1. If not, the block is invalid. It is currently impossible to apply a block with height larger than 2^31-1.
  2. Previous Hash Check: The block's prev_block_hash must match the hash of the last applied block. If not, the block is invalid.
  3. Permission Check (Initial): Determine the permissions of the block creator (identified by signature_public_key). Permissions are sourced from the previous block's state or external_permissions if the creator wasn't already a participant.
  4. Signature Verification: Verify the block's signature using the creator's public key. If invalid, the block is rejected.
  5. Apply Changes Sequentially: Iterate through the changes vector:
    • Verify the creator has sufficient permissions for the specific change, using their current permissions (which might have been updated by a previous change within the same block). If permissions are insufficient, the entire block is invalid.
    • Apply the change to the state (updating the group state or shared key info). If the change itself is malformed or invalid (e.g., invalid participant data), the entire block is invalid.
  6. State Proof Validation: After applying all changes, verify that the resulting state hashes (for group state, shared key state) match the information provided in the block's state_proof. If not, the block is invalid.

The blockchain starts with a conceptual “genesis” block at height: -1 with a hash of UInt256(0) and effective self_join_permissions allowing the very first participant action.

Note: For optimization purposes, the signature_public_key can be omitted if it matches the first participant's key in the group state. Similarly, state proof components (group_state, shared_key) can sometimes be omitted if corresponding Set* changes are present in the block.

Applying Specific Changes

  • Participant Management (ChangeSetGroupState):
    • Requires the add_users permission to add participants. Added users receive permissions that are a non-strict subset of the creator's permissions (with an exception allowing granting permissions to others).
    • Requires the remove_users permission to remove participants.
    • Participant user_id and public_key must be unique.
    • This change always clears the existing shared key state. A new key must be set in a subsequent block if needed.
  • Shared Key Updates (ChangeSetSharedKey):
    • Can only be initiated by an existing participant.
    • Cannot overwrite an existing key directly; requires a ChangeSetGroupState (which clears the key) first, followed by a new ChangeSetSharedKey in a subsequent block.
    • The dest_user_id list in the SharedKey structure must exactly match the current list of participants in the group state.
    • The block creator must be included as a participant when setting a new key.

Note: Participants cannot remove themselves via ChangeSetGroupState, as this would require generating a new shared key for the remaining members, which they couldn't do after removal. Active participants should remove inactive ones.

Implementation Notes
  • Serialization: Blocks and their contents are serialized using the standard Telegram TL serialization methods before signing or hashing.
  • Concurrency: If multiple valid blocks for the same height are created concurrently, only the first one to be successfully applied will be appended. Subsequent blocks for that height will be rejected by participants due to the height mismatch, preventing forks and ensuring a linear history.
  • Validation: Clients must only apply blocks received from the server (even blocks they created themselves). The server performs validation and ordering to prevent forks and ensure consistency. Clients should retry sending created blocks/broadcasts until acknowledged (success or error) by the server.

Encryption Protocol

The following protocol encrypts call data (audio/video frames) and manages shared keys securely.

Core Primitives

The encryption relies on the following primitive functions, similar to MTProto 2.0. Note that KDF refers to HMAC-SHA512 throughout this document.

  • encrypt_data(payload, secret, extra_data)

Encrypts payload using a secret. extra_data will be used as part of MAC. large_msg_id will be used later to sign the packet.

padding_size = 16 + 15 - (payload.size + 15) % 16
padding = random_bytes(padding_size)
padding[0] = padding_size
padded_data = padding || payload
large_secret = KDF(secret, "tde2e_encrypt_data")
encrypt_secret = large_secret[0:32]
hmac_secret = large_secret[32:64]
large_msg_id = HMAC-SHA256(hmac_secret, padded_data || extra_data || len(extra_data))
msg_id = large_msg_id[0:16]
(aes_key, aes_iv) = HMAC-SHA512(encrypt_secret, msg_id)[0:48]
encrypted = aes_cbc(aes_key, aes_iv, padded_data)
Result: (msg_id || encrypted), large_msg_id
  • encrypt_header(header, encrypted_msg, secret)

Encrypts a 32-byte header using context from encrypted_msg and a secret.

msg_id = encrypted_msg[0:16]
encrypt_secret = KDF(secret, "tde2e_encrypt_header")[0:32]
(aes_key, aes_iv) = HMAC-SHA512(encrypt_secret, msg_id)[0:48]
encrypted_header = aes_cbc(aes_key, aes_iv, header)

Security:

  • Decryption routines must re-calculate and verify the msg_id before processing the decrypted payload.
  • Replay protection is managed at the packet level using seqno.

Packet Encryption

Audio and video data packets are encrypted using the following process:

  • encrypt_packet(payload, extra_data, active_epochs, user_id, channel_id, seqno, private_key)

Encrypts payload for transmission, associating it with active blockchain epochs. Epochs are essentially blocks whose shared keys are currently used for encryption.

  1. Generate Header A (Epoch List):

    • epoch_id[i] = active_epochs[i].block_hash (32 bytes per epoch_id)
    • header_a = active_epochs.size (4 bytes) || epoch_id[0] || epoch_id[1] || ...
  2. Encrypt Payload with One-Time Key:

    • one_time_key = random(32)
    • packet_payload = channel_id (4 bytes) || seqno (4 bytes) || payload
    • inner_extra_data = magic1 || header_a || extra_data
    • encrypted_payload, large_msg_id = encrypt_data(packet_payload, one_time_key, extra_data)
  3. Generate signature

    • signature = sign(magic2 || large_msg_id, private_key)
  4. Generate Header B (Encrypted One-Time Keys):

    • For each i in active_epochs:
      • encrypted_key[i] = encrypt_header(one_time_key, encrypted_payload, active_epochs[i].shared_key)
    • header_b = encrypted_key[0] || encrypted_key[1] || ...
  5. Final Packet: (header_a || header_b || encrypted_payload || signature)

magic1 is magic for e2e.callPacket = e2e.CallPacket;
magic2 is magic for e2e.callPacketLargeMsgId = e2e.CallPacketLargeMsgId;

Security Considerations

  • Replay Protection: The seqno must be unique and monotonically increasing for each (public key, channel_id) pair. In case of overflow, the client must leave the call. Receivers must track recently received seqno values and discard packets with old or duplicate numbers.
  • Signature Verification: During decryption, the receiver must use the user_id (provided out-of-band) to look up the sender's public_key in the relevant blockchain state (epoch specified in header_a). This public key is used to verify the signature within the decrypted signed_payload.
  • Unique private keys: Clients must use unique private keys each time they add themselves to the blockchain. Otherwise, replay attacks could be possible.

Shared Key Encryption

When a ChangeSetSharedKey operation occurs in the blockchain, the new shared key material is distributed securely as follows:

  1. Generate New Material:

    • raw_group_shared_key = random(32 bytes) (The actual shared key for data encryption).
    • one_time_secret = random(32 bytes) (A temporary secret for encrypting the group_shared_key).
    • e_private_key, e_public_key = generate_private_key() (Key pair used to encrypt the one_time_secret)
  2. Encrypt the Group Shared Key:

    • encrypted_group_shared_key = encrypt_data(group_shared_key, one_time_secret)
  3. Encrypt one_time_secret for Each Participant:

    • For each participant in the current group state:
      • shared_secret = compute_shared_secret(e_private_key, participant.public_key)
      • encrypted_header = encrypt_header(one_time_secret, encrypted_group_shared_key, shared_secret)
  4. Store in Blockchain: The e_public_key, encrypted_group_shared_key, and the list of encrypted_header (one per participant) are recorded in the blockchain state.

  5. Generate the real shared key used for packets encryption:

    • block_hash is the hash of the block where this shared key is set.
    • group_shared_key = HMAC-SHA512(raw_group_shared_key, block_hash)[0:32]

Security Considerations

  • Decryption is not guaranteed for all participants (e.g., if a participant has an outdated app or corrupted state).
  • However, all participants who can successfully decrypt the key material (by reversing the encrypt_header and encrypt_data steps using their private key and the ephemeral public key) will arrive at the identical group_shared_key.
  • Participants unable to decrypt the key must exit the call immediately, and specifically must not participate in the emoji generation process.

Key Verification and Emoji Generation

To ensure participants are communicating securely without a Man-in-the-Middle (MitM) attack, and to prevent manipulation of verification codes, a commit-reveal protocol is used to generate emojis based on the blockchain state and shared randomness.

Commit-Reveal Protocol Workflow

  1. Initial Setup (Per Participant):

    • Generate a cryptographically secure random 32-byte nonce.
    • Compute nonce_hash = SHA256(nonce).
  2. Commit Phase:

    • Each participant broadcasts their nonce_hash with a signature.
    • Use the e2e.chain.groupBroadcastNonceCommit structure.
    • The system (coordinated via the server) waits until commits have been received from all expected participants (based on the blockchain state at the specified height).
  3. Reveal Phase:

    • Once all commits are collected, each participant broadcasts their original nonce, again with a signature.
    • Use the e2e.chain.groupBroadcastNonceReveal structure.
    • The system verifies each revealed nonce by checking SHA256(revealed_nonce) == committed_nonce_hash.
    • The system waits until all valid nonces have been revealed.
  4. Final Hash Generation:

    • Concatenate all successfully revealed nonces sorted in lexicographic order. Let this be concatenated_sorted_nonces.
    • Obtain the blockchain_hash (the hash of the latest block for which verification is being performed).
    • Compute emoji_hash = HMAC-SHA512(concatenated_sorted_nonces, blockchain_hash).
    • This emoji_hash is then deterministically converted into a short sequence of emojis for display.

TL Schema for Broadcasts

// Phase 1: Commit
e2e.chain.groupBroadcastNonceCommit signature:int512 public_key:int256 chain_height:int32 chain_hash:int256 nonce_hash:int256 = e2e.chain.GroupBroadcast;

// Phase 2: Reveal
e2e.chain.groupBroadcastNonceReveal signature:int512 public_key:int256 chain_height:int32 chain_hash:int256 nonce:int256 = e2e.chain.GroupBroadcast;

The signature in both cases covers the TL-serialized object with the signature field itself zeroed out.

Security Considerations

  • The final emoji_hash is unpredictable to any single participant before the reveal phase, as it depends on random nonces from all others.
  • Participants should only process broadcast messages (commits/reveals) received from the server. Emojis should only be displayed once the process completes successfully for all participants (within reasonable network latency).
  • The two-phase protocol prevents any participant (even one controlling block creation) from selectively revealing their nonce or trying multiple nonces to influence the final emoji outcome based on others' revealed values.

Full TL Schema

e2e.chain.groupBroadcastNonceCommit#d1512ae7 signature:int512 user_id:int64 chain_height:int32 chain_hash:int256 nonce_hash:int256 = e2e.chain.GroupBroadcast;
e2e.chain.groupBroadcastNonceReveal#83f4f9d8 signature:int512 user_id:int64 chain_height:int32 chain_hash:int256 nonce:int256 = e2e.chain.GroupBroadcast;

e2e.chain.groupParticipant user_id:long public_key:int256 flags:# add_users:flags.0?true remove_users:flags.1?true version:int = e2e.chain.GroupParticipant;
e2e.chain.groupState participants:vector<e2e.chain.GroupParticipant> external_permissions:int = e2e.chain.GroupState;
e2e.chain.sharedKey ek:int256 encrypted_shared_key:string dest_user_id:vector<long> dest_header:vector<bytes> = e2e.chain.SharedKey;

e2e.chain.changeNoop nonce:int256 = e2e.chain.Change;
e2e.chain.changeSetValue key:bytes value:bytes = e2e.chain.Change;
e2e.chain.changeSetGroupState group_state:e2e.chain.GroupState = e2e.chain.Change;
e2e.chain.changeSetSharedKey shared_key:e2e.chain.SharedKey = e2e.chain.Change;

e2e.chain.stateProof flags:# kv_hash:int256 group_state:flags.0?e2e.chain.GroupState shared_key:flags.1?e2e.chain.SharedKey = e2e.chain.StateProof;

e2e.chain.block#639a3db6 signature:int512 flags:# prev_block_hash:int256 changes:vector<e2e.chain.Change> height:int state_proof:e2e.chain.StateProof signature_public_key:flags.0?int256 = e2e.chain.Block;

e2e.callPacket = e2e.CallPacket;
e2e.callPacketLargeMsgId = e2e.CallPacketLargeMsgId;