Skip to main content

ferogram_crypto/
lib.rs

1/*
2 * Copyright (c) 2026 Ankit Chaubey <ankitchaubey.dev@gmail.com>
3 * https://github.com/ankit-chaubey
4 *
5 * Project: ferogram
6 * Website: https://ferogram.dev
7 *
8 * Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
9 * https://www.apache.org/licenses/LICENSE-2.0> or the MIT license
10 * <LICENSE-MIT or https://opensource.org/licenses/MIT>, at your option.
11 * This file may not be copied, modified, or distributed except according
12 * to those terms.
13 */
14
15#![cfg_attr(docsrs, feature(doc_cfg))]
16#![doc(html_root_url = "https://docs.rs/ferogram-crypto/0.6.5")]
17//! Cryptographic primitives for Telegram MTProto 2.0.
18//!
19//! This crate is part of [ferogram](https://crates.io/crates/ferogram), an async Rust
20//! MTProto client built by [Ankit Chaubey](https://github.com/ankit-chaubey).
21//!
22//! - Channel: [t.me/Ferogram](https://t.me/Ferogram)
23//! - Chat: [t.me/FerogramChat](https://t.me/FerogramChat)
24//!
25//! Most users do not need this crate directly. The `ferogram` crate wraps
26//! everything. Use `ferogram-crypto` only if you are building your own MTProto
27//! transport layer or need direct access to the primitives.
28//!
29//! # What's in here
30//!
31//! - **AES-256-IGE**: MTProto's symmetric cipher. [`aes::ige_encrypt`] and
32//!   [`aes::ige_decrypt`] operate on 16-byte-aligned buffers.
33//! - **SHA-1 / SHA-256**: Hash macros used throughout key derivation and
34//!   message authentication.
35//! - **Pollard-rho PQ factorization**: Required by the DH handshake:
36//!   Telegram sends a 64-bit semiprime and expects you to factor it.
37//!   [`factorize`] does this.
38//! - **RSA (MTProto RSA-PAD)**: Used during the initial key exchange to
39//!   encrypt the inner request to Telegram's known public keys.
40//!   See [`rsa`].
41//! - **`AuthKey`**: The 256-byte session key derived after a successful DH
42//!   exchange. Wraps the raw bytes and exposes the auxiliary hash needed for
43//!   MTProto 2.0 message encryption.
44//! - **MTProto 2.0 encrypt / decrypt**: [`encrypt_data_v2`] and
45//!   [`decrypt_data_v2`] implement the full AES-IGE + SHA-256 message
46//!   protection scheme from the spec.
47//! - **DH nonce-to-key derivation**: Derives `auth_key` from the DH result
48//!   bytes using the MTProto KDF.
49//! - **Obfuscated transport**: [`ObfuscatedCipher`] implements the random-padding
50//!   + AES-CTR obfuscation layer used by `ObfuscatedAbridged` transport.
51//!
52//! # Example: AES-IGE round-trip
53//!
54//! ```rust
55//! use ferogram_crypto::aes::{ige_encrypt, ige_decrypt};
56//!
57//! let key = [0u8; 32];
58//! let iv  = [0u8; 32];
59//! let mut data = vec![0u8; 48]; // must be 16-byte aligned
60//!
61//! ige_encrypt(&mut data, &key, &iv);
62//! ige_decrypt(&mut data, &key, &iv);
63//! // data is back to zeros
64//! ```
65//!
66//! # Example: factorize
67//!
68//! ```rust
69//! use ferogram_crypto::factorize;
70//!
71//! let (p, q) = factorize(0x17ED48941A08F981).expect("factors exist");
72//! assert!(p < q);
73//! assert_eq!(p * q, 0x17ED48941A08F981);
74//! ```
75
76#![deny(unsafe_code)]
77
78pub mod aes;
79mod auth_key;
80mod deque_buffer;
81pub mod dh;
82mod factorize;
83mod obfuscated;
84pub mod rsa;
85mod sha;
86pub mod srp;
87
88pub use auth_key::AuthKey;
89pub use deque_buffer::DequeBuffer;
90pub use factorize::factorize;
91pub use obfuscated::{
92    ObfuscatedCipher, build_obfuscated_init, fake_tls_client_digest, fake_tls_verify_server_digest,
93};
94
95/// Fill `buf` with cryptographically secure random bytes.
96///
97/// Panics if the OS RNG is unavailable (this should never happen in practice).
98pub fn fill_random(buf: &mut [u8]) {
99    getrandom::fill(buf).expect("OS RNG unavailable");
100}
101
102/// Errors from [`decrypt_data_v2`].
103#[derive(Clone, Debug, PartialEq)]
104pub enum DecryptError {
105    /// Ciphertext too short or not block-aligned.
106    InvalidBuffer,
107    /// The `auth_key_id` in the ciphertext does not match our key.
108    AuthKeyMismatch,
109    /// The `msg_key` in the ciphertext does not match our computed value.
110    MessageKeyMismatch,
111}
112
113impl std::fmt::Display for DecryptError {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        match self {
116            Self::InvalidBuffer => write!(f, "invalid ciphertext buffer length"),
117            Self::AuthKeyMismatch => write!(f, "auth_key_id mismatch"),
118            Self::MessageKeyMismatch => write!(f, "msg_key mismatch"),
119        }
120    }
121}
122impl std::error::Error for DecryptError {}
123
124enum Side {
125    Client,
126    Server,
127}
128impl Side {
129    fn x(&self) -> usize {
130        match self {
131            Side::Client => 0,
132            Side::Server => 8,
133        }
134    }
135}
136
137fn calc_key(auth_key: &AuthKey, msg_key: &[u8; 16], side: Side) -> ([u8; 32], [u8; 32]) {
138    let x = side.x();
139    let sha_a = sha256!(msg_key, &auth_key.data[x..x + 36]);
140    let sha_b = sha256!(&auth_key.data[40 + x..40 + x + 36], msg_key);
141
142    let mut aes_key = [0u8; 32];
143    aes_key[..8].copy_from_slice(&sha_a[..8]);
144    aes_key[8..24].copy_from_slice(&sha_b[8..24]);
145    aes_key[24..].copy_from_slice(&sha_a[24..]);
146
147    let mut aes_iv = [0u8; 32];
148    aes_iv[..8].copy_from_slice(&sha_b[..8]);
149    aes_iv[8..24].copy_from_slice(&sha_a[8..24]);
150    aes_iv[24..].copy_from_slice(&sha_b[24..]);
151
152    (aes_key, aes_iv)
153}
154
155fn padding_len(len: usize) -> usize {
156    // MTProto 2.0 requires 12-1024 bytes of random padding, and the total
157    // (payload + padding) must be a multiple of 16.
158    // Minimum padding = 12; extra bytes to hit the next 16-byte boundary.
159    let rem = (len + 12) % 16;
160    if rem == 0 { 12 } else { 12 + (16 - rem) }
161}
162
163/// Encrypt `buffer` (in-place, with prepended header) using MTProto 2.0.
164///
165/// After this call `buffer` contains `key_id || msg_key || ciphertext`.
166pub fn encrypt_data_v2(buffer: &mut DequeBuffer, auth_key: &AuthKey) {
167    let mut rnd = [0u8; 32];
168    getrandom::fill(&mut rnd).expect("getrandom failed");
169    do_encrypt_data_v2(buffer, auth_key, &rnd);
170}
171
172pub(crate) fn do_encrypt_data_v2(buffer: &mut DequeBuffer, auth_key: &AuthKey, rnd: &[u8; 32]) {
173    let pad = padding_len(buffer.len());
174    buffer.extend(rnd.iter().take(pad).copied());
175
176    let x = Side::Client.x();
177    let msg_key_large = sha256!(&auth_key.data[88 + x..88 + x + 32], buffer.as_ref());
178    let mut msg_key = [0u8; 16];
179    msg_key.copy_from_slice(&msg_key_large[8..24]);
180
181    let (key, iv) = calc_key(auth_key, &msg_key, Side::Client);
182    aes::ige_encrypt(buffer.as_mut(), &key, &iv);
183
184    buffer.extend_front(&msg_key);
185    buffer.extend_front(&auth_key.key_id);
186}
187
188/// Decrypt an MTProto 2.0 ciphertext.
189///
190/// `buffer` must start with `key_id || msg_key || ciphertext`.
191/// On success returns a slice of `buffer` containing the plaintext.
192pub fn decrypt_data_v2<'a>(
193    buffer: &'a mut [u8],
194    auth_key: &AuthKey,
195) -> Result<&'a mut [u8], DecryptError> {
196    // diagnostic: dump raw frame on any error path
197    let frame_len = buffer.len();
198    let frame_hex: String = buffer
199        .iter()
200        .take(32)
201        .map(|b| format!("{b:02x}"))
202        .collect::<Vec<_>>()
203        .join(" ");
204
205    // `buffer` here is whatever the transport framing handed up. Abridged/
206    // Intermediate frames are exactly key_id(8)+msg_key(16)+ciphertext with
207    // no trailer, so (len-24) is already a multiple of 16. PaddedIntermediate
208    // and FakeTls (dd/ee) frames may have 0-15 extra random bytes appended
209    // by the sender *outside* the AES-256-IGE ciphertext purely for traffic
210    // size obfuscation -- those bytes were never part of the encrypted
211    // message and must be dropped, not decrypted. Floor to the nearest
212    // 16-byte boundary rather than rejecting the whole frame.
213    let usable_cipher_len = buffer.len().saturating_sub(24) / 16 * 16;
214    if buffer.len() < 24 || usable_cipher_len == 0 {
215        // Expected: frame_len >= 24 and at least one full AES block of
216        // ciphertext once any trailing transport padding is floored off.
217        // Minimum valid frame: key_id(8) + msg_key(16) + 1 AES block(16) = 40 bytes
218        tracing::warn!(
219            frame_len,
220            first_bytes = %frame_hex,
221            "decrypt failed: frame too short to contain even one AES block \
222             (need >= 40 bytes)"
223        );
224        return Err(DecryptError::InvalidBuffer);
225    }
226
227    let our_key_id = auth_key.key_id();
228    let frame_key_id = &buffer[..8];
229    if our_key_id != frame_key_id {
230        // Expected: frame[0..8] == SHA-1(auth_key)[12..20]
231        // Possible causes:
232        //   1. A stale response from old session arriving on new TCP socket
233        //   2. A genuinely different key (wrong DC or session)
234        let our_hex = our_key_id
235            .iter()
236            .map(|b| format!("{b:02x}"))
237            .collect::<Vec<_>>()
238            .join("");
239        let frame_hex_id = frame_key_id
240            .iter()
241            .map(|b| format!("{b:02x}"))
242            .collect::<Vec<_>>()
243            .join("");
244        tracing::warn!(
245            frame_len,
246            our_key_id = %our_hex,
247            frame_key_id = %frame_hex_id,
248            first_bytes = %frame_hex,
249            "decrypt failed: auth_key_id mismatch (stale session or wrong DC key)"
250        );
251        return Err(DecryptError::AuthKeyMismatch);
252    }
253    let mut msg_key = [0u8; 16];
254    msg_key.copy_from_slice(&buffer[8..24]);
255
256    let (key, iv) = calc_key(auth_key, &msg_key, Side::Server);
257    let cipher_end = 24 + usable_cipher_len;
258    aes::ige_decrypt(&mut buffer[24..cipher_end], &key, &iv);
259
260    let x = Side::Server.x();
261    let our_key = sha256!(&auth_key.data[88 + x..88 + x + 32], &buffer[24..cipher_end]);
262    if msg_key != our_key[8..24] {
263        return Err(DecryptError::MessageKeyMismatch);
264    }
265    Ok(&mut buffer[24..cipher_end])
266}
267
268/// Derive `(key, iv)` from nonces for decrypting `ServerDhParams.encrypted_answer`.
269pub fn generate_key_data_from_nonce(
270    server_nonce: &[u8; 16],
271    new_nonce: &[u8; 32],
272) -> ([u8; 32], [u8; 32]) {
273    let h1 = sha1!(new_nonce, server_nonce);
274    let h2 = sha1!(server_nonce, new_nonce);
275    let h3 = sha1!(new_nonce, new_nonce);
276
277    let mut key = [0u8; 32];
278    key[..20].copy_from_slice(&h1);
279    key[20..].copy_from_slice(&h2[..12]);
280
281    let mut iv = [0u8; 32];
282    iv[..8].copy_from_slice(&h2[12..]);
283    iv[8..28].copy_from_slice(&h3);
284    iv[28..].copy_from_slice(&new_nonce[..4]);
285
286    (key, iv)
287}
288
289/// Derive the AES key and IV for **MTProto v1** (old-style, SHA-1-based).
290///
291/// Used exclusively for `auth.bindTempAuthKey` encrypted_message, which must
292/// be encrypted with the permanent key using the legacy SHA-1 scheme - NOT the
293/// SHA-256 MTProto 2.0 scheme used for all normal messages.
294pub fn derive_aes_key_iv_v1(auth_key: &[u8; 256], msg_key: &[u8; 16]) -> ([u8; 32], [u8; 32]) {
295    let sha1_a = sha1!(msg_key, &auth_key[0..32]);
296    let sha1_b = sha1!(&auth_key[32..48], msg_key, &auth_key[48..64]);
297    let sha1_c = sha1!(&auth_key[64..96], msg_key);
298    let sha1_d = sha1!(msg_key, &auth_key[96..128]);
299
300    let mut key = [0u8; 32];
301    key[..8].copy_from_slice(&sha1_a[..8]);
302    key[8..20].copy_from_slice(&sha1_b[8..20]);
303    key[20..32].copy_from_slice(&sha1_c[4..16]);
304
305    let mut iv = [0u8; 32];
306    iv[..12].copy_from_slice(&sha1_a[8..20]);
307    iv[12..20].copy_from_slice(&sha1_b[..8]);
308    iv[20..24].copy_from_slice(&sha1_c[16..20]);
309    iv[24..32].copy_from_slice(&sha1_d[..8]);
310
311    (key, iv)
312}
313
314/// Telegram's published 2048-bit safe DH prime (big-endian, 256 bytes).
315///
316/// Source: <https://core.telegram.org/mtproto/auth_key>
317#[rustfmt::skip]
318const TELEGRAM_DH_PRIME: [u8; 256] = [
319    0xC7, 0x1C, 0xAE, 0xB9, 0xC6, 0xB1, 0xC9, 0x04,
320    0x8E, 0x6C, 0x52, 0x2F, 0x70, 0xF1, 0x3F, 0x73,
321    0x98, 0x0D, 0x40, 0x23, 0x8E, 0x3E, 0x21, 0xC1,
322    0x49, 0x34, 0xD0, 0x37, 0x56, 0x3D, 0x93, 0x0F,
323    0x48, 0x19, 0x8A, 0x0A, 0xA7, 0xC1, 0x40, 0x58,
324    0x22, 0x94, 0x93, 0xD2, 0x25, 0x30, 0xF4, 0xDB,
325    0xFA, 0x33, 0x6F, 0x6E, 0x0A, 0xC9, 0x25, 0x13,
326    0x95, 0x43, 0xAE, 0xD4, 0x4C, 0xCE, 0x7C, 0x37,
327    0x20, 0xFD, 0x51, 0xF6, 0x94, 0x58, 0x70, 0x5A,
328    0xC6, 0x8C, 0xD4, 0xFE, 0x6B, 0x6B, 0x13, 0xAB,
329    0xDC, 0x97, 0x46, 0x51, 0x29, 0x69, 0x32, 0x84,
330    0x54, 0xF1, 0x8F, 0xAF, 0x8C, 0x59, 0x5F, 0x64,
331    0x24, 0x77, 0xFE, 0x96, 0xBB, 0x2A, 0x94, 0x1D,
332    0x5B, 0xCD, 0x1D, 0x4A, 0xC8, 0xCC, 0x49, 0x88,
333    0x07, 0x08, 0xFA, 0x9B, 0x37, 0x8E, 0x3C, 0x4F,
334    0x3A, 0x90, 0x60, 0xBE, 0xE6, 0x7C, 0xF9, 0xA4,
335    0xA4, 0xA6, 0x95, 0x81, 0x10, 0x51, 0x90, 0x7E,
336    0x16, 0x27, 0x53, 0xB5, 0x6B, 0x0F, 0x6B, 0x41,
337    0x0D, 0xBA, 0x74, 0xD8, 0xA8, 0x4B, 0x2A, 0x14,
338    0xB3, 0x14, 0x4E, 0x0E, 0xF1, 0x28, 0x47, 0x54,
339    0xFD, 0x17, 0xED, 0x95, 0x0D, 0x59, 0x65, 0xB4,
340    0xB9, 0xDD, 0x46, 0x58, 0x2D, 0xB1, 0x17, 0x8D,
341    0x16, 0x9C, 0x6B, 0xC4, 0x65, 0xB0, 0xD6, 0xFF,
342    0x9C, 0xA3, 0x92, 0x8F, 0xEF, 0x5B, 0x9A, 0xE4,
343    0xE4, 0x18, 0xFC, 0x15, 0xE8, 0x3E, 0xBE, 0xA0,
344    0xF8, 0x7F, 0xA9, 0xFF, 0x5E, 0xED, 0x70, 0x05,
345    0x0D, 0xED, 0x28, 0x49, 0xF4, 0x7B, 0xF9, 0x59,
346    0xD9, 0x56, 0x85, 0x0C, 0xE9, 0x29, 0x85, 0x1F,
347    0x0D, 0x81, 0x15, 0xF6, 0x35, 0xB1, 0x05, 0xEE,
348    0x2E, 0x4E, 0x15, 0xD0, 0x4B, 0x24, 0x54, 0xBF,
349    0x6F, 0x4F, 0xAD, 0xF0, 0x34, 0xB1, 0x04, 0x03,
350    0x11, 0x9C, 0xD8, 0xE3, 0xB9, 0x2F, 0xCC, 0x5B,
351];
352
353/// Errors returned by [`check_p_and_g`].
354#[derive(Clone, Debug, PartialEq, Eq)]
355pub enum DhError {
356    /// `dh_prime` is not exactly 256 bytes (2048 bits).
357    PrimeLengthInvalid,
358    /// The most-significant bit of `dh_prime` is zero, so it is actually
359    /// shorter than 2048 bits.
360    PrimeTooSmall,
361    /// `dh_prime` does not match Telegram's published safe prime.
362    PrimeUnknown,
363    /// `g` is outside the set {2, 3, 4, 5, 6, 7}.
364    GeneratorOutOfRange,
365    /// The modular-residue condition required by `g` and the prime is not
366    /// satisfied (see MTProto spec §4.5).
367    GeneratorInvalid,
368}
369
370impl std::fmt::Display for DhError {
371    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
372        match self {
373            Self::PrimeLengthInvalid => write!(f, "dh_prime must be exactly 256 bytes"),
374            Self::PrimeTooSmall => write!(f, "dh_prime high bit is clear (< 2048 bits)"),
375            Self::PrimeUnknown => {
376                write!(f, "dh_prime does not match any known Telegram safe prime")
377            }
378            Self::GeneratorOutOfRange => write!(f, "generator g must be 2, 3, 4, 5, 6, or 7"),
379            Self::GeneratorInvalid => write!(
380                f,
381                "g fails the required modular-residue check for this prime"
382            ),
383        }
384    }
385}
386
387impl std::error::Error for DhError {}
388
389/// Compute `big_endian_bytes mod modulus` (all values < 2^64).
390fn prime_residue(bytes: &[u8], modulus: u64) -> u64 {
391    bytes
392        .iter()
393        .fold(0u64, |acc, &b| (acc * 256 + b as u64) % modulus)
394}
395
396/// Validate the Diffie-Hellman prime `p` and generator `g` received from
397/// the Telegram server during MTProto key exchange.
398///
399/// Checks performed (per MTProto spec §4.5):
400///
401/// 1. `dh_prime` is exactly 256 bytes (2048 bits).
402/// 2. The most-significant bit is set: the number is truly 2048 bits.
403/// 3. `dh_prime` matches Telegram's published safe prime exactly.
404/// 4. `g` ∈ {2, 3, 4, 5, 6, 7}.
405/// 5. The residue condition for `g` and the prime holds:
406///
407///    | g | condition           |
408///    |---|---------------------|
409///    | 2 | p mod 8 = 7         |
410///    | 3 | p mod 3 = 2         |
411///    | 4 | always valid        |
412///    | 5 | p mod 5 ∈ {1, 4}    |
413///    | 6 | p mod 24 ∈ {19, 23} |
414///    | 7 | p mod 7 ∈ {3, 5, 6} |
415pub fn check_p_and_g(dh_prime: &[u8], g: u32) -> Result<(), DhError> {
416    // 1. Length
417    if dh_prime.len() != 256 {
418        return Err(DhError::PrimeLengthInvalid);
419    }
420
421    // 2. High bit set
422    if dh_prime[0] & 0x80 == 0 {
423        return Err(DhError::PrimeTooSmall);
424    }
425
426    // 3. Known prime: exact match guarantees the residue conditions below
427    //  are deterministic constants, so check 5 is redundant after this.
428    if dh_prime != &TELEGRAM_DH_PRIME[..] {
429        return Err(DhError::PrimeUnknown);
430    }
431
432    // 4. Generator range
433    if !(2..=7).contains(&g) {
434        return Err(DhError::GeneratorOutOfRange);
435    }
436
437    // 5. Residue condition per MTProto spec §4.5.
438    let valid = match g {
439        2 => prime_residue(dh_prime, 8) == 7,
440        3 => prime_residue(dh_prime, 3) == 2,
441        4 => true,
442        5 => {
443            let r = prime_residue(dh_prime, 5);
444            r == 1 || r == 4
445        }
446        6 => {
447            let r = prime_residue(dh_prime, 24);
448            r == 19 || r == 23
449        }
450        7 => {
451            let r = prime_residue(dh_prime, 7);
452            r == 3 || r == 5 || r == 6
453        }
454        _ => unreachable!(),
455    };
456    if !valid {
457        return Err(DhError::GeneratorInvalid);
458    }
459
460    Ok(())
461}
462
463#[cfg(test)]
464mod dh_tests {
465    use super::*;
466
467    #[test]
468    fn known_prime_g3_valid() {
469        // Telegram almost always sends g=3 with this prime.
470        assert_eq!(check_p_and_g(&TELEGRAM_DH_PRIME, 3), Ok(()));
471    }
472
473    #[test]
474    fn wrong_length_rejected() {
475        assert_eq!(
476            check_p_and_g(&[0u8; 128], 3),
477            Err(DhError::PrimeLengthInvalid)
478        );
479    }
480
481    #[test]
482    fn unknown_prime_rejected() {
483        let mut fake = TELEGRAM_DH_PRIME;
484        fake[255] ^= 0x01; // flip last bit
485        assert_eq!(check_p_and_g(&fake, 3), Err(DhError::PrimeUnknown));
486    }
487
488    #[test]
489    fn out_of_range_g_rejected() {
490        assert_eq!(
491            check_p_and_g(&TELEGRAM_DH_PRIME, 1),
492            Err(DhError::GeneratorOutOfRange)
493        );
494        assert_eq!(
495            check_p_and_g(&TELEGRAM_DH_PRIME, 8),
496            Err(DhError::GeneratorOutOfRange)
497        );
498    }
499}