Skip to main content

ferogram_crypto/
lib.rs

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