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.subchain=0) block, it must immediately generate a fresh nonce commitment message bound to the new block height and hash, submit it through the broadcast subchain (subchain=1), and wait for the commit-reveal protocol described below to complete before displaying verification emojis for that state.groupCallParticipant#2a3dc7ac flags:# muted:flags.0?true left:flags.1?true can_self_unmute:flags.2?true just_joined:flags.4?true versioned:flags.5?true min:flags.8?true muted_by_you:flags.9?true volume_by_admin:flags.10?true self:flags.12?true video_joined:flags.15?true peer:Peer date:int active_date:flags.3?int source:int volume:flags.7?int about:flags.11?string raise_hand_rating:flags.13?long video:flags.6?GroupCallParticipantVideo presentation:flags.14?GroupCallParticipantVideo paid_stars_total:flags.16?long = GroupCallParticipant;
updateGroupCallParticipants#f2ebdb4e call:InputGroupCall participants:Vector<GroupCallParticipant> version:int = Update;
---functions---
phone.leaveGroupCall#500377f9 call:InputGroupCall source:int = Updates;
phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector<long> block:bytes = Updates;
There are two distinct removal operations, distinguished by the flag passed to phone.deleteConferenceCallParticipants:
only_left=true — Pruning of participants that already disconnected from the media layer.kick=true — Forced removal of a currently active participant.Note: Self-removal is not supported via phone.deleteConferenceCallParticipants, as a participant cannot create a block that removes themselves while simultaneously generating a new shared key for the others; instead, the other participants must prune from the blockchain users that left the call, as specified below.
only_left)After a participant disconnects from the RTC media layer by invoking phone.leaveGroupCall, a updateGroupCallParticipants update with groupCallParticipant.left=true is delivered to all other participants. However, the departing user's entry persists in the E2E blockchain (e2e.chain.groupState). Any other active participant that holds the remove_users permission must prune these stale entries from the blockchain.
Detecting stale participants:
Clients detect stale participants via two complementary paths:
Via incoming updateGroupCallParticipants: When a groupCallParticipant with left=true is received for a user currently listed in the local E2E blockchain state (e2e.chain.groupState.participants), that user is immediately considered stale.
Via full participant list comparison: When the E2E blockchain state is updated (e.g., after applying a new main-chain block that changes the participant list), the client compares the blockchain's e2e.chain.groupState.participants against the RTC participant list fetched from the server via phone.getGroupParticipants. Any user present in the blockchain state but absent from the server-reported RTC participant list is stale. If the full RTC participant list has not yet been loaded, the client first paginates through it with phone.getGroupParticipants and performs this check once all pages have been received.
Submitting the removal:
Once one or more stale participants are identified:
ChangeSetGroupState change that removes all stale participants from the participant list.ChangeSetSharedKey change establishing a new shared key encrypted only for the remaining participants.only_left=true — signals to the server that these users already left the media layer.ids — the list of user IDs being pruned.block — the serialized E2E chain block.kick)An active participant may forcefully remove another currently active participant by invoking phone.deleteConferenceCallParticipants with kick=true (instead of only_left=true), along with the same ids and block fields described above. As with stale pruning, the remove_users permission is required.
API schema:
// Receive blocks from any chain
updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector<bytes> next_offset:int = Update;
---functions---
// Fetch blocks from any chain
phone.getGroupCallChainBlocks#ee9f88a6 call:InputGroupCall sub_chain_id:int offset:int limit:int = Updates;
// The following methods submit blocks to chain 0
phone.createConferenceCall#7d0444bb flags:# muted:flags.0?true video_stopped:flags.2?true join:flags.3?true random_id:int public_key:flags.3?int256 block:flags.3?bytes params:flags.3?DataJSON = Updates;
phone.joinGroupCall#8fb53057 flags:# muted:flags.0?true video_stopped:flags.2?true call:InputGroupCall join_as:InputPeer invite_hash:flags.1?string public_key:flags.3?int256 block:flags.3?bytes params:DataJSON = Updates;
phone.deleteConferenceCallParticipants#8ca60525 flags:# only_left:flags.0?true kick:flags.1?true call:InputGroupCall ids:Vector<long> block:bytes = Updates;
// The following method submits blocks to chain 1
phone.sendConferenceCallBroadcast#c6701900 call:InputGroupCall block:bytes = Updates;
E2E-call schema:
// Chain 0 blocks
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;
// Chain 1 blocks
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;
Currently, each and every conference call has two independent blockchains, independent from other calls and from each other.
These two blockchains are identified by their subchain ID:
Subchain ID 0: Main blockchain.
The main blockchain contains a sequence of only e2e.chain.Block objects, containing state changes for the call.
Each block has its own height (aka offset within the subchain): the first block in the subchain has height 0, then 1, etc.
Every block is linked to its previous block by the prev_block_hash parameter.
To submit blocks to subchain 0, only the following methods can be used:
All of these methods will return an updateGroupCallChainBlocks with the submitted blocks on success.
All of these methods except for phone.createConferenceCall can return an RPC error that starts with CONF_WRITE_CHAIN_INVALID, if the passed block is not based on the latest block of the subchain: in this case, the latest block must be re-fetched using phone.getGroupCallChainBlocks, the new block must re-generated on top of it and re-submitted by re-invoking the method.
Subchain ID 1: Call verification blockchain.
The call verification blockchain contains a sequence of only objects of type e2e.chain.GroupBroadcast, containing messages used for the commit-reveal emoji verification protocol ».
Each block has its own height (aka offset within the subchain): the first block in the subchain has height 0, then 1, etc.
All subchain 1 blocks are linked to a block from subchain 0 via chain_height and chain_hash, both pointing to a block in subchain 0, not to the previous block within subchain 1, so they're not strictly a blockchain, but the API still uses the term subchain for simplicity.
To submit blocks to subchain 1, only the following methods can be used:
1.Blocks are returned by the server inside of updateGroupCallChainBlocks updates, which are returned by all block submission methods and also delivered passively for group call members using the usual update delivery mechanism, and must be handled as specified here ».
Subchain blocks can also be fetched using phone.getGroupCallChainBlocks, which will return a maximum of limit blocks from subchain sub_chain_id with height bigger than or equal to offset, as an updateGroupCallChainBlocks update; offset can also be -1 to fetch the latest block on the specified subchain.
If the client is not a member of the call, phone.getGroupCallChainBlocks will only return the last block regardless of the values of offset and limit.
Please note that as long as the client is a member of the call, it must also manually poll for new chain blocks by invoking phone.getGroupCallChainBlocks with limit=50 every 5 seconds in normal conditions, and every second when key verification is in progress (i.e. a new block was added to the main subchain, and an emoji fingerprint is currently being generated for that block via commit-reveal messages on the verification subchain), passing to offset, for each subchain, the height of the last accepted block plus 1.
If the number of blocks returned by any call to phone.getGroupCallChainBlocks is equal to limit, more blocks may be available server-side, so phone.getGroupCallChainBlocks must be re-invoked immediately after processing the returned updateGroupCallChainBlocks, with the newly committed offset.
Note: the constructor IDs of all blocks returned by the server inside of updateGroupCallChainBlocks either passively or actively from phone.getGroupCallChainBlocks are modified, adding 1 to the constructor ID, so for example:
#639a3db6 becomes 639a3db7#d1512ae7 becomes d1512ae8#83f4f9d8 becomes 83f4f9d9Only accept blocks received from the server if their ID is the increased version (specifically, only accept 639a3db7 for the main subchain, and d1512ae8, 83f4f9d9 for the verification subchain).
When submitting blocks to the server, use the canonical constructor IDs (639a3db6, d1512ae7, 83f4f9d8).
Clients must apply both main-chain blocks and verification messages only from the server: this includes blocks and verification messages created by the local client, apply only the blocks echoed back from the server.
updateGroupCallChainBlocks#a477288f call:InputGroupCall sub_chain_id:int blocks:Vector<bytes> next_offset:int = Update;
Blocks are returned by the server inside of updateGroupCallChainBlocks updates, which must be additionally deduplicated by comparing the height of received blocks, similarly to the usual pts deduplication logic, which still applies to these updates, just earlier in the handling process, together with all other update types.
The updateGroupCallChainBlocks.next_offset field refers to the height/offset (within the subchain identified by sub_chain_id) of the block located after the last block in updateGroupCallChainBlocks.blocks.
The height/offset of the first block in updateGroupCallChainBlocks.blocks is equal to (next_offset - blocks.length) + 0, the height of the second is (next_offset - blocks.length) + 1, and so on until (next_offset - blocks.length) + (blocks.length - 1) == (next_offset - 1) for the last block in blocks.
While in practice, the height/offset of a main subchain block will match the value of the corresponding e2e.chain.block.height, the API offset should not be confused with the block height: in particular, clients should not attempt to overwrite the value of e2e.chain.block.height based on the computed offset, and call verification subchain e2e.chain.GroupBroadcast blocks don't even have an explicit height field anyway: clients must still associate the computed height to each block in blocks in order to deduplicate blocks, but without affecting the block contents, for example via a container blockOffset{block: bytes, offset: int} object (or virtually via a simple counter when iterating over blocks).
Clients must separately store the offset+1 of the last accepted block in both subchains, i.e. in an int next_offset[2] variable for each conference.
The initial value for next_offset when initializing the conference state before invoking phone.createConferenceCall/phone.joinGroupCall to join the call must be equal to {-1, -1}.
Clients must also keep a joined boolean variable for each conference, initially false.
If the client leaves the call or is kicked from it, clear all local blockchain state, including next_offset and joined.
The updateGroupCallChainBlocks handling logic (triggered by the update system both for passive updates and updates returned by any method) differs depending on whether we joined the call:
If we haven't fully joined the call yet (joined == false):
If the updateGroupCallChainBlocks came from a phone.createConferenceCall/phone.joinGroupCall:
For each individual block in updateGroupCallChainBlocks.blocks:
block.offset.If block validation and application succeeds for all blocks, set next_offset[updateGroupCallChainBlocks.sub_chain_id] := updateGroupCallChainBlocks.next_offset.
Then, if next_offset[0] >= 0 && next_offset[1] >= 0, set joined := true.
Otherwise, completely ignore the update.
This logic means that i.e. the phone.getGroupCallChainBlocks{offset=-1} call used to fetch the last block when joining an existing call must not trigger any local state change, and the last block must be manually extracted by the caller in order to construct the self-add block.
This also means that i.e. passive late updateGroupCallChainBlocks related to a left call we're trying to rejoin will be ignored, instead of triggering gap recovery logic that will not work anyway, because as mentioned above, if the client is not a member of the call, phone.getGroupCallChainBlocks will only return the last block regardless of the values of offset and limit, obviously breaking gap recovery logic.
Otherwise, if joined == true:
For each individual block in updateGroupCallChainBlocks.blocks:
If next_offset == block.offset, apply the block: if block validation and application succeeds, immediately set next_offset[updateGroupCallChainBlocks.sub_chain_id] := block.offset + 1 before proceeding to the next block.
If next_offset > block.offset, the block was already applied, and must be skipped.
If next_offset < block.offset, there's a gap in the block update sequence, handle it as follows:
Skip all remaining blocks in the update, starting from and including the current one, then:
Fill the gap by invoking phone.getGroupCallChainBlocks with offset=next_offset and limit=50; in other words, by early-triggering the phone.getGroupCallChainBlocks poll loop mentioned above.
As mentioned above, if the number of blocks returned by any call to phone.getGroupCallChainBlocks is equal to limit, phone.getGroupCallChainBlocks must be re-invoked immediately after processing the returned updateGroupCallChainBlocks, with the newly committed offset (usually equal to the returned next_offset), so the skipped block will be processed anyway once the client catches up to it during gap recovery.
Alternatively, instead of throwing away and re-downloading blocks that form a gap, they could be slotted into a queue, and the client could wait 0.5 seconds before invoking phone.getGroupCallChainBlocks if a gap is still present, as an update that fills the gap may arrive by itself a bit later, out of order.
Regardless of the chosen approach, take care when invoking phone.getGroupCallChainBlocks to fill gaps: if the user leaves the conference while a gap is being filled, the method will return only the latest block, not the requested range, potentially creating another gap, recursively triggering the gap recovery logic: the joined flag must be set to false when the user leaves the conference, but there may still be a race condition between joined := false and the polling logic, so make sure to disable wakeups of the poll loop when the user leaves the call.
To fill gaps, do not invoke phone.getGroupCallChainBlocks blockingly within the update handling thread/actor, instead simply schedule an early wakeup of the polling logic, which will naturally execute the method on a separate thread/actor (with the limitation described above).
updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update;
---functions---
phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool;
Conference in-call messages are serialized as JSON and encrypted using the packet encryption process.
The server only forwards the opaque encrypted packet and provides the sender separately in updateGroupCallEncryptedMessage.from_id.
The plaintext of the message is a JSON object with the following structure:
{
"_": "groupCallMessage",
"random_id": "1234567890123456789",
"message": {
"_": "textWithEntities",
"text": "Hello!",
"entities": [
{
"_": "messageEntityBold",
"offset": 0,
"length": 5
}
]
}
}
_ identifies the serialized object or constructor.
random_id is a non-zero, client-generated random signed 64-bit integer, encoded as a decimal JSON string to avoid precision loss, it uniquely identifies messages sent by the current user within the chat, similar to the random_id in secret chats.
message is the JSON representation of a textWithEntities constructor.
Each entry in entities is the JSON representation (with the predicate name stored in the _ key) of only the following supported message entity types:
document_id is represented as a string, like random_id)All other entity types must be transformed to a messageEntityUnknown.
The offset and the length of the entities are both represented in UTF-16 code units, as usual.
This is not a serialization of the API groupCallMessage constructor: conference messages do not contain its server-assigned id, from_id, date, from_admin or paid_message_stars fields, they only contain a client-generated random_id, and the message.
The sender is provided by the surrounding updateGroupCallEncryptedMessage, while the display date is generated by the receiving client.
Receivers must validate the JSON structure, reject invalid text or entity bounds and ignore unsupported entity constructors.
Conference calls support the same animated standard and custom emoji reactions as other group call types, but the reaction payload must be serialized and E2E-encrypted as a conference in-call message.
For a standard emoji reaction, set message.text to only the selected available reaction emoji and leave message.entities empty:
{
"_": "groupCallMessage",
"random_id": "1234567890123456789",
"message": {
"_": "textWithEntities",
"text": "
",
"entities": []
}
}
For a custom emoji reaction, set message.text to the custom emoji's fallback emoji text and add exactly one messageEntityCustomEmoji entity spanning the entire text:
{
"_": "groupCallMessage",
"random_id": "1234567890123456789",
"message": {
"_": "textWithEntities",
"text": "
",
"entities": [
{
"_": "messageEntityCustomEmoji",
"offset": 0,
"length": 2,
"document_id": "1234567890123456789"
}
]
}
}
Serialize, encrypt and send this payload using the normal conference in-call message flow. Receivers should recognize a supported emoji-only payload or a single full-span custom emoji entity as a reaction and should render its animated effect instead of displaying it as ordinary text.
---functions---
phone.sendGroupCallEncryptedMessage#e5afa56d call:InputGroupCall encrypted_message:bytes = Bool;
To send a conference in-call message:
payload set to the serialized JSON bytes.channel_id set to 0.extra_data emptyseqno set to the next sequence number for channel 0.encrypted_message.updateGroupCallEncryptedMessage#c957a766 call:InputGroupCall from_id:Peer encrypted_message:bytes = Update;
Incoming messages are delivered in updateGroupCallEncryptedMessage updates. To process one:
from_id identifies a user and extract its user ID.encrypted_message using the packet decryption process, passing the extracted sender user ID and expecting channel ID 0, applying all the usual checks.(from_id, random_id) pair before displaying it.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:
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;
Namely:
signature: A cryptographic signature verifying the block's authenticity, calculated over the TL serialization of the e2e.chain.block with the signature field itself zeroed out.
prev_block_hash: The SHA256 hash of the complete TL serialization of the preceding e2e.chain.block, forming the chain link.
changes: A list of state modifications applied by this block.
A block must contain at least a ChangeSetGroupState or a ChangeSetValue.
A block containing only ChangeNoop or only ChangeSetSharedKey is invalid.
See here » for the full list of available change types and how to apply them.
height: The sequential number of the block in the chain.
state_proof: Cryptographic proof of the blockchain state after this block is applied, including the key-value state hash and, depending on the block contents, the group state and shared key state.
See here » for a more detailed description of this field.
signature_public_key: The public key of the participant who created and signed the block.
Note: For optimization purposes, the signature_public_key can be omitted if it matches the first participant's key in the group state (except for the block at height 0, where it must always be present).
e2e.chain.groupState#1ddc7584 participants:Vector<e2e.chain.GroupParticipant> external_permissions:int = e2e.chain.GroupState;
e2e.chain.groupParticipant#28852f20 user_id:long public_key:int256 flags:# add_users:flags.0?true remove_users:flags.1?true set_value:flags.2?true version:int = e2e.chain.GroupParticipant;
Participants are described by the e2e.chain.groupState.participants field inside the local state and incoming blocks, and are identified by user_id or public_key.
Each participant is represented by a e2e.chain.groupParticipant object, containing the following fields:
user_id: Contains the participant's Telegram user IDpublic_key: Contains the participant's public keyversion: Indicates the maximum version of the E2E group calls protocol » supported by this participant.add_users: If set, this user has permission to add new participants.remove_users: If set, this user has permission to remove existing participants.set_value: If set, this user has permission to modify the kv trie.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 e2e.chain.groupState.
external_permissionsin the blockchain state to allow self-addition to groups.
e2e.chain.groupState.external_permissions is used when the client needs to fetch permissions for a user that isn't present in the participants vector of the current (pre-block application) e2e.chain.groupState.
e2e.chain.groupState.external_permissions can contain exactly the same bitflags that can be contained in e2e.chain.groupParticipant.flags (add_users, remove_users, set_value).
e2e.chain.groupState.external_permissions is only used when applying changes of type ChangeSetGroupState.
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.chain.changeSetGroupState#2cf17146 group_state:e2e.chain.GroupState = e2e.chain.Change;
e2e.chain.changeSetSharedKey#987a2158 shared_key:e2e.chain.SharedKey = e2e.chain.Change;
e2e.chain.changeSetValue#7c4f9bfa key:bytes value:bytes = e2e.chain.Change;
e2e.chain.changeNoop#deb4a41b nonce:int256 = e2e.chain.Change;
Blocks contain changes that modify the blockchain state.
A block must contain at least a ChangeSetGroupState or a ChangeSetValue.
A block containing only ChangeNoop or only ChangeSetSharedKey is invalid.
To apply changes, follow these instructions:
ChangeSetGroupState: Modifies the list of participants and their permissions. This action clears the current shared key, requiring a subsequent ChangeSetSharedKey within the same block.
e2e.chain.groupParticipant#28852f20 user_id:long public_key:int256 flags:# add_users:flags.0?true remove_users:flags.1?true set_value:flags.2?true version:int = e2e.chain.GroupParticipant;
e2e.chain.groupState#1ddc7584 participants:Vector<e2e.chain.GroupParticipant> external_permissions:int = e2e.chain.GroupState;
e2e.chain.changeSetGroupState#2cf17146 group_state:e2e.chain.GroupState = e2e.chain.Change;
The initial local value of the local group_state when at the conceptual height=-1, only used when applying the first block of the blockchain with height=0, must be equal to:
local_group_state=e2e.chain.GroupState{participants: [], external_permissions: add_users | remove_users | set_value}To apply incoming changes of this type, follow these steps:
Initialize local_group_state with the locally stored e2e.chain.GroupState, before the application of this change.
Initialize incoming_group_state with the incoming e2e.chain.GroupState.
Initialize local_permissions with the permissions of the block's author, looked up in local_group_state.participants based on block's signature_public_key: if no matching entry can be found for the public key, fallback to local_group_state.external_permissions.
Validate that incoming_group_state.external_permissions is exactly equal to or a subset of add_users | remove_users | set_value (don't allow unknown flags)
Validate that incoming_group_state.external_permissions is equal to or is a strict subset of local_group_state.external_permissions, i.e. (incoming_group_state->external_permissions & ~local_group_state->external_permissions) == 0.
In other words, external_permissions cannot be increased compared to the previous state, though it may be reduced.
Validate that all user_id values are unique across incoming_group_state.participants
Validate that all public_key values are unique across incoming_group_state.participants
Validate incoming_group_state.participants: note that in this context, each participant must be uniquely identified by both the user_id and the public_key fields (i.e. generate a new combined identifier by concatenating the binary version of user_id and public_key), this is used when comparing participants in the local and incoming list.
flags of all incoming_group_state.participants are exactly equal to or a subset of add_users | remove_users | set_value (don't allow unknown flags)local_group_state.participants is not present in incoming_group_state.participants, validate that local_permissions contains remove_users (the block signer must have the remove_users permission to remove users)incoming_group_state.participants but not present in local_group_state.participants:
local_permissions contains add_users (the block signer must have the add_users permission to add users, or add_users must be present in local_group_state.external_permissions for self-add blocks)incoming_participant.flags is equal to or is a subset of local_permissions (a newly added user cannot have more permissions than the adder, or more permissions than local_group_state.external_permissions for self-add blocks)incoming_group_state.participants and local_group_state.participants, if the local flags field differs from the incoming flags field:
local_permissions contains both add_users and remove_users (the block signer must have both add_users and remove_users permissions to change someone else's or their own permissions).incoming_participant.flags is equal to or is a subset of local_permissions (the block signer cannot grant permission bits that the signer does not currently have).If all these validation steps succeed, apply the change as follows:
local_group_state = incoming_group_statelocal_shared_key = *empty*
A new key must be set by a subsequent ChangeSetSharedKey in the same block.Please note that these state updates (like all other state updates triggered by all change types) must be rolled back if any of the changes within the current block fails validation.
One of the reasons why all changes must be applied to the local state even if the entire block wasn't processed yet (and then rolled back on error) is because the ChangeSetSharedKey that must follow a ChangeSetGroupState relies on the user being already present in the local participant list.
ChangeSetSharedKey: Establishes a new shared encryption key, encrypted individually for each listed participant.
e2e.chain.sharedKey#8a847e7f ek:int256 encrypted_shared_key:string dest_user_id:Vector<long> dest_header:Vector<bytes> = e2e.chain.SharedKey;
e2e.chain.changeSetSharedKey#987a2158 shared_key:e2e.chain.SharedKey = e2e.chain.Change;
To apply incoming changes of this type, follow these steps:
ChangeSetGroupState)local_group_state with the locally stored e2e.chain.GroupState, before the application of this change.local_permissions with the permissions of the block's author, looked up in local_group_state.participants based on block's signature_public_key, abort validation if no matching entry can be found for the public key: do not fallback to local_group_state.external_permissions.local_permissions contains add_users and/or remove_usersdest_user_id and dest_header contain the same number of elementsdest_user_id doesn't contain duplicated user IDsdest_user_id contains exactly one entry for each participant in local_group_state.participants, in any order.ChangeSetValue: Updates the key-value trie.
e2e.chain.changeSetValue#7c4f9bfa key:bytes value:bytes = e2e.chain.Change;
Clients that do not implement the key-value trie must still store the latest accepted kv_hash, and handle blockchain updates as follows:
incoming.state_proof.kv_hash must be identical to the locally stored value.key and value contents and just accept the incoming.state_proof.kv_hash (from the containing block), storing that value as the new local kv_hash, but only if the block author has the set_value permission.ChangeNoop: A no-operation change, potentially used for hash randomization.
e2e.chain.changeNoop#deb4a41b nonce:int256 = e2e.chain.Change;
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.chain.stateProof#d6b679e6 flags:# kv_hash:int256 group_state:flags.0?e2e.chain.GroupState shared_key:flags.1?e2e.chain.SharedKey = e2e.chain.StateProof;
e2e.chain.groupState#1ddc7584 participants:Vector<e2e.chain.GroupParticipant> external_permissions:int = e2e.chain.GroupState;
e2e.chain.groupParticipant#28852f20 user_id:long public_key:int256 flags:# add_users:flags.0?true remove_users:flags.1?true set_value:flags.2?true version:int = e2e.chain.GroupParticipant;
e2e.chain.sharedKey#8a847e7f ek:int256 encrypted_shared_key:string dest_user_id:Vector<long> dest_header:Vector<bytes> = e2e.chain.SharedKey;
Blockchain states must be stored locally and are used to verify incoming blocks.
The state of a specific blockchain is composed of the following fields:
current_height - Height of the last block: The current height of the blockchain (equal to the height of the last applied block)current_hash - Hash of the last block: The hash of the last applied blockshared_key - Shared Key: Shared group key encrypted for each group participant.group_state - Group State: List of group participants and their permissions.kv_hash - Key Value Storage hash: Root hash of the key-value trie after applying the block. The full trie format is intentionally out of scope for this document, because the key-value trie is currently not used anywhere. The kv_hash, however, is still partially used during block verification, as described below.The client must keep all these fields stored locally, and use them to validate changes when applying blocks: if block application succeeds, the fields must be updated according to the changes contained in the block.
Application of incoming blocks where group_state and/or shared_key are not set according to the omission rules specified below must NOT remove those fields from the local state.
The initial local value of these fields when at the conceptual height=-1, only used when applying the first block of the blockchain with height=0, must be equal to:
current_height=-1current_hash=0shared_key=*empty*group_state=e2e.chain.GroupState{participants: [], external_permissions: add_users | remove_users | set_value}kv_hash=0e2e.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;
Blocks must be applied atomically (all changes succeed or none do) and sequentially.
The validation process validates incoming blocks based on and modifying the current local value of the local blockchain state ».
height must be exactly local.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 (local.current_hash). If not, the block is invalid.signature_public_key field is populated, use it, otherwise:
height is equal to 0, abort (the first block must have an attached signature_public_key)local.group_state.participants[0].public_keysignature_public_key extracted in the previous step). Permissions are sourced from the previous block's state or external_permissions if the creator wasn't already a participant.signature using the block creator's public key. If invalid, the block is rejected.changes vector:
state_proof. If not, the block is invalid.If the block is invalid, all changes made to the local state by the block must be rolled back (or, the block application process can run on a copy of the local state which is mutated when iterating over the changes, and only applied if the block is fully validated).
The blockchain starts with a conceptual "genesis" block at height: -1 with a hash of UInt256(0) (the full local blockchain state at height -1 is described here »).
e2e.chain.groupParticipant#28852f20 user_id:long public_key:int256 flags:# add_users:flags.0?true remove_users:flags.1?true set_value:flags.2?true version:int = e2e.chain.GroupParticipant;
The E2E group call encryption protocol specified in this article may change in the future: to preserve backwards-compatibility with clients that do not support newer protocols yet, each participant must announce its maximum supported version in e2e.chain.groupParticipant.version.
The protocol version used when generating and applying blocks must be equal to the smallest version contained in the participant list, clamped to the inclusive range 0...255.
Protocol changelog:
0: Initial version.1: The shared group key now must be hashed via HMAC-SHA512(raw_group_shared_key, block_hash)[0:32] before use (step 5 in shared key encryption »).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
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
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:
msg_id before processing the decrypted payload.seqno.Audio, video and conference in-call message packets are encrypted using the following process:
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, inner_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: extra_data || header_a || header_b || encrypted_payload || signature || extra_data_size.
The final unencrypted_prefix_size is a 4-byte little-endian integer stored at the end of the packet. On decryption, receivers first remove this trailer, split off the unencrypted prefix, parse header_a and header_b, and then try the advertised epochs until one decrypts the one-time key successfully.
magic1 is the CRC32 constructor magic (the part after #) for e2e.callPacket#40a6bee9 = e2e.CallPacket;
magic2 is the CRC32 constructor magic (the part after #) for e2e.callPacketLargeMsgId#1ce56c2d = 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). The signature is stored as the 64 bytes immediately following encrypted_payload, and this public key is used to verify it against magic2 || large_msg_id after recalculating large_msg_id while decrypting the 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 raw_group_shared_key).e_private_key, e_public_key = generate_private_key() (Ephemeral key pair used to derive the per-participant secret, one_time_secret)Encrypt the Group Shared Key:
encrypted_group_shared_key = encrypt_data(raw_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 packet encryption: (only for protocol version >= 1)
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]Each accepted main-chain block creates a new encryption epoch identified by that block hash. Old epochs remain usable for a short grace period, so packets in flight can still be decrypted; tdlib currently keeps old epochs for about 10 seconds and caps the active epoch list at 15.
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.
The protocol is run for every newly accepted main-chain block. The selected block is identified by (chain_height, chain_hash), respectively the height and the hash of the selected block.
Initial Setup (Per Participant):
nonce_hash = SHA256(nonce).Commit Phase:
nonce_hash, chain_height and chain_hash with a signature.Reveal Phase:
nonce, again with a signature.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(key=concatenated_sorted_nonces, message=blockchain_hash).emoji_hash, ignoring the remaining 32 bytes.emoji_hash_sliced into the four-emoji fingerprint described below.A 32-byte binary string (emoji_hash_sliced for group calls, SHA256(key || g_a) for one-on-one calls) is converted into an ordered fingerprint of four emojis as follows:
In pseudocode:
fingerprint_hash = emoji_hash[0:32]
for i in 0..3:
offset = i * 8
value = big_endian_uint64(fingerprint_hash[offset:offset + 8])
value &= 0x7fffffffffffffff
fingerprint[i] = emoji_table[value % emoji_table.length]
// fingerprint now contains the 4 emojis
See here » for the exact contents of the emoji table (contains 333 emojis).
// Phase 1: Commit
e2e.chain.groupBroadcastNonceCommit#d1512ae7 signature:int512 user_id:int64 chain_height:int32 chain_hash:int256 nonce_hash:int256 = e2e.chain.GroupBroadcast;
// Phase 2: Reveal
e2e.chain.groupBroadcastNonceReveal#83f4f9d8 signature:int512 user_id:int64 chain_height:int32 chain_hash:int256 nonce:int256 = e2e.chain.GroupBroadcast;
The public_key is extracted from the user's e2e.chain.groupParticipant object using the user_id.
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#28852f20 user_id:long public_key:int256 flags:# add_users:flags.0?true remove_users:flags.1?true set_value:flags.2?true version:int = e2e.chain.GroupParticipant;
e2e.chain.groupState#1ddc7584 participants:Vector<e2e.chain.GroupParticipant> external_permissions:int = e2e.chain.GroupState;
e2e.chain.sharedKey#8a847e7f ek:int256 encrypted_shared_key:string dest_user_id:Vector<long> dest_header:Vector<bytes> = e2e.chain.SharedKey;
e2e.chain.changeNoop#deb4a41b nonce:int256 = e2e.chain.Change;
e2e.chain.changeSetValue#7c4f9bfa key:bytes value:bytes = e2e.chain.Change;
e2e.chain.changeSetGroupState#2cf17146 group_state:e2e.chain.GroupState = e2e.chain.Change;
e2e.chain.changeSetSharedKey#987a2158 shared_key:e2e.chain.SharedKey = e2e.chain.Change;
e2e.chain.stateProof#d6b679e6 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#40a6bee9 = e2e.CallPacket;
e2e.callPacketLargeMsgId#1ce56c2d = e2e.CallPacketLargeMsgId;