Skip to main content

ferogram_mtproto/
encrypted.rs

1// Copyright (c) Ankit Chaubey <ankitchaubey.dev@gmail.com>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3//
4// ferogram: async Telegram MTProto client in Rust
5// https://github.com/ankit-chaubey/ferogram
6//
7// If you use or modify this code, keep this notice at the top of your file
8// and include the LICENSE-MIT or LICENSE-APACHE file from this repository:
9// https://github.com/ankit-chaubey/ferogram
10
11use std::collections::{HashSet, VecDeque};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use ferogram_crypto::{AuthKey, DequeBuffer, decrypt_data_v2, encrypt_data_v2};
15use ferogram_tl_types::RemoteCall;
16
17/// Rolling deduplication buffer for server msg_ids.
18const SEEN_MSG_IDS_MAX: usize = 500;
19
20/// Errors that can occur when decrypting a server message.
21#[derive(Debug)]
22pub enum DecryptError {
23    /// The underlying crypto layer rejected the message.
24    Crypto(ferogram_crypto::DecryptError),
25    /// The decrypted inner message was too short to contain a valid header.
26    FrameTooShort,
27    /// Session-ID mismatch (possible replay or wrong connection).
28    SessionMismatch,
29    /// Server msg_id is outside the allowed time window (-300s / +30s).
30    MsgIdTimeWindow,
31    /// This msg_id was already seen in the rolling 500-entry buffer.
32    DuplicateMsgId,
33    /// Server msg_id has even parity; server messages must have odd msg_id.
34    InvalidMsgId,
35}
36
37impl std::fmt::Display for DecryptError {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::Crypto(e) => write!(f, "crypto: {e}"),
41            Self::FrameTooShort => write!(f, "inner plaintext too short"),
42            Self::SessionMismatch => write!(f, "session_id mismatch"),
43            Self::MsgIdTimeWindow => write!(f, "server msg_id outside -300s/+30s time window"),
44            Self::DuplicateMsgId => write!(f, "duplicate server msg_id (replay)"),
45            Self::InvalidMsgId => write!(f, "server msg_id has even parity (must be odd)"),
46        }
47    }
48}
49impl std::error::Error for DecryptError {}
50
51/// The inner payload extracted from a successfully decrypted server frame.
52pub struct DecryptedMessage {
53    /// `salt` sent by the server.
54    pub salt: i64,
55    /// The `session_id` from the frame.
56    pub session_id: i64,
57    /// The `msg_id` of the inner message.
58    pub msg_id: i64,
59    /// `seq_no` of the inner message.
60    pub seq_no: i32,
61    /// TL-serialized body of the inner message.
62    pub body: Vec<u8>,
63}
64
65/// Shared, persistent dedup ring for server msg_ids.
66///
67/// `VecDeque` provides O(1) push/pop for eviction order; `HashSet` provides
68/// O(1) membership checks, replacing the previous O(n) `VecDeque::contains`
69/// scan that became a serialisation bottleneck under 12 concurrent workers.
70///
71/// Outlives individual `EncryptedSession` objects so that replayed frames
72/// from a prior connection cycle are still rejected after reconnect.
73pub type SeenMsgIds = std::sync::Arc<std::sync::Mutex<(VecDeque<i64>, HashSet<i64>)>>;
74
75/// Allocate a fresh seen-msg_id ring.
76pub fn new_seen_msg_ids() -> SeenMsgIds {
77    std::sync::Arc::new(std::sync::Mutex::new((
78        VecDeque::with_capacity(SEEN_MSG_IDS_MAX),
79        HashSet::with_capacity(SEEN_MSG_IDS_MAX),
80    )))
81}
82
83/// MTProto 2.0 encrypted session state.
84pub struct EncryptedSession {
85    auth_key: AuthKey,
86    session_id: i64,
87    sequence: i32,
88    last_msg_id: i64,
89    /// Current server salt to include in outgoing messages.
90    pub salt: i64,
91    /// Clock skew in seconds vs. server.
92    pub time_offset: i32,
93    /// Rolling 500-entry dedup buffer of seen server msg_ids.
94    /// Shared with the owning DcConnection so it survives reconnects.
95    seen_msg_ids: SeenMsgIds,
96}
97
98impl EncryptedSession {
99    /// Create a new encrypted session from the output of `authentication::finish`.
100    ///
101    /// `seen_msg_ids` should be the persistent ring owned by the `DcConnection`
102    /// (or any other owner that outlives individual sessions).  Pass
103    /// `new_seen_msg_ids()` for the very first connection on a slot.
104    pub fn new(auth_key: [u8; 256], first_salt: i64, time_offset: i32) -> Self {
105        Self::with_seen(auth_key, first_salt, time_offset, new_seen_msg_ids())
106    }
107
108    /// Like `new` but reuses an existing seen-msg_id ring (reconnect path).
109    pub fn with_seen(
110        auth_key: [u8; 256],
111        first_salt: i64,
112        time_offset: i32,
113        seen_msg_ids: SeenMsgIds,
114    ) -> Self {
115        let mut rnd = [0u8; 8];
116        getrandom::getrandom(&mut rnd).expect("getrandom");
117        Self {
118            auth_key: AuthKey::from_bytes(auth_key),
119            session_id: i64::from_le_bytes(rnd),
120            sequence: 0,
121            last_msg_id: 0,
122            salt: first_salt,
123            time_offset,
124            seen_msg_ids,
125        }
126    }
127
128    /// Return a clone of the shared seen-msg_id ring for passing to a
129    /// replacement session on reconnect.
130    pub fn seen_msg_ids(&self) -> SeenMsgIds {
131        std::sync::Arc::clone(&self.seen_msg_ids)
132    }
133
134    /// Compute the next message ID (based on corrected server time).
135    fn next_msg_id(&mut self) -> i64 {
136        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
137        // Keep arithmetic in u64: seconds since epoch with time_offset applied.
138        let secs = now.as_secs().wrapping_add(self.time_offset as i64 as u64);
139        let nanos = now.subsec_nanos() as u64;
140        let mut id = ((secs << 32) | (nanos << 2)) as i64;
141        // Spec requires lower 32 bits to be non-zero ("must present a
142        // fractional part").  On coarse-grained clocks (e.g. some Android/Termux
143        // environments) subsec_nanos() can be exactly 0, making the lower half 0.
144        // Set the minimum valid bit (bit 2, step = 4) when lower half is zero.
145        if (id as u64 & 0xFFFF_FFFF) == 0 {
146            id |= 4;
147        }
148        if self.last_msg_id >= id {
149            id = self.last_msg_id + 4;
150        }
151        self.last_msg_id = id;
152        id
153    }
154
155    /// Next content-related seq_no (odd) and advance the counter.
156    /// Used for all regular RPC requests.
157    fn next_seq_no(&mut self) -> i32 {
158        let n = self.sequence * 2 + 1;
159        self.sequence += 1;
160        n
161    }
162
163    /// Return the current even seq_no WITHOUT advancing the counter.
164    ///
165    /// Service messages (MsgsAck, containers, etc.) MUST use an even seqno
166    /// per the MTProto spec so the server does not expect a reply.
167    pub fn next_seq_no_ncr(&self) -> i32 {
168        self.sequence * 2
169    }
170
171    /// Handle `bad_msg_notification` codes 32/33 (seq_no too low / too high).
172    ///
173    /// The previous implementation used magic offsets (+64 / -16) that have no
174    /// basis in the MTProto spec. These caused ping-pong loops: +64 triggered
175    /// code 33 (now too high), -16 triggered code 32 (now too low), repeating
176    /// until the connection was dropped, which then hit the session_id reset bug.
177    ///
178    /// The spec-correct recovery is a full session reset (new session_id, seq_no=0).
179    /// This is what TDesktop does. The caller (`dc_pool::rpc_call`) must then resend
180    /// using the new session context.
181    pub fn correct_seq_no(&mut self, _code: u32) {
182        // Full session reset: new session_id, seq_no = 0.
183        // The server will see a brand-new session and accept seq_no starting from 1.
184        self.reset_session();
185        log::debug!("[ferogram] seq_no desync (code {_code}): performed full session reset");
186    }
187
188    /// Undo the last `next_seq_no` increment.
189    ///
190    /// Called before retrying a request after `bad_server_salt` so the resent
191    /// message uses the same seq_no slot rather than advancing the counter a
192    /// second time (which would produce seq_no too high → bad_msg_notification
193    /// code 33 → server closes TCP → early eof).
194    pub fn undo_seq_no(&mut self) {
195        self.sequence = self.sequence.saturating_sub(1);
196    }
197
198    /// Re-derive the clock skew from a server-provided `msg_id`.
199    ///
200    /// Called on `bad_msg_notification` error codes 16 (msg_id too low) and
201    /// 17 (msg_id too high) so clock drift is corrected at any point in the
202    /// session, not only at connect time.
203    ///
204    pub fn correct_time_offset(&mut self, server_msg_id: i64) {
205        // Upper 32 bits of msg_id = Unix seconds on the server
206        let server_time = (server_msg_id >> 32) as i32;
207        let local_now = SystemTime::now()
208            .duration_since(UNIX_EPOCH)
209            .unwrap()
210            .as_secs() as i32;
211        let new_offset = server_time.wrapping_sub(local_now);
212        log::debug!(
213            "[ferogram] time_offset correction: {} → {} (server_time={server_time})",
214            self.time_offset,
215            new_offset
216        );
217        self.time_offset = new_offset;
218        // Seed last_msg_id from the server's msg_id (bits 1-0 cleared to 0b00)
219        // so the next next_msg_id() call produces a strictly larger value.
220        self.last_msg_id = (server_msg_id & !0x3i64).max(self.last_msg_id);
221    }
222
223    /// Allocate a fresh `(msg_id, seqno)` pair for an inner container message
224    /// WITHOUT encrypting anything.
225    ///
226    /// `content_related = true`  → odd seqno, advances counter  (regular RPCs)
227    /// `content_related = false` → even seqno, no advance       (MsgsAck, container)
228    ///
229    pub fn alloc_msg_seqno(&mut self, content_related: bool) -> (i64, i32) {
230        let msg_id = self.next_msg_id();
231        let seqno = if content_related {
232            self.next_seq_no()
233        } else {
234            self.next_seq_no_ncr()
235        };
236        (msg_id, seqno)
237    }
238
239    /// Encrypt a pre-serialized TL body into a wire-ready MTProto frame.
240    ///
241    /// `content_related` controls whether the seqno is odd (content, advances
242    /// the counter) or even (service, no advance).
243    ///
244    /// Returns `(encrypted_wire_bytes, msg_id)`.
245    /// Used for (bad_msg re-send) and (container inner messages).
246    pub fn pack_body_with_msg_id(&mut self, body: &[u8], content_related: bool) -> (Vec<u8>, i64) {
247        let msg_id = self.next_msg_id();
248        let seq_no = if content_related {
249            self.next_seq_no()
250        } else {
251            self.next_seq_no_ncr()
252        };
253
254        let inner_len = 8 + 8 + 8 + 4 + 4 + body.len();
255        let mut buf = DequeBuffer::with_capacity(inner_len, 32);
256        buf.extend(self.salt.to_le_bytes());
257        buf.extend(self.session_id.to_le_bytes());
258        buf.extend(msg_id.to_le_bytes());
259        buf.extend(seq_no.to_le_bytes());
260        buf.extend((body.len() as u32).to_le_bytes());
261        buf.extend(body.iter().copied());
262
263        encrypt_data_v2(&mut buf, &self.auth_key);
264        (buf.as_ref().to_vec(), msg_id)
265    }
266
267    /// Encrypt a pre-built `msg_container` body (the container itself is
268    /// a non-content-related message with an even seqno).
269    ///
270    /// Returns `(encrypted_wire_bytes, container_msg_id)`.
271    /// The container_msg_id is needed so callers can map it back to inner
272    /// requests when a bad_msg_notification or bad_server_salt arrives for
273    /// the container rather than the individual inner message.
274    ///
275    pub fn pack_container(&mut self, container_body: &[u8]) -> (Vec<u8>, i64) {
276        self.pack_body_with_msg_id(container_body, false)
277    }
278
279    /// Encrypt `body` using a **caller-supplied** `msg_id` instead of generating one.
280    ///
281    /// Required by `auth.bindTempAuthKey`, which must use the same `msg_id`
282    /// in both the outer MTProto envelope and the inner `bind_auth_key_inner`.
283    pub fn pack_body_at_msg_id(&mut self, body: &[u8], msg_id: i64) -> Vec<u8> {
284        let seq_no = self.next_seq_no();
285        let inner_len = 8 + 8 + 8 + 4 + 4 + body.len();
286        let mut buf = DequeBuffer::with_capacity(inner_len, 32);
287        buf.extend(self.salt.to_le_bytes());
288        buf.extend(self.session_id.to_le_bytes());
289        buf.extend(msg_id.to_le_bytes());
290        buf.extend(seq_no.to_le_bytes());
291        buf.extend((body.len() as u32).to_le_bytes());
292        buf.extend(body.iter().copied());
293        encrypt_data_v2(&mut buf, &self.auth_key);
294        buf.as_ref().to_vec()
295    }
296
297    /// Serialize and encrypt a TL function into a wire-ready byte vector.
298    pub fn pack_serializable<S: ferogram_tl_types::Serializable>(&mut self, call: &S) -> Vec<u8> {
299        let body = call.to_bytes();
300        let msg_id = self.next_msg_id();
301        let seq_no = self.next_seq_no();
302
303        let inner_len = 8 + 8 + 8 + 4 + 4 + body.len();
304        let mut buf = DequeBuffer::with_capacity(inner_len, 32);
305        buf.extend(self.salt.to_le_bytes());
306        buf.extend(self.session_id.to_le_bytes());
307        buf.extend(msg_id.to_le_bytes());
308        buf.extend(seq_no.to_le_bytes());
309        buf.extend((body.len() as u32).to_le_bytes());
310        buf.extend(body.iter().copied());
311
312        encrypt_data_v2(&mut buf, &self.auth_key);
313        buf.as_ref().to_vec()
314    }
315
316    /// Like `pack_serializable` but also returns the `msg_id`.
317    pub fn pack_serializable_with_msg_id<S: ferogram_tl_types::Serializable>(
318        &mut self,
319        call: &S,
320    ) -> (Vec<u8>, i64) {
321        let body = call.to_bytes();
322        let msg_id = self.next_msg_id();
323        let seq_no = self.next_seq_no();
324        let inner_len = 8 + 8 + 8 + 4 + 4 + body.len();
325        let mut buf = DequeBuffer::with_capacity(inner_len, 32);
326        buf.extend(self.salt.to_le_bytes());
327        buf.extend(self.session_id.to_le_bytes());
328        buf.extend(msg_id.to_le_bytes());
329        buf.extend(seq_no.to_le_bytes());
330        buf.extend((body.len() as u32).to_le_bytes());
331        buf.extend(body.iter().copied());
332        encrypt_data_v2(&mut buf, &self.auth_key);
333        (buf.as_ref().to_vec(), msg_id)
334    }
335
336    /// Like [`pack`] but also returns the `msg_id` allocated for this message.
337    pub fn pack_with_msg_id<R: RemoteCall>(&mut self, call: &R) -> (Vec<u8>, i64) {
338        let body = call.to_bytes();
339        let msg_id = self.next_msg_id();
340        let seq_no = self.next_seq_no();
341        let inner_len = 8 + 8 + 8 + 4 + 4 + body.len();
342        let mut buf = DequeBuffer::with_capacity(inner_len, 32);
343        buf.extend(self.salt.to_le_bytes());
344        buf.extend(self.session_id.to_le_bytes());
345        buf.extend(msg_id.to_le_bytes());
346        buf.extend(seq_no.to_le_bytes());
347        buf.extend((body.len() as u32).to_le_bytes());
348        buf.extend(body.iter().copied());
349        encrypt_data_v2(&mut buf, &self.auth_key);
350        (buf.as_ref().to_vec(), msg_id)
351    }
352
353    /// Encrypt and frame a [`RemoteCall`] into a ready-to-send MTProto message.
354    pub fn pack<R: RemoteCall>(&mut self, call: &R) -> Vec<u8> {
355        let body = call.to_bytes();
356        let msg_id = self.next_msg_id();
357        let seq_no = self.next_seq_no();
358
359        let inner_len = 8 + 8 + 8 + 4 + 4 + body.len();
360        let mut buf = DequeBuffer::with_capacity(inner_len, 32);
361        buf.extend(self.salt.to_le_bytes());
362        buf.extend(self.session_id.to_le_bytes());
363        buf.extend(msg_id.to_le_bytes());
364        buf.extend(seq_no.to_le_bytes());
365        buf.extend((body.len() as u32).to_le_bytes());
366        buf.extend(body.iter().copied());
367
368        encrypt_data_v2(&mut buf, &self.auth_key);
369        buf.as_ref().to_vec()
370    }
371
372    /// Decrypt an encrypted server frame.
373    pub fn unpack(&self, frame: &mut [u8]) -> Result<DecryptedMessage, DecryptError> {
374        let plaintext = decrypt_data_v2(frame, &self.auth_key).map_err(DecryptError::Crypto)?;
375
376        if plaintext.len() < 32 {
377            return Err(DecryptError::FrameTooShort);
378        }
379
380        let salt = i64::from_le_bytes(plaintext[..8].try_into().unwrap());
381        let session_id = i64::from_le_bytes(plaintext[8..16].try_into().unwrap());
382        let msg_id = i64::from_le_bytes(plaintext[16..24].try_into().unwrap());
383        let seq_no = i32::from_le_bytes(plaintext[24..28].try_into().unwrap());
384        let body_len = u32::from_le_bytes(plaintext[28..32].try_into().unwrap()) as usize;
385
386        if session_id != self.session_id {
387            return Err(DecryptError::SessionMismatch);
388        }
389
390        // MTProto: server msg_id must be odd.
391        if msg_id & 1 == 0 {
392            return Err(DecryptError::InvalidMsgId);
393        }
394
395        // Time window is intentionally asymmetric: -300s past, +30s future.
396        let server_secs = (msg_id as u64 >> 32) as i64;
397        let now = SystemTime::now()
398            .duration_since(UNIX_EPOCH)
399            .unwrap()
400            .as_secs() as i64;
401        let corrected = now + self.time_offset as i64;
402        let skew = server_secs - corrected;
403        if !(-300..=30).contains(&skew) {
404            return Err(DecryptError::MsgIdTimeWindow);
405        }
406
407        // Rolling 500-entry dedup.
408        {
409            let mut seen = self.seen_msg_ids.lock().unwrap();
410            if seen.1.contains(&msg_id) {
411                return Err(DecryptError::DuplicateMsgId);
412            }
413            seen.0.push_back(msg_id);
414            seen.1.insert(msg_id);
415            if seen.0.len() > SEEN_MSG_IDS_MAX
416                && let Some(old_id) = seen.0.pop_front()
417            {
418                seen.1.remove(&old_id);
419            }
420        }
421
422        // Maximum body length: 16 MB.
423        if body_len > 16 * 1024 * 1024 {
424            return Err(DecryptError::FrameTooShort);
425        }
426        if 32 + body_len > plaintext.len() {
427            return Err(DecryptError::FrameTooShort);
428        }
429        // TL payload must be 4-byte aligned.
430        if !body_len.is_multiple_of(4) {
431            return Err(DecryptError::FrameTooShort);
432        }
433        // MTProto 2.0: padding must be in range [12, 1024] bytes (Security Guidelines).
434        let padding = plaintext.len() - 32 - body_len;
435        if !(12..=1024).contains(&padding) {
436            return Err(DecryptError::FrameTooShort);
437        }
438        let body = plaintext[32..32 + body_len].to_vec();
439
440        Ok(DecryptedMessage {
441            salt,
442            session_id,
443            msg_id,
444            seq_no,
445            body,
446        })
447    }
448
449    /// Return the auth_key bytes (for persistence).
450    pub fn auth_key_bytes(&self) -> [u8; 256] {
451        self.auth_key.to_bytes()
452    }
453
454    /// Return the current session_id.
455    pub fn session_id(&self) -> i64 {
456        self.session_id
457    }
458
459    /// Reset session state: new random session_id, zeroed seq_no and last_msg_id.
460    ///
461    /// Use this for genuine new-session creation (e.g. reconnect after auth loss,
462    /// or bad_msg_notification codes 32/33 seq_no desync).
463    /// For `new_session_created` server notifications received mid-session, use
464    /// `reset_seq_no_only()` which preserves the client session_id so that
465    /// in-flight server responses still decrypt correctly.
466    pub fn reset_session(&mut self) {
467        let mut rnd = [0u8; 8];
468        getrandom::getrandom(&mut rnd).expect("getrandom");
469        let old_session = self.session_id;
470        self.session_id = i64::from_le_bytes(rnd);
471        self.sequence = 0;
472        self.last_msg_id = 0;
473        // Do not clear seen_msg_ids: the ring is shared with the owning
474        // DcConnection and must survive session resets to reject replayed frames.
475        log::debug!(
476            "[ferogram] session reset: {:#018x} → {:#018x}",
477            old_session,
478            self.session_id
479        );
480    }
481
482    /// Reset only the sequence counter and last_msg_id, keeping session_id intact.
483    ///
484    /// # Protocol basis
485    /// When the server sends `new_session_created`, it has created fresh server-side
486    /// state for the client's **existing** session_id. The client must reset seq_no
487    /// to 0 (server expectation is now 0) but MUST NOT change session_id. Doing so
488    /// would cause the server's pending response (encrypted with the old session_id)
489    /// to fail decryption with `SessionMismatch`.
490    ///
491    /// Replaces the previous `reset_session()` call in the `new_session_created` handler.
492    pub fn reset_seq_no_only(&mut self) {
493        self.sequence = 0;
494        self.last_msg_id = 0;
495        log::debug!(
496            "[ferogram] seq_no reset (session_id unchanged): {:#018x}",
497            self.session_id
498        );
499    }
500}
501
502impl EncryptedSession {
503    /// Like [`decrypt_frame`] but also performs seen-msg_id deduplication using the
504    /// supplied ring. Pass `&self.inner.seen_msg_ids` from the client.
505    ///
506    /// Hard-codes `time_offset = 0`. On systems where the local clock differs from
507    /// the server by more than 30 seconds, valid server messages are rejected with
508    /// `MsgIdTimeWindow`. Prefer `decrypt_frame_dedup_with_offset` when the session's
509    /// clock skew is known.
510    pub fn decrypt_frame_dedup(
511        auth_key: &[u8; 256],
512        session_id: i64,
513        frame: &mut [u8],
514        seen: &SeenMsgIds,
515    ) -> Result<DecryptedMessage, DecryptError> {
516        Self::decrypt_frame_dedup_with_offset(auth_key, session_id, frame, seen, 0)
517    }
518
519    /// Like [`decrypt_frame_dedup`] but applies the time-window check with the given
520    /// `time_offset` (seconds, server_time − local_time).
521    ///
522    /// Callers that track the session's clock skew (from `correct_time_offset`) should
523    /// use this variant to avoid falsely rejecting valid server frames on clock-skewed
524    /// systems. Pass `enc.time_offset()` from the owning `EncryptedSession`.
525    pub fn decrypt_frame_dedup_with_offset(
526        auth_key: &[u8; 256],
527        session_id: i64,
528        frame: &mut [u8],
529        seen: &SeenMsgIds,
530        time_offset: i32,
531    ) -> Result<DecryptedMessage, DecryptError> {
532        let msg = Self::decrypt_frame_with_offset(auth_key, session_id, frame, time_offset)?;
533        {
534            let mut s = seen.lock().unwrap();
535            if s.1.contains(&msg.msg_id) {
536                return Err(DecryptError::DuplicateMsgId);
537            }
538            s.0.push_back(msg.msg_id);
539            s.1.insert(msg.msg_id);
540            if s.0.len() > SEEN_MSG_IDS_MAX
541                && let Some(old_id) = s.0.pop_front()
542            {
543                s.1.remove(&old_id);
544            }
545        }
546        Ok(msg)
547    }
548
549    /// Decrypt a frame using explicit key + session_id: no mutable state needed.
550    /// Used by the split-reader task so it can decrypt without locking the writer.
551    /// `time_offset` is the session's current clock skew (seconds); pass 0 if unknown.
552    pub fn decrypt_frame(
553        auth_key: &[u8; 256],
554        session_id: i64,
555        frame: &mut [u8],
556    ) -> Result<DecryptedMessage, DecryptError> {
557        Self::decrypt_frame_with_offset(auth_key, session_id, frame, 0)
558    }
559
560    /// Like [`decrypt_frame`] but applies the time-window check with the given
561    /// `time_offset` (seconds, server_time − local_time).
562    pub fn decrypt_frame_with_offset(
563        auth_key: &[u8; 256],
564        session_id: i64,
565        frame: &mut [u8],
566        time_offset: i32,
567    ) -> Result<DecryptedMessage, DecryptError> {
568        let key = AuthKey::from_bytes(*auth_key);
569        let plaintext = decrypt_data_v2(frame, &key).map_err(DecryptError::Crypto)?;
570        if plaintext.len() < 32 {
571            return Err(DecryptError::FrameTooShort);
572        }
573        let salt = i64::from_le_bytes(plaintext[..8].try_into().unwrap());
574        let sid = i64::from_le_bytes(plaintext[8..16].try_into().unwrap());
575        let msg_id = i64::from_le_bytes(plaintext[16..24].try_into().unwrap());
576        let seq_no = i32::from_le_bytes(plaintext[24..28].try_into().unwrap());
577        let body_len = u32::from_le_bytes(plaintext[28..32].try_into().unwrap()) as usize;
578        if sid != session_id {
579            return Err(DecryptError::SessionMismatch);
580        }
581        // MTProto: server msg_id must be odd.
582        if msg_id & 1 == 0 {
583            return Err(DecryptError::InvalidMsgId);
584        }
585        // Time window is intentionally asymmetric: -300s past, +30s future.
586        let server_secs = (msg_id as u64 >> 32) as i64;
587        let now = SystemTime::now()
588            .duration_since(UNIX_EPOCH)
589            .unwrap()
590            .as_secs() as i64;
591        let corrected = now + time_offset as i64;
592        let skew = server_secs - corrected;
593        if !(-300..=30).contains(&skew) {
594            return Err(DecryptError::MsgIdTimeWindow);
595        }
596        // Maximum body length: 16 MB.
597        if body_len > 16 * 1024 * 1024 {
598            return Err(DecryptError::FrameTooShort);
599        }
600        if 32 + body_len > plaintext.len() {
601            return Err(DecryptError::FrameTooShort);
602        }
603        // TL payload must be 4-byte aligned.
604        if !body_len.is_multiple_of(4) {
605            return Err(DecryptError::FrameTooShort);
606        }
607        // MTProto 2.0: padding must be in range [12, 1024] bytes (Security Guidelines).
608        let padding = plaintext.len() - 32 - body_len;
609        if !(12..=1024).contains(&padding) {
610            return Err(DecryptError::FrameTooShort);
611        }
612        let body = plaintext[32..32 + body_len].to_vec();
613        Ok(DecryptedMessage {
614            salt,
615            session_id: sid,
616            msg_id,
617            seq_no,
618            body,
619        })
620    }
621}