Skip to main content

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