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.
Telegram end-to-end encrypted group calls generally rely on 3 components to manage communication securely among multiple participants:
This document details the technical implementation of these components.
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.
ChangeSetGroupState
change adding the user to the participant list.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.remove_users
flag) can initiate the removal of another (e.g., inactive) participant.ChangeSetGroupState
change removing the target participant.ChangeSetSharedKey
change establishing a new key encrypted only for the remaining participants.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.
A dedicated blockchain provides a distributed, verifiable, and synchronized history of the group call's state.
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.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:
signature
field itself zeroed out. The specific serialization format follows standard TL rules.Blocks contain changes that modify the blockchain state. The types used in group calls are:
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;
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;
e2e.chain.changeNoop random:int256 = e2e.chain.Change;
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.
Blocks must be applied atomically (all changes succeed or none do) and sequentially. The validation process is as follows:
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
.prev_block_hash
must match the hash of the last applied block. If not, the block is invalid.signature_public_key
). Permissions are sourced from the previous block's state or external_permissions
if the creator wasn't already a participant.signature
using the creator's public key. If invalid, the block is rejected.changes
vector: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 correspondingSet*
changes are present in the block.
ChangeSetGroupState
):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).remove_users
permission to remove participants.user_id
and public_key
must be unique.ChangeSetSharedKey
):ChangeSetGroupState
(which clears the key) first, followed by a new ChangeSetSharedKey
in a subsequent block.dest_user_id
list in the SharedKey
structure must exactly match the current list of participants in the group state.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.
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.The following protocol encrypts call data (audio/video frames) and manages shared keys securely.
The encryption relies on the following primitive functions, similar to MTProto 2.0. Note that KDF refers to HMAC-SHA512 throughout this document.
Encrypts
payload
using asecret
.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
Encrypts a 32-byte
header
using context fromencrypted_msg
and 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:
msg_id
before processing the decrypted payload.seqno
.Audio and video data packets are encrypted using the following process:
Encrypts
payload
for 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) || payload
inner_extra_data = magic1 || header_a || extra_data
encrypted_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):
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] || ...
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;
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.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
.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_secret
for Each Participant:
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)
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.
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]
encrypt_header
and encrypt_data
steps using their private key and the ephemeral public key) will arrive at the identical group_shared_key
.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.
Initial Setup (Per Participant):
nonce_hash = SHA256(nonce)
.Commit Phase:
nonce_hash
with a signature.e2e.chain.groupBroadcastNonceCommit
structure.Reveal Phase:
nonce
, again with a signature.e2e.chain.groupBroadcastNonceReveal
structure.nonce
by checking SHA256(revealed_nonce) == committed_nonce_hash
.Final Hash Generation:
concatenated_sorted_nonces
.blockchain_hash
(the hash of the latest block for which verification is being performed).emoji_hash = HMAC-SHA512(concatenated_sorted_nonces, blockchain_hash)
.emoji_hash
is then deterministically converted into a short sequence of emojis for display.// 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.
emoji_hash
is unpredictable to any single participant before the reveal phase, as it depends on random nonces from all others.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;