Skip to main content

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