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:
- 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.
- 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.
- 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
- Fetch State: A user wishing to join requests the latest blockchain block (representing the current call state) from the server.
- Create Join Block: The user constructs a new block proposal. This block:
- References the previous block's hash.
- Includes a
ChangeSetGroupStatechange adding the user to the participant list. - Includes a
ChangeSetSharedKeychange 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.
- Submit Block: The user sends this proposed block to the server.
- 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.
- Client Update: Eventually, all participants receive the new block from the server and start using the new shared key.
Removing a Participant
- Initiation: Any active participant with the necessary permissions (
remove_usersflag) can initiate the removal of another (e.g., inactive) participant. - Create Removal Block: The initiating participant creates a block proposal containing:
- A
ChangeSetGroupStatechange removing the target participant. - A subsequent
ChangeSetSharedKeychange establishing a new key encrypted only for the remaining participants.
- A
- 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:
- Group State: List of group participants and their permissions.
- Shared Key: Shared group key encrypted for each group participant.
- 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
signaturefield 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:
- ChangeSetGroupState: Modifies the list of participants and their permissions. This action clears the current shared key, requiring a subsequent
ChangeSetSharedKeyin 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; - 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; - 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_permissionsin 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:
- Height Check: The block's
heightmust be exactlycurrent_height + 1. If not, the block is invalid. It is currently impossible to apply a block with height larger than2^31-1. - Previous Hash Check: The block's
prev_block_hashmust match the hash of the last applied block. If not, the block is invalid. - Permission Check (Initial): Determine the permissions of the block creator (identified by
signature_public_key). Permissions are sourced from the previous block's state orexternal_permissionsif the creator wasn't already a participant. - Signature Verification: Verify the block's
signatureusing the creator's public key. If invalid, the block is rejected. - Apply Changes Sequentially: Iterate through the
changesvector:- 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.
- 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_keycan 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 correspondingSet*changes are present in the block.
Applying Specific Changes
- Participant Management (
ChangeSetGroupState):- Requires the
add_userspermission 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_userspermission to remove participants. - Participant
user_idandpublic_keymust be unique. - This change always clears the existing shared key state. A new key must be set in a subsequent block if needed.
- Requires the
- 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 newChangeSetSharedKeyin a subsequent block. - The
dest_user_idlist in theSharedKeystructure 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
heightare 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
payloadusing asecret.extra_datawill be used as part of MAC.large_msg_idwill 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
headerusing context fromencrypted_msgand asecret.
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_idbefore 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
payloadfor transmission, associating it with active blockchain epochs. Epochs are essentially blocks whose shared keys are currently used for encryption.
-
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] || ...
-
Encrypt Payload with One-Time Key:
one_time_key = random(32)packet_payload = channel_id (4 bytes) || seqno (4 bytes) || payloadinner_extra_data = magic1 || header_a || extra_dataencrypted_payload, large_msg_id = encrypt_data(packet_payload, one_time_key, extra_data)
-
Generate signature
signature = sign(magic2 || large_msg_id, private_key)
-
Generate Header B (Encrypted One-Time Keys):
- For each
iinactive_epochs:encrypted_key[i] = encrypt_header(one_time_key, encrypted_payload, active_epochs[i].shared_key)
header_b = encrypted_key[0] || encrypted_key[1] || ...
- For each
-
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
seqnomust 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 receivedseqnovalues 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'spublic_keyin the relevant blockchain state (epoch specified inheader_a). This public key is used to verify thesignaturewithin the decryptedsigned_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:
-
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)
-
Encrypt the Group Shared Key:
encrypted_group_shared_key = encrypt_data(group_shared_key, one_time_secret)
-
Encrypt
one_time_secretfor Each Participant:- For each
participantin 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)
- For each
-
Store in Blockchain: The
e_public_key,encrypted_group_shared_key, and the list ofencrypted_header(one per participant) are recorded in the blockchain state. -
Generate the real shared key used for packets encryption:
block_hashis 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_headerandencrypt_datasteps using their private key and the ephemeral public key) will arrive at the identicalgroup_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
-
Initial Setup (Per Participant):
- Generate a cryptographically secure random 32-byte nonce.
- Compute
nonce_hash = SHA256(nonce).
-
Commit Phase:
- Each participant broadcasts their
nonce_hashwith a signature. - Use the
e2e.chain.groupBroadcastNonceCommitstructure. - 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).
- Each participant broadcasts their
-
Reveal Phase:
- Once all commits are collected, each participant broadcasts their original
nonce, again with a signature. - Use the
e2e.chain.groupBroadcastNonceRevealstructure. - The system verifies each revealed
nonceby checkingSHA256(revealed_nonce) == committed_nonce_hash. - The system waits until all valid nonces have been revealed.
- Once all commits are collected, each participant broadcasts their original
-
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_hashis then deterministically converted into a short sequence of emojis for display.
- Concatenate all successfully revealed nonces sorted in lexicographic order. Let this be
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_hashis 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;