telegram-mtproto-ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'digest'
5
+ require_relative '../connection/tcp_full_connection'
6
+ require_relative '../crypto'
7
+
8
+ module Telegram
9
+ module Senders
10
+ # Encrypted MTProto sender EXACTLY like Telethon MTProtoSender
11
+ class MTProtoEncryptedSender
12
+ def initialize(ip, port, auth_key, time_offset = 0)
13
+ @ip = ip
14
+ @port = port
15
+ @auth_key = auth_key
16
+ @time_offset = time_offset # Time offset from server like Telethon
17
+ @connection = nil
18
+ @last_msg_id = 0
19
+ session_bytes = SecureRandom.random_bytes(8)
20
+ @session_id = session_bytes.unpack('q<')[0]
21
+ Rails.logger.debug "🔐 Generated session_id: #{@session_id} from bytes: #{session_bytes.unpack('H*')[0]}"
22
+ Rails.logger.debug "⏰ Using time_offset: #{@time_offset} seconds"
23
+ @sequence = 0
24
+ @salt = 0 # Initialize salt to 0 like Telethon
25
+ Rails.logger.debug "🧂 Initialized salt to 0"
26
+ end
27
+
28
+ def send(request_body)
29
+ Rails.logger.info "🔐 MTProtoEncryptedSender.send() starting, request_body=#{request_body.length} bytes (#{request_body.class})"
30
+
31
+ # Ensure request_body is a binary string
32
+ request_body = request_body.force_encoding('ASCII-8BIT') if request_body.is_a?(String)
33
+
34
+ @connection ||= Telegram::Connection::TcpFullConnection.new(@ip, @port)
35
+
36
+ Rails.logger.info "🔐 Current TCP connection counter: #{@connection.instance_variable_get(:@send_counter) || 'unknown'}"
37
+
38
+ # Create encrypted message EXACTLY like Telethon
39
+ msg_id = get_new_msg_id
40
+ seq_no = get_sequence_number(content_related: true)
41
+
42
+ Rails.logger.info "🔐 Encrypted message params: session_id=#{@session_id}, msg_id=#{msg_id}, seq_no=#{seq_no}, body_len=#{request_body.length}"
43
+ Rails.logger.info "🔐 Auth key length: #{@auth_key.length} bytes"
44
+ Rails.logger.info "🔐 Auth key first 32 bytes: #{@auth_key[0, 32].unpack('H*')[0]}"
45
+
46
+ # Plain message EXACTLY like Telethon MTProtoState.encrypt_message_data (line 135)
47
+ begin
48
+ # In Telethon: data = struct.pack('<qq', self.salt, self.id) + data
49
+ # where 'data' already contains: msg_id + seq_no + length + body
50
+
51
+ # First, build the inner data (msg_id + seq_no + length + body)
52
+ inner_data = [msg_id].pack('q<') +
53
+ [seq_no].pack('i<') +
54
+ [request_body.length].pack('i<') +
55
+ request_body
56
+
57
+ # Then add salt + session_id before it (like Telethon line 135)
58
+ plain_message = [@salt].pack('q<') + [@session_id].pack('q<') + inner_data
59
+
60
+ Rails.logger.info "🔐 MTProto 2.0 encrypted message structure:"
61
+ Rails.logger.info " salt: #{@salt} (8 bytes)"
62
+ Rails.logger.info " session_id: #{@session_id} (8 bytes)"
63
+ Rails.logger.info " msg_id: #{msg_id} (8 bytes)"
64
+ Rails.logger.info " seq_no: #{seq_no} (4 bytes)"
65
+ Rails.logger.info " length: #{request_body.length} (4 bytes)"
66
+ Rails.logger.info " body: #{request_body.length} bytes"
67
+ Rails.logger.info " total plain_message: #{plain_message.length} bytes"
68
+ Rails.logger.debug "🔐 Plain message HEX: #{plain_message.unpack('H*')[0]}"
69
+ rescue => e
70
+ Rails.logger.error "❌ Failed to pack encrypted message: #{e.message}"
71
+ Rails.logger.error " session_id: #{@session_id} (#{@session_id.class})"
72
+ Rails.logger.error " msg_id: #{msg_id} (#{msg_id.class})"
73
+ Rails.logger.error " seq_no: #{seq_no} (#{seq_no.class})"
74
+ Rails.logger.error " body_len: #{request_body.length} (#{request_body.length.class})"
75
+ raise
76
+ end
77
+
78
+ # Add padding like Telethon (line 136): padding = os.urandom(-(len(data) + 12) % 16 + 12)
79
+ # This ensures minimum 12 bytes padding, aligned to 16-byte boundary
80
+ padding_length = (-(plain_message.length + 12) % 16) + 12
81
+ padding = SecureRandom.random_bytes(padding_length)
82
+ Rails.logger.debug "🔐 Telethon-style padding: message=#{plain_message.length}, padding=#{padding_length}, total=#{plain_message.length + padding_length}"
83
+
84
+ # Calculate msg_key from auth_key and message+padding (like Telethon line 140-141)
85
+ message_with_padding = plain_message + padding
86
+ msg_key = calculate_msg_key(message_with_padding, true) # true = from client
87
+
88
+ # Derive AES key and IV from auth_key and msg_key
89
+ aes_key, aes_iv = derive_aes_key_iv(msg_key, true)
90
+
91
+ # Encrypt with AES-IGE (message + padding)
92
+ encrypted_data = Telegram::Crypto.encrypt_ige(message_with_padding, aes_key, aes_iv)
93
+
94
+ # Final message: auth_key_id + msg_key + encrypted_data
95
+ auth_key_id = calculate_auth_key_id
96
+ final_message = [auth_key_id].pack('q<') + msg_key + encrypted_data
97
+
98
+ Rails.logger.info "🔐 Sending encrypted MTProto: auth_key_id=#{auth_key_id}, msg_id=#{msg_id}, seq_no=#{seq_no}"
99
+ Rails.logger.info "🔐 Final message structure:"
100
+ Rails.logger.info " auth_key_id: #{auth_key_id} (8 bytes)"
101
+ Rails.logger.info " msg_key: #{msg_key.unpack('H*')[0]} (16 bytes)"
102
+ Rails.logger.info " encrypted_data: #{encrypted_data.length} bytes"
103
+ Rails.logger.info " total: #{final_message.length} bytes"
104
+ Rails.logger.info "🔐 Final message HEX: #{final_message.unpack('H*')[0]}"
105
+
106
+ @connection.send(final_message)
107
+
108
+ # Read and decrypt response
109
+ response_body = @connection.recv(timeout: 30)
110
+ decrypt_response(response_body)
111
+ end
112
+
113
+ private
114
+
115
+ def get_new_msg_id
116
+ # Generate msg_id EXACTLY like Telethon (lines 244-252)
117
+ now = Time.now.to_f # time.time() equivalent
118
+ now_adjusted = now + @time_offset # Use server time_offset like Telethon!
119
+
120
+
121
+ seconds = now_adjusted.to_i
122
+ nanoseconds = ((now_adjusted - seconds) * 1e9).to_i
123
+ new_msg_id = (seconds << 32) | (nanoseconds << 2)
124
+
125
+ if @last_msg_id >= new_msg_id
126
+ new_msg_id = @last_msg_id + 4
127
+ end
128
+
129
+ @last_msg_id = new_msg_id
130
+ Rails.logger.debug "🕒 Generated msg_id: #{new_msg_id} (seconds=#{seconds}, nanoseconds=#{nanoseconds})"
131
+ new_msg_id
132
+ end
133
+
134
+ def get_sequence_number(content_related: false)
135
+ result = @sequence * 2
136
+ result += 1 if content_related
137
+ @sequence += 1 if content_related
138
+ result
139
+ end
140
+
141
+ def calculate_auth_key_id
142
+ # auth_key_id = bytes 12-20 of SHA1(auth_key) EXACTLY like Telethon AuthKey (lines 39-42)
143
+ hash = Digest::SHA1.digest(@auth_key)
144
+ # Telethon: aux_hash (8 bytes) + skip(4 bytes) + key_id (8 bytes) = bytes 12-20
145
+ auth_key_id = hash[12, 8].unpack('q<')[0]
146
+ Rails.logger.debug "🔐 Auth key ID calculation (Telethon format):"
147
+ Rails.logger.debug " auth_key: #{@auth_key.length} bytes"
148
+ Rails.logger.debug " sha1: #{hash.unpack('H*')[0]}"
149
+ Rails.logger.debug " aux_hash: #{hash[0, 8].unpack('Q<')[0]} (bytes 0-8)"
150
+ Rails.logger.debug " skipped: #{hash[8, 4].unpack('H*')[0]} (bytes 8-12)"
151
+ Rails.logger.debug " auth_key_id: #{auth_key_id} (bytes 12-20)"
152
+ auth_key_id
153
+ end
154
+
155
+ def calculate_msg_key(message, from_client)
156
+ # MTProto 2.0 msg_key calculation like Telethon (line 140-144)
157
+ # msg_key_large = SHA256(substr(auth_key, 88+x, 32) + message)
158
+ x = from_client ? 0 : 8
159
+ auth_key_part = @auth_key[88 + x, 32]
160
+ msg_key_large = Digest::SHA256.digest(auth_key_part + message)
161
+ msg_key = msg_key_large[8, 16] # bytes 8-24 of SHA256
162
+
163
+ Rails.logger.debug "🔐 Msg key calculation (MTProto 2.0):"
164
+ Rails.logger.debug " from_client: #{from_client}, x: #{x}"
165
+ Rails.logger.debug " auth_key_part: #{auth_key_part.unpack('H*')[0][0..20]}..."
166
+ Rails.logger.debug " message: #{message.length} bytes"
167
+ Rails.logger.debug " msg_key_large: #{msg_key_large.unpack('H*')[0]}"
168
+ Rails.logger.debug " msg_key: #{msg_key.unpack('H*')[0]}"
169
+
170
+ msg_key
171
+ end
172
+
173
+ def derive_aes_key_iv(msg_key, from_client)
174
+ # MTProto 2.0 AES key/IV derivation like Telethon _calc_key (lines 100-107)
175
+ x = from_client ? 0 : 8
176
+ sha256a = Digest::SHA256.digest(msg_key + @auth_key[x, 36])
177
+ sha256b = Digest::SHA256.digest(@auth_key[x + 40, 36] + msg_key)
178
+
179
+ aes_key = sha256a[0, 8] + sha256b[8, 16] + sha256a[24, 8]
180
+ aes_iv = sha256b[0, 8] + sha256a[8, 16] + sha256b[24, 8]
181
+
182
+ Rails.logger.debug "🔐 AES key/IV derivation (MTProto 2.0):"
183
+ Rails.logger.debug " from_client: #{from_client}, x: #{x}"
184
+ Rails.logger.debug " aes_key: #{aes_key.unpack('H*')[0]}"
185
+ Rails.logger.debug " aes_iv: #{aes_iv.unpack('H*')[0]}"
186
+
187
+ [aes_key, aes_iv]
188
+ end
189
+
190
+ def decrypt_response(response_body)
191
+ return nil if response_body.length < 24 # auth_key_id(8) + msg_key(16)
192
+
193
+ # Parse response
194
+ offset = 0
195
+ auth_key_id = response_body[offset, 8].unpack('q<')[0]
196
+ offset += 8
197
+
198
+ msg_key = response_body[offset, 16]
199
+ offset += 16
200
+
201
+ encrypted_data = response_body[offset..-1]
202
+
203
+ # Derive decryption key/IV
204
+ aes_key, aes_iv = derive_aes_key_iv(msg_key, false) # false = from server
205
+
206
+ # Decrypt
207
+ plain_data = Telegram::Crypto.decrypt_ige(encrypted_data, aes_key, aes_iv)
208
+
209
+ # Parse decrypted message - MTProto 2.0 structure: salt + session_id + msg_id + seq_no + length + message_data
210
+ offset = 0
211
+ salt = plain_data[offset, 8].unpack('q<')[0]
212
+ offset += 8
213
+
214
+ session_id = plain_data[offset, 8].unpack('q<')[0]
215
+ offset += 8
216
+
217
+ msg_id = plain_data[offset, 8].unpack('q<')[0]
218
+ offset += 8
219
+
220
+ seq_no = plain_data[offset, 4].unpack('i<')[0]
221
+ offset += 4
222
+
223
+ length = plain_data[offset, 4].unpack('i<')[0]
224
+ offset += 4
225
+
226
+ message_data = plain_data[offset, length]
227
+
228
+ Rails.logger.debug "🔓 Decrypted MTProto response: salt=#{salt}, session_id=#{session_id}, msg_id=#{msg_id}, seq_no=#{seq_no}, length=#{length}"
229
+
230
+ message_data
231
+ end
232
+ end
233
+ end
234
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../connection/tcp_full_connection'
4
+
5
+ module Telegram
6
+ module Senders
7
+ # Plain sender EXACTLY like Telethon MTProtoPlainSender
8
+ class MTProtoPlainSender
9
+ class ConnectionError < StandardError; end
10
+ class SecurityError < StandardError; end
11
+
12
+ def initialize(ip, port)
13
+ @ip = ip
14
+ @port = port
15
+ @connection = nil
16
+ @last_msg_id = 0
17
+ end
18
+
19
+ def send(request_body)
20
+ Rails.logger.debug "📡 MTProtoPlainSender.send() starting, request_body=#{request_body.length} bytes"
21
+
22
+ @connection ||= Telegram::Connection::TcpFullConnection.new(@ip, @port)
23
+
24
+ # EXACTLY like Telethon: struct.pack('<qqi', 0, msg_id, len(body)) + body
25
+ msg_id = get_new_msg_id
26
+ unencrypted_message = [0].pack('q<') + [msg_id].pack('q<') + [request_body.length].pack('i<') + request_body
27
+
28
+ Rails.logger.info "📤 Sending unencrypted MTProto: auth_key_id=0, msg_id=#{msg_id}, len=#{request_body.length}"
29
+ Rails.logger.info "🔍 MTProto message structure:"
30
+ Rails.logger.info " auth_key_id: 0 (8 bytes)"
31
+ Rails.logger.info " msg_id: #{msg_id} (8 bytes)"
32
+ Rails.logger.info " length: #{request_body.length} (4 bytes)"
33
+ Rails.logger.info " body: #{request_body.length} bytes"
34
+ Rails.logger.info " total: #{unencrypted_message.length} bytes"
35
+ Rails.logger.debug "🔍 MTProto message HEX: #{unencrypted_message.unpack('H*')[0]}"
36
+ Rails.logger.debug "🔍 Request body HEX: #{request_body.unpack('H*')[0]}"
37
+
38
+ @connection.send(unencrypted_message)
39
+
40
+ # Read response
41
+ Rails.logger.info "📥 Waiting for MTProto response..."
42
+
43
+ begin
44
+ body = @connection.recv(timeout: 30)
45
+ Rails.logger.info "📥 Got MTProto response: #{body.length} bytes"
46
+ rescue => e
47
+ Rails.logger.error "❌ MTProto response failed: #{e.class}: #{e.message}"
48
+ raise
49
+ end
50
+
51
+ if body.length < 20 # auth_key_id(8) + msg_id(8) + length(4) = 20 minimum
52
+ raise ConnectionError.new("MTProto response too short: #{body.length}")
53
+ end
54
+
55
+ # Parse response EXACTLY like Telethon BinaryReader
56
+ offset = 0
57
+ auth_key_id = body[offset, 8].unpack('q<')[0]
58
+ offset += 8
59
+
60
+ unless auth_key_id == 0
61
+ raise SecurityError.new("Bad auth_key_id: #{auth_key_id}")
62
+ end
63
+
64
+ remote_msg_id = body[offset, 8].unpack('q<')[0]
65
+ offset += 8
66
+
67
+ unless remote_msg_id != 0
68
+ raise SecurityError.new("Bad msg_id: #{remote_msg_id}")
69
+ end
70
+
71
+ # Update last_msg_id like Telethon to ensure monotonicity
72
+ if remote_msg_id > @last_msg_id
73
+ @last_msg_id = remote_msg_id
74
+ Rails.logger.debug "🕒 Updated last_msg_id from server: #{@last_msg_id}"
75
+ end
76
+
77
+ length = body[offset, 4].unpack('i<')[0]
78
+ offset += 4
79
+
80
+ unless length > 0
81
+ raise SecurityError.new("Bad length: #{length}")
82
+ end
83
+
84
+ # Extract TL object data
85
+ if offset + length > body.length
86
+ raise ConnectionError.new("Response body truncated: need #{length}, have #{body.length - offset}")
87
+ end
88
+
89
+ response_body = body[offset, length]
90
+ Rails.logger.info "✅ MTProto response parsed: #{response_body.length} bytes TL object"
91
+ response_body
92
+ end
93
+
94
+ private
95
+
96
+ def get_new_msg_id
97
+ # Generate msg_id EXACTLY like Telethon (lines 244-252)
98
+ now = Time.now.to_f # time.time() equivalent
99
+ time_offset = 0 # time_offset for plain sender is 0
100
+ now_adjusted = now + time_offset
101
+
102
+ seconds = now_adjusted.to_i
103
+ nanoseconds = ((now_adjusted - seconds) * 1e9).to_i
104
+ new_msg_id = (seconds << 32) | (nanoseconds << 2)
105
+
106
+ if @last_msg_id >= new_msg_id
107
+ new_msg_id = @last_msg_id + 4
108
+ end
109
+
110
+ @last_msg_id = new_msg_id
111
+ Rails.logger.debug "🕒 Generated msg_id: #{new_msg_id} (seconds=#{seconds}, nanoseconds=#{nanoseconds})"
112
+ new_msg_id
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Telegram
4
+ module Serialization
5
+ # Convert integer to big-endian byte array EXACTLY like Telethon
6
+ def self.integer_to_byte_array(value, signed: true)
7
+ return "\x00".force_encoding('ASCII-8BIT') if value == 0
8
+
9
+ # Handle negative numbers with two's complement
10
+ if signed && value < 0
11
+ # Calculate the minimum number of bits needed
12
+ bits_needed = (value.abs - 1).bit_length + 1
13
+ # Round up to next byte boundary
14
+ byte_length = (bits_needed + 7) / 8
15
+ # Convert to unsigned using two's complement
16
+ value = (1 << (byte_length * 8)) + value
17
+ end
18
+
19
+ # Convert to hex and ensure even length
20
+ hex = value.to_s(16)
21
+ hex = '0' + hex if hex.length.odd?
22
+
23
+ # Convert hex to bytes (big-endian)
24
+ bytes = [hex].pack('H*')
25
+ bytes.force_encoding('ASCII-8BIT')
26
+ end
27
+
28
+ # Convert bytes to integer EXACTLY like Python's int.from_bytes
29
+ def self.bytes_to_int(bytes, byteorder, signed: false)
30
+ bytes = bytes.force_encoding('ASCII-8BIT')
31
+
32
+ if byteorder == 'little'
33
+ bytes = bytes.reverse
34
+ elsif byteorder != 'big'
35
+ raise ArgumentError, "byteorder must be 'big' or 'little'"
36
+ end
37
+
38
+ # Convert to integer (always unsigned first)
39
+ value = bytes.unpack1('H*').to_i(16)
40
+
41
+ # Handle signed conversion
42
+ if signed && bytes.length > 0
43
+ sign_bit = 1 << ((bytes.length * 8) - 1)
44
+ value -= (1 << (bytes.length * 8)) if value >= sign_bit
45
+ end
46
+
47
+ value
48
+ end
49
+
50
+ # Serialize int128 to little endian bytes EXACTLY like Telethon
51
+ def self.serialize_int128(value)
52
+ # EXACTLY copy Python's int.to_bytes(16, 'little', signed=True) logic
53
+ if value < 0
54
+ # Convert negative to unsigned (two's complement)
55
+ value = (1 << 128) + value
56
+ end
57
+
58
+ # Split into two 64-bit parts for little endian
59
+ low = value & 0xFFFFFFFFFFFFFFFF
60
+ high = (value >> 64) & 0xFFFFFFFFFFFFFFFF
61
+
62
+ # Pack as two little-endian 64-bit integers
63
+ [low, high].pack('Q<Q<')
64
+ end
65
+
66
+ # Serialize int256 to little endian bytes EXACTLY like Telethon
67
+ def self.serialize_int256(value)
68
+ # int256 = 32 bytes little-endian signed integer
69
+ # EXACTLY like Python's int.to_bytes(32, 'little', signed=True)
70
+ if value < 0
71
+ # Handle negative numbers (two's complement for 256-bit)
72
+ value = (1 << 256) + value
73
+ end
74
+
75
+ # Split into four 64-bit parts for little endian
76
+ parts = []
77
+ 4.times do
78
+ parts << (value & 0xFFFFFFFFFFFFFFFF)
79
+ value >>= 64
80
+ end
81
+
82
+ # Pack as four little-endian 64-bit integers
83
+ parts.pack('Q<Q<Q<Q<')
84
+ end
85
+
86
+ # Pack TL string EXACTLY like Telethon
87
+ def self.pack_tl_string(data)
88
+ # Convert to byte string if needed
89
+ byte_data = data.dup.force_encoding('ASCII-8BIT')
90
+
91
+ result = if byte_data.length >= 254
92
+ # Long form: 254 + 3-byte length + data + padding
93
+ length_bytes = [byte_data.length].pack('L<')[0, 3] # First 3 bytes of little-endian uint32
94
+ [254].pack('C') + length_bytes + byte_data
95
+ else
96
+ # Short form: length + data + padding
97
+ [byte_data.length].pack('C') + byte_data
98
+ end
99
+
100
+ # Padding to 4-byte boundary (EXACTLY like Telethon)
101
+ padding = (4 - (result.length % 4)) % 4
102
+ result += "\x00" * padding
103
+ result
104
+ end
105
+ end
106
+ end